Add WeatherLayers module for deriving upper-air map fields from HRRR profiles
This commit is contained in:
parent
109558f7cc
commit
489a886b0e
2 changed files with 266 additions and 0 deletions
123
lib/microwaveprop/weather/weather_layers.ex
Normal file
123
lib/microwaveprop/weather/weather_layers.ex
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
defmodule Microwaveprop.Weather.WeatherLayers do
|
||||
@moduledoc "Derives scalar weather map layers from HRRR profile data."
|
||||
|
||||
alias Microwaveprop.Weather.SoundingParams
|
||||
|
||||
@doc """
|
||||
Derives a map of scalar weather fields from a row containing HRRR profile data.
|
||||
|
||||
Expects a map with keys: `:surface_temp_c`, `:surface_dewpoint_c`,
|
||||
`:surface_pressure_mb`, `:surface_refractivity`, `:profile`, `:duct_characteristics`.
|
||||
|
||||
Returns a map with derived fields: `:surface_rh`, `:surface_refractivity`,
|
||||
`:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`,
|
||||
`:inversion_base_m`, `:duct_base_m`, `:duct_strength`.
|
||||
"""
|
||||
def derive(row) do
|
||||
sorted = sort_profile(row.profile)
|
||||
|
||||
%{
|
||||
surface_rh: compute_rh(row.surface_temp_c, row.surface_dewpoint_c),
|
||||
surface_refractivity: row.surface_refractivity,
|
||||
temp_850mb: level_value(sorted, 850, "tmpc"),
|
||||
dewpoint_850mb: level_value(sorted, 850, "dwpc"),
|
||||
lapse_rate: compute_lapse_rate(sorted),
|
||||
inversion_strength: inversion_strength(sorted),
|
||||
inversion_base_m: inversion_base_m(sorted),
|
||||
duct_base_m: duct_field(row.duct_characteristics, "base"),
|
||||
duct_strength: duct_field(row.duct_characteristics, "strength")
|
||||
}
|
||||
end
|
||||
|
||||
defp sort_profile(nil), do: []
|
||||
defp sort_profile([]), do: []
|
||||
|
||||
defp sort_profile(profile) do
|
||||
profile
|
||||
|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|
||||
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
|
||||
end
|
||||
|
||||
defp compute_rh(t_c, td_c) do
|
||||
100.0 * SoundingParams.sat_vap_pres(td_c) / SoundingParams.sat_vap_pres(t_c)
|
||||
end
|
||||
|
||||
defp level_value([], _target_pres, _key), do: nil
|
||||
|
||||
defp level_value(sorted, target_pres, key) do
|
||||
nearest = Enum.min_by(sorted, fn p -> abs(p["pres"] - target_pres) end)
|
||||
|
||||
if abs(nearest["pres"] - target_pres) <= 25.0 do
|
||||
nearest[key]
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_lapse_rate([]), do: nil
|
||||
|
||||
defp compute_lapse_rate(sorted) when length(sorted) < 2, do: nil
|
||||
|
||||
defp compute_lapse_rate(sorted) do
|
||||
surface = hd(sorted)
|
||||
top = List.last(sorted)
|
||||
dh_km = (top["hght"] - surface["hght"]) / 1000.0
|
||||
|
||||
if dh_km > 0 do
|
||||
(surface["tmpc"] - top["tmpc"]) / dh_km
|
||||
end
|
||||
end
|
||||
|
||||
defp inversion_strength([]), do: 0.0
|
||||
|
||||
defp inversion_strength(sorted) do
|
||||
sorted
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.reduce(0.0, fn [lower, upper], max_str ->
|
||||
dt = upper["tmpc"] - lower["tmpc"]
|
||||
|
||||
if dt > max_str do
|
||||
dt
|
||||
else
|
||||
max_str
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp inversion_base_m([]), do: nil
|
||||
|
||||
defp inversion_base_m(sorted) do
|
||||
sfc_hght = hd(sorted)["hght"]
|
||||
|
||||
result =
|
||||
sorted
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.reduce({0.0, nil}, fn [lower, upper], {max_str, _base} = acc ->
|
||||
dt = upper["tmpc"] - lower["tmpc"]
|
||||
|
||||
if dt > 0 and dt > max_str do
|
||||
{dt, lower["hght"] - sfc_hght}
|
||||
else
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|
||||
case result do
|
||||
{str, base} when str > 0 -> base
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp duct_field(nil, _key), do: nil
|
||||
defp duct_field([], _key), do: nil
|
||||
|
||||
defp duct_field(ducts, "base") do
|
||||
ducts
|
||||
|> Enum.map(fn d -> d["base"] end)
|
||||
|> Enum.min()
|
||||
end
|
||||
|
||||
defp duct_field(ducts, "strength") do
|
||||
ducts
|
||||
|> Enum.map(fn d -> d["strength"] end)
|
||||
|> Enum.max()
|
||||
end
|
||||
end
|
||||
143
test/microwaveprop/weather/weather_layers_test.exs
Normal file
143
test/microwaveprop/weather/weather_layers_test.exs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
defmodule Microwaveprop.Weather.WeatherLayersTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Weather.SoundingParams
|
||||
alias Microwaveprop.Weather.WeatherLayers
|
||||
|
||||
@sample_profile [
|
||||
%{"pres" => 1000.0, "tmpc" => 28.0, "dwpc" => 20.0, "hght" => 100.0},
|
||||
%{"pres" => 975.0, "tmpc" => 26.0, "dwpc" => 18.0, "hght" => 340.0},
|
||||
%{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 16.0, "hght" => 580.0},
|
||||
%{"pres" => 925.0, "tmpc" => 22.0, "dwpc" => 14.0, "hght" => 830.0},
|
||||
%{"pres" => 900.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 1090.0},
|
||||
%{"pres" => 875.0, "tmpc" => 18.0, "dwpc" => 10.0, "hght" => 1360.0},
|
||||
%{"pres" => 850.0, "tmpc" => 16.0, "dwpc" => 8.0, "hght" => 1640.0},
|
||||
%{"pres" => 825.0, "tmpc" => 14.0, "dwpc" => 6.0, "hght" => 1930.0},
|
||||
%{"pres" => 800.0, "tmpc" => 12.0, "dwpc" => 4.0, "hght" => 2230.0},
|
||||
%{"pres" => 775.0, "tmpc" => 10.0, "dwpc" => 2.0, "hght" => 2540.0},
|
||||
%{"pres" => 750.0, "tmpc" => 8.0, "dwpc" => 0.0, "hght" => 2860.0},
|
||||
%{"pres" => 725.0, "tmpc" => 6.0, "dwpc" => -2.0, "hght" => 3200.0},
|
||||
%{"pres" => 700.0, "tmpc" => 4.0, "dwpc" => -4.0, "hght" => 3550.0}
|
||||
]
|
||||
|
||||
@inversion_profile [
|
||||
%{"pres" => 1000.0, "tmpc" => 28.0, "dwpc" => 20.0, "hght" => 100.0},
|
||||
%{"pres" => 975.0, "tmpc" => 26.0, "dwpc" => 18.0, "hght" => 340.0},
|
||||
%{"pres" => 950.0, "tmpc" => 22.0, "dwpc" => 14.0, "hght" => 580.0},
|
||||
%{"pres" => 925.0, "tmpc" => 25.0, "dwpc" => 10.0, "hght" => 830.0},
|
||||
%{"pres" => 900.0, "tmpc" => 20.0, "dwpc" => 8.0, "hght" => 1090.0}
|
||||
]
|
||||
|
||||
defp sample_row(overrides \\ %{}) do
|
||||
Map.merge(
|
||||
%{
|
||||
surface_temp_c: 28.0,
|
||||
surface_dewpoint_c: 20.0,
|
||||
surface_pressure_mb: 1000.0,
|
||||
surface_refractivity: 350.0,
|
||||
profile: @sample_profile,
|
||||
duct_characteristics: nil
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
describe "derive/1 surface relative humidity" do
|
||||
test "computes surface RH from temp and dewpoint using Buck equation" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
|
||||
expected_rh = 100.0 * SoundingParams.sat_vap_pres(20.0) / SoundingParams.sat_vap_pres(28.0)
|
||||
assert_in_delta result.surface_rh, expected_rh, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 850mb extraction" do
|
||||
test "extracts temperature at 850mb from profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
assert result.temp_850mb == 16.0
|
||||
end
|
||||
|
||||
test "extracts dewpoint at 850mb from profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
assert result.dewpoint_850mb == 8.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 lapse rate" do
|
||||
test "computes lapse rate in C/km from surface to top of profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
|
||||
# (28.0 - 4.0) / ((3550.0 - 100.0) / 1000.0) = 24.0 / 3.45 = ~6.957
|
||||
expected = (28.0 - 4.0) / ((3550.0 - 100.0) / 1000.0)
|
||||
assert_in_delta result.lapse_rate, expected, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 inversion detection" do
|
||||
test "detects no inversion in standard lapse profile" do
|
||||
result = WeatherLayers.derive(sample_row())
|
||||
assert result.inversion_strength == 0.0
|
||||
end
|
||||
|
||||
test "detects inversion strength and base height" do
|
||||
result = WeatherLayers.derive(sample_row(%{profile: @inversion_profile}))
|
||||
|
||||
# Inversion: 950mb (22C, 580m) -> 925mb (25C, 830m), strength = 3.0
|
||||
assert_in_delta result.inversion_strength, 3.0, 0.01
|
||||
# Base height AGL: 580m - 100m (surface) = 480m
|
||||
assert_in_delta result.inversion_base_m, 480.0, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 duct characteristics" do
|
||||
test "extracts duct base and strength from duct_characteristics" do
|
||||
ducts = [
|
||||
%{"base" => 500.0, "top" => 800.0, "strength" => 12.5},
|
||||
%{"base" => 200.0, "top" => 400.0, "strength" => 8.0}
|
||||
]
|
||||
|
||||
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
||||
|
||||
# min base
|
||||
assert result.duct_base_m == 200.0
|
||||
# max strength
|
||||
assert result.duct_strength == 12.5
|
||||
end
|
||||
|
||||
test "handles nil duct_characteristics" do
|
||||
result = WeatherLayers.derive(sample_row(%{duct_characteristics: nil}))
|
||||
|
||||
assert result.duct_base_m == nil
|
||||
assert result.duct_strength == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 nil/empty profile" do
|
||||
test "handles nil profile gracefully" do
|
||||
result = WeatherLayers.derive(sample_row(%{profile: nil}))
|
||||
|
||||
assert result.temp_850mb == nil
|
||||
assert result.dewpoint_850mb == nil
|
||||
assert result.lapse_rate == nil
|
||||
assert result.inversion_strength == 0.0
|
||||
assert result.inversion_base_m == nil
|
||||
end
|
||||
|
||||
test "handles empty profile gracefully" do
|
||||
result = WeatherLayers.derive(sample_row(%{profile: []}))
|
||||
|
||||
assert result.temp_850mb == nil
|
||||
assert result.dewpoint_850mb == nil
|
||||
assert result.lapse_rate == nil
|
||||
assert result.inversion_strength == 0.0
|
||||
assert result.inversion_base_m == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "derive/1 surface refractivity passthrough" do
|
||||
test "passes through surface_refractivity unchanged" do
|
||||
result = WeatherLayers.derive(sample_row(%{surface_refractivity: 342.7}))
|
||||
assert result.surface_refractivity == 342.7
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue