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