sci-cream 0.0.2

Library that facilitates the mathematical analysis of ice cream mixes and their properties.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
//! Top-level [`Composition`] struct and related types and traits, representing the composition of
//! an ingredient or mix, and providing key methods for accessing specific components/properties.

use approx::AbsDiffEq;
use serde::{Deserialize, Serialize};
use struct_iterable::Iterable;
use strum_macros::EnumIter;

use crate::{
    composition::{Alcohol, Micro, PAC, Solids},
    error::Result,
    validate::assert_are_positive,
};

#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;

#[cfg(doc)]
use crate::{
    composition::{ArtificialSweeteners, Carbohydrates, Polyols, Sugars},
    error::Error,
    specs::ChocolateSpec,
};

/// Trait for converting various types, e.g. [`specs`](crate::specs), into a [`Composition`]
pub trait IntoComposition {
    /// Converts `self` into a [`Composition`], which may involve complex calculations
    #[allow(clippy::missing_errors_doc)] // Specifics depend on the implementation
    fn into_composition(self) -> Result<Composition>;
}

/// Trait for scaling and adding [`Composition`]s and their constituent types
///
/// This is used for calculating the composition of mixes from a weighted combination of its
/// ingredients. It should be implemented by all types that are part of the composition, including
/// [`Composition`] itself, and all constituent types such as [`Solids`], [`Micro`], etc.
pub trait ScaleComponents {
    /// Scales `self` by a factor, typically `ing_qty/mix_total_qty` when calculating the
    /// contribution of ingredients to a mix composition.
    ///
    /// **Note**: Composition values are typically expressed as grams per 100g, where the rest is
    /// assumed to be water. A such, scaling a composition by a factor `f` actually changes the
    /// relative proportions of components and therefore the nature of the composition. After
    /// scaling, a composition represents `f` grams of ingredient per 100g of mix, and only becomes
    /// a valid composition when combined with other scaled compositions of other ingredients, which
    /// together sum to 100g of ingredient per 100g of mix. This is a necessary step in calculating
    /// the composition of mixes from their ingredients, but should not be used for other purposes.
    /// See [`add`](Self::add) for [`Composition::from_combination`] for more details about this.
    #[must_use]
    fn scale(&self, factor: f64) -> Self;

    /// Adds `self` and `other`, typically scaled compositions of ingredients, when calculating the
    /// contributions of ingredients to a mix composition.
    ///
    /// **Note**: This is a simple addition of composition values, and does not take into account
    /// the relative proportions of ingredients in a mix. As such, it should only be used for adding
    /// scaled compositions of ingredients, which together sum to 100g of ingredient per 100g of
    /// mix, to calculate the composition of mixes. See [`scale`](Self::scale) and
    /// [`Composition::from_combination`] for more details about and implementations of this.
    #[must_use]
    fn add(&self, other: &Self) -> Self;
}

/// Composition of an ingredient or mix, mostly as grams of component per 100g of ingredient/mix
///
/// [`Composition`] is the top-level struct representing the composition of an ingredient or mix,
/// holding several other constituent types, and is used as the basis for all composition-related
/// calculations and analyses.
///
/// In a hypothetical 100g of ingredient/mix: `solids.total() + alcohol + water() == 100`.
///
/// [`micro`](Self::micro) components are accounted for in `solids`, and should not be
/// double-counted. They are  provided separately to facilitate the analysis of key components.
///
/// POD and [PAC](crate::docs#pac-afp-fpdf-se) are expressed as a sucrose equivalence and do not
/// necessarily represent real weights of components. While some underlying components may have
/// utilities to calculate their contributions to POD and PAC, the overall POD and PAC of a
/// composition are independent values and are set during composition construction, taking all
/// underlying contributions into account.
#[cfg_attr(feature = "wasm", wasm_bindgen)]
#[derive(Iterable, PartialEq, Serialize, Deserialize, Copy, Clone, Debug)]
pub struct Composition {
    /// Total energy content in kilocalories (kcal) per 100g of ingredient/mix
    pub energy: f64,
    /// Detailed breakdown of solid components, as grams of component per 100g of ingredient/mix
    pub solids: Solids,
    /// Tracking of micronutrients and other micro components and properties
    ///
    /// These are accounted for in `solids`; they are provided separately to facilitate the tracking
    /// and analysis of key components and properties, e.g. stabilizers, emulsifiers, salt, etc.
    pub micro: Micro,
    /// Alcohol content, as grams of alcohol per 100g of ingredient/mix, with ABV conversions
    pub alcohol: Alcohol,
    /// [Potere Dolcificante (POD)](crate::docs#pod), expressed as a sucrose equivalence
    pub pod: f64,
    /// [Potere Anti-Cristallizzante (PAC)](crate::docs#pac), expressed as a sucrose equivalence
    pub pac: PAC,
}

