Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 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
- 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

- 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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 20 additions & 8 deletions src/stormscope/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,19 @@ 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 "
"station pressure was converted to SLP using station elevation, 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."
Expand Down Expand Up @@ -139,9 +144,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:
Expand Down
3 changes: 2 additions & 1 deletion src/stormscope/tempest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
95 changes: 86 additions & 9 deletions src/stormscope/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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³"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -161,6 +192,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)
Expand All @@ -171,15 +216,38 @@ def _merge_tempest_conditions(
result["wind"] = _fmt_wind(wind_n, cardinal, prefs)
if "wind_direction" in result:
result["wind_direction"] = cardinal or "N/A"
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)
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)

# 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

Expand Down Expand Up @@ -515,7 +583,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)
Expand Down Expand Up @@ -554,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
Expand All @@ -580,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"),
}
Expand Down
Loading
Loading