Fix noisy consumption alerts and make predictions adaptive

This commit is contained in:
dadaloop82
2026-05-15 11:41:29 +00:00
parent 02964ecf23
commit 850c5047b8
5 changed files with 44 additions and 22 deletions
+1
View File
@@ -45,6 +45,7 @@
- **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata. - **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata.
- **Shelf life latte UHT** — Il latte generico è ora trattato come UHT (7 giorni dopo apertura) invece che fresco (4 giorni). - **Shelf life latte UHT** — Il latte generico è ora trattato come UHT (7 giorni dopo apertura) invece che fresco (4 giorni).
- **Niente più false anomalie di consumo** — Il rilevatore ora ignora i casi in cui `expected = 0` (prodotto probabilmente ricomprato) e alza la soglia "more than expected" al 400%. Le notifiche rimangono solo per consumi significativamente inferiori al previsto. - **Niente più false anomalie di consumo** — Il rilevatore ora ignora i casi in cui `expected = 0` (prodotto probabilmente ricomprato) e alza la soglia "more than expected" al 400%. Le notifiche rimangono solo per consumi significativamente inferiori al previsto.
- **Previsioni consumo adattive e meno rumorose** — Le previsioni ora pesano anche il comportamento recente dopo l'ultimo rifornimento; i banner "più del previsto" non vengono più mostrati (spesso erano falsi positivi), mentre i casi "meno del previsto" appaiono solo con evidenza sufficiente.
- **Scaduti nascondono prodotti già buttati** — La sezione scaduti ora filtra correttamente i prodotti con `quantity = 0`. - **Scaduti nascondono prodotti già buttati** — La sezione scaduti ora filtra correttamente i prodotti con `quantity = 0`.
- **Docker: fix permessi DB al primo avvio** — `_ensureDataDir()` crea la directory `data/` se mancante e tenta `chmod(0775)` se non scrivibile, risolvendo `SQLSTATE[HY000][14]` su volumi Docker freschi. - **Docker: fix permessi DB al primo avvio** — `_ensureDataDir()` crea la directory `data/` se mancante e tenta `chmod(0775)` se non scrivibile, risolvendo `SQLSTATE[HY000][14]` su volumi Docker freschi.
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab. - **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
+29 -8
View File
@@ -2132,9 +2132,9 @@ function getConsumptionPredictions(PDO $db): void {
$daySpan = ($lastDate - $firstDate) / 86400; $daySpan = ($lastDate - $firstDate) / 86400;
// If all transactions are clustered within a week, the rate is unreliable // If all transactions are clustered within a week, the rate is unreliable
if ($daySpan < 7) continue; if ($daySpan < 7) continue;
$dailyRate = $totalUsed / $daySpan; $historicalRate = $totalUsed / $daySpan;
if ($dailyRate < 0.01) continue; // negligible consumption if ($historicalRate < 0.01) continue; // negligible consumption
// Get the most recent restock (last 'in' transaction) // Get the most recent restock (last 'in' transaction)
$lastIn = $db->prepare(" $lastIn = $db->prepare("
@@ -2166,16 +2166,35 @@ function getConsumptionPredictions(PDO $db): void {
$baselineQty = floatval($item['quantity']) + $usedSinceRestock; $baselineQty = floatval($item['quantity']) + $usedSinceRestock;
$daysSinceRestock = max(1, (time() - $restockDate) / 86400); $daysSinceRestock = max(1, (time() - $restockDate) / 86400);
// Recalculate the expected consumption with an adaptive rate:
// blend long-term history with post-restock behavior when available.
$txSinceRestock = 0;
foreach ($rows as $r) {
if (strtotime($r['created_at']) >= $restockDate) $txSinceRestock++;
}
$observedRate = $daysSinceRestock > 0 ? ($usedSinceRestock / $daysSinceRestock) : 0;
$dailyRate = $historicalRate;
if ($observedRate > 0) {
if ($txSinceRestock >= 3) {
$dailyRate = ($historicalRate * 0.45) + ($observedRate * 0.55);
} elseif ($txSinceRestock >= 1) {
$dailyRate = ($historicalRate * 0.70) + ($observedRate * 0.30);
}
}
// If the model predicts you should have consumed less than 15% of baseline // If the model predicts you should have consumed less than 15% of baseline
// in this period, the daily rate is too low to make reliable predictions: // in this period, the daily rate is too low to make reliable predictions:
// any single normal use will look like an anomaly. Skip it. // any single normal use will look like an anomaly. Skip it.
$predictedConsumption = $dailyRate * $daysSinceRestock; $predictedConsumption = $dailyRate * $daysSinceRestock;
if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue; if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue;
// Predicted remaining qty = baseline - (daily rate * days since restock) // Predicted remaining qty = baseline - (adaptive daily rate * days since restock)
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock)); $expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
$actualQty = floatval($item['quantity']); $actualQty = floatval($item['quantity']);
// Need at least some post-restock usage observations before warning.
if ($txSinceRestock < 2) continue;
// Flag if deviation > 30% and absolute diff > meaningful threshold // Flag if deviation > 30% and absolute diff > meaningful threshold
$deviation = abs($actualQty - $expectedQty); $deviation = abs($actualQty - $expectedQty);
$threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units $threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units
@@ -2188,10 +2207,12 @@ function getConsumptionPredictions(PDO $db): void {
$pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0);
// "more than expected" is almost always a restock the model doesn't know about yet. // "More than expected" usually means slower real consumption, not bad data.
// Only flag it at very high deviation (>400%) to catch truly impossible values. // Suppress this direction to avoid noisy/accusatory banners.
// "less than expected" is more actionable: user may have consumed without registering. if ($actualQty > $expectedQty) continue;
$flagThreshold = ($actualQty > $expectedQty) ? 4.0 : 0.30;
// Only keep meaningful "less than expected" deviations.
$flagThreshold = 0.45;
if ($pctDev > $flagThreshold && $deviation > $threshold) { if ($pctDev > $flagThreshold && $deviation > $threshold) {
$unit = $item['unit']; $unit = $item['unit'];
@@ -2220,7 +2241,7 @@ function getConsumptionPredictions(PDO $db): void {
'daily_rate' => round($dailyRate, 3), 'daily_rate' => round($dailyRate, 3),
'deviation_pct' => round($pctDev * 100), 'deviation_pct' => round($pctDev * 100),
'days_since_restock' => (int)round($daysSinceRestock), 'days_since_restock' => (int)round($daysSinceRestock),
'direction' => $actualQty > $expectedQty ? 'more' : 'less', 'direction' => 'less',
'tx_count' => count($rows), 'tx_count' => count($rows),
]; ];
} }
+4 -4
View File
@@ -91,8 +91,8 @@
"banner_review_action_edit": "Korrigieren", "banner_review_action_edit": "Korrigieren",
"banner_review_action_weigh": "Wiegen", "banner_review_action_weigh": "Wiegen",
"banner_review_dismiss": "Ignorieren", "banner_review_dismiss": "Ignorieren",
"banner_prediction_title": "Ungewöhnlicher Verbrauch", "banner_prediction_title": "Verbrauch zur Prüfung",
"banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", "banner_prediction_hint": "Die Verbrauchsschätzung passt sich aktuellen Daten an: bitte nur die aktuelle Menge bestätigen.",
"banner_prediction_action_confirm": "{qty} {unit} bestätigen", "banner_prediction_action_confirm": "{qty} {unit} bestätigen",
"banner_prediction_action_weigh": "Jetzt wiegen", "banner_prediction_action_weigh": "Jetzt wiegen",
"banner_prediction_action_edit": "Menge aktualisieren", "banner_prediction_action_edit": "Menge aktualisieren",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag", "banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag",
"banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche", "banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche",
"banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt", "banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt",
"banner_prediction_more": "Ich erwartete {expected} {unit}{time}, du hast aber {actual} {unit}. Hast du Bestand ohne Buchung hinzugefügt?", "banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.",
"banner_prediction_less": "Ich erwartete {expected} {unit}{time}, du hast aber nur {actual} {unit}. Hast du mehr als üblich verbraucht?", "banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.",
"banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.", "banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.",
"banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.", "banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.",
"banner_finished_check": "Kannst du nachschauen?", "banner_finished_check": "Kannst du nachschauen?",
+5 -5
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correct", "banner_review_action_edit": "Correct",
"banner_review_action_weigh": "Weigh", "banner_review_action_weigh": "Weigh",
"banner_review_dismiss": "Dismiss", "banner_review_dismiss": "Dismiss",
"banner_prediction_title": "Anomalous consumption", "banner_prediction_title": "Consumption to review",
"banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", "banner_prediction_hint": "The consumption estimate adapts to recent data: confirm only if the current quantity is correct.",
"banner_prediction_action_confirm": "Confirm {qty} {unit} is correct", "banner_prediction_action_confirm": "Confirm {qty} {unit}",
"banner_prediction_action_weigh": "Weigh now", "banner_prediction_action_weigh": "Weigh now",
"banner_prediction_action_edit": "Update quantity", "banner_prediction_action_edit": "Update quantity",
"banner_expired_title": "Expired product", "banner_expired_title": "Expired product",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Average ~{n} {unit}/day", "banner_prediction_rate_day": "Average ~{n} {unit}/day",
"banner_prediction_rate_week": "Average ~{n} {unit}/week", "banner_prediction_rate_week": "Average ~{n} {unit}/week",
"banner_prediction_days_ago": "{n} days ago you restocked", "banner_prediction_days_ago": "{n} days ago you restocked",
"banner_prediction_more": "I expected {expected} {unit}{time}, but you have {actual} {unit}. Did you add stock without recording it?", "banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.",
"banner_prediction_less": "I expected {expected} {unit}{time}, but you only have {actual} {unit}. Did you use more than usual?", "banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.",
"banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.", "banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.",
"banner_finished_expected": "According to records you should still have {qty} {unit}.", "banner_finished_expected": "According to records you should still have {qty} {unit}.",
"banner_finished_check": "Can you check?", "banner_finished_check": "Can you check?",
+5 -5
View File
@@ -91,9 +91,9 @@
"banner_review_action_edit": "Correggi", "banner_review_action_edit": "Correggi",
"banner_review_action_weigh": "Pesa", "banner_review_action_weigh": "Pesa",
"banner_review_dismiss": "Ignora", "banner_review_dismiss": "Ignora",
"banner_prediction_title": "Consumo anomalo", "banner_prediction_title": "Consumo da verificare",
"banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", "banner_prediction_hint": "La stima di consumo si adatta ai dati recenti: verifica solo se la quantità corrente è corretta.",
"banner_prediction_action_confirm": "Confermo la quantità di {qty} {unit}", "banner_prediction_action_confirm": "Confermo {qty} {unit}",
"banner_prediction_action_weigh": "Pesa ora", "banner_prediction_action_weigh": "Pesa ora",
"banner_prediction_action_edit": "Aggiorna quantità", "banner_prediction_action_edit": "Aggiorna quantità",
"banner_expired_title": "Prodotto scaduto", "banner_expired_title": "Prodotto scaduto",
@@ -128,8 +128,8 @@
"banner_prediction_rate_day": "Media ~{n} {unit}/giorno", "banner_prediction_rate_day": "Media ~{n} {unit}/giorno",
"banner_prediction_rate_week": "Media ~{n} {unit}/settimana", "banner_prediction_rate_week": "Media ~{n} {unit}/settimana",
"banner_prediction_days_ago": "{n} giorni fa hai rifornito", "banner_prediction_days_ago": "{n} giorni fa hai rifornito",
"banner_prediction_more": "mi aspettavo {expected} {unit}{time}, ne hai invece {actual} {unit}. Hai aggiunto scorte senza registrarle?", "banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.",
"banner_prediction_less": "mi aspettavo {expected} {unit}{time}, ne hai solo {actual} {unit}. Hai consumato di più del solito?", "banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.",
"banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.", "banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.",
"banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.", "banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.",
"banner_finished_check": "Puoi controllare?", "banner_finished_check": "Puoi controllare?",