melvin_ob/scheduling/task_controller.rs
1use super::{AtomicDecision, AtomicDecisionCube, EndCondition, LinkedBox, ScoreGrid, task::Task};
2use crate::imaging::CameraAngle;
3use crate::flight_control::{FlightComputer, FlightState,
4 orbit::{
5 BurnSequence, BurnSequenceEvaluator, ClosedOrbit, ExitBurnResult, IndexedOrbitPosition,
6 },
7};
8use crate::util::Vec2D;
9use crate::{error, info, log};
10use bitvec::prelude::BitRef;
11use chrono::{DateTime, TimeDelta, Utc};
12use fixed::types::{I32F32, I96F32};
13use num::Zero;
14use std::{collections::VecDeque, fmt::Debug, sync::Arc};
15use tokio::sync::RwLock;
16
17/// [`TaskController`] manages and schedules tasks for MELVIN.
18/// It leverages a thread-safe task queue and powerful scheduling algorithms.
19#[derive(Debug)]
20pub struct TaskController {
21 /// Schedule for the next task, e.g. state switches, burn sequences, ...
22 task_schedule: Arc<RwLock<VecDeque<Task>>>,
23}
24
25/// Helper Struct holding the result of the optimal orbit dynamic program
26struct OptimalOrbitResult {
27 /// Flattened 3D-Array holding decisions in time, energy, state dimension
28 pub decisions: AtomicDecisionCube,
29 /// [`LinkedBox`] holding some of the last scores over the energy and the state dimension for the calculation
30 pub coverage_slice: LinkedBox<ScoreGrid>,
31}
32
33impl TaskController {
34 /// The maximum number of seconds for orbit prediction calculations.
35 const MAX_ORBIT_PREDICTION_SECS: u32 = 80000;
36 /// The resolution for battery levels used in calculations, expressed in fixed-point format.
37 const BATTERY_RESOLUTION: I32F32 = I32F32::lit("0.1");
38 /// The minimum batter threshold for all scheduling operations
39 pub const MIN_BATTERY_THRESHOLD: I32F32 = I32F32::lit("10.00");
40 /// The maximum battery treshold for all scheduling operations
41 pub const MAX_BATTERY_THRESHOLD: I32F32 = I32F32::lit("90.00");
42 /// The resolution for time duration calculations, expressed in fixed-point format.
43 const TIME_RESOLUTION: I32F32 = I32F32::lit("1.0");
44 /// The minimum delta time for scheduling objectives, in seconds.
45 const OBJECTIVE_SCHEDULE_MIN_DT: usize = 1000;
46 /// The minimum tolerance for retrieving scheduled objectives.
47 const OBJECTIVE_MIN_RETRIEVAL_TOL: usize = 100;
48 /// The initial battery threshold for performing a maneuver.
49 const MANEUVER_INIT_BATT_TOL: I32F32 = I32F32::lit("10.0");
50 /// The minimum delta time required for detumble maneuvers, in seconds.
51 pub(crate) const MANEUVER_MIN_DETUMBLE_DT: usize = 20;
52 /// The Delay for imaging objectives when the first image should be shot
53 pub const ZO_IMAGE_FIRST_DEL: TimeDelta = TimeDelta::seconds(5);
54 /// The number of seconds that are planned per acquisition cycle
55 pub const IN_COMMS_SCHED_SECS: usize = 1100;
56 /// The period (number of seconds) after which another comms sequence should be scheduled.
57 const COMMS_SCHED_PERIOD: usize = 800;
58 /// The usable `TimeDelta` between communication state switches
59 #[allow(clippy::cast_possible_wrap)]
60 const COMMS_SCHED_USABLE_TIME: TimeDelta =
61 TimeDelta::seconds((Self::COMMS_SCHED_PERIOD - 2 * 180) as i64);
62 /// The charge usage per strictly timed communication cycle
63 pub const COMMS_CHARGE_USAGE: I32F32 = I32F32::lit("9.00");
64 /// The minimum charge needed to enter communication state
65 pub const MIN_COMMS_START_CHARGE: I32F32 = I32F32::lit("20.0");
66
67 /// Creates a new instance of the [`TaskController`] struct.
68 ///
69 /// # Returns
70 /// - A new [`TaskController`] with an empty task schedule.
71 pub fn new() -> Self { Self { task_schedule: Arc::new(RwLock::new(VecDeque::new())) } }
72
73 /// Initializes the optimal orbit schedule calculation.
74 ///
75 /// This method sets up the required data structures and parameters necessary for determining
76 /// the most efficient orbit path based on the given parameters.
77 ///
78 /// # Arguments
79 /// * `orbit` - Reference to the [`ClosedOrbit`] structure representing the current orbit configuration.
80 /// * `p_t_shift` - The starting index used to shift and reorder the bitvector of the orbit.
81 /// * `dt` - Optional maximum prediction duration in seconds. If `None`, defaults to the orbit period or the maximum prediction length.
82 /// * `end_status` - Optional tuple containing the end flight state ([`FlightState`]) and battery level (`I32F32`) constraints.
83 ///
84 /// # Returns
85 /// * `OptimalOrbitResult` - The final result containing calculated decisions and coverage slice used in the optimization.
86 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
87 fn init_sched_dp(
88 orbit: &ClosedOrbit,
89 p_t_shift: usize,
90 dt: Option<usize>,
91 end_state: Option<FlightState>,
92 end_batt: Option<I32F32>,
93 ) -> OptimalOrbitResult {
94 // List of potential states during the orbit scheduling process.
95 let states = [FlightState::Charge, FlightState::Acquisition];
96 // Calculate the usable battery range based on the fixed thresholds.
97 let usable_batt_range = Self::MAX_BATTERY_THRESHOLD - Self::MIN_BATTERY_THRESHOLD;
98 // Determine the maximum number of battery levels that can be represented.
99 let max_battery = (usable_batt_range / Self::BATTERY_RESOLUTION).round().to_num::<usize>();
100 // Determine the prediction duration in seconds, constrained by the orbit period or `dt` if provided.
101 let prediction_secs = {
102 if let Some(pred_secs) = dt {
103 // Ensure the prediction duration does not exceed the maximum prediction length or the provided duration.
104 pred_secs
105 } else {
106 Self::MAX_ORBIT_PREDICTION_SECS.min(orbit.period().0.to_num::<u32>()) as usize
107 }
108 };
109
110 // Retrieve a reordered iterator over the orbit's completion bitvector to optimize scheduling.
111 let p_t_iter = orbit.get_p_t_reordered(
112 p_t_shift,
113 orbit.period().0.to_num::<usize>() - prediction_secs,
114 );
115 // Create a blank decision buffer and score grid for the orbit schedule calculation.
116 let decision_buffer =
117 AtomicDecisionCube::new(prediction_secs, max_battery + 1, states.len());
118 let cov_dt_temp = ScoreGrid::new(max_battery + 1, states.len());
119 // Initialize the first coverage grid based on the end status or use a default grid.
120 let cov_dt_first = {
121 let batt = end_batt.map_or(max_battery + 1, Self::map_e_to_dp);
122 let state = end_state.map(|o| o as usize);
123 let end_cast = (state, batt);
124 ScoreGrid::new_from_condition(max_battery + 1, states.len(), end_cast)
125 };
126 // Initialize a linked list of score cubes with a fixed size and push the initial coverage grid.
127 let mut score_cube = LinkedBox::new(180);
128 score_cube.push(cov_dt_first);
129 // Perform the calculation for the optimal orbit schedule using the prepared variables.
130 Self::calculate_optimal_orbit_schedule(
131 prediction_secs,
132 p_t_iter,
133 score_cube,
134 &cov_dt_temp,
135 decision_buffer,
136 )
137 }
138
139 /// Calculates the optimal orbit schedule based on predicted states and actions.
140 ///
141 /// This function iterates backward over a prediction window (`pred_dt`) to compute the best decisions
142 /// and score grid values for optimizing orbit transitions. It uses battery levels, state transitions,
143 /// and the orbits `done`-`BitBox`.
144 ///
145 /// # Arguments
146 /// - `pred_dt`: The number of prediction time steps.
147 /// - `p_t_it`: Iterator over the orbit's completion bitvector, providing timed scores.
148 /// - `score_cube`: A linked list holding previous and current score grids for dynamic programming.
149 /// - `score_grid_default`: A grid initialized with default scores used during calculations.
150 /// - `dec_cube`: A decision cube to store the selected actions at each time step.
151 ///
152 /// # Returns
153 /// - `OptimalOrbitResult`: Contains the final decision cube and the score grid linked box.
154 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_possible_wrap)]
155 fn calculate_optimal_orbit_schedule<'a>(
156 pred_dt: usize,
157 mut p_t_it: impl Iterator<Item = BitRef<'a>>,
158 mut score_cube: LinkedBox<ScoreGrid>,
159 score_grid_default: &ScoreGrid,
160 mut dec_cube: AtomicDecisionCube,
161 ) -> OptimalOrbitResult {
162 let max_battery = score_grid_default.e_len() - 1;
163 for t in (0..pred_dt).rev() {
164 let mut cov_dt = score_grid_default.clone();
165 let p_dt = i32::from(!*p_t_it.next().unwrap());
166 for e in 0..=max_battery {
167 for s in 0..=1 {
168 let de = if s == 0 { 1 } else { -1 };
169 let new_e = (e as isize + de) as usize;
170 // Compute score for the decision to stay in the current state.
171 let stay = if s == 0 {
172 // If in charge state, calculate score for staying.
173 score_cube.front().unwrap().get(new_e.min(max_battery), s)
174 } else if e > 0 {
175 // If in acquisition state, consider score and state.
176 score_cube.front().unwrap().get(new_e, s) + p_dt
177 } else {
178 // If battery is depleted, staying is not possible.
179 i32::MIN
180 };
181
182 let switch = if score_cube.len() < score_cube.size() {
183 // We do not swap here as the time after the maximum prediction time is not predictable
184 ScoreGrid::MIN_SCORE - 1
185 } else {
186 // Compute score for the decision to switch to the other state.
187 score_cube.back().unwrap().get(e, s ^ 1)
188 };
189 // Choose the better decision and record it.
190 if stay >= switch {
191 dec_cube.set(t, e, s, AtomicDecision::stay(s));
192 cov_dt.set(e, s, stay);
193 } else {
194 dec_cube.set(t, e, s, AtomicDecision::switch(s ^ 1));
195 cov_dt.set(e, s, switch);
196 }
197 }
198 }
199 // Push the updated score grid for the current time step into the linked box.
200 score_cube.push(cov_dt);
201 }
202 // Return the resulting decision cube and the score grid linked box.
203 OptimalOrbitResult { decisions: dec_cube, coverage_slice: score_cube }
204 }
205
206 /// Finds the last possible time offset (`dt`) at which a burn can still start to reach a target.
207 ///
208 /// The method simulates forward motion and calculates how long a burn can be delayed while
209 /// still ensuring that MELVIN can traverse the remaining distance in time.
210 ///
211 /// # Arguments
212 /// - `i`: The current orbit index position.
213 /// - `vel`: Current velocity vector.
214 /// - `targets`: Target positions and additional target direction vector.
215 /// - `max_dt`: Upper bound for time offset.
216 ///
217 /// # Returns
218 /// - `dt`: The latest viable starting offset in seconds.
219 fn find_last_possible_dt(
220 i: &IndexedOrbitPosition,
221 vel: &Vec2D<I32F32>,
222 targets: &[(Vec2D<I32F32>, Vec2D<I32F32>)],
223 max_dt: usize,
224 ) -> usize {
225 let orbit_vel_abs = vel.abs();
226
227 for dt in (Self::OBJECTIVE_SCHEDULE_MIN_DT..max_dt).rev() {
228 let pos_i96: Vec2D<I96F32> =
229 i.pos().to_num::<I96F32>() + (*vel).to_num::<I96F32>() * I96F32::from_num(dt);
230 let pos = pos_i96.to_num::<I32F32>().wrap_around_map();
231 let mut min_dt = usize::MAX;
232
233 for target_pos in targets {
234 let to_target = pos.unwrapped_to(&target_pos.0);
235 let this_min_dt = ((to_target.abs() + target_pos.1.abs()) / orbit_vel_abs)
236 .abs()
237 .round()
238 .to_num::<usize>();
239 if this_min_dt < min_dt {
240 min_dt = this_min_dt;
241 }
242 }
243
244 if min_dt + dt < max_dt {
245 return dt;
246 }
247 }
248 Self::OBJECTIVE_SCHEDULE_MIN_DT
249 }
250
251 /// Calculates the optimal burn sequence to reach a single target position
252 /// within a specified end time.
253 ///
254 /// This function determines the most efficient sequence of orbital maneuvers
255 /// (acceleration vectors) to steer the spacecraft from its current position
256 /// towards a given target position, considering time and energy constraints.
257 /// The resulting burn sequence minimizes fuel consumption, angle deviation,
258 /// and off-orbit time while ensuring sufficient battery charge.
259 ///
260 /// # Arguments
261 /// * `curr_i` - The current indexed orbit position of the spacecraft.
262 /// * `f_cont_lock` - A shared lock on the `FlightComputer` for velocity and control access.
263 /// * `target_pos` - The target position as a `Vec2D<I32F32>`.
264 /// * `target_end_time` - The deadline by which the target must be reached.
265 ///
266 /// # Returns
267 /// * `(BurnSequence, I32F32)` - A tuple containing:
268 /// - The optimized `BurnSequence` object representing the maneuver sequence.
269 /// - The minimum battery charge needed for the burn sequence.
270 ///
271 /// # Panics
272 /// Panics if no valid burn sequence is found or the target is unreachable.
273 pub fn calculate_single_target_burn_sequence(
274 curr_i: IndexedOrbitPosition,
275 curr_vel: Vec2D<I32F32>,
276 target_pos: Vec2D<I32F32>,
277 target_start_time: DateTime<Utc>,
278 target_end_time: DateTime<Utc>,
279 fuel_left: I32F32,
280 target_id: usize,
281 ) -> Option<ExitBurnResult> {
282 info!("Starting to calculate single-target burn towards {target_pos}");
283 let target = [(target_pos, Vec2D::zero())];
284 let (min_dt, max_dt) = Self::get_min_max_dt(target_start_time, target_end_time, curr_i.t());
285 let max_off_orbit_dt = max_dt - Self::OBJECTIVE_SCHEDULE_MIN_DT;
286
287 // Spawn a task to compute possible turns asynchronously
288 let turns = FlightComputer::compute_possible_turns(curr_vel);
289
290 let last_possible_dt = Self::find_last_possible_dt(&curr_i, &curr_vel, &target, max_dt);
291
292 // Define range for evaluation and initialize best burn sequence tracker
293 let remaining_range = Self::OBJECTIVE_SCHEDULE_MIN_DT..=last_possible_dt;
294
295 // Await the result of possible turn computations
296 let mut evaluator = BurnSequenceEvaluator::new(
297 curr_i,
298 curr_vel,
299 &target,
300 min_dt,
301 max_dt,
302 max_off_orbit_dt,
303 turns,
304 fuel_left,
305 target_id,
306 );
307
308 for dt in remaining_range.rev() {
309 evaluator.process_dt(dt, Self::MAX_BATTERY_THRESHOLD);
310 }
311 // Return the best burn sequence, panicking if none was found
312 evaluator.get_best_burn()
313 }
314
315 /// Calculates an optimal burn sequence targeting multiple positions within a time window.
316 ///
317 /// # Arguments
318 /// - `curr_i`: Current indexed orbit position.
319 /// - `curr_vel`: Current velocity vector.
320 /// - `entries`: Array of target positions with uncertainties.
321 /// - `target_start_time`: When acquisition window starts.
322 /// - `target_end_time`: Deadline to acquire.
323 /// - `fuel_left`: Remaining propellant budget.
324 /// - `target_id`: ID of the image objective.
325 ///
326 /// # Returns
327 /// `Some(ExitBurnResult)` on success, or `None` if no valid burn sequence was found.
328 pub fn calculate_multi_target_burn_sequence(
329 curr_i: IndexedOrbitPosition,
330 curr_vel: Vec2D<I32F32>,
331 entries: [(Vec2D<I32F32>, Vec2D<I32F32>); 4],
332 target_start_time: DateTime<Utc>,
333 target_end_time: DateTime<Utc>,
334 fuel_left: I32F32,
335 target_id: usize,
336 ) -> Option<ExitBurnResult> {
337 info!("Starting to calculate multi-target burn sequence!");
338 let (min_dt, max_dt) = Self::get_min_max_dt(target_start_time, target_end_time, curr_i.t());
339 let max_off_orbit_dt = max_dt - Self::OBJECTIVE_SCHEDULE_MIN_DT;
340
341 // Spawn a task to compute possible turns asynchronously
342 let turns = FlightComputer::compute_possible_turns(curr_vel);
343
344 let last_possible_dt = Self::find_last_possible_dt(&curr_i, &curr_vel, &entries, max_dt);
345
346 // Define range for evaluation and initialize best burn sequence tracker
347 let remaining_range = Self::OBJECTIVE_SCHEDULE_MIN_DT..=last_possible_dt;
348
349 // Await the result of possible turn computations
350 let mut evaluator = BurnSequenceEvaluator::new(
351 curr_i,
352 curr_vel,
353 &entries,
354 min_dt,
355 max_dt,
356 max_off_orbit_dt,
357 turns,
358 fuel_left,
359 target_id,
360 );
361
362 for dt in remaining_range.rev() {
363 evaluator.process_dt(dt, Self::MAX_BATTERY_THRESHOLD);
364 }
365 // Return the best burn sequence, panicking if none was found
366 evaluator.get_best_burn()
367 }
368
369 /// Determines the earliest and latest time offsets (in seconds) for a given target interval.
370 ///
371 /// # Arguments
372 /// - `start_time`: UTC time when the target becomes valid.
373 /// - `end_time`: UTC time by which the target must be acquired.
374 /// - `curr`: The current UTC time.
375 ///
376 /// # Returns
377 /// A tuple of `(min_dt, max_dt)`:
378 /// - `min_dt`: The earliest time offset from `curr` to consider.
379 /// - `max_dt`: The latest time offset from `curr` before the target deadline.
380 fn get_min_max_dt(
381 start_time: DateTime<Utc>,
382 end_time: DateTime<Utc>,
383 curr: DateTime<Utc>,
384 ) -> (usize, usize) {
385 // Calculate maximum allowed time delta for the maneuver, clamp to a maximum of 8 hours
386 let time_left = (end_time - curr).clamp(TimeDelta::zero(), TimeDelta::hours(8));
387 let max_dt = {
388 let max = usize::try_from(time_left.num_seconds()).unwrap_or(0);
389 max - Self::OBJECTIVE_MIN_RETRIEVAL_TOL
390 };
391
392 let time_to_start = (start_time - curr).max(TimeDelta::zero());
393 let min_dt = {
394 if time_to_start.num_seconds() > 0 {
395 let min = usize::try_from(time_to_start.num_seconds()).unwrap_or(0);
396 min + Self::OBJECTIVE_MIN_RETRIEVAL_TOL
397 } else {
398 0
399 }
400 };
401 (min_dt, max_dt)
402 }
403
404 /// Schedules a single communication cycle within an orbit plan.
405 ///
406 /// This function is responsible for planning a charge-acquire-comm cycle based on
407 /// the given start time and constraints. It ensures sufficient battery and time are
408 /// available to enter communication mode, and either schedules the cycle or short-circuits
409 /// if the window is too small.
410 ///
411 /// # Arguments
412 /// - `c_end`: A tuple of the form `(DateTime<Utc>, I32F32)` representing the end time of the
413 /// previous communication cycle and the remaining battery charge.
414 /// - `sched_start`: A tuple `(DateTime<Utc>, usize)` indicating the start time and the
415 /// orbit index for scheduling.
416 /// - `orbit`: A reference to the [`ClosedOrbit`] used for orbit-based scheduling decisions.
417 /// - `strict_end`: A tuple `(DateTime<Utc>, usize)` specifying the hard cutoff for scheduling.
418 ///
419 /// # Returns
420 /// - `Some((DateTime<Utc>, I32F32))` with the projected end time and battery after the
421 /// next comms cycle, if another cycle can be scheduled.
422 /// - `None` if the scheduling window is too short and no comms cycle can be inserted.
423 ///
424 /// # Notes
425 /// - This method ensures each comms cycle starts with sufficient charge.
426 /// - Uses `COMMS_SCHED_USABLE_TIME` and `COMMS_CHARGE_USAGE` constants to
427 /// define time and battery requirements.
428 #[allow(clippy::cast_possible_wrap)]
429 async fn sched_single_comms_cycle(
430 &self,
431 c_end: (DateTime<Utc>, I32F32),
432 sched_start: (DateTime<Utc>, usize),
433 orbit: &ClosedOrbit,
434 strict_end: (DateTime<Utc>, usize),
435 ) -> Option<(DateTime<Utc>, I32F32)> {
436 let t_time = FlightState::Charge.dt_to(FlightState::Comms);
437 let sched_end = sched_start.0 + Self::COMMS_SCHED_USABLE_TIME;
438 let t_ch = Self::MIN_COMMS_START_CHARGE;
439
440 if sched_end + t_time > strict_end.0 {
441 let dt = usize::try_from((strict_end.0 - sched_start.0).num_seconds()).unwrap_or(0);
442 let result = Self::init_sched_dp(orbit, sched_start.1, Some(dt), None, None);
443 let target = {
444 let st =
445 result.coverage_slice.front().unwrap().get_max_s(Self::map_e_to_dp(c_end.1));
446 (c_end.1, st)
447 };
448 self.schedule_switch(FlightState::from_dp_usize(target.1), c_end.0).await;
449 self.sched_opt_orbit_res(sched_start.0, result, 0, false, target).await;
450 None
451 } else {
452 let dt = usize::try_from((sched_end - sched_start.0).num_seconds()).unwrap_or(0);
453 let result = Self::init_sched_dp(orbit, sched_start.1, Some(dt), None, Some(t_ch));
454 let target = {
455 let st =
456 result.coverage_slice.front().unwrap().get_max_s(Self::map_e_to_dp(c_end.1));
457 (c_end.1, st)
458 };
459 self.schedule_switch(FlightState::from_dp_usize(target.1), c_end.0).await;
460 let (_, batt) = self.sched_opt_orbit_res(sched_start.0, result, 0, false, target).await;
461 self.schedule_switch(FlightState::Comms, sched_end).await;
462 let next_c_end =
463 sched_end + t_time + TimeDelta::seconds(Self::IN_COMMS_SCHED_SECS as i64);
464 Some((next_c_end, batt - Self::COMMS_CHARGE_USAGE))
465 }
466 }
467
468 /// Computes and schedules tasks that balance imaging and communication passes.
469 ///
470 /// This scheduling method handles alternating communication slots interleaved with optimized orbit
471 /// operation schedules. It tries to maximize productivity while periodically entering comms mode.
472 ///
473 /// # Arguments
474 /// - `self`: Shared reference to this `TaskController`.
475 /// - `orbit_lock`: Reference to the current orbital model.
476 /// - `f_cont_lock`: Reference to the flight controller state.
477 /// - `scheduling_start_i`: Position to start scheduling from.
478 /// - `last_bo_end_t`: Deadline after which comms mode must stop.
479 /// - `first_comms_end`: Initial estimate of when the first comms cycle ends.
480 /// - `end_cond`: Optional condition that defines the final desired state and battery level.
481 #[allow(clippy::cast_possible_wrap, clippy::cast_precision_loss)]
482 pub async fn sched_opt_orbit_w_comms(
483 self: Arc<TaskController>,
484 orbit_lock: Arc<RwLock<ClosedOrbit>>,
485 f_cont_lock: Arc<RwLock<FlightComputer>>,
486 scheduling_start_i: IndexedOrbitPosition,
487 last_bo_end_t: DateTime<Utc>,
488 first_comms_end: DateTime<Utc>,
489 end_cond: Option<EndCondition>,
490 ) {
491 log!("Calculating/Scheduling optimal orbit with passive beacon scanning.");
492 let computation_start = Utc::now();
493 self.clear_schedule().await;
494 let t_time = FlightState::Charge.td_dt_to(FlightState::Comms);
495 let strict_end = (last_bo_end_t, scheduling_start_i.index_then(last_bo_end_t));
496
497 let is_next_possible: Box<dyn Fn(DateTime<Utc>) -> bool + Send> =
498 if let Some(end) = &end_cond {
499 let dt = end.abs_charge_dt() + t_time * 2;
500 Box::new(move |comms_end: DateTime<Utc>| -> bool {
501 let n_end = comms_end
502 + TaskController::COMMS_SCHED_USABLE_TIME
503 + t_time * 2
504 + TimeDelta::seconds(TaskController::IN_COMMS_SCHED_SECS as i64);
505 n_end + dt <= end.time()
506 })
507 } else {
508 Box::new(|_| -> bool { true })
509 };
510
511 let mut curr_comms_end = {
512 let dt = first_comms_end - Utc::now();
513 let batt = f_cont_lock.read().await.batt_in_dt(dt);
514 Some((first_comms_end, batt))
515 };
516
517 let mut next_start = (Utc::now(), scheduling_start_i.index());
518 let mut next_start_e = I32F32::zero();
519
520 let orbit = orbit_lock.read().await;
521 while let Some(end) = curr_comms_end {
522 (next_start, next_start_e) = {
523 let t = end.0 + t_time;
524 let i = scheduling_start_i.index_then(t);
525 ((t, i), end.1)
526 };
527 if is_next_possible(next_start.0) {
528 curr_comms_end =
529 self.sched_single_comms_cycle(end, next_start, &orbit, strict_end).await;
530 } else {
531 break;
532 }
533 }
534
535 if let Some(e) = &end_cond {
536 let (left_dt, ch, s) = {
537 let dt = usize::try_from((e.time() - next_start.0).num_seconds()).unwrap_or(0);
538 (Some(dt), Some(e.charge()), Some(e.state()))
539 };
540 let result = Self::init_sched_dp(&orbit, next_start.1, left_dt, s, ch);
541 let target = {
542 let st = result
543 .coverage_slice
544 .front()
545 .unwrap()
546 .get_max_s(Self::map_e_to_dp(next_start_e));
547 (next_start_e, st)
548 };
549 self.schedule_switch(FlightState::from_dp_usize(target.1), next_start.0 - t_time).await;
550 self.sched_opt_orbit_res(next_start.0, result, 0, false, target).await;
551 }
552
553 let n_tasks = self.task_schedule.read().await.len();
554 let dt_tot = (Utc::now() - computation_start).num_milliseconds() as f32 / 1000.0;
555 info!(
556 "Number of tasks after scheduling: {n_tasks}. \
557 Calculation and processing took {dt_tot:.2}s.",
558 );
559 }
560
561 /// Calculates and schedules the optimal orbit trajectory based on the current position and state.
562 ///
563 /// # Arguments
564 /// - `self`: A reference-counted `TaskController` used for task scheduling.
565 /// - `orbit_lock`: An `Arc<RwLock<ClosedOrbit>>` containing the shared closed orbit data.
566 /// - `f_cont_lock`: An `Arc<RwLock<FlightComputer>>` containing the flight control state.
567 /// - `scheduling_start_i`: The starting orbital position as an `IndexedOrbitPosition`.
568 /// - `end`: An optional `EndCondition` indicating the desired final status of MELVIN
569 #[allow(
570 clippy::cast_precision_loss,
571 clippy::cast_possible_wrap,
572 clippy::cast_possible_truncation,
573 clippy::cast_sign_loss
574 )]
575 pub async fn sched_opt_orbit(
576 self: Arc<TaskController>,
577 orbit_lock: Arc<RwLock<ClosedOrbit>>,
578 f_cont_lock: Arc<RwLock<FlightComputer>>,
579 scheduling_start_i: IndexedOrbitPosition,
580 end: Option<EndCondition>,
581 ) {
582 log!("Calculating/Scheduling optimal orbit.");
583 self.clear_schedule().await;
584 let p_t_shift = scheduling_start_i.index();
585 let comp_start = scheduling_start_i.t();
586 let (dt, batt, state) = if let Some(end_c) = end {
587 let end_t = (end_c.time() - Utc::now()).num_seconds().max(0) as usize;
588 (Some(end_t), Some(end_c.charge()), Some(end_c.state()))
589 } else {
590 (None, None, None)
591 };
592 let result = {
593 let orbit = orbit_lock.read().await;
594 Self::init_sched_dp(&orbit, p_t_shift, dt, state, batt)
595 };
596 let dt_calc = (Utc::now() - comp_start).num_milliseconds() as f32 / 1000.0;
597 let dt_shift = dt_calc.ceil() as usize;
598
599 let (st_batt, dt_sh) = {
600 let (batt, st) = Self::get_batt_and_state(&f_cont_lock).await;
601 if st == 2 {
602 let best_st =
603 result.coverage_slice.back().unwrap().get_max_s(Self::map_e_to_dp(batt));
604 self.schedule_switch(FlightState::from_dp_usize(best_st), comp_start).await;
605 ((batt, best_st), dt_shift + 180)
606 } else {
607 ((batt, st), dt_shift)
608 }
609 };
610 let (n_tasks, _) =
611 self.sched_opt_orbit_res(comp_start, result, dt_sh, false, st_batt).await;
612 let dt_tot = (Utc::now() - comp_start).num_milliseconds() as f32 / 1000.0;
613 info!("Tasks after scheduling: {n_tasks}. Calculation and processing took {dt_tot:.2}s.");
614 }
615
616 /// Retrieves the current battery level and flight state index from the [`FlightComputer`].
617 ///
618 /// # Arguments
619 /// - `f_cont_lock`: A shared reference to the flight computer's `RwLock` wrapper.
620 ///
621 /// # Returns
622 /// - A tuple containing:
623 /// - `I32F32`: The current battery level.
624 /// - `usize`: The current flight state encoded as a decision-programming (DP) index.
625 async fn get_batt_and_state(f_cont_lock: &Arc<RwLock<FlightComputer>>) -> (I32F32, usize) {
626 // Retrieve the current battery level and satellite state
627 let f_cont = f_cont_lock.read().await;
628 let batt: I32F32 = f_cont.current_battery();
629 (batt, f_cont.state().to_dp_usize())
630 }
631
632 /// Maps a battery level (`I32F32`) to a discrete DP index for scheduling purposes.
633 ///
634 /// # Arguments
635 /// - `e`: The current battery level to convert.
636 ///
637 /// # Returns
638 /// - `usize`: The index used in dynamic programming grids to represent energy.
639 fn map_e_to_dp(e: I32F32) -> usize {
640 let e_clamp = e.clamp(Self::MIN_BATTERY_THRESHOLD, Self::MAX_BATTERY_THRESHOLD);
641
642 ((e_clamp - Self::MIN_BATTERY_THRESHOLD) / Self::BATTERY_RESOLUTION)
643 .round()
644 .to_num::<usize>()
645 }
646
647 /// Maps a DP battery index (`usize`) back to a continuous `I32F32` battery level.
648 ///
649 /// # Arguments
650 /// - `dp`: The index representing the discrete battery level.
651 ///
652 /// # Returns
653 /// - `I32F32`: The real-valued battery charge corresponding to the DP index.
654 fn map_dp_to_e(dp: usize) -> I32F32 {
655 (Self::MIN_BATTERY_THRESHOLD + (I32F32::from_num(dp) * Self::BATTERY_RESOLUTION))
656 .min(Self::MAX_BATTERY_THRESHOLD)
657 }
658
659 #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
660 /// Schedules the result of an optimal orbit calculation as tasks.
661 ///
662 /// # Arguments
663 /// - `f_cont_lock`: A reference-counted and thread-safe lock for accessing the flight computer state.
664 /// - `base_t`: The base timestamp used for scheduling adjustments.
665 /// - `res`: The result of the optimal orbit calculation, including decisions about state transitions.
666 /// - `dt_sh`: The initial shift in time steps to apply during scheduling.
667 /// - `trunc`: A flag indicating whether to clear the current schedule before scheduling new tasks.
668 ///
669 /// # Returns
670 /// - The total number of tasks added to the task schedule.
671 async fn sched_opt_orbit_res(
672 &self,
673 base_t: DateTime<Utc>,
674 res: OptimalOrbitResult,
675 dt_sh: usize,
676 trunc: bool,
677 (batt_f32, mut state): (I32F32, usize),
678 ) -> (usize, I32F32) {
679 if trunc {
680 // Clear the existing schedule if truncation is requested.
681 self.clear_schedule().await;
682 }
683
684 let mut dt = dt_sh;
685 let max_mapped = Self::map_e_to_dp(Self::MAX_BATTERY_THRESHOLD);
686
687 // Map the current battery level into a discrete range.
688 let mut batt = Self::map_e_to_dp(batt_f32);
689 let pred_secs = res.decisions.dt_len();
690 let decisions = &res.decisions;
691
692 // Iterate through each time step and apply the corresponding decision logic.
693 while dt < pred_secs {
694 let decision = decisions.get(dt, batt, state);
695
696 match decision {
697 AtomicDecision::StayInCharge => {
698 // Stay in the charge state, increment battery level.
699 state = 0;
700 batt = (batt + 1).min(max_mapped);
701 dt += 1;
702 }
703 AtomicDecision::StayInAcquisition => {
704 // Stay in the acquisition state, decrement battery level.
705 state = 1;
706 if batt == 0 {
707 error!("Battery level is already at 0!");
708 error!("current: {dt} max: {pred_secs} init_batt: {batt_f32}");
709 } else {
710 batt -= 1;
711 }
712 dt += 1;
713 }
714 AtomicDecision::SwitchToCharge => {
715 // Schedule a state change to "Charge" with an appropriate time delay.
716 let sched_t = base_t + TimeDelta::seconds(dt as i64);
717 self.schedule_switch(FlightState::Charge, sched_t).await;
718 state = 0;
719 dt = (dt + 180).min(pred_secs); // Add a delay for the transition.
720 }
721 AtomicDecision::SwitchToAcquisition => {
722 // Schedule a state change to "Acquisition" with an appropriate time delay.
723 let sched_t = base_t + TimeDelta::seconds(dt as i64);
724 self.schedule_switch(FlightState::Acquisition, sched_t).await;
725 state = 1;
726 dt = (dt + 180).min(pred_secs); // Add a delay for the transition.
727 }
728 }
729 }
730 // Return the final number of tasks in the schedule.
731 (
732 self.task_schedule.read().await.len(),
733 Self::map_dp_to_e(batt),
734 )
735 }
736
737 /// Provides a reference to the image task schedule.
738 ///
739 /// # Returns
740 /// - An `Arc` pointing to the `LockedTaskQueue`.
741 pub fn sched_arc(&self) -> Arc<RwLock<VecDeque<Task>>> { Arc::clone(&self.task_schedule) }
742
743 /// Schedules a task to switch the flight state at a specific time.
744 ///
745 /// # Arguments
746 /// - `target`: The target flight state to switch to.
747 /// - `sched_t`: The scheduled time for the state change as a `DateTime`.
748 async fn schedule_switch(&self, target: FlightState, sched_t: DateTime<Utc>) {
749 self.enqueue_task(Task::switch_target(target, sched_t)).await;
750 }
751
752 /// Schedules a task to capture an image at a specific time and position using the given camera lens.
753 ///
754 /// This method creates and enqueues a `Task::TakeImage` operation, wrapping the target
755 /// position to `u32` dimensions and assigning the provided lens type.
756 ///
757 /// # Arguments
758 /// - `t`: The scheduled time to capture the image.
759 /// - `pos`: The unwrapped 2D map position of the target.
760 /// - `lens`: The [`CameraAngle`] specifying which lens to use.
761 async fn schedule_zo_image(&self, t: DateTime<Utc>, pos: Vec2D<I32F32>, lens: CameraAngle) {
762 let pos_u32 = Vec2D::new(pos.x().to_num::<u32>(), pos.y().to_num::<u32>());
763 self.enqueue_task(Task::image_task(pos_u32, lens, t)).await;
764 }
765
766 /// Prepares and schedules the full sequence for capturing a Zoned Objective (ZO) image.
767 ///
768 /// This includes scheduling a transition from the current flight state to [`FlightState::Charge`],
769 /// then back to [`FlightState::Acquisition`] if feasible just before the first image capture, and finally
770 /// scheduling the actual image task.
771 ///
772 /// # Arguments
773 /// - `t`: The nominal time at which the image should be taken.
774 /// - `pos`: The target position on the map for the ZO image.
775 /// - `lens`: The lens configuration to use for capturing the image.
776 pub async fn schedule_retrieval_phase(
777 &self,
778 t: DateTime<Utc>,
779 pos: Vec2D<I32F32>,
780 lens: CameraAngle,
781 ) {
782 let t_first = t - Self::ZO_IMAGE_FIRST_DEL;
783 let trans_time = FlightState::Acquisition.td_dt_to(FlightState::Charge);
784 if Utc::now() + trans_time * 2 < t_first {
785 self.schedule_switch(FlightState::Charge, Utc::now()).await;
786 let last_charge_leave = t_first - trans_time;
787 self.schedule_switch(FlightState::Acquisition, last_charge_leave).await;
788 }
789 self.schedule_zo_image(t_first, pos, lens).await;
790 }
791
792 /// Schedules a velocity change task for a given burn sequence.
793 ///
794 /// # Arguments
795 /// - `burn`: The `BurnSequence` containing the velocity change details.
796 ///
797 /// # Returns
798 /// - The total number of tasks in the schedule after adding the velocity change task.
799 pub async fn schedule_vel_change(self: Arc<TaskController>, burn: BurnSequence) -> usize {
800 let due = burn.start_i().t();
801 self.enqueue_task(Task::vel_change_task(burn, due)).await;
802 self.task_schedule.read().await.len()
803 }
804
805 /// Clears tasks scheduled after a specified delay.
806 ///
807 /// # Arguments
808 /// - `dt`: The `DateTime<Utc>` representing the cutoff time for retaining tasks.
809 pub async fn clear_after_dt(&self, dt: DateTime<Utc>) {
810 let schedule_lock = &*self.task_schedule;
811 if !schedule_lock.read().await.is_empty() {
812 return;
813 }
814 let mut schedule = schedule_lock.write().await;
815 let schedule_len = schedule.len();
816 let mut first_remove = 0;
817 for i in 0..schedule_len {
818 if schedule[i].t() > dt {
819 first_remove = i;
820 break;
821 }
822 }
823 schedule.drain(first_remove..schedule_len);
824 }
825
826 /// Adds a task to the task schedule.
827 ///
828 /// # Arguments
829 /// - `task`: The `Task` to be added to the task schedule.
830 async fn enqueue_task(&self, task: Task) { self.task_schedule.write().await.push_back(task); }
831
832 /// Clears all pending tasks in the schedule.
833 pub async fn clear_schedule(&self) {
834 let schedule = &*self.task_schedule;
835 log!("Clearing task schedule...");
836 schedule.write().await.clear();
837 }
838}