mirror of https://github.com/kcal-app/kcal.git
253 lines
8.1 KiB
PHP
253 lines
8.1 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\Food;
|
|
use App\Models\Recipe;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class Nutrients
|
|
{
|
|
public static float $gramsPerOunce = 28.349523125;
|
|
|
|
/**
|
|
* Get all supported units and metadata.
|
|
*
|
|
* Each entry has the following keys:
|
|
* - value: Machine name for the unit.
|
|
* - label: Human-readable name for the unit.
|
|
* - plural: Human-readable plural form of the unit name.
|
|
* - type: Unit type -- matching types can be converted.
|
|
*/
|
|
public static function units(): Collection {
|
|
return new Collection([
|
|
'cup' => [
|
|
'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}.")
|
|
};
|
|
}
|
|
}
|