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