1use 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#[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 #[error("Schema validation failed:\n{0}")]
406 SchemaValidation(String),
407
408 #[error("Cross-field validation failed:\n{0}")]
411 CrossFieldValidation(String),
412}
413
414pub type Result<T> = std::result::Result<T, SpectrumFileError>;
415
416#[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 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 pub fn from_json_str(json: &str) -> Result<Self> {
449 let value: serde_json::Value = serde_json::from_str(json)?;
451
452 validate_schema(&value)?;
454
455 let file: SpectrumFile = serde_json::from_value(value)?;
457
458 file.validate_cross_fields()?;
460
461 Ok(file)
462 }
463
464 pub fn from_str_unchecked(json: &str) -> Result<Self> {
466 Ok(serde_json::from_str(json)?)
467 }
468
469 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 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 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 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 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 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 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 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 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#[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 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 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 pub fn n_points(&self) -> usize {
628 self.spectral_data.values.len()
629 }
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
634pub struct SpectrumMetadata {
635 pub measurement_type: MeasurementType,
636 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 #[serde(skip_serializing_if = "Option::is_none")]
654 pub surface: Option<String>,
655 #[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 #[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#[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 Sensitivity,
681}
682
683#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
713 pub spectral_resolution_nm: Option<f64>,
714 #[serde(skip_serializing_if = "Option::is_none")]
716 pub measurement_aperture_mm: Option<f64>,
717 #[serde(skip_serializing_if = "Option::is_none")]
719 pub measurement_filter: Option<String>,
720}
721
722#[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#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct WavelengthAxis {
737 #[serde(skip_serializing_if = "Option::is_none")]
739 pub values_nm: Option<Vec<f64>>,
740 #[serde(skip_serializing_if = "Option::is_none")]
742 pub range_nm: Option<WavelengthRange>,
743}
744
745impl WavelengthAxis {
746 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#[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 pub fn expand(&self) -> Vec<f64> {
769 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#[derive(Debug, Clone, Serialize, Deserialize)]
781pub struct SpectralData {
782 pub values: Vec<f64>,
783 #[serde(skip_serializing_if = "Option::is_none")]
785 pub uncertainty: Option<Vec<f64>>,
786 #[serde(skip_serializing_if = "Option::is_none")]
788 pub scale: Option<String>,
789}
790
791#[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#[derive(Debug, Clone, Serialize, Deserialize)]
811pub struct ColorScienceResults {
812 #[serde(rename = "XYZ", skip_serializing_if = "Option::is_none")]
814 pub xyz: Option<[f64; 3]>,
815 #[serde(skip_serializing_if = "Option::is_none")]
817 pub xy: Option<[f64; 2]>,
818 #[serde(skip_serializing_if = "Option::is_none")]
820 pub uv_prime: Option<[f64; 2]>,
821 #[serde(rename = "Lab", skip_serializing_if = "Option::is_none")]
823 pub lab: Option<[f64; 3]>,
824 #[serde(rename = "CCT_K", skip_serializing_if = "Option::is_none")]
826 pub cct_k: Option<f64>,
827 #[serde(rename = "Duv", skip_serializing_if = "Option::is_none")]
829 pub duv: Option<f64>,
830}
831
832#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct CustomIlluminantSd {
835 pub wavelengths_nm: Vec<f64>,
836 pub values: Vec<f64>,
837}
838
839#[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#[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#[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#[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
897const 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 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 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 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 _ => {} }
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 require_string(obj, "id", path, errors);
1038
1039 if let Some(meta) = require_object(obj, "metadata", path, errors) {
1041 validate_metadata(meta, &format!("{path}.metadata"), errors);
1042 }
1043
1044 if let Some(wa) = require_object(obj, "wavelength_axis", path, errors) {
1046 validate_wavelength_axis(wa, &format!("{path}.wavelength_axis"), errors);
1047 }
1048
1049 if let Some(sd) = require_object(obj, "spectral_data", path, errors) {
1051 validate_spectral_data(sd, &format!("{path}.spectral_data"), errors);
1052 }
1053
1054 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 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 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 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 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 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 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 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
1221fn 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#[cfg(feature = "spectrashop")]
1262impl SpectrumFile {
1263 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 pub fn from_spectrashop_str(input: &str) -> Result<Self> {
1283 spectrashop::ss_parse(input, None)
1284 }
1285}
1286
1287#[cfg(feature = "csv")]
1292impl SpectrumFile {
1293 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 pub fn from_csv_str(input: &str) -> Result<Self> {
1313 csv_text::csv_parse(input, None)
1314 }
1315
1316 pub fn to_tsv(&self) -> String {
1323 csv_text::csv_write(self, '\t')
1324 }
1325
1326 pub fn to_csv(&self) -> String {
1330 csv_text::csv_write(self, ',')
1331 }
1332
1333 pub fn write_tsv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1335 Ok(std::fs::write(path, self.to_tsv())?)
1336 }
1337
1338 pub fn write_csv<P: AsRef<Path>>(&self, path: P) -> Result<()> {
1340 Ok(std::fs::write(path, self.to_csv())?)
1341 }
1342}
1343
1344#[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]; 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 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 #[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 #[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 #[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 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 #[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 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 }