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"