/// Keys for accessing specific composition values from a [`Composition`] via [`Composition::get()`]
///
/// [`Composition`] is a complex struct with several levels of nesting, and may continuously evolve
/// to include more components, which makes direct field access cumbersome and error-prone. This
/// enum provides a more stable and convenient interface for accessing specific composition values.
#[cfg_attr(feature = "wasm", wasm_bindgen)]
#[derive(EnumIter, Hash, PartialEq, Eq, Serialize, Deserialize, Copy, Clone, Debug)]
pub enum CompKey {
    /// Total energy content in kilocalories (kcal) per 100g of ingredient/mix
    Energy,

    /// Milk Fats, the fat content of dairy ingredients, e.g. 2% in 2% milk, etc.
    ///
    /// This is a key component of ice cream mixes, contributing to creaminess and mouthfeel.
    MilkFat,
    /// Milk Solids Non-Fat (MSNF), the non-fat solid content of dairy ingredients
    ///
    /// This includes lactose, proteins, and minerals. It is a key component in the analysis of ice
    /// cream mixes, contributing to freezing point depression, body, and texture.
    MSNF,
    /// Milk Solids Non-Fat Non-Sugar (SNFS), the non-fat non-lactose content of dairy ingredients
    ///
    /// This includes proteins and minerals; and is equivalent to [`CompKey::MSNF`] minus lactose.
    MilkSNFS,
    /// Protein content from milk ingredients, i.e. casein and whey proteins
    MilkProteins,
    /// Total solids from milk ingredients
    ///
    /// Ths includes fats, lactose, proteins, and minerals. It is the sum of both
    /// [`CompKey::MilkFat`] and [`CompKey::MSNF`].
    MilkSolids,

    /// Cocoa butter, the fat component extracted from cacao solids [`CompKey::CacaoSolids`].
    ///
    /// Sometimes referred to as "cocoa fat"; see [`ChocolateSpec`] for more details. This component
    /// affects the texture of ice creams by hardening the frozen product, and contributes to the
    /// _"perception of richness in chocolate ice creams... [due to] the lubricating effect that
    /// cocoa butter provides as it melts in the mouth."_ (Goff & Hartel, 2025, p. 107)[^20]
    #[doc = include_str!("../../docs/bibs/20.md")]
    CocoaButter,
    /// _Cocoa_ solids, the non-fat component of cacao solids [`CompKey::CacaoSolids`]
    ///
    /// Sometimes referred to as "cocoa powder" or "cocoa fiber", i.e. cacao solids minus cocoa
    /// butter; see [`ChocolateSpec`] for more details. In ice cream mixes, this generally
    /// determines the intensity of the chocolate flavor, and contributes to the texture and body.
    CocoaSolids,
    /// _Cacao_ solids, the total dry matter content derived from the cacao bean
    ///
    /// Sometimes referred to as "chocolate liquor", "cocoa mass", etc., it includes both cocoa
    /// butter (fat) [`CompKey::CocoaButter`] and cocoa solids (non-fat solids)
    /// [`CompKey::CocoaSolids`]. See [`ChocolateSpec`] for more details.
    ///
    /// **Note**: This does not include any sugar content that may be present in chocolate
    /// ingredients; that is accounted for separately via [`CompKey::TotalSugars`].
    CacaoSolids,

