sci_cream/lib.rs
1/*!
2`sci-cream` is a Rust library that facilitates the mathematical analysis of ice cream mixes and
3their properties. It includes a comprehensive system to represent the [composition of ingredients
4and ice cream mixes](#ingredientmix-composition), a system to [define ingredients via user-friendly
5specifications](#ingredient-specifications), an expansive collection of [ingredient
6definitions][data/ingredients] that can optionally be included as embedded data, an in-memory
7ingredient database that can be used to look up ingredient definitions, and a system to calculate
8the properties of ice cream mixes based on their composition. It has [support for
9WebAssembly](#wasm-interoperability), including TypeScript bindings and utilities to facilitate
10JS <-> WASM interoperability, allowing it to be used in web applications. Lastly, it includes
11[documentation and literature references](crate::docs) for ice cream science concepts which are
12useful for understanding this library.
13
14# Usage
15
16Add `sci-cream` as a dependency in your `Cargo.toml`:
17
18```toml
19[dependencies]
20sci-cream = "0.0.3"
21```
22
23<br>
24
25Then, for example, you can use the library to instantiate an [`IngredientDatabase`] pre-seeded with
26embedded ingredient data, and then create a [`Recipe`] from a const recipe format and the database.
27This looks up the ingredients by name in the database, to access their full [`Composition`]s used to
28calculate the composition and properties of the mix.
29
30<br>
31
32```
33# fn main() -> Result<(), Box<dyn std::error::Error>> {
34# use sci_cream::docs::{assert_eq_float, main_recipe};
35use sci_cream::{CompKey::*, FpdKey::*, IngredientDatabase, Recipe};
36
37let db = IngredientDatabase::new_seeded_from_embedded_data();
38
39let recipe = Recipe::from_const_recipe(
40 Some("Chocolate Ice Cream".into()),
41 &[
42 ("Whole Milk", 245.0),
43 ("Whipping Cream", 215.0),
44 ("Cocoa Powder, 17% Fat", 28.0),
45 ("Skimmed Milk Powder", 21.0),
46 ("Egg Yolk", 18.0),
47 ("Dextrose", 45.0),
48 ("Fructose", 32.0),
49 ("Salt", 0.5),
50 ("Rich Ice Cream SB", 1.25),
51 ("Vanilla Extract", 6.0),
52 ],
53 &db,
54)?;
55# Ok(()) }
56```
57
58<br>
59
60The [`Recipe`] can then be used to calculate the properties of the mix, which are returned in a
61[`MixProperties`] struct. This struct contains a wide range of properties, including compositional
62properties like grams of each component per 100g of mix, as well as functional properties like
63[freezing point depression](crate::docs#freezing-point-depression), hardness at different
64temperatures, and more. These properties can be accessed via [`MixProperties::get`], using the
65appropriate keys from either [`CompKey`] or [`FpdKey`].
66
67<br>
68
69```
70# fn main() -> Result<(), Box<dyn std::error::Error>> {
71# use sci_cream::docs::{assert_eq_float, main_recipe};
72# use sci_cream::{CompKey::*, FpdKey::*, IngredientDatabase, Recipe};
73# let db = IngredientDatabase::new_seeded_from_embedded_data();
74# let recipe = Recipe::from_light_recipe(None, &main_recipe!(), &db)?;
75let mix_properties = recipe.calculate_mix_properties()?;
76
77for (key, value) in [
78 (Energy.into(), 228.865), // kcal per 100g
79 (MilkFat.into(), 13.602), // grams per 100g
80 (Lactose.into(), 4.836), // ...
81 (MSNF.into(), 8.873),
82 (MilkProteins.into(), 3.106),
83 (MilkSolids.into(), 22.475),
84 (CocoaButter.into(), 0.778),
85 (CocoaSolids.into(), 3.799),
86 (Glucose.into(), 6.767),
87 (Fructose.into(), 5.23),
88 (TotalSugars.into(), 16.834),
89 (ABV.into(), 0.343), // Alcohol-by-value %
90 (Salt.into(), 0.082),
91 (TotalSolids.into(), 40.779),
92 (Water.into(), 58.95),
93 (POD.into(), 15.237),
94 (PACsgr.into(), 27.633),
95 (PACmlk.into(), 3.26),
96 (PACalc.into(), 2.012),
97 (PACtotal.into(), 33.383),
98 (AbsPAC.into(), 56.63), // PACtotal / Water
99 (HF.into(), 7.538),
100 (FPD.into(), -3.604), // °C
101 (ServingTemp.into(), -13.371), // °C
102 (HardnessAt14C.into(), 76.268), // [0, 100] scale
103] {
104 assert_eq_float!(mix_properties.get(key), value);
105}
106# Ok(()) }
107```
108
109<br>
110
111[`MixProperties::fpd`] contains an [`FPD`] struct which, in addition to the properties accessible
112via [`FpdKey`], also contains [`Curves`] with detailed data about the [freezing point depression
113curves](crate::docs#freezing-point-depression-curve) of the mix. This can be accessed like this:
114
115<br>
116
117```
118# fn main() -> Result<(), Box<dyn std::error::Error>> {
119# use sci_cream::docs::{assert_eq_float, main_recipe};
120# use sci_cream::{CompKey::*, FpdKey::*, IngredientDatabase, Recipe};
121# let db = IngredientDatabase::new_seeded_from_embedded_data();
122# let recipe = Recipe::from_light_recipe(None, &main_recipe!(), &db)?;
123# let mix_properties = recipe.calculate_mix_properties()?;
124let curves = &mix_properties.fpd.curves;
125
126assert_eq!(curves.frozen_water.len(), 100);
127assert_eq!(curves.hardness.len(), 100);
128assert_eq_float!(curves.frozen_water[0].temp, mix_properties.get(FPD.into()));
129assert_eq_float!(curves.hardness[75].temp, mix_properties.get(ServingTemp.into()));
130# Ok(()) }
131```
132<br>
133
134The data in [`Curves`] can be used to create visualizations of the freezing point depression
135behavior of the mix, including the relationship between temperature and frozen water content, and
136the relationship between temperature and estimated hardness. For example, the graph below shows the
137frozen water and hardness curves:
138
139<br>
140
141![FPD Graph][fpd-graph-png]
142
143<br>
144
145# Features
146
147The library has the following features that enable optional functionality:
148
149- `data`: Enables embedded ingredient definitions data from [`data/ingredients`][data/ingredients],
150 accessible via the [`data`] module, e.g.
151 [`get_all_ingredient_specs`](crate::data::get_all_ingredient_specs). This can be used to access
152 pre-defined [`IngredientSpec`]s, in most cases obviating the need for users to define their own.
153 If the `database` feature is enabled, it can also be used to seed an [`IngredientDatabase`] via
154 [`IngredientDatabase::new_seeded_from_embedded_data`]. This feature is enabled by default.
155- `database`: Enables the [`IngredientDatabase`] struct and related functionality, which provides
156 an in-memory database of [`Ingredient`]s that can be looked up by name. It can be seeded with
157 [`Ingredient`]s and [`IngredientSpec`]s defined by the user. Alternatively, if the `data` feature
158 is enabled, it can be seeded with the embedded ingredient definitions data via
159 [`IngredientDatabase::new_seeded_from_embedded_data`]. This feature is enabled by default.
160- `wasm`: Enables WebAssembly support, including TypeScript bindings via
161 [`wasm-bindgen`](https://crates.io/crates/wasm-bindgen) and
162 [`wasm-pack`](https://drager.github.io/wasm-pack/book/), and utilities to facilitate JS <-> WASM
163 interoperability. This allows the library to be used in web applications. See [WASM
164 Interoperability](#wasm-interoperability) for more information. This feature is not enabled by
165 default.
166
167# Ingredient/Mix Composition
168
169The composition of an ingredient or mix is the most fundamental representation of its properties;
170it directly represents many key quantities and aspects that are relevant to the formulation of ice
171cream mixes, e.g. fat and sugar content, milk solids non-fat (MSNF), sweetness as [Potere
172Dolcificante (POD)](crate::docs#pod), [PAC](crate::docs#pac-afp-fpdf-se), energy in kcal, etc., and
173is the basis for all further calculation and analyses, e.g. [Freezing Point Depression
174(FPD)](crate::docs#freezing-point-depression) calculations. It is the core of the functionality of
175this library.
176
177[`Composition`] is the top-level struct that holds the full breakdown of the composition of an
178ingredient or mix, including various sub-structs that represent different aspects of the
179composition, e.g. [`SolidsBreakdown`], [`Fats`], [`Carbohydrates`], [`Sugars`], etc. Most values are
180expressed in terms of grams per 100g of total ingredient/mix, with some exceptions, e.g. POD and PAC
181are expressed as a sucrose equivalence, energy is expressed in kcal per 100g, Abs.PAC is expressed
182as a ratio, etc. See the [`composition`] module and each type's documentation for more details.
183
184Due to the complexity and level of detail of these types, they are primarily intended for
185internal use within the library, and to be constructed internally from more user-friendly input
186types; see [Ingredient Specifications](#ingredient-specifications). They are not necessarily
187intended to be constructed directly by users, but they are left public and can be used directly if
188needed for advanced use cases not covered by the library. See the [`composition`] module for more
189details and examples of how to construct and use these types.
190
191<a id="composition-example"></a>
192As an example, the code snippet below shows how to construct a [`Composition`] for '2% Milk',
193utilizing various sub-structs, their calculations methods, e.g. [`to_pod`](Carbohydrates::to_pod),
194[`to_pac`](Carbohydrates::to_pac), [`energy`](SolidsBreakdown::energy), etc. and several constants
195from the [`constants`] module, e.g. [`STD_MSNF_IN_MILK_SERUM`], [`STD_LACTOSE_IN_MSNF`], etc.
196
197<br>
198
199```
200# fn main() -> Result<(), Box<dyn std::error::Error>> {
201# use sci_cream::docs::assert_eq_float;
202use sci_cream::{CompKey::*, composition::*, constants::{composition::*, pac}};
203
204let msnf = (100.0 - 2.0) * STD_MSNF_IN_MILK_SERUM;
205let lactose = msnf * STD_LACTOSE_IN_MSNF;
206let proteins = msnf * STD_PROTEIN_IN_MSNF;
207
208let milk_solids = SolidsBreakdown::new()
209 .fats(
210 Fats::new()
211 .total(2.0)
212 .saturated(2.0 * STD_SATURATED_FAT_IN_MILK_FAT)
213 .trans(2.0 * STD_TRANS_FAT_IN_MILK_FAT),
214 )
215 .carbohydrates(Carbohydrates::new().sugars(Sugars::new().lactose(lactose)))
216 .proteins(proteins)
217 .others(msnf - lactose - proteins);
218
219let pod = milk_solids.carbohydrates.to_pod()?;
220let pac = PAC::new()
221 .sugars(milk_solids.carbohydrates.to_pac()?)
222 .msnf_ws_salts(msnf * pac::MSNF_WS_SALTS / 100.0);
223
224// Composition for 2% milk
225let comp = Composition::new()
226 .energy(milk_solids.energy()?)
227 .solids(Solids::new().milk(milk_solids))
228 .pod(pod)
229 .pac(pac);
230
231assert_eq_float!(comp.get(Energy), 49.576);
232assert_eq_float!(comp.get(MilkFat), 2.0);
233assert_eq_float!(comp.get(Lactose), 4.807);
234assert_eq_float!(comp.get(MSNF), 8.82);
235assert_eq_float!(comp.get(MilkProteins), 3.087);
236// ...
237# Ok(()) }
238```
239
240# Ingredient Specifications
241
242An [`Ingredient`] is defined chiefly by its [`Composition`], which is used to calculate its
243contributions to the overall properties of a mix. The [`Composition`] struct is very complex and
244subject to change as more tracking and properties are added, which makes directly defining it for
245each ingredient very cumbersome and error-prone, to the point of being impractical.
246
247Instead, we define various specifications or "specs" that provide greatly simplified interfaces for
248defining ingredients of different common categories, such as dairy, sweeteners, fruits, etc. These
249specs are then internally converted into the full [`Composition`] struct using a multitude of
250researched calculation and typical composition data. This allows for much easier and more intuitive
251ingredient definitions, while still providing accurate and detailed composition data for
252calculations. See the [`specs`] module for more details about the different specs that are
253available, and examples of how to use them.
254
255<a id="dairy-spec-example"></a>
256As an example, the code snippet below shows how to define a [`Composition`] for _'2% Milk'_ using
257the [`DairySpec`], which only requires the user to specify the fat content. The resulting
258composition is equivalent to the one constructed in the [previous example](#composition-example).
259
260<br>
261
262```
263# fn main() -> Result<(), Box<dyn std::error::Error>> {
264# use sci_cream::docs::assert_eq_float;
265use sci_cream::{CompKey::*, composition::IntoComposition, specs::DairySpec};
266
267let dairy_spec = DairySpec { fat: 2.0, msnf: None };
268let comp = dairy_spec.into_composition()?;
269
270assert_eq_float!(comp.get(Energy), 49.576);
271assert_eq_float!(comp.get(MilkFat), 2.0);
272assert_eq_float!(comp.get(Lactose), 4.807);
273assert_eq_float!(comp.get(MSNF), 8.82);
274assert_eq_float!(comp.get(MilkProteins), 3.087);
275// ...
276# Ok(()) }
277```
278
279<br>
280
281Specs can also be deserialized from JSON format - they are actually designed to be most
282user-friendly when defined in JSON. This allows them to be easily defined in external files, stored
283in databases, sent over APIs, etc. See the documentation of each spec for more details and examples
284of how to define them in JSON. More expansively, the ingredient definitions in the embedded data are
285all defined as JSON strings of [`IngredientSpec`]s and serve as good examples, located at
286[`data/ingredients`][data/ingredients].
287
288<a id="ingredient-spec-dairy-json-example"></a>
289For example, `"DairySpec": { "fat": 2 }` is the JSON representation of the [`DairySpec`] [example
290above](#dairy-spec-example) for _'2% Milk'_. Typically they are defined as [`IngredientSpec`]s that
291include the ingredient name and category as well. Below is an example for a _'2% Milk'_ ingredient.
292
293```json
294{
295 "name": "2% Milk",
296 "category": "Dairy",
297 "DairySpec": { "fat": 2 }
298}
299```
300
301<br>
302
303<a id="ingredient-spec-sweetener-json-example"></a>
304Below is an example of a more complex [`SweetenerSpec`] for _'Splenda (Sucralose)'_. This spec
305has several more fields, including [`sweeteners`](SweetenerSpec::sweeteners) holding a
306[`Sweeteners`] struct which itself is relatively complex, as well as [`basis`](SweetenerSpec::basis)
307for [`CompositionBasis`] specification, and [`Scaling`] and [`Unit`] specifiers for some fields like
308[`pod`](SweetenerSpec::pod) and [`pac`](SweetenerSpec::pac). See the [`specs::units`] module for
309more details about composition basis, units, and scaling. The embedded ingredient definition JSON
310files include comments that detail how the values in the spec were determined.
311
312```json
313{
314 "name": "Splenda (Sucralose)",
315 "category": "Sweetener",
316 "SweetenerSpec": {
317 "sweeteners": {
318 "sugars": { "glucose": 55.0 },
319 "artificial": { "sucralose": 1.32 }
320 },
321 "other_carbohydrates": 38.68,
322 "ByTotalWeight": { "water": 5 },
323 "pod": { "OfWhole": 840 },
324 "pac": { "OfWhole": { "grams": 112.6 } }
325 },
326 "comments": "POD value taken from..."
327}
328```
329_"POD value taken from the manufacturer's suggested 2tsp:1packet sugar to sweetener conversion,
330where a teaspoon of granulated sugar is 4.2g (see
331[`constants::density::GRAMS_IN_TEASPOON_OF_SUGAR`]) and a packet is 1g (from the manufacturer's
332packaging and empirically measured with a 0.01g precision scale). The composition is inferred from
333the ingredient list, assuming 55% dextrose, ~40% maltodextrin, 5% water, and enough sucralose to
334reach a POD of 840 (works out to ~1.32% using a POD of 11 for maltodextrin). PAC is calculated for
33555% dextrose and 40% Maltodextrin 10 DE with a PAC of 18. Energy is calculated internally from the
336composition. <https://www.splenda.com/product/splenda-sweetener-packets/>"_
337
338
339# WASM Interoperability
340
341If the `wasm` feature is enabled, the library can be compiled to WebAssembly - target
342`wasm32-unknown-unknown` - and used in web applications. The library includes TypeScript bindings
343via [`wasm-bindgen`](https://crates.io/crates/wasm-bindgen) and
344[`wasm-pack`](https://drager.github.io/wasm-pack/book/), and utilities to facilitate JS <-> WASM
345interoperability. Running `pnpm build:package` either at the repo root or at the package level will
346build the library with the `wasm`, `data`, and `database` features enabled and generate the
347corresponding WASM and TypeScript bindings. These can be imported and used as such:
348
349<br>
350
351```ts
352import {
353 getIngredientSpecByName,
354 into_ingredient_from_spec,
355 Recipe,
356 RecipeLine,
357 CompKey,
358 FpdKey,
359 compToPropKey,
360 fpdToPropKey,
361 getMixProperty,
362} from "@workspace/sci-cream";
363
364const RECIPE = [
365 ["Whole Milk", 245],
366 ["Whipping Cream", 215],
367 ["Cocoa Powder, 17% Fat", 28],
368 ["Skimmed Milk Powder", 21],
369 ["Egg Yolk", 18],
370 ["Dextrose", 45],
371 ["Fructose", 32],
372 ["Salt", 0.5],
373 ["Rich Ice Cream SB", 1.25],
374 ["Vanilla Extract", 6],
375];
376
377const recipeLines = RECIPE.map(
378([name, quantity]) =>
379 new RecipeLine(
380 into_ingredient_from_spec(getIngredientSpecByName(name as string)!),
381 quantity as number,
382 ),
383);
384
385const recipe = new Recipe("Chocolate Ice Cream", recipeLines);
386const mix_properties = recipe.calculate_mix_properties();
387
388const comp = mix_properties.composition;
389expect(comp.get(CompKey.Energy)).toBeCloseTo(228.865);
390expect(comp.get(CompKey.MilkFat)).toBeCloseTo(13.602);
391expect(comp.get(CompKey.Lactose)).toBeCloseTo(4.836);
392// ...
393
394const fpd = mix_properties.fpd;
395expect(fpd.get(FpdKey.FPD)).toBeCloseTo(-3.604);
396expect(fpd.get(FpdKey.ServingTemp)).toBeCloseTo(-13.371);
397expect(fpd.get(FpdKey.HardnessAt14C)).toBeCloseTo(76.268);
398
399// Via prop keys:
400expect(getMixProperty(mix_properties, compToPropKey(CompKey.Energy))).toBeCloseTo(228.865);
401expect(getMixProperty(mix_properties, fpdToPropKey(FpdKey.FPD))).toBeCloseTo(-3.604);
402```
403
404<br>
405
406When using the package in this manner, one needs to be mindful of the performance overhead of doing
407JS <-> WASM crossings, which can be very expensive in some cases. See the benchmarks in
408[`benches/ts`][benches/ts] that explore this issue, [tracked here][bench-tracking]. In general, it
409is best to minimize the number of crossings and keep as much of the logic as possible on the WASM
410side. To facilitate this, the library provides the [`wasm::Bridge`], which in addition to being
411performant, also provides a more ergonomic interface for JS <-> WASM interoperability. It can be
412used as such:
413
414<br>
415
416```ts
417import {
418 Bridge as WasmBridge,
419 new_ingredient_database_seeded_from_embedded_data,
420} from "@workspace/sci-cream";
421
422const bridge = new WasmBridge(new_ingredient_database_seeded_from_embedded_data());
423const mix_properties = bridge.calculate_recipe_mix_properties(RECIPE);
424
425expect(mix_properties.composition.get(CompKey.Energy)).toBeCloseTo(228.865);
426// ...
427expect(mix_properties.fpd.get(FpdKey.FPD)).toBeCloseTo(-3.604);
428// ...
429```
430
431[data/ingredients]: https://github.com/ramonrsv/sci-cream/tree/main/packages/sci-cream/data/ingredients
432[fpd-graph-png]: https://media.githubusercontent.com/media/ramonrsv/sci-cream/2c35c15bb6f19980cc809c6a8a6e8908ba29a5ba/packages/app/src/__tests__/visual/components.spec.ts-snapshots/fpd-graph-populated-main-visual-linux.png
433[benches/ts]: https://github.com/ramonrsv/sci-cream/tree/main/packages/sci-cream/benches/ts
434[bench-tracking]: https://ramonrsv.github.io/sci-cream/dev/bench/
435*/
436
437#![cfg_attr(coverage, feature(coverage_attribute))]
438
439pub mod composition;
440pub mod constants;
441pub mod display;
442pub mod docs;
443pub mod error;
444pub mod fpd;
445pub mod ingredient;
446pub mod properties;
447pub mod recipe;
448pub mod specs;
449pub mod util;
450pub mod validate;
451
452#[cfg(feature = "data")]
453pub mod data;
454#[cfg(feature = "database")]
455pub mod database;
456#[cfg(feature = "diesel")]
457pub mod diesel;
458#[cfg(feature = "wasm")]
459pub mod wasm;
460
461pub use {
462 composition::{CompKey, Composition},
463 fpd::{FPD, FpdKey},
464 ingredient::{Category, Ingredient},
465 properties::{MixProperties, PropKey},
466 recipe::Recipe,
467};
468
469#[cfg(feature = "database")]
470pub use database::IngredientDatabase;
471
472#[cfg(doc)]
473use crate::{
474 composition::{Carbohydrates, Fats, SolidsBreakdown, Sugars, Sweeteners},
475 constants::composition::{STD_LACTOSE_IN_MSNF, STD_MSNF_IN_MILK_SERUM},
476 fpd::Curves,
477 specs::{
478 DairySpec, IngredientSpec, SweetenerSpec,
479 units::{CompositionBasis, Scaling, Unit},
480 },
481};
482
483#[cfg(test)]
484mod tests;
485
486// Silence unused_crate_dependencies lint for [dev-dependencies] used in /benches and /examples.
487#[cfg(test)]
488mod _lint {
489 use criterion as _;
490}