elevator_core/dispatch/mod.rs
1//! Pluggable dispatch strategies for assigning elevators to stops.
2//!
3//! Strategies express preferences as scores on `(car, stop)` pairs via
4//! [`DispatchStrategy::rank`]. The dispatch system then runs an optimal
5//! bipartite assignment (Kuhn–Munkres / Hungarian algorithm) so coordination
6//! — one car per hall call — is a library invariant, not a per-strategy
7//! responsibility. Cars left unassigned are handed to
8//! [`DispatchStrategy::fallback`] for per-car policy (idle, park, etc.).
9//!
10//! # Example: custom dispatch strategy
11//!
12//! ```rust
13//! use elevator_core::prelude::*;
14//! use elevator_core::dispatch::{
15//! DispatchDecision, DispatchManifest, ElevatorGroup,
16//! };
17//!
18//! struct AlwaysFirstStop;
19//!
20//! impl DispatchStrategy for AlwaysFirstStop {
21//! fn rank(
22//! &mut self,
23//! _car: EntityId,
24//! car_position: f64,
25//! stop: EntityId,
26//! stop_position: f64,
27//! group: &ElevatorGroup,
28//! _manifest: &DispatchManifest,
29//! _world: &elevator_core::world::World,
30//! ) -> Option<f64> {
31//! // Prefer the group's first stop; everything else is unavailable.
32//! if Some(&stop) == group.stop_entities().first() {
33//! Some((car_position - stop_position).abs())
34//! } else {
35//! None
36//! }
37//! }
38//! }
39//!
40//! let sim = SimulationBuilder::demo()
41//! .dispatch(AlwaysFirstStop)
42//! .build()
43//! .unwrap();
44//! ```
45
46/// Hall-call destination dispatch algorithm.
47pub mod destination;
48/// Estimated Time to Destination dispatch algorithm.
49pub mod etd;
50/// LOOK dispatch algorithm.
51pub mod look;
52/// Nearest-car dispatch algorithm.
53pub mod nearest_car;
54/// Built-in repositioning strategies.
55pub mod reposition;
56/// SCAN dispatch algorithm.
57pub mod scan;
58/// Shared sweep-direction logic used by SCAN and LOOK.
59pub(crate) mod sweep;
60
61pub use destination::{AssignedCar, DestinationDispatch};
62pub use etd::EtdDispatch;
63pub use look::LookDispatch;
64pub use nearest_car::NearestCarDispatch;
65pub use scan::ScanDispatch;
66
67use serde::{Deserialize, Serialize};
68
69use crate::entity::EntityId;
70use crate::ids::GroupId;
71use crate::world::World;
72use std::collections::BTreeMap;
73
74/// Metadata about a single rider, available to dispatch strategies.
75#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub struct RiderInfo {
78 /// Rider entity ID.
79 pub id: EntityId,
80 /// Rider's destination stop entity (from route).
81 pub destination: Option<EntityId>,
82 /// Rider weight.
83 pub weight: f64,
84 /// Ticks this rider has been waiting (0 if riding).
85 pub wait_ticks: u64,
86}
87
88/// Full demand picture for dispatch decisions.
89///
90/// Contains per-rider metadata grouped by stop, enabling entity-aware
91/// dispatch strategies (priority, weight-aware, VIP-first, etc.).
92///
93/// Uses `BTreeMap` for deterministic iteration order.
94#[derive(Debug, Clone, Default)]
95pub struct DispatchManifest {
96 /// Riders waiting at each stop, with full per-rider metadata.
97 pub waiting_at_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
98 /// Riders currently aboard elevators, grouped by their destination stop.
99 pub riding_to_stop: BTreeMap<EntityId, Vec<RiderInfo>>,
100 /// Number of residents at each stop (read-only hint for dispatch strategies).
101 pub resident_count_at_stop: BTreeMap<EntityId, usize>,
102}
103
104impl DispatchManifest {
105 /// Number of riders waiting at a stop.
106 #[must_use]
107 pub fn waiting_count_at(&self, stop: EntityId) -> usize {
108 self.waiting_at_stop.get(&stop).map_or(0, Vec::len)
109 }
110
111 /// Total weight of riders waiting at a stop.
112 #[must_use]
113 pub fn total_weight_at(&self, stop: EntityId) -> f64 {
114 self.waiting_at_stop
115 .get(&stop)
116 .map_or(0.0, |riders| riders.iter().map(|r| r.weight).sum())
117 }
118
119 /// Number of riders heading to a stop (aboard elevators).
120 #[must_use]
121 pub fn riding_count_to(&self, stop: EntityId) -> usize {
122 self.riding_to_stop.get(&stop).map_or(0, Vec::len)
123 }
124
125 /// Whether a stop has any demand (waiting riders or riders heading there).
126 #[must_use]
127 pub fn has_demand(&self, stop: EntityId) -> bool {
128 self.waiting_count_at(stop) > 0 || self.riding_count_to(stop) > 0
129 }
130
131 /// Number of residents at a stop (read-only hint, not active demand).
132 #[must_use]
133 pub fn resident_count_at(&self, stop: EntityId) -> usize {
134 self.resident_count_at_stop.get(&stop).copied().unwrap_or(0)
135 }
136}
137
138/// Serializable identifier for built-in dispatch strategies.
139///
140/// Used in snapshots and config files to restore the correct strategy
141/// without requiring the game to manually re-wire dispatch. Custom strategies
142/// are represented by the `Custom(String)` variant.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[non_exhaustive]
145pub enum BuiltinStrategy {
146 /// SCAN (elevator) algorithm — sweeps end-to-end.
147 Scan,
148 /// LOOK algorithm — reverses at last request.
149 Look,
150 /// Nearest-car — assigns closest idle elevator.
151 NearestCar,
152 /// Estimated Time to Destination — minimizes total cost.
153 Etd,
154 /// Hall-call destination dispatch — sticky per-rider assignment.
155 Destination,
156 /// Custom strategy identified by name. The game must provide a factory.
157 Custom(String),
158}
159
160impl std::fmt::Display for BuiltinStrategy {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Scan => write!(f, "Scan"),
164 Self::Look => write!(f, "Look"),
165 Self::NearestCar => write!(f, "NearestCar"),
166 Self::Etd => write!(f, "Etd"),
167 Self::Destination => write!(f, "Destination"),
168 Self::Custom(name) => write!(f, "Custom({name})"),
169 }
170 }
171}
172
173impl BuiltinStrategy {
174 /// Instantiate the dispatch strategy for this variant.
175 ///
176 /// Returns `None` for `Custom` — the game must provide those via
177 /// a factory function.
178 #[must_use]
179 pub fn instantiate(&self) -> Option<Box<dyn DispatchStrategy>> {
180 match self {
181 Self::Scan => Some(Box::new(scan::ScanDispatch::new())),
182 Self::Look => Some(Box::new(look::LookDispatch::new())),
183 Self::NearestCar => Some(Box::new(nearest_car::NearestCarDispatch::new())),
184 Self::Etd => Some(Box::new(etd::EtdDispatch::new())),
185 Self::Destination => Some(Box::new(destination::DestinationDispatch::new())),
186 Self::Custom(_) => None,
187 }
188 }
189}
190
191/// Decision returned by a dispatch strategy.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193#[non_exhaustive]
194pub enum DispatchDecision {
195 /// Go to the specified stop entity.
196 GoToStop(EntityId),
197 /// Remain idle.
198 Idle,
199}
200
201/// Per-line relationship data within an [`ElevatorGroup`].
202///
203/// This is a denormalized cache maintained by [`Simulation`](crate::sim::Simulation).
204/// The source of truth for intrinsic line properties is the
205/// [`Line`](crate::components::Line) component in World.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct LineInfo {
208 /// Line entity ID.
209 entity: EntityId,
210 /// Elevator entities on this line.
211 elevators: Vec<EntityId>,
212 /// Stop entities served by this line.
213 serves: Vec<EntityId>,
214}
215
216impl LineInfo {
217 /// Create a new `LineInfo`.
218 #[must_use]
219 pub const fn new(entity: EntityId, elevators: Vec<EntityId>, serves: Vec<EntityId>) -> Self {
220 Self {
221 entity,
222 elevators,
223 serves,
224 }
225 }
226
227 /// Line entity ID.
228 #[must_use]
229 pub const fn entity(&self) -> EntityId {
230 self.entity
231 }
232
233 /// Elevator entities on this line.
234 #[must_use]
235 pub fn elevators(&self) -> &[EntityId] {
236 &self.elevators
237 }
238
239 /// Stop entities served by this line.
240 #[must_use]
241 pub fn serves(&self) -> &[EntityId] {
242 &self.serves
243 }
244
245 /// Set the line entity ID (used during snapshot restore).
246 pub(crate) const fn set_entity(&mut self, entity: EntityId) {
247 self.entity = entity;
248 }
249
250 /// Mutable access to elevator entities on this line.
251 pub(crate) const fn elevators_mut(&mut self) -> &mut Vec<EntityId> {
252 &mut self.elevators
253 }
254
255 /// Mutable access to stop entities served by this line.
256 pub(crate) const fn serves_mut(&mut self) -> &mut Vec<EntityId> {
257 &mut self.serves
258 }
259}
260
261/// Runtime elevator group: a set of lines sharing a dispatch strategy.
262///
263/// A group is the logical dispatch unit. It contains one or more
264/// [`LineInfo`] entries, each representing a physical path with its
265/// elevators and served stops.
266///
267/// The flat `elevator_entities` and `stop_entities` fields are derived
268/// caches (union of all lines' elevators/stops), rebuilt automatically
269/// via [`rebuild_caches()`](Self::rebuild_caches).
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct ElevatorGroup {
272 /// Unique group identifier.
273 id: GroupId,
274 /// Human-readable group name.
275 name: String,
276 /// Lines belonging to this group.
277 lines: Vec<LineInfo>,
278 /// Derived flat cache — rebuilt by `rebuild_caches()`.
279 elevator_entities: Vec<EntityId>,
280 /// Derived flat cache — rebuilt by `rebuild_caches()`.
281 stop_entities: Vec<EntityId>,
282}
283
284impl ElevatorGroup {
285 /// Create a new group with the given lines. Caches are built automatically.
286 #[must_use]
287 pub fn new(id: GroupId, name: String, lines: Vec<LineInfo>) -> Self {
288 let mut group = Self {
289 id,
290 name,
291 lines,
292 elevator_entities: Vec::new(),
293 stop_entities: Vec::new(),
294 };
295 group.rebuild_caches();
296 group
297 }
298
299 /// Unique group identifier.
300 #[must_use]
301 pub const fn id(&self) -> GroupId {
302 self.id
303 }
304
305 /// Human-readable group name.
306 #[must_use]
307 pub fn name(&self) -> &str {
308 &self.name
309 }
310
311 /// Lines belonging to this group.
312 #[must_use]
313 pub fn lines(&self) -> &[LineInfo] {
314 &self.lines
315 }
316
317 /// Mutable access to lines (call [`rebuild_caches()`](Self::rebuild_caches) after mutating).
318 pub const fn lines_mut(&mut self) -> &mut Vec<LineInfo> {
319 &mut self.lines
320 }
321
322 /// Elevator entities belonging to this group (derived from lines).
323 #[must_use]
324 pub fn elevator_entities(&self) -> &[EntityId] {
325 &self.elevator_entities
326 }
327
328 /// Stop entities served by this group (derived from lines, deduplicated).
329 #[must_use]
330 pub fn stop_entities(&self) -> &[EntityId] {
331 &self.stop_entities
332 }
333
334 /// Push a stop entity directly into the group's stop cache.
335 ///
336 /// Use when a stop belongs to the group for dispatch purposes but is
337 /// not (yet) assigned to any line. Call `add_stop_to_line` later to
338 /// wire it into the topology graph.
339 pub(crate) fn push_stop(&mut self, stop: EntityId) {
340 if !self.stop_entities.contains(&stop) {
341 self.stop_entities.push(stop);
342 }
343 }
344
345 /// Push an elevator entity directly into the group's elevator cache
346 /// (in addition to the line it belongs to).
347 pub(crate) fn push_elevator(&mut self, elevator: EntityId) {
348 if !self.elevator_entities.contains(&elevator) {
349 self.elevator_entities.push(elevator);
350 }
351 }
352
353 /// Rebuild derived caches from lines. Call after mutating lines.
354 pub fn rebuild_caches(&mut self) {
355 self.elevator_entities = self
356 .lines
357 .iter()
358 .flat_map(|li| li.elevators.iter().copied())
359 .collect();
360 let mut stops: Vec<EntityId> = self
361 .lines
362 .iter()
363 .flat_map(|li| li.serves.iter().copied())
364 .collect();
365 stops.sort_unstable();
366 stops.dedup();
367 self.stop_entities = stops;
368 }
369}
370
371/// Pluggable dispatch algorithm.
372///
373/// Strategies implement [`rank`](Self::rank) to score each `(car, stop)`
374/// pair; the dispatch system then performs an optimal assignment across
375/// the whole group, guaranteeing that no two cars are sent to the same
376/// hall call.
377///
378/// Returning `None` from `rank` excludes a pair from assignment — useful
379/// for capacity limits, direction preferences, restricted stops, or
380/// sticky commitments.
381///
382/// Cars that receive no stop fall through to [`fallback`](Self::fallback),
383/// which returns the policy for that car (idle, park, etc.).
384pub trait DispatchStrategy: Send + Sync {
385 /// Optional hook called once per group before the assignment pass.
386 ///
387 /// Strategies that need to mutate [`World`] extension storage (e.g.
388 /// [`DestinationDispatch`] writing sticky rider → car assignments)
389 /// or pre-populate [`crate::components::DestinationQueue`] entries
390 /// override this. Default: no-op.
391 fn pre_dispatch(
392 &mut self,
393 _group: &ElevatorGroup,
394 _manifest: &DispatchManifest,
395 _world: &mut World,
396 ) {
397 }
398
399 /// Optional hook called once per candidate car, before any
400 /// [`rank`](Self::rank) calls for that car in the current pass.
401 ///
402 /// Strategies whose ranking depends on stable per-car state (e.g. the
403 /// sweep direction used by SCAN/LOOK) set that state here so later
404 /// `rank` calls see a consistent view regardless of iteration order.
405 /// The default is a no-op.
406 fn prepare_car(
407 &mut self,
408 _car: EntityId,
409 _car_position: f64,
410 _group: &ElevatorGroup,
411 _manifest: &DispatchManifest,
412 _world: &World,
413 ) {
414 }
415
416 /// Score the cost of sending `car` to `stop`. Lower is better.
417 ///
418 /// Returning `None` marks this `(car, stop)` pair as unavailable;
419 /// the assignment algorithm will never pair them. Use this for
420 /// capacity limits, wrong-direction stops, stops outside the line's
421 /// topology, or pairs already committed via a sticky assignment.
422 ///
423 /// Must return a finite, non-negative value if `Some` — infinities
424 /// and NaN can destabilize the underlying Hungarian solver.
425 ///
426 /// Implementations must not mutate per-car state inside `rank`: the
427 /// dispatch system calls `rank(car, stop_0..stop_m)` in a loop, so
428 /// mutating `self` on one call affects subsequent calls for the same
429 /// car within the same pass and produces an asymmetric cost matrix
430 /// whose results depend on iteration order. Use
431 /// [`prepare_car`](Self::prepare_car) to compute and store any
432 /// per-car state before `rank` is called.
433 #[allow(clippy::too_many_arguments)]
434 fn rank(
435 &mut self,
436 car: EntityId,
437 car_position: f64,
438 stop: EntityId,
439 stop_position: f64,
440 group: &ElevatorGroup,
441 manifest: &DispatchManifest,
442 world: &World,
443 ) -> Option<f64>;
444
445 /// Decide what an idle car should do when no stop was assigned to it.
446 ///
447 /// Called for each car the assignment phase could not pair with a
448 /// stop (because there were no stops, or all candidate stops had
449 /// rank `None` for this car). Default: [`DispatchDecision::Idle`].
450 fn fallback(
451 &mut self,
452 _car: EntityId,
453 _car_position: f64,
454 _group: &ElevatorGroup,
455 _manifest: &DispatchManifest,
456 _world: &World,
457 ) -> DispatchDecision {
458 DispatchDecision::Idle
459 }
460
461 /// Notify the strategy that an elevator has been removed.
462 ///
463 /// Implementations with per-elevator state (e.g. direction tracking)
464 /// should clean up here to prevent unbounded memory growth.
465 fn notify_removed(&mut self, _elevator: EntityId) {}
466}
467
468/// Resolution of a single dispatch assignment pass for one group.
469///
470/// Produced by [`assign`] and consumed by
471/// [`crate::systems::dispatch::run`] to apply decisions to the world.
472#[derive(Debug, Clone)]
473pub struct AssignmentResult {
474 /// `(car, decision)` pairs for every idle car in the group.
475 pub decisions: Vec<(EntityId, DispatchDecision)>,
476}
477
478/// Sentinel weight used to pad unavailable `(car, stop)` pairs when
479/// building the cost matrix for the Hungarian solver. Chosen so that
480/// `n · SENTINEL` can't overflow `i64`: the Kuhn–Munkres implementation
481/// sums weights and potentials across each row/column internally, so
482/// headroom of ~2¹⁵ above the sentinel lets groups scale past 30 000
483/// cars or stops before any arithmetic risk appears.
484const ASSIGNMENT_SENTINEL: i64 = 1 << 48;
485/// Fixed-point scale for converting `f64` costs to the `i64` values the
486/// Hungarian solver requires. One unit ≈ one micro-tick / millimeter.
487const ASSIGNMENT_SCALE: f64 = 1_000_000.0;
488
489/// Convert a `f64` rank cost into the fixed-point `i64` the Hungarian
490/// solver consumes. Non-finite, negative, or overflow-prone inputs map
491/// to the unavailable sentinel.
492fn scale_cost(cost: f64) -> i64 {
493 if !cost.is_finite() || cost < 0.0 {
494 return ASSIGNMENT_SENTINEL;
495 }
496 // Cap at just below sentinel so any real rank always beats unavailable.
497 (cost * ASSIGNMENT_SCALE)
498 .round()
499 .clamp(0.0, (ASSIGNMENT_SENTINEL - 1) as f64) as i64
500}
501
502/// Run one group's assignment pass: build the cost matrix, solve the
503/// optimal bipartite matching, then resolve unassigned cars via
504/// [`DispatchStrategy::fallback`].
505///
506/// Visible to the `systems` module; not part of the public API.
507pub(crate) fn assign(
508 strategy: &mut dyn DispatchStrategy,
509 idle_cars: &[(EntityId, f64)],
510 group: &ElevatorGroup,
511 manifest: &DispatchManifest,
512 world: &World,
513) -> AssignmentResult {
514 // Collect stops with active demand and known positions.
515 let pending_stops: Vec<(EntityId, f64)> = group
516 .stop_entities()
517 .iter()
518 .filter(|s| manifest.has_demand(**s))
519 .filter_map(|s| world.stop_position(*s).map(|p| (*s, p)))
520 .collect();
521
522 let n = idle_cars.len();
523 let m = pending_stops.len();
524
525 if n == 0 {
526 return AssignmentResult {
527 decisions: Vec::new(),
528 };
529 }
530
531 let mut decisions: Vec<(EntityId, DispatchDecision)> = Vec::with_capacity(n);
532
533 if m == 0 {
534 for &(eid, pos) in idle_cars {
535 let d = strategy.fallback(eid, pos, group, manifest, world);
536 decisions.push((eid, d));
537 }
538 return AssignmentResult { decisions };
539 }
540
541 // Build cost matrix. Hungarian requires rows <= cols.
542 let cols = n.max(m);
543 let mut data: Vec<i64> = vec![ASSIGNMENT_SENTINEL; n * cols];
544 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
545 strategy.prepare_car(car_eid, car_pos, group, manifest, world);
546 for (j, &(stop_eid, stop_pos)) in pending_stops.iter().enumerate() {
547 let scaled = strategy
548 .rank(car_eid, car_pos, stop_eid, stop_pos, group, manifest, world)
549 .map_or(ASSIGNMENT_SENTINEL, scale_cost);
550 data[i * cols + j] = scaled;
551 }
552 }
553 // `from_vec` only fails if `n * cols != data.len()` — both derived
554 // from `n` and `cols` above, so the construction is infallible. Fall
555 // back to an empty-result shape in the unlikely event the invariant
556 // is violated in future refactors.
557 let Ok(matrix) = pathfinding::matrix::Matrix::from_vec(n, cols, data) else {
558 for &(car_eid, car_pos) in idle_cars {
559 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
560 decisions.push((car_eid, d));
561 }
562 return AssignmentResult { decisions };
563 };
564 let (_, assignments) = pathfinding::kuhn_munkres::kuhn_munkres_min(&matrix);
565
566 for (i, &(car_eid, car_pos)) in idle_cars.iter().enumerate() {
567 let col = assignments[i];
568 // A real assignment is: col points to a real stop (col < m) AND
569 // the cost isn't sentinel-padded (meaning rank() returned Some).
570 if col < m && matrix[(i, col)] < ASSIGNMENT_SENTINEL {
571 let (stop_eid, _) = pending_stops[col];
572 decisions.push((car_eid, DispatchDecision::GoToStop(stop_eid)));
573 } else {
574 let d = strategy.fallback(car_eid, car_pos, group, manifest, world);
575 decisions.push((car_eid, d));
576 }
577 }
578
579 AssignmentResult { decisions }
580}
581
582/// Pluggable strategy for repositioning idle elevators.
583///
584/// After the dispatch phase, elevators that remain idle (no pending
585/// assignments) are candidates for repositioning. The strategy decides
586/// where each idle elevator should move to improve coverage and reduce
587/// expected response times.
588///
589/// Implementations receive the set of idle elevator positions and the
590/// group's stop positions, then return a target stop for each elevator
591/// (or `None` to leave it in place).
592pub trait RepositionStrategy: Send + Sync {
593 /// Decide where to reposition idle elevators.
594 ///
595 /// Returns a vec of `(elevator_entity, target_stop_entity)` pairs.
596 /// Elevators not in the returned vec remain idle.
597 fn reposition(
598 &mut self,
599 idle_elevators: &[(EntityId, f64)],
600 stop_positions: &[(EntityId, f64)],
601 group: &ElevatorGroup,
602 world: &World,
603 ) -> Vec<(EntityId, EntityId)>;
604}
605
606/// Serializable identifier for built-in repositioning strategies.
607///
608/// Used in config and snapshots to restore the correct strategy.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[non_exhaustive]
611pub enum BuiltinReposition {
612 /// Distribute idle elevators evenly across stops.
613 SpreadEvenly,
614 /// Return idle elevators to a configured home stop.
615 ReturnToLobby,
616 /// Position near stops with historically high demand.
617 DemandWeighted,
618 /// Keep idle elevators where they are (no-op).
619 NearestIdle,
620 /// Custom strategy identified by name.
621 Custom(String),
622}
623
624impl std::fmt::Display for BuiltinReposition {
625 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626 match self {
627 Self::SpreadEvenly => write!(f, "SpreadEvenly"),
628 Self::ReturnToLobby => write!(f, "ReturnToLobby"),
629 Self::DemandWeighted => write!(f, "DemandWeighted"),
630 Self::NearestIdle => write!(f, "NearestIdle"),
631 Self::Custom(name) => write!(f, "Custom({name})"),
632 }
633 }
634}
635
636impl BuiltinReposition {
637 /// Instantiate the reposition strategy for this variant.
638 ///
639 /// Returns `None` for `Custom` — the game must provide those via
640 /// a factory function. `ReturnToLobby` uses stop index 0 as default.
641 #[must_use]
642 pub fn instantiate(&self) -> Option<Box<dyn RepositionStrategy>> {
643 match self {
644 Self::SpreadEvenly => Some(Box::new(reposition::SpreadEvenly)),
645 Self::ReturnToLobby => Some(Box::new(reposition::ReturnToLobby::new())),
646 Self::DemandWeighted => Some(Box::new(reposition::DemandWeighted)),
647 Self::NearestIdle => Some(Box::new(reposition::NearestIdle)),
648 Self::Custom(_) => None,
649 }
650 }
651}