prop/lib/microwaveprop/propagation.ex
Graham McIntire 2e216d4244
Make point detail popup instant with client-side lookup and range circles
- Scores now include factors and valid_time in the viewport query,
  eliminating the server round-trip for popups
- Click shows two range circles: solid inner (typical range) and
  dashed outer (max estimated range), colored by score tier
- Circles disappear when popup closes
- Band info pushed to client on band switch for accurate range estimates
2026-03-31 09:50:48 -05:00

171 lines
5 KiB
Elixir

defmodule Microwaveprop.Propagation do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams
@doc """
Score a single grid point across all bands using HRRR profile data.
Returns a list of %{band_mhz, score, factors} maps.
"""
def score_grid_point(hrrr_profile, valid_time) do
derived = derive_from_hrrr(hrrr_profile)
temp_c = hrrr_profile.surface_temp_c
dewpoint_c = hrrr_profile.surface_dewpoint_c
# Skip points with missing surface data (ocean/outside-domain)
if is_nil(temp_c) or is_nil(dewpoint_c) do
[]
else
score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived)
end
end
defp score_grid_point_with_data(hrrr_profile, valid_time, temp_c, dewpoint_c, derived) do
temp_f = Scorer.c_to_f(temp_c)
dewpoint_f = Scorer.c_to_f(dewpoint_c)
conditions = %{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: temp_f,
dewpoint_f: dewpoint_f,
wind_speed_kts: Scorer.wind_speed_kts(hrrr_profile[:wind_u], hrrr_profile[:wind_v]),
sky_cover_pct: hrrr_profile[:cloud_cover_pct],
utc_hour: valid_time.hour,
utc_minute: valid_time.minute,
month: valid_time.month,
pressure_mb: hrrr_profile.surface_pressure_mb,
prev_pressure_mb: nil,
rain_rate_mmhr: Scorer.precip_to_rate_mmhr(hrrr_profile[:precip_mm]),
min_refractivity_gradient: derived[:min_refractivity_gradient],
bl_depth_m: hrrr_profile[:hpbl_m]
}
Enum.map(BandConfig.all_bands(), fn band_config ->
result = Scorer.composite_score(conditions, band_config)
Map.put(result, :band_mhz, band_config.freq_mhz)
end)
end
@doc "Upsert propagation scores in batches."
def upsert_scores(scores) do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries =
Enum.map(scores, fn s ->
%{
id: Ecto.UUID.generate(),
lat: s.lat,
lon: s.lon,
valid_time: s.valid_time,
band_mhz: s.band_mhz,
score: s.score,
factors: s.factors,
inserted_at: now,
updated_at: now
}
end)
total =
entries
|> Enum.chunk_every(500)
|> Enum.reduce(0, fn chunk, acc ->
{count, _} =
Repo.insert_all(GridScore, chunk,
on_conflict: {:replace, [:score, :factors, :updated_at]},
conflict_target: [:lat, :lon, :valid_time, :band_mhz]
)
acc + count
end)
{:ok, total}
end
@doc "Get the latest scores for a band, optionally within a bounding box."
def latest_scores(band_mhz, bounds \\ nil) do
latest_time_query =
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz,
select: max(gs.valid_time)
)
case Repo.one(latest_time_query) do
nil ->
[]
latest_time ->
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^latest_time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score, factors: gs.factors, valid_time: gs.valid_time}
)
|> maybe_filter_bounds(bounds)
|> Repo.all()
end
end
@doc "Get the full score and factors for a specific grid point, snapped to nearest grid."
def point_detail(band_mhz, lat, lon) do
step = Grid.step()
snapped_lat = Float.round(Float.round(lat / step) * step, 3)
snapped_lon = Float.round(Float.round(lon / step) * step, 3)
latest_time_query =
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz,
select: max(gs.valid_time)
)
case Repo.one(latest_time_query) do
nil ->
nil
latest_time ->
Repo.one(
from(gs in GridScore,
where:
gs.band_mhz == ^band_mhz and
gs.valid_time == ^latest_time and
gs.lat == ^snapped_lat and
gs.lon == ^snapped_lon,
select: %{
lat: gs.lat,
lon: gs.lon,
score: gs.score,
factors: gs.factors,
valid_time: gs.valid_time
}
)
)
end
end
defp maybe_filter_bounds(query, nil), do: query
defp maybe_filter_bounds(query, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
from(gs in query,
where: gs.lat >= ^s and gs.lat <= ^n and gs.lon >= ^w and gs.lon <= ^e
)
end
@doc "Get the latest valid_time across all scores."
def latest_valid_time do
Repo.one(from(gs in GridScore, select: max(gs.valid_time)))
end
defp derive_from_hrrr(%{profile: profile}) when is_list(profile) and length(profile) >= 3 do
case SoundingParams.derive(profile) do
nil -> %{}
derived -> %{min_refractivity_gradient: derived.min_refractivity_gradient}
end
end
defp derive_from_hrrr(_), do: %{}
end