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
45fn sync_hall_call_modes(
54 groups: &mut [ElevatorGroup],
55 strategy_ids: &BTreeMap<GroupId, BuiltinStrategy>,
56) {
57 for group in groups.iter_mut() {
58 if strategy_ids.get(&group.id()) == Some(&BuiltinStrategy::Destination) {
59 group.set_hall_call_mode(crate::dispatch::HallCallMode::Destination);
60 }
61 }
62}
63
64#[allow(clippy::too_many_arguments)]
70pub(super) fn validate_elevator_physics(
71 max_speed: f64,
72 acceleration: f64,
73 deceleration: f64,
74 weight_capacity: f64,
75 inspection_speed_factor: f64,
76 door_transition_ticks: u32,
77 door_open_ticks: u32,
78 bypass_load_up_pct: Option<f64>,
79 bypass_load_down_pct: Option<f64>,
80) -> Result<(), SimError> {
81 if !max_speed.is_finite() || max_speed <= 0.0 {
82 return Err(SimError::InvalidConfig {
83 field: "elevators.max_speed",
84 reason: format!("must be finite and positive, got {max_speed}"),
85 });
86 }
87 if !acceleration.is_finite() || acceleration <= 0.0 {
88 return Err(SimError::InvalidConfig {
89 field: "elevators.acceleration",
90 reason: format!("must be finite and positive, got {acceleration}"),
91 });
92 }
93 if !deceleration.is_finite() || deceleration <= 0.0 {
94 return Err(SimError::InvalidConfig {
95 field: "elevators.deceleration",
96 reason: format!("must be finite and positive, got {deceleration}"),
97 });
98 }
99 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
100 return Err(SimError::InvalidConfig {
101 field: "elevators.weight_capacity",
102 reason: format!("must be finite and positive, got {weight_capacity}"),
103 });
104 }
105 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
106 return Err(SimError::InvalidConfig {
107 field: "elevators.inspection_speed_factor",
108 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
109 });
110 }
111 if door_transition_ticks == 0 {
112 return Err(SimError::InvalidConfig {
113 field: "elevators.door_transition_ticks",
114 reason: "must be > 0".into(),
115 });
116 }
117 if door_open_ticks == 0 {
118 return Err(SimError::InvalidConfig {
119 field: "elevators.door_open_ticks",
120 reason: "must be > 0".into(),
121 });
122 }
123 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
124 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
125 Ok(())
126}
127
128fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
133 let Some(pct) = pct else {
134 return Ok(());
135 };
136 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
137 return Err(SimError::InvalidConfig {
138 field,
139 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
140 });
141 }
142 Ok(())
143}
144
145impl Simulation {
146 pub fn new(
157 config: &SimConfig,
158 dispatch: impl DispatchStrategy + 'static,
159 ) -> Result<Self, SimError> {
160 let mut dispatchers = BTreeMap::new();
161 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
162 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
163 }
164
165 #[allow(clippy::too_many_lines)]
169 pub(crate) fn new_with_hooks(
170 config: &SimConfig,
171 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
172 hooks: PhaseHooks,
173 ) -> Result<Self, SimError> {
174 Self::validate_config(config)?;
175
176 let mut world = World::new();
177
178 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
180 for sc in &config.building.stops {
181 let eid = world.spawn();
182 world.set_stop(
183 eid,
184 Stop {
185 name: sc.name.clone(),
186 position: sc.position,
187 },
188 );
189 world.set_position(eid, Position { value: sc.position });
190 stop_lookup.insert(sc.id, eid);
191 }
192
193 let mut sorted: Vec<(f64, EntityId)> = world
195 .iter_stops()
196 .map(|(eid, stop)| (stop.position, eid))
197 .collect();
198 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
199 world.insert_resource(crate::world::SortedStops(sorted));
200
201 world.insert_resource(crate::arrival_log::ArrivalLog::default());
211 world.insert_resource(crate::arrival_log::DestinationLog::default());
212 world.insert_resource(crate::arrival_log::CurrentTick::default());
213 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
214 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
218 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
224 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
230
231 let (mut groups, dispatchers, strategy_ids) =
232 if let Some(line_configs) = &config.building.lines {
233 Self::build_explicit_topology(
234 &mut world,
235 config,
236 line_configs,
237 &stop_lookup,
238 builder_dispatchers,
239 )
240 } else {
241 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
242 };
243 sync_hall_call_modes(&mut groups, &strategy_ids);
244
245 let dt = 1.0 / config.simulation.ticks_per_second;
246
247 world.insert_resource(crate::tagged_metrics::MetricTags::default());
248
249 world.register_ext::<crate::dispatch::destination::AssignedCar>(
258 crate::dispatch::destination::ASSIGNED_CAR_KEY,
259 );
260
261 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
264 .iter()
265 .flat_map(|group| {
266 group.lines().iter().filter_map(|li| {
267 let line_comp = world.line(li.entity())?;
268 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
269 })
270 })
271 .collect();
272
273 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
275 for (line_eid, name, elevators) in &line_tag_info {
276 let tag = format!("line:{name}");
277 tags.tag(*line_eid, tag.clone());
278 for elev_eid in elevators {
279 tags.tag(*elev_eid, tag.clone());
280 }
281 }
282 }
283
284 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
286 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
287 if let Some(group_configs) = &config.building.groups {
288 for gc in group_configs {
289 if let Some(ref repo_id) = gc.reposition
290 && let Some(strategy) = repo_id.instantiate()
291 {
292 let gid = GroupId(gc.id);
293 repositioners.insert(gid, strategy);
294 reposition_ids.insert(gid, repo_id.clone());
295 }
296 }
297 }
298
299 Ok(Self {
300 world,
301 events: EventBus::default(),
302 pending_output: Vec::new(),
303 tick: 0,
304 dt,
305 groups,
306 stop_lookup,
307 dispatchers,
308 strategy_ids,
309 repositioners,
310 reposition_ids,
311 metrics: Metrics::new(),
312 time: TimeAdapter::new(config.simulation.ticks_per_second),
313 hooks,
314 elevator_ids_buf: Vec::new(),
315 reposition_buf: Vec::new(),
316 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
317 topo_graph: Mutex::new(TopologyGraph::new()),
318 rider_index: RiderIndex::default(),
319 tick_in_progress: false,
320 })
321 }
322
323 fn spawn_elevator_entity(
329 world: &mut World,
330 ec: &crate::config::ElevatorConfig,
331 line: EntityId,
332 stop_lookup: &HashMap<StopId, EntityId>,
333 start_pos_lookup: &[crate::stop::StopConfig],
334 ) -> EntityId {
335 let eid = world.spawn();
336 let start_pos = start_pos_lookup
337 .iter()
338 .find(|s| s.id == ec.starting_stop)
339 .map_or(0.0, |s| s.position);
340 world.set_position(eid, Position { value: start_pos });
341 world.set_velocity(eid, Velocity { value: 0.0 });
342 let restricted: HashSet<EntityId> = ec
343 .restricted_stops
344 .iter()
345 .filter_map(|sid| stop_lookup.get(sid).copied())
346 .collect();
347 world.set_elevator(
348 eid,
349 Elevator {
350 phase: ElevatorPhase::Idle,
351 door: DoorState::Closed,
352 max_speed: ec.max_speed,
353 acceleration: ec.acceleration,
354 deceleration: ec.deceleration,
355 weight_capacity: ec.weight_capacity,
356 current_load: crate::components::Weight::ZERO,
357 riders: Vec::new(),
358 target_stop: None,
359 door_transition_ticks: ec.door_transition_ticks,
360 door_open_ticks: ec.door_open_ticks,
361 line,
362 repositioning: false,
363 restricted_stops: restricted,
364 inspection_speed_factor: ec.inspection_speed_factor,
365 going_up: true,
366 going_down: true,
367 move_count: 0,
368 door_command_queue: Vec::new(),
369 manual_target_velocity: None,
370 bypass_load_up_pct: ec.bypass_load_up_pct,
371 bypass_load_down_pct: ec.bypass_load_down_pct,
372 home_stop: None,
373 },
374 );
375 #[cfg(feature = "energy")]
376 if let Some(ref profile) = ec.energy_profile {
377 world.set_energy_profile(eid, profile.clone());
378 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
379 }
380 if let Some(mode) = ec.service_mode {
381 world.set_service_mode(eid, mode);
382 }
383 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
384 eid
385 }
386
387 fn build_legacy_topology(
389 world: &mut World,
390 config: &SimConfig,
391 stop_lookup: &HashMap<StopId, EntityId>,
392 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
393 ) -> TopologyResult {
394 let all_stop_entities: Vec<EntityId> = config
400 .building
401 .stops
402 .iter()
403 .filter_map(|s| stop_lookup.get(&s.id).copied())
404 .collect();
405 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
406 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
407 let max_pos = stop_positions
408 .iter()
409 .copied()
410 .fold(f64::NEG_INFINITY, f64::max);
411
412 let default_line_eid = world.spawn();
413 world.set_line(
414 default_line_eid,
415 Line {
416 name: "Default".into(),
417 group: GroupId(0),
418 orientation: Orientation::Vertical,
419 position: None,
420 min_position: min_pos,
421 max_position: max_pos,
422 max_cars: None,
423 },
424 );
425
426 let mut elevator_entities = Vec::new();
427 for ec in &config.elevators {
428 let eid = Self::spawn_elevator_entity(
429 world,
430 ec,
431 default_line_eid,
432 stop_lookup,
433 &config.building.stops,
434 );
435 elevator_entities.push(eid);
436 }
437
438 let default_line_info =
439 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
440
441 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
442
443 let mut dispatchers = BTreeMap::new();
447 let mut strategy_ids = BTreeMap::new();
448 let user_dispatcher = builder_dispatchers
449 .into_iter()
450 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
451 let inferred_id = user_dispatcher
456 .as_ref()
457 .and_then(|d| d.builtin_id())
458 .unwrap_or(BuiltinStrategy::Scan);
459 if let Some(d) = user_dispatcher {
460 dispatchers.insert(GroupId(0), d);
461 } else {
462 dispatchers.insert(
463 GroupId(0),
464 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
465 );
466 }
467 strategy_ids.insert(GroupId(0), inferred_id);
468
469 (vec![group], dispatchers, strategy_ids)
470 }
471
472 #[allow(clippy::too_many_lines)]
474 fn build_explicit_topology(
475 world: &mut World,
476 config: &SimConfig,
477 line_configs: &[crate::config::LineConfig],
478 stop_lookup: &HashMap<StopId, EntityId>,
479 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
480 ) -> TopologyResult {
481 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
487
488 for lc in line_configs {
489 let served_entities: Vec<EntityId> = lc
491 .serves
492 .iter()
493 .filter_map(|sid| stop_lookup.get(sid).copied())
494 .collect();
495
496 let stop_positions: Vec<f64> = lc
498 .serves
499 .iter()
500 .filter_map(|sid| {
501 config
502 .building
503 .stops
504 .iter()
505 .find(|s| s.id == *sid)
506 .map(|s| s.position)
507 })
508 .collect();
509 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
510 let auto_max = stop_positions
511 .iter()
512 .copied()
513 .fold(f64::NEG_INFINITY, f64::max);
514
515 let min_pos = lc.min_position.unwrap_or(auto_min);
516 let max_pos = lc.max_position.unwrap_or(auto_max);
517
518 let line_eid = world.spawn();
519 world.set_line(
522 line_eid,
523 Line {
524 name: lc.name.clone(),
525 group: GroupId(0),
526 orientation: lc.orientation,
527 position: lc.position,
528 min_position: min_pos,
529 max_position: max_pos,
530 max_cars: lc.max_cars,
531 },
532 );
533
534 let mut elevator_entities = Vec::new();
536 for ec in &lc.elevators {
537 let eid = Self::spawn_elevator_entity(
538 world,
539 ec,
540 line_eid,
541 stop_lookup,
542 &config.building.stops,
543 );
544 elevator_entities.push(eid);
545 }
546
547 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
548 line_map.insert(lc.id, (line_eid, line_info));
549 }
550
551 let group_configs = config.building.groups.as_deref();
553 let mut groups = Vec::new();
554 let mut dispatchers = BTreeMap::new();
555 let mut strategy_ids = BTreeMap::new();
556
557 if let Some(gcs) = group_configs {
558 for gc in gcs {
559 let group_id = GroupId(gc.id);
560
561 let mut group_lines = Vec::new();
562
563 for &lid in &gc.lines {
564 if let Some((line_eid, li)) = line_map.get(&lid) {
565 if let Some(line_comp) = world.line_mut(*line_eid) {
567 line_comp.group = group_id;
568 }
569 group_lines.push(li.clone());
570 }
571 }
572
573 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
574 if let Some(mode) = gc.hall_call_mode {
575 group.set_hall_call_mode(mode);
576 }
577 if let Some(ticks) = gc.ack_latency_ticks {
578 group.set_ack_latency_ticks(ticks);
579 }
580 groups.push(group);
581
582 let dispatch: Box<dyn DispatchStrategy> = gc
584 .dispatch
585 .instantiate()
586 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
587 dispatchers.insert(group_id, dispatch);
588 strategy_ids.insert(group_id, gc.dispatch.clone());
589 }
590 } else {
591 let group_id = GroupId(0);
593 let mut group_lines = Vec::new();
594
595 for (line_eid, li) in line_map.values() {
596 if let Some(line_comp) = world.line_mut(*line_eid) {
597 line_comp.group = group_id;
598 }
599 group_lines.push(li.clone());
600 }
601
602 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
603 groups.push(group);
604
605 let dispatch: Box<dyn DispatchStrategy> =
606 Box::new(crate::dispatch::scan::ScanDispatch::new());
607 dispatchers.insert(group_id, dispatch);
608 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
609 }
610
611 for (gid, d) in builder_dispatchers {
616 let inferred_id = d.builtin_id();
617 dispatchers.insert(gid, d);
618 match inferred_id {
619 Some(id) => {
620 strategy_ids.insert(gid, id);
621 }
622 None => {
623 strategy_ids
624 .entry(gid)
625 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
626 }
627 }
628 }
629
630 (groups, dispatchers, strategy_ids)
631 }
632
633 #[allow(clippy::too_many_arguments)]
635 pub(crate) fn from_parts(
636 world: World,
637 tick: u64,
638 dt: f64,
639 groups: Vec<ElevatorGroup>,
640 stop_lookup: HashMap<StopId, EntityId>,
641 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
642 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
643 metrics: Metrics,
644 ticks_per_second: f64,
645 ) -> Self {
646 let mut rider_index = RiderIndex::default();
647 rider_index.rebuild(&world);
648 let mut world = world;
654 world.insert_resource(crate::time::TickRate(ticks_per_second));
655 if world
656 .resource::<crate::traffic_detector::TrafficDetector>()
657 .is_none()
658 {
659 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
660 }
661 if world
666 .resource::<crate::arrival_log::DestinationLog>()
667 .is_none()
668 {
669 world.insert_resource(crate::arrival_log::DestinationLog::default());
670 }
671 world.register_ext::<crate::dispatch::destination::AssignedCar>(
686 crate::dispatch::destination::ASSIGNED_CAR_KEY,
687 );
688 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
689 let data = pending.0.clone();
690 world.deserialize_extensions(&data);
691 }
692 Self {
693 world,
694 events: EventBus::default(),
695 pending_output: Vec::new(),
696 tick,
697 dt,
698 groups,
699 stop_lookup,
700 dispatchers,
701 strategy_ids,
702 repositioners: BTreeMap::new(),
703 reposition_ids: BTreeMap::new(),
704 metrics,
705 time: TimeAdapter::new(ticks_per_second),
706 hooks: PhaseHooks::default(),
707 elevator_ids_buf: Vec::new(),
708 reposition_buf: Vec::new(),
709 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
710 topo_graph: Mutex::new(TopologyGraph::new()),
711 rider_index,
712 tick_in_progress: false,
713 }
714 }
715
716 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
718 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
725 return Err(SimError::InvalidConfig {
726 field: "schema_version",
727 reason: format!(
728 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
729 config.schema_version,
730 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
731 ),
732 });
733 }
734 if config.schema_version == 0 {
735 return Err(SimError::InvalidConfig {
736 field: "schema_version",
737 reason: format!(
738 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
739 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
740 ),
741 });
742 }
743
744 if config.building.stops.is_empty() {
745 return Err(SimError::InvalidConfig {
746 field: "building.stops",
747 reason: "at least one stop is required".into(),
748 });
749 }
750
751 let mut seen_ids = HashSet::new();
753 for stop in &config.building.stops {
754 if !seen_ids.insert(stop.id) {
755 return Err(SimError::InvalidConfig {
756 field: "building.stops",
757 reason: format!("duplicate {}", stop.id),
758 });
759 }
760 if !stop.position.is_finite() {
761 return Err(SimError::InvalidConfig {
762 field: "building.stops.position",
763 reason: format!("{} has non-finite position {}", stop.id, stop.position),
764 });
765 }
766 }
767
768 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
769
770 if let Some(line_configs) = &config.building.lines {
771 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
773 } else {
774 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
776 }
777
778 if !config.simulation.ticks_per_second.is_finite()
779 || config.simulation.ticks_per_second <= 0.0
780 {
781 return Err(SimError::InvalidConfig {
782 field: "simulation.ticks_per_second",
783 reason: format!(
784 "must be finite and positive, got {}",
785 config.simulation.ticks_per_second
786 ),
787 });
788 }
789
790 Self::validate_passenger_spawning(&config.passenger_spawning)?;
791
792 Ok(())
793 }
794
795 fn validate_passenger_spawning(
800 spawn: &crate::config::PassengerSpawnConfig,
801 ) -> Result<(), SimError> {
802 let (lo, hi) = spawn.weight_range;
803 if !lo.is_finite() || !hi.is_finite() {
804 return Err(SimError::InvalidConfig {
805 field: "passenger_spawning.weight_range",
806 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
807 });
808 }
809 if lo < 0.0 || hi < 0.0 {
810 return Err(SimError::InvalidConfig {
811 field: "passenger_spawning.weight_range",
812 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
813 });
814 }
815 if lo > hi {
816 return Err(SimError::InvalidConfig {
817 field: "passenger_spawning.weight_range",
818 reason: format!("min must be <= max, got ({lo}, {hi})"),
819 });
820 }
821 if spawn.mean_interval_ticks == 0 {
822 return Err(SimError::InvalidConfig {
823 field: "passenger_spawning.mean_interval_ticks",
824 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
825 every catch-up tick"
826 .into(),
827 });
828 }
829 Ok(())
830 }
831
832 fn validate_legacy_elevators(
834 elevators: &[crate::config::ElevatorConfig],
835 building: &crate::config::BuildingConfig,
836 ) -> Result<(), SimError> {
837 if elevators.is_empty() {
838 return Err(SimError::InvalidConfig {
839 field: "elevators",
840 reason: "at least one elevator is required".into(),
841 });
842 }
843
844 for elev in elevators {
845 Self::validate_elevator_config(elev, building)?;
846 }
847
848 Ok(())
849 }
850
851 fn validate_elevator_config(
853 elev: &crate::config::ElevatorConfig,
854 building: &crate::config::BuildingConfig,
855 ) -> Result<(), SimError> {
856 validate_elevator_physics(
857 elev.max_speed.value(),
858 elev.acceleration.value(),
859 elev.deceleration.value(),
860 elev.weight_capacity.value(),
861 elev.inspection_speed_factor,
862 elev.door_transition_ticks,
863 elev.door_open_ticks,
864 elev.bypass_load_up_pct,
865 elev.bypass_load_down_pct,
866 )?;
867 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
868 return Err(SimError::InvalidConfig {
869 field: "elevators.starting_stop",
870 reason: format!("references non-existent {}", elev.starting_stop),
871 });
872 }
873 Ok(())
874 }
875
876 fn validate_explicit_topology(
878 line_configs: &[crate::config::LineConfig],
879 stop_ids: &HashSet<StopId>,
880 building: &crate::config::BuildingConfig,
881 ) -> Result<(), SimError> {
882 let mut seen_line_ids = HashSet::new();
884 for lc in line_configs {
885 if !seen_line_ids.insert(lc.id) {
886 return Err(SimError::InvalidConfig {
887 field: "building.lines",
888 reason: format!("duplicate line id {}", lc.id),
889 });
890 }
891 }
892
893 for lc in line_configs {
895 if lc.serves.is_empty() {
896 return Err(SimError::InvalidConfig {
897 field: "building.lines.serves",
898 reason: format!("line {} has no stops", lc.id),
899 });
900 }
901 for sid in &lc.serves {
902 if !stop_ids.contains(sid) {
903 return Err(SimError::InvalidConfig {
904 field: "building.lines.serves",
905 reason: format!("line {} references non-existent {}", lc.id, sid),
906 });
907 }
908 }
909 for ec in &lc.elevators {
911 Self::validate_elevator_config(ec, building)?;
912 }
913
914 if let Some(max) = lc.max_cars
916 && lc.elevators.len() > max
917 {
918 return Err(SimError::InvalidConfig {
919 field: "building.lines.max_cars",
920 reason: format!(
921 "line {} has {} elevators but max_cars is {max}",
922 lc.id,
923 lc.elevators.len()
924 ),
925 });
926 }
927 }
928
929 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
931 if !has_elevator {
932 return Err(SimError::InvalidConfig {
933 field: "building.lines",
934 reason: "at least one line must have at least one elevator".into(),
935 });
936 }
937
938 let served: HashSet<StopId> = line_configs
940 .iter()
941 .flat_map(|lc| lc.serves.iter().copied())
942 .collect();
943 for sid in stop_ids {
944 if !served.contains(sid) {
945 return Err(SimError::InvalidConfig {
946 field: "building.lines",
947 reason: format!("orphaned stop {sid} not served by any line"),
948 });
949 }
950 }
951
952 if let Some(group_configs) = &building.groups {
954 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
955
956 let mut seen_group_ids = HashSet::new();
957 for gc in group_configs {
958 if !seen_group_ids.insert(gc.id) {
959 return Err(SimError::InvalidConfig {
960 field: "building.groups",
961 reason: format!("duplicate group id {}", gc.id),
962 });
963 }
964 for &lid in &gc.lines {
965 if !line_id_set.contains(&lid) {
966 return Err(SimError::InvalidConfig {
967 field: "building.groups.lines",
968 reason: format!(
969 "group {} references non-existent line id {}",
970 gc.id, lid
971 ),
972 });
973 }
974 }
975 }
976
977 let referenced_line_ids: HashSet<u32> = group_configs
979 .iter()
980 .flat_map(|g| g.lines.iter().copied())
981 .collect();
982 for lc in line_configs {
983 if !referenced_line_ids.contains(&lc.id) {
984 return Err(SimError::InvalidConfig {
985 field: "building.lines",
986 reason: format!("line {} is not assigned to any group", lc.id),
987 });
988 }
989 }
990 }
991
992 Ok(())
993 }
994
995 pub fn set_dispatch(
1011 &mut self,
1012 group: GroupId,
1013 strategy: Box<dyn DispatchStrategy>,
1014 id: crate::dispatch::BuiltinStrategy,
1015 ) {
1016 let resolved_id = strategy.builtin_id().unwrap_or(id);
1017 let mode = match &resolved_id {
1018 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
1019 BuiltinStrategy::Custom(_) => None,
1020 BuiltinStrategy::Scan
1021 | BuiltinStrategy::Look
1022 | BuiltinStrategy::NearestCar
1023 | BuiltinStrategy::Etd
1024 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
1025 };
1026 if let Some(mode) = mode
1027 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1028 {
1029 g.set_hall_call_mode(mode);
1030 }
1031 self.dispatchers.insert(group, strategy);
1032 self.strategy_ids.insert(group, resolved_id);
1033 }
1034
1035 pub fn set_reposition(
1066 &mut self,
1067 group: GroupId,
1068 strategy: Box<dyn RepositionStrategy>,
1069 id: BuiltinReposition,
1070 ) {
1071 let resolved_id = strategy.builtin_id().unwrap_or(id);
1072 let needed_window = strategy.min_arrival_log_window();
1073 self.repositioners.insert(group, strategy);
1074 self.reposition_ids.insert(group, resolved_id);
1075 if needed_window > 0
1081 && let Some(retention) = self
1082 .world
1083 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1084 && needed_window > retention.0
1085 {
1086 retention.0 = needed_window;
1087 }
1088 }
1089
1090 pub fn remove_reposition(&mut self, group: GroupId) {
1101 self.repositioners.remove(&group);
1102 self.reposition_ids.remove(&group);
1103 }
1104
1105 #[must_use]
1107 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1108 self.reposition_ids.get(&group)
1109 }
1110
1111 pub fn add_before_hook(
1118 &mut self,
1119 phase: Phase,
1120 hook: impl Fn(&mut World) + Send + Sync + 'static,
1121 ) {
1122 self.hooks.add_before(phase, Box::new(hook));
1123 }
1124
1125 pub fn add_after_hook(
1130 &mut self,
1131 phase: Phase,
1132 hook: impl Fn(&mut World) + Send + Sync + 'static,
1133 ) {
1134 self.hooks.add_after(phase, Box::new(hook));
1135 }
1136
1137 pub fn add_before_group_hook(
1139 &mut self,
1140 phase: Phase,
1141 group: GroupId,
1142 hook: impl Fn(&mut World) + Send + Sync + 'static,
1143 ) {
1144 self.hooks.add_before_group(phase, group, Box::new(hook));
1145 }
1146
1147 pub fn add_after_group_hook(
1149 &mut self,
1150 phase: Phase,
1151 group: GroupId,
1152 hook: impl Fn(&mut World) + Send + Sync + 'static,
1153 ) {
1154 self.hooks.add_after_group(phase, group, Box::new(hook));
1155 }
1156}