Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ luxpath's one-page summary. Details live in:

| Date | Change |
|---|---|
| 2026-10-25 | Findings per requirement: `luxpath check` lists every EN 1838:2024 requirement on every route, area and point with its clause (`luxpath_rules::en1838`), each "cannot verify" until its value is verified (gap 4); scenes gain the area kinds 2024 added (`toilets`, `swimming-pool`, `plant-room`, `local-area`). |
| 2026-10-25 | EN 1838:2024 clause map: `docs/standards-parity.md` cites the 2024 clause of every requirement, from the table of contents in the free SIST preview (values stay unverified, gap 4), and lists what 2024 added: specific areas (5.4), safety signs (6), local area and standby lighting (7), the annexes. New parity entries `en1838.specific-areas`, `en1838.local-area`, `en1838.safety-signs`. |
| 2026-10-22 | Public: the repository opened after a licence check; vulnerability reporting, topics, protected `main` with hosted CI on Linux, macOS and Windows (its first Windows run found a line-ending bug in a test, fixed). |
| 2026-10-21 | Routes can be given as their floor outline (BIM models draw corridors as rooms), for Ashlar's emergency-lighting plugin (R2.9a). |
Expand Down
6 changes: 6 additions & 0 deletions apps/luxpath-cli/tests/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ fn every_example_is_calculated_and_cannot_be_verified_until_m4() {
assert!(!out.contains("CANNOT VERIFY:") || out.contains("Verdict: CANNOT VERIFY"), "{out}");
assert!(out.contains("min ") && out.contains("not a certificate"), "{out}");
assert!(out.contains("walls block the light"), "the assumptions are stated:\n{out}");
// Every element's requirements are listed with their clause, none passing while values are unverified.
assert!(out.contains("Requirements (EN 1838:2024):"), "{out}");
for id in ids {
let cited = out.lines().any(|l| l.split_whitespace().nth(1) == Some(*id) && l.contains("CANNOT VERIFY"));
assert!(cited, "{scene}: no requirement cited for {id}:\n{out}");
}
}
}

Expand Down
25 changes: 18 additions & 7 deletions crates/report/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@
//!
//! Layer L5: depends on `luxpath-scene` and `luxpath-rules`.

use luxpath_rules::{ElementKind, Evaluation, Outcome, Verdict};
use luxpath_rules::{ElementKind, Evaluation, Outcome, Verdict, en1838};
use luxpath_scene::{AreaKind, Scene};

const DISCLAIMER: &str =
"This is a calculation, not a certificate: a competent designer remains responsible for the design.";

fn verdict(v: Verdict) -> &'static str {
match v {
Verdict::Pass => "PASS",
Verdict::Fail => "FAIL",
Verdict::CannotVerify => "CANNOT VERIFY",
}
}

