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}