Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
161
backend/utils/time_alignment.py
Normal file
161
backend/utils/time_alignment.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""
|
||||
Time alignment utilities for prior year comparisons.
|
||||
|
||||
Ensures consistent comparison logic across the application:
|
||||
- Daily: 364 days (52 weeks) for day-of-week alignment (Mon→Mon, Sat→Sat)
|
||||
- Weekly: ISO week number matching (Week 6 2026 vs Week 6 2025)
|
||||
- Monthly: Same month, prior year
|
||||
|
||||
This module should be used whenever comparing to prior year data.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
def get_prior_year_daily(target_date: date) -> date:
|
||||
"""
|
||||
Get the comparable date from prior year for daily comparisons.
|
||||
|
||||
Uses 364 days (exactly 52 weeks) to ensure day-of-week alignment:
|
||||
- Monday → Monday
|
||||
- Saturday → Saturday
|
||||
|
||||
Example:
|
||||
Wed 11 Feb 2026 → Wed 12 Feb 2025 (not 11 Feb 2025 which was a different day)
|
||||
|
||||
Args:
|
||||
target_date: The date to find comparison for
|
||||
|
||||
Returns:
|
||||
The prior year date with same day of week
|
||||
"""
|
||||
return target_date - timedelta(days=364)
|
||||
|
||||
|
||||
def get_prior_year_weekly(target_date: date) -> Tuple[int, int]:
|
||||
"""
|
||||
Get the ISO week and year for weekly year-over-year comparison.
|
||||
|
||||
Uses ISO week numbers so Week 6 of 2026 compares to Week 6 of 2025.
|
||||
This ensures full weeks are compared (Mon-Sun) regardless of calendar dates.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target week
|
||||
|
||||
Returns:
|
||||
Tuple of (year, week_number) for the comparison week
|
||||
"""
|
||||
iso_cal = target_date.isocalendar()
|
||||
return (iso_cal.year - 1, iso_cal.week)
|
||||
|
||||
|
||||
def get_prior_year_week_dates(target_date: date) -> Tuple[date, date]:
|
||||
"""
|
||||
Get the start and end dates of the same ISO week in the prior year.
|
||||
|
||||
Useful for querying data for the entire comparison week.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target week
|
||||
|
||||
Returns:
|
||||
Tuple of (week_start, week_end) for the prior year's matching week
|
||||
"""
|
||||
iso_cal = target_date.isocalendar()
|
||||
prior_year = iso_cal.year - 1
|
||||
prior_week = iso_cal.week
|
||||
|
||||
# Handle edge case: if prior year doesn't have this week number
|
||||
# (can happen with week 53), fall back to last week of prior year
|
||||
try:
|
||||
# Find the first day of the target week in prior year
|
||||
# Week 1 day 1 of the prior year
|
||||
jan_1_prior = date(prior_year, 1, 1)
|
||||
jan_1_iso = jan_1_prior.isocalendar()
|
||||
|
||||
# Calculate days to add to get to the target week
|
||||
# First, get to week 1 day 1
|
||||
days_to_week_1 = (1 - jan_1_iso.weekday) % 7
|
||||
week_1_monday = jan_1_prior + timedelta(days=days_to_week_1)
|
||||
|
||||
# Adjust if Jan 1 is in the previous year's last week
|
||||
if jan_1_iso.week != 1:
|
||||
week_1_monday = jan_1_prior + timedelta(days=(7 - jan_1_prior.weekday()))
|
||||
|
||||
# Now add weeks to get to target week
|
||||
week_start = week_1_monday + timedelta(weeks=prior_week - 1)
|
||||
|
||||
# Verify we got the right week
|
||||
if week_start.isocalendar().week != prior_week:
|
||||
# Fallback: use last week of prior year
|
||||
dec_28_prior = date(prior_year, 12, 28) # Always in last week
|
||||
dec_28_iso = dec_28_prior.isocalendar()
|
||||
week_start = dec_28_prior - timedelta(days=dec_28_prior.weekday())
|
||||
|
||||
week_end = week_start + timedelta(days=6)
|
||||
return (week_start, week_end)
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
# Fallback to simpler calculation
|
||||
prior_date = get_prior_year_daily(target_date)
|
||||
week_start = prior_date - timedelta(days=prior_date.weekday())
|
||||
week_end = week_start + timedelta(days=6)
|
||||
return (week_start, week_end)
|
||||
|
||||
|
||||
def get_prior_year_monthly(target_date: date) -> Tuple[date, date]:
|
||||
"""
|
||||
Get the start and end dates of the same month in the prior year.
|
||||
|
||||
Args:
|
||||
target_date: Any date within the target month
|
||||
|
||||
Returns:
|
||||
Tuple of (month_start, month_end) for the prior year's matching month
|
||||
"""
|
||||
prior_year = target_date.year - 1
|
||||
month = target_date.month
|
||||
|
||||
# First day of the month
|
||||
month_start = date(prior_year, month, 1)
|
||||
|
||||
# Last day of the month
|
||||
if month == 12:
|
||||
month_end = date(prior_year + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
month_end = date(prior_year, month + 1, 1) - timedelta(days=1)
|
||||
|
||||
return (month_start, month_end)
|
||||
|
||||
|
||||
# SQL helper constants for use in queries
|
||||
SQL_PRIOR_YEAR_DAILY = "INTERVAL '364 days'" # For daily comparisons
|
||||
SQL_PRIOR_YEAR_OFFSET = 364 # Days offset for daily DOW alignment
|
||||
|
||||
|
||||
def get_comparison_info(target_date: date) -> dict:
|
||||
"""
|
||||
Get formatted comparison information for display.
|
||||
|
||||
Useful for showing users what date/week is being compared.
|
||||
|
||||
Args:
|
||||
target_date: The date being forecasted/analyzed
|
||||
|
||||
Returns:
|
||||
Dict with comparison details
|
||||
"""
|
||||
prior_daily = get_prior_year_daily(target_date)
|
||||
prior_week_year, prior_week_num = get_prior_year_weekly(target_date)
|
||||
|
||||
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
return {
|
||||
"target_date": target_date,
|
||||
"target_day": day_names[target_date.weekday()],
|
||||
"target_iso_week": target_date.isocalendar().week,
|
||||
"prior_year_date": prior_daily,
|
||||
"prior_year_day": day_names[prior_daily.weekday()], # Should match target_day
|
||||
"prior_year_iso_week": prior_week_num,
|
||||
"comparison_note": f"vs {day_names[prior_daily.weekday()]} {prior_daily.strftime('%d %b %Y')}"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue