prop/lib/microwaveprop/weather/map_layers.ex
Graham McIntire a14f14485e
perf(weather): serve /weather from chunked scalar artifacts
Move the /weather render path off raw HRRR profile decoding + per-cell
SoundingParams + WeatherLayers derivation. The dominant cost was
re-deriving the same scalar fields on every viewport pan and timeline
scrub.

Server side:

- New Microwaveprop.Weather.ScalarFile persists pre-derived scalar
  rows on NFS, bucketed into 5°×5° chunk files under
  <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport
  reads only decode the chunks that overlap the requested bounds;
  point-detail clicks read exactly one chunk.
- weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile;
  ProfilesFile is now the cold-start fallback only.
- warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0
  also persist the scalar artifact, and the cold-derive path kicks
  off a per-valid_time-locked async materialization so the next
  reader gets the cheap path.
- NotifyListener.handle_propagation_ready/1 fires
  Weather.materialize_scalar_file/1 in a detached Task on the Rust
  pipeline's NOTIFY propagation_ready, so steady-state forecast hours
  arrive with their scalar artifact already on disk.
- available_weather_valid_times/0 unions ScalarFile + ProfilesFile so
  the timeline survives an aggressive retention sweep on either side.

Browser side (finding #8):

- Mount the legend Leaflet control once and patch its inner content
  on layer changes instead of remove + re-add on every renderLayer.
- renderTimeline now keys off the rendered button set: selection-only
  updates take an applyTimelineSelection patch path that restyles
  existing buttons in place. Full innerHTML rebuild only fires when
  timelineData itself changes.

Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting
interpolate_pair/2 from the inner anonymous function.
2026-04-28 17:15:36 -05:00

162 lines
5.5 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Weather.MapLayers do
@moduledoc false
@layers [
%{
id: "temperature",
label: "Temperature",
unit: "°C",
group: "Surface",
desc: "2m air temperature. Warm, humid air increases refractivity and ducting potential at lower frequencies."
},
%{
id: "dewpoint_depression",
label: "Td Depression",
unit: "°C",
group: "Surface",
desc:
"Temperature minus dewpoint. Small values (< 5°C) mean moist boundary layer — favorable for ducting at 10 GHz, but increases absorption above 24 GHz."
},
%{
id: "surface_rh",
label: "Humidity",
unit: "%",
group: "Surface",
desc:
"Relative humidity from surface T and Td. High RH supports refractivity gradients that bend microwave signals."
},
%{
id: "pwat",
label: "PWAT",
unit: "mm",
group: "Surface",
desc:
"Precipitable water — total moisture in the atmospheric column. High values signal rain fade risk for 24 GHz and above."
},
%{
id: "surface_refractivity",
label: "Refractivity",
unit: "N",
group: "Surface",
desc:
"Radio refractivity (N-units) at the surface. Typical values 280380. Higher N means the atmosphere bends signals more toward the ground."
},
%{
id: "refractivity_gradient",
label: "N-Gradient",
unit: "N/km",
group: "Surface",
desc:
"Minimum refractivity gradient in the profile. Standard is 40 N/km. Below 157 N/km signals are trapped in a duct. More negative = stronger ducting."
},
%{
id: "bl_height",
label: "BL Height",
unit: "m",
group: "Surface",
desc:
"Planetary boundary layer height. Shallow BL (< 500m) concentrates moisture and heat near the surface, favoring temperature inversions and ducting."
},
%{
id: "temp_850mb",
label: "T @ 850mb",
unit: "°C",
group: "Upper Air",
desc:
"Temperature at 850 mb (~1500m altitude). Warm 850mb air over cool surface air indicates a capping inversion — a classic ducting setup."
},
%{
id: "dewpoint_850mb",
label: "Td @ 850mb",
unit: "°C",
group: "Upper Air",
desc:
"Dewpoint at 850 mb. A sharp moisture drop between the surface and 850mb creates an elevated refractivity gradient that can form ducts."
},
%{
id: "temp_700mb",
label: "T @ 700mb",
unit: "°C",
group: "Upper Air",
desc:
"Temperature at 700 mb (~3000m altitude). The classic capping diagnostic over the southern Plains: ≥10°C indicates a moderate cap, ≥12°C strong. Combined with warm 850 mb, signals a 'loaded gun' setup that suppresses convection until forcing arrives."
},
%{
id: "dewpoint_700mb",
label: "Td @ 700mb",
unit: "°C",
group: "Upper Air",
desc:
"Dewpoint at 700 mb. Wide depressions (T-Td > 10°C) signal the elevated mixed layer (EML) plume — a hallmark cap mechanism off the Mexican plateau."
},
%{
id: "lapse_rate",
label: "Lapse Rate",
unit: "°C/km",
group: "Upper Air",
desc:
"Temperature decrease per km from surface to 700mb. Low rates (< 5 °C/km) mean stable air that preserves inversions. High rates (> 8) mean convective mixing that destroys them."
},
%{
id: "mid_lapse_rate",
label: "Mid Lapse 850-700",
unit: "°C/km",
group: "Upper Air",
desc:
"Lapse rate across the 850→700 mb layer. Steep values (≥7 °C/km) above a warm moist boundary layer indicate an elevated mixed layer — the textbook cap structure that holds back convection until the inversion is broken."
},
%{
id: "inversion_strength",
label: "Inversion",
unit: "°C",
group: "Upper Air",
desc:
"Strongest temperature increase between adjacent levels. Inversions trap microwave signals. > 3°C is significant, > 5°C is strong."
},
%{
id: "inversion_base_m",
label: "Inv. Base",
unit: "m",
group: "Upper Air",
desc:
"Height (m AGL) where the strongest inversion begins. Surface-based inversions (< 200m) create surface ducts. Elevated inversions create elevated ducts."
},
%{
id: "ducting",
label: "Ducting",
unit: "",
group: "Ducting",
desc:
"Whether a trapping layer (modified refractivity decreasing with height) was detected in the profile. Green = duct present, signals can travel far beyond line of sight."
},
%{
id: "duct_base_m",
label: "Duct Base",
unit: "m",
group: "Ducting",
desc:
"Height of the lowest duct base. Surface ducts (< 100m) are most effective for ground-based stations. Elevated ducts require antennas near the duct height."
},
%{
id: "duct_strength",
label: "Duct Strength",
unit: "M",
group: "Ducting",
desc:
"Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies."
}
]
@default_layer "temperature"
@valid_ids MapSet.new(Enum.map(@layers, & &1.id))
@spec all() :: [map()]
def all, do: @layers
@spec default_id() :: String.t()
def default_id, do: @default_layer
@spec valid_id?(term()) :: boolean()
def valid_id?(id) when is_binary(id), do: MapSet.member?(@valid_ids, id)
def valid_id?(_id), do: false
end