Files
eamco_auto_api/app/routers/main.py
T
anekdotin f1e311a0b1 feat: add /main/verify endpoint and fix estimation column precision
- Added GET /main/verify?count=N to spot-check auto estimations against
  temp history — recomputes expected gallons from HDD since last fill and
  reports drift per customer; warnings flagged when drift > 15 gal
- estimated_gallons_left and estimated_gallons_left_prev_day altered from
  INTEGER to NUMERIC(6,2) in both dev and prod DBs to eliminate ~0.3 gal/day
  rounding drift that accumulated from integer truncation on each daily update

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 15:05:31 -04:00

174 lines
6.1 KiB
Python

import logging
from decimal import Decimal
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from database import session
logger = logging.getLogger(__name__)
from app.script.fuel_estimator import FuelEstimator
from app.script.temp_getter import fetch_and_store_daily_temp
from app.script.fuel_estimator_customer import FuelEstimatorCustomer
from app.models.auto import Auto_Delivery, Auto_Temp
from app.constants import TANK_MAX_FILLS, DEFAULT_MAX_FILL_GALLONS
router = APIRouter(
prefix="/main",
tags=["main"],
responses={404: {"description": "Not found"}},
)
@router.get("/temp", status_code=200)
def update_temp_manually():
"""
Manually triggers the fetch and storage of today's temperature.
This is useful for testing or for manual intervention if the cron job fails.
"""
try:
success = fetch_and_store_daily_temp()
if success:
session.commit()
return {"ok": True, "message": "Temperature updated or already exists."}
else:
# The function already rolled back, so just return an error
return HTTPException(status_code=500, detail="Failed to fetch temperature from the weather API.")
except Exception as e:
session.rollback()
raise HTTPException(status_code=500, detail=f"An unexpected server error occurred: {str(e)}")
@router.get("/update/auto", status_code=200)
def update_all_customer_fuel_levels_auto():
"""
This endpoint triggers the daily update for all customers.
It should be called once per day by a cron job or scheduler.
"""
try:
estimator = FuelEstimator(session)
result = estimator.run_daily_update()
session.commit()
return result
except Exception as e:
session.rollback()
# Log the exception e
logger.error(str(e))
return {"ok": False, "message": "An internal error occurred."}
@router.get("/update/normal", status_code=200)
def update_all_customer_fuel_levels_normal():
"""
This endpoint triggers the daily update for all customers.
It should be called once per day by a cron job or scheduler.
"""
try:
estimator = FuelEstimatorCustomer(session)
result = estimator.run_daily_update()
session.commit()
return result
except Exception as e:
session.rollback()
# Log the exception e
logger.error(str(e))
return {"ok": False, "message": "An internal error occurred."}
@router.get("/verify", status_code=200)
def verify_auto_estimations(count: int = Query(default=10, ge=1, le=50)):
"""
Spot-check auto delivery estimations against temperature history.
For each sampled customer, recomputes expected gallons from the last fill
date using daily HDD records, then compares to the stored estimate.
Drift > 15 gal is flagged as a warning.
"""
try:
today = date.today()
import random
customers = (
session.query(Auto_Delivery)
.filter(Auto_Delivery.auto_status == 1, Auto_Delivery.last_fill.isnot(None))
.all()
)
sample = random.sample(customers, min(count, len(customers)))
# Build a date→degree_day lookup using raw temp_avg (matches estimator logic)
temps = session.query(Auto_Temp).all()
hdd_by_date: dict[date, Decimal] = {
t.todays_date: Decimal(str(max(0, 65 - float(t.temp_avg))))
for t in temps
if t.temp_avg is not None
}
HOT_WATER_DAILY = Decimal("1.0")
results = []
warnings = 0
for c in sample:
tank_size = float(Decimal(c.tank_size)) if c.tank_size else 275.0
max_fill = Decimal(str(TANK_MAX_FILLS.get(tank_size, DEFAULT_MAX_FILL_GALLONS)))
k = Decimal(str(c.house_factor)) if c.house_factor else Decimal("0.12")
hot_water = c.hot_water_summer == 1
# Accumulate usage from day after last fill through today
total_hdd = Decimal("0")
total_days = 0
cursor = c.last_fill + timedelta(days=1)
missing_temps = []
while cursor <= today:
dd = hdd_by_date.get(cursor)
if dd is None:
missing_temps.append(str(cursor))
else:
total_hdd += dd
total_days += 1
cursor += timedelta(days=1)
heating_used = k * total_hdd
hot_water_used = HOT_WATER_DAILY * total_days if hot_water else Decimal("0")
expected = max(Decimal("0"), max_fill - heating_used - hot_water_used)
actual = Decimal(str(c.estimated_gallons_left)) if c.estimated_gallons_left is not None else Decimal("0")
drift = float(actual - expected)
is_warning = abs(drift) > 15
if is_warning:
warnings += 1
results.append({
"customer_id": c.customer_id,
"name": c.customer_full_name,
"town": c.customer_town,
"tank_size": c.tank_size,
"k_factor": float(k),
"k_factor_source": c.k_factor_source,
"hot_water": hot_water,
"last_fill": str(c.last_fill),
"days_since_fill": total_days,
"total_hdd": float(total_hdd),
"max_fill": float(max_fill),
"expected_gallons": round(float(expected), 1),
"actual_gallons": float(actual),
"drift_gallons": round(drift, 1),
"status": "WARNING" if is_warning else "OK",
"missing_temp_dates": missing_temps,
})
results.sort(key=lambda r: abs(r["drift_gallons"]), reverse=True)
return {
"ok": True,
"checked": len(results),
"warnings": warnings,
"summary": "All OK" if warnings == 0 else f"{warnings} customer(s) have drift > 15 gal",
"customers": results,
}
except Exception as e:
logger.error(f"verify endpoint error: {e}")
raise HTTPException(status_code=500, detail=str(e))