Skip to main content

sci_cream/
fpd.rs

1//! [Freezing Point Depression (FPD)](crate::docs#freezing-point-depression) properties and
2//! associated calculations.
3//!
4//! This module contains structs to represent FPD properties of ice cream mixes, including [FPD
5//! curves](crate::docs#freezing-point-depression-curve), as well as functionality to calculate
6//! these using various methods from literature, notably the Goff & Hartel method (2013, p. 181)[^2]
7//! and a modified version incorporating Corvitto's (2005, p. 243)[^3] approach for handling
8//! hardness factors from cocoa and nuts.
9#![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/// Keys for accessing specific composition values from an [`FPD`] via [`FPD::get()`]
38///
39/// This exists largely to mirror how [`CompKey`] and [`Composition::get()`] work, which is helpful
40/// in downstream applications, e.g. to have a single flattened list of keys for properties; see
41/// [`PropKey`](properties::PropKey) and [`MixProperties::get()`](properties::MixProperties::get).
42#[cfg_attr(feature = "wasm", wasm_bindgen)]
43#[derive(EnumIter, Hash, PartialEq, Eq, Serialize, Deserialize, Copy, Clone, Debug)]
44pub enum FpdKey {
45    /// [Freezing Point Depression (FPD)](crate::docs#freezing-point-depression) in °C
46    ///
47    /// This denotes the temperature at which a mix begins to freeze, which is typically depressed
48    /// to a temperature lower than 0°C, that at which pure water freezes. This value is the first
49    /// point on the [frozen water curve](crate::docs#freezing-point-depression-curve) at which the
50    /// x-axis is 0% frozen water; see [`CurvePoint`] and [`Curves::frozen_water`].
51    FPD,
52    /// Temperature at which the mix reaches a desired serving hardness
53    ///
54    /// This is the y-value (temperature) intersection of the [hardness](Curves::hardness) [FPD
55    /// curve](crate::docs#freezing-point-depression-curve) in [`Curves`] at a specific hardness
56    /// value, typically 70-75% - defined by [`SERVING_TEMP_X_AXIS`] in current calculations.
57    ServingTemp,
58    /// Hardness of the mix at -14°C, a typical target serving temperature for ice cream
59    ///
60    /// This is the x-value (hardness) intersection of the [hardness](Curves::hardness) [FPD
61    /// curve](crate::docs#freezing-point-depression-curve) at the specific y-value (temperature)
62    /// of -14°C, a typical target serving temperature for ice cream.
63    HardnessAt14C,
64}
65
66/// [Freezing Point Depression (FPD)](crate::docs#freezing-point-depression) properties...
67#[cfg_attr(feature = "wasm", wasm_bindgen)]
68#[derive(Clone, Debug)]
69pub struct FPD {
70    /// [FPD](crate::docs#freezing-point-depression) in °C, maps to [`FpdKey::FPD`]
71    pub fpd: f64,
72    /// Serving temperature in °C, maps to [`FpdKey::ServingTemp`]
73    pub serving_temp: f64,
74    /// Hardness at -14°C, maps to [`FpdKey::HardnessAt14C`]
75    pub hardness_at_14c: f64,
76    /// [FPD curves](crate::docs#freezing-point-depression-curve) for the mix
77    #[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))]
78    pub curves: Curves,
79}
80
81impl FPD {
82    /// Create an empty FPD properties, which is equivalent to the properties of a 100% water
83    #[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    /// Compute FPD properties from a given mix composition.
94    ///
95    /// A [`Composition::empty()`] is equivalent to a 100% water composition, and will result in an
96    /// [`FPD`](Self::fpd) of 0°C, [serving temperature](Self::serving_temp) of 0°C, [hardness at
97    /// -14°C](Self::hardness_at_14c) of [`f64::NAN`], and straight [curves](Self::curves) at 0°C.
98    ///
99    /// # Errors
100    ///
101    /// Forwards any errors that may arise from [`compute_fpd_curves`].
102    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    /// Access specific FPD property values via an [`FpdKey`]
125    #[allow(clippy::missing_const_for_fn)] // wasm_bindgen does not support const
126    #[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/// A point on an [FPD curve](crate::docs#freezing-point-depression-curve), representing the
137/// relationship between temperature (y-axis) and frozen water percentage or hardness (x-axis)
138#[cfg_attr(feature = "wasm", wasm_bindgen)]
139#[derive(Copy, Clone, Debug)]
140pub struct CurvePoint {
141    /// The x-axis value, representing either frozen water percentage or hardness
142    ///
143    /// This value ranges from 0 to 100, representing the percentage of the total water in the mix
144    /// that is frozen, or the "hardness" in [`Curves::hardness`], at a given temperature.
145    pub x_axis: f64,
146    /// The y-axis value, representing temperature in °C, less than or equal to 0°C
147    pub temp: f64,
148}
149
150impl CurvePoint {
151    /// Create a new [`CurvePoint`] with the given x-axis and temperature values
152    #[must_use]
153    pub const fn new(x_axis: f64, temp: f64) -> Self {
154        Self { x_axis, temp }
155    }
156}
157
158/// [Freezing Point Depression Curves](crate::docs#freezing-point-depression-curve) for a mix
159#[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    /// Represents the relationship between frozen water percentage and temperature
165    ///
166    /// That is, the percentage of the total water in the mix that is frozen at a given temperature.
167    #[cfg_attr(feature = "wasm", wasm_bindgen(getter_with_clone))]
168    pub frozen_water: Vec<CurvePoint>,
169    /// Represents the relationship between "hardness" and temperature
170    ///
171    /// "Hardness" attempts to quantity the perceived firmness of the mix at various temperatures.
172    /// If using [`FpdCurvesMethod::GoffHartel`], this curve is equivalent to the `frozen_water`
173    /// curve, frozen water percentage being a proxy for hardness. If using
174    /// [`FpdCurvesMethod::ModifiedGoffHartelCorvitto`], this curves also incorporates the effects
175    /// of hardness factors (e.g., from cocoa or nut ingredients) as per Corvitto.
176    #[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    /// Create empty FPD curves, which are straight lines at 0°C, equivalent to those of 100% water
184    #[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/// Methods for calculating FPD from [PAC](crate::docs#pac-afp-fpdf-se)
196#[derive(Copy, Clone, Debug)]
197pub enum PacToFpdMethod {
198    /// FPD from PAC via interpolation of [`PAC_TO_FPD_TABLE`]
199    Interpolation,
200    /// FPD from PAC via polynomial equation with coefficients [`PAC_TO_FPD_POLY_COEFFS`]
201    Polynomial,
202}
203
204/// Methods for calculating [FPD curves](crate::docs#freezing-point-depression-curve)
205#[derive(Copy, Clone, Debug)]
206pub enum FpdCurvesMethod {
207    /// Goff & Hartel method (2013, p. 181)[^2]
208    ///
209    /// See [`compute_fpd_curve_step_goff_hartel`].
210    #[doc = include_str!("../docs/bibs/2.md")]
211    GoffHartel,
212    /// Modified Goff & Hartel method (2013, p. 181)[^2] incorporating Corvitto (2005, p. 243)[^3]
213    ///
214    /// See [`compute_fpd_curve_step_modified_goff_hartel_corvitto`].
215    #[doc = include_str!("../docs/bibs/2.md")]
216    #[doc = include_str!("../docs/bibs/3.md")]
217    ModifiedGoffHartelCorvitto,
218}
219
220/// Compute FPD curves for a given mix composition using specified methods
221///
222/// # Errors
223///
224/// Forwards any errors that may arise from the underlying functions called based on the specified
225/// `pac_to_fpd_method` and `curves_method` arguments. See [`get_fpd_from_pac_interpolation`],
226/// [`get_fpd_from_pac_polynomial`], [`compute_fpd_curve_step_goff_hartel`], and
227/// [`compute_fpd_curve_step_modified_goff_hartel_corvitto`] for more details on potential errors.
228pub 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
264/// Compute FPD from PAC via interpolation of [`PAC_TO_FPD_TABLE`]
265///
266/// # Errors
267///
268/// Returns an [`Error::NegativePacValue`] if the provided PAC value is negative.
269pub 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
304/// Compute FPD from PAC using a polynomial equation with given coefficients
305///
306/// The coefficients are in the form `[a, b, c]` for the polynomial equation `a*x^2 + b*x + c`.
307/// They are an argument for flexibility, but are likely to always be [`PAC_TO_FPD_POLY_COEFFS`].
308///
309/// <div class='warning'>
310/// Summing multiple PAC values and then computing FPD with this function can yield significantly
311/// different results than computing FPD for each PAC value separately and then summing the FPDs,
312/// particularly at higher PAC values. Summing the PAC values first is the recommended approach.
313/// </div>
314///
315/// # Errors
316///
317/// Returns an [`Error::NegativePacValue`] if the provided PAC value is negative.
318pub 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
328/// Compute PAC from FPD using a polynomial equation with given coefficients
329///
330/// The coefficients are in the form `[a, b, c]` for the polynomial equation `a*x^2 + b*x + c`.
331/// They are an argument for flexibility, but are likely to always be [`PAC_TO_FPD_POLY_COEFFS`].
332///
333/// This function is the inverse of [`get_fpd_from_pac_polynomial`].
334///
335/// # Errors
336///
337/// Returns an [`Error::PositiveFpdValue`] if the provided FPD value is positive, as FPD cannot be
338/// positive. It may also return an [`Error::CannotComputePAC`] if the discriminant of the
339/// polynomial equation is negative (i.e., no real roots exist), or if both roots are negative
340/// (i.e., PAC cannot be negative) or both roots are positive (i.e., ambiguous PAC value).
341pub 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
369/// Compute serving temperature from PAC using [`CORVITTO_PAC_TO_SERVING_TEMP_TABLE`]
370///
371/// # Errors
372///
373/// Returns an [`Error::NegativePacValue`] if the provided PAC value is negative.
374pub 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/// A step in an FPD curve using the Goff & Hartel method
391///
392/// Maps to [`FpdCurvesMethod::GoffHartel`] and [`compute_fpd_curve_step_goff_hartel`].
393#[doc = include_str!("../docs/bibs/2.md")]
394#[derive(Iterable, PartialEq, Copy, Clone, Debug)]
395pub struct GoffHartelFpdCurveStep {
396    /// Percentage of total water in mix that's frozen at this step
397    pub frozen_water: f64,
398    /// g/100g of mix that is still liquid water at this step
399    pub water: f64,
400    /// Sucrose equivalent concentration [`PACsgr`](CompKey::PACsgr) at this step, g/100g water
401    pub se: f64,
402    /// Sucrose equivalent salt concentration [`PACslt`](CompKey::PACslt) at this step, g/100g water
403    pub sa: f64,
404    /// Sucrose eq. alcohol concentration [`PACalc`](CompKey::PACalc) at this step, g/100g water
405    pub alc: f64,
406    /// FPD due to sucrose equivalent concentration at this step, °C
407    pub fpd_se: f64,
408    /// FPD due to salt concentration (from `sa` and MSNF/WS salts) at this step, °C
409    pub fpd_sa: f64,
410    /// FPD due to alcohol concentration at this step, °C
411    pub fpd_alc: f64,
412    /// Total FPD at this step, °C
413    pub fpd_total: f64,
414}
415
416impl GoffHartelFpdCurveStep {
417    /// Create an empty Goff-Hartel FPD curve step
418    #[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/// Compute a single step in the FPD curve using the Goff & Hartel method (2013, p. 181)[^2]
435///
436/// # Errors
437///
438/// Forwards any errors that may arise from the provided `get_fpd_from_pac` function.
439#[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/// A step in an FPD curve using a modified Goff & Hartel method incorporating Corvitto's
464///
465/// Maps to [`FpdCurvesMethod::ModifiedGoffHartelCorvitto`] and
466/// [`compute_fpd_curve_step_modified_goff_hartel_corvitto`].
467#[derive(Iterable, PartialEq, Copy, Clone, Debug)]
468pub struct ModifiedGoffHartelCorvittoFpdCurveStep {
469    /// Percentage of total water in mix that's frozen at this step
470    pub frozen_water: f64,
471    /// g/100g of mix that is still liquid water at this step
472    pub water: f64,
473    /// This includes PAC from salts in MSNF and WS, calculated with [`pac::MSNF_WS_SALTS`], and so
474    /// [`FPD_CONST_FOR_MSNF_WS_SALTS`] is not applied separately as in the Goff & Hartel method.
475    pub pac_exc_hf: f64,
476    /// HF/water contribution to PAC at this step
477    pub hf: f64,
478    /// FPD due to PAC excluding hardness factor at this step, °C
479    pub fpd_exc_hf: f64,
480    /// FPD due to PAC and hardness factor at this step, °C
481    pub fpd_inc_hf: f64,
482}
483
484impl ModifiedGoffHartelCorvittoFpdCurveStep {
485    /// Create an empty Modified Goff-Hartel-Corvitto FPD curve step
486    #[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/// Compute a single step in the FPD curve using a modified Goff & Hartel & Corvitto method
500///
501/// This function implements a modified version of the Goff & Hartel method (2013, p. 181)[^2]
502/// implemented in [`compute_fpd_curve_step_goff_hartel`], with the difference that the
503/// contributions from salts in MSNF and WS are included in the PAC values, which are summed before
504/// computing FPD. This can yield significantly different results than computing FPD for each PAC
505/// value separately and then summing the FPDs, particularly at higher PAC values. I theorize that
506/// this method is more accurate, but it needs further validation.
507///
508/// The Corvitto method (2005, p. 243)[^3] for calculating hardness with cocoa and nut ingredients
509/// is also integrated here, subtracting the hardness factor from the total PAC before computing
510/// a separate FPD including hardness factor.
511///
512/// # Errors
513///
514/// Forwards any errors that may arise from the provided `get_fpd_from_pac` function.
515#[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    // It's important to sum the PAC values before computing FPD, rather than computing FPD for
532    // each PAC value separately and summing the FPDs. See [`get_fpd_from_pac_polynomial`]'s docs.
533    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/// Get the x-axis (frozen water or hardness) value at a given FPD (temperature) from an FPD curve
548#[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    /// Using [`PAC_TO_FPD_TABLE`] as f64 for testing `get_fpd_from_pac_*` functions
623    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    /// Extend [`PAC_TO_FPD_TABLE_FLOAT`] with more granular values for testing
632    const PAC_TO_FPD_TABLE_EXTENDED: [(f64, f64); 12] = [
633        // (pac, expected_fpd)
634        (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            // Outliers that differ between interpolation and polynomial methods
654            &[(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            // Outliers that differ between interpolation and polynomial methods
669            &[(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            // Polynomial vs interpolation start to diverge a lot after PAC ~180
689            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        // Significant divergence at higher PAC values, see docs for [`get_fpd_from_pac_polynomial`]
713        assert_abs_diff_eq!(diff, 1.8, epsilon = 0.01);
714    }
715
716    /// [`get_fpd_from_pac_polynomial`] above verifies the sanity of [`get_fpd_from_pac_polynomial`]
717    /// With that verified, we can generate a reference table for testing other related functions.
718    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    /// Ref. composition for [`REF_FROZEN_WATER_FPD`] (Goff & Hartel, 2013, Table 6.2, p. 184)[^2]
746    ///
747    /// _10% MSNF, 2% whey solids, 12% sucrose, 4% 42DE CSS, 60% water (40% total solids)_
748    #[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    /// Same as [`REF_COMP`], but with alcohol added
774    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    /// Same as [`REF_COMP`], but with hardness factor added
782    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    /// Reference freezing curve for [`REF_COMP`] (Goff & Hartel, 2013, Table 6.2, p. 184)[^2]
820    #[doc = include_str!("../docs/bibs/2.md")]
821    static REF_COMP_FREEZING_CURVE: LazyLock<Vec<GoffHartelFpdCurveStep>> = LazyLock::new(|| {
822        [
823            // (fw, w,    se,  fpd_se, fpd_sa, fpd_t)
824            (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    /// Based on [`REF_COMP_FREEZING_CURVE`] but using [`REF_COMP_WITH_ALCOHOL`]
851    static REF_COMP_WITH_ALCOHOL_FREEZING_CURVE: LazyLock<Vec<GoffHartelFpdCurveStep>> = LazyLock::new(|| {
852        [
853            // (fw, w,    se,  alc, fpd_se, fpd_sa, fpd_alc, fpd_t)
854            (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            // pac.salt == 4.4088
909            // (water, pac_slt)
910            (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    /// The same as [`REF_COMP_FREEZING_CURVE`], but for with a `pac_slt` component added
948    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    /// The same as [`REF_COMP_WITH_ALCOHOL_FREEZING_CURVE`], but for with a `pac_slt` added
958    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            // This divergence at higher PAC values is expected due to the different way of summing
988            // PAC before calculating FPD, as explained in the docs [`get_fpd_from_pac_polynomial`]
989            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    /// Reference composition for Corvitto FPD tests (Corvitto, 2005, p. 150)[^3]
1065    /// _Fat 8%, POD 18, MSNF 10%, Total Solids 36.1%, PAC 26.7, Serving Temperature -11°C_
1066    #[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    /// Reference composition for Corvitto FPD tests (Corvitto, 2005, p. 151)[^3]
1084    /// _Fat 8%, POD 18, MSNF 10%, Total Solids 39.3%, PAC 40.9, Serving Temperature -18°C_
1085    #[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    /// Reference composition for Corvitto FPD with HF tests (Corvitto, 2005, p. 251)[^3]
1103    /// _Fat 8%, POD 24.5, MSNF 8%, Cocoa SNF: 4.7%, Total Solids 38.2%, PAC 37.3,
1104    /// Hardness Factor: 9.7, Serving Temperature -11°C_
1105    #[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    /// Reference composition for Corvitto FPD with HF tests (Corvitto, 2005, p. 252)[^3]
1133    /// _Fat 8%, POD 33.6, MSNF 8%, Cocoa SNF: 4.7%, Total Solids 43.2%, PAC 50.9,
1134    /// Hardness Factor: 9.7, Serving Temperature -18°C_
1135    #[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        // Fat 8%, POD 18, MSNF 10%, Total Solids 36.1%, PAC 26.7
1165        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        // Fat 8%, POD 18, MSNF 10%, Total Solids 39.3%, PAC 40.9
1180        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        // Fat 8%, POD 24.5, MSNF 8%, Cocoa SNF: 4.7%, Total Solids 38.2%, PAC 37.3, Hardness Factor: 9.7
1195        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        // Fat 8%, POD 33.6, MSNF 8%, Cocoa SNF: 4.7%, Total Solids 43.2%, PAC 50.9, Hardness Factor: 9.7
1216        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            // @todo The composition with HF and -18C expected serving temp shows a large deviation.
1301            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}