From 065c6e9bdff74927537dd103d7265ab3b93ec396 Mon Sep 17 00:00:00 2001 From: Enrico Sita Date: Sun, 25 Oct 2026 20:22:07 +0100 Subject: [PATCH] M4: a finding per requirement, citing its clause `luxpath check` lists, for every route, area and point, each EN 1838:2024 requirement that applies to it with its clause (luxpath_rules::en1838). Every finding is "cannot verify": the values are not verified against the standard's text (gap 4), or the element's numbers could not be computed, which the finding then says. Scenes gain the area kinds the 2024 edition added: toilets (5.4.1), swimming-pool (5.4.2), plant-room (5.4.3) and local-area (7.1). A test keeps the code's requirements, the parity list and the coverage table in agreement. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + apps/luxpath-cli/tests/check.rs | 6 ++ crates/report/src/lib.rs | 25 +++++--- crates/rules/src/en1838.rs | 106 ++++++++++++++++++++++++++++++++ crates/rules/src/lib.rs | 56 ++++++++++++++--- crates/rules/src/tests.rs | 58 +++++++++++++++++ crates/scene/src/lib.rs | 39 +++++++++--- crates/scene/src/tests.rs | 20 ++++++ docs/scene-format.md | 15 ++++- docs/status.md | 8 +++ parity/features.toml | 2 + 11 files changed, 313 insertions(+), 23 deletions(-) create mode 100644 crates/rules/src/en1838.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3a8901f..e09746d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). | diff --git a/apps/luxpath-cli/tests/check.rs b/apps/luxpath-cli/tests/check.rs index 9749ab6..4bb7334 100644 --- a/apps/luxpath-cli/tests/check.rs +++ b/apps/luxpath-cli/tests/check.rs @@ -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}"); + } } } diff --git a/crates/report/src/lib.rs b/crates/report/src/lib.rs index b2baced..12d6c28 100644 --- a/crates/report/src/lib.rs +++ b/crates/report/src/lib.rs @@ -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) => { @@ -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) }; @@ -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 { @@ -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 } diff --git a/crates/rules/src/en1838.rs b/crates/rules/src/en1838.rs new file mode 100644 index 0000000..052ddd3 --- /dev/null +++ b/crates/rules/src/en1838.rs @@ -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 { + REQUIREMENTS.iter().filter(move |r| r.applies_to.includes(kind)) +} diff --git a/crates/rules/src/lib.rs b/crates/rules/src/lib.rs index bf67eb8..8fcd33b 100644 --- a/crates/rules/src/lib.rs +++ b/crates/rules/src/lib.rs @@ -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. //! @@ -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 { @@ -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, + /// Each requirement on each element it applies to, in the order of [`Evaluation::results`]. + pub findings: Vec, /// What the numbers assume, for the report. pub assumptions: Vec, /// Implausible photometric files and the like: shown, not hidden. @@ -93,7 +112,7 @@ pub fn evaluate(scene: &Scene, photometry: &[Photometry]) -> Result Result Result 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(), }) } diff --git a/crates/rules/src/tests.rs b/crates/rules/src/tests.rs index 46bd6b1..a966dee 100644 --- a/crates/rules/src/tests.rs +++ b/crates/rules/src/tests.rs @@ -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::(); + 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); + } +} diff --git a/crates/scene/src/lib.rs b/crates/scene/src/lib.rs index 245cf2d..81cc800 100644 --- a/crates/scene/src/lib.rs +++ b/crates/scene/src/lib.rs @@ -215,12 +215,37 @@ pub struct Route { pub floor_m: f64, } +/// What an area is, for EN 1838:2024: each kind has its own requirements (`docs/standards-parity.md`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AreaKind { - /// An open (anti-panic) area. + /// An open (anti-panic) area (5.2). Open, - /// A high-risk task area. + /// A high-risk task area (5.3). HighRisk, + /// Toilets and changing rooms (5.4.1). + Toilets, + /// A public indoor swimming pool (5.4.2). + SwimmingPool, + /// A motor generator, control, switch or plant room (5.4.3). + PlantRoom, + /// An area lit by local area lighting (7.1). + LocalArea, +} + +impl AreaKind { + const NAMES: [(&'static str, AreaKind); 6] = [ + ("open", AreaKind::Open), + ("high-risk", AreaKind::HighRisk), + ("toilets", AreaKind::Toilets), + ("swimming-pool", AreaKind::SwimmingPool), + ("plant-room", AreaKind::PlantRoom), + ("local-area", AreaKind::LocalArea), + ]; + + /// The name scenes use for this kind. + pub fn name(self) -> &'static str { + Self::NAMES.iter().find(|(_, k)| *k == self).map_or("open", |(n, _)| n) + } } #[derive(Debug, Clone, PartialEq)] @@ -493,11 +518,11 @@ impl Scene { for a in &file.area { let a = a.get_ref(); c.unique(&mut ids, &a.id)?; - let kind = match a.kind.get_ref().as_str() { - "open" => AreaKind::Open, - "high-risk" => AreaKind::HighRisk, - other => return Err(c.err(a.kind.span(), format!("`{other}` is not an area kind (open, high-risk)"))), - }; + let kind = + AreaKind::NAMES.iter().find(|(n, _)| *n == a.kind.get_ref()).map(|(_, k)| *k).ok_or_else(|| { + let names: Vec<&str> = AreaKind::NAMES.iter().map(|(n, _)| *n).collect(); + c.err(a.kind.span(), format!("`{}` is not an area kind ({})", a.kind.get_ref(), names.join(", "))) + })?; let normal_lighting_lx = match &a.normal_lighting_lx { Some(lx) if kind == AreaKind::HighRisk => Some(c.positive(lx, "the normal lighting's illuminance")?), Some(lx) => return Err(c.err(lx.span(), "`normal_lighting_lx` belongs to high-risk areas only")), diff --git a/crates/scene/src/tests.rs b/crates/scene/src/tests.rs index a83973c..ae87a6c 100644 --- a/crates/scene/src/tests.rs +++ b/crates/scene/src/tests.rs @@ -107,6 +107,26 @@ fn malformed_scenes_give_located_errors() { } } +#[test] +fn every_area_kind_reads_by_its_name() { + let kinds = [ + ("open", AreaKind::Open), + ("toilets", AreaKind::Toilets), + ("swimming-pool", AreaKind::SwimmingPool), + ("plant-room", AreaKind::PlantRoom), + ("local-area", AreaKind::LocalArea), + ]; + for (name, kind) in kinds { + let scene = with( + "kind = \"high-risk\"\noutline = [[0, 3], [6, 3], [6, 8], [0, 8]]\nnormal_lighting_lx = 500", + &format!("kind = \"{name}\"\noutline = [[0, 3], [6, 3], [6, 8], [0, 8]]"), + ); + let s = Scene::parse(&scene).unwrap(); + assert_eq!((s.areas[0].kind, kind.name()), (kind, name)); + } + assert_eq!(AreaKind::HighRisk.name(), "high-risk"); +} + #[test] fn a_route_can_be_its_floor_outline() { let s = Scene::parse(&with( diff --git a/docs/scene-format.md b/docs/scene-format.md index 52bdb87..e317058 100644 --- a/docs/scene-format.md +++ b/docs/scene-format.md @@ -98,13 +98,24 @@ outline = [[0, 0], [16, 0], [16, 2], [0, 2]] ```toml [[area]] id = "A1" -kind = "open" # "open" (anti-panic) or "high-risk" (high-risk task area) +kind = "open" # see the kinds below outline = [[0, 0], [15, 0], [15, 10], [0, 10]] # a polygon, at least three corners floor_m = 0.0 # optional normal_lighting_lx = 300 # high-risk areas only: the normal lighting's maintained illuminance ``` -Open areas are computed less `open_area_border_m` from their edges; high-risk areas over their whole outline. +Kinds, each with its EN 1838:2024 clause (`docs/standards-parity.md`): + +| `kind` | Area | Clause | +|---|---|---| +| `open` | open (anti-panic) area | 5.2 | +| `high-risk` | high-risk task area | 5.3 | +| `toilets` | toilets and changing rooms | 5.4.1 | +| `swimming-pool` | public indoor swimming pool | 5.4.2 | +| `plant-room` | motor generator, control, switch or plant room | 5.4.3 | +| `local-area` | area lit by local area lighting | 7.1 | + +Open areas are computed less `open_area_border_m` from their edges; every other kind over its whole outline. ## Points of emphasis diff --git a/docs/status.md b/docs/status.md index 44e3a22..0bf7465 100644 --- a/docs/status.md +++ b/docs/status.md @@ -12,6 +12,14 @@ edition added, now rows and parity entries: specific areas (5.4: toilets and cha pools, plant rooms), safety signs (6), local area lighting (7.1). Next: the scene's area kinds for 5.4 and 7.1, and a finding per requirement, each "cannot verify" until its value is verified. +## Findings per requirement (2026-10-25) +`luxpath check` now lists, under "Requirements (EN 1838:2024)", every requirement on every route, area and point, +citing its clause (`luxpath_rules::en1838`). Each is "cannot verify": its value is not verified (gap 4), or the +element's numbers could not be computed, which the finding then says. Scenes have four more area kinds for what 2024 +added: `toilets` (5.4.1), `swimming-pool` (5.4.2), `plant-room` (5.4.3) and `local-area` (7.1). A test keeps the +code's requirements, `parity/features.toml` and `docs/standards-parity.md` in agreement. Next, once the standard is +bought: thresholds per requirement, tested on both sides (M4). + ## Walls (2026-10-21) Scenes have `[[wall]]`s (polylines with a height); a luminaire whose sight line to a point crosses one gives that point nothing. In the corridor example the minimum on the route drops from 1.45 lx to 0.60 lx once light no longer diff --git a/parity/features.toml b/parity/features.toml index 57edf4b..272b957 100644 --- a/parity/features.toml +++ b/parity/features.toml @@ -268,6 +268,7 @@ area = "en1838" tier = "P1" milestone = "M4" status = "planned" +notes = "Scenes describe them (`toilets`, `swimming-pool`, `plant-room`) and each gets a finding citing 5.4.1–5.4.3, \"cannot verify\": what the clauses require is not known yet (gap 4)." [[feature]] id = "en1838.local-area" @@ -276,6 +277,7 @@ area = "en1838" tier = "P2" milestone = "M4" status = "planned" +notes = "Scenes describe them (`local-area`) and each gets a finding citing 7.1, \"cannot verify\" until the requirement is read (gap 4)." [[feature]] id = "en1838.safety-signs"