From d21524bf7fb5884557582bd7876b9a6c4c9b6caa Mon Sep 17 00:00:00 2001 From: Konstantinos Michalitsis Date: Sat, 26 Sep 2026 09:29:44 +0300 Subject: [PATCH] Remove orphaned forecaster entities, fix rain-expected device_class, and add PM ET0 / nowcast diagnostics - Remove switch.ws_enable_local_forecaster and number.ws_forecaster_learning_rate from the entity registry on every setup: leftovers of the unreleased v2.0 AI/local-forecaster work, confirmed dead (zero code references, no trace in git history under those unique_ids either). - Drop the moisture device_class from binary_sensor.ws_rain_expected_1h; that class is reserved for a current wetness/leak reading, not a forecast flag. - Add a Repairs issue explaining when sensor.ws_et0_penman_monteith silently never appears because no solar_radiation source is configured. - Add an optional illuminance-derived solar radiation fallback for PM ET0, tagged via a radiation_source attribute so consumers know it's an estimate. - Expose nowcast staleness (nowcast_fetched_at / nowcast_stale attributes) on binary_sensor.ws_rain_expected_1h, and fall back to a local Zambretti-derived rain-expected signal (tagged source: local_fallback) when the Open-Meteo nowcast has failed or gone stale, rather than holding a stale value or always going unavailable. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 + README.md | 2 + custom_components/ws_core/__init__.py | 23 ++ custom_components/ws_core/binary_sensor.py | 34 ++- custom_components/ws_core/config_flow.py | 27 +- custom_components/ws_core/const.py | 37 +++ custom_components/ws_core/coordinator.py | 240 ++++++++++++++---- custom_components/ws_core/sensor.py | 2 + custom_components/ws_core/strings.json | 4 + .../ws_core/translations/en.json | 4 + tests/test_binary_sensor.py | 77 ++++++ tests/test_coordinator.py | 169 ++++++++++++ tests/test_integration.py | 7 + tests/test_migration.py | 57 +++++ 14 files changed, 637 insertions(+), 60 deletions(-) create mode 100644 tests/test_binary_sensor.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b115e..29726b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to Weather Station Core are documented here. +## [Unreleased] + +### Added + +- **Illuminance-based fallback for ET₀ Penman-Monteith.** When no `solar_radiation` source is mapped, an optional "illuminance fallback" (Solar Forecast step of the config/options flow) derives an approximate solar radiation from a mapped illuminance (lux) sensor using a ~0.0079 W/m² per lux daylight conversion factor. `sensor.ws_et0_penman_monteith` reports the value's `radiation_source` attribute (`sensor` or `illuminance_estimate`) so automations can tell a real pyranometer reading from an estimate (accurate to roughly ±25%, since luminous efficacy varies with sky conditions and sun elevation). +- **Repair issue for a silently-missing PM ET₀ sensor.** `sensor.ws_et0_penman_monteith` never appeared when Solar Forecast was enabled but no `solar_radiation` source (and no illuminance fallback) was configured, with no indication why. A Repairs issue now explains it. +- **Nowcast staleness is now visible.** `binary_sensor.ws_rain_expected_1h` exposes `nowcast_fetched_at` and `nowcast_stale` attributes, and a `source` attribute (`nowcast` or `local_fallback`). +- **Local-only nowcast fallback.** When the Open-Meteo nowcast fetch has failed or its cache is older than 2x the fetch interval, `binary_sensor.ws_rain_expected_1h` now falls back to a coarser rain-expected signal derived from the local pressure-trend/Zambretti forecast instead of silently holding a stale value. The entity goes explicitly `unavailable` only if neither a fresh nowcast nor a Zambretti reading exists. + +### Fixed + +- **Two orphaned entities from an unreleased feature.** `switch.ws_enable_local_forecaster` and `number.ws_forecaster_learning_rate` are leftovers of the v2.0 AI/local-forecaster work that never shipped (v1.8.0 stripped its user-facing surfaces before release), but a handful of instances that briefly ran a pre-release build still carry them as `restored`/`unavailable`. They are now removed from the entity registry on every setup. Other restored/unavailable entities a user has legitimately disabled are untouched. +- **`binary_sensor.ws_rain_expected_1h` used `device_class: moisture`,** which Home Assistant reserves for a *current* wetness/leak reading, not a forecast flag. The device_class has been removed. + ## [2.8.1] - 2026-09-25 ### Changed diff --git a/README.md b/README.md index 336a8b2..55db77c 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ to a bug report. Use the one calibrated for your region: **FFDI** (`sensor.ws_ffdi`) in Australia and New Zealand, **FWI** (`sensor.ws_fwi`, Canadian system) elsewhere, and **FFWI** (`sensor.ws_ffwi`) as a US/global cross-check. `sensor.ws_fire_risk_score` is a simplified 1-10 display value. None of these is an official warning - always defer to your local fire authority. * **Which ET0 should I use for irrigation?** If you have mapped a solar-radiation sensor, prefer `sensor.ws_et0_penman_monteith` (FAO-56 Penman-Monteith, the reference method). Without solar radiation, use `sensor.ws_et0_daily` (Hargreaves-Samani, ±15-20%). +* **No solar radiation sensor, but I have a lux sensor - can I still get Penman-Monteith ET0?** + Yes. Enable "illuminance fallback" in the Solar Forecast step of the config/options flow (requires an illuminance source mapped in Sources). It derives an approximate solar radiation from lux using a standard daylight conversion factor of ~0.0079 W/m² per lux, but real luminous efficacy varies roughly ±25% with sky conditions (clear vs overcast) and sun elevation/spectrum, so treat the result as an estimate, not a pyranometer-grade reading. When this fallback is active, `sensor.ws_et0_penman_monteith` reports a `radiation_source: illuminance_estimate` attribute (vs `sensor` for a real solar-radiation sensor) so downstream automations can tell which mode produced the value. If neither a solar-radiation sensor is mapped nor the fallback is enabled, a repair issue explains why the sensor never appears. * **How do I uninstall?** Go to **Settings → Devices & Services → Weather Station Core → ⋮ → Delete**. This removes the integration, its device, and all derived entities and their history. To remove the code as well, delete the repository from **HACS → Integrations**. Any dashboards or blueprints you imported are independent and can be removed separately. diff --git a/custom_components/ws_core/__init__.py b/custom_components/ws_core/__init__.py index 46ae307..97df48d 100644 --- a/custom_components/ws_core/__init__.py +++ b/custom_components/ws_core/__init__.py @@ -27,6 +27,7 @@ DEFAULT_CLIMATE_REGION, DEFAULT_HEMISPHERE, DEPRECATED_CONF_KEYS_V030, + DEPRECATED_KEYS_FORECASTER, DEPRECATED_KEYS_V030, DOMAIN, ENTRY_REPAIR_ISSUES, @@ -177,6 +178,26 @@ def _loaded_coordinators(hass: HomeAssistant, entry_id: str | None = None) -> li ] +def _async_remove_orphaned_forecaster_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove registry entries left over from the unreleased local-forecaster feature. + + switch._enable_local_forecaster and number._forecaster_learning_rate + were never part of a public release (see DEPRECATED_KEYS_FORECASTER), but a handful + of instances that briefly ran a pre-release build still show them as + restored/unavailable. Unlike the v0.3.0 cleanup this isn't tied to a config version + bump, since affected instances are already on the latest schema - so it runs on + every setup and is a no-op once the entities are gone. + """ + registry = er.async_get(hass) + deprecated_uids = {f"{entry.entry_id}_{key}" for key in DEPRECATED_KEYS_FORECASTER} + for ent in list(registry.entities.values()): + if ent.config_entry_id != entry.entry_id: + continue + if ent.unique_id in deprecated_uids: + _LOGGER.info("Removing orphaned local-forecaster entity %s (unique_id=%s)", ent.entity_id, ent.unique_id) + registry.async_remove(ent.entity_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: from .coordinator import WSStationCoordinator @@ -185,6 +206,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for name in ENTRY_REPAIR_ISSUES: ir.async_delete_issue(hass, DOMAIN, name) + _async_remove_orphaned_forecaster_entities(hass, entry) + coordinator = WSStationCoordinator(hass, {**entry.data, "entry_id": entry.entry_id}, entry.options) entry.runtime_data = coordinator diff --git a/custom_components/ws_core/binary_sensor.py b/custom_components/ws_core/binary_sensor.py index 44d271d..78751db 100644 --- a/custom_components/ws_core/binary_sensor.py +++ b/custom_components/ws_core/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -18,8 +20,11 @@ DEFAULT_ENABLE_SNOW, DEFAULT_PREFIX, DOMAIN, + KEY_NOWCAST_FETCHED_AT, + KEY_NOWCAST_STALE, KEY_PACKAGE_OK, KEY_RAIN_EXPECTED_1H, + KEY_RAIN_EXPECTED_SOURCE, KEY_SNOW_FALLING, ) @@ -86,10 +91,16 @@ def is_on(self) -> bool | None: class WSRainExpected1h(CoordinatorEntity, BinarySensorEntity): - """True when measurable rain is expected within the next 60 minutes.""" + """True when measurable rain is expected within the next 60 minutes. + + No BinarySensorDeviceClass fits a forecast flag: MOISTURE is reserved for + a wetness/leak sensor reporting a *current* physical condition, which this + is not - it's a nowcast prediction. Left without a device_class so HA + doesn't imply a leak-detection semantic that isn't there (see issue about + device_class MOISTURE misuse). + """ _attr_has_entity_name = True - _attr_device_class = BinarySensorDeviceClass.MOISTURE def __init__(self, coordinator, entry: ConfigEntry, prefix: str): super().__init__(coordinator) @@ -103,6 +114,16 @@ def __init__(self, coordinator, entry: ConfigEntry, prefix: str): def device_info(self): return {"identifiers": {(DOMAIN, self._entry.entry_id)}} + @property + def available(self) -> bool: + # Explicitly unavailable once the Open-Meteo nowcast is stale/failed + # and the local Zambretti fallback couldn't produce a value either, + # rather than silently holding the last (possibly hours-old) reading. + if not super().available: + return False + d = self.coordinator.data or {} + return d.get(KEY_RAIN_EXPECTED_1H) is not None + @property def is_on(self) -> bool | None: d = self.coordinator.data or {} @@ -111,6 +132,15 @@ def is_on(self) -> bool | None: return None return bool(v) + @property + def extra_state_attributes(self) -> dict[str, Any]: + d = self.coordinator.data or {} + return { + "source": d.get(KEY_RAIN_EXPECTED_SOURCE), + "nowcast_fetched_at": d.get(KEY_NOWCAST_FETCHED_AT), + "nowcast_stale": d.get(KEY_NOWCAST_STALE), + } + class WSSnowFalling(CoordinatorEntity, BinarySensorEntity): """True when the estimated precipitation phase is snow. (v2.7, opt-in) diff --git a/custom_components/ws_core/config_flow.py b/custom_components/ws_core/config_flow.py index d6d1066..14be8a3 100644 --- a/custom_components/ws_core/config_flow.py +++ b/custom_components/ws_core/config_flow.py @@ -70,6 +70,7 @@ CONF_ENABLE_WOW, CONF_ENABLE_WUNDERGROUND, CONF_ENABLE_ZAMBRETTI, + CONF_ET0_ILLUMINANCE_FALLBACK, CONF_FORECAST_API_KEY, CONF_FORECAST_ENABLED, CONF_FORECAST_ENTITY, @@ -147,6 +148,7 @@ DEFAULT_ENABLE_WINDY, DEFAULT_ENABLE_WOW, DEFAULT_ENABLE_WUNDERGROUND, + DEFAULT_ET0_ILLUMINANCE_FALLBACK, DEFAULT_FORECAST_ENABLED, DEFAULT_FORECAST_INTERVAL_MIN, DEFAULT_FORECAST_PROVIDER, @@ -1386,6 +1388,9 @@ async def async_step_solar_forecast(self, user_input: dict[str, Any] | None = No self._data[CONF_SOLAR_INTERVAL_MIN] = int( user_input.get(CONF_SOLAR_INTERVAL_MIN, DEFAULT_SOLAR_INTERVAL_MIN) ) + self._data[CONF_ET0_ILLUMINANCE_FALLBACK] = bool( + user_input.get(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK) + ) if self._data.get(CONF_ENABLE_VIGICRUES): return await self.async_step_vigicrues_station() if self._data.get(CONF_ENABLE_INDOOR): @@ -1416,10 +1421,16 @@ async def async_step_solar_forecast(self, user_input: dict[str, Any] | None = No vol.Optional(CONF_SOLAR_INTERVAL_MIN, default=DEFAULT_SOLAR_INTERVAL_MIN): selector.NumberSelector( selector.NumberSelectorConfig(min=30, max=360, step=30, mode="box", unit_of_measurement="min") ), + vol.Optional( + CONF_ET0_ILLUMINANCE_FALLBACK, default=DEFAULT_ET0_ILLUMINANCE_FALLBACK + ): selector.BooleanSelector(), } ), description_placeholders={ - "info": "Free solar PV generation forecast from forecast.solar. Uses forecast lat/lon. Azimuth: 0=N, 90=E, 180=S, 270=W." + "info": "Free solar PV generation forecast from forecast.solar. Uses forecast lat/lon. Azimuth: 0=N, 90=E, 180=S, 270=W. " + "The illuminance fallback derives an approximate solar radiation from a lux sensor for the ET₀ " + "Penman-Monteith sensor when no solar_radiation source is mapped - lower confidence than a real " + "pyranometer reading, and only used if an illuminance sensor is also mapped in Sources." }, last_step=False, ) @@ -2320,6 +2331,9 @@ async def async_step_solar_forecast_opt(self, user_input: dict[str, Any] | None self._opt[CONF_SOLAR_INTERVAL_MIN] = int( user_input.get(CONF_SOLAR_INTERVAL_MIN, DEFAULT_SOLAR_INTERVAL_MIN) ) + self._opt[CONF_ET0_ILLUMINANCE_FALLBACK] = bool( + user_input.get(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK) + ) return await self._finish_or_next("solar_forecast_opt") return self.async_show_form( step_id="solar_forecast_opt", @@ -2351,9 +2365,18 @@ async def async_step_solar_forecast_opt(self, user_input: dict[str, Any] | None ): selector.NumberSelector( selector.NumberSelectorConfig(min=30, max=360, step=30, mode="box", unit_of_measurement="min") ), + vol.Optional( + CONF_ET0_ILLUMINANCE_FALLBACK, + default=g(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK), + ): selector.BooleanSelector(), } ), - description_placeholders={"info": "Azimuth: 0=N, 90=E, 180=S, 270=W. Tilt: degrees from horizontal."}, + description_placeholders={ + "info": "Azimuth: 0=N, 90=E, 180=S, 270=W. Tilt: degrees from horizontal. " + "The illuminance fallback derives an approximate solar radiation from a lux sensor for the ET₀ " + "Penman-Monteith sensor when no solar_radiation source is mapped - lower confidence than a real " + "pyranometer reading, and only used if an illuminance sensor is also mapped in Sources." + }, last_step=False, ) diff --git a/custom_components/ws_core/const.py b/custom_components/ws_core/const.py index 0d4514c..9af5746 100644 --- a/custom_components/ws_core/const.py +++ b/custom_components/ws_core/const.py @@ -438,6 +438,18 @@ DEFAULT_SOLAR_PANEL_TILT = 30 # degrees from horizontal DEFAULT_SOLAR_INTERVAL_MIN = 60 +# Penman-Monteith ET₀ - optional lux-derived solar radiation fallback. +# Used when no solar_radiation source is mapped but an illuminance sensor is. +# 0.0079 W/m² per lux is a widely-cited rule-of-thumb luminous efficacy for +# daylight (~126 lm/W); actual efficacy varies roughly +/-25% with sky +# conditions (clear vs overcast) and spectrum (sun elevation), so ET₀ derived +# this way is an estimate, not a pyranometer-grade measurement. +CONF_ET0_ILLUMINANCE_FALLBACK = "et0_illuminance_fallback" +DEFAULT_ET0_ILLUMINANCE_FALLBACK = False +LUX_TO_WM2_FACTOR = 0.0079 +ET0_RADIATION_SOURCE_SENSOR = "sensor" +ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE = "illuminance_estimate" + # --------------------------------------------------------------------------- # Data keys - v0.7.0 Air Quality # --------------------------------------------------------------------------- @@ -468,6 +480,7 @@ KEY_SOLAR_FORECAST_TOMORROW_KWH = "solar_forecast_tomorrow_kwh" KEY_SOLAR_FORECAST_STATUS = "solar_forecast_status" KEY_ET0_PM_DAILY_MM = "et0_pm_daily_mm" # Penman-Monteith (when solar available) +KEY_ET0_PM_RADIATION_SOURCE = "et0_pm_radiation_source" # "sensor" or "illuminance_estimate" # --------------------------------------------------------------------------- # v1.2.0 - Self-learning, new met sensors, station intelligence, climatology @@ -675,6 +688,11 @@ DEFAULT_ENABLE_NOWCAST = False DEFAULT_NOWCAST_INTERVAL_MIN = 15 +# The Open-Meteo minutely_15 nowcast is considered stale (and the rain-expected +# entity falls back to the local Zambretti forecast, or unavailable) once its +# cache is older than this many fetch intervals. +NOWCAST_STALE_MULTIPLIER = 2 + # Suppress HA Repairs notifications (issue #20) CONF_SUPPRESS_NOTIFICATIONS = "suppress_notifications" DEFAULT_SUPPRESS_NOTIFICATIONS = False @@ -686,6 +704,14 @@ KEY_NOWCAST_INTENSITY = "nowcast_intensity" KEY_NOWCAST_CONFIDENCE = "nowcast_confidence" # "high", "medium", "low" KEY_RAIN_EXPECTED_1H = "rain_expected_1h" +KEY_NOWCAST_FETCHED_AT = "_nowcast_fetched_at" +KEY_NOWCAST_STALE = "nowcast_stale" +KEY_RAIN_EXPECTED_SOURCE = "rain_expected_source" # "nowcast" or "local_fallback" +RAIN_EXPECTED_SOURCE_NOWCAST = "nowcast" +RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK = "local_fallback" +# Z-number implied rain probability (%) at/above which the Zambretti-derived +# local fallback reports rain expected. Coarser than the 15-min NWP nowcast. +ZAMBRETTI_FALLBACK_RAIN_THRESHOLD_PCT = 50 # --------------------------------------------------------------------------- # v2.0 - Degree days + leaf wetness (new agrometeorological group) @@ -1075,6 +1101,17 @@ def normalize_indoor_rooms(raw: Any) -> list[dict]: "time_since_rain", ) +# --------------------------------------------------------------------------- +# Orphaned entities left behind by the v2.0 AI/local-forecaster work that was +# stripped from the public release before it shipped (see v1.8.0 changelog). +# Some instances that briefly ran a pre-release build still carry these in +# their entity registry as restored/unavailable. Removed on every setup. +# --------------------------------------------------------------------------- +DEPRECATED_KEYS_FORECASTER = ( + "enable_local_forecaster", + "forecaster_learning_rate", +) + DEPRECATED_CONF_KEYS_V030 = ( "enable_laundry_score", "enable_stargazing_score", diff --git a/custom_components/ws_core/coordinator.py b/custom_components/ws_core/coordinator.py index 6c7ffd1..4f520aa 100644 --- a/custom_components/ws_core/coordinator.py +++ b/custom_components/ws_core/coordinator.py @@ -169,6 +169,7 @@ CONF_ENABLE_WINDY, CONF_ENABLE_WOW, CONF_ENABLE_WUNDERGROUND, + CONF_ET0_ILLUMINANCE_FALLBACK, CONF_FORECAST_API_KEY, CONF_FORECAST_ENABLED, CONF_FORECAST_ENTITY, @@ -267,6 +268,7 @@ DEFAULT_ENABLE_WINDY, DEFAULT_ENABLE_WOW, DEFAULT_ENABLE_WUNDERGROUND, + DEFAULT_ET0_ILLUMINANCE_FALLBACK, DEFAULT_FORECAST_INTERVAL_MIN, DEFAULT_FORECAST_PROVIDER, DEFAULT_GDD_BASE_C, @@ -308,6 +310,8 @@ DRIFT_STUCK_BUCKET_SAMPLES, DRIFT_STUCK_RATE_RANGE_MAX, DRIFT_WINDOW_SAMPLES, + ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE, + ET0_RADIATION_SOURCE_SENSOR, FORECAST_AGREEMENT_ALIGNED_PP, FORECAST_AGREEMENT_CONFLICT_PP, FORECAST_MAX_RETRY_S, @@ -356,6 +360,7 @@ KEY_ET0_DAILY_MM, KEY_ET0_HOURLY_MM, KEY_ET0_PM_DAILY_MM, + KEY_ET0_PM_RADIATION_SOURCE, KEY_FEELS_LIKE_C, KEY_FFDI, KEY_FFWI, @@ -431,7 +436,9 @@ KEY_NORM_WIND_GUST_MS, KEY_NORM_WIND_SPEED_MS, KEY_NOWCAST_CONFIDENCE, + KEY_NOWCAST_FETCHED_AT, KEY_NOWCAST_INTENSITY, + KEY_NOWCAST_STALE, KEY_OWM_STATIONS_STATUS, KEY_OZONE, KEY_PACKAGE_OK, @@ -454,6 +461,7 @@ KEY_RAIN_ANOMALY_NORMAL, KEY_RAIN_DISPLAY, KEY_RAIN_EXPECTED_1H, + KEY_RAIN_EXPECTED_SOURCE, KEY_RAIN_NEXT_60MIN, KEY_RAIN_NORMAL_MM, KEY_RAIN_PROBABILITY, @@ -539,8 +547,12 @@ KEY_ZAMBRETTI_FORECAST, KEY_ZAMBRETTI_NUMBER, LEARNING_SAVE_INTERVAL_S, + LUX_TO_WM2_FACTOR, + NOWCAST_STALE_MULTIPLIER, PRESSURE_HISTORY_INTERVAL_MIN, PRESSURE_HISTORY_SAMPLES, + RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK, + RAIN_EXPECTED_SOURCE_NOWCAST, RAIN_RATE_PHYSICAL_CAP_MMPH, RAIN_RATE_WINDOW_H, REQUIRED_SOURCES, @@ -576,6 +588,7 @@ VALID_TEMP_MAX_C, VALID_TEMP_MIN_C, WIND_SMOOTH_ALPHA, + ZAMBRETTI_FALLBACK_RAIN_THRESHOLD_PCT, issue_id_for_entry, normalize_indoor_rooms, ) @@ -971,6 +984,8 @@ def _get(key: str, default: Any) -> Any: self.solar_panel_tilt = int(_get(CONF_SOLAR_PANEL_TILT, DEFAULT_SOLAR_PANEL_TILT)) self.solar_interval_min = int(_get(CONF_SOLAR_INTERVAL_MIN, DEFAULT_SOLAR_INTERVAL_MIN)) self._solar_cache: dict[str, Any] | None = None + self.et0_illuminance_fallback = bool(_get(CONF_ET0_ILLUMINANCE_FALLBACK, DEFAULT_ET0_ILLUMINANCE_FALLBACK)) + self._solar_radiation_source: str | None = None # Risk feature toggles (all default-off, opt-in via wizard) self.fog_enabled = bool(_get(CONF_ENABLE_FOG, DEFAULT_ENABLE_FOG)) @@ -2265,6 +2280,113 @@ def _compute_forecast_agreement(self, data: dict) -> None: data["_forecast_agreement_api_precip_prob"] = round(api_precip_prob) data["_forecast_agreement_delta"] = round(delta) + def _compute_nowcast(self, data: dict) -> None: + """Precipitation nowcast (Open-Meteo minutely_15), with staleness handling. + + The cache is considered stale once older than NOWCAST_STALE_MULTIPLIER x + the configured fetch interval. When fresh, blends the NWP buckets with + the local rain-gauge rate for the first 30 minutes. When stale or never + fetched, falls back to a coarser rain-expected signal derived from the + local pressure-trend/Zambretti forecast (KEY_ZAMBRETTI_NUMBER) instead of + silently reusing an old NWP value or leaving the entity unavailable. + KEY_RAIN_EXPECTED_SOURCE tags which path produced the current value. + """ + nc = self._nowcast_cache + fetched_at_iso = nc.get("fetched_at") if nc else None + + nowcast_stale = True + if fetched_at_iso: + fetched_dt = dt_util.parse_datetime(fetched_at_iso) + if fetched_dt is not None: + age_s = (dt_util.now() - dt_util.as_local(fetched_dt)).total_seconds() + nowcast_stale = age_s > self.nowcast_interval_min * 60 * NOWCAST_STALE_MULTIPLIER + + data[KEY_NOWCAST_FETCHED_AT] = fetched_at_iso + data[KEY_NOWCAST_STALE] = nowcast_stale + + if nc and not nowcast_stale: + # Local rain rate blending for the first 2 x 15-min buckets (0–30 min window) + # Local gauge is ground truth for current conditions; NWP leads at t+30min+ + raw_times = nc.get("_raw_times") + raw_precip = nc.get("_raw_precip") + if raw_times and raw_precip and len(raw_precip) >= 2: + local_rate_mmph = float(data.get(KEY_RAIN_RATE_FILT) or 0.0) + local_rate_per_15min = local_rate_mmph / 4.0 # mm/h → mm per 15-min bucket + + # Work on a mutable copy — never mutate the cached NWP data + blended_precip = list(raw_precip) + nwp_bucket_0 = blended_precip[0] + nwp_bucket_1 = blended_precip[1] + + if local_rate_per_15min > 0.05: + # Local gauge confirms rain: 70% local / 30% NWP for bucket 0, + # 50/50 for bucket 1 (gauge influence fades at t+30 min) + blended_precip[0] = round(0.7 * local_rate_per_15min + 0.3 * nwp_bucket_0, 2) + blended_precip[1] = round(0.5 * local_rate_per_15min + 0.5 * nwp_bucket_1, 2) + nowcast_confidence = "high" + elif local_rate_per_15min == 0.0 and nwp_bucket_0 > 0.1: + # Gauge is dry but NWP says rain is here — low confidence + nowcast_confidence = "low" + else: + # Gauge and NWP broadly agree + nowcast_confidence = "high" if abs(local_rate_per_15min - nwp_bucket_0) < 0.2 else "medium" + + # Re-derive nowcast from the blended bucket list + nc_blended = derive_nowcast(raw_times, blended_precip, dt_util.now()) + nc_blended["rain_expected_1h"] = bool( + nc_blended.get("next_60min_mm", 0.0) >= NOWCAST_BUCKET_THRESHOLD_MM + ) + + data[KEY_RAIN_NEXT_60MIN] = nc_blended.get("next_60min_mm") + data[KEY_MINUTES_UNTIL_RAIN] = nc_blended.get("minutes_until_rain") + data[KEY_MINUTES_UNTIL_DRY] = nc_blended.get("minutes_until_dry") + data[KEY_NOWCAST_INTENSITY] = nc_blended.get("intensity") + data[KEY_RAIN_EXPECTED_1H] = nc_blended.get("rain_expected_1h") + data["_nowcast_peak_rate_mmph"] = nc_blended.get("peak_rate_mmph") + data["_nowcast_raining_now"] = nc_blended.get("raining_now") + data[KEY_NOWCAST_CONFIDENCE] = nowcast_confidence + else: + # No raw buckets available — fall back to cached derived values as-is + data[KEY_RAIN_NEXT_60MIN] = nc.get("next_60min_mm") + data[KEY_MINUTES_UNTIL_RAIN] = nc.get("minutes_until_rain") + data[KEY_MINUTES_UNTIL_DRY] = nc.get("minutes_until_dry") + data[KEY_NOWCAST_INTENSITY] = nc.get("intensity") + data[KEY_RAIN_EXPECTED_1H] = nc.get("rain_expected_1h") + data["_nowcast_peak_rate_mmph"] = nc.get("peak_rate_mmph") + data["_nowcast_raining_now"] = nc.get("raining_now") + + data[KEY_RAIN_EXPECTED_SOURCE] = RAIN_EXPECTED_SOURCE_NOWCAST + else: + # Open-Meteo nowcast has failed or gone stale (never fetched, or + # older than NOWCAST_STALE_MULTIPLIER x the fetch interval). + # Fall back to the coarser local pressure-trend/Zambretti path + # instead of silently reusing a stale NWP value or going + # unavailable outright. + data[KEY_RAIN_NEXT_60MIN] = None + data[KEY_MINUTES_UNTIL_RAIN] = None + data[KEY_MINUTES_UNTIL_DRY] = None + data[KEY_NOWCAST_INTENSITY] = None + data["_nowcast_peak_rate_mmph"] = None + data["_nowcast_raining_now"] = None + + z_idx = None + z_number = data.get(KEY_ZAMBRETTI_NUMBER) + if z_number is not None: + with contextlib.suppress(TypeError, ValueError): + candidate = int(z_number) - 1 + if 0 <= candidate < len(ZAMBRETTI_RAIN_PCT): + z_idx = candidate + + if z_idx is not None: + z_rain_pct = ZAMBRETTI_RAIN_PCT[z_idx] + data[KEY_RAIN_EXPECTED_1H] = z_rain_pct >= ZAMBRETTI_FALLBACK_RAIN_THRESHOLD_PCT + data[KEY_NOWCAST_CONFIDENCE] = "low" + data[KEY_RAIN_EXPECTED_SOURCE] = RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK + else: + # Neither a fresh nowcast nor a Zambretti reading is available. + data[KEY_RAIN_EXPECTED_1H] = None + data[KEY_RAIN_EXPECTED_SOURCE] = None + def _compute_degree_days( self, data: dict, now: Any, tc: float | None, dew_c: float | None, rh: float | None, sample: bool = True ) -> None: @@ -2495,8 +2617,10 @@ def _compute_et0(self, data: dict, now: Any) -> None: rain_today = data.get("_rain_today_mm", 0.0) or 0.0 data[KEY_IRRIGATION_DEFICIT] = calculate_irrigation_deficit(float(et0_daily), float(rain_today)) - # v2.0 Solar energy accumulation (Wh/m²) — requires solar radiation sensor - solar_rad = self._get_solar_radiation() + # v2.0 Solar energy accumulation (Wh/m²) — requires solar radiation sensor, + # or an illuminance-derived estimate when CONF_ET0_ILLUMINANCE_FALLBACK is on. + # This feeds the Penman-Monteith ET₀ daily mean below. + solar_rad = self._get_solar_radiation_for_et0() if solar_rad is not None: now_local = dt_util.now() solar_date = now_local.strftime("%Y-%m-%d") @@ -3269,6 +3393,7 @@ def _compute_health(self, data: dict, now: Any, missing: list, missing_entities: ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("forecast_api_failures")) ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("stuck_sensors")) ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("sensor_drift_detected")) + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("et0_pm_unavailable")) else: if missing_entities: ir.async_create_issue( @@ -3345,6 +3470,29 @@ def _compute_health(self, data: dict, now: Any, missing: list, missing_entities: else: ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("sensor_drift_detected")) + # PM ET₀ silently never appears when enable_solar_forecast is on but + # no solar_radiation source is mapped and the illuminance fallback + # (CONF_ET0_ILLUMINANCE_FALLBACK) isn't compensating for it. + from .const import SRC_SOLAR_RADIATION + + et0_pm_blocked = ( + self.solar_forecast_enabled + and self.forecast_lat is not None + and not self.sources.get(SRC_SOLAR_RADIATION) + and data.get(KEY_ET0_PM_DAILY_MM) is None + ) + if et0_pm_blocked: + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id("et0_pm_unavailable"), + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="et0_pm_unavailable", + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id("et0_pm_unavailable")) + # ------------------------------------------------------------------ # v1.2.0 - Fog, precipitation type, thunderstorm index # ------------------------------------------------------------------ @@ -4358,60 +4506,8 @@ def _compute(self, sample: bool = True) -> dict[str, Any]: data[f"_river_station_code_{code}"] = vr.get("station_code") # v1.7.0 - Precipitation nowcast (Open-Meteo minutely_15) - if self.nowcast_enabled and self._nowcast_cache: - nc = self._nowcast_cache - - # Local rain rate blending for the first 2 x 15-min buckets (0–30 min window) - # Local gauge is ground truth for current conditions; NWP leads at t+30min+ - raw_times = nc.get("_raw_times") - raw_precip = nc.get("_raw_precip") - if raw_times and raw_precip and len(raw_precip) >= 2: - local_rate_mmph = float(data.get(KEY_RAIN_RATE_FILT) or 0.0) - local_rate_per_15min = local_rate_mmph / 4.0 # mm/h → mm per 15-min bucket - - # Work on a mutable copy — never mutate the cached NWP data - blended_precip = list(raw_precip) - nwp_bucket_0 = blended_precip[0] - nwp_bucket_1 = blended_precip[1] - - if local_rate_per_15min > 0.05: - # Local gauge confirms rain: 70% local / 30% NWP for bucket 0, - # 50/50 for bucket 1 (gauge influence fades at t+30 min) - blended_precip[0] = round(0.7 * local_rate_per_15min + 0.3 * nwp_bucket_0, 2) - blended_precip[1] = round(0.5 * local_rate_per_15min + 0.5 * nwp_bucket_1, 2) - nowcast_confidence = "high" - elif local_rate_per_15min == 0.0 and nwp_bucket_0 > 0.1: - # Gauge is dry but NWP says rain is here — low confidence - nowcast_confidence = "low" - else: - # Gauge and NWP broadly agree - nowcast_confidence = "high" if abs(local_rate_per_15min - nwp_bucket_0) < 0.2 else "medium" - - # Re-derive nowcast from the blended bucket list - nc_blended = derive_nowcast(raw_times, blended_precip, dt_util.now()) - nc_blended["rain_expected_1h"] = bool( - nc_blended.get("next_60min_mm", 0.0) >= NOWCAST_BUCKET_THRESHOLD_MM - ) - - data[KEY_RAIN_NEXT_60MIN] = nc_blended.get("next_60min_mm") - data[KEY_MINUTES_UNTIL_RAIN] = nc_blended.get("minutes_until_rain") - data[KEY_MINUTES_UNTIL_DRY] = nc_blended.get("minutes_until_dry") - data[KEY_NOWCAST_INTENSITY] = nc_blended.get("intensity") - data[KEY_RAIN_EXPECTED_1H] = nc_blended.get("rain_expected_1h") - data["_nowcast_peak_rate_mmph"] = nc_blended.get("peak_rate_mmph") - data["_nowcast_raining_now"] = nc_blended.get("raining_now") - data[KEY_NOWCAST_CONFIDENCE] = nowcast_confidence - else: - # No raw buckets available — fall back to cached derived values as-is - data[KEY_RAIN_NEXT_60MIN] = nc.get("next_60min_mm") - data[KEY_MINUTES_UNTIL_RAIN] = nc.get("minutes_until_rain") - data[KEY_MINUTES_UNTIL_DRY] = nc.get("minutes_until_dry") - data[KEY_NOWCAST_INTENSITY] = nc.get("intensity") - data[KEY_RAIN_EXPECTED_1H] = nc.get("rain_expected_1h") - data["_nowcast_peak_rate_mmph"] = nc.get("peak_rate_mmph") - data["_nowcast_raining_now"] = nc.get("raining_now") - - data["_nowcast_fetched_at"] = nc.get("fetched_at") + if self.nowcast_enabled: + self._compute_nowcast(data) # Moon (pure calculation, no external API) if self.moon_enabled: @@ -4464,6 +4560,7 @@ def _compute(self, sample: bool = True) -> dict[str, Any]: latitude_deg=float(self.forecast_lat), ) data[KEY_ET0_PM_DAILY_MM] = et0_pm + data[KEY_ET0_PM_RADIATION_SOURCE] = self._solar_radiation_source or ET0_RADIATION_SOURCE_SENSOR # --- v1.5.0 wind run, chill hours, clearness index --- if self.comfort_indices_enabled: @@ -5458,6 +5555,37 @@ def _get_solar_radiation(self) -> float | None: return None return self._num(self.hass, eid) + def _get_solar_radiation_for_et0(self) -> float | None: + """Solar radiation feeding the Penman-Monteith ET0 daily-energy accumulator. + + Prefers the real solar_radiation sensor. When none is mapped and + CONF_ET0_ILLUMINANCE_FALLBACK is enabled, derives an approximate value + from an illuminance (lux) sensor via LUX_TO_WM2_FACTOR and records + which path was used in self._solar_radiation_source, so the ET₀ + sensor can tag its result as an estimate. Deliberately not used by + the comfort-index calculations (THSW/WBGT/UTCI) elsewhere in this + file - those stay silent rather than degrade on an illuminance guess. + """ + from .const import SRC_SOLAR_RADIATION + + eid = self.sources.get(SRC_SOLAR_RADIATION) + if eid: + val = self._num(self.hass, eid) + if val is not None: + self._solar_radiation_source = ET0_RADIATION_SOURCE_SENSOR + return val + + if self.et0_illuminance_fallback: + lux_eid = self.sources.get(SRC_LUX) + if lux_eid: + lux = self._num(self.hass, lux_eid) + if lux is not None: + self._solar_radiation_source = ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE + return float(lux) * LUX_TO_WM2_FACTOR + + self._solar_radiation_source = None + return None + # ------------------------------------------------------------------ # v0.7.0 - Air Quality fetch (Open-Meteo AQI API, free, no key) # ------------------------------------------------------------------ diff --git a/custom_components/ws_core/sensor.py b/custom_components/ws_core/sensor.py index 5e9a3ac..c5764c8 100644 --- a/custom_components/ws_core/sensor.py +++ b/custom_components/ws_core/sensor.py @@ -109,6 +109,7 @@ KEY_ET0_DAILY_MM, KEY_ET0_HOURLY_MM, KEY_ET0_PM_DAILY_MM, + KEY_ET0_PM_RADIATION_SOURCE, KEY_FEELS_LIKE_C, KEY_FFDI, KEY_FFWI, @@ -2243,6 +2244,7 @@ class WSSensorDescription: unit_group="rain", attrs_fn=lambda d: { "hargreaves_et0": d.get(KEY_ET0_DAILY_MM), + "radiation_source": d.get(KEY_ET0_PM_RADIATION_SOURCE), }, ), # v2.0 — Irrigation water deficit (ET₀ − rain today) diff --git a/custom_components/ws_core/strings.json b/custom_components/ws_core/strings.json index 0be748e..692369b 100644 --- a/custom_components/ws_core/strings.json +++ b/custom_components/ws_core/strings.json @@ -725,6 +725,10 @@ "large_calibration_offset": { "title": "Large calibration offset applied", "description": "A calibration offset of {value} was applied to {field}. Large offsets may indicate sensor hardware failure rather than calibration drift. Consider replacing the sensor." + }, + "et0_pm_unavailable": { + "title": "PM ET₀ unavailable: no solar_radiation source configured", + "description": "Solar forecast is enabled, so the ET₀ Penman-Monteith sensor should be available, but it never appears because no solar_radiation source is mapped in this integration's sources. Either map a solar radiation (pyranometer) sensor, or enable the illuminance-based fallback in the options flow if you have a lux sensor (less accurate, tagged as an estimate)." } }, "entity": { diff --git a/custom_components/ws_core/translations/en.json b/custom_components/ws_core/translations/en.json index 0be748e..692369b 100644 --- a/custom_components/ws_core/translations/en.json +++ b/custom_components/ws_core/translations/en.json @@ -725,6 +725,10 @@ "large_calibration_offset": { "title": "Large calibration offset applied", "description": "A calibration offset of {value} was applied to {field}. Large offsets may indicate sensor hardware failure rather than calibration drift. Consider replacing the sensor." + }, + "et0_pm_unavailable": { + "title": "PM ET₀ unavailable: no solar_radiation source configured", + "description": "Solar forecast is enabled, so the ET₀ Penman-Monteith sensor should be available, but it never appears because no solar_radiation source is mapped in this integration's sources. Either map a solar radiation (pyranometer) sensor, or enable the illuminance-based fallback in the options flow if you have a lux sensor (less accurate, tagged as an estimate)." } }, "entity": { diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py new file mode 100644 index 0000000..3c7ba7a --- /dev/null +++ b/tests/test_binary_sensor.py @@ -0,0 +1,77 @@ +"""Tests for WSRainExpected1h: device_class, availability, and source tagging. + +Covers the nowcast-staleness / local-fallback work: the entity must go +explicitly unavailable when neither a fresh nowcast nor a Zambretti fallback +value exists, and must expose which path (nowcast vs local_fallback) produced +its current state. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from custom_components.ws_core.binary_sensor import WSRainExpected1h +from custom_components.ws_core.const import ( + KEY_NOWCAST_FETCHED_AT, + KEY_NOWCAST_STALE, + KEY_RAIN_EXPECTED_1H, + KEY_RAIN_EXPECTED_SOURCE, +) + + +def _make_entity(data: dict, last_update_success: bool = True) -> WSRainExpected1h: + coordinator = MagicMock() + coordinator.data = data + coordinator.last_update_success = last_update_success + entry = MagicMock() + entry.entry_id = "test_entry" + return WSRainExpected1h(coordinator, entry, "ws") + + +class TestDeviceClass: + def test_no_moisture_device_class(self): + # MOISTURE is reserved for a current wetness/leak reading, not a + # forecast flag - the entity must not claim it. + ent = _make_entity({KEY_RAIN_EXPECTED_1H: True}) + assert ent.device_class is None + + +class TestAvailability: + def test_available_with_nowcast_value(self): + ent = _make_entity({KEY_RAIN_EXPECTED_1H: True, KEY_RAIN_EXPECTED_SOURCE: "nowcast"}) + assert ent.available is True + assert ent.is_on is True + + def test_available_with_local_fallback_value(self): + ent = _make_entity({KEY_RAIN_EXPECTED_1H: False, KEY_RAIN_EXPECTED_SOURCE: "local_fallback"}) + assert ent.available is True + assert ent.is_on is False + + def test_unavailable_when_no_value_at_all(self): + # Stale/failed nowcast and no Zambretti reading either. + ent = _make_entity({KEY_RAIN_EXPECTED_1H: None, KEY_RAIN_EXPECTED_SOURCE: None}) + assert ent.available is False + + def test_unavailable_when_coordinator_update_failed(self): + ent = _make_entity({KEY_RAIN_EXPECTED_1H: True}, last_update_success=False) + assert ent.available is False + + +class TestAttributes: + def test_attrs_expose_source_and_staleness(self): + ent = _make_entity( + { + KEY_RAIN_EXPECTED_1H: True, + KEY_RAIN_EXPECTED_SOURCE: "local_fallback", + KEY_NOWCAST_FETCHED_AT: "2026-09-26T10:00:00+00:00", + KEY_NOWCAST_STALE: True, + } + ) + attrs = ent.extra_state_attributes + assert attrs["source"] == "local_fallback" + assert attrs["nowcast_stale"] is True + assert attrs["nowcast_fetched_at"] == "2026-09-26T10:00:00+00:00" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 01727c2..3c8dd8b 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -22,6 +22,8 @@ CONF_HEMISPHERE, CONF_SOURCES, CONF_STALENESS_S, + ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE, + ET0_RADIATION_SOURCE_SENSOR, KEY_ALERT_STATE, KEY_DATA_QUALITY, KEY_DEW_POINT_C, @@ -29,7 +31,10 @@ KEY_FROST_POINT_C, KEY_HEALTH_DISPLAY, KEY_NORM_WIND_GUST_MS, + KEY_NOWCAST_STALE, KEY_PACKAGE_OK, + KEY_RAIN_EXPECTED_1H, + KEY_RAIN_EXPECTED_SOURCE, KEY_SEA_LEVEL_PRESSURE_HPA, KEY_TEMP_HIGH_ALL_TIME, KEY_TEMP_HIGH_MONTH, @@ -52,10 +57,14 @@ KEY_WIND_QUADRANT, KEY_ZAMBRETTI_FORECAST, KEY_ZAMBRETTI_NUMBER, + RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK, + RAIN_EXPECTED_SOURCE_NOWCAST, SRC_GUST, SRC_HUM, + SRC_LUX, SRC_PRESS, SRC_RAIN_TOTAL, + SRC_SOLAR_RADIATION, SRC_TEMP, SRC_WIND, SRC_WIND_DIR, @@ -274,6 +283,15 @@ def _make_coordinator( coord._snowiest_day_cm = 0.0 coord._snowiest_day_date = "" + # v2.9 solar forecast / ET0 illuminance fallback + nowcast staleness + coord.solar_forecast_enabled = False + coord.et0_illuminance_fallback = False + coord._solar_radiation_source = None + coord._solar_energy_previous_whm2 = 0.0 + coord.nowcast_enabled = False + coord.nowcast_interval_min = 15 + coord._nowcast_cache = None + return coord @@ -1100,3 +1118,154 @@ def test_blitzortung_entities_still_auto_detected(self): coord = self._run(entries) assert coord._blitzortung_sources[SRC_LIGHTNING_COUNT] == "sensor.blitzortung_lightning_counter" assert coord._blitzortung_sources[SRC_LIGHTNING_DISTANCE] == "sensor.blitzortung_lightning_distance" + + +# --------------------------------------------------------------------------- +# Tests: Nowcast staleness + local Zambretti fallback +# --------------------------------------------------------------------------- + + +class TestComputeNowcast: + def _coord_with_zambretti(self, z_number): + coord = _make_coordinator() + coord.nowcast_enabled = True + return coord, {KEY_ZAMBRETTI_NUMBER: z_number} + + def test_fresh_cache_uses_nowcast_source(self): + coord, data = self._coord_with_zambretti(10) + now_iso = dt_util.now().isoformat() + coord._nowcast_cache = { + "fetched_at": now_iso, + "next_60min_mm": 0.0, + "minutes_until_rain": None, + "minutes_until_dry": None, + "intensity": "none", + "rain_expected_1h": False, + "peak_rate_mmph": 0.0, + "raining_now": False, + } + coord._compute_nowcast(data) + assert data[KEY_NOWCAST_STALE] is False + assert data[KEY_RAIN_EXPECTED_SOURCE] == RAIN_EXPECTED_SOURCE_NOWCAST + assert data[KEY_RAIN_EXPECTED_1H] is False + + def test_stale_cache_falls_back_to_zambretti_high_rain_pct(self): + # Z-number 26 ("Very Unsettled, Rain") sits at the high-rain-probability + # end of ZAMBRETTI_RAIN_PCT. + coord, data = self._coord_with_zambretti(26) + old_iso = (dt_util.now() - timedelta(hours=2)).isoformat() + coord._nowcast_cache = {"fetched_at": old_iso, "next_60min_mm": 0.0, "rain_expected_1h": False} + coord._compute_nowcast(data) + assert data[KEY_NOWCAST_STALE] is True + assert data[KEY_RAIN_EXPECTED_SOURCE] == RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK + assert data[KEY_RAIN_EXPECTED_1H] is True + + def test_never_fetched_falls_back_to_zambretti_low_rain_pct(self): + # Z-number 1 ("Settled Fine") sits at the low-rain-probability end. + coord, data = self._coord_with_zambretti(1) + coord._nowcast_cache = None + coord._compute_nowcast(data) + assert data[KEY_NOWCAST_STALE] is True + assert data[KEY_RAIN_EXPECTED_SOURCE] == RAIN_EXPECTED_SOURCE_LOCAL_FALLBACK + assert data[KEY_RAIN_EXPECTED_1H] is False + + def test_no_nowcast_and_no_zambretti_is_unavailable(self): + coord, data = self._coord_with_zambretti(None) + coord._nowcast_cache = None + coord._compute_nowcast(data) + assert data[KEY_RAIN_EXPECTED_1H] is None + assert data[KEY_RAIN_EXPECTED_SOURCE] is None + + +# --------------------------------------------------------------------------- +# Tests: PM ET0 illuminance fallback for solar radiation +# --------------------------------------------------------------------------- + + +class TestSolarRadiationForEt0: + def test_prefers_real_solar_sensor(self): + coord = _make_coordinator() + coord.sources = {SRC_SOLAR_RADIATION: "sensor.solar", SRC_LUX: "sensor.lux"} + coord.et0_illuminance_fallback = True + coord.hass.states.get = lambda eid: { + "sensor.solar": _make_state("500.0"), + "sensor.lux": _make_state("40000"), + }.get(eid) + val = coord._get_solar_radiation_for_et0() + assert val == 500.0 + assert coord._solar_radiation_source == ET0_RADIATION_SOURCE_SENSOR + + def test_falls_back_to_illuminance_when_enabled(self): + coord = _make_coordinator() + coord.sources = {SRC_LUX: "sensor.lux"} # no solar_radiation mapped + coord.et0_illuminance_fallback = True + coord.hass.states.get = lambda eid: {"sensor.lux": _make_state("40000")}.get(eid) + val = coord._get_solar_radiation_for_et0() + assert val == 40000 * 0.0079 + assert coord._solar_radiation_source == ET0_RADIATION_SOURCE_ILLUMINANCE_ESTIMATE + + def test_no_fallback_when_disabled(self): + coord = _make_coordinator() + coord.sources = {SRC_LUX: "sensor.lux"} + coord.et0_illuminance_fallback = False + coord.hass.states.get = lambda eid: {"sensor.lux": _make_state("40000")}.get(eid) + val = coord._get_solar_radiation_for_et0() + assert val is None + assert coord._solar_radiation_source is None + + def test_no_fallback_when_no_illuminance_source(self): + coord = _make_coordinator() + coord.sources = {} + coord.et0_illuminance_fallback = True + val = coord._get_solar_radiation_for_et0() + assert val is None + assert coord._solar_radiation_source is None + + +# --------------------------------------------------------------------------- +# Tests: PM ET0 "unavailable" repair issue +# --------------------------------------------------------------------------- + + +class TestEt0PmUnavailableIssue: + def _run(self, solar_forecast_enabled, forecast_lat, sources, et0_present): + coord = _make_coordinator() + coord.solar_forecast_enabled = solar_forecast_enabled + coord.forecast_lat = forecast_lat + coord.sources = sources + coord.suppress_notifications = False + from custom_components.ws_core.const import KEY_ET0_PM_DAILY_MM + + data = {KEY_ET0_PM_DAILY_MM: 3.2} if et0_present else {} + now = datetime.now(UTC) + with ( + patch("custom_components.ws_core.coordinator.ir.async_create_issue") as mock_create, + patch("custom_components.ws_core.coordinator.ir.async_delete_issue") as mock_delete, + ): + coord._compute_health(data, now, missing=[], missing_entities=[]) + return mock_create, mock_delete + + def test_issue_created_when_solar_forecast_on_but_no_source(self): + mock_create, _ = self._run(solar_forecast_enabled=True, forecast_lat=37.9, sources={}, et0_present=False) + keys = [c.args[2] for c in mock_create.call_args_list] + assert any(k.startswith("et0_pm_unavailable") for k in keys) + + def test_no_issue_when_solar_forecast_disabled(self): + mock_create, _ = self._run(solar_forecast_enabled=False, forecast_lat=37.9, sources={}, et0_present=False) + keys = [c.args[2] for c in mock_create.call_args_list] + assert not any(k.startswith("et0_pm_unavailable") for k in keys) + + def test_no_issue_when_source_configured(self): + mock_create, _ = self._run( + solar_forecast_enabled=True, + forecast_lat=37.9, + sources={SRC_SOLAR_RADIATION: "sensor.solar"}, + et0_present=False, + ) + keys = [c.args[2] for c in mock_create.call_args_list] + assert not any(k.startswith("et0_pm_unavailable") for k in keys) + + def test_no_issue_when_et0_already_computed(self): + mock_create, _ = self._run(solar_forecast_enabled=True, forecast_lat=37.9, sources={}, et0_present=True) + keys = [c.args[2] for c in mock_create.call_args_list] + assert not any(k.startswith("et0_pm_unavailable") for k in keys) diff --git a/tests/test_integration.py b/tests/test_integration.py index bd7022d..f87a2c0 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -114,6 +114,13 @@ def _make_coordinator(**overrides): coord._alert_debounce_raw: dict = {} coord._alert_debounce_clear: dict = {} coord._alert_active: dict = {} + coord.solar_forecast_enabled = False + coord.et0_illuminance_fallback = False + coord._solar_radiation_source = None + coord._solar_energy_previous_whm2 = 0.0 + coord.nowcast_enabled = False + coord.nowcast_interval_min = 15 + coord._nowcast_cache = None return coord, hass, states diff --git a/tests/test_migration.py b/tests/test_migration.py index 51602f2..c5caf93 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -85,6 +85,63 @@ def test_v1_entry_gets_slug_defaults(self): } +class TestOrphanedForecasterCleanup: + """switch.ws_enable_local_forecaster / number.ws_forecaster_learning_rate are + leftovers from the unreleased v2.0 AI/local-forecaster work and show up as + restored/unavailable on some instances. Cleanup runs on every setup (not + tied to a config version bump, since affected instances are already current). + """ + + def _entity(self, entity_id, unique_id, config_entry_id): + ent = MagicMock() + ent.entity_id = entity_id + ent.unique_id = unique_id + ent.config_entry_id = config_entry_id + return ent + + def test_removes_only_matching_uids_for_this_entry(self): + from custom_components.ws_core import _async_remove_orphaned_forecaster_entities + + entry = MagicMock() + entry.entry_id = "entry1" + + dead_switch = self._entity("switch.ws_enable_local_forecaster", "entry1_enable_local_forecaster", "entry1") + dead_number = self._entity("number.ws_forecaster_learning_rate", "entry1_forecaster_learning_rate", "entry1") + legit_sensor = self._entity("sensor.ws_temperature", "entry1_temperature", "entry1") + # Same unique_id suffix but a *different* config entry - must be left alone. + other_entry_dead = self._entity( + "switch.ws2_enable_local_forecaster", "entry2_enable_local_forecaster", "entry2" + ) + # A legitimately user-disabled restored switch - must not be touched. + user_disabled = self._entity("switch.ws_enable_lightning", "entry1_enable_lightning", "entry1") + + fake_registry = MagicMock() + fake_registry.entities = { + e.entity_id: e for e in (dead_switch, dead_number, legit_sensor, other_entry_dead, user_disabled) + } + + hass = MagicMock() + with patch("custom_components.ws_core.er.async_get", return_value=fake_registry): + _async_remove_orphaned_forecaster_entities(hass, entry) + + removed = {c.args[0] for c in fake_registry.async_remove.call_args_list} + assert removed == {"switch.ws_enable_local_forecaster", "number.ws_forecaster_learning_rate"} + + def test_no_op_once_already_removed(self): + from custom_components.ws_core import _async_remove_orphaned_forecaster_entities + + entry = MagicMock() + entry.entry_id = "entry1" + fake_registry = MagicMock() + fake_registry.entities = {} + + hass = MagicMock() + with patch("custom_components.ws_core.er.async_get", return_value=fake_registry): + _async_remove_orphaned_forecaster_entities(hass, entry) + + fake_registry.async_remove.assert_not_called() + + class TestIndoorRoomsMigration: """v3 -> v4 (v2.6.0): legacy list[str] indoor rooms -> named-room dicts."""