fn outcome(o: &Outcome) -> String {
match o {
Outcome::Computed(s) => {
Expand All @@ -35,6 +43,7 @@ pub fn text(scene: &Scene, evaluation: &Evaluation) -> String {
ElementKind::Route => "escape route".to_string(),
ElementKind::Area(AreaKind::Open) => "open area".to_string(),
ElementKind::Area(AreaKind::HighRisk) => "high-risk area".to_string(),
ElementKind::Area(k) => format!("area ({})", k.name()),
ElementKind::Point(k) => format!("point ({})", k.name()),
};
let name = if r.name.is_empty() { String::new() } else { format!(" \"{}\"", r.name) };
Expand All @@ -45,6 +54,13 @@ pub fn text(scene: &Scene, evaluation: &Evaluation) -> String {
out += &format!(" {:<10} {}\n", "facing", outcome(v));
}
}
if !evaluation.findings.is_empty() {
out += &format!("\nRequirements ({}):\n", en1838::EDITION);
for f in &evaluation.findings {
let r = f.requirement;
out += &format!(" {:<6} {:<8} {}: {}: {}\n", r.clause, f.element, r.title, verdict(f.verdict), f.reason);
}
}
if !evaluation.warnings.is_empty() {
out += "\nWarnings:\n";
for w in &evaluation.warnings {
Expand All @@ -55,11 +71,6 @@ pub fn text(scene: &Scene, evaluation: &Evaluation) -> String {
for a in &evaluation.assumptions {
out += &format!(" - {a}\n");
}
let verdict = match evaluation.verdict {
Verdict::Pass => "PASS",
Verdict::Fail => "FAIL",
Verdict::CannotVerify => "CANNOT VERIFY",
};
out += &format!("\nVerdict: {verdict}: {}\n{DISCLAIMER}\n", evaluation.verdict_reason);
out += &format!("\nVerdict: {}: {}\n{DISCLAIMER}\n", verdict(evaluation.verdict), evaluation.verdict_reason);
out
}
106 changes: 106 additions & 0 deletions crates/rules/src/en1838.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! EN 1838:2024's requirements on routes, areas and points, as luxpath knows them so far: each with its clause
//! (`docs/standards-parity.md`) and the elements it applies to.
//!
//! No requirement has a threshold yet. Their values come from secondary sources until someone checks them against
//! the standard's text (gap 4), so every finding is "cannot verify" and never "pass"; M4 gives each requirement its
//! verified threshold. Requirements on the whole installation rather than on an element (disability glare,
//! duration, response time, colour rendering) are not listed here yet.

use luxpath_scene::AreaKind;

use crate::ElementKind;

/// The edition every clause below belongs to.
pub const EDITION: &str = "EN 1838:2024";

/// Which elements a requirement applies to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppliesTo {
Routes,
Areas(AreaKind),
Points,
}

impl AppliesTo {
pub fn includes(self, kind: ElementKind) -> bool {
match (self, kind) {
(Self::Routes, ElementKind::Route) | (Self::Points, ElementKind::Point(_)) => true,
(Self::Areas(want), ElementKind::Area(have)) => want == have,
_ => false,
}
}
}

/// One requirement: what it asks for, in our own words, never the standard's text (`AGENTS.md` §1.1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Requirement {
/// Its entry in `parity/features.toml`.
pub id: &'static str,
/// The clause of [`EDITION`] it comes from.
pub clause: &'static str,
pub title: &'static str,
pub applies_to: AppliesTo,
}

/// Every requirement on routes, areas and points, in clause order within each kind of element.
pub const REQUIREMENTS: &[Requirement] = &[
Requirement {
id: "en1838.escape-route-full-width",
clause: "5.1",
title: "minimum illuminance over the route's full width",
applies_to: AppliesTo::Routes,
},
Requirement {
id: "en1838.escape-route-uniformity",
clause: "5.1",
title: "uniformity along the route (max/min)",
applies_to: AppliesTo::Routes,
},
Requirement {
id: "en1838.open-area",
clause: "5.2",
title: "minimum illuminance less the border, and uniformity",
applies_to: AppliesTo::Areas(AreaKind::Open),
},
Requirement {
id: "en1838.high-risk",
clause: "5.3",
title: "illuminance relative to the normal lighting, and uniformity",
applies_to: AppliesTo::Areas(AreaKind::HighRisk),
},
Requirement {
id: "en1838.specific-areas",
clause: "5.4.1",
title: "toilets and changing rooms",
applies_to: AppliesTo::Areas(AreaKind::Toilets),
},
Requirement {
id: "en1838.specific-areas",
clause: "5.4.2",
title: "public indoor swimming pools",
applies_to: AppliesTo::Areas(AreaKind::SwimmingPool),
},
Requirement {
id: "en1838.specific-areas",
clause: "5.4.3",
title: "motor generator, control, switch and plant rooms",
applies_to: AppliesTo::Areas(AreaKind::PlantRoom),
},
Requirement {
id: "en1838.local-area",
clause: "7.1",
title: "local area lighting",
applies_to: AppliesTo::Areas(AreaKind::LocalArea),
},
Requirement {
id: "en1838.points-of-emphasis",
clause: "4.2",
title: "illuminance at the point of emphasis",
applies_to: AppliesTo::Points,
},
];

/// The requirements that apply to an element of `kind`.
pub fn requirements_for(kind: ElementKind) -> impl Iterator<Item = &'static Requirement> {
REQUIREMENTS.iter().filter(move |r| r.applies_to.includes(kind))
}
56 changes: 49 additions & 7 deletions crates/rules/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
//! file's table, an undefined point) makes the affected results "cannot verify", never a number.
//!
//! First standard (M4): **EN 1838:2024**, emergency lighting for buildings. Every requirement is
//! a row in `docs/standards-parity.md` with its clause number; every check will cite that clause in
//! its finding. Values come from the standard itself, paraphrased: never copy the standard's text or
//! tables into this repository (`AGENTS.md` §1). Until M4, no requirement is checked, so the
//! overall verdict is always [`Verdict::CannotVerify`].
//! a row in `docs/standards-parity.md` with its clause number, and [`en1838`] lists those on routes,
//! areas and points: each element gets a [`Finding`] per requirement, citing the clause. Values come
//! from the standard itself, paraphrased: never copy the standard's text or tables into this
//! repository (`AGENTS.md` §1). Until the values are verified (gap 4) and M4 checks them, every
//! finding and the overall verdict are [`Verdict::CannotVerify`].
//!
//! Later: EN 12464-1 (interior workplaces) and EN 13201 (road lighting), as separate modules.
//!
Expand All @@ -20,6 +21,8 @@ use luxpath_geom::Vec3;
use luxpath_photometry::Photometry;
use luxpath_scene::{AreaKind, PointKind, RouteShape, Scene};

pub mod en1838;

/// What one element's illuminance came to.
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Expand Down Expand Up @@ -54,10 +57,26 @@ pub enum Verdict {
CannotVerify,
}

/// One requirement applied to one element.
#[derive(Debug, Clone, PartialEq)]
pub struct Finding {
pub requirement: &'static en1838::Requirement,
/// The route, area or point it is about.
pub element: String,
pub verdict: Verdict,
/// Why the verdict is what it is.
pub reason: String,
}

/// Why no requirement can pass or fail yet.
const UNVERIFIED_VALUE: &str = "its value is not yet verified against the standard's text (gap 4)";

/// A scene's evaluation.
#[derive(Debug, Clone, PartialEq)]
pub struct Evaluation {
pub results: Vec<ElementResult>,
/// Each requirement on each element it applies to, in the order of [`Evaluation::results`].
pub findings: Vec<Finding>,
/// What the numbers assume, for the report.
pub assumptions: Vec<String>,
/// Implausible photometric files and the like: shown, not hidden.
Expand Down Expand Up @@ -93,7 +112,7 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
},
format!("maintenance factor {}", scene.maintenance_factor),
format!(
"grids every {} m; {} points across each route's full width; open areas less a {} m border",
"grids every {} m; {} points across each route's full width; open areas less a {} m border, other areas over their whole outline",
calc.spacing_m, calc.route_points_across, calc.open_area_border_m
),
];
Expand Down Expand Up @@ -177,6 +196,7 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
});
}
for a in &scene.areas {
// Only open areas are known to leave a border out; the others are computed over their whole outline.
let border = if a.kind == AreaKind::Open { calc.open_area_border_m } else { 0.0 };
results.push(ElementResult {
id: a.id.clone(),
Expand All @@ -196,13 +216,35 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result<Evaluation,
});
}

