order-forecast / app /utils /data.py
sviatoslav948
Claude Fable 5
Прогноз: покрытие регионов, офлайн-календарь, граница окна выработки
acd4123
Raw History Blame Contribute Delete
10.2 kB
# -*- coding: utf-8 -*-
import os
import pandas as pd
import numpy as np
import requests
import io
TYPE_META = {
"Доставка за 1 час": {"short": "1 час", "color": "#22c55e", "order": 0},
"Доставка за 2 час": {"short": "2 часа", "color": "#f59e0b", "order": 1},
"Доставка за 30 мин": {"short": "30 мин", "color": "#a855f7", "order": 2},
"Экспресс": {"short": "Экспресс", "color": "#ec4899", "order": 3},
"Остальное": {"short": "Остальное","color": "#f97316", "order": 4},
}
def load_orders(file) -> pd.DataFrame:
"""Читает xlsx/csv, возвращает: date, type, count."""
if hasattr(file, "name"):
name = file.name
else:
name = str(file)
if name.endswith(".xlsx") or name.endswith(".xls"):
df = pd.read_excel(file, skiprows=2, header=0)
else:
df = pd.read_csv(file)
df.columns = ["date", "count", "type"]
df = df.dropna(subset=["date"]).copy()
df["date"] = pd.to_datetime(df["date"]).dt.normalize()
df["count"] = pd.to_numeric(df["count"], errors="coerce").fillna(0).astype(int)
df["type"] = df["type"].str.replace("до", "за", regex=False).str.strip()
return df
def fetch_weather(start: str, end: str,
lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame:
url = "https://archive-api.open-meteo.com/v1/archive"
r = requests.get(url, params={
"latitude": lat, "longitude": lon,
"start_date": start, "end_date": end,
"daily": "temperature_2m_max,precipitation_sum,snowfall_sum",
"timezone": "Europe/Moscow",
}, timeout=20)
r.raise_for_status()
d = r.json()["daily"]
return pd.DataFrame({
"date": pd.to_datetime(d["time"]),
"temp_max": pd.array(d["temperature_2m_max"], dtype="Float64").fillna(0),
"precipitation": pd.array(d["precipitation_sum"], dtype="Float64").fillna(0),
"snowfall": pd.array(d["snowfall_sum"], dtype="Float64").fillna(0),
})
def fetch_weather_forecast(days: int = 16,
lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame:
url = "https://api.open-meteo.com/v1/forecast"
r = requests.get(url, params={
"latitude": lat, "longitude": lon,
"daily": "temperature_2m_max,precipitation_sum,snowfall_sum",
"forecast_days": days,
"timezone": "Europe/Moscow",
}, timeout=20)
r.raise_for_status()
d = r.json()["daily"]
return pd.DataFrame({
"date": pd.to_datetime(d["time"]),
"temp_max": pd.array(d["temperature_2m_max"], dtype="Float64").fillna(0),
"precipitation": pd.array(d["precipitation_sum"], dtype="Float64").fillna(0),
"snowfall": pd.array(d["snowfall_sum"], dtype="Float64").fillna(0),
})
def fetch_weather_hourly(start: str, end: str,
lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame:
"""Почасовая температура/осадки/снег из архива Open-Meteo. Колонки: timestamp, temp, precip, snow."""
url = "https://archive-api.open-meteo.com/v1/archive"
r = requests.get(url, params={
"latitude": lat, "longitude": lon,
"start_date": start, "end_date": end,
"hourly": "temperature_2m,precipitation,snowfall",
"timezone": "Europe/Moscow",
}, timeout=30)
r.raise_for_status()
h = r.json()["hourly"]
return pd.DataFrame({
"timestamp": pd.to_datetime(h["time"]),
"temp": pd.array(h["temperature_2m"], dtype="Float64").fillna(0),
"precip": pd.array(h["precipitation"], dtype="Float64").fillna(0),
"snow": pd.array(h["snowfall"], dtype="Float64").fillna(0),
})
def fetch_weather_forecast_hourly(days: int = 16,
lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame:
"""Почасовой прогноз погоды Open-Meteo. Колонки: timestamp, temp, precip, snow."""
url = "https://api.open-meteo.com/v1/forecast"
r = requests.get(url, params={
"latitude": lat, "longitude": lon,
"hourly": "temperature_2m,precipitation,snowfall",
"forecast_days": days,
"timezone": "Europe/Moscow",
}, timeout=30)
r.raise_for_status()
h = r.json()["hourly"]
return pd.DataFrame({
"timestamp": pd.to_datetime(h["time"]),
"temp": pd.array(h["temperature_2m"], dtype="Float64").fillna(0),
"precip": pd.array(h["precipitation"], dtype="Float64").fillna(0),
"snow": pd.array(h["snowfall"], dtype="Float64").fillna(0),
})
# ── производственный календарь: API → локальный кэш → офлайн-реконструкция ──────
# isdayoff.ru недоступен из-под VPN (проверено 31.07.2026), а прогноз без календаря
# не считается вообще. Поэтому: удачный ответ API кэшируем на диск, при отказе
# читаем кэш, и только если кэша нет — собираем календарь сами.
_CAL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "data", "calendar")
# нерабочие дни сверх выходных (ст. 112 ТК + переносы правительства).
# 2027 заполнен только до апреля — дальше горизонта прогноза нет.
_HOLIDAYS = {
2024: ["01-01", "01-02", "01-03", "01-04", "01-05", "01-08", "02-23", "03-08",
"04-29", "04-30", "05-01", "05-09", "05-10", "06-12", "11-04", "12-30", "12-31"],
2025: ["01-01", "01-02", "01-03", "01-06", "01-07", "01-08", "02-24", "03-08",
"05-01", "05-02", "05-08", "05-09", "06-12", "06-13", "11-03", "11-04", "12-31"],
2026: ["01-01", "01-02", "01-05", "01-06", "01-07", "01-08", "02-23", "03-09",
"05-01", "05-11", "06-12", "11-04", "12-31"],
2027: ["01-01", "01-04", "01-05", "01-06", "01-07", "01-08", "02-23", "03-08",
"05-03", "05-10", "06-14", "11-04"],
}
# предпраздничные сокращённые дни
_SHORT = {
2024: ["02-22", "03-07", "04-28", "05-08", "06-11", "11-02", "12-28"],
2025: ["03-07", "04-30", "06-11", "11-01", "12-30"],
2026: ["02-20", "03-06", "04-30", "05-08", "06-11", "11-03", "12-30"],
2027: ["02-22", "03-05", "04-30", "05-07", "06-11", "11-03"],
}
def _calendar_offline(year: int) -> pd.DataFrame:
"""Реконструкция: выходные + праздники из таблицы. is_holiday в isdayoff — это
ЛЮБОЙ нерабочий день, включая субботы и воскресенья, поэтому они тоже = 1."""
dates = pd.date_range(f"{year}-01-01", f"{year}-12-31", freq="D")
md = dates.strftime("%m-%d")
hol = set(_HOLIDAYS.get(year, []))
sht = set(_SHORT.get(year, []))
is_hol = ((dates.dayofweek >= 5) | pd.Index(md).isin(hol)).astype(int)
is_short = (pd.Index(md).isin(sht) & (is_hol == 0)).astype(int)
return pd.DataFrame({"date": dates, "is_holiday": is_hol, "is_short": is_short})
def fetch_ru_calendar(year: int) -> pd.DataFrame:
cache = os.path.join(_CAL_DIR, f"ru_{year}.csv")
try:
r = requests.get(f"https://isdayoff.ru/api/getdata?year={year}", timeout=10)
r.raise_for_status()
data_str = r.text.strip()
if not set(data_str) <= {"0", "1", "2", "4"} or len(data_str) < 365:
raise ValueError(f"неожиданный ответ isdayoff для {year}: {data_str[:40]!r}")
dates, holidays, shorts = [], [], []
date = pd.Timestamp(f"{year}-01-01")
for ch in data_str:
dates.append(date)
holidays.append(1 if ch == "1" else 0)
shorts.append(1 if ch == "2" else 0)
date += pd.Timedelta(days=1)
df = pd.DataFrame({"date": dates, "is_holiday": holidays, "is_short": shorts})
os.makedirs(_CAL_DIR, exist_ok=True)
df.to_csv(cache, index=False)
return df
except Exception as e:
if os.path.exists(cache):
print(f" календарь {year}: API недоступен ({type(e).__name__}), взят кэш {cache}")
df = pd.read_csv(cache)
df["date"] = pd.to_datetime(df["date"])
return df
print(f" ⚠ календарь {year}: API недоступен ({type(e).__name__}) и кэша нет → "
"офлайн-реконструкция (выходные + праздники по таблице)")
df = _calendar_offline(year)
os.makedirs(_CAL_DIR, exist_ok=True)
df.to_csv(cache, index=False)
return df
def build_calendar(start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame:
years = range(start.year, end.year + 1)
frames = [fetch_ru_calendar(y) for y in years]
cal = pd.concat(frames).reset_index(drop=True)
dates = pd.date_range(start, end, freq="D")
return cal[cal["date"].isin(dates)].reset_index(drop=True)
def build_series(orders_df, dtype, weather_df, cal_df, start, end, item_id):
full_dates = pd.date_range(start, end, freq="D")
agg = orders_df[orders_df["type"] == dtype].groupby("date")["count"].sum()
agg = agg.reindex(full_dates, fill_value=0).reset_index()
agg.columns = ["date", "target"]
cov_cols = ["temp_max", "precipitation", "snowfall", "is_holiday", "is_short"]
agg = agg.merge(weather_df, on="date", how="left")
agg = agg.merge(cal_df[["date", "is_holiday", "is_short"]], on="date", how="left")
agg[cov_cols] = agg[cov_cols].fillna(0)
agg["item_id"] = item_id
agg.rename(columns={"date": "timestamp"}, inplace=True)
return agg