use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
#[cfg(feature = "spectrashop")]
mod spectrashop;
#[cfg(feature = "csv")]
mod csv_text;
mod resample;
pub use resample::ResampleMethod;
#[derive(Debug, Error)]
pub enum SpectrumFileError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("Schema validation failed:\n{0}")]
SchemaValidation(String),
#[error("Cross-field validation failed:\n{0}")]
CrossFieldValidation(String),
}
pub type Result<T> = std::result::Result<T, SpectrumFileError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "file_type", rename_all = "snake_case")]
pub enum SpectrumFile {
Single {
schema_version: String,
spectrum: Box<SpectrumRecord>,
},
Batch {
schema_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
batch_metadata: Option<Box<BatchMetadata>>,
spectra: Vec<SpectrumRecord>,
},
}
impl SpectrumFile {
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let raw = std::fs::read_to_string(path)?;
Self::from_json_str(&raw)
}
pub fn from_json_str(json: &str) -> Result<Self> {
let value: serde_json::Value = serde_json::from_str(json)?;
validate_schema(&value)?;
let file: SpectrumFile = serde_json::from_value(value)?;
file.validate_cross_fields()?;
Ok(file)
}
pub fn from_str_unchecked(json: &str) -> Result<Self> {
Ok(serde_json::from_str(json)?)
}
pub fn spectra(&self) -> Vec<&SpectrumRecord> {
match self {
SpectrumFile::Single { spectrum, .. } => vec![spectrum.as_ref()],
SpectrumFile::Batch { spectra, .. } => spectra.iter().collect(),
}
}
pub fn schema_version(&self) -> &str {
match self {
SpectrumFile::Single { schema_version, .. } => schema_version,
SpectrumFile::Batch { schema_version, .. } => schema_version,
}
}
pub fn batch_metadata(&self) -> Option<&BatchMetadata> {
match self {
SpectrumFile::Batch { batch_metadata, .. } => batch_metadata.as_deref(),
_ => None,
}
}
fn validate_cross_fields(&self) -> Result<()> {
let mut errors: Vec<String> = Vec::new();
for sp in self.spectra() {
let id = &sp.id;
let wl = sp.wavelength_axis.wavelengths_nm();
let vals = &sp.spectral_data.values;
if wl.len() != vals.len() {
errors.push(format!(
"SpectrumRecord '{id}': wavelength_axis has {} points \
but spectral_data.values has {} — must match.",
wl.len(),
vals.len()
));
}
if let Some(u) = &sp.spectral_data.uncertainty {
if u.len() != vals.len() {
errors.push(format!(
"SpectrumRecord '{id}': spectral_data.uncertainty has {} points \
but spectral_data.values has {} — must match.",
u.len(),
vals.len()
));
}
}
if wl.windows(2).any(|w| w[0] >= w[1]) {
errors.push(format!(
"SpectrumRecord '{id}': wavelength_axis is not strictly increasing."
));
}
let scale = sp.spectral_data.scale.as_deref().unwrap_or("fractional");
let is_bounded = matches!(
sp.metadata.measurement_type,
MeasurementType::Reflectance | MeasurementType::Transmittance
);
if is_bounded && scale == "fractional" {
let bad: Vec<f64> = vals
.iter()
.copied()
.filter(|&v| !(0.0..=1.0).contains(&v))
.collect();
if !bad.is_empty() {
errors.push(format!(
"SpectrumRecord '{id}': measurement_type={:?}, scale='fractional' \
but {} value(s) fall outside [0,1]. First offender: {}",
sp.metadata.measurement_type,
bad.len(),
bad[0]
));
}
}
if let Some(cs) = &sp.color_science {
if cs.illuminant.as_deref() == Some("custom") && cs.illuminant_custom_sd.is_none() {
errors.push(format!(
"SpectrumRecord '{id}': color_science.illuminant is 'custom' \
but illuminant_custom_sd is missing."
));
}
if let Some(csd) = &cs.illuminant_custom_sd {
if csd.wavelengths_nm.len() != csd.values.len() {
errors.push(format!(
"SpectrumRecord '{id}': illuminant_custom_sd.wavelengths_nm ({}) \
and .values ({}) must have equal length.",
csd.wavelengths_nm.len(),
csd.values.len()
));
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(SpectrumFileError::CrossFieldValidation(errors.join("\n")))
}
}
}
impl std::str::FromStr for SpectrumFile {
type Err = SpectrumFileError;
fn from_str(s: &str) -> Result<Self> {
Self::from_json_str(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectrumRecord {
pub id: String,
pub metadata: SpectrumMetadata,
pub wavelength_axis: WavelengthAxis,
pub spectral_data: SpectralData,
#[serde(skip_serializing_if = "Option::is_none")]
pub color_science: Option<ColorScience>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<Provenance>,
}
impl SpectrumRecord {
pub fn points(&self) -> Vec<(f64, f64)> {
self.wavelength_axis
.wavelengths_nm()
.into_iter()
.zip(self.spectral_data.values.iter().copied())
.collect()
}
pub fn wavelength_range_nm(&self) -> Option<(f64, f64)> {
let wl = self.wavelength_axis.wavelengths_nm();
Some((*wl.first()?, *wl.last()?))
}
pub fn n_points(&self) -> usize {
self.spectral_data.values.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectrumMetadata {
pub measurement_type: MeasurementType,
pub date: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instrument: Option<Instrument>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measurement_conditions: Option<MeasurementConditions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_backing: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyright: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MeasurementType {
Reflectance,
Transmittance,
Absorbance,
Radiance,
Irradiance,
Emission,
Sensitivity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Instrument {
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub detector_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub light_source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeasurementConditions {
#[serde(skip_serializing_if = "Option::is_none")]
pub integration_time_ms: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub averaging: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature_celsius: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub geometry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub specular_component: Option<SpecularComponent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spectral_resolution_nm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measurement_aperture_mm: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measurement_filter: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpecularComponent {
Included,
Excluded,
#[serde(rename = "not applicable")]
NotApplicable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WavelengthAxis {
#[serde(skip_serializing_if = "Option::is_none")]
pub values_nm: Option<Vec<f64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub range_nm: Option<WavelengthRange>,
}
impl WavelengthAxis {
pub fn wavelengths_nm(&self) -> Vec<f64> {
if let Some(v) = &self.values_nm {
v.clone()
} else if let Some(r) = &self.range_nm {
r.expand()
} else {
vec![]
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WavelengthRange {
pub start: f64,
pub end: f64,
pub interval: f64,
}
impl WavelengthRange {
pub fn expand(&self) -> Vec<f64> {
let n = ((self.end - self.start) / self.interval + 1e-9).floor() as usize + 1;
(0..n)
.map(|i| self.start + i as f64 * self.interval)
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectralData {
pub values: Vec<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uncertainty: Option<Vec<f64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scale: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorScience {
#[serde(skip_serializing_if = "Option::is_none")]
pub illuminant: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub illuminant_custom_sd: Option<CustomIlluminantSd>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cie_observer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub white_reference: Option<WhiteReference>,
#[serde(skip_serializing_if = "Option::is_none")]
pub results: Option<ColorScienceResults>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorScienceResults {
#[serde(rename = "XYZ", skip_serializing_if = "Option::is_none")]
pub xyz: Option<[f64; 3]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub xy: Option<[f64; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uv_prime: Option<[f64; 2]>,
#[serde(rename = "Lab", skip_serializing_if = "Option::is_none")]
pub lab: Option<[f64; 3]>,
#[serde(rename = "CCT_K", skip_serializing_if = "Option::is_none")]
pub cct_k: Option<f64>,
#[serde(rename = "Duv", skip_serializing_if = "Option::is_none")]
pub duv: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomIlluminantSd {
pub wavelengths_nm: Vec<f64>,
pub values: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhiteReference {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub serial_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calibration_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_values: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
#[serde(skip_serializing_if = "Option::is_none")]
pub software: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub software_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub processing_steps: Option<Vec<ProcessingStep>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessingStep {
pub step: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instrument: Option<Instrument>,
#[serde(skip_serializing_if = "Option::is_none")]
pub measurement_conditions: Option<MeasurementConditions>,
}
const ALLOWED_MEASUREMENT_TYPES: &[&str] = &[
"reflectance",
"transmittance",
"absorbance",
"radiance",
"irradiance",
"emission",
"sensitivity",
];
const ALLOWED_ILLUMINANTS: &[&str] = &[
"D65", "D50", "D55", "D75", "A", "B", "C", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8",
"F9", "F10", "F11", "F12", "LED-B1", "LED-B2", "LED-B3", "LED-B4", "LED-B5", "LED-BH1",
"LED-RGB1", "LED-V1", "LED-V2", "custom",
];
const ALLOWED_OBSERVERS: &[&str] = &[
"CIE 1931 2 degree",
"CIE 1964 10 degree",
"CIE 2015 2 degree",
"CIE 2015 10 degree",
];
fn validate_schema(v: &serde_json::Value) -> Result<()> {
let mut errors: Vec<String> = Vec::new();
let obj = match v.as_object() {
Some(o) => o,
None => {
return Err(SpectrumFileError::SchemaValidation(
"Root value must be a JSON object.".into(),
))
}
};
match obj.get("schema_version") {
None => errors.push("Missing required field: schema_version".into()),
Some(sv) => {
if !sv.is_string() {
errors.push("schema_version must be a string".into());
} else {
let s = sv.as_str().unwrap();
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 || parts.iter().any(|p| p.parse::<u32>().is_err()) {
errors.push(format!(
"schema_version '{s}' does not look like semver (e.g. 1.0.0)"
));
}
}
}
}
let file_type = match obj.get("file_type") {
None => {
errors.push("Missing required field: file_type".into());
None
}
Some(ft) => match ft.as_str() {
Some(s @ "single") | Some(s @ "batch") => Some(s.to_string()),
Some(other) => {
errors.push(format!(
"file_type must be 'single' or 'batch', got '{other}'"
));
None
}
None => {
errors.push("file_type must be a string".into());
None
}
},
};
match file_type.as_deref() {
Some("single") => {
match obj.get("spectrum") {
None => errors.push("Single file must have a 'spectrum' field".into()),
Some(sp) => validate_spectrum(sp, "spectrum", &mut errors),
}
if obj.contains_key("spectra") {
errors.push(
"Single file must not have a 'spectra' array (use file_type='batch')".into(),
);
}
}
Some("batch") => {
match obj.get("spectra") {
None => errors.push("Batch file must have a 'spectra' array".into()),
Some(arr) => match arr.as_array() {
None => errors.push("'spectra' must be an array".into()),
Some(items) => {
if items.is_empty() {
errors.push("'spectra' array must not be empty".into());
}
for (i, sp) in items.iter().enumerate() {
validate_spectrum(sp, &format!("spectra[{i}]"), &mut errors);
}
}
},
}
if obj.contains_key("spectrum") {
errors.push("Batch file must not have a 'spectrum' field".into());
}
}
_ => {} }
if errors.is_empty() {
Ok(())
} else {
Err(SpectrumFileError::SchemaValidation(errors.join("\n")))
}
}
fn validate_spectrum(v: &serde_json::Value, path: &str, errors: &mut Vec<String>) {
let obj = match v.as_object() {
Some(o) => o,
None => {
errors.push(format!("{path}: must be an object"));
return;
}
};
require_string(obj, "id", path, errors);
if let Some(meta) = require_object(obj, "metadata", path, errors) {
validate_metadata(meta, &format!("{path}.metadata"), errors);
}
if let Some(wa) = require_object(obj, "wavelength_axis", path, errors) {
validate_wavelength_axis(wa, &format!("{path}.wavelength_axis"), errors);
}
if let Some(sd) = require_object(obj, "spectral_data", path, errors) {
validate_spectral_data(sd, &format!("{path}.spectral_data"), errors);
}
if let Some(cs) = obj.get("color_science") {
if let Some(cso) = cs.as_object() {
validate_color_science(cso, &format!("{path}.color_science"), errors);
} else {
errors.push(format!("{path}.color_science must be an object"));
}
}
}
fn validate_metadata(
obj: &serde_json::Map<String, serde_json::Value>,
path: &str,
errors: &mut Vec<String>,
) {
match obj.get("measurement_type") {
None => errors.push(format!("{path}: missing required field 'measurement_type'")),
Some(mt) => match mt.as_str() {
None => errors.push(format!("{path}.measurement_type must be a string")),
Some(s) if !ALLOWED_MEASUREMENT_TYPES.contains(&s) => errors.push(format!(
"{path}.measurement_type '{s}' is not allowed. Must be one of: {}",
ALLOWED_MEASUREMENT_TYPES.join(", ")
)),
_ => {}
},
}
require_string(obj, "date", path, errors);
}
fn validate_wavelength_axis(
obj: &serde_json::Map<String, serde_json::Value>,
path: &str,
errors: &mut Vec<String>,
) {
let has_values = obj.contains_key("values_nm");
let has_range = obj.contains_key("range_nm");
match (has_values, has_range) {
(false, false) => {
errors.push(format!(
"{path}: exactly one of 'values_nm' or 'range_nm' must be present (neither found)"
));
return;
}
(true, true) => {
errors.push(format!(
"{path}: exactly one of 'values_nm' or 'range_nm' must be present (both found)"
));
return;
}
_ => {}
}
if has_values {
match obj.get("values_nm").and_then(|v| v.as_array()) {
None => errors.push(format!("{path}.values_nm must be an array")),
Some(items) => {
if items.len() < 2 {
errors.push(format!("{path}.values_nm must have at least 2 elements"));
}
if items.iter().any(|x| !x.is_number()) {
errors.push(format!("{path}.values_nm must contain only numbers"));
}
}
}
} else {
match obj.get("range_nm").and_then(|v| v.as_object()) {
None => errors.push(format!("{path}.range_nm must be an object")),
Some(r) => {
for field in ["start", "end", "interval"] {
match r.get(field) {
None => errors
.push(format!("{path}.range_nm: missing required field '{field}'")),
Some(v) if !v.is_number() => {
errors.push(format!("{path}.range_nm.{field} must be a number"))
}
_ => {}
}
}
if let Some(iv) = r.get("interval").and_then(|v| v.as_f64()) {
if iv <= 0.0 {
errors.push(format!("{path}.range_nm.interval must be positive"));
}
}
}
}
}
}
fn validate_spectral_data(
obj: &serde_json::Map<String, serde_json::Value>,
path: &str,
errors: &mut Vec<String>,
) {
match obj.get("values") {
None => errors.push(format!("{path}: missing required field 'values'")),
Some(arr) => match arr.as_array() {
None => errors.push(format!("{path}.values must be an array")),
Some(items) => {
if items.len() < 2 {
errors.push(format!("{path}.values must have at least 2 elements"));
}
if items.iter().any(|x| !x.is_number()) {
errors.push(format!("{path}.values must contain only numbers"));
}
}
},
}
if let Some(unc) = obj.get("uncertainty") {
match unc.as_array() {
None => errors.push(format!("{path}.uncertainty must be an array")),
Some(items) => {
if items.iter().any(|x| !x.is_number()) {
errors.push(format!("{path}.uncertainty must contain only numbers"));
} else if items.iter().any(|x| x.as_f64().unwrap_or(0.0) < 0.0) {
errors.push(format!("{path}.uncertainty values must be non-negative"));
}
}
}
}
if let Some(sc) = obj.get("scale") {
match sc.as_str() {
None => errors.push(format!("{path}.scale must be a string")),
Some(s) if s != "fractional" && s != "percent" => errors.push(format!(
"{path}.scale must be 'fractional' or 'percent', got '{s}'"
)),
_ => {}
}
}
}
fn validate_color_science(
obj: &serde_json::Map<String, serde_json::Value>,
path: &str,
errors: &mut Vec<String>,
) {
if let Some(il) = obj.get("illuminant") {
match il.as_str() {
None => errors.push(format!("{path}.illuminant must be a string")),
Some(s) if !ALLOWED_ILLUMINANTS.contains(&s) => errors.push(format!(
"{path}.illuminant '{s}' is not a recognised CIE illuminant"
)),
_ => {}
}
}
if let Some(obs) = obj.get("cie_observer") {
match obs.as_str() {
None => errors.push(format!("{path}.cie_observer must be a string")),
Some(s) if !ALLOWED_OBSERVERS.contains(&s) => errors.push(format!(
"{path}.cie_observer '{s}' not recognised. Must be one of: {}",
ALLOWED_OBSERVERS.join(", ")
)),
_ => {}
}
}
}
fn require_string(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
path: &str,
errors: &mut Vec<String>,
) {
match obj.get(key) {
None => errors.push(format!("{path}: missing required field '{key}'")),
Some(v) if !v.is_string() => errors.push(format!("{path}.{key} must be a string")),
_ => {}
}
}
fn require_object<'a>(
obj: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
path: &str,
errors: &mut Vec<String>,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
match obj.get(key) {
None => {
errors.push(format!("{path}: missing required field '{key}'"));
None
}
Some(v) => match v.as_object() {
None => {
errors.push(format!("{path}.{key} must be an object"));
None
}
Some(o) => Some(o),
},
}
}
#[cfg(feature = "spectrashop")]
impl SpectrumFile {
pub fn from_spectrashop_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let bytes = std::fs::read(path)?;
let raw = String::from_utf8_lossy(&bytes).into_owned();
let filename = path.file_name().and_then(|f| f.to_str());
spectrashop::ss_parse(&raw, filename)
}
pub fn from_spectrashop_str(input: &str) -> Result<Self> {
spectrashop::ss_parse(input, None)
}
}
#[cfg(feature = "csv")]
impl SpectrumFile {
pub fn from_csv_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path)?;
let filename = path.file_name().and_then(|f| f.to_str());
csv_text::csv_parse(&raw, filename)
}
pub fn from_csv_str(input: &str) -> Result<Self> {
csv_text::csv_parse(input, None)
}
pub fn to_tsv(&self) -> String {
csv_text::csv_write(self, '\t')
}
pub fn to_csv(&self) -> String {
csv_text::csv_write(self, ',')
}
pub fn write_tsv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
Ok(std::fs::write(path, self.to_tsv())?)
}
pub fn write_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
Ok(std::fs::write(path, self.to_csv())?)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_single(mtype: &str, wls: &[f64], vals: &[f64]) -> String {
let wl_s: Vec<String> = wls.iter().map(|w| w.to_string()).collect();
let v_s: Vec<String> = vals.iter().map(|v| v.to_string()).collect();
format!(
r#"{{"schema_version":"1.0.0","file_type":"single","spectrum":{{"id":"t1",
"metadata":{{"measurement_type":"{mtype}","date":"2026-04-29"}},
"wavelength_axis":{{"values_nm":[{wl}]}},
"spectral_data":{{"values":[{v}]}}}}}}"#,
mtype = mtype,
wl = wl_s.join(","),
v = v_s.join(","),
)
}
fn wls_41() -> Vec<f64> {
(0..41).map(|i| 380.0 + i as f64 * 10.0).collect()
}
fn vals_41() -> Vec<f64> {
(0..41).map(|i| i as f64 / 100.0).collect()
}
#[test]
fn valid_single_spectrum() {
let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
.unwrap();
let spectra = file.spectra();
assert_eq!(spectra.len(), 1);
assert_eq!(spectra[0].n_points(), 41);
assert_eq!(file.schema_version(), "1.0.0");
}
#[test]
fn valid_batch_file() {
let json = r#"{"schema_version":"1.0.0","file_type":"batch","spectra":[
{"id":"a","metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3]}},
{"id":"b","metadata":{"measurement_type":"transmittance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.5,0.6,0.7]}}
]}"#;
let file = SpectrumFile::from_json_str(json).unwrap();
assert_eq!(file.spectra().len(), 2);
}
#[test]
fn missing_measurement_type_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3]}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn invalid_measurement_type_is_schema_error() {
let json = make_single("fluorescence", &[380.0, 390.0], &[0.1, 0.2]);
assert!(matches!(
SpectrumFile::from_json_str(&json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn wavelength_value_length_mismatch() {
let wls = vec![380.0, 390.0, 400.0];
let vals = vec![0.1, 0.2]; assert!(matches!(
SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn non_monotonic_wavelengths() {
let wls = vec![380.0, 370.0, 400.0];
let vals = vec![0.1, 0.2, 0.3];
assert!(matches!(
SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn reflectance_out_of_range() {
let wls = vec![380.0, 390.0, 400.0];
let vals = vec![0.1, 1.5, 0.3];
assert!(matches!(
SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn absorbance_above_one_is_ok() {
let wls = vec![380.0, 390.0, 400.0];
let vals = vec![0.1, 1.8, 2.5];
assert!(SpectrumFile::from_json_str(&make_single("absorbance", &wls, &vals)).is_ok());
}
#[test]
fn custom_illuminant_missing_sd() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3]},
"color_science":{"illuminant":"custom"}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn points_iterator_correct() {
let wls = vec![380.0, 390.0, 400.0];
let vals = vec![0.1, 0.2, 0.3];
let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls, &vals)).unwrap();
let pts = file.spectra()[0].points();
assert_eq!(pts, vec![(380.0, 0.1), (390.0, 0.2), (400.0, 0.3)]);
}
#[test]
fn wavelength_range_accessor() {
let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
.unwrap();
assert_eq!(
file.spectra()[0].wavelength_range_nm(),
Some((380.0, 780.0))
);
}
#[test]
fn invalid_scale_value() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3],"scale":"ratio"}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn wavelength_axis_values_nm_variant() {
let axis = WavelengthAxis {
values_nm: Some(vec![380.0, 450.0, 550.0, 700.0]),
range_nm: None,
};
assert_eq!(axis.wavelengths_nm(), vec![380.0, 450.0, 550.0, 700.0]);
}
#[test]
fn wavelength_axis_range_nm_variant() {
let axis = WavelengthAxis {
values_nm: None,
range_nm: Some(WavelengthRange {
start: 380.0,
end: 400.0,
interval: 10.0,
}),
};
let wls = axis.wavelengths_nm();
assert_eq!(wls.len(), 3);
assert!((wls[0] - 380.0).abs() < 1e-10);
assert!((wls[1] - 390.0).abs() < 1e-10);
assert!((wls[2] - 400.0).abs() < 1e-10);
}
#[test]
fn wavelength_range_expand_direct() {
let r = WavelengthRange {
start: 380.0,
end: 780.0,
interval: 10.0,
};
let wls = r.expand();
assert_eq!(wls.len(), 41);
assert!((wls[0] - 380.0).abs() < 1e-10);
assert!((wls[40] - 780.0).abs() < 1e-10);
}
#[test]
fn uncertainty_length_mismatch_is_error() {
let json = r#"{
"schema_version": "1.0.0",
"file_type": "single",
"spectrum": {
"id": "x",
"metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
"wavelength_axis": {"values_nm": [380, 390, 400]},
"spectral_data": {"values": [0.1, 0.2, 0.3], "uncertainty": [0.01, 0.01]}
}
}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn illuminant_custom_sd_length_mismatch_is_error() {
let json = r#"{
"schema_version": "1.0.0",
"file_type": "single",
"spectrum": {
"id": "x",
"metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
"wavelength_axis": {"values_nm": [380, 390, 400]},
"spectral_data": {"values": [0.1, 0.2, 0.3]},
"color_science": {
"illuminant": "custom",
"illuminant_custom_sd": {
"wavelengths_nm": [380, 390, 400],
"values": [1.0, 1.1]
}
}
}
}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn from_path_loads_single_example() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_single.json");
let file = SpectrumFile::from_path(path).unwrap();
assert_eq!(file.spectra().len(), 1);
assert_eq!(file.spectra()[0].id, "sample-001");
}
#[test]
fn from_path_loads_batch_example() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_batch.json");
let file = SpectrumFile::from_path(path).unwrap();
assert_eq!(file.spectra().len(), 2);
}
#[test]
fn from_str_unchecked_skips_cross_field_validation() {
let json = r#"{
"schema_version": "1.0.0",
"file_type": "single",
"spectrum": {
"id": "x",
"metadata": {"measurement_type": "reflectance", "date": "2026-04-29"},
"wavelength_axis": {"values_nm": [380, 390, 400]},
"spectral_data": {"values": [0.1, 0.2]}
}
}"#;
assert!(SpectrumFile::from_str_unchecked(json).is_ok());
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::CrossFieldValidation(_))
));
}
#[test]
fn batch_metadata_fields_accessible() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/example_batch.json");
let file = SpectrumFile::from_path(path).unwrap();
let meta = file
.batch_metadata()
.expect("batch file must have metadata");
assert_eq!(
meta.title.as_deref(),
Some("Ceramic tile color survey - April 2026")
);
assert_eq!(meta.operator.as_deref(), Some("J. Smith"));
}
#[test]
fn batch_metadata_returns_none_for_single_file() {
let file = SpectrumFile::from_json_str(&make_single("reflectance", &wls_41(), &vals_41()))
.unwrap();
assert!(file.batch_metadata().is_none());
}
#[test]
fn percent_scale_reflectance_above_one_is_ok() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[50.0,75.0,85.0],"scale":"percent"}}}"#;
assert!(SpectrumFile::from_json_str(json).is_ok());
}
#[test]
fn single_file_with_spectra_key_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single",
"spectrum":{"id":"x","metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390]},"spectral_data":{"values":[0.1,0.2]}},
"spectra":[]}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn empty_spectra_array_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"batch","spectra":[]}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn invalid_illuminant_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3]},
"color_science":{"illuminant":"TL84"}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn invalid_cie_observer_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380,390,400]},
"spectral_data":{"values":[0.1,0.2,0.3]},
"color_science":{"cie_observer":"CIE 2006"}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn values_nm_fewer_than_two_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"values_nm":[380]},
"spectral_data":{"values":[0.1]}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
#[test]
fn range_nm_non_positive_interval_is_schema_error() {
let json = r#"{"schema_version":"1.0.0","file_type":"single","spectrum":{"id":"x",
"metadata":{"measurement_type":"reflectance","date":"2026-04-29"},
"wavelength_axis":{"range_nm":{"start":380,"end":780,"interval":0}},
"spectral_data":{"values":[0.1,0.2]}}}"#;
assert!(matches!(
SpectrumFile::from_json_str(json),
Err(SpectrumFileError::SchemaValidation(_))
));
}
}