diff --git a/Cargo.toml b/Cargo.toml index 0cb1788..84532e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,6 @@ regex = "1" serde = { version = "1.0.116", features = ["derive"] } serde_json = "1.0.58" once_cell = "1.4.1" + +[dev-dependencies] +average = "0.10" diff --git a/src/calculators/ibu.rs b/src/calculators/ibu.rs new file mode 100644 index 0000000..b587b9c --- /dev/null +++ b/src/calculators/ibu.rs @@ -0,0 +1,334 @@ +/// A module for calculating IBU using Tinseth Formula: +/// +/// IBUs = decimal alpha acid utilization * mg/l of added alpha acids +/// +/// +/// See: +/// https://www.realbeer.com/hops/research.html +/// http://www.backtoschoolbrewing.com/blog/2016/9/5/how-to-calculate-ibus +/// https://straighttothepint.com/ibu-calculator/ +/// https://www.brewersfriend.com/2010/02/27/hops-alpha-acid-table-2009/ +/// + +/// Internal function to calculate Aplha Acid Utilization (Tinseth formula) +/// given Boil Time and Wort Original Gravity +/// # Arguments +/// +/// * `wort_gravity`: wort Original Gravity +/// * `time_mins`: boil time (min) +/// +fn _calculate_utilization(wort_gravity: f64, time_mins: u32) -> f64 { + let bigness_factor = 1.65 * f64::powf(0.000125, wort_gravity - 1.0); + let boil_time_factor = (1.0 - f64::exp(-0.04 * (time_mins as f64))) / 4.15; + bigness_factor * boil_time_factor +} + +/// Internal function to calculate IBU contributed by single hop addition, +/// +/// # Arguments +/// +/// * `weight_grams`: weight of the hop addition (gm) +/// * `alpha_acid_percentage`: AA% of the hop variety +/// * `time_mins`: boil time (min) +/// * `finished_volume_liters`: volume of the final wort (liters) +/// * `gravity_boil`: the wort original gravity +/// +fn _calculate_ibu_single_hop( + weight_grams: f64, + alpha_acid_percentage: f64, + time_mins: u32, + finished_volume_liters: f64, + gravity_boil: f64, + utilization_multiplier: f64, +) -> f64 { + let mg_per_liter_added_aa = + (alpha_acid_percentage * weight_grams * 1000.0) / finished_volume_liters; + let decimal_alpha_acid_utilization = + _calculate_utilization(gravity_boil, time_mins) * utilization_multiplier; + mg_per_liter_added_aa * decimal_alpha_acid_utilization +} + +/// An enum of hop types +#[derive(Debug, Copy, Clone)] +pub enum HopAdditionType { + /// Whole, default + Whole, + // Plugs, same utilization as whole hops + Plug, + /// Pellets, 10% higher utilization + Pellet, +} + +impl Default for HopAdditionType { + fn default() -> Self { + HopAdditionType::Whole + } +} + +/// A representation of one hop addition +/// +/// Example: +/// ``` +/// use rustybeer::calculators::ibu::{HopAddition, HopAdditionType}; +/// // Centennial (8.5% AA) Pellets: 7g - 60 min +/// HopAddition { +/// weight_grams: 7., +/// alpha_acid_percentage: 0.085, +/// time_mins: 60, +/// hop_type: HopAdditionType::Pellet +/// }; +///``` +/// +#[derive(Debug, Copy, Clone)] +// TODO: YAML/JSON serialization +pub struct HopAddition { + /// the weight of the hop addition (gm) + pub weight_grams: f64, + /// AA% of the hop variety + pub alpha_acid_percentage: f64, + /// boil time (min) + pub time_mins: u32, + /// type of hop added: whole or pellets. [default() = HopAdditionType::Whole] + pub hop_type: HopAdditionType, +} + +impl HopAddition { + pub fn new( + weight_grams: f64, + alpha_acid_percentage: f64, + time_mins: u32, + hop_type: HopAdditionType, + ) -> Self { + Self { + weight_grams, + alpha_acid_percentage, + time_mins, + hop_type, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegativeIbuError; + +/// Calculates IBU contributed by hop additions +/// +/// # Arguments +/// +/// * `hop_additions`: the added hops weights (g), AA%, and boil time (min) +/// * `finished_volume_liters`: volume of the final wort (liters) +/// * `gravity_boil`: wort original gravity +/// +/// # Examples +/// +/// * Target Batch Size: 20 liters +/// * Original Gravity: 1.050 +/// * Cascade (6.4% AA): 28g - 45 mins +/// +/// ``` +/// use rustybeer::calculators::ibu::HopAddition; +/// use rustybeer::calculators::ibu::calculate_ibu; +/// assert!( (18.972_316 - calculate_ibu(vec![HopAddition::new(28.0, 0.064, 45, Default::default())], 20.0, 1.050)).abs() < 0.01); +/// ``` +/// +pub fn calculate_ibu( + hop_additions: Vec, + finished_volume_liters: f64, + gravity_boil: f64, +) -> f64 { + hop_additions + .into_iter() + .map(|h| { + _calculate_ibu_single_hop( + h.weight_grams, + h.alpha_acid_percentage, + h.time_mins, + finished_volume_liters, + gravity_boil, + match h.hop_type { + HopAdditionType::Whole | HopAdditionType::Plug => 1., + HopAdditionType::Pellet => 1.1, + }, + ) + }) + .sum() +} + +/// Calculates the needed amount of bittering hop to reach a target IBU for given variety alpha +/// acid percentage and boil time of the hop +/// +/// # Arguments +/// +/// * `hop_additions`: Optional other flavor or aroma hops additions +/// * `bittering_alpha_acid_percentage`: the alpha acid percentage of the bittering hop variety +/// * `bittering_time_mins`: Optional boil time of the bittering hop (min) +/// * `finished_volume_liters`: volume of the final wort (liters) +/// * `gravity_boil`: wort original gravity +/// * `target_ibu`: target IBU +/// +/// # Examples +/// +/// * Target Batch Size: 22 liters +/// * Original Gravity: 1.058 +/// * Target IBU: 17 +/// * Centennial (8.5% AA) hops to be added for 60min boil +/// * No other hops additions +/// ``` +/// use rustybeer::calculators::ibu::calculate_bittering_weight; +/// let bittering = calculate_bittering_weight(None, 0.085, None, 22., 1.058, 17.); +/// assert!( (20.50 - bittering.unwrap()).abs() < 0.01); +/// ``` +/// +/// With addition of 20gm of Centennial (8.5% AA) for 60min boil, +/// can't get IBU down to just 10 +/// +/// ```{.should_panic} +/// use rustybeer::calculators::ibu::calculate_bittering_weight; +/// use rustybeer::calculators::ibu::HopAddition; +/// let bittering = calculate_bittering_weight(Some(vec![ +/// HopAddition { +/// weight_grams: 20., +/// alpha_acid_percentage: 0.085, +/// time_mins: 60, +/// hop_type: Default::default()}]), +/// 0.085, None, 22., 1.058, 10.); +/// +/// bittering.expect("Too low IBU target"); +/// ``` +/// +pub fn calculate_bittering_weight( + hop_additions: Option>, + bittering_alpha_acid_percentage: f64, + bittering_time_mins: Option, + finished_volume_liters: f64, + gravity_boil: f64, + target_ibu: f64, +) -> Result { + let bittering_ibu = match hop_additions { + Some(h) => target_ibu - calculate_ibu(h, finished_volume_liters, gravity_boil), + None => target_ibu, + }; + + match bittering_ibu.is_sign_positive() { + true => { + let bittering_time = bittering_time_mins.unwrap_or(60); + let bittering_alpha_acid_utilization = + _calculate_utilization(gravity_boil, bittering_time); + + let bittering_weight = (bittering_ibu * finished_volume_liters) + / (bittering_alpha_acid_utilization * bittering_alpha_acid_percentage) + / 1000.0; + + Ok(bittering_weight) + } + false => Err(NegativeIbuError), + } +} + +#[cfg(test)] +pub mod test { + use super::{ + calculate_bittering_weight, calculate_ibu, HopAddition, HopAdditionType, NegativeIbuError, + _calculate_ibu_single_hop, _calculate_utilization, + }; + use crate::calculators::utilization_test_vector; + use average::assert_almost_eq; + + #[test] + fn utilization() { + let test_vector: utilization_test_vector::TestVector = + utilization_test_vector::get_vector(); + for (og_idx, og) in test_vector.og.iter().enumerate() { + for (boiling_time_idx, boiling_time) in test_vector.boiling_time.iter().enumerate() { + let ut = _calculate_utilization(*og, *boiling_time); + assert_almost_eq!(test_vector.utilization[boiling_time_idx][og_idx], ut, 0.001); + } + } + } + + #[test] + fn single_hop_ibu() { + assert_almost_eq!( + 2.88, + _calculate_ibu_single_hop(7.0, 0.085, 15, 22.0, 1.058, 1.), + 0.01 + ); + } + + #[test] + fn multiple_hops_ibu() { + assert_almost_eq!( + 5.76, + calculate_ibu( + vec![ + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Whole), + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Whole) + ], + 22.0, + 1.058 + ), + 0.01 + ); + } + + #[test] + fn pellet_hops_ibu() { + // 6.336 = 5.76 * 1.1 + assert_almost_eq!( + 6.336, + calculate_ibu( + vec![ + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Pellet), + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Pellet) + ], + 22.0, + 1.058 + ), + 0.01 + ); + } + + #[test] + #[should_panic] + fn negative_ibu() { + calculate_bittering_weight( + Some(vec![HopAddition::new( + 20.0, + 0.085, + 60, + HopAdditionType::Whole, + )]), + 0.085, + None, + 22.0, + 1.058, + 10., + ) + .expect("too low IBU"); + } + + #[test] + fn bitter_hops_weight() -> Result<(), NegativeIbuError> { + assert_almost_eq!( + 13.2611, + calculate_bittering_weight( + Some(vec![ + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Whole), + HopAddition::new(7.0, 0.085, 15, HopAdditionType::Plug) + ]), + 0.085, + Some(60), + 22.0, + 1.058, + 16.76, + )?, + 0.001 + ); + Ok(()) + } + + #[test] + fn zero_hops_ibu() { + assert_almost_eq!(0., calculate_ibu(vec![], 22.0, 1.058), f64::EPSILON); + } +} diff --git a/src/calculators/mod.rs b/src/calculators/mod.rs index e899444..427129b 100644 --- a/src/calculators/mod.rs +++ b/src/calculators/mod.rs @@ -1,5 +1,6 @@ pub mod abv; pub mod diluting; +pub mod ibu; pub mod num_bottles; pub mod priming; pub mod sg_correction; @@ -9,3 +10,6 @@ pub use diluting::{calculate_new_gravity, calculate_new_volume}; pub use num_bottles::calculate_num_bottles; pub use priming::{calculate_co2, calculate_sugars}; pub use sg_correction::correct_sg;*/ + +#[cfg(test)] +mod utilization_test_vector; diff --git a/src/calculators/utilization_test_vector.rs b/src/calculators/utilization_test_vector.rs new file mode 100644 index 0000000..9f4c274 --- /dev/null +++ b/src/calculators/utilization_test_vector.rs @@ -0,0 +1,94 @@ +pub struct TestVector { + pub og: Vec, + pub boiling_time: Vec, + pub utilization: Vec>, +} + +pub fn get_vector() -> TestVector { + TestVector { + og: vec![ + 1.030, 1.040, 1.050, 1.060, 1.070, 1.080, 1.090, 1.100, 1.110, 1.120, 1.130, + ], + boiling_time: vec![ + 0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48, 51, 54, 57, 60, 70, 80, + 90, 120, + ], + utilization: vec![ + vec![ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + ], + vec![ + 0.034, 0.031, 0.029, 0.026, 0.024, 0.022, 0.020, 0.018, 0.017, 0.015, 0.014, + ], + vec![ + 0.065, 0.059, 0.054, 0.049, 0.045, 0.041, 0.038, 0.035, 0.032, 0.029, 0.026, + ], + vec![ + 0.092, 0.084, 0.077, 0.070, 0.064, 0.059, 0.054, 0.049, 0.045, 0.041, 0.037, + ], + vec![ + 0.116, 0.106, 0.097, 0.088, 0.081, 0.074, 0.068, 0.062, 0.056, 0.052, 0.047, + ], + vec![ + 0.137, 0.125, 0.114, 0.105, 0.096, 0.087, 0.080, 0.073, 0.067, 0.061, 0.056, + ], + vec![ + 0.156, 0.142, 0.130, 0.119, 0.109, 0.099, 0.091, 0.083, 0.076, 0.069, 0.063, + ], + vec![ + 0.173, 0.158, 0.144, 0.132, 0.120, 0.110, 0.101, 0.092, 0.084, 0.077, 0.070, + ], + vec![ + 0.187, 0.171, 0.157, 0.143, 0.131, 0.120, 0.109, 0.100, 0.091, 0.083, 0.076, + ], + vec![ + 0.201, 0.183, 0.168, 0.153, 0.140, 0.128, 0.117, 0.107, 0.098, 0.089, 0.082, + ], + vec![ + 0.212, 0.194, 0.177, 0.162, 0.148, 0.135, 0.124, 0.113, 0.103, 0.094, 0.086, + ], + vec![ + 0.223, 0.203, 0.186, 0.170, 0.155, 0.142, 0.130, 0.119, 0.108, 0.099, 0.091, + ], + vec![ + 0.232, 0.212, 0.194, 0.177, 0.162, 0.148, 0.135, 0.124, 0.113, 0.103, 0.094, + ], + vec![ + 0.240, 0.219, 0.200, 0.183, 0.167, 0.153, 0.140, 0.128, 0.117, 0.107, 0.098, + ], + vec![ + 0.247, 0.226, 0.206, 0.189, 0.172, 0.158, 0.144, 0.132, 0.120, 0.110, 0.101, + ], + vec![ + 0.253, 0.232, 0.212, 0.194, 0.177, 0.162, 0.148, 0.135, 0.123, 0.113, 0.103, + ], + vec![ + 0.259, 0.237, 0.216, 0.198, 0.181, 0.165, 0.151, 0.138, 0.126, 0.115, 0.105, + ], + vec![ + 0.264, 0.241, 0.221, 0.202, 0.184, 0.169, 0.154, 0.141, 0.129, 0.118, 0.108, + ], + vec![ + 0.269, 0.246, 0.224, 0.205, 0.188, 0.171, 0.157, 0.143, 0.131, 0.120, 0.109, + ], + vec![ + 0.273, 0.249, 0.228, 0.208, 0.190, 0.174, 0.159, 0.145, 0.133, 0.121, 0.111, + ], + vec![ + 0.276, 0.252, 0.231, 0.211, 0.193, 0.176, 0.161, 0.147, 0.135, 0.123, 0.112, + ], + vec![ + 0.285, 0.261, 0.238, 0.218, 0.199, 0.182, 0.166, 0.152, 0.139, 0.127, 0.116, + ], + vec![ + 0.291, 0.266, 0.243, 0.222, 0.203, 0.186, 0.170, 0.155, 0.142, 0.130, 0.119, + ], + vec![ + 0.295, 0.270, 0.247, 0.226, 0.206, 0.188, 0.172, 0.157, 0.144, 0.132, 0.120, + ], + vec![ + 0.301, 0.275, 0.252, 0.230, 0.210, 0.192, 0.176, 0.161, 0.147, 0.134, 0.123, + ], + ], + } +} diff --git a/src/utils/conversions.rs b/src/utils/conversions.rs index 786454d..a8d608e 100644 --- a/src/utils/conversions.rs +++ b/src/utils/conversions.rs @@ -1,7 +1,7 @@ use regex::Regex; use std::num::ParseFloatError; -use measurements::{Temperature, Volume}; +use measurements::{Mass, Temperature, Volume}; /// Used to build new measurements::Temperature structs. /// @@ -83,10 +83,50 @@ impl VolumeBuilder { } } +/// Used to build new measurements::Mass structs. +/// +/// To be removed if the dependency some time allows creating measurement units from +/// strings. +pub struct MassBuilder; + +impl MassBuilder { + /// Creates measurements::Mass from string + /// + /// Tries to figure out the mass unit from the string. If the string value is plain + /// number, it will be considered as grams. Also empty strings are considered as + /// zero grams in Mass. + pub fn from_str(val: &str) -> Result { + if val.is_empty() { + return Ok(Mass::from_grams(0.0)); + } + + let re = Regex::new(r"([0-9.]*)\s?([a-zA-Zμ]{1,3})$").unwrap(); + if let Some(caps) = re.captures(val) { + let float_val = caps.get(1).unwrap().as_str(); + return Ok(match caps.get(2).unwrap().as_str() { + "ug" | "μg" => Mass::from_micrograms(float_val.parse::()?), + "mg" => Mass::from_milligrams(float_val.parse::()?), + "ct" => Mass::from_carats(float_val.parse::()?), + "g" => Mass::from_grams(float_val.parse::()?), + "kg" => Mass::from_kilograms(float_val.parse::()?), + "T" => Mass::from_metric_tons(float_val.parse::()?), + "gr" => Mass::from_grains(float_val.parse::()?), + "dwt" => Mass::from_pennyweights(float_val.parse::()?), + "oz" => Mass::from_ounces(float_val.parse::()?), + "st" => Mass::from_stones(float_val.parse::()?), + "lbs" => Mass::from_pounds(float_val.parse::()?), + _ => Mass::from_grams(float_val.parse::()?), + }); + } + + Ok(Mass::from_grams(val.parse::()?)) + } +} + #[cfg(test)] mod tests { - use super::TemperatureBuilder; - use super::VolumeBuilder; + use super::{MassBuilder, TemperatureBuilder, VolumeBuilder}; + use std::num::ParseFloatError; const DELTA: f64 = 1e-5; fn abs(x: f64) -> f64 { @@ -181,12 +221,14 @@ mod tests { ); assert_almost_equal(123.0, VolumeBuilder::from_str("123").unwrap().as_litres()); + assert_almost_equal(123.0, MassBuilder::from_str("123").unwrap().as_grams()); } #[test] fn zero_from_string() { assert_eq!(0.0, TemperatureBuilder::from_str("").unwrap().as_celsius()); assert_eq!(0.0, VolumeBuilder::from_str("").unwrap().as_litres()); + assert_eq!(0.0, MassBuilder::from_str("").unwrap().as_grams()); } #[test] @@ -233,4 +275,96 @@ mod tests { assert_almost_equal(123.0, VolumeBuilder::from_str("123 p").unwrap().as_pints()); assert_almost_equal(123.0, VolumeBuilder::from_str("123 P").unwrap().as_pints()); } + + #[test] + fn micrograms_from_string() { + assert_almost_equal( + 123.0, + MassBuilder::from_str("123ug").unwrap().as_micrograms(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123 ug").unwrap().as_micrograms(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123μg").unwrap().as_micrograms(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123 μg").unwrap().as_micrograms(), + ); + } + + #[test] + fn milligrams_from_string() { + assert_almost_equal( + 123.0, + MassBuilder::from_str("123mg").unwrap().as_milligrams(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123 mg").unwrap().as_milligrams(), + ); + } + + #[test] + fn carats_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123ct").unwrap().as_carats()); + assert_almost_equal(123.0, MassBuilder::from_str("123 ct").unwrap().as_carats()); + } + + #[test] + fn grams_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123g").unwrap().as_grams()); + assert_almost_equal(123.0, MassBuilder::from_str("123 g").unwrap().as_grams()); + } + + #[test] + fn kilograms_from_string() { + assert_almost_equal( + 123.0, + MassBuilder::from_str("123kg").unwrap().as_kilograms(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123 kg").unwrap().as_kilograms(), + ); + } + + #[test] + fn tonnes_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123T").unwrap().as_tonnes()); + assert_almost_equal(123.0, MassBuilder::from_str("123 T").unwrap().as_tonnes()); + } + + #[test] + fn grains_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123gr").unwrap().as_grains()); + assert_almost_equal(123.0, MassBuilder::from_str("123 gr").unwrap().as_grains()); + } + + #[test] + fn pennyweights_from_string() { + assert_almost_equal( + 123.0, + MassBuilder::from_str("123dwt").unwrap().as_pennyweights(), + ); + assert_almost_equal( + 123.0, + MassBuilder::from_str("123 dwt").unwrap().as_pennyweights(), + ); + } + + #[test] + fn ounces_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123oz").unwrap().as_ounces()); + assert_almost_equal(123.0, MassBuilder::from_str("123 oz").unwrap().as_ounces()); + } + + #[test] + fn pounds_from_string() { + assert_almost_equal(123.0, MassBuilder::from_str("123lbs").unwrap().as_pounds()); + assert_almost_equal(123.0, MassBuilder::from_str("123 lbs").unwrap().as_pounds()); + } } diff --git a/src/utils/hops.json b/src/utils/hops.json new file mode 100644 index 0000000..6743710 --- /dev/null +++ b/src/utils/hops.json @@ -0,0 +1,338 @@ +[ + { + "hop": "Admiral", + "alpha_acids": 14.5 + }, + { + "hop": "Ahtanum", + "alpha_acids": 5.5 + }, + { + "hop": "Amarillo", + "alpha_acids": 8.6 + }, + { + "hop": "Aquila", + "alpha_acids": 7 + }, + { + "hop": "B. C. Goldings", + "alpha_acids": 5 + }, + { + "hop": "Banner", + "alpha_acids": 10 + }, + { + "hop": "Bramling Cross", + "alpha_acids": 6.5 + }, + { + "hop": "Brewer’s Gold", + "alpha_acids": 9 + }, + { + "hop": "Bullion", + "alpha_acids": 7.5 + }, + { + "hop": "Cascade", + "alpha_acids": 7 + }, + { + "hop": "Centennial", + "alpha_acids": 8.5 + }, + { + "hop": "Challenger", + "alpha_acids": 7.5 + }, + { + "hop": "Chinook", + "alpha_acids": 13 + }, + { + "hop": "Citra", + "alpha_acids": 11 + }, + { + "hop": "Cluster", + "alpha_acids": 6.5 + }, + { + "hop": "Columbus", + "alpha_acids": 15 + }, + { + "hop": "Comet", + "alpha_acids": 10 + }, + { + "hop": "Crystal", + "alpha_acids": 4.3 + }, + { + "hop": "Domesic Hallertau", + "alpha_acids": 3.9 + }, + { + "hop": "East Kent Goldings", + "alpha_acids": 5 + }, + { + "hop": "Eroica", + "alpha_acids": 12 + }, + { + "hop": "First Gold", + "alpha_acids": 7.5 + }, + { + "hop": "Fuggles", + "alpha_acids": 4.5 + }, + { + "hop": "Galena", + "alpha_acids": 13 + }, + { + "hop": "Glacier", + "alpha_acids": 5.5 + }, + { + "hop": "Goldings", + "alpha_acids": 4.5 + }, + { + "hop": "Galaxy", + "alpha_acids": 13.5 + }, + { + "hop": "Hallertau Mittelfruh", + "alpha_acids": 3.75 + }, + { + "hop": "Hallertau Hersbrucker", + "alpha_acids": 4 + }, + { + "hop": "Herald", + "alpha_acids": 12 + }, + { + "hop": "Hersbrucker", + "alpha_acids": 4 + }, + { + "hop": "Horizon", + "alpha_acids": 12.5 + }, + { + "hop": "Huller Bitterer", + "alpha_acids": 5.75 + }, + { + "hop": "Kent Goldings", + "alpha_acids": 5 + }, + { + "hop": "Liberty", + "alpha_acids": 4 + }, + { + "hop": "Lublin", + "alpha_acids": 4.5 + }, + { + "hop": "Magnum", + "alpha_acids": 15 + }, + { + "hop": "Millenium", + "alpha_acids": 15.5 + }, + { + "hop": "Mount Hood", + "alpha_acids": 4.8 + }, + { + "hop": "Mount Rainier", + "alpha_acids": 6.2 + }, + { + "hop": "Motueka", + "alpha_acids": 7.0 + }, + { + "hop": "Nelson Sauvin", + "alpha_acids": 12.5 + }, + { + "hop": "Newport", + "alpha_acids": 15.5 + }, + { + "hop": "Northdown", + "alpha_acids": 8.6 + }, + { + "hop": "Northern Brewer", + "alpha_acids": 7.8 + }, + { + "hop": "Nugget", + "alpha_acids": 14 + }, + { + "hop": "Olympic", + "alpha_acids": 12 + }, + { + "hop": "Omega", + "alpha_acids": 10 + }, + { + "hop": "Orion", + "alpha_acids": 7 + }, + { + "hop": "Pacific Gem", + "alpha_acids": 15.4 + }, + { + "hop": "Perle", + "alpha_acids": 8.2 + }, + { + "hop": "Phoenix", + "alpha_acids": 10 + }, + { + "hop": "Pioneer", + "alpha_acids": 9 + }, + { + "hop": "Pride of Ringwood", + "alpha_acids": 10 + }, + { + "hop": "Progress", + "alpha_acids": 6.25 + }, + { + "hop": "Record", + "alpha_acids": 6.5 + }, + { + "hop": "Saaz", + "alpha_acids": 3.5 + }, + { + "hop": "Santiam", + "alpha_acids": 6.5 + }, + { + "hop": "Satus", + "alpha_acids": 13 + }, + { + "hop": "Simcoe", + "alpha_acids": 12.7 + }, + { + "hop": "Sorachi Ace", + "alpha_acids": 11.1 + }, + { + "hop": "Spalt", + "alpha_acids": 4.5 + }, + { + "hop": "Sterling", + "alpha_acids": 8.7 + }, + { + "hop": "Sticklebract", + "alpha_acids": 11.5 + }, + { + "hop": "Strisselspalt", + "alpha_acids": 3.5 + }, + { + "hop": "Styrian Goldings", + "alpha_acids": 5.5 + }, + { + "hop": "Super Alpha", + "alpha_acids": 13 + }, + { + "hop": "Super Styrians", + "alpha_acids": 9 + }, + { + "hop": "Summit", + "alpha_acids": 18.5 + }, + { + "hop": "Talisman", + "alpha_acids": 8 + }, + { + "hop": "Target", + "alpha_acids": 11.5 + }, + { + "hop": "Tettnanger", + "alpha_acids": 4.5 + }, + { + "hop": "Tomahawk", + "alpha_acids": 15 + }, + { + "hop": "Ultra", + "alpha_acids": 4.5 + }, + { + "hop": "Vanguard", + "alpha_acids": 5 + }, + { + "hop": "Warrior", + "alpha_acids": 16 + }, + { + "hop": "Whitbread Golding", + "alpha_acids": 6 + }, + { + "hop": "Willamette", + "alpha_acids": 4.5 + }, + { + "hop": "Wye Target", + "alpha_acids": 10 + }, + { + "hop": "Yamhill Goldings", + "alpha_acids": 4 + }, + { + "hop": "Yakima Cluster", + "alpha_acids": 7 + }, + { + "hop": "Yeoman", + "alpha_acids": 7.25 + }, + { + "hop": "Zenith", + "alpha_acids": 9 + }, + { + "hop": "Zeus", + "alpha_acids": 16 + } +] \ No newline at end of file diff --git a/src/utils/hops.rs b/src/utils/hops.rs new file mode 100644 index 0000000..ee52058 --- /dev/null +++ b/src/utils/hops.rs @@ -0,0 +1,39 @@ +/// Hops list curated from https://www.brewersfriend.com/2010/02/27/hops-alpha-acid-table-2009/ +use once_cell::sync::Lazy; +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Hop { + #[serde(alias = "hop")] + pub name: String, + + #[serde(alias = "alpha_acids", deserialize_with = "percentage_to_float")] + pub alpha_acid: f64, +} + +const HOPS_JSON: &str = include_str!("hops.json"); + +fn percentage_to_float<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(f64::deserialize(deserializer)? / 100.) +} + +pub static HOPS: Lazy> = Lazy::new(|| serde_json::from_str(HOPS_JSON).unwrap()); + +#[cfg(test)] +pub mod test { + use super::{Hop, HOPS}; + + #[test] + fn centennial() { + assert_eq!( + Some(&Hop { + name: "Centennial".to_string(), + alpha_acid: 0.085 + }), + HOPS.iter().find(|&h| h.name == "Centennial") + ) + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1be6e72..8b4d0bf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod beer_styles; pub mod conversions; +pub mod hops;