Skip to main content

rustsim_spaces/
density.rs

1//! Spatial density grid and Level-of-Service (LoS) analysis for transportation.
2//!
3//! LoS is not a single metric -- the Highway Capacity Manual (HCM) defines
4//! separate criteria for different facility types and modes:
5//!
6//! | Criteria type | Input metric | Typical use |
7//! |---------------|-------------|-------------|
8//! | [`PedestrianWalkway`] | Density (pax/m2) | Sidewalks, corridors (Fruin) |
9//! | [`PedestrianStairway`] | Density (pax/m2) | Stairs, escalator approaches |
10//! | [`PedestrianQueuing`] | Density (pax/m2) | Waiting areas, platforms |
11//! | [`VehicularFreeway`] | Density (pc/km/ln) | Freeway segments |
12//! | [`VehicularUrbanStreet`] | Delay (s/veh) | Signalized intersections, arterials |
13//! | [`BicycleFacility`] | Events per min | Bike lanes, shared paths |
14//! | [`TransitCapacity`] | Load factor | Bus, rail passenger loading |
15//! | [`CustomLosCriteria`] | User-defined thresholds | Any metric |
16//!
17//! # Architecture
18//!
19//! The [`LosCriteria`] trait defines how a raw measurement is classified
20//! into a [`LosGrade`] (A through F). Built-in implementations cover
21//! the standard HCM facility types. Users can implement the trait for
22//! custom metrics.
23//!
24//! [`DensityGrid`] is a spatial grid that counts agents per cell and
25//! can classify each cell using any [`LosCriteria`] implementation.
26//!
27//! # Usage
28//!
29//! ```ignore
30//! use rustsim_spaces::density::*;
31//!
32//! let mut grid = DensityGrid::new(100.0, 50.0, 2.0);
33//!
34//! for agent in model.agents() {
35//!     grid.add_position(agent.x, agent.y);
36//! }
37//!
38//! // Pedestrian walkway LoS (Fruin / HCM)
39//! let los = PedestrianWalkway.classify(grid.density_at(10.0, 20.0));
40//!
41//! // Pedestrian stairway LoS
42//! let los = PedestrianStairway.classify(grid.density_at(5.0, 3.0));
43//!
44//! // Full statistics with a specific criteria
45//! let stats = grid.statistics(&PedestrianWalkway);
46//!
47//! // Vehicular delay-based LoS (not grid-based -- classify directly)
48//! let los = VehicularUrbanStreet.classify(35.0); // 35 s/veh delay
49//!
50//! grid.clear();
51//! ```
52
53// ---------------------------------------------------------------------------
54// LoS grade
55// ---------------------------------------------------------------------------
56
57use thiserror::Error;
58
59/// Level-of-Service grade A through F (HCM convention).
60///
61/// Ordered from best (A) to worst (F). The meaning of each grade
62/// depends on the facility type and the [`LosCriteria`] used.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub enum LosGrade {
65    /// Best operating conditions.
66    A,
67    /// Stable flow / minor conflicts.
68    B,
69    /// Stable flow with some restrictions.
70    C,
71    /// Approaching unstable flow.
72    D,
73    /// Unstable flow / significant congestion.
74    E,
75    /// Breakdown / forced flow.
76    F,
77}
78
79impl std::fmt::Display for LosGrade {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::A => write!(f, "A"),
83            Self::B => write!(f, "B"),
84            Self::C => write!(f, "C"),
85            Self::D => write!(f, "D"),
86            Self::E => write!(f, "E"),
87            Self::F => write!(f, "F"),
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// LosCriteria trait
94// ---------------------------------------------------------------------------
95
96/// Trait for classifying a raw measurement into a [`LosGrade`].
97///
98/// Each implementation encodes the HCM thresholds for a specific
99/// facility type and mode. The `value` parameter is the measurement
100/// (density, delay, events/min, load factor, etc.).
101pub trait LosCriteria {
102    /// Classify a measurement value into a LoS grade.
103    fn classify(&self, value: f64) -> LosGrade;
104
105    /// Human-readable name of this criteria type.
106    fn name(&self) -> &'static str;
107
108    /// Unit of the input measurement (e.g. "pax/m2", "s/veh").
109    fn unit(&self) -> &'static str;
110}
111
112// ---------------------------------------------------------------------------
113// Pedestrian walkway (Fruin / HCM Exhibit 24-1)
114// ---------------------------------------------------------------------------
115
116/// Pedestrian walkway LoS based on density (Fruin / HCM Chapter 24).
117///
118/// | Grade | Density (pax/m2) | Space (m2/pax) | Description |
119/// |-------|-----------------|----------------|-------------|
120/// | A     | <= 0.31         | >= 3.24        | Free flow, no conflicts |
121/// | B     | <= 0.43         | >= 2.32        | Minor conflicts |
122/// | C     | <= 0.72         | >= 1.39        | Restricted speed |
123/// | D     | <= 1.08         | >= 0.93        | Severely restricted |
124/// | E     | <= 2.17         | >= 0.46        | Shuffling gait |
125/// | F     | > 2.17          | < 0.46         | Breakdown |
126#[derive(Debug, Clone, Copy)]
127pub struct PedestrianWalkway;
128
129impl LosCriteria for PedestrianWalkway {
130    fn classify(&self, density: f64) -> LosGrade {
131        if density <= 0.31 {
132            LosGrade::A
133        } else if density <= 0.43 {
134            LosGrade::B
135        } else if density <= 0.72 {
136            LosGrade::C
137        } else if density <= 1.08 {
138            LosGrade::D
139        } else if density <= 2.17 {
140            LosGrade::E
141        } else {
142            LosGrade::F
143        }
144    }
145    fn name(&self) -> &'static str {
146        "Pedestrian Walkway"
147    }
148    fn unit(&self) -> &'static str {
149        "pax/m2"
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Pedestrian stairway (HCM Exhibit 24-2)
155// ---------------------------------------------------------------------------
156
157/// Pedestrian stairway LoS based on density (HCM Chapter 24).
158///
159/// Thresholds are tighter than walkways because stairways have
160/// reduced maneuvering space and slower speeds.
161///
162/// | Grade | Density (pax/m2) | Space (m2/pax) |
163/// |-------|-----------------|----------------|
164/// | A     | <= 0.54         | >= 1.85        |
165/// | B     | <= 0.72         | >= 1.39        |
166/// | C     | <= 1.08         | >= 0.93        |
167/// | D     | <= 1.54         | >= 0.65        |
168/// | E     | <= 2.70         | >= 0.37        |
169/// | F     | > 2.70          | < 0.37         |
170#[derive(Debug, Clone, Copy)]
171pub struct PedestrianStairway;
172
173impl LosCriteria for PedestrianStairway {
174    fn classify(&self, density: f64) -> LosGrade {
175        if density <= 0.54 {
176            LosGrade::A
177        } else if density <= 0.72 {
178            LosGrade::B
179        } else if density <= 1.08 {
180            LosGrade::C
181        } else if density <= 1.54 {
182            LosGrade::D
183        } else if density <= 2.70 {
184            LosGrade::E
185        } else {
186            LosGrade::F
187        }
188    }
189    fn name(&self) -> &'static str {
190        "Pedestrian Stairway"
191    }
192    fn unit(&self) -> &'static str {
193        "pax/m2"
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Pedestrian queuing (HCM Exhibit 24-3)
199// ---------------------------------------------------------------------------
200
201/// Pedestrian queuing area LoS based on density (HCM Chapter 24).
202///
203/// For waiting areas, platforms, ticket halls, etc.
204///
205/// | Grade | Density (pax/m2) | Space (m2/pax) |
206/// |-------|-----------------|----------------|
207/// | A     | <= 0.83         | >= 1.21        |
208/// | B     | <= 1.11         | >= 0.90        |
209/// | C     | <= 1.43         | >= 0.70        |
210/// | D     | <= 3.33         | >= 0.30        |
211/// | E     | <= 5.00         | >= 0.20        |
212/// | F     | > 5.00          | < 0.20         |
213#[derive(Debug, Clone, Copy)]
214pub struct PedestrianQueuing;
215
216impl LosCriteria for PedestrianQueuing {
217    fn classify(&self, density: f64) -> LosGrade {
218        if density <= 0.83 {
219            LosGrade::A
220        } else if density <= 1.11 {
221            LosGrade::B
222        } else if density <= 1.43 {
223            LosGrade::C
224        } else if density <= 3.33 {
225            LosGrade::D
226        } else if density <= 5.00 {
227            LosGrade::E
228        } else {
229            LosGrade::F
230        }
231    }
232    fn name(&self) -> &'static str {
233        "Pedestrian Queuing"
234    }
235    fn unit(&self) -> &'static str {
236        "pax/m2"
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Vehicular freeway (HCM Chapter 12, density-based)
242// ---------------------------------------------------------------------------
243
244/// Vehicular freeway LoS based on density (HCM Chapter 12).
245///
246/// Input: density in passenger cars per km per lane (pc/km/ln).
247///
248/// | Grade | Density (pc/km/ln) | Description |
249/// |-------|-------------------|-------------|
250/// | A     | <= 7              | Free flow   |
251/// | B     | <= 11             | Reasonably free flow |
252/// | C     | <= 16             | Stable flow |
253/// | D     | <= 22             | Approaching unstable |
254/// | E     | <= 28             | Unstable flow |
255/// | F     | > 28              | Forced / breakdown |
256#[derive(Debug, Clone, Copy)]
257pub struct VehicularFreeway;
258
259impl LosCriteria for VehicularFreeway {
260    fn classify(&self, density_pc_km_ln: f64) -> LosGrade {
261        if density_pc_km_ln <= 7.0 {
262            LosGrade::A
263        } else if density_pc_km_ln <= 11.0 {
264            LosGrade::B
265        } else if density_pc_km_ln <= 16.0 {
266            LosGrade::C
267        } else if density_pc_km_ln <= 22.0 {
268            LosGrade::D
269        } else if density_pc_km_ln <= 28.0 {
270            LosGrade::E
271        } else {
272            LosGrade::F
273        }
274    }
275    fn name(&self) -> &'static str {
276        "Vehicular Freeway"
277    }
278    fn unit(&self) -> &'static str {
279        "pc/km/ln"
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Vehicular urban street / signalized intersection (HCM Ch 16/19, delay-based)
285// ---------------------------------------------------------------------------
286
287/// Vehicular urban street and signalized intersection LoS based on
288/// control delay (HCM Chapters 16 and 19).
289///
290/// Input: average control delay in seconds per vehicle.
291///
292/// | Grade | Delay (s/veh) | Description |
293/// |-------|--------------|-------------|
294/// | A     | <= 10        | Free flow   |
295/// | B     | <= 20        | Stable flow |
296/// | C     | <= 35        | Stable, acceptable delay |
297/// | D     | <= 55        | Approaching unstable |
298/// | E     | <= 80        | Unstable, significant delay |
299/// | F     | > 80         | Forced flow / gridlock |
300#[derive(Debug, Clone, Copy)]
301pub struct VehicularUrbanStreet;
302
303impl LosCriteria for VehicularUrbanStreet {
304    fn classify(&self, delay_s: f64) -> LosGrade {
305        if delay_s <= 10.0 {
306            LosGrade::A
307        } else if delay_s <= 20.0 {
308            LosGrade::B
309        } else if delay_s <= 35.0 {
310            LosGrade::C
311        } else if delay_s <= 55.0 {
312            LosGrade::D
313        } else if delay_s <= 80.0 {
314            LosGrade::E
315        } else {
316            LosGrade::F
317        }
318    }
319    fn name(&self) -> &'static str {
320        "Vehicular Urban Street"
321    }
322    fn unit(&self) -> &'static str {
323        "s/veh"
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Unsignalized intersection (HCM Ch 20/21, delay-based)
329// ---------------------------------------------------------------------------
330
331/// Unsignalized intersection LoS based on control delay
332/// (HCM Chapters 20 and 21).
333///
334/// Input: average control delay in seconds per vehicle.
335///
336/// | Grade | Delay (s/veh) | Description |
337/// |-------|--------------|-------------|
338/// | A     | <= 10        | Little or no delay |
339/// | B     | <= 15        | Short delays |
340/// | C     | <= 25        | Average delays |
341/// | D     | <= 35        | Long delays |
342/// | E     | <= 50        | Very long delays |
343/// | F     | > 50         | Extreme delay / failure |
344#[derive(Debug, Clone, Copy)]
345pub struct VehicularUnsignalized;
346
347impl LosCriteria for VehicularUnsignalized {
348    fn classify(&self, delay_s: f64) -> LosGrade {
349        if delay_s <= 10.0 {
350            LosGrade::A
351        } else if delay_s <= 15.0 {
352            LosGrade::B
353        } else if delay_s <= 25.0 {
354            LosGrade::C
355        } else if delay_s <= 35.0 {
356            LosGrade::D
357        } else if delay_s <= 50.0 {
358            LosGrade::E
359        } else {
360            LosGrade::F
361        }
362    }
363    fn name(&self) -> &'static str {
364        "Vehicular Unsignalized Intersection"
365    }
366    fn unit(&self) -> &'static str {
367        "s/veh"
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Bicycle facility (HCM Ch 24, event-based)
373// ---------------------------------------------------------------------------
374
375/// Bicycle facility LoS based on hindrance events per minute
376/// (HCM Chapter 24).
377///
378/// Events include meetings, passings, and active conflicts with
379/// other bicyclists or pedestrians on shared-use paths.
380///
381/// | Grade | Events/min | Description |
382/// |-------|-----------|-------------|
383/// | A     | <= 10     | Few conflicts |
384/// | B     | <= 20     | Occasional conflicts |
385/// | C     | <= 30     | Frequent conflicts |
386/// | D     | <= 40     | Significant conflicts |
387/// | E     | <= 60     | Serious conflicts |
388/// | F     | > 60      | Very crowded, breakdown |
389#[derive(Debug, Clone, Copy)]
390pub struct BicycleFacility;
391
392impl LosCriteria for BicycleFacility {
393    fn classify(&self, events_per_min: f64) -> LosGrade {
394        if events_per_min <= 10.0 {
395            LosGrade::A
396        } else if events_per_min <= 20.0 {
397            LosGrade::B
398        } else if events_per_min <= 30.0 {
399            LosGrade::C
400        } else if events_per_min <= 40.0 {
401            LosGrade::D
402        } else if events_per_min <= 60.0 {
403            LosGrade::E
404        } else {
405            LosGrade::F
406        }
407    }
408    fn name(&self) -> &'static str {
409        "Bicycle Facility"
410    }
411    fn unit(&self) -> &'static str {
412        "events/min"
413    }
414}
415
416// ---------------------------------------------------------------------------
417// Transit capacity (TCQSM, load-factor-based)
418// ---------------------------------------------------------------------------
419
420/// Transit vehicle LoS based on passenger load factor
421/// (Transit Capacity and Quality of Service Manual).
422///
423/// Load factor = passengers / seats. Values > 1.0 mean standees.
424///
425/// | Grade | Load factor | Description |
426/// |-------|------------|-------------|
427/// | A     | <= 0.50    | Many empty seats |
428/// | B     | <= 0.75    | Some empty seats |
429/// | C     | <= 1.00    | All seats occupied |
430/// | D     | <= 1.25    | Comfortable standee load |
431/// | E     | <= 1.50    | Maximum schedule load |
432/// | F     | > 1.50     | Crush loading |
433#[derive(Debug, Clone, Copy)]
434pub struct TransitCapacity;
435
436impl LosCriteria for TransitCapacity {
437    fn classify(&self, load_factor: f64) -> LosGrade {
438        if load_factor <= 0.50 {
439            LosGrade::A
440        } else if load_factor <= 0.75 {
441            LosGrade::B
442        } else if load_factor <= 1.00 {
443            LosGrade::C
444        } else if load_factor <= 1.25 {
445            LosGrade::D
446        } else if load_factor <= 1.50 {
447            LosGrade::E
448        } else {
449            LosGrade::F
450        }
451    }
452    fn name(&self) -> &'static str {
453        "Transit Capacity"
454    }
455    fn unit(&self) -> &'static str {
456        "load factor"
457    }
458}
459
460// ---------------------------------------------------------------------------
461// Custom LoS criteria
462// ---------------------------------------------------------------------------
463
464/// Errors returned by custom LoS criteria validation.
465#[derive(Debug, Clone, Copy, PartialEq, Error)]
466pub enum LosCriteriaConfigError {
467    #[error("thresholds must be in strictly ascending order")]
468    NonAscendingThresholds,
469}
470
471/// User-defined LoS criteria with custom thresholds.
472#[derive(Debug, Clone)]
473pub struct CustomLosCriteria {
474    name: &'static str,
475    unit: &'static str,
476    thresholds: [f64; 5],
477}
478
479impl CustomLosCriteria {
480    /// Create custom LoS criteria.
481    pub fn new(
482        name: &'static str,
483        unit: &'static str,
484        thresholds: [f64; 5],
485    ) -> Result<Self, LosCriteriaConfigError> {
486        for i in 1..5 {
487            if thresholds[i] <= thresholds[i - 1] {
488                return Err(LosCriteriaConfigError::NonAscendingThresholds);
489            }
490        }
491        Ok(Self {
492            name,
493            unit,
494            thresholds,
495        })
496    }
497}
498
499impl LosCriteria for CustomLosCriteria {
500    fn classify(&self, value: f64) -> LosGrade {
501        if value <= self.thresholds[0] {
502            LosGrade::A
503        } else if value <= self.thresholds[1] {
504            LosGrade::B
505        } else if value <= self.thresholds[2] {
506            LosGrade::C
507        } else if value <= self.thresholds[3] {
508            LosGrade::D
509        } else if value <= self.thresholds[4] {
510            LosGrade::E
511        } else {
512            LosGrade::F
513        }
514    }
515    fn name(&self) -> &'static str {
516        self.name
517    }
518    fn unit(&self) -> &'static str {
519        self.unit
520    }
521}
522
523// ---------------------------------------------------------------------------
524// DensityStatistics
525// ---------------------------------------------------------------------------
526
527/// Summary statistics from a density grid.
528#[derive(Debug, Clone)]
529pub struct DensityStatistics {
530    /// Maximum density across all cells (agents per cell area).
531    pub max_density: f64,
532    /// Mean density across occupied cells.
533    pub mean_density: f64,
534    /// Number of cells with at least one agent.
535    pub occupied_cells: usize,
536    /// Total number of agents counted.
537    pub total_agents: usize,
538    /// Worst LoS grade observed.
539    pub worst_los: LosGrade,
540    /// Count of cells at each LoS grade (A=0, B=1, ..., F=5).
541    pub los_distribution: [usize; 6],
542}
543
544// ---------------------------------------------------------------------------
545// DensityGrid
546// ---------------------------------------------------------------------------
547
548/// Errors returned by density-grid validation.
549#[derive(Debug, Clone, Copy, PartialEq, Error)]
550pub enum DensityGridError {
551    #[error("extent must be positive")]
552    InvalidExtent,
553    #[error("cell_size must be positive")]
554    InvalidCellSize,
555}
556
557/// Spatial density grid for computing agents-per-square-meter.
558#[derive(Debug, Clone)]
559pub struct DensityGrid {
560    extent_x: f64,
561    extent_y: f64,
562    cell_size: f64,
563    cell_area: f64,
564    grid_w: usize,
565    grid_h: usize,
566    counts: Vec<u32>,
567    total_agents: usize,
568}
569
570impl DensityGrid {
571    /// Create a new density grid.
572    pub fn new(extent_x: f64, extent_y: f64, cell_size: f64) -> Result<Self, DensityGridError> {
573        if extent_x <= 0.0 || extent_y <= 0.0 {
574            return Err(DensityGridError::InvalidExtent);
575        }
576        if cell_size <= 0.0 {
577            return Err(DensityGridError::InvalidCellSize);
578        }
579        let grid_w = (extent_x / cell_size).ceil() as usize;
580        let grid_h = (extent_y / cell_size).ceil() as usize;
581        Ok(Self {
582            extent_x,
583            extent_y,
584            cell_size,
585            cell_area: cell_size * cell_size,
586            grid_w,
587            grid_h,
588            counts: vec![0; grid_w * grid_h],
589            total_agents: 0,
590        })
591    }
592
593    /// Grid dimensions as `(width_cells, height_cells)`.
594    pub fn grid_dimensions(&self) -> (usize, usize) {
595        (self.grid_w, self.grid_h)
596    }
597
598    /// Cell side length in meters.
599    pub fn cell_size(&self) -> f64 {
600        self.cell_size
601    }
602
603    /// Space extent as `(extent_x, extent_y)`.
604    pub fn extent(&self) -> (f64, f64) {
605        (self.extent_x, self.extent_y)
606    }
607
608    /// Register an agent's position.
609    ///
610    /// Positions outside the extent are clamped to the nearest edge cell.
611    pub fn add_position(&mut self, x: f64, y: f64) {
612        let cx = ((x / self.cell_size).floor() as usize).min(self.grid_w.saturating_sub(1));
613        let cy = ((y / self.cell_size).floor() as usize).min(self.grid_h.saturating_sub(1));
614        let idx = cy * self.grid_w + cx;
615        self.counts[idx] += 1;
616        self.total_agents += 1;
617    }
618
619    /// Register multiple agent positions from an iterator of `(x, y)` pairs.
620    pub fn add_positions(&mut self, positions: impl IntoIterator<Item = (f64, f64)>) {
621        for (x, y) in positions {
622            self.add_position(x, y);
623        }
624    }
625
626    /// Agent count in the cell containing `(x, y)`.
627    pub fn count_at(&self, x: f64, y: f64) -> u32 {
628        let cx = ((x / self.cell_size).floor() as usize).min(self.grid_w.saturating_sub(1));
629        let cy = ((y / self.cell_size).floor() as usize).min(self.grid_h.saturating_sub(1));
630        self.counts[cy * self.grid_w + cx]
631    }
632
633    /// Density (agents per cell area) in the cell containing `(x, y)`.
634    pub fn density_at(&self, x: f64, y: f64) -> f64 {
635        self.count_at(x, y) as f64 / self.cell_area
636    }
637
638    /// LoS grade for the cell containing `(x, y)` using the given criteria.
639    pub fn los_at(&self, x: f64, y: f64, criteria: &dyn LosCriteria) -> LosGrade {
640        criteria.classify(self.density_at(x, y))
641    }
642
643    /// Density for a specific cell index `(cx, cy)`.
644    pub fn density_at_cell(&self, cx: usize, cy: usize) -> f64 {
645        if cx >= self.grid_w || cy >= self.grid_h {
646            return 0.0;
647        }
648        self.counts[cy * self.grid_w + cx] as f64 / self.cell_area
649    }
650
651    /// Agent count for a specific cell index `(cx, cy)`.
652    pub fn count_at_cell(&self, cx: usize, cy: usize) -> u32 {
653        if cx >= self.grid_w || cy >= self.grid_h {
654            return 0;
655        }
656        self.counts[cy * self.grid_w + cx]
657    }
658
659    /// Maximum density across all cells.
660    pub fn max_density(&self) -> f64 {
661        self.counts.iter().copied().max().unwrap_or(0) as f64 / self.cell_area
662    }
663
664    /// Mean density across occupied cells only.
665    ///
666    /// Returns 0.0 if no cells are occupied.
667    pub fn mean_density_occupied(&self) -> f64 {
668        let occupied: Vec<u32> = self.counts.iter().copied().filter(|&c| c > 0).collect();
669        if occupied.is_empty() {
670            return 0.0;
671        }
672        let sum: u32 = occupied.iter().sum();
673        (sum as f64 / occupied.len() as f64) / self.cell_area
674    }
675
676    /// Mean density across all cells.
677    pub fn mean_density_all(&self) -> f64 {
678        let total_area = self.grid_w as f64 * self.grid_h as f64 * self.cell_area;
679        if total_area == 0.0 {
680            return 0.0;
681        }
682        self.total_agents as f64 / total_area
683    }
684
685    /// Compute full statistics using the given LoS criteria.
686    pub fn statistics(&self, criteria: &dyn LosCriteria) -> DensityStatistics {
687        let mut max_count: u32 = 0;
688        let mut occupied_cells = 0usize;
689        let mut los_distribution = [0usize; 6];
690
691        for &count in &self.counts {
692            if count > 0 {
693                occupied_cells += 1;
694                if count > max_count {
695                    max_count = count;
696                }
697                let density = count as f64 / self.cell_area;
698                let los = criteria.classify(density);
699                let idx = match los {
700                    LosGrade::A => 0,
701                    LosGrade::B => 1,
702                    LosGrade::C => 2,
703                    LosGrade::D => 3,
704                    LosGrade::E => 4,
705                    LosGrade::F => 5,
706                };
707                los_distribution[idx] += 1;
708            }
709        }
710
711        let max_density = max_count as f64 / self.cell_area;
712        let worst_los = criteria.classify(max_density);
713
714        let mean_density = if occupied_cells > 0 {
715            (self.total_agents as f64 / occupied_cells as f64) / self.cell_area
716        } else {
717            0.0
718        };
719
720        DensityStatistics {
721            max_density,
722            mean_density,
723            occupied_cells,
724            total_agents: self.total_agents,
725            worst_los,
726            los_distribution,
727        }
728    }
729
730    /// Raw counts slice (row-major: `counts[cy * grid_w + cx]`).
731    pub fn counts(&self) -> &[u32] {
732        &self.counts
733    }
734
735    /// Total number of agents registered.
736    pub fn total_agents(&self) -> usize {
737        self.total_agents
738    }
739
740    /// Clear all counts for the next time step.
741    pub fn clear(&mut self) {
742        self.counts.fill(0);
743        self.total_agents = 0;
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    #[test]
752    fn pedestrian_walkway_thresholds() {
753        let c = PedestrianWalkway;
754        assert_eq!(c.classify(0.0), LosGrade::A);
755        assert_eq!(c.classify(0.31), LosGrade::A);
756        assert_eq!(c.classify(0.32), LosGrade::B);
757        assert_eq!(c.classify(0.43), LosGrade::B);
758        assert_eq!(c.classify(0.44), LosGrade::C);
759        assert_eq!(c.classify(0.72), LosGrade::C);
760        assert_eq!(c.classify(0.73), LosGrade::D);
761        assert_eq!(c.classify(1.08), LosGrade::D);
762        assert_eq!(c.classify(1.09), LosGrade::E);
763        assert_eq!(c.classify(2.17), LosGrade::E);
764        assert_eq!(c.classify(2.18), LosGrade::F);
765        assert_eq!(c.classify(10.0), LosGrade::F);
766    }
767
768    #[test]
769    fn pedestrian_stairway_thresholds() {
770        let c = PedestrianStairway;
771        assert_eq!(c.classify(0.5), LosGrade::A);
772        assert_eq!(c.classify(0.55), LosGrade::B);
773        assert_eq!(c.classify(0.73), LosGrade::C);
774        assert_eq!(c.classify(1.1), LosGrade::D);
775        assert_eq!(c.classify(1.55), LosGrade::E);
776        assert_eq!(c.classify(3.0), LosGrade::F);
777    }
778
779    #[test]
780    fn pedestrian_queuing_thresholds() {
781        let c = PedestrianQueuing;
782        assert_eq!(c.classify(0.5), LosGrade::A);
783        assert_eq!(c.classify(0.9), LosGrade::B);
784        assert_eq!(c.classify(1.2), LosGrade::C);
785        assert_eq!(c.classify(2.0), LosGrade::D);
786        assert_eq!(c.classify(4.0), LosGrade::E);
787        assert_eq!(c.classify(6.0), LosGrade::F);
788    }
789
790    #[test]
791    fn vehicular_freeway_thresholds() {
792        let c = VehicularFreeway;
793        assert_eq!(c.classify(5.0), LosGrade::A);
794        assert_eq!(c.classify(9.0), LosGrade::B);
795        assert_eq!(c.classify(14.0), LosGrade::C);
796        assert_eq!(c.classify(20.0), LosGrade::D);
797        assert_eq!(c.classify(26.0), LosGrade::E);
798        assert_eq!(c.classify(35.0), LosGrade::F);
799    }
800
801    #[test]
802    fn vehicular_urban_street_thresholds() {
803        let c = VehicularUrbanStreet;
804        assert_eq!(c.classify(5.0), LosGrade::A);
805        assert_eq!(c.classify(15.0), LosGrade::B);
806        assert_eq!(c.classify(30.0), LosGrade::C);
807        assert_eq!(c.classify(45.0), LosGrade::D);
808        assert_eq!(c.classify(70.0), LosGrade::E);
809        assert_eq!(c.classify(100.0), LosGrade::F);
810    }
811
812    #[test]
813    fn vehicular_unsignalized_thresholds() {
814        let c = VehicularUnsignalized;
815        assert_eq!(c.classify(5.0), LosGrade::A);
816        assert_eq!(c.classify(12.0), LosGrade::B);
817        assert_eq!(c.classify(20.0), LosGrade::C);
818        assert_eq!(c.classify(30.0), LosGrade::D);
819        assert_eq!(c.classify(45.0), LosGrade::E);
820        assert_eq!(c.classify(60.0), LosGrade::F);
821    }
822
823    #[test]
824    fn bicycle_facility_thresholds() {
825        let c = BicycleFacility;
826        assert_eq!(c.classify(5.0), LosGrade::A);
827        assert_eq!(c.classify(15.0), LosGrade::B);
828        assert_eq!(c.classify(25.0), LosGrade::C);
829        assert_eq!(c.classify(35.0), LosGrade::D);
830        assert_eq!(c.classify(55.0), LosGrade::E);
831        assert_eq!(c.classify(70.0), LosGrade::F);
832    }
833
834    #[test]
835    fn transit_capacity_thresholds() {
836        let c = TransitCapacity;
837        assert_eq!(c.classify(0.3), LosGrade::A);
838        assert_eq!(c.classify(0.6), LosGrade::B);
839        assert_eq!(c.classify(0.9), LosGrade::C);
840        assert_eq!(c.classify(1.1), LosGrade::D);
841        assert_eq!(c.classify(1.4), LosGrade::E);
842        assert_eq!(c.classify(2.0), LosGrade::F);
843    }
844
845    #[test]
846    fn custom_criteria() {
847        let c = CustomLosCriteria::new("Test", "units", [1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
848        assert_eq!(c.classify(0.5), LosGrade::A);
849        assert_eq!(c.classify(1.5), LosGrade::B);
850        assert_eq!(c.classify(2.5), LosGrade::C);
851        assert_eq!(c.classify(3.5), LosGrade::D);
852        assert_eq!(c.classify(4.5), LosGrade::E);
853        assert_eq!(c.classify(5.5), LosGrade::F);
854        assert_eq!(c.name(), "Test");
855        assert_eq!(c.unit(), "units");
856    }
857
858    #[test]
859    fn custom_criteria_bad_order() {
860        let err = CustomLosCriteria::new("Bad", "x", [1.0, 3.0, 2.0, 4.0, 5.0]).unwrap_err();
861        assert_eq!(err, LosCriteriaConfigError::NonAscendingThresholds);
862    }
863
864    #[test]
865    fn basic_density() {
866        let mut grid = DensityGrid::new(10.0, 10.0, 1.0).unwrap();
867        grid.add_position(0.5, 0.5);
868        grid.add_position(0.1, 0.9);
869        grid.add_position(0.3, 0.3);
870        grid.add_position(0.9, 0.1);
871
872        assert_eq!(grid.count_at(0.5, 0.5), 4);
873        assert!((grid.density_at(0.5, 0.5) - 4.0).abs() < 1e-9);
874        assert_eq!(grid.los_at(0.5, 0.5, &PedestrianWalkway), LosGrade::F);
875    }
876
877    #[test]
878    fn empty_cell_density() {
879        let grid = DensityGrid::new(10.0, 10.0, 1.0).unwrap();
880        assert_eq!(grid.count_at(5.0, 5.0), 0);
881        assert!((grid.density_at(5.0, 5.0)).abs() < 1e-9);
882        assert_eq!(grid.los_at(5.0, 5.0, &PedestrianWalkway), LosGrade::A);
883    }
884
885    #[test]
886    fn statistics_with_walkway_criteria() {
887        let mut grid = DensityGrid::new(10.0, 10.0, 2.0).unwrap();
888        // 2 agents in one cell: density = 2/4 = 0.5 pax/m2 -> LoS C (walkway)
889        grid.add_position(1.0, 1.0);
890        grid.add_position(1.5, 1.5);
891        // 1 agent in another cell: density = 1/4 = 0.25 pax/m2 -> LoS A
892        grid.add_position(5.0, 5.0);
893
894        let stats = grid.statistics(&PedestrianWalkway);
895        assert_eq!(stats.total_agents, 3);
896        assert_eq!(stats.occupied_cells, 2);
897        assert!((stats.max_density - 0.5).abs() < 1e-9);
898        assert_eq!(stats.worst_los, LosGrade::C);
899        assert_eq!(stats.los_distribution[0], 1); // A
900        assert_eq!(stats.los_distribution[2], 1); // C
901    }
902
903    #[test]
904    fn statistics_with_queuing_criteria() {
905        let mut grid = DensityGrid::new(10.0, 10.0, 2.0).unwrap();
906        // Same data as above, but with queuing criteria:
907        // 0.5 pax/m2 -> LoS A for queuing (threshold is 0.83)
908        grid.add_position(1.0, 1.0);
909        grid.add_position(1.5, 1.5);
910        grid.add_position(5.0, 5.0);
911
912        let stats = grid.statistics(&PedestrianQueuing);
913        assert_eq!(stats.worst_los, LosGrade::A); // 0.5 < 0.83
914        assert_eq!(stats.los_distribution[0], 2); // both cells are A
915    }
916
917    #[test]
918    fn different_criteria_same_density() {
919        // 0.6 pax/m2 is:
920        //   Walkway: C (0.43 < 0.6 <= 0.72)
921        //   Stairway: B (0.54 < 0.6 <= 0.72)
922        //   Queuing: A (0.6 <= 0.83)
923        assert_eq!(PedestrianWalkway.classify(0.6), LosGrade::C);
924        assert_eq!(PedestrianStairway.classify(0.6), LosGrade::B);
925        assert_eq!(PedestrianQueuing.classify(0.6), LosGrade::A);
926    }
927
928    #[test]
929    fn clear_resets() {
930        let mut grid = DensityGrid::new(10.0, 10.0, 1.0).unwrap();
931        grid.add_position(0.5, 0.5);
932        assert_eq!(grid.total_agents(), 1);
933        grid.clear();
934        assert_eq!(grid.total_agents(), 0);
935        assert_eq!(grid.count_at(0.5, 0.5), 0);
936    }
937
938    #[test]
939    fn add_positions_batch() {
940        let mut grid = DensityGrid::new(10.0, 10.0, 1.0).unwrap();
941        let positions = vec![(0.5, 0.5), (0.1, 0.1), (5.0, 5.0)];
942        grid.add_positions(positions);
943        assert_eq!(grid.total_agents(), 3);
944        assert_eq!(grid.count_at(0.5, 0.5), 2);
945        assert_eq!(grid.count_at(5.0, 5.0), 1);
946    }
947
948    #[test]
949    fn clamping_out_of_bounds() {
950        let mut grid = DensityGrid::new(10.0, 10.0, 1.0).unwrap();
951        grid.add_position(-5.0, -5.0);
952        assert_eq!(grid.count_at(0.0, 0.0), 1);
953        grid.add_position(100.0, 100.0);
954        assert_eq!(grid.count_at(9.9, 9.9), 1);
955    }
956
957    #[test]
958    fn los_grade_ordering() {
959        assert!(LosGrade::A < LosGrade::B);
960        assert!(LosGrade::B < LosGrade::C);
961        assert!(LosGrade::E < LosGrade::F);
962    }
963
964    #[test]
965    fn los_grade_display() {
966        assert_eq!(format!("{}", LosGrade::A), "A");
967        assert_eq!(format!("{}", LosGrade::F), "F");
968    }
969
970    #[test]
971    fn criteria_metadata() {
972        assert_eq!(PedestrianWalkway.name(), "Pedestrian Walkway");
973        assert_eq!(PedestrianWalkway.unit(), "pax/m2");
974        assert_eq!(VehicularUrbanStreet.name(), "Vehicular Urban Street");
975        assert_eq!(VehicularUrbanStreet.unit(), "s/veh");
976        assert_eq!(TransitCapacity.name(), "Transit Capacity");
977        assert_eq!(TransitCapacity.unit(), "load factor");
978    }
979}