[ 'value' => 'cup', 'label' => 'cup', 'plural' => 'cups', 'type' => 'volume', ], 'gram' => [ 'value' => 'gram', 'label' => 'gram', 'plural' => 'grams', 'type' => 'weight', ], 'oz' => [ 'value' => 'oz', 'label' => 'oz', 'plural' => 'oz', 'type' => 'weight', ], 'serving' => [ 'value' => 'serving', 'label' => 'serving', 'plural' => 'servings', 'type' => 'division', ], 'tbsp' => [ 'value' => 'tbsp', 'label' => 'tbsp.', 'plural' => 'tbsp.', 'type' => 'volume', ], 'tsp' => [ 'value' => 'tsp', 'label' => 'tsp.', 'plural' => 'tsp.', 'type' => 'volume', ], ]); } /** * Get all trackable "nutrients" (calories are not technically a nutrient). * * Each entry has four keys: * - value: Machine name for the entry. * - label: Human-readable name for the entry. * - unit: Unit of measure for the entry. * - weight: Sort weight for presentation. * - rdi: US FDA's recommended daily intake for adults (https://www.fda.gov/media/99059/download). */ public static function all(): Collection { return new Collection([ 'calories' => [ 'value' => 'calories', 'label' => 'calories', 'unit' => null, 'weight' => 0, 'rdi' => 2000, ], 'carbohydrates' => [ 'value' => 'carbohydrates', 'label' => 'carbohydrates', 'unit' => 'g', 'weight' => 40, 'rdi' => 275, ], 'cholesterol' => [ 'value' => 'cholesterol', 'label' => 'cholesterol', 'unit' => 'mg', 'weight' => 20, 'rdi' => 300, ], 'fat' => [ 'value' => 'fat', 'label' => 'fat', 'unit' => 'g', 'weight' => 10, 'rdi' => 78, ], 'protein' => [ 'value' => 'protein', 'label' => 'protein', 'unit' => 'g', 'weight' => 50, 'rdi' => 50, ], 'sodium' => [ 'value' => 'sodium', 'label' => 'sodium', 'unit' => 'mg', 'weight' => 30, 'rdi' => 2300, ], ]); } /** * Calculate a nutrient multiplier for a Food. */ public static function calculateFoodNutrientMultiplier( Food $food, float $amount, string|null $fromUnit ): float { if ($fromUnit === 'oz') { return $amount * self::$gramsPerOunce / $food->serving_weight; } elseif ($fromUnit === 'serving') { return $amount; } elseif ($fromUnit === 'gram') { return $amount / $food->serving_weight; } // @todo Determine if `empty($food->serving_unit)` case makes sense. if ( empty($fromUnit) || empty($food->serving_unit) || $food->serving_unit === $fromUnit ) { $multiplier = 1; } elseif ($fromUnit === 'tsp') { $multiplier = match ($food->serving_unit) { 'tbsp' => 1/3, 'cup' => 1/48, default => throw new \DomainException(), }; } elseif ($fromUnit === 'tbsp') { $multiplier = match ($food->serving_unit) { 'tsp' => 3, 'cup' => 1/16, default => throw new \DomainException(), }; } elseif ($fromUnit === 'cup') { $multiplier = match ($food->serving_unit) { 'tsp' => 48, 'tbsp' => 16, default => throw new \DomainException(), }; } else { throw new \DomainException("Unhandled unit combination: {$fromUnit}, {$food->serving_unit} ({$food->name})"); } return $multiplier / $food->serving_size * $amount; } /** * Calculate a nutrient amount for a recipe. * * Weight base unit is grams, volume base unit is cups. */ public static function calculateRecipeNutrientAmount( Recipe $recipe, string $nutrient, float $amount, string $fromUnit ): float { if ($fromUnit === 'serving') { // Use "per serving" methods directly. return $recipe->{"{$nutrient}PerServing"}() * $amount; } $multiplier = match ($fromUnit) { 'oz' => $amount * self::$gramsPerOunce / $recipe->weight, 'gram' => $amount / $recipe->weight, 'tsp' => $amount / 48 / $recipe->volume, 'tbsp' => $amount / 16 / $recipe->volume, 'cup' => $amount / $recipe->volume, default => throw new \DomainException("Unsupported recipe unit: {$fromUnit}"), }; return $multiplier * $recipe->{"{$nutrient}Total"}(); } /** * Round a nutrient amount according to FDA guidelines. * * Note: this stays mostly true to the guidelines except that carbohydrates * and protein are meant to state "less than 1 gram" when the amount is less * than 1 gram. Instead, this method treats anything less than 1 gram as * zero. * * @url https://labelcalc.com/food-labeling/a-guide-to-using-fda-rounding-rules-for-your-food-label/ * * @throws \InvalidArgumentException */ public static function round(float $amount, string $nutrient): float { return match ($nutrient) { /* * Calories: * - Less than 5 goes to zero. * - Between 5 and 50 rounds to nearest number divisible by 5. * - Greater than 50 rounds to nearest number divisible by 10. */ 'calories' => ($amount < 5 ? 0 : ($amount <= 50 ? round($amount / 5 ) * 5 : round($amount / 10 ) * 10)), /* * Carbohydrates and protein: * - Less than 1 goes to zero. * - Greater than 1 rounds to nearest whole. */ 'carbohydrates', 'protein' => ($amount < 1 ? 0 : round($amount)), /* * Cholesterol and fat: * - Less than 0.5 goes to zero. * - Between 0.5 and 5 rounds to nearest half. * - Greater than 5 rounds to nearest whole. */ 'cholesterol', 'fat' => ($amount < 0.5 ? 0 : ($amount <= 5 ? round($amount / 5, 1 ) * 5 : round($amount))), /* * Sodium: * - Less than 5 goes to zero. * - Between 5 and 140 rounds to nearest number divisible by 5. * - Greater than 140 rounds to nearest number divisible by 10. */ 'sodium' => ($amount < 5 ? 0 : ($amount <= 140 ? round($amount / 5 ) * 5 : round($amount / 10 ) * 10)), /* * Anything else excepts! */ default => throw new \InvalidArgumentException("Unrecognized nutrient {$nutrient}.") }; } }