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