1use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Mutex;
16
17use crate::components::{
18 Elevator, ElevatorPhase, Line, LineKind, Orientation, Position, Stop, Velocity,
19};
20use crate::config::SimConfig;
21use crate::dispatch::{
22 BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
23 RepositionStrategy,
24};
25use crate::door::DoorState;
26use crate::entity::EntityId;
27use crate::error::SimError;
28use crate::events::EventBus;
29use crate::hooks::{Phase, PhaseHooks};
30use crate::ids::GroupId;
31use crate::metrics::Metrics;
32use crate::rider_index::RiderIndex;
33use crate::stop::StopId;
34use crate::time::TimeAdapter;
35use crate::topology::TopologyGraph;
36use crate::world::World;
37
38use super::Simulation;
39
40type TopologyResult = (
42 Vec<ElevatorGroup>,
43 BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
44 BTreeMap<GroupId, BuiltinStrategy>,
45);
46
47pub(super) const fn canonical_hall_call_mode(
59 strategy: &BuiltinStrategy,
60) -> Option<crate::dispatch::HallCallMode> {
61 match strategy {
62 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
63 BuiltinStrategy::Custom(_) => None,
64 BuiltinStrategy::Scan
65 | BuiltinStrategy::Look
66 | BuiltinStrategy::NearestCar
67 | BuiltinStrategy::Etd
68 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
69 #[cfg(feature = "loop_lines")]
74 BuiltinStrategy::LoopSweep => Some(crate::dispatch::HallCallMode::Classic),
75 }
76}
77
78fn sync_hall_call_modes(
87 groups: &mut [ElevatorGroup],
88 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
89) {
90 for group in groups.iter_mut() {
91 if let Some(strategy) = strategy_ids.get(&group.id())
92 && canonical_hall_call_mode(strategy)
93 == Some(crate::dispatch::HallCallMode::Destination)
94 {
95 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
96 }
97 }
98}
99
100#[allow(clippy::too_many_arguments)]
106pub(super) fn validate_elevator_physics(
107 max_speed: f64,
108 acceleration: f64,
109 deceleration: f64,
110 weight_capacity: f64,
111 inspection_speed_factor: f64,
112 door_transition_ticks: u32,
113 door_open_ticks: u32,
114 bypass_load_up_pct: Option<f64>,
115 bypass_load_down_pct: Option<f64>,
116) -> Result<(), SimError> {
117 if !max_speed.is_finite() || max_speed <= 0.0 {
118 return Err(SimError::InvalidConfig {
119 field: "elevators.max_speed",
120 reason: format!("must be finite and positive, got {max_speed}"),
121 });
122 }
123 if !acceleration.is_finite() || acceleration <= 0.0 {
124 return Err(SimError::InvalidConfig {
125 field: "elevators.acceleration",
126 reason: format!("must be finite and positive, got {acceleration}"),
127 });
128 }
129 if !deceleration.is_finite() || deceleration <= 0.0 {
130 return Err(SimError::InvalidConfig {
131 field: "elevators.deceleration",
132 reason: format!("must be finite and positive, got {deceleration}"),
133 });
134 }
135 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
136 return Err(SimError::InvalidConfig {
137 field: "elevators.weight_capacity",
138 reason: format!("must be finite and positive, got {weight_capacity}"),
139 });
140 }
141 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
142 return Err(SimError::InvalidConfig {
143 field: "elevators.inspection_speed_factor",
144 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
145 });
146 }
147 if door_transition_ticks == 0 {
148 return Err(SimError::InvalidConfig {
149 field: "elevators.door_transition_ticks",
150 reason: "must be > 0".into(),
151 });
152 }
153 if door_open_ticks == 0 {
154 return Err(SimError::InvalidConfig {
155 field: "elevators.door_open_ticks",
156 reason: "must be > 0".into(),
157 });
158 }
159 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
160 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
161 Ok(())
162}
163
164fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
169 let Some(pct) = pct else {
170 return Ok(());
171 };
172 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
173 return Err(SimError::InvalidConfig {
174 field,
175 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
176 });
177 }
178 Ok(())
179}
180
181impl Simulation {
182 pub fn new(
193 config: &SimConfig,
194 dispatch: impl DispatchStrategy + 'static,
195 ) -> Result<Self, SimError> {
196 let mut dispatchers = BTreeMap::new();
197 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
198 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
199 }
200
201 #[allow(clippy::too_many_lines)]
205 pub(crate) fn new_with_hooks(
206 config: &SimConfig,
207 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
208 hooks: PhaseHooks,
209 ) -> Result<Self, SimError> {
210 Self::validate_config(config)?;
211
212 let mut world = World::new();
213
214 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
216 for sc in &config.building.stops {
217 let eid = world.spawn();
218 world.set_stop(
219 eid,
220 Stop {
221 name: sc.name.clone(),
222 position: sc.position,
223 },
224 );
225 world.set_position(eid, Position { value: sc.position });
226 stop_lookup.insert(sc.id, eid);
227 }
228
229 let mut sorted: Vec<(f64, EntityId)> = world
231 .iter_stops()
232 .map(|(eid, stop)| (stop.position, eid))
233 .collect();
234 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
235 world.insert_resource(crate::world::SortedStops(sorted));
236
237 world.insert_resource(crate::arrival_log::ArrivalLog::default());
247 world.insert_resource(crate::arrival_log::DestinationLog::default());
248 world.insert_resource(crate::arrival_log::CurrentTick::default());
249 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
250 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
254 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
260 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
266
267 let (mut groups, dispatchers, strategy_ids) =
268 if let Some(line_configs) = &config.building.lines {
269 Self::build_explicit_topology(
270 &mut world,
271 config,
272 line_configs,
273 &stop_lookup,
274 builder_dispatchers,
275 )
276 } else {
277 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
278 };
279 sync_hall_call_modes(&mut groups, &strategy_ids);
280
281 let dt = 1.0 / config.simulation.ticks_per_second;
282
283 world.insert_resource(crate::tagged_metrics::MetricTags::default());
284
285 world.register_ext::<crate::dispatch::destination::AssignedCar>(
294 crate::dispatch::destination::ASSIGNED_CAR_KEY,
295 );
296
297 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
300 .iter()
301 .flat_map(|group| {
302 group.lines().iter().filter_map(|li| {
303 let line_comp = world.line(li.entity())?;
304 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
305 })
306 })
307 .collect();
308
309 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
311 for (line_eid, name, elevators) in &line_tag_info {
312 let tag = format!("line:{name}");
313 tags.tag(*line_eid, tag.clone());
314 for elev_eid in elevators {
315 tags.tag(*elev_eid, tag.clone());
316 }
317 }
318 }
319
320 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
322 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
323 if let Some(group_configs) = &config.building.groups {
324 for gc in group_configs {
325 if let Some(ref repo_id) = gc.reposition
326 && let Some(strategy) = repo_id.instantiate()
327 {
328 let gid = GroupId(gc.id);
329 repositioners.insert(gid, strategy);
330 reposition_ids.insert(gid, repo_id.clone());
331 }
332 }
333 }
334
335 Ok(Self {
336 world,
337 events: EventBus::default(),
338 pending_output: Vec::new(),
339 tick: 0,
340 dt,
341 groups,
342 stop_lookup,
343 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
344 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
345 metrics: Metrics::new(),
346 time: TimeAdapter::new(config.simulation.ticks_per_second),
347 hooks,
348 elevator_ids_buf: Vec::new(),
349 reposition_buf: Vec::new(),
350 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
351 topo_graph: Mutex::new(TopologyGraph::new()),
352 rider_index: RiderIndex::default(),
353 tick_in_progress: false,
354 })
355 }
356
357 fn spawn_elevator_entity(
363 world: &mut World,
364 ec: &crate::config::ElevatorConfig,
365 line: EntityId,
366 stop_lookup: &HashMap<StopId, EntityId>,
367 start_pos_lookup: &[crate::stop::StopConfig],
368 ) -> EntityId {
369 let eid = world.spawn();
370 let start_pos = start_pos_lookup
371 .iter()
372 .find(|s| s.id == ec.starting_stop)
373 .map_or(0.0, |s| s.position);
374 world.set_position(eid, Position { value: start_pos });
375 world.set_velocity(eid, Velocity { value: 0.0 });
376 let restricted: HashSet<EntityId> = ec
377 .restricted_stops
378 .iter()
379 .filter_map(|sid| stop_lookup.get(sid).copied())
380 .collect();
381 world.set_elevator(
382 eid,
383 Elevator {
384 phase: ElevatorPhase::Idle,
385 door: DoorState::Closed,
386 max_speed: ec.max_speed,
387 acceleration: ec.acceleration,
388 deceleration: ec.deceleration,
389 weight_capacity: ec.weight_capacity,
390 current_load: crate::components::Weight::ZERO,
391 riders: Vec::new(),
392 target_stop: None,
393 door_transition_ticks: ec.door_transition_ticks,
394 door_open_ticks: ec.door_open_ticks,
395 line,
396 repositioning: false,
397 restricted_stops: restricted,
398 inspection_speed_factor: ec.inspection_speed_factor,
399 going_up: true,
400 going_down: true,
401 going_forward: false,
402 move_count: 0,
403 door_command_queue: Vec::new(),
404 manual_target_velocity: None,
405 bypass_load_up_pct: ec.bypass_load_up_pct,
406 bypass_load_down_pct: ec.bypass_load_down_pct,
407 home_stop: None,
408 },
409 );
410 #[cfg(feature = "energy")]
411 if let Some(ref profile) = ec.energy_profile {
412 world.set_energy_profile(eid, profile.clone());
413 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
414 }
415 if let Some(mode) = ec.service_mode {
416 world.set_service_mode(eid, mode);
417 }
418 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
419 eid
420 }
421
422 fn build_legacy_topology(
424 world: &mut World,
425 config: &SimConfig,
426 stop_lookup: &HashMap<StopId, EntityId>,
427 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
428 ) -> TopologyResult {
429 let all_stop_entities: Vec<EntityId> = config
435 .building
436 .stops
437 .iter()
438 .filter_map(|s| stop_lookup.get(&s.id).copied())
439 .collect();
440 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
441 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
442 let max_pos = stop_positions
443 .iter()
444 .copied()
445 .fold(f64::NEG_INFINITY, f64::max);
446
447 let default_line_eid = world.spawn();
448 world.set_line(
449 default_line_eid,
450 Line {
451 name: "Default".into(),
452 group: GroupId(0),
453 orientation: Orientation::Vertical,
454 position: None,
455 kind: LineKind::Linear {
456 min: min_pos,
457 max: max_pos,
458 },
459 max_cars: None,
460 },
461 );
462
463 let mut elevator_entities = Vec::new();
464 for ec in &config.elevators {
465 let eid = Self::spawn_elevator_entity(
466 world,
467 ec,
468 default_line_eid,
469 stop_lookup,
470 &config.building.stops,
471 );
472 elevator_entities.push(eid);
473 }
474
475 let default_line_info =
476 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
477
478 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
479
480 let mut dispatchers = BTreeMap::new();
484 let mut strategy_ids = BTreeMap::new();
485 let user_dispatcher = builder_dispatchers
486 .into_iter()
487 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
488 let inferred_id = user_dispatcher
493 .as_ref()
494 .and_then(|d| d.builtin_id())
495 .unwrap_or(BuiltinStrategy::Scan);
496 if let Some(d) = user_dispatcher {
497 dispatchers.insert(GroupId(0), d);
498 } else {
499 dispatchers.insert(
500 GroupId(0),
501 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
502 );
503 }
504 strategy_ids.insert(GroupId(0), inferred_id);
505
506 (vec![group], dispatchers, strategy_ids)
507 }
508
509 #[allow(clippy::too_many_lines)]
511 fn build_explicit_topology(
512 world: &mut World,
513 config: &SimConfig,
514 line_configs: &[crate::config::LineConfig],
515 stop_lookup: &HashMap<StopId, EntityId>,
516 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
517 ) -> TopologyResult {
518 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
524
525 for lc in line_configs {
526 let served_entities: Vec<EntityId> = lc
528 .serves
529 .iter()
530 .filter_map(|sid| stop_lookup.get(sid).copied())
531 .collect();
532
533 let stop_positions: Vec<f64> = lc
535 .serves
536 .iter()
537 .filter_map(|sid| {
538 config
539 .building
540 .stops
541 .iter()
542 .find(|s| s.id == *sid)
543 .map(|s| s.position)
544 })
545 .collect();
546 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
547 let auto_max = stop_positions
548 .iter()
549 .copied()
550 .fold(f64::NEG_INFINITY, f64::max);
551
552 let min_pos = lc.min_position.unwrap_or(auto_min);
553 let max_pos = lc.max_position.unwrap_or(auto_max);
554
555 let line_eid = world.spawn();
556 world.set_line(
560 line_eid,
561 Line {
562 name: lc.name.clone(),
563 group: GroupId(0),
564 orientation: lc.orientation,
565 position: lc.position,
566 kind: lc.kind.unwrap_or(LineKind::Linear {
567 min: min_pos,
568 max: max_pos,
569 }),
570 max_cars: lc.max_cars,
571 },
572 );
573
574 let mut elevator_entities = Vec::new();
576 for ec in &lc.elevators {
577 let eid = Self::spawn_elevator_entity(
578 world,
579 ec,
580 line_eid,
581 stop_lookup,
582 &config.building.stops,
583 );
584 elevator_entities.push(eid);
585 }
586
587 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
588 line_map.insert(lc.id, (line_eid, line_info));
589 }
590
591 let group_configs = config.building.groups.as_deref();
593 let mut groups = Vec::new();
594 let mut dispatchers = BTreeMap::new();
595 let mut strategy_ids = BTreeMap::new();
596
597 if let Some(gcs) = group_configs {
598 for gc in gcs {
599 let group_id = GroupId(gc.id);
600
601 let mut group_lines = Vec::new();
602
603 for &lid in &gc.lines {
604 if let Some((line_eid, li)) = line_map.get(&lid) {
605 if let Some(line_comp) = world.line_mut(*line_eid) {
607 line_comp.group = group_id;
608 }
609 group_lines.push(li.clone());
610 }
611 }
612
613 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
614 if let Some(mode) = gc.hall_call_mode {
615 group.set_hall_call_mode(mode);
616 }
617 if let Some(ticks) = gc.ack_latency_ticks {
618 group.set_ack_latency_ticks(ticks);
619 }
620 groups.push(group);
621
622 let dispatch: Box<dyn DispatchStrategy> = gc
624 .dispatch
625 .instantiate()
626 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
627 dispatchers.insert(group_id, dispatch);
628 strategy_ids.insert(group_id, gc.dispatch.clone());
629 }
630 } else {
631 let group_id = GroupId(0);
633 let mut group_lines = Vec::new();
634
635 for (line_eid, li) in line_map.values() {
636 if let Some(line_comp) = world.line_mut(*line_eid) {
637 line_comp.group = group_id;
638 }
639 group_lines.push(li.clone());
640 }
641
642 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
643 groups.push(group);
644
645 let dispatch: Box<dyn DispatchStrategy> =
646 Box::new(crate::dispatch::scan::ScanDispatch::new());
647 dispatchers.insert(group_id, dispatch);
648 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
649 }
650
651 for (gid, d) in builder_dispatchers {
656 let inferred_id = d.builtin_id();
657 dispatchers.insert(gid, d);
658 match inferred_id {
659 Some(id) => {
660 strategy_ids.insert(gid, id);
661 }
662 None => {
663 strategy_ids
664 .entry(gid)
665 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
666 }
667 }
668 }
669
670 (groups, dispatchers, strategy_ids)
671 }
672
673 #[allow(clippy::too_many_arguments)]
675 pub(crate) fn from_parts(
676 world: World,
677 tick: u64,
678 dt: f64,
679 groups: Vec<ElevatorGroup>,
680 stop_lookup: HashMap<StopId, EntityId>,
681 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
682 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
683 metrics: Metrics,
684 ticks_per_second: f64,
685 ) -> Self {
686 let mut rider_index = RiderIndex::default();
687 rider_index.rebuild(&world);
688 let mut world = world;
694 world.insert_resource(crate::time::TickRate(ticks_per_second));
695 if world
696 .resource::<crate::traffic_detector::TrafficDetector>()
697 .is_none()
698 {
699 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
700 }
701 if world
706 .resource::<crate::arrival_log::DestinationLog>()
707 .is_none()
708 {
709 world.insert_resource(crate::arrival_log::DestinationLog::default());
710 }
711 world.register_ext::<crate::dispatch::destination::AssignedCar>(
726 crate::dispatch::destination::ASSIGNED_CAR_KEY,
727 );
728 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
729 let data = pending.0.clone();
730 world.deserialize_extensions(&data);
731 }
732 Self {
733 world,
734 events: EventBus::default(),
735 pending_output: Vec::new(),
736 tick,
737 dt,
738 groups,
739 stop_lookup,
740 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
741 repositioner_set: super::RepositionerSet::new(),
742 metrics,
743 time: TimeAdapter::new(ticks_per_second),
744 hooks: PhaseHooks::default(),
745 elevator_ids_buf: Vec::new(),
746 reposition_buf: Vec::new(),
747 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
748 topo_graph: Mutex::new(TopologyGraph::new()),
749 rider_index,
750 tick_in_progress: false,
751 }
752 }
753
754 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
756 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
763 return Err(SimError::InvalidConfig {
764 field: "schema_version",
765 reason: format!(
766 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
767 config.schema_version,
768 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
769 ),
770 });
771 }
772 if config.schema_version == 0 {
773 return Err(SimError::InvalidConfig {
774 field: "schema_version",
775 reason: format!(
776 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
777 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
778 ),
779 });
780 }
781
782 if config.building.stops.is_empty() {
783 return Err(SimError::InvalidConfig {
784 field: "building.stops",
785 reason: "at least one stop is required".into(),
786 });
787 }
788
789 let mut seen_ids = HashSet::new();
791 for stop in &config.building.stops {
792 if !seen_ids.insert(stop.id) {
793 return Err(SimError::InvalidConfig {
794 field: "building.stops",
795 reason: format!("duplicate {}", stop.id),
796 });
797 }
798 if !stop.position.is_finite() {
799 return Err(SimError::InvalidConfig {
800 field: "building.stops.position",
801 reason: format!("{} has non-finite position {}", stop.id, stop.position),
802 });
803 }
804 }
805
806 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
807
808 if let Some(line_configs) = &config.building.lines {
809 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
811 } else {
812 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
814 }
815
816 if !config.simulation.ticks_per_second.is_finite()
817 || config.simulation.ticks_per_second <= 0.0
818 {
819 return Err(SimError::InvalidConfig {
820 field: "simulation.ticks_per_second",
821 reason: format!(
822 "must be finite and positive, got {}",
823 config.simulation.ticks_per_second
824 ),
825 });
826 }
827
828 Self::validate_passenger_spawning(&config.passenger_spawning)?;
829
830 Ok(())
831 }
832
833 fn validate_passenger_spawning(
838 spawn: &crate::config::PassengerSpawnConfig,
839 ) -> Result<(), SimError> {
840 let (lo, hi) = spawn.weight_range;
841 if !lo.is_finite() || !hi.is_finite() {
842 return Err(SimError::InvalidConfig {
843 field: "passenger_spawning.weight_range",
844 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
845 });
846 }
847 if lo < 0.0 || hi < 0.0 {
848 return Err(SimError::InvalidConfig {
849 field: "passenger_spawning.weight_range",
850 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
851 });
852 }
853 if lo > hi {
854 return Err(SimError::InvalidConfig {
855 field: "passenger_spawning.weight_range",
856 reason: format!("min must be <= max, got ({lo}, {hi})"),
857 });
858 }
859 if spawn.mean_interval_ticks == 0 {
860 return Err(SimError::InvalidConfig {
861 field: "passenger_spawning.mean_interval_ticks",
862 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
863 every catch-up tick"
864 .into(),
865 });
866 }
867 Ok(())
868 }
869
870 fn validate_legacy_elevators(
872 elevators: &[crate::config::ElevatorConfig],
873 building: &crate::config::BuildingConfig,
874 ) -> Result<(), SimError> {
875 if elevators.is_empty() {
876 return Err(SimError::InvalidConfig {
877 field: "elevators",
878 reason: "at least one elevator is required".into(),
879 });
880 }
881
882 for elev in elevators {
883 Self::validate_elevator_config(elev, building)?;
884 }
885
886 Ok(())
887 }
888
889 fn validate_elevator_config(
891 elev: &crate::config::ElevatorConfig,
892 building: &crate::config::BuildingConfig,
893 ) -> Result<(), SimError> {
894 validate_elevator_physics(
895 elev.max_speed.value(),
896 elev.acceleration.value(),
897 elev.deceleration.value(),
898 elev.weight_capacity.value(),
899 elev.inspection_speed_factor,
900 elev.door_transition_ticks,
901 elev.door_open_ticks,
902 elev.bypass_load_up_pct,
903 elev.bypass_load_down_pct,
904 )?;
905 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
906 return Err(SimError::InvalidConfig {
907 field: "elevators.starting_stop",
908 reason: format!("references non-existent {}", elev.starting_stop),
909 });
910 }
911 Ok(())
912 }
913
914 #[allow(
916 clippy::too_many_lines,
917 reason = "validation reads top-to-bottom; extracting helpers would scatter related rejections across files"
918 )]
919 fn validate_explicit_topology(
920 line_configs: &[crate::config::LineConfig],
921 stop_ids: &HashSet<StopId>,
922 building: &crate::config::BuildingConfig,
923 ) -> Result<(), SimError> {
924 let mut seen_line_ids = HashSet::new();
926 for lc in line_configs {
927 if !seen_line_ids.insert(lc.id) {
928 return Err(SimError::InvalidConfig {
929 field: "building.lines",
930 reason: format!("duplicate line id {}", lc.id),
931 });
932 }
933 }
934
935 for lc in line_configs {
937 if lc.serves.is_empty() {
938 return Err(SimError::InvalidConfig {
939 field: "building.lines.serves",
940 reason: format!("line {} has no stops", lc.id),
941 });
942 }
943 for sid in &lc.serves {
944 if !stop_ids.contains(sid) {
945 return Err(SimError::InvalidConfig {
946 field: "building.lines.serves",
947 reason: format!("line {} references non-existent {}", lc.id, sid),
948 });
949 }
950 }
951 for ec in &lc.elevators {
953 Self::validate_elevator_config(ec, building)?;
954 }
955
956 if let Some(max) = lc.max_cars
958 && lc.elevators.len() > max
959 {
960 return Err(SimError::InvalidConfig {
961 field: "building.lines.max_cars",
962 reason: format!(
963 "line {} has {} elevators but max_cars is {max}",
964 lc.id,
965 lc.elevators.len()
966 ),
967 });
968 }
969
970 if let Some(kind) = lc.kind
974 && let Err((field, reason)) = kind.validate()
975 {
976 return Err(SimError::InvalidConfig { field, reason });
977 }
978
979 #[cfg(feature = "loop_lines")]
986 if let Some(crate::components::LineKind::Loop {
987 circumference,
988 min_headway,
989 }) = lc.kind
990 {
991 let car_count = lc
992 .max_cars
993 .map_or_else(|| lc.elevators.len(), |max| max.max(lc.elevators.len()));
994 if car_count > 0 {
995 #[allow(
996 clippy::cast_precision_loss,
997 reason = "car_count is bounded by usize and the comparison is against a finite f64"
998 )]
999 let required = (car_count as f64) * min_headway;
1000 if required > circumference {
1001 return Err(SimError::InvalidConfig {
1002 field: "building.lines.kind",
1003 reason: format!(
1004 "loop line {}: {car_count} cars × min_headway {min_headway} = {required} \
1005 exceeds circumference {circumference}",
1006 lc.id,
1007 ),
1008 });
1009 }
1010 }
1011 }
1012 }
1013
1014 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
1016 if !has_elevator {
1017 return Err(SimError::InvalidConfig {
1018 field: "building.lines",
1019 reason: "at least one line must have at least one elevator".into(),
1020 });
1021 }
1022
1023 let served: HashSet<StopId> = line_configs
1025 .iter()
1026 .flat_map(|lc| lc.serves.iter().copied())
1027 .collect();
1028 for sid in stop_ids {
1029 if !served.contains(sid) {
1030 return Err(SimError::InvalidConfig {
1031 field: "building.lines",
1032 reason: format!("orphaned stop {sid} not served by any line"),
1033 });
1034 }
1035 }
1036
1037 if let Some(group_configs) = &building.groups {
1039 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
1040
1041 let mut seen_group_ids = HashSet::new();
1042 for gc in group_configs {
1043 if !seen_group_ids.insert(gc.id) {
1044 return Err(SimError::InvalidConfig {
1045 field: "building.groups",
1046 reason: format!("duplicate group id {}", gc.id),
1047 });
1048 }
1049 for &lid in &gc.lines {
1050 if !line_id_set.contains(&lid) {
1051 return Err(SimError::InvalidConfig {
1052 field: "building.groups.lines",
1053 reason: format!(
1054 "group {} references non-existent line id {}",
1055 gc.id, lid
1056 ),
1057 });
1058 }
1059 }
1060 }
1061
1062 let referenced_line_ids: HashSet<u32> = group_configs
1064 .iter()
1065 .flat_map(|g| g.lines.iter().copied())
1066 .collect();
1067 for lc in line_configs {
1068 if !referenced_line_ids.contains(&lc.id) {
1069 return Err(SimError::InvalidConfig {
1070 field: "building.lines",
1071 reason: format!("line {} is not assigned to any group", lc.id),
1072 });
1073 }
1074 }
1075
1076 #[cfg(feature = "loop_lines")]
1082 for gc in group_configs {
1083 let lines: Vec<&crate::config::LineConfig> = gc
1084 .lines
1085 .iter()
1086 .filter_map(|lid| line_configs.iter().find(|lc| lc.id == *lid))
1087 .collect();
1088 let any_loop = lines
1089 .iter()
1090 .any(|lc| matches!(lc.kind, Some(crate::components::LineKind::Loop { .. })));
1091 let any_linear = lines
1099 .iter()
1100 .any(|lc| lc.kind.as_ref().is_none_or(LineKind::is_linear));
1101 if any_loop && any_linear {
1106 return Err(SimError::InvalidConfig {
1107 field: "building.groups",
1108 reason: format!(
1109 "group {} mixes Loop and Linear lines; groups must be homogeneous",
1110 gc.id,
1111 ),
1112 });
1113 }
1114 if any_loop && gc.reposition.is_some() {
1120 return Err(SimError::InvalidConfig {
1121 field: "building.groups.reposition",
1122 reason: format!(
1123 "group {} contains Loop lines; reposition strategies are unsupported on Loop",
1124 gc.id,
1125 ),
1126 });
1127 }
1128 if any_loop && !matches!(gc.dispatch, BuiltinStrategy::LoopSweep) {
1137 return Err(SimError::InvalidConfig {
1138 field: "building.groups.dispatch",
1139 reason: format!(
1140 "group {} contains Loop lines but uses {} dispatch; \
1141 only LoopSweep is supported for Loop groups in v1",
1142 gc.id, gc.dispatch,
1143 ),
1144 });
1145 }
1146 }
1147 }
1148
1149 #[cfg(feature = "loop_lines")]
1152 for lc in line_configs {
1153 let Some(crate::components::LineKind::Loop {
1154 circumference,
1155 min_headway,
1156 }) = lc.kind
1157 else {
1158 continue;
1159 };
1160
1161 let stop_positions: Vec<f64> = lc
1166 .serves
1167 .iter()
1168 .filter_map(|sid| {
1169 building
1170 .stops
1171 .iter()
1172 .find(|s| s.id == *sid)
1173 .map(|s| s.position)
1174 })
1175 .collect();
1176 for (i, &pi) in stop_positions.iter().enumerate() {
1177 for (j, &pj) in stop_positions.iter().enumerate().skip(i + 1) {
1178 if (pi - pj).abs() < 1e-9 {
1179 return Err(SimError::InvalidConfig {
1180 field: "building.lines.serves",
1181 reason: format!(
1182 "loop line {} has duplicate stop positions at indices {i} and {j} (both at {pi})",
1183 lc.id,
1184 ),
1185 });
1186 }
1187 }
1188 }
1189
1190 let car_starts: Vec<f64> = lc
1196 .elevators
1197 .iter()
1198 .filter_map(|ec| {
1199 building
1200 .stops
1201 .iter()
1202 .find(|s| s.id == ec.starting_stop)
1203 .map(|s| s.position)
1204 })
1205 .collect();
1206 for (i, &a) in car_starts.iter().enumerate() {
1207 for (j, &b) in car_starts.iter().enumerate().skip(i + 1) {
1208 let d = crate::components::cyclic::cyclic_distance(a, b, circumference);
1209 if d < min_headway - 1e-9 {
1210 return Err(SimError::InvalidConfig {
1211 field: "building.lines.elevators.starting_stop",
1212 reason: format!(
1213 "loop line {}: cars at indices {i} ({a}) and {j} ({b}) are {d} apart \
1214 — below min_headway {min_headway}",
1215 lc.id,
1216 ),
1217 });
1218 }
1219 }
1220 }
1221 }
1222
1223 Ok(())
1224 }
1225
1226 pub fn set_dispatch(
1242 &mut self,
1243 group: GroupId,
1244 strategy: Box<dyn DispatchStrategy>,
1245 id: crate::dispatch::BuiltinStrategy,
1246 ) {
1247 let resolved_id = strategy.builtin_id().unwrap_or(id);
1248 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1249 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1250 {
1251 g.set_hall_call_mode(mode);
1252 }
1253 self.dispatcher_set.insert(group, strategy, resolved_id);
1254 }
1255
1256 pub fn set_reposition(
1287 &mut self,
1288 group: GroupId,
1289 strategy: Box<dyn RepositionStrategy>,
1290 id: BuiltinReposition,
1291 ) {
1292 let resolved_id = strategy.builtin_id().unwrap_or(id);
1293 let needed_window = strategy.min_arrival_log_window();
1294 self.repositioner_set.insert(group, strategy, resolved_id);
1295 if needed_window > 0
1301 && let Some(retention) = self
1302 .world
1303 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1304 && needed_window > retention.0
1305 {
1306 retention.0 = needed_window;
1307 }
1308 }
1309
1310 pub fn remove_reposition(&mut self, group: GroupId) {
1321 self.repositioner_set.remove(group);
1322 }
1323
1324 #[must_use]
1326 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1327 self.repositioner_set.id_for(group)
1328 }
1329
1330 pub fn add_before_hook(
1337 &mut self,
1338 phase: Phase,
1339 hook: impl Fn(&mut World) + Send + Sync + 'static,
1340 ) {
1341 self.hooks.add_before(phase, Box::new(hook));
1342 }
1343
1344 pub fn add_after_hook(
1349 &mut self,
1350 phase: Phase,
1351 hook: impl Fn(&mut World) + Send + Sync + 'static,
1352 ) {
1353 self.hooks.add_after(phase, Box::new(hook));
1354 }
1355
1356 pub fn add_before_group_hook(
1358 &mut self,
1359 phase: Phase,
1360 group: GroupId,
1361 hook: impl Fn(&mut World) + Send + Sync + 'static,
1362 ) {
1363 self.hooks.add_before_group(phase, group, Box::new(hook));
1364 }
1365
1366 pub fn add_after_group_hook(
1368 &mut self,
1369 phase: Phase,
1370 group: GroupId,
1371 hook: impl Fn(&mut World) + Send + Sync + 'static,
1372 ) {
1373 self.hooks.add_after_group(phase, group, Box::new(hook));
1374 }
1375}