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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ task id per commit (e.g. `M1.2: EULUMDAT reader`), commit only green states, ope

## Running
- `cargo run -p luxpath-cli -- --help`
- `cargo run -p luxpath-cli -- check examples/corridor.toml` (a scene; format in `docs/scene-format.md`)
- `cargo run -p luxpath-cli -- point 2.5 3` (direct illuminance demo)
- `cargo run -p luxpath-cli -- photometry <file.ies|.ldt>` (what a photometric file says)
- `cargo xtask fuzz [seconds]` (fuzz the photometric readers; 600 s is M1's exit criterion)
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ luxpath checks emergency escape lighting designs against **EN 1838:2024**: escap
high-risk task areas and points of emphasis. You describe a floor in a readable text file and give it photometric
files (IES, EULUMDAT). luxpath computes the illuminance and tells you which requirements pass, which fail and why.

> **Status: pre-alpha (M0).** The calculation core exists and is tested. Reading photometric files, the scene
> format and the EN 1838 checks are next. See [ROADMAP.md](ROADMAP.md).
> **Status: pre-alpha (M3).** Photometric files (IES, EULUMDAT), the calculation engine and the scene format work,
> and `luxpath check` computes a scene's emergency lighting. **No EN 1838 requirement is checked yet** (M4), and
> nothing is validated against another tool (M6): do not rely on the results. See [ROADMAP.md](ROADMAP.md).

## Why

Expand All @@ -23,6 +24,7 @@ GUI-only. luxpath aims to be:
## Try it

```sh
cargo run -p luxpath-cli -- check examples/corridor.toml # emergency lighting of an example scene
cargo run -p luxpath-cli -- point 2.5 3 # direct illuminance 3 m away from a source mounted at 2.5 m
cargo run -p luxpath-cli -- photometry crates/photometry/tests/fixtures/downlight.ldt # read an IES or LDT file
cargo xtask ci # every quality gate
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: pre-alpha** · next: alpha (M1–M4 + M6 validation) · ~150–250 agent-assisted hours away

> **Last updated:** 2026-10-21 · **Change:** M2 (engine) done
> **Last updated:** 2026-10-21 · **Change:** M3 (scene and `luxpath check`) done

luxpath's one-page summary. Details live in:

Expand All @@ -24,14 +24,15 @@ luxpath's one-page summary. Details live in:

## Next up

1. **M3:** the scene format and `luxpath check`; obstruction by walls (gap 16) before M4.
1. **Obstruction by walls** (gap 16), then **M4:** the EN 1838:2024 checks (needs the standard's text, gap 4).
2. **Real photometric files** from the owner's practice, kept in `plan/` (gap 12).
3. **Open question:** buy EN 1838:2024 (around €100 from a national standards body) to verify the requirement values.

## Progress log

| Date | Change |
|---|---|
| 2026-10-21 | M3: scene format version 1 (docs/scene-format.md: photometry with emergency output, luminaires, routes, open and high-risk areas, points of emphasis; unknown fields refused, errors with line and column); `luxpath check` with exit codes 0/1/2/3, results per element with their assumptions, "cannot verify" when an emergency output is missing; three example scenes with synthetic photometry. |
| 2026-10-21 | M2: luminaires with heading and tilt (IES tilt factors applied), illuminance on any surface, area and full-width route grids summed over luminaires, statistics that are unverifiable when any point is; isotropic and Lambertian cases within 1e-6; 100 luminaires × 10,000 points in about 55 ms (release, in `cargo xtask ci`); photometry plausibility warnings. |
| 2026-10-21 | M1: IES LM-63 (1986–2019) and EULUMDAT readers, symmetry expansion, tilt data, emergency flux, located errors, property tests, a 10-minute fuzz run (22.8 million inputs) without a panic; `luxpath photometry`. Existing crates evaluated first: none usable (docs/architecture.md). |
| 2026-10-16 | M7 (early): `luxpath-ifc` and `luxpath inspect-ifc`; 9 public models inspected, findings in docs/research.md §7. |
Expand Down
3 changes: 3 additions & 0 deletions apps/luxpath-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ luxpath-geom.workspace = true
luxpath-photometry.workspace = true
luxpath-calc.workspace = true
luxpath-ifc.workspace = true
luxpath-scene.workspace = true
luxpath-rules.workspace = true
luxpath-report.workspace = true
serde_json.workspace = true

[lints]
Expand Down
57 changes: 50 additions & 7 deletions apps/luxpath-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! `luxpath`: the command-line tool.
//!
//! Commands: `point` (direct illuminance under an isotropic source, a demonstration),
//! `photometry` (what a photometric file says, as luxpath reads it) and `inspect-ifc` (what an IFC
//! model contains for an emergency-lighting check). `luxpath check scene.toml` is M3–M4.
//! `photometry` (what a photometric file says, as luxpath reads it), `check` (a scene's emergency
//! lighting) and `inspect-ifc` (what an IFC model contains for an emergency-lighting check).
//!
//! `check` exits with 0 (every requirement passes), 1 (one fails), 2 (the input cannot be read) or
//! 3 (cannot verify: data missing, or, until M4, no requirement checked yet).

use std::process::ExitCode;

Expand All @@ -16,6 +19,9 @@ luxpath — open emergency-lighting calculations
USAGE:
luxpath point <mounting height m> <horizontal offset m> [flux lm]
Direct illuminance on the floor from an isotropic 1000 cd/klm source (demonstration).
luxpath check <scene.toml>
Emergency lighting of a scene: illuminance on every escape route, area and point.
Exit codes: 0 pass, 1 fail, 2 input error, 3 cannot verify.
luxpath photometry <file.ies|file.ldt>
What a photometric file says, as luxpath reads it: flux, symmetry, peak, light output ratio.
luxpath inspect-ifc <model.ifc> [--json]
Expand Down Expand Up @@ -46,6 +52,16 @@ fn main() -> ExitCode {
ExitCode::from(2)
}
},
Some("check") => match check(args.get(1..).unwrap_or_default()) {
Ok((text, code)) => {
print!("{text}");
ExitCode::from(code)
}
Err(message) => {
eprintln!("error: {message}");
ExitCode::from(2)
}
},
Some("photometry") => match photometry(args.get(1..).unwrap_or_default()) {
Ok(text) => {
print!("{text}");
Expand Down Expand Up @@ -92,13 +108,40 @@ fn inspect_ifc(args: &[String]) -> Result<String, String> {
}
}

/// Reads a file of at most `limit` bytes.
fn read_limited(path: &std::path::Path, limit: usize) -> Result<Vec<u8>, String> {
let shown = path.display();
let size = std::fs::metadata(path).map_err(|e| format!("{shown}: {e}"))?.len();
if size > limit as u64 {
return Err(format!("{shown}: {size} bytes is more than the {limit}-byte limit"));
}
std::fs::read(path).map_err(|e| format!("{shown}: {e}"))
}

fn check(args: &[String]) -> Result<(String, u8), String> {
let path = std::path::Path::new(args.first().ok_or("missing the scene file path")?);
let bytes = read_limited(path, luxpath_scene::MAX_SCENE_BYTES)?;
let text = String::from_utf8(bytes).map_err(|_| format!("{}: not UTF-8 text", path.display()))?;
let scene = luxpath_scene::Scene::parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
let dir = path.parent().unwrap_or(std::path::Path::new("."));
let mut photometry = Vec::new();
for reference in &scene.photometry {
let file = dir.join(&reference.file);
let bytes = read_limited(&file, MAX_FILE_BYTES)?;
photometry.push(luxpath_photometry::read(&bytes).map_err(|e| format!("{}: {e}", file.display()))?);
}
let evaluation = luxpath_rules::evaluate(&scene, &photometry)?;
let code = match evaluation.verdict {
luxpath_rules::Verdict::Pass => 0,
luxpath_rules::Verdict::Fail => 1,
luxpath_rules::Verdict::CannotVerify => 3,
};
Ok((luxpath_report::text(&scene, &evaluation), code))
}

fn photometry(args: &[String]) -> Result<String, String> {
let path = args.first().ok_or("missing the photometric file path")?;
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > MAX_FILE_BYTES as u64 {
return Err(format!("{path}: {size} bytes is more than the {MAX_FILE_BYTES}-byte limit"));
}
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
let bytes = read_limited(std::path::Path::new(path), MAX_FILE_BYTES)?;
let p = luxpath_photometry::read(&bytes).map_err(|e| format!("{path}: {e}"))?;
Ok(describe(&p))
}
Expand Down
81 changes: 81 additions & 0 deletions apps/luxpath-cli/tests/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//! End to end: `luxpath check` on the example scenes and on broken ones.

#![allow(clippy::unwrap_used)]

use std::path::PathBuf;
use std::process::Command;

fn luxpath(args: &[&str]) -> (i32, String, String) {
let out = Command::new(env!("CARGO_BIN_EXE_luxpath")).args(args).output().unwrap();
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).into(),
String::from_utf8_lossy(&out.stderr).into(),
)
}

fn examples() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples")
}

/// A scratch directory with the examples' photometry next to the given scene.
fn scratch(name: &str, scene: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("luxpath-check-{name}-{}", std::process::id()));
std::fs::create_dir_all(dir.join("photometry")).unwrap();
for file in ["bulkhead.ldt", "corridor.ies"] {
std::fs::copy(examples().join("photometry").join(file), dir.join("photometry").join(file)).unwrap();
}
std::fs::write(dir.join("scene.toml"), scene).unwrap();
dir
}

#[test]
fn every_example_is_calculated_and_cannot_be_verified_until_m4() {
for (scene, ids) in [
("corridor.toml", &["R1", "E1", "D1", "F1"][..]),
("open-office.toml", &["R1", "A1", "E1", "C1"][..]),
("plant-room.toml", &["H1", "S1", "E1"][..]),
] {
let path = examples().join(scene);
let (code, out, err) = luxpath(&["check", path.to_str().unwrap()]);
assert_eq!(code, 3, "{scene}: {err}");
for id in ids {
assert!(out.contains(&format!(" {id} ")), "{scene} should report {id}:\n{out}");
}
assert!(!out.contains("CANNOT VERIFY:") || out.contains("Verdict: CANNOT VERIFY"), "{out}");
assert!(out.contains("min ") && out.contains("not a certificate"), "{out}");
assert!(out.contains("nothing blocks their light"), "the assumptions are stated:\n{out}");
}
}

#[test]
fn a_luminaire_without_emergency_output_is_reported_not_guessed() {
let scene = std::fs::read_to_string(examples().join("open-office.toml")).unwrap();
let dir = scratch("no-emergency", &scene.replace("emergency = { flux_lm = 200 }\n", ""));
let (code, out, _) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]);
assert_eq!(code, 3);
assert!(out.contains("CANNOT VERIFY: photometry `bulkhead` declares no emergency output"), "{out}");
assert!(!out.contains(" lx,"), "no numbers when the flux is unknown:\n{out}");
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn broken_inputs_exit_with_2_and_say_where() {
let scene = std::fs::read_to_string(examples().join("corridor.toml")).unwrap();
let dir = scratch("broken", &scene.replace("width_m = 2.0", "width_m = \"wide\""));
let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]);
assert_eq!(code, 2);
assert!(err.contains("scene.toml: line 54, column 11"), "{err}");

std::fs::write(dir.join("scene.toml"), scene.replace("photometry/bulkhead.ldt", "photometry/missing.ldt")).unwrap();
let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]);
assert_eq!(code, 2);
assert!(err.contains("missing.ldt"), "{err}");

std::fs::write(dir.join("photometry/bulkhead.ldt"), "not a photometric file\n").unwrap();
std::fs::write(dir.join("scene.toml"), &scene).unwrap();
let (code, _, err) = luxpath(&["check", dir.join("scene.toml").to_str().unwrap()]);
assert_eq!(code, 2);
assert!(err.contains("bulkhead.ldt: line"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
67 changes: 62 additions & 5 deletions crates/report/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,65 @@
//! Reports: what a designer hands over.
//!
//! Planned (M5): a plain-text summary for the terminal, JSON for tools and CI, and an SVG plan
//! (false-colour illuminance, escape routes, luminaires, failing points marked). Every report
//! states the standard edition, the calculation assumptions (direct light only, maintenance
//! factor, emergency-mode flux) and that it is not a certificate (`AGENTS.md` §2).
//! Now (M3): [`text`], a plain-text summary for the terminal. Planned (M5): JSON for tools and CI,
//! and an SVG plan (false-colour illuminance, escape routes, luminaires, failing points marked).
//! Every report states the calculation's assumptions and that it is a calculation, not a
//! certificate (`AGENTS.md` §2).
//!
//! Layer L4: depends on `luxpath-scene` and `luxpath-rules`.
//! Layer L5: depends on `luxpath-scene` and `luxpath-rules`.

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

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

fn outcome(o: &Outcome) -> String {
match o {
Outcome::Computed(s) => {
let ratio = s.max_to_min().map_or("unbounded (minimum 0 lx)".to_string(), |r| format!("{r:.1}:1"));
format!(
"min {:.2} lx, mean {:.2} lx, max {:.2} lx, max/min {ratio} ({} points)",
s.min_lx, s.mean_lx, s.max_lx, s.points
)
}
Outcome::CannotVerify(why) => format!("CANNOT VERIFY: {why}"),
}
}

/// The evaluation as text for the terminal.
pub fn text(scene: &Scene, evaluation: &Evaluation) -> String {
let mut out = format!("luxpath: {}\n", scene.name);
out += "Emergency mode, direct light.\n\n";
for r in &evaluation.results {
let kind = match r.kind {
ElementKind::Route => "escape route".to_string(),
ElementKind::Area(AreaKind::Open) => "open area".to_string(),
ElementKind::Area(AreaKind::HighRisk) => "high-risk area".to_string(),
ElementKind::Point(k) => format!("point ({})", k.name()),
};
let name = if r.name.is_empty() { String::new() } else { format!(" \"{}\"", r.name) };
out += &format!("{} {}{name}\n", kind, r.id);
let plane = if matches!(r.kind, ElementKind::Point(_)) { "horizontal" } else { "floor" };
out += &format!(" {plane:<10} {}\n", outcome(&r.horizontal));
if let Some(v) = &r.vertical {
out += &format!(" {:<10} {}\n", "facing", outcome(v));
}
}
if !evaluation.warnings.is_empty() {
out += "\nWarnings:\n";
for w in &evaluation.warnings {
out += &format!(" - {w}\n");
}
}
out += "\nAssumptions:\n";
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
}
2 changes: 2 additions & 0 deletions crates/rules/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ repository.workspace = true
[dependencies]
luxpath-scene.workspace = true
luxpath-calc.workspace = true
luxpath-geom.workspace = true
luxpath-photometry.workspace = true

[lints]
workspace = true
Loading
Loading