From c4ef84c1065fef474325f3c44486ffe0e7e13760 Mon Sep 17 00:00:00 2001 From: Jade Michael Thornton Date: Mon, 15 Jun 2026 09:36:58 -0500 Subject: [PATCH 1/4] fix Tempest dew point dropped from conditions _merge_tempest_conditions enriched temperature, feels-like, humidity, wind, wet-bulb, and pressure from the Tempest station but never wrote dew point. Dew point came only from the NWS METAR, so when the METAR omitted it the field showed N/A even though the Tempest observation carried dew_point. Backfill dew point from the normalized Tempest value, labeling it frost_point at and below 0C to match the NWS path, and drop the opposite label so the merged result never carries both. --- CHANGELOG.md | 4 +++ pyproject.toml | 2 +- src/stormscope/tools.py | 14 ++++++++ tests/test_tools.py | 80 +++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5464fd..271323f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.4.8 + +- fix dew point reporting `N/A` when the NWS METAR omits it but a Tempest station is active; `_merge_tempest_conditions` now backfills dew point from the Tempest `dew_point` field, with frost-point labeling at and below 0°C to match the NWS path + ## 1.4.7 - fix probabilistic tornado/wind/hail outlooks always returning 0% with null valid/expire times; SPC's March 2026 outlook revamp serializes probabilities as decimal fractions (`"0.05"`) and `int()` parsing skipped every feature diff --git a/pyproject.toml b/pyproject.toml index 62fb655..57c96ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "stormscope" -version = "1.4.7" +version = "1.4.8" description = "MCP server for real-time US weather data via NWS API, NOAA SPC, and IEM radar" readme = "README.md" license = "ISC" diff --git a/src/stormscope/tools.py b/src/stormscope/tools.py index 5e44017..ce62514 100644 --- a/src/stormscope/tools.py +++ b/src/stormscope/tools.py @@ -161,6 +161,20 @@ def _merge_tempest_conditions( f_val = feels_n if prefs.temperature == "f" else None c_val = feels_n if prefs.temperature == "c" else None result["feels_like"] = _fmt_temp(f_val, c_val, prefs) + + dew_n = normalized.get("dew_point") + if dew_n is not None: + # raw obs dew_point is °C (API is SI); use it for the frost-point + # threshold so labeling matches the NWS path regardless of prefs + dew_c = obs.get("dew_point") + dp_key = "frost_point" if dew_c is not None and dew_c <= 0 else "dewpoint" + f_val = dew_n if prefs.temperature == "f" else None + c_val = dew_n if prefs.temperature == "c" else None + result[dp_key] = _fmt_temp(f_val, c_val, prefs) + # the NWS path set exactly one of these; drop the other so the + # merged result never carries both labels + result.pop("frost_point" if dp_key == "dewpoint" else "dewpoint", None) + humidity = normalized.get("relative_humidity") if humidity is not None: result["humidity"] = _fmt_humidity(humidity) diff --git a/tests/test_tools.py b/tests/test_tools.py index 75b6f54..0bbe57a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1756,6 +1756,86 @@ def test_merge_conditions_uses_normalized_units(self): inhg_val = float(result["pressure"].split()[0]) assert 29.8 <= inhg_val <= 30.1, f"expected ~29.92 inHg, got {result['pressure']}" + def test_merge_conditions_backfills_dewpoint_from_tempest(self): + """Tempest dew_point fills the field when the NWS METAR omits it.""" + from stormscope.tempest import TempestClient + from stormscope.tools import _merge_tempest_conditions + + client = TempestClient(token="test") + obs = {"air_temperature": 18.9, "dew_point": 9.9} # 9.9°C → ~50°F + nws_result = {"temperature": "66°F", "dewpoint": "N/A"} + + import stormscope.tools as tools_mod + orig = tools_mod._tempest + tools_mod._tempest = client + try: + result = _merge_tempest_conditions(nws_result, obs, US_PREFS) + finally: + tools_mod._tempest = orig + + assert result["dewpoint"] == "50°F" + assert "frost_point" not in result + + def test_merge_conditions_dewpoint_metric_prefs(self): + """Dew point is formatted in °C under SI prefs.""" + from stormscope.tempest import TempestClient + from stormscope.tools import _merge_tempest_conditions + + client = TempestClient(token="test") + obs = {"air_temperature": 18.9, "dew_point": 9.9} # already °C + nws_result = {"temperature": "19°C", "dewpoint": "N/A"} + + import stormscope.tools as tools_mod + orig = tools_mod._tempest + tools_mod._tempest = client + try: + result = _merge_tempest_conditions(nws_result, obs, SI_PREFS) + finally: + tools_mod._tempest = orig + + assert result["dewpoint"] == "10°C" + assert "frost_point" not in result + + def test_merge_conditions_dewpoint_frost_point_labeling(self): + """A sub-zero Tempest dew point is labeled frost_point, not dewpoint.""" + from stormscope.tempest import TempestClient + from stormscope.tools import _merge_tempest_conditions + + client = TempestClient(token="test") + obs = {"air_temperature": 2.0, "dew_point": -3.0} # -3°C → ~27°F + nws_result = {"temperature": "36°F", "dewpoint": "N/A"} + + import stormscope.tools as tools_mod + orig = tools_mod._tempest + tools_mod._tempest = client + try: + result = _merge_tempest_conditions(nws_result, obs, US_PREFS) + finally: + tools_mod._tempest = orig + + assert result["frost_point"] == "27°F" + assert "dewpoint" not in result + + def test_merge_conditions_dewpoint_clears_stale_nws_frost_point(self): + """A positive Tempest dew point clears a frost_point the NWS path set.""" + from stormscope.tempest import TempestClient + from stormscope.tools import _merge_tempest_conditions + + client = TempestClient(token="test") + obs = {"air_temperature": 18.9, "dew_point": 9.9} + nws_result = {"temperature": "66°F", "frost_point": "28°F"} + + import stormscope.tools as tools_mod + orig = tools_mod._tempest + tools_mod._tempest = client + try: + result = _merge_tempest_conditions(nws_result, obs, US_PREFS) + finally: + tools_mod._tempest = orig + + assert result["dewpoint"] == "50°F" + assert "frost_point" not in result + def test_merge_forecast_no_tempest_hourly_key(self): """S2: _merge_tempest_forecast must not leak _tempest_hourly into output.""" from stormscope.tools import _merge_tempest_forecast diff --git a/uv.lock b/uv.lock index 433070d..a9cc83f 100644 --- a/uv.lock +++ b/uv.lock @@ -1456,7 +1456,7 @@ wheels = [ [[package]] name = "stormscope" -version = "1.4.7" +version = "1.4.8" source = { editable = "." } dependencies = [ { name = "fastmcp" }, From d91867d738dabca742312bd46b82f855170fbe98 Mon Sep 17 00:00:00 2001 From: Jade Michael Thornton Date: Mon, 15 Jun 2026 10:07:29 -0500 Subject: [PATCH 2/4] make Tempest primary for all conditions fields When a Tempest station is in range it should be the primary source for every field it measures, but several were still NWS-only or dropped. - source wind_gust from Tempest (was NWS-only) and add wind_lull - set observation_time to the Tempest reading time, not the NWS METAR - fix sea-level pressure never computing: station elevation lives under station_meta.elevation, so the top-level lookup always returned None and pressure silently fell back to NWS; pressure_source is now tempest_slp - add hyper-local fields: brightness, wet_bulb_globe_temperature, delta_t (degrees C by convention), precip_last_hour, and last-strike distance (shown only after recent lightning) --- CHANGELOG.md | 3 ++ src/stormscope/server.py | 18 +++++++--- src/stormscope/tempest.py | 3 +- src/stormscope/tools.py | 50 +++++++++++++++++++++++++- tests/test_tools.py | 75 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 271323f..d3f1038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## 1.4.8 - fix dew point reporting `N/A` when the NWS METAR omits it but a Tempest station is active; `_merge_tempest_conditions` now backfills dew point from the Tempest `dew_point` field, with frost-point labeling at and below 0°C to match the NWS path +- make the Tempest station the primary source for every field it measures in current conditions: `wind_gust` now comes from Tempest (was NWS-only), and `observation_time` reflects the Tempest reading time rather than the NWS METAR time +- fix sea-level pressure silently never computing from Tempest data; station elevation is read from `station_meta.elevation` (the top-level `elevation` key is absent), so `pressure_source` is now `tempest_slp` instead of falling back to NWS +- add Tempest hyper-local fields to conditions: `wind_lull`, `brightness`, `wet_bulb_globe_temperature`, `delta_t` (°C), `precip_last_hour`, and last-strike distance (shown only when there has been recent lightning) ## 1.4.7 diff --git a/src/stormscope/server.py b/src/stormscope/server.py index 426c02b..b1a9152 100644 --- a/src/stormscope/server.py +++ b/src/stormscope/server.py @@ -50,9 +50,12 @@ async def _lifespan(app): "sectors, surface lows/highs, or synoptic surface patterns. Returns " "distance and bearing to nearby fronts and pressure centers, plus " "warm/cold sector detection relative to the nearest cold front.\n\n" - "When a Tempest weather station is configured, get_conditions includes " - "hyper-local sensor data from the user's personal station: solar_radiation, " - "uv_index, lightning_strikes_1hr, air_density, and wet_bulb_temperature. " + "When a Tempest weather station is configured, it is the primary source " + "for every field it measures and get_conditions adds hyper-local sensor " + "data from the user's personal station: solar_radiation, uv_index, " + "brightness, lightning_strikes_1hr, last-strike distance (when recent), " + "air_density, wet_bulb_temperature, wet_bulb_globe_temperature, delta_t, " + "precip_last_hour, wind_lull. " "The data_source field indicates whether primary readings (temperature, " "wind) come from the Tempest station or NWS. All pressure values are sea " "level pressure. The pressure_source field is 'tempest_slp' when Tempest " @@ -139,9 +142,16 @@ async def get_conditions( Use when: "What's the weather right now?", "How hot is it?", "Is it windy?" detail="standard": temperature, feels-like, dewpoint (or frost_point when <= 0C), - humidity, wind, sky, visibility, pressure. + humidity, wind, gust, sky, visibility, pressure. detail="full": adds cloud layers, present weather, raw METAR. + When a Tempest station is in range it is the primary source for every + field it measures (temperature, feels-like, dewpoint, humidity, wind, + gust, lull, pressure, observation_time) and adds hyper-local fields: + solar_radiation, uv_index, brightness, lightning_strikes_1hr, last-strike + distance (when recent), air_density, wet_bulb_temperature, + wet_bulb_globe_temperature, delta_t (°C), precip_last_hour, pressure_trend. + Omit lat/lon to use configured primary location. units: "us" or "si" for base system, with optional field overrides: diff --git a/src/stormscope/tempest.py b/src/stormscope/tempest.py index 0e5c9d1..25c0906 100644 --- a/src/stormscope/tempest.py +++ b/src/stormscope/tempest.py @@ -193,7 +193,8 @@ def normalize_obs(self, obs: dict, prefs: UnitPrefs) -> dict: wind_unit = "mps" # normalize temperature fields - for field in ("air_temperature", "wet_bulb_temperature", "feels_like", + for field in ("air_temperature", "wet_bulb_temperature", + "wet_bulb_globe_temperature", "feels_like", "dew_point", "air_temperature_high", "air_temperature_low"): val = obs.get(field) if val is None: diff --git a/src/stormscope/tools.py b/src/stormscope/tools.py index ce62514..2a8c568 100644 --- a/src/stormscope/tools.py +++ b/src/stormscope/tools.py @@ -113,10 +113,23 @@ def _merge_tempest_conditions( if uv is not None: result["uv_index"] = round(uv, 1) + brightness = normalized.get("brightness") + if brightness is not None: + result["brightness"] = f"{round(brightness)} lux" + lightning = normalized.get("lightning_strike_count_last_1hr") or normalized.get("lightning_strike_count") if lightning is not None: result["lightning_strikes_1hr"] = int(lightning) + # only surface last-strike distance when there has been recent activity, + # otherwise the value is stale (last strike could be days old) + strikes_3hr = obs.get("lightning_strike_count_last_3hr") or 0 + last_dist_km = obs.get("lightning_strike_last_distance") + if strikes_3hr and last_dist_km is not None: + result["lightning_last_strike_distance"] = _fmt_visibility( + last_dist_km * 0.621371, last_dist_km * 1000, prefs, + ) + air_density = normalized.get("air_density") if air_density is not None: result["air_density"] = f"{air_density:.3f} kg/m³" @@ -128,6 +141,18 @@ def _merge_tempest_conditions( c_val = wet_bulb_n if prefs.temperature == "c" else None result["wet_bulb_temperature"] = _fmt_temp(f_val, c_val, prefs) + wbgt_n = normalized.get("wet_bulb_globe_temperature") + if wbgt_n is not None: + f_val = wbgt_n if prefs.temperature == "f" else None + c_val = wbgt_n if prefs.temperature == "c" else None + result["wet_bulb_globe_temperature"] = _fmt_temp(f_val, c_val, prefs) + + # delta-T (dry-bulb minus wet-bulb) is an agronomy spray metric defined + # in °C by convention, so it is reported in °C regardless of prefs + delta_t = obs.get("delta_t") + if delta_t is not None: + result["delta_t"] = f"{round(delta_t, 1)}°C" + trend = normalized.get("pressure_trend") if trend is not None: result["pressure_trend"] = trend @@ -140,6 +165,12 @@ def _merge_tempest_conditions( # normalized values are already in prefs units result["data_source"] = "tempest" + # the displayed readings are now the Tempest station's, so the timestamp + # should reflect the Tempest observation, not the NWS METAR time + ts = obs.get("timestamp") + if ts is not None: + result["observation_time"] = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + temp_n = normalized.get("air_temperature") if temp_n is not None: f_val = temp_n if prefs.temperature == "f" else None @@ -185,6 +216,20 @@ def _merge_tempest_conditions( result["wind"] = _fmt_wind(wind_n, cardinal, prefs) if "wind_direction" in result: result["wind_direction"] = cardinal or "N/A" + + gust_n = normalized.get("wind_gust") + if gust_n is not None: + result["wind_gust"] = _fmt_gust(gust_n, prefs) + + lull_n = normalized.get("wind_lull") + if lull_n is not None: + result["wind_lull"] = _fmt_gust(lull_n, prefs) + + # last-hour precipitation accumulation; raw obs value is mm (API is SI) + precip_mm = obs.get("precip_accum_last_1hr") + if precip_mm is not None: + result["precip_last_hour"] = _fmt_accumulation(precip_mm, prefs) + station_press_mb = obs.get("station_pressure") elevation_m = obs.get("station_elevation") air_temp_c = obs.get("air_temperature") @@ -529,7 +574,10 @@ async def _fetch_tempest_obs(lat: float, lon: float) -> dict | None: return None # attach station name for display obs["station_name"] = station.get("name") or station.get("public_name") - obs["station_elevation"] = station.get("elevation") + # elevation lives under station_meta; the top-level key is absent, so + # without this the SLP computation silently never ran + meta_elev = (station.get("station_meta") or {}).get("elevation") + obs["station_elevation"] = meta_elev if meta_elev is not None else station.get("elevation") return obs except Exception: logger.debug("tempest observation fetch failed", exc_info=True) diff --git a/tests/test_tools.py b/tests/test_tools.py index 0bbe57a..fe2f15e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1836,6 +1836,81 @@ def test_merge_conditions_dewpoint_clears_stale_nws_frost_point(self): assert result["dewpoint"] == "50°F" assert "frost_point" not in result + def _merge(self, obs, nws_result, prefs=US_PREFS): + from stormscope.tempest import TempestClient + from stormscope.tools import _merge_tempest_conditions + import stormscope.tools as tools_mod + orig = tools_mod._tempest + tools_mod._tempest = TempestClient(token="test") + try: + return _merge_tempest_conditions(nws_result, obs, prefs) + finally: + tools_mod._tempest = orig + + def test_merge_conditions_wind_gust_and_lull(self): + """wind_gust comes from Tempest (primary); wind_lull is added.""" + obs = {"wind_gust": 10.0, "wind_lull": 0.5} # m/s + result = self._merge(obs, {"wind_gust": "5 mph"}) + assert result["wind_gust"] == "22 mph" # 10 m/s, from Tempest not NWS + assert result["wind_lull"] == "1 mph" + + def test_merge_conditions_tempest_only_fields(self): + """WBGT, brightness, delta-T, and last-hour precip are surfaced.""" + obs = { + "wet_bulb_globe_temperature": 18.1, # °C → ~65°F + "brightness": 68771, + "delta_t": 5.6, + "precip_accum_last_1hr": 2.5, # mm + } + result = self._merge(obs, {}) + assert result["wet_bulb_globe_temperature"] == "65°F" + assert result["brightness"] == "68771 lux" + assert result["delta_t"] == "5.6°C" # always °C by convention + assert result["precip_last_hour"] == "0.10 in" + + # WBGT honors metric prefs; delta_t stays °C regardless + si = self._merge( + {"wet_bulb_globe_temperature": 18.1, "delta_t": 5.6}, {}, prefs=SI_PREFS, + ) + assert si["wet_bulb_globe_temperature"] == "18°C" + assert si["delta_t"] == "5.6°C" + + def test_merge_conditions_observation_time_from_tempest(self): + """observation_time reflects the Tempest reading, not the NWS METAR.""" + obs = {"timestamp": 1700000000, "air_temperature": 20.0} + result = self._merge(obs, {"observation_time": "2026-01-01T00:00:00+00:00"}) + assert result["observation_time"] == "2023-11-14T22:13:20+00:00" + + def test_merge_conditions_lightning_distance_gated(self): + """Last-strike distance shows only with recent strikes, else is omitted.""" + recent = self._merge( + {"lightning_strike_count_last_3hr": 3, "lightning_strike_last_distance": 8}, + {}, + ) + assert recent["lightning_last_strike_distance"] == "5.0 mi" + + stale = self._merge( + {"lightning_strike_count_last_3hr": 0, "lightning_strike_last_distance": 8}, + {}, + ) + assert "lightning_last_strike_distance" not in stale + + @patch("stormscope.tools._get_tempest_station", new_callable=AsyncMock) + @patch("stormscope.tools._tempest") + async def test_fetch_tempest_obs_sources_elevation_from_meta(self, mock_tempest, mock_station): + """station elevation lives under station_meta; without this SLP never computes.""" + mock_station.return_value = { + "station_id": 1, + "name": "Test", + "station_meta": {"elevation": 285.0}, + } + mock_tempest.get_observations = AsyncMock(return_value={"air_temperature": 20.0}) + + from stormscope.tools import _fetch_tempest_obs + obs = await _fetch_tempest_obs(44.9, -93.2) + + assert obs["station_elevation"] == 285.0 + def test_merge_forecast_no_tempest_hourly_key(self): """S2: _merge_tempest_forecast must not leak _tempest_hourly into output.""" from stormscope.tools import _merge_tempest_forecast From 00c6d3accec3681776bffc74df4c06f5e202142e Mon Sep 17 00:00:00 2001 From: Jade Michael Thornton Date: Mon, 15 Jun 2026 10:43:10 -0500 Subject: [PATCH 3/4] use Tempest's own sea-level pressure as primary The local station-pressure reduction disagreed with the station's own sea_level_pressure by ~1 mb because it uses instantaneous temperature rather than a mean column temperature. Pass through the station's value instead (pressure_source: tempest), and reduce locally only when the station supplies no SLP (pressure_source: tempest_slp). --- CHANGELOG.md | 2 +- src/stormscope/server.py | 7 ++++--- src/stormscope/tools.py | 23 ++++++++++++++++------- tests/test_tools.py | 20 ++++++++++++++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f1038..bebaa16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - fix dew point reporting `N/A` when the NWS METAR omits it but a Tempest station is active; `_merge_tempest_conditions` now backfills dew point from the Tempest `dew_point` field, with frost-point labeling at and below 0°C to match the NWS path - make the Tempest station the primary source for every field it measures in current conditions: `wind_gust` now comes from Tempest (was NWS-only), and `observation_time` reflects the Tempest reading time rather than the NWS METAR time -- fix sea-level pressure silently never computing from Tempest data; station elevation is read from `station_meta.elevation` (the top-level `elevation` key is absent), so `pressure_source` is now `tempest_slp` instead of falling back to NWS +- use the Tempest station's own `sea_level_pressure` directly (`pressure_source: tempest`) rather than reducing station pressure locally; the local hypsometric reduction was an approximation that disagreed with the station's value by ~1 mb, and is now a fallback used only when the station supplies no SLP. Also fixes that fallback never running: station elevation is read from `station_meta.elevation`, not the absent top-level `elevation` key - add Tempest hyper-local fields to conditions: `wind_lull`, `brightness`, `wet_bulb_globe_temperature`, `delta_t` (°C), `precip_last_hour`, and last-strike distance (shown only when there has been recent lightning) ## 1.4.7 diff --git a/src/stormscope/server.py b/src/stormscope/server.py index b1a9152..10a3e03 100644 --- a/src/stormscope/server.py +++ b/src/stormscope/server.py @@ -58,9 +58,10 @@ async def _lifespan(app): "precip_last_hour, wind_lull. " "The data_source field indicates whether primary readings (temperature, " "wind) come from the Tempest station or NWS. All pressure values are sea " - "level pressure. The pressure_source field is 'tempest_slp' when Tempest " - "station pressure was converted to SLP using station elevation, or " - "'sea_level' when NWS altimeter-corrected pressure was used. Forecasts " + "level pressure. The pressure_source field is 'tempest' when the Tempest " + "station's own SLP is used, 'tempest_slp' when station pressure was " + "reduced to SLP locally as a fallback, or 'sea_level' when NWS " + "altimeter-corrected pressure was used. Forecasts " "include a data_source field (NWS/{office} or tempest). When Tempest is " "the primary source, original NWS values are retained as nws_* fields for " "comparison." diff --git a/src/stormscope/tools.py b/src/stormscope/tools.py index 2a8c568..2f3cbb3 100644 --- a/src/stormscope/tools.py +++ b/src/stormscope/tools.py @@ -230,15 +230,24 @@ def _merge_tempest_conditions( if precip_mm is not None: result["precip_last_hour"] = _fmt_accumulation(precip_mm, prefs) - station_press_mb = obs.get("station_pressure") - elevation_m = obs.get("station_elevation") - air_temp_c = obs.get("air_temperature") - - if station_press_mb is not None and elevation_m is not None and air_temp_c is not None: - slp_mb = station_pressure_to_slp_mb(station_press_mb, elevation_m, air_temp_c) + # Tempest reports its own sea-level pressure, reduced with the proper + # (mean-column-temperature) method, so prefer it. Reduce station pressure + # ourselves only when the station does not supply SLP directly: our + # instantaneous-temperature reduction is an approximation that can differ + # from the official value by ~1 mb. + slp_mb = obs.get("sea_level_pressure") + source = "tempest" + if slp_mb is None: + station_press_mb = obs.get("station_pressure") + elevation_m = obs.get("station_elevation") + air_temp_c = obs.get("air_temperature") + if station_press_mb is not None and elevation_m is not None and air_temp_c is not None: + slp_mb = station_pressure_to_slp_mb(station_press_mb, elevation_m, air_temp_c) + source = "tempest_slp" + if slp_mb is not None: slp_pa = slp_mb * 100 result["pressure"] = _fmt_pressure(pa_to_inhg(slp_pa), slp_pa, prefs) - result["pressure_source"] = "tempest_slp" + result["pressure_source"] = source return result diff --git a/tests/test_tools.py b/tests/test_tools.py index fe2f15e..04e9846 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1911,6 +1911,26 @@ async def test_fetch_tempest_obs_sources_elevation_from_meta(self, mock_tempest, assert obs["station_elevation"] == 285.0 + def test_merge_conditions_pressure_prefers_tempest_slp(self): + """The station's own sea_level_pressure is used directly (source 'tempest').""" + obs = { + "sea_level_pressure": 1013.2, + "station_pressure": 979.2, + "station_elevation": 285.0, + "air_temperature": 19.8, + } + result = self._merge(obs, {"pressure": "29.00 inHg", "pressure_source": "sea_level"}) + assert result["pressure_source"] == "tempest" + assert result["pressure"] == "29.92 inHg" # 1013.2 mb + + def test_merge_conditions_pressure_computes_only_as_fallback(self): + """With no station SLP, fall back to the local reduction (source 'tempest_slp').""" + obs = {"station_pressure": 979.2, "station_elevation": 285.0, "air_temperature": 19.8} + result = self._merge(obs, {"pressure": "29.00 inHg", "pressure_source": "sea_level"}) + assert result["pressure_source"] == "tempest_slp" + # locally reduced value, distinct from the NWS 29.00 it replaced + assert result["pressure"] == "29.89 inHg" + def test_merge_forecast_no_tempest_hourly_key(self): """S2: _merge_tempest_forecast must not leak _tempest_hourly into output.""" from stormscope.tools import _merge_tempest_forecast From 3c1ea728cb78ce26841fffa973dd2efbf5abde27 Mon Sep 17 00:00:00 2001 From: Jade Michael Thornton Date: Mon, 15 Jun 2026 16:12:55 -0500 Subject: [PATCH 4/4] stop mislabeling NWS station pressure as sea level When a METAR reports no seaLevelPressure the code fell back to barometricPressure, which is absolute station pressure, but still labeled pressure_source "sea_level". Label that fallback "station" so station pressure is not presented as a sea-level value. --- CHANGELOG.md | 1 + src/stormscope/server.py | 11 ++++++----- src/stormscope/tools.py | 10 ++++++++-- tests/test_tools.py | 2 ++ 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bebaa16..4fdcd7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - make the Tempest station the primary source for every field it measures in current conditions: `wind_gust` now comes from Tempest (was NWS-only), and `observation_time` reflects the Tempest reading time rather than the NWS METAR time - use the Tempest station's own `sea_level_pressure` directly (`pressure_source: tempest`) rather than reducing station pressure locally; the local hypsometric reduction was an approximation that disagreed with the station's value by ~1 mb, and is now a fallback used only when the station supplies no SLP. Also fixes that fallback never running: station elevation is read from `station_meta.elevation`, not the absent top-level `elevation` key - add Tempest hyper-local fields to conditions: `wind_lull`, `brightness`, `wet_bulb_globe_temperature`, `delta_t` (°C), `precip_last_hour`, and last-strike distance (shown only when there has been recent lightning) +- stop labeling NWS station pressure as sea-level when a METAR reports no sea-level pressure; `pressure_source` is now `station` in that case instead of presenting absolute station pressure as SLP ## 1.4.7 diff --git a/src/stormscope/server.py b/src/stormscope/server.py index 10a3e03..085f347 100644 --- a/src/stormscope/server.py +++ b/src/stormscope/server.py @@ -57,11 +57,12 @@ async def _lifespan(app): "air_density, wet_bulb_temperature, wet_bulb_globe_temperature, delta_t, " "precip_last_hour, wind_lull. " "The data_source field indicates whether primary readings (temperature, " - "wind) come from the Tempest station or NWS. All pressure values are sea " - "level pressure. The pressure_source field is 'tempest' when the Tempest " - "station's own SLP is used, 'tempest_slp' when station pressure was " - "reduced to SLP locally as a fallback, or 'sea_level' when NWS " - "altimeter-corrected pressure was used. Forecasts " + "wind) come from the Tempest station or NWS. The pressure_source field " + "tells you how to read the pressure value: 'tempest' is the Tempest " + "station's own SLP, 'tempest_slp' is station pressure reduced to SLP " + "locally as a fallback, 'sea_level' is the NWS sea-level pressure, and " + "'station' is raw NWS station pressure (not reduced to sea level) when " + "no SLP is reported. Forecasts " "include a data_source field (NWS/{office} or tempest). When Tempest is " "the primary source, original NWS values are retained as nws_* fields for " "comparison." diff --git a/src/stormscope/tools.py b/src/stormscope/tools.py index 2f3cbb3..7b40b9d 100644 --- a/src/stormscope/tools.py +++ b/src/stormscope/tools.py @@ -625,7 +625,13 @@ async def get_conditions( gust_kmh = _obs_value(obs, "windGust") humidity = _obs_value(obs, "relativeHumidity") vis_m = _obs_value(obs, "visibility") - pressure_pa = _obs_value(obs, "seaLevelPressure") or _obs_value(obs, "barometricPressure") + # seaLevelPressure is reduced to sea level; barometricPressure is the + # absolute station pressure, so it must not be labeled sea_level + slp_pa = _obs_value(obs, "seaLevelPressure") + if slp_pa is not None: + pressure_pa, pressure_source = slp_pa, "sea_level" + else: + pressure_pa, pressure_source = _obs_value(obs, "barometricPressure"), "station" temp_f = c_to_f(temp_c) feels_like_c = heat_index_c if heat_index_c is not None else wind_chill_c @@ -651,7 +657,7 @@ async def get_conditions( "sky_condition": obs.get("textDescription", "N/A"), "visibility": _fmt_visibility(m_to_miles(vis_m), vis_m, prefs), "pressure": _fmt_pressure(pa_to_inhg(pressure_pa), pressure_pa, prefs), - "pressure_source": "sea_level", + "pressure_source": pressure_source, "station_name": station.get("name", "Unknown"), "observation_time": obs.get("timestamp", "N/A"), } diff --git a/tests/test_tools.py b/tests/test_tools.py index 04e9846..1ff1d0b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -145,6 +145,8 @@ async def test_pressure_falls_back_to_barometric(self, mock_nws): result = await get_conditions(MINNEAPOLIS_LAT, MINNEAPOLIS_LON) assert result["pressure"] == "29.92 inHg" + # station pressure must not be mislabeled as sea level + assert result["pressure_source"] == "station" @patch("stormscope.tools._nws") async def test_error_for_non_us(self, mock_nws):