1use std::collections::{BTreeMap, HashMap, HashSet};
15use std::sync::Mutex;
16
17use crate::components::{Elevator, ElevatorPhase, Line, Orientation, Position, Stop, Velocity};
18use crate::config::SimConfig;
19use crate::dispatch::{
20 BuiltinReposition, BuiltinStrategy, DispatchStrategy, ElevatorGroup, LineInfo,
21 RepositionStrategy,
22};
23use crate::door::DoorState;
24use crate::entity::EntityId;
25use crate::error::SimError;
26use crate::events::EventBus;
27use crate::hooks::{Phase, PhaseHooks};
28use crate::ids::GroupId;
29use crate::metrics::Metrics;
30use crate::rider_index::RiderIndex;
31use crate::stop::StopId;
32use crate::time::TimeAdapter;
33use crate::topology::TopologyGraph;
34use crate::world::World;
35
36use super::Simulation;
37
38type TopologyResult = (
40 Vec<ElevatorGroup>,
41 BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
42 BTreeMap<GroupId, BuiltinStrategy>,
43);
44
45pub(super) const fn canonical_hall_call_mode(
57 strategy: &BuiltinStrategy,
58) -> Option<crate::dispatch::HallCallMode> {
59 match strategy {
60 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
61 BuiltinStrategy::Custom(_) => None,
62 BuiltinStrategy::Scan
63 | BuiltinStrategy::Look
64 | BuiltinStrategy::NearestCar
65 | BuiltinStrategy::Etd
66 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
67 }
68}
69
70fn sync_hall_call_modes(
79 groups: &mut [ElevatorGroup],
80 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
81) {
82 for group in groups.iter_mut() {
83 if let Some(strategy) = strategy_ids.get(&group.id())
84 && canonical_hall_call_mode(strategy)
85 == Some(crate::dispatch::HallCallMode::Destination)
86 {
87 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
88 }
89 }
90}
91
92#[allow(clippy::too_many_arguments)]
98pub(super) fn validate_elevator_physics(
99 max_speed: f64,
100 acceleration: f64,
101 deceleration: f64,
102 weight_capacity: f64,
103 inspection_speed_factor: f64,
104 door_transition_ticks: u32,
105 door_open_ticks: u32,
106 bypass_load_up_pct: Option<f64>,
107 bypass_load_down_pct: Option<f64>,
108) -> Result<(), SimError> {
109 if !max_speed.is_finite() || max_speed <= 0.0 {
110 return Err(SimError::InvalidConfig {
111 field: "elevators.max_speed",
112 reason: format!("must be finite and positive, got {max_speed}"),
113 });
114 }
115 if !acceleration.is_finite() || acceleration <= 0.0 {
116 return Err(SimError::InvalidConfig {
117 field: "elevators.acceleration",
118 reason: format!("must be finite and positive, got {acceleration}"),
119 });
120 }
121 if !deceleration.is_finite() || deceleration <= 0.0 {
122 return Err(SimError::InvalidConfig {
123 field: "elevators.deceleration",
124 reason: format!("must be finite and positive, got {deceleration}"),
125 });
126 }
127 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
128 return Err(SimError::InvalidConfig {
129 field: "elevators.weight_capacity",
130 reason: format!("must be finite and positive, got {weight_capacity}"),
131 });
132 }
133 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
134 return Err(SimError::InvalidConfig {
135 field: "elevators.inspection_speed_factor",
136 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
137 });
138 }
139 if door_transition_ticks == 0 {
140 return Err(SimError::InvalidConfig {
141 field: "elevators.door_transition_ticks",
142 reason: "must be > 0".into(),
143 });
144 }
145 if door_open_ticks == 0 {
146 return Err(SimError::InvalidConfig {
147 field: "elevators.door_open_ticks",
148 reason: "must be > 0".into(),
149 });
150 }
151 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
152 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
153 Ok(())
154}
155
156fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
161 let Some(pct) = pct else {
162 return Ok(());
163 };
164 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
165 return Err(SimError::InvalidConfig {
166 field,
167 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
168 });
169 }
170 Ok(())
171}
172
173impl Simulation {
174 pub fn new(
185 config: &SimConfig,
186 dispatch: impl DispatchStrategy + 'static,
187 ) -> Result<Self, SimError> {
188 let mut dispatchers = BTreeMap::new();
189 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
190 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
191 }
192
193 #[allow(clippy::too_many_lines)]
197 pub(crate) fn new_with_hooks(
198 config: &SimConfig,
199 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
200 hooks: PhaseHooks,
201 ) -> Result<Self, SimError> {
202 Self::validate_config(config)?;
203
204 let mut world = World::new();
205
206 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
208 for sc in &config.building.stops {
209 let eid = world.spawn();
210 world.set_stop(
211 eid,
212 Stop {
213 name: sc.name.clone(),
214 position: sc.position,
215 },
216 );
217 world.set_position(eid, Position { value: sc.position });
218 stop_lookup.insert(sc.id, eid);
219 }
220
221 let mut sorted: Vec<(f64, EntityId)> = world
223 .iter_stops()
224 .map(|(eid, stop)| (stop.position, eid))
225 .collect();
226 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
227 world.insert_resource(crate::world::SortedStops(sorted));
228
229 world.insert_resource(crate::arrival_log::ArrivalLog::default());
239 world.insert_resource(crate::arrival_log::DestinationLog::default());
240 world.insert_resource(crate::arrival_log::CurrentTick::default());
241 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
242 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
246 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
252 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
258
259 let (mut groups, dispatchers, strategy_ids) =
260 if let Some(line_configs) = &config.building.lines {
261 Self::build_explicit_topology(
262 &mut world,
263 config,
264 line_configs,
265 &stop_lookup,
266 builder_dispatchers,
267 )
268 } else {
269 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
270 };
271 sync_hall_call_modes(&mut groups, &strategy_ids);
272
273 let dt = 1.0 / config.simulation.ticks_per_second;
274
275 world.insert_resource(crate::tagged_metrics::MetricTags::default());
276
277 world.register_ext::<crate::dispatch::destination::AssignedCar>(
286 crate::dispatch::destination::ASSIGNED_CAR_KEY,
287 );
288
289 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
292 .iter()
293 .flat_map(|group| {
294 group.lines().iter().filter_map(|li| {
295 let line_comp = world.line(li.entity())?;
296 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
297 })
298 })
299 .collect();
300
301 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
303 for (line_eid, name, elevators) in &line_tag_info {
304 let tag = format!("line:{name}");
305 tags.tag(*line_eid, tag.clone());
306 for elev_eid in elevators {
307 tags.tag(*elev_eid, tag.clone());
308 }
309 }
310 }
311
312 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
314 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
315 if let Some(group_configs) = &config.building.groups {
316 for gc in group_configs {
317 if let Some(ref repo_id) = gc.reposition
318 && let Some(strategy) = repo_id.instantiate()
319 {
320 let gid = GroupId(gc.id);
321 repositioners.insert(gid, strategy);
322 reposition_ids.insert(gid, repo_id.clone());
323 }
324 }
325 }
326
327 Ok(Self {
328 world,
329 events: EventBus::default(),
330 pending_output: Vec::new(),
331 tick: 0,
332 dt,
333 groups,
334 stop_lookup,
335 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
336 repositioner_set: super::RepositionerSet::from_parts(repositioners, reposition_ids),
337 metrics: Metrics::new(),
338 time: TimeAdapter::new(config.simulation.ticks_per_second),
339 hooks,
340 elevator_ids_buf: Vec::new(),
341 reposition_buf: Vec::new(),
342 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
343 topo_graph: Mutex::new(TopologyGraph::new()),
344 rider_index: RiderIndex::default(),
345 tick_in_progress: false,
346 })
347 }
348
349 fn spawn_elevator_entity(
355 world: &mut World,
356 ec: &crate::config::ElevatorConfig,
357 line: EntityId,
358 stop_lookup: &HashMap<StopId, EntityId>,
359 start_pos_lookup: &[crate::stop::StopConfig],
360 ) -> EntityId {
361 let eid = world.spawn();
362 let start_pos = start_pos_lookup
363 .iter()
364 .find(|s| s.id == ec.starting_stop)
365 .map_or(0.0, |s| s.position);
366 world.set_position(eid, Position { value: start_pos });
367 world.set_velocity(eid, Velocity { value: 0.0 });
368 let restricted: HashSet<EntityId> = ec
369 .restricted_stops
370 .iter()
371 .filter_map(|sid| stop_lookup.get(sid).copied())
372 .collect();
373 world.set_elevator(
374 eid,
375 Elevator {
376 phase: ElevatorPhase::Idle,
377 door: DoorState::Closed,
378 max_speed: ec.max_speed,
379 acceleration: ec.acceleration,
380 deceleration: ec.deceleration,
381 weight_capacity: ec.weight_capacity,
382 current_load: crate::components::Weight::ZERO,
383 riders: Vec::new(),
384 target_stop: None,
385 door_transition_ticks: ec.door_transition_ticks,
386 door_open_ticks: ec.door_open_ticks,
387 line,
388 repositioning: false,
389 restricted_stops: restricted,
390 inspection_speed_factor: ec.inspection_speed_factor,
391 going_up: true,
392 going_down: true,
393 move_count: 0,
394 door_command_queue: Vec::new(),
395 manual_target_velocity: None,
396 bypass_load_up_pct: ec.bypass_load_up_pct,
397 bypass_load_down_pct: ec.bypass_load_down_pct,
398 home_stop: None,
399 },
400 );
401 #[cfg(feature = "energy")]
402 if let Some(ref profile) = ec.energy_profile {
403 world.set_energy_profile(eid, profile.clone());
404 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
405 }
406 if let Some(mode) = ec.service_mode {
407 world.set_service_mode(eid, mode);
408 }
409 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
410 eid
411 }
412
413 fn build_legacy_topology(
415 world: &mut World,
416 config: &SimConfig,
417 stop_lookup: &HashMap<StopId, EntityId>,
418 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
419 ) -> TopologyResult {
420 let all_stop_entities: Vec<EntityId> = config
426 .building
427 .stops
428 .iter()
429 .filter_map(|s| stop_lookup.get(&s.id).copied())
430 .collect();
431 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
432 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
433 let max_pos = stop_positions
434 .iter()
435 .copied()
436 .fold(f64::NEG_INFINITY, f64::max);
437
438 let default_line_eid = world.spawn();
439 world.set_line(
440 default_line_eid,
441 Line {
442 name: "Default".into(),
443 group: GroupId(0),
444 orientation: Orientation::Vertical,
445 position: None,
446 min_position: min_pos,
447 max_position: max_pos,
448 max_cars: None,
449 },
450 );
451
452 let mut elevator_entities = Vec::new();
453 for ec in &config.elevators {
454 let eid = Self::spawn_elevator_entity(
455 world,
456 ec,
457 default_line_eid,
458 stop_lookup,
459 &config.building.stops,
460 );
461 elevator_entities.push(eid);
462 }
463
464 let default_line_info =
465 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
466
467 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
468
469 let mut dispatchers = BTreeMap::new();
473 let mut strategy_ids = BTreeMap::new();
474 let user_dispatcher = builder_dispatchers
475 .into_iter()
476 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
477 let inferred_id = user_dispatcher
482 .as_ref()
483 .and_then(|d| d.builtin_id())
484 .unwrap_or(BuiltinStrategy::Scan);
485 if let Some(d) = user_dispatcher {
486 dispatchers.insert(GroupId(0), d);
487 } else {
488 dispatchers.insert(
489 GroupId(0),
490 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
491 );
492 }
493 strategy_ids.insert(GroupId(0), inferred_id);
494
495 (vec![group], dispatchers, strategy_ids)
496 }
497
498 #[allow(clippy::too_many_lines)]
500 fn build_explicit_topology(
501 world: &mut World,
502 config: &SimConfig,
503 line_configs: &[crate::config::LineConfig],
504 stop_lookup: &HashMap<StopId, EntityId>,
505 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
506 ) -> TopologyResult {
507 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
513
514 for lc in line_configs {
515 let served_entities: Vec<EntityId> = lc
517 .serves
518 .iter()
519 .filter_map(|sid| stop_lookup.get(sid).copied())
520 .collect();
521
522 let stop_positions: Vec<f64> = lc
524 .serves
525 .iter()
526 .filter_map(|sid| {
527 config
528 .building
529 .stops
530 .iter()
531 .find(|s| s.id == *sid)
532 .map(|s| s.position)
533 })
534 .collect();
535 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
536 let auto_max = stop_positions
537 .iter()
538 .copied()
539 .fold(f64::NEG_INFINITY, f64::max);
540
541 let min_pos = lc.min_position.unwrap_or(auto_min);
542 let max_pos = lc.max_position.unwrap_or(auto_max);
543
544 let line_eid = world.spawn();
545 world.set_line(
548 line_eid,
549 Line {
550 name: lc.name.clone(),
551 group: GroupId(0),
552 orientation: lc.orientation,
553 position: lc.position,
554 min_position: min_pos,
555 max_position: max_pos,
556 max_cars: lc.max_cars,
557 },
558 );
559
560 let mut elevator_entities = Vec::new();
562 for ec in &lc.elevators {
563 let eid = Self::spawn_elevator_entity(
564 world,
565 ec,
566 line_eid,
567 stop_lookup,
568 &config.building.stops,
569 );
570 elevator_entities.push(eid);
571 }
572
573 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
574 line_map.insert(lc.id, (line_eid, line_info));
575 }
576
577 let group_configs = config.building.groups.as_deref();
579 let mut groups = Vec::new();
580 let mut dispatchers = BTreeMap::new();
581 let mut strategy_ids = BTreeMap::new();
582
583 if let Some(gcs) = group_configs {
584 for gc in gcs {
585 let group_id = GroupId(gc.id);
586
587 let mut group_lines = Vec::new();
588
589 for &lid in &gc.lines {
590 if let Some((line_eid, li)) = line_map.get(&lid) {
591 if let Some(line_comp) = world.line_mut(*line_eid) {
593 line_comp.group = group_id;
594 }
595 group_lines.push(li.clone());
596 }
597 }
598
599 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
600 if let Some(mode) = gc.hall_call_mode {
601 group.set_hall_call_mode(mode);
602 }
603 if let Some(ticks) = gc.ack_latency_ticks {
604 group.set_ack_latency_ticks(ticks);
605 }
606 groups.push(group);
607
608 let dispatch: Box<dyn DispatchStrategy> = gc
610 .dispatch
611 .instantiate()
612 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
613 dispatchers.insert(group_id, dispatch);
614 strategy_ids.insert(group_id, gc.dispatch.clone());
615 }
616 } else {
617 let group_id = GroupId(0);
619 let mut group_lines = Vec::new();
620
621 for (line_eid, li) in line_map.values() {
622 if let Some(line_comp) = world.line_mut(*line_eid) {
623 line_comp.group = group_id;
624 }
625 group_lines.push(li.clone());
626 }
627
628 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
629 groups.push(group);
630
631 let dispatch: Box<dyn DispatchStrategy> =
632 Box::new(crate::dispatch::scan::ScanDispatch::new());
633 dispatchers.insert(group_id, dispatch);
634 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
635 }
636
637 for (gid, d) in builder_dispatchers {
642 let inferred_id = d.builtin_id();
643 dispatchers.insert(gid, d);
644 match inferred_id {
645 Some(id) => {
646 strategy_ids.insert(gid, id);
647 }
648 None => {
649 strategy_ids
650 .entry(gid)
651 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
652 }
653 }
654 }
655
656 (groups, dispatchers, strategy_ids)
657 }
658
659 #[allow(clippy::too_many_arguments)]
661 pub(crate) fn from_parts(
662 world: World,
663 tick: u64,
664 dt: f64,
665 groups: Vec<ElevatorGroup>,
666 stop_lookup: HashMap<StopId, EntityId>,
667 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
668 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
669 metrics: Metrics,
670 ticks_per_second: f64,
671 ) -> Self {
672 let mut rider_index = RiderIndex::default();
673 rider_index.rebuild(&world);
674 let mut world = world;
680 world.insert_resource(crate::time::TickRate(ticks_per_second));
681 if world
682 .resource::<crate::traffic_detector::TrafficDetector>()
683 .is_none()
684 {
685 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
686 }
687 if world
692 .resource::<crate::arrival_log::DestinationLog>()
693 .is_none()
694 {
695 world.insert_resource(crate::arrival_log::DestinationLog::default());
696 }
697 world.register_ext::<crate::dispatch::destination::AssignedCar>(
712 crate::dispatch::destination::ASSIGNED_CAR_KEY,
713 );
714 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
715 let data = pending.0.clone();
716 world.deserialize_extensions(&data);
717 }
718 Self {
719 world,
720 events: EventBus::default(),
721 pending_output: Vec::new(),
722 tick,
723 dt,
724 groups,
725 stop_lookup,
726 dispatcher_set: super::DispatcherSet::from_parts(dispatchers, strategy_ids),
727 repositioner_set: super::RepositionerSet::new(),
728 metrics,
729 time: TimeAdapter::new(ticks_per_second),
730 hooks: PhaseHooks::default(),
731 elevator_ids_buf: Vec::new(),
732 reposition_buf: Vec::new(),
733 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
734 topo_graph: Mutex::new(TopologyGraph::new()),
735 rider_index,
736 tick_in_progress: false,
737 }
738 }
739
740 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
742 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
749 return Err(SimError::InvalidConfig {
750 field: "schema_version",
751 reason: format!(
752 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
753 config.schema_version,
754 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
755 ),
756 });
757 }
758 if config.schema_version == 0 {
759 return Err(SimError::InvalidConfig {
760 field: "schema_version",
761 reason: format!(
762 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
763 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
764 ),
765 });
766 }
767
768 if config.building.stops.is_empty() {
769 return Err(SimError::InvalidConfig {
770 field: "building.stops",
771 reason: "at least one stop is required".into(),
772 });
773 }
774
775 let mut seen_ids = HashSet::new();
777 for stop in &config.building.stops {
778 if !seen_ids.insert(stop.id) {
779 return Err(SimError::InvalidConfig {
780 field: "building.stops",
781 reason: format!("duplicate {}", stop.id),
782 });
783 }
784 if !stop.position.is_finite() {
785 return Err(SimError::InvalidConfig {
786 field: "building.stops.position",
787 reason: format!("{} has non-finite position {}", stop.id, stop.position),
788 });
789 }
790 }
791
792 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
793
794 if let Some(line_configs) = &config.building.lines {
795 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
797 } else {
798 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
800 }
801
802 if !config.simulation.ticks_per_second.is_finite()
803 || config.simulation.ticks_per_second <= 0.0
804 {
805 return Err(SimError::InvalidConfig {
806 field: "simulation.ticks_per_second",
807 reason: format!(
808 "must be finite and positive, got {}",
809 config.simulation.ticks_per_second
810 ),
811 });
812 }
813
814 Self::validate_passenger_spawning(&config.passenger_spawning)?;
815
816 Ok(())
817 }
818
819 fn validate_passenger_spawning(
824 spawn: &crate::config::PassengerSpawnConfig,
825 ) -> Result<(), SimError> {
826 let (lo, hi) = spawn.weight_range;
827 if !lo.is_finite() || !hi.is_finite() {
828 return Err(SimError::InvalidConfig {
829 field: "passenger_spawning.weight_range",
830 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
831 });
832 }
833 if lo < 0.0 || hi < 0.0 {
834 return Err(SimError::InvalidConfig {
835 field: "passenger_spawning.weight_range",
836 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
837 });
838 }
839 if lo > hi {
840 return Err(SimError::InvalidConfig {
841 field: "passenger_spawning.weight_range",
842 reason: format!("min must be <= max, got ({lo}, {hi})"),
843 });
844 }
845 if spawn.mean_interval_ticks == 0 {
846 return Err(SimError::InvalidConfig {
847 field: "passenger_spawning.mean_interval_ticks",
848 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
849 every catch-up tick"
850 .into(),
851 });
852 }
853 Ok(())
854 }
855
856 fn validate_legacy_elevators(
858 elevators: &[crate::config::ElevatorConfig],
859 building: &crate::config::BuildingConfig,
860 ) -> Result<(), SimError> {
861 if elevators.is_empty() {
862 return Err(SimError::InvalidConfig {
863 field: "elevators",
864 reason: "at least one elevator is required".into(),
865 });
866 }
867
868 for elev in elevators {
869 Self::validate_elevator_config(elev, building)?;
870 }
871
872 Ok(())
873 }
874
875 fn validate_elevator_config(
877 elev: &crate::config::ElevatorConfig,
878 building: &crate::config::BuildingConfig,
879 ) -> Result<(), SimError> {
880 validate_elevator_physics(
881 elev.max_speed.value(),
882 elev.acceleration.value(),
883 elev.deceleration.value(),
884 elev.weight_capacity.value(),
885 elev.inspection_speed_factor,
886 elev.door_transition_ticks,
887 elev.door_open_ticks,
888 elev.bypass_load_up_pct,
889 elev.bypass_load_down_pct,
890 )?;
891 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
892 return Err(SimError::InvalidConfig {
893 field: "elevators.starting_stop",
894 reason: format!("references non-existent {}", elev.starting_stop),
895 });
896 }
897 Ok(())
898 }
899
900 fn validate_explicit_topology(
902 line_configs: &[crate::config::LineConfig],
903 stop_ids: &HashSet<StopId>,
904 building: &crate::config::BuildingConfig,
905 ) -> Result<(), SimError> {
906 let mut seen_line_ids = HashSet::new();
908 for lc in line_configs {
909 if !seen_line_ids.insert(lc.id) {
910 return Err(SimError::InvalidConfig {
911 field: "building.lines",
912 reason: format!("duplicate line id {}", lc.id),
913 });
914 }
915 }
916
917 for lc in line_configs {
919 if lc.serves.is_empty() {
920 return Err(SimError::InvalidConfig {
921 field: "building.lines.serves",
922 reason: format!("line {} has no stops", lc.id),
923 });
924 }
925 for sid in &lc.serves {
926 if !stop_ids.contains(sid) {
927 return Err(SimError::InvalidConfig {
928 field: "building.lines.serves",
929 reason: format!("line {} references non-existent {}", lc.id, sid),
930 });
931 }
932 }
933 for ec in &lc.elevators {
935 Self::validate_elevator_config(ec, building)?;
936 }
937
938 if let Some(max) = lc.max_cars
940 && lc.elevators.len() > max
941 {
942 return Err(SimError::InvalidConfig {
943 field: "building.lines.max_cars",
944 reason: format!(
945 "line {} has {} elevators but max_cars is {max}",
946 lc.id,
947 lc.elevators.len()
948 ),
949 });
950 }
951 }
952
953 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
955 if !has_elevator {
956 return Err(SimError::InvalidConfig {
957 field: "building.lines",
958 reason: "at least one line must have at least one elevator".into(),
959 });
960 }
961
962 let served: HashSet<StopId> = line_configs
964 .iter()
965 .flat_map(|lc| lc.serves.iter().copied())
966 .collect();
967 for sid in stop_ids {
968 if !served.contains(sid) {
969 return Err(SimError::InvalidConfig {
970 field: "building.lines",
971 reason: format!("orphaned stop {sid} not served by any line"),
972 });
973 }
974 }
975
976 if let Some(group_configs) = &building.groups {
978 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
979
980 let mut seen_group_ids = HashSet::new();
981 for gc in group_configs {
982 if !seen_group_ids.insert(gc.id) {
983 return Err(SimError::InvalidConfig {
984 field: "building.groups",
985 reason: format!("duplicate group id {}", gc.id),
986 });
987 }
988 for &lid in &gc.lines {
989 if !line_id_set.contains(&lid) {
990 return Err(SimError::InvalidConfig {
991 field: "building.groups.lines",
992 reason: format!(
993 "group {} references non-existent line id {}",
994 gc.id, lid
995 ),
996 });
997 }
998 }
999 }
1000
1001 let referenced_line_ids: HashSet<u32> = group_configs
1003 .iter()
1004 .flat_map(|g| g.lines.iter().copied())
1005 .collect();
1006 for lc in line_configs {
1007 if !referenced_line_ids.contains(&lc.id) {
1008 return Err(SimError::InvalidConfig {
1009 field: "building.lines",
1010 reason: format!("line {} is not assigned to any group", lc.id),
1011 });
1012 }
1013 }
1014 }
1015
1016 Ok(())
1017 }
1018
1019 pub fn set_dispatch(
1035 &mut self,
1036 group: GroupId,
1037 strategy: Box<dyn DispatchStrategy>,
1038 id: crate::dispatch::BuiltinStrategy,
1039 ) {
1040 let resolved_id = strategy.builtin_id().unwrap_or(id);
1041 if let Some(mode) = canonical_hall_call_mode(&resolved_id)
1042 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1043 {
1044 g.set_hall_call_mode(mode);
1045 }
1046 self.dispatcher_set.insert(group, strategy, resolved_id);
1047 }
1048
1049 pub fn set_reposition(
1080 &mut self,
1081 group: GroupId,
1082 strategy: Box<dyn RepositionStrategy>,
1083 id: BuiltinReposition,
1084 ) {
1085 let resolved_id = strategy.builtin_id().unwrap_or(id);
1086 let needed_window = strategy.min_arrival_log_window();
1087 self.repositioner_set.insert(group, strategy, resolved_id);
1088 if needed_window > 0
1094 && let Some(retention) = self
1095 .world
1096 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1097 && needed_window > retention.0
1098 {
1099 retention.0 = needed_window;
1100 }
1101 }
1102
1103 pub fn remove_reposition(&mut self, group: GroupId) {
1114 self.repositioner_set.remove(group);
1115 }
1116
1117 #[must_use]
1119 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1120 self.repositioner_set.id_for(group)
1121 }
1122
1123 pub fn add_before_hook(
1130 &mut self,
1131 phase: Phase,
1132 hook: impl Fn(&mut World) + Send + Sync + 'static,
1133 ) {
1134 self.hooks.add_before(phase, Box::new(hook));
1135 }
1136
1137 pub fn add_after_hook(
1142 &mut self,
1143 phase: Phase,
1144 hook: impl Fn(&mut World) + Send + Sync + 'static,
1145 ) {
1146 self.hooks.add_after(phase, Box::new(hook));
1147 }
1148
1149 pub fn add_before_group_hook(
1151 &mut self,
1152 phase: Phase,
1153 group: GroupId,
1154 hook: impl Fn(&mut World) + Send + Sync + 'static,
1155 ) {
1156 self.hooks.add_before_group(phase, group, Box::new(hook));
1157 }
1158
1159 pub fn add_after_group_hook(
1161 &mut self,
1162 phase: Phase,
1163 group: GroupId,
1164 hook: impl Fn(&mut World) + Send + Sync + 'static,
1165 ) {
1166 self.hooks.add_after_group(phase, group, Box::new(hook));
1167 }
1168}