    /// Nut Fats, the fat content of nut ingredients
    ///
    /// It is roughly equivalent to [`CompKey::CocoaButter`] if cacao were treated as a nut.
    /// This component affects the texture of ice creams by hardening the frozen product.
    NutFat,
    /// Nut Solids Non-Fat Non-Sugar (SNFS), the non-fat, non-sugar solid content of nuts
    ///
    /// This generally includes proteins, fibers, and minerals. It is roughly equivalent to
    /// [`CompKey::CocoaSolids`] if cacao were treated as a nut. For nut flavored ice cream recipes,
    /// this value directly correlates with the perceived intensity of the nut flavor.
    ///
    /// **Note**: This does not include any sugar content that may be present in nut ingredients;
    /// that is accounted for separately via [`CompKey::TotalSugars`]. As such, it would be
    /// equivalent to a hypothetical `NutSNFS` (Nut Solids Non-Fat Non-Sugar).
    NutSNF,
    /// Nut Solids, the total solid content of nut ingredients
    ///
    /// This generally includes fats, proteins, fibers, and minerals. It includes both
    /// [`CompKey::NutFat`] and [`CompKey::NutSNF`], and is roughly equivalent to
    /// [`CompKey::CacaoSolids`] if cacao were treated as a nut.
    ///
    /// **Note**: This does not include any sugar content that may be present in nut ingredients;
    /// that is accounted for separately via [`CompKey::TotalSugars`].
    NutSolids,

    /// Egg Fats, the fat content of egg ingredients
    EggFat,
    /// Egg Solids Non-Fat (SNF), the non-fat solid content of egg ingredients
    ///
    /// **Note**: This does not include any sugar content that may be present in egg ingredients;
    /// that is accounted for separately via [`CompKey::TotalSugars`]. As such, it would be
    /// equivalent to a hypothetical `EggSNFS` (Egg Solids Non-Fat Non-Sugar).
    EggSNF,
    /// Egg Solids, the total solid content of egg ingredients
    ///
    /// **Note**: This does not include any sugar content that may be present in egg ingredients;
    /// that is accounted for separately via [`CompKey::TotalSugars`].
    EggSolids,

    /// Other Fats, the fat content of other ingredients not milk, cocoa, nut, or egg
    OtherFats,
    /// Other Solids Non-Fat Non-Sugar (SNFS), the non-fat, non-sugar solid content of other
    /// ingredients not milk, cocoa, nut, or egg
    OtherSNFS,

    /// Total Fats, sum of all fat components
    ///
    /// This is a key component of ice cream mixes, contributing to creaminess and mouthfeel.
    TotalFats,
    /// Total Solids Non-Fat, sum of all non-fat solid components
    TotalSNF,
    /// Total Solids Non-Fat Non-Sugar (SNFS), sum of all non-fat, non-sugar solid components
    ///
    /// This is a key component in the analysis of ice cream mixes, contributing to freezing point
    /// depression, body, and texture. If ice creams feel "cakey" or "spongy", this value is often
    /// a key indicator of the cause, being too high or too low, respectively.
    TotalSNFS,
    /// Total Proteins, sum of all protein components
    TotalProteins,
    /// Total Solids, sum of all solid components
    TotalSolids,

    /// Water content, `100 - TotalSolids - Alcohol.by_weight`
    Water,

    // Carbohydrates and Artificial Sweeteners
    // ---------------------------------------
    //
    /// Total fiber content, as tracked in [`Carbohydrates::fiber`]
    Fiber,
    /// Total free glucose content, i.e. glucose not part of disaccharides or polysaccharides
    Glucose,
    /// Total free fructose content, i.e. fructose not part of disaccharides or polysaccharides
    Fructose,
    /// Total free galactose content, i.e. galactose not part of disaccharides or polysaccharides
    Galactose,
    /// Total sucrose content, as tracked in [`Carbohydrates::sugars::sucrose`](Sugars::sucrose)
    Sucrose,
    /// Total lactose content, as tracked in [`Carbohydrates::sugars::lactose`](Sugars::lactose)
    Lactose,
    /// Total maltose content, as tracked in [`Carbohydrates::sugars::maltose`](Sugars::maltose)
    Maltose,
    /// Total trehalose content, tracked in [`Carbohydrates::sugars::trehalose`](Sugars::trehalose)
    Trehalose,
    /// Total sugar content, including all mono/disaccharides tracked in [`Carbohydrates::sugars`]
    TotalSugars,
    /// Total erythritol content, as tracked in
    /// [`Carbohydrates::polyols::erythritol`](Polyols::erythritol)
    Erythritol,
    /// Total polyol content, including all polyols tracked in [`Carbohydrates::polyols`]
    TotalPolyols,
    /// Total carbohydrate content, including all sugars, polyols, and fiber in [`Carbohydrates`]
    TotalCarbohydrates,
    /// Total artificial sweetener content, including all artificial sweeteners tracked in
    /// [`ArtificialSweeteners`]
    TotalArtificial,
    /// Total sweetener content, including all sugars, polyols, and artificial sweeteners, but
    /// excluding fiber and other carbohydrates not contributing to sweetness
    TotalSweeteners,

