use super::layout::{Arrangement, StreetLayout};
use crate::standards::DesignResult;
use crate::Eulumdat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OptimizerObjective {
PoleCountPerKm,
TotalFluxPerKm,
SafetyMargin,
}
impl OptimizerObjective {
pub fn as_str(self) -> &'static str {
match self {
Self::PoleCountPerKm => "Pole count per km",
Self::TotalFluxPerKm => "Total flux per km",
Self::SafetyMargin => "Safety margin (min/avg)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OptimizerBounds {
pub pole_spacing_m: (f64, f64),
pub pole_spacing_step_m: f64,
pub mounting_height_m: (f64, f64),
pub mounting_height_step_m: f64,
pub arrangements: &'static [Arrangement],
pub maintenance_factor: f64,
}
impl Default for OptimizerBounds {
fn default() -> Self {
Self {
pole_spacing_m: (15.0, 60.0),
pole_spacing_step_m: 5.0,
mounting_height_m: (4.0, 15.0),
mounting_height_step_m: 1.0,
arrangements: &[
Arrangement::SingleSide,
Arrangement::Opposite,
Arrangement::Staggered,
],
maintenance_factor: 0.8,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct OptimizationCandidate {
pub pole_spacing_m: f64,
pub mounting_height_m: f64,
pub arrangement: Arrangement,
pub design: DesignResult,
pub cost: f64,
pub poles_per_km: f64,
pub flux_per_km: f64,
}
pub fn optimize_layout(
ldc: &Eulumdat,
base: &StreetLayout,
bounds: &OptimizerBounds,
objective: OptimizerObjective,
max_results: usize,
fit: impl Fn(&DesignResult) -> bool,
) -> Vec<OptimizationCandidate> {
let mut passing = optimize_layout_all(ldc, base, bounds, objective, fit);
passing.sort_by(|a, b| {
a.cost
.partial_cmp(&b.cost)
.unwrap_or(std::cmp::Ordering::Equal)
});
passing.truncate(max_results);
passing
}
pub fn optimize_layout_all(
ldc: &Eulumdat,
base: &StreetLayout,
bounds: &OptimizerBounds,
objective: OptimizerObjective,
fit: impl Fn(&DesignResult) -> bool,
) -> Vec<OptimizationCandidate> {
let arrangements = if bounds.arrangements.is_empty() {
&[
Arrangement::SingleSide,
Arrangement::Opposite,
Arrangement::Staggered,
][..]
} else {
bounds.arrangements
};
let luminaire_flux_lm: f64 = ldc
.lamp_sets
.iter()
.map(|ls| ls.total_luminous_flux * ls.num_lamps.unsigned_abs() as f64)
.sum();
let spacings = float_range(
bounds.pole_spacing_m.0,
bounds.pole_spacing_m.1,
bounds.pole_spacing_step_m,
);
let heights = float_range(
bounds.mounting_height_m.0,
bounds.mounting_height_m.1,
bounds.mounting_height_step_m,
);
let mut passing: Vec<OptimizationCandidate> = Vec::new();
for &arrangement in arrangements {
for &spacing in &spacings {
for &height in &heights {
let mut candidate = base.clone();
candidate.pole_spacing_m = spacing;
candidate.mounting_height_m = height;
candidate.arrangement = arrangement;
let area = candidate.compute(ldc, bounds.maintenance_factor);
let design = candidate.design_result(&area);
if !fit(&design) {
continue;
}
let poles_per_km = poles_per_km(spacing, arrangement);
let flux_per_km = poles_per_km * luminaire_flux_lm;
let cost = match objective {
OptimizerObjective::PoleCountPerKm => poles_per_km,
OptimizerObjective::TotalFluxPerKm => flux_per_km,
OptimizerObjective::SafetyMargin => -design.uniformity_overall,
};
passing.push(OptimizationCandidate {
pole_spacing_m: spacing,
mounting_height_m: height,
arrangement,
design,
cost,
poles_per_km,
flux_per_km,
});
}
}
}
passing
}
pub fn pareto_front_tradeoff(candidates: &[OptimizationCandidate]) -> Vec<usize> {
let mut front: Vec<usize> = (0..candidates.len()).collect();
front.retain(|&i| {
let a = &candidates[i];
!candidates.iter().enumerate().any(|(j, b)| {
i != j
&& b.poles_per_km <= a.poles_per_km
&& b.design.avg_illuminance_lux >= a.design.avg_illuminance_lux
&& (b.poles_per_km < a.poles_per_km
|| b.design.avg_illuminance_lux > a.design.avg_illuminance_lux)
})
});
front.sort_by(|&i, &j| {
candidates[i]
.poles_per_km
.partial_cmp(&candidates[j].poles_per_km)
.unwrap_or(std::cmp::Ordering::Equal)
});
front
}
fn float_range(lo: f64, hi: f64, step: f64) -> Vec<f64> {
if step <= 0.0 || lo >= hi {
return vec![lo];
}
let mut v = Vec::new();
let mut x = lo;
while x <= hi + step * 0.001 {
v.push((x * 1e6).round() / 1e6);
x += step;
}
v
}
fn poles_per_km(spacing_m: f64, arrangement: Arrangement) -> f64 {
let per_cycle = match arrangement {
Arrangement::SingleSide => 1.0,
Arrangement::Staggered => 1.0,
Arrangement::Opposite => 2.0,
};
1000.0 / spacing_m * per_cycle
}
#[cfg(test)]
mod tests {
use super::*;
use crate::standards::{
rp8::{PedestrianConflict, RoadClass, Rp8Selection, Rp8Standard},
LightingStandard,
};
fn road_ldc() -> Eulumdat {
let p = "../eulumdat-wasm/templates/road_luminaire.ldt";
let content = std::fs::read_to_string(p).expect("template must exist");
Eulumdat::parse(&content).expect("template must parse")
}
fn base_layout() -> StreetLayout {
StreetLayout {
length_m: 60.0,
lane_width_m: 3.5,
num_lanes: 2,
pole_spacing_m: 30.0,
arrangement: Arrangement::Staggered,
mounting_height_m: 10.0,
overhang_m: 1.0,
tilt_deg: 0.0,
pole_offset_m: 0.5,
sidewalk_width_m: 0.0,
}
}
#[test]
fn poles_per_km_math_matches_arrangement_families() {
assert!((poles_per_km(25.0, Arrangement::SingleSide) - 40.0).abs() < 1e-6);
assert!((poles_per_km(25.0, Arrangement::Opposite) - 80.0).abs() < 1e-6);
assert!((poles_per_km(25.0, Arrangement::Staggered) - 40.0).abs() < 1e-6);
}
#[test]
fn float_range_is_inclusive_and_deterministic() {
assert_eq!(float_range(15.0, 25.0, 5.0), vec![15.0, 20.0, 25.0]);
assert_eq!(float_range(4.0, 4.0, 1.0), vec![4.0]);
}
#[test]
fn pole_count_objective_prefers_longer_spacing() {
let ldc = road_ldc();
let base = base_layout();
let sel = Rp8Selection {
road_class: RoadClass::Local,
pedestrian_conflict: PedestrianConflict::Low,
};
let standard = Rp8Standard;
let fit = |design: &DesignResult| {
standard
.check_design(&sel, design)
.map(|r| r.passed())
.unwrap_or(false)
};
let bounds = OptimizerBounds {
pole_spacing_m: (20.0, 40.0),
pole_spacing_step_m: 10.0,
mounting_height_m: (8.0, 10.0),
mounting_height_step_m: 2.0,
..OptimizerBounds::default()
};
let top = optimize_layout(
&ldc,
&base,
&bounds,
OptimizerObjective::PoleCountPerKm,
3,
fit,
);
assert!(!top.is_empty(), "expected ≥1 passing candidate");
for w in top.windows(2) {
assert!(
w[0].poles_per_km <= w[1].poles_per_km + 1e-9,
"results not sorted by pole count: {:?}",
top
);
}
}
#[test]
fn no_passing_means_empty_result() {
let ldc = road_ldc();
let base = base_layout();
let fit = |design: &DesignResult| design.avg_illuminance_lux > 1_000_000.0;
let out = optimize_layout(
&ldc,
&base,
&OptimizerBounds {
pole_spacing_m: (30.0, 30.0),
pole_spacing_step_m: 5.0,
mounting_height_m: (10.0, 10.0),
mounting_height_step_m: 1.0,
..OptimizerBounds::default()
},
OptimizerObjective::PoleCountPerKm,
3,
fit,
);
assert!(out.is_empty());
}
#[test]
fn safety_margin_objective_inverts_ordering() {
let ldc = road_ldc();
let base = base_layout();
let sel = Rp8Selection {
road_class: RoadClass::Local,
pedestrian_conflict: PedestrianConflict::Low,
};
let standard = Rp8Standard;
let fit = |design: &DesignResult| {
standard
.check_design(&sel, design)
.map(|r| r.passed())
.unwrap_or(false)
};
let bounds = OptimizerBounds {
pole_spacing_m: (20.0, 40.0),
pole_spacing_step_m: 10.0,
mounting_height_m: (8.0, 10.0),
mounting_height_step_m: 2.0,
..OptimizerBounds::default()
};
let by_safety = optimize_layout(
&ldc,
&base,
&bounds,
OptimizerObjective::SafetyMargin,
3,
fit,
);
for w in by_safety.windows(2) {
assert!(
w[0].design.uniformity_overall >= w[1].design.uniformity_overall - 1e-9,
"safety results not sorted by uniformity descending: {:?}",
by_safety
);
}
}
fn synthetic_candidate(poles_per_km: f64, avg_lux: f64) -> OptimizationCandidate {
OptimizationCandidate {
pole_spacing_m: 1000.0 / poles_per_km,
mounting_height_m: 10.0,
arrangement: Arrangement::SingleSide,
design: DesignResult {
avg_illuminance_lux: avg_lux,
min_illuminance_lux: avg_lux * 0.4,
max_illuminance_lux: avg_lux * 1.5,
avg_luminance_cd_m2: None,
uniformity_overall: 0.4,
uniformity_longitudinal: None,
threshold_increment_pct: None,
},
cost: poles_per_km,
poles_per_km,
flux_per_km: poles_per_km * 10000.0,
}
}
#[test]
fn pareto_front_drops_dominated_candidates() {
let cands = vec![
synthetic_candidate(40.0, 25.0),
synthetic_candidate(50.0, 30.0),
synthetic_candidate(50.0, 20.0),
];
let front = pareto_front_tradeoff(&cands);
assert_eq!(front.len(), 2, "expected 2 non-dominated, got {front:?}");
assert_eq!(front, vec![0, 1]);
}
#[test]
fn pareto_front_keeps_strict_lex_dominators_on_ties() {
let cands = vec![
synthetic_candidate(50.0, 30.0),
synthetic_candidate(50.0, 25.0),
];
let front = pareto_front_tradeoff(&cands);
assert_eq!(front, vec![0]);
}
#[test]
fn pareto_front_empty_input_returns_empty() {
let front = pareto_front_tradeoff(&[]);
assert!(front.is_empty());
}
}