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 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
248 .iter()
249 .flat_map(|group| {
250 group.lines().iter().filter_map(|li| {
251 let line_comp = world.line(li.entity())?;
252 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
253 })
254 })
255 .collect();
256
257 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
259 for (line_eid, name, elevators) in &line_tag_info {
260 let tag = format!("line:{name}");
261 tags.tag(*line_eid, tag.clone());
262 for elev_eid in elevators {
263 tags.tag(*elev_eid, tag.clone());
264 }
265 }
266 }
267
268 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
270 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
271 if let Some(group_configs) = &config.building.groups {
272 for gc in group_configs {
273 if let Some(ref repo_id) = gc.reposition
274 && let Some(strategy) = repo_id.instantiate()
275 {
276 let gid = GroupId(gc.id);
277 repositioners.insert(gid, strategy);
278 reposition_ids.insert(gid, repo_id.clone());
279 }
280 }
281 }
282
283 Ok(Self {
284 world,
285 events: EventBus::default(),
286 pending_output: Vec::new(),
287 tick: 0,
288 dt,
289 groups,
290 stop_lookup,
291 dispatchers,
292 strategy_ids,
293 repositioners,
294 reposition_ids,
295 metrics: Metrics::new(),
296 time: TimeAdapter::new(config.simulation.ticks_per_second),
297 hooks,
298 elevator_ids_buf: Vec::new(),
299 reposition_buf: Vec::new(),
300 topo_graph: Mutex::new(TopologyGraph::new()),
301 rider_index: RiderIndex::default(),
302 tick_in_progress: false,
303 })
304 }
305
306 fn spawn_elevator_entity(
312 world: &mut World,
313 ec: &crate::config::ElevatorConfig,
314 line: EntityId,
315 stop_lookup: &HashMap<StopId, EntityId>,
316 start_pos_lookup: &[crate::stop::StopConfig],
317 ) -> EntityId {
318 let eid = world.spawn();
319 let start_pos = start_pos_lookup
320 .iter()
321 .find(|s| s.id == ec.starting_stop)
322 .map_or(0.0, |s| s.position);
323 world.set_position(eid, Position { value: start_pos });
324 world.set_velocity(eid, Velocity { value: 0.0 });
325 let restricted: HashSet<EntityId> = ec
326 .restricted_stops
327 .iter()
328 .filter_map(|sid| stop_lookup.get(sid).copied())
329 .collect();
330 world.set_elevator(
331 eid,
332 Elevator {
333 phase: ElevatorPhase::Idle,
334 door: DoorState::Closed,
335 max_speed: ec.max_speed,
336 acceleration: ec.acceleration,
337 deceleration: ec.deceleration,
338 weight_capacity: ec.weight_capacity,
339 current_load: crate::components::Weight::ZERO,
340 riders: Vec::new(),
341 target_stop: None,
342 door_transition_ticks: ec.door_transition_ticks,
343 door_open_ticks: ec.door_open_ticks,
344 line,
345 repositioning: false,
346 restricted_stops: restricted,
347 inspection_speed_factor: ec.inspection_speed_factor,
348 going_up: true,
349 going_down: true,
350 move_count: 0,
351 door_command_queue: Vec::new(),
352 manual_target_velocity: None,
353 bypass_load_up_pct: ec.bypass_load_up_pct,
354 bypass_load_down_pct: ec.bypass_load_down_pct,
355 },
356 );
357 #[cfg(feature = "energy")]
358 if let Some(ref profile) = ec.energy_profile {
359 world.set_energy_profile(eid, profile.clone());
360 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
361 }
362 if let Some(mode) = ec.service_mode {
363 world.set_service_mode(eid, mode);
364 }
365 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
366 eid
367 }
368
369 fn build_legacy_topology(
371 world: &mut World,
372 config: &SimConfig,
373 stop_lookup: &HashMap<StopId, EntityId>,
374 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
375 ) -> TopologyResult {
376 let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
377 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
378 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
379 let max_pos = stop_positions
380 .iter()
381 .copied()
382 .fold(f64::NEG_INFINITY, f64::max);
383
384 let default_line_eid = world.spawn();
385 world.set_line(
386 default_line_eid,
387 Line {
388 name: "Default".into(),
389 group: GroupId(0),
390 orientation: Orientation::Vertical,
391 position: None,
392 min_position: min_pos,
393 max_position: max_pos,
394 max_cars: None,
395 },
396 );
397
398 let mut elevator_entities = Vec::new();
399 for ec in &config.elevators {
400 let eid = Self::spawn_elevator_entity(
401 world,
402 ec,
403 default_line_eid,
404 stop_lookup,
405 &config.building.stops,
406 );
407 elevator_entities.push(eid);
408 }
409
410 let default_line_info =
411 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
412
413 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
414
415 let mut dispatchers = BTreeMap::new();
422 let mut strategy_ids = BTreeMap::new();
423 let user_dispatcher = builder_dispatchers
424 .into_iter()
425 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
426 let inferred_id = user_dispatcher
439 .as_ref()
440 .and_then(|d| d.builtin_id())
441 .unwrap_or(BuiltinStrategy::Scan);
442 if let Some(d) = user_dispatcher {
443 dispatchers.insert(GroupId(0), d);
444 } else {
445 dispatchers.insert(
446 GroupId(0),
447 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
448 );
449 }
450 strategy_ids.insert(GroupId(0), inferred_id);
451
452 (vec![group], dispatchers, strategy_ids)
453 }
454
455 #[allow(clippy::too_many_lines)]
457 fn build_explicit_topology(
458 world: &mut World,
459 config: &SimConfig,
460 line_configs: &[crate::config::LineConfig],
461 stop_lookup: &HashMap<StopId, EntityId>,
462 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
463 ) -> TopologyResult {
464 let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
466
467 for lc in line_configs {
468 let served_entities: Vec<EntityId> = lc
470 .serves
471 .iter()
472 .filter_map(|sid| stop_lookup.get(sid).copied())
473 .collect();
474
475 let stop_positions: Vec<f64> = lc
477 .serves
478 .iter()
479 .filter_map(|sid| {
480 config
481 .building
482 .stops
483 .iter()
484 .find(|s| s.id == *sid)
485 .map(|s| s.position)
486 })
487 .collect();
488 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
489 let auto_max = stop_positions
490 .iter()
491 .copied()
492 .fold(f64::NEG_INFINITY, f64::max);
493
494 let min_pos = lc.min_position.unwrap_or(auto_min);
495 let max_pos = lc.max_position.unwrap_or(auto_max);
496
497 let line_eid = world.spawn();
498 world.set_line(
501 line_eid,
502 Line {
503 name: lc.name.clone(),
504 group: GroupId(0),
505 orientation: lc.orientation,
506 position: lc.position,
507 min_position: min_pos,
508 max_position: max_pos,
509 max_cars: lc.max_cars,
510 },
511 );
512
513 let mut elevator_entities = Vec::new();
515 for ec in &lc.elevators {
516 let eid = Self::spawn_elevator_entity(
517 world,
518 ec,
519 line_eid,
520 stop_lookup,
521 &config.building.stops,
522 );
523 elevator_entities.push(eid);
524 }
525
526 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
527 line_map.insert(lc.id, (line_eid, line_info));
528 }
529
530 let group_configs = config.building.groups.as_deref();
532 let mut groups = Vec::new();
533 let mut dispatchers = BTreeMap::new();
534 let mut strategy_ids = BTreeMap::new();
535
536 if let Some(gcs) = group_configs {
537 for gc in gcs {
538 let group_id = GroupId(gc.id);
539
540 let mut group_lines = Vec::new();
541
542 for &lid in &gc.lines {
543 if let Some((line_eid, li)) = line_map.get(&lid) {
544 if let Some(line_comp) = world.line_mut(*line_eid) {
546 line_comp.group = group_id;
547 }
548 group_lines.push(li.clone());
549 }
550 }
551
552 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
553 if let Some(mode) = gc.hall_call_mode {
554 group.set_hall_call_mode(mode);
555 }
556 if let Some(ticks) = gc.ack_latency_ticks {
557 group.set_ack_latency_ticks(ticks);
558 }
559 groups.push(group);
560
561 let dispatch: Box<dyn DispatchStrategy> = gc
563 .dispatch
564 .instantiate()
565 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
566 dispatchers.insert(group_id, dispatch);
567 strategy_ids.insert(group_id, gc.dispatch.clone());
568 }
569 } else {
570 let group_id = GroupId(0);
572 let mut group_lines = Vec::new();
573
574 for (line_eid, li) in line_map.values() {
575 if let Some(line_comp) = world.line_mut(*line_eid) {
576 line_comp.group = group_id;
577 }
578 group_lines.push(li.clone());
579 }
580
581 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
582 groups.push(group);
583
584 let dispatch: Box<dyn DispatchStrategy> =
585 Box::new(crate::dispatch::scan::ScanDispatch::new());
586 dispatchers.insert(group_id, dispatch);
587 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
588 }
589
590 for (gid, d) in builder_dispatchers {
600 let inferred_id = d.builtin_id();
601 dispatchers.insert(gid, d);
602 match inferred_id {
603 Some(id) => {
604 strategy_ids.insert(gid, id);
605 }
606 None => {
607 strategy_ids
608 .entry(gid)
609 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
610 }
611 }
612 }
613
614 (groups, dispatchers, strategy_ids)
615 }
616
617 #[allow(clippy::too_many_arguments)]
619 pub(crate) fn from_parts(
620 world: World,
621 tick: u64,
622 dt: f64,
623 groups: Vec<ElevatorGroup>,
624 stop_lookup: HashMap<StopId, EntityId>,
625 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
626 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
627 metrics: Metrics,
628 ticks_per_second: f64,
629 ) -> Self {
630 let mut rider_index = RiderIndex::default();
631 rider_index.rebuild(&world);
632 let mut world = world;
638 world.insert_resource(crate::time::TickRate(ticks_per_second));
639 if world
647 .resource::<crate::traffic_detector::TrafficDetector>()
648 .is_none()
649 {
650 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
651 }
652 if world
657 .resource::<crate::arrival_log::DestinationLog>()
658 .is_none()
659 {
660 world.insert_resource(crate::arrival_log::DestinationLog::default());
661 }
662 world.register_ext::<crate::dispatch::destination::AssignedCar>(
677 crate::dispatch::destination::ASSIGNED_CAR_KEY,
678 );
679 if let Some(pending) = world.resource::<crate::snapshot::PendingExtensions>() {
680 let data = pending.0.clone();
681 world.deserialize_extensions(&data);
682 }
683 Self {
684 world,
685 events: EventBus::default(),
686 pending_output: Vec::new(),
687 tick,
688 dt,
689 groups,
690 stop_lookup,
691 dispatchers,
692 strategy_ids,
693 repositioners: BTreeMap::new(),
694 reposition_ids: BTreeMap::new(),
695 metrics,
696 time: TimeAdapter::new(ticks_per_second),
697 hooks: PhaseHooks::default(),
698 elevator_ids_buf: Vec::new(),
699 reposition_buf: Vec::new(),
700 topo_graph: Mutex::new(TopologyGraph::new()),
701 rider_index,
702 tick_in_progress: false,
703 }
704 }
705
706 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
708 if config.building.stops.is_empty() {
709 return Err(SimError::InvalidConfig {
710 field: "building.stops",
711 reason: "at least one stop is required".into(),
712 });
713 }
714
715 let mut seen_ids = HashSet::new();
717 for stop in &config.building.stops {
718 if !seen_ids.insert(stop.id) {
719 return Err(SimError::InvalidConfig {
720 field: "building.stops",
721 reason: format!("duplicate {}", stop.id),
722 });
723 }
724 if !stop.position.is_finite() {
725 return Err(SimError::InvalidConfig {
726 field: "building.stops.position",
727 reason: format!("{} has non-finite position {}", stop.id, stop.position),
728 });
729 }
730 }
731
732 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
733
734 if let Some(line_configs) = &config.building.lines {
735 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
737 } else {
738 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
740 }
741
742 if !config.simulation.ticks_per_second.is_finite()
743 || config.simulation.ticks_per_second <= 0.0
744 {
745 return Err(SimError::InvalidConfig {
746 field: "simulation.ticks_per_second",
747 reason: format!(
748 "must be finite and positive, got {}",
749 config.simulation.ticks_per_second
750 ),
751 });
752 }
753
754 Self::validate_passenger_spawning(&config.passenger_spawning)?;
755
756 Ok(())
757 }
758
759 fn validate_passenger_spawning(
764 spawn: &crate::config::PassengerSpawnConfig,
765 ) -> Result<(), SimError> {
766 let (lo, hi) = spawn.weight_range;
767 if !lo.is_finite() || !hi.is_finite() {
768 return Err(SimError::InvalidConfig {
769 field: "passenger_spawning.weight_range",
770 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
771 });
772 }
773 if lo < 0.0 || hi < 0.0 {
774 return Err(SimError::InvalidConfig {
775 field: "passenger_spawning.weight_range",
776 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
777 });
778 }
779 if lo > hi {
780 return Err(SimError::InvalidConfig {
781 field: "passenger_spawning.weight_range",
782 reason: format!("min must be <= max, got ({lo}, {hi})"),
783 });
784 }
785 if spawn.mean_interval_ticks == 0 {
786 return Err(SimError::InvalidConfig {
787 field: "passenger_spawning.mean_interval_ticks",
788 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
789 every catch-up tick"
790 .into(),
791 });
792 }
793 Ok(())
794 }
795
796 fn validate_legacy_elevators(
798 elevators: &[crate::config::ElevatorConfig],
799 building: &crate::config::BuildingConfig,
800 ) -> Result<(), SimError> {
801 if elevators.is_empty() {
802 return Err(SimError::InvalidConfig {
803 field: "elevators",
804 reason: "at least one elevator is required".into(),
805 });
806 }
807
808 for elev in elevators {
809 Self::validate_elevator_config(elev, building)?;
810 }
811
812 Ok(())
813 }
814
815 fn validate_elevator_config(
817 elev: &crate::config::ElevatorConfig,
818 building: &crate::config::BuildingConfig,
819 ) -> Result<(), SimError> {
820 validate_elevator_physics(
821 elev.max_speed.value(),
822 elev.acceleration.value(),
823 elev.deceleration.value(),
824 elev.weight_capacity.value(),
825 elev.inspection_speed_factor,
826 elev.door_transition_ticks,
827 elev.door_open_ticks,
828 elev.bypass_load_up_pct,
829 elev.bypass_load_down_pct,
830 )?;
831 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
832 return Err(SimError::InvalidConfig {
833 field: "elevators.starting_stop",
834 reason: format!("references non-existent {}", elev.starting_stop),
835 });
836 }
837 Ok(())
838 }
839
840 fn validate_explicit_topology(
842 line_configs: &[crate::config::LineConfig],
843 stop_ids: &HashSet<StopId>,
844 building: &crate::config::BuildingConfig,
845 ) -> Result<(), SimError> {
846 let mut seen_line_ids = HashSet::new();
848 for lc in line_configs {
849 if !seen_line_ids.insert(lc.id) {
850 return Err(SimError::InvalidConfig {
851 field: "building.lines",
852 reason: format!("duplicate line id {}", lc.id),
853 });
854 }
855 }
856
857 for lc in line_configs {
859 if lc.serves.is_empty() {
860 return Err(SimError::InvalidConfig {
861 field: "building.lines.serves",
862 reason: format!("line {} has no stops", lc.id),
863 });
864 }
865 for sid in &lc.serves {
866 if !stop_ids.contains(sid) {
867 return Err(SimError::InvalidConfig {
868 field: "building.lines.serves",
869 reason: format!("line {} references non-existent {}", lc.id, sid),
870 });
871 }
872 }
873 for ec in &lc.elevators {
875 Self::validate_elevator_config(ec, building)?;
876 }
877
878 if let Some(max) = lc.max_cars
880 && lc.elevators.len() > max
881 {
882 return Err(SimError::InvalidConfig {
883 field: "building.lines.max_cars",
884 reason: format!(
885 "line {} has {} elevators but max_cars is {max}",
886 lc.id,
887 lc.elevators.len()
888 ),
889 });
890 }
891 }
892
893 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
895 if !has_elevator {
896 return Err(SimError::InvalidConfig {
897 field: "building.lines",
898 reason: "at least one line must have at least one elevator".into(),
899 });
900 }
901
902 let served: HashSet<StopId> = line_configs
904 .iter()
905 .flat_map(|lc| lc.serves.iter().copied())
906 .collect();
907 for sid in stop_ids {
908 if !served.contains(sid) {
909 return Err(SimError::InvalidConfig {
910 field: "building.lines",
911 reason: format!("orphaned stop {sid} not served by any line"),
912 });
913 }
914 }
915
916 if let Some(group_configs) = &building.groups {
918 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
919
920 let mut seen_group_ids = HashSet::new();
921 for gc in group_configs {
922 if !seen_group_ids.insert(gc.id) {
923 return Err(SimError::InvalidConfig {
924 field: "building.groups",
925 reason: format!("duplicate group id {}", gc.id),
926 });
927 }
928 for &lid in &gc.lines {
929 if !line_id_set.contains(&lid) {
930 return Err(SimError::InvalidConfig {
931 field: "building.groups.lines",
932 reason: format!(
933 "group {} references non-existent line id {}",
934 gc.id, lid
935 ),
936 });
937 }
938 }
939 }
940
941 let referenced_line_ids: HashSet<u32> = group_configs
943 .iter()
944 .flat_map(|g| g.lines.iter().copied())
945 .collect();
946 for lc in line_configs {
947 if !referenced_line_ids.contains(&lc.id) {
948 return Err(SimError::InvalidConfig {
949 field: "building.lines",
950 reason: format!("line {} is not assigned to any group", lc.id),
951 });
952 }
953 }
954 }
955
956 Ok(())
957 }
958
959 pub fn set_dispatch(
975 &mut self,
976 group: GroupId,
977 strategy: Box<dyn DispatchStrategy>,
978 id: crate::dispatch::BuiltinStrategy,
979 ) {
980 let resolved_id = strategy.builtin_id().unwrap_or(id);
981 let mode = match &resolved_id {
982 BuiltinStrategy::Destination => Some(crate::dispatch::HallCallMode::Destination),
983 BuiltinStrategy::Custom(_) => None,
984 BuiltinStrategy::Scan
985 | BuiltinStrategy::Look
986 | BuiltinStrategy::NearestCar
987 | BuiltinStrategy::Etd
988 | BuiltinStrategy::Rsr => Some(crate::dispatch::HallCallMode::Classic),
989 };
990 if let Some(mode) = mode
991 && let Some(g) = self.groups.iter_mut().find(|g| g.id() == group)
992 {
993 g.set_hall_call_mode(mode);
994 }
995 self.dispatchers.insert(group, strategy);
996 self.strategy_ids.insert(group, resolved_id);
997 }
998
999 pub fn set_reposition(
1014 &mut self,
1015 group: GroupId,
1016 strategy: Box<dyn RepositionStrategy>,
1017 id: BuiltinReposition,
1018 ) {
1019 let resolved_id = strategy.builtin_id().unwrap_or(id);
1020 self.repositioners.insert(group, strategy);
1021 self.reposition_ids.insert(group, resolved_id);
1022 }
1023
1024 pub fn remove_reposition(&mut self, group: GroupId) {
1026 self.repositioners.remove(&group);
1027 self.reposition_ids.remove(&group);
1028 }
1029
1030 #[must_use]
1032 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
1033 self.reposition_ids.get(&group)
1034 }
1035
1036 pub fn add_before_hook(
1043 &mut self,
1044 phase: Phase,
1045 hook: impl Fn(&mut World) + Send + Sync + 'static,
1046 ) {
1047 self.hooks.add_before(phase, Box::new(hook));
1048 }
1049
1050 pub fn add_after_hook(
1055 &mut self,
1056 phase: Phase,
1057 hook: impl Fn(&mut World) + Send + Sync + 'static,
1058 ) {
1059 self.hooks.add_after(phase, Box::new(hook));
1060 }
1061
1062 pub fn add_before_group_hook(
1064 &mut self,
1065 phase: Phase,
1066 group: GroupId,
1067 hook: impl Fn(&mut World) + Send + Sync + 'static,
1068 ) {
1069 self.hooks.add_before_group(phase, group, Box::new(hook));
1070 }
1071
1072 pub fn add_after_group_hook(
1074 &mut self,
1075 phase: Phase,
1076 group: GroupId,
1077 hook: impl Fn(&mut World) + Send + Sync + 'static,
1078 ) {
1079 self.hooks.add_after_group(phase, group, Box::new(hook));
1080 }
1081}