use crate::area::AreaResult;
use crate::standards::ComplianceResult;
use crate::street::{Arrangement, OptimizationCandidate, StreetLayout};
pub struct StreetReportInput<'a> {
pub luminaire_name: &'a str,
pub standard_name: &'a str,
pub layout: &'a StreetLayout,
pub area: &'a AreaResult,
pub compliance: &'a [ComplianceResult],
pub plan_svg: Option<&'a str>,
pub principal_planes_svg: Option<&'a str>,
pub tradeoff_svg: Option<&'a str>,
pub optimizer_top: &'a [OptimizationCandidate],
}
pub fn generate_street_report(input: &StreetReportInput<'_>) -> String {
let mut out = String::new();
out.push_str(&preamble(input.luminaire_name));
out.push_str(&title_page(input));
out.push_str(&design_summary(input));
out.push_str(&compliance_section(input));
if let Some(svg) = input.plan_svg {
out.push_str(&plan_section(svg));
}
if let Some(svg) = input.principal_planes_svg {
out.push_str(&principal_planes_section(svg));
}
if let Some(svg) = input.tradeoff_svg {
out.push_str(&tradeoff_section(svg));
}
if !input.optimizer_top.is_empty() {
out.push_str(&optimizer_section(input.optimizer_top));
}
out
}
fn preamble(luminaire: &str) -> String {
let title = typst_string_escape(&format!("Street design report ā {luminaire}"));
format!(
r#"// Street designer report
// Generated by eulumdat::street::report
#set document(title: "{title}", author: "eulumdat-rs")
#set page(paper: "a4", margin: (x: 2cm, y: 2.5cm))
#set text(size: 10pt)
#set heading(numbering: "1.1")
#set par(justify: true)
"#,
)
}
fn title_page(input: &StreetReportInput<'_>) -> String {
let lum = typst_string_escape(input.luminaire_name);
let std = typst_string_escape(input.standard_name);
format!(
r#"#align(center + horizon)[
#text(size: 22pt, weight: "bold")[Street Lighting Design Report]
#v(1em)
#text(size: 14pt)[Luminaire: {lum}]
#v(0.4em)
#text(size: 12pt)[Reference standard: {std}]
]
#pagebreak()
"#,
)
}
fn design_summary(input: &StreetReportInput<'_>) -> String {
let layout = input.layout;
let area = input.area;
let arr = match layout.arrangement {
Arrangement::SingleSide => "Single-side",
Arrangement::Opposite => "Opposite",
Arrangement::Staggered => "Staggered",
};
format!(
r#"= Design summary
#table(
columns: 2,
stroke: 0.5pt,
[*Length*], [{length:.1} m],
[*Lane width*], [{lane:.2} m],
[*Number of lanes*], [{num_lanes}],
[*Pole spacing*], [{spacing:.1} m],
[*Mounting height*], [{height:.1} m],
[*Arrangement*], [{arr}],
[*Overhang*], [{overhang:.2} m],
[*Tilt*], [{tilt:.1}°],
[*Avg illuminance*], [{avg:.1} lx],
[*Min illuminance*], [{min:.1} lx],
[*Max illuminance*], [{max:.1} lx],
[*Uniformity (min/avg)*], [{u:.2}],
)
#v(1em)
"#,
length = layout.length_m,
lane = layout.lane_width_m,
num_lanes = layout.num_lanes,
spacing = layout.pole_spacing_m,
height = layout.mounting_height_m,
overhang = layout.overhang_m,
tilt = layout.tilt_deg,
avg = area.avg_lux,
min = area.min_lux,
max = area.max_lux,
u = area.uniformity_min_avg,
)
}
fn compliance_section(input: &StreetReportInput<'_>) -> String {
if input.compliance.is_empty() {
return String::new();
}
let mut s = String::from("= Compliance\n\n");
for r in input.compliance {
let badge = if r.passed() { "ā PASS" } else { "ā FAIL" };
s.push_str(&format!(
"== {} Ā· {} Ā· {}\n\n",
typst_inline_escape(&r.region.to_string()),
typst_inline_escape(&r.standard),
badge,
));
if r.items.is_empty() {
s.push_str("_No criteria evaluated._\n\n");
continue;
}
s.push_str("#table(\n columns: 4,\n stroke: 0.5pt,\n [*Criterion*], [*Required*], [*Achieved*], [*Status*],\n");
for it in &r.items {
let status = if it.passed { "ā" } else { "ā" };
s.push_str(&format!(
" [{}], [{}], [{}], [{}],\n",
typst_inline_escape(&it.parameter),
typst_inline_escape(&it.required),
typst_inline_escape(&it.achieved),
status,
));
}
s.push_str(")\n\n");
}
s
}
fn plan_section(svg: &str) -> String {
format!(
r#"#pagebreak()
= Plan view
#align(center)[
#image(bytes("{svg}"), width: 100%)
]
"#,
svg = typst_string_escape(svg),
)
}
fn principal_planes_section(svg: &str) -> String {
format!(
r#"#pagebreak()
= PV / PC distribution
The principal-planes diagram shows the longitudinal (along-road, C0āC180)
and transverse (across-road, C90āC270) intensity cuts at the same scale.
#align(center)[
#image(bytes("{svg}"), width: 90%)
]
"#,
svg = typst_string_escape(svg),
)
}
fn tradeoff_section(svg: &str) -> String {
format!(
r#"#pagebreak()
= Layout trade-off
Each marker is a passing layout from the optimizer. The Pareto frontier
identifies designs where you cannot reduce poles per km without losing
average illuminance.
#align(center)[
#image(bytes("{svg}"), width: 95%)
]
"#,
svg = typst_string_escape(svg),
)
}
fn optimizer_section(top: &[OptimizationCandidate]) -> String {
let mut s = String::from("#pagebreak()\n= Optimizer top picks\n\n#table(\n columns: 7,\n stroke: 0.5pt,\n [*Spacing*], [*Height*], [*Arrangement*], [*Poles/km*], [*Avg lux*], [*Uā*], [*Flux/km*],\n");
for c in top {
let arr = match c.arrangement {
Arrangement::SingleSide => "Single",
Arrangement::Opposite => "Opposite",
Arrangement::Staggered => "Staggered",
};
s.push_str(&format!(
" [{spacing:.1} m], [{height:.1} m], [{arr}], [{poles:.1}], [{avg:.1}], [{u:.2}], [{flux:.0}],\n",
spacing = c.pole_spacing_m,
height = c.mounting_height_m,
poles = c.poles_per_km,
avg = c.design.avg_illuminance_lux,
u = c.design.uniformity_overall,
flux = c.flux_per_km,
));
}
s.push_str(")\n\n");
s
}
fn typst_string_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
fn typst_inline_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'[' | ']' | '#' | '*' | '\\' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::area::AreaResult;
use crate::street::StreetLayout;
fn dummy_input() -> (StreetLayout, AreaResult) {
let area = AreaResult {
lux_grid: Vec::new(),
min_lux: 5.0,
avg_lux: 12.5,
max_lux: 20.0,
uniformity_min_avg: 0.4,
uniformity_avg_min: 2.5,
uniformity_min_max: 0.25,
area_width: 7.0,
area_depth: 30.0,
grid_resolution: 10,
mask: None,
};
(StreetLayout::default(), area)
}
#[test]
fn report_renders_minimal_input() {
let (layout, area) = dummy_input();
let input = StreetReportInput {
luminaire_name: "Test \"luminaire\"",
standard_name: "RP-8",
layout: &layout,
area: &area,
compliance: &[],
plan_svg: None,
principal_planes_svg: None,
tradeoff_svg: None,
optimizer_top: &[],
};
let s = generate_street_report(&input);
assert!(s.starts_with("// Street designer report"));
assert!(s.contains("Street Lighting Design Report"));
assert!(s.contains("RP-8"));
assert!(s.contains(r#"Test \"luminaire\""#));
}
#[test]
fn report_includes_plan_svg_when_provided() {
let (layout, area) = dummy_input();
let input = StreetReportInput {
luminaire_name: "X",
standard_name: "Y",
layout: &layout,
area: &area,
compliance: &[],
plan_svg: Some(r#"<svg id="plan"/>"#),
principal_planes_svg: None,
tradeoff_svg: None,
optimizer_top: &[],
};
let s = generate_street_report(&input);
assert!(s.contains("= Plan view"));
assert!(s.contains(r#"<svg id=\"plan\"/>"#));
}
#[test]
fn typst_escape_handles_brackets_and_hash() {
let s = typst_inline_escape("a [b] #c *d* \\e");
assert_eq!(s, r"a \[b\] \#c \*d\* \\e");
}
}