diet-guard/diet_guard/_portions.py
Krzysztof kuhy Rudnicki 843f5e0221 Extract diet_guard from testsAndMisc as a standalone repo
Rewrites python_pkg.diet_guard imports to diet_guard, vendors the
shared as_float coercion helper, drops the monorepo PYTHONPATH from
install.sh and the systemd unit (package is now pip-installed), and
scaffolds standalone lint/test config matching testsAndMisc's real
enforced bar (pylint --fail-under=10 with tests excluded and the
use-implicit-booleaness/consider-using-with disables, mypy's actual
disabled-error-code set, ruff ALL, bandit, 100% branch coverage).
2026-06-22 12:18:39 +02:00

172 lines
5.9 KiB
Python

"""Built-in portion knowledge: unit weights and macros for common staples.
Two problems this solves, both seen in real use:
* **Counting by the piece.** People eat "5 apples", not "910 grams of apple".
To turn a count into grams the program needs to know what one piece weighs.
* **Open Food Facts is wrong for bare generics.** Searching OFF for "apple"
returns a packaged apple *pastry* (~500 kcal), not the fruit. For staple
whole foods a small, offline, curated table is both more correct and faster.
So this module gives diet_guard, for each common countable food, the typical
mass of one piece and its macros per 100 g. It is consulted *before* Open Food
Facts (see :mod:`diet_guard._resolve`), so a bare staple resolves
locally and sensibly, and a count multiplies cleanly into grams.
The numbers are deliberately round "good enough" averages (USDA ballpark); the
goal is a sane estimate the user can override with an explicit weight, not lab
precision.
"""
from __future__ import annotations
from dataclasses import dataclass
from diet_guard._estimator import Nutrition
from diet_guard._fuzzy import match_score
# Same close-match bar the food bank uses, so matching feels consistent.
_MATCH_THRESHOLD = 0.6
# Assumed mass of one piece when a counted food is not in the table, so "3 of
# something" still produces a number (flagged to the user as an assumption).
DEFAULT_ITEM_GRAMS = 100.0
@dataclass(frozen=True)
class Staple:
"""A common whole food: typical piece weight and per-100 g macros.
Attributes:
name: Canonical lowercase food name matched against the description.
unit_grams: Typical mass of one piece, in grams.
kcal_100: Calories per 100 g.
protein_100: Protein grams per 100 g.
carbs_100: Carbohydrate grams per 100 g.
fat_100: Fat grams per 100 g.
"""
name: str
unit_grams: float
kcal_100: float
protein_100: float
carbs_100: float
fat_100: float
# Per-100 g macros with one typical piece weight, for the common countable
# foods. Ordered roughly by how often they are eaten by the piece.
_STAPLES: tuple[Staple, ...] = (
Staple("apple", 182, 52, 0.3, 14.0, 0.2),
Staple("banana", 118, 89, 1.1, 23.0, 0.3),
Staple("orange", 131, 47, 0.9, 12.0, 0.1),
Staple("egg", 50, 143, 13.0, 1.1, 9.5),
Staple("boiled egg", 50, 155, 13.0, 1.1, 11.0),
Staple("slice of bread", 28, 265, 9.0, 49.0, 3.2),
Staple("potato", 173, 77, 2.0, 17.0, 0.1),
Staple("tomato", 123, 18, 0.9, 3.9, 0.2),
Staple("carrot", 61, 41, 0.9, 10.0, 0.2),
Staple("pear", 178, 57, 0.4, 15.0, 0.1),
Staple("peach", 150, 39, 0.9, 10.0, 0.3),
Staple("kiwi", 69, 61, 1.1, 15.0, 0.5),
Staple("mandarin", 74, 53, 0.8, 13.0, 0.3),
Staple("clementine", 74, 47, 0.9, 12.0, 0.2),
Staple("plum", 66, 46, 0.7, 11.0, 0.3),
Staple("strawberry", 12, 32, 0.7, 7.7, 0.3),
Staple("slice of pizza", 107, 266, 11.0, 33.0, 10.0),
Staple("rice cake", 9, 387, 8.0, 82.0, 2.8),
)
def _best_staple(description: str) -> Staple | None:
"""Return the staple best matching ``description``, or None below threshold.
Args:
description: Free-text food name (e.g. ``"apple"``, ``"apples"``).
Returns:
The closest :class:`Staple`, or None if nothing clears the match bar.
"""
key = description.strip().casefold()
if not key:
return None
best: Staple | None = None
best_score = _MATCH_THRESHOLD
for staple in _STAPLES:
score = match_score(key, staple.name)
if score > best_score:
best = staple
best_score = score
return best
def estimate_unit_grams(description: str) -> float | None:
"""Return the typical grams of one piece of ``description``, or None.
Args:
description: Free-text food name.
Returns:
The unit weight in grams for a known staple, else None (the caller then
falls back to :data:`DEFAULT_ITEM_GRAMS` and tells the user it guessed).
"""
staple = _best_staple(description)
return staple.unit_grams if staple is not None else None
def _staple_to_nutrition(staple: Staple) -> Nutrition:
"""Return a staple's per-100 g :class:`Nutrition` (source ``"staple: name"``)."""
return Nutrition(
kcal=staple.kcal_100,
protein_g=staple.protein_100,
carbs_g=staple.carbs_100,
fat_g=staple.fat_100,
grams=100.0,
source=f"staple: {staple.name}",
)
def staple_nutrition(description: str) -> Nutrition | None:
"""Return per-100 g :class:`Nutrition` for a known staple, else None.
The grams are fixed at 100 so the result is a clean reference basis the
caller can rescale to the actual amount eaten via
:func:`diet_guard._estimator.scale_nutrition`.
Args:
description: Free-text food name.
Returns:
The staple's per-100 g Nutrition (source ``"staple: <name>"``), or None.
"""
staple = _best_staple(description)
return _staple_to_nutrition(staple) if staple is not None else None
def suggest_staples(
query: str,
limit: int = 6,
) -> list[tuple[str, Nutrition]]:
"""Return staples whose name matches ``query``, best match first.
Used to surface built-in whole foods in the gate's live autocomplete (so
typing "apple" suggests the staple immediately, without a separate lookup
step), alongside the user's banked foods.
Args:
query: Free-text the user has typed so far.
limit: Maximum number of suggestions to return.
Returns:
``(name, per-100 g Nutrition)`` pairs, ranked, at most ``limit`` long.
"""
key = query.strip().casefold()
if not key:
return []
scored: list[tuple[float, Staple]] = []
for staple in _STAPLES:
score = match_score(key, staple.name)
if score >= _MATCH_THRESHOLD:
scored.append((score, staple))
scored.sort(key=lambda item: item[0], reverse=True)
return [(staple.name, _staple_to_nutrition(staple)) for _, staple in scored[:limit]]