Skip to main content

spectral_io/
lib.rs

1//! `spectral-io` reads, writes, and validates optical spectral data files.
2//! It defines a compact JSON format — `spectrum_file_schema.json` v1.0.0 — for
3//! UV-Vis and visible-range measurements, designed to be suitable for color-science
4//! calculations, long-term archiving, and data exchange between instruments,
5//! pipelines, and applications.
6//!
7//! The format captures everything a downstream calculation needs in one place:
8//! the measured spectrum, the physical conditions under which it was taken
9//! (instrument, geometry, illuminant, observer), and an optional provenance trail.
10//! The crate also ships support for additional formats:
11//!
12//! - **CSV / TSV** (`csv` feature) — generic delimited text with an optional
13//!   `KEY: VALUE` metadata header block; import via [`SpectrumFile::from_csv_path`]
14//!   / [`SpectrumFile::from_csv_str`], export via [`SpectrumFile::to_tsv`] /
15//!   [`SpectrumFile::to_csv`].
16//! - **SpectraShop** (`spectrashop` feature) — the tab-separated
17//!   text format used to distribute the
18//!   [Chromaxion Spectral Library](https://www.chromaxion.com/spectral-library.php),
19//!   one of the largest freely available collections of measured spectra; import
20//!   via [`SpectrumFile::from_spectrashop_path`] /
21//!   [`SpectrumFile::from_spectrashop_str`].
22//!
23//! ## Quick start
24//!
25//! ### Reading a JSON file
26//!
27//! ```no_run
28//! use spectral_io::SpectrumFile;
29//!
30//! let file = SpectrumFile::from_path("spectrum.json").expect("could not load file");
31//! for sp in file.spectra() {
32//!     let (min_nm, max_nm) = sp.wavelength_range_nm().unwrap();
33//!     println!("{}: {} points, {:.0}–{:.0} nm", sp.id, sp.n_points(), min_nm, max_nm);
34//! }
35//! ```
36//!
37//! ### Importing from other formats
38//!
39//! - CSV / TSV (`csv` feature):
40//!
41//! ```no_run
42//! # #[cfg(feature = "csv")]
43//! # {
44//! use spectral_io::SpectrumFile;
45//!
46//! let file = SpectrumFile::from_csv_path("measurements.tsv")
47//!     .expect("could not parse file");
48//! let tsv = file.to_tsv();
49//! # }
50//! ```
51//!
52//! - SpectraShop (`spectrashop` feature):
53//!
54//! ```no_run
55//! # #[cfg(feature = "spectrashop")]
56//! # {
57//! use spectral_io::SpectrumFile;
58//!
59//! let file = SpectrumFile::from_spectrashop_path("Munsell Matte 1994.txt")
60//!     .expect("could not parse SpectraShop file");
61//! println!("{} spectra imported", file.spectra().len());
62//! # }
63//! ```
64//!
65//! ### Resampling to an equidistant grid
66//!
67//! ```no_run
68//! use spectral_io::{SpectrumFile, ResampleMethod, WavelengthAxis, WavelengthRange};
69//!
70//! let file = SpectrumFile::from_path("spectrum.json").unwrap();
71//! let target = WavelengthAxis {
72//!     range_nm: Some(WavelengthRange { start: 380.0, end: 780.0, interval: 10.0 }),
73//!     values_nm: None,
74//! };
75//! for sp in file.spectra() {
76//!     let resampled = sp.resample(&target, ResampleMethod::Linear);
77//!     println!("{}: {} points", resampled.id, resampled.n_points());
78//! }
79//! ```
80//!
81//! ### Serialising back to JSON
82//!
83//! Any [`SpectrumFile`] can be round-tripped through `serde_json`:
84//!
85//! ```no_run
86//! # use spectral_io::SpectrumFile;
87//! # let file = SpectrumFile::from_path("spectrum.json").unwrap();
88//! let json = serde_json::to_string_pretty(&file).expect("serialisation failed");
89//! std::fs::write("output.json", json).unwrap();
90//! ```
91//!
92//! ## Cargo features
93//!
94//! | Feature | Default | Description |
95//! |---|---|---|
96//! | `spectrashop` | no | Enables [`SpectrumFile::from_spectrashop_path`] and [`SpectrumFile::from_spectrashop_str`] |
97//! | `csv` | no | Enables [`SpectrumFile::from_csv_path`], [`SpectrumFile::from_csv_str`], [`SpectrumFile::to_tsv`], [`SpectrumFile::to_csv`], [`SpectrumFile::write_tsv`], and [`SpectrumFile::write_csv`] |
98//!
99//! ## Error handling
100//!
101//! All fallible entry points return `Result<_, `[`SpectrumFileError`]`>`.
102//! [`SpectrumFileError`] has four variants:
103//!
104//! - **`Io`** — file not found or unreadable.
105//! - **`Json`** — not valid JSON.
106//! - **`SchemaValidation`** — structural problems: missing required fields, wrong
107//!   types, unknown enum values. All errors for the whole file are collected and
108//!   returned together so you see every problem at once, not just the first.
109//! - **`CrossFieldValidation`** — inter-field constraint failures: wavelength/value
110//!   array length mismatches, non-monotonic wavelengths, reflectance values outside
111//!   `[0, 1]`, a `"custom"` illuminant without its spectral power distribution, etc.
112//!   Again all errors are collected before returning.
113//!
114//! Use [`SpectrumFile::from_str_unchecked`] to bypass both validation passes when
115//! you are certain the source is well-formed (e.g. data you just wrote yourself).
116//!
117//! ## File format
118//!
119//! Files are JSON objects with a `schema_version` (semver string) and a
120//! `file_type` of either `"single"` or `"batch"`.
121//!
122//! ### Single file
123//!
124//! Use a single file when the measurement session produced exactly one spectrum —
125//! for example a single colour patch or a one-off transmission measurement.
126//! The spectrum lives directly under the key `"spectrum"`:
127//!
128//! ```json
129//! {
130//!   "schema_version": "1.0.0",
131//!   "file_type": "single",
132//!   "spectrum": { "id": "patch-01", "metadata": { "..." }, "..." }
133//! }
134//! ```
135//!
136//! ### Batch file
137//!
138//! Use a batch file when multiple spectra share common conditions — a colour
139//! chart, a paint swatch book, or a series of time-series measurements. An
140//! optional `"batch_metadata"` block carries fields common to the whole set
141//! (title, operator, instrument, measurement conditions) so they do not need
142//! to be repeated on every spectrum:
143//!
144//! ```json
145//! {
146//!   "schema_version": "1.0.0",
147//!   "file_type": "batch",
148//!   "batch_metadata": { "title": "Munsell Matte 1994", "date": "1994-01-01" },
149//!   "spectra": [ { "id": "5R 4/2", "..." }, { "id": "5YR 4/2", "..." } ]
150//! }
151//! ```
152//!
153//! ### SpectrumRecord object
154//!
155//! Each spectrum has four top-level sections (two required, two optional).
156//!
157//! #### `metadata` (required)
158//!
159//! Descriptive information about what was measured and how. `measurement_type`
160//! and `date` are the only required sub-fields; everything else is optional but
161//! strongly encouraged for reproducibility.
162//!
163//! | Field | Type | Notes |
164//! |---|---|---|
165//! | `measurement_type` | string enum | `reflectance`, `transmittance`, `absorbance`, `radiance`, `irradiance`, `emission`, `sensitivity` |
166//! | `date` | string | ISO 8601 date (`YYYY-MM-DD`) |
167//! | `title` | string | optional human-readable name for the sample |
168//! | `sample_id` | string | optional machine-readable sample identifier |
169//! | `operator` | string | optional name or ID of the person who measured |
170//! | `instrument` | object | optional: `manufacturer`, `model`, `serial_number`, `detector_type`, `light_source` |
171//! | `measurement_conditions` | object | optional: `integration_time_ms`, `averaging`, `temperature_celsius`, `geometry`, `specular_component`, `spectral_resolution_nm`, `measurement_aperture_mm`, `measurement_filter` |
172//! | `surface` | string | optional surface finish of the specimen (e.g. `"Matte"`, `"Gloss"`, `"Semigloss"`) |
173//! | `sample_backing` | string | optional backing used behind the specimen during measurement (e.g. `"Black"`, `"White"`) |
174//! | `tags` | string[] | optional free-form labels for search and filtering |
175//! | `copyright` | string | optional copyright notice (e.g. `"© 2024 Acme Lab"`) |
176//! | `custom` | object | optional user-defined key/value pairs for application-specific metadata |
177//!
178//! #### `wavelength_axis` (required)
179//!
180//! Exactly one of `values_nm` or `range_nm` must be present — not both, not
181//! neither. Use `range_nm` for the common case of an evenly-spaced grid (e.g.
182//! 380–780 nm at 10 nm steps); it is more compact and unambiguous. Use
183//! `values_nm` when the instrument produces an irregular grid or when the
184//! spacing is not constant.
185//!
186//! | Field | Type | Notes |
187//! |---|---|---|
188//! | `values_nm` | number[] | explicit wavelength list in nm; min 2 entries, strictly increasing |
189//! | `range_nm` | object | evenly-spaced grid: `start`, `end`, and `interval` (all in nm) |
190//!
191//! #### `spectral_data` (required)
192//!
193//! The measured values, one per wavelength point. For reflectance and
194//! transmittance, values must lie in `[0, 1]` when `scale` is `"fractional"`
195//! (the default), or in `[0, 100]` when `scale` is `"percent"`. There is no
196//! range constraint for `absorbance`, `radiance`, or `irradiance`.
197//!
198//! | Field | Type | Notes |
199//! |---|---|---|
200//! | `values` | number[] | measured values, one per wavelength point |
201//! | `uncertainty` | number[] | optional per-point 1-σ uncertainty, same length as `values` |
202//! | `scale` | string enum | `"fractional"` (0–1, default) or `"percent"` (0–100) |
203//!
204//! #### `color_science` (optional)
205//!
206//! Metadata needed to perform CIE colorimetric calculations from the spectral
207//! data — the illuminant under which the sample is viewed, the observer (colour
208//! matching functions), and an optional white reference. Pre-computed colorimetric
209//! results (`XYZ`, `Lab`, CCT, …) may also be stored here as a convenience cache;
210//! the spectral data is always the authoritative source.
211//!
212//! | Field | Type | Notes |
213//! |---|---|---|
214//! | `illuminant` | string enum | `D65`, `D50`, `D55`, `D75`, `A`, `B`, `C`, `F1`–`F12`, `LED-*`, or `"custom"` |
215//! | `illuminant_custom_sd` | object | required when `illuminant` is `"custom"`; provide the SPD as `wavelengths_nm` and `values` arrays |
216//! | `cie_observer` | string enum | `"CIE 1931 2 degree"` (default), `"CIE 1964 10 degree"`, `"CIE 2015 2 degree"`, `"CIE 2015 10 degree"` |
217//! | `white_reference` | object | optional calibration tile description and spectral reflectance values |
218//! | `results` | object | optional pre-computed colorimetric values — informational only |
219//!
220//! Available `results` sub-fields (all optional):
221//!
222//! | Field | Type | Notes |
223//! |---|---|---|
224//! | `XYZ` | `[number, number, number]` | CIE tristimulus values [X, Y, Z] |
225//! | `xy` | `[number, number]` | CIE 1931 chromaticity coordinates [x, y] |
226//! | `uv_prime` | `[number, number]` | CIE 1976 UCS chromaticity [u′, v′] |
227//! | `Lab` | `[number, number, number]` | CIELAB [L\*, a\*, b\*] |
228//! | `CCT_K` | number | Correlated colour temperature in Kelvin |
229//! | `Duv` | number | Distance from the Planckian locus (signed, CIE 1960 UCS) |
230//!
231//! #### `provenance` (optional)
232//!
233//! An audit trail recording where the data came from and what has been done to
234//! it. Particularly valuable when spectra have been converted from another
235//! format, averaged, smoothed, or trimmed. Fields: `software`,
236//! `software_version`, `source_file`, `source_format`, `notes`, and an ordered
237//! `processing_steps` array (each step has a `step` name, `description`, and
238//! optional `parameters` object).
239//!
240//! ### Full single-spectrum example
241//!
242//! A reflectance measurement of Munsell chip 5R 4/2, measured with a
243//! Konica Minolta CM-700d in diffuse/8° geometry, stored as a regular
244//! 380–780 nm grid at 10 nm intervals:
245//!
246//! ```json
247//! {
248//!   "schema_version": "1.0.0",
249//!   "file_type": "single",
250//!   "spectrum": {
251//!     "id": "chip-5R-4-2",
252//!     "metadata": {
253//!       "measurement_type": "reflectance",
254//!       "date": "2026-04-01",
255//!       "title": "Munsell 5R 4/2",
256//!       "instrument": { "manufacturer": "Konica Minolta", "model": "CM-700d" },
257//!       "measurement_conditions": { "geometry": "d:8", "specular_component": "excluded" }
258//!     },
259//!     "wavelength_axis": {
260//!       "range_nm": { "start": 380, "end": 780, "interval": 10 }
261//!     },
262//!     "spectral_data": {
263//!       "values": [0.048, 0.051, 0.054, 0.058, 0.063],
264//!       "scale": "fractional"
265//!     },
266//!     "color_science": {
267//!       "illuminant": "D65",
268//!       "cie_observer": "CIE 1931 2 degree",
269//!       "results": {
270//!         "XYZ": [17.35, 9.12, 1.18],
271//!         "xy": [0.629, 0.330],
272//!         "Lab": [36.1, 55.7, 37.2]
273//!       }
274//!     }
275//!   }
276//! }
277//! ```
278//!
279//! ## Validation
280//!
281//! [`SpectrumFile::from_path`] and [`SpectrumFile::from_json_str`] run two validation
282//! passes before returning:
283//!
284//! 1. **Schema validation** — checks required fields, correct types, and that
285//!    enum fields (`measurement_type`, `illuminant`, `cie_observer`,
286//!    `scale`) contain only allowed values.
287//! 2. **Cross-field validation** — checks that `values_nm` and `values` have
288//!    equal length; that `uncertainty` (if present) has the same length; that
289//!    wavelengths are strictly increasing; that reflectance/transmittance values
290//!    lie in `[0, 1]` when `scale` is `"fractional"`; and that a custom
291//!    illuminant is accompanied by `illuminant_custom_sd`.
292//!
293//! Both passes collect all errors before returning, so a single call surfaces
294//! every problem in the file at once. Use [`SpectrumFile::from_str_unchecked`]
295//! to skip validation entirely when the source is fully trusted.
296//!
297//! ## Importing SpectraShop files
298//!
299//! [SpectraShop](https://www.chromaxion.com/) is measurement and colour-analysis
300//! software by Robin Myers Imaging. Its tab-separated text export format (`.txt`)
301//! is used to distribute the
302//! [Chromaxion Spectral Library](https://www.chromaxion.com/spectral-library.php),
303//! which contains measured reflectance, transmittance, and irradiance spectra for
304//! hundreds of real-world materials — paint colours, Munsell chips, colour charts,
305//! photographic filters, monitor primaries, fabrics, inks, and more.
306//!
307//! Requires the `spectrashop` feature.
308//! [`SpectrumFile::from_spectrashop_path`] and [`SpectrumFile::from_spectrashop_str`]
309//! parse the format and convert each data record in the `BEGIN_DATA`/`END_DATA`
310//! block into a [`SpectrumRecord`]. File-level metadata (spectrum type, illuminant,
311//! observer, geometry, etc.) is applied to every record. A file with one record
312//! returns [`SpectrumFile::Single`]; two or more return [`SpectrumFile::Batch`].
313//!
314//! The `spectrashop_to_json` example binary converts a SpectraShop file to
315//! the `spectral-io` JSON format and can optionally embed a copyright notice:
316//!
317//! ```text
318//! cargo run --example spectrashop_to_json -- -c "© Author" input.txt output.json
319//! ```
320//!
321//! ### Format and data licensing
322//!
323//! The SpectraShop text format is proprietary to Robin Myers Imaging. A format
324//! specification is published at
325//! <https://www.chromaxion.com/spectral_library/SpectraShop_Import-Export_Format.pdf>
326//! specifically to permit third-party readers and writers.
327//!
328//! Spectral data files from the
329//! [Chromaxion Spectral Library](https://www.chromaxion.com/spectral-library.php)
330//! are subject to the following terms:
331//!
332//! - **Personal, scientific, and teaching use** is free.
333//! - **Redistribution** requires attribution to *Chromaxion.com* or *Robin Myers*.
334//! - **Commercial sale** of the data in any form requires express written
335//!   permission from Robin Myers.
336//!
337//! All data © Robin D. Myers, all rights reserved worldwide.
338//! Contact <robin@rmimaging.com> for commercial licensing enquiries.
339//!
340//! ## Importing and exporting CSV / TSV files
341//!
342//! Requires the `csv` feature.
343//!
344//! [`SpectrumFile::from_csv_path`] and [`SpectrumFile::from_csv_str`] read a
345//! generic delimited text file. The delimiter (tab or comma) is auto-detected.
346//! Files have two sections:
347//!
348//! 1. **Header block** — zero or more `KEY: VALUE` metadata lines (or
349//!    `KEY = VALUE`; or `KEY<delim>VALUE` for a set of recognised keywords).
350//!    Lines starting with `#` and blank lines are ignored throughout.
351//!
352//! 2. **Data block** — the first row whose first cell parses as a number
353//!    (wavelength in nm) starts the data block. The immediately preceding
354//!    non-blank line (if non-numeric) is the optional column-header row.
355//!    First column = wavelength; each further column becomes one
356//!    [`SpectrumRecord`].
357//!
358//! A file with one data column returns [`SpectrumFile::Single`]; two or more
359//! return [`SpectrumFile::Batch`].
360//!
361//! ```text
362//! Measurement_Type: reflectance
363//! Date: 2026-05-15
364//! Illuminant: D65
365//!
366//! wavelength_nm    patch_A    patch_B
367//! 380    0.041    0.089
368//! 390    0.052    0.092
369//! 400    0.063    0.095
370//! ```
371//!
372//! [`SpectrumFile::to_tsv`] and [`SpectrumFile::to_csv`] serialise back to
373//! tab- or comma-separated text, writing `KEY: VALUE` metadata lines so files
374//! round-trip cleanly. [`SpectrumFile::write_tsv`] and
375//! [`SpectrumFile::write_csv`] write directly to a file path.
376
377use serde::{Deserialize, Serialize};
378use std::path::Path;
379use thiserror::Error;
380
381#[cfg(feature = "spectrashop")]
382mod spectrashop;
383
384#[cfg(feature = "csv")]
385mod csv_text;
386
387mod resample;
388pub use resample::ResampleMethod;
389
390// ─────────────────────────────────────────────────────────────────────────────
391// Error type
392// ─────────────────────────────────────────────────────────────────────────────
393
394/// All errors that can occur while loading or validating a spectrum file.
395#[derive(Debug, Error)]
396pub enum SpectrumFileError {
397    #[error("I/O error: {0}")]
398    Io(#[from] std::io::Error),
399
400    #[error("JSON parse error: {0}")]
401    Json(#[from] serde_json::Error),
402
403    /// Structural schema violation (wrong type, missing required field,
404    /// value not in allowed enum set, etc.)
405    #[error("Schema validation failed:\n{0}")]
406    SchemaValidation(String),
407
408    /// Cross-field constraint violation (array length mismatch,
409    /// non-monotonic wavelengths, value out of physical range, etc.)
410    #[error("Cross-field validation failed:\n{0}")]
411    CrossFieldValidation(String),
412}
413
414pub type Result<T> = std::result::Result<T, SpectrumFileError>;
415
416// ─────────────────────────────────────────────────────────────────────────────
417// Top-level file enum
418// ─────────────────────────────────────────────────────────────────────────────
419
420/// The top-level structure of a spectrum JSON file.
421/// Tagged by `file_type`: either `"single"` or `"batch"`.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(tag = "file_type", rename_all = "snake_case")]
424pub enum SpectrumFile {
425    Single {
426        schema_version: String,
427        spectrum: Box<SpectrumRecord>,
428    },
429    Batch {
430        schema_version: String,
431        #[serde(skip_serializing_if = "Option::is_none")]
432        batch_metadata: Option<Box<BatchMetadata>>,
433        spectra: Vec<SpectrumRecord>,
434    },
435}
436
437impl SpectrumFile {
438    // ── Constructors ──────────────────────────────────────────────────────────
439
440    /// Load and fully validate a UV-Vis JSON file from a file path.
441    /// Runs structural schema validation then cross-field checks.
442    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
443        let raw = std::fs::read_to_string(path)?;
444        Self::from_json_str(&raw)
445    }
446
447    /// Load and fully validate a UV-Vis JSON file from a JSON string.
448    pub fn from_json_str(json: &str) -> Result<Self> {
449        // 1. Parse into untyped Value for structural checks
450        let value: serde_json::Value = serde_json::from_str(json)?;
451
452        // 2. Structural / schema-level validation
453        validate_schema(&value)?;
454
455        // 3. Deserialise into typed structs
456        let file: SpectrumFile = serde_json::from_value(value)?;
457
458        // 4. Cross-field validation
459        file.validate_cross_fields()?;
460
461        Ok(file)
462    }
463
464    /// Deserialise without any validation. Useful when you fully trust the source.
465    pub fn from_str_unchecked(json: &str) -> Result<Self> {
466        Ok(serde_json::from_str(json)?)
467    }
468
469    // ── Accessors ─────────────────────────────────────────────────────────────
470
471    /// Returns all spectra in the file (works for both single and batch).
472    pub fn spectra(&self) -> Vec<&SpectrumRecord> {
473        match self {
474            SpectrumFile::Single { spectrum, .. } => vec![spectrum.as_ref()],
475            SpectrumFile::Batch { spectra, .. } => spectra.iter().collect(),
476        }
477    }
478
479    /// The schema version declared in the file.
480    pub fn schema_version(&self) -> &str {
481        match self {
482            SpectrumFile::Single { schema_version, .. } => schema_version,
483            SpectrumFile::Batch { schema_version, .. } => schema_version,
484        }
485    }
486
487    /// Batch metadata, if this is a batch file.
488    pub fn batch_metadata(&self) -> Option<&BatchMetadata> {
489        match self {
490            SpectrumFile::Batch { batch_metadata, .. } => batch_metadata.as_deref(),
491            _ => None,
492        }
493    }
494
495    // ── Cross-field validation ────────────────────────────────────────────────
496
497    fn validate_cross_fields(&self) -> Result<()> {
498        let mut errors: Vec<String> = Vec::new();
499
500        for sp in self.spectra() {
501            let id = &sp.id;
502            let wl = sp.wavelength_axis.wavelengths_nm();
503            let vals = &sp.spectral_data.values;
504
505            // wavelength count == value count
506            if wl.len() != vals.len() {
507                errors.push(format!(
508                    "SpectrumRecord '{id}': wavelength_axis has {} points \
509                     but spectral_data.values has {} — must match.",
510                    wl.len(),
511                    vals.len()
512                ));
513            }
514
515            // uncertainty length == value count
516            if let Some(u) = &sp.spectral_data.uncertainty {
517                if u.len() != vals.len() {
518                    errors.push(format!(
519                        "SpectrumRecord '{id}': spectral_data.uncertainty has {} points \
520                         but spectral_data.values has {} — must match.",
521                        u.len(),
522                        vals.len()
523                    ));
524                }
525            }
526
527            // wavelengths strictly increasing
528            if wl.windows(2).any(|w| w[0] >= w[1]) {
529                errors.push(format!(
530                    "SpectrumRecord '{id}': wavelength_axis is not strictly increasing."
531                ));
532            }
533
534            // reflectance / transmittance in [0,1] when scale = fractional
535            let scale = sp.spectral_data.scale.as_deref().unwrap_or("fractional");
536            let is_bounded = matches!(
537                sp.metadata.measurement_type,
538                MeasurementType::Reflectance | MeasurementType::Transmittance
539            );
540            if is_bounded && scale == "fractional" {
541                let bad: Vec<f64> = vals
542                    .iter()
543                    .copied()
544                    .filter(|&v| !(0.0..=1.0).contains(&v))
545                    .collect();
546                if !bad.is_empty() {
547                    errors.push(format!(
548                        "SpectrumRecord '{id}': measurement_type={:?}, scale='fractional' \
549                         but {} value(s) fall outside [0,1]. First offender: {}",
550                        sp.metadata.measurement_type,
551                        bad.len(),
552                        bad[0]
553                    ));
554                }
555            }
556
557            // custom illuminant requires illuminant_custom_sd
558            if let Some(cs) = &sp.color_science {
559                if cs.illuminant.as_deref() == Some("custom") && cs.illuminant_custom_sd.is_none() {
560                    errors.push(format!(
561                        "SpectrumRecord '{id}': color_science.illuminant is 'custom' \
562                         but illuminant_custom_sd is missing."
563                    ));
564                }
565                if let Some(csd) = &cs.illuminant_custom_sd {
566                    if csd.wavelengths_nm.len() != csd.values.len() {
567                        errors.push(format!(
568                            "SpectrumRecord '{id}': illuminant_custom_sd.wavelengths_nm ({}) \
569                             and .values ({}) must have equal length.",
570                            csd.wavelengths_nm.len(),
571                            csd.values.len()
572                        ));
573                    }
574                }
575            }
576        }
577
578        if errors.is_empty() {
579            Ok(())
580        } else {
581            Err(SpectrumFileError::CrossFieldValidation(errors.join("\n")))
582        }
583    }
584}
585
586impl std::str::FromStr for SpectrumFile {
587    type Err = SpectrumFileError;
588    fn from_str(s: &str) -> Result<Self> {
589        Self::from_json_str(s)
590    }
591}
592
593// ─────────────────────────────────────────────────────────────────────────────
594// Structs — mirror the JSON schema
595// ─────────────────────────────────────────────────────────────────────────────
596
597/// A single spectral measurement.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct SpectrumRecord {
600    pub id: String,
601    pub metadata: SpectrumMetadata,
602    pub wavelength_axis: WavelengthAxis,
603    pub spectral_data: SpectralData,
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub color_science: Option<ColorScience>,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub provenance: Option<Provenance>,
608}
609
610impl SpectrumRecord {
611    /// Returns all `(wavelength_nm, value)` pairs.
612    pub fn points(&self) -> Vec<(f64, f64)> {
613        self.wavelength_axis
614            .wavelengths_nm()
615            .into_iter()
616            .zip(self.spectral_data.values.iter().copied())
617            .collect()
618    }
619
620    /// Wavelength range as `(min_nm, max_nm)`, or `None` if the axis is empty.
621    pub fn wavelength_range_nm(&self) -> Option<(f64, f64)> {
622        let wl = self.wavelength_axis.wavelengths_nm();
623        Some((*wl.first()?, *wl.last()?))
624    }
625
626    /// Number of spectral data points.
627    pub fn n_points(&self) -> usize {
628        self.spectral_data.values.len()
629    }
630}
631
632/// Descriptive metadata for one spectrum.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct SpectrumMetadata {
635    pub measurement_type: MeasurementType,
636    /// ISO 8601 date (YYYY-MM-DD).
637    pub date: String,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    pub title: Option<String>,
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub description: Option<String>,
642    #[serde(skip_serializing_if = "Option::is_none")]
643    pub sample_id: Option<String>,
644    #[serde(skip_serializing_if = "Option::is_none")]
645    pub time: Option<String>,
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub operator: Option<String>,
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub instrument: Option<Instrument>,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub measurement_conditions: Option<MeasurementConditions>,
652    /// Type of surface for a reflective specimen (e.g. `"Matte"`, `"Gloss"`, `"Semigloss"`).
653    #[serde(skip_serializing_if = "Option::is_none")]
654    pub surface: Option<String>,
655    /// Backing used behind the sample during measurement (e.g. `"Black"`, `"White"`, `"Substrate"`).
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub sample_backing: Option<String>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub tags: Option<Vec<String>>,
660    /// Copyright notice for this spectrum (e.g. `"© 2024 Acme Lab"`).
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub copyright: Option<String>,
663    #[serde(skip_serializing_if = "Option::is_none")]
664    pub custom: Option<serde_json::Value>,
665}
666
667/// The physical quantity measured.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(rename_all = "snake_case")]
670pub enum MeasurementType {
671    Reflectance,
672    Transmittance,
673    Absorbance,
674    Radiance,
675    Irradiance,
676    Emission,
677    /// Dimensionless spectral sensitivity or response function — colour matching
678    /// functions, cone fundamentals, luminous efficiency V(λ), action spectra.
679    /// Values are not constrained to [0, 1].
680    Sensitivity,
681}
682
683/// Minimal instrument identification.
684#[derive(Debug, Clone, Serialize, Deserialize)]
685pub struct Instrument {
686    #[serde(skip_serializing_if = "Option::is_none")]
687    pub manufacturer: Option<String>,
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub model: Option<String>,
690    #[serde(skip_serializing_if = "Option::is_none")]
691    pub serial_number: Option<String>,
692    #[serde(skip_serializing_if = "Option::is_none")]
693    pub detector_type: Option<String>,
694    #[serde(skip_serializing_if = "Option::is_none")]
695    pub light_source: Option<String>,
696}
697
698/// Physical conditions under which the measurement was made.
699#[derive(Debug, Clone, Serialize, Deserialize)]
700pub struct MeasurementConditions {
701    #[serde(skip_serializing_if = "Option::is_none")]
702    pub integration_time_ms: Option<f64>,
703    #[serde(skip_serializing_if = "Option::is_none")]
704    pub averaging: Option<u32>,
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub temperature_celsius: Option<f64>,
707    #[serde(skip_serializing_if = "Option::is_none")]
708    pub geometry: Option<String>,
709    #[serde(skip_serializing_if = "Option::is_none")]
710    pub specular_component: Option<SpecularComponent>,
711    /// Optical (spectral) resolution of the instrument in nm, typically the FWHM of the slit function.
712    #[serde(skip_serializing_if = "Option::is_none")]
713    pub spectral_resolution_nm: Option<f64>,
714    /// Instrument measurement aperture size in mm.
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub measurement_aperture_mm: Option<f64>,
717    /// Filter used on the spectrometer during measurement (e.g. `"UV Block"`, `"Polarizer"`).
718    #[serde(skip_serializing_if = "Option::is_none")]
719    pub measurement_filter: Option<String>,
720}
721
722/// Whether the specular component is included or excluded.
723#[derive(Debug, Clone, Serialize, Deserialize)]
724#[serde(rename_all = "snake_case")]
725pub enum SpecularComponent {
726    Included,
727    Excluded,
728    #[serde(rename = "not applicable")]
729    NotApplicable,
730}
731
732/// The wavelength axis of the measurement. All values are in nm.
733///
734/// Exactly one of `values_nm` or `range_nm` must be present.
735#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct WavelengthAxis {
737    /// Explicit wavelength list in nm. Use for irregular grids.
738    #[serde(skip_serializing_if = "Option::is_none")]
739    pub values_nm: Option<Vec<f64>>,
740    /// Evenly-spaced grid descriptor. Use for regular grids.
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub range_nm: Option<WavelengthRange>,
743}
744
745impl WavelengthAxis {
746    /// Returns the wavelength values in nm, expanding `range_nm` if that variant is used.
747    pub fn wavelengths_nm(&self) -> Vec<f64> {
748        if let Some(v) = &self.values_nm {
749            v.clone()
750        } else if let Some(r) = &self.range_nm {
751            r.expand()
752        } else {
753            vec![]
754        }
755    }
756}
757
758/// Evenly-spaced wavelength grid defined by start, end, and interval (all in nm).
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct WavelengthRange {
761    pub start: f64,
762    pub end: f64,
763    pub interval: f64,
764}
765
766impl WavelengthRange {
767    /// Expands the range into an explicit list of wavelength values in nm.
768    pub fn expand(&self) -> Vec<f64> {
769        // Use floor with a small epsilon so floating-point imprecision never
770        // produces an extra step beyond `end` (e.g. 40.9999… flooring to 40,
771        // not 41 after rounding).
772        let n = ((self.end - self.start) / self.interval + 1e-9).floor() as usize + 1;
773        (0..n)
774            .map(|i| self.start + i as f64 * self.interval)
775            .collect()
776    }
777}
778
779/// The measured spectral values.
780#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct SpectralData {
782    pub values: Vec<f64>,
783    /// Optional per-point uncertainty (1 standard deviation), same length as `values`.
784    #[serde(skip_serializing_if = "Option::is_none")]
785    pub uncertainty: Option<Vec<f64>>,
786    /// `"fractional"` (0–1) or `"percent"` (0–100). Defaults to `"fractional"`.
787    #[serde(skip_serializing_if = "Option::is_none")]
788    pub scale: Option<String>,
789}
790
791/// Metadata required for CIE colorimetry and color-science calculations.
792#[derive(Debug, Clone, Serialize, Deserialize)]
793pub struct ColorScience {
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub illuminant: Option<String>,
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub illuminant_custom_sd: Option<CustomIlluminantSd>,
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub cie_observer: Option<String>,
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub white_reference: Option<WhiteReference>,
802    #[serde(skip_serializing_if = "Option::is_none")]
803    pub results: Option<ColorScienceResults>,
804}
805
806/// Pre-computed colorimetric results derived from the spectral data.
807///
808/// All fields are optional and informational — the spectral data is always the authoritative
809/// source. Any subset may be present.
810#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct ColorScienceResults {
812    /// CIE tristimulus values [X, Y, Z].
813    #[serde(rename = "XYZ", skip_serializing_if = "Option::is_none")]
814    pub xyz: Option<[f64; 3]>,
815    /// CIE 1931 chromaticity coordinates [x, y].
816    #[serde(skip_serializing_if = "Option::is_none")]
817    pub xy: Option<[f64; 2]>,
818    /// CIE 1976 UCS chromaticity coordinates [u′, v′].
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub uv_prime: Option<[f64; 2]>,
821    /// CIELAB coordinates [L*, a*, b*].
822    #[serde(rename = "Lab", skip_serializing_if = "Option::is_none")]
823    pub lab: Option<[f64; 3]>,
824    /// Correlated color temperature in Kelvin.
825    #[serde(rename = "CCT_K", skip_serializing_if = "Option::is_none")]
826    pub cct_k: Option<f64>,
827    /// Distance from the Planckian locus (signed) in the CIE 1960 UCS.
828    #[serde(rename = "Duv", skip_serializing_if = "Option::is_none")]
829    pub duv: Option<f64>,
830}
831
832/// Spectral power distribution for a custom illuminant.
833#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct CustomIlluminantSd {
835    pub wavelengths_nm: Vec<f64>,
836    pub values: Vec<f64>,
837}
838
839/// White reference / calibration tile.
840#[derive(Debug, Clone, Serialize, Deserialize)]
841pub struct WhiteReference {
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub description: Option<String>,
844    #[serde(skip_serializing_if = "Option::is_none")]
845    pub manufacturer: Option<String>,
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub serial_number: Option<String>,
848    #[serde(skip_serializing_if = "Option::is_none")]
849    pub calibration_date: Option<String>,
850    #[serde(skip_serializing_if = "Option::is_none")]
851    pub reference_values: Option<Vec<f64>>,
852}
853
854/// Processing history and software trail.
855#[derive(Debug, Clone, Serialize, Deserialize)]
856pub struct Provenance {
857    #[serde(skip_serializing_if = "Option::is_none")]
858    pub software: Option<String>,
859    #[serde(skip_serializing_if = "Option::is_none")]
860    pub software_version: Option<String>,
861    #[serde(skip_serializing_if = "Option::is_none")]
862    pub source_file: Option<String>,
863    #[serde(skip_serializing_if = "Option::is_none")]
864    pub source_format: Option<String>,
865    #[serde(skip_serializing_if = "Option::is_none")]
866    pub processing_steps: Option<Vec<ProcessingStep>>,
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub notes: Option<String>,
869}
870
871/// A single processing step applied to the raw data.
872#[derive(Debug, Clone, Serialize, Deserialize)]
873pub struct ProcessingStep {
874    pub step: String,
875    pub description: String,
876    #[serde(skip_serializing_if = "Option::is_none")]
877    pub parameters: Option<serde_json::Value>,
878}
879
880/// Optional metadata common to all spectra in a batch file.
881#[derive(Debug, Clone, Serialize, Deserialize)]
882pub struct BatchMetadata {
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub title: Option<String>,
885    #[serde(skip_serializing_if = "Option::is_none")]
886    pub description: Option<String>,
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub operator: Option<String>,
889    #[serde(skip_serializing_if = "Option::is_none")]
890    pub date: Option<String>,
891    #[serde(skip_serializing_if = "Option::is_none")]
892    pub instrument: Option<Instrument>,
893    #[serde(skip_serializing_if = "Option::is_none")]
894    pub measurement_conditions: Option<MeasurementConditions>,
895}
896
897// ─────────────────────────────────────────────────────────────────────────────
898// Structural schema validator (pure serde_json, no external crate)
899// ─────────────────────────────────────────────────────────────────────────────
900//
901// Checks that are enforced here (equivalent to JSON Schema):
902//   - Required top-level fields present and correct type
903//   - file_type is "single" or "batch"
904//   - schema_version matches semver pattern
905//   - Each spectrum has required fields (id, metadata, wavelength_axis, spectral_data)
906//   - measurement_type is one of the allowed enum values
907//   - scale, if present, is "fractional" or "percent"
908//   - values_nm has at least 2 entries
909//   - All numeric arrays contain only numbers
910
911const ALLOWED_MEASUREMENT_TYPES: &[&str] = &[
912    "reflectance",
913    "transmittance",
914    "absorbance",
915    "radiance",
916    "irradiance",
917    "emission",
918    "sensitivity",
919];
920
921const ALLOWED_ILLUMINANTS: &[&str] = &[
922    "D65", "D50", "D55", "D75", "A", "B", "C", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8",
923    "F9", "F10", "F11", "F12", "LED-B1", "LED-B2", "LED-B3", "LED-B4", "LED-B5", "LED-BH1",
924    "LED-RGB1", "LED-V1", "LED-V2", "custom",
925];
926
927const ALLOWED_OBSERVERS: &[&str] = &[
928    "CIE 1931 2 degree",
929    "CIE 1964 10 degree",
930    "CIE 2015 2 degree",
931    "CIE 2015 10 degree",
932];
933
934fn validate_schema(v: &serde_json::Value) -> Result<()> {
935    let mut errors: Vec<String> = Vec::new();
936
937    // Top-level must be an object
938    let obj = match v.as_object() {
939        Some(o) => o,
940        None => {
941            return Err(SpectrumFileError::SchemaValidation(
942                "Root value must be a JSON object.".into(),
943            ))
944        }
945    };
946
947    // schema_version: required, string, semver-ish
948    match obj.get("schema_version") {
949        None => errors.push("Missing required field: schema_version".into()),
950        Some(sv) => {
951            if !sv.is_string() {
952                errors.push("schema_version must be a string".into());
953            } else {
954                let s = sv.as_str().unwrap();
955                let parts: Vec<&str> = s.split('.').collect();
956                if parts.len() != 3 || parts.iter().any(|p| p.parse::<u32>().is_err()) {
957                    errors.push(format!(
958                        "schema_version '{s}' does not look like semver (e.g. 1.0.0)"
959                    ));
960                }
961            }
962        }
963    }
964
965    // file_type: required, "single" or "batch"
966    let file_type = match obj.get("file_type") {
967        None => {
968            errors.push("Missing required field: file_type".into());
969            None
970        }
971        Some(ft) => match ft.as_str() {
972            Some(s @ "single") | Some(s @ "batch") => Some(s.to_string()),
973            Some(other) => {
974                errors.push(format!(
975                    "file_type must be 'single' or 'batch', got '{other}'"
976                ));
977                None
978            }
979            None => {
980                errors.push("file_type must be a string".into());
981                None
982            }
983        },
984    };
985
986    match file_type.as_deref() {
987        Some("single") => {
988            match obj.get("spectrum") {
989                None => errors.push("Single file must have a 'spectrum' field".into()),
990                Some(sp) => validate_spectrum(sp, "spectrum", &mut errors),
991            }
992            if obj.contains_key("spectra") {
993                errors.push(
994                    "Single file must not have a 'spectra' array (use file_type='batch')".into(),
995                );
996            }
997        }
998        Some("batch") => {
999            match obj.get("spectra") {
1000                None => errors.push("Batch file must have a 'spectra' array".into()),
1001                Some(arr) => match arr.as_array() {
1002                    None => errors.push("'spectra' must be an array".into()),
1003                    Some(items) => {
1004                        if items.is_empty() {
1005                            errors.push("'spectra' array must not be empty".into());
1006                        }
1007                        for (i, sp) in items.iter().enumerate() {
1008                            validate_spectrum(sp, &format!("spectra[{i}]"), &mut errors);
1009                        }
1010                    }
1011                },
1012            }
1013            if obj.contains_key("spectrum") {
1014                errors.push("Batch file must not have a 'spectrum' field".into());
1015            }
1016        }
1017        _ => {} // already reported
1018    }
1019
1020    if errors.is_empty() {
1021        Ok(())
1022    } else {
1023        Err(SpectrumFileError::SchemaValidation(errors.join("\n")))
1024    }
1025}
1026
1027fn validate_spectrum(v: &serde_json::Value, path: &str, errors: &mut Vec<String>) {
1028    let obj = match v.as_object() {
1029        Some(o) => o,
1030        None => {
1031            errors.push(format!("{path}: must be an object"));
1032            return;
1033        }
1034    };
1035
1036    // id: required string
1037    require_string(obj, "id", path, errors);
1038
1039    // metadata: required object
1040    if let Some(meta) = require_object(obj, "metadata", path, errors) {
1041        validate_metadata(meta, &format!("{path}.metadata"), errors);
1042    }
1043
1044    // wavelength_axis: required object
1045    if let Some(wa) = require_object(obj, "wavelength_axis", path, errors) {
1046        validate_wavelength_axis(wa, &format!("{path}.wavelength_axis"), errors);
1047    }
1048
1049    // spectral_data: required object
1050    if let Some(sd) = require_object(obj, "spectral_data", path, errors) {
1051        validate_spectral_data(sd, &format!("{path}.spectral_data"), errors);
1052    }
1053
1054    // color_science: optional
1055    if let Some(cs) = obj.get("color_science") {
1056        if let Some(cso) = cs.as_object() {
1057            validate_color_science(cso, &format!("{path}.color_science"), errors);
1058        } else {
1059            errors.push(format!("{path}.color_science must be an object"));
1060        }
1061    }
1062}
1063
1064fn validate_metadata(
1065    obj: &serde_json::Map<String, serde_json::Value>,
1066    path: &str,
1067    errors: &mut Vec<String>,
1068) {
1069    // measurement_type: required, enum
1070    match obj.get("measurement_type") {
1071        None => errors.push(format!("{path}: missing required field 'measurement_type'")),
1072        Some(mt) => match mt.as_str() {
1073            None => errors.push(format!("{path}.measurement_type must be a string")),
1074            Some(s) if !ALLOWED_MEASUREMENT_TYPES.contains(&s) => errors.push(format!(
1075                "{path}.measurement_type '{s}' is not allowed. Must be one of: {}",
1076                ALLOWED_MEASUREMENT_TYPES.join(", ")
1077            )),
1078            _ => {}
1079        },
1080    }
1081    // date: required string
1082    require_string(obj, "date", path, errors);
1083}
1084
1085fn validate_wavelength_axis(
1086    obj: &serde_json::Map<String, serde_json::Value>,
1087    path: &str,
1088    errors: &mut Vec<String>,
1089) {
1090    let has_values = obj.contains_key("values_nm");
1091    let has_range = obj.contains_key("range_nm");
1092
1093    match (has_values, has_range) {
1094        (false, false) => {
1095            errors.push(format!(
1096                "{path}: exactly one of 'values_nm' or 'range_nm' must be present (neither found)"
1097            ));
1098            return;
1099        }
1100        (true, true) => {
1101            errors.push(format!(
1102                "{path}: exactly one of 'values_nm' or 'range_nm' must be present (both found)"
1103            ));
1104            return;
1105        }
1106        _ => {}
1107    }
1108
1109    if has_values {
1110        match obj.get("values_nm").and_then(|v| v.as_array()) {
1111            None => errors.push(format!("{path}.values_nm must be an array")),
1112            Some(items) => {
1113                if items.len() < 2 {
1114                    errors.push(format!("{path}.values_nm must have at least 2 elements"));
1115                }
1116                if items.iter().any(|x| !x.is_number()) {
1117                    errors.push(format!("{path}.values_nm must contain only numbers"));
1118                }
1119            }
1120        }
1121    } else {
1122        match obj.get("range_nm").and_then(|v| v.as_object()) {
1123            None => errors.push(format!("{path}.range_nm must be an object")),
1124            Some(r) => {
1125                for field in ["start", "end", "interval"] {
1126                    match r.get(field) {
1127                        None => errors
1128                            .push(format!("{path}.range_nm: missing required field '{field}'")),
1129                        Some(v) if !v.is_number() => {
1130                            errors.push(format!("{path}.range_nm.{field} must be a number"))
1131                        }
1132                        _ => {}
1133                    }
1134                }
1135                if let Some(iv) = r.get("interval").and_then(|v| v.as_f64()) {
1136                    if iv <= 0.0 {
1137                        errors.push(format!("{path}.range_nm.interval must be positive"));
1138                    }
1139                }
1140            }
1141        }
1142    }
1143}
1144
1145fn validate_spectral_data(
1146    obj: &serde_json::Map<String, serde_json::Value>,
1147    path: &str,
1148    errors: &mut Vec<String>,
1149) {
1150    // values: required, array of numbers, min 2
1151    match obj.get("values") {
1152        None => errors.push(format!("{path}: missing required field 'values'")),
1153        Some(arr) => match arr.as_array() {
1154            None => errors.push(format!("{path}.values must be an array")),
1155            Some(items) => {
1156                if items.len() < 2 {
1157                    errors.push(format!("{path}.values must have at least 2 elements"));
1158                }
1159                if items.iter().any(|x| !x.is_number()) {
1160                    errors.push(format!("{path}.values must contain only numbers"));
1161                }
1162            }
1163        },
1164    }
1165
1166    // uncertainty: optional array of non-negative numbers
1167    if let Some(unc) = obj.get("uncertainty") {
1168        match unc.as_array() {
1169            None => errors.push(format!("{path}.uncertainty must be an array")),
1170            Some(items) => {
1171                if items.iter().any(|x| !x.is_number()) {
1172                    errors.push(format!("{path}.uncertainty must contain only numbers"));
1173                } else if items.iter().any(|x| x.as_f64().unwrap_or(0.0) < 0.0) {
1174                    errors.push(format!("{path}.uncertainty values must be non-negative"));
1175                }
1176            }
1177        }
1178    }
1179
1180    // scale: optional, enum
1181    if let Some(sc) = obj.get("scale") {
1182        match sc.as_str() {
1183            None => errors.push(format!("{path}.scale must be a string")),
1184            Some(s) if s != "fractional" && s != "percent" => errors.push(format!(
1185                "{path}.scale must be 'fractional' or 'percent', got '{s}'"
1186            )),
1187            _ => {}
1188        }
1189    }
1190}
1191
1192fn validate_color_science(
1193    obj: &serde_json::Map<String, serde_json::Value>,
1194    path: &str,
1195    errors: &mut Vec<String>,
1196) {
1197    // illuminant: optional, enum
1198    if let Some(il) = obj.get("illuminant") {
1199        match il.as_str() {
1200            None => errors.push(format!("{path}.illuminant must be a string")),
1201            Some(s) if !ALLOWED_ILLUMINANTS.contains(&s) => errors.push(format!(
1202                "{path}.illuminant '{s}' is not a recognised CIE illuminant"
1203            )),
1204            _ => {}
1205        }
1206    }
1207
1208    // cie_observer: optional, enum
1209    if let Some(obs) = obj.get("cie_observer") {
1210        match obs.as_str() {
1211            None => errors.push(format!("{path}.cie_observer must be a string")),
1212            Some(s) if !ALLOWED_OBSERVERS.contains(&s) => errors.push(format!(
1213                "{path}.cie_observer '{s}' not recognised. Must be one of: {}",
1214                ALLOWED_OBSERVERS.join(", ")
1215            )),
1216            _ => {}
1217        }
1218    }
1219}
1220
1221// ── Helpers ───────────────────────────────────────────────────────────────────
1222
1223fn require_string(
1224    obj: &serde_json::Map<String, serde_json::Value>,
1225    key: &str,
1226    path: &str,
1227    errors: &mut Vec<String>,
1228) {
1229    match obj.get(key) {
1230        None => errors.push(format!("{path}: missing required field '{key}'")),
1231        Some(v) if !v.is_string() => errors.push(format!("{path}.{key} must be a string")),
1232        _ => {}
1233    }
1234}
1235
1236fn require_object<'a>(
1237    obj: &'a serde_json::Map<String, serde_json::Value>,
1238    key: &str,
1239    path: &str,
1240    errors: &mut Vec<String>,
1241) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
1242    match obj.get(key) {
1243        None => {
1244            errors.push(format!("{path}: missing required field '{key}'"));
1245            None
1246        }
1247        Some(v) => match v.as_object() {
1248            None => {
1249                errors.push(format!("{path}.{key} must be an object"));
1250                None
1251            }
1252            Some(o) => Some(o),
1253        },
1254    }
1255}
1256
1257// ─────────────────────────────────────────────────────────────────────────────
1258// SpectraShop text-format importer
1259// ─────────────────────────────────────────────────────────────────────────────
1260
1261#[cfg(feature = "spectrashop")]
1262impl SpectrumFile {
1263    /// Load a SpectraShop text-export file from a path.
1264    ///
1265    /// Parses the SpectraShop tab-separated text format (`.txt`) and converts each
1266    /// data record into a [`SpectrumRecord`]. File-level metadata (illuminant, observer,
1267    /// geometry, etc.) is applied to every record. Returns [`SpectrumFile::Single`]
1268    /// for one record or [`SpectrumFile::Batch`] for multiple records.
1269    ///
1270    /// Non-UTF-8 bytes (e.g. Latin-1 encoded files) are replaced with U+FFFD.
1271    pub fn from_spectrashop_path<P: AsRef<Path>>(path: P) -> Result<Self> {
1272        let path = path.as_ref();
1273        let bytes = std::fs::read(path)?;
1274        let raw = String::from_utf8_lossy(&bytes).into_owned();
1275        let filename = path.file_name().and_then(|f| f.to_str());
1276        spectrashop::ss_parse(&raw, filename)
1277    }
1278
1279    /// Parse a SpectraShop text-export string.
1280    ///
1281    /// See [`SpectrumFile::from_spectrashop_path`] for format details.
1282    pub fn from_spectrashop_str(input: &str) -> Result<Self> {
1283        spectrashop::ss_parse(input, None)
1284    }
1285}
1286
1287// ─────────────────────────────────────────────────────────────────────────────
1288// CSV / TSV importer and exporter
1289// ─────────────────────────────────────────────────────────────────────────────
1290
1291#[cfg(feature = "csv")]
1292impl SpectrumFile {
1293    /// Load a CSV or TSV spectral data file from a path.
1294    ///
1295    /// The delimiter (tab or comma) is auto-detected. An optional header block
1296    /// of `KEY: VALUE` lines precedes the data. The first row whose first cell
1297    /// parses as a number starts the data block; the immediately preceding
1298    /// non-blank line (if non-numeric) is treated as the column-header row.
1299    /// First data column = wavelength in nm; each subsequent column becomes one
1300    /// [`SpectrumRecord`]. Returns [`SpectrumFile::Single`] for one data column
1301    /// or [`SpectrumFile::Batch`] for multiple.
1302    pub fn from_csv_path<P: AsRef<Path>>(path: P) -> Result<Self> {
1303        let path = path.as_ref();
1304        let raw = std::fs::read_to_string(path)?;
1305        let filename = path.file_name().and_then(|f| f.to_str());
1306        csv_text::csv_parse(&raw, filename)
1307    }
1308
1309    /// Parse a CSV or TSV spectral data string.
1310    ///
1311    /// See [`SpectrumFile::from_csv_path`] for format details.
1312    pub fn from_csv_str(input: &str) -> Result<Self> {
1313        csv_text::csv_parse(input, None)
1314    }
1315
1316    /// Serialise to a tab-separated string.
1317    ///
1318    /// Writes a `KEY: VALUE` metadata header derived from the first spectrum,
1319    /// followed by a column-header row and one data row per wavelength point.
1320    /// For a batch file all spectra are written as parallel columns sharing the
1321    /// wavelength axis of the first spectrum.
1322    pub fn to_tsv(&self) -> String {
1323        csv_text::csv_write(self, '\t')
1324    }
1325
1326    /// Serialise to a comma-separated string.
1327    ///
1328    /// See [`SpectrumFile::to_tsv`] for format details.
1329    pub fn to_csv(&self) -> String {
1330        csv_text::csv_write(self, ',')
1331    }
1332
1333    /// Write a tab-separated file to the given path.
1334    pub fn write_tsv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1335        Ok(std::fs::write(path, self.to_tsv())?)
1336    }
1337
1338    /// Write a comma-separated file to the given path.
1339    pub fn write_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1340        Ok(std::fs::write(path, self.to_csv())?)
1341    }
1342}
1343
1344// ─────────────────────────────────────────────────────────────────────────────
1345// Tests
1346// ─────────────────────────────────────────────────────────────────────────────
1347
1348#[cfg(test)]
1349mod tests {
1350    use super::*;
1351
1352    fn make_single(mtype: &str, wls: &[f64], vals: &[f64]) -> String {
1353        let wl_s: Vec<String> = wls.iter().map(|w| w.to_string()).collect();
1354        let v_s: Vec<String> = vals.iter().map(|v| v.to_string()).collect();
1355        format!(
1356            r#"{{"schema_version":"1.0.0","file_type":"single","spectrum":{{"id":"t1",
1357            "metadata":{{"measurement_type":"{mtype}","date":"2026-04-29"}},
1358            "wavelength_axis":{{"values_nm":[{wl}]}},
1359            "spectral_data":{{"values":[{v}]}}}}}}"#,
1360            mtype = mtype,
1361            wl = wl_s.join(","),
1362            v = v_s.join(","),
1363        )
1364    }
1365
1366    fn wls_41() -> Vec<f64> {
1367        (0..41).map(|i| 380.0 + i as f64 * 10.0).collect()
1368    }
1369    fn vals_41() -> Vec<f64> {
1370        (0..41).map(|i| i as f64 / 100.0).collect()
1371    }
1372
1373    #[test]
1374    fn valid_single_spectrum() {
1375        let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
1376            .unwrap();
1377        let spectra = file.spectra();
1378        assert_eq!(spectra.len(), 1);
1379        assert_eq!(spectra[0].n_points(), 41);
1380        assert_eq!(file.schema_version(), "1.0.0");
1381    }
1382
1383    #[test]
1384    fn valid_batch_file() {
1385        let json = r#"{"schema_version":"1.0.0","file_type":"batch","spectra":[
1386            {"id":"a","metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1387             "wavelength_axis":{"values_nm":[380,390,400]},
1388             "spectral_data":{"values":[0.1,0.2,0.3]}},
1389            {"id":"b","metadata":{"measurement_type":"transmittance","date":"2026-04-29"},
1390             "wavelength_axis":{"values_nm":[380,390,400]},
1391             "spectral_data":{"values":[0.5,0.6,0.7]}}
1392        ]}"#;
1393        let file = SpectrumFile::from_json_str(json).unwrap();
1394        assert_eq!(file.spectra().len(), 2);
1395    }
1396
1397    #[test]
1398    fn missing_measurement_type_is_schema_error() {
1399        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1400            "metadata":{"date":"2026-04-29"},
1401            "wavelength_axis":{"values_nm":[380,390,400]},
1402            "spectral_data":{"values":[0.1,0.2,0.3]}}}"#;
1403        assert!(matches!(
1404            SpectrumFile::from_json_str(json),
1405            Err(SpectrumFileError::SchemaValidation(_))
1406        ));
1407    }
1408
1409    #[test]
1410    fn invalid_measurement_type_is_schema_error() {
1411        let json = make_single("fluorescence", &[380.0, 390.0], &[0.1, 0.2]);
1412        assert!(matches!(
1413            SpectrumFile::from_json_str(&json),
1414            Err(SpectrumFileError::SchemaValidation(_))
1415        ));
1416    }
1417
1418    #[test]
1419    fn wavelength_value_length_mismatch() {
1420        let wls = vec![380.0, 390.0, 400.0];
1421        let vals = vec![0.1, 0.2]; // too short
1422        assert!(matches!(
1423            SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
1424            Err(SpectrumFileError::CrossFieldValidation(_))
1425        ));
1426    }
1427
1428    #[test]
1429    fn non_monotonic_wavelengths() {
1430        let wls = vec![380.0, 370.0, 400.0];
1431        let vals = vec![0.1, 0.2, 0.3];
1432        assert!(matches!(
1433            SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
1434            Err(SpectrumFileError::CrossFieldValidation(_))
1435        ));
1436    }
1437
1438    #[test]
1439    fn reflectance_out_of_range() {
1440        let wls = vec![380.0, 390.0, 400.0];
1441        let vals = vec![0.1, 1.5, 0.3];
1442        assert!(matches!(
1443            SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
1444            Err(SpectrumFileError::CrossFieldValidation(_))
1445        ));
1446    }
1447
1448    #[test]
1449    fn absorbance_above_one_is_ok() {
1450        // Absorbance is not bounded by [0,1]
1451        let wls = vec![380.0, 390.0, 400.0];
1452        let vals = vec![0.1, 1.8, 2.5];
1453        assert!(SpectrumFile::from_json_str(&make_single("absorbance", &wls, &vals)).is_ok());
1454    }
1455
1456    #[test]
1457    fn custom_illuminant_missing_sd() {
1458        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1459            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1460            "wavelength_axis":{"values_nm":[380,390,400]},
1461            "spectral_data":{"values":[0.1,0.2,0.3]},
1462            "color_science":{"illuminant":"custom"}}}"#;
1463        assert!(matches!(
1464            SpectrumFile::from_json_str(json),
1465            Err(SpectrumFileError::CrossFieldValidation(_))
1466        ));
1467    }
1468
1469    #[test]
1470    fn points_iterator_correct() {
1471        let wls = vec![380.0, 390.0, 400.0];
1472        let vals = vec![0.1, 0.2, 0.3];
1473        let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)).unwrap();
1474        let pts = file.spectra()[0].points();
1475        assert_eq!(pts, vec![(380.0, 0.1), (390.0, 0.2), (400.0, 0.3)]);
1476    }
1477
1478    #[test]
1479    fn wavelength_range_accessor() {
1480        let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
1481            .unwrap();
1482        assert_eq!(
1483            file.spectra()[0].wavelength_range_nm(),
1484            Some((380.0, 780.0))
1485        );
1486    }
1487
1488    #[test]
1489    fn invalid_scale_value() {
1490        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1491            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1492            "wavelength_axis":{"values_nm":[380,390,400]},
1493            "spectral_data":{"values":[0.1,0.2,0.3],"scale":"ratio"}}}"#;
1494        assert!(matches!(
1495            SpectrumFile::from_json_str(json),
1496            Err(SpectrumFileError::SchemaValidation(_))
1497        ));
1498    }
1499
1500    // ── WavelengthAxis and WavelengthRange unit tests ─────────────────────────
1501
1502    #[test]
1503    fn wavelength_axis_values_nm_variant() {
1504        let axis = WavelengthAxis {
1505            values_nm: Some(vec![380.0, 450.0, 550.0, 700.0]),
1506            range_nm: None,
1507        };
1508        assert_eq!(axis.wavelengths_nm(), vec![380.0, 450.0, 550.0, 700.0]);
1509    }
1510
1511    #[test]
1512    fn wavelength_axis_range_nm_variant() {
1513        let axis = WavelengthAxis {
1514            values_nm: None,
1515            range_nm: Some(WavelengthRange {
1516                start: 380.0,
1517                end: 400.0,
1518                interval: 10.0,
1519            }),
1520        };
1521        let wls = axis.wavelengths_nm();
1522        assert_eq!(wls.len(), 3);
1523        assert!((wls[0] - 380.0).abs() < 1e-10);
1524        assert!((wls[1] - 390.0).abs() < 1e-10);
1525        assert!((wls[2] - 400.0).abs() < 1e-10);
1526    }
1527
1528    #[test]
1529    fn wavelength_range_expand_direct() {
1530        let r = WavelengthRange {
1531            start: 380.0,
1532            end: 780.0,
1533            interval: 10.0,
1534        };
1535        let wls = r.expand();
1536        assert_eq!(wls.len(), 41);
1537        assert!((wls[0] - 380.0).abs() < 1e-10);
1538        assert!((wls[40] - 780.0).abs() < 1e-10);
1539    }
1540
1541    // ── Cross-field validation edge cases ─────────────────────────────────────
1542
1543    #[test]
1544    fn uncertainty_length_mismatch_is_error() {
1545        let json = r#"{
1546            "schema_version": "1.0.0",
1547            "file_type": "single",
1548            "spectrum": {
1549                "id": "x",
1550                "metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
1551                "wavelength_axis": {"values_nm": [380, 390, 400]},
1552                "spectral_data": {"values": [0.1, 0.2, 0.3], "uncertainty": [0.01, 0.01]}
1553            }
1554        }"#;
1555        assert!(matches!(
1556            SpectrumFile::from_json_str(json),
1557            Err(SpectrumFileError::CrossFieldValidation(_))
1558        ));
1559    }
1560
1561    #[test]
1562    fn illuminant_custom_sd_length_mismatch_is_error() {
1563        let json = r#"{
1564            "schema_version": "1.0.0",
1565            "file_type": "single",
1566            "spectrum": {
1567                "id": "x",
1568                "metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
1569                "wavelength_axis": {"values_nm": [380, 390, 400]},
1570                "spectral_data": {"values": [0.1, 0.2, 0.3]},
1571                "color_science": {
1572                    "illuminant": "custom",
1573                    "illuminant_custom_sd": {
1574                        "wavelengths_nm": [380, 390, 400],
1575                        "values": [1.0, 1.1]
1576                    }
1577                }
1578            }
1579        }"#;
1580        assert!(matches!(
1581            SpectrumFile::from_json_str(json),
1582            Err(SpectrumFileError::CrossFieldValidation(_))
1583        ));
1584    }
1585
1586    // ── from_path and from_str_unchecked ──────────────────────────────────────
1587
1588    #[test]
1589    fn from_path_loads_single_example() {
1590        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_single.json");
1591        let file = SpectrumFile::from_path(path).unwrap();
1592        assert_eq!(file.spectra().len(), 1);
1593        assert_eq!(file.spectra()[0].id, "sample-001");
1594    }
1595
1596    #[test]
1597    fn from_path_loads_batch_example() {
1598        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_batch.json");
1599        let file = SpectrumFile::from_path(path).unwrap();
1600        assert_eq!(file.spectra().len(), 2);
1601    }
1602
1603    #[test]
1604    fn from_str_unchecked_skips_cross_field_validation() {
1605        // 3 wavelengths but only 2 values — cross-field check rejects this, unchecked accepts it
1606        let json = r#"{
1607            "schema_version": "1.0.0",
1608            "file_type": "single",
1609            "spectrum": {
1610                "id": "x",
1611                "metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
1612                "wavelength_axis": {"values_nm": [380, 390, 400]},
1613                "spectral_data": {"values": [0.1, 0.2]}
1614            }
1615        }"#;
1616        assert!(SpectrumFile::from_str_unchecked(json).is_ok());
1617        assert!(matches!(
1618            SpectrumFile::from_json_str(json),
1619            Err(SpectrumFileError::CrossFieldValidation(_))
1620        ));
1621    }
1622
1623    // ── batch_metadata accessor ───────────────────────────────────────────────
1624
1625    #[test]
1626    fn batch_metadata_fields_accessible() {
1627        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_batch.json");
1628        let file = SpectrumFile::from_path(path).unwrap();
1629        let meta = file
1630            .batch_metadata()
1631            .expect("batch file must have metadata");
1632        assert_eq!(
1633            meta.title.as_deref(),
1634            Some("Ceramic tile color survey - April 2026")
1635        );
1636        assert_eq!(meta.operator.as_deref(), Some("J. Smith"));
1637    }
1638
1639    #[test]
1640    fn batch_metadata_returns_none_for_single_file() {
1641        let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
1642            .unwrap();
1643        assert!(file.batch_metadata().is_none());
1644    }
1645
1646    #[test]
1647    fn percent_scale_reflectance_above_one_is_ok() {
1648        // scale="percent" means values are 0–100; the [0,1] bounds check must not fire.
1649        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1650            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1651            "wavelength_axis":{"values_nm":[380,390,400]},
1652            "spectral_data":{"values":[50.0,75.0,85.0],"scale":"percent"}}}"#;
1653        assert!(SpectrumFile::from_json_str(json).is_ok());
1654    }
1655
1656    #[test]
1657    fn single_file_with_spectra_key_is_schema_error() {
1658        let json = r#"{"schema_version":"1.0.0","file_type":"single",
1659            "spectrum":{"id":"x","metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1660            "wavelength_axis":{"values_nm":[380,390]},"spectral_data":{"values":[0.1,0.2]}},
1661            "spectra":[]}"#;
1662        assert!(matches!(
1663            SpectrumFile::from_json_str(json),
1664            Err(SpectrumFileError::SchemaValidation(_))
1665        ));
1666    }
1667
1668    #[test]
1669    fn empty_spectra_array_is_schema_error() {
1670        let json = r#"{"schema_version":"1.0.0","file_type":"batch","spectra":[]}"#;
1671        assert!(matches!(
1672            SpectrumFile::from_json_str(json),
1673            Err(SpectrumFileError::SchemaValidation(_))
1674        ));
1675    }
1676
1677    #[test]
1678    fn invalid_illuminant_is_schema_error() {
1679        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1680            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1681            "wavelength_axis":{"values_nm":[380,390,400]},
1682            "spectral_data":{"values":[0.1,0.2,0.3]},
1683            "color_science":{"illuminant":"TL84"}}}"#;
1684        assert!(matches!(
1685            SpectrumFile::from_json_str(json),
1686            Err(SpectrumFileError::SchemaValidation(_))
1687        ));
1688    }
1689
1690    #[test]
1691    fn invalid_cie_observer_is_schema_error() {
1692        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1693            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1694            "wavelength_axis":{"values_nm":[380,390,400]},
1695            "spectral_data":{"values":[0.1,0.2,0.3]},
1696            "color_science":{"cie_observer":"CIE 2006"}}}"#;
1697        assert!(matches!(
1698            SpectrumFile::from_json_str(json),
1699            Err(SpectrumFileError::SchemaValidation(_))
1700        ));
1701    }
1702
1703    #[test]
1704    fn values_nm_fewer_than_two_is_schema_error() {
1705        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1706            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1707            "wavelength_axis":{"values_nm":[380]},
1708            "spectral_data":{"values":[0.1]}}}"#;
1709        assert!(matches!(
1710            SpectrumFile::from_json_str(json),
1711            Err(SpectrumFileError::SchemaValidation(_))
1712        ));
1713    }
1714
1715    #[test]
1716    fn range_nm_non_positive_interval_is_schema_error() {
1717        let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
1718            "metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
1719            "wavelength_axis":{"range_nm":{"start":380,"end":780,"interval":0}},
1720            "spectral_data":{"values":[0.1,0.2]}}}"#;
1721        assert!(matches!(
1722            SpectrumFile::from_json_str(json),
1723            Err(SpectrumFileError::SchemaValidation(_))
1724        ));
1725    }
1726
1727    // Conversion-to-Spectrum tests live in the colorimetry crate.
1728}