microclimate-x-demo / backend /rule_engine.py
W1nd5pac's picture
Deploy 2026-05-20T07:09:24Z โ€” 11e81c5 (code)
a8358d8 verified
"""
Topographic Rule-Based Expert System โ€” Engine B of the hybrid architecture.
This module is structured to mirror D5 proposal ยง3.7 / P4 so it is auditable
against the thesis section by section:
P4.1 Load Dynamic Risk Rules โ†’ constants in backend/config.py
P4.2 Fetch User Context (activity) โ†’ `evaluate(activity=โ€ฆ)` parameter
P4.3 Evaluate Environmental Risks โ†’ four `score_*_risk()` functions
(rainfall / fog / wind_gust / thunderstorm)
P4.4 Apply Activity-Specific Weight โ†’ `apply_activity_weighting()`
P4.5 Calculate Composite Risk Score โ†’ weighted sum + Veto cap
P4.6 Generate Actionable Advice โ†’ bilingual advice helpers
In parallel, the Veto cascade (life-safety overrides) and the D5 ยง3.7.2
Table 4.2 Decision Table run alongside the composite score.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from . import config
from .schemas import (
ActivityType,
DecisionTableMatch,
HazardSubscores,
InferenceStep,
RiskLevel,
VetoTrigger,
)
@dataclass
class RuleResult:
risk_score: int = 0
risk_level: RiskLevel = "Safe"
veto_triggers: list[VetoTrigger] = field(default_factory=list)
inference_log: list[InferenceStep] = field(default_factory=list)
advice_en: str = ""
advice_zh: str = ""
hazard_subscores: HazardSubscores = field(
default_factory=lambda: HazardSubscores(rainfall=0, fog=0, wind_gust=0, thunderstorm=0)
)
decision_table_matches: list[DecisionTableMatch] = field(default_factory=list)
activity: ActivityType = "general"
@property
def has_veto(self) -> bool:
return len(self.veto_triggers) > 0
def _bin_level(score: int) -> RiskLevel:
if score >= 80:
return "Danger"
if score >= 55:
return "Warning"
if score >= 30:
return "Caution"
return "Safe"
def _clip(x: float) -> int:
return max(0, min(100, round(x)))
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# P4.3 โ€” Four Hazard Sub-Scorers (each returns 0-100)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def score_rainfall_risk(
*, ml_rain_prob: float, terrain: str, orographic_dot: float,
pressure_change_3h: float, humidity_pct: float,
) -> int:
"""Rainfall sub-score. Backbone is ML probability; terrain amplifies.
Calibration: ml_rain_prob 0.45 on flat terrain should yield ~40
(matching the proposal's intuition that 45 % probability already warrants
a 'Caution' verdict)."""
s = ml_rain_prob * 55.0 # baseline 0-55 from ML
if ml_rain_prob >= 0.70:
s += 20.0 # high-confidence rain bonus
elif ml_rain_prob >= 0.40:
s += 12.0
if terrain == "Valley":
s += 8.0
elif terrain == "Slope":
s += orographic_dot * 25.0 # up to +25 on a windward slope
if pressure_change_3h <= -1.5: # storm-precursor pressure fall
s += 8.0
if humidity_pct >= 90.0:
s += 6.0
return _clip(s)
def score_fog_risk(
*, humidity_pct: float, dew_point_depression: float,
cloud_cover_pct: float, terrain: str, elevation_m: float,
) -> int:
"""Fog sub-score. Saturated boundary layer + heavy low cloud + a basin
or slope that traps the radiation/advection fog."""
if dew_point_depression > 5.0:
return _clip(humidity_pct - 80.0) # near-zero unless very humid
s = 0.0
# Humidity โ†’ saturation contribution.
if humidity_pct >= config.FOG_HUMIDITY_PCT:
s += 55.0
elif humidity_pct >= 90.0:
s += 25.0
elif humidity_pct >= 85.0:
s += 10.0
# Dew-point depression: smaller = closer to saturation.
if dew_point_depression <= config.FOG_DEW_DEP_MAX_C:
s += 25.0
elif dew_point_depression <= 3.5:
s += 12.0
# Low cloud cover suggests a low-lying cloud deck = potential fog when
# cloud base meets terrain.
if cloud_cover_pct >= 90.0:
s += 10.0
elif cloud_cover_pct >= 70.0:
s += 5.0
# Terrain modifier: valleys trap radiation fog; high peaks intersect cloud base.
if terrain == "Valley":
s += 10.0
elif terrain == "Peak" and elevation_m >= 1500.0:
s += 8.0
return _clip(s)
def score_wind_gust_risk(
*, wind_speed_kmh: float, terrain: str, slope_deg: float,
orographic_dot: float,
) -> int:
"""Wind gust sub-score. Sustained wind ร— topographic acceleration."""
if wind_speed_kmh < config.GUST_WIND_MIN_KMH * 0.6:
# Calm conditions โ€” even ridges won't produce dangerous gusts.
return _clip(wind_speed_kmh)
# Baseline: linear in sustained wind, saturating at the gale Veto level.
s = (wind_speed_kmh / config.GALE_WIND_KMH) * 55.0
# Topographic acceleration on ridges and exposed slopes.
if terrain in {"Peak", "Slope"}:
s += min(slope_deg, 30.0) # up to +30 for very steep slopes
if terrain == "Slope" and abs(orographic_dot) >= 0.5:
s += 8.0 # pass / saddle wind funnel
return _clip(s)
def score_thunderstorm_risk(
*, cape_jkg: float, pressure_change_3h: float, humidity_pct: float,
) -> int:
"""Thunderstorm sub-score. Atmospheric instability + storm precursors."""
s = 0.0
# CAPE โ€” primary indicator. Linear up to NWS "strong instability" 2500 J/kg.
if cape_jkg >= config.HIGH_CAPE_JKG:
s += 60.0
elif cape_jkg >= config.THUNDER_CAPE_MIN_JKG:
s += 35.0 + (cape_jkg - config.THUNDER_CAPE_MIN_JKG) / 20.0
elif cape_jkg >= 200.0:
s += 12.0
# Falling pressure precedes convective initiation.
if pressure_change_3h <= config.THUNDER_PRESSURE_DROP:
s += 20.0
elif pressure_change_3h <= -1.0:
s += 8.0
# Humidity gates whether instability can actually produce a thunderstorm.
if humidity_pct >= 80.0:
s += 10.0
return _clip(s)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# D5 ยง3.7.2 / Table 4.2 โ€” Decision Table R1-R4
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def apply_decision_table_3_7_2(
*,
macro_rain_prob: float,
humidity_pct: float,
wind_into_slope: bool,
terrain: str,
pressure_change_3h: float,
cloud_base_m: float | None,
) -> list[DecisionTableMatch]:
"""Returns the list of decision-table rules (R1-R4) that fired.
One-to-one match against D5 ยง3.7.2 Table 4.2."""
terrain_kind = "WindwardSlope" if (terrain == "Slope" and wind_into_slope) else \
"LeewardOrValley" if terrain in {"Valley"} or (terrain == "Slope" and not wind_into_slope) else \
terrain
matches: list[DecisionTableMatch] = []
for rule_id, rule in config.DECISION_TABLE_3_7_2.items():
ok = True
if rule["macro_rain_prob_max"] is not None and macro_rain_prob > rule["macro_rain_prob_max"]:
ok = False
if rule["macro_rain_prob_min"] is not None and macro_rain_prob < rule["macro_rain_prob_min"]:
ok = False
if rule["humidity_min_pct"] is not None and humidity_pct < rule["humidity_min_pct"]:
ok = False
if rule["wind_into_slope"] is not None and wind_into_slope != rule["wind_into_slope"]:
ok = False
if rule["terrain"] is not None and terrain_kind != rule["terrain"]:
ok = False
if rule["pressure_change_3h_max"] is not None and pressure_change_3h > rule["pressure_change_3h_max"]:
ok = False
if rule["cloud_base_max_m"] is not None and (cloud_base_m is None or cloud_base_m > rule["cloud_base_max_m"]):
ok = False
if ok:
matches.append(DecisionTableMatch(
rule=rule_id,
description=rule["description"],
conclusion_en=rule["conclusion_en"],
conclusion_zh=rule["conclusion_zh"],
))
return matches
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# P4.4 โ€” Activity-aware composite scoring
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def apply_activity_weighting(
subs: HazardSubscores, activity: ActivityType,
) -> int:
"""Composite 0-100 score.
Design rationale: a naive mean dilutes the dominant hazard โ€” e.g. an
extreme thunderstorm risk (90) averaged with three safe (10) values
would yield 30, which understates the actual danger. We therefore use
a **dominant-hazard + secondary-contribution** formulation:
composite = 0.80 ยท max(weighted sub-scores)
+ 0.20 ยท mean(weighted sub-scores excluding max)
This ensures the worst hazard for the user's activity drives the score,
while still allowing multiple moderate hazards to push the score up.
"""
w = config.ACTIVITY_WEIGHTS[activity]
weighted = [
min(100.0, w["rainfall"] * subs.rainfall),
min(100.0, w["fog"] * subs.fog),
min(100.0, w["wind_gust"] * subs.wind_gust),
min(100.0, w["thunderstorm"] * subs.thunderstorm),
]
top = max(weighted)
rest = sum(weighted) - top
others_mean = rest / 3.0
return _clip(top * 0.80 + others_mean * 0.20)
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Veto cascade (life-safety overrides) โ€” same as before, unchanged behaviour
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def _collect_veto_triggers(
*, elevation_m: float, terrain: str, weather: dict[str, Any],
ml_rain_prob: float, orographic_dot: float,
) -> list[VetoTrigger]:
temp_c = weather.get("temperature_c", 25.0)
wind_kmh = weather.get("wind_speed_kmh", 0.0)
cape = weather.get("cape_jkg", 0.0)
visibility = weather.get("visibility_m", 10000.0)
out: list[VetoTrigger] = []
if elevation_m > config.ALTITUDE_HYPOXIA_M:
out.append(VetoTrigger(
rule="altitude_hypoxia", value=elevation_m,
message_en=f"Altitude {elevation_m:.0f} m exceeds {config.ALTITUDE_HYPOXIA_M:.0f} m โ€” severe hypoxia risk.",
message_zh=f"ๆตทๆ‹” {elevation_m:.0f} m ่ถ…่ฟ‡ {config.ALTITUDE_HYPOXIA_M:.0f} m๏ผŒๅญ˜ๅœจไธฅ้‡็ผบๆฐง้ฃŽ้™ฉใ€‚",
))
if temp_c <= config.EXTREME_COLD_C:
out.append(VetoTrigger(
rule="extreme_cold", value=temp_c,
message_en=f"Temperature {temp_c:.1f}ยฐC โ€” frostbite risk per UIAA guidance.",
message_zh=f"ๆธฉๅบฆ {temp_c:.1f}ยฐC๏ผŒUIAA ๆŒ‡ๅ—ๅˆคๅฎšไธบๅ†ปไผค้ฃŽ้™ฉใ€‚",
))
if wind_kmh >= config.GALE_WIND_KMH:
out.append(VetoTrigger(
rule="gale_wind", value=wind_kmh,
message_en=f"Wind speed {wind_kmh:.0f} km/h โ‰ฅ Beaufort Force 6 โ€” hazardous.",
message_zh=f"้ฃŽ้€Ÿ {wind_kmh:.0f} km/h ่พพๅˆฐ่’ฒ็ฆ้ฃŽ็บง 6 ็บงไปฅไธŠ๏ผŒๅญ˜ๅœจๅฑ้™ฉใ€‚",
))
if cape >= config.HIGH_CAPE_JKG:
out.append(VetoTrigger(
rule="high_cape_lightning", value=cape,
message_en=f"CAPE {cape:.0f} J/kg โ€” significant thunderstorm potential.",
message_zh=f"CAPE {cape:.0f} J/kg๏ผŒๅญ˜ๅœจๆ˜พ่‘—้›ทๆšด้ฃŽ้™ฉใ€‚",
))
if visibility < config.LOW_VISIBILITY_M:
out.append(VetoTrigger(
rule="low_visibility", value=visibility,
message_en=f"Visibility {visibility:.0f} m โ€” whiteout / dense fog.",
message_zh=f"่ƒฝ่งๅบฆ {visibility:.0f} m๏ผŒ็™ฝๆฏ›้ฃŽๆˆ–ๆต“้›พใ€‚",
))
if (terrain == "Slope" and orographic_dot >= config.OROGRAPHIC_DOT_THRESHOLD
and ml_rain_prob >= 0.50):
out.append(VetoTrigger(
rule="orographic_lift_storm", value=orographic_dot,
message_en="Wind impinging on windward slope with high rain probability โ€” enhanced orographic precipitation.",
message_zh="้ฃŽๅ‘ๆญฃๅฏน่ฟŽ้ฃŽๅก๏ผŒๅ ๅŠ ้ซ˜้™้›จๆฆ‚็އ๏ผŒๅœฐๅฝขๆŠฌๅ‡ๅผบๅŒ–้™ๆฐดใ€‚",
))
if terrain == "Valley" and ml_rain_prob >= config.VALLEY_FLOOD_PROB:
out.append(VetoTrigger(
rule="valley_flash_flood", value=ml_rain_prob,
message_en="Valley basin with very high rain probability โ€” flash-flood risk.",
message_zh="ๅค„ไบŽๅฑฑ่ฐท็›†ๅœฐไธ”้™้›จๆฆ‚็އๆž้ซ˜๏ผŒๅญ˜ๅœจๅฑฑๆดชๆšดๅ‘้ฃŽ้™ฉใ€‚",
))
return out
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# Top-level entry point โ€” orchestrates P4.2 โ†’ P4.3 โ†’ P4.4 โ†’ P4.5 โ†’ P4.6
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def evaluate(
*,
lat: float,
lon: float,
elevation_m: float,
terrain: str,
weather: dict[str, Any],
ml_rain_prob: float,
slope_deg: float,
aspect_deg: float,
orographic_dot: float,
activity: ActivityType = "general",
) -> RuleResult:
"""Apply the full Hybrid scoring + Veto cascade + D5 ยง3.7 pipeline."""
result = RuleResult(activity=activity)
log = result.inference_log
log.append(InferenceStep(
kind="info",
text_en=f"Inference @ ({lat:.4f}, {lon:.4f}) elev={elevation_m:.0f} m terrain={terrain} activity={activity}",
text_zh=f"ๆŽจ็†ไฝ็ฝฎ ({lat:.4f}, {lon:.4f}) ๆตทๆ‹” {elevation_m:.0f} m ๅœฐๅฝข {terrain} ๆดปๅŠจ็ฑปๅž‹ {activity}",
))
log.append(InferenceStep(
kind="ml",
text_en=f"Engine A (Random Forest) โ€” rain probability next hour = {ml_rain_prob:.1%}",
text_zh=f"ๅผ•ๆ“Ž A๏ผˆ้šๆœบๆฃฎๆž—๏ผ‰ไธ‹ไธ€ๅฐๆ—ถ้™้›จๆฆ‚็އ = {ml_rain_prob:.1%}",
))
# โ”€โ”€ P4.3: Four hazard sub-scores โ”€โ”€
humidity = weather.get("humidity_pct", 60.0)
dew_dep = weather.get("dew_point_depression",
weather.get("temperature_c", 25.0) - weather.get("dew_point_c",
weather.get("temperature_c", 25.0)))
pres_dp = weather.get("pressure_change_3h", 0.0)
cloud = weather.get("cloud_cover_pct", 50.0)
cape = weather.get("cape_jkg", 0.0)
wind_kmh = weather.get("wind_speed_kmh", 0.0)
subs = HazardSubscores(
rainfall = score_rainfall_risk(
ml_rain_prob=ml_rain_prob, terrain=terrain, orographic_dot=orographic_dot,
pressure_change_3h=pres_dp, humidity_pct=humidity),
fog = score_fog_risk(
humidity_pct=humidity, dew_point_depression=dew_dep,
cloud_cover_pct=cloud, terrain=terrain, elevation_m=elevation_m),
wind_gust = score_wind_gust_risk(
wind_speed_kmh=wind_kmh, terrain=terrain,
slope_deg=slope_deg, orographic_dot=orographic_dot),
thunderstorm= score_thunderstorm_risk(
cape_jkg=cape, pressure_change_3h=pres_dp, humidity_pct=humidity),
)
result.hazard_subscores = subs
log.append(InferenceStep(
kind="hazard",
text_en=f"Sub-scores โ€” Rainfall={subs.rainfall} Fog={subs.fog} Gust={subs.wind_gust} Thunder={subs.thunderstorm}",
text_zh=f"ๅˆ†้กน่ฏ„ๅˆ† โ€” ้™้›จ={subs.rainfall} ้›พ={subs.fog} ้˜ต้ฃŽ={subs.wind_gust} ้›ทๆšด={subs.thunderstorm}",
))
# โ”€โ”€ D5 ยง3.7.2 Decision Table R1-R4 (informational, not score-changing) โ”€โ”€
wind_into_slope = (terrain == "Slope" and orographic_dot >= 0.3)
cloud_base_m = weather.get("cloud_base_m")
if cloud_base_m is None and cloud >= 90.0 and dew_dep <= 2.0:
cloud_base_m = 600.0 # crude proxy when API doesn't provide cloud base
result.decision_table_matches = apply_decision_table_3_7_2(
macro_rain_prob=ml_rain_prob,
humidity_pct=humidity,
wind_into_slope=wind_into_slope,
terrain=terrain,
pressure_change_3h=pres_dp,
cloud_base_m=cloud_base_m,
)
for m in result.decision_table_matches:
log.append(InferenceStep(
kind="table",
text_en=f"D5 ยง3.7.2 {m.rule} fired โ€” {m.conclusion_en}",
text_zh=f"D5 ยง3.7.2 {m.rule} ่งฆๅ‘ โ€”โ€” {m.conclusion_zh}",
))
# โ”€โ”€ Veto cascade (life-safety overrides) โ”€โ”€
result.veto_triggers = _collect_veto_triggers(
elevation_m=elevation_m, terrain=terrain, weather=weather,
ml_rain_prob=ml_rain_prob, orographic_dot=orographic_dot,
)
if result.has_veto:
for v in result.veto_triggers:
log.append(InferenceStep(kind="veto", text_en=f"VETO: {v.message_en}",
text_zh=f"ๅฆๅ†ณ่งฆๅ‘๏ผš{v.message_zh}"))
result.risk_score = 100
result.risk_level = "Danger"
result.advice_en, result.advice_zh = _veto_advice(result.veto_triggers)
log.append(InferenceStep(kind="score",
text_en="Final risk = 100 (Veto cascade; ML probability overridden).",
text_zh="ๆœ€็ปˆ้ฃŽ้™ฉ = 100๏ผˆไธ€็ฅจๅฆๅ†ณ๏ผ›ML ๆฆ‚็އ่ขซ่ฆ†็›–๏ผ‰ใ€‚"))
return result
# โ”€โ”€ P4.4 + P4.5: activity-weighted composite score โ”€โ”€
composite = apply_activity_weighting(subs, activity)
result.risk_score = composite
result.risk_level = _bin_level(composite)
log.append(InferenceStep(
kind="activity",
text_en=f"Activity={activity}: weighted composite score = {composite}.",
text_zh=f"ๆดปๅŠจ็ฑปๅž‹ {activity}๏ผšๅŠ ๆƒ็ปผๅˆ่ฏ„ๅˆ† = {composite}ใ€‚",
))
# โ”€โ”€ P4.6: bilingual advice โ”€โ”€
result.advice_en, result.advice_zh = _normal_advice(
composite, terrain, ml_rain_prob, subs, activity)
log.append(InferenceStep(kind="score",
text_en=f"Final risk score = {composite} โ†’ {result.risk_level}.",
text_zh=f"ๆœ€็ปˆ้ฃŽ้™ฉ่ฏ„ๅˆ† = {composite} โ†’ {result.risk_level}ใ€‚"))
return result
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
# P4.6 โ€” Bilingual advice generation
# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
def _veto_advice(triggers: list[VetoTrigger]) -> tuple[str, str]:
en = "DANGER โ€” do not proceed. " + " ".join(t.message_en for t in triggers)
zh = "ๅฑ้™ฉ โ€”โ€” ่ฏทๅ‹ฟๅ‰ๅพ€ใ€‚" + " ".join(t.message_zh for t in triggers)
return en, zh
def _normal_advice(score: int, terrain: str, ml_prob: float,
subs: HazardSubscores, activity: ActivityType) -> tuple[str, str]:
# Pick the dominant hazard to mention specifically.
by_score = sorted(
[("Rainfall", "้™้›จ", subs.rainfall),
("Fog", "้›พ", subs.fog),
("Wind gust","้˜ต้ฃŽ", subs.wind_gust),
("Thunderstorm","้›ทๆšด", subs.thunderstorm)],
key=lambda x: -x[2],
)
top_en, top_zh, top_score = by_score[0]
if score >= 80:
en = f"Danger ({top_en} dominant, {top_score}/100): cancel outdoor activity; seek shelter immediately."
zh = f"ๅฑ้™ฉ๏ผˆไธป่ฆ้ฃŽ้™ฉ {top_zh} {top_score}/100๏ผ‰๏ผš็ซ‹ๅณๅ–ๆถˆๆˆทๅค–ๆดปๅŠจ๏ผŒๅฏปๆ‰พ้ฟ้šพๆ‰€ใ€‚"
elif score >= 55:
en = (f"Warning ({top_en} dominant, {top_score}/100) in {terrain.lower()} terrain "
f"for activity={activity}. Postpone non-essential travel.")
zh = f"่ญฆๅ‘Š๏ผˆไธป่ฆ้ฃŽ้™ฉ {top_zh} {top_score}/100๏ผ‰๏ผš{terrain}ๅœฐๅฝขไธ‹ {activity} ๆดปๅŠจ๏ผŒๅปบ่ฎฎๆŽจ่ฟŸ้žๅฟ…่ฆๅ‡บ่กŒใ€‚"
elif score >= 30:
en = (f"Caution ({top_en} dominant, {top_score}/100): monitor weather closely; "
f"carry appropriate gear (rain prob {ml_prob:.0%}).")
zh = f"ๆณจๆ„๏ผˆไธป่ฆ้ฃŽ้™ฉ {top_zh} {top_score}/100๏ผ‰๏ผšๅฏ†ๅˆ‡ๅ…ณๆณจๅคฉๆฐ”๏ผŒๆบๅธฆ้€‚ๅฝ“่ฃ…ๅค‡๏ผˆ้™้›จๆฆ‚็އ {ml_prob:.0%}๏ผ‰ใ€‚"
else:
en = "Safe: conditions favourable for outdoor activity. Stay aware."
zh = "ๅฎ‰ๅ…จ๏ผšๅฝ“ๅ‰ๆกไปถ้€‚ๅˆๆˆทๅค–ๆดปๅŠจ๏ผŒไป่ฏทไฟๆŒ่ญฆ่ง‰ใ€‚"
return en, zh