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
45#[allow(clippy::too_many_arguments)]
51pub(super) fn validate_elevator_physics(
52 max_speed: f64,
53 acceleration: f64,
54 deceleration: f64,
55 weight_capacity: f64,
56 inspection_speed_factor: f64,
57 door_transition_ticks: u32,
58 door_open_ticks: u32,
59 bypass_load_up_pct: Option<f64>,
60 bypass_load_down_pct: Option<f64>,
61) -> Result<(), SimError> {
62 if !max_speed.is_finite() || max_speed <= 0.0 {
63 return Err(SimError::InvalidConfig {
64 field: "elevators.max_speed",
65 reason: format!("must be finite and positive, got {max_speed}"),
66 });
67 }
68 if !acceleration.is_finite() || acceleration <= 0.0 {
69 return Err(SimError::InvalidConfig {
70 field: "elevators.acceleration",
71 reason: format!("must be finite and positive, got {acceleration}"),
72 });
73 }
74 if !deceleration.is_finite() || deceleration <= 0.0 {
75 return Err(SimError::InvalidConfig {
76 field: "elevators.deceleration",
77 reason: format!("must be finite and positive, got {deceleration}"),
78 });
79 }
80 if !weight_capacity.is_finite() || weight_capacity <= 0.0 {
81 return Err(SimError::InvalidConfig {
82 field: "elevators.weight_capacity",
83 reason: format!("must be finite and positive, got {weight_capacity}"),
84 });
85 }
86 if !inspection_speed_factor.is_finite() || inspection_speed_factor <= 0.0 {
87 return Err(SimError::InvalidConfig {
88 field: "elevators.inspection_speed_factor",
89 reason: format!("must be finite and positive, got {inspection_speed_factor}"),
90 });
91 }
92 if door_transition_ticks == 0 {
93 return Err(SimError::InvalidConfig {
94 field: "elevators.door_transition_ticks",
95 reason: "must be > 0".into(),
96 });
97 }
98 if door_open_ticks == 0 {
99 return Err(SimError::InvalidConfig {
100 field: "elevators.door_open_ticks",
101 reason: "must be > 0".into(),
102 });
103 }
104 validate_bypass_pct("elevators.bypass_load_up_pct", bypass_load_up_pct)?;
105 validate_bypass_pct("elevators.bypass_load_down_pct", bypass_load_down_pct)?;
106 Ok(())
107}
108
109fn validate_bypass_pct(field: &'static str, pct: Option<f64>) -> Result<(), SimError> {
114 let Some(pct) = pct else {
115 return Ok(());
116 };
117 if !pct.is_finite() || pct <= 0.0 || pct > 1.0 {
118 return Err(SimError::InvalidConfig {
119 field,
120 reason: format!("must be finite in (0.0, 1.0] when set, got {pct}"),
121 });
122 }
123 Ok(())
124}
125
126impl Simulation {
127 pub fn new(
138 config: &SimConfig,
139 dispatch: impl DispatchStrategy + 'static,
140 ) -> Result<Self, SimError> {
141 let mut dispatchers = BTreeMap::new();
142 dispatchers.insert(GroupId(0), Box::new(dispatch) as Box<dyn DispatchStrategy>);
143 Self::new_with_hooks(config, dispatchers, PhaseHooks::default())
144 }
145
146 #[allow(clippy::too_many_lines)]
150 pub(crate) fn new_with_hooks(
151 config: &SimConfig,
152 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
153 hooks: PhaseHooks,
154 ) -> Result<Self, SimError> {
155 Self::validate_config(config)?;
156
157 let mut world = World::new();
158
159 let mut stop_lookup: HashMap<StopId, EntityId> = HashMap::new();
161 for sc in &config.building.stops {
162 let eid = world.spawn();
163 world.set_stop(
164 eid,
165 Stop {
166 name: sc.name.clone(),
167 position: sc.position,
168 },
169 );
170 world.set_position(eid, Position { value: sc.position });
171 stop_lookup.insert(sc.id, eid);
172 }
173
174 let mut sorted: Vec<(f64, EntityId)> = world
176 .iter_stops()
177 .map(|(eid, stop)| (stop.position, eid))
178 .collect();
179 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
180 world.insert_resource(crate::world::SortedStops(sorted));
181
182 world.insert_resource(crate::arrival_log::ArrivalLog::default());
186 world.insert_resource(crate::arrival_log::CurrentTick::default());
187 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
188 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
192 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
198
199 let (groups, dispatchers, strategy_ids) = if let Some(line_configs) = &config.building.lines
200 {
201 Self::build_explicit_topology(
202 &mut world,
203 config,
204 line_configs,
205 &stop_lookup,
206 builder_dispatchers,
207 )
208 } else {
209 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
210 };
211
212 let dt = 1.0 / config.simulation.ticks_per_second;
213
214 world.insert_resource(crate::tagged_metrics::MetricTags::default());
215
216 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
219 .iter()
220 .flat_map(|group| {
221 group.lines().iter().filter_map(|li| {
222 let line_comp = world.line(li.entity())?;
223 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
224 })
225 })
226 .collect();
227
228 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
230 for (line_eid, name, elevators) in &line_tag_info {
231 let tag = format!("line:{name}");
232 tags.tag(*line_eid, tag.clone());
233 for elev_eid in elevators {
234 tags.tag(*elev_eid, tag.clone());
235 }
236 }
237 }
238
239 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
241 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
242 if let Some(group_configs) = &config.building.groups {
243 for gc in group_configs {
244 if let Some(ref repo_id) = gc.reposition
245 && let Some(strategy) = repo_id.instantiate()
246 {
247 let gid = GroupId(gc.id);
248 repositioners.insert(gid, strategy);
249 reposition_ids.insert(gid, repo_id.clone());
250 }
251 }
252 }
253
254 Ok(Self {
255 world,
256 events: EventBus::default(),
257 pending_output: Vec::new(),
258 tick: 0,
259 dt,
260 groups,
261 stop_lookup,
262 dispatchers,
263 strategy_ids,
264 repositioners,
265 reposition_ids,
266 metrics: Metrics::new(),
267 time: TimeAdapter::new(config.simulation.ticks_per_second),
268 hooks,
269 elevator_ids_buf: Vec::new(),
270 reposition_buf: Vec::new(),
271 topo_graph: Mutex::new(TopologyGraph::new()),
272 rider_index: RiderIndex::default(),
273 tick_in_progress: false,
274 })
275 }
276
277 fn spawn_elevator_entity(
283 world: &mut World,
284 ec: &crate::config::ElevatorConfig,
285 line: EntityId,
286 stop_lookup: &HashMap<StopId, EntityId>,
287 start_pos_lookup: &[crate::stop::StopConfig],
288 ) -> EntityId {
289 let eid = world.spawn();
290 let start_pos = start_pos_lookup
291 .iter()
292 .find(|s| s.id == ec.starting_stop)
293 .map_or(0.0, |s| s.position);
294 world.set_position(eid, Position { value: start_pos });
295 world.set_velocity(eid, Velocity { value: 0.0 });
296 let restricted: HashSet<EntityId> = ec
297 .restricted_stops
298 .iter()
299 .filter_map(|sid| stop_lookup.get(sid).copied())
300 .collect();
301 world.set_elevator(
302 eid,
303 Elevator {
304 phase: ElevatorPhase::Idle,
305 door: DoorState::Closed,
306 max_speed: ec.max_speed,
307 acceleration: ec.acceleration,
308 deceleration: ec.deceleration,
309 weight_capacity: ec.weight_capacity,
310 current_load: crate::components::Weight::ZERO,
311 riders: Vec::new(),
312 target_stop: None,
313 door_transition_ticks: ec.door_transition_ticks,
314 door_open_ticks: ec.door_open_ticks,
315 line,
316 repositioning: false,
317 restricted_stops: restricted,
318 inspection_speed_factor: ec.inspection_speed_factor,
319 going_up: true,
320 going_down: true,
321 move_count: 0,
322 door_command_queue: Vec::new(),
323 manual_target_velocity: None,
324 bypass_load_up_pct: ec.bypass_load_up_pct,
325 bypass_load_down_pct: ec.bypass_load_down_pct,
326 },
327 );
328 #[cfg(feature = "energy")]
329 if let Some(ref profile) = ec.energy_profile {
330 world.set_energy_profile(eid, profile.clone());
331 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
332 }
333 if let Some(mode) = ec.service_mode {
334 world.set_service_mode(eid, mode);
335 }
336 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
337 eid
338 }
339
340 fn build_legacy_topology(
342 world: &mut World,
343 config: &SimConfig,
344 stop_lookup: &HashMap<StopId, EntityId>,
345 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
346 ) -> TopologyResult {
347 let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
348 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
349 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
350 let max_pos = stop_positions
351 .iter()
352 .copied()
353 .fold(f64::NEG_INFINITY, f64::max);
354
355 let default_line_eid = world.spawn();
356 world.set_line(
357 default_line_eid,
358 Line {
359 name: "Default".into(),
360 group: GroupId(0),
361 orientation: Orientation::Vertical,
362 position: None,
363 min_position: min_pos,
364 max_position: max_pos,
365 max_cars: None,
366 },
367 );
368
369 let mut elevator_entities = Vec::new();
370 for ec in &config.elevators {
371 let eid = Self::spawn_elevator_entity(
372 world,
373 ec,
374 default_line_eid,
375 stop_lookup,
376 &config.building.stops,
377 );
378 elevator_entities.push(eid);
379 }
380
381 let default_line_info =
382 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
383
384 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
385
386 let mut dispatchers = BTreeMap::new();
393 let mut strategy_ids = BTreeMap::new();
394 let user_dispatcher = builder_dispatchers
395 .into_iter()
396 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
397 if let Some(d) = user_dispatcher {
398 dispatchers.insert(GroupId(0), d);
399 } else {
400 dispatchers.insert(
401 GroupId(0),
402 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
403 );
404 }
405 strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
411
412 (vec![group], dispatchers, strategy_ids)
413 }
414
415 #[allow(clippy::too_many_lines)]
417 fn build_explicit_topology(
418 world: &mut World,
419 config: &SimConfig,
420 line_configs: &[crate::config::LineConfig],
421 stop_lookup: &HashMap<StopId, EntityId>,
422 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
423 ) -> TopologyResult {
424 let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
426
427 for lc in line_configs {
428 let served_entities: Vec<EntityId> = lc
430 .serves
431 .iter()
432 .filter_map(|sid| stop_lookup.get(sid).copied())
433 .collect();
434
435 let stop_positions: Vec<f64> = lc
437 .serves
438 .iter()
439 .filter_map(|sid| {
440 config
441 .building
442 .stops
443 .iter()
444 .find(|s| s.id == *sid)
445 .map(|s| s.position)
446 })
447 .collect();
448 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
449 let auto_max = stop_positions
450 .iter()
451 .copied()
452 .fold(f64::NEG_INFINITY, f64::max);
453
454 let min_pos = lc.min_position.unwrap_or(auto_min);
455 let max_pos = lc.max_position.unwrap_or(auto_max);
456
457 let line_eid = world.spawn();
458 world.set_line(
461 line_eid,
462 Line {
463 name: lc.name.clone(),
464 group: GroupId(0),
465 orientation: lc.orientation,
466 position: lc.position,
467 min_position: min_pos,
468 max_position: max_pos,
469 max_cars: lc.max_cars,
470 },
471 );
472
473 let mut elevator_entities = Vec::new();
475 for ec in &lc.elevators {
476 let eid = Self::spawn_elevator_entity(
477 world,
478 ec,
479 line_eid,
480 stop_lookup,
481 &config.building.stops,
482 );
483 elevator_entities.push(eid);
484 }
485
486 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
487 line_map.insert(lc.id, (line_eid, line_info));
488 }
489
490 let group_configs = config.building.groups.as_deref();
492 let mut groups = Vec::new();
493 let mut dispatchers = BTreeMap::new();
494 let mut strategy_ids = BTreeMap::new();
495
496 if let Some(gcs) = group_configs {
497 for gc in gcs {
498 let group_id = GroupId(gc.id);
499
500 let mut group_lines = Vec::new();
501
502 for &lid in &gc.lines {
503 if let Some((line_eid, li)) = line_map.get(&lid) {
504 if let Some(line_comp) = world.line_mut(*line_eid) {
506 line_comp.group = group_id;
507 }
508 group_lines.push(li.clone());
509 }
510 }
511
512 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
513 if let Some(mode) = gc.hall_call_mode {
514 group.set_hall_call_mode(mode);
515 }
516 if let Some(ticks) = gc.ack_latency_ticks {
517 group.set_ack_latency_ticks(ticks);
518 }
519 groups.push(group);
520
521 let dispatch: Box<dyn DispatchStrategy> = gc
523 .dispatch
524 .instantiate()
525 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
526 dispatchers.insert(group_id, dispatch);
527 strategy_ids.insert(group_id, gc.dispatch.clone());
528 }
529 } else {
530 let group_id = GroupId(0);
532 let mut group_lines = Vec::new();
533
534 for (line_eid, li) in line_map.values() {
535 if let Some(line_comp) = world.line_mut(*line_eid) {
536 line_comp.group = group_id;
537 }
538 group_lines.push(li.clone());
539 }
540
541 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
542 groups.push(group);
543
544 let dispatch: Box<dyn DispatchStrategy> =
545 Box::new(crate::dispatch::scan::ScanDispatch::new());
546 dispatchers.insert(group_id, dispatch);
547 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
548 }
549
550 for (gid, d) in builder_dispatchers {
557 dispatchers.insert(gid, d);
558 strategy_ids
565 .entry(gid)
566 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
567 }
568
569 (groups, dispatchers, strategy_ids)
570 }
571
572 #[allow(clippy::too_many_arguments)]
574 pub(crate) fn from_parts(
575 world: World,
576 tick: u64,
577 dt: f64,
578 groups: Vec<ElevatorGroup>,
579 stop_lookup: HashMap<StopId, EntityId>,
580 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
581 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
582 metrics: Metrics,
583 ticks_per_second: f64,
584 ) -> Self {
585 let mut rider_index = RiderIndex::default();
586 rider_index.rebuild(&world);
587 let mut world = world;
593 world.insert_resource(crate::time::TickRate(ticks_per_second));
594 if world
602 .resource::<crate::traffic_detector::TrafficDetector>()
603 .is_none()
604 {
605 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
606 }
607 if world
612 .resource::<crate::arrival_log::DestinationLog>()
613 .is_none()
614 {
615 world.insert_resource(crate::arrival_log::DestinationLog::default());
616 }
617 Self {
618 world,
619 events: EventBus::default(),
620 pending_output: Vec::new(),
621 tick,
622 dt,
623 groups,
624 stop_lookup,
625 dispatchers,
626 strategy_ids,
627 repositioners: BTreeMap::new(),
628 reposition_ids: BTreeMap::new(),
629 metrics,
630 time: TimeAdapter::new(ticks_per_second),
631 hooks: PhaseHooks::default(),
632 elevator_ids_buf: Vec::new(),
633 reposition_buf: Vec::new(),
634 topo_graph: Mutex::new(TopologyGraph::new()),
635 rider_index,
636 tick_in_progress: false,
637 }
638 }
639
640 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
642 if config.building.stops.is_empty() {
643 return Err(SimError::InvalidConfig {
644 field: "building.stops",
645 reason: "at least one stop is required".into(),
646 });
647 }
648
649 let mut seen_ids = HashSet::new();
651 for stop in &config.building.stops {
652 if !seen_ids.insert(stop.id) {
653 return Err(SimError::InvalidConfig {
654 field: "building.stops",
655 reason: format!("duplicate {}", stop.id),
656 });
657 }
658 if !stop.position.is_finite() {
659 return Err(SimError::InvalidConfig {
660 field: "building.stops.position",
661 reason: format!("{} has non-finite position {}", stop.id, stop.position),
662 });
663 }
664 }
665
666 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
667
668 if let Some(line_configs) = &config.building.lines {
669 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
671 } else {
672 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
674 }
675
676 if !config.simulation.ticks_per_second.is_finite()
677 || config.simulation.ticks_per_second <= 0.0
678 {
679 return Err(SimError::InvalidConfig {
680 field: "simulation.ticks_per_second",
681 reason: format!(
682 "must be finite and positive, got {}",
683 config.simulation.ticks_per_second
684 ),
685 });
686 }
687
688 Self::validate_passenger_spawning(&config.passenger_spawning)?;
689
690 Ok(())
691 }
692
693 fn validate_passenger_spawning(
698 spawn: &crate::config::PassengerSpawnConfig,
699 ) -> Result<(), SimError> {
700 let (lo, hi) = spawn.weight_range;
701 if !lo.is_finite() || !hi.is_finite() {
702 return Err(SimError::InvalidConfig {
703 field: "passenger_spawning.weight_range",
704 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
705 });
706 }
707 if lo < 0.0 || hi < 0.0 {
708 return Err(SimError::InvalidConfig {
709 field: "passenger_spawning.weight_range",
710 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
711 });
712 }
713 if lo > hi {
714 return Err(SimError::InvalidConfig {
715 field: "passenger_spawning.weight_range",
716 reason: format!("min must be <= max, got ({lo}, {hi})"),
717 });
718 }
719 if spawn.mean_interval_ticks == 0 {
720 return Err(SimError::InvalidConfig {
721 field: "passenger_spawning.mean_interval_ticks",
722 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
723 every catch-up tick"
724 .into(),
725 });
726 }
727 Ok(())
728 }
729
730 fn validate_legacy_elevators(
732 elevators: &[crate::config::ElevatorConfig],
733 building: &crate::config::BuildingConfig,
734 ) -> Result<(), SimError> {
735 if elevators.is_empty() {
736 return Err(SimError::InvalidConfig {
737 field: "elevators",
738 reason: "at least one elevator is required".into(),
739 });
740 }
741
742 for elev in elevators {
743 Self::validate_elevator_config(elev, building)?;
744 }
745
746 Ok(())
747 }
748
749 fn validate_elevator_config(
751 elev: &crate::config::ElevatorConfig,
752 building: &crate::config::BuildingConfig,
753 ) -> Result<(), SimError> {
754 validate_elevator_physics(
755 elev.max_speed.value(),
756 elev.acceleration.value(),
757 elev.deceleration.value(),
758 elev.weight_capacity.value(),
759 elev.inspection_speed_factor,
760 elev.door_transition_ticks,
761 elev.door_open_ticks,
762 elev.bypass_load_up_pct,
763 elev.bypass_load_down_pct,
764 )?;
765 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
766 return Err(SimError::InvalidConfig {
767 field: "elevators.starting_stop",
768 reason: format!("references non-existent {}", elev.starting_stop),
769 });
770 }
771 Ok(())
772 }
773
774 fn validate_explicit_topology(
776 line_configs: &[crate::config::LineConfig],
777 stop_ids: &HashSet<StopId>,
778 building: &crate::config::BuildingConfig,
779 ) -> Result<(), SimError> {
780 let mut seen_line_ids = HashSet::new();
782 for lc in line_configs {
783 if !seen_line_ids.insert(lc.id) {
784 return Err(SimError::InvalidConfig {
785 field: "building.lines",
786 reason: format!("duplicate line id {}", lc.id),
787 });
788 }
789 }
790
791 for lc in line_configs {
793 if lc.serves.is_empty() {
794 return Err(SimError::InvalidConfig {
795 field: "building.lines.serves",
796 reason: format!("line {} has no stops", lc.id),
797 });
798 }
799 for sid in &lc.serves {
800 if !stop_ids.contains(sid) {
801 return Err(SimError::InvalidConfig {
802 field: "building.lines.serves",
803 reason: format!("line {} references non-existent {}", lc.id, sid),
804 });
805 }
806 }
807 for ec in &lc.elevators {
809 Self::validate_elevator_config(ec, building)?;
810 }
811
812 if let Some(max) = lc.max_cars
814 && lc.elevators.len() > max
815 {
816 return Err(SimError::InvalidConfig {
817 field: "building.lines.max_cars",
818 reason: format!(
819 "line {} has {} elevators but max_cars is {max}",
820 lc.id,
821 lc.elevators.len()
822 ),
823 });
824 }
825 }
826
827 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
829 if !has_elevator {
830 return Err(SimError::InvalidConfig {
831 field: "building.lines",
832 reason: "at least one line must have at least one elevator".into(),
833 });
834 }
835
836 let served: HashSet<StopId> = line_configs
838 .iter()
839 .flat_map(|lc| lc.serves.iter().copied())
840 .collect();
841 for sid in stop_ids {
842 if !served.contains(sid) {
843 return Err(SimError::InvalidConfig {
844 field: "building.lines",
845 reason: format!("orphaned stop {sid} not served by any line"),
846 });
847 }
848 }
849
850 if let Some(group_configs) = &building.groups {
852 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
853
854 let mut seen_group_ids = HashSet::new();
855 for gc in group_configs {
856 if !seen_group_ids.insert(gc.id) {
857 return Err(SimError::InvalidConfig {
858 field: "building.groups",
859 reason: format!("duplicate group id {}", gc.id),
860 });
861 }
862 for &lid in &gc.lines {
863 if !line_id_set.contains(&lid) {
864 return Err(SimError::InvalidConfig {
865 field: "building.groups.lines",
866 reason: format!(
867 "group {} references non-existent line id {}",
868 gc.id, lid
869 ),
870 });
871 }
872 }
873 }
874
875 let referenced_line_ids: HashSet<u32> = group_configs
877 .iter()
878 .flat_map(|g| g.lines.iter().copied())
879 .collect();
880 for lc in line_configs {
881 if !referenced_line_ids.contains(&lc.id) {
882 return Err(SimError::InvalidConfig {
883 field: "building.lines",
884 reason: format!("line {} is not assigned to any group", lc.id),
885 });
886 }
887 }
888 }
889
890 Ok(())
891 }
892
893 pub fn set_dispatch(
900 &mut self,
901 group: GroupId,
902 strategy: Box<dyn DispatchStrategy>,
903 id: crate::dispatch::BuiltinStrategy,
904 ) {
905 self.dispatchers.insert(group, strategy);
906 self.strategy_ids.insert(group, id);
907 }
908
909 pub fn set_reposition(
916 &mut self,
917 group: GroupId,
918 strategy: Box<dyn RepositionStrategy>,
919 id: BuiltinReposition,
920 ) {
921 self.repositioners.insert(group, strategy);
922 self.reposition_ids.insert(group, id);
923 }
924
925 pub fn remove_reposition(&mut self, group: GroupId) {
927 self.repositioners.remove(&group);
928 self.reposition_ids.remove(&group);
929 }
930
931 #[must_use]
933 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
934 self.reposition_ids.get(&group)
935 }
936
937 pub fn add_before_hook(
944 &mut self,
945 phase: Phase,
946 hook: impl Fn(&mut World) + Send + Sync + 'static,
947 ) {
948 self.hooks.add_before(phase, Box::new(hook));
949 }
950
951 pub fn add_after_hook(
956 &mut self,
957 phase: Phase,
958 hook: impl Fn(&mut World) + Send + Sync + 'static,
959 ) {
960 self.hooks.add_after(phase, Box::new(hook));
961 }
962
963 pub fn add_before_group_hook(
965 &mut self,
966 phase: Phase,
967 group: GroupId,
968 hook: impl Fn(&mut World) + Send + Sync + 'static,
969 ) {
970 self.hooks.add_before_group(phase, group, Box::new(hook));
971 }
972
973 pub fn add_after_group_hook(
975 &mut self,
976 phase: Phase,
977 group: GroupId,
978 hook: impl Fn(&mut World) + Send + Sync + 'static,
979 ) {
980 self.hooks.add_after_group(phase, group, Box::new(hook));
981 }
982}