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 }
70}
71
72fn sync_hall_call_modes(
81 groups: &mut [ElevatorGroup],
82 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
83) {
84 for group in groups.iter_mut() {
85 if let Some(strategy) = strategy_ids.get(&group.id())
86 && canonical_hall_call_mode(strategy)
87 == Some(crate::dispatch::HallCallMode::Destination)
88 {
89 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
90 }
91 }
92}
93
94#[allow(clippy::too_many_arguments)]
100pub(super) fn validate_elevator_physics(
101 max_speed: f64,
102 acceleration: f64,
103 deceleration: f64,
104 weight_capacity: f64,
105 inspection_speed_factor: f64,
106 door_transition_ticks: u32,
107 door_open_ticks: u32,
108 bypass_load_up_pct: Option<f64>,
109 bypass_load_down_pct: Option<f64>,
110) -> Result<(), SimError> {
111 if !max_speed.is_finite() || max_speed <= 0.0 {
112 return Err(SimError::InvalidConfig {
113 field: "elevators.max_speed",
114 reason: format!("must be finite and positive, got {max_speed}"),
115 });
116 }
117 if !acceleration.is_finite() || acceleration <= 0.0 {
118 return Err(SimError::InvalidConfig {
119 field: "elevators.acceleration",
120 reason: format!("must be finite and positive, got {acceleration}"),
121 });
122 }
123 if !deceleration.is_finite() || deceleration <= 0.0 {
124 return Err(SimError::InvalidConfig {
125 field: "elevators.deceleration",
126 reason: format!("must be finite and positive, got {deceleration}"),
127 });
128 }
129 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
130 return Err(SimError::InvalidConfig {
131 field: "elevators.weight_capacity",
132 reason: format!("must be finite and positive, got {weight_capacity}"),
133 });
134 }
135 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
136 return Err(SimError::InvalidConfig {
137 field: "elevators.inspection_speed_factor",
138 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
139 });
140 }
141 if door_transition_ticks == 0 {
142 return Err(SimError::InvalidConfig {
143 field: "elevators.door_transition_ticks",
144 reason: "must be > 0".into(),
145 });
146 }
147 if door_open_ticks == 0 {
148 return Err(SimError::InvalidConfig {
149 field: "elevators.door_open_ticks",
150 reason: "must be > 0".into(),
151 });
152 }
153 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
154 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
155 Ok(())
156}
157
158fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
163 let Some(pct) = pct else {
164 return Ok(());
165 };
166 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
167 return Err(SimError::InvalidConfig {
168 field,
169 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
170 });
171 }
172 Ok(())
173}
174
175impl Simulation {
176 pub fn new(
187 config: &SimConfig,
188 dispatch: impl DispatchStrategy + 'static,
189 ) -> Result<Self, SimError> {
190 let mut dispatchers = BTreeMap::new();
191 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
192 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
193 }
194
195 #[allow(clippy::too_many_lines)]
199 pub(crate) fn new_with_hooks(
200 config: &SimConfig,
201 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
202 hooks: PhaseHooks,
203 ) -> Result<Self, SimError> {
204 Self::validate_config(config)?;
205
206 let mut world = World::new();
207
208 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
210 for sc in &config.building.stops {
211 let eid = world.spawn();
212 world.set_stop(
213 eid,
214 Stop {
215 name: sc.name.clone(),
216 position: sc.position,
217 },
218 );
219 world.set_position(eid, Position { value: sc.position });
220 stop_lookup.insert(sc.id, eid);
221 }
222
223 let mut sorted: Vec<(f64, EntityId)> = world
225 .iter_stops()
226 .map(|(eid, stop)| (stop.position, eid))
227 .collect();
228 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
229 world.insert_resource(crate::world::SortedStops(sorted));
230
231 world.insert_resource(crate::arrival_log::ArrivalLog::default());
241 world.insert_resource(crate::arrival_log::DestinationLog::default());
242 world.insert_resource(crate::arrival_log::CurrentTick::default());
243 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
244 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
248 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
254 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
260
261 let (mut groups, dispatchers, strategy_ids) =
262 if let Some(line_configs) = &config.building.lines {
263 Self::build_explicit_topology(
264 &mut world,
265 config,
266 line_configs,
267 &stop_lookup,
268 builder_dispatchers,
269 )
270 } else {
271 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
272 };
273 sync_hall_call_modes(&mut groups, &strategy_ids);
274
275 let dt = 1.0 / config.simulation.ticks_per_second;
276
277 world.insert_resource(crate::tagged_metrics::MetricTags::default());
278
279 world.register_ext::<crate::dispatch::destination::AssignedCar>(
288 crate::dispatch::destination::ASSIGNED_CAR_KEY,
289 );
290
291 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
294 .iter()
295 .flat_map(|group| {
296 group.lines().iter().filter_map(|li| {
297 let line_comp = world.line(li.entity())?;
298 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
299 })
300 })
301 .collect();
302
303 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
305 for (line_eid, name, elevators) in &line_tag_info {
306 let tag = format!("line:{name}");
307 tags.tag(*line_eid, tag.clone());
308 for elev_eid in elevators {
309 tags.tag(*elev_eid, tag.clone());
310 }
311 }
312 }
313
314 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
316 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
317 if let Some(group_configs) = &config.building.groups {
318 for gc in group_configs {
319 if let Some(ref repo_id) = gc.reposition
320 && let Some(strategy) = repo_id.instantiate()
321 {
322 let gid = GroupId(gc.id);
323 repositioners.insert(gid, strategy);
324 reposition_ids.insert(gid, repo_id.clone());
325 }
326 }
327 }
328
329 Ok(Self {
330 world,
331 events: EventBus::default(),
332 pending_output: Vec::new(),
333 tick: 0,
334 dt,
335 groups,
336 stop_lookup,
337 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
338 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
339 metrics: Metrics::new(),
340 time: TimeAdapter::new(config.simulation.ticks_per_second),
341 hooks,
342 elevator_ids_buf: Vec::new(),
343 reposition_buf: Vec::new(),
344 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
345 topo_graph: Mutex::new(TopologyGraph::new()),
346 rider_index: RiderIndex::default(),
347 tick_in_progress: false,
348 })
349 }
350
351 fn spawn_elevator_entity(
357 world: &mut World,
358 ec: &crate::config::ElevatorConfig,
359 line: EntityId,
360 stop_lookup: &HashMap<StopId, EntityId>,
361 start_pos_lookup: &[crate::stop::StopConfig],
362 ) -> EntityId {
363 let eid = world.spawn();
364 let start_pos = start_pos_lookup
365 .iter()
366 .find(|s| s.id == ec.starting_stop)
367 .map_or(0.0, |s| s.position);
368 world.set_position(eid, Position { value: start_pos });
369 world.set_velocity(eid, Velocity { value: 0.0 });
370 let restricted: HashSet<EntityId> = ec
371 .restricted_stops
372 .iter()
373 .filter_map(|sid| stop_lookup.get(sid).copied())
374 .collect();
375 world.set_elevator(
376 eid,
377 Elevator {
378 phase: ElevatorPhase::Idle,
379 door: DoorState::Closed,
380 max_speed: ec.max_speed,
381 acceleration: ec.acceleration,
382 deceleration: ec.deceleration,
383 weight_capacity: ec.weight_capacity,
384 current_load: crate::components::Weight::ZERO,
385 riders: Vec::new(),
386 target_stop: None,
387 door_transition_ticks: ec.door_transition_ticks,
388 door_open_ticks: ec.door_open_ticks,
389 line,
390 repositioning: false,
391 restricted_stops: restricted,
392 inspection_speed_factor: ec.inspection_speed_factor,
393 going_up: true,
394 going_down: true,
395 going_forward: false,
396 move_count: 0,
397 door_command_queue: Vec::new(),
398 manual_target_velocity: None,
399 bypass_load_up_pct: ec.bypass_load_up_pct,
400 bypass_load_down_pct: ec.bypass_load_down_pct,
401 home_stop: None,
402 },
403 );
404 #[cfg(feature = "energy")]
405 if let Some(ref profile) = ec.energy_profile {
406 world.set_energy_profile(eid, profile.clone());
407 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
408 }
409 if let Some(mode) = ec.service_mode {
410 world.set_service_mode(eid, mode);
411 }
412 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
413 eid
414 }
415
416 fn build_legacy_topology(
418 world: &mut World,
419 config: &SimConfig,
420 stop_lookup: &HashMap<StopId, EntityId>,
421 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
422 ) -> TopologyResult {
423 let all_stop_entities: Vec<EntityId> = config
429 .building
430 .stops
431 .iter()
432 .filter_map(|s| stop_lookup.get(&s.id).copied())
433 .collect();
434 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
435 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
436 let max_pos = stop_positions
437 .iter()
438 .copied()
439 .fold(f64::NEG_INFINITY, f64::max);
440
441 let default_line_eid = world.spawn();
442 world.set_line(
443 default_line_eid,
444 Line {
445 name: "Default".into(),
446 group: GroupId(0),
447 orientation: Orientation::Vertical,
448 position: None,
449 kind: LineKind::Linear {
450 min: min_pos,
451 max: max_pos,
452 },
453 max_cars: None,
454 },
455 );
456
457 let mut elevator_entities = Vec::new();
458 for ec in &config.elevators {
459 let eid = Self::spawn_elevator_entity(
460 world,
461 ec,
462 default_line_eid,
463 stop_lookup,
464 &config.building.stops,
465 );
466 elevator_entities.push(eid);
467 }
468
469 let default_line_info =
470 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
471
472 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
473
474 let mut dispatchers = BTreeMap::new();
478 let mut strategy_ids = BTreeMap::new();
479 let user_dispatcher = builder_dispatchers
480 .into_iter()
481 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
482 let inferred_id = user_dispatcher
487 .as_ref()
488 .and_then(|d| d.builtin_id())
489 .unwrap_or(BuiltinStrategy::Scan);
490 if let Some(d) = user_dispatcher {
491 dispatchers.insert(GroupId(0), d);
492 } else {
493 dispatchers.insert(
494 GroupId(0),
495 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
496 );
497 }
498 strategy_ids.insert(GroupId(0), inferred_id);
499
500 (vec![group], dispatchers, strategy_ids)
501 }
502
503 #[allow(clippy::too_many_lines)]
505 fn build_explicit_topology(
506 world: &mut World,
507 config: &SimConfig,
508 line_configs: &[crate::config::LineConfig],
509 stop_lookup: &HashMap<StopId, EntityId>,
510 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
511 ) -> TopologyResult {
512 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
518
519 for lc in line_configs {
520 let served_entities: Vec<EntityId> = lc
522 .serves
523 .iter()
524 .filter_map(|sid| stop_lookup.get(sid).copied())
525 .collect();
526
527 let stop_positions: Vec<f64> = lc
529 .serves
530 .iter()
531 .filter_map(|sid| {
532 config
533 .building
534 .stops
535 .iter()
536 .find(|s| s.id == *sid)
537 .map(|s| s.position)
538 })
539 .collect();
540 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
541 let auto_max = stop_positions
542 .iter()
543 .copied()
544 .fold(f64::NEG_INFINITY, f64::max);
545
546 let min_pos = lc.min_position.unwrap_or(auto_min);
547 let max_pos = lc.max_position.unwrap_or(auto_max);
548
549 let line_eid = world.spawn();
550 world.set_line(
554 line_eid,
555 Line {
556 name: lc.name.clone(),
557 group: GroupId(0),
558 orientation: lc.orientation,
559 position: lc.position,
560 kind: lc.kind.unwrap_or(LineKind::Linear {
561 min: min_pos,
562 max: max_pos,
563 }),
564 max_cars: lc.max_cars,
565 },
566 );
567
568 let mut elevator_entities = Vec::new();
570 for ec in &lc.elevators {
571 let eid = Self::spawn_elevator_entity(
572 world,
573 ec,
574 line_eid,
575 stop_lookup,
576 &config.building.stops,
577 );
578 elevator_entities.push(eid);
579 }
580
581 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
582 line_map.insert(lc.id, (line_eid, line_info));
583 }
584
585 let group_configs = config.building.groups.as_deref();
587 let mut groups = Vec::new();
588 let mut dispatchers = BTreeMap::new();
589 let mut strategy_ids = BTreeMap::new();
590
591 if let Some(gcs) = group_configs {
592 for gc in gcs {
593 let group_id = GroupId(gc.id);
594
595 let mut group_lines = Vec::new();
596
597 for &lid in &gc.lines {
598 if let Some((line_eid, li)) = line_map.get(&lid) {
599 if let Some(line_comp) = world.line_mut(*line_eid) {
601 line_comp.group = group_id;
602 }
603 group_lines.push(li.clone());
604 }
605 }
606
607 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
608 if let Some(mode) = gc.hall_call_mode {
609 group.set_hall_call_mode(mode);
610 }
611 if let Some(ticks) = gc.ack_latency_ticks {
612 group.set_ack_latency_ticks(ticks);
613 }
614 groups.push(group);
615
616 let dispatch: Box<dyn DispatchStrategy> = gc
618 .dispatch
619 .instantiate()
620 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
621 dispatchers.insert(group_id, dispatch);
622 strategy_ids.insert(group_id, gc.dispatch.clone());
623 }
624 } else {
625 let group_id = GroupId(0);
627 let mut group_lines = Vec::new();
628
629 for (line_eid, li) in line_map.values() {
630 if let Some(line_comp) = world.line_mut(*line_eid) {
631 line_comp.group = group_id;
632 }
633 group_lines.push(li.clone());
634 }
635
636 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
637 groups.push(group);
638
639 let dispatch: Box<dyn DispatchStrategy> =
640 Box::new(crate::dispatch::scan::ScanDispatch::new());
641 dispatchers.insert(group_id, dispatch);
642 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
643 }
644
645 for (gid, d) in builder_dispatchers {
650 let inferred_id = d.builtin_id();
651 dispatchers.insert(gid, d);
652 match inferred_id {
653 Some(id) => {
654 strategy_ids.insert(gid, id);
655 }
656 None => {
657 strategy_ids
658 .entry(gid)
659 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
660 }
661 }
662 }
663
664 (groups, dispatchers, strategy_ids)
665 }
666
667 #[allow(clippy::too_many_arguments)]
669 pub(crate) fn from_parts(
670 world: World,
671 tick: u64,
672 dt: f64,
673 groups: Vec<ElevatorGroup>,
674 stop_lookup: HashMap<StopId, EntityId>,
675 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
676 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
677 metrics: Metrics,
678 ticks_per_second: f64,
679 ) -> Self {
680 let mut rider_index = RiderIndex::default();
681 rider_index.rebuild(&world);
682 let mut world = world;
688 world.insert_resource(crate::time::TickRate(ticks_per_second));
689 if world
690 .resource::<crate::traffic_detector::TrafficDetector>()
691 .is_none()
692 {
693 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
694 }
695 if world
700 .resource::<crate::arrival_log::DestinationLog>()
701 .is_none()
702 {
703 world.insert_resource(crate::arrival_log::DestinationLog::default());
704 }
705 world.register_ext::<crate::dispatch::destination::AssignedCar>(
720 crate::dispatch::destination::ASSIGNED_CAR_KEY,
721 );
722 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
723 let data = pending.0.clone();
724 world.deserialize_extensions(&data);
725 }
726 Self {
727 world,
728 events: EventBus::default(),
729 pending_output: Vec::new(),
730 tick,
731 dt,
732 groups,
733 stop_lookup,
734 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
735 repositioner_set: super::RepositionerSet::new(),
736 metrics,
737 time: TimeAdapter::new(ticks_per_second),
738 hooks: PhaseHooks::default(),
739 elevator_ids_buf: Vec::new(),
740 reposition_buf: Vec::new(),
741 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
742 topo_graph: Mutex::new(TopologyGraph::new()),
743 rider_index,
744 tick_in_progress: false,
745 }
746 }
747
748 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
750 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
757 return Err(SimError::InvalidConfig {
758 field: "schema_version",
759 reason: format!(
760 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
761 config.schema_version,
762 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
763 ),
764 });
765 }
766 if config.schema_version == 0 {
767 return Err(SimError::InvalidConfig {
768 field: "schema_version",
769 reason: format!(
770 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
771 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
772 ),
773 });
774 }
775
776 if config.building.stops.is_empty() {
777 return Err(SimError::InvalidConfig {
778 field: "building.stops",
779 reason: "at least one stop is required".into(),
780 });
781 }
782
783 let mut seen_ids = HashSet::new();
785 for stop in &config.building.stops {
786 if !seen_ids.insert(stop.id) {
787 return Err(SimError::InvalidConfig {
788 field: "building.stops",
789 reason: format!("duplicate {}", stop.id),
790 });
791 }
792 if !stop.position.is_finite() {
793 return Err(SimError::InvalidConfig {
794 field: "building.stops.position",
795 reason: format!("{} has non-finite position {}", stop.id, stop.position),
796 });
797 }
798 }
799
800 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
801
802 if let Some(line_configs) = &config.building.lines {
803 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
805 } else {
806 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
808 }
809
810 if !config.simulation.ticks_per_second.is_finite()
811 || config.simulation.ticks_per_second <= 0.0
812 {
813 return Err(SimError::InvalidConfig {
814 field: "simulation.ticks_per_second",
815 reason: format!(
816 "must be finite and positive, got {}",
817 config.simulation.ticks_per_second
818 ),
819 });
820 }
821
822 Self::validate_passenger_spawning(&config.passenger_spawning)?;
823
824 Ok(())
825 }
826
827 fn validate_passenger_spawning(
832 spawn: &crate::config::PassengerSpawnConfig,
833 ) -> Result<(), SimError> {
834 let (lo, hi) = spawn.weight_range;
835 if !lo.is_finite() || !hi.is_finite() {
836 return Err(SimError::InvalidConfig {
837 field: "passenger_spawning.weight_range",
838 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
839 });
840 }
841 if lo < 0.0 || hi < 0.0 {
842 return Err(SimError::InvalidConfig {
843 field: "passenger_spawning.weight_range",
844 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
845 });
846 }
847 if lo > hi {
848 return Err(SimError::InvalidConfig {
849 field: "passenger_spawning.weight_range",
850 reason: format!("min must be <= max, got ({lo}, {hi})"),
851 });
852 }
853 if spawn.mean_interval_ticks == 0 {
854 return Err(SimError::InvalidConfig {
855 field: "passenger_spawning.mean_interval_ticks",
856 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
857 every catch-up tick"
858 .into(),
859 });
860 }
861 Ok(())
862 }
863
864 fn validate_legacy_elevators(
866 elevators: &[crate::config::ElevatorConfig],
867 building: &crate::config::BuildingConfig,
868 ) -> Result<(), SimError> {
869 if elevators.is_empty() {
870 return Err(SimError::InvalidConfig {
871 field: "elevators",
872 reason: "at least one elevator is required".into(),
873 });
874 }
875
876 for elev in elevators {
877 Self::validate_elevator_config(elev, building)?;
878 }
879
880 Ok(())
881 }
882
883 fn validate_elevator_config(
885 elev: &crate::config::ElevatorConfig,
886 building: &crate::config::BuildingConfig,
887 ) -> Result<(), SimError> {
888 validate_elevator_physics(
889 elev.max_speed.value(),
890 elev.acceleration.value(),
891 elev.deceleration.value(),
892 elev.weight_capacity.value(),
893 elev.inspection_speed_factor,
894 elev.door_transition_ticks,
895 elev.door_open_ticks,
896 elev.bypass_load_up_pct,
897 elev.bypass_load_down_pct,
898 )?;
899 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
900 return Err(SimError::InvalidConfig {
901 field: "elevators.starting_stop",
902 reason: format!("references non-existent {}", elev.starting_stop),
903 });
904 }
905 Ok(())
906 }
907
908 #[allow(
910 clippy::too_many_lines,
911 reason = "validation reads top-to-bottom; extracting helpers would scatter related rejections across files"
912 )]
913 fn validate_explicit_topology(
914 line_configs: &[crate::config::LineConfig],
915 stop_ids: &HashSet<StopId>,
916 building: &crate::config::BuildingConfig,
917 ) -> Result<(), SimError> {
918 let mut seen_line_ids = HashSet::new();
920 for lc in line_configs {
921 if !seen_line_ids.insert(lc.id) {
922 return Err(SimError::InvalidConfig {
923 field: "building.lines",
924 reason: format!("duplicate line id {}", lc.id),
925 });
926 }
927 }
928
929 for lc in line_configs {
931 if lc.serves.is_empty() {
932 return Err(SimError::InvalidConfig {
933 field: "building.lines.serves",
934 reason: format!("line {} has no stops", lc.id),
935 });
936 }
937 for sid in &lc.serves {
938 if !stop_ids.contains(sid) {
939 return Err(SimError::InvalidConfig {
940 field: "building.lines.serves",
941 reason: format!("line {} references non-existent {}", lc.id, sid),
942 });
943 }
944 }
945 for ec in &lc.elevators {
947 Self::validate_elevator_config(ec, building)?;
948 }
949
950 if let Some(max) = lc.max_cars
952 && lc.elevators.len() > max
953 {
954 return Err(SimError::InvalidConfig {
955 field: "building.lines.max_cars",
956 reason: format!(
957 "line {} has {} elevators but max_cars is {max}",
958 lc.id,
959 lc.elevators.len()
960 ),
961 });
962 }
963
964 if let Some(kind) = lc.kind
968 && let Err((field, reason)) = kind.validate()
969 {
970 return Err(SimError::InvalidConfig { field, reason });
971 }
972
973 #[cfg(feature = "loop_lines")]
980 if let Some(crate::components::LineKind::Loop {
981 circumference,
982 min_headway,
983 }) = lc.kind
984 {
985 let car_count = lc
986 .max_cars
987 .map_or_else(|| lc.elevators.len(), |max| max.max(lc.elevators.len()));
988 if car_count > 0 {
989 #[allow(
990 clippy::cast_precision_loss,
991 reason = "car_count is bounded by usize and the comparison is against a finite f64"
992 )]
993 let required = (car_count as f64) * min_headway;
994 if required > circumference {
995 return Err(SimError::InvalidConfig {
996 field: "building.lines.kind",
997 reason: format!(
998 "loop line {}: {car_count} cars × min_headway {min_headway} = {required} \
999 exceeds circumference {circumference}",
1000 lc.id,
1001 ),
1002 });
1003 }
1004 }
1005 }
1006 }
1007
1008 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
1010 if !has_elevator {
1011 return Err(SimError::InvalidConfig {
1012 field: "building.lines",
1013 reason: "at least one line must have at least one elevator".into(),
1014 });
1015 }
1016
1017 let served: HashSet<StopId> = line_configs
1019 .iter()
1020 .flat_map(|lc| lc.serves.iter().copied())
1021 .collect();
1022 for sid in stop_ids {
1023 if !served.contains(sid) {
1024 return Err(SimError::InvalidConfig {
1025 field: "building.lines",
1026 reason: format!("orphaned stop {sid} not served by any line"),
1027 });
1028 }
1029 }
1030
1031 if let Some(group_configs) = &building.groups {
1033 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
1034
1035 let mut seen_group_ids = HashSet::new();
1036 for gc in group_configs {
1037 if !seen_group_ids.insert(gc.id) {
1038 return Err(SimError::InvalidConfig {
1039 field: "building.groups",
1040 reason: format!("duplicate group id {}", gc.id),
1041 });
1042 }
1043 for &lid in &gc.lines {
1044 if !line_id_set.contains(&lid) {
1045 return Err(SimError::InvalidConfig {
1046 field: "building.groups.lines",
1047 reason: format!(
1048 "group {} references non-existent line id {}",
1049 gc.id, lid
1050 ),
1051 });
1052 }
1053 }
1054 }
1055
1056 let referenced_line_ids: HashSet<u32> = group_configs
1058 .iter()
1059 .flat_map(|g| g.lines.iter().copied())
1060 .collect();
1061 for lc in line_configs {
1062 if !referenced_line_ids.contains(&lc.id) {
1063 return Err(SimError::InvalidConfig {
1064 field: "building.lines",
1065 reason: format!("line {} is not assigned to any group", lc.id),
1066 });
1067 }
1068 }
1069
1070 #[cfg(feature = "loop_lines")]
1076 for gc in group_configs {
1077 let lines: Vec<&crate::config::LineConfig> = gc
1078 .lines
1079 .iter()
1080 .filter_map(|lid| line_configs.iter().find(|lc| lc.id == *lid))
1081 .collect();
1082 let any_loop = lines
1083 .iter()
1084 .any(|lc| matches!(lc.kind, Some(crate::components::LineKind::Loop { .. })));
1085 let any_linear = lines
1093 .iter()
1094 .any(|lc| lc.kind.as_ref().is_none_or(LineKind::is_linear));
1095 if any_loop && any_linear {
1100 return Err(SimError::InvalidConfig {
1101 field: "building.groups",
1102 reason: format!(
1103 "group {} mixes Loop and Linear lines; groups must be homogeneous",
1104 gc.id,
1105 ),
1106 });
1107 }
1108 if any_loop && gc.reposition.is_some() {
1114 return Err(SimError::InvalidConfig {
1115 field: "building.groups.reposition",
1116 reason: format!(
1117 "group {} contains Loop lines; reposition strategies are unsupported on Loop",
1118 gc.id,
1119 ),
1120 });
1121 }
1122 }
1123 }
1124
1125 #[cfg(feature = "loop_lines")]
1128 for lc in line_configs {
1129 let Some(crate::components::LineKind::Loop {
1130 circumference,
1131 min_headway,
1132 }) = lc.kind
1133 else {
1134 continue;
1135 };
1136
1137 let stop_positions: Vec<f64> = lc
1142 .serves
1143 .iter()
1144 .filter_map(|sid| {
1145 building
1146 .stops
1147 .iter()
1148 .find(|s| s.id == *sid)
1149 .map(|s| s.position)
1150 })
1151 .collect();
1152 for (i, &pi) in stop_positions.iter().enumerate() {
1153 for (j, &pj) in stop_positions.iter().enumerate().skip(i + 1) {
1154 if (pi - pj).abs() < 1e-9 {
1155 return Err(SimError::InvalidConfig {
1156 field: "building.lines.serves",
1157 reason: format!(
1158 "loop line {} has duplicate stop positions at indices {i} and {j} (both at {pi})",
1159 lc.id,
1160 ),
1161 });
1162 }
1163 }
1164 }
1165
1166 let car_starts: Vec<f64> = lc
1172 .elevators
1173 .iter()
1174 .filter_map(|ec| {
1175 building
1176 .stops
1177 .iter()
1178 .find(|s| s.id == ec.starting_stop)
1179 .map(|s| s.position)
1180 })
1181 .collect();
1182 for (i, &a) in car_starts.iter().enumerate() {
1183 for (j, &b) in car_starts.iter().enumerate().skip(i + 1) {
1184 let d = crate::components::cyclic::cyclic_distance(a, b, circumference);
1185 if d < min_headway - 1e-9 {
1186 return Err(SimError::InvalidConfig {
1187 field: "building.lines.elevators.starting_stop",
1188 reason: format!(
1189 "loop line {}: cars at indices {i} ({a}) and {j} ({b}) are {d} apart \
1190 — below min_headway {min_headway}",
1191 lc.id,
1192 ),
1193 });
1194 }
1195 }
1196 }
1197 }
1198
1199 Ok(())
1200 }
1201
1202 pub fn set_dispatch(
1218 &mut self,
1219 group: GroupId,
1220 strategy: Box<dyn DispatchStrategy>,
1221 id: crate::dispatch::BuiltinStrategy,
1222 ) {
1223 let resolved_id = strategy.builtin_id().unwrap_or(id);
1224 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1225 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1226 {
1227 g.set_hall_call_mode(mode);
1228 }
1229 self.dispatcher_set.insert(group, strategy, resolved_id);
1230 }
1231
1232 pub fn set_reposition(
1263 &mut self,
1264 group: GroupId,
1265 strategy: Box<dyn RepositionStrategy>,
1266 id: BuiltinReposition,
1267 ) {
1268 let resolved_id = strategy.builtin_id().unwrap_or(id);
1269 let needed_window = strategy.min_arrival_log_window();
1270 self.repositioner_set.insert(group, strategy, resolved_id);
1271 if needed_window > 0
1277 && let Some(retention) = self
1278 .world
1279 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1280 && needed_window > retention.0
1281 {
1282 retention.0 = needed_window;
1283 }
1284 }
1285
1286 pub fn remove_reposition(&mut self, group: GroupId) {
1297 self.repositioner_set.remove(group);
1298 }
1299
1300 #[must_use]
1302 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1303 self.repositioner_set.id_for(group)
1304 }
1305
1306 pub fn add_before_hook(
1313 &mut self,
1314 phase: Phase,
1315 hook: impl Fn(&mut World) + Send + Sync + 'static,
1316 ) {
1317 self.hooks.add_before(phase, Box::new(hook));
1318 }
1319
1320 pub fn add_after_hook(
1325 &mut self,
1326 phase: Phase,
1327 hook: impl Fn(&mut World) + Send + Sync + 'static,
1328 ) {
1329 self.hooks.add_after(phase, Box::new(hook));
1330 }
1331
1332 pub fn add_before_group_hook(
1334 &mut self,
1335 phase: Phase,
1336 group: GroupId,
1337 hook: impl Fn(&mut World) + Send + Sync + 'static,
1338 ) {
1339 self.hooks.add_before_group(phase, group, Box::new(hook));
1340 }
1341
1342 pub fn add_after_group_hook(
1344 &mut self,
1345 phase: Phase,
1346 group: GroupId,
1347 hook: impl Fn(&mut World) + Send + Sync + 'static,
1348 ) {
1349 self.hooks.add_after_group(phase, group, Box::new(hook));
1350 }
1351}