    // Alcohol and Micro Components
    // ----------------------------
    //
    /// Total alcohol content, as grams of alcohol per 100g of ingredient/mix
    Alcohol,
    /// Total alcohol by volume (ABV) content, calculated from the alcohol content by weight
    ABV,

    /// Total salt content, excluding salts from milk ingredients, as tracked in [`Micro::salt`]
    ///
    /// This includes any salt added as an ingredient, as well as any salt that is part of other
    /// ingredients, e.g. the salt in chocolate or nut ingredients, but excludes salts naturally
    /// present in milk ingredients, which are accounted for separately in [`CompKey::MilkSNFS`] .
    ///
    /// Note that this is a subset of [`CompKey::TotalSolids`].
    Salt,
    /// Total lecithin content, a subset of emulsifiers, tracked in [`Micro::lecithin`]
    Lecithin,
    /// Total emulsifier content, including lecithin and others tracked in [`Micro::emulsifiers`]
    // @todo Should this be explicit about the concept and unit of "strength" of emulsifiers?
    Emulsifiers,
    /// Total stabilizer content, e.g. from Locust Bean Gum, etc. tracked in [`Micro::stabilizers`]
    // @todo Should this be explicit about the concept and unit of "strength" of stabilizers?
    Stabilizers,
    /// Total emulsifier content per fat content, i.e. `Emulsifiers / TotalFats`, as a percentage
    EmulsifiersPerFat,
    /// Total stabilizer content per water content, i.e. `Stabilizers / Water`, as a percentage
    StabilizersPerWater,

    // POD, PAC, and Hardness Factor
    // -----------------------------
    //
    /// [Potere Dolcificante (POD)](crate::docs#pod) of the ingredient or mix as a whole
    POD,

    /// [Potere Anti-Cristallizzante (PAC)](crate::docs#pac) contributions from sugars and polyols
    PACsgr,
    /// [Potere Anti-Cristallizzante (PAC)](crate::docs#pac) contributions from [`CompKey::Salt`]
    PACslt,
    /// [Potere Anti-Cristallizzante (PAC)](crate::docs#pac) contributions from milk minerals
    PACmlk,
    /// [Potere Anti-Cristallizzante (PAC)](crate::docs#pac) contributions from alcohol
    PACalc,
    /// Total [Potere Anti-Cristallizzante (PAC)](crate::docs#pac) of the ingredient or mix as whole
    PACtotal,
    /// [Absolute PAC](crate::docs#absolute-pac), i.e. `PACtotal / Water`, as a percentage
    AbsPAC,
    /// [Hardness Factor (HF)](crate::docs#corvitto-method-hardness-factor) of the ingredient or mix
    HF,
}

impl Composition {
    /// Creates an empty composition, which is equivalent to the composition of 100% water.
    #[must_use]
    pub fn empty() -> Self {
        Self {
            energy: 0.0,
            solids: Solids::empty(),
            micro: Micro::empty(),
            alcohol: Alcohol::empty(),
            pod: 0.0,
            pac: PAC::empty(),
        }
    }

    /// Field-update method for [`energy`](Self::energy)
    #[must_use]
    pub fn energy(self, energy: f64) -> Self {
        Self { energy, ..self }
    }

