fix(weather): read atom-keyed ducts in duct base/strength layers

duct_field/2 did d["base"] || d["base_m"], but ProfilesFile.read/1
atomizes both keys via its @mp_atom_keys whitelist. String indexing an
atom-keyed map returns nil, so the Duct Base and Duct Strength layers
were always nil on the cold-derive path and in point-detail popups —
despite the comment directly above claiming both shapes were handled.

Normalize keys before lookup, the way duct_min_freq/1 already did, and
share that normalization between the two.
This commit is contained in:
Graham McIntire 2026-08-05 17:33:54 -05:00
parent ad600361e0
commit 65f405cce7
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 55 additions and 3 deletions

View file

@ -54,7 +54,7 @@ defmodule Microwaveprop.Weather.WeatherLayers do
end
defp duct_min_freq(duct) when is_map(duct) do
d = Map.new(duct, fn {k, v} -> {to_string(k), v} end)
d = stringify_keys(duct)
case d["min_freq_ghz"] do
f when is_number(f) and f > 0 -> f * 1.0
@ -198,15 +198,29 @@ defmodule Microwaveprop.Weather.WeatherLayers do
# wider frequency range, so it's the most meaningful scalar.
defp duct_field(ducts, "base") do
ducts
|> Enum.map(fn d -> d["base"] || d["base_m"] end)
|> Enum.map(&duct_value(&1, ["base", "base_m"]))
|> Enum.reject(&is_nil/1)
|> Enum.min(fn -> nil end)
end
defp duct_field(ducts, "strength") do
ducts
|> Enum.map(fn d -> d["strength"] || d["thickness_m"] end)
|> Enum.map(&duct_value(&1, ["strength", "thickness_m"]))
|> Enum.reject(&is_nil/1)
|> Enum.max(fn -> nil end)
end
# First present key wins. Keys are normalized to strings first because
# a duct read back through `ProfilesFile.read/1` carries atom keys
# (`:base_m`) while a `Msgpax`-decoded or sounding-derived one carries
# strings — indexing an atom-keyed map with a string silently returns
# nil, which blanks the layer.
defp duct_value(duct, keys) when is_map(duct) do
d = stringify_keys(duct)
Enum.find_value(keys, fn key -> d[key] end)
end
defp duct_value(_duct, _keys), do: nil
defp stringify_keys(map), do: Map.new(map, fn {k, v} -> {to_string(k), v} end)
end

View file

@ -154,6 +154,44 @@ defmodule Microwaveprop.Weather.WeatherLayersTest do
assert result.duct_base_m == nil
assert result.duct_strength == nil
end
# HrrrNativeClient and the Rust f00 pipeline emit `:base_m` /
# `:thickness_m`, which `ProfilesFile.read/1` atomizes via its
# `@mp_atom_keys` whitelist. String-key access on an atom-keyed map
# returns nil, which blanks the Duct Base / Duct Strength overlays
# on every cold-derive read.
test "extracts base and strength from atom-keyed native ducts" do
ducts = [
%{base_m: 500.0, top_m: 800.0, thickness_m: 300.0},
%{base_m: 200.0, top_m: 400.0, thickness_m: 200.0}
]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert result.duct_base_m == 200.0
assert result.duct_strength == 300.0
end
test "extracts base and strength from string-keyed native ducts" do
ducts = [
%{"base_m" => 500.0, "top_m" => 800.0, "thickness_m" => 300.0},
%{"base_m" => 200.0, "top_m" => 400.0, "thickness_m" => 200.0}
]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert result.duct_base_m == 200.0
assert result.duct_strength == 300.0
end
test "extracts base and strength from atom-keyed sounding ducts" do
ducts = [%{base: 500.0, strength: 12.5}, %{base: 200.0, strength: 8.0}]
result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts}))
assert result.duct_base_m == 200.0
assert result.duct_strength == 12.5
end
end
describe "derive/1 duct cutoff frequency" do