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 groups, dispatchers, strategy_ids) =
270 if let Some(line_configs) = &config.building.lines {
271 Self::build_explicit_topology(
272 &mut world,
273 config,
274 line_configs,
275 &stop_lookup,
276 builder_dispatchers,
277 )
278 } else {
279 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
280 };
281 sync_hall_call_modes(&mut groups, &strategy_ids);
282
283 let dt = 1.0 / config.simulation.ticks_per_second;
284
285 world.insert_resource(crate::tagged_metrics::MetricTags::default());
286
287 world.register_ext::<crate::dispatch::destination::AssignedCar>(
296 crate::dispatch::destination::ASSIGNED_CAR_KEY,
297 );
298
299 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
302 .iter()
303 .flat_map(|group| {
304 group.lines().iter().filter_map(|li| {
305 let line_comp = world.line(li.entity())?;
306 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
307 })
308 })
309 .collect();
310
311 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
313 for (line_eid, name, elevators) in &line_tag_info {
314 let tag = format!("line:{name}");
315 tags.tag(*line_eid, tag.clone());
316 for elev_eid in elevators {
317 tags.tag(*elev_eid, tag.clone());
318 }
319 }
320 }
321
322 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
324 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
325 if let Some(group_configs) = &config.building.groups {
326 for gc in group_configs {
327 if let Some(ref repo_id) = gc.reposition
328 && let Some(strategy) = repo_id.instantiate()
329 {
330 let gid = GroupId(gc.id);
331 repositioners.insert(gid, strategy);
332 reposition_ids.insert(gid, repo_id.clone());
333 }
334 }
335 }
336
337 Ok(Self {
338 world,
339 events: EventBus::default(),
340 pending_output: Vec::new(),
341 tick: 0,
342 dt,
343 groups,
344 stop_lookup,
345 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
346 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
347 metrics: Metrics::new(),
348 time: TimeAdapter::new(config.simulation.ticks_per_second),
349 hooks,
350 elevator_ids_buf: Vec::new(),
351 reposition_buf: Vec::new(),
352 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
353 topo_graph: Mutex::new(TopologyGraph::new()),
354 rider_index: RiderIndex::default(),
355 tick_in_progress: false,
356 })
357 }
358
359 fn spawn_elevator_entity(
365 world: &mut World,
366 ec: &crate::config::ElevatorConfig,
367 line: EntityId,
368 stop_lookup: &HashMap<StopId, EntityId>,
369 start_pos_lookup: &[crate::stop::StopConfig],
370 ) -> EntityId {
371 let eid = world.spawn();
372 let start_pos = start_pos_lookup
373 .iter()
374 .find(|s| s.id == ec.starting_stop)
375 .map_or(0.0, |s| s.position);
376 world.set_position(eid, Position { value: start_pos });
377 world.set_velocity(eid, Velocity { value: 0.0 });
378 let restricted: HashSet<EntityId> = ec
379 .restricted_stops
380 .iter()
381 .filter_map(|sid| stop_lookup.get(sid).copied())
382 .collect();
383 let is_loop = world.line(line).is_some_and(Line::is_loop);
389 world.set_elevator(
390 eid,
391 Elevator {
392 phase: ElevatorPhase::Idle,
393 door: DoorState::Closed,
394 max_speed: ec.max_speed,
395 acceleration: ec.acceleration,
396 deceleration: ec.deceleration,
397 weight_capacity: ec.weight_capacity,
398 current_load: crate::components::Weight::ZERO,
399 riders: Vec::new(),
400 target_stop: None,
401 door_transition_ticks: ec.door_transition_ticks,
402 door_open_ticks: ec.door_open_ticks,
403 line,
404 repositioning: false,
405 restricted_stops: restricted,
406 inspection_speed_factor: ec.inspection_speed_factor,
407 going_up: !is_loop,
408 going_down: !is_loop,
409 going_forward: is_loop,
410 move_count: 0,
411 door_command_queue: Vec::new(),
412 manual_target_velocity: None,
413 bypass_load_up_pct: ec.bypass_load_up_pct,
414 bypass_load_down_pct: ec.bypass_load_down_pct,
415 home_stop: None,
416 },
417 );
418 #[cfg(feature = "energy")]
419 if let Some(ref profile) = ec.energy_profile {
420 world.set_energy_profile(eid, profile.clone());
421 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
422 }
423 if let Some(mode) = ec.service_mode {
424 world.set_service_mode(eid, mode);
425 }
426 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
427 eid
428 }
429
430 fn build_legacy_topology(
432 world: &mut World,
433 config: &SimConfig,
434 stop_lookup: &HashMap<StopId, EntityId>,
435 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
436 ) -> TopologyResult {
437 let all_stop_entities: Vec<EntityId> = config
443 .building
444 .stops
445 .iter()
446 .filter_map(|s| stop_lookup.get(&s.id).copied())
447 .collect();
448 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
449 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
450 let max_pos = stop_positions
451 .iter()
452 .copied()
453 .fold(f64::NEG_INFINITY, f64::max);
454
455 let default_line_eid = world.spawn();
456 world.set_line(
457 default_line_eid,
458 Line {
459 name: "Default".into(),
460 group: GroupId(0),
461 orientation: Orientation::Vertical,
462 position: None,
463 kind: LineKind::Linear {
464 min: min_pos,
465 max: max_pos,
466 },
467 max_cars: None,
468 },
469 );
470
471 let mut elevator_entities = Vec::new();
472 for ec in &config.elevators {
473 let eid = Self::spawn_elevator_entity(
474 world,
475 ec,
476 default_line_eid,
477 stop_lookup,
478 &config.building.stops,
479 );
480 elevator_entities.push(eid);
481 }
482
483 let default_line_info =
484 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
485
486 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
487
488 let mut dispatchers = BTreeMap::new();
492 let mut strategy_ids = BTreeMap::new();
493 let user_dispatcher = builder_dispatchers
494 .into_iter()
495 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
496 let inferred_id = user_dispatcher
501 .as_ref()
502 .and_then(|d| d.builtin_id())
503 .unwrap_or(BuiltinStrategy::Scan);
504 if let Some(d) = user_dispatcher {
505 dispatchers.insert(GroupId(0), d);
506 } else {
507 dispatchers.insert(
508 GroupId(0),
509 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
510 );
511 }
512 strategy_ids.insert(GroupId(0), inferred_id);
513
514 (vec![group], dispatchers, strategy_ids)
515 }
516
517 #[allow(clippy::too_many_lines)]
519 fn build_explicit_topology(
520 world: &mut World,
521 config: &SimConfig,
522 line_configs: &[crate::config::LineConfig],
523 stop_lookup: &HashMap<StopId, EntityId>,
524 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
525 ) -> TopologyResult {
526 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
532
533 for lc in line_configs {
534 let served_entities: Vec<EntityId> = lc
536 .serves
537 .iter()
538 .filter_map(|sid| stop_lookup.get(sid).copied())
539 .collect();
540
541 let stop_positions: Vec<f64> = lc
543 .serves
544 .iter()
545 .filter_map(|sid| {
546 config
547 .building
548 .stops
549 .iter()
550 .find(|s| s.id == *sid)
551 .map(|s| s.position)
552 })
553 .collect();
554 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
555 let auto_max = stop_positions
556 .iter()
557 .copied()
558 .fold(f64::NEG_INFINITY, f64::max);
559
560 let min_pos = lc.min_position.unwrap_or(auto_min);
561 let max_pos = lc.max_position.unwrap_or(auto_max);
562
563 let line_eid = world.spawn();
564 world.set_line(
568 line_eid,
569 Line {
570 name: lc.name.clone(),
571 group: GroupId(0),
572 orientation: lc.orientation,
573 position: lc.position,
574 kind: lc.kind.unwrap_or(LineKind::Linear {
575 min: min_pos,
576 max: max_pos,
577 }),
578 max_cars: lc.max_cars,
579 },
580 );
581
582 let mut elevator_entities = Vec::new();
584 for ec in &lc.elevators {
585 let eid = Self::spawn_elevator_entity(
586 world,
587 ec,
588 line_eid,
589 stop_lookup,
590 &config.building.stops,
591 );
592 elevator_entities.push(eid);
593 }
594
595 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
596 line_map.insert(lc.id, (line_eid, line_info));
597 }
598
599 let group_configs = config.building.groups.as_deref();
601 let mut groups = Vec::new();
602 let mut dispatchers = BTreeMap::new();
603 let mut strategy_ids = BTreeMap::new();
604
605 if let Some(gcs) = group_configs {
606 for gc in gcs {
607 let group_id = GroupId(gc.id);
608
609 let mut group_lines = Vec::new();
610
611 for &lid in &gc.lines {
612 if let Some((line_eid, li)) = line_map.get(&lid) {
613 if let Some(line_comp) = world.line_mut(*line_eid) {
615 line_comp.group = group_id;
616 }
617 group_lines.push(li.clone());
618 }
619 }
620
621 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
622 if let Some(mode) = gc.hall_call_mode {
623 group.set_hall_call_mode(mode);
624 }
625 if let Some(ticks) = gc.ack_latency_ticks {
626 group.set_ack_latency_ticks(ticks);
627 }
628 groups.push(group);
629
630 let dispatch: Box<dyn DispatchStrategy> = gc
632 .dispatch
633 .instantiate()
634 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
635 dispatchers.insert(group_id, dispatch);
636 strategy_ids.insert(group_id, gc.dispatch.clone());
637 }
638 } else {
639 let group_id = GroupId(0);
641 let mut group_lines = Vec::new();
642
643 for (line_eid, li) in line_map.values() {
644 if let Some(line_comp) = world.line_mut(*line_eid) {
645 line_comp.group = group_id;
646 }
647 group_lines.push(li.clone());
648 }
649
650 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
651 groups.push(group);
652
653 let dispatch: Box<dyn DispatchStrategy> =
654 Box::new(crate::dispatch::scan::ScanDispatch::new());
655 dispatchers.insert(group_id, dispatch);
656 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
657 }
658
659 for (gid, d) in builder_dispatchers {
664 let inferred_id = d.builtin_id();
665 dispatchers.insert(gid, d);
666 match inferred_id {
667 Some(id) => {
668 strategy_ids.insert(gid, id);
669 }
670 None => {
671 strategy_ids
672 .entry(gid)
673 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
674 }
675 }
676 }
677
678 (groups, dispatchers, strategy_ids)
679 }
680
681 #[allow(clippy::too_many_arguments)]
683 pub(crate) fn from_parts(
684 world: World,
685 tick: u64,
686 dt: f64,
687 groups: Vec<ElevatorGroup>,
688 stop_lookup: HashMap<StopId, EntityId>,
689 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
690 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
691 metrics: Metrics,
692 ticks_per_second: f64,
693 ) -> Self {
694 let mut rider_index = RiderIndex::default();
695 rider_index.rebuild(&world);
696 let mut world = world;
702 world.insert_resource(crate::time::TickRate(ticks_per_second));
703 if world
704 .resource::<crate::traffic_detector::TrafficDetector>()
705 .is_none()
706 {
707 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
708 }
709 if world
714 .resource::<crate::arrival_log::DestinationLog>()
715 .is_none()
716 {
717 world.insert_resource(crate::arrival_log::DestinationLog::default());
718 }
719 world.register_ext::<crate::dispatch::destination::AssignedCar>(
734 crate::dispatch::destination::ASSIGNED_CAR_KEY,
735 );
736 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
737 let data = pending.0.clone();
738 world.deserialize_extensions(&data);
739 }
740 Self {
741 world,
742 events: EventBus::default(),
743 pending_output: Vec::new(),
744 tick,
745 dt,
746 groups,
747 stop_lookup,
748 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
749 repositioner_set: super::RepositionerSet::new(),
750 metrics,
751 time: TimeAdapter::new(ticks_per_second),
752 hooks: PhaseHooks::default(),
753 elevator_ids_buf: Vec::new(),
754 reposition_buf: Vec::new(),
755 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
756 topo_graph: Mutex::new(TopologyGraph::new()),
757 rider_index,
758 tick_in_progress: false,
759 }
760 }
761
762 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
764 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
771 return Err(SimError::InvalidConfig {
772 field: "schema_version",
773 reason: format!(
774 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
775 config.schema_version,
776 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
777 ),
778 });
779 }
780 if config.schema_version == 0 {
781 return Err(SimError::InvalidConfig {
782 field: "schema_version",
783 reason: format!(
784 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
785 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
786 ),
787 });
788 }
789
790 if config.building.stops.is_empty() {
791 return Err(SimError::InvalidConfig {
792 field: "building.stops",
793 reason: "at least one stop is required".into(),
794 });
795 }
796
797 let mut seen_ids = HashSet::new();
799 for stop in &config.building.stops {
800 if !seen_ids.insert(stop.id) {
801 return Err(SimError::InvalidConfig {
802 field: "building.stops",
803 reason: format!("duplicate {}", stop.id),
804 });
805 }
806 if !stop.position.is_finite() {
807 return Err(SimError::InvalidConfig {
808 field: "building.stops.position",
809 reason: format!("{} has non-finite position {}", stop.id, stop.position),
810 });
811 }
812 }
813
814 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
815
816 if let Some(line_configs) = &config.building.lines {
817 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
819 } else {
820 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
822 }
823
824 if !config.simulation.ticks_per_second.is_finite()
825 || config.simulation.ticks_per_second <= 0.0
826 {
827 return Err(SimError::InvalidConfig {
828 field: "simulation.ticks_per_second",
829 reason: format!(
830 "must be finite and positive, got {}",
831 config.simulation.ticks_per_second
832 ),
833 });
834 }
835
836 Self::validate_passenger_spawning(&config.passenger_spawning)?;
837
838 Ok(())
839 }
840
841 fn validate_passenger_spawning(
846 spawn: &crate::config::PassengerSpawnConfig,
847 ) -> Result<(), SimError> {
848 let (lo, hi) = spawn.weight_range;
849 if !lo.is_finite() || !hi.is_finite() {
850 return Err(SimError::InvalidConfig {
851 field: "passenger_spawning.weight_range",
852 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
853 });
854 }
855 if lo < 0.0 || hi < 0.0 {
856 return Err(SimError::InvalidConfig {
857 field: "passenger_spawning.weight_range",
858 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
859 });
860 }
861 if lo > hi {
862 return Err(SimError::InvalidConfig {
863 field: "passenger_spawning.weight_range",
864 reason: format!("min must be <= max, got ({lo}, {hi})"),
865 });
866 }
867 if spawn.mean_interval_ticks == 0 {
868 return Err(SimError::InvalidConfig {
869 field: "passenger_spawning.mean_interval_ticks",
870 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
871 every catch-up tick"
872 .into(),
873 });
874 }
875 Ok(())
876 }
877
878 fn validate_legacy_elevators(
880 elevators: &[crate::config::ElevatorConfig],
881 building: &crate::config::BuildingConfig,
882 ) -> Result<(), SimError> {
883 if elevators.is_empty() {
884 return Err(SimError::InvalidConfig {
885 field: "elevators",
886 reason: "at least one elevator is required".into(),
887 });
888 }
889
890 for elev in elevators {
891 Self::validate_elevator_config(elev, building)?;
892 }
893
894 Ok(())
895 }
896
897 fn validate_elevator_config(
899 elev: &crate::config::ElevatorConfig,
900 building: &crate::config::BuildingConfig,
901 ) -> Result<(), SimError> {
902 validate_elevator_physics(
903 elev.max_speed.value(),
904 elev.acceleration.value(),
905 elev.deceleration.value(),
906 elev.weight_capacity.value(),
907 elev.inspection_speed_factor,
908 elev.door_transition_ticks,
909 elev.door_open_ticks,
910 elev.bypass_load_up_pct,
911 elev.bypass_load_down_pct,
912 )?;
913 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
914 return Err(SimError::InvalidConfig {
915 field: "elevators.starting_stop",
916 reason: format!("references non-existent {}", elev.starting_stop),
917 });
918 }
919 Ok(())
920 }
921
922 #[allow(
924 clippy::too_many_lines,
925 reason = "validation reads top-to-bottom; extracting helpers would scatter related rejections across files"
926 )]
927 fn validate_explicit_topology(
928 line_configs: &[crate::config::LineConfig],
929 stop_ids: &HashSet<StopId>,
930 building: &crate::config::BuildingConfig,
931 ) -> Result<(), SimError> {
932 let mut seen_line_ids = HashSet::new();
934 for lc in line_configs {
935 if !seen_line_ids.insert(lc.id) {
936 return Err(SimError::InvalidConfig {
937 field: "building.lines",
938 reason: format!("duplicate line id {}", lc.id),
939 });
940 }
941 }
942
943 for lc in line_configs {
945 if lc.serves.is_empty() {
946 return Err(SimError::InvalidConfig {
947 field: "building.lines.serves",
948 reason: format!("line {} has no stops", lc.id),
949 });
950 }
951 for sid in &lc.serves {
952 if !stop_ids.contains(sid) {
953 return Err(SimError::InvalidConfig {
954 field: "building.lines.serves",
955 reason: format!("line {} references non-existent {}", lc.id, sid),
956 });
957 }
958 }
959 for ec in &lc.elevators {
961 Self::validate_elevator_config(ec, building)?;
962 }
963
964 if let Some(max) = lc.max_cars
966 && lc.elevators.len() > max
967 {
968 return Err(SimError::InvalidConfig {
969 field: "building.lines.max_cars",
970 reason: format!(
971 "line {} has {} elevators but max_cars is {max}",
972 lc.id,
973 lc.elevators.len()
974 ),
975 });
976 }
977
978 if let Some(kind) = lc.kind
982 && let Err((field, reason)) = kind.validate()
983 {
984 return Err(SimError::InvalidConfig { field, reason });
985 }
986
987 #[cfg(feature = "loop_lines")]
994 if let Some(crate::components::LineKind::Loop {
995 circumference,
996 min_headway,
997 }) = lc.kind
998 {
999 let car_count = lc
1000 .max_cars
1001 .map_or_else(|| lc.elevators.len(), |max| max.max(lc.elevators.len()));
1002 if car_count > 0 {
1003 #[allow(
1004 clippy::cast_precision_loss,
1005 reason = "car_count is bounded by usize and the comparison is against a finite f64"
1006 )]
1007 let required = (car_count as f64) * min_headway;
1008 if required > circumference {
1009 return Err(SimError::InvalidConfig {
1010 field: "building.lines.kind",
1011 reason: format!(
1012 "loop line {}: {car_count} cars × min_headway {min_headway} = {required} \
1013 exceeds circumference {circumference}",
1014 lc.id,
1015 ),
1016 });
1017 }
1018 }
1019 }
1020 }
1021
1022 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
1024 if !has_elevator {
1025 return Err(SimError::InvalidConfig {
1026 field: "building.lines",
1027 reason: "at least one line must have at least one elevator".into(),
1028 });
1029 }
1030
1031 let served: HashSet<StopId> = line_configs
1033 .iter()
1034 .flat_map(|lc| lc.serves.iter().copied())
1035 .collect();
1036 for sid in stop_ids {
1037 if !served.contains(sid) {
1038 return Err(SimError::InvalidConfig {
1039 field: "building.lines",
1040 reason: format!("orphaned stop {sid} not served by any line"),
1041 });
1042 }
1043 }
1044
1045 if let Some(group_configs) = &building.groups {
1047 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
1048
1049 let mut seen_group_ids = HashSet::new();
1050 for gc in group_configs {
1051 if !seen_group_ids.insert(gc.id) {
1052 return Err(SimError::InvalidConfig {
1053 field: "building.groups",
1054 reason: format!("duplicate group id {}", gc.id),
1055 });
1056 }
1057 for &lid in &gc.lines {
1058 if !line_id_set.contains(&lid) {
1059 return Err(SimError::InvalidConfig {
1060 field: "building.groups.lines",
1061 reason: format!(
1062 "group {} references non-existent line id {}",
1063 gc.id, lid
1064 ),
1065 });
1066 }
1067 }
1068 }
1069
1070 let referenced_line_ids: HashSet<u32> = group_configs
1072 .iter()
1073 .flat_map(|g| g.lines.iter().copied())
1074 .collect();
1075 for lc in line_configs {
1076 if !referenced_line_ids.contains(&lc.id) {
1077 return Err(SimError::InvalidConfig {
1078 field: "building.lines",
1079 reason: format!("line {} is not assigned to any group", lc.id),
1080 });
1081 }
1082 }
1083
1084 #[cfg(feature = "loop_lines")]
1090 for gc in group_configs {
1091 let lines: Vec<&crate::config::LineConfig> = gc
1092 .lines
1093 .iter()
1094 .filter_map(|lid| line_configs.iter().find(|lc| lc.id == *lid))
1095 .collect();
1096 let any_loop = lines
1097 .iter()
1098 .any(|lc| matches!(lc.kind, Some(crate::components::LineKind::Loop { .. })));
1099 let any_linear = lines
1107 .iter()
1108 .any(|lc| lc.kind.as_ref().is_none_or(LineKind::is_linear));
1109 if any_loop && any_linear {
1114 return Err(SimError::InvalidConfig {
1115 field: "building.groups",
1116 reason: format!(
1117 "group {} mixes Loop and Linear lines; groups must be homogeneous",
1118 gc.id,
1119 ),
1120 });
1121 }
1122 if any_loop && gc.reposition.is_some() {
1127 return Err(SimError::InvalidConfig {
1128 field: "building.groups.reposition",
1129 reason: format!(
1130 "group {} contains Loop lines; reposition strategies are unsupported on Loop",
1131 gc.id,
1132 ),
1133 });
1134 }
1135 if any_loop
1143 && !matches!(
1144 gc.dispatch,
1145 BuiltinStrategy::LoopSweep | BuiltinStrategy::LoopSchedule,
1146 )
1147 {
1148 return Err(SimError::InvalidConfig {
1149 field: "building.groups.dispatch",
1150 reason: format!(
1151 "group {} contains Loop lines but uses {} dispatch; \
1152 only LoopSweep or LoopSchedule is supported for Loop groups",
1153 gc.id, gc.dispatch,
1154 ),
1155 });
1156 }
1157 }
1158 }
1159
1160 #[cfg(feature = "loop_lines")]
1163 for lc in line_configs {
1164 let Some(crate::components::LineKind::Loop {
1165 circumference,
1166 min_headway,
1167 }) = lc.kind
1168 else {
1169 continue;
1170 };
1171
1172 let stop_positions: Vec<f64> = lc
1177 .serves
1178 .iter()
1179 .filter_map(|sid| {
1180 building
1181 .stops
1182 .iter()
1183 .find(|s| s.id == *sid)
1184 .map(|s| s.position)
1185 })
1186 .collect();
1187 for (i, &pi) in stop_positions.iter().enumerate() {
1188 for (j, &pj) in stop_positions.iter().enumerate().skip(i + 1) {
1189 if (pi - pj).abs() < 1e-9 {
1190 return Err(SimError::InvalidConfig {
1191 field: "building.lines.serves",
1192 reason: format!(
1193 "loop line {} has duplicate stop positions at indices {i} and {j} (both at {pi})",
1194 lc.id,
1195 ),
1196 });
1197 }
1198 }
1199 }
1200
1201 let car_starts: Vec<f64> = lc
1207 .elevators
1208 .iter()
1209 .filter_map(|ec| {
1210 building
1211 .stops
1212 .iter()
1213 .find(|s| s.id == ec.starting_stop)
1214 .map(|s| s.position)
1215 })
1216 .collect();
1217 for (i, &a) in car_starts.iter().enumerate() {
1218 for (j, &b) in car_starts.iter().enumerate().skip(i + 1) {
1219 let d = crate::components::cyclic::cyclic_distance(a, b, circumference);
1220 if d < min_headway - 1e-9 {
1221 return Err(SimError::InvalidConfig {
1222 field: "building.lines.elevators.starting_stop",
1223 reason: format!(
1224 "loop line {}: cars at indices {i} ({a}) and {j} ({b}) are {d} apart \
1225 — below min_headway {min_headway}",
1226 lc.id,
1227 ),
1228 });
1229 }
1230 }
1231 }
1232 }
1233
1234 Ok(())
1235 }
1236
1237 pub fn set_dispatch(
1253 &mut self,
1254 group: GroupId,
1255 strategy: Box<dyn DispatchStrategy>,
1256 id: crate::dispatch::BuiltinStrategy,
1257 ) {
1258 let resolved_id = strategy.builtin_id().unwrap_or(id);
1259 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1260 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1261 {
1262 g.set_hall_call_mode(mode);
1263 }
1264 self.dispatcher_set.insert(group, strategy, resolved_id);
1265 }
1266
1267 pub fn set_reposition(
1298 &mut self,
1299 group: GroupId,
1300 strategy: Box<dyn RepositionStrategy>,
1301 id: BuiltinReposition,
1302 ) {
1303 let resolved_id = strategy.builtin_id().unwrap_or(id);
1304 let needed_window = strategy.min_arrival_log_window();
1305 self.repositioner_set.insert(group, strategy, resolved_id);
1306 if needed_window > 0
1312 && let Some(retention) = self
1313 .world
1314 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1315 && needed_window > retention.0
1316 {
1317 retention.0 = needed_window;
1318 }
1319 }
1320
1321 pub fn remove_reposition(&mut self, group: GroupId) {
1332 self.repositioner_set.remove(group);
1333 }
1334
1335 #[must_use]
1337 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1338 self.repositioner_set.id_for(group)
1339 }
1340
1341 pub fn add_before_hook(
1348 &mut self,
1349 phase: Phase,
1350 hook: impl Fn(&mut World) + Send + Sync + 'static,
1351 ) {
1352 self.hooks.add_before(phase, Box::new(hook));
1353 }
1354
1355 pub fn add_after_hook(
1360 &mut self,
1361 phase: Phase,
1362 hook: impl Fn(&mut World) + Send + Sync + 'static,
1363 ) {
1364 self.hooks.add_after(phase, Box::new(hook));
1365 }
1366
1367 pub fn add_before_group_hook(
1369 &mut self,
1370 phase: Phase,
1371 group: GroupId,
1372 hook: impl Fn(&mut World) + Send + Sync + 'static,
1373 ) {
1374 self.hooks.add_before_group(phase, group, Box::new(hook));
1375 }
1376
1377 pub fn add_after_group_hook(
1379 &mut self,
1380 phase: Phase,
1381 group: GroupId,
1382 hook: impl Fn(&mut World) + Send + Sync + 'static,
1383 ) {
1384 self.hooks.add_after_group(phase, group, Box::new(hook));
1385 }
1386}