    /// Field-update method for [`solids`](Self::solids)
    #[must_use]
    pub fn solids(self, solids: Solids) -> Self {
        Self { solids, ..self }
    }

    /// Field-update method for [`micro`](Self::micro)
    #[must_use]
    pub fn micro(self, micro: Micro) -> Self {
        Self { micro, ..self }
    }

    /// Field-update method for [`alcohol`](Self::alcohol)
    #[must_use]
    pub fn alcohol(self, alcohol: Alcohol) -> Self {
        Self { alcohol, ..self }
    }

    /// Field-update method for [`pod`](Self::pod)
    #[must_use]
    pub fn pod(self, pod: f64) -> Self {
        Self { pod, ..self }
    }

    /// Field-update method for [`pac`](Self::pac)
    #[must_use]
    pub fn pac(self, pac: PAC) -> Self {
        Self { pac, ..self }
    }

    /// Calculates the composition of a mix from a weighted combination of its ingredients
    ///
    /// # Errors
    ///
    /// Returns an [`Error::CompositionNotPositive`] if any of the ingredient amounts are negative.
    pub fn from_combination(compositions: &[(Composition, f64)]) -> Result<Self> {
        assert_are_positive(&compositions.iter().map(|line| line.1).collect::<Vec<_>>())?;

        let total_amount: f64 = compositions.iter().map(|line| line.1).sum();

        if total_amount == 0.0 {
            return Ok(Composition::empty());
        }

        compositions.iter().try_fold(Composition::empty(), |acc, line| {
            let mut mix_comp = acc;
            let weight = line.1 / total_amount;
            mix_comp = mix_comp.add(&line.0.scale(weight));
            Ok(mix_comp)
        })
    }
}

#[cfg_attr(feature = "wasm", wasm_bindgen)]
impl Composition {
    /// Creates an empty composition, equivalent to 100% water; forwards to [`empty`](Self::empty)
    #[cfg_attr(feature = "wasm", wasm_bindgen(constructor))]
    #[must_use]
    pub fn new() -> Self {
        Self::empty()
    }

    /// Calculates the water content as `100 - TotalSolids - Alcohol`
    ///
    /// An empty composition would have `TotalSolids == 0` and `Alcohol == 0`, resulting in
    /// `Water == 100`, which is in accordance with an empty composition being equivalent to 100%
    /// water. This is equivalent to [`get(CompKey::Water)`](Self::get).
    #[must_use]
    pub fn water(&self) -> f64 {
        100.0 - self.solids.total() - self.alcohol.by_weight
    }

    /// Calculates the emulsifier per fat content, i.e. `Emulsifiers / TotalFats`, as a percentage
    ///
    /// This is equivalent to [`get(CompKey::EmulsifiersPerFat)`](Self::get).
    ///
    /// Note that [`f64::NAN`] is a valid result, if there are no fats.
    #[must_use]
    pub fn emulsifiers_per_fat(&self) -> f64 {
        if self.solids.all().fats.total > 0.0 {
            (self.micro.emulsifiers / self.solids.all().fats.total) * 100.0
        } else {
            f64::NAN
        }
    }

    /// Calculates the stabilizer per water content, i.e. `Stabilizers / Water`, as a percentage
    ///
    /// This is equivalent to [`get(CompKey::StabilizersPerWater)`](Self::get).
    ///
    /// Note that [`f64::NAN`] is a valid result, if there is no water
    #[must_use]
    pub fn stabilizers_per_water(&self) -> f64 {
        if self.water() > 0.0 {
            (self.micro.stabilizers / self.water()) * 100.0
        } else {
            f64::NAN
        }
    }

    /// Calculates [Absolute PAC](crate::docs#absolute-pac), i.e. `PACtotal / Water`, as a
    /// percentage, excluding the hardness factor
    ///
    /// This is equivalent to [`get(CompKey::AbsPAC)`](Self::get).
    ///
    /// Note that [`f64::NAN`] is a valid result, if there is no water
    #[must_use]
    pub fn absolute_pac(&self) -> f64 {
        if self.water() > 0.0 {
            (self.pac.total() / self.water()) * 100.0
        } else {
            f64::NAN
        }
    }

