feat: fallback nutrient estimation for potassium/calcium/magnesium

OFF data is sparse for fresh produce — spinach, oats, most frozen fruit
have no potassium. Added a curated fallback table (114 foods, USDA-sourced)
that injects estimated K/Ca/Mg at log time when OFF has no data.

How it works:
- data/fallback_nutrients.json: 114 foods with mg/100g values
- FallbackTable: case-insensitive exact + substring name matching
- log_food: injects _est keys (potassium_100g_est etc.) when OFF missing
- sum_nutriments: tries OFF value first (grams×1000), then _est (already mg)

Results for today's smoothie + lunch:
- Potassium: 691mg → 2,530mg (47% of 5400mg goal)
- Calcium: 14mg → 304mg (30% of 1000mg goal)
- Magnesium: 61mg → 330mg (73% of 450mg goal)

38 tests passing (7 new: fallback lookup, est fallback, OFF vs est priority)
This commit is contained in:
Anthony Merlo 2026-08-21 11:36:14 +01:00
parent a072c6ba5e
commit d0b7b003f9
4 changed files with 281 additions and 5 deletions

View file

@ -0,0 +1,116 @@
{
"spinach": { "potassium_100g": 558, "calcium_100g": 99, "magnesium_100g": 79 },
"banana": { "potassium_100g": 358, "calcium_100g": 5, "magnesium_100g": 27 },
"oats": { "potassium_100g": 429, "calcium_100g": 54, "magnesium_100g": 177 },
"oat milk": { "potassium_100g": 180, "calcium_100g": 120, "magnesium_100g": 20 },
"strawberry": { "potassium_100g": 153, "calcium_100g": 16, "magnesium_100g": 13 },
"strawberries": { "potassium_100g": 153, "calcium_100g": 16, "magnesium_100g": 13 },
"blueberry": { "potassium_100g": 77, "calcium_100g": 6, "magnesium_100g": 6 },
"blueberries": { "potassium_100g": 77, "calcium_100g": 6, "magnesium_100g": 6 },
"pineapple": { "potassium_100g": 109, "calcium_100g": 13, "magnesium_100g": 12 },
"cherry": { "potassium_100g": 222, "calcium_100g": 13, "magnesium_100g": 11 },
"cherries": { "potassium_100g": 222, "calcium_100g": 13, "magnesium_100g": 11 },
"egg": { "potassium_100g": 138, "calcium_100g": 56, "magnesium_100g": 12 },
"eggs": { "potassium_100g": 138, "calcium_100g": 56, "magnesium_100g": 12 },
"cheddar": { "potassium_100g": 76, "calcium_100g": 721, "magnesium_100g": 28 },
"flatbread": { "potassium_100g": 120, "calcium_100g": 100, "magnesium_100g": 30 },
"chicken": { "potassium_100g": 256, "calcium_100g": 11, "magnesium_100g": 28 },
"chicken breast": { "potassium_100g": 256, "calcium_100g": 11, "magnesium_100g": 28 },
"salmon": { "potassium_100g": 363, "calcium_100g": 9, "magnesium_100g": 30 },
"avocado": { "potassium_100g": 485, "calcium_100g": 12, "magnesium_100g": 29 },
"potato": { "potassium_100g": 421, "calcium_100g": 12, "magnesium_100g": 23 },
"sweet potato": { "potassium_100g": 337, "calcium_100g": 30, "magnesium_100g": 25 },
"broccoli": { "potassium_100g": 316, "calcium_100g": 47, "magnesium_100g": 21 },
"yogurt": { "potassium_100g": 155, "calcium_100g": 121, "magnesium_100g": 12 },
"greek yogurt": { "potassium_100g": 141, "calcium_100g": 115, "magnesium_100g": 11 },
"rice": { "potassium_100g": 86, "calcium_100g": 10, "magnesium_100g": 12 },
"brown rice": { "potassium_100g": 86, "calcium_100g": 10, "magnesium_100g": 12 },
"tomato": { "potassium_100g": 237, "calcium_100g": 10, "magnesium_100g": 11 },
"tomatoes": { "potassium_100g": 237, "calcium_100g": 10, "magnesium_100g": 11 },
"mushroom": { "potassium_100g": 318, "calcium_100g": 3, "magnesium_100g": 9 },
"mushrooms": { "potassium_100g": 318, "calcium_100g": 3, "magnesium_100g": 9 },
"onion": { "potassium_100g": 146, "calcium_100g": 23, "magnesium_100g": 10 },
"garlic": { "potassium_100g": 401, "calcium_100g": 181, "magnesium_100g": 25 },
"carrot": { "potassium_100g": 320, "calcium_100g": 33, "magnesium_100g": 12 },
"carrots": { "potassium_100g": 320, "calcium_100g": 33, "magnesium_100g": 12 },
"cucumber": { "potassium_100g": 147, "calcium_100g": 16, "magnesium_100g": 13 },
"pepper": { "potassium_100g": 211, "calcium_100g": 10, "magnesium_100g": 10 },
"bell pepper": { "potassium_100g": 211, "calcium_100g": 10, "magnesium_100g": 10 },
"courgette": { "potassium_100g": 261, "calcium_100g": 16, "magnesium_100g": 18 },
"zucchini": { "potassium_100g": 261, "calcium_100g": 16, "magnesium_100g": 18 },
"kale": { "potassium_100g": 491, "calcium_100g": 150, "magnesium_100g": 47 },
"lettuce": { "potassium_100g": 194, "calcium_100g": 36, "magnesium_100g": 13 },
"cabbage": { "potassium_100g": 170, "calcium_100g": 40, "magnesium_100g": 12 },
"cauliflower": { "potassium_100g": 299, "calcium_100g": 22, "magnesium_100g": 15 },
"peas": { "potassium_100g": 244, "calcium_100g": 25, "magnesium_100g": 33 },
"green beans": { "potassium_100g": 209, "calcium_100g": 37, "magnesium_100g": 25 },
"green peas": { "potassium_100g": 244, "calcium_100g": 25, "magnesium_100g": 33 },
"beans": { "potassium_100g": 405, "calcium_100g": 143, "magnesium_100g": 140 },
"kidney beans": { "potassium_100g": 405, "calcium_100g": 143, "magnesium_100g": 140 },
"chickpeas": { "potassium_100g": 291, "calcium_100g": 49, "magnesium_100g": 48 },
"lentils": { "potassium_100g": 369, "calcium_100g": 35, "magnesium_100g": 47 },
"almond": { "potassium_100g": 733, "calcium_100g": 269, "magnesium_100g": 270 },
"almonds": { "potassium_100g": 733, "calcium_100g": 269, "magnesium_100g": 270 },
"walnut": { "potassium_100g": 441, "calcium_100g": 98, "magnesium_100g": 158 },
"walnuts": { "potassium_100g": 441, "calcium_100g": 98, "magnesium_100g": 158 },
"cashew": { "potassium_100g": 660, "calcium_100g": 37, "magnesium_100g": 292 },
"cashews": { "potassium_100g": 660, "calcium_100g": 37, "magnesium_100g": 292 },
"peanut": { "potassium_100g": 705, "calcium_100g": 92, "magnesium_100g": 168 },
"peanuts": { "potassium_100g": 705, "calcium_100g": 92, "magnesium_100g": 168 },
"peanut butter": { "potassium_100g": 649, "calcium_100g": 49, "magnesium_100g": 154 },
"beef": { "potassium_100g": 271, "calcium_100g": 6, "magnesium_100g": 19 },
"pork": { "potassium_100g": 287, "calcium_100g": 5, "magnesium_100g": 22 },
"lamb": { "potassium_100g": 247, "calcium_100g": 6, "magnesium_100g": 22 },
"turkey": { "potassium_100g": 239, "calcium_100g": 7, "magnesium_100g": 26 },
"tuna": { "potassium_100g": 252, "calcium_100g": 4, "magnesium_100g": 27 },
"cod": { "potassium_100g": 228, "calcium_100g": 6, "magnesium_100g": 24 },
"prawn": { "potassium_100g": 253, "calcium_100g": 70, "magnesium_100g": 22 },
"shrimp": { "potassium_100g": 253, "calcium_100g": 70, "magnesium_100g": 22 },
"milk": { "potassium_100g": 150, "calcium_100g": 120, "magnesium_100g": 11 },
"whole milk": { "potassium_100g": 150, "calcium_100g": 120, "magnesium_100g": 11 },
"cheese": { "potassium_100g": 76, "calcium_100g": 721, "magnesium_100g": 28 },
"mozzarella": { "potassium_100g": 75, "calcium_100g": 505, "magnesium_100g": 20 },
"feta": { "potassium_100g": 62, "calcium_100g": 493, "magnesium_100g": 10 },
"bread": { "potassium_100g": 115, "calcium_100g": 114, "magnesium_100g": 25 },
"whole wheat bread": { "potassium_100g": 220, "calcium_100g": 107, "magnesium_100g": 76 },
"pasta": { "potassium_100g": 44, "calcium_100g": 7, "magnesium_100g": 18 },
"noodle": { "potassium_100g": 44, "calcium_100g": 7, "magnesium_100g": 18 },
"quinoa": { "potassium_100g": 172, "calcium_100g": 47, "magnesium_100g": 197 },
"buckwheat": { "potassium_100g": 460, "calcium_100g": 18, "magnesium_100g": 231 },
"orange": { "potassium_100g": 181, "calcium_100g": 40, "magnesium_100g": 10 },
"apple": { "potassium_100g": 107, "calcium_100g": 6, "magnesium_100g": 5 },
"pear": { "potassium_100g": 116, "calcium_100g": 9, "magnesium_100g": 7 },
"grape": { "potassium_100g": 191, "calcium_100g": 10, "magnesium_100g": 7 },
"grapes": { "potassium_100g": 191, "calcium_100g": 10, "magnesium_100g": 7 },
"mango": { "potassium_100g": 168, "calcium_100g": 11, "magnesium_100g": 10 },
"kiwi": { "potassium_100g": 312, "calcium_100g": 34, "magnesium_100g": 17 },
"watermelon": { "potassium_100g": 112, "calcium_100g": 7, "magnesium_100g": 10 },
"raspberry": { "potassium_100g": 151, "calcium_100g": 20, "magnesium_100g": 22 },
"raspberries": { "potassium_100g": 151, "calcium_100g": 20, "magnesium_100g": 22 },
"blackberry": { "potassium_100g": 162, "calcium_100g": 29, "magnesium_100g": 20 },
"blackberries": { "potassium_100g": 162, "calcium_100g": 29, "magnesium_100g": 20 },
"cranberry": { "potassium_100g": 85, "calcium_100g": 8, "magnesium_100g": 6 },
"pomegranate": { "potassium_100g": 236, "calcium_100g": 10, "magnesium_100g": 12 },
"fig": { "potassium_100g": 232, "calcium_100g": 35, "magnesium_100g": 17 },
"date": { "potassium_100g": 656, "calcium_100g": 64, "magnesium_100g": 54 },
"dates": { "potassium_100g": 656, "calcium_100g": 64, "magnesium_100g": 54 },
"raisin": { "potassium_100g": 749, "calcium_100g": 50, "magnesium_100g": 32 },
"raisins": { "potassium_100g": 749, "calcium_100g": 50, "magnesium_100g": 32 },
"apricot": { "potassium_100g": 259, "calcium_100g": 13, "magnesium_100g": 10 },
"peach": { "potassium_100g": 190, "calcium_100g": 6, "magnesium_100g": 9 },
"plum": { "potassium_100g": 157, "calcium_100g": 6, "magnesium_100g": 7 },
"lemon": { "potassium_100g": 138, "calcium_100g": 26, "magnesium_100g": 8 },
"lime": { "potassium_100g": 102, "calcium_100g": 33, "magnesium_100g": 6 },
"coconut": { "potassium_100g": 356, "calcium_100g": 14, "magnesium_100g": 32 },
"coconut water": { "potassium_100g": 250, "calcium_100g": 24, "magnesium_100g": 25 },
"dark chocolate": { "potassium_100g": 559, "calcium_100g": 73, "magnesium_100g": 146 },
"cocoa": { "potassium_100g": 1524, "calcium_100g": 128, "magnesium_100g": 499 },
"tofu": { "potassium_100g": 121, "calcium_100g": 350, "magnesium_100g": 30 },
"tempeh": { "potassium_100g": 412, "calcium_100g": 111, "magnesium_100g": 81 },
"hummus": { "potassium_100g": 228, "calcium_100g": 38, "magnesium_100g": 36 },
"falafel": { "potassium_100g": 385, "calcium_100g": 54, "magnesium_100g": 46 },
"olive oil": { "potassium_100g": 1, "calcium_100g": 1, "magnesium_100g": 0 },
"butter": { "potassium_100g": 24, "calcium_100g": 24, "magnesium_100g": 2 },
"honey": { "potassium_100g": 52, "calcium_100g": 6, "magnesium_100g": 2 },
"maple syrup": { "potassium_100g": 204, "calcium_100g": 102, "magnesium_100g": 12 }
}

