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());
207 world.insert_resource(crate::arrival_log::DestinationLog::default());
208 world.insert_resource(crate::arrival_log::CurrentTick::default());
209 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
210 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
214 world.insert_resource(crate::dispatch::reposition::RepositionCooldowns::default());
220 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
226
227 let (mut groups, dispatchers, strategy_ids) =
228 if let Some(line_configs) = &config.building.lines {
229 Self::build_explicit_topology(
230 &mut world,
231 config,
232 line_configs,
233 &stop_lookup,
234 builder_dispatchers,
235 )
236 } else {
237 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
238 };
239 sync_hall_call_modes(&mut groups, &strategy_ids);
240
241 let dt = 1.0 / config.simulation.ticks_per_second;
242
243 world.insert_resource(crate::tagged_metrics::MetricTags::default());
244
245 world.register_ext::<crate::dispatch::destination::AssignedCar>(
254 crate::dispatch::destination::ASSIGNED_CAR_KEY,
255 );
256
257 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
260 .iter()
261 .flat_map(|group| {
262 group.lines().iter().filter_map(|li| {
263 let line_comp = world.line(li.entity())?;
264 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
265 })
266 })
267 .collect();
268
269 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
271 for (line_eid, name, elevators) in &line_tag_info {
272 let tag = format!("line:{name}");
273 tags.tag(*line_eid, tag.clone());
274 for elev_eid in elevators {
275 tags.tag(*elev_eid, tag.clone());
276 }
277 }
278 }
279
280 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
282 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
283 if let Some(group_configs) = &config.building.groups {
284 for gc in group_configs {
285 if let Some(ref repo_id) = gc.reposition
286 && let Some(strategy) = repo_id.instantiate()
287 {
288 let gid = GroupId(gc.id);
289 repositioners.insert(gid, strategy);
290 reposition_ids.insert(gid, repo_id.clone());
291 }
292 }
293 }
294
295 Ok(Self {
296 world,
297 events: EventBus::default(),
298 pending_output: Vec::new(),
299 tick: 0,
300 dt,
301 groups,
302 stop_lookup,
303 dispatchers,
304 strategy_ids,
305 repositioners,
306 reposition_ids,
307 metrics: Metrics::new(),
308 time: TimeAdapter::new(config.simulation.ticks_per_second),
309 hooks,
310 elevator_ids_buf: Vec::new(),
311 reposition_buf: Vec::new(),
312 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
313 topo_graph: Mutex::new(TopologyGraph::new()),
314 rider_index: RiderIndex::default(),
315 tick_in_progress: false,
316 })
317 }
318
319 fn spawn_elevator_entity(
325 world: &mut World,
326 ec: &crate::config::ElevatorConfig,
327 line: EntityId,
328 stop_lookup: &HashMap<StopId, EntityId>,
329 start_pos_lookup: &[crate::stop::StopConfig],
330 ) -> EntityId {
331 let eid = world.spawn();
332 let start_pos = start_pos_lookup
333 .iter()
334 .find(|s| s.id == ec.starting_stop)
335 .map_or(0.0, |s| s.position);
336 world.set_position(eid, Position { value: start_pos });
337 world.set_velocity(eid, Velocity { value: 0.0 });
338 let restricted: HashSet<EntityId> = ec
339 .restricted_stops
340 .iter()
341 .filter_map(|sid| stop_lookup.get(sid).copied())
342 .collect();
343 world.set_elevator(
344 eid,
345 Elevator {
346 phase: ElevatorPhase::Idle,
347 door: DoorState::Closed,
348 max_speed: ec.max_speed,
349 acceleration: ec.acceleration,
350 deceleration: ec.deceleration,
351 weight_capacity: ec.weight_capacity,
352 current_load: crate::components::Weight::ZERO,
353 riders: Vec::new(),
354 target_stop: None,
355 door_transition_ticks: ec.door_transition_ticks,
356 door_open_ticks: ec.door_open_ticks,
357 line,
358 repositioning: false,
359 restricted_stops: restricted,
360 inspection_speed_factor: ec.inspection_speed_factor,
361 going_up: true,
362 going_down: true,
363 move_count: 0,
364 door_command_queue: Vec::new(),
365 manual_target_velocity: None,
366 bypass_load_up_pct: ec.bypass_load_up_pct,
367 bypass_load_down_pct: ec.bypass_load_down_pct,
368 home_stop: None,
369 },
370 );
371 #[cfg(feature = "energy")]
372 if let Some(ref profile) = ec.energy_profile {
373 world.set_energy_profile(eid, profile.clone());
374 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
375 }
376 if let Some(mode) = ec.service_mode {
377 world.set_service_mode(eid, mode);
378 }
379 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
380 eid
381 }
382
383 fn build_legacy_topology(
385 world: &mut World,
386 config: &SimConfig,
387 stop_lookup: &HashMap<StopId, EntityId>,
388 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
389 ) -> TopologyResult {
390 let all_stop_entities: Vec<EntityId> = config
396 .building
397 .stops
398 .iter()
399 .filter_map(|s| stop_lookup.get(&s.id).copied())
400 .collect();
401 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
402 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
403 let max_pos = stop_positions
404 .iter()
405 .copied()
406 .fold(f64::NEG_INFINITY, f64::max);
407
408 let default_line_eid = world.spawn();
409 world.set_line(
410 default_line_eid,
411 Line {
412 name: "Default".into(),
413 group: GroupId(0),
414 orientation: Orientation::Vertical,
415 position: None,
416 min_position: min_pos,
417 max_position: max_pos,
418 max_cars: None,
419 },
420 );
421
422 let mut elevator_entities = Vec::new();
423 for ec in &config.elevators {
424 let eid = Self::spawn_elevator_entity(
425 world,
426 ec,
427 default_line_eid,
428 stop_lookup,
429 &config.building.stops,
430 );
431 elevator_entities.push(eid);
432 }
433
434 let default_line_info =
435 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
436
437 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
438
439 let mut dispatchers = BTreeMap::new();
446 let mut strategy_ids = BTreeMap::new();
447 let user_dispatcher = builder_dispatchers
448 .into_iter()
449 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
450 let inferred_id = user_dispatcher
463 .as_ref()
464 .and_then(|d| d.builtin_id())
465 .unwrap_or(BuiltinStrategy::Scan);
466 if let Some(d) = user_dispatcher {
467 dispatchers.insert(GroupId(0), d);
468 } else {
469 dispatchers.insert(
470 GroupId(0),
471 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
472 );
473 }
474 strategy_ids.insert(GroupId(0), inferred_id);
475
476 (vec![group], dispatchers, strategy_ids)
477 }
478
479 #[allow(clippy::too_many_lines)]
481 fn build_explicit_topology(
482 world: &mut World,
483 config: &SimConfig,
484 line_configs: &[crate::config::LineConfig],
485 stop_lookup: &HashMap<StopId, EntityId>,
486 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
487 ) -> TopologyResult {
488 let mut line_map: BTreeMap<u32, (EntityId, LineInfo)> = BTreeMap::new();
494
495 for lc in line_configs {
496 let served_entities: Vec<EntityId> = lc
498 .serves
499 .iter()
500 .filter_map(|sid| stop_lookup.get(sid).copied())
501 .collect();
502
503 let stop_positions: Vec<f64> = lc
505 .serves
506 .iter()
507 .filter_map(|sid| {
508 config
509 .building
510 .stops
511 .iter()
512 .find(|s| s.id == *sid)
513 .map(|s| s.position)
514 })
515 .collect();
516 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
517 let auto_max = stop_positions
518 .iter()
519 .copied()
520 .fold(f64::NEG_INFINITY, f64::max);
521
522 let min_pos = lc.min_position.unwrap_or(auto_min);
523 let max_pos = lc.max_position.unwrap_or(auto_max);
524
525 let line_eid = world.spawn();
526 world.set_line(
529 line_eid,
530 Line {
531 name: lc.name.clone(),
532 group: GroupId(0),
533 orientation: lc.orientation,
534 position: lc.position,
535 min_position: min_pos,
536 max_position: max_pos,
537 max_cars: lc.max_cars,
538 },
539 );
540
541 let mut elevator_entities = Vec::new();
543 for ec in &lc.elevators {
544 let eid = Self::spawn_elevator_entity(
545 world,
546 ec,
547 line_eid,
548 stop_lookup,
549 &config.building.stops,
550 );
551 elevator_entities.push(eid);
552 }
553
554 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
555 line_map.insert(lc.id, (line_eid, line_info));
556 }
557
558 let group_configs = config.building.groups.as_deref();
560 let mut groups = Vec::new();
561 let mut dispatchers = BTreeMap::new();
562 let mut strategy_ids = BTreeMap::new();
563
564 if let Some(gcs) = group_configs {
565 for gc in gcs {
566 let group_id = GroupId(gc.id);
567
568 let mut group_lines = Vec::new();
569
570 for &lid in &gc.lines {
571 if let Some((line_eid, li)) = line_map.get(&lid) {
572 if let Some(line_comp) = world.line_mut(*line_eid) {
574 line_comp.group = group_id;
575 }
576 group_lines.push(li.clone());
577 }
578 }
579
580 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
581 if let Some(mode) = gc.hall_call_mode {
582 group.set_hall_call_mode(mode);
583 }
584 if let Some(ticks) = gc.ack_latency_ticks {
585 group.set_ack_latency_ticks(ticks);
586 }
587 groups.push(group);
588
589 let dispatch: Box<dyn DispatchStrategy> = gc
591 .dispatch
592 .instantiate()
593 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
594 dispatchers.insert(group_id, dispatch);
595 strategy_ids.insert(group_id, gc.dispatch.clone());
596 }
597 } else {
598 let group_id = GroupId(0);
600 let mut group_lines = Vec::new();
601
602 for (line_eid, li) in line_map.values() {
603 if let Some(line_comp) = world.line_mut(*line_eid) {
604 line_comp.group = group_id;
605 }
606 group_lines.push(li.clone());
607 }
608
609 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
610 groups.push(group);
611
612 let dispatch: Box<dyn DispatchStrategy> =
613 Box::new(crate::dispatch::scan::ScanDispatch::new());
614 dispatchers.insert(group_id, dispatch);
615 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
616 }
617
618 for (gid, d) in builder_dispatchers {
628 let inferred_id = d.builtin_id();
629 dispatchers.insert(gid, d);
630 match inferred_id {
631 Some(id) => {
632 strategy_ids.insert(gid, id);
633 }
634 None => {
635 strategy_ids
636 .entry(gid)
637 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
638 }
639 }
640 }
641
642 (groups, dispatchers, strategy_ids)
643 }
644
645 #[allow(clippy::too_many_arguments)]
647 pub(crate) fn from_parts(
648 world: World,
649 tick: u64,
650 dt: f64,
651 groups: Vec<ElevatorGroup>,
652 stop_lookup: HashMap<StopId, EntityId>,
653 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
654 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
655 metrics: Metrics,
656 ticks_per_second: f64,
657 ) -> Self {
658 let mut rider_index = RiderIndex::default();
659 rider_index.rebuild(&world);
660 let mut world = world;
666 world.insert_resource(crate::time::TickRate(ticks_per_second));
667 if world
675 .resource::<crate::traffic_detector::TrafficDetector>()
676 .is_none()
677 {
678 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
679 }
680 if world
685 .resource::<crate::arrival_log::DestinationLog>()
686 .is_none()
687 {
688 world.insert_resource(crate::arrival_log::DestinationLog::default());
689 }
690 world.register_ext::<crate::dispatch::destination::AssignedCar>(
705 crate::dispatch::destination::ASSIGNED_CAR_KEY,
706 );
707 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
708 let data = pending.0.clone();
709 world.deserialize_extensions(&data);
710 }
711 Self {
712 world,
713 events: EventBus::default(),
714 pending_output: Vec::new(),
715 tick,
716 dt,
717 groups,
718 stop_lookup,
719 dispatchers,
720 strategy_ids,
721 repositioners: BTreeMap::new(),
722 reposition_ids: BTreeMap::new(),
723 metrics,
724 time: TimeAdapter::new(ticks_per_second),
725 hooks: PhaseHooks::default(),
726 elevator_ids_buf: Vec::new(),
727 reposition_buf: Vec::new(),
728 dispatch_scratch: crate::dispatch::DispatchScratch::default(),
729 topo_graph: Mutex::new(TopologyGraph::new()),
730 rider_index,
731 tick_in_progress: false,
732 }
733 }
734
735 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
737 if config.schema_version > crate::config::CURRENT_CONFIG_SCHEMA_VERSION {
744 return Err(SimError::InvalidConfig {
745 field: "schema_version",
746 reason: format!(
747 "config schema_version={} is newer than this build's CURRENT_CONFIG_SCHEMA_VERSION={}; upgrade elevator-core or downgrade the config",
748 config.schema_version,
749 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
750 ),
751 });
752 }
753 if config.schema_version == 0 {
754 return Err(SimError::InvalidConfig {
755 field: "schema_version",
756 reason: format!(
757 "config schema_version=0 (pre-versioning legacy file) — set schema_version: {} explicitly after auditing field defaults; see docs/src/config-versioning.md",
758 crate::config::CURRENT_CONFIG_SCHEMA_VERSION,
759 ),
760 });
761 }
762
763 if config.building.stops.is_empty() {
764 return Err(SimError::InvalidConfig {
765 field: "building.stops",
766 reason: "at least one stop is required".into(),
767 });
768 }
769
770 let mut seen_ids = HashSet::new();
772 for stop in &config.building.stops {
773 if !seen_ids.insert(stop.id) {
774 return Err(SimError::InvalidConfig {
775 field: "building.stops",
776 reason: format!("duplicate {}", stop.id),
777 });
778 }
779 if !stop.position.is_finite() {
780 return Err(SimError::InvalidConfig {
781 field: "building.stops.position",
782 reason: format!("{} has non-finite position {}", stop.id, stop.position),
783 });
784 }
785 }
786
787 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
788
789 if let Some(line_configs) = &config.building.lines {
790 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
792 } else {
793 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
795 }
796
797 if !config.simulation.ticks_per_second.is_finite()
798 || config.simulation.ticks_per_second <= 0.0
799 {
800 return Err(SimError::InvalidConfig {
801 field: "simulation.ticks_per_second",
802 reason: format!(
803 "must be finite and positive, got {}",
804 config.simulation.ticks_per_second
805 ),
806 });
807 }
808
809 Self::validate_passenger_spawning(&config.passenger_spawning)?;
810
811 Ok(())
812 }
813
814 fn validate_passenger_spawning(
819 spawn: &crate::config::PassengerSpawnConfig,
820 ) -> Result<(), SimError> {
821 let (lo, hi) = spawn.weight_range;
822 if !lo.is_finite() || !hi.is_finite() {
823 return Err(SimError::InvalidConfig {
824 field: "passenger_spawning.weight_range",
825 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
826 });
827 }
828 if lo < 0.0 || hi < 0.0 {
829 return Err(SimError::InvalidConfig {
830 field: "passenger_spawning.weight_range",
831 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
832 });
833 }
834 if lo > hi {
835 return Err(SimError::InvalidConfig {
836 field: "passenger_spawning.weight_range",
837 reason: format!("min must be <= max, got ({lo}, {hi})"),
838 });
839 }
840 if spawn.mean_interval_ticks == 0 {
841 return Err(SimError::InvalidConfig {
842 field: "passenger_spawning.mean_interval_ticks",
843 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
844 every catch-up tick"
845 .into(),
846 });
847 }
848 Ok(())
849 }
850
851 fn validate_legacy_elevators(
853 elevators: &[crate::config::ElevatorConfig],
854 building: &crate::config::BuildingConfig,
855 ) -> Result<(), SimError> {
856 if elevators.is_empty() {
857 return Err(SimError::InvalidConfig {
858 field: "elevators",
859 reason: "at least one elevator is required".into(),
860 });
861 }
862
863 for elev in elevators {
864 Self::validate_elevator_config(elev, building)?;
865 }
866
867 Ok(())
868 }
869
870 fn validate_elevator_config(
872 elev: &crate::config::ElevatorConfig,
873 building: &crate::config::BuildingConfig,
874 ) -> Result<(), SimError> {
875 validate_elevator_physics(
876 elev.max_speed.value(),
877 elev.acceleration.value(),
878 elev.deceleration.value(),
879 elev.weight_capacity.value(),
880 elev.inspection_speed_factor,
881 elev.door_transition_ticks,
882 elev.door_open_ticks,
883 elev.bypass_load_up_pct,
884 elev.bypass_load_down_pct,
885 )?;
886 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
887 return Err(SimError::InvalidConfig {
888 field: "elevators.starting_stop",
889 reason: format!("references non-existent {}", elev.starting_stop),
890 });
891 }
892 Ok(())
893 }
894
895 fn validate_explicit_topology(
897 line_configs: &[crate::config::LineConfig],
898 stop_ids: &HashSet<StopId>,
899 building: &crate::config::BuildingConfig,
900 ) -> Result<(), SimError> {
901 let mut seen_line_ids = HashSet::new();
903 for lc in line_configs {
904 if !seen_line_ids.insert(lc.id) {
905 return Err(SimError::InvalidConfig {
906 field: "building.lines",
907 reason: format!("duplicate line id {}", lc.id),
908 });
909 }
910 }
911
912 for lc in line_configs {
914 if lc.serves.is_empty() {
915 return Err(SimError::InvalidConfig {
916 field: "building.lines.serves",
917 reason: format!("line {} has no stops", lc.id),
918 });
919 }
920 for sid in &lc.serves {
921 if !stop_ids.contains(sid) {
922 return Err(SimError::InvalidConfig {
923 field: "building.lines.serves",
924 reason: format!("line {} references non-existent {}", lc.id, sid),
925 });
926 }
927 }
928 for ec in &lc.elevators {
930 Self::validate_elevator_config(ec, building)?;
931 }
932
933 if let Some(max) = lc.max_cars
935 && lc.elevators.len() > max
936 {
937 return Err(SimError::InvalidConfig {
938 field: "building.lines.max_cars",
939 reason: format!(
940 "line {} has {} elevators but max_cars is {max}",
941 lc.id,
942 lc.elevators.len()
943 ),
944 });
945 }
946 }
947
948 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
950 if !has_elevator {
951 return Err(SimError::InvalidConfig {
952 field: "building.lines",
953 reason: "at least one line must have at least one elevator".into(),
954 });
955 }
956
957 let served: HashSet<StopId> = line_configs
959 .iter()
960 .flat_map(|lc| lc.serves.iter().copied())
961 .collect();
962 for sid in stop_ids {
963 if !served.contains(sid) {
964 return Err(SimError::InvalidConfig {
965 field: "building.lines",
966 reason: format!("orphaned stop {sid} not served by any line"),
967 });
968 }
969 }
970
971 if let Some(group_configs) = &building.groups {
973 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
974
975 let mut seen_group_ids = HashSet::new();
976 for gc in group_configs {
977 if !seen_group_ids.insert(gc.id) {
978 return Err(SimError::InvalidConfig {
979 field: "building.groups",
980 reason: format!("duplicate group id {}", gc.id),
981 });
982 }
983 for &lid in &gc.lines {
984 if !line_id_set.contains(&lid) {
985 return Err(SimError::InvalidConfig {
986 field: "building.groups.lines",
987 reason: format!(
988 "group {} references non-existent line id {}",
989 gc.id, lid
990 ),
991 });
992 }
993 }
994 }
995
996 let referenced_line_ids: HashSet<u32> = group_configs
998 .iter()
999 .flat_map(|g| g.lines.iter().copied())
1000 .collect();
1001 for lc in line_configs {
1002 if !referenced_line_ids.contains(&lc.id) {
1003 return Err(SimError::InvalidConfig {
1004 field: "building.lines",
1005 reason: format!("line {} is not assigned to any group", lc.id),
1006 });
1007 }
1008 }
1009 }
1010
1011 Ok(())
1012 }
1013
1014 pub fn set_dispatch(
1030 &mut self,
1031 group: GroupId,
1032 strategy: Box<dyn DispatchStrategy>,
1033 id: crate::dispatch::BuiltinStrategy,
1034 ) {
1035 let resolved_id = strategy.builtin_id().unwrap_or(id);
1036 let mode = match &resolved_id {
1037 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
1038 BuiltinStrategy::Custom(_) => None,
1039 BuiltinStrategy::Scan
1040 | BuiltinStrategy::Look
1041 | BuiltinStrategy::NearestCar
1042 | BuiltinStrategy::Etd
1043 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
1044 };
1045 if let Some(mode) = mode
1046 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
1047 {
1048 g.set_hall_call_mode(mode);
1049 }
1050 self.dispatchers.insert(group, strategy);
1051 self.strategy_ids.insert(group, resolved_id);
1052 }
1053
1054 pub fn set_reposition(
1085 &mut self,
1086 group: GroupId,
1087 strategy: Box<dyn RepositionStrategy>,
1088 id: BuiltinReposition,
1089 ) {
1090 let resolved_id = strategy.builtin_id().unwrap_or(id);
1091 let needed_window = strategy.min_arrival_log_window();
1092 self.repositioners.insert(group, strategy);
1093 self.reposition_ids.insert(group, resolved_id);
1094 if needed_window > 0
1100 && let Some(retention) = self
1101 .world
1102 .resource_mut::<crate::arrival_log::ArrivalLogRetention>()
1103 && needed_window > retention.0
1104 {
1105 retention.0 = needed_window;
1106 }
1107 }
1108
1109 pub fn remove_reposition(&mut self, group: GroupId) {
1120 self.repositioners.remove(&group);
1121 self.reposition_ids.remove(&group);
1122 }
1123
1124 #[must_use]
1126 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1127 self.reposition_ids.get(&group)
1128 }
1129
1130 pub fn add_before_hook(
1137 &mut self,
1138 phase: Phase,
1139 hook: impl Fn(&mut World) + Send + Sync + 'static,
1140 ) {
1141 self.hooks.add_before(phase, Box::new(hook));
1142 }
1143
1144 pub fn add_after_hook(
1149 &mut self,
1150 phase: Phase,
1151 hook: impl Fn(&mut World) + Send + Sync + 'static,
1152 ) {
1153 self.hooks.add_after(phase, Box::new(hook));
1154 }
1155
1156 pub fn add_before_group_hook(
1158 &mut self,
1159 phase: Phase,
1160 group: GroupId,
1161 hook: impl Fn(&mut World) + Send + Sync + 'static,
1162 ) {
1163 self.hooks.add_before_group(phase, group, Box::new(hook));
1164 }
1165
1166 pub fn add_after_group_hook(
1168 &mut self,
1169 phase: Phase,
1170 group: GroupId,
1171 hook: impl Fn(&mut World) + Send + Sync + 'static,
1172 ) {
1173 self.hooks.add_after_group(phase, group, Box::new(hook));
1174 }
1175}