    /// Gets a specific composition value by key, using [`CompKey`] to specify the desired value
    #[must_use]
    pub fn get(&self, key: CompKey) -> f64 {
        match key {
            CompKey::Energy => self.energy,

            CompKey::MilkFat => self.solids.milk.fats.total,
            CompKey::MSNF => self.solids.milk.snf(),
            CompKey::MilkSNFS => self.solids.milk.snfs(),
            CompKey::MilkProteins => self.solids.milk.proteins,
            CompKey::MilkSolids => self.solids.milk.total(),

            CompKey::CocoaButter => self.solids.cocoa.fats.total,
            CompKey::CocoaSolids => self.solids.cocoa.snfs(),
            CompKey::CacaoSolids => self.solids.cocoa.total() - self.solids.cocoa.carbohydrates.sugars.total(),
            CompKey::NutFat => self.solids.nut.fats.total,
            CompKey::NutSNF => self.solids.nut.snfs(),
            CompKey::NutSolids => self.solids.nut.total() - self.solids.nut.carbohydrates.sugars.total(),

            CompKey::EggFat => self.solids.egg.fats.total,
            CompKey::EggSNF => self.solids.egg.snfs(),
            CompKey::EggSolids => self.solids.egg.total() - self.solids.egg.carbohydrates.sugars.total(),
            CompKey::OtherFats => self.solids.other.fats.total,
            CompKey::OtherSNFS => self.solids.other.snfs(),

            CompKey::TotalFats => self.solids.all().fats.total,
            CompKey::TotalSNF => self.solids.all().snf(),
            CompKey::TotalSNFS => self.solids.all().snfs(),
            CompKey::TotalProteins => self.solids.all().proteins,
            CompKey::TotalSolids => self.solids.total(),

            CompKey::Water => self.water(),

            CompKey::Fiber => self.solids.all().carbohydrates.fiber.total(),
            CompKey::Glucose => self.solids.all().carbohydrates.sugars.glucose,
            CompKey::Fructose => self.solids.all().carbohydrates.sugars.fructose,
            CompKey::Galactose => self.solids.all().carbohydrates.sugars.galactose,
            CompKey::Sucrose => self.solids.all().carbohydrates.sugars.sucrose,
            CompKey::Lactose => self.solids.all().carbohydrates.sugars.lactose,
            CompKey::Maltose => self.solids.all().carbohydrates.sugars.maltose,
            CompKey::Trehalose => self.solids.all().carbohydrates.sugars.trehalose,
            CompKey::TotalSugars => self.solids.all().carbohydrates.sugars.total(),
            CompKey::Erythritol => self.solids.all().carbohydrates.polyols.erythritol,
            CompKey::TotalPolyols => self.solids.all().carbohydrates.polyols.total(),
            CompKey::TotalCarbohydrates => self.solids.all().carbohydrates.total(),
            CompKey::TotalArtificial => self.solids.all().artificial_sweeteners.total(),
            CompKey::TotalSweeteners => {
                self.solids.all().carbohydrates.sugars.total()
                    + self.solids.all().carbohydrates.polyols.total()
                    + self.solids.all().artificial_sweeteners.total()
            }

            CompKey::Alcohol => self.alcohol.by_weight,
            CompKey::ABV => self.alcohol.to_abv(),

            CompKey::Salt => self.micro.salt,
            CompKey::Lecithin => self.micro.lecithin,
            CompKey::Emulsifiers => self.micro.emulsifiers,
            CompKey::Stabilizers => self.micro.stabilizers,
            CompKey::EmulsifiersPerFat => self.emulsifiers_per_fat(),
            CompKey::StabilizersPerWater => self.stabilizers_per_water(),

            CompKey::POD => self.pod,

            CompKey::PACsgr => self.pac.sugars,
            CompKey::PACslt => self.pac.salt,
            CompKey::PACmlk => self.pac.msnf_ws_salts,
            CompKey::PACalc => self.pac.alcohol,
            CompKey::PACtotal => self.pac.total(),
            CompKey::AbsPAC => self.absolute_pac(),
            CompKey::HF => self.pac.hardness_factor,
        }
    }
}

