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