prop/lib/microwaveprop/weather/frontal_analysis.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

177 lines
6.3 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.FrontalAnalysis do
@moduledoc """
Detects surface frontal boundaries from HRRR surface grids using
the Thermal Front Parameter (TFP).
The TFP (Renard & Clarke, 1965) locates the warm side of frontal
zones by finding where the gradient of potential temperature
magnitude is maximized:
TFP = −∇|∇θ| · ∇θ/|∇θ|
The most negative TFP values mark cold fronts; values near zero
are non-frontal. We threshold at an empirically determined value
and produce a boolean front mask plus a bearing per pixel.
The meteorologist's key claim: propagation is best parallel to and
south of an advancing cold front. Paths that cross a front are
turbulently mixed and go dead. This module enables the geometric
features (distance_to_front, path_front_angle) that Phase 5 needs.
All grid math uses Nx for vectorized 2D operations.
"""
@doc """
Detect fronts from a 2D grid of surface temperature and pressure.
## Arguments
- `temp_grid` - 2D Nx tensor of surface temperature (K or °C),
shape `{ny, nx}`, row-major (latitude varies slowest).
- `pressure_grid` - 2D Nx tensor of surface pressure (Pa),
same shape. Used to compute potential temperature.
- `grid_spec` - `%{lat_start, lat_step, lon_start, lon_step}`.
## Returns
`%{mask: Nx.tensor (boolean), bearing_deg: Nx.tensor (float),
front_points: [{lat, lon, bearing}]}`
where `front_points` is a list of the strongest frontal pixels
for easy lookup.
"""
@spec detect_fronts(Nx.Tensor.t(), Nx.Tensor.t(), map()) :: %{
tfp: Nx.Tensor.t(),
mask: Nx.Tensor.t(),
bearing_deg: Nx.Tensor.t(),
front_points: [{float(), float(), float()}]
}
def detect_fronts(temp_grid, pressure_grid, grid_spec) do
# Potential temperature: θ = T * (100000/P)^0.286
theta = Nx.multiply(temp_grid, Nx.pow(Nx.divide(100_000.0, pressure_grid), 0.286))
# Gradient of theta: dθ/dx (east-west) and dθ/dy (north-south)
# Use central differences (Nx doesn't have gradient, so manual)
{ny, nx} = Nx.shape(theta)
{grad_y, grad_x} = central_gradient(theta)
# Magnitude of gradient
grad_mag = Nx.sqrt(Nx.add(Nx.pow(grad_x, 2), Nx.pow(grad_y, 2)))
# Avoid division by zero
grad_mag_safe = Nx.max(grad_mag, 1.0e-10)
# Gradient of gradient magnitude (the TFP numerator uses this)
{grad_mag_y, grad_mag_x} = central_gradient(grad_mag)
# TFP = (∇|∇θ| · ∇θ/|∇θ|)
# Dot product of grad(|grad_theta|) with the unit gradient of theta
unit_x = Nx.divide(grad_x, grad_mag_safe)
unit_y = Nx.divide(grad_y, grad_mag_safe)
dot = Nx.add(Nx.multiply(grad_mag_x, unit_x), Nx.multiply(grad_mag_y, unit_y))
tfp = Nx.negate(dot)
# Threshold: most negative TFP = strongest fronts
# Empirical threshold calibrated against NWS surface analysis
threshold = -2.0e-10
mask = Nx.less(tfp, threshold)
# Bearing of the front at each pixel (perpendicular to the temperature gradient)
bearing_rad = Nx.atan2(grad_x, grad_y)
bearing_deg = Nx.multiply(bearing_rad, 180.0 / :math.pi())
# Extract front points as a list for spatial queries
front_points = extract_front_points(mask, bearing_deg, grid_spec, ny, nx)
%{
tfp: tfp,
mask: mask,
bearing_deg: bearing_deg,
front_points: front_points
}
end
@doc """
Compute distance (km) and angle to the nearest front point from a given lat/lon.
"""
@spec nearest_front([{float(), float(), float()}], float(), float()) ::
%{distance_km: float(), front_bearing_deg: float(), front_lat: float(), front_lon: float()} | nil
def nearest_front(front_points, lat, lon) when is_list(front_points) do
if front_points == [] do
nil
else
front_points
|> Enum.min_by(fn {flat, flon, _bearing} ->
dlat = flat - lat
dlon = (flon - lon) * :math.cos(lat * :math.pi() / 180.0)
dlat * dlat + dlon * dlon
end)
|> then(fn {flat, flon, bearing} ->
dlat = flat - lat
dlon = (flon - lon) * :math.cos(lat * :math.pi() / 180.0)
distance_km = :math.sqrt(dlat * dlat + dlon * dlon) * 111.0
%{
distance_km: distance_km,
front_bearing_deg: bearing,
front_lat: flat,
front_lon: flon
}
end)
end
end
@doc """
Angle between a QSO path and the nearest front (0° = parallel, 90° = crosses).
"""
@spec path_front_angle(float(), float()) :: number()
def path_front_angle(path_bearing_deg, front_bearing_deg) do
diff = abs(path_bearing_deg - front_bearing_deg)
diff = if diff > 180, do: 360 - diff, else: diff
if diff > 90, do: 180 - diff, else: diff
end
# 2D central differences (interior points only; edges use forward/backward)
defp central_gradient(tensor) do
{ny, nx} = Nx.shape(tensor)
# dy: grad along rows (north-south)
top = Nx.slice(tensor, [0, 0], [ny - 2, nx])
bottom = Nx.slice(tensor, [2, 0], [ny - 2, nx])
dy_interior = Nx.divide(Nx.subtract(bottom, top), 2.0)
# Pad edges with forward/backward diff
dy_top = Nx.subtract(Nx.slice(tensor, [1, 0], [1, nx]), Nx.slice(tensor, [0, 0], [1, nx]))
dy_bottom = Nx.subtract(Nx.slice(tensor, [ny - 1, 0], [1, nx]), Nx.slice(tensor, [ny - 2, 0], [1, nx]))
dy = Nx.concatenate([dy_top, dy_interior, dy_bottom], axis: 0)
# dx: grad along columns (east-west)
left = Nx.slice(tensor, [0, 0], [ny, nx - 2])
right = Nx.slice(tensor, [0, 2], [ny, nx - 2])
dx_interior = Nx.divide(Nx.subtract(right, left), 2.0)
dx_left = Nx.subtract(Nx.slice(tensor, [0, 1], [ny, 1]), Nx.slice(tensor, [0, 0], [ny, 1]))
dx_right = Nx.subtract(Nx.slice(tensor, [0, nx - 1], [ny, 1]), Nx.slice(tensor, [0, nx - 2], [ny, 1]))
dx = Nx.concatenate([dx_left, dx_interior, dx_right], axis: 1)
{dy, dx}
end
defp extract_front_points(mask, bearing_deg, grid_spec, _ny, nx) do
mask_binary = Nx.to_flat_list(mask)
bearing_list = Nx.to_flat_list(bearing_deg)
mask_binary
|> Enum.zip(bearing_list)
|> Enum.with_index()
|> Enum.flat_map(fn {{is_front, bearing}, idx} ->
if is_front == 1 do
row = div(idx, nx)
col = rem(idx, nx)
lat = grid_spec.lat_start + row * grid_spec.lat_step
lon = grid_spec.lon_start + col * grid_spec.lon_step
[{lat, lon, bearing}]
else
[]
end
end)
end
end