diff --git a/CLAUDE.md b/CLAUDE.md index bf1ab8e..dc889e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,4 +49,6 @@ 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 -- point 2.5 3` (direct illuminance demo) +- `cargo run -p luxpath-cli -- photometry ` (what a photometric file says) +- `cargo xtask fuzz [seconds]` (fuzz the photometric readers; 600 s is M1's exit criterion) - `cargo xtask parity` (feature progress by milestone) diff --git a/Cargo.lock b/Cargo.lock index 8d66a1d..923bf1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,50 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -50,6 +94,18 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -146,6 +202,12 @@ dependencies = [ "lexical-util", ] +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + [[package]] name = "luxpath-calc" version = "0.0.1" @@ -182,6 +244,7 @@ name = "luxpath-photometry" version = "0.0.1" dependencies = [ "luxpath-geom", + "proptest", ] [[package]] @@ -230,6 +293,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -245,6 +317,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bitflags", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "unarray", +] + [[package]] name = "quote" version = "1.0.47" @@ -254,6 +342,43 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -394,6 +519,12 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.27" diff --git a/Cargo.toml b/Cargo.toml index 6627e01..954c53e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ serde_json = "1" toml = "0.9" # IFC (STEP) parsing; MPL-2.0, used unmodified as a dependency (AGENTS.md §1.2) ifc-lite-core = "20.4" +# tests only +proptest = { version = "1.11", default-features = false, features = ["std"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 02ba71c..2f56587 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ GUI-only. luxpath aims to be: ```sh 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 cargo xtask parity # feature progress by milestone ``` diff --git a/ROADMAP.md b/ROADMAP.md index 5971593..4620888 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha (M1–M4 + M6 validation) · ~150–250 agent-assisted hours away -> **Last updated:** 2026-10-16 · **Change:** project created (M0 scaffold) +> **Last updated:** 2026-10-21 · **Change:** M1 (photometry readers) done luxpath's one-page summary. Details live in: @@ -24,13 +24,14 @@ luxpath's one-page summary. Details live in: ## Next up -1. **M1:** IES LM-63 and EULUMDAT readers, symmetry expansion, emergency-mode flux. -2. **M2:** grids, sums over luminaires, tilt, vertical illuminance. +1. **M2:** grids, sums over luminaires, tilt, vertical illuminance, photometry plausibility warnings (gap 13). +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 | 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. | | 2026-10-16 | M0: workspace, layering and parity gates, C-γ intensity table, direct illuminance, demo CLI, docs. | diff --git a/apps/luxpath-cli/src/main.rs b/apps/luxpath-cli/src/main.rs index 9939eb3..be05467 100644 --- a/apps/luxpath-cli/src/main.rs +++ b/apps/luxpath-cli/src/main.rs @@ -1,14 +1,14 @@ //! `luxpath`: the command-line tool. //! -//! Commands: `point` (direct illuminance under an isotropic source, a demonstration) and -//! `inspect-ifc` (what an IFC model contains for an emergency-lighting check). `luxpath check -//! scene.toml` is M3–M4. +//! 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. use std::process::ExitCode; use luxpath_calc::{Luminaire, direct_horizontal_illuminance}; use luxpath_geom::Vec3; -use luxpath_photometry::IntensityTable; +use luxpath_photometry::{Format, IntensityTable, MAX_FILE_BYTES, Photometry, Symmetry}; const USAGE: &str = "\ luxpath — open emergency-lighting calculations @@ -16,6 +16,8 @@ luxpath — open emergency-lighting calculations USAGE: luxpath point [flux lm] Direct illuminance on the floor from an isotropic 1000 cd/klm source (demonstration). + luxpath photometry + What a photometric file says, as luxpath reads it: flux, symmetry, peak, light output ratio. luxpath inspect-ifc [--json] What an IFC model contains for an emergency-lighting check, and what is missing. luxpath --version @@ -44,6 +46,16 @@ fn main() -> ExitCode { ExitCode::from(2) } }, + Some("photometry") => match photometry(args.get(1..).unwrap_or_default()) { + Ok(text) => { + print!("{text}"); + ExitCode::SUCCESS + } + Err(message) => { + eprintln!("error: {message}"); + ExitCode::from(2) + } + }, Some("inspect-ifc") => match inspect_ifc(args.get(1..).unwrap_or_default()) { Ok(text) => { print!("{text}"); @@ -80,6 +92,67 @@ fn inspect_ifc(args: &[String]) -> Result { } } +fn photometry(args: &[String]) -> Result { + 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 p = luxpath_photometry::read(&bytes).map_err(|e| format!("{path}: {e}"))?; + Ok(describe(&p)) +} + +fn describe(p: &Photometry) -> String { + let format = match p.format() { + Format::Ies(edition) => format!("IES {edition:?}").replace("Lm63_", "LM-63-"), + Format::Eulumdat => "EULUMDAT".to_string(), + }; + let symmetry = match p.symmetry() { + Symmetry::None => "none (every C plane stored)", + Symmetry::Rotational => "rotational (one C plane)", + Symmetry::PlaneC0C180 => "about the C0–C180 plane", + Symmetry::PlaneC90C270 => "about the C90–C270 plane", + Symmetry::Quadrants => "quadrants (C0–C90 stored)", + }; + let t = p.table(); + let n_gamma = t.gamma_angles().len().max(1); + let (peak_at, peak) = + t.values().iter().enumerate().fold((0, 0.0), |best, (i, v)| if *v > best.1 { (i, *v) } else { best }); + let peak_c = t.c_angles().get(peak_at / n_gamma).copied().unwrap_or(0.0); + let peak_gamma = t.gamma_angles().get(peak_at % n_gamma).copied().unwrap_or(0.0); + let gamma = |i: Option<&f64>| i.copied().unwrap_or(0.0); + let mut out = String::new(); + let mut line = |label: &str, value: String| { + if !value.is_empty() { + out += &format!("{label:<18} {value}\n"); + } + }; + line("format", format); + line("manufacturer", p.manufacturer.clone()); + line("luminaire", p.name.replace('\n', " ")); + line("catalogue", p.catalogue.clone()); + let kind = if p.is_absolute() { "luminaire flux, absolute photometry" } else { "lamp flux" }; + line("rated flux", format!("{:.1} lm ({kind})", p.flux_lm())); + line("symmetry", symmetry.to_string()); + line( + "table", + format!( + "{} C planes × {} γ angles (γ {}°–{}°)", + t.c_angles().len(), + t.gamma_angles().len(), + gamma(t.gamma_angles().first()), + gamma(t.gamma_angles().last()) + ), + ); + line("peak", format!("{peak:.1} cd/klm at C{peak_c}° γ{peak_gamma}°")); + line("light output", format!("{:.1} % of the rated flux", p.light_output_ratio() * 100.0)); + if let Some(tilt) = p.tilt() { + line("tilt", format!("{} angles, lamp geometry {}", tilt.angles_deg().len(), tilt.lamp_geometry)); + } + out +} + fn point(args: &[String]) -> Result { let number = |i: usize, name: &str| -> Result, String> { args.get(i) diff --git a/apps/luxpath-cli/tests/photometry.rs b/apps/luxpath-cli/tests/photometry.rs new file mode 100644 index 0000000..1b362da --- /dev/null +++ b/apps/luxpath-cli/tests/photometry.rs @@ -0,0 +1,40 @@ +//! End to end: `luxpath photometry` on the committed synthetic fixtures and on a broken file. + +#![allow(clippy::unwrap_used)] + +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(), + ) +} + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../crates/photometry/tests/fixtures"); + +#[test] +fn describes_ies_and_eulumdat_files_alike() { + for (file, format) in [("downlight.ies", "IES LM-63-2002"), ("downlight.ldt", "EULUMDAT")] { + let (code, out, err) = luxpath(&["photometry", &format!("{FIXTURES}/{file}")]); + assert_eq!(code, 0, "{err}"); + assert!(out.contains(format), "{out}"); + assert!(out.contains("1000.0 lm (lamp flux)"), "{out}"); + assert!(out.contains("rotational"), "{out}"); + assert!(out.contains("300.0 cd/klm at C0° γ0°"), "{out}"); + } +} + +#[test] +fn a_broken_file_is_an_error_with_its_line() { + let dir = std::env::temp_dir().join(format!("luxpath-cli-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("broken.ies"); + std::fs::write(&path, "IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100\n").unwrap(); + let (code, _, err) = luxpath(&["photometry", path.to_str().unwrap()]); + assert_eq!(code, 2); + assert!(err.contains("line 7: the file ends before the candela values"), "{err}"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/photometry/Cargo.toml b/crates/photometry/Cargo.toml index c5580b1..baa0ed4 100644 --- a/crates/photometry/Cargo.toml +++ b/crates/photometry/Cargo.toml @@ -10,5 +10,8 @@ repository.workspace = true [dependencies] luxpath-geom.workspace = true +[dev-dependencies] +proptest.workspace = true + [lints] workspace = true diff --git a/crates/photometry/src/ies.rs b/crates/photometry/src/ies.rs new file mode 100644 index 0000000..58a4ac0 --- /dev/null +++ b/crates/photometry/src/ies.rs @@ -0,0 +1,347 @@ +//! The IES LM-63 reader (editions 1986, 1991, 1995, 2002 and 2019), type C photometry. +//! +//! A file is: an edition line (none in 1986), `[KEYWORD] value` lines (`[MORE]` continues the +//! previous one), a `TILT=` line and, for `TILT=INCLUDE`, the tilt table; then numbers separated by +//! whitespace or commas, across lines as the writer pleased: lamps, lumens per lamp (−1 for +//! absolute photometry), candela multiplier, the vertical and horizontal angle counts, photometric +//! type, units, the luminous opening, ballast factor, a factor whose meaning changed between +//! editions, input watts, the vertical angles, the horizontal angles, and the candela values, one +//! horizontal angle after another. + +use crate::model::{Format, Photometry, Tilt}; +use crate::symmetry::{ExpandError, Symmetry}; +use crate::text::{ReadError, ReadErrorKind, Tokens, decode, error, invalid, is_tilt_line, table_error}; +use crate::{IntensityTable, MAX_TABLE_VALUES}; + +/// The LM-63 edition a file declares in its first line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum IesEdition { + /// No edition line. + Lm63_1986, + /// `IESNA91`. + Lm63_1991, + /// `IESNA:LM-63-1995`. + Lm63_1995, + /// `IESNA:LM-63-2002`. + Lm63_2002, + /// `IES:LM-63-2019`. + Lm63_2019, +} + +/// Tilt tables longer than this are refused (real ones have a few dozen angles). +const MAX_TILT_ANGLES: usize = 10_000; + +/// Angles closer than this are equal (files write angles with a few decimals). +const SAME_ANGLE_DEG: f64 = 1e-6; + +fn edition(first_line: &str) -> Result, ReadErrorKind> { + // Writers vary the spacing (`IESNA: LM-63-2002`). + let line: String = first_line.chars().filter(|c| !c.is_whitespace()).collect::().to_ascii_uppercase(); + let known = [ + ("IESNA:LM-63-1995", IesEdition::Lm63_1995), + ("IESNA:LM-63-2002", IesEdition::Lm63_2002), + ("IES:LM-63-2019", IesEdition::Lm63_2019), + ("IESNA:LM-63-2019", IesEdition::Lm63_2019), + ("IESNA:LM-63-1991", IesEdition::Lm63_1991), + ("IESNA91", IesEdition::Lm63_1991), + ]; + if let Some((_, e)) = known.iter().find(|(tag, _)| line.starts_with(tag)) { + return Ok(Some(*e)); + } + if line.starts_with("IESNA") || line.starts_with("IES:") { + return Err(ReadErrorKind::Unsupported("this LM-63 edition")); + } + Ok(None) +} + +/// Reads an IES LM-63 file. +pub fn read_ies(bytes: &[u8]) -> Result { + let text = decode(bytes)?; + let mut edition_found = IesEdition::Lm63_1986; + let mut keywords: Vec<(String, String)> = Vec::new(); + let mut tilt = None; + let mut offset = 0; + let mut last_line = 1; + for (i, raw) in text.split_inclusive('\n').enumerate() { + let n = i + 1; + last_line = n; + offset += raw.len(); + let line = raw.trim(); + if n == 1 + && let Some(e) = edition(line).map_err(|kind| error(n, kind))? + { + edition_found = e; + continue; + } + if is_tilt_line(line.as_bytes()) { + let value = line.split_once('=').map_or("", |(_, v)| v); + tilt = Some((n, value.trim().to_ascii_uppercase())); + break; + } + if let Some((key, value)) = line.strip_prefix('[').and_then(|kw| kw.split_once(']')) { + let value = value.trim().to_string(); + match keywords.last_mut() { + Some((_, previous)) if key == "MORE" => { + previous.push('\n'); + previous.push_str(&value); + } + _ => keywords.push((key.trim().to_ascii_uppercase(), value)), + } + } + // Other lines before TILT are free text (1986 files). + } + let (tilt_line, tilt_value) = tilt.ok_or_else(|| error(last_line, ReadErrorKind::Missing("TILT= line")))?; + let mut tokens = Tokens::new(text.get(offset..).unwrap_or(""), tilt_line + 1); + + let tilt = match tilt_value.as_str() { + "NONE" => None, + "INCLUDE" => Some(read_tilt(&mut tokens)?), + _ => return Err(error(tilt_line, ReadErrorKind::Unsupported("TILT data in a separate file"))), + }; + + let (line, lamps) = tokens.next_count("number of lamps")?; + if lamps == 0 { + return Err(invalid(line, "number of lamps", "is zero")); + } + let (lumens_line, lumens) = tokens.next_number("lumens per lamp")?; + let (line, multiplier) = tokens.next_number("candela multiplier")?; + if multiplier <= 0.0 { + return Err(invalid(line, "candela multiplier", "is not positive")); + } + let (line, n_vertical) = tokens.next_count("number of vertical angles")?; + if n_vertical == 0 { + return Err(invalid(line, "number of vertical angles", "is zero")); + } + let (line, n_horizontal) = tokens.next_count("number of horizontal angles")?; + if n_horizontal == 0 { + return Err(invalid(line, "number of horizontal angles", "is zero")); + } + let n_values = n_vertical.checked_mul(n_horizontal).filter(|n| *n <= MAX_TABLE_VALUES); + let Some(n_values) = n_values else { + return Err(error( + line, + ReadErrorKind::Table(crate::PhotometryError::TooLarge { values: n_vertical.saturating_mul(n_horizontal) }), + )); + }; + let (line, photometric_type) = tokens.next_count("photometric type")?; + match photometric_type { + 1 => {} + 2 | 3 => return Err(error(line, ReadErrorKind::Unsupported("type A or B photometry (only type C)"))), + _ => return Err(invalid(line, "photometric type", "is not 1, 2 or 3")), + } + let (line, units) = tokens.next_count("units type")?; + if !(1..=2).contains(&units) { + return Err(invalid(line, "units type", "is not 1 (feet) or 2 (metres)")); + } + for what in ["luminous opening width", "luminous opening length", "luminous opening height"] { + tokens.next_number(what)?; + } + let (line, ballast_factor) = tokens.next_number("ballast factor")?; + if ballast_factor <= 0.0 { + return Err(invalid(line, "ballast factor", "is not positive")); + } + // 1986–1995: the ballast-lamp photometric factor, which multiplies the candela values. 2002 + // made it "future use" and 2019 the file generation type: no longer a factor. + let (line, second_factor) = tokens.next_number("ballast-lamp photometric factor")?; + let photometric_factor = if edition_found <= IesEdition::Lm63_1995 { + if second_factor <= 0.0 { + return Err(invalid(line, "ballast-lamp photometric factor", "is not positive")); + } + second_factor + } else { + 1.0 + }; + tokens.next_number("input watts")?; + + let vertical_line = tokens.next_line(); + let vertical = read_angles(&mut tokens, n_vertical, "vertical angles")?; + let horizontal_line = tokens.next_line(); + let horizontal = read_angles(&mut tokens, n_horizontal, "horizontal angles")?; + let values_line = tokens.next_line(); + let mut values = Vec::with_capacity(n_values); + for _ in 0..n_values { + let (line, v) = tokens.next_number("candela values")?; + if v < 0.0 { + return Err(invalid(line, "candela value", "is negative")); + } + values.push(v * multiplier * ballast_factor * photometric_factor); + } + if let Some((line, _)) = tokens.next() { + return Err(invalid(line, "file", "has data after the candela values (are the angle counts right?)")); + } + + let symmetry = symmetry(&horizontal).ok_or_else(|| { + invalid( + horizontal_line, + "horizontal angles", + "do not start and end where LM-63 allows (0–0, 0–90, 0–180, 90–270, 0–360)", + ) + })?; + let table = symmetry.expand(&horizontal, vertical, &values).map_err(|e| match e { + ExpandError::Table(t) => table_error(t, horizontal_line, vertical_line, values_line), + other => invalid(horizontal_line, "horizontal angles", other.reason()), + })?; + let absolute = lumens == -1.0; + let flux_lm = if absolute { + Some(table.integrated_flux()) + .filter(|flux| flux.is_finite() && *flux > 0.0) + .ok_or_else(|| invalid(values_line, "candela values", "emit no light"))? + } else if lumens > 0.0 { + lamps as f64 * lumens + } else { + return Err(invalid(lumens_line, "lumens per lamp", "is neither positive nor −1 (absolute photometry)")); + }; + let table: IntensityTable = + table.scaled(1000.0 / flux_lm).map_err(|e| error(values_line, ReadErrorKind::Table(e)))?; + let mut photometry = Photometry::new(table, flux_lm, absolute, symmetry, Format::Ies(edition_found)) + .map_err(|e| error(lumens_line, ReadErrorKind::Table(e)))? + .with_tilt(tilt); + let keyword = |k: &str| keywords.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()).unwrap_or_default(); + photometry.manufacturer = keyword("MANUFAC"); + photometry.name = keyword("LUMINAIRE"); + photometry.catalogue = keyword("LUMCAT"); + Ok(photometry) +} + +fn read_tilt(tokens: &mut Tokens<'_>) -> Result { + let (line, geometry) = tokens.next_count("lamp-to-luminaire geometry")?; + let geometry = u8::try_from(geometry) + .ok() + .filter(|g| (1..=3).contains(g)) + .ok_or_else(|| invalid(line, "lamp-to-luminaire geometry", "is not 1, 2 or 3"))?; + let (line, n) = tokens.next_count("number of tilt angles")?; + if n == 0 || n > MAX_TILT_ANGLES { + return Err(invalid(line, "number of tilt angles", "is zero or too large")); + } + let angles = read_angles(tokens, n, "tilt angles")?; + let mut factors = Vec::with_capacity(n); + for _ in 0..n { + factors.push(tokens.next_number("tilt multiplying factors")?.1); + } + Tilt::new(geometry, angles, factors) + .ok_or_else(|| invalid(line, "tilt table", "has angles that do not increase, or negative factors")) +} + +fn read_angles(tokens: &mut Tokens<'_>, n: usize, what: &'static str) -> Result, ReadError> { + let mut angles = Vec::with_capacity(n); + for _ in 0..n { + angles.push(tokens.next_number(what)?.1); + } + Ok(angles) +} + +/// The symmetry LM-63 implies by the first and last horizontal angle. +fn symmetry(horizontal: &[f64]) -> Option { + let (first, last) = (*horizontal.first()?, *horizontal.last()?); + let near = |a: f64, b: f64| (a - b).abs() < SAME_ANGLE_DEG; + if horizontal.len() == 1 { + return Some(Symmetry::Rotational); + } + if near(first, 0.0) { + if near(last, 90.0) { + return Some(Symmetry::Quadrants); + } + if near(last, 180.0) { + return Some(Symmetry::PlaneC0C180); + } + if last > 180.0 && last <= 360.0 + SAME_ANGLE_DEG { + return Some(Symmetry::None); + } + } + if near(first, 90.0) && near(last, 270.0) { + return Some(Symmetry::PlaneC90C270); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A small 2002 file: two lamps of 500 lm, quadrant symmetric, values doubled by the multiplier. + const QUADRANT: &str = "IESNA:LM-63-2002\n[TEST] T1\n[MANUFAC] Example\n[LUMCAT] EX-1\n[LUMINAIRE] Downlight\n[MORE] white\nTILT=NONE\n2 500 2.0 3 2 1 2 0.1 0.2 0.0\n1.0 1.0 10\n0 45 90\n0 90\n100 50 0\n80 40, 0\n"; + + #[test] + fn a_quadrant_file_reads_into_a_full_table_in_cd_per_klm() { + let p = read_ies(QUADRANT.as_bytes()).unwrap(); + assert_eq!(p.format(), Format::Ies(IesEdition::Lm63_2002)); + assert_eq!(p.symmetry(), Symmetry::Quadrants); + // Flux 2 × 500 = 1000 lm; 100 cd × multiplier 2 = 200 cd, that is 200 cd/klm. + assert_eq!(p.flux_lm(), 1000.0); + assert_eq!(p.table().intensity(0.0, 0.0), 200.0); + assert_eq!(p.table().intensity(90.0, 45.0), 80.0); + assert_eq!(p.table().intensity(270.0, 45.0), 80.0); + assert_eq!(p.table().c_angles(), &[0.0, 90.0, 180.0, 270.0]); + assert_eq!((p.manufacturer.as_str(), p.catalogue.as_str()), ("Example", "EX-1")); + assert_eq!(p.name, "Downlight\nwhite"); + } + + #[test] + fn absolute_photometry_takes_its_flux_from_the_table() { + // An isotropic 100 cd source: Φ = 4π × 100 lm, and 100 cd is 1000/(4π) cd/klm of it. + let text = "IESNA:LM-63-2002\nTILT=NONE\n1 -1 1 2 1 1 2 0 0 0\n1 1 0\n0 180\n0\n100 100\n"; + let p = read_ies(text.as_bytes()).unwrap(); + assert!(p.is_absolute()); + let flux = 400.0 * std::f64::consts::PI; + assert!((p.flux_lm() - flux).abs() < 1e-9); + assert!((p.candela(0.0, 30.0, p.flux_lm()) - 100.0).abs() < 1e-9); + assert!((p.light_output_ratio() - 1.0).abs() < 1e-12); + } + + #[test] + fn older_editions_apply_the_ballast_lamp_factor_and_newer_ones_do_not() { + let body = "TILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n0.5 0.8 10\n0 90\n0\n100 100\n"; + let old = read_ies(format!("IESNA:LM-63-1995\n{body}").as_bytes()).unwrap(); + let new = read_ies(format!("IES:LM-63-2019\n{body}").as_bytes()).unwrap(); + // 100 cd × ballast 0.5 × photometric factor 0.8 = 40 cd/klm at 1000 lm; 2019: 100 × 0.5 = 50. + assert!((old.table().intensity(0.0, 0.0) - 40.0).abs() < 1e-12); + assert!((new.table().intensity(0.0, 0.0) - 50.0).abs() < 1e-12); + let spaced = read_ies(format!("IESNA: LM-63-2002\n{body}").as_bytes()).unwrap(); + assert_eq!(spaced.format(), Format::Ies(IesEdition::Lm63_2002)); + let ancient = read_ies(format!("Some free text\n{body}").as_bytes()).unwrap(); + assert_eq!(ancient.format(), Format::Ies(IesEdition::Lm63_1986)); + } + + #[test] + fn tilt_tables_are_read() { + let text = + "IESNA:LM-63-2002\nTILT=INCLUDE\n1\n3\n0 45 90\n1 0.9 0.8\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100 0\n"; + let p = read_ies(text.as_bytes()).unwrap(); + let tilt = p.tilt().unwrap(); + assert_eq!( + (tilt.lamp_geometry, tilt.angles_deg(), tilt.factors()), + (1, &[0.0, 45.0, 90.0][..], &[1.0, 0.9, 0.8][..]) + ); + } + + #[test] + fn errors_name_their_line() { + let cases: &[(&str, usize, &str)] = &[ + ("IESNA:LM-63-2002\n[TEST] x\n", 2, "TILT"), + ("IESNA:LM-63-2002\nTILT=other.tlt\n", 2, "separate file"), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 2 2 0 0 0\n", 3, "type A or B"), + ( + "IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100\n", + 7, + "ends before the candela values", + ), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100 -5\n", 7, "negative"), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100 x\n", 7, "number"), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n100 5\n7\n", 8, "after the candela"), + ( + "IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 2 1 2 0 0 0\n1 1 0\n0 90\n0 45\n1 1\n1 1\n", + 6, + "horizontal angles", + ), + ("IESNA:LM-63-2002\nTILT=NONE\n1 0 1 2 1 1 2 0 0 0\n1 1 0\n0 90\n0\n1 1\n", 3, "lumens per lamp"), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 2 1 1 2 0 0 0\n1 1 0\n0 200\n0\n1 1\n", 5, "gamma angles"), + ("IESNA:LM-63-2002\nTILT=NONE\n1 1000 1 99999 99999 1 2 0 0 0\n", 3, "limit"), + ("IESNA:LM-63-2025\nTILT=NONE\n", 1, "edition"), + ]; + for (text, line, words) in cases { + let e = read_ies(text.as_bytes()).unwrap_err(); + assert_eq!(e.line, *line, "{e}"); + assert!(e.to_string().contains(words), "{e} should mention {words}"); + } + } +} diff --git a/crates/photometry/src/ldt.rs b/crates/photometry/src/ldt.rs new file mode 100644 index 0000000..ca21b4b --- /dev/null +++ b/crates/photometry/src/ldt.rs @@ -0,0 +1,268 @@ +//! The EULUMDAT (.ldt) reader. +//! +//! EULUMDAT is line-based: one item per line, in a fixed order. Lines 1–25 describe the file and the +//! luminaire (the symmetry indicator on line 3, the C-plane and γ counts on lines 4 and 6, the +//! intensity conversion factor on line 24); line 26 gives the number of lamp sets, each set six +//! lines (lamps, type, total flux, colour temperature, colour rendering, wattage); ten direct ratios +//! follow, then every C angle, every γ angle, and the intensities (cd/klm) of the planes the symmetry +//! indicator says are stored, one value per line. Decimal commas are accepted. + +use crate::model::{Format, Photometry}; +use crate::symmetry::{ExpandError, Symmetry}; +use crate::text::{ReadError, ReadErrorKind, count, decode, error, invalid, number, table_error}; +use crate::{MAX_TABLE_VALUES, PhotometryError}; + +/// The file's lines, read one item at a time. +struct Fields<'a> { + lines: std::iter::Enumerate>, + line: usize, +} + +impl<'a> Fields<'a> { + fn next(&mut self, what: &'static str) -> Result<(usize, &'a str), ReadError> { + let (i, text) = self.lines.next().ok_or_else(|| error(self.line, ReadErrorKind::Missing(what)))?; + self.line = i + 1; + Ok((self.line, text.trim_matches(|c: char| c.is_whitespace() || c == '\u{1A}'))) + } + + fn text(&mut self, what: &'static str) -> Result { + Ok(self.next(what)?.1.to_string()) + } + + fn number(&mut self, what: &'static str) -> Result<(usize, f64), ReadError> { + let (line, text) = self.next(what)?; + Ok((line, decimal(line, what, text)?)) + } + + fn count(&mut self, what: &'static str) -> Result<(usize, usize), ReadError> { + let (line, text) = self.next(what)?; + let text = if text.contains(',') && !text.contains('.') { text.replace(',', ".") } else { text.to_string() }; + Ok((line, count(line, what, &text)?)) + } +} + +/// A number, with a decimal comma if the writer used one. +fn decimal(line: usize, what: &'static str, text: &str) -> Result { + if text.contains(',') && !text.contains('.') { + number(line, what, &text.replace(',', ".")) + } else { + number(line, what, text) + } +} + +/// Reads a EULUMDAT file. +pub fn read_eulumdat(bytes: &[u8]) -> Result { + let text = decode(bytes)?; + let mut fields = Fields { lines: text.lines().enumerate(), line: 1 }; + let company = fields.text("company identification")?; + fields.next("type indicator")?; + let (line, indicator) = fields.count("symmetry indicator")?; + let symmetry = match indicator { + 0 => Symmetry::None, + 1 => Symmetry::Rotational, + 2 => Symmetry::PlaneC0C180, + 3 => Symmetry::PlaneC90C270, + 4 => Symmetry::Quadrants, + _ => return Err(invalid(line, "symmetry indicator", "is not 0 to 4")), + }; + let (c_line, n_c) = fields.count("number of C planes")?; + if n_c == 0 { + return Err(invalid(c_line, "number of C planes", "is zero")); + } + fields.next("distance between C planes")?; + let (line, n_gamma) = fields.count("number of intensities per C plane")?; + if n_gamma == 0 { + return Err(invalid(line, "number of intensities per C plane", "is zero")); + } + fields.next("distance between γ angles")?; + fields.next("measurement report number")?; + let name = fields.text("luminaire name")?; + let catalogue = fields.text("luminaire number")?; + for what in ["file name", "date and user"] { + fields.next(what)?; + } + for what in [ + "luminaire length", + "luminaire width", + "luminaire height", + "luminous area length", + "luminous area width", + "luminous area height at C0", + "luminous area height at C90", + "luminous area height at C180", + "luminous area height at C270", + "downward flux fraction", + "light output ratio", + ] { + fields.number(what)?; + } + let (line, conversion) = fields.number("intensity conversion factor")?; + if conversion <= 0.0 { + return Err(invalid(line, "intensity conversion factor", "is not positive")); + } + fields.number("tilt during measurement")?; + let (line, sets) = fields.count("number of lamp sets")?; + match sets { + 0 => return Err(invalid(line, "number of lamp sets", "is zero")), + 1 => {} + _ => return Err(error(line, ReadErrorKind::Unsupported("more than one set of lamps"))), + } + // A negative number of lamps marks absolute photometry (a convention of some writers): the + // flux is then the luminaire's. + let (_, lamps) = fields.number("number of lamps")?; + fields.next("type of lamps")?; + let (flux_line, flux_lm) = fields.number("total luminous flux of lamps")?; + if flux_lm <= 0.0 { + return Err(invalid(flux_line, "total luminous flux of lamps", "is not positive")); + } + for what in ["colour temperature", "colour rendering", "wattage"] { + fields.next(what)?; + } + for _ in 0..10 { + fields.number("direct ratios")?; + } + + let c_first_line = fields.line + 1; + let mut c_angles = Vec::with_capacity(n_c.min(MAX_TABLE_VALUES)); + for _ in 0..n_c { + c_angles.push(fields.number("C angles")?.1); + } + let gamma_first_line = fields.line + 1; + let mut gamma_angles = Vec::with_capacity(n_gamma.min(MAX_TABLE_VALUES)); + for _ in 0..n_gamma { + gamma_angles.push(fields.number("γ angles")?.1); + } + + // The stored planes: all, the first, the first half (C0–C180), the half from C90 (C90–C270), + // or the first quarter (C0–C90). + let stored = match symmetry { + Symmetry::None => Some(c_angles.as_slice()), + Symmetry::Rotational => Some(&[0.0][..]), + Symmetry::PlaneC0C180 => c_angles.get(..=n_c / 2), + Symmetry::PlaneC90C270 => { + let start = c_angles.iter().position(|c| (c - 90.0).abs() < 1e-6); + start.and_then(|s| c_angles.get(s..=s.saturating_add(n_c / 2))) + } + Symmetry::Quadrants => c_angles.get(..=n_c / 4), + } + .filter(|s| !s.is_empty()) + .ok_or_else(|| invalid(c_first_line, "C angles", "do not match the symmetry indicator"))?; + let n_values = stored.len().checked_mul(n_gamma).filter(|n| *n <= MAX_TABLE_VALUES).ok_or_else(|| { + error(c_line, ReadErrorKind::Table(PhotometryError::TooLarge { values: stored.len().saturating_mul(n_gamma) })) + })?; + let values_line = fields.line + 1; + let mut values = Vec::with_capacity(n_values); + for _ in 0..n_values { + let (line, v) = fields.number("intensities")?; + if v < 0.0 { + return Err(invalid(line, "intensity", "is negative")); + } + values.push(v * conversion); + } + while let Ok((line, rest)) = fields.next("") { + if !rest.is_empty() { + return Err(invalid(line, "file", "has data after the intensities (are the counts right?)")); + } + } + + let table = symmetry.expand(stored, gamma_angles, &values).map_err(|e| match e { + ExpandError::Table(t) => table_error(t, c_first_line, gamma_first_line, values_line), + other => invalid(c_first_line, "C angles", other.reason()), + })?; + let mut photometry = Photometry::new(table, flux_lm, lamps < 0.0, symmetry, Format::Eulumdat) + .map_err(|e| error(flux_line, ReadErrorKind::Table(e)))?; + photometry.manufacturer = company; + photometry.name = name; + photometry.catalogue = catalogue; + Ok(photometry) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A EULUMDAT file with symmetry indicator `isym`, `c` angles (all of them), `g` angles and + /// the stored `values`, one lamp set of 2000 lm. + fn ldt(isym: u8, c: &[f64], g: &[f64], values: &[f64]) -> String { + let mut lines: Vec = vec!["Example Lighting".into(), "1".into(), isym.to_string(), c.len().to_string()]; + lines.extend(["0".into(), g.len().to_string(), "0".into(), "R1".into(), "Panel".into(), "EX-2".into()]); + lines.extend(["f.ldt".into(), "2026-10-21".into()]); + lines.extend( + ["600", "600", "50", "580", "580", "0", "0", "0", "0", "100", "80", "1", "0", "1"].map(String::from), + ); + lines.extend(["1", "LED", "2000", "4000", "80", "20"].map(String::from)); + lines.extend((0..10).map(|_| "0.5".to_string())); + lines.extend(c.iter().chain(g).chain(values).map(|v| v.to_string())); + lines.join("\r\n") + "\r\n" + } + + #[test] + fn a_c0_c180_file_reads_into_a_full_table() { + let c = [0.0, 90.0, 180.0, 270.0]; + let text = ldt(2, &c, &[0.0, 90.0], &[300.0, 100.0, 200.0, 50.0, 100.0, 0.0]); + let p = read_eulumdat(text.as_bytes()).unwrap(); + assert_eq!(p.format(), Format::Eulumdat); + assert_eq!(p.symmetry(), Symmetry::PlaneC0C180); + assert_eq!(p.flux_lm(), 2000.0); + assert_eq!(p.table().c_angles(), &c); + assert_eq!(p.table().intensity(270.0, 0.0), 200.0); + assert_eq!(p.table().intensity(180.0, 90.0), 0.0); + assert_eq!( + (p.manufacturer.as_str(), p.name.as_str(), p.catalogue.as_str()), + ("Example Lighting", "Panel", "EX-2") + ); + } + + #[test] + fn every_symmetry_indicator_selects_its_planes() { + let c: Vec = (0..8).map(|i| f64::from(i) * 45.0).collect(); + let g = [0.0, 90.0]; + // Stored planes per indicator: 8, 1, 5, 5, 3. + for (isym, planes) in [(0, 8), (1, 1), (2, 5), (3, 5), (4, 3)] { + let values: Vec = (0..planes * 2).map(|i| f64::from(i as u8)).collect(); + let p = read_eulumdat(ldt(isym, &c, &g, &values).as_bytes()).unwrap(); + let expected_planes = if isym == 1 { 1 } else { 8 }; + assert_eq!(p.table().c_angles().len(), expected_planes, "isym {isym}"); + } + } + + #[test] + fn decimal_commas_and_latin1_are_accepted() { + let text = ldt(1, &[0.0], &[0.0, 90.0], &[100.0, 50.0]) + .replace("\r\n80\r\n1\r\n0\r\n1\r\n", "\r\n80\r\n1,5\r\n0\r\n1\r\n"); + let p = read_eulumdat(text.as_bytes()).unwrap(); + // Conversion factor 1,5: 100 × 1.5 = 150 cd/klm. + assert_eq!(p.table().intensity(0.0, 0.0), 150.0); + let mut bytes = ldt(1, &[0.0], &[0.0, 90.0], &[100.0, 50.0]).into_bytes(); + bytes.splice(0..7, b"Lumi\xE8re".iter().copied()); + assert!(read_eulumdat(&bytes).unwrap().manufacturer.starts_with("Lumière")); + } + + #[test] + fn errors_name_their_line() { + let good = ldt(1, &[0.0], &[0.0, 90.0], &[100.0, 50.0]); + let lines: Vec<&str> = good.lines().collect(); + let with = |n: usize, value: &str| { + let mut l = lines.clone(); + l[n - 1] = value; + l.join("\n") + }; + let cases: Vec<(String, usize, &str)> = vec![ + (with(3, "7"), 3, "symmetry indicator"), + (with(24, "0"), 24, "conversion factor"), + (with(26, "2"), 26, "more than one set"), + (with(29, "-5"), 29, "not positive"), + (with(46, "-1"), 46, "negative"), + (with(46, "abc"), 46, "number"), + (lines[..46].join("\n"), 46, "ends before the intensities"), + (good.clone() + "7\n", 48, "after the intensities"), + (with(44, "200"), 44, "gamma angles"), + (String::new(), 1, "company"), + ]; + for (text, line, words) in cases { + let e = read_eulumdat(text.as_bytes()).unwrap_err(); + assert_eq!(e.line, line, "{e}"); + assert!(e.to_string().contains(words), "{e} should mention {words}"); + } + } +} diff --git a/crates/photometry/src/lib.rs b/crates/photometry/src/lib.rs index b4c8dec..4bacf11 100644 --- a/crates/photometry/src/lib.rs +++ b/crates/photometry/src/lib.rs @@ -8,10 +8,25 @@ //! Values are stored **per 1000 lm of lamp flux** (cd/klm), the form EULUMDAT uses; IES files //! (absolute candela) are normalised to it on read. Multiply by `flux / 1000` for candela. //! -//! Status: M0 has the table and its interpolation. The IES LM-63 and EULUMDAT readers, and -//! symmetry expansion, are M1 (see `parity/features.toml`, area `photometry`). +//! Files are read into a [`Photometry`] (the table, the flux it is relative to, tilt data) by +//! [`read`], [`read_ies`] (IES LM-63, 1986 to 2019) and [`read_eulumdat`] (EULUMDAT, .ldt); the part +//! of the distribution a file stores is mirrored into a full table ([`Symmetry`]). Every file is +//! untrusted: readers return a [`ReadError`] with the line it concerns, never panic, and cap what +//! they allocate. //! -//! Layer L1: depends on `luxpath-geom`. +//! Layer L1: depends on `luxpath-geom`. No I/O: readers take the file's bytes. + +mod ies; +mod ldt; +mod model; +mod symmetry; +mod text; + +pub use ies::{IesEdition, read_ies}; +pub use ldt::read_eulumdat; +pub use model::{EmergencyOutput, Format, Photometry, Tilt, read}; +pub use symmetry::Symmetry; +pub use text::{MAX_FILE_BYTES, ReadError, ReadErrorKind}; use luxpath_geom::wrap_degrees; @@ -31,6 +46,8 @@ pub enum PhotometryError { BadAngles { which: &'static str, reason: &'static str }, /// A value is negative, NaN or infinite. BadValue { index: usize }, + /// A flux or a factor is zero, negative, not a number, or out of its range. + BadFlux { reason: &'static str }, } impl std::fmt::Display for PhotometryError { @@ -45,6 +62,7 @@ impl std::fmt::Display for PhotometryError { } Self::BadAngles { which, reason } => write!(f, "{which} angles: {reason}"), Self::BadValue { index } => write!(f, "intensity value #{index} is negative or not a number"), + Self::BadFlux { reason } => write!(f, "{reason}"), } } } @@ -96,15 +114,78 @@ impl IntensityTable { Self::new(vec![0.0], vec![0.0, 180.0], vec![cd_per_klm, cd_per_klm]) } + /// The C angles in degrees, from C0, strictly increasing, below 360. + pub fn c_angles(&self) -> &[f64] { + &self.c_angles + } + + /// The γ angles in degrees, strictly increasing, within `[0, 180]`. + pub fn gamma_angles(&self) -> &[f64] { + &self.gamma_angles + } + + /// The values, one C plane after another. + pub fn values(&self) -> &[f64] { + &self.values + } + + /// The same table with every value multiplied by `factor` (finite, non-negative). + pub fn scaled(&self, factor: f64) -> Result { + if !factor.is_finite() || factor < 0.0 { + return Err(PhotometryError::BadFlux { reason: "a scale factor is negative or not a number" }); + } + Self::new(self.c_angles.clone(), self.gamma_angles.clone(), self.values.iter().map(|v| v * factor).collect()) + } + + /// The flux the table emits, ∫ I dΩ over the sphere: lumens for a table in candela, lumens per + /// 1000 lamp lumens for a table in cd/klm (so 1000 × the light output ratio). + /// + /// Zonal sum: each γ row is averaged over C (trapezoids around the circle), and each band + /// between two rows gets the mean of its rows times its solid angle, 2π (cos γ₁ − cos γ₂). + /// Exact for distributions constant within each band (an isotropic I gives 4π I); otherwise the + /// error shrinks with the angle step (about 1e-4 for a Lambertian table at 1° steps). + pub fn integrated_flux(&self) -> f64 { + let mean_over_c = |g: usize| -> f64 { + let n = self.c_angles.len(); + if n == 1 { + return self.value(0, g); + } + let mut sum = 0.0; + for i in 0..n { + let (c0, v0) = (self.c_angles.get(i).copied().unwrap_or(0.0), self.value(i, g)); + let (c1, v1) = match self.c_angles.get(i + 1) { + Some(c1) => (*c1, self.value(i + 1, g)), + None => (360.0, self.value(0, g)), + }; + sum += (v0 + v1) / 2.0 * (c1 - c0); + } + sum / 360.0 + }; + let means: Vec = (0..self.gamma_angles.len()).map(mean_over_c).collect(); + let mut flux = 0.0; + for (g, pair) in self.gamma_angles.windows(2).enumerate() { + let (Some(g0), Some(g1)) = (pair.first(), pair.get(1)) else { continue }; + let (Some(m0), Some(m1)) = (means.get(g), means.get(g + 1)) else { continue }; + let solid_angle = std::f64::consts::TAU * (g0.to_radians().cos() - g1.to_radians().cos()); + flux += (m0 + m1) / 2.0 * solid_angle; + } + flux + } + /// Intensity in cd/klm towards `(c, gamma)` in degrees, bilinearly interpolated. /// - /// `c` wraps around 360°. `gamma` outside the table's range is clamped to its end values - /// (a table that stops at γ90 has no upward light, which its last row should say). + /// `c` wraps around 360°; `gamma` is clamped to `[0, 180]`. Outside the table's γ range the + /// intensity is 0: a file measured from γ0 to γ90 says the luminaire emits no upward light. /// Non-finite angles return 0. pub fn intensity(&self, c: f64, gamma: f64) -> f64 { if !c.is_finite() || !gamma.is_finite() { return 0.0; } + let gamma = gamma.clamp(0.0, 180.0); + let (Some(first), Some(last)) = (self.gamma_angles.first(), self.gamma_angles.last()) else { return 0.0 }; + if gamma < *first || gamma > *last { + return 0.0; + } let (g0, g1, gt) = bracket(&self.gamma_angles, gamma); let c = wrap_degrees(c); // Find the C planes either side of `c`, wrapping from the last plane back to C0 (= C360). @@ -196,6 +277,54 @@ mod tests { assert_eq!(t.intensity(0.0, 200.0), 0.0); } + #[test] + fn there_is_no_light_outside_the_measured_gamma_range() { + // Measured from γ0 to γ90 only, with light at γ90: none above it. + let down = IntensityTable::new(vec![0.0], vec![0.0, 90.0], vec![100.0, 40.0]).unwrap(); + assert_eq!(down.intensity(0.0, 90.0), 40.0); + assert_eq!(down.intensity(0.0, 90.5), 0.0); + // Measured from γ90 to γ180 only (an uplight): none below γ90. + let up = IntensityTable::new(vec![0.0], vec![90.0, 180.0], vec![40.0, 100.0]).unwrap(); + assert_eq!(up.intensity(0.0, 89.5), 0.0); + assert_eq!(up.intensity(0.0, 135.0), 70.0); + } + + #[test] + fn integrated_flux_is_exact_for_isotropic_and_close_for_lambertian() { + // Isotropic: Φ = 4π I. + let iso = IntensityTable::isotropic(100.0).unwrap(); + assert!((iso.integrated_flux() - 400.0 * std::f64::consts::PI).abs() < 1e-9); + // Lambertian, I = I₀ cos γ below the horizon: Φ = π I₀ (∫₀^{π/2} cos γ sin γ dγ · 2π = π). + let gammas: Vec = (0..=180).map(f64::from).collect(); + let values: Vec = + gammas.iter().map(|g| if *g <= 90.0 { 1000.0 * g.to_radians().cos() } else { 0.0 }).collect(); + let lambert = IntensityTable::new( + vec![0.0, 90.0, 180.0, 270.0], + gammas.clone(), + [values.clone(), values.clone(), values.clone(), values].concat(), + ) + .unwrap(); + let expected = 1000.0 * std::f64::consts::PI; + assert!((lambert.integrated_flux() - expected).abs() / expected < 2e-4, "{}", lambert.integrated_flux()); + // Doubling C0 and C180 against C90 and C270 averages around the circle by trapezoids: + // the mean over C of 2, 1, 2, 1 is 1.5. + let uneven = IntensityTable::new( + vec![0.0, 90.0, 180.0, 270.0], + vec![0.0, 180.0], + vec![2.0, 2.0, 1.0, 1.0, 2.0, 2.0, 1.0, 1.0], + ) + .unwrap(); + assert!((uneven.integrated_flux() - 1.5 * 4.0 * std::f64::consts::PI).abs() < 1e-9); + } + + #[test] + fn scaling_multiplies_every_value_and_refuses_bad_factors() { + let t = two_plane_table().scaled(0.5).unwrap(); + assert_eq!(t.values(), &[100.0, 50.0, 0.0, 50.0, 25.0, 0.0]); + assert!(two_plane_table().scaled(-1.0).is_err()); + assert!(two_plane_table().scaled(f64::NAN).is_err()); + } + #[test] fn c_is_interpolated_and_wraps_back_to_c0() { let t = two_plane_table(); diff --git a/crates/photometry/src/model.rs b/crates/photometry/src/model.rs new file mode 100644 index 0000000..dfc6486 --- /dev/null +++ b/crates/photometry/src/model.rs @@ -0,0 +1,237 @@ +//! A luminaire's photometry as read from a file: the full table, the flux it is relative to, tilt +//! data, and where it came from. + +use crate::{IesEdition, IntensityTable, PhotometryError, ReadError, Symmetry, read_eulumdat, read_ies}; + +/// The file format a photometry was read from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Ies(IesEdition), + Eulumdat, +} + +/// How a luminaire's light varies with its tilt from the measured position (IES `TILT=INCLUDE`, +/// for lamps whose output depends on their burning position). +#[derive(Debug, Clone, PartialEq)] +pub struct Tilt { + /// The file's lamp-to-luminaire geometry code: 1, 2 or 3. + pub lamp_geometry: u8, + angles_deg: Vec, + factors: Vec, +} + +impl Tilt { + /// Checks that the angles increase strictly and the factors are finite and non-negative. + pub fn new(lamp_geometry: u8, angles_deg: Vec, factors: Vec) -> Option { + let ok = !angles_deg.is_empty() + && angles_deg.len() == factors.len() + && angles_deg.iter().all(|a| a.is_finite()) + && angles_deg.windows(2).all(|w| w.first() < w.get(1)) + && factors.iter().all(|f| f.is_finite() && *f >= 0.0); + ok.then_some(Self { lamp_geometry, angles_deg, factors }) + } + + pub fn angles_deg(&self) -> &[f64] { + &self.angles_deg + } + + pub fn factors(&self) -> &[f64] { + &self.factors + } + + /// The multiplier at `tilt_deg`, linearly interpolated; `None` outside the measured angles + /// (the file does not say, so a check must not guess). + pub fn factor(&self, tilt_deg: f64) -> Option { + let (first, last) = (self.angles_deg.first()?, self.angles_deg.last()?); + if !tilt_deg.is_finite() || tilt_deg < *first || tilt_deg > *last { + return None; + } + let hi = self.angles_deg.iter().position(|a| *a >= tilt_deg)?; + let (a1, f1) = (self.angles_deg.get(hi)?, self.factors.get(hi)?); + if hi == 0 || *a1 == tilt_deg { + return Some(*f1); + } + let (a0, f0) = (self.angles_deg.get(hi - 1)?, self.factors.get(hi - 1)?); + Some(f0 + (f1 - f0) * (tilt_deg - a0) / (a1 - a0)) + } +} + +/// How much light a luminaire gives in emergency mode, relative to the flux in its photometry. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EmergencyOutput { + /// The emergency ballast lumen factor: emergency flux over rated flux, in `(0, 1]`. + BallastLumenFactor(f64), + /// The emergency flux in lumens, measured like [`Photometry::flux_lm`] (the lamps' flux for + /// relative photometry, the luminaire's for absolute), at most the rated flux. A luminaire flux + /// given for relative photometry counts the light output ratio twice: it underestimates, never + /// overestimates. + FluxLm(f64), +} + +/// A luminaire's photometry. +#[derive(Debug, Clone, PartialEq)] +pub struct Photometry { + table: IntensityTable, + flux_lm: f64, + absolute: bool, + symmetry: Symmetry, + format: Format, + tilt: Option, + /// The manufacturer, as the file names it (may be empty). + pub manufacturer: String, + /// The luminaire's name or description (may be empty). + pub name: String, + /// The catalogue or article number (may be empty). + pub catalogue: String, +} + +impl Photometry { + /// `table` in cd/klm of `flux_lm` (finite and positive). + pub(crate) fn new( + table: IntensityTable, + flux_lm: f64, + absolute: bool, + symmetry: Symmetry, + format: Format, + ) -> Result { + if !flux_lm.is_finite() || flux_lm <= 0.0 { + return Err(PhotometryError::BadFlux { reason: "the rated flux is not a positive number" }); + } + Ok(Self { + table, + flux_lm, + absolute, + symmetry, + format, + tilt: None, + manufacturer: String::new(), + name: String::new(), + catalogue: String::new(), + }) + } + + pub(crate) fn with_tilt(mut self, tilt: Option) -> Self { + self.tilt = tilt; + self + } + + /// The full distribution, in cd per 1000 lm of [`Self::flux_lm`]. + pub fn table(&self) -> &IntensityTable { + &self.table + } + + /// The rated flux in mains operation, in lumens: the lamps' flux, or for absolute photometry + /// (LED luminaires measured as a whole) the luminaire's own, integrated from the table. + pub fn flux_lm(&self) -> f64 { + self.flux_lm + } + + /// Whether the file gave absolute intensities (IES lumens per lamp = −1). + pub fn is_absolute(&self) -> bool { + self.absolute + } + + /// The symmetry the file declared (the table is already expanded). + pub fn symmetry(&self) -> Symmetry { + self.symmetry + } + + pub fn format(&self) -> Format { + self.format + } + + pub fn tilt(&self) -> Option<&Tilt> { + self.tilt.as_ref() + } + + /// The luminaire's flux over the rated flux: 1 for absolute photometry. + pub fn light_output_ratio(&self) -> f64 { + self.table.integrated_flux() / 1000.0 + } + + /// Intensity in candela towards `(c, gamma)` (degrees) at a flux of `flux_lm`. + pub fn candela(&self, c: f64, gamma: f64, flux_lm: f64) -> f64 { + self.table.intensity(c, gamma) * flux_lm / 1000.0 + } + + /// The flux in emergency mode, in lumens. Data the file does not hold (emergency output is + /// declared separately by the luminaire's maker), so the caller supplies it; out-of-range + /// values are errors, never clamped. + pub fn emergency_flux_lm(&self, output: EmergencyOutput) -> Result { + match output { + EmergencyOutput::BallastLumenFactor(f) if f.is_finite() && f > 0.0 && f <= 1.0 => Ok(self.flux_lm * f), + EmergencyOutput::BallastLumenFactor(_) => Err(PhotometryError::BadFlux { + reason: "the emergency ballast lumen factor must be above 0 and at most 1", + }), + EmergencyOutput::FluxLm(lm) if lm.is_finite() && lm > 0.0 && lm <= self.flux_lm => Ok(lm), + EmergencyOutput::FluxLm(_) => Err(PhotometryError::BadFlux { + reason: "the emergency flux must be above 0 lm and at most the rated flux", + }), + } + } +} + +/// Reads an IES LM-63 or EULUMDAT file, telling them apart by content: IES files have a `TILT=` +/// line, EULUMDAT files never do. +pub fn read(bytes: &[u8]) -> Result { + if bytes.split(|b| *b == b'\n' || *b == b'\r').any(crate::text::is_tilt_line) { + read_ies(bytes) + } else { + read_eulumdat(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn photometry(flux: f64) -> Photometry { + Photometry::new(IntensityTable::isotropic(1000.0).unwrap(), flux, false, Symmetry::Rotational, Format::Eulumdat) + .unwrap() + } + + #[test] + fn emergency_flux_from_a_factor_or_lumens_within_range() { + let p = photometry(2000.0); + assert_eq!(p.emergency_flux_lm(EmergencyOutput::BallastLumenFactor(0.25)), Ok(500.0)); + assert_eq!(p.emergency_flux_lm(EmergencyOutput::BallastLumenFactor(1.0)), Ok(2000.0)); + assert_eq!(p.emergency_flux_lm(EmergencyOutput::FluxLm(2000.0)), Ok(2000.0)); + for bad in [0.0, -0.1, 1.0001, f64::NAN] { + assert!(p.emergency_flux_lm(EmergencyOutput::BallastLumenFactor(bad)).is_err(), "{bad}"); + } + for bad in [0.0, 2000.5, f64::INFINITY] { + assert!(p.emergency_flux_lm(EmergencyOutput::FluxLm(bad)).is_err(), "{bad}"); + } + } + + #[test] + fn candela_scales_with_flux() { + // 1000 cd/klm at 500 lm is 500 cd. + assert_eq!(photometry(2000.0).candela(10.0, 20.0, 500.0), 500.0); + } + + #[test] + fn rated_flux_must_be_positive() { + for bad in [0.0, -1.0, f64::NAN] { + assert!( + Photometry::new(IntensityTable::isotropic(1.0).unwrap(), bad, false, Symmetry::None, Format::Eulumdat) + .is_err() + ); + } + } + + #[test] + fn tilt_factors_interpolate_inside_and_say_nothing_outside() { + let t = Tilt::new(1, vec![0.0, 30.0, 90.0], vec![1.0, 0.9, 0.6]).unwrap(); + assert_eq!(t.factor(0.0), Some(1.0)); + assert!((t.factor(15.0).unwrap() - 0.95).abs() < 1e-12); + assert!((t.factor(60.0).unwrap() - 0.75).abs() < 1e-12); + assert_eq!(t.factor(90.0), Some(0.6)); + assert_eq!(t.factor(-1.0), None); + assert_eq!(t.factor(90.1), None); + assert_eq!(t.factor(f64::NAN), None); + assert!(Tilt::new(1, vec![0.0, 0.0], vec![1.0, 1.0]).is_none()); + assert!(Tilt::new(1, vec![0.0], vec![-1.0]).is_none()); + assert!(Tilt::new(1, vec![], vec![]).is_none()); + } +} diff --git a/crates/photometry/src/symmetry.rs b/crates/photometry/src/symmetry.rs new file mode 100644 index 0000000..abd3e1a --- /dev/null +++ b/crates/photometry/src/symmetry.rs @@ -0,0 +1,199 @@ +//! Symmetry expansion: files store only the C planes a luminaire's symmetry does not repeat, and the +//! rest is their mirror image. + +use crate::{IntensityTable, PhotometryError}; + +/// Which C planes a file stores. Angles are the file's own (C0 is the file's C0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Symmetry { + /// Every plane, from C0 to below C360 (a C360 plane, if present, repeats C0). + None, + /// One plane, the same all around the vertical axis. + Rotational, + /// C0 to C180, mirrored about the C0–C180 plane: C(360 − c) = C(c). + PlaneC0C180, + /// C90 to C270, mirrored about the C90–C270 plane: C(180 − c) = C(c). + PlaneC90C270, + /// C0 to C90, mirrored about both planes. + Quadrants, +} + +/// Angles closer than this are the same plane (files write angles with a few decimals). +const SAME_ANGLE_DEG: f64 = 1e-6; + +impl Symmetry { + /// The first and last stored C angle this symmetry expects. + pub fn stored_range(self) -> (f64, f64) { + match self { + Self::None => (0.0, 360.0), + Self::Rotational => (0.0, 0.0), + Self::PlaneC0C180 => (0.0, 180.0), + Self::PlaneC90C270 => (90.0, 270.0), + Self::Quadrants => (0.0, 90.0), + } + } + + /// The angles an angle `c` (stored) also stands for, `c` included, each in `[0, 360)`. + fn images(self, c: f64) -> Vec { + let images = match self { + Self::None | Self::Rotational => vec![c], + Self::PlaneC0C180 => vec![c, 360.0 - c], + Self::PlaneC90C270 => vec![c, 180.0 - c], + Self::Quadrants => vec![c, 180.0 - c, 180.0 + c, 360.0 - c], + }; + images.into_iter().map(|a| a.rem_euclid(360.0)).filter(|a| *a < 360.0 - SAME_ANGLE_DEG).collect() + } + + /// The full table from the stored planes: `c_angles` (stored, increasing) and their `values` + /// (one plane of `gamma_angles.len()` values after another). + /// + /// Errors when the stored angles do not span what the symmetry needs. A full table without a C0 + /// plane (possible when C90–C270 planes do not include C180) gets one, interpolated across the + /// wrap from the planes either side. + pub fn expand( + self, + c_angles: &[f64], + gamma_angles: Vec, + values: &[f64], + ) -> Result { + let n_gamma = gamma_angles.len(); + let (Some(first), Some(last)) = (c_angles.first(), c_angles.last()) else { return Err(ExpandError::NoPlanes) }; + let (want_first, want_last) = self.stored_range(); + let near = |a: f64, b: f64| (a - b).abs() < SAME_ANGLE_DEG; + let spans = match self { + Self::Rotational => c_angles.len() == 1, + // Every plane: from C0 up to C360 at most. + Self::None => near(*first, want_first) && *last <= want_last + SAME_ANGLE_DEG, + _ => near(*first, want_first) && near(*last, want_last), + }; + if !spans { + return Err(ExpandError::Span); + } + if c_angles.windows(2).any(|w| w.first() >= w.get(1)) { + return Err(ExpandError::NotIncreasing); + } + let plane = |i: usize| -> Result<&[f64], ExpandError> { + let start = i.checked_mul(n_gamma).ok_or(ExpandError::Shape)?; + values.get(start..start.saturating_add(n_gamma)).ok_or(ExpandError::Shape) + }; + if c_angles.len().checked_mul(n_gamma) != Some(values.len()) { + return Err(ExpandError::Shape); + } + + // Every angle with the stored plane it repeats; the first stored plane to claim an angle wins. + let mut planes: Vec<(f64, usize)> = Vec::new(); + for (i, c) in c_angles.iter().enumerate() { + let c = if self == Self::Rotational { 0.0 } else { *c }; + for image in self.images(c) { + planes.push((image, i)); + } + } + planes.sort_by(|a, b| a.0.total_cmp(&b.0)); + planes.dedup_by(|later, earlier| near(later.0, earlier.0)); + + let mut angles = Vec::with_capacity(planes.len() + 1); + let mut full = Vec::with_capacity((planes.len() + 1).saturating_mul(n_gamma)); + if let (Some((lo, lo_i)), Some((hi, hi_i))) = (planes.first(), planes.last()) + && !near(*lo, 0.0) + { + // No C0 plane: interpolate between the last plane (as hi − 360) and the first. + let t = (360.0 - hi) / (360.0 - hi + lo); + let (lo_row, hi_row) = (plane(*lo_i)?, plane(*hi_i)?); + angles.push(0.0); + full.extend(hi_row.iter().zip(lo_row).map(|(h, l)| h + (l - h) * t)); + } + for (angle, i) in &planes { + angles.push(if near(*angle, 0.0) { 0.0 } else { *angle }); + full.extend_from_slice(plane(*i)?); + } + IntensityTable::new(angles, gamma_angles, full).map_err(ExpandError::Table) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExpandError { + NoPlanes, + /// The stored C angles do not start and end where the symmetry needs. + Span, + NotIncreasing, + /// The values are not one plane per angle. + Shape, + Table(PhotometryError), +} + +impl ExpandError { + pub(crate) fn reason(&self) -> &'static str { + match self { + Self::NoPlanes => "has no C planes", + Self::Span => "do not span what the file's symmetry needs", + Self::NotIncreasing => "are not strictly increasing", + Self::Shape => "do not match the number of values", + Self::Table(_) => "do not form a valid table", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(planes: &[f64]) -> Vec { + // Two γ angles per plane; plane i has values 10i and 10i + 1. + planes.iter().enumerate().flat_map(|(i, _)| [10.0 * i as f64, 10.0 * i as f64 + 1.0]).collect() + } + + fn expand(s: Symmetry, c: &[f64]) -> IntensityTable { + s.expand(c, vec![0.0, 90.0], &rows(c)).unwrap() + } + + #[test] + fn quadrants_mirror_into_four() { + let t = expand(Symmetry::Quadrants, &[0.0, 30.0, 90.0]); + assert_eq!(t.c_angles(), &[0.0, 30.0, 90.0, 150.0, 180.0, 210.0, 270.0, 330.0]); + // C150 = C30 (plane 1), C180 = C0, C210 = C30, C330 = C30. + let first: Vec = t.values().chunks(2).map(|p| p[0]).collect(); + assert_eq!(first, vec![0.0, 10.0, 20.0, 10.0, 0.0, 10.0, 20.0, 10.0]); + } + + #[test] + fn half_planes_mirror_about_their_plane() { + let t = expand(Symmetry::PlaneC0C180, &[0.0, 90.0, 180.0]); + assert_eq!(t.c_angles(), &[0.0, 90.0, 180.0, 270.0]); + assert_eq!(t.intensity(270.0, 0.0), t.intensity(90.0, 0.0)); + let t = expand(Symmetry::PlaneC90C270, &[90.0, 135.0, 180.0, 225.0, 270.0]); + // C0 = C180, C45 = C135, C315 = C225. + assert_eq!(t.c_angles(), &[0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0]); + assert_eq!(t.intensity(0.0, 0.0), t.intensity(180.0, 0.0)); + assert_eq!(t.intensity(45.0, 0.0), t.intensity(135.0, 0.0)); + assert_eq!(t.intensity(315.0, 0.0), t.intensity(225.0, 0.0)); + } + + #[test] + fn a_c90_c270_file_without_c180_gets_an_interpolated_c0() { + // Stored C90, C160, C200, C270: images C20 (of C160) and C340 (of C200), no C0. + let t = expand(Symmetry::PlaneC90C270, &[90.0, 160.0, 200.0, 270.0]); + assert_eq!(t.c_angles().first(), Some(&0.0)); + // C0 lies halfway between C340 (= C200, value 20) and C20 (= C160, value 10): 15. + assert!((t.intensity(0.0, 0.0) - 15.0).abs() < 1e-9); + } + + #[test] + fn full_and_rotational() { + let t = expand(Symmetry::None, &[0.0, 120.0, 240.0, 360.0]); + assert_eq!(t.c_angles(), &[0.0, 120.0, 240.0]); + let t = expand(Symmetry::Rotational, &[0.0]); + assert_eq!(t.c_angles(), &[0.0]); + } + + #[test] + fn wrong_spans_are_errors() { + let e = |s: Symmetry, c: &[f64]| s.expand(c, vec![0.0, 90.0], &rows(c)).unwrap_err(); + assert_eq!(e(Symmetry::Quadrants, &[0.0, 45.0]), ExpandError::Span); + assert_eq!(e(Symmetry::PlaneC0C180, &[10.0, 180.0]), ExpandError::Span); + assert_eq!(e(Symmetry::Rotational, &[0.0, 10.0]), ExpandError::Span); + assert_eq!(e(Symmetry::None, &[0.0, 400.0]), ExpandError::Span); + assert_eq!(e(Symmetry::Quadrants, &[0.0, 60.0, 30.0, 90.0]), ExpandError::NotIncreasing); + assert_eq!(Symmetry::None.expand(&[0.0], vec![0.0], &[1.0, 2.0]).unwrap_err(), ExpandError::Shape); + assert_eq!(Symmetry::None.expand(&[], vec![0.0], &[]).unwrap_err(), ExpandError::NoPlanes); + } +} diff --git a/crates/photometry/src/text.rs b/crates/photometry/src/text.rs new file mode 100644 index 0000000..d52afa2 --- /dev/null +++ b/crates/photometry/src/text.rs @@ -0,0 +1,222 @@ +//! What the text readers share: decoding, tokens with their line numbers, numbers, and errors. + +use std::borrow::Cow; + +use crate::{MAX_TABLE_VALUES, PhotometryError}; + +/// Larger files are refused before reading. Real photometric files are a few hundred kilobytes. +pub const MAX_FILE_BYTES: usize = 16 << 20; + +/// Why a file could not be read, and the line (1-based) it concerns. +#[derive(Debug, Clone, PartialEq)] +pub struct ReadError { + pub line: usize, + pub kind: ReadErrorKind, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ReadErrorKind { + /// More than [`MAX_FILE_BYTES`]. + TooLarge { bytes: usize }, + /// The file ends before this item. + Missing(&'static str), + /// This item is not a (finite) number. + NotANumber { what: &'static str, found: String }, + /// This item has a value the format does not allow. + Invalid { what: &'static str, reason: &'static str }, + /// Allowed by the format, not supported by luxpath yet. + Unsupported(&'static str), + /// The distribution breaks an invariant of [`crate::IntensityTable`]. + Table(PhotometryError), +} + +impl std::fmt::Display for ReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "line {}: ", self.line)?; + match &self.kind { + ReadErrorKind::TooLarge { bytes } => { + write!(f, "{bytes} bytes is more than the {MAX_FILE_BYTES}-byte limit") + } + ReadErrorKind::Missing(what) => write!(f, "the file ends before the {what}"), + ReadErrorKind::NotANumber { what, found } => write!(f, "the {what} should be a number, found `{found}`"), + ReadErrorKind::Invalid { what, reason } => write!(f, "the {what} {reason}"), + ReadErrorKind::Unsupported(what) => write!(f, "not supported yet: {what}"), + ReadErrorKind::Table(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for ReadError {} + +pub(crate) fn error(line: usize, kind: ReadErrorKind) -> ReadError { + ReadError { line, kind } +} + +/// A table error at the line it concerns: the first line of the γ angles, of the C angles, or of +/// the values. +pub(crate) fn table_error(e: PhotometryError, c_line: usize, gamma_line: usize, values_line: usize) -> ReadError { + let line = match &e { + PhotometryError::BadAngles { which: "gamma", .. } => gamma_line, + PhotometryError::BadAngles { .. } => c_line, + _ => values_line, + }; + error(line, ReadErrorKind::Table(e)) +} + +/// Whether `line` is the `TILT=` line of an IES file. +pub(crate) fn is_tilt_line(line: &[u8]) -> bool { + let line = line.trim_ascii_start(); + line.get(..4).is_some_and(|w| w.eq_ignore_ascii_case(b"TILT")) + && line.get(4..).is_some_and(|rest| rest.trim_ascii_start().starts_with(b"=")) +} + +pub(crate) fn invalid(line: usize, what: &'static str, reason: &'static str) -> ReadError { + error(line, ReadErrorKind::Invalid { what, reason }) +} + +/// The file as text: UTF-8 if it is, otherwise Latin-1 (older EULUMDAT and IES files), without a +/// byte-order mark. +pub(crate) fn decode(bytes: &[u8]) -> Result, ReadError> { + if bytes.len() > MAX_FILE_BYTES { + return Err(error(1, ReadErrorKind::TooLarge { bytes: bytes.len() })); + } + let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes); + let text = match std::str::from_utf8(bytes) { + Ok(s) => Cow::Borrowed(s), + Err(_) => Cow::Owned(bytes.iter().map(|b| char::from(*b)).collect()), + }; + // Classic Mac OS files end lines with a bare carriage return. + if text.contains('\r') && !text.contains('\n') { + return Ok(Cow::Owned(text.replace('\r', "\n"))); + } + Ok(text) +} + +/// A short, char-safe excerpt of what was found, for messages. +pub(crate) fn excerpt(found: &str) -> String { + let mut out: String = found.chars().take(40).collect(); + if found.chars().nth(40).is_some() { + out.push('…'); + } + out +} + +/// A finite number. +pub(crate) fn number(line: usize, what: &'static str, found: &str) -> Result { + found + .parse::() + .ok() + .filter(|v| v.is_finite()) + .ok_or_else(|| error(line, ReadErrorKind::NotANumber { what, found: excerpt(found) })) +} + +/// A count: a whole number from 0 to [`MAX_TABLE_VALUES`] (written as `12` or `12.0`). +pub(crate) fn count(line: usize, what: &'static str, found: &str) -> Result { + let v = number(line, what, found)?; + if v < 0.0 || v.fract() != 0.0 || v > MAX_TABLE_VALUES as f64 { + return Err(invalid(line, what, "is not a whole number in range")); + } + // Whole, non-negative and at most MAX_TABLE_VALUES: the cast is exact. + Ok(v as usize) +} + +fn is_separator(c: char) -> bool { + // Commas separate values in some IES files; 0x1A ends files written by old DOS tools. + c.is_whitespace() || c == ',' || c == '\u{1A}' +} + +/// The whitespace- or comma-separated words of some lines, each with its line number. +#[derive(Clone)] +pub(crate) struct Tokens<'a> { + lines: std::iter::Enumerate>, + first_line: usize, + line: usize, + rest: &'a str, +} + +impl<'a> Tokens<'a> { + /// The words of `text`, whose first line is line `first_line` of the file. + pub(crate) fn new(text: &'a str, first_line: usize) -> Self { + Self { lines: text.lines().enumerate(), first_line, line: first_line, rest: "" } + } + + /// The line of the next word (or of the last, at the end). + pub(crate) fn next_line(&self) -> usize { + self.clone().next().map_or(self.line, |(line, _)| line) + } + + /// The next word as a number, or an error naming `what`. + pub(crate) fn next_number(&mut self, what: &'static str) -> Result<(usize, f64), ReadError> { + let (line, word) = self.next().ok_or_else(|| error(self.line, ReadErrorKind::Missing(what)))?; + Ok((line, number(line, what, word)?)) + } + + /// The next word as a count. + pub(crate) fn next_count(&mut self, what: &'static str) -> Result<(usize, usize), ReadError> { + let (line, word) = self.next().ok_or_else(|| error(self.line, ReadErrorKind::Missing(what)))?; + Ok((line, count(line, what, word)?)) + } +} + +impl<'a> Iterator for Tokens<'a> { + type Item = (usize, &'a str); + + fn next(&mut self) -> Option { + loop { + let rest = self.rest.trim_start_matches(is_separator); + if !rest.is_empty() { + // `find` returns a char boundary, so the split cannot fail. + let end = rest.find(is_separator).unwrap_or(rest.len()); + let (word, tail) = rest.split_at_checked(end).unwrap_or((rest, "")); + self.rest = tail; + return Some((self.line, word)); + } + let (i, line) = self.lines.next()?; + self.line = self.first_line.saturating_add(i); + self.rest = line; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokens_carry_their_line_numbers() { + let words: Vec<_> = Tokens::new("1 2,3\n\n 4\u{1A}\n", 10).collect(); + assert_eq!(words, vec![(10, "1"), (10, "2"), (10, "3"), (12, "4")]); + let mut t = Tokens::new("1\n\n2", 1); + t.next(); + assert_eq!((t.line, t.next_line()), (1, 3)); + } + + #[test] + fn numbers_must_be_finite_and_counts_whole() { + assert_eq!(number(1, "x", "1e3"), Ok(1000.0)); + for bad in ["inf", "NaN", "1e999", "", "1,5", "x"] { + assert!(number(1, "x", bad).is_err(), "{bad}"); + } + assert_eq!(count(1, "n", "12.0"), Ok(12)); + for bad in ["-1", "1.5", "1e9"] { + assert!(count(1, "n", bad).is_err(), "{bad}"); + } + } + + #[test] + fn latin1_files_decode_and_large_ones_are_refused() { + assert_eq!(decode(b"Lumi\xE8re").unwrap(), "Lumière"); + assert_eq!(decode(b"\xEF\xBB\xBFok").unwrap(), "ok"); + assert_eq!(decode(b"a\rb").unwrap(), "a\nb"); + assert!(is_tilt_line(b" tilt = NONE") && !is_tilt_line(b"Tilt Lighting Ltd")); + assert!(matches!( + decode(&vec![b' '; MAX_FILE_BYTES + 1]), + Err(ReadError { kind: ReadErrorKind::TooLarge { .. }, .. }) + )); + } + + #[test] + fn excerpts_stop_at_a_char_boundary() { + assert_eq!(excerpt(&"é".repeat(50)).chars().count(), 41); + } +} diff --git a/crates/photometry/tests/common/mod.rs b/crates/photometry/tests/common/mod.rs new file mode 100644 index 0000000..aa9d36b --- /dev/null +++ b/crates/photometry/tests/common/mod.rs @@ -0,0 +1,107 @@ +//! Synthetic photometric files: one distribution written as IES LM-63 and as EULUMDAT, for every +//! symmetry. Written by our code, so they are free to commit and to generate (AGENTS.md §1.3). + +#![allow(dead_code, clippy::indexing_slicing, clippy::unwrap_used)] + +use luxpath_photometry::Symmetry; + +/// A distribution as a file stores it. +#[derive(Debug, Clone)] +pub struct Stored { + pub symmetry: Symmetry, + /// Distance between C planes, in degrees; divides 90. + pub c_step: f64, + pub gamma: Vec, + /// cd/klm, one stored plane after another (planes as [`Stored::stored_c`]). + pub values: Vec, + /// Lamp flux in lumens. + pub flux_lm: f64, +} + +/// Every C angle from 0 below 360 at `step`. +pub fn all_c(step: f64) -> Vec { + let n = (360.0 / step).round() as usize; + (0..n).map(|i| i as f64 * step).collect() +} + +impl Stored { + /// The C angles of the stored planes. + pub fn stored_c(&self) -> Vec { + let (first, last) = match self.symmetry { + Symmetry::Rotational => return vec![0.0], + Symmetry::None => (0.0, 360.0 - self.c_step), + s => s.stored_range(), + }; + all_c(self.c_step).into_iter().chain([360.0]).filter(|c| *c >= first - 1e-9 && *c <= last + 1e-9).collect() + } + + /// A distribution whose value at plane `p`, γ index `g` comes from `f(p, g)`. + pub fn new(symmetry: Symmetry, c_step: f64, gamma: Vec, f: impl Fn(usize, usize) -> f64) -> Self { + let mut s = Self { symmetry, c_step, gamma, values: vec![], flux_lm: 2000.0 }; + let planes = s.stored_c().len(); + s.values = (0..planes).flat_map(|p| (0..s.gamma.len()).map(move |g| (p, g))).map(|(p, g)| f(p, g)).collect(); + s + } + + fn plane(&self, p: usize) -> &[f64] { + let n = self.gamma.len(); + &self.values[p * n..(p + 1) * n] + } + + /// The IES LM-63-2002 file: one lamp of `flux_lm`, absolute candela values. + pub fn ies(&self) -> String { + let mut c = self.stored_c(); + let mut planes: Vec<&[f64]> = (0..c.len()).map(|p| self.plane(p)).collect(); + if self.symmetry == Symmetry::None { + // LM-63 full distributions end with C360, a copy of C0. + c.push(360.0); + planes.push(self.plane(0)); + } + let k = self.flux_lm / 1000.0; + let mut out = String::from("IESNA:LM-63-2002\n[TEST] synthetic\n[MANUFAC] luxpath tests\nTILT=NONE\n"); + out += &format!("1 {} 1 {} {} 1 2 0.2 0.2 0\n1 1 10\n", self.flux_lm, self.gamma.len(), c.len()); + out += &join(&self.gamma); + out += &join(&c); + for p in planes { + out += &join(&p.iter().map(|v| v * k).collect::>()); + } + out + } + + /// The EULUMDAT file (CRLF line ends, as Windows writers produce). + pub fn ldt(&self) -> String { + let isym = match self.symmetry { + Symmetry::None => 0, + Symmetry::Rotational => 1, + Symmetry::PlaneC0C180 => 2, + Symmetry::PlaneC90C270 => 3, + Symmetry::Quadrants => 4, + }; + let c = if self.symmetry == Symmetry::Rotational { vec![0.0] } else { all_c(self.c_step) }; + let mut lines: Vec = vec!["luxpath tests".into(), "1".into(), isym.to_string(), c.len().to_string()]; + lines.extend([self.c_step.to_string(), self.gamma.len().to_string(), "0".into(), "synthetic".into()]); + lines.extend(["Synthetic luminaire", "SYN-1", "synthetic.ldt", "2026-10-21"].map(String::from)); + lines.extend( + ["600", "600", "50", "580", "580", "0", "0", "0", "0", "100", "80", "1", "0", "1"].map(String::from), + ); + lines.extend(["1".into(), "LED".into(), self.flux_lm.to_string(), "4000".into(), "80".into(), "20".into()]); + lines.extend((0..10).map(|_| "0.5".to_string())); + lines.extend(c.iter().chain(&self.gamma).chain(&self.values).map(|v| v.to_string())); + lines.join("\r\n") + "\r\n" + } +} + +fn join(values: &[f64]) -> String { + // Ten values a line, as writers wrap them. + values.chunks(10).map(|chunk| chunk.iter().map(|v| v.to_string()).collect::>().join(" ") + "\n").collect() +} + +/// One distribution per symmetry, uneven enough that a mirrored plane in the wrong place shows. +pub fn every_symmetry() -> Vec { + let gamma: Vec = (0..=36).map(|i| f64::from(i) * 5.0).collect(); + let value = |p: usize, g: usize| (100 * p + 3 * g) as f64 + 0.5; + [Symmetry::None, Symmetry::Rotational, Symmetry::PlaneC0C180, Symmetry::PlaneC90C270, Symmetry::Quadrants] + .into_iter() + .map(|s| Stored::new(s, 15.0, gamma.clone(), value)) + .collect() +} diff --git a/crates/photometry/tests/fixtures/README.md b/crates/photometry/tests/fixtures/README.md new file mode 100644 index 0000000..fbc4ae4 --- /dev/null +++ b/crates/photometry/tests/fixtures/README.md @@ -0,0 +1,11 @@ +# Test fixtures + +Every file here is synthetic: written by hand for luxpath's tests, describing no real product. They are under the +repository's licence (MIT OR Apache-2.0). Manufacturers' files are never committed (AGENTS.md §1.3). + +| File | Source | Licence | What it is | +|---|---|---|---| +| `downlight.ies` | written by hand, 2026-10-21 | MIT OR Apache-2.0 | IES LM-63-2002, rotationally symmetric, 1000 lm; 300/250/120/0 cd/klm at γ 0/30/60/90 | +| `downlight.ldt` | written by hand, 2026-10-21 | MIT OR Apache-2.0 | The same distribution as EULUMDAT (symmetry indicator 1), CRLF line ends | + +The other test files (`tests/common/mod.rs`) are generated by the tests themselves, one per symmetry type. diff --git a/crates/photometry/tests/fixtures/downlight.ies b/crates/photometry/tests/fixtures/downlight.ies new file mode 100644 index 0000000..e0cb2ec --- /dev/null +++ b/crates/photometry/tests/fixtures/downlight.ies @@ -0,0 +1,12 @@ +IESNA:LM-63-2002 +[TEST] luxpath synthetic fixture +[TESTLAB] none (written by hand) +[MANUFAC] luxpath tests +[LUMCAT] SYN-DOWN-1 +[LUMINAIRE] Synthetic downlight, rotationally symmetric +TILT=NONE +1 1000 1 4 1 1 2 0.1 0.1 0 +1 1 10 +0 30 60 90 +0 +300 250 120 0 diff --git a/crates/photometry/tests/fixtures/downlight.ldt b/crates/photometry/tests/fixtures/downlight.ldt new file mode 100644 index 0000000..955b4fc --- /dev/null +++ b/crates/photometry/tests/fixtures/downlight.ldt @@ -0,0 +1,51 @@ +luxpath tests +1 +1 +1 +0 +4 +30 +synthetic +Synthetic downlight, rotationally symmetric +SYN-DOWN-1 +downlight.ldt +2026-10-21 luxpath +100 +0 +50 +100 +0 +0 +0 +0 +0 +100 +84.5 +1 +0 +1 +1 +LED +1000 +4000 +80 +10 +0.5 +0.6 +0.7 +0.75 +0.8 +0.85 +0.88 +0.9 +0.92 +0.95 +0 +0 +30 +60 +90 +300 +250 +120 +0 diff --git a/crates/photometry/tests/formats.proptest-regressions b/crates/photometry/tests/formats.proptest-regressions new file mode 100644 index 0000000..245a829 --- /dev/null +++ b/crates/photometry/tests/formats.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b63c7d25550f9a6fc3e736b487f38fe6f727c81277ee2cad42bb862eea46e828 # shrinks to stored = Stored { symmetry: PlaneC90C270, c_step: 5.0, gamma: [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, 30.0, 31.0, 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0, 50.0, 51.0, 52.0, 53.0, 54.0, 55.0, 56.0, 57.0, 58.0, 59.0, 60.0, 61.0, 62.0, 63.0, 64.0, 65.0, 66.0, 67.0, 68.0, 69.0, 70.0, 71.0, 72.0, 73.0, 74.0, 75.0, 76.0, 77.0, 78.0, 79.0, 80.0, 81.0, 82.0, 83.0, 84.0, 85.0, 86.0, 87.0, 88.0, 89.0, 90.0, 91.0, 92.0, 93.0, 94.0, 95.0, 96.0, 97.0, 98.0, 99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0, 111.0, 112.0, 113.0, 114.0, 115.0, 116.0, 117.0, 118.0, 119.0, 120.0, 121.0, 122.0, 123.0, 124.0, 125.0, 126.0, 127.0, 128.0, 129.0, 130.0, 131.0, 132.0, 133.0, 134.0, 135.0, 136.0, 137.0, 138.0, 139.0, 140.0, 141.0, 142.0, 143.0, 144.0, 145.0, 146.0, 147.0, 148.0, 149.0, 150.0, 151.0, 152.0, 153.0, 154.0, 155.0, 156.0, 157.0, 158.0, 159.0, 160.0, 161.0, 162.0, 163.0, 164.0, 165.0, 166.0, 167.0, 168.0, 169.0, 170.0, 171.0, 172.0, 173.0, 174.0, 175.0, 176.0, 177.0, 178.0, 179.0, 180.0], values: [0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 1453.5, 973.0, 492.5, 12.0, 1532.0, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1073.5, 593.0, 112.5, 1632.5, 1152.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 693.5, 213.0, 1733.0, 1252.5, 772.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 313.5, 1833.5, 1353.0, 872.5, 392.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 1453.5, 973.0, 492.5, 12.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1073.5, 593.0, 112.5, 1632.5, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 693.5, 213.0, 1733.0, 1252.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 313.5, 1833.5, 1353.0, 872.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 1453.5, 973.0, 492.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1073.5, 593.0, 112.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 693.5, 213.0, 1733.0, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 313.5, 1833.5, 1353.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 1453.5, 973.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1073.5, 593.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 693.5, 213.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 313.5, 1833.5, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 1453.5, 1542.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1073.5, 1162.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 693.5, 782.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 313.5, 402.0, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 1934.0, 22.0, 1542.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 1554.0, 1642.5, 1162.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1174.0, 1262.5, 782.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 794.0, 882.5, 402.0, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 414.0, 502.5, 22.0, 1542.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 34.0, 122.5, 1642.5, 1162.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 1654.5, 1743.0, 1262.5, 782.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1274.5, 1363.0, 882.5, 402.0, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 894.5, 983.0, 502.5, 22.0, 1542.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 514.5, 603.0, 122.5, 1642.5, 1162.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 134.5, 223.0, 1743.0, 1262.5, 782.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 1755.0, 1843.5, 1363.0, 882.5, 402.0, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5, 1375.0, 1463.5, 983.0, 502.5, 22.0, 1542.0, 1061.5, 581.0, 100.5, 1620.5, 1140.0, 659.5, 179.0, 1699.0, 1218.5, 738.0, 257.5, 1777.5, 1297.0, 816.5, 336.0, 1856.0, 1375.5, 895.0, 414.5, 1934.5, 1454.0, 973.5, 493.0, 12.5, 1532.5, 1052.0, 571.5, 91.0, 1611.0, 1130.5, 650.0, 169.5, 1689.5, 1209.0, 728.5, 248.0, 1768.0, 1287.5, 807.0, 326.5, 1846.5, 1366.0, 885.5, 405.0, 1925.0, 1444.5, 964.0, 483.5, 3.0, 1523.0, 1042.5, 562.0, 81.5, 1601.5, 1121.0, 640.5, 160.0, 1680.0, 1199.5, 719.0, 238.5, 1758.5, 1278.0, 797.5, 317.0, 1837.0, 1356.5, 876.0, 395.5, 1915.5, 1435.0, 954.5, 474.0, 1994.0, 1513.5, 1033.0, 552.5, 72.0, 1592.0, 1111.5, 631.0, 150.5, 1670.5, 1190.0, 709.5, 229.0, 1749.0, 1268.5, 788.0, 307.5, 1827.5, 1347.0, 866.5, 386.0, 1906.0, 1425.5, 945.0, 464.5, 1984.5, 1504.0, 1023.5, 543.0, 62.5, 1582.5, 1102.0, 621.5, 141.0, 1661.0, 1180.5, 700.0, 219.5, 1739.5, 1259.0, 778.5, 298.0, 1818.0, 1337.5, 857.0, 376.5, 1896.5, 1416.0, 935.5, 455.0, 1975.0, 1494.5, 1014.0, 533.5, 53.0, 1573.0, 1092.5, 612.0, 131.5, 1651.5, 1171.0, 690.5, 210.0, 1730.0, 1249.5, 769.0, 288.5, 1808.5, 1328.0, 847.5, 367.0, 1887.0, 1406.5, 926.0, 445.5, 1965.5, 1485.0, 1004.5, 524.0, 43.5, 1563.5, 1083.0, 602.5, 122.0, 1642.0, 1161.5, 681.0, 200.5, 1720.5, 1240.0, 759.5, 279.0, 1799.0, 1318.5, 838.0, 357.5, 1877.5, 1397.0, 916.5, 436.0, 1956.0, 1475.5, 995.0, 1083.5, 603.0, 122.5, 1642.5, 1162.0, 681.5, 201.0, 1721.0, 1240.5, 760.0, 279.5, 1799.5, 1319.0, 838.5, 358.0, 1878.0, 1397.5, 917.0, 436.5, 1956.5, 1476.0, 995.5, 515.0, 34.5, 1554.5, 1074.0, 593.5, 113.0, 1633.0, 1152.5, 672.0, 191.5, 1711.5, 1231.0, 750.5, 270.0, 1790.0, 1309.5, 829.0, 348.5, 1868.5, 1388.0, 907.5, 427.0, 1947.0, 1466.5, 986.0, 505.5, 25.0, 1545.0, 1064.5, 584.0, 103.5, 1623.5, 1143.0, 662.5, 182.0, 1702.0, 1221.5, 741.0, 260.5, 1780.5, 1300.0, 819.5, 339.0, 1859.0, 1378.5, 898.0, 417.5, 1937.5, 1457.0, 976.5, 496.0, 15.5, 1535.5, 1055.0, 574.5, 94.0, 1614.0, 1133.5, 653.0, 172.5, 1692.5, 1212.0, 731.5, 251.0, 1771.0, 1290.5, 810.0, 329.5, 1849.5, 1369.0, 888.5, 408.0, 1928.0, 1447.5, 967.0, 486.5, 6.0, 1526.0, 1045.5, 565.0, 84.5, 1604.5, 1124.0, 643.5, 163.0, 1683.0, 1202.5, 722.0, 241.5, 1761.5, 1281.0, 800.5, 320.0, 1840.0, 1359.5, 879.0, 398.5, 1918.5, 1438.0, 957.5, 477.0, 1997.0, 1516.5, 1036.0, 555.5, 75.0, 1595.0, 1114.5, 634.0, 153.5, 1673.5, 1193.0, 712.5, 232.0, 1752.0, 1271.5, 791.0, 310.5, 1830.5, 1350.0, 869.5, 389.0, 1909.0, 1428.5, 948.0, 467.5, 1987.5, 1507.0, 1026.5, 546.0, 65.5, 1585.5, 1105.0, 624.5, 144.0, 1664.0, 1183.5, 703.0, 222.5, 1742.5, 1262.0, 781.5, 301.0, 1821.0, 1340.5, 860.0, 379.5, 1899.5, 1419.0, 938.5, 458.0, 1978.0, 1497.5, 1017.0, 536.5, 56.0, 1576.0, 1095.5, 615.0, 703.5, 223.0, 1743.0, 1262.5, 782.0, 301.5, 1821.5, 1341.0, 860.5, 380.0, 1900.0, 1419.5, 939.0, 458.5, 1978.5, 1498.0, 1017.5, 537.0, 56.5, 1576.5, 1096.0, 615.5, 135.0, 1655.0, 1174.5, 694.0, 213.5, 1733.5, 1253.0, 772.5, 292.0, 1812.0, 1331.5, 851.0, 370.5, 1890.5, 1410.0, 929.5, 449.0, 1969.0, 1488.5, 1008.0, 527.5, 47.0, 1567.0, 1086.5, 606.0, 125.5, 1645.5, 1165.0, 684.5, 204.0, 1724.0, 1243.5, 763.0, 282.5, 1802.5, 1322.0, 841.5, 361.0, 1881.0, 1400.5, 920.0, 439.5, 1959.5, 1479.0, 998.5, 518.0, 37.5, 1557.5, 1077.0, 596.5, 116.0, 1636.0, 1155.5, 675.0, 194.5, 1714.5, 1234.0, 753.5, 273.0, 1793.0, 1312.5, 832.0, 351.5, 1871.5, 1391.0, 910.5, 430.0, 1950.0, 1469.5, 989.0, 508.5, 28.0, 1548.0, 1067.5, 587.0, 106.5, 1626.5, 1146.0, 665.5, 185.0, 1705.0, 1224.5, 744.0, 263.5, 1783.5, 1303.0, 822.5, 342.0, 1862.0, 1381.5, 901.0, 420.5, 1940.5, 1460.0, 979.5, 499.0, 18.5, 1538.5, 1058.0, 577.5, 97.0, 1617.0, 1136.5, 656.0, 175.5, 1695.5, 1215.0, 734.5, 254.0, 1774.0, 1293.5, 813.0, 332.5, 1852.5, 1372.0, 891.5, 411.0, 1931.0, 1450.5, 970.0, 489.5, 9.0, 1529.0, 1048.5, 568.0, 87.5, 1607.5, 1127.0, 646.5, 166.0, 1686.0, 1205.5, 725.0, 244.5, 1764.5, 1284.0, 803.5, 323.0, 1843.0, 1362.5, 882.0, 401.5, 1921.5, 1441.0, 960.5, 480.0, 2000.0, 1519.5, 1039.0, 558.5, 78.0, 1598.0, 1117.5, 637.0, 156.5, 1676.5, 1196.0, 715.5, 235.0, 323.5, 1843.5, 1363.0, 882.5, 402.0, 1922.0, 1441.5, 961.0, 480.5, 0.0, 1520.0, 1039.5, 559.0, 78.5, 1598.5, 1118.0, 637.5, 157.0, 1677.0, 1196.5, 716.0, 235.5, 1755.5, 1275.0, 794.5, 314.0, 1834.0, 1353.5, 873.0, 392.5, 1912.5, 1432.0, 951.5, 471.0, 1991.0, 1510.5, 1030.0, 549.5, 69.0, 1589.0, 1108.5, 628.0, 147.5, 1667.5, 1187.0, 706.5, 226.0, 1746.0, 1265.5, 785.0, 304.5, 1824.5, 1344.0, 863.5, 383.0, 1903.0, 1422.5, 942.0, 461.5, 1981.5, 1501.0, 1020.5, 540.0, 59.5, 1579.5, 1099.0, 618.5, 138.0, 1658.0, 1177.5, 697.0, 216.5, 1736.5, 1256.0, 775.5, 295.0, 1815.0, 1334.5, 854.0, 373.5, 1893.5, 1413.0, 932.5, 452.0, 1972.0, 1491.5, 1011.0, 530.5, 50.0, 1570.0, 1089.5, 609.0, 128.5, 1648.5, 1168.0, 687.5, 207.0, 1727.0, 1246.5, 766.0, 285.5, 1805.5, 1325.0, 844.5, 364.0, 1884.0, 1403.5, 923.0, 442.5, 1962.5, 1482.0, 1001.5, 521.0, 40.5, 1560.5, 1080.0, 599.5, 119.0, 1639.0, 1158.5, 678.0, 197.5, 1717.5, 1237.0, 756.5, 276.0, 1796.0, 1315.5, 835.0, 354.5, 1874.5, 1394.0, 913.5, 433.0, 1953.0, 1472.5, 992.0, 511.5, 31.0, 1551.0, 1070.5, 590.0, 109.5, 1629.5, 1149.0, 668.5, 188.0, 1708.0, 1227.5, 747.0, 266.5, 1786.5, 1306.0, 825.5, 345.0, 1865.0, 1384.5, 904.0, 423.5, 1943.5, 1463.0, 982.5, 502.0, 21.5, 1541.5, 1061.0, 580.5, 100.0, 1620.0, 1139.5, 659.0, 178.5, 1698.5, 1218.0, 737.5, 257.0, 1777.0, 1296.5, 816.0, 335.5, 1855.5], flux_lm: 1.0 }, c = 0.0, gamma = 0.0 diff --git a/crates/photometry/tests/formats.rs b/crates/photometry/tests/formats.rs new file mode 100644 index 0000000..fedd693 --- /dev/null +++ b/crates/photometry/tests/formats.rs @@ -0,0 +1,148 @@ +//! M1's exit test: one distribution per symmetry, written as IES and as EULUMDAT, reads into the +//! same full table, and every direction carries the value of the stored plane it mirrors. + +#![allow(clippy::unwrap_used, clippy::indexing_slicing)] + +mod common; + +use common::{Stored, all_c, every_symmetry}; +use luxpath_photometry::{Format, IesEdition, Symmetry, read, read_eulumdat, read_ies}; +use proptest::prelude::*; + +/// The stored C angle that `c` mirrors, by the symmetry's definition. +fn mirrored(symmetry: Symmetry, c: f64) -> f64 { + match symmetry { + Symmetry::None => c, + Symmetry::Rotational => 0.0, + Symmetry::PlaneC0C180 => { + if c <= 180.0 { + c + } else { + 360.0 - c + } + } + Symmetry::PlaneC90C270 => { + if (90.0..=270.0).contains(&c) { + c + } else { + (180.0 - c).rem_euclid(360.0) + } + } + Symmetry::Quadrants => { + let c = c % 180.0; + if c > 90.0 { 180.0 - c } else { c } + } + } +} + +fn check(stored: &Stored) { + let ies = read_ies(stored.ies().as_bytes()).unwrap(); + let ldt = read_eulumdat(stored.ldt().as_bytes()).unwrap(); + assert_eq!(ies.format(), Format::Ies(IesEdition::Lm63_2002)); + assert_eq!(ldt.format(), Format::Eulumdat); + assert_eq!((ies.symmetry(), ldt.symmetry()), (stored.symmetry, stored.symmetry)); + assert_eq!(ies.flux_lm(), stored.flux_lm); + assert_eq!(ldt.flux_lm(), stored.flux_lm); + assert_eq!(ies.table(), ldt.table(), "{:?}", stored.symmetry); + + let planes = stored.stored_c(); + let full_c = if stored.symmetry == Symmetry::Rotational { vec![0.0] } else { all_c(stored.c_step) }; + assert_eq!(ldt.table().c_angles(), &full_c[..]); + for c in &full_c { + let source = mirrored(stored.symmetry, *c); + let p = planes.iter().position(|a| (a - source).abs() < 1e-9).unwrap(); + for (g, gamma) in stored.gamma.iter().enumerate() { + let want = stored.values[p * stored.gamma.len() + g]; + assert_eq!(ldt.table().intensity(*c, *gamma), want, "{:?} C{c} γ{gamma}", stored.symmetry); + } + } +} + +#[test] +fn every_symmetry_reads_the_same_from_ies_and_eulumdat() { + for stored in every_symmetry() { + check(&stored); + } +} + +#[test] +fn read_tells_the_formats_apart() { + for stored in every_symmetry() { + assert!(matches!(read(stored.ies().as_bytes()).unwrap().format(), Format::Ies(_))); + assert_eq!(read(stored.ldt().as_bytes()).unwrap().format(), Format::Eulumdat); + } +} + +#[test] +fn the_committed_fixtures_read_and_agree() { + let ies = read(include_bytes!("fixtures/downlight.ies")).unwrap(); + let ldt = read(include_bytes!("fixtures/downlight.ldt")).unwrap(); + assert_eq!(ies.table(), ldt.table()); + assert_eq!(ies.flux_lm(), 1000.0); + // 300 cd/klm straight down at 1000 lm is 300 cd. + assert_eq!(ldt.candela(0.0, 0.0, 1000.0), 300.0); +} + +fn symmetry() -> impl Strategy { + prop_oneof![ + Just(Symmetry::None), + Just(Symmetry::Rotational), + Just(Symmetry::PlaneC0C180), + Just(Symmetry::PlaneC90C270), + Just(Symmetry::Quadrants), + ] +} + +fn stored() -> impl Strategy { + let c_step = prop::sample::select(vec![5.0, 7.5, 10.0, 15.0, 22.5, 30.0, 45.0, 90.0]); + let gamma = prop_oneof![ + prop::sample::select(vec![1.0, 2.5, 5.0, 10.0, 45.0]).prop_map(|s| (0.0, 180.0, s)), + prop::sample::select(vec![2.5, 5.0, 30.0]).prop_map(|s| (0.0, 90.0, s)), + prop::sample::select(vec![5.0, 90.0]).prop_map(|s| (90.0, 180.0, s)), + ] + .prop_map(|(from, to, step): (f64, f64, f64)| { + let n = ((to - from) / step).round() as usize; + (0..=n).map(|i| from + i as f64 * step).collect::>() + }); + (symmetry(), c_step, gamma, any::(), 1u32..100_000).prop_map(|(symmetry, c_step, gamma, seed, flux)| { + Stored { + flux_lm: f64::from(flux), + ..Stored::new(symmetry, c_step, gamma, move |p, g| { + // Values from 0 to 2000 in half candelas, which every step represents exactly. + let h = seed.wrapping_mul(6364136223846793005).wrapping_add((p * 1000 + g) as u64).rotate_left(17); + (h % 4001) as f64 / 2.0 + }) + } + }) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + #[test] + fn any_distribution_reads_the_same_from_both_formats(stored in stored()) { + // Flux values that do not halve exactly make IES and EULUMDAT differ in the last bit. + let ies = read_ies(stored.ies().as_bytes()).unwrap(); + let ldt = read_eulumdat(stored.ldt().as_bytes()).unwrap(); + prop_assert_eq!(ies.table().c_angles(), ldt.table().c_angles()); + prop_assert_eq!(ies.table().gamma_angles(), ldt.table().gamma_angles()); + for (a, b) in ies.table().values().iter().zip(ldt.table().values()) { + prop_assert!((a - b).abs() <= 1e-9 * b.max(1.0), "{} vs {}", a, b); + } + } + + #[test] + fn mirrored_directions_have_equal_intensities(stored in stored(), c in 0.0f64..360.0, gamma in 0.0f64..180.0) { + let t = read_eulumdat(stored.ldt().as_bytes()).unwrap(); + let t = t.table(); + let m = mirrored(stored.symmetry, c); + prop_assert!((t.intensity(c, gamma) - t.intensity(m, gamma)).abs() < 1e-9); + } + + #[test] + fn arbitrary_bytes_never_panic(bytes in prop::collection::vec(any::(), 0..2000)) { + let _ = read(&bytes); + let _ = read_ies(&bytes); + let _ = read_eulumdat(&bytes); + } +} diff --git a/crates/photometry/tests/fuzz.rs b/crates/photometry/tests/fuzz.rs new file mode 100644 index 0000000..ffadb21 --- /dev/null +++ b/crates/photometry/tests/fuzz.rs @@ -0,0 +1,182 @@ +//! Mutation fuzzing of the readers (AGENTS.md §3: no input may crash them). +//! +//! Seeds are synthetic files of every symmetry, with and without tilt data; each round mutates one +//! (truncation, deleted, repeated or swapped lines, a number replaced by an awkward one, random +//! bytes) and feeds it to every reader. `fuzz_briefly` runs on every `cargo test`; the long run is +//! +//! ```text +//! LUXPATH_FUZZ_SECONDS=600 cargo test --release -p luxpath-photometry --test fuzz -- --ignored +//! ``` +//! +//! (`cargo xtask fuzz [seconds]`). A crashing input is written to the temporary directory and named +//! in the failure. + +#![allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] + +mod common; + +use std::time::{Duration, Instant}; + +use common::every_symmetry; +use luxpath_photometry::{read, read_eulumdat, read_ies}; + +const AWKWARD: &[&str] = &[ + "-1", + "0", + "1", + "2", + "3", + "4", + "5", + "-0", + "1e308", + "-1e308", + "1e-320", + "99999999", + "1000000", + "4294967296", + "nan", + "inf", + "", + "x", + "0.5", + "89.9999999", + "90", + "180", + "270", + "360", + "360.0000001", + "720", + "INCLUDE", + "NONE", + "TILT=", + "[MORE]", + ",", + "1,5", +]; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + + /// A line index, half the time among the first 60, where the headers and counts are. + fn line(&mut self, n: usize) -> usize { + if self.next().is_multiple_of(2) { self.below(n.min(60)) } else { self.below(n) } + } +} + +fn seeds() -> Vec> { + let mut seeds = + vec![include_bytes!("fixtures/downlight.ies").to_vec(), include_bytes!("fixtures/downlight.ldt").to_vec()]; + for stored in every_symmetry() { + let ies = stored.ies(); + seeds.push(ies.clone().into_bytes()); + seeds.push(ies.replace("TILT=NONE\n", "TILT=INCLUDE\n1\n3\n0 45 90\n1 0.9 0.8\n").into_bytes()); + seeds.push(stored.ldt().into_bytes()); + } + seeds +} + +fn mutate(rng: &mut Rng, seed: &[u8]) -> Vec { + let text = String::from_utf8_lossy(seed).into_owned(); + let mut lines: Vec = text.lines().map(String::from).collect(); + let rounds = 1 + rng.below(3); + let mut bytes = Vec::new(); + for _ in 0..rounds { + match rng.below(7) { + 0 => { + let keep = rng.below(lines.len() + 1); + lines.truncate(keep); + } + 1 if !lines.is_empty() => { + let i = rng.line(lines.len()); + lines.remove(i); + } + 2 if !lines.is_empty() => { + let i = rng.line(lines.len()); + let line = lines[i].clone(); + lines.insert(i, line); + } + 3 if lines.len() > 1 => { + let (a, b) = (rng.line(lines.len()), rng.below(lines.len())); + lines.swap(a, b); + } + 4 | 5 if !lines.is_empty() => { + // Replace one word of one line with an awkward value. + let i = rng.line(lines.len()); + let mut words: Vec = lines[i].split_whitespace().map(String::from).collect(); + let w = rng.below(words.len() + 1); + let awkward = AWKWARD[rng.below(AWKWARD.len())].to_string(); + if w < words.len() { + words[w] = awkward; + } else { + words.push(awkward); + } + lines[i] = words.join(" "); + } + _ => { + // Random bytes somewhere in the file. + bytes = lines.join("\n").into_bytes(); + for _ in 0..1 + rng.below(4) { + let at = rng.below(bytes.len() + 1); + bytes.insert(at, (rng.next() >> 24) as u8); + } + lines = String::from_utf8_lossy(&bytes).lines().map(String::from).collect(); + } + } + } + if bytes.is_empty() || rng.below(2) == 0 { + lines.join(if rng.below(2) == 0 { "\n" } else { "\r\n" }).into_bytes() + } else { + bytes + } +} + +/// Fuzzes for `duration`; returns the number of inputs tried. +fn fuzz(duration: Duration, seed: u64) -> usize { + let seeds = seeds(); + let mut rng = Rng(seed | 1); + let start = Instant::now(); + let mut tried = 0; + while start.elapsed() < duration { + let pick = rng.below(seeds.len()); + let input = mutate(&mut rng, &seeds[pick]); + let outcome = std::panic::catch_unwind(|| { + let _ = read(&input); + let _ = read_ies(&input); + let _ = read_eulumdat(&input); + }); + if outcome.is_err() { + let path = std::env::temp_dir().join(format!("luxpath-fuzz-crash-{seed}-{tried}.txt")); + std::fs::write(&path, &input).unwrap(); + panic!("a reader panicked on the input saved at {}", path.display()); + } + tried += 1; + } + tried +} + +#[test] +fn fuzz_briefly() { + let tried = fuzz(Duration::from_secs(2), 0x5eed); + assert!(tried > 100, "only {tried} inputs in two seconds"); +} + +#[test] +#[ignore = "long run: LUXPATH_FUZZ_SECONDS=600 cargo test --release -p luxpath-photometry --test fuzz -- --ignored"] +fn fuzz_long() { + let seconds = std::env::var("LUXPATH_FUZZ_SECONDS").ok().and_then(|s| s.parse().ok()).unwrap_or(600); + let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(1, |d| d.as_nanos() as u64); + let tried = fuzz(Duration::from_secs(seconds), seed); + eprintln!("fuzzed {tried} inputs in {seconds} s (seed {seed}) without a panic"); +} diff --git a/docs/architecture.md b/docs/architecture.md index bfb4ba9..f367cc7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,5 +40,8 @@ new crate isn't listed in `xtask/src/layers.rs`. are applied at calculation time, so one table serves mains and emergency modes. - **Verdicts are three-valued:** pass, fail, cannot verify. Missing data is never a pass. - **Text formats for everything a user writes** (scenes in TOML), so designs can be reviewed and diffed. -- **Own photometry readers.** Existing crates are AGPL (`eulumdat`) or unmaintained (`lidrs`, 2022); the formats - are small and well documented. +- **Own photometry readers.** Re-evaluated 2026-10-21 before M1: `eulumdat` and `gldf-rs` are AGPL-3.0 and + `l3d_rs` GPL-3.0 (excluded, AGENTS.md §1.2). `lidrs` (MIT) is the only permissive one, but its development + stopped in 2022 (later commits are dependency bumps); it has `todo!()` for type A/B files, no LM-63-2019, panics + on 126 of about 18,000 mutated inputs, and accepts 85 of 415 truncated IES files as valid. The formats are + small, so luxpath has its own readers (`luxpath-photometry`), built to AGENTS.md §3. diff --git a/docs/gaps.md b/docs/gaps.md index 38fef84..c7f1683 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,14 +1,12 @@ # Gaps: what luxpath cannot do yet -> **Last updated:** 2026-10-16 · **Change:** initial list +> **Last updated:** 2026-10-21 · **Change:** M1 closed rows 1–2; added rows 12–15 Every known shortfall, ranked by user impact. This is the work list: pick from the top, and remove or update a row in the same PR that closes it. **[alpha blocker]** rows fail the alpha gate (ROADMAP.md). | # | Gap | Why it ranks here | Milestone | Est. h | |---|---|---|---|---| -| 1 | **[alpha blocker]** No photometric file readers (IES, EULUMDAT) | Nothing real can be calculated without them | M1 | 15–25 | -| 2 | **[alpha blocker]** No symmetry expansion | Most files store a half or a quarter of the distribution | M1 | 3–5 | | 3 | **[alpha blocker]** No grids or multi-luminaire sums | Requirements are about areas and routes, not points | M2 | 8–12 | | 4 | **[alpha blocker]** Requirement values not verified against EN 1838:2024 text | Values in `standards-parity.md` come from secondary sources | M4 | 3 + buying the standard | | 5 | **[alpha blocker]** No scene format or `check` command | Users can't describe a floor | M3 | 15–20 | @@ -17,4 +15,8 @@ in the same PR that closes it. **[alpha blocker]** rows fail the alpha gate (ROA | 8 | Only downward-pointing luminaires | Wall-mounted luminaires and exit signs are common | M2 | 4–6 | | 9 | No vertical illuminance | Needed for safety equipment checks | M2 | 2–4 | | 10 | No reports beyond the terminal | Designers hand over documents | M5 | 15–25 | -| 11 | No CI on a hosted repository yet | The workflow exists but the repository isn't published | — | 1 | \ No newline at end of file +| 11 | No CI on a hosted repository yet | The workflow exists but the repository isn't published | — | 1 | +| 12 | **[alpha blocker]** Readers tried on only two real files | Synthetic files test the formats as we read them; manufacturers' files (kept in `plan/`, never committed) test them as written. Wanted: a few dozen IES and LDT files, including emergency luminaires | M6 | 2–4 + files from the owner | +| 13 | No plausibility warnings on photometry | A relative file emitting more than its lamps (the LM-63 example file integrates to 211 %), or a declared LDT light output ratio far from the table's, should be reported, not silently used | M2 | 2–3 | +| 14 | Format details from our reading, not the specification texts | The LM-63 factor that changed meaning between editions, and the C0 orientation of each format, are unverified like the EN 1838 values (gap 4); LM-63-2019 is sold by the IES | M6 | 2 + buying LM-63-2019 | +| 15 | Refused file variants | IES type A and B photometry, `TILT=`, and EULUMDAT files with several lamp sets are refused with a located error | M7 | 4–8 | \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index 60b67a6..f2dd6aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Milestones -> **Last updated:** 2026-10-16 · **Change:** initial plan +> **Last updated:** 2026-10-21 · **Change:** M1 done Estimates are agent-assisted hours (a human directing an agent, reviewing every change) and are rough until M2 calibrates them. Each milestone ends with its exit criteria met, `cargo xtask ci` green, and the parity entries @@ -9,7 +9,7 @@ updated. | M | Name | Scope | Exit criteria | Est. h | Status | |---|---|---|---|---|---| | M0 | Foundations | Workspace, lints, CI, `xtask` gates (ci, layers, parity), docs, intensity table, direct point illuminance, demo CLI | All gates green; one hand-checked calculation end to end | 5–8 | **done** | -| M1 | Photometry | IES LM-63 (1995/2002/2019) and EULUMDAT readers; symmetry expansion; tilt data; emergency-mode flux; validation errors with line numbers; property tests and fuzzing | Synthetic fixtures for every symmetry type read identically from IES and LDT; fuzzing finds no panic in 10 min | 20–35 | planned | +| M1 | Photometry | IES LM-63 (1995/2002/2019) and EULUMDAT readers; symmetry expansion; tilt data; emergency-mode flux; validation errors with line numbers; property tests and fuzzing | Synthetic fixtures for every symmetry type read identically from IES and LDT; fuzzing finds no panic in 10 min | 20–35 | **done** | | M2 | Engine | Calculation grids (areas, route bands), sums over luminaires, tilted/wall-mounted luminaires, vertical illuminance, min/avg/max/uniformity statistics | Analytic test cases (isotropic, Lambertian) match to 1e-6; performance: 100 luminaires × 10k points < 100 ms | 20–30 | planned | | M3 | Scene and CLI | TOML scene format (versioned), 2D geometry (polygons, routes with width), `luxpath check scene.toml` with exit codes | Three example scenes in `examples/`; malformed scenes give located errors | 20–30 | planned | | M4 | EN 1838:2024 | Every requirement in `docs/standards-parity.md`: escape routes over the full width, uniformity, open areas, high-risk areas, points of emphasis, glare limits, declared duration/response | Each P0 requirement tested on both sides of its threshold; values verified against the standard's text | 25–40 | planned | diff --git a/docs/status.md b/docs/status.md index ffe6314..0f038e9 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,9 +1,16 @@ # Status -> **Last updated:** 2026-10-16 +> **Last updated:** 2026-10-21 -**Milestone:** M0 done. **Direction under study:** EN 1838 checks on IFC models (docs/research.md §6–7). The IFC -inspector (`luxpath inspect-ifc`, an early piece of M7) is done. +**Milestone:** M1 done (photometry readers). **Direction under study:** EN 1838 checks on IFC models +(docs/research.md §6–7). The IFC inspector (`luxpath inspect-ifc`, an early piece of M7) is done. + +## M1 (2026-10-21) +`luxpath-photometry` reads IES LM-63 (1986–2019, type C, relative and absolute, tilt tables) and EULUMDAT (every +symmetry indicator, Latin-1, decimal commas) into one full C-γ table; errors name their line. Synthetic files of +every symmetry read identically from both formats; a 10-minute fuzz run (`cargo xtask fuzz`) found no panic. +`luxpath photometry ` shows what a file says. Of two real files tried, both read; one (the LM-63 example) +emits 211 % of its lamp flux, which gap 13 will flag. No usable existing crate (docs/architecture.md). ## What the inspector showed Public Revit models carry luminaires, spaces, doors and stairs, but no photometry, no emergency marking and no @@ -13,10 +20,11 @@ FireExit flags. Luminaires collapse into few types (410 → 5), so a photometry - **A. Evidence:** inspect one real project IFC from a practising office (privately, in `plan/`). - **B. IFC scene:** read luminaire positions and space outlines (geometry) and define the mapping file (type name or product code → photometric file, emergency yes/no). -- **C. M1 photometry:** the IES/EULUMDAT readers. Needed by every direction. +- **C. M2 engine:** grids, sums over luminaires, tilted luminaires, vertical illuminance (with M1's photometry). +- **D. Real files:** photometric files of luminaires the owner's practice uses, privately in `plan/` (gap 12). ## Blockers -- None for M1. **Before M4:** the EN 1838:2024 text (Italian edition UNI EN 1838:2025) is needed to verify values (gap 4). +- None for M2. **Before M4:** the EN 1838:2024 text (Italian edition UNI EN 1838:2025) is needed to verify values (gap 4). ## Open questions (owner) - Practitioner questions in `docs/research.md` §5. diff --git a/parity/features.toml b/parity/features.toml index 9dd27b6..ba6afa3 100644 --- a/parity/features.toml +++ b/parity/features.toml @@ -45,9 +45,19 @@ title = "C-γ intensity table with bilinear interpolation" area = "photometry" tier = "P0" milestone = "M0" -status = "partial" -evidence = ["crates/photometry/src/lib.rs::c_is_interpolated_and_wraps_back_to_c0", "crates/photometry/src/lib.rs::malformed_tables_are_rejected"] -notes = "Full (expanded) tables only. Missing: symmetry expansion (C0, C0-C180, C90-C270, quadrant), A/B-type photometry." +status = "shipped" +evidence = ["crates/photometry/src/lib.rs::c_is_interpolated_and_wraps_back_to_c0", "crates/photometry/src/lib.rs::malformed_tables_are_rejected", "crates/photometry/src/lib.rs::there_is_no_light_outside_the_measured_gamma_range", "crates/photometry/src/lib.rs::integrated_flux_is_exact_for_isotropic_and_close_for_lambertian"] +notes = "Full tables; zero outside the measured γ range; flux by zonal integration. Symmetry expansion is `photometry.symmetry`." + +[[feature]] +id = "photometry.symmetry" +title = "Symmetry expansion (rotational, C0–C180, C90–C270, quadrants, none)" +area = "photometry" +tier = "P0" +milestone = "M1" +status = "shipped" +evidence = ["crates/photometry/src/symmetry.rs::quadrants_mirror_into_four", "crates/photometry/src/symmetry.rs::half_planes_mirror_about_their_plane", "crates/photometry/tests/formats.rs::every_symmetry_reads_the_same_from_ies_and_eulumdat", "crates/photometry/tests/formats.rs::mirrored_directions_have_equal_intensities"] +notes = "Every symmetry of both formats, checked plane by plane against the definition and by property tests. A C90–C270 file without a C180 plane gets an interpolated C0." [[feature]] id = "photometry.ies-read" @@ -55,7 +65,9 @@ title = "Read IES LM-63 files (1995, 2002, 2019)" area = "photometry" tier = "P0" milestone = "M1" -status = "planned" +status = "partial" +evidence = ["crates/photometry/src/ies.rs::a_quadrant_file_reads_into_a_full_table_in_cd_per_klm", "crates/photometry/src/ies.rs::absolute_photometry_takes_its_flux_from_the_table", "crates/photometry/src/ies.rs::older_editions_apply_the_ballast_lamp_factor_and_newer_ones_do_not", "crates/photometry/src/ies.rs::tilt_tables_are_read", "crates/photometry/src/ies.rs::errors_name_their_line", "crates/photometry/tests/fuzz.rs::fuzz_briefly"] +notes = "Editions 1986–2019, type C, relative and absolute photometry, TILT=INCLUDE, located errors, fuzzed. Missing: type A and B photometry (refused, not guessed), TILT=; the edition-specific factors are from our reading of the format, not yet checked against the LM-63 texts; no real manufacturer file tried yet." [[feature]] id = "photometry.ldt-read" @@ -63,7 +75,9 @@ title = "Read EULUMDAT (.ldt) files" area = "photometry" tier = "P0" milestone = "M1" -status = "planned" +status = "partial" +evidence = ["crates/photometry/src/ldt.rs::a_c0_c180_file_reads_into_a_full_table", "crates/photometry/src/ldt.rs::every_symmetry_indicator_selects_its_planes", "crates/photometry/src/ldt.rs::decimal_commas_and_latin1_are_accepted", "crates/photometry/src/ldt.rs::errors_name_their_line", "crates/photometry/tests/fuzz.rs::fuzz_briefly"] +notes = "All five symmetry indicators, Latin-1, decimal commas, located errors, fuzzed. Missing: files with more than one lamp set (refused), a check of the declared light output ratio against the table; no real manufacturer file tried yet." [[feature]] id = "photometry.emergency-flux" @@ -71,7 +85,9 @@ title = "Emergency-mode flux (ballast lumen factor) separate from mains flux" area = "photometry" tier = "P0" milestone = "M1" -status = "planned" +status = "shipped" +evidence = ["crates/photometry/src/model.rs::emergency_flux_from_a_factor_or_lumens_within_range"] +notes = "`Photometry::emergency_flux_lm` from a ballast lumen factor in (0, 1] or lumens up to the rated flux; out of range is an error. Photometric files do not hold it: the scene (M3) supplies it, and a missing value must give \"cannot verify\"." [[feature]] id = "photometry.gldf-read" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1f14432..8d04418 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,6 +3,7 @@ //! - `ci`: every gate CI runs (fmt, clippy -D warnings, tests, wasm check, layers, parity). //! - `layers`: checks that crates only depend on crates in lower layers. //! - `parity`: validates `parity/features.toml` and prints progress by milestone. +//! - `fuzz [seconds]`: fuzzes the photometric file readers (default 600 s, M1's exit criterion). mod layers; mod parity; @@ -15,7 +16,8 @@ fn main() -> ExitCode { Some("ci") => ci(), Some("layers") => layers::run(), Some("parity") => parity::run(), - _ => Err("usage: cargo xtask ".to_string()), + Some("fuzz") => fuzz(args.get(1).map_or("600", String::as_str)), + _ => Err("usage: cargo xtask ".to_string()), }; match result { Ok(()) => ExitCode::SUCCESS, @@ -54,6 +56,17 @@ fn ci() -> Result<(), String> { parity::run() } +fn fuzz(seconds: &str) -> Result<(), String> { + let seconds: u64 = seconds.parse().map_err(|_| format!("`{seconds}` is not a number of seconds"))?; + eprintln!("$ LUXPATH_FUZZ_SECONDS={seconds} cargo test --release -p luxpath-photometry --test fuzz -- --ignored"); + let status = Command::new(env!("CARGO")) + .args(["test", "--release", "-p", "luxpath-photometry", "--test", "fuzz", "--", "--ignored", "--nocapture"]) + .env("LUXPATH_FUZZ_SECONDS", seconds.to_string()) + .status() + .map_err(|e| format!("could not run cargo: {e}"))?; + if status.success() { Ok(()) } else { Err("the fuzzer found a panic".into()) } +} + fn cargo(args: &[&str]) -> Result<(), String> { eprintln!("$ cargo {}", args.join(" ")); let status = Command::new(env!("CARGO")).args(args).status().map_err(|e| format!("could not run cargo: {e}"))?;