let mut findings = Vec::new();
for r in &results {
// An element whose numbers cannot be trusted says why; otherwise the value is what is missing.
let unverifiable = [Some(&r.horizontal), r.vertical.as_ref()].into_iter().flatten().find_map(|o| match o {
Outcome::CannotVerify(why) => Some(why.clone()),
Outcome::Computed(_) => None,
});
for requirement in en1838::requirements_for(r.kind) {
findings.push(Finding {
requirement,
element: r.id.clone(),
verdict: Verdict::CannotVerify,
reason: unverifiable.clone().unwrap_or_else(|| UNVERIFIED_VALUE.into()),
});
}
}

Ok(Evaluation {
results,
verdict_reason: format!(
"no {} requirement is checked yet: {} finding{} cannot be verified (milestone M4, gap 4); the values above are calculations only",
en1838::EDITION,
findings.len(),
if findings.len() == 1 { "" } else { "s" }
),
findings,
assumptions,
warnings,
verdict: Verdict::CannotVerify,
verdict_reason: "no EN 1838 requirement is checked yet (milestone M4); the values above are calculations only"
.into(),
})
}

Expand Down
58 changes: 58 additions & 0 deletions crates/rules/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,61 @@ fn a_route_given_as_its_floor_is_computed_over_all_of_it() {
fn photometry_must_match_the_scene() {
assert!(evaluate(&scene(""), &[]).is_err());
}

#[test]
fn every_element_gets_a_finding_per_requirement_citing_its_clause() {
let areas = ["open", "high-risk", "toilets", "swimming-pool", "plant-room", "local-area"]
.iter()
.enumerate()
.map(|(i, kind)| {
let normal = if *kind == "high-risk" { "normal_lighting_lx = 300\n" } else { "" };
format!("[[area]]\nid = \"A{i}\"\nkind = \"{kind}\"\noutline = [[0, 1], [2, 1], [2, 3], [0, 3]]\n{normal}")
})
.collect::<String>();
let e = evaluate(&scene_with("emergency = { flux_lm = 500 }", &areas), &[isotropic()]).unwrap();
let cited: Vec<(&str, &str)> = e.findings.iter().map(|f| (f.requirement.clause, f.element.as_str())).collect();
assert_eq!(
cited,
[
("5.1", "R1"),
("5.1", "R1"),
("5.2", "A0"),
("5.3", "A1"),
("5.4.1", "A2"),
("5.4.2", "A3"),
("5.4.3", "A4"),
("7.1", "A5"),
("4.2", "P1"),
]
);
// Nothing passes or fails while the values are unverified (gap 4), and the verdict says how many wait.
assert!(e.findings.iter().all(|f| f.verdict == Verdict::CannotVerify && f.reason.contains("gap 4")));
assert_eq!(e.verdict, Verdict::CannotVerify);
assert!(e.verdict_reason.contains("9 findings cannot be verified"), "{}", e.verdict_reason);
}

#[test]
fn a_finding_on_an_element_that_cannot_be_computed_says_why() {
let e = evaluate(&scene(""), &[isotropic()]).unwrap();
assert!(!e.findings.is_empty());
for f in &e.findings {
assert_eq!(f.verdict, Verdict::CannotVerify);
assert!(f.reason.contains("declares no emergency output"), "{}", f.reason);
}
}

/// The requirements in code and the two lists people read must agree: every requirement's id is a feature in
/// `parity/features.toml`, and its clause is on a row of `docs/standards-parity.md` with that id.
#[test]
fn every_requirement_is_in_the_parity_list_and_the_coverage_table_with_its_clause() {
let features = include_str!("../../../parity/features.toml");
let coverage = include_str!("../../../docs/standards-parity.md");
for r in en1838::REQUIREMENTS {
assert!(features.contains(&format!("id = \"{}\"", r.id)), "{} is not in parity/features.toml", r.id);
let on_a_row = coverage
.lines()
.filter(|l| l.contains(&format!("`{}`", r.id)))
.any(|l| l.split('|').nth(3).is_some_and(|clause| clause.split_whitespace().any(|w| w == r.clause)));
assert!(on_a_row, "{} (clause {}) has no row in docs/standards-parity.md", r.id, r.clause);
}
}
Loading
Loading