1use std::collections::BTreeMap;
54use std::fmt;
55use std::path::{Path, PathBuf};
56
57use kallo_core::CharacteristicCurve;
58
59#[derive(Debug, Clone)]
61pub struct StockFile {
62 pub path: PathBuf,
64 table: BTreeMap<String, Value>,
66}
67
68impl StockFile {
69 pub fn read(path: impl AsRef<Path>) -> Result<Self, StockError> {
75 let path = path.as_ref().to_path_buf();
76 let text = std::fs::read_to_string(&path).map_err(|source| StockError::Io {
77 path: path.clone(),
78 source,
79 })?;
80 let table = parse(&text, &path)?;
81 let file = Self { path, table };
82 file.require(&["stock.id", "stock.label", "stock.kind", "stock.claim"])?;
83 let kind = file.string("stock.kind")?;
84 if kind != "film" && kind != "paper" {
85 return Err(file.fail(format!(
86 "stock.kind is {kind:?}; a stock is \"film\" or \"paper\""
87 )));
88 }
89 let claim = file.string("stock.claim")?;
90 if claim != "measured" && claim != "placeholder" {
91 return Err(file.fail(format!(
92 "stock.claim is {claim:?}; it is \"measured\" or \"placeholder\""
93 )));
94 }
95 Ok(file)
96 }
97
98 #[must_use]
100 pub fn path(&self) -> &Path {
101 &self.path
102 }
103
104 pub fn id(&self) -> String {
110 self.string("stock.id").unwrap_or_default()
111 }
112
113 pub fn label(&self) -> String {
119 self.string("stock.label").unwrap_or_default()
120 }
121
122 pub fn is_film(&self) -> bool {
128 self.string("stock.kind")
129 .map(|k| k == "film")
130 .unwrap_or(false)
131 }
132
133 pub fn is_reversal(&self) -> bool {
139 self.string("stock.process")
140 .map(|p| p == "reversal")
141 .unwrap_or(false)
142 }
143
144 #[must_use]
146 pub fn claims_measured(&self) -> bool {
147 self.string("stock.claim")
148 .map(|c| c == "measured")
149 .unwrap_or(false)
150 }
151
152 #[must_use]
154 pub fn check(&self) -> Vec<Finding> {
155 let mut findings = Vec::new();
156 findings.push(self.check_provenance());
157 findings.push(self.check_process());
158 findings.push(self.check_borrowed());
159 findings.extend(self.check_curves());
160 findings.push(self.check_base());
161 findings.push(self.check_emulsion());
162 findings.extend(self.check_spectra());
163 findings.push(self.check_cl_layer());
164 findings
165 }
166
167 fn check_provenance(&self) -> Finding {
169 if !self.claims_measured() {
170 return Finding::note(
171 "provenance",
172 "placeholder: no document is claimed, and the render says so",
173 );
174 }
175 let mut missing = Vec::new();
176 for key in [
177 "provenance.source",
178 "provenance.publication",
179 "provenance.retrieved",
180 "provenance.checksum",
181 ] {
182 if self.string(key).is_err() {
183 missing.push(key);
184 }
185 }
186 if missing.is_empty() {
187 Finding::ok(
188 "provenance",
189 format!(
190 "{} p{} retrieved {}, {}",
191 self.string("provenance.publication").unwrap_or_default(),
192 self.integer("provenance.page").unwrap_or(0),
193 self.string("provenance.retrieved").unwrap_or_default(),
194 self.string("provenance.checksum").unwrap_or_default()
195 ),
196 )
197 } else {
198 Finding::fail(
199 "provenance",
200 format!(
201 "claims to be measured without {} — it is a placeholder until they are filled in",
202 missing.join(", ")
203 ),
204 )
205 }
206 }
207
208 fn check_curves(&self) -> Vec<Finding> {
210 let mut findings = Vec::new();
211 let base = if self.is_film() {
212 self.floats("base.density").ok().filter(|v| v.len() == 3)
213 } else {
214 None
215 };
216 for (index, layer) in ["red", "green", "blue"].into_iter().enumerate() {
217 let prefix = format!("curve.{layer}");
218 let curve = match self.curve(&prefix) {
219 Ok(curve) => curve,
220 Err(error) => {
221 findings.push(Finding::fail(layer, error.to_string()));
222 continue;
223 }
224 };
225 if self.is_reversal() {
229 if curve.d_min <= curve.d_max {
230 findings.push(Finding::fail(
231 layer,
232 format!(
233 "d_min {} is not above d_max {} — a reversal starts at its \
234 maximum density on unexposed film and clears as the image forms",
235 curve.d_min, curve.d_max
236 ),
237 ));
238 continue;
239 }
240 } else if curve.d_min >= curve.d_max {
241 findings.push(Finding::fail(
242 layer,
243 format!("d_min {} is not below d_max {}", curve.d_min, curve.d_max),
244 ));
245 continue;
246 }
247 if curve.gamma <= 0.0 {
248 findings.push(Finding::fail(
249 layer,
250 format!("gamma {} is not positive", curve.gamma),
251 ));
252 continue;
253 }
254 if self.is_film() && !self.is_reversal() && curve.d_min.abs() > 1e-9 {
258 findings.push(Finding::fail(
259 layer,
260 format!(
261 "d_min {} — a film's curve is dye above base, so d_min is 0 and \
262 `base.density` carries the rest",
263 curve.d_min
264 ),
265 ));
266 continue;
267 }
268 let base_for_fit = if self.is_reversal() {
271 None
272 } else {
273 base.as_ref().map(|b| b[index])
274 };
275 match self.check_fit(&prefix, &curve, base_for_fit) {
276 Ok(text) => findings.push(Finding::ok(layer, text)),
277 Err(error) => findings.push(Finding::fail(layer, error.to_string())),
278 }
279 }
280 findings
281 }
282
283 fn check_fit(
290 &self,
291 prefix: &str,
292 curve: &CharacteristicCurve,
293 base: Option<f64>,
294 ) -> Result<String, StockError> {
295 let Ok(points) = self.string(&format!("{prefix}.points")) else {
296 return Ok("no digitised points named; the curve is taken on trust".to_owned());
297 };
298 let path = self.path.parent().unwrap_or(Path::new(".")).join(&points);
299 let rows = read_points(&path)?;
300 if rows.len() < 20 {
301 return Err(StockError::Check {
302 path: self.path.clone(),
303 stage: prefix.to_owned(),
304 message: format!(
305 "{} has {} points, which is not a digitised curve",
306 path.display(),
307 rows.len()
308 ),
309 });
310 }
311 let scale = self
312 .string(&format!("{prefix}.x_scale"))
313 .unwrap_or_else(|_| "log_exposure".to_owned());
314 let factor = if scale == "camera_stops" {
315 std::f64::consts::LOG10_2
316 } else {
317 1.0
318 };
319 let mut sum = 0.0;
320 let offset = base.unwrap_or(0.0);
321 for (x, density) in &rows {
322 let model = curve.density(x * factor) + offset;
323 sum += (model - density) * (model - density);
324 }
325 let rms = (sum / rows.len() as f64).sqrt();
326 let lowest = rows
345 .iter()
346 .map(|(_, density)| *density)
347 .fold(f64::INFINITY, f64::min);
348 const REACH: f64 = 1e-2;
352 let mut base_note = None;
353 if let Some(base) = base {
354 if base > lowest + REACH {
355 base_note = Some(format!(
356 "{lowest:.4} is the sheet's lowest density and the declared base is \
357 {base:.4}, so the curve bottoms out at the base and cannot reach the \
358 ink — the base is the number to look at"
359 ));
360 }
361 }
362 let bottom = curve.d_min.min(curve.d_max) + offset;
365 if base_note.is_none() && bottom > lowest + REACH {
366 return Err(StockError::Check {
367 path: self.path.clone(),
368 stage: prefix.to_owned(),
369 message: format!(
370 "the curve bottoms out at {bottom:.4}, above the lowest density it was \
371 fitted to ({lowest:.4}) — it cannot reach that part of the sheet at any \
372 exposure, which usually means one corner rate is doing two corners' work"
373 ),
374 });
375 }
376 let declared = self.float(&format!("{prefix}.rms")).ok();
377 let verdict = match declared {
378 Some(declared) if declared > 0.1 => Err(StockError::Check {
379 path: self.path.clone(),
380 stage: prefix.to_owned(),
381 message: format!(
382 "declares an rms of {declared:.3}; a curve fitted to a digitised sheet \
383 lands under 0.05, so that number would excuse anything"
384 ),
385 }),
386 Some(declared) if rms <= declared * 1.10 => Ok(format!(
390 "{} points, rms {rms:.4} against a declared {declared:.4}",
391 rows.len()
392 )),
393 Some(declared) => Err(StockError::Check {
394 path: self.path.clone(),
395 stage: prefix.to_owned(),
396 message: format!(
397 "rms {rms:.4} against the declared {declared:.4} — the curve does not \
398 reproduce the points it names, so one of the two is wrong"
399 ),
400 }),
401 None => Err(StockError::Check {
402 path: self.path.clone(),
403 stage: prefix.to_owned(),
404 message: format!(
405 "names points but no `rms`; the fit's residual is {rms:.4} and cannot be \
406 checked against anything"
407 ),
408 }),
409 };
410 match (verdict, base_note) {
411 (Ok(message), Some(note)) => Ok(format!("{message}; {note}")),
412 (other, _) => other,
413 }
414 }
415
416 fn check_base(&self) -> Finding {
418 if self.is_film() {
419 match (
420 self.floats("base.density"),
421 self.floats("base.grains_per_um2"),
422 ) {
423 (Ok(density), Ok(grains)) if density.len() == 3 && grains.len() == 3 => {
424 Finding::ok(
425 "base",
426 format!("status density {density:?}, grains {grains:?} per µm²"),
427 )
428 }
429 _ => Finding::fail(
430 "base",
431 "a film needs `base.density` and `base.grains_per_um2`, three values each",
432 ),
433 }
434 } else {
435 match self.float("base.target_mid_reflectance") {
436 Ok(value) if (0.01..=1.0).contains(&value) => {
437 Finding::ok("base", format!("target mid reflectance {value:.3}"))
438 }
439 Ok(value) => Finding::fail(
440 "base",
441 format!("target_mid_reflectance {value} is not a reflectance"),
442 ),
443 Err(_) => Finding::fail("base", "a paper needs `base.target_mid_reflectance`"),
444 }
445 }
446 }
447
448 fn check_process(&self) -> Finding {
455 let process = self
456 .string("stock.process")
457 .unwrap_or_else(|_| "negative".to_owned());
458 let dyes = self
459 .strings("spectra.dyes")
460 .map(|d| !d.is_empty())
461 .unwrap_or(false);
462 match process.as_str() {
463 "negative" => Finding::ok(
464 "process",
465 if dyes {
466 "negative, dyes on the sheet".to_owned()
467 } else {
468 "negative, dyes borrowed".to_owned()
469 },
470 ),
471 "reversal" if dyes => {
472 Finding::ok("reversal", "dyes are its own, and there is no print")
473 }
474 "reversal" => Finding::fail(
475 "reversal",
476 "a reversal film is its own picture: it needs `spectra.dyes`",
477 ),
478 "monochrome" if dyes => Finding::fail(
479 "monochrome",
480 "a silver film has no dyes; a chromogenic one needs a process this format lacks",
481 ),
482 "monochrome" => Finding::ok("monochrome", "one record, no dyes, no mask"),
483 other => Finding::fail(
484 "process",
485 format!("{other:?} is not negative, reversal or monochrome"),
486 ),
487 }
488 }
489
490 fn check_borrowed(&self) -> Finding {
498 match self.strings("stock.borrowed") {
499 Ok(names) if names.is_empty() => {
500 Finding::note("borrowed", "nothing; every quantity is this sheet's")
501 }
502 Ok(names) => Finding::ok("borrowed", format!("{:?} from the compiled stock", names)),
503 Err(_) => Finding::note("borrowed", "nothing declared"),
504 }
505 }
506
507 fn check_emulsion(&self) -> Finding {
513 if !self.is_film() {
514 return Finding::note("emulsion", "not a film");
515 }
516 let Ok(sigma) = self.floats("emulsion.sigma_um") else {
517 return Finding::note(
518 "emulsion",
519 "no `emulsion.sigma_um`; the stock falls back to the compiled blur",
520 );
521 };
522 if sigma.len() == 3 && sigma.iter().all(|s| s.is_finite() && *s > 0.0) {
523 Finding::ok(
524 "emulsion",
525 format!("sigma {:?} µm, measured from the sheet's MTF", sigma),
526 )
527 } else {
528 Finding::fail(
529 "emulsion",
530 "`emulsion.sigma_um` must be three positive finite numbers",
531 )
532 }
533 }
534
535 fn check_cl_layer(&self) -> Finding {
541 let Ok(path) = self.string("cl_layer.path") else {
542 return Finding::note("cl layer", "none declared; only some sheets give one");
543 };
544 let path = self.path.parent().unwrap_or(Path::new(".")).join(path);
545 match check_spectrum(&path) {
546 Ok(peak) => Finding::ok(
547 "cl layer",
548 format!(
549 "{}: 71 samples on the grid, peak {peak:.3} — inhibitor, not a dye",
550 path.display()
551 ),
552 ),
553 Err(message) => Finding::fail("cl layer", format!("{}: {message}", path.display())),
554 }
555 }
556
557 fn check_spectra(&self) -> Vec<Finding> {
559 let mut findings = Vec::new();
560 for section in ["spectra.dyes", "spectra.layers"] {
561 let Ok(paths) = self.strings(section) else {
562 findings.push(Finding::note(
563 section,
564 "not given; the stock falls back to the placeholder spectra",
565 ));
566 continue;
567 };
568 if paths.len() != 3 {
569 findings.push(Finding::fail(
570 section,
571 format!("{} files; a paper or film has three layers", paths.len()),
572 ));
573 continue;
574 }
575 let mut problems = Vec::new();
576 for name in &paths {
577 let path = self.path.parent().unwrap_or(Path::new(".")).join(name);
578 match check_spectrum(&path) {
579 Ok(peak) => findings.push(Finding::ok(
580 section,
581 format!("{}: 71 samples on the grid, peak {peak:.3}", path.display()),
582 )),
583 Err(message) => problems.push(format!("{}: {message}", path.display())),
584 }
585 }
586 if !problems.is_empty() {
587 findings.push(Finding::fail(section, problems.join("; ")));
588 }
589 }
590 findings
591 }
592
593 fn require(&self, keys: &[&str]) -> Result<(), StockError> {
596 for key in keys {
597 if !self.table.contains_key(*key) {
598 return Err(self.fail(format!("missing required field `{key}`")));
599 }
600 }
601 Ok(())
602 }
603
604 fn fail(&self, message: String) -> StockError {
605 StockError::Check {
606 path: self.path.clone(),
607 stage: "parse".to_owned(),
608 message,
609 }
610 }
611
612 fn string(&self, key: &str) -> Result<String, StockError> {
613 match self.table.get(key) {
614 Some(Value::String(value)) => Ok(value.clone()),
615 Some(_) => Err(self.fail(format!("`{key}` is not a string"))),
616 None => Err(self.fail(format!("missing `{key}`"))),
617 }
618 }
619
620 fn float(&self, key: &str) -> Result<f64, StockError> {
621 match self.table.get(key) {
622 Some(Value::Number(value)) => Ok(*value),
623 Some(_) => Err(self.fail(format!("`{key}` is not a number"))),
624 None => Err(self.fail(format!("missing `{key}`"))),
625 }
626 }
627
628 fn integer(&self, key: &str) -> Result<u32, StockError> {
629 self.float(key).map(|value| value.max(0.0) as u32)
630 }
631
632 fn floats(&self, key: &str) -> Result<Vec<f64>, StockError> {
633 match self.table.get(key) {
634 Some(Value::List(values)) => values
635 .iter()
636 .map(|value| match value {
637 Value::Number(number) => Ok(*number),
638 _ => Err(self.fail(format!("`{key}` has a non-numeric entry"))),
639 })
640 .collect(),
641 Some(_) => Err(self.fail(format!("`{key}` is not a list"))),
642 None => Err(self.fail(format!("missing `{key}`"))),
643 }
644 }
645
646 fn strings(&self, key: &str) -> Result<Vec<String>, StockError> {
647 match self.table.get(key) {
648 Some(Value::List(values)) => values
649 .iter()
650 .map(|value| match value {
651 Value::String(text) => Ok(text.clone()),
652 _ => Err(self.fail(format!("`{key}` has a non-string entry"))),
653 })
654 .collect(),
655 Some(_) => Err(self.fail(format!("`{key}` is not a list"))),
656 None => Err(self.fail(format!("missing `{key}`"))),
657 }
658 }
659
660 fn curve(&self, prefix: &str) -> Result<CharacteristicCurve, StockError> {
661 let base = CharacteristicCurve::new(
662 self.float(&format!("{prefix}.d_min"))?,
663 self.float(&format!("{prefix}.d_max"))?,
664 self.float(&format!("{prefix}.gamma"))?,
665 self.float(&format!("{prefix}.log_e0"))?,
666 );
667 let base = match self.float(&format!("{prefix}.transition")) {
668 Ok(transition) if transition > 0.0 => base.with_transition(transition),
669 Ok(transition) => {
670 return Err(self.fail(format!(
671 "{prefix}.transition is {transition}, which is not a transition"
672 )));
673 }
674 Err(_) => base,
675 };
676 let mut curve = base;
681 for (key, is_toe) in [("toe_transition", true), ("shoulder_transition", false)] {
682 match self.float(&format!("{prefix}.{key}")) {
683 Ok(rate) if rate > 0.0 => {
684 curve = if is_toe {
685 curve.with_corners(rate, curve.shoulder_transition)
686 } else {
687 curve.with_corners(curve.toe_transition, rate)
688 };
689 }
690 Ok(rate) => {
691 return Err(self.fail(format!(
692 "{prefix}.{key} is {rate}, which is not a transition"
693 )));
694 }
695 Err(_) => {}
696 }
697 }
698 Ok(curve)
699 }
700}
701
702#[derive(Debug, Clone, PartialEq, Eq)]
704pub struct Finding {
705 pub stage: String,
707 pub verdict: Verdict,
709 pub message: String,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub enum Verdict {
716 Ok,
718 Note,
720 Fail,
722}
723
724impl Finding {
725 fn ok(stage: impl Into<String>, message: impl Into<String>) -> Self {
726 Self {
727 stage: stage.into(),
728 verdict: Verdict::Ok,
729 message: message.into(),
730 }
731 }
732 fn note(stage: impl Into<String>, message: impl Into<String>) -> Self {
733 Self {
734 stage: stage.into(),
735 verdict: Verdict::Note,
736 message: message.into(),
737 }
738 }
739 fn fail(stage: impl Into<String>, message: impl Into<String>) -> Self {
740 Self {
741 stage: stage.into(),
742 verdict: Verdict::Fail,
743 message: message.into(),
744 }
745 }
746}
747
748impl fmt::Display for Finding {
749 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
750 let mark = match self.verdict {
751 Verdict::Ok => "ok ",
752 Verdict::Note => "note",
753 Verdict::Fail => "FAIL",
754 };
755 write!(formatter, " {mark} {:<10} {}", self.stage, self.message)
756 }
757}
758
759#[derive(Debug)]
761pub enum StockError {
762 Io {
764 path: PathBuf,
766 source: std::io::Error,
768 },
769 Syntax {
771 path: PathBuf,
773 line: usize,
775 message: String,
777 },
778 Check {
780 path: PathBuf,
782 stage: String,
784 message: String,
786 },
787}
788
789impl fmt::Display for StockError {
790 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
791 match self {
792 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
793 Self::Syntax {
794 path,
795 line,
796 message,
797 } => {
798 write!(formatter, "{}:{line}: {message}", path.display())
799 }
800 Self::Check {
801 path,
802 stage,
803 message,
804 } => {
805 write!(formatter, "{} [{stage}]: {message}", path.display())
806 }
807 }
808 }
809}
810
811impl std::error::Error for StockError {
812 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
813 match self {
814 Self::Io { source, .. } => Some(source),
815 _ => None,
816 }
817 }
818}
819
820#[derive(Debug, Clone, PartialEq)]
824pub(crate) enum Value {
825 String(String),
826 Number(f64),
827 List(Vec<Value>),
828}
829
830pub(crate) fn parse(text: &str, path: &Path) -> Result<BTreeMap<String, Value>, StockError> {
831 let mut table = BTreeMap::new();
832 let mut section = String::new();
833 let lines: Vec<&str> = text.lines().collect();
834 let mut index = 0;
835 while index < lines.len() {
836 let raw = lines[index];
837 let line = index + 1;
838 let trimmed = raw.trim();
839 if trimmed.is_empty() || trimmed.starts_with('#') {
840 index += 1;
841 continue;
842 }
843 if let Some(rest) = trimmed.strip_prefix('[') {
844 let Some(name) = rest.strip_suffix(']') else {
845 return Err(StockError::Syntax {
846 path: path.to_path_buf(),
847 line,
848 message: "a section header ends with `]`".to_owned(),
849 });
850 };
851 section = name.trim().to_owned();
852 index += 1;
853 continue;
854 }
855 let Some((key, value)) = trimmed.split_once('=') else {
856 return Err(StockError::Syntax {
857 path: path.to_path_buf(),
858 line,
859 message: "expected `key = value` or `[section]`".to_owned(),
860 });
861 };
862 let key = key.trim();
863 let full = if section.is_empty() {
864 key.to_owned()
865 } else {
866 format!("{section}.{key}")
867 };
868 let mut literal = value.trim().to_owned();
871 while literal.starts_with('[') && !literal.contains(']') {
872 index += 1;
873 let Some(next) = lines.get(index) else {
874 return Err(StockError::Syntax {
875 path: path.to_path_buf(),
876 line,
877 message: "a list is not closed with `]`".to_owned(),
878 });
879 };
880 literal.push(' ');
881 literal.push_str(next.trim());
882 }
883 let parsed = parse_value(&literal).map_err(|message| StockError::Syntax {
884 path: path.to_path_buf(),
885 line,
886 message,
887 })?;
888 table.insert(full, parsed);
889 index += 1;
890 }
891 Ok(table)
892}
893
894fn parse_value(text: &str) -> Result<Value, String> {
895 let text = text.trim();
896 if let Some(rest) = text.strip_prefix('"') {
897 let Some(end) = rest.find('"') else {
898 return Err("a string ends with `\"`".to_owned());
899 };
900 let trailing = rest[end + 1..].trim();
901 if !trailing.is_empty() && !trailing.starts_with('#') {
902 return Err(format!("unexpected {trailing:?} after a string"));
903 }
904 return Ok(Value::String(rest[..end].to_owned()));
905 }
906 if let Some(rest) = text.strip_prefix('[') {
907 let Some(end) = rest.find(']') else {
908 return Err("a list ends with `]`".to_owned());
909 };
910 let mut values = Vec::new();
911 for item in rest[..end].split(',') {
912 let item = item.trim();
913 if item.is_empty() {
914 continue;
915 }
916 values.push(parse_value(item)?);
917 }
918 return Ok(Value::List(values));
919 }
920 let bare = text.split('#').next().unwrap_or(text).trim();
922 bare.parse::<f64>()
923 .map(Value::Number)
924 .map_err(|_| format!("{bare:?} is not a number or a quoted string"))
925}
926
927fn read_points(path: &Path) -> Result<Vec<(f64, f64)>, StockError> {
930 let text = std::fs::read_to_string(path).map_err(|source| StockError::Io {
931 path: path.to_path_buf(),
932 source,
933 })?;
934 let mut rows = Vec::new();
935 for line in text.lines().skip(1) {
936 let mut fields = line.split(',');
937 let (Some(x), Some(y)) = (fields.next(), fields.next()) else {
938 continue;
939 };
940 let (Ok(x), Ok(y)) = (x.trim().parse::<f64>(), y.trim().parse::<f64>()) else {
941 continue;
942 };
943 rows.push((x, y));
944 }
945 Ok(rows)
946}
947
948fn check_spectrum(path: &Path) -> Result<f64, String> {
950 let set = kallo_spectral::load_csv(path).map_err(|error| error.to_string())?;
951 if set.samples.is_empty() {
952 return Err("no columns".to_owned());
953 }
954 let grid = &set.grid;
955 if grid.values().len() != 71 {
956 return Err(format!("{} samples, not 71", grid.values().len()));
957 }
958 if (grid.start_nm() - 380.0).abs() > 1e-9 || (grid.step_nm() - 5.0).abs() > 1e-9 {
959 return Err(format!(
960 "starts at {} nm in {} nm steps, not 380 in 5",
961 grid.start_nm(),
962 grid.step_nm()
963 ));
964 }
965 let mut peak: f64 = 0.0;
966 for value in set.samples[0].reflectance.values() {
967 if !value.is_finite() || *value < 0.0 {
968 return Err(format!("value {value} is not a non-negative number"));
969 }
970 peak = peak.max(*value);
971 }
972 Ok(peak)
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 const EXAMPLE: &str = r#"
980# A stock, as data.
981[stock]
982id = "example" # comment after a value
983label = "An example paper"
984kind = "paper"
985claim = "measured"
986
987[provenance]
988source = "A data sheet"
989publication = "E-0000"
990page = 7
991retrieved = "2026-11-07"
992checksum = "sha256:0000"
993
994[curve.red]
995d_min = 0.10
996d_max = 2.50
997gamma = 3.5
998log_e0 = -1.40
999transition = 7.0
1000
1001[curve.green]
1002d_min = 0.10
1003d_max = 2.50
1004gamma = 3.5
1005log_e0 = -1.40
1006
1007[curve.blue]
1008d_min = 0.12
1009d_max = 2.40
1010gamma = 4.0
1011log_e0 = -1.42
1012
1013[base]
1014target_mid_reflectance = 0.18
1015"#;
1016
1017 fn parsed() -> StockFile {
1018 let table = parse(EXAMPLE, Path::new("example.toml")).expect("parses");
1019 StockFile {
1020 path: PathBuf::from("example.toml"),
1021 table,
1022 }
1023 }
1024
1025 #[test]
1026 fn the_subset_parses() {
1027 let file = parsed();
1028 assert_eq!(file.id(), "example");
1029 assert_eq!(file.label(), "An example paper");
1030 assert!(!file.is_film());
1031 assert!(file.claims_measured());
1032 assert_eq!(file.float("curve.red.gamma").unwrap(), 3.5);
1033 let curve = file.curve("curve.green").expect("green");
1035 assert_eq!(curve.toe_transition, kallo_core::curve::TRANSITION);
1036 assert_eq!(file.curve("curve.red").unwrap().toe_transition, 7.0);
1038 assert_eq!(file.curve("curve.red").unwrap().shoulder_transition, 7.0);
1039 }
1040
1041 #[test]
1042 fn a_comment_does_not_join_the_value() {
1043 let file = parsed();
1044 assert_eq!(file.string("stock.id").unwrap(), "example");
1045 }
1046
1047 #[test]
1048 fn a_measured_stock_without_a_document_fails() {
1049 let text = EXAMPLE.replace("checksum = \"sha256:0000\"\n", "");
1050 let table = parse(&text, Path::new("t.toml")).expect("parses");
1051 let file = StockFile {
1052 path: PathBuf::from("t.toml"),
1053 table,
1054 };
1055 let finding = file.check_provenance();
1056 assert_eq!(finding.verdict, Verdict::Fail);
1057 assert!(
1058 finding.message.contains("provenance.checksum"),
1059 "{}",
1060 finding.message
1061 );
1062 }
1063
1064 #[test]
1065 fn a_placeholder_says_so_and_passes() {
1066 let text = EXAMPLE
1067 .replace("claim = \"measured\"", "claim = \"placeholder\"")
1068 .replace("checksum = \"sha256:0000\"\n", "");
1069 let table = parse(&text, Path::new("t.toml")).expect("parses");
1070 let file = StockFile {
1071 path: PathBuf::from("t.toml"),
1072 table,
1073 };
1074 let finding = file.check_provenance();
1075 assert_eq!(finding.verdict, Verdict::Note);
1076 }
1077
1078 #[test]
1079 fn a_curve_that_is_not_a_curve_fails() {
1080 let text = EXAMPLE.replace("d_min = 0.10", "d_min = 3.0");
1081 let table = parse(&text, Path::new("t.toml")).expect("parses");
1082 let file = StockFile {
1083 path: PathBuf::from("t.toml"),
1084 table,
1085 };
1086 let findings = file.check_curves();
1087 assert!(
1088 findings.iter().any(|f| f.verdict == Verdict::Fail),
1089 "{findings:?}"
1090 );
1091 }
1092
1093 #[test]
1094 fn a_paper_needs_its_target_and_a_film_its_grain() {
1095 let file = parsed();
1096 assert_eq!(file.check_base().verdict, Verdict::Ok);
1097 let text = EXAMPLE.replace("kind = \"paper\"", "kind = \"film\"");
1098 let table = parse(&text, Path::new("t.toml")).expect("parses");
1099 let film = StockFile {
1100 path: PathBuf::from("t.toml"),
1101 table,
1102 };
1103 assert_eq!(film.check_base().verdict, Verdict::Fail);
1104 }
1105
1106 #[test]
1107 fn a_syntax_error_names_its_line() {
1108 let error = parse(
1109 "[stock]\nid = \"x\"\nthis is not a pair\n",
1110 Path::new("t.toml"),
1111 )
1112 .expect_err("a line that is not a pair fails");
1113 let text = error.to_string();
1114 assert!(text.contains(":3:"), "{text}");
1115 }
1116
1117 #[test]
1118 fn a_number_and_a_string_are_not_confused() {
1119 let table = parse("[a]\nb = 1\nc = \"2\"\n", Path::new("t.toml")).expect("parses");
1120 let file = StockFile {
1121 path: PathBuf::from("t.toml"),
1122 table,
1123 };
1124 assert!(file.float("a.b").is_ok());
1125 assert!(file.float("a.c").is_err());
1126 assert!(file.string("a.c").is_ok());
1127 assert!(file.string("a.b").is_err());
1128 }
1129
1130 #[test]
1131 fn a_film_may_not_carry_its_base_in_the_curve() {
1132 let text = EXAMPLE.replace("kind = \"paper\"", "kind = \"film\"");
1133 let table = parse(&text, Path::new("t.toml")).expect("parses");
1134 let file = StockFile {
1135 path: PathBuf::from("t.toml"),
1136 table,
1137 };
1138 let findings = file.check_curves();
1139 assert!(
1140 findings
1141 .iter()
1142 .any(|f| f.verdict == Verdict::Fail && f.message.contains("dye above base")),
1143 "{findings:?}"
1144 );
1145 }
1146
1147 #[test]
1151 fn a_films_points_are_base_plus_dye() {
1152 let root = std::env::temp_dir().join(format!("kallo-stock-{}", std::process::id()));
1153 std::fs::create_dir_all(&root).expect("a temporary directory");
1154 let layers = ["red", "green", "blue"];
1155 let curves = [
1156 CharacteristicCurve::new(0.0, 1.70, 0.48, -0.76),
1157 CharacteristicCurve::new(0.0, 2.00, 0.56, -0.73),
1158 CharacteristicCurve::new(0.0, 1.92, 0.55, -0.75),
1159 ];
1160 let base = [0.19, 0.58, 0.83];
1161
1162 let text = |base: [f64; 3]| {
1163 let mut text = String::from(
1164 "[stock]\nid = \"test_film\"\nlabel = \"A film\"\nkind = \"film\"\n\
1165 claim = \"placeholder\"\n",
1166 );
1167 for (index, layer) in layers.iter().enumerate() {
1168 let name = format!("{layer}.csv");
1169 let mut csv = String::from("log_exposure,density\n");
1170 let mut x = -4.0;
1171 while x <= 1.0 {
1172 let density = curves[index].density(x) + base[index];
1173 csv.push_str(&format!("{x:.3},{density:.4}\n"));
1174 x += 0.02;
1175 }
1176 std::fs::write(root.join(&name), csv).expect("a points file");
1177 let curve = &curves[index];
1178 text.push_str(&format!(
1179 "\n[curve.{layer}]\nd_min = {:.4}\nd_max = {:.4}\ngamma = {:.4}\n\
1180 log_e0 = {:.4}\npoints = \"{name}\"\nrms = 0.001\n",
1181 curve.d_min, curve.d_max, curve.gamma, curve.log_e0
1182 ));
1183 }
1184 text.push_str(&format!(
1185 "\n[base]\ndensity = [{}, {}, {}]\ngrains_per_um2 = [1.0, 1.0, 1.0]\n",
1186 base[0], base[1], base[2]
1187 ));
1188 text
1189 };
1190
1191 let written = text(base);
1192 let right = root.join("right.toml");
1193 std::fs::write(&right, &written).expect("a stock file");
1194 let findings = StockFile::read(&right).expect("reads").check_curves();
1195 assert!(
1196 findings.iter().all(|f| f.verdict == Verdict::Ok),
1197 "{findings:?}"
1198 );
1199
1200 let wrong = root.join("wrong.toml");
1203 std::fs::write(
1204 &wrong,
1205 written.replace(
1206 "density = [0.19, 0.58, 0.83]",
1207 "density = [0.29, 0.58, 0.83]",
1208 ),
1209 )
1210 .expect("a stock file");
1211 let findings = StockFile::read(&wrong).expect("reads").check_curves();
1212 assert!(
1213 findings
1214 .iter()
1215 .any(|f| f.verdict == Verdict::Fail && f.message.contains("rms")),
1216 "{findings:?}"
1217 );
1218 std::fs::remove_dir_all(&root).expect("clean up");
1219 }
1220
1221 #[test]
1232 fn a_curve_may_not_bottom_out_above_its_own_ink() {
1233 let root = std::env::temp_dir().join(format!("kallo-reach-{}", std::process::id()));
1234 std::fs::create_dir_all(&root).expect("a temporary directory");
1235 let said = CharacteristicCurve::new(3.2820, 0.0481, 2.5666, -1.3485).with_corners(5.0, 2.5);
1238 let mut csv = String::from("log_exposure,density\n");
1239 let mut x = -2.6;
1240 while x <= 0.2 {
1241 if said.density(x) >= 0.1418 {
1242 csv.push_str(&format!("{x:.3},{:.4}\n", said.density(x)));
1243 }
1244 x += 0.02;
1245 }
1246 std::fs::write(root.join("red.csv"), &csv).expect("a points file");
1247 let text = |d_max: f64, toe: f64, shoulder: f64| {
1248 format!(
1249 "[stock]\nid = \"test_reversal\"\nlabel = \"A reversal\"\nkind = \"film\"\n\
1250 claim = \"placeholder\"\nprocess = \"reversal\"\n\
1251 \n[curve.red]\nd_min = 3.2820\nd_max = {d_max:.4}\ngamma = 2.5666\n\
1252 log_e0 = -1.3485\ntoe_transition = {toe:.1}\n\
1253 shoulder_transition = {shoulder:.1}\npoints = \"red.csv\"\nrms = 0.020\n\
1254 \n[base]\ndensity = [0.0, 0.0, 0.0]\ngrains_per_um2 = [3.6, 3.6, 3.6]\n"
1255 )
1256 };
1257 let good = root.join("good.toml");
1259 std::fs::write(&good, text(0.0481, 5.0, 2.5)).expect("a stock file");
1260 let findings = StockFile::read(&good).expect("reads").check_curves();
1261 assert!(
1262 findings
1263 .iter()
1264 .filter(|f| f.stage == "red")
1265 .all(|f| f.verdict == Verdict::Ok),
1266 "{findings:?}"
1267 );
1268
1269 let bad = root.join("bad.toml");
1271 std::fs::write(&bad, text(0.1816, 4.0, 4.0)).expect("a stock file");
1272 let findings = StockFile::read(&bad).expect("reads").check_curves();
1273 assert!(
1274 findings
1275 .iter()
1276 .any(|f| f.verdict == Verdict::Fail && f.message.contains("bottoms out")),
1277 "{findings:?}"
1278 );
1279 std::fs::remove_dir_all(&root).expect("clean up");
1280 }
1281}