Spaces:
Sleeping
Sleeping
sviatoslav948
Claude Fable 5
Прогноз: покрытие регионов, офлайн-календарь, граница окна выработки
acd4123 Download app/utils/data.py from akisov/order-forecast: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/akisov/order-forecast/resolve/main/app/utils/data.py
- Command line
-
hf download hf://spaces/akisov/order-forecast/app/utils/data.py
-
curl -L -o data.py https://huggingface.co/spaces/akisov/order-forecast/resolve/main/app/utils/data.py
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 | |