Show on /weather where the detected duct geometry traps each microwave band. Overview mode bins cells by their lowest trapped frequency (Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't reach the selected band. Cutoff is pre-computed per cell in both writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from CellValues), persisted to ScalarFile, and shipped to the JS hook via the existing binary cell pack. Also cleans up six pre-existing length/1 credo warnings in unrelated test files.
245 lines
9.1 KiB
Elixir
245 lines
9.1 KiB
Elixir
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 700mb extraction (cap diagnostics)" do
|
|
test "extracts temperature at 700mb from profile" do
|
|
result = WeatherLayers.derive(sample_row())
|
|
# 700 mb level in @sample_profile sits at 4.0 °C
|
|
assert result.temp_700mb == 4.0
|
|
end
|
|
|
|
test "extracts dewpoint at 700mb from profile" do
|
|
result = WeatherLayers.derive(sample_row())
|
|
assert result.dewpoint_700mb == -4.0
|
|
end
|
|
|
|
test "returns nil when profile does not reach 700 mb" do
|
|
shallow = [
|
|
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
|
%{"pres" => 925.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 800.0}
|
|
]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{profile: shallow}))
|
|
assert result.temp_700mb == nil
|
|
assert result.dewpoint_700mb == nil
|
|
end
|
|
end
|
|
|
|
describe "derive/1 mid-layer lapse rate (850-700 cap mechanism)" do
|
|
test "computes lapse rate over the 850-700 mb layer in C/km" do
|
|
result = WeatherLayers.derive(sample_row())
|
|
# 850 mb: 16.0 °C @ 1640 m, 700 mb: 4.0 °C @ 3550 m
|
|
# dT/dh = (16.0 - 4.0) / ((3550 - 1640) / 1000) = 12.0 / 1.91 ≈ 6.28
|
|
expected = (16.0 - 4.0) / ((3550.0 - 1640.0) / 1000.0)
|
|
assert_in_delta result.mid_lapse_rate, expected, 0.01
|
|
end
|
|
|
|
test "returns nil when one of the bracketing levels is missing" do
|
|
shallow = [
|
|
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
|
%{"pres" => 925.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 800.0}
|
|
]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{profile: shallow}))
|
|
assert result.mid_lapse_rate == nil
|
|
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 duct cutoff frequency" do
|
|
test "takes the min min_freq_ghz across native-shape ducts" do
|
|
ducts = [
|
|
%{base_m: 0.0, top_m: 60.0, thickness_m: 60.0, m_deficit: 15.0, min_freq_ghz: 28.4},
|
|
%{base_m: 200.0, top_m: 320.0, thickness_m: 120.0, m_deficit: 12.0, min_freq_ghz: 12.7}
|
|
]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
|
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
|
|
end
|
|
|
|
test "accepts string-keyed native ducts (read back from msgpack)" do
|
|
ducts = [
|
|
%{"thickness_m" => 60.0, "m_deficit" => 15.0, "min_freq_ghz" => 28.4},
|
|
%{"thickness_m" => 120.0, "m_deficit" => 12.0, "min_freq_ghz" => 12.7}
|
|
]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
|
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
|
|
end
|
|
|
|
test "falls back to computing from thickness + m_deficit when min_freq_ghz missing" do
|
|
ducts = [%{thickness_m: 100.0, m_deficit: 10.0}]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
|
# Bean & Dutton: λ_max = 2.5·100·√(10·1e-6) ≈ 0.7906 m → f ≈ 0.379 GHz
|
|
expected = 3.0e8 / (2.5 * 100.0 * :math.sqrt(10.0 * 1.0e-6)) / 1.0e9
|
|
assert_in_delta result.duct_cutoff_ghz, expected, 1.0e-3
|
|
end
|
|
|
|
test "skips sounding-shape ducts that lack thickness/m_deficit" do
|
|
ducts = [
|
|
%{"base" => 500.0, "strength" => 12.5},
|
|
%{thickness_m: 120.0, m_deficit: 12.0, min_freq_ghz: 12.7}
|
|
]
|
|
|
|
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
|
assert_in_delta result.duct_cutoff_ghz, 12.7, 1.0e-6
|
|
end
|
|
|
|
test "returns nil when no ducts have any computable cutoff" do
|
|
ducts = [%{"base" => 500.0, "strength" => 12.5}]
|
|
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
|
|
assert result.duct_cutoff_ghz == nil
|
|
end
|
|
|
|
test "returns nil for nil and empty duct_characteristics" do
|
|
assert WeatherLayers.derive(sample_row(%{duct_characteristics: nil})).duct_cutoff_ghz == nil
|
|
assert WeatherLayers.derive(sample_row(%{duct_characteristics: []})).duct_cutoff_ghz == 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.temp_700mb == nil
|
|
assert result.dewpoint_700mb == nil
|
|
assert result.lapse_rate == nil
|
|
assert result.mid_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.temp_700mb == nil
|
|
assert result.dewpoint_700mb == nil
|
|
assert result.lapse_rate == nil
|
|
assert result.mid_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
|