From c2d9dd5408f5caa5967c7759886cae781bcc2ed7 Mon Sep 17 00:00:00 2001 From: Edwin Eames Date: Mon, 5 Jan 2026 08:43:40 -0500 Subject: [PATCH] Working site --- app/models/auto.py | 3 + app/models/customer.py | 41 +- app/routers/{fixstuff.py => fixstuff_auto.py} | 14 +- app/routers/fixstuff_customer.py | 443 ++++++++++++++++++ app/routers/main.py | 24 +- app/script/fuel_estimator.py | 120 +++-- app/script/fuel_estimator_customer.py | 219 +++++++++ main.py | 7 +- 8 files changed, 831 insertions(+), 40 deletions(-) rename app/routers/{fixstuff.py => fixstuff_auto.py} (94%) create mode 100644 app/routers/fixstuff_customer.py create mode 100644 app/script/fuel_estimator_customer.py diff --git a/app/models/auto.py b/app/models/auto.py index 8e31643..3b540f1 100644 --- a/app/models/auto.py +++ b/app/models/auto.py @@ -63,6 +63,9 @@ class Auto_Delivery(Base): hot_water_summer = Column(INTEGER()) + + + class Tickets_Auto_Delivery(Base): __tablename__ = 'auto_tickets' diff --git a/app/models/customer.py b/app/models/customer.py index 5ae81da..97b59df 100644 --- a/app/models/customer.py +++ b/app/models/customer.py @@ -1,4 +1,4 @@ -from sqlalchemy import (Column, Integer, +from sqlalchemy import (Column, Integer,DECIMAL, String, VARCHAR, DATE, INTEGER) @@ -29,3 +29,42 @@ class Customer_Customer(Base): company_id = Column(INTEGER) auth_net_profile_id = Column(String(100)) + + +class Customer_estimate_gallons(Base): + __tablename__ = 'customer_estimate' + + id = Column(Integer, + primary_key=True, + autoincrement=True, + unique=False) + + customer_id = Column(INTEGER()) + account_number = Column(VARCHAR(25)) + customer_town = Column(VARCHAR(140)) + customer_state = Column(INTEGER) + customer_address = Column(VARCHAR(1000)) + customer_zip = Column(VARCHAR(25)) + customer_full_name = Column(VARCHAR(250)) + last_fill = Column(DATE()) + days_since_last_fill = Column(INTEGER()) + last_updated = Column(DATE()) + estimated_gallons_left = Column(DECIMAL(6, 2)) + estimated_gallons_left_prev_day = Column(DECIMAL(6, 2)) + tank_height = Column(VARCHAR(25)) + tank_size = Column(VARCHAR(25)) + house_factor = Column(DECIMAL(5, 2)) + auto_status = Column(INTEGER()) + open_ticket_id = Column(Integer, nullable=True) + hot_water_summer = Column(INTEGER()) + + +class Customer_Update(Base): + __tablename__ = 'customer_update' + + id = Column(Integer, + primary_key=True, + autoincrement=True, + unique=False) + + last_updated = Column(DATE()) diff --git a/app/routers/fixstuff.py b/app/routers/fixstuff_auto.py similarity index 94% rename from app/routers/fixstuff.py rename to app/routers/fixstuff_auto.py index a109be9..ffb9c62 100644 --- a/app/routers/fixstuff.py +++ b/app/routers/fixstuff_auto.py @@ -10,8 +10,9 @@ from app.models.auto import Auto_Delivery, Tickets_Auto_Delivery, Auto_Temp from app.models.delivery import Delivery # Constants from fuel_estimator -HOT_WATER_DAILY_USAGE = Decimal('2.0') +HOT_WATER_DAILY_USAGE = Decimal('1.0') K_FACTOR_SMOOTHING_WEIGHT = Decimal('0.7') +TUNING_FACTOR = Decimal('1.1') @@ -98,13 +99,15 @@ def estimate_customer_gallons(update_db: int): if not tickets: estimated_gallons = Decimal('100') + calculated_scaling = Decimal('0.12') # No deliveries = use average baseline else: last_fill = tickets[-1].fill_date estimated_gallons_left = effective_tank today = date.today() if len(tickets) == 1: - # Single delivery: use weather data for 2000 sq ft home, only heat when temp <=70 + # Single delivery: use weather data for 2000 sq ft home, only heat when temp <=65 + calculated_scaling = Decimal('0.12') if last_fill < today: # Get daily weather data temp_days = session.query(Auto_Temp).filter( @@ -113,7 +116,7 @@ def estimate_customer_gallons(update_db: int): ).all() heating_usage = Decimal('0') hot_water_usage = Decimal('0') - house_factor_2000_sqft = Decimal('0.005') # gallons per degree day + house_factor_2000_sqft = Decimal('0.12') # gallons per degree day (average) for temp in temp_days: degree_day = max(0, 65 - float(temp.temp_avg)) heating_usage += house_factor_2000_sqft * Decimal(degree_day) @@ -150,9 +153,9 @@ def estimate_customer_gallons(update_db: int): average_daily_heating = sum(daily_heating_usages) / len(daily_heating_usages) average_degree_days_per_day = sum(avg_degree_per_days) / len(avg_degree_per_days) house_factor = average_daily_heating / average_degree_days_per_day - calculated_scaling = house_factor + calculated_scaling = house_factor * TUNING_FACTOR else: - house_factor = Decimal('0.005') # Default + house_factor = Decimal('0.12') # Default average calculated_scaling = house_factor # Calculate usage from last_fill to today using temperature-dependent heating @@ -184,6 +187,7 @@ def estimate_customer_gallons(update_db: int): scaling_factor = float(ad.house_factor) if ad.house_factor else None estimates.append({ "id": ad.id, + "total_deliveries": len(tickets), "customer_full_name": ad.customer_full_name, "account_number": ad.account_number, "address": ad.customer_address, diff --git a/app/routers/fixstuff_customer.py b/app/routers/fixstuff_customer.py new file mode 100644 index 0000000..4355c2d --- /dev/null +++ b/app/routers/fixstuff_customer.py @@ -0,0 +1,443 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder +from database import session +from sqlalchemy import func +from datetime import date +from decimal import Decimal + +from app.models.customer import Customer_Customer, Customer_estimate_gallons +from app.models.delivery import Delivery +from app.models.auto import Auto_Temp + +# Constants from fuel_estimator +HOT_WATER_DAILY_USAGE = Decimal('1.0') +K_FACTOR_SMOOTHING_WEIGHT = Decimal('0.7') +TUNING_FACTOR = Decimal('1.1') + +# Maximum fill amounts for different tank sizes (gallons we can actually fill) +TANK_MAX_FILLS = { + 275: 240, + 330: 280, + 500: 475, + 550: 500 +} + + + +router = APIRouter( + prefix="/fixstuff_customer", + tags=["fixstuff_customer"], + responses={404: {"description": "Not found"}}, +) + + + +@router.get("/lastdelivered", status_code=200) +def fix_customer_last_delivered(): + """ + Updates the last_fill date in the customer_estimate table for each customer + by finding the most recent completed delivery (delivery with non-NULL when_delivered) + from the delivery table, matched by customer_id and automatic == 0. + + Returns statistics and a list of changes made. + """ + session.rollback() # Reset any aborted transaction state + customer_estimates = session.query(Customer_estimate_gallons).all() + changes = [] + total_customers = len(customer_estimates) + deliveries_found = 0 + updates_made = 0 + for ce in customer_estimates: + latest_delivery = session.query(Delivery).filter( + Delivery.customer_id == ce.customer_id, + Delivery.automatic == 0, + Delivery.when_delivered.isnot(None) + ).order_by(Delivery.when_delivered.desc()).first() + if latest_delivery: + deliveries_found += 1 + if ce.last_fill != latest_delivery.when_delivered: + updates_made += 1 + old_date = ce.last_fill + ce.last_fill = latest_delivery.when_delivered + changes.append({ + "id": ce.id, + "customer_full_name": ce.customer_full_name, + "before_date": str(old_date) if old_date else None, + "new_date": str(latest_delivery.when_delivered) + }) + session.add(ce) + + session.commit() + result = { + "total_customers": total_customers, + "deliveries_found": deliveries_found, + "updates_made": updates_made, + "changes": changes + } + return JSONResponse(content=jsonable_encoder(result)) + + +@router.get("/estimate_gallons/{update_db}", status_code=200) +def estimate_customer_gallons(update_db: int): + """ + Estimates current gallons for each regular customer based on delivery history and weather. + update_db: 0 for estimation only (no DB changes), 1 for estimation with DB updates. + No deliveries: assume 100 gallons. Single delivery: use weather for 2000 sq ft home. + Multiple deliveries: use historical average. Includes address and scaling factor. + When update_db=1, updates estimated_gallons_left and house_factor in database. + """ + session.rollback() # Reset any aborted transaction state + + # Check if weather data is available + auto_temp_count = session.query(Auto_Temp).count() + if auto_temp_count == 0: + return JSONResponse(content={ + "error": "Auto_Temp table is empty. Cannot perform fuel estimations without weather data.", + "solution": "Populate the Auto_Temp table with weather data first." + }) + + customer_estimates = session.query(Customer_estimate_gallons).all() + estimates = [] + for ce in customer_estimates: + deliveries = session.query(Delivery).filter( + Delivery.customer_id == ce.customer_id, + Delivery.automatic == 0, + Delivery.when_delivered.isnot(None) + ).order_by(Delivery.when_delivered).all() + + # Get tank size and hot water setting + tank_size = Decimal(ce.tank_size) if ce.tank_size else Decimal('275') + # Use maximum fill amount (how much we can actually fill the tank) + tank_size_float = float(tank_size) + max_fill = TANK_MAX_FILLS.get(tank_size_float, tank_size_float) + effective_tank = Decimal(str(max_fill)) + hot_water = ce.hot_water_summer == 1 + + calculated_scaling = None # For DB update + + if not deliveries: + estimated_gallons = Decimal('100') + calculated_scaling = Decimal('0.12') # No deliveries = use average baseline + else: + last_fill = deliveries[-1].when_delivered + estimated_gallons_left = effective_tank + today = date.today() + + if len(deliveries) == 1: + # Single delivery: use weather data for 2000 sq ft home, only heat when temp <=65 + calculated_scaling = Decimal('0.12') + if last_fill < today: + # Get daily weather data + temp_days = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > last_fill, + Auto_Temp.todays_date <= today + ).all() + heating_usage = Decimal('0') + hot_water_usage = Decimal('0') + house_factor_2000_sqft = Decimal('0.12') # gallons per degree day (average) + for temp in temp_days: + degree_day = max(0, 65 - float(temp.temp_avg)) + heating_usage += house_factor_2000_sqft * Decimal(degree_day) + if hot_water: + hot_water_usage += HOT_WATER_DAILY_USAGE + total_usage = heating_usage + hot_water_usage + estimated_gallons_left = max(Decimal('0'), estimated_gallons_left - total_usage) + else: + # Multiple deliveries: calculate house_factor (gallons per degree day) + calculated_scaling = Decimal('0.12') # Default average + daily_heating_usages = [] + avg_degree_per_days = [] + for i in range(len(deliveries) - 1): + prev_delivery = deliveries[i] + next_delivery = deliveries[i + 1] + days = (next_delivery.when_delivered - prev_delivery.when_delivered).days + if days > 0: + # Calculate degree days for this interval from temp_avg + interval_temps = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > prev_delivery.when_delivered, + Auto_Temp.todays_date <= next_delivery.when_delivered + ).all() + total_degree_days = sum(max(0, 65 - float(temp.temp_avg)) for temp in interval_temps) + total_degree_days = Decimal(total_degree_days) + avg_degree_per_day = total_degree_days / days + + total_hot_water = HOT_WATER_DAILY_USAGE * days + gallons_heating = prev_delivery.gallons_delivered - total_hot_water + if gallons_heating > 0 and total_degree_days > 0: + daily_heating = gallons_heating / days + daily_heating_usages.append(daily_heating) + avg_degree_per_days.append(avg_degree_per_day) + + if daily_heating_usages and avg_degree_per_days: + average_daily_heating = sum(daily_heating_usages) / len(daily_heating_usages) + average_degree_days_per_day = sum(avg_degree_per_days) / len(avg_degree_per_days) + if average_degree_days_per_day > 0: + house_factor = average_daily_heating / average_degree_days_per_day + calculated_scaling = house_factor * TUNING_FACTOR # Override default with calculated value + + house_factor = calculated_scaling # Use the scaling factor for usage calculation + + # Calculate usage from last_fill to today using temperature-dependent heating + if last_fill < today: + temp_days = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > last_fill, + Auto_Temp.todays_date <= today + ).all() + heating_usage = Decimal('0') + hot_water_usage = Decimal('0') + for temp in temp_days: + degree_day = max(0, 65 - float(temp.temp_avg)) + heating_usage += house_factor * Decimal(degree_day) + if hot_water: + hot_water_usage += HOT_WATER_DAILY_USAGE + total_usage = heating_usage + hot_water_usage + estimated_gallons_left = max(Decimal('0'), estimated_gallons_left - total_usage) + + estimated_gallons = estimated_gallons_left + + # Update database if requested + if update_db == 1: + ce.estimated_gallons_left = estimated_gallons + if calculated_scaling is not None: + ce.house_factor = calculated_scaling + session.add(ce) + + last_5 = deliveries[-5:] if deliveries else [] + scaling_factor = float(calculated_scaling) if calculated_scaling is not None else (float(ce.house_factor) if ce.house_factor else None) + estimates.append({ + "id": ce.id, + "total_deliveries": len(deliveries), + "customer_full_name": ce.customer_full_name, + "account_number": ce.account_number, + "address": ce.customer_address, + "estimated_gallons": float(estimated_gallons), + "scaling_factor": scaling_factor, + "last_5_deliveries": [ + { + "fill_date": str(d.when_delivered), + "gallons_delivered": float(d.gallons_delivered), + "price_per_gallon": float(d.customer_price / d.gallons_delivered) if d.gallons_delivered and d.gallons_delivered > 0 else None, + "total_amount_customer": float(d.customer_price) + } for d in last_5 + ] + }) + + if update_db == 1: + session.commit() + + return JSONResponse(content=jsonable_encoder(estimates)) + + +@router.get("/estimate_gallons/customer/{customer_id}", status_code=200) +def estimate_customer_gallons_specific(customer_id: int): + """ + Estimates current gallons for a specific regular customer based on delivery history and weather. + Returns estimation data for the specified customer only. + """ + session.rollback() # Reset any aborted transaction state + + # Check if weather data is available + auto_temp_count = session.query(Auto_Temp).count() + if auto_temp_count == 0: + return JSONResponse(content={ + "error": "Auto_Temp table is empty. Cannot perform fuel estimations without weather data.", + "solution": "Populate the Auto_Temp table with weather data first." + }) + + customer_estimate = session.query(Customer_estimate_gallons).filter( + Customer_estimate_gallons.customer_id == customer_id + ).first() + + if not customer_estimate: + return JSONResponse(content={ + "error": f"No fuel estimation data found for customer {customer_id}", + "solution": "Run the populate_estimates endpoint first to initialize customer data." + }) + + deliveries = session.query(Delivery).filter( + Delivery.customer_id == customer_estimate.customer_id, + Delivery.automatic == 0, + Delivery.when_delivered.isnot(None) + ).order_by(Delivery.when_delivered).all() + + # Get tank size and hot water setting + tank_size = Decimal(customer_estimate.tank_size) if customer_estimate.tank_size else Decimal('275') + # Use maximum fill amount (how much we can actually fill the tank) + tank_size_float = float(tank_size) + max_fill = TANK_MAX_FILLS.get(tank_size_float, tank_size_float) + effective_tank = Decimal(str(max_fill)) + hot_water = customer_estimate.hot_water_summer == 1 + + calculated_scaling = None # For DB update + + if not deliveries: + estimated_gallons = Decimal('100') + calculated_scaling = Decimal('0.12') # No deliveries = use average baseline + else: + last_fill = deliveries[-1].when_delivered + estimated_gallons_left = effective_tank + today = date.today() + + if len(deliveries) == 1: + # Single delivery: use weather data for 2000 sq ft home, only heat when temp <=65 + calculated_scaling = Decimal('0.12') + if last_fill < today: + # Get daily weather data + temp_days = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > last_fill, + Auto_Temp.todays_date <= today + ).all() + heating_usage = Decimal('0') + hot_water_usage = Decimal('0') + house_factor_2000_sqft = Decimal('0.12') # gallons per degree day (average) + for temp in temp_days: + degree_day = max(0, 65 - float(temp.temp_avg)) + heating_usage += house_factor_2000_sqft * Decimal(degree_day) + if hot_water: + hot_water_usage += HOT_WATER_DAILY_USAGE + total_usage = heating_usage + hot_water_usage + estimated_gallons_left = max(Decimal('0'), estimated_gallons_left - total_usage) + else: + # Multiple deliveries: calculate house_factor (gallons per degree day) + calculated_scaling = Decimal('0.12') # Default average + daily_heating_usages = [] + avg_degree_per_days = [] + for i in range(len(deliveries) - 1): + prev_delivery = deliveries[i] + next_delivery = deliveries[i + 1] + days = (next_delivery.when_delivered - prev_delivery.when_delivered).days + if days > 0: + # Calculate degree days for this interval from temp_avg + interval_temps = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > prev_delivery.when_delivered, + Auto_Temp.todays_date <= next_delivery.when_delivered + ).all() + total_degree_days = sum(max(0, 65 - float(temp.temp_avg)) for temp in interval_temps) + total_degree_days = Decimal(total_degree_days) + avg_degree_per_day = total_degree_days / days + + total_hot_water = HOT_WATER_DAILY_USAGE * days + gallons_heating = prev_delivery.gallons_delivered - total_hot_water + if gallons_heating > 0 and total_degree_days > 0: + daily_heating = gallons_heating / days + daily_heating_usages.append(daily_heating) + avg_degree_per_days.append(avg_degree_per_day) + + if daily_heating_usages and avg_degree_per_days: + average_daily_heating = sum(daily_heating_usages) / len(daily_heating_usages) + average_degree_days_per_day = sum(avg_degree_per_days) / len(avg_degree_per_days) + if average_degree_days_per_day > 0: + house_factor = average_daily_heating / average_degree_days_per_day + calculated_scaling = house_factor * TUNING_FACTOR # Override default with calculated value + + house_factor = calculated_scaling # Use the scaling factor for usage calculation + + # Calculate usage from last_fill to today using temperature-dependent heating + if last_fill < today: + temp_days = session.query(Auto_Temp).filter( + Auto_Temp.todays_date > last_fill, + Auto_Temp.todays_date <= today + ).all() + heating_usage = Decimal('0') + hot_water_usage = Decimal('0') + for temp in temp_days: + degree_day = max(0, 65 - float(temp.temp_avg)) + heating_usage += house_factor * Decimal(degree_day) + if hot_water: + hot_water_usage += HOT_WATER_DAILY_USAGE + total_usage = heating_usage + hot_water_usage + estimated_gallons_left = max(Decimal('0'), estimated_gallons_left - total_usage) + + estimated_gallons = estimated_gallons_left + + last_5 = deliveries[-5:] if deliveries else [] + scaling_factor = float(calculated_scaling) if calculated_scaling is not None else (float(customer_estimate.house_factor) if customer_estimate.house_factor else None) + + result = { + "id": customer_estimate.id, + "customer_id": customer_estimate.customer_id, + "total_deliveries": len(deliveries), + "customer_full_name": customer_estimate.customer_full_name, + "account_number": customer_estimate.account_number, + "address": customer_estimate.customer_address, + "estimated_gallons": float(estimated_gallons), + "tank_size": float(tank_size), + "scaling_factor": scaling_factor, + "last_5_deliveries": [ + { + "fill_date": str(d.when_delivered), + "gallons_delivered": float(d.gallons_delivered), + "price_per_gallon": float(d.customer_price / d.gallons_delivered) if d.gallons_delivered and d.gallons_delivered > 0 else None, + "total_amount_customer": float(d.customer_price) + } for d in last_5 + ] + } + + return JSONResponse(content=jsonable_encoder(result)) + + +@router.get("/populate_estimates", status_code=200) +def populate_customer_estimates(): + """ + Populates the customer_estimate table with data from customer_customer for regular (non-automatic) customers. + Only creates records for customers that don't already exist in customer_estimate. + Sets default values for fuel estimation fields. + + Returns statistics on records created. + """ + session.rollback() # Reset any aborted transaction state + + # Get all regular customers (customer_automatic == 0) + regular_customers = session.query(Customer_Customer).filter( + Customer_Customer.customer_automatic == 0 + ).all() + + records_created = 0 + skipped_existing = 0 + + for customer in regular_customers: + # Check if estimate record already exists + existing_estimate = session.query(Customer_estimate_gallons).filter( + Customer_estimate_gallons.customer_id == customer.id + ).first() + + if existing_estimate: + skipped_existing += 1 + continue + + # Create new estimate record with defaults + new_estimate = Customer_estimate_gallons( + customer_id=customer.id, + account_number=customer.account_number, + customer_town=customer.customer_town, + customer_state=customer.customer_state, + customer_address=customer.customer_address, + customer_zip=customer.customer_zip, + customer_full_name=f"{customer.customer_first_name} {customer.customer_last_name}".strip(), + last_fill=None, + days_since_last_fill=None, + last_updated=None, + estimated_gallons_left=Decimal('100'), # Default starting value + estimated_gallons_left_prev_day=Decimal('100'), + tank_height=None, + tank_size='275', # Default tank size + house_factor=None, + auto_status=1, # Active + open_ticket_id=None, + hot_water_summer=0 # Default to no hot water heating + ) + + session.add(new_estimate) + records_created += 1 + + session.commit() + + result = { + "total_regular_customers": len(regular_customers), + "records_created": records_created, + "skipped_existing": skipped_existing, + "message": f"Created {records_created} new customer estimate records" + } + + return JSONResponse(content=jsonable_encoder(result)) diff --git a/app/routers/main.py b/app/routers/main.py index 7e285fb..0dc5581 100644 --- a/app/routers/main.py +++ b/app/routers/main.py @@ -3,7 +3,7 @@ from database import session 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 router = APIRouter( prefix="/main", tags=["main"], @@ -31,8 +31,8 @@ def update_temp_manually(): -@router.get("/update", status_code=200) -def update_all_customer_fuel_levels(): +@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. @@ -47,3 +47,21 @@ def update_all_customer_fuel_levels(): # Log the exception e print(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 + print(str(e)) + return {"ok": False, "message": "An internal error occurred."} diff --git a/app/script/fuel_estimator.py b/app/script/fuel_estimator.py index 21132ce..84df9b7 100644 --- a/app/script/fuel_estimator.py +++ b/app/script/fuel_estimator.py @@ -9,12 +9,21 @@ from app.models.auto import Auto_Delivery, Auto_Temp, Auto_Update, Tickets_Auto_ # --- Constants for the Model --- # This is a baseline daily usage for homes that use oil for hot water. # A typical value is 0.5 to 1.0 gallons per day. Adjust as needed. -HOT_WATER_DAILY_USAGE = Decimal('0.7') +HOT_WATER_DAILY_USAGE = Decimal('1.0') # This determines how quickly the K-Factor adjusts. # 0.7 means 70% weight is given to the historical factor and 30% to the new one. # This prevents wild swings from a single unusual delivery period. -K_FACTOR_SMOOTHING_WEIGHT = Decimal('0.7') +K_FACTOR_SMOOTHING_WEIGHT = Decimal('0.7') + +TANK_MAX_FILLS = { + 275: 240, + 330: 280, + 500: 475, + 550: 500 +} + +PARTIAL_DELIVERIES = [100, 125, 150, 200] class FuelEstimator: @@ -25,6 +34,37 @@ class FuelEstimator: """Helper to fetch weather data for a specific date.""" return self.session.query(Auto_Temp).filter(Auto_Temp.todays_date == target_date).first() + def _estimate_initial_house_factor(self, customer: Auto_Delivery) -> Decimal: + """ + Generic function to estimate initial house factor for customers with only one delivery. + This can be improved with more sophisticated logic (e.g., averaging similar customers). + """ + # Default generic house factor: 0.12 gallons per degree day (average based on existing customer data) + # This represents typical heating usage and can be adjusted based on future data analysis + return Decimal('0.12') + + def _verify_house_factor_correctness(self, customer: Auto_Delivery) -> bool: + """ + Verify and correct house_factor based on delivery history. + Returns True if correction was made. + """ + # Count deliveries for this customer + delivery_count = self.session.query(func.count(Tickets_Auto_Delivery.id)).filter( + Tickets_Auto_Delivery.customer_id == customer.customer_id + ).scalar() + + corrected = False + + if delivery_count <= 1: + # Customers with 0 or 1 delivery should have house_factor = 0.12 (initial average) + if customer.house_factor != Decimal('0.12'): + print(f"Correcting house_factor for customer {customer.customer_id} from {customer.house_factor} to 0.12 (1 or fewer deliveries)") + customer.house_factor = Decimal('0.12') + corrected = True + # For customers with 2+ deliveries, keep their calculated factor (no correction needed) + + return corrected + def run_daily_update(self): """ Main function to run once per day. It updates the estimated fuel level @@ -43,8 +83,8 @@ class FuelEstimator: print(f"Error: Weather data for {today} not found. Cannot run update.") return {"ok": False, "message": f"Weather data for {today} not found."} - # Degree days can't be negative for this calculation. If it's warm, HDD is 0. - degree_day = Decimal(max(0, todays_weather.degree_day)) + # Degree days can't be negative for this calculation. If it's warm, HDD = 0. + degree_day = Decimal(max(0, 65 - float(todays_weather.temp_avg))) # 3. Get all active automatic customers auto_customers = self.session.query(Auto_Delivery).filter( @@ -54,19 +94,25 @@ class FuelEstimator: if not auto_customers: print("No active automatic delivery customers found.") return {"ok": True, "message": "No active customers to update."} - + print(f"Staging daily fuel update for {len(auto_customers)} customers...") - + + corrections_made = 0 + # 4. Loop through each customer and update their fuel level for customer in auto_customers: + # Verify and correct house_factor if needed + if self._verify_house_factor_correctness(customer): + corrections_made += 1 + heating_usage = customer.house_factor * degree_day - + hot_water_usage = Decimal('0.0') if customer.hot_water_summer == 1: hot_water_usage = HOT_WATER_DAILY_USAGE - + gallons_used_today = heating_usage + hot_water_usage - + customer.estimated_gallons_left_prev_day = customer.estimated_gallons_left new_estimated_gallons = customer.estimated_gallons_left - gallons_used_today customer.estimated_gallons_left = max(Decimal('0.0'), new_estimated_gallons) @@ -77,9 +123,13 @@ class FuelEstimator: # 5. Log that today's update is complete new_update_log = Auto_Update(last_updated=today) self.session.add(new_update_log) - + print("Daily update staged. Awaiting commit.") - return {"ok": True, "message": f"Successfully staged updates for {len(auto_customers)} customers."} + message = f"Successfully staged updates for {len(auto_customers)} customers." + if corrections_made > 0: + message += f" Corrected house factors for {corrections_made} customers." + + return {"ok": True, "message": message} def refine_factor_after_delivery(self, ticket: Tickets_Auto_Delivery): """ @@ -90,8 +140,13 @@ class FuelEstimator: Auto_Delivery.customer_id == ticket.customer_id ).first() - if not customer or not customer.last_fill: - print(f"Cannot refine K-Factor: Customer {ticket.customer_id} not found or has no previous fill date. Resetting tank only.") + if not customer: + print(f"Customer {ticket.customer_id} not found.") + return + + if not customer.last_fill: + print(f"Setting initial K-Factor for new customer {ticket.customer_id} with only one delivery.") + customer.house_factor = self._estimate_initial_house_factor(customer) self._update_tank_after_fill(customer, ticket) return @@ -103,13 +158,12 @@ class FuelEstimator: self._update_tank_after_fill(customer, ticket) return - total_hdd_result = self.session.query(func.sum(Auto_Temp.degree_day)).filter( + interval_temps = self.session.query(Auto_Temp).filter( Auto_Temp.todays_date > start_date, - Auto_Temp.todays_date <= end_date, - Auto_Temp.degree_day > 0 - ).scalar() - - total_hdd = Decimal(total_hdd_result or 0) + Auto_Temp.todays_date <= end_date + ).all() + total_degree_days = sum(max(0, 65 - float(temp.temp_avg)) for temp in interval_temps) + total_hdd = Decimal(total_degree_days) total_hot_water_usage = Decimal('0.0') if customer.hot_water_summer == 1: @@ -136,18 +190,28 @@ class FuelEstimator: print(f"K-Factor and tank status for Customer {customer.customer_id} staged for update.") def _update_tank_after_fill(self, customer: Auto_Delivery, ticket: Tickets_Auto_Delivery): - """Helper to reset customer tank status after a fill-up.""" + """Helper to update customer tank status after a fill-up or partial delivery.""" customer.last_fill = ticket.fill_date customer.days_since_last_fill = 0 - - # A "fill-up" means the tank is full. This is critical for accuracy. + + # Determine max fill capacity if customer.tank_size and Decimal(customer.tank_size) > 0: - customer.estimated_gallons_left = Decimal(customer.tank_size) + tank_size = float(Decimal(customer.tank_size)) + max_fill = TANK_MAX_FILLS.get(tank_size, tank_size) else: - # Default to a common tank size if not specified, e.g., 275 - customer.estimated_gallons_left = Decimal('275.0') - - # The previous day's value should match the new full value on a fill day. + # Default to legal max for common tank size (275 gallons = 240) + max_fill = 240.0 + + # Check if this is a partial delivery + if float(ticket.gallons_delivered) in PARTIAL_DELIVERIES: + # Partial delivery: add to current level, cap at max_fill + customer.estimated_gallons_left += ticket.gallons_delivered + customer.estimated_gallons_left = min(customer.estimated_gallons_left, Decimal(str(max_fill))) + else: + # Full delivery: set to max_fill + customer.estimated_gallons_left = Decimal(str(max_fill)) + + # The previous day's value should match the new value on a fill day. customer.estimated_gallons_left_prev_day = customer.estimated_gallons_left customer.last_updated = date.today() - customer.auto_status = 1 # Reactivate the customer \ No newline at end of file + customer.auto_status = 1 # Reactivate the customer diff --git a/app/script/fuel_estimator_customer.py b/app/script/fuel_estimator_customer.py new file mode 100644 index 0000000..e904219 --- /dev/null +++ b/app/script/fuel_estimator_customer.py @@ -0,0 +1,219 @@ +from sqlalchemy.orm import Session +from sqlalchemy import func +from datetime import date, timedelta +from decimal import Decimal + +# Import your existing database models +from app.models.customer import Customer_estimate_gallons, Customer_Update +from app.models.delivery import Delivery +from app.models.auto import Auto_Temp + +# --- Constants for the Model --- +# This is a baseline daily usage for homes that use oil for hot water. +# A typical value is 0.5 to 1.0 gallons per day. Adjust as needed. +HOT_WATER_DAILY_USAGE = Decimal('1.0') + +# This determines how quickly the K-Factor adjusts. +# 0.7 means 70% weight is given to the historical factor and 30% to the new one. +# This prevents wild swings from a single unusual delivery period. +K_FACTOR_SMOOTHING_WEIGHT = Decimal('0.7') + +TANK_MAX_FILLS = { + 275: 240, + 330: 280, + 500: 475, + 550: 500 +} + +PARTIAL_DELIVERIES = [100, 125, 150, 200] + + +class FuelEstimatorCustomer: + def __init__(self, session: Session): + self.session = session + + def _get_weather_for_date(self, target_date: date) -> Auto_Temp | None: + """Helper to fetch weather data for a specific date.""" + return self.session.query(Auto_Temp).filter(Auto_Temp.todays_date == target_date).first() + + def _estimate_initial_house_factor(self, customer: Customer_estimate_gallons) -> Decimal: + """ + Generic function to estimate initial house factor for customers with only one delivery. + This can be improved with more sophisticated logic (e.g., averaging similar customers). + """ + # Default generic house factor: 0.12 gallons per degree day (average based on existing customer data) + # This represents typical heating usage and can be adjusted based on future data analysis + return Decimal('0.12') + + def _verify_house_factor_correctness(self, customer: Customer_estimate_gallons) -> bool: + """ + Verify and correct house_factor based on delivery history. + Returns True if correction was made. + """ + # Count deliveries for this customer + delivery_count = self.session.query(func.count(Delivery.id)).filter( + Delivery.customer_id == customer.customer_id + ).scalar() + + corrected = False + + if delivery_count <= 1: + # Customers with 0 or 1 delivery should have house_factor = 0.12 (initial average) + if customer.house_factor != Decimal('0.12'): + print(f"Correcting house_factor for customer {customer.customer_id} from {customer.house_factor} to 0.12 (1 or fewer deliveries)") + customer.house_factor = Decimal('0.12') + corrected = True + # For customers with 2+ deliveries, keep their calculated factor (no correction needed) + + return corrected + + def run_daily_update(self): + """ + Main function to run once per day. It updates the estimated fuel level + for all active regular customers. The calling function must commit the session. + """ + today = date.today() + + # 1. Check if the update has already run today + if self.session.query(Customer_Update).filter(Customer_Update.last_updated == today).first(): + print(f"Daily update for {today} has already been completed.") + return {"ok": True, "message": "Update already run today."} + + # 2. Get today's weather data (specifically the Heating Degree Days) + todays_weather = self._get_weather_for_date(today) + if not todays_weather: + print(f"Error: Weather data for {today} not found. Cannot run update.") + return {"ok": False, "message": f"Weather data for {today} not found."} + + # Degree days can't be negative for this calculation. If it's warm, HDD = 0. + degree_day = Decimal(max(0, 65 - float(todays_weather.temp_avg))) + + # 3. Get all active regular customers + customer_estimates = self.session.query(Customer_estimate_gallons).filter( + Customer_estimate_gallons.auto_status == 1 # Assuming 1 means active + ).all() + + if not customer_estimates: + print("No active regular delivery customers found.") + return {"ok": True, "message": "No active customers to update."} + + print(f"Staging daily fuel update for {len(customer_estimates)} customers...") + + corrections_made = 0 + + # 4. Loop through each customer and update their fuel level + for customer in customer_estimates: + # Verify and correct house_factor if needed + if self._verify_house_factor_correctness(customer): + corrections_made += 1 + + heating_usage = customer.house_factor * degree_day + + hot_water_usage = Decimal('0.0') + if customer.hot_water_summer == 1: + hot_water_usage = HOT_WATER_DAILY_USAGE + + gallons_used_today = heating_usage + hot_water_usage + + customer.estimated_gallons_left_prev_day = customer.estimated_gallons_left + new_estimated_gallons = customer.estimated_gallons_left - gallons_used_today + customer.estimated_gallons_left = max(Decimal('0.0'), new_estimated_gallons) + customer.last_updated = today + if customer.days_since_last_fill is not None: + customer.days_since_last_fill += 1 + + # 5. Log that today's update is complete + new_update_log = Customer_Update(last_updated=today) + self.session.add(new_update_log) + + print("Daily update staged. Awaiting commit.") + message = f"Successfully staged updates for {len(customer_estimates)} customers." + if corrections_made > 0: + message += f" Corrected house factors for {corrections_made} customers." + + return {"ok": True, "message": message} + + def refine_factor_after_delivery(self, delivery: Delivery): + """ + This is the self-correction logic. It recalculates and refines the customer's + K-Factor (house_factor) after a delivery. The calling function must commit the session. + """ + customer = self.session.query(Customer_estimate_gallons).filter( + Customer_estimate_gallons.customer_id == delivery.customer_id + ).first() + + if not customer: + print(f"Customer {delivery.customer_id} not found.") + return + + if not customer.last_fill: + print(f"Setting initial K-Factor for new customer {delivery.customer_id} with only one delivery.") + customer.house_factor = self._estimate_initial_house_factor(customer) + self._update_tank_after_fill(customer, delivery) + return + + start_date = customer.last_fill + end_date = delivery.when_delivered + + if start_date >= end_date: + print(f"Cannot refine K-Factor for customer {delivery.customer_id}: New fill date is not after the last one. Resetting tank only.") + self._update_tank_after_fill(customer, delivery) + return + + interval_temps = self.session.query(Auto_Temp).filter( + Auto_Temp.todays_date > start_date, + Auto_Temp.todays_date <= end_date + ).all() + total_degree_days = sum(max(0, 65 - float(temp.temp_avg)) for temp in interval_temps) + total_hdd = Decimal(total_degree_days) + + total_hot_water_usage = Decimal('0.0') + if customer.hot_water_summer == 1: + num_days = (end_date - start_date).days + total_hot_water_usage = Decimal(num_days) * HOT_WATER_DAILY_USAGE + + gallons_for_heating = delivery.gallons_delivered - total_hot_water_usage + if gallons_for_heating <= 0 or total_hdd == 0: + print(f"Cannot calculate new K-Factor for customer {delivery.customer_id}. (HDD: {total_hdd}, Heating Gallons: {gallons_for_heating}). Resetting tank only.") + self._update_tank_after_fill(customer, delivery) + return + + new_k_factor = gallons_for_heating / total_hdd + + current_k_factor = customer.house_factor + smoothed_k_factor = (current_k_factor * K_FACTOR_SMOOTHING_WEIGHT) + (new_k_factor * (Decimal('1.0') - K_FACTOR_SMOOTHING_WEIGHT)) + + print(f"Refining K-Factor for Customer ID {customer.customer_id}:") + print(f" - Old K-Factor: {current_k_factor:.4f}, New Smoothed K-Factor: {smoothed_k_factor:.4f}") + + customer.house_factor = smoothed_k_factor + self._update_tank_after_fill(customer, delivery) + + print(f"K-Factor and tank status for Customer {customer.customer_id} staged for update.") + + def _update_tank_after_fill(self, customer: Customer_estimate_gallons, delivery: Delivery): + """Helper to update customer tank status after a fill-up or partial delivery.""" + customer.last_fill = delivery.when_delivered + customer.days_since_last_fill = 0 + + # Determine max fill capacity + if customer.tank_size and Decimal(customer.tank_size) > 0: + tank_size = float(Decimal(customer.tank_size)) + max_fill = TANK_MAX_FILLS.get(tank_size, tank_size) + else: + # Default to legal max for common tank size (275 gallons = 240) + max_fill = 240.0 + + # Check if this is a partial delivery + if float(delivery.gallons_delivered) in PARTIAL_DELIVERIES: + # Partial delivery: add to current level, cap at max_fill + customer.estimated_gallons_left += delivery.gallons_delivered + customer.estimated_gallons_left = min(customer.estimated_gallons_left, Decimal(str(max_fill))) + else: + # Full delivery: set to max_fill + customer.estimated_gallons_left = Decimal(str(max_fill)) + + # The previous day's value should match the new value on a fill day. + customer.estimated_gallons_left_prev_day = customer.estimated_gallons_left + customer.last_updated = date.today() + customer.auto_status = 1 # Reactivate the customer diff --git a/main.py b/main.py index c961a1b..d74c06b 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ +from app.routers import fixstuff_auto from fastapi import FastAPI -from app.routers import main, delivery, confirm, fixstuff +from app.routers import main, delivery, confirm, fixstuff_customer from fastapi.middleware.cors import CORSMiddleware import os from config import load_config @@ -14,8 +15,8 @@ app = FastAPI() app.include_router(main.router) app.include_router(delivery.router) app.include_router(confirm.router) -app.include_router(fixstuff.router) - +app.include_router(fixstuff_auto.router) +app.include_router(fixstuff_customer.router) # print(ApplicationConfig.origins) app.add_middleware(