Skip to main content

elevator_core/sim/
construction.rs

1//! Simulation construction, validation, and topology assembly.
2//!
3//! Split out from `sim.rs` to keep each concern readable. Holds:
4//!
5//! - [`Simulation::new`] and [`Simulation::new_with_hooks`]
6//! - Config validation ([`Simulation::validate_config`] and helpers)
7//! - Legacy and explicit topology builders
8//! - [`Simulation::from_parts`] for snapshot restore
9//! - Dispatch, reposition, and hook registration helpers
10//!
11//! Since this is a child module of `crate::sim`, it can access `Simulation`'s
12//! private fields directly — no visibility relaxation required.
13
14use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Mutex;
16
17use crate::components::{Elevator, ElevatorPhase, Line, Orientation, Position, Stop, Velocity};
18use crate::config::SimConfig;
19use crate::dispatch::{
20    BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
21    RepositionStrategy,
22};
23use crate::door::DoorState;
24use crate::entity::EntityId;
25use crate::error::SimError;
26use crate::events::EventBus;
27use crate::hooks::{Phase, PhaseHooks};
28use crate::ids::GroupId;
29use crate::metrics::Metrics;
30use crate::rider_index::RiderIndex;
31use crate::stop::StopId;
32use crate::time::TimeAdapter;
33use crate::topology::TopologyGraph;
34use crate::world::World;
35
36use super::Simulation;
37
38/// Bundled topology result: groups, dispatchers, and strategy IDs.
39type TopologyResult = (
40    Vec<ElevatorGroup>,
41    BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
42    BTreeMap<GroupId, BuiltinStrategy>,
43);
44
45/// Ensure DCS groups have `HallCallMode::Destination` at construction
46/// time. Non-DCS groups are left at whatever the config specified —
47/// forcing Classic here would clobber explicit config overrides (e.g. a
48/// Scan group that the author deliberately set to Destination mode).
49///
50/// Runtime swaps via [`Simulation::set_dispatch`] do a full bidirectional
51/// sync because a strategy change is an explicit user action where
52/// resetting the mode is expected.
53fn sync_hall_call_modes(
54    groups: &mut [ElevatorGroup],
55    strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
56) {
57    for group in groups.iter_mut() {
58        if strategy_ids.get(&group.id()) == Some(&BuiltinStrategy::Destination) {
59            group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
60        }
61    }
62}
63
64/// Validate the physics fields shared by [`crate::config::ElevatorConfig`]
65/// and [`super::ElevatorParams`]. Both construction-time validation and
66/// the runtime `add_elevator` path call this so an invalid set of params
67/// can never reach the world (zeroes blow up movement; zero door ticks
68/// stall the door FSM).
69#[allow(clippy::too_many_arguments)]
70pub(super) fn validate_elevator_physics(
71    max_speed: f64,
72    acceleration: f64,
73    deceleration: f64,
74    weight_capacity: f64,
75    inspection_speed_factor: f64,
76    door_transition_ticks: u32,
77    door_open_ticks: u32,
78    bypass_load_up_pct: Option<f64>,
79    bypass_load_down_pct: Option<f64>,
80) -> Result<(), SimError> {
81    if !max_speed.is_finite() || max_speed <= 0.0 {
82        return Err(SimError::InvalidConfig {
83            field: "elevators.max_speed",
84            reason: format!("must be finite and positive, got {max_speed}"),
85        });
86    }
87    if !acceleration.is_finite() || acceleration <= 0.0 {
88        return Err(SimError::InvalidConfig {
89            field: "elevators.acceleration",
90            reason: format!("must be finite and positive, got {acceleration}"),
91        });
92    }
93    if !deceleration.is_finite() || deceleration <= 0.0 {
94        return Err(SimError::InvalidConfig {
95            field: "elevators.deceleration",
96            reason: format!("must be finite and positive, got {deceleration}"),
97        });
98    }
99    if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
100        return Err(SimError::InvalidConfig {
101            field: "elevators.weight_capacity",
102            reason: format!("must be finite and positive, got {weight_capacity}"),
103        });
104    }
105    if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
106        return Err(SimError::InvalidConfig {
107            field: "elevators.inspection_speed_factor",
108            reason: format!("must be finite and positive, got {inspection_speed_factor}"),
109        });
110    }
111    if door_transition_ticks == 0 {
112        return Err(SimError::InvalidConfig {
113            field: "elevators.door_transition_ticks",
114            reason: "must be > 0".into(),
115        });
116    }
117    if door_open_ticks == 0 {
118        return Err(SimError::InvalidConfig {
119            field: "elevators.door_open_ticks",
120            reason: "must be > 0".into(),
121        });
122    }
123    validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
124    validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
125    Ok(())
126}
127
128/// `bypass_load_{up,down}_pct` must be a finite fraction in `(0.0, 1.0]`
129/// when set. `pct = 0.0` would bypass at an empty car (nonsense); `NaN`
130/// and infinities silently disable the bypass under the dispatch guard,
131/// which is a silent foot-gun. Reject at config time instead.
132fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
133    let Some(pct) = pct else {
134        return Ok(());
135    };
136    if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
137        return Err(SimError::InvalidConfig {
138            field,
139            reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
140        });
141    }
142    Ok(())
143}
144
145impl Simulation {
146    /// Create a new simulation from config and a dispatch strategy.
147    ///
148    /// Returns `Err` if the config is invalid (zero stops, duplicate IDs,
149    /// negative speeds, etc.).
150    ///
151    /// # Errors
152    ///
153    /// Returns [`SimError::InvalidConfig`] if the configuration has zero stops,
154    /// duplicate stop IDs, zero elevators, non-positive physics parameters,
155    /// invalid starting stops, or non-positive tick rate.
156    pub fn new(
157        config: &SimConfig,
158        dispatch: impl DispatchStrategy + 'static,
159    ) -> Result<Self, SimError> {
160        let mut dispatchers = BTreeMap::new();
161        dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
162        Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
163    }
164
165    /// Create a simulation with pre-configured lifecycle hooks.
166    ///
167    /// Used by [`SimulationBuilder`](crate::builder::SimulationBuilder).
168    #[allow(clippy::too_many_lines)]
169    pub(crate) fn new_with_hooks(
170        config: &SimConfig,
171        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
172        hooks: PhaseHooks,
173    ) -> Result<Self, SimError> {
174        Self::validate_config(config)?;
175
176        let mut world = World::new();
177
178        // Create stop entities.
179        let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
180        for sc in &config.building.stops {
181            let eid = world.spawn();
182            world.set_stop(
183                eid,
184                Stop {
185                    name: sc.name.clone(),
186                    position: sc.position,
187                },
188            );
189            world.set_position(eid, Position { value: sc.position });
190            stop_lookup.insert(sc.id, eid);
191        }
192
193        // Build sorted-stops index for O(log n) PassingFloor detection.
194        let mut sorted: Vec<(f64, EntityId)> = world
195            .iter_stops()
196            .map(|(eid, stop)| (stop.position, eid))
197            .collect();
198        sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
199        world.insert_resource(crate::world::SortedStops(sorted));
200
201        // Per-stop arrival signal, appended on rider spawn and queried
202        // by dispatch/reposition strategies to drive traffic-mode
203        // switches and predictive parking. The destination mirror is
204        // what powers down-peak detection — without it the classifier
205        // sees `total_dest = 0` and silently never emits `DownPeak`.
206        world.insert_resource(crate::arrival_log::ArrivalLog::default());
207        world.insert_resource(crate::arrival_log::DestinationLog::default());
208        world.insert_resource(crate::arrival_log::CurrentTick::default());
209        world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
210        // Traffic-mode classifier. Auto-refreshed in the metrics phase
211        // from the same rolling window; strategies read the current
212        // mode via `World::resource::<TrafficDetector>()`.
213        world.insert_resource(crate::traffic_detector::TrafficDetector::default());
214        // Per-car reposition cooldown. Populated by the movement
215        // phase when a repositioning car arrives; consulted by the
216        // reposition phase to skip cars that just parked so the
217        // hot-stop ranking can't flip them around again the next
218        // tick.
219        world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
220        // Expose tick rate to strategies that need to unit-convert
221        // tick-denominated elevator fields (door cycle, ack latency)
222        // into the second-denominated terms of their cost functions.
223        // Without this, ETD's door-overhead term was summing ticks
224        // into a seconds expression and getting ~60× over-weighted.
225        world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
226
227        let (mut groups, dispatchers, strategy_ids) =
228            if let Some(line_configs) = &config.building.lines {
229                Self::build_explicit_topology(
230                    &mut world,
231                    config,
232                    line_configs,
233                    &stop_lookup,
234                    builder_dispatchers,
235                )
236            } else {
237                Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
238            };
239        sync_hall_call_modes(&mut groups, &strategy_ids);
240
241        let dt = 1.0 / config.simulation.ticks_per_second;
242
243        world.insert_resource(crate::tagged_metrics::MetricTags::default());
244
245        // Collect line tag info (entity + name + elevator entities) before
246        // borrowing world mutably for MetricTags.
247        let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
248            .iter()
249            .flat_map(|group| {
250                group.lines().iter().filter_map(|li| {
251                    let line_comp = world.line(li.entity())?;
252                    Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
253                })
254            })
255            .collect();
256
257        // Tag line entities and their elevators with "line:{name}".
258        if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
259            for (line_eid, name, elevators) in &line_tag_info {
260                let tag = format!("line:{name}");
261                tags.tag(*line_eid, tag.clone());
262                for elev_eid in elevators {
263                    tags.tag(*elev_eid, tag.clone());
264                }
265            }
266        }
267
268        // Wire reposition strategies from group configs.
269        let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
270        let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
271        if let Some(group_configs) = &config.building.groups {
272            for gc in group_configs {
273                if let Some(ref repo_id) = gc.reposition
274                    && let Some(strategy) = repo_id.instantiate()
275                {
276                    let gid = GroupId(gc.id);
277                    repositioners.insert(gid, strategy);
278                    reposition_ids.insert(gid, repo_id.clone());
279                }
280            }
281        }
282
283        Ok(Self {
284            world,
285            events: EventBus::default(),
286            pending_output: Vec::new(),
287            tick: 0,
288            dt,
289            groups,
290            stop_lookup,
291            dispatchers,
292            strategy_ids,
293            repositioners,
294            reposition_ids,
295            metrics: Metrics::new(),
296            time: TimeAdapter::new(config.simulation.ticks_per_second),
297            hooks,
298            elevator_ids_buf: Vec::new(),
299            reposition_buf: Vec::new(),
300            topo_graph: Mutex::new(TopologyGraph::new()),
301            rider_index: RiderIndex::default(),
302            tick_in_progress: false,
303        })
304    }
305
306    /// Spawn a single elevator entity from an `ElevatorConfig` onto `line`.
307    ///
308    /// Sets position, velocity, all `Elevator` fields, optional energy profile,
309    /// optional service mode, and an empty `DestinationQueue`.
310    /// Returns the new entity ID.
311    fn spawn_elevator_entity(
312        world: &mut World,
313        ec: &crate::config::ElevatorConfig,
314        line: EntityId,
315        stop_lookup: &HashMap<StopId, EntityId>,
316        start_pos_lookup: &[crate::stop::StopConfig],
317    ) -> EntityId {
318        let eid = world.spawn();
319        let start_pos = start_pos_lookup
320            .iter()
321            .find(|s| s.id == ec.starting_stop)
322            .map_or(0.0, |s| s.position);
323        world.set_position(eid, Position { value: start_pos });
324        world.set_velocity(eid, Velocity { value: 0.0 });
325        let restricted: HashSet<EntityId> = ec
326            .restricted_stops
327            .iter()
328            .filter_map(|sid| stop_lookup.get(sid).copied())
329            .collect();
330        world.set_elevator(
331            eid,
332            Elevator {
333                phase: ElevatorPhase::Idle,
334                door: DoorState::Closed,
335                max_speed: ec.max_speed,
336                acceleration: ec.acceleration,
337                deceleration: ec.deceleration,
338                weight_capacity: ec.weight_capacity,
339                current_load: crate::components::Weight::ZERO,
340                riders: Vec::new(),
341                target_stop: None,
342                door_transition_ticks: ec.door_transition_ticks,
343                door_open_ticks: ec.door_open_ticks,
344                line,
345                repositioning: false,
346                restricted_stops: restricted,
347                inspection_speed_factor: ec.inspection_speed_factor,
348                going_up: true,
349                going_down: true,
350                move_count: 0,
351                door_command_queue: Vec::new(),
352                manual_target_velocity: None,
353                bypass_load_up_pct: ec.bypass_load_up_pct,
354                bypass_load_down_pct: ec.bypass_load_down_pct,
355            },
356        );
357        #[cfg(feature = "energy")]
358        if let Some(ref profile) = ec.energy_profile {
359            world.set_energy_profile(eid, profile.clone());
360            world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
361        }
362        if let Some(mode) = ec.service_mode {
363            world.set_service_mode(eid, mode);
364        }
365        world.set_destination_queue(eid, crate::components::DestinationQueue::new());
366        eid
367    }
368
369    /// Build topology from the legacy flat elevator list (single default line + group).
370    fn build_legacy_topology(
371        world: &mut World,
372        config: &SimConfig,
373        stop_lookup: &HashMap<StopId, EntityId>,
374        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
375    ) -> TopologyResult {
376        let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
377        let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
378        let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
379        let max_pos = stop_positions
380            .iter()
381            .copied()
382            .fold(f64::NEG_INFINITY, f64::max);
383
384        let default_line_eid = world.spawn();
385        world.set_line(
386            default_line_eid,
387            Line {
388                name: "Default".into(),
389                group: GroupId(0),
390                orientation: Orientation::Vertical,
391                position: None,
392                min_position: min_pos,
393                max_position: max_pos,
394                max_cars: None,
395            },
396        );
397
398        let mut elevator_entities = Vec::new();
399        for ec in &config.elevators {
400            let eid = Self::spawn_elevator_entity(
401                world,
402                ec,
403                default_line_eid,
404                stop_lookup,
405                &config.building.stops,
406            );
407            elevator_entities.push(eid);
408        }
409
410        let default_line_info =
411            LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
412
413        let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
414
415        // Legacy topology has exactly one group: GroupId(0). Honour a
416        // builder-provided dispatcher for that group; ignore any builder
417        // entry keyed on a different GroupId (it would have nothing to
418        // attach to). Pre-fix this used `into_iter().next()` which
419        // discarded the GroupId entirely and could attach a dispatcher
420        // intended for a different group to GroupId(0). (#288)
421        let mut dispatchers = BTreeMap::new();
422        let mut strategy_ids = BTreeMap::new();
423        let user_dispatcher = builder_dispatchers
424            .into_iter()
425            .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
426        if let Some(d) = user_dispatcher {
427            dispatchers.insert(GroupId(0), d);
428        } else {
429            dispatchers.insert(
430                GroupId(0),
431                Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
432            );
433        }
434        // strategy_ids defaults to Scan (the legacy-topology default and
435        // the type passed by every Simulation::new caller in practice).
436        // Builder users who install a non-Scan dispatcher should also
437        // call `.with_strategy_id(...)` if they need snapshot fidelity —
438        // we can't infer the BuiltinStrategy class from `Box<dyn>`.
439        strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
440
441        (vec![group], dispatchers, strategy_ids)
442    }
443
444    /// Build topology from explicit `LineConfig`/`GroupConfig` definitions.
445    #[allow(clippy::too_many_lines)]
446    fn build_explicit_topology(
447        world: &mut World,
448        config: &SimConfig,
449        line_configs: &[crate::config::LineConfig],
450        stop_lookup: &HashMap<StopId, EntityId>,
451        builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
452    ) -> TopologyResult {
453        // Map line config id → (line EntityId, LineInfo).
454        let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
455
456        for lc in line_configs {
457            // Resolve served stop entities.
458            let served_entities: Vec<EntityId> = lc
459                .serves
460                .iter()
461                .filter_map(|sid| stop_lookup.get(sid).copied())
462                .collect();
463
464            // Compute min/max from stops if not explicitly set.
465            let stop_positions: Vec<f64> = lc
466                .serves
467                .iter()
468                .filter_map(|sid| {
469                    config
470                        .building
471                        .stops
472                        .iter()
473                        .find(|s| s.id == *sid)
474                        .map(|s| s.position)
475                })
476                .collect();
477            let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
478            let auto_max = stop_positions
479                .iter()
480                .copied()
481                .fold(f64::NEG_INFINITY, f64::max);
482
483            let min_pos = lc.min_position.unwrap_or(auto_min);
484            let max_pos = lc.max_position.unwrap_or(auto_max);
485
486            let line_eid = world.spawn();
487            // The group assignment will be set when we process GroupConfigs.
488            // Default to GroupId(0) initially.
489            world.set_line(
490                line_eid,
491                Line {
492                    name: lc.name.clone(),
493                    group: GroupId(0),
494                    orientation: lc.orientation,
495                    position: lc.position,
496                    min_position: min_pos,
497                    max_position: max_pos,
498                    max_cars: lc.max_cars,
499                },
500            );
501
502            // Spawn elevators for this line.
503            let mut elevator_entities = Vec::new();
504            for ec in &lc.elevators {
505                let eid = Self::spawn_elevator_entity(
506                    world,
507                    ec,
508                    line_eid,
509                    stop_lookup,
510                    &config.building.stops,
511                );
512                elevator_entities.push(eid);
513            }
514
515            let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
516            line_map.insert(lc.id, (line_eid, line_info));
517        }
518
519        // Build groups from GroupConfigs, or auto-infer a single group.
520        let group_configs = config.building.groups.as_deref();
521        let mut groups = Vec::new();
522        let mut dispatchers = BTreeMap::new();
523        let mut strategy_ids = BTreeMap::new();
524
525        if let Some(gcs) = group_configs {
526            for gc in gcs {
527                let group_id = GroupId(gc.id);
528
529                let mut group_lines = Vec::new();
530
531                for &lid in &gc.lines {
532                    if let Some((line_eid, li)) = line_map.get(&lid) {
533                        // Update the line's group assignment.
534                        if let Some(line_comp) = world.line_mut(*line_eid) {
535                            line_comp.group = group_id;
536                        }
537                        group_lines.push(li.clone());
538                    }
539                }
540
541                let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
542                if let Some(mode) = gc.hall_call_mode {
543                    group.set_hall_call_mode(mode);
544                }
545                if let Some(ticks) = gc.ack_latency_ticks {
546                    group.set_ack_latency_ticks(ticks);
547                }
548                groups.push(group);
549
550                // GroupConfig strategy; builder overrides applied after this loop.
551                let dispatch: Box<dyn DispatchStrategy> = gc
552                    .dispatch
553                    .instantiate()
554                    .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
555                dispatchers.insert(group_id, dispatch);
556                strategy_ids.insert(group_id, gc.dispatch.clone());
557            }
558        } else {
559            // No explicit groups — create a single default group with all lines.
560            let group_id = GroupId(0);
561            let mut group_lines = Vec::new();
562
563            for (line_eid, li) in line_map.values() {
564                if let Some(line_comp) = world.line_mut(*line_eid) {
565                    line_comp.group = group_id;
566                }
567                group_lines.push(li.clone());
568            }
569
570            let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
571            groups.push(group);
572
573            let dispatch: Box<dyn DispatchStrategy> =
574                Box::new(crate::dispatch::scan::ScanDispatch::new());
575            dispatchers.insert(group_id, dispatch);
576            strategy_ids.insert(group_id, BuiltinStrategy::Scan);
577        }
578
579        // Override with builder-provided dispatchers (they take precedence).
580        // Pre-fix this could mismatch `strategy_ids` against `dispatchers`
581        // when both config and builder specified a strategy for the same
582        // group (#287). The new precedence: builder wins for the dispatcher
583        // and we keep the config's strategy_id only when no builder
584        // override touched the group.
585        for (gid, d) in builder_dispatchers {
586            dispatchers.insert(gid, d);
587            // Builder dispatchers don't carry a `BuiltinStrategy` discriminant.
588            // If there's no config strategy_id for this group, leave it absent
589            // (snapshot will fail to instantiate; caller must register a
590            // factory). If there IS one, keep it: in practice, the
591            // `Simulation::new(cfg, X)` direct path always passes an X that
592            // matches the config's declared strategy.
593            strategy_ids
594                .entry(gid)
595                .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
596        }
597
598        (groups, dispatchers, strategy_ids)
599    }
600
601    /// Restore a simulation from pre-built parts (used by snapshot restore).
602    #[allow(clippy::too_many_arguments)]
603    pub(crate) fn from_parts(
604        world: World,
605        tick: u64,
606        dt: f64,
607        groups: Vec<ElevatorGroup>,
608        stop_lookup: HashMap<StopId, EntityId>,
609        dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
610        strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
611        metrics: Metrics,
612        ticks_per_second: f64,
613    ) -> Self {
614        let mut rider_index = RiderIndex::default();
615        rider_index.rebuild(&world);
616        // Ensure the dispatch-visible tick rate matches the simulation
617        // tick rate after a snapshot restore; a snapshot that predates
618        // the `TickRate` resource leaves it absent and dispatch would
619        // otherwise fall back to the 60 Hz default even for a 30 Hz
620        // sim, silently halving ETD's door-cost scale.
621        let mut world = world;
622        world.insert_resource(crate::time::TickRate(ticks_per_second));
623        // Re-insert the traffic detector for the same forward-compat
624        // reason as `TickRate`: a snapshot taken before this resource
625        // existed wouldn't carry it, and `refresh_traffic_detector` in
626        // the metrics phase would silently no-op forever post-restore
627        // (greptile review of #361). `insert_resource` is
628        // last-writer-wins, so snapshots that already carry a
629        // detector keep their stored state.
630        if world
631            .resource::<crate::traffic_detector::TrafficDetector>()
632            .is_none()
633        {
634            world.insert_resource(crate::traffic_detector::TrafficDetector::default());
635        }
636        // Same forward-compat pattern for the destination log. An
637        // older snapshot would leave the detector unable to detect
638        // down-peak post-restore; a fresh empty log lets it resume
639        // classification after a few ticks of observed traffic.
640        if world
641            .resource::<crate::arrival_log::DestinationLog>()
642            .is_none()
643        {
644            world.insert_resource(crate::arrival_log::DestinationLog::default());
645        }
646        Self {
647            world,
648            events: EventBus::default(),
649            pending_output: Vec::new(),
650            tick,
651            dt,
652            groups,
653            stop_lookup,
654            dispatchers,
655            strategy_ids,
656            repositioners: BTreeMap::new(),
657            reposition_ids: BTreeMap::new(),
658            metrics,
659            time: TimeAdapter::new(ticks_per_second),
660            hooks: PhaseHooks::default(),
661            elevator_ids_buf: Vec::new(),
662            reposition_buf: Vec::new(),
663            topo_graph: Mutex::new(TopologyGraph::new()),
664            rider_index,
665            tick_in_progress: false,
666        }
667    }
668
669    /// Validate configuration before constructing the simulation.
670    pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
671        if config.building.stops.is_empty() {
672            return Err(SimError::InvalidConfig {
673                field: "building.stops",
674                reason: "at least one stop is required".into(),
675            });
676        }
677
678        // Check for duplicate stop IDs and validate positions.
679        let mut seen_ids = HashSet::new();
680        for stop in &config.building.stops {
681            if !seen_ids.insert(stop.id) {
682                return Err(SimError::InvalidConfig {
683                    field: "building.stops",
684                    reason: format!("duplicate {}", stop.id),
685                });
686            }
687            if !stop.position.is_finite() {
688                return Err(SimError::InvalidConfig {
689                    field: "building.stops.position",
690                    reason: format!("{} has non-finite position {}", stop.id, stop.position),
691                });
692            }
693        }
694
695        let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
696
697        if let Some(line_configs) = &config.building.lines {
698            // ── Explicit topology validation ──
699            Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
700        } else {
701            // ── Legacy flat elevator list validation ──
702            Self::validate_legacy_elevators(&config.elevators, &config.building)?;
703        }
704
705        if !config.simulation.ticks_per_second.is_finite()
706            || config.simulation.ticks_per_second <= 0.0
707        {
708            return Err(SimError::InvalidConfig {
709                field: "simulation.ticks_per_second",
710                reason: format!(
711                    "must be finite and positive, got {}",
712                    config.simulation.ticks_per_second
713                ),
714            });
715        }
716
717        Self::validate_passenger_spawning(&config.passenger_spawning)?;
718
719        Ok(())
720    }
721
722    /// Validate `PassengerSpawnConfig`. Without this, bad inputs reach
723    /// `PoissonSource::from_config` and panic later (NaN/negative weights
724    /// crash `random_range`/`Weight::from`; zero `mean_interval_ticks`
725    /// burst-fires every catch-up tick). (#272)
726    fn validate_passenger_spawning(
727        spawn: &crate::config::PassengerSpawnConfig,
728    ) -> Result<(), SimError> {
729        let (lo, hi) = spawn.weight_range;
730        if !lo.is_finite() || !hi.is_finite() {
731            return Err(SimError::InvalidConfig {
732                field: "passenger_spawning.weight_range",
733                reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
734            });
735        }
736        if lo < 0.0 || hi < 0.0 {
737            return Err(SimError::InvalidConfig {
738                field: "passenger_spawning.weight_range",
739                reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
740            });
741        }
742        if lo > hi {
743            return Err(SimError::InvalidConfig {
744                field: "passenger_spawning.weight_range",
745                reason: format!("min must be <= max, got ({lo}, {hi})"),
746            });
747        }
748        if spawn.mean_interval_ticks == 0 {
749            return Err(SimError::InvalidConfig {
750                field: "passenger_spawning.mean_interval_ticks",
751                reason: "must be > 0; mean_interval_ticks=0 burst-fires \
752                         every catch-up tick"
753                    .into(),
754            });
755        }
756        Ok(())
757    }
758
759    /// Validate the legacy flat elevator list.
760    fn validate_legacy_elevators(
761        elevators: &[crate::config::ElevatorConfig],
762        building: &crate::config::BuildingConfig,
763    ) -> Result<(), SimError> {
764        if elevators.is_empty() {
765            return Err(SimError::InvalidConfig {
766                field: "elevators",
767                reason: "at least one elevator is required".into(),
768            });
769        }
770
771        for elev in elevators {
772            Self::validate_elevator_config(elev, building)?;
773        }
774
775        Ok(())
776    }
777
778    /// Validate a single elevator config's physics and starting stop.
779    fn validate_elevator_config(
780        elev: &crate::config::ElevatorConfig,
781        building: &crate::config::BuildingConfig,
782    ) -> Result<(), SimError> {
783        validate_elevator_physics(
784            elev.max_speed.value(),
785            elev.acceleration.value(),
786            elev.deceleration.value(),
787            elev.weight_capacity.value(),
788            elev.inspection_speed_factor,
789            elev.door_transition_ticks,
790            elev.door_open_ticks,
791            elev.bypass_load_up_pct,
792            elev.bypass_load_down_pct,
793        )?;
794        if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
795            return Err(SimError::InvalidConfig {
796                field: "elevators.starting_stop",
797                reason: format!("references non-existent {}", elev.starting_stop),
798            });
799        }
800        Ok(())
801    }
802
803    /// Validate explicit line/group topology.
804    fn validate_explicit_topology(
805        line_configs: &[crate::config::LineConfig],
806        stop_ids: &HashSet<StopId>,
807        building: &crate::config::BuildingConfig,
808    ) -> Result<(), SimError> {
809        // No duplicate line IDs.
810        let mut seen_line_ids = HashSet::new();
811        for lc in line_configs {
812            if !seen_line_ids.insert(lc.id) {
813                return Err(SimError::InvalidConfig {
814                    field: "building.lines",
815                    reason: format!("duplicate line id {}", lc.id),
816                });
817            }
818        }
819
820        // Every line's serves must reference existing stops and be non-empty.
821        for lc in line_configs {
822            if lc.serves.is_empty() {
823                return Err(SimError::InvalidConfig {
824                    field: "building.lines.serves",
825                    reason: format!("line {} has no stops", lc.id),
826                });
827            }
828            for sid in &lc.serves {
829                if !stop_ids.contains(sid) {
830                    return Err(SimError::InvalidConfig {
831                        field: "building.lines.serves",
832                        reason: format!("line {} references non-existent {}", lc.id, sid),
833                    });
834                }
835            }
836            // Validate elevators within each line.
837            for ec in &lc.elevators {
838                Self::validate_elevator_config(ec, building)?;
839            }
840
841            // Validate max_cars is not exceeded.
842            if let Some(max) = lc.max_cars
843                && lc.elevators.len() > max
844            {
845                return Err(SimError::InvalidConfig {
846                    field: "building.lines.max_cars",
847                    reason: format!(
848                        "line {} has {} elevators but max_cars is {max}",
849                        lc.id,
850                        lc.elevators.len()
851                    ),
852                });
853            }
854        }
855
856        // At least one line with at least one elevator.
857        let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
858        if !has_elevator {
859            return Err(SimError::InvalidConfig {
860                field: "building.lines",
861                reason: "at least one line must have at least one elevator".into(),
862            });
863        }
864
865        // No orphaned stops: every stop must be served by at least one line.
866        let served: HashSet<StopId> = line_configs
867            .iter()
868            .flat_map(|lc| lc.serves.iter().copied())
869            .collect();
870        for sid in stop_ids {
871            if !served.contains(sid) {
872                return Err(SimError::InvalidConfig {
873                    field: "building.lines",
874                    reason: format!("orphaned stop {sid} not served by any line"),
875                });
876            }
877        }
878
879        // Validate groups if present.
880        if let Some(group_configs) = &building.groups {
881            let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
882
883            let mut seen_group_ids = HashSet::new();
884            for gc in group_configs {
885                if !seen_group_ids.insert(gc.id) {
886                    return Err(SimError::InvalidConfig {
887                        field: "building.groups",
888                        reason: format!("duplicate group id {}", gc.id),
889                    });
890                }
891                for &lid in &gc.lines {
892                    if !line_id_set.contains(&lid) {
893                        return Err(SimError::InvalidConfig {
894                            field: "building.groups.lines",
895                            reason: format!(
896                                "group {} references non-existent line id {}",
897                                gc.id, lid
898                            ),
899                        });
900                    }
901                }
902            }
903
904            // Check for orphaned lines (not referenced by any group).
905            let referenced_line_ids: HashSet<u32> = group_configs
906                .iter()
907                .flat_map(|g| g.lines.iter().copied())
908                .collect();
909            for lc in line_configs {
910                if !referenced_line_ids.contains(&lc.id) {
911                    return Err(SimError::InvalidConfig {
912                        field: "building.lines",
913                        reason: format!("line {} is not assigned to any group", lc.id),
914                    });
915                }
916            }
917        }
918
919        Ok(())
920    }
921
922    // ── Dispatch management ──────────────────────────────────────────
923
924    /// Replace the dispatch strategy for a group.
925    ///
926    /// Also synchronises `HallCallMode`: `Destination` for DCS, `Classic`
927    /// for other built-ins; `Custom` strategies leave the mode untouched.
928    pub fn set_dispatch(
929        &mut self,
930        group: GroupId,
931        strategy: Box<dyn DispatchStrategy>,
932        id: crate::dispatch::BuiltinStrategy,
933    ) {
934        let mode = match &id {
935            BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
936            BuiltinStrategy::Custom(_) => None,
937            BuiltinStrategy::Scan
938            | BuiltinStrategy::Look
939            | BuiltinStrategy::NearestCar
940            | BuiltinStrategy::Etd
941            | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
942        };
943        if let Some(mode) = mode
944            && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
945        {
946            g.set_hall_call_mode(mode);
947        }
948        self.dispatchers.insert(group, strategy);
949        self.strategy_ids.insert(group, id);
950    }
951
952    // ── Reposition management ─────────────────────────────────────────
953
954    /// Set the reposition strategy for a group.
955    ///
956    /// Enables the reposition phase for this group. Idle elevators will
957    /// be repositioned according to the strategy after each dispatch phase.
958    pub fn set_reposition(
959        &mut self,
960        group: GroupId,
961        strategy: Box<dyn RepositionStrategy>,
962        id: BuiltinReposition,
963    ) {
964        self.repositioners.insert(group, strategy);
965        self.reposition_ids.insert(group, id);
966    }
967
968    /// Remove the reposition strategy for a group, disabling repositioning.
969    pub fn remove_reposition(&mut self, group: GroupId) {
970        self.repositioners.remove(&group);
971        self.reposition_ids.remove(&group);
972    }
973
974    /// Get the reposition strategy identifier for a group.
975    #[must_use]
976    pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
977        self.reposition_ids.get(&group)
978    }
979
980    // ── Hooks ────────────────────────────────────────────────────────
981
982    /// Register a hook to run before a simulation phase.
983    ///
984    /// Hooks are called in registration order. The hook receives mutable
985    /// access to the world, allowing entity inspection or modification.
986    pub fn add_before_hook(
987        &mut self,
988        phase: Phase,
989        hook: impl Fn(&mut World) + Send + Sync + 'static,
990    ) {
991        self.hooks.add_before(phase, Box::new(hook));
992    }
993
994    /// Register a hook to run after a simulation phase.
995    ///
996    /// Hooks are called in registration order. The hook receives mutable
997    /// access to the world, allowing entity inspection or modification.
998    pub fn add_after_hook(
999        &mut self,
1000        phase: Phase,
1001        hook: impl Fn(&mut World) + Send + Sync + 'static,
1002    ) {
1003        self.hooks.add_after(phase, Box::new(hook));
1004    }
1005
1006    /// Register a hook to run before a phase for a specific group.
1007    pub fn add_before_group_hook(
1008        &mut self,
1009        phase: Phase,
1010        group: GroupId,
1011        hook: impl Fn(&mut World) + Send + Sync + 'static,
1012    ) {
1013        self.hooks.add_before_group(phase, group, Box::new(hook));
1014    }
1015
1016    /// Register a hook to run after a phase for a specific group.
1017    pub fn add_after_group_hook(
1018        &mut self,
1019        phase: Phase,
1020        group: GroupId,
1021        hook: impl Fn(&mut World) + Send + Sync + 'static,
1022    ) {
1023        self.hooks.add_after_group(phase, group, Box::new(hook));
1024    }
1025}