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 | BuiltinStrategy::LoopSchedule => {
75 Some(crate::dispatch::HallCallMode::Classic)
76 }
77 }
78}
79
80fn sync_hall_call_modes(
89 groups: &mut [ElevatorGroup],
90 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
91) {
92 for group in groups.iter_mut() {
93 if let Some(strategy) = strategy_ids.get(&group.id())
94 && canonical_hall_call_mode(strategy)
95 == Some(crate::dispatch::HallCallMode::Destination)
96 {
97 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
98 }
99 }
100}
101
102#[allow(clippy::too_many_arguments)]
108pub(super) fn validate_elevator_physics(
109 max_speed: f64,
110 acceleration: f64,
111 deceleration: f64,
112 weight_capacity: f64,
113 inspection_speed_factor: f64,
114 door_transition_ticks: u32,
115 door_open_ticks: u32,
116 bypass_load_up_pct: Option<f64>,
117 bypass_load_down_pct: Option<f64>,
118) -> Result<(), SimError> {
119 if !max_speed.is_finite() || max_speed <= 0.0 {
120 return Err(SimError::InvalidConfig {
121 field: "elevators.max_speed",
122 reason: format!("must be finite and positive, got {max_speed}"),
123 });
124 }
125 if !acceleration.is_finite() || acceleration <= 0.0 {
126 return Err(SimError::InvalidConfig {
127 field: "elevators.acceleration",
128 reason: format!("must be finite and positive, got {acceleration}"),
129 });
130 }
131 if !deceleration.is_finite() || deceleration <= 0.0 {
132 return Err(SimError::InvalidConfig {
133 field: "elevators.deceleration",
134 reason: format!("must be finite and positive, got {deceleration}"),
135 });
136 }
137 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
138 return Err(SimError::InvalidConfig {
139 field: "elevators.weight_capacity",
140 reason: format!("must be finite and positive, got {weight_capacity}"),
141 });
142 }
143 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
144 return Err(SimError::InvalidConfig {
145 field: "elevators.inspection_speed_factor",
146 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
147 });
148 }
149 if door_transition_ticks == 0 {
150 return Err(SimError::InvalidConfig {
151 field: "elevators.door_transition_ticks",
152 reason: "must be > 0".into(),
153 });
154 }
155 if door_open_ticks == 0 {
156 return Err(SimError::InvalidConfig {
157 field: "elevators.door_open_ticks",
158 reason: "must be > 0".into(),
159 });
160 }
161 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
162 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
163 Ok(())
164}
165
166fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
171 let Some(pct) = pct else {
172 return Ok(());
173 };
174 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
175 return Err(SimError::InvalidConfig {
176 field,
177 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
178 });
179 }
180 Ok(())
181}
182
183impl Simulation {
184 pub fn new(
195 config: &SimConfig,
196 dispatch: impl DispatchStrategy + 'static,
197 ) -> Result<Self, SimError> {
198 let mut dispatchers = BTreeMap::new();
199 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
200 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
201 }
202
203 #[allow(clippy::too_many_lines)]
207 pub(crate) fn new_with_hooks(
208 config: &SimConfig,
209 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
210 hooks: PhaseHooks,
211 ) -> Result<Self, SimError> {
212 Self::validate_config(config)?;
213
214 let mut world = World::new();
215
216 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
218 for sc in &config.building.stops {
219 let eid = world.spawn();
220 world.set_stop(
221 eid,
222 Stop {
223 name: sc.name.clone(),
224 position: sc.position,
225 },
226 );
227 world.set_position(eid, Position { value: sc.position });
228 stop_lookup.insert(sc.id, eid);
229 }
230
231 let mut sorted: Vec<(f64, EntityId)> = world
233 .iter_stops()
234 .map(|(eid, stop)| (stop.position, eid))
235 .collect();
236 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
237 world.insert_resource(crate::world::SortedStops(sorted));
238
239 world.insert_resource(crate::arrival_log::ArrivalLog::default());
249 world.insert_resource(crate::arrival_log::DestinationLog::default());
250 world.insert_resource(crate::arrival_log::CurrentTick::default());
251 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
252 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
256 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
262 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
268
269 let mut elevator_lookup: HashMap<crate::config::ElevatorConfigId, EntityId> =
270 HashMap::new();
271 let mut line_lookup: HashMap<crate::config::LineConfigId, EntityId> = HashMap::new();
272 let (mut groups, dispatchers, strategy_ids) =
273 if let Some(line_configs) = &config.building.lines {
274 Self::build_explicit_topology(
275 &mut world,
276 config,
277 line_configs,
278 &stop_lookup,
279 &mut elevator_lookup,
280 &mut line_lookup,
281 builder_dispatchers,
282 )
283 } else {
284 Self::build_legacy_topology(
285 &mut world,
286 config,
287 &stop_lookup,
288 &mut elevator_lookup,
289 builder_dispatchers,
290 )
291 };
292 sync_hall_call_modes(&mut groups, &strategy_ids);
293
294 let dt = 1.0 / config.simulation.ticks_per_second;
295
296 world.insert_resource(crate::tagged_metrics::MetricTags::default());
297
298 world.register_ext::<crate::dispatch::destination::AssignedCar>(
307 crate::dispatch::destination::ASSIGNED_CAR_KEY,
308 );
309
310 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
313 .iter()
314 .flat_map(|group| {
315 group.lines().iter().filter_map(|li| {
316 let line_comp = world.line(li.entity())?;
317 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
318 })
319 })
320 .collect();
321
322 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
324 for (line_eid, name, elevators) in &line_tag_info {
325 let tag = format!("line:{name}");
326 tags.tag(*line_eid, tag.clone());
327 for elev_eid in elevators {
328 tags.tag(*elev_eid, tag.clone());
329 }
330 }
331 }
332
333 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
335 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
336 if let Some(group_configs) = &config.building.groups {
337 for gc in group_configs {
338 if let Some(ref repo_id) = gc.reposition
339 && let Some(strategy) = repo_id.instantiate()
340 {
341 let gid = GroupId(gc.id);
342 repositioners.insert(gid, strategy);
343 reposition_ids.insert(gid, repo_id.clone());
344 }
345 }
346 }
347
348 Ok(Self {
349 world,
350 events: EventBus::default(),
351 pending_output: Vec::new(),
352 tick: 0,
353 dt,
354 groups,
355 stop_lookup,
356 elevator_lookup,
357 line_lookup,
358 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
359 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
360 metrics: Metrics::new(),
361 time: TimeAdapter::new(config.simulation.ticks_per_second),
362 hooks,
363 elevator_ids_buf: Vec::new(),
364 reposition_buf: Vec::new(),
365 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
366 topo_graph: Mutex::new(TopologyGraph::new()),
367 rider_index: RiderIndex::default(),
368 tick_in_progress: false,
369 phase_check: super::PhaseCheck::Disabled,
370 })
371 }
372
373 fn spawn_elevator_entity(
379 world: &mut World,
380 ec: &crate::config::ElevatorConfig,
381 line: EntityId,
382 stop_lookup: &HashMap<StopId, EntityId>,
383 start_pos_lookup: &[crate::stop::StopConfig],
384 ) -> EntityId {
385 let eid = world.spawn();
386 let start_pos = start_pos_lookup
387 .iter()
388 .find(|s| s.id == ec.starting_stop)
389 .map_or(0.0, |s| s.position);
390 world.set_position(eid, Position { value: start_pos });
391 world.set_velocity(eid, Velocity { value: 0.0 });
392 let restricted: HashSet<EntityId> = ec
393 .restricted_stops
394 .iter()
395 .filter_map(|sid| stop_lookup.get(sid).copied())
396 .collect();
397 let is_loop = world.line(line).is_some_and(Line::is_loop);
403 world.set_elevator(
404 eid,
405 Elevator {
406 phase: ElevatorPhase::Idle,
407 door: DoorState::Closed,
408 max_speed: ec.max_speed,
409 acceleration: ec.acceleration,
410 deceleration: ec.deceleration,
411 weight_capacity: ec.weight_capacity,
412 current_load: crate::components::Weight::ZERO,
413 riders: Vec::new(),
414 target_stop: None,
415 door_transition_ticks: ec.door_transition_ticks,
416 door_open_ticks: ec.door_open_ticks,
417 line,
418 repositioning: false,
419 restricted_stops: restricted,
420 inspection_speed_factor: ec.inspection_speed_factor,
421 going_up: !is_loop,
422 going_down: !is_loop,
423 going_forward: is_loop,
424 move_count: 0,
425 door_command_queue: Vec::new(),
426 manual_target_velocity: None,
427 bypass_load_up_pct: ec.bypass_load_up_pct,
428 bypass_load_down_pct: ec.bypass_load_down_pct,
429 home_stop: None,
430 },
431 );
432 #[cfg(feature = "energy")]
433 if let Some(ref profile) = ec.energy_profile {
434 world.set_energy_profile(eid, profile.clone());
435 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
436 }
437 if let Some(mode) = ec.service_mode {
438 world.set_service_mode(eid, mode);
439 }
440 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
441 eid
442 }
443
444 fn build_legacy_topology(
446 world: &mut World,
447 config: &SimConfig,
448 stop_lookup: &HashMap<StopId, EntityId>,
449 elevator_lookup: &mut HashMap<crate::config::ElevatorConfigId, EntityId>,
450 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
451 ) -> TopologyResult {
452 let all_stop_entities: Vec<EntityId> = config
458 .building
459 .stops
460 .iter()
461 .filter_map(|s| stop_lookup.get(&s.id).copied())
462 .collect();
463 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
464 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
465 let max_pos = stop_positions
466 .iter()
467 .copied()
468 .fold(f64::NEG_INFINITY, f64::max);
469
470 let default_line_eid = world.spawn();
471 world.set_line(
472 default_line_eid,
473 Line {
474 name: "Default".into(),
475 group: GroupId(0),
476 orientation: Orientation::Vertical,
477 position: None,
478 kind: LineKind::Linear {
479 min: min_pos,
480 max: max_pos,
481 },
482 max_cars: None,
483 },
484 );
485
486 let mut elevator_entities = Vec::new();
487 for ec in &config.elevators {
488 let eid = Self::spawn_elevator_entity(
489 world,
490 ec,
491 default_line_eid,
492 stop_lookup,
493 &config.building.stops,
494 );
495 elevator_lookup.insert(ec.id, eid);
496 elevator_entities.push(eid);
497 }
498
499 let default_line_info =
500 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
501
502 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
503
504 let mut dispatchers = BTreeMap::new();
508 let mut strategy_ids = BTreeMap::new();
509 let user_dispatcher = builder_dispatchers
510 .into_iter()
511 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
512 let inferred_id = user_dispatcher
517 .as_ref()
518 .and_then(|d| d.builtin_id())
519 .unwrap_or(BuiltinStrategy::Scan);
520 if let Some(d) = user_dispatcher {
521 dispatchers.insert(GroupId(0), d);
522 } else {
523 dispatchers.insert(
524 GroupId(0),
525 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
526 );
527 }
528 strategy_ids.insert(GroupId(0), inferred_id);
529
530 (vec![group], dispatchers, strategy_ids)
531 }
532
533 #[allow(clippy::too_many_lines)]
535 fn build_explicit_topology(
536 world: &mut World,
537 config: &SimConfig,
538 line_configs: &[crate::config::LineConfig],
539 stop_lookup: &HashMap<StopId, EntityId>,
540 elevator_lookup: &mut HashMap<crate::config::ElevatorConfigId, EntityId>,
541 line_lookup: &mut HashMap<crate::config::LineConfigId, EntityId>,
542 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
543 ) -> TopologyResult {
544 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
550
551 for lc in line_configs {
552 let served_entities: Vec<EntityId> = lc
554 .serves
555 .iter()
556 .filter_map(|sid| stop_lookup.get(sid).copied())
557 .collect();
558
559 let stop_positions: Vec<f64> = lc
561 .serves
562 .iter()
563 .filter_map(|sid| {
564 config
565 .building
566 .stops
567 .iter()
568 .find(|s| s.id == *sid)
569 .map(|s| s.position)
570 })
571 .collect();
572 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
573 let auto_max = stop_positions
574 .iter()
575 .copied()
576 .fold(f64::NEG_INFINITY, f64::max);
577
578 let min_pos = lc.min_position.unwrap_or(auto_min);
579 let max_pos = lc.max_position.unwrap_or(auto_max);
580
581 let line_eid = world.spawn();
582 world.set_line(
586 line_eid,
587 Line {
588 name: lc.name.clone(),
589 group: GroupId(0),
590 orientation: lc.orientation,
591 position: lc.position,
592 kind: lc.kind.unwrap_or(LineKind::Linear {
593 min: min_pos,
594 max: max_pos,
595 }),
596 max_cars: lc.max_cars,
597 },
598 );
599
600 let mut elevator_entities = Vec::new();
602 for ec in &lc.elevators {
603 let eid = Self::spawn_elevator_entity(
604 world,
605 ec,
606 line_eid,
607 stop_lookup,
608 &config.building.stops,
609 );
610 elevator_lookup.insert(ec.id, eid);
611 elevator_entities.push(eid);
612 }
613
614 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
615 line_lookup.insert(lc.id, line_eid);
616 line_map.insert(lc.id.0, (line_eid, line_info));
617 }
618
619 let group_configs = config.building.groups.as_deref();
621 let mut groups = Vec::new();
622 let mut dispatchers = BTreeMap::new();
623 let mut strategy_ids = BTreeMap::new();
624
625 if let Some(gcs) = group_configs {
626 for gc in gcs {
627 let group_id = GroupId(gc.id);
628
629 let mut group_lines = Vec::new();
630
631 for &lid in &gc.lines {
632 if let Some((line_eid, li)) = line_map.get(&lid) {
633 if let Some(line_comp) = world.line_mut(*line_eid) {
635 line_comp.group = group_id;
636 }
637 group_lines.push(li.clone());
638 }
639 }
640
641 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
642 if let Some(mode) = gc.hall_call_mode {
643 group.set_hall_call_mode(mode);
644 }
645 if let Some(ticks) = gc.ack_latency_ticks {
646 group.set_ack_latency_ticks(ticks);
647 }
648 groups.push(group);
649
650 let dispatch: Box<dyn DispatchStrategy> = gc
652 .dispatch
653 .instantiate()
654 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
655 dispatchers.insert(group_id, dispatch);
656 strategy_ids.insert(group_id, gc.dispatch.clone());
657 }
658 } else {
659 let group_id = GroupId(0);
661 let mut group_lines = Vec::new();
662
663 for (line_eid, li) in line_map.values() {
664 if let Some(line_comp) = world.line_mut(*line_eid) {
665 line_comp.group = group_id;
666 }
667 group_lines.push(li.clone());
668 }
669
670 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
671 groups.push(group);
672
673 let dispatch: Box<dyn DispatchStrategy> =
674 Box::new(crate::dispatch::scan::ScanDispatch::new());
675 dispatchers.insert(group_id, dispatch);
676 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
677 }
678
679 for (gid, d) in builder_dispatchers {
684 let inferred_id = d.builtin_id();
685 dispatchers.insert(gid, d);
686 match inferred_id {
687 Some(id) => {
688 strategy_ids.insert(gid, id);
689 }
690 None => {
691 strategy_ids
692 .entry(gid)
693 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
694 }
695 }
696 }
697
698 (groups, dispatchers, strategy_ids)
699 }
700
701 #[allow(clippy::too_many_arguments)]
703 pub(crate) fn from_parts(
704 world: World,
705 tick: u64,
706 dt: f64,
707 groups: Vec<ElevatorGroup>,
708 stop_lookup: HashMap<StopId, EntityId>,
709 elevator_lookup: HashMap<crate::config::ElevatorConfigId, EntityId>,
710 line_lookup: HashMap<crate::config::LineConfigId, EntityId>,
711 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
712 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
713 metrics: Metrics,
714 ticks_per_second: f64,
715 ) -> Self {
716 let mut rider_index = RiderIndex::default();
717 rider_index.rebuild(&world);
718 let mut world = world;
724 world.insert_resource(crate::time::TickRate(ticks_per_second));
725 if world
726 .resource::<crate::traffic_detector::TrafficDetector>()
727 .is_none()
728 {
729 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
730 }
731 if world
736 .resource::<crate::arrival_log::DestinationLog>()
737 .is_none()
738 {
739 world.insert_resource(crate::arrival_log::DestinationLog::default());
740 }
741 world.register_ext::<crate::dispatch::destination::AssignedCar>(
756 crate::dispatch::destination::ASSIGNED_CAR_KEY,
757 );
758 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
759 let data = pending.0.clone();
760 world.deserialize_extensions(&data);
761 }
762 Self {
763 world,
764 events: EventBus::default(),
765 pending_output: Vec::new(),
766 tick,
767 dt,
768 groups,
769 stop_lookup,
770 elevator_lookup,
771 line_lookup,
772 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
773 repositioner_set: super::RepositionerSet::new(),
774 metrics,
775 time: TimeAdapter::new(ticks_per_second),
776 hooks: PhaseHooks::default(),
777 elevator_ids_buf: Vec::new(),
778 reposition_buf: Vec::new(),
779 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
780 topo_graph: Mutex::new(TopologyGraph::new()),
781 rider_index,
782 tick_in_progress: false,
783 phase_check: super::PhaseCheck::Disabled,
784 }
785 }
786
787 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
789 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
796 return Err(SimError::InvalidConfig {
797 field: "schema_version",
798 reason: format!(
799 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
800 config.schema_version,
801 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
802 ),
803 });
804 }
805 if config.schema_version == 0 {
806 return Err(SimError::InvalidConfig {
807 field: "schema_version",
808 reason: format!(
809 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
810 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
811 ),
812 });
813 }
814
815 if config.building.stops.is_empty() {
816 return Err(SimError::InvalidConfig {
817 field: "building.stops",
818 reason: "at least one stop is required".into(),
819 });
820 }
821
822 let mut seen_ids = HashSet::new();
824 for stop in &config.building.stops {
825 if !seen_ids.insert(stop.id) {
826 return Err(SimError::InvalidConfig {
827 field: "building.stops",
828 reason: format!("duplicate {}", stop.id),
829 });
830 }
831 if !stop.position.is_finite() {
832 return Err(SimError::InvalidConfig {
833 field: "building.stops.position",
834 reason: format!("{} has non-finite position {}", stop.id, stop.position),
835 });
836 }
837 }
838
839 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
840
841 if let Some(line_configs) = &config.building.lines {
842 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
844 } else {
845 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
847 }
848
849 if !config.simulation.ticks_per_second.is_finite()
850 || config.simulation.ticks_per_second <= 0.0
851 {
852 return Err(SimError::InvalidConfig {
853 field: "simulation.ticks_per_second",
854 reason: format!(
855 "must be finite and positive, got {}",
856 config.simulation.ticks_per_second
857 ),
858 });
859 }
860
861 Self::validate_passenger_spawning(&config.passenger_spawning)?;
862
863 Ok(())
864 }
865
866 fn validate_passenger_spawning(
871 spawn: &crate::config::PassengerSpawnConfig,
872 ) -> Result<(), SimError> {
873 let (lo, hi) = spawn.weight_range;
874 if !lo.is_finite() || !hi.is_finite() {
875 return Err(SimError::InvalidConfig {
876 field: "passenger_spawning.weight_range",
877 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
878 });
879 }
880 if lo < 0.0 || hi < 0.0 {
881 return Err(SimError::InvalidConfig {
882 field: "passenger_spawning.weight_range",
883 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
884 });
885 }
886 if lo > hi {
887 return Err(SimError::InvalidConfig {
888 field: "passenger_spawning.weight_range",
889 reason: format!("min must be <= max, got ({lo}, {hi})"),
890 });
891 }
892 if spawn.mean_interval_ticks == 0 {
893 return Err(SimError::InvalidConfig {
894 field: "passenger_spawning.mean_interval_ticks",
895 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
896 every catch-up tick"
897 .into(),
898 });
899 }
900 Ok(())
901 }
902
903 fn validate_legacy_elevators(
905 elevators: &[crate::config::ElevatorConfig],
906 building: &crate::config::BuildingConfig,
907 ) -> Result<(), SimError> {
908 if elevators.is_empty() {
909 return Err(SimError::InvalidConfig {
910 field: "elevators",
911 reason: "at least one elevator is required".into(),
912 });
913 }
914
915 for elev in elevators {
916 Self::validate_elevator_config(elev, building)?;
917 }
918
919 Ok(())
920 }
921
922 fn validate_elevator_config(
924 elev: &crate::config::ElevatorConfig,
925 building: &crate::config::BuildingConfig,
926 ) -> Result<(), SimError> {
927 validate_elevator_physics(
928 elev.max_speed.value(),
929 elev.acceleration.value(),
930 elev.deceleration.value(),
931 elev.weight_capacity.value(),
932 elev.inspection_speed_factor,
933 elev.door_transition_ticks,
934 elev.door_open_ticks,
935 elev.bypass_load_up_pct,
936 elev.bypass_load_down_pct,
937 )?;
938 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
939 return Err(SimError::InvalidConfig {
940 field: "elevators.starting_stop",
941 reason: format!("references non-existent {}", elev.starting_stop),
942 });
943 }
944 Ok(())
945 }
946
947 #[allow(
949 clippy::too_many_lines,
950 reason = "validation reads top-to-bottom; extracting helpers would scatter related rejections across files"
951 )]
952 fn validate_explicit_topology(
953 line_configs: &[crate::config::LineConfig],
954 stop_ids: &HashSet<StopId>,
955 building: &crate::config::BuildingConfig,
956 ) -> Result<(), SimError> {
957 let mut seen_line_ids = HashSet::new();
959 for lc in line_configs {
960 if !seen_line_ids.insert(lc.id) {
961 return Err(SimError::InvalidConfig {
962 field: "building.lines",
963 reason: format!("duplicate line id {}", lc.id),
964 });
965 }
966 }
967
968 for lc in line_configs {
970 if lc.serves.is_empty() {
971 return Err(SimError::InvalidConfig {
972 field: "building.lines.serves",
973 reason: format!("line {} has no stops", lc.id),
974 });
975 }
976 for sid in &lc.serves {
977 if !stop_ids.contains(sid) {
978 return Err(SimError::InvalidConfig {
979 field: "building.lines.serves",
980 reason: format!("line {} references non-existent {}", lc.id, sid),
981 });
982 }
983 }
984 for ec in &lc.elevators {
986 Self::validate_elevator_config(ec, building)?;
987 }
988
989 if let Some(max) = lc.max_cars
991 && lc.elevators.len() > max
992 {
993 return Err(SimError::InvalidConfig {
994 field: "building.lines.max_cars",
995 reason: format!(
996 "line {} has {} elevators but max_cars is {max}",
997 lc.id,
998 lc.elevators.len()
999 ),
1000 });
1001 }
1002
1003 if let Some(kind) = lc.kind
1007 && let Err((field, reason)) = kind.validate()
1008 {
1009 return Err(SimError::InvalidConfig { field, reason });
1010 }
1011
1012 #[cfg(feature = "loop_lines")]
1019 if let Some(crate::components::LineKind::Loop {
1020 circumference,
1021 min_headway,
1022 }) = lc.kind
1023 {
1024 let car_count = lc
1025 .max_cars
1026 .map_or_else(|| lc.elevators.len(), |max| max.max(lc.elevators.len()));
1027 if car_count > 0 {
1028 #[allow(
1029 clippy::cast_precision_loss,
1030 reason = "car_count is bounded by usize and the comparison is against a finite f64"
1031 )]
1032 let required = (car_count as f64) * min_headway;
1033 if required > circumference {
1034 return Err(SimError::InvalidConfig {
1035 field: "building.lines.kind",
1036 reason: format!(
1037 "loop line {}: {car_count} cars × min_headway {min_headway} = {required} \
1038 exceeds circumference {circumference}",
1039 lc.id,
1040 ),
1041 });
1042 }
1043 }
1044 }
1045 }
1046
1047 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
1049 if !has_elevator {
1050 return Err(SimError::InvalidConfig {
1051 field: "building.lines",
1052 reason: "at least one line must have at least one elevator".into(),
1053 });
1054 }
1055
1056 let served: HashSet<StopId> = line_configs
1058 .iter()
1059 .flat_map(|lc| lc.serves.iter().copied())
1060 .collect();
1061 for sid in stop_ids {
1062 if !served.contains(sid) {
1063 return Err(SimError::InvalidConfig {
1064 field: "building.lines",
1065 reason: format!("orphaned stop {sid} not served by any line"),
1066 });
1067 }
1068 }
1069
1070 if let Some(group_configs) = &building.groups {
1072 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id.0).collect();
1073
1074 let mut seen_group_ids = HashSet::new();
1075 for gc in group_configs {
1076 if !seen_group_ids.insert(gc.id) {
1077 return Err(SimError::InvalidConfig {
1078 field: "building.groups",
1079 reason: format!("duplicate group id {}", gc.id),
1080 });
1081 }
1082 for &lid in &gc.lines {
1083 if !line_id_set.contains(&lid) {
1084 return Err(SimError::InvalidConfig {
1085 field: "building.groups.lines",
1086 reason: format!(
1087 "group {} references non-existent line id {}",
1088 gc.id, lid
1089 ),
1090 });
1091 }
1092 }
1093 }
1094
1095 let referenced_line_ids: HashSet<u32> = group_configs
1097 .iter()
1098 .flat_map(|g| g.lines.iter().copied())
1099 .collect();
1100 for lc in line_configs {
1101 if !referenced_line_ids.contains(&lc.id.0) {
1102 return Err(SimError::InvalidConfig {
1103 field: "building.lines",
1104 reason: format!("line {} is not assigned to any group", lc.id),
1105 });
1106 }
1107 }
1108
1109 #[cfg(feature = "loop_lines")]
1115 for gc in group_configs {
1116 let lines: Vec<&crate::config::LineConfig> = gc
1117 .lines
1118 .iter()
1119 .filter_map(|lid| line_configs.iter().find(|lc| lc.id.0 == *lid))
1120 .collect();
1121 let any_loop = lines
1122 .iter()
1123 .any(|lc| matches!(lc.kind, Some(crate::components::LineKind::Loop { .. })));
1124 let any_linear = lines
1132 .iter()
1133 .any(|lc| lc.kind.as_ref().is_none_or(LineKind::is_linear));
1134 if any_loop && any_linear {
1139 return Err(SimError::InvalidConfig {
1140 field: "building.groups",
1141 reason: format!(
1142 "group {} mixes Loop and Linear lines; groups must be homogeneous",
1143 gc.id,
1144 ),
1145 });
1146 }
1147 if any_loop && gc.reposition.is_some() {
1152 return Err(SimError::InvalidConfig {
1153 field: "building.groups.reposition",
1154 reason: format!(
1155 "group {} contains Loop lines; reposition strategies are unsupported on Loop",
1156 gc.id,
1157 ),
1158 });
1159 }
1160 if any_loop
1168 && !matches!(
1169 gc.dispatch,
1170 BuiltinStrategy::LoopSweep | BuiltinStrategy::LoopSchedule,
1171 )
1172 {
1173 return Err(SimError::InvalidConfig {
1174 field: "building.groups.dispatch",
1175 reason: format!(
1176 "group {} contains Loop lines but uses {} dispatch; \
1177 only LoopSweep or LoopSchedule is supported for Loop groups",
1178 gc.id, gc.dispatch,
1179 ),
1180 });
1181 }
1182 }
1183 }
1184
1185 #[cfg(feature = "loop_lines")]
1188 for lc in line_configs {
1189 let Some(crate::components::LineKind::Loop {
1190 circumference,
1191 min_headway,
1192 }) = lc.kind
1193 else {
1194 continue;
1195 };
1196
1197 let stop_positions: Vec<f64> = lc
1202 .serves
1203 .iter()
1204 .filter_map(|sid| {
1205 building
1206 .stops
1207 .iter()
1208 .find(|s| s.id == *sid)
1209 .map(|s| s.position)
1210 })
1211 .collect();
1212 for (i, &pi) in stop_positions.iter().enumerate() {
1213 for (j, &pj) in stop_positions.iter().enumerate().skip(i + 1) {
1214 if (pi - pj).abs() < 1e-9 {
1215 return Err(SimError::InvalidConfig {
1216 field: "building.lines.serves",
1217 reason: format!(
1218 "loop line {} has duplicate stop positions at indices {i} and {j} (both at {pi})",
1219 lc.id,
1220 ),
1221 });
1222 }
1223 }
1224 }
1225
1226 let car_starts: Vec<f64> = lc
1232 .elevators
1233 .iter()
1234 .filter_map(|ec| {
1235 building
1236 .stops
1237 .iter()
1238 .find(|s| s.id == ec.starting_stop)
1239 .map(|s| s.position)
1240 })
1241 .collect();
1242 for (i, &a) in car_starts.iter().enumerate() {
1243 for (j, &b) in car_starts.iter().enumerate().skip(i + 1) {
1244 let d = crate::components::cyclic::cyclic_distance(a, b, circumference);
1245 if d < min_headway - 1e-9 {
1246 return Err(SimError::InvalidConfig {
1247 field: "building.lines.elevators.starting_stop",
1248 reason: format!(
1249 "loop line {}: cars at indices {i} ({a}) and {j} ({b}) are {d} apart \
1250 — below min_headway {min_headway}",
1251 lc.id,
1252 ),
1253 });
1254 }
1255 }
1256 }
1257 }
1258
1259 Ok(())
1260 }
1261
1262 pub fn set_dispatch(
1289 &mut self,
1290 group: GroupId,
1291 strategy: Box<dyn DispatchStrategy>,
1292 id: crate::dispatch::BuiltinStrategy,
1293 ) {
1294 let resolved_id = strategy.builtin_id().unwrap_or(id);
1295 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1296 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1297 {
1298 g.set_hall_call_mode(mode);
1299 }
1300 self.dispatcher_set.insert(group, strategy, resolved_id);
1301 }
1302
1303 pub fn set_reposition(
1346 &mut self,
1347 group: GroupId,
1348 strategy: Box<dyn RepositionStrategy>,
1349 id: BuiltinReposition,
1350 ) {
1351 let resolved_id = strategy.builtin_id().unwrap_or(id);
1352 let needed_window = strategy.min_arrival_log_window();
1353 self.repositioner_set.insert(group, strategy, resolved_id);
1354 if needed_window > 0
1360 && let Some(retention) = self
1361 .world
1362 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1363 && needed_window > retention.0
1364 {
1365 retention.0 = needed_window;
1366 }
1367 }
1368
1369 pub fn remove_reposition(&mut self, group: GroupId) {
1380 self.repositioner_set.remove(group);
1381 }
1382
1383 #[must_use]
1385 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1386 self.repositioner_set.id_for(group)
1387 }
1388
1389 pub fn add_before_hook(
1396 &mut self,
1397 phase: Phase,
1398 hook: impl Fn(&mut World) + Send + Sync + 'static,
1399 ) {
1400 self.hooks.add_before(phase, Box::new(hook));
1401 }
1402
1403 pub fn add_after_hook(
1408 &mut self,
1409 phase: Phase,
1410 hook: impl Fn(&mut World) + Send + Sync + 'static,
1411 ) {
1412 self.hooks.add_after(phase, Box::new(hook));
1413 }
1414
1415 pub fn add_before_group_hook(
1417 &mut self,
1418 phase: Phase,
1419 group: GroupId,
1420 hook: impl Fn(&mut World) + Send + Sync + 'static,
1421 ) {
1422 self.hooks.add_before_group(phase, group, Box::new(hook));
1423 }
1424
1425 pub fn add_after_group_hook(
1427 &mut self,
1428 phase: Phase,
1429 group: GroupId,
1430 hook: impl Fn(&mut World) + Send + Sync + 'static,
1431 ) {
1432 self.hooks.add_after_group(phase, group, Box::new(hook));
1433 }
1434}