View file

@ -1,16 +1,67 @@
// OpenFood Facts API client: Search-a-licious + v3 barcode + SQLite caching.
use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::db::DbPool;
/// Fallback nutrient estimates (mg per 100g) for fresh produce where OFF data is sparse.
/// Bundled at compile time from data/fallback_nutrients.json.
#[derive(Debug, Clone, Default)]
pub struct FallbackTable {
foods: HashMap<String, FallbackNutrients>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FallbackNutrients {
#[serde(default)]
pub potassium_100g: Option<f64>, // mg
#[serde(default)]
pub calcium_100g: Option<f64>, // mg
#[serde(default)]
pub magnesium_100g: Option<f64>, // mg
}
impl FallbackTable {
/// Load from the bundled JSON file at compile time.
pub fn load() -> Self {
let json = include_str!("../data/fallback_nutrients.json");
let foods: HashMap<String, FallbackNutrients> =
serde_json::from_str(json).unwrap_or_default();
Self { foods }
}
/// Try to find fallback nutrients for a product name.
/// Does case-insensitive exact match first, then substring match.
pub fn lookup(&self, product_name: &str) -> Option<&FallbackNutrients> {
let name_lower = product_name.to_lowercase();
// Exact match first
if let Some(n) = self.foods.get(&name_lower) {
return Some(n);
}
// Substring match (e.g. "Leaf spinach" matches "spinach")
// Try longer keys first to prefer more specific matches
let mut best: Option<(&String, &FallbackNutrients)> = None;
for (key, nutrients) in &self.foods {
if name_lower.contains(key.as_str()) {
if best.is_none_or(|(k, _)| k.len() < key.len()) {
best = Some((key, nutrients));
}
}
}
best.map(|(_, n)| n)
}
}
/// OFF API client for food lookups.
#[derive(Clone)]
pub struct OffClient {
http: reqwest::Client,
user_agent: String,
fallback: FallbackTable,
}
/// A cached product from OFF (stored in SQLite products table).
@ -81,9 +132,15 @@ impl OffClient {
Self {
http,
user_agent: user_agent.to_string(),
fallback: FallbackTable::load(),
}
}
/// Access the fallback nutrient table (for use at log time).
pub fn fallback(&self) -> &FallbackTable {
&self.fallback
}
/// Search products by name using Search-a-licious.
/// Returns up to `page_size` results.
/// Note: Search-a-licious doesn't return nutriments in search hits,

View file

@ -229,11 +229,25 @@ pub fn sum_nutriments(entries_nutriments: &[Value]) -> Value {
let mut sum = 0.0;
let mut has_data = false;
let is_mg = MG_NUTRIENTS.contains(tracked_key);
let est_key = format!("{}_est", tracked_key);
for entry_nutriments in entries_nutriments {
// Try main key first (OFF value, in grams for mg nutrients)
if let Some(val) = entry_nutriments.get(tracked_key).and_then(|v| v.as_f64()) {
// OFF stores potassium/calcium/magnesium in grams; convert to mg
sum += if is_mg { val * 1000.0 } else { val };
has_data = true;
if val > 0.0 {
sum += if is_mg { val * 1000.0 } else { val };
has_data = true;
continue;
}
}
// Try fallback estimate (already in mg, no conversion needed)
if is_mg {
if let Some(val) = entry_nutriments.get(&est_key).and_then(|v| v.as_f64()) {
if val > 0.0 {
sum += val;
has_data = true;
}
}
}
}
totals.insert(
@ -416,8 +430,36 @@ impl NutritionServer {
100.0 // default to 100g
};
// Scale nutriments
let nutriments: Value = serde_json::from_str(&product.nutriments).unwrap_or(json!({}));
// Scale nutriments — inject fallback estimates for missing K/Ca/Mg first
let mut nutriments: Value = serde_json::from_str(&product.nutriments).unwrap_or(json!({}));
// Check if potassium/calcium/magnesium are missing or zero from OFF
let needs_k = nutriments.get("potassium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0);
let needs_ca = nutriments.get("calcium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0);
let needs_mg = nutriments.get("magnesium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0);
if needs_k || needs_ca || needs_mg {
if let Some(fb) = self.off_client.fallback().lookup(&product.product_name) {
if let Some(obj) = nutriments.as_object_mut() {
if needs_k {
if let Some(k) = fb.potassium_100g {
obj.insert("potassium_100g_est".to_string(), json!(k));
}
}
if needs_ca {
if let Some(ca) = fb.calcium_100g {
obj.insert("calcium_100g_est".to_string(), json!(ca));
}
}
if needs_mg {
if let Some(mg) = fb.magnesium_100g {
obj.insert("magnesium_100g_est".to_string(), json!(mg));
}
}
}
}
}
let scaled = scale_nutriments(&nutriments, grams);
// Insert entry

View file

@ -249,6 +249,67 @@ mod tests {
assert_eq!(totals["energy_kcal"], json!(200.0));
}
#[test]
fn test_sum_nutriments_uses_est_fallback() {
// Entry has only _est potassium (from fallback table, already in mg)
let entries = vec![json!({"potassium_100g_est": 558.0})];
let totals = tools::sum_nutriments(&entries);
assert_eq!(totals["potassium"], json!(558.0));
}
#[test]
fn test_sum_nutriments_prefers_off_over_est() {
// Entry has both OFF value (0.32g = 320mg) and _est (558mg)
// OFF value should win
let entries = vec![json!({"potassium_100g": 0.32, "potassium_100g_est": 558.0})];
let totals = tools::sum_nutriments(&entries);
assert_eq!(totals["potassium"], json!(320.0)); // 0.32g × 1000 = 320mg
}
#[test]
fn test_sum_nutriments_mixed_off_and_est() {
// Entry 1 has OFF potassium (0.32g = 320mg)
// Entry 2 has only _est potassium (558mg from fallback)
let entries = vec![
json!({"potassium_100g": 0.32}),
json!({"potassium_100g_est": 558.0}),
];
let totals = tools::sum_nutriments(&entries);
assert_eq!(totals["potassium"], json!(878.0)); // 320 + 558
}
#[test]
fn test_fallback_lookup_exact() {
let table = nutrition_mcp::api::FallbackTable::load();
let result = table.lookup("spinach");
assert!(result.is_some());
assert_eq!(result.unwrap().potassium_100g, Some(558.0));
}
#[test]
fn test_fallback_lookup_substring() {
let table = nutrition_mcp::api::FallbackTable::load();
// "Leaf spinach" should match "spinach" via substring
let result = table.lookup("Leaf spinach");
assert!(result.is_some());
assert_eq!(result.unwrap().potassium_100g, Some(558.0));
}
#[test]
fn test_fallback_lookup_no_match() {
let table = nutrition_mcp::api::FallbackTable::load();
let result = table.lookup("alien space dust");
assert!(result.is_none());
}
#[test]
fn test_fallback_lookup_case_insensitive() {
let table = nutrition_mcp::api::FallbackTable::load();
let result = table.lookup("BANANA");
assert!(result.is_some());
assert_eq!(result.unwrap().potassium_100g, Some(358.0));
}
// ── Date/meal tests ──────────────────────────────────────────
#[test]