impl ScaleComponents for Composition {
    fn scale(&self, factor: f64) -> Self {
        Self {
            energy: self.energy * factor,
            solids: self.solids.scale(factor),
            micro: self.micro.scale(factor),
            alcohol: self.alcohol.scale(factor),
            pod: self.pod * factor,
            pac: self.pac.scale(factor),
        }
    }

    fn add(&self, other: &Self) -> Self {
        Self {
            energy: self.energy + other.energy,
            solids: self.solids.add(&other.solids),
            micro: self.micro.add(&other.micro),
            alcohol: self.alcohol.add(&other.alcohol),
            pod: self.pod + other.pod,
            pac: self.pac.add(&other.pac),
        }
    }
}

impl AbsDiffEq for Composition {
    type Epsilon = f64;

    fn default_epsilon() -> Self::Epsilon {
        f64::default_epsilon()
    }

    fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
        self.energy.abs_diff_eq(&other.energy, epsilon)
            && self.solids.abs_diff_eq(&other.solids, epsilon)
            && self.micro.abs_diff_eq(&other.micro, epsilon)
            && self.alcohol.abs_diff_eq(&other.alcohol, epsilon)
            && self.pod.abs_diff_eq(&other.pod, epsilon)
            && self.pac.abs_diff_eq(&other.pac, epsilon)
    }
}

impl Default for Composition {
    fn default() -> Self {
        Self::empty()
    }
}

#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
#[allow(clippy::float_cmp)]
mod tests {
    use std::collections::HashMap;

    use strum::IntoEnumIterator;

    use crate::tests::asserts::shadow_asserts::assert_eq;
    use crate::tests::asserts::*;

    use crate::tests::assets::*;

    use super::*;
    use crate::composition::*;

    #[test]
    fn composition_nan_values() {
        let comp = Composition::new();

        assert_eq!(comp.get(CompKey::Water), 100.0);
        assert_eq!(comp.get(CompKey::TotalSolids), 0.0);
        assert_eq!(comp.get(CompKey::TotalFats), 0.0);
        assert!(comp.get(CompKey::EmulsifiersPerFat).is_nan());
        assert_eq!(comp.get(CompKey::StabilizersPerWater), 0.0);
        assert_eq!(comp.get(CompKey::AbsPAC), 0.0);

        let comp = Composition::new().solids(Solids::new().other(SolidsBreakdown::new().others(100.0)));

        assert_eq!(comp.get(CompKey::Water), 0.0);
        assert_eq!(comp.get(CompKey::TotalSolids), 100.0);
        assert!(comp.get(CompKey::EmulsifiersPerFat).is_nan());
        assert!(comp.get(CompKey::StabilizersPerWater).is_nan());
        assert!(comp.get(CompKey::AbsPAC).is_nan());
    }

    #[test]
    fn composition_get() {
        let expected = HashMap::from([
            (CompKey::Energy, 49.5756),
            (CompKey::MilkFat, 2.0),
            (CompKey::MSNF, 8.82),
            (CompKey::MilkSNFS, 4.0131),
            (CompKey::MilkProteins, 3.087),
            (CompKey::MilkSolids, 10.82),
            (CompKey::TotalFats, 2.0),
            (CompKey::TotalSNF, 8.82),
            (CompKey::TotalSNFS, 4.0131),
            (CompKey::TotalProteins, 3.087),
            (CompKey::TotalSolids, 10.82),
            (CompKey::Water, 89.18),
            (CompKey::Lactose, 4.8069),
            (CompKey::TotalSugars, 4.8069),
            (CompKey::TotalArtificial, 0.0),
            (CompKey::TotalSweeteners, 4.8069),
            (CompKey::TotalCarbohydrates, 4.8069),
            (CompKey::POD, 0.769_104),
            (CompKey::PACsgr, 4.8069),
            (CompKey::PACmlk, 3.2405),
            (CompKey::PACtotal, 8.0474),
            (CompKey::AbsPAC, 9.02377),
        ]);

        CompKey::iter().for_each(|key| assert_eq_flt_test!(COMP_2_MILK.get(key), *expected.get(&key).unwrap_or(&0.0)));
    }
}