prop/test/microwaveprop/weather_grid_test.exs
Graham McIntire a24ce027f4 Fix /weather 500 on cold cache, untrack k8s secret
The /weather LiveView was timing out on pod restart because
latest_weather_grid/1 ran load_weather_grid_from_db/1 synchronously on
cache miss — that query reads 176k hrrr_profiles rows with two huge
JSONB columns (profile array + duct_characteristics), and JSONB
decoding blocked the LiveView process past the 15-second pool
ownership timeout.

On cache miss latest_weather_grid/1 now returns empty immediately and
kicks off a deduped background fill through GridCache.claim_fill/1 (a
per-valid_time ETS lock so N concurrent mounts after restart don't each
fire the slow query). When the fill completes it broadcasts
weather:updated, and the handle_info in WeatherMapLive hits the warm
cache and pushes rows over the socket.

Added load_weather_grid/1 as a synchronous sibling for tests and any
caller that genuinely needs inline data. Tests updated to use it and
to clear GridCache in setup so ETS state doesn't leak between cases.

Also untrack k8s/secret.yaml and add both it and k8s/*-secret.yaml to
.gitignore — those manifests hold plaintext SMTP and database
credentials that should not live in the repo. Secrets already in git
history should be rotated separately.
2026-04-12 13:56:59 -05:00

277 lines
8.3 KiB
Elixir

defmodule Microwaveprop.WeatherGridTest do
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.GridCache
setup do
# GridCache ETS state leaks between tests (it's a named table outside the
# test sandbox). Clear it so each test gets a fresh cache and the DB load
# path actually runs.
GridCache.clear()
:ok
end
describe "load_weather_grid/1" do
test "returns grid data for latest valid_time within bounds" do
now = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0,
min_refractivity_gradient: -280.0,
ducting_detected: true
})
insert_hrrr_profile(%{
lat: 32.250,
lon: -97.375,
valid_time: now,
surface_temp_c: 26.0,
surface_dewpoint_c: 19.0,
surface_pressure_mb: 1012.0,
hpbl_m: 600.0,
pwat_mm: 32.0,
min_refractivity_gradient: -150.0,
ducting_detected: false
})
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
result = Weather.load_weather_grid(bounds)
assert length(result) == 2
point = Enum.find(result, &(&1.lat == 32.125))
assert point.temperature == 25.0
assert point.dewpoint_depression == 7.0
assert point.bl_height == 500.0
assert point.pwat == 30.0
assert point.refractivity_gradient == -280.0
assert point.ducting == true
end
test "returns empty list when no data exists" do
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
assert Weather.load_weather_grid(bounds) == []
end
test "only returns points on 0.125 grid" do
now = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0
})
insert_hrrr_profile(%{
lat: 32.1337,
lon: -97.3821,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0
})
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
result = Weather.load_weather_grid(bounds)
assert length(result) == 1
assert hd(result).lat == 32.125
end
test "uses most recent valid_time only" do
old = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.truncate(:second)
new = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: old,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1010.0,
hpbl_m: 400.0,
pwat_mm: 25.0
})
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: new,
surface_temp_c: 28.0,
surface_dewpoint_c: 20.0,
surface_pressure_mb: 1015.0,
hpbl_m: 700.0,
pwat_mm: 35.0
})
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
result = Weather.load_weather_grid(bounds)
assert length(result) == 1
assert hd(result).temperature == 28.0
end
test "includes derived upper-air fields and strips raw profile data" do
now = DateTime.truncate(DateTime.utc_now(), :second)
profile = [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 925.0, "tmpc" => 20.0, "dwpc" => 14.0, "hght" => 800.0},
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0},
%{"pres" => 700.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 3100.0},
%{"pres" => 500.0, "tmpc" => -15.0, "dwpc" => -25.0, "hght" => 5600.0}
]
duct_chars = [
%{"base" => 200.0, "strength" => 50.0},
%{"base" => 800.0, "strength" => 30.0}
]
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.0,
hpbl_m: 500.0,
pwat_mm: 30.0,
min_refractivity_gradient: -280.0,
ducting_detected: true,
profile: profile,
duct_characteristics: duct_chars
})
bounds = %{"south" => 32.0, "north" => 33.0, "west" => -98.0, "east" => -97.0}
[point] = Weather.load_weather_grid(bounds)
# Derived fields should be present
assert is_float(point.surface_rh)
assert point.surface_refractivity == 320.0
assert point.temp_850mb == 15.0
assert point.dewpoint_850mb == 10.0
assert is_float(point.lapse_rate)
assert point.duct_base_m == 200.0
assert point.duct_strength == 50.0
# Raw bulky fields should be stripped
refute Map.has_key?(point, :profile)
refute Map.has_key?(point, :duct_characteristics)
refute Map.has_key?(point, :surface_temp_c)
refute Map.has_key?(point, :surface_dewpoint_c)
end
end
describe "weather_point_detail/3" do
test "returns all fields for a single grid point" do
now = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
pwat_mm: 30.0,
min_refractivity_gradient: -280.0,
ducting_detected: true
})
result = Weather.weather_point_detail(32.15, -97.40, now)
assert result.lat == 32.125
assert result.temperature == 25.0
assert result.surface_pressure_mb == 1013.0
end
test "returns nil when no data at point" do
now = DateTime.truncate(DateTime.utc_now(), :second)
assert Weather.weather_point_detail(32.15, -97.40, now) == nil
end
test "includes derived upper-air fields and strips raw profile data" do
now = DateTime.truncate(DateTime.utc_now(), :second)
profile = [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0},
%{"pres" => 500.0, "tmpc" => -15.0, "dwpc" => -25.0, "hght" => 5600.0}
]
duct_chars = [%{"base" => 300.0, "strength" => 40.0}]
insert_hrrr_profile(%{
lat: 32.125,
lon: -97.375,
valid_time: now,
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 320.0,
hpbl_m: 500.0,
pwat_mm: 30.0,
min_refractivity_gradient: -280.0,
ducting_detected: true,
profile: profile,
duct_characteristics: duct_chars
})
result = Weather.weather_point_detail(32.15, -97.40, now)
# Derived fields
assert is_float(result.surface_rh)
assert result.surface_refractivity == 320.0
assert result.temp_850mb == 15.0
assert result.duct_base_m == 300.0
assert result.duct_strength == 40.0
# Raw fields stripped
refute Map.has_key?(result, :profile)
refute Map.has_key?(result, :duct_characteristics)
refute Map.has_key?(result, :surface_temp_c)
refute Map.has_key?(result, :surface_dewpoint_c)
end
end
defp insert_hrrr_profile(attrs) do
now = DateTime.truncate(DateTime.utc_now(), :second)
is_gp =
rem(round(attrs.lat * 1000), 125) == 0 and
rem(round(attrs.lon * 1000), 125) == 0
defaults = %{
id: Ecto.UUID.bingenerate(),
run_time: DateTime.add(attrs.valid_time, -3600, :second),
profile: [],
surface_refractivity: nil,
min_refractivity_gradient: nil,
ducting_detected: nil,
duct_characteristics: nil,
is_grid_point: is_gp,
inserted_at: now,
updated_at: now
}
entry = Map.merge(defaults, attrs)
Repo.insert_all("hrrr_profiles", [entry])
end
end