""" weather.py — головной модуль генератора погоды ACKS II. Использует классификацию климата Кёппена (Köppen) + сезон для генерации случайной погоды на основе бросков 2d6 с модификаторами. Экспортирует функцию generate_weather(constraints), которую вызывает тул с фронта. constraints — список объектов вида: { "koppen": "Cfb", # Köppen-код климата (обязательно) "season": "Winter", # Winter / Spring / Summer / Fall (обязательно) "count": 3, # сколько дней сгенерировать (default 1) } Возвращает строку в формате Markdown. """ import random from typing import Any from typing_extensions import TypedDict # ────────────────────────────────────────────────────────────────────────────── # Единая температурная таблица [-7 … 19] # Устраняет скачок между Temp_cold и Temp_hot: # оба конца шкалы теперь в одном непрерывном словаре. # ────────────────────────────────────────────────────────────────────────────── TEMP_TABLE: dict[int, str] = { -7: "Frigid (-75 °F или ниже)", -6: "Frigid (-65 до -74 °F)", -5: "Frigid (-55 до -64 °F)", -4: "Frigid (-50 до -54 °F)", -3: "Frigid (-40 до -49 °F)", -2: "Frigid (-25 до -39 °F)", -1: "Frigid (-12 до -24 °F)", 0: "Frigid (-6 до -11 °F)", 1: "Frigid (-5 до 0 °F)", 2: "Cold (6–15 °F)", 3: "Cold (16–25 °F)", 4: "Cold (26–32 °F)", 5: "Very Chilly (33–36 °F)", 6: "Very Chilly (37–40 °F)", 7: "Chilly (41–50 °F)", 8: "Chilly (51–55 °F)", 9: "Brisk (56–60 °F)", 10: "Brisk (61–65 °F)", 11: "Balmy (66–68 °F)", 12: "Balmy (69–72 °F)", 13: "Warm (73–76 °F)", 14: "Warm (77–80 °F)", 15: "Hot (81–86 °F)", 16: "Hot (87–92 °F)", 17: "Sweltering (93–100 °F)", 18: "Sweltering (101–110 °F)", 19: "Sweltering (111 °F или выше)", } # Порог температурного индекса, ниже которого осадки — снег/метель, # а не дождь. Индекс 4 = Cold (26–32 °F) — около точки замерзания. SNOW_THRESHOLD = 4 # ────────────────────────────────────────────────────────────────────────────── # Таблица осадков [-7 … 19] # ────────────────────────────────────────────────────────────────────────────── PRECIPITATION_TABLE: dict[int, str] = { -7: "Sunbaked", -6: "Sunbaked", -5: "Sunbaked", -4: "Sunbaked", -3: "Sunbaked", -2: "Sunbaked", -1: "Clear", 0: "Clear", 1: "Clear", 2: "Clear", 3: "Clear", 4: "Partly Cloudy", 5: "Mostly Cloudy", 6: "Overcast", 7: "Drizzly", 8: "Drizzly", 9: "Drizzly", 10: "Rainy", 11: "Rainy", 12: "Rainy", 13: "Rainy", 14: "Rainy", 15: "Rainy", 16: "Rainy", 17: "Rainy", 18: "Rainy", 19: "Rainy", } # Соответствие «тёплых» осадков → «зимним» при t_index < SNOW_THRESHOLD. # Drizzly → Flurries (снежная крупа), Rainy → Snowy (снег/метель). PRECIP_COLD_REMAP: dict[str, str] = { "Drizzly": "Flurries (снежная крупа)", "Rainy": "Snowy / Blizzard (снег / метель)", } # ────────────────────────────────────────────────────────────────────────────── # Таблица ветра [-7 … 19] # ────────────────────────────────────────────────────────────────────────────── WINDS_TABLE: dict[int, str] = { -7: "Still", -6: "Still", -5: "Still", -4: "Still", -3: "Still", -2: "Still", -1: "Still", 0: "Still", 1: "Still", 2: "Still", 3: "Still", 4: "Still", 5: "Gentle", 6: "Gentle", 7: "Moderate", 8: "Moderate", 9: "Moderate", 10: "Strong", 11: "Strong", 12: "Windy", 13: "Windy", 14: "Stormy", 15: "Stormy", 16: "Stormy", 17: "Stormy", 18: "Stormy", 19: "Stormy", } # ────────────────────────────────────────────────────────────────────────────── # Климатические модификаторы (Köppen × Season) # td — смещение дневного броска температуры # tn — дополнительное смещение ночного броска (поверх td) # p — смещение броска осадков # w — смещение броска ветра # # Исправлено: p=−0 → p=0 (Cwc Spring) # ────────────────────────────────────────────────────────────────────────────── CLIMATE_MODS: dict[str, dict[str, dict[str, int]]] = { "Af": { "Winter": {"td": +3, "tn": 0, "p": -3, "w": +2}, "Spring": {"td": +2, "tn": 0, "p": -2, "w": 0}, "Summer": {"td": +2, "tn": 0, "p": -2, "w": 0}, "Fall": {"td": +1, "tn": 0, "p": -1, "w": 0}, }, "Am": { "Winter": {"td": +2, "tn": 0, "p": -1, "w": +2}, "Spring": {"td": +7, "tn": 0, "p": -1, "w": 0}, "Summer": {"td": +6, "tn": 0, "p": +4, "w": 0}, "Fall": {"td": +4, "tn": 0, "p": -1, "w": 0}, }, "Aw": { "Winter": {"td": +5, "tn": 0, "p": -5, "w": +2}, "Spring": {"td": +4, "tn": 0, "p": -4, "w": 0}, "Summer": {"td": +3, "tn": 0, "p": -1, "w": 0}, "Fall": {"td": +3, "tn": 0, "p": -4, "w": 0}, }, "As": { "Winter": {"td": +4, "tn": 0, "p": -1, "w": +2}, "Spring": {"td": +4, "tn": 0, "p": -2, "w": 0}, "Summer": {"td": +4, "tn": 0, "p": -5, "w": 0}, "Fall": {"td": +4, "tn": 0, "p": -3, "w": 0}, }, "BWh": { "Winter": {"td": +1, "tn": 0, "p": -5, "w": +2}, "Spring": {"td": +6, "tn": 0, "p": -5, "w": 0}, "Summer": {"td": +7, "tn": +2, "p": -5, "w": 0}, "Fall": {"td": +4, "tn": 0, "p": 0, "w": 0}, }, "BWk": { "Winter": {"td": 0, "tn": -4, "p": -4, "w": +2}, "Spring": {"td": 0, "tn": 0, "p": -5, "w": 0}, "Summer": {"td": +2, "tn": 0, "p": -4, "w": 0}, "Fall": {"td": 0, "tn": -1, "p": -5, "w": 0}, }, "BSh": { "Winter": {"td": +6, "tn": 0, "p": -5, "w": +2}, "Spring": {"td": +7, "tn": +3, "p": -3, "w": 0}, "Summer": {"td": +6, "tn": +2, "p": -2, "w": 0}, "Fall": {"td": +6, "tn": +2, "p": -5, "w": 0}, }, "BSk": { "Winter": {"td": 0, "tn": -1, "p": -4, "w": +2}, "Spring": {"td": +1, "tn": 0, "p": -4, "w": 0}, "Summer": {"td": +4, "tn": 0, "p": -5, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -4, "w": 0}, }, "Csa": { "Winter": {"td": 0, "tn": 0, "p": -4, "w": +2}, "Spring": {"td": +1, "tn": 0, "p": -4, "w": 0}, "Summer": {"td": +3, "tn": 0, "p": -4, "w": 0}, "Fall": {"td": +1, "tn": 0, "p": -3, "w": 0}, }, "Csb": { "Winter": {"td": 0, "tn": 0, "p": -2, "w": +2}, "Spring": {"td": 0, "tn": 0, "p": -3, "w": 0}, "Summer": {"td": +2, "tn": 0, "p": -4, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -1, "w": 0}, }, "Csc": { "Winter": {"td": 0, "tn": 0, "p": +2, "w": +2}, "Spring": {"td": 0, "tn": 0, "p": -3, "w": 0}, "Summer": {"td": 0, "tn": 0, "p": -3, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -2, "w": 0}, }, "Cwa": { "Winter": {"td": +1, "tn": 0, "p": -3, "w": +2}, "Spring": {"td": +3, "tn": 0, "p": -2, "w": 0}, "Summer": {"td": +4, "tn": +2, "p": -1, "w": +2}, "Fall": {"td": +2, "tn": 0, "p": -3, "w": 0}, }, "Cwb": { "Winter": {"td": +1, "tn": 0, "p": -3, "w": +2}, "Spring": {"td": +1, "tn": 0, "p": -2, "w": 0}, "Summer": {"td": +1, "tn": 0, "p": -1, "w": +2}, "Fall": {"td": +1, "tn": 0, "p": -5, "w": 0}, }, "Cwc": { "Winter": {"td": 0, "tn": 0, "p": -3, "w": +2}, "Spring": {"td": 0, "tn": 0, "p": 0, "w": 0}, # было p=-0 "Summer": {"td": 0, "tn": 0, "p": +2, "w": +2}, "Fall": {"td": 0, "tn": 0, "p": -2, "w": 0}, }, "Cfa": { "Winter": {"td": 0, "tn": 0, "p": -3, "w": +2}, "Spring": {"td": +2, "tn": 0, "p": -2, "w": 0}, "Summer": {"td": +4, "tn": +1, "p": -1, "w": 0}, "Fall": {"td": +1, "tn": 0, "p": -4, "w": 0}, }, "Cfb": { "Winter": {"td": 0, "tn": 0, "p": -1, "w": +2}, "Spring": {"td": +1, "tn": 0, "p": -1, "w": 0}, "Summer": {"td": +3, "tn": +1, "p": -1, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -2, "w": 0}, }, "Cfc": { "Winter": {"td": 0, "tn": -1, "p": -3, "w": +2}, "Spring": {"td": 0, "tn": 0, "p": -3, "w": 0}, "Summer": {"td": 0, "tn": 0, "p": -3, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -3, "w": 0}, }, "Dsa": { "Winter": {"td": 0, "tn": -2, "p": -3, "w": +2}, "Spring": {"td": +3, "tn": +1, "p": -4, "w": 0}, "Summer": {"td": +5, "tn": +3, "p": -5, "w": 0}, "Fall": {"td": +2, "tn": +1, "p": -4, "w": 0}, }, "Dsb": { "Winter": {"td": -2, "tn": -5, "p": -4, "w": +2}, "Spring": {"td": +1, "tn": -2, "p": -2, "w": 0}, "Summer": {"td": +3, "tn": +1, "p": -3, "w": 0}, "Fall": {"td": 0, "tn": -1, "p": -3, "w": 0}, }, "Dsc": { "Winter": {"td": -2, "tn": -3, "p": -2, "w": +2}, "Spring": {"td": +1, "tn": -1, "p": -3, "w": 0}, "Summer": {"td": +2, "tn": -1, "p": -2, "w": 0}, "Fall": {"td": -1, "tn": -1, "p": -1, "w": 0}, }, "Dwa": { "Winter": {"td": 0, "tn": -3, "p": -5, "w": +2}, "Spring": {"td": +1, "tn": 0, "p": -4, "w": 0}, "Summer": {"td": +3, "tn": 0, "p": -2, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -1, "w": 0}, }, "Dwb": { "Winter": {"td": -1, "tn": -3, "p": -4, "w": +2}, "Spring": {"td": +2, "tn": 0, "p": -3, "w": 0}, "Summer": {"td": +3, "tn": +1, "p": -2, "w": 0}, "Fall": {"td": 0, "tn": 0, "p": -3, "w": 0}, }, "Dwc": { "Winter": {"td": -1, "tn": -5, "p": -4, "w": +2}, "Spring": {"td": 0, "tn": -3, "p": -4, "w": 0}, "Summer": {"td": +1, "tn": -1, "p": -2, "w": 0}, "Fall": {"td": 0, "tn": -2, "p": -3, "w": 0}, }, "Dwd": { "Winter": {"td": -9, "tn": -11, "p": -3, "w": +2}, "Spring": {"td": 0, "tn": -4, "p": -4, "w": 0}, "Summer": {"td": +2, "tn": -2, "p": -3, "w": +2}, "Fall": {"td": -4, "tn": -7, "p": -5, "w": 0}, }, "Dfa": { "Winter": {"td": -2, "tn": -4, "p": -3, "w": +2}, "Spring": {"td": +3, "tn": -1, "p": -2, "w": 0}, "Summer": {"td": +4, "tn": +2, "p": -4, "w": +2}, "Fall": {"td": +2, "tn": -2, "p": -3, "w": 0}, }, "Dfb": { "Winter": {"td": -3, "tn": -4, "p": -3, "w": +2}, "Spring": {"td": +2, "tn": -2, "p": -2, "w": 0}, "Summer": {"td": +3, "tn": +1, "p": -3, "w": +2}, "Fall": {"td": 0, "tn": -2, "p": -3, "w": 0}, }, "Dfc": { "Winter": {"td": -3, "tn": -5, "p": -4, "w": +2}, "Spring": {"td": +1, "tn": -2, "p": -4, "w": 0}, "Summer": {"td": +2, "tn": 0, "p": -3, "w": 0}, "Fall": {"td": -1, "tn": -2, "p": -3, "w": 0}, }, "Dfd": { "Winter": {"td": -9, "tn": -11, "p": -4, "w": +2}, "Spring": {"td": 0, "tn": -4, "p": -4, "w": 0}, "Summer": {"td": +2, "tn": -2, "p": -4, "w": 0}, "Fall": {"td": -5, "tn": -6, "p": -5, "w": 0}, }, "ET": { "Winter": {"td": -8, "tn": -11, "p": -2, "w": +2}, "Spring": {"td": 0, "tn": -1, "p": -3, "w": 0}, "Summer": {"td": 0, "tn": 0, "p": -2, "w": 0}, "Fall": {"td": -1, "tn": -2, "p": -1, "w": 0}, }, "EF": { # Примечание: EF (Антарктида/ледники) даёт td=-11 для всех сезонов. # Из-за клэмпинга [-7, 19] большинство бросков упрётся в -7 («Frigid»). # Это архитектурное ограничение системы 2d6: вариативность намеренно # мала — все дни там одинаково экстремальны. "Winter": {"td": -11, "tn": -11, "p": -5, "w": -2}, "Spring": {"td": -11, "tn": -11, "p": -5, "w": 0}, "Summer": {"td": -11, "tn": -11, "p": -5, "w": 0}, "Fall": {"td": -11, "tn": -11, "p": -5, "w": 0}, }, } VALID_SEASONS = ("Winter", "Spring", "Summer", "Fall") # ────────────────────────────────────────────────────────────────────────────── # Вспомогательные функции # ────────────────────────────────────────────────────────────────────────────── def _roll_2d6() -> int: """Бросок 2d6 → [2, 12].""" return random.randint(1, 6) + random.randint(1, 6) def _clamp(value: int, lo: int = -7, hi: int = 19) -> int: return max(lo, min(hi, value)) def _lookup_temp(index: int) -> str: """Возвращает описание температуры по индексу (с клэмпингом).""" return TEMP_TABLE[_clamp(index)] def _lookup_precip(index: int, t_index: int) -> str: """ Возвращает описание осадков с учётом температуры. Если t_index < SNOW_THRESHOLD и осадки «тёплые» (Drizzly/Rainy), автоматически заменяет их на снежный эквивалент. """ raw = PRECIPITATION_TABLE[_clamp(index)] if t_index < SNOW_THRESHOLD and raw in PRECIP_COLD_REMAP: return PRECIP_COLD_REMAP[raw] return raw def _lookup_wind(index: int) -> str: return WINDS_TABLE[_clamp(index)] def _f_to_c(fahrenheit: float) -> float: """Конвертация °F → °C.""" return (fahrenheit - 32) * 5 / 9 # Регулярное выражение для замены диапазонов °F на °C в строках таблицы. import re def _convert_temp_str_to_celsius(temp_str: str) -> str: """ Принимает строку вида 'Balmy (69–72 °F)' или 'Frigid (-75 °F или ниже)' и возвращает строку с диапазоном в °C. Обрабатывает форматы: (X °F или ниже) (X до Y °F) (X–Y °F) (X °F) """ # «-75 °F или ниже» m = re.search(r'\((-?\d+)\s*°F или ниже\)', temp_str) if m: f = int(m.group(1)) c = round(_f_to_c(f)) return re.sub(r'\((-?\d+)\s*°F или ниже\)', f'({c} °C или ниже)', temp_str) # «111 °F или выше» m = re.search(r'\((-?\d+)\s*°F или выше\)', temp_str) if m: f = int(m.group(1)) c = round(_f_to_c(f)) return re.sub(r'\((-?\d+)\s*°F или выше\)', f'({c} °C или выше)', temp_str) # «-65 до -74 °F» m = re.search(r'\((-?\d+)\s*до\s*(-?\d+)\s*°F\)', temp_str) if m: c1 = round(_f_to_c(int(m.group(1)))) c2 = round(_f_to_c(int(m.group(2)))) return re.sub(r'\((-?\d+)\s*до\s*(-?\d+)\s*°F\)', f'({c1} до {c2} °C)', temp_str) # «69–72 °F» m = re.search(r'\((-?\d+)–(-?\d+)\s*°F\)', temp_str) if m: c1 = round(_f_to_c(int(m.group(1)))) c2 = round(_f_to_c(int(m.group(2)))) return re.sub(r'\((-?\d+)–(-?\d+)\s*°F\)', f'({c1}–{c2} °C)', temp_str) # одиночное значение «X °F» m = re.search(r'\((-?\d+)\s*°F\)', temp_str) if m: c = round(_f_to_c(int(m.group(1)))) return re.sub(r'\((-?\d+)\s*°F\)', f'({c} °C)', temp_str) return temp_str # ────────────────────────────────────────────────────────────────────────────── # Генерация одного дня # ────────────────────────────────────────────────────────────────────────────── def _generate_single_day(koppen: str, season: str, use_celsius: bool = False) -> str: """ Генерирует погоду на один день и возвращает Markdown-строку. Механика: - Дневная температура: 2d6 + td → клэмп → таблица - Ночная температура: (2d6 + td) + tn → клэмп → таблица (tn — дополнительная дельта ночи относительно дня) - Осадки: 2d6 + p → клэмп → таблица + проверка снега - Ветер: 2d6 + w → клэмп → таблица """ mods = CLIMATE_MODS[koppen][season] td = mods["td"] tn = mods["tn"] # смещение ночи поверх дневного броска p = mods["p"] w = mods["w"] # Бросок температуры — один базовый бросок для дня base_t_roll = _roll_2d6() day_t_index = _clamp(base_t_roll + td) # Ночь: тот же базовый бросок + tn (ночная дельта стабильна для климата, # но сам бросок дня добавляет случайность) night_t_index = _clamp(base_t_roll + td + tn) p_roll = _clamp(_roll_2d6() + p) w_roll = _clamp(_roll_2d6() + w) day_temp = _lookup_temp(day_t_index) night_temp = _lookup_temp(night_t_index) # Осадки проверяем по ДНЕВНОЙ температуре # (если днём уже ниже нуля — всё идёт снегом) precip = _lookup_precip(p_roll, day_t_index) wind = _lookup_wind(w_roll) # Отладочные индексы (удобно при тестировании) debug = ( f"*(rolls: base_t={base_t_roll}, " f"day_idx={day_t_index}, night_idx={night_t_index}, " f"p_idx={p_roll}, w_idx={w_roll})*" ) if use_celsius: day_temp = _convert_temp_str_to_celsius(day_temp) night_temp = _convert_temp_str_to_celsius(night_temp) return ( f"- **День:** {day_temp} | " f"**Ночь:** {night_temp} | " f"**Осадки:** {precip} | " f"**Ветер:** {wind} " # f"\n {debug}" ) # ────────────────────────────────────────────────────────────────────────────── # TypedDict для аннотации параметров тула # ────────────────────────────────────────────────────────────────────────────── class WeatherConstraint(TypedDict, total=False): """ Параметры генерации погоды для одной локации/периода. Поля: koppen: Köppen-код климата. Допустимые значения: Af, Am, Aw, As, BWh, BWk, BSh, BSk, Csa, Csb, Csc, Cwa, Cwb, Cwc, Cfa, Cfb, Cfc, Dsa, Dsb, Dsc, Dwa, Dwb, Dwc, Dwd, Dfa, Dfb, Dfc, Dfd, ET, EF. season: Сезон: Winter, Spring, Summer, Fall. count: Количество дней для генерации. По умолчанию 1. use_celsius: default False — если True, выводить °C вместо °F """ koppen: str # обязательно season: str # обязательно count: int # default 1 use_celsius: bool # default False — если True, выводить °C вместо °F # ────────────────────────────────────────────────────────────────────────────── # Публичный метод тула # ────────────────────────────────────────────────────────────────────────────── def generate_weather( debug_description: str, constraints: list[WeatherConstraint], ) -> str: """ Основной метод, который вызывает тул с фронта. Принимает список объектов WeatherConstraint. Для каждого объекта: - валидирует Köppen-код и сезон; - генерирует `count` дней погоды. Возвращает Markdown-строку со всеми результатами. Пример вызова: generate_weather("тест", [ {"koppen": "Cfb", "season": "Winter", "count": 3}, {"koppen": "BWh", "season": "Summer", "count": 1}, ]) """ if not constraints: return "❌ Не передано ни одного ограничения." sections: list[str] = [] for idx, constraint in enumerate(constraints, start=1): koppen = constraint.get("koppen", "").strip() season = constraint.get("season", "").strip() count = int(constraint.get("count", 1)) use_celsius = bool(constraint.get("use_celsius", False)) # ── Валидация ──────────────────────────────────────────────────────── errors: list[str] = [] if not koppen: errors.append("не указан `koppen`") elif koppen not in CLIMATE_MODS: valid = ", ".join(sorted(CLIMATE_MODS.keys())) errors.append(f"неизвестный Köppen-код `{koppen}`. Допустимые: {valid}") if not season: errors.append("не указан `season`") elif season not in VALID_SEASONS: errors.append( f"неизвестный сезон `{season}`. " f"Допустимые: {', '.join(VALID_SEASONS)}" ) if errors: sections.append( f"### Набор {idx} — ошибка\n" + "\n".join(f"- {e}" for e in errors) ) continue count = max(1, min(count, 30)) # не более 30 дней за раз # ── Генерация ──────────────────────────────────────────────────────── mods = CLIMATE_MODS[koppen][season] header = ( f"### Набор {idx}: {koppen} / {season} ({count} дн.)\n" # f"*Модификаторы: td={mods['td']:+d}, tn={mods['tn']:+d}, " # f"p={mods['p']:+d}, w={mods['w']:+d}*\n" ) lines: list[str] = [header] for day_num in range(1, count + 1): day_line = _generate_single_day(koppen, season, use_celsius) lines.append(f"**День {day_num}:** \n{day_line}") sections.append("\n\n".join(lines)) return "\n\n---\n\n".join(sections) # ────────────────────────────────────────────────────────────────────────────── # Быстрый тест при запуске напрямую # ────────────────────────────────────────────────────────────────────────────── if __name__ == "__main__": result = generate_weather( "тест", [ { "koppen": "Cfb", "season": "Winter", "count": 3 }, { "koppen": "BWh", "season": "Summer", "count": 2 }, { "koppen": "EF", "season": "Winter", "count": 2 }, { "koppen": "Dwd", "season": "Winter", "count": 2 }, { "koppen": "Af", "season": "Summer", "count": 1 }, # Проверка ошибки валидации { "koppen": "XXX", "season": "Winter", "count": 1 }, ]) print(result)