diff --git a/web/analysis_service.py b/web/analysis_service.py index 9999c954..6c0289f2 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -39,6 +39,14 @@ from web.services.observation_freshness import ( build_observation_freshness as _build_observation_freshness, observation_age_min as _observation_age_min, ) +from web.services.analysis_utils import ( + add_signal as _add_signal, + bucket_label as _bucket_label, + bucket_label_from_value as _bucket_label_from_value, + format_clock_minutes as _format_clock_minutes, + next_observation_clock as _next_observation_clock, + top_probability_bucket as _top_probability_bucket, +) from web.services.analysis_signals import ( _build_deviation_monitor, _build_taf_signal, @@ -305,84 +313,6 @@ def _maybe_enrich_dynamic_commentary_with_groq( - -def _clock_minutes(value: Any) -> Optional[int]: - text = str(value or "").strip() - match = re.search(r"\b(\d{1,2}):(\d{2})\b", text) - if not match: - return None - hour = int(match.group(1)) - minute = int(match.group(2)) - if hour < 0 or hour > 23 or minute < 0 or minute > 59: - return None - return hour * 60 + minute - - -def _format_clock_minutes(value: int) -> str: - value = max(0, min(23 * 60 + 59, int(value))) - return f"{value // 60:02d}:{value % 60:02d}" - - -def _next_observation_clock(local_time: Any) -> str: - minutes = _clock_minutes(local_time) - if minutes is None: - return "--" - next_slot = ((minutes // 30) + 1) * 30 - if next_slot > 23 * 60 + 59: - return "23:59" - return _format_clock_minutes(next_slot) - - -def _bucket_label_from_value(value: Optional[float], unit: str) -> Optional[str]: - if value is None: - return None - try: - return f"{int(round(float(value)))}{unit or '°C'}" - except Exception: - return None - - -def _top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]: - if not isinstance(distribution, list): - return None - candidates = [row for row in distribution if isinstance(row, dict)] - if not candidates: - return None - return max(candidates, key=lambda row: _sf(row.get("probability")) or -1.0) - - -def _bucket_label(row: Optional[Dict[str, Any]], unit: str) -> Optional[str]: - if not isinstance(row, dict): - return None - for key in ("label", "bucket", "range"): - raw = str(row.get(key) or "").strip() - if raw: - return raw - return _bucket_label_from_value(_sf(row.get("value")), unit) - - -def _add_signal( - signals: list, - *, - label: str, - direction: str, - strength: str, - summary: str, - label_en: Optional[str] = None, - summary_en: Optional[str] = None, -) -> None: - signals.append( - { - "label": label, - "label_en": label_en or label, - "direction": direction, - "strength": strength, - "summary": summary, - "summary_en": summary_en or summary, - } - ) - - def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: """Build a paid-product intraday meteorology read from existing layers.""" current = data.get("current") or {} diff --git a/web/services/analysis_utils.py b/web/services/analysis_utils.py new file mode 100644 index 00000000..4fba7e0c --- /dev/null +++ b/web/services/analysis_utils.py @@ -0,0 +1,94 @@ +"""Analysis utility functions extracted from analysis_service.py. + +Pure helpers: clock arithmetic, bucket labelling, signal packaging. +""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Optional + +from web.core import _sf + + +# ── Clock / time-slot helpers ────────────────────────────────────────── + +def clock_minutes(value: Any) -> Optional[int]: + text = str(value or "").strip() + match = re.search(r"\b(\d{1,2}):(\d{2})\b", text) + if not match: + return None + hour = int(match.group(1)) + minute = int(match.group(2)) + if hour < 0 or hour > 23 or minute < 0 or minute > 59: + return None + return hour * 60 + minute + + +def format_clock_minutes(value: int) -> str: + value = max(0, min(23 * 60 + 59, int(value))) + return f"{value // 60:02d}:{value % 60:02d}" + + +def next_observation_clock(local_time: Any) -> str: + minutes = clock_minutes(local_time) + if minutes is None: + return "--" + next_slot = ((minutes // 30) + 1) * 30 + if next_slot > 23 * 60 + 59: + return "23:59" + return format_clock_minutes(next_slot) + + +# ── Probability bucket helpers ───────────────────────────────────────── + +def bucket_label_from_value(value: Optional[float], unit: str) -> Optional[str]: + if value is None: + return None + try: + return f"{int(round(float(value)))}{unit or '°C'}" + except Exception: + return None + + +def top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]: + if not isinstance(distribution, list): + return None + candidates = [row for row in distribution if isinstance(row, dict)] + if not candidates: + return None + return max(candidates, key=lambda row: _sf(row.get("probability")) or -1.0) + + +def bucket_label(row: Optional[Dict[str, Any]], unit: str) -> Optional[str]: + if not isinstance(row, dict): + return None + for key in ("label", "bucket", "range"): + raw = str(row.get(key) or "").strip() + if raw: + return raw + return bucket_label_from_value(_sf(row.get("value")), unit) + + +# ── Signal packaging ─────────────────────────────────────────────────── + +def add_signal( + signals: list, + *, + label: str, + direction: str, + strength: str, + summary: str, + label_en: Optional[str] = None, + summary_en: Optional[str] = None, +) -> None: + signals.append( + { + "label": label, + "label_en": label_en or label, + "direction": direction, + "strength": strength, + "summary": summary, + "summary_en": summary_en or summary, + } + )