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());
188 world.insert_resource(crate::arrival_log::DestinationLog::default());
189 world.insert_resource(crate::arrival_log::CurrentTick::default());
190 world.insert_resource(crate::arrival_log::ArrivalLogRetention::default());
191 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
195 world.insert_resource(crate::time::TickRate(config.simulation.ticks_per_second));
201
202 let (groups, dispatchers, strategy_ids) = if let Some(line_configs) = &config.building.lines
203 {
204 Self::build_explicit_topology(
205 &mut world,
206 config,
207 line_configs,
208 &stop_lookup,
209 builder_dispatchers,
210 )
211 } else {
212 Self::build_legacy_topology(&mut world, config, &stop_lookup, builder_dispatchers)
213 };
214
215 let dt = 1.0 / config.simulation.ticks_per_second;
216
217 world.insert_resource(crate::tagged_metrics::MetricTags::default());
218
219 let line_tag_info: Vec<(EntityId, String, Vec<EntityId>)> = groups
222 .iter()
223 .flat_map(|group| {
224 group.lines().iter().filter_map(|li| {
225 let line_comp = world.line(li.entity())?;
226 Some((li.entity(), line_comp.name.clone(), li.elevators().to_vec()))
227 })
228 })
229 .collect();
230
231 if let Some(tags) = world.resource_mut::<crate::tagged_metrics::MetricTags>() {
233 for (line_eid, name, elevators) in &line_tag_info {
234 let tag = format!("line:{name}");
235 tags.tag(*line_eid, tag.clone());
236 for elev_eid in elevators {
237 tags.tag(*elev_eid, tag.clone());
238 }
239 }
240 }
241
242 let mut repositioners: BTreeMap<GroupId, Box<dyn RepositionStrategy>> = BTreeMap::new();
244 let mut reposition_ids: BTreeMap<GroupId, BuiltinReposition> = BTreeMap::new();
245 if let Some(group_configs) = &config.building.groups {
246 for gc in group_configs {
247 if let Some(ref repo_id) = gc.reposition
248 && let Some(strategy) = repo_id.instantiate()
249 {
250 let gid = GroupId(gc.id);
251 repositioners.insert(gid, strategy);
252 reposition_ids.insert(gid, repo_id.clone());
253 }
254 }
255 }
256
257 Ok(Self {
258 world,
259 events: EventBus::default(),
260 pending_output: Vec::new(),
261 tick: 0,
262 dt,
263 groups,
264 stop_lookup,
265 dispatchers,
266 strategy_ids,
267 repositioners,
268 reposition_ids,
269 metrics: Metrics::new(),
270 time: TimeAdapter::new(config.simulation.ticks_per_second),
271 hooks,
272 elevator_ids_buf: Vec::new(),
273 reposition_buf: Vec::new(),
274 topo_graph: Mutex::new(TopologyGraph::new()),
275 rider_index: RiderIndex::default(),
276 tick_in_progress: false,
277 })
278 }
279
280 fn spawn_elevator_entity(
286 world: &mut World,
287 ec: &crate::config::ElevatorConfig,
288 line: EntityId,
289 stop_lookup: &HashMap<StopId, EntityId>,
290 start_pos_lookup: &[crate::stop::StopConfig],
291 ) -> EntityId {
292 let eid = world.spawn();
293 let start_pos = start_pos_lookup
294 .iter()
295 .find(|s| s.id == ec.starting_stop)
296 .map_or(0.0, |s| s.position);
297 world.set_position(eid, Position { value: start_pos });
298 world.set_velocity(eid, Velocity { value: 0.0 });
299 let restricted: HashSet<EntityId> = ec
300 .restricted_stops
301 .iter()
302 .filter_map(|sid| stop_lookup.get(sid).copied())
303 .collect();
304 world.set_elevator(
305 eid,
306 Elevator {
307 phase: ElevatorPhase::Idle,
308 door: DoorState::Closed,
309 max_speed: ec.max_speed,
310 acceleration: ec.acceleration,
311 deceleration: ec.deceleration,
312 weight_capacity: ec.weight_capacity,
313 current_load: crate::components::Weight::ZERO,
314 riders: Vec::new(),
315 target_stop: None,
316 door_transition_ticks: ec.door_transition_ticks,
317 door_open_ticks: ec.door_open_ticks,
318 line,
319 repositioning: false,
320 restricted_stops: restricted,
321 inspection_speed_factor: ec.inspection_speed_factor,
322 going_up: true,
323 going_down: true,
324 move_count: 0,
325 door_command_queue: Vec::new(),
326 manual_target_velocity: None,
327 bypass_load_up_pct: ec.bypass_load_up_pct,
328 bypass_load_down_pct: ec.bypass_load_down_pct,
329 },
330 );
331 #[cfg(feature = "energy")]
332 if let Some(ref profile) = ec.energy_profile {
333 world.set_energy_profile(eid, profile.clone());
334 world.set_energy_metrics(eid, crate::energy::EnergyMetrics::default());
335 }
336 if let Some(mode) = ec.service_mode {
337 world.set_service_mode(eid, mode);
338 }
339 world.set_destination_queue(eid, crate::components::DestinationQueue::new());
340 eid
341 }
342
343 fn build_legacy_topology(
345 world: &mut World,
346 config: &SimConfig,
347 stop_lookup: &HashMap<StopId, EntityId>,
348 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
349 ) -> TopologyResult {
350 let all_stop_entities: Vec<EntityId> = stop_lookup.values().copied().collect();
351 let stop_positions: Vec<f64> = config.building.stops.iter().map(|s| s.position).collect();
352 let min_pos = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
353 let max_pos = stop_positions
354 .iter()
355 .copied()
356 .fold(f64::NEG_INFINITY, f64::max);
357
358 let default_line_eid = world.spawn();
359 world.set_line(
360 default_line_eid,
361 Line {
362 name: "Default".into(),
363 group: GroupId(0),
364 orientation: Orientation::Vertical,
365 position: None,
366 min_position: min_pos,
367 max_position: max_pos,
368 max_cars: None,
369 },
370 );
371
372 let mut elevator_entities = Vec::new();
373 for ec in &config.elevators {
374 let eid = Self::spawn_elevator_entity(
375 world,
376 ec,
377 default_line_eid,
378 stop_lookup,
379 &config.building.stops,
380 );
381 elevator_entities.push(eid);
382 }
383
384 let default_line_info =
385 LineInfo::new(default_line_eid, elevator_entities, all_stop_entities);
386
387 let group = ElevatorGroup::new(GroupId(0), "Default".into(), vec![default_line_info]);
388
389 let mut dispatchers = BTreeMap::new();
396 let mut strategy_ids = BTreeMap::new();
397 let user_dispatcher = builder_dispatchers
398 .into_iter()
399 .find_map(|(gid, d)| if gid == GroupId(0) { Some(d) } else { None });
400 if let Some(d) = user_dispatcher {
401 dispatchers.insert(GroupId(0), d);
402 } else {
403 dispatchers.insert(
404 GroupId(0),
405 Box::new(crate::dispatch::scan::ScanDispatch::new()) as Box<dyn DispatchStrategy>,
406 );
407 }
408 strategy_ids.insert(GroupId(0), BuiltinStrategy::Scan);
414
415 (vec![group], dispatchers, strategy_ids)
416 }
417
418 #[allow(clippy::too_many_lines)]
420 fn build_explicit_topology(
421 world: &mut World,
422 config: &SimConfig,
423 line_configs: &[crate::config::LineConfig],
424 stop_lookup: &HashMap<StopId, EntityId>,
425 builder_dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
426 ) -> TopologyResult {
427 let mut line_map: HashMap<u32, (EntityId, LineInfo)> = HashMap::new();
429
430 for lc in line_configs {
431 let served_entities: Vec<EntityId> = lc
433 .serves
434 .iter()
435 .filter_map(|sid| stop_lookup.get(sid).copied())
436 .collect();
437
438 let stop_positions: Vec<f64> = lc
440 .serves
441 .iter()
442 .filter_map(|sid| {
443 config
444 .building
445 .stops
446 .iter()
447 .find(|s| s.id == *sid)
448 .map(|s| s.position)
449 })
450 .collect();
451 let auto_min = stop_positions.iter().copied().fold(f64::INFINITY, f64::min);
452 let auto_max = stop_positions
453 .iter()
454 .copied()
455 .fold(f64::NEG_INFINITY, f64::max);
456
457 let min_pos = lc.min_position.unwrap_or(auto_min);
458 let max_pos = lc.max_position.unwrap_or(auto_max);
459
460 let line_eid = world.spawn();
461 world.set_line(
464 line_eid,
465 Line {
466 name: lc.name.clone(),
467 group: GroupId(0),
468 orientation: lc.orientation,
469 position: lc.position,
470 min_position: min_pos,
471 max_position: max_pos,
472 max_cars: lc.max_cars,
473 },
474 );
475
476 let mut elevator_entities = Vec::new();
478 for ec in &lc.elevators {
479 let eid = Self::spawn_elevator_entity(
480 world,
481 ec,
482 line_eid,
483 stop_lookup,
484 &config.building.stops,
485 );
486 elevator_entities.push(eid);
487 }
488
489 let line_info = LineInfo::new(line_eid, elevator_entities, served_entities);
490 line_map.insert(lc.id, (line_eid, line_info));
491 }
492
493 let group_configs = config.building.groups.as_deref();
495 let mut groups = Vec::new();
496 let mut dispatchers = BTreeMap::new();
497 let mut strategy_ids = BTreeMap::new();
498
499 if let Some(gcs) = group_configs {
500 for gc in gcs {
501 let group_id = GroupId(gc.id);
502
503 let mut group_lines = Vec::new();
504
505 for &lid in &gc.lines {
506 if let Some((line_eid, li)) = line_map.get(&lid) {
507 if let Some(line_comp) = world.line_mut(*line_eid) {
509 line_comp.group = group_id;
510 }
511 group_lines.push(li.clone());
512 }
513 }
514
515 let mut group = ElevatorGroup::new(group_id, gc.name.clone(), group_lines);
516 if let Some(mode) = gc.hall_call_mode {
517 group.set_hall_call_mode(mode);
518 }
519 if let Some(ticks) = gc.ack_latency_ticks {
520 group.set_ack_latency_ticks(ticks);
521 }
522 groups.push(group);
523
524 let dispatch: Box<dyn DispatchStrategy> = gc
526 .dispatch
527 .instantiate()
528 .unwrap_or_else(|| Box::new(crate::dispatch::scan::ScanDispatch::new()));
529 dispatchers.insert(group_id, dispatch);
530 strategy_ids.insert(group_id, gc.dispatch.clone());
531 }
532 } else {
533 let group_id = GroupId(0);
535 let mut group_lines = Vec::new();
536
537 for (line_eid, li) in line_map.values() {
538 if let Some(line_comp) = world.line_mut(*line_eid) {
539 line_comp.group = group_id;
540 }
541 group_lines.push(li.clone());
542 }
543
544 let group = ElevatorGroup::new(group_id, "Default".into(), group_lines);
545 groups.push(group);
546
547 let dispatch: Box<dyn DispatchStrategy> =
548 Box::new(crate::dispatch::scan::ScanDispatch::new());
549 dispatchers.insert(group_id, dispatch);
550 strategy_ids.insert(group_id, BuiltinStrategy::Scan);
551 }
552
553 for (gid, d) in builder_dispatchers {
560 dispatchers.insert(gid, d);
561 strategy_ids
568 .entry(gid)
569 .or_insert_with(|| BuiltinStrategy::Custom("user-supplied".into()));
570 }
571
572 (groups, dispatchers, strategy_ids)
573 }
574
575 #[allow(clippy::too_many_arguments)]
577 pub(crate) fn from_parts(
578 world: World,
579 tick: u64,
580 dt: f64,
581 groups: Vec<ElevatorGroup>,
582 stop_lookup: HashMap<StopId, EntityId>,
583 dispatchers: BTreeMap<GroupId, Box<dyn DispatchStrategy>>,
584 strategy_ids: BTreeMap<GroupId, crate::dispatch::BuiltinStrategy>,
585 metrics: Metrics,
586 ticks_per_second: f64,
587 ) -> Self {
588 let mut rider_index = RiderIndex::default();
589 rider_index.rebuild(&world);
590 let mut world = world;
596 world.insert_resource(crate::time::TickRate(ticks_per_second));
597 if world
605 .resource::<crate::traffic_detector::TrafficDetector>()
606 .is_none()
607 {
608 world.insert_resource(crate::traffic_detector::TrafficDetector::default());
609 }
610 if world
615 .resource::<crate::arrival_log::DestinationLog>()
616 .is_none()
617 {
618 world.insert_resource(crate::arrival_log::DestinationLog::default());
619 }
620 Self {
621 world,
622 events: EventBus::default(),
623 pending_output: Vec::new(),
624 tick,
625 dt,
626 groups,
627 stop_lookup,
628 dispatchers,
629 strategy_ids,
630 repositioners: BTreeMap::new(),
631 reposition_ids: BTreeMap::new(),
632 metrics,
633 time: TimeAdapter::new(ticks_per_second),
634 hooks: PhaseHooks::default(),
635 elevator_ids_buf: Vec::new(),
636 reposition_buf: Vec::new(),
637 topo_graph: Mutex::new(TopologyGraph::new()),
638 rider_index,
639 tick_in_progress: false,
640 }
641 }
642
643 pub(crate) fn validate_config(config: &SimConfig) -> Result<(), SimError> {
645 if config.building.stops.is_empty() {
646 return Err(SimError::InvalidConfig {
647 field: "building.stops",
648 reason: "at least one stop is required".into(),
649 });
650 }
651
652 let mut seen_ids = HashSet::new();
654 for stop in &config.building.stops {
655 if !seen_ids.insert(stop.id) {
656 return Err(SimError::InvalidConfig {
657 field: "building.stops",
658 reason: format!("duplicate {}", stop.id),
659 });
660 }
661 if !stop.position.is_finite() {
662 return Err(SimError::InvalidConfig {
663 field: "building.stops.position",
664 reason: format!("{} has non-finite position {}", stop.id, stop.position),
665 });
666 }
667 }
668
669 let stop_ids: HashSet<StopId> = config.building.stops.iter().map(|s| s.id).collect();
670
671 if let Some(line_configs) = &config.building.lines {
672 Self::validate_explicit_topology(line_configs, &stop_ids, &config.building)?;
674 } else {
675 Self::validate_legacy_elevators(&config.elevators, &config.building)?;
677 }
678
679 if !config.simulation.ticks_per_second.is_finite()
680 || config.simulation.ticks_per_second <= 0.0
681 {
682 return Err(SimError::InvalidConfig {
683 field: "simulation.ticks_per_second",
684 reason: format!(
685 "must be finite and positive, got {}",
686 config.simulation.ticks_per_second
687 ),
688 });
689 }
690
691 Self::validate_passenger_spawning(&config.passenger_spawning)?;
692
693 Ok(())
694 }
695
696 fn validate_passenger_spawning(
701 spawn: &crate::config::PassengerSpawnConfig,
702 ) -> Result<(), SimError> {
703 let (lo, hi) = spawn.weight_range;
704 if !lo.is_finite() || !hi.is_finite() {
705 return Err(SimError::InvalidConfig {
706 field: "passenger_spawning.weight_range",
707 reason: format!("both endpoints must be finite, got ({lo}, {hi})"),
708 });
709 }
710 if lo < 0.0 || hi < 0.0 {
711 return Err(SimError::InvalidConfig {
712 field: "passenger_spawning.weight_range",
713 reason: format!("both endpoints must be non-negative, got ({lo}, {hi})"),
714 });
715 }
716 if lo > hi {
717 return Err(SimError::InvalidConfig {
718 field: "passenger_spawning.weight_range",
719 reason: format!("min must be <= max, got ({lo}, {hi})"),
720 });
721 }
722 if spawn.mean_interval_ticks == 0 {
723 return Err(SimError::InvalidConfig {
724 field: "passenger_spawning.mean_interval_ticks",
725 reason: "must be > 0; mean_interval_ticks=0 burst-fires \
726 every catch-up tick"
727 .into(),
728 });
729 }
730 Ok(())
731 }
732
733 fn validate_legacy_elevators(
735 elevators: &[crate::config::ElevatorConfig],
736 building: &crate::config::BuildingConfig,
737 ) -> Result<(), SimError> {
738 if elevators.is_empty() {
739 return Err(SimError::InvalidConfig {
740 field: "elevators",
741 reason: "at least one elevator is required".into(),
742 });
743 }
744
745 for elev in elevators {
746 Self::validate_elevator_config(elev, building)?;
747 }
748
749 Ok(())
750 }
751
752 fn validate_elevator_config(
754 elev: &crate::config::ElevatorConfig,
755 building: &crate::config::BuildingConfig,
756 ) -> Result<(), SimError> {
757 validate_elevator_physics(
758 elev.max_speed.value(),
759 elev.acceleration.value(),
760 elev.deceleration.value(),
761 elev.weight_capacity.value(),
762 elev.inspection_speed_factor,
763 elev.door_transition_ticks,
764 elev.door_open_ticks,
765 elev.bypass_load_up_pct,
766 elev.bypass_load_down_pct,
767 )?;
768 if !building.stops.iter().any(|s| s.id == elev.starting_stop) {
769 return Err(SimError::InvalidConfig {
770 field: "elevators.starting_stop",
771 reason: format!("references non-existent {}", elev.starting_stop),
772 });
773 }
774 Ok(())
775 }
776
777 fn validate_explicit_topology(
779 line_configs: &[crate::config::LineConfig],
780 stop_ids: &HashSet<StopId>,
781 building: &crate::config::BuildingConfig,
782 ) -> Result<(), SimError> {
783 let mut seen_line_ids = HashSet::new();
785 for lc in line_configs {
786 if !seen_line_ids.insert(lc.id) {
787 return Err(SimError::InvalidConfig {
788 field: "building.lines",
789 reason: format!("duplicate line id {}", lc.id),
790 });
791 }
792 }
793
794 for lc in line_configs {
796 if lc.serves.is_empty() {
797 return Err(SimError::InvalidConfig {
798 field: "building.lines.serves",
799 reason: format!("line {} has no stops", lc.id),
800 });
801 }
802 for sid in &lc.serves {
803 if !stop_ids.contains(sid) {
804 return Err(SimError::InvalidConfig {
805 field: "building.lines.serves",
806 reason: format!("line {} references non-existent {}", lc.id, sid),
807 });
808 }
809 }
810 for ec in &lc.elevators {
812 Self::validate_elevator_config(ec, building)?;
813 }
814
815 if let Some(max) = lc.max_cars
817 && lc.elevators.len() > max
818 {
819 return Err(SimError::InvalidConfig {
820 field: "building.lines.max_cars",
821 reason: format!(
822 "line {} has {} elevators but max_cars is {max}",
823 lc.id,
824 lc.elevators.len()
825 ),
826 });
827 }
828 }
829
830 let has_elevator = line_configs.iter().any(|lc| !lc.elevators.is_empty());
832 if !has_elevator {
833 return Err(SimError::InvalidConfig {
834 field: "building.lines",
835 reason: "at least one line must have at least one elevator".into(),
836 });
837 }
838
839 let served: HashSet<StopId> = line_configs
841 .iter()
842 .flat_map(|lc| lc.serves.iter().copied())
843 .collect();
844 for sid in stop_ids {
845 if !served.contains(sid) {
846 return Err(SimError::InvalidConfig {
847 field: "building.lines",
848 reason: format!("orphaned stop {sid} not served by any line"),
849 });
850 }
851 }
852
853 if let Some(group_configs) = &building.groups {
855 let line_id_set: HashSet<u32> = line_configs.iter().map(|lc| lc.id).collect();
856
857 let mut seen_group_ids = HashSet::new();
858 for gc in group_configs {
859 if !seen_group_ids.insert(gc.id) {
860 return Err(SimError::InvalidConfig {
861 field: "building.groups",
862 reason: format!("duplicate group id {}", gc.id),
863 });
864 }
865 for &lid in &gc.lines {
866 if !line_id_set.contains(&lid) {
867 return Err(SimError::InvalidConfig {
868 field: "building.groups.lines",
869 reason: format!(
870 "group {} references non-existent line id {}",
871 gc.id, lid
872 ),
873 });
874 }
875 }
876 }
877
878 let referenced_line_ids: HashSet<u32> = group_configs
880 .iter()
881 .flat_map(|g| g.lines.iter().copied())
882 .collect();
883 for lc in line_configs {
884 if !referenced_line_ids.contains(&lc.id) {
885 return Err(SimError::InvalidConfig {
886 field: "building.lines",
887 reason: format!("line {} is not assigned to any group", lc.id),
888 });
889 }
890 }
891 }
892
893 Ok(())
894 }
895
896 pub fn set_dispatch(
903 &mut self,
904 group: GroupId,
905 strategy: Box<dyn DispatchStrategy>,
906 id: crate::dispatch::BuiltinStrategy,
907 ) {
908 self.dispatchers.insert(group, strategy);
909 self.strategy_ids.insert(group, id);
910 }
911
912 pub fn set_reposition(
919 &mut self,
920 group: GroupId,
921 strategy: Box<dyn RepositionStrategy>,
922 id: BuiltinReposition,
923 ) {
924 self.repositioners.insert(group, strategy);
925 self.reposition_ids.insert(group, id);
926 }
927
928 pub fn remove_reposition(&mut self, group: GroupId) {
930 self.repositioners.remove(&group);
931 self.reposition_ids.remove(&group);
932 }
933
934 #[must_use]
936 pub fn reposition_id(&self, group: GroupId) -> Option<&BuiltinReposition> {
937 self.reposition_ids.get(&group)
938 }
939
940 pub fn add_before_hook(
947 &mut self,
948 phase: Phase,
949 hook: impl Fn(&mut World) + Send + Sync + 'static,
950 ) {
951 self.hooks.add_before(phase, Box::new(hook));
952 }
953
954 pub fn add_after_hook(
959 &mut self,
960 phase: Phase,
961 hook: impl Fn(&mut World) + Send + Sync + 'static,
962 ) {
963 self.hooks.add_after(phase, Box::new(hook));
964 }
965
966 pub fn add_before_group_hook(
968 &mut self,
969 phase: Phase,
970 group: GroupId,
971 hook: impl Fn(&mut World) + Send + Sync + 'static,
972 ) {
973 self.hooks.add_before_group(phase, group, Box::new(hook));
974 }
975
976 pub fn add_after_group_hook(
978 &mut self,
979 phase: Phase,
980 group: GroupId,
981 hook: impl Fn(&mut World) + Send + Sync + 'static,
982 ) {
983 self.hooks.add_after_group(phase, group, Box::new(hook));
984 }
985}