1#![doc = include_str!("../docs/bibs/2.md")]
10#![doc = include_str!("../docs/bibs/3.md")]
11
12use approx::AbsDiffEq;
13use approx::abs_diff_eq;
14use serde::{Deserialize, Serialize};
15use struct_iterable::Iterable;
16use strum_macros::EnumIter;
17
18#[cfg(feature = "wasm")]
19use wasm_bindgen::prelude::*;
20
21use crate::{
22 composition::{CompKey, Composition},
23 constants::{
24 COMPOSITION_EPSILON,
25 fpd::{
26 CORVITTO_PAC_TO_SERVING_TEMP_TABLE, FPD_CONST_FOR_MSNF_WS_SALTS, PAC_TO_FPD_POLY_COEFFS, PAC_TO_FPD_TABLE,
27 SERVING_TEMP_X_AXIS, TARGET_SERVING_TEMP_14C,
28 },
29 },
30 error::{Error, Result},
31 util::iter_all_abs_diff_eq,
32};
33
34#[cfg(doc)]
35use crate::{constants::pac, properties};
36
37#[cfg_attr(feature = "wasm", wasm_bindgen)]
43#[derive(EnumIter, Hash, PartialEq, Eq, Serialize, Deserialize, Copy, Clone, Debug)]
44pub enum FpdKey {
45 FPD,
52 ServingTemp,
58 HardnessAt14C,
64}
65
66#[cfg_attr(feature = "wasm", wasm_bindgen)]
68#[derive(Clone, Debug)]
69pub struct FPD {
70 pub fpd: f64,
72 pub serving_temp: f64,
74 pub hardness_at_14c: f64,
76 #[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))]
78 pub curves: Curves,
79}
80
81impl FPD {
82 #[must_use]
84 pub fn empty() -> Self {
85 Self {
86 fpd: 0.0,
87 serving_temp: 0.0,
88 hardness_at_14c: f64::NAN,
89 curves: Curves::empty(),
90 }
91 }
92
93 pub fn compute_from_composition(composition: Composition) -> Result<Self> {
103 let curves = compute_fpd_curves(
104 composition,
105 PacToFpdMethod::Interpolation,
106 FpdCurvesMethod::ModifiedGoffHartelCorvitto,
107 )?;
108
109 let fpd = curves.frozen_water[0].temp;
110 let serving_temp = curves.hardness[SERVING_TEMP_X_AXIS].temp;
111 let hardness_at_14c = get_x_axis_at_fpd(&curves.hardness, TARGET_SERVING_TEMP_14C);
112
113 Ok(Self {
114 fpd,
115 serving_temp,
116 hardness_at_14c: hardness_at_14c.unwrap_or(f64::NAN),
117 curves,
118 })
119 }
120}
121
122#[cfg_attr(feature = "wasm", wasm_bindgen)]
123impl FPD {
124 #[allow(clippy::missing_const_for_fn)] #[must_use]
127 pub fn get(&self, key: FpdKey) -> f64 {
128 match key {
129 FpdKey::FPD => self.fpd,
130 FpdKey::ServingTemp => self.serving_temp,
131 FpdKey::HardnessAt14C => self.hardness_at_14c,
132 }
133 }
134}
135
136#[cfg_attr(feature = "wasm", wasm_bindgen)]
139#[derive(Copy, Clone, Debug)]
140pub struct CurvePoint {
141 pub x_axis: f64,
146 pub temp: f64,
148}
149
150impl CurvePoint {
151 #[must_use]
153 pub const fn new(x_axis: f64, temp: f64) -> Self {
154 Self { x_axis, temp }
155 }
156}
157
158#[doc = include_str!("../docs/bibs/2.md")]
160#[doc = include_str!("../docs/bibs/3.md")]
161#[cfg_attr(feature = "wasm", wasm_bindgen)]
162#[derive(Clone, Debug)]
163pub struct Curves {
164 #[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))]
168 pub frozen_water: Vec<CurvePoint>,
169 #[doc = include_str!("../docs/bibs/2.md")]
177 #[doc = include_str!("../docs/bibs/3.md")]
178 #[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))]
179 pub hardness: Vec<CurvePoint>,
180}
181
182impl Curves {
183 #[must_use]
185 pub fn empty() -> Self {
186 let make_empty_curve = || (0..100).map(|x_axis| CurvePoint::new(f64::from(x_axis), 0.0)).collect();
187
188 Self {
189 frozen_water: make_empty_curve(),
190 hardness: make_empty_curve(),
191 }
192 }
193}
194
195#[derive(Copy, Clone, Debug)]
197pub enum PacToFpdMethod {
198 Interpolation,
200 Polynomial,
202}
203
204#[derive(Copy, Clone, Debug)]
206pub enum FpdCurvesMethod {
207 #[doc = include_str!("../docs/bibs/2.md")]
211 GoffHartel,
212 #[doc = include_str!("../docs/bibs/2.md")]
216 #[doc = include_str!("../docs/bibs/3.md")]
217 ModifiedGoffHartelCorvitto,
218}
219
220pub fn compute_fpd_curves(
229 composition: Composition,
230 pac_to_fpd_method: PacToFpdMethod,
231 curves_method: FpdCurvesMethod,
232) -> Result<Curves> {
233 let mut curves = Curves {
234 frozen_water: Vec::new(),
235 hardness: Vec::new(),
236 };
237
238 for x_axis in 0..100 {
239 let frozen_water = f64::from(x_axis);
240
241 let get_fpd_from_pac = match pac_to_fpd_method {
242 PacToFpdMethod::Interpolation => get_fpd_from_pac_interpolation,
243 PacToFpdMethod::Polynomial => |pac| get_fpd_from_pac_polynomial(pac, None),
244 };
245
246 let (fpd_fw, fpd_hardness) = match curves_method {
247 FpdCurvesMethod::GoffHartel => {
248 compute_fpd_curve_step_goff_hartel(composition, frozen_water, &get_fpd_from_pac)
249 .map(|step| (step.fpd_total, f64::NAN))?
250 }
251 FpdCurvesMethod::ModifiedGoffHartelCorvitto => {
252 compute_fpd_curve_step_modified_goff_hartel_corvitto(composition, frozen_water, &get_fpd_from_pac)
253 .map(|step| (step.fpd_exc_hf, step.fpd_inc_hf))?
254 }
255 };
256
257 curves.frozen_water.push(CurvePoint::new(frozen_water, fpd_fw));
258 curves.hardness.push(CurvePoint::new(frozen_water, fpd_hardness));
259 }
260
261 Ok(curves)
262}
263
264pub fn get_fpd_from_pac_interpolation(pac: f64) -> Result<f64> {
270 if pac < 0.0 {
271 return Err(Error::NegativePacValue(pac));
272 }
273
274 let (step, max_pac) = (PAC_TO_FPD_TABLE[1].0, PAC_TO_FPD_TABLE.last().unwrap_or_else(|| unreachable!()).0);
275
276 #[expect(
277 clippy::cast_precision_loss,
278 clippy::cast_possible_truncation,
279 clippy::cast_sign_loss
280 )]
281 let (floor_pac, ceil_pac) =
282 (((pac / step as f64).floor() as usize * step), ((pac / step as f64).ceil() as usize * step));
283
284 let (floor_pac, ceil_pac) = if ceil_pac <= max_pac {
285 (floor_pac, ceil_pac)
286 } else {
287 (max_pac - step, max_pac)
288 };
289
290 let idx_floor_pac = floor_pac / step;
291 let idx_ceil_pac = ceil_pac / step;
292
293 let floor_fpd = PAC_TO_FPD_TABLE[idx_floor_pac].1;
294 let ceil_fpd = PAC_TO_FPD_TABLE[idx_ceil_pac].1;
295
296 #[expect(clippy::cast_precision_loss)]
297 let run = pac - floor_pac as f64;
298 #[expect(clippy::cast_precision_loss)]
299 let slope = (ceil_fpd - floor_fpd) / step as f64;
300
301 Ok(-(floor_fpd + slope * run))
302}
303
304pub fn get_fpd_from_pac_polynomial(pac: f64, coeffs: Option<[f64; 3]>) -> Result<f64> {
319 let [a, b, c] = coeffs.unwrap_or(PAC_TO_FPD_POLY_COEFFS);
320
321 if pac < 0.0 {
322 return Err(Error::NegativePacValue(pac));
323 }
324
325 Ok((a * pac.powi(2)) + (b * pac) + c)
326}
327
328pub fn get_pac_from_fpd_polynomial(fpd: f64, coeffs: Option<[f64; 3]>) -> Result<f64> {
342 let [a, b, c] = coeffs.unwrap_or(PAC_TO_FPD_POLY_COEFFS);
343
344 if fpd > 0.0 {
345 return Err(Error::PositiveFpdValue(fpd));
346 }
347
348 let discriminant = b.powi(2) - 4.0 * a * (c - fpd);
349
350 if discriminant < 0.0 {
351 return Err(Error::CannotComputePAC("Discriminant is negative, no real roots exist".to_string()));
352 }
353
354 let sqrt_discriminant = discriminant.sqrt();
355 let root1 = (-b + sqrt_discriminant) / (2.0 * a);
356 let root2 = (-b - sqrt_discriminant) / (2.0 * a);
357
358 if root1 < 0.0 && root2 < 0.0 {
359 return Err(Error::CannotComputePAC("Both roots are negative, PAC cannot be negative".to_string()));
360 }
361
362 if root1 > 0.0 && root2 > 0.0 {
363 return Err(Error::CannotComputePAC("Both roots are positive, ambiguous PAC value".to_string()));
364 }
365
366 Ok(root1.max(root2))
367}
368
369pub fn get_serving_temp_from_pac_corvitto(pac: f64) -> Result<f64> {
375 if pac < 0.0 {
376 return Err(Error::NegativePacValue(pac));
377 }
378
379 let first = CORVITTO_PAC_TO_SERVING_TEMP_TABLE[0];
380 let last = CORVITTO_PAC_TO_SERVING_TEMP_TABLE
381 .last()
382 .unwrap_or_else(|| unreachable!());
383
384 let slope = (first.1 - last.1) / (first.0 - last.0);
385 let run = pac - first.0;
386
387 Ok(slope * run + first.1)
388}
389
390#[doc = include_str!("../docs/bibs/2.md")]
394#[derive(Iterable, PartialEq, Copy, Clone, Debug)]
395pub struct GoffHartelFpdCurveStep {
396 pub frozen_water: f64,
398 pub water: f64,
400 pub se: f64,
402 pub sa: f64,
404 pub alc: f64,
406 pub fpd_se: f64,
408 pub fpd_sa: f64,
410 pub fpd_alc: f64,
412 pub fpd_total: f64,
414}
415
416impl GoffHartelFpdCurveStep {
417 #[must_use]
419 pub const fn empty() -> Self {
420 Self {
421 frozen_water: f64::NAN,
422 water: f64::NAN,
423 se: f64::NAN,
424 sa: f64::NAN,
425 alc: f64::NAN,
426 fpd_se: f64::NAN,
427 fpd_sa: f64::NAN,
428 fpd_alc: f64::NAN,
429 fpd_total: f64::NAN,
430 }
431 }
432}
433
434#[doc = include_str!("../docs/bibs/2.md")]
440pub fn compute_fpd_curve_step_goff_hartel(
441 composition: Composition,
442 next_frozen_water: f64,
443 get_fpd_from_pac: &impl Fn(f64) -> Result<f64>,
444) -> Result<GoffHartelFpdCurveStep> {
445 let mut next = GoffHartelFpdCurveStep::empty();
446
447 next.frozen_water = next_frozen_water;
448 next.water = (100.0 - next.frozen_water) / 100.0 * composition.get(CompKey::Water);
449 next.se = composition.get(CompKey::PACsgr) / next.water * 100.0;
450 next.sa = composition.get(CompKey::PACslt) / next.water * 100.0;
451 next.alc = composition.get(CompKey::PACalc) / next.water * 100.0;
452
453 let fpd_msnf_ws = composition.get(CompKey::MSNF) * FPD_CONST_FOR_MSNF_WS_SALTS / next.water;
454 next.fpd_se = get_fpd_from_pac(next.se)?;
455 next.fpd_sa = get_fpd_from_pac(next.sa)? + fpd_msnf_ws;
456 next.fpd_alc = get_fpd_from_pac(next.alc)?;
457
458 next.fpd_total = next.fpd_se + next.fpd_sa + next.fpd_alc;
459
460 Ok(next)
461}
462
463#[derive(Iterable, PartialEq, Copy, Clone, Debug)]
468pub struct ModifiedGoffHartelCorvittoFpdCurveStep {
469 pub frozen_water: f64,
471 pub water: f64,
473 pub pac_exc_hf: f64,
476 pub hf: f64,
478 pub fpd_exc_hf: f64,
480 pub fpd_inc_hf: f64,
482}
483
484impl ModifiedGoffHartelCorvittoFpdCurveStep {
485 #[must_use]
487 pub const fn empty() -> Self {
488 Self {
489 frozen_water: f64::NAN,
490 water: f64::NAN,
491 pac_exc_hf: f64::NAN,
492 hf: f64::NAN,
493 fpd_exc_hf: f64::NAN,
494 fpd_inc_hf: f64::NAN,
495 }
496 }
497}
498
499#[doc = include_str!("../docs/bibs/2.md")]
516#[doc = include_str!("../docs/bibs/3.md")]
517pub fn compute_fpd_curve_step_modified_goff_hartel_corvitto(
518 composition: Composition,
519 next_frozen_water: f64,
520 get_fpd_from_pac: &impl Fn(f64) -> Result<f64>,
521) -> Result<ModifiedGoffHartelCorvittoFpdCurveStep> {
522 let mut next = ModifiedGoffHartelCorvittoFpdCurveStep::empty();
523
524 if abs_diff_eq!(composition.get(CompKey::Water), 0.0, epsilon = COMPOSITION_EPSILON) {
525 return Ok(next);
526 }
527
528 next.frozen_water = next_frozen_water;
529 next.water = (100.0 - next.frozen_water) / 100.0 * composition.get(CompKey::Water);
530
531 next.pac_exc_hf = composition.get(CompKey::PACtotal) / next.water * 100.0;
534 next.hf = composition.get(CompKey::HF) / next.water * 100.0;
535 let pac_inc_hf = next.pac_exc_hf - next.hf;
536
537 next.fpd_exc_hf = get_fpd_from_pac(next.pac_exc_hf)?;
538 next.fpd_inc_hf = if pac_inc_hf >= 0.0 {
539 get_fpd_from_pac(pac_inc_hf)?
540 } else {
541 f64::NAN
542 };
543
544 Ok(next)
545}
546
547#[must_use]
549pub fn get_x_axis_at_fpd(curve: &[CurvePoint], target_fpd: f64) -> Option<f64> {
550 for i in 0..curve.len() - 1 {
551 let high = &curve[i];
552 let low = &curve[i + 1];
553
554 if high.temp >= target_fpd && low.temp <= target_fpd {
555 let run = target_fpd - high.temp;
556 let slope = (low.x_axis - high.x_axis) / (low.temp - high.temp);
557
558 return Some(high.x_axis + run * slope);
559 }
560 }
561
562 None
563}
564
565impl Default for FPD {
566 fn default() -> Self {
567 Self::empty()
568 }
569}
570
571impl AbsDiffEq for GoffHartelFpdCurveStep {
572 type Epsilon = f64;
573
574 fn default_epsilon() -> Self::Epsilon {
575 f64::default_epsilon()
576 }
577
578 fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
579 iter_all_abs_diff_eq::<f64, f64, Self>(self, other, epsilon)
580 }
581}
582
583impl AbsDiffEq for ModifiedGoffHartelCorvittoFpdCurveStep {
584 type Epsilon = f64;
585
586 fn default_epsilon() -> Self::Epsilon {
587 f64::default_epsilon()
588 }
589
590 fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
591 iter_all_abs_diff_eq::<f64, f64, Self>(self, other, epsilon)
592 }
593}
594
595#[cfg(test)]
596#[cfg_attr(coverage, coverage(off))]
597#[allow(clippy::unwrap_used, clippy::float_cmp)]
598mod tests {
599 use std::sync::LazyLock;
600
601 use crate::tests::asserts::shadow_asserts::assert_eq;
602 use crate::tests::asserts::*;
603
604 use super::*;
605 use crate::{
606 composition::{Carbohydrates, CompKey, Composition, Fats, PAC, Solids, SolidsBreakdown, Sugars},
607 constants::{
608 composition::{STD_LACTOSE_IN_MSNF, STD_LACTOSE_IN_WS},
609 fpd::{CORVITTO_PAC_TO_SERVING_TEMP_TABLE, FPD_CONST_FOR_MSNF_WS_SALTS},
610 pac,
611 },
612 };
613
614 fn get_fpd_from_pac_inter(pac: f64) -> Result<f64> {
615 super::get_fpd_from_pac_interpolation(pac)
616 }
617
618 fn get_fpd_from_pac_poly(pac: f64) -> Result<f64> {
619 super::get_fpd_from_pac_polynomial(pac, None)
620 }
621
622 static PAC_TO_FPD_TABLE_FLOAT: LazyLock<Vec<(f64, f64)>> = LazyLock::new(|| {
624 #[expect(clippy::cast_precision_loss)]
625 PAC_TO_FPD_TABLE
626 .iter()
627 .map(|(pac, fpd)| (*pac as f64, -*fpd))
628 .collect::<Vec<(f64, f64)>>()
629 });
630
631 const PAC_TO_FPD_TABLE_EXTENDED: [(f64, f64); 12] = [
633 (0.5, -0.03),
635 (1.0, -0.06),
636 (1.5, -0.09),
637 (2.0, -0.12),
638 (2.5, -0.15),
639 (93.5, -6.55),
640 (94.0, -6.60),
641 (94.5, -6.65),
642 (95.0, -6.70),
643 (95.5, -6.75),
644 (177.1, -13.4867),
645 (181.0, -13.7467),
646 ];
647
648 #[test]
649 fn get_fpd_from_pac_interpolation() {
650 for ref_table in [
651 PAC_TO_FPD_TABLE_FLOAT.as_slice(),
652 &PAC_TO_FPD_TABLE_EXTENDED[..],
653 &[(38.2, -2.35)],
655 ] {
656 for (pac, expected_fpd) in ref_table {
657 let fpd = get_fpd_from_pac_inter(*pac).unwrap();
658 assert_abs_diff_eq!(fpd, expected_fpd, epsilon = 0.001);
659 }
660 }
661 }
662
663 #[test]
664 fn get_fpd_from_pac_polynomial() {
665 for ref_table in [
666 PAC_TO_FPD_TABLE_FLOAT.as_slice(),
667 &PAC_TO_FPD_TABLE_EXTENDED[..],
668 &[(38.2, -2.347)],
670 ] {
671 for (pac, expected_fpd) in ref_table {
672 let fpd = get_fpd_from_pac_poly(*pac).unwrap();
673 assert_abs_diff_eq!(fpd, expected_fpd, epsilon = 0.35);
674 }
675 }
676 }
677
678 #[test]
679 fn get_fpd_from_pac_polynomial_vs_interpolation() {
680 #[expect(
681 clippy::cast_precision_loss,
682 clippy::cast_possible_truncation,
683 clippy::cast_sign_loss
684 )]
685 for pac_int in 0..=((200.0 / 0.25) as usize) {
686 let pac = pac_int as f64 * 0.25;
687
688 let epsilon = if pac <= 180.0 { 0.35 } else { 0.85 };
690
691 let fpd_poly = get_fpd_from_pac_poly(pac).unwrap();
692 let fpd_inter = get_fpd_from_pac_inter(pac).unwrap();
693
694 assert_abs_diff_eq!(fpd_poly, fpd_inter, epsilon = epsilon);
695 }
696 }
697
698 #[test]
699 fn get_fpd_from_pac_interpolation_combine_pac_vs_fpd() {
700 let whole_fpd = get_fpd_from_pac_inter(200.0).unwrap();
701 let half_fpd = get_fpd_from_pac_inter(100.0).unwrap();
702 let diff = (whole_fpd - half_fpd * 2.0).abs();
703 assert_abs_diff_eq!(diff, 0.745, epsilon = 0.01);
704 }
705
706 #[test]
707 fn get_fpd_from_pac_polynomial_combine_pac_vs_fpd() {
708 let whole_fpd = get_fpd_from_pac_poly(200.0).unwrap();
709 let half_fpd = get_fpd_from_pac_poly(100.0).unwrap();
710 let diff = (whole_fpd - half_fpd * 2.0).abs();
711
712 assert_abs_diff_eq!(diff, 1.8, epsilon = 0.01);
714 }
715
716 static PAC_TO_FPD_TABLE_POLY: LazyLock<Vec<(f64, f64)>> = LazyLock::new(|| {
719 #[expect(
720 clippy::cast_precision_loss,
721 clippy::cast_possible_truncation,
722 clippy::cast_sign_loss
723 )]
724 (0..=((200.0 / 0.25) as usize))
725 .map(|pac_int| pac_int as f64 * 0.25)
726 .map(|pac| (pac, super::get_fpd_from_pac_polynomial(pac, None).unwrap()))
727 .collect()
728 });
729
730 #[test]
731 fn get_pac_from_fpd_polynomial() {
732 for (expected_pac, fpd) in PAC_TO_FPD_TABLE_POLY.as_slice() {
733 assert_eq_flt_test!(super::get_pac_from_fpd_polynomial(*fpd, None).unwrap(), *expected_pac);
734 }
735 }
736
737 #[test]
738 fn pac_msnf_ws_salts() {
739 assert_eq_flt_test!(
740 super::get_pac_from_fpd_polynomial(FPD_CONST_FOR_MSNF_WS_SALTS, None).unwrap(),
741 pac::MSNF_WS_SALTS
742 );
743 }
744
745 #[doc = include_str!("../docs/bibs/2.md")]
749 static REF_COMP: LazyLock<Composition> = LazyLock::new(|| {
750 let (msnf, ws, sucrose, css_42de, total_solids) = (10.0, 2.0, 12.0, 4.0, 40.0);
751
752 let lactose = (msnf * STD_LACTOSE_IN_MSNF) + (ws * STD_LACTOSE_IN_WS);
753 let milk_snfs = msnf + ws - (lactose);
754 let milk_fats = total_solids - sucrose - css_42de - lactose - milk_snfs;
755
756 let milk_solids = SolidsBreakdown::new()
757 .fats(Fats::new().total(milk_fats))
758 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().lactose(lactose)))
759 .others(milk_snfs);
760
761 let other_solids = SolidsBreakdown::new()
762 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(sucrose + css_42de)));
763
764 Composition::new()
765 .solids(Solids::new().milk(milk_solids).other(other_solids))
766 .pac(
767 PAC::new()
768 .sugars(22.18)
769 .msnf_ws_salts((10.0 + 2.0) * pac::MSNF_WS_SALTS / 100.0),
770 )
771 });
772
773 static REF_COMP_WITH_ALCOHOL: LazyLock<Composition> = LazyLock::new(|| {
775 let mut ref_comp = *REF_COMP;
776 ref_comp.alcohol.by_weight = 2.0;
777 ref_comp.pac.alcohol = 14.8;
778 ref_comp
779 });
780
781 static REF_COMP_WITH_HF: LazyLock<Composition> = LazyLock::new(|| {
783 let mut ref_comp = *REF_COMP;
784 ref_comp.pac.hardness_factor = 10.0;
785 ref_comp
786 });
787
788 #[test]
789 fn validate_reference_compositions() {
790 let comp = *REF_COMP;
791 assert_eq!(comp.get(CompKey::MSNF), 12.0);
792 assert_eq!(comp.get(CompKey::TotalSolids), 40.0);
793 assert_eq!(comp.get(CompKey::Water), 60.0);
794 assert_eq!(comp.get(CompKey::PACsgr), 22.18);
795 assert_eq_flt_test!(comp.get(CompKey::PACmlk), 4.4088);
796 assert_eq_flt_test!(comp.get(CompKey::PACtotal), 26.5888);
797
798 let comp = *REF_COMP_WITH_ALCOHOL;
799 assert_eq!(comp.get(CompKey::MSNF), 12.0);
800 assert_eq!(comp.get(CompKey::Alcohol), 2.0);
801 assert_eq!(comp.get(CompKey::TotalSolids), 40.0);
802 assert_eq!(comp.get(CompKey::Water), 58.0);
803 assert_eq!(comp.get(CompKey::PACsgr), 22.18);
804 assert_eq!(comp.get(CompKey::PACalc), 14.8);
805 assert_eq_flt_test!(comp.get(CompKey::PACmlk), 4.4088);
806 assert_eq_flt_test!(comp.get(CompKey::PACtotal), 41.3888);
807
808 let comp = *REF_COMP_WITH_HF;
809 assert_eq!(comp.get(CompKey::MSNF), 12.0);
810 assert_eq!(comp.get(CompKey::TotalSolids), 40.0);
811 assert_eq!(comp.get(CompKey::Water), 60.0);
812 assert_eq!(comp.get(CompKey::PACsgr), 22.18);
813 assert_eq_flt_test!(comp.get(CompKey::PACmlk), 4.4088);
814 assert_eq!(comp.get(CompKey::HF), 10.0);
815 assert_eq_flt_test!(comp.get(CompKey::PACtotal), 26.5888);
816 assert_eq_flt_test!(comp.get(CompKey::PACtotal) - comp.get(CompKey::HF), 16.5888);
817 }
818
819 #[doc = include_str!("../docs/bibs/2.md")]
821 static REF_COMP_FREEZING_CURVE: LazyLock<Vec<GoffHartelFpdCurveStep>> = LazyLock::new(|| {
822 [
823 (0.0, 60.0, 36.97, -2.27, -0.47, -2.74),
825 (10.0, 54.0, 41.07, -2.53, -0.53, -3.06),
826 (20.0, 48.0, 46.21, -2.86, -0.59, -3.45),
827 (30.0, 42.0, 52.81, -3.33, -0.68, -4.01),
828 (40.0, 36.0, 61.61, -3.97, -0.79, -4.76),
829 (50.0, 30.0, 73.93, -4.92, -0.95, -5.87),
830 (60.0, 24.0, 92.42, -6.45, -1.18, -7.63),
831 (70.0, 18.0, 123.22, -9.21, -1.58, -10.79),
832 (75.0, 15.0, 147.87, -11.26, -1.90, -13.16),
833 (80.0, 12.0, 184.83, -14.27, -2.37, -16.61),
834 ]
835 .into_iter()
836 .map(|(fw, w, se, fpd_se, fpd_sa, fpd_t)| GoffHartelFpdCurveStep {
837 frozen_water: fw,
838 water: w,
839 se,
840 sa: 0.0,
841 alc: 0.0,
842 fpd_se,
843 fpd_sa,
844 fpd_alc: 0.0,
845 fpd_total: fpd_t,
846 })
847 .collect()
848 });
849
850 static REF_COMP_WITH_ALCOHOL_FREEZING_CURVE: LazyLock<Vec<GoffHartelFpdCurveStep>> = LazyLock::new(|| {
852 [
853 (0.0, 58.0, 38.24, 25.52, -2.47, -0.49, -1.62, -4.58),
855 (10.0, 52.2, 42.49, 28.35, -2.76, -0.54, -1.81, -5.12),
856 ]
857 .into_iter()
858 .map(|(fw, w, se, alc, fpd_se, fpd_sa, fpd_alc, fpd_t)| GoffHartelFpdCurveStep {
859 frozen_water: fw,
860 water: w,
861 se,
862 sa: 0.0,
863 alc,
864 fpd_se,
865 fpd_sa,
866 fpd_alc,
867 fpd_total: fpd_t,
868 })
869 .collect()
870 });
871
872 #[test]
873 fn compute_fpd_curve_goff_hartel_interpolation() {
874 for ref_step in REF_COMP_FREEZING_CURVE.iter() {
875 let step =
876 compute_fpd_curve_step_goff_hartel(*REF_COMP, ref_step.frozen_water, &get_fpd_from_pac_inter).unwrap();
877
878 assert_abs_diff_eq!(step, ref_step, epsilon = 0.27);
879 }
880 }
881
882 #[test]
883 fn compute_fpd_curve_goff_hartel_polynomial() {
884 for ref_step in REF_COMP_FREEZING_CURVE.iter() {
885 let step =
886 compute_fpd_curve_step_goff_hartel(*REF_COMP, ref_step.frozen_water, &get_fpd_from_pac_poly).unwrap();
887
888 assert_abs_diff_eq!(step, ref_step, epsilon = 0.31);
889 }
890 }
891
892 #[test]
893 fn compute_fpd_curve_goff_hartel_polynomial_with_alcohol() {
894 for ref_step in REF_COMP_WITH_ALCOHOL_FREEZING_CURVE.iter() {
895 let step = compute_fpd_curve_step_goff_hartel(
896 *REF_COMP_WITH_ALCOHOL,
897 ref_step.frozen_water,
898 &get_fpd_from_pac_poly,
899 )
900 .unwrap();
901
902 assert_abs_diff_eq!(step, ref_step, epsilon = 0.005);
903 }
904 }
905
906 static PAC_SALT_LOOKUP: LazyLock<Vec<(f64, f64)>> = LazyLock::new(|| {
907 vec![
908 (60.0, 7.348),
911 (58.0, 7.601),
912 (54.0, 8.164),
913 (52.2, 8.398),
914 (48.0, 9.185),
915 (42.0, 10.497),
916 (36.0, 12.247),
917 (30.0, 14.696),
918 (24.0, 18.37),
919 (18.0, 24.493),
920 (15.0, 29.392),
921 (12.0, 36.74),
922 ]
923 });
924
925 fn map_goff_hartel_to_modified_corvitto(
926 step: &GoffHartelFpdCurveStep,
927 ) -> (ModifiedGoffHartelCorvittoFpdCurveStep, f64) {
928 let pac_slt = PAC_SALT_LOOKUP
929 .iter()
930 .find(|(water, _)| *water == step.water)
931 .unwrap()
932 .1;
933
934 (
935 ModifiedGoffHartelCorvittoFpdCurveStep {
936 frozen_water: step.frozen_water,
937 water: step.water,
938 pac_exc_hf: step.se + step.alc + pac_slt,
939 hf: 0.0,
940 fpd_exc_hf: step.fpd_total,
941 fpd_inc_hf: step.fpd_total,
942 },
943 pac_slt,
944 )
945 }
946
947 static REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO: LazyLock<
949 Vec<(ModifiedGoffHartelCorvittoFpdCurveStep, f64)>,
950 > = LazyLock::new(|| {
951 REF_COMP_FREEZING_CURVE
952 .iter()
953 .map(map_goff_hartel_to_modified_corvitto)
954 .collect()
955 });
956
957 static REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO_WITH_ALCOHOL: LazyLock<
959 Vec<(ModifiedGoffHartelCorvittoFpdCurveStep, f64)>,
960 > = LazyLock::new(|| {
961 REF_COMP_WITH_ALCOHOL_FREEZING_CURVE
962 .iter()
963 .map(map_goff_hartel_to_modified_corvitto)
964 .collect()
965 });
966
967 #[test]
968 fn get_fpd_from_pac_modified_goff_hartel_corvitto_polynomial() {
969 for (ref_step, (_, pac_slt)) in REF_COMP_FREEZING_CURVE
970 .iter()
971 .zip(REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO.iter())
972 {
973 assert_abs_diff_eq!(get_fpd_from_pac_poly(*pac_slt).unwrap(), ref_step.fpd_sa, epsilon = 0.05);
974 }
975 }
976
977 #[test]
978 fn compute_fpd_curve_modified_goff_hartel_corvitto_polynomial() {
979 for (ref_step, _) in REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO.iter() {
980 let step = compute_fpd_curve_step_modified_goff_hartel_corvitto(
981 *REF_COMP,
982 ref_step.frozen_water,
983 &get_fpd_from_pac_poly,
984 )
985 .unwrap();
986
987 let epsilon = if ref_step.pac_exc_hf <= 177.0 { 0.31 } else { 1.4 };
990
991 assert_abs_diff_eq!(&step, ref_step, epsilon = epsilon);
992 }
993 }
994
995 #[test]
996 fn compute_fpd_curve_modified_goff_hartel_corvitto_polynomial_with_alcohol() {
997 for (ref_step, _) in REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO_WITH_ALCOHOL.iter() {
998 let step = compute_fpd_curve_step_modified_goff_hartel_corvitto(
999 *REF_COMP_WITH_ALCOHOL,
1000 ref_step.frozen_water,
1001 &get_fpd_from_pac_poly,
1002 )
1003 .unwrap();
1004
1005 assert_abs_diff_eq!(&step, ref_step, epsilon = 0.3);
1006 }
1007 }
1008
1009 #[test]
1010 fn compute_fpd_curve_modified_goff_hartel_corvitto_polynomial_with_hf() {
1011 let comp_pac_less_hf = {
1012 let mut comp = *REF_COMP;
1013 comp.pac.sugars -= 10.0;
1014 comp
1015 };
1016
1017 for (ref_step, _) in REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO.iter() {
1018 let step_with_hf = compute_fpd_curve_step_modified_goff_hartel_corvitto(
1019 *REF_COMP_WITH_HF,
1020 ref_step.frozen_water,
1021 &get_fpd_from_pac_poly,
1022 )
1023 .unwrap();
1024
1025 let step_pac_less_hf = compute_fpd_curve_step_modified_goff_hartel_corvitto(
1026 comp_pac_less_hf,
1027 ref_step.frozen_water,
1028 &get_fpd_from_pac_poly,
1029 )
1030 .unwrap();
1031
1032 assert_eq_flt_test!(step_with_hf.hf, (10.0 / ref_step.water) * 100.0);
1033 assert_abs_diff_eq!(step_with_hf.pac_exc_hf, ref_step.pac_exc_hf, epsilon = 0.01);
1034 assert_eq_flt_test!(step_with_hf.fpd_inc_hf, step_pac_less_hf.fpd_exc_hf);
1035 }
1036 }
1037
1038 #[test]
1039 fn compute_fpd_curve_modified_goff_hartel_corvitto_polynomial_with_neg_pac_due_to_hf() {
1040 let mut comp = *REF_COMP;
1041 assert_eq!(comp.get(CompKey::PACsgr), 22.18);
1042 assert_eq!(comp.get(CompKey::HF), 0.0);
1043 assert_eq_flt_test!(comp.get(CompKey::PACtotal) - comp.get(CompKey::HF), 26.5888);
1044
1045 comp.pac.hardness_factor = 30.0;
1046 assert_eq!(comp.get(CompKey::PACsgr), 22.18);
1047 assert_eq!(comp.get(CompKey::HF), 30.0);
1048 assert_eq_flt_test!(comp.get(CompKey::PACtotal) - comp.get(CompKey::HF), -3.4112);
1049
1050 for (ref_step, _) in REF_COMP_FREEZING_CURVE_MODIFIED_GOFF_HARTEL_CORVITTO.iter() {
1051 let step_with_hf = compute_fpd_curve_step_modified_goff_hartel_corvitto(
1052 comp,
1053 ref_step.frozen_water,
1054 &get_fpd_from_pac_poly,
1055 )
1056 .unwrap();
1057
1058 assert_eq_flt_test!(step_with_hf.hf, (30.0 / ref_step.water) * 100.0);
1059 assert_abs_diff_eq!(step_with_hf.pac_exc_hf, ref_step.pac_exc_hf, epsilon = 0.01);
1060 assert_true!(step_with_hf.fpd_inc_hf.is_nan());
1061 }
1062 }
1063
1064 #[doc = include_str!("../docs/bibs/3.md")]
1067 static CORVITTO_REF_COMP_11ST: LazyLock<Composition> = LazyLock::new(|| {
1068 let milk_solids = SolidsBreakdown::new()
1069 .fats(Fats::new().total(8.0))
1070 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().lactose(5.45)))
1071 .others_from_total(18.0)
1072 .unwrap();
1073
1074 let other_solids =
1075 SolidsBreakdown::new().carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(18.1)));
1076
1077 Composition::new()
1078 .solids(Solids::new().milk(milk_solids).other(other_solids))
1079 .pod(18.0)
1080 .pac(PAC::new().sugars(26.7))
1081 });
1082
1083 #[doc = include_str!("../docs/bibs/3.md")]
1086 static CORVITTO_REF_COMP_18ST: LazyLock<Composition> = LazyLock::new(|| {
1087 let milk_solids = SolidsBreakdown::new()
1088 .fats(Fats::new().total(8.0))
1089 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().lactose(5.45)))
1090 .others_from_total(18.0)
1091 .unwrap();
1092
1093 let other_solids =
1094 SolidsBreakdown::new().carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(21.3)));
1095
1096 Composition::new()
1097 .solids(Solids::new().milk(milk_solids).other(other_solids))
1098 .pod(18.0)
1099 .pac(PAC::new().sugars(40.9))
1100 });
1101
1102 #[doc = include_str!("../docs/bibs/3.md")]
1106 static CORVITTO_REF_COMP_WITH_HF_11ST: LazyLock<Composition> = LazyLock::new(|| {
1107 let milk_solids = SolidsBreakdown::new()
1108 .fats(Fats::new().total(6.1))
1109 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(3.4)))
1110 .others_from_total(14.1)
1111 .unwrap();
1112
1113 let egg_solids = SolidsBreakdown::new().fats(Fats::new().total(0.6)).others(0.5);
1114
1115 let cocoa_solids = SolidsBreakdown::new().fats(Fats::new().total(1.3)).others(4.7);
1116
1117 let other_solids =
1118 SolidsBreakdown::new().carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(17.0)));
1119
1120 Composition::new()
1121 .solids(
1122 Solids::new()
1123 .milk(milk_solids)
1124 .egg(egg_solids)
1125 .cocoa(cocoa_solids)
1126 .other(other_solids),
1127 )
1128 .pod(24.9)
1129 .pac(PAC::new().sugars(37.3).hardness_factor(9.7))
1130 });
1131
1132 #[doc = include_str!("../docs/bibs/3.md")]
1136 static CORVITTO_REF_COMP_WITH_HF_18ST: LazyLock<Composition> = LazyLock::new(|| {
1137 let milk_solids = SolidsBreakdown::new()
1138 .fats(Fats::new().total(6.1))
1139 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(4.1)))
1140 .others_from_total(14.1)
1141 .unwrap();
1142
1143 let egg_solids = SolidsBreakdown::new().fats(Fats::new().total(0.6)).others(0.5);
1144
1145 let cocoa_solids = SolidsBreakdown::new().fats(Fats::new().total(1.3)).others(4.7);
1146
1147 let other_solids =
1148 SolidsBreakdown::new().carbohydrates(Carbohydrates::new().sugars(Sugars::new().sucrose(22.0)));
1149
1150 Composition::new()
1151 .solids(
1152 Solids::new()
1153 .milk(milk_solids)
1154 .egg(egg_solids)
1155 .cocoa(cocoa_solids)
1156 .other(other_solids),
1157 )
1158 .pod(33.6)
1159 .pac(PAC::new().sugars(50.9).hardness_factor(9.7))
1160 });
1161
1162 #[test]
1163 fn validate_corvitto_reference_compositions() {
1164 let comp = *CORVITTO_REF_COMP_11ST;
1166 assert_eq!(comp.get(CompKey::MilkFat), 8.0);
1167 assert_eq!(comp.get(CompKey::MSNF), 10.0);
1168 assert_eq!(comp.get(CompKey::MilkSolids), 18.0);
1169 assert_eq!(comp.get(CompKey::TotalFats), 8.0);
1170 assert_eq!(comp.get(CompKey::TotalSolids), 36.1);
1171 assert_eq!(comp.get(CompKey::PACsgr), 26.7);
1172 assert_eq!(comp.get(CompKey::PACtotal), 26.7);
1173 assert_abs_diff_eq!(
1174 super::get_serving_temp_from_pac_corvitto(comp.get(CompKey::PACtotal)).unwrap(),
1175 -11.0,
1176 epsilon = 0.25
1177 );
1178
1179 let comp = *CORVITTO_REF_COMP_18ST;
1181 assert_eq!(comp.get(CompKey::MilkFat), 8.0);
1182 assert_eq!(comp.get(CompKey::MSNF), 10.0);
1183 assert_eq!(comp.get(CompKey::MilkSolids), 18.0);
1184 assert_eq!(comp.get(CompKey::TotalFats), 8.0);
1185 assert_eq!(comp.get(CompKey::TotalSolids), 39.3);
1186 assert_eq!(comp.get(CompKey::PACsgr), 40.9);
1187 assert_eq!(comp.get(CompKey::PACtotal), 40.9);
1188 assert_abs_diff_eq!(
1189 super::get_serving_temp_from_pac_corvitto(comp.get(CompKey::PACtotal)).unwrap(),
1190 -18.0,
1191 epsilon = 0.25
1192 );
1193
1194 let comp = *CORVITTO_REF_COMP_WITH_HF_11ST;
1196 assert_eq!(comp.get(CompKey::MilkFat), 6.1);
1197 assert_eq!(comp.get(CompKey::MSNF), 8.0);
1198 assert_eq!(comp.get(CompKey::MilkSolids), 14.1);
1199 assert_eq!(comp.get(CompKey::EggFat), 0.6);
1200 assert_eq_flt_test!(comp.get(CompKey::EggSNF), 0.5);
1201 assert_eq!(comp.get(CompKey::CocoaButter), 1.3);
1202 assert_eq!(comp.get(CompKey::CocoaSolids), 4.7);
1203 assert_eq_flt_test!(comp.get(CompKey::TotalFats), 8.0);
1204 assert_eq!(comp.get(CompKey::TotalSweeteners), 3.4 + 17.0);
1205 assert_eq!(comp.get(CompKey::TotalSolids), 38.2);
1206 assert_eq!(comp.get(CompKey::PACsgr), 37.3);
1207 assert_eq!(comp.get(CompKey::PACtotal), 37.3);
1208 assert_eq!(comp.get(CompKey::HF), 9.7);
1209 assert_abs_diff_eq!(
1210 super::get_serving_temp_from_pac_corvitto(comp.get(CompKey::PACtotal) - comp.get(CompKey::HF)).unwrap(),
1211 -11.0,
1212 epsilon = 0.3
1213 );
1214
1215 let comp = *CORVITTO_REF_COMP_WITH_HF_18ST;
1217 assert_eq!(comp.get(CompKey::MilkFat), 6.1);
1218 assert_eq!(comp.get(CompKey::MSNF), 8.0);
1219 assert_eq!(comp.get(CompKey::MilkSolids), 14.1);
1220 assert_eq!(comp.get(CompKey::EggFat), 0.6);
1221 assert_eq_flt_test!(comp.get(CompKey::EggSNF), 0.5);
1222 assert_eq!(comp.get(CompKey::CocoaButter), 1.3);
1223 assert_eq!(comp.get(CompKey::CocoaSolids), 4.7);
1224 assert_eq_flt_test!(comp.get(CompKey::TotalFats), 8.0);
1225 assert_eq!(comp.get(CompKey::TotalSweeteners), 4.1 + 22.0);
1226 assert_eq!(comp.get(CompKey::TotalSolids), 43.2);
1227 assert_eq!(comp.get(CompKey::PACsgr), 50.9);
1228 assert_eq!(comp.get(CompKey::PACtotal), 50.9);
1229 assert_eq!(comp.get(CompKey::HF), 9.7);
1230 assert_abs_diff_eq!(
1231 super::get_serving_temp_from_pac_corvitto(comp.get(CompKey::PACtotal) - comp.get(CompKey::HF)).unwrap(),
1232 -18.0,
1233 epsilon = 0.3
1234 );
1235 }
1236
1237 #[test]
1238 fn get_serving_temp_from_pac_corvitto() {
1239 for table in [
1240 &CORVITTO_PAC_TO_SERVING_TEMP_TABLE[..],
1241 &[(23.0, -9.0), (24.0, -9.5), (42.0, -18.5), (43.0, -19.0)],
1242 ] {
1243 for (pac, serving_temp) in table {
1244 let computed_serving_temp = super::get_serving_temp_from_pac_corvitto(*pac).unwrap();
1245 assert_eq_flt_test!(computed_serving_temp, *serving_temp);
1246 }
1247 }
1248
1249 let pac = CORVITTO_REF_COMP_11ST.pac.sugars;
1250 let computed_serving_temp = super::get_serving_temp_from_pac_corvitto(pac).unwrap();
1251 assert_abs_diff_eq!(computed_serving_temp, -11.0, epsilon = 0.2);
1252
1253 let pac = CORVITTO_REF_COMP_18ST.pac.sugars;
1254 let computed_serving_temp = super::get_serving_temp_from_pac_corvitto(pac).unwrap();
1255 assert_abs_diff_eq!(computed_serving_temp, -18.0, epsilon = 0.2);
1256 }
1257
1258 #[test]
1259 fn corvitto_pac_to_serving_temp_vs_goff_hartel_fpd_at_70_frozen_water() {
1260 for (pac, expected_serving_temp) in CORVITTO_PAC_TO_SERVING_TEMP_TABLE {
1261 let mut comp =
1262 Composition::from_combination(&[(*CORVITTO_REF_COMP_11ST, 50.0), (*CORVITTO_REF_COMP_18ST, 50.0)])
1263 .unwrap();
1264 comp.pac.sugars = pac;
1265
1266 let fpd_curve_step_at_xx_fw =
1267 compute_fpd_curve_step_goff_hartel(comp, 68.25, &get_fpd_from_pac_poly).unwrap();
1268 assert_abs_diff_eq!(fpd_curve_step_at_xx_fw.fpd_total, expected_serving_temp, epsilon = 0.4);
1269 }
1270 }
1271
1272 #[test]
1273 fn corvitto_pac_to_serving_temp_vs_modified_goff_hartel_corvitto_fpd_at_70_frozen_water() {
1274 for (pac, expected_serving_temp) in CORVITTO_PAC_TO_SERVING_TEMP_TABLE {
1275 let mut comp =
1276 Composition::from_combination(&[(*CORVITTO_REF_COMP_11ST, 50.0), (*CORVITTO_REF_COMP_18ST, 50.0)])
1277 .unwrap();
1278 comp.pac.sugars = pac;
1279
1280 let fpd_curve_step_at_xx_fw =
1281 compute_fpd_curve_step_modified_goff_hartel_corvitto(comp, 70.0, &get_fpd_from_pac_poly).unwrap();
1282 assert_abs_diff_eq!(fpd_curve_step_at_xx_fw.fpd_exc_hf, expected_serving_temp, epsilon = 0.4);
1283 }
1284 }
1285
1286 #[test]
1287 fn compute_fpd_curve_modified_goff_hartel_corvitto_polynomial_corvitto_ref() {
1288 for ref_comp in &[
1289 *CORVITTO_REF_COMP_11ST,
1290 *CORVITTO_REF_COMP_18ST,
1291 *CORVITTO_REF_COMP_WITH_HF_11ST,
1292 *CORVITTO_REF_COMP_WITH_HF_18ST,
1293 ] {
1294 let expected_serving_temp =
1295 super::get_serving_temp_from_pac_corvitto(ref_comp.get(CompKey::PACtotal) - ref_comp.get(CompKey::HF))
1296 .unwrap();
1297 let fpd_curve_step_at_xx_fw =
1298 compute_fpd_curve_step_modified_goff_hartel_corvitto(*ref_comp, 70.0, &get_fpd_from_pac_poly).unwrap();
1299
1300 let is_hf_18st = ref_comp.get(CompKey::HF) > 0.0 && expected_serving_temp < -12.0;
1302 let epsilon = if is_hf_18st { 2.0 } else { 0.6 };
1303
1304 assert_abs_diff_eq!(fpd_curve_step_at_xx_fw.fpd_inc_hf, expected_serving_temp, epsilon = epsilon);
1305 }
1306 }
1307
1308 #[test]
1309 fn get_x_axis_at_fpd() {
1310 let curve = &REF_COMP_FREEZING_CURVE
1311 .iter()
1312 .map(|step| CurvePoint::new(step.frozen_water, step.fpd_total))
1313 .collect::<Vec<CurvePoint>>();
1314
1315 for point in curve {
1316 let x_axis = super::get_x_axis_at_fpd(curve, point.temp).unwrap();
1317 assert_eq_flt_test!(x_axis, point.x_axis);
1318 }
1319
1320 for fpd in [-1.0, 85.0] {
1321 assert_true!(super::get_x_axis_at_fpd(curve, fpd).is_none());
1322 }
1323
1324 for (expected_x_axis, target_fpd) in &[(5.0, -2.9), (7.5, -2.98), (77.5, -14.885)] {
1325 let x_axis = super::get_x_axis_at_fpd(curve, *target_fpd).unwrap();
1326 assert_eq_flt_test!(x_axis, *expected_x_axis);
1327 }
1328 }
1329
1330 #[test]
1331 fn fpd_compute_from_empty_composition() {
1332 let validate_empty_fpd = |fpd: &FPD| {
1333 assert_eq!(fpd.fpd, 0.0);
1334 assert_eq!(fpd.serving_temp, 0.0);
1335 assert_true!(fpd.hardness_at_14c.is_nan());
1336
1337 for x_axis in 0..100 {
1338 #[expect(clippy::cast_precision_loss)]
1339 let x_axis_f = x_axis as f64;
1340
1341 for curve in [&fpd.curves.frozen_water, &fpd.curves.hardness] {
1342 let curve_point = &curve[x_axis];
1343 assert_eq!(curve_point.x_axis, x_axis_f);
1344 assert_eq!(curve_point.temp, 0.0);
1345 }
1346 }
1347 };
1348
1349 let comp = Composition::new();
1350 assert_eq!(comp.get(CompKey::Water), 100.0);
1351 assert_eq!(comp.get(CompKey::TotalSolids), 0.0);
1352
1353 validate_empty_fpd(&FPD::empty());
1354 validate_empty_fpd(&FPD::compute_from_composition(comp).unwrap());
1355 }
1356}