Merge branch 'worktree-agent-a791a51b' into feature/propagation-map
This commit is contained in:
commit
61e7bbdb3a
4 changed files with 279 additions and 1 deletions
|
|
@ -26,6 +26,34 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
|
||||||
results
|
results
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Extract weather values from a GRIB2 binary blob for multiple lat/lon points.
|
||||||
|
|
||||||
|
Takes a binary and a list of `{lat, lon}` tuples.
|
||||||
|
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, term}`.
|
||||||
|
"""
|
||||||
|
def extract_grid(binary, points) do
|
||||||
|
messages = split_messages(binary)
|
||||||
|
|
||||||
|
Enum.reduce_while(messages, {:ok, init_grid(points)}, fn msg, {:ok, acc} ->
|
||||||
|
case extract_single_grid(msg, points) do
|
||||||
|
{:ok, point_values} ->
|
||||||
|
merged =
|
||||||
|
Enum.reduce(point_values, acc, fn {point, key, value}, grid ->
|
||||||
|
Map.update!(grid, point, &Map.put(&1, key, value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:cont, {:ok, merged}}
|
||||||
|
|
||||||
|
{:error, :outside_grid} ->
|
||||||
|
{:halt, {:error, :outside_grid}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Split a binary blob into individual GRIB2 messages by scanning for "GRIB" magic bytes
|
Split a binary blob into individual GRIB2 messages by scanning for "GRIB" magic bytes
|
||||||
and reading the total length from the indicator section.
|
and reading the total length from the indicator section.
|
||||||
|
|
@ -82,6 +110,40 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
|
||||||
split_messages(rest, acc)
|
split_messages(rest, acc)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp init_grid(points) do
|
||||||
|
Map.new(points, fn point -> {point, %{}} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_single_grid(msg, points) do
|
||||||
|
with {:ok, parsed} <- Section.parse_message(msg) do
|
||||||
|
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed
|
||||||
|
key = "#{prod.var}:#{prod.level}"
|
||||||
|
|
||||||
|
results =
|
||||||
|
Enum.reduce_while(points, {:ok, []}, fn {lat, lon} = point, {:ok, acc} ->
|
||||||
|
case LambertConformal.to_grid_index(grid, lat, lon) do
|
||||||
|
{:ok, {i, j}} ->
|
||||||
|
index = linear_index({i, j}, grid.nx, grid.scan_mode)
|
||||||
|
|
||||||
|
case unpack_value(packing, data, index) do
|
||||||
|
{:ok, value} -> {:cont, {:ok, [{point, key, value} | acc]}}
|
||||||
|
{:error, reason} -> {:halt, {:error, reason}}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, :outside_grid} ->
|
||||||
|
{:halt, {:error, :outside_grid}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case results do
|
||||||
|
{:ok, point_values} -> {:ok, point_values}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
e -> {:error, "GRIB2 grid extraction failed: #{inspect(e)}"}
|
||||||
|
end
|
||||||
|
|
||||||
defp extract_single(msg, lat, lon) do
|
defp extract_single(msg, lat, lon) do
|
||||||
with {:ok, parsed} <- Section.parse_message(msg),
|
with {:ok, parsed} <- Section.parse_message(msg),
|
||||||
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed,
|
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ defmodule Microwaveprop.Weather.Grib2.Section do
|
||||||
def identify_var(0, 3, 5), do: "HGT"
|
def identify_var(0, 3, 5), do: "HGT"
|
||||||
def identify_var(0, 3, 18), do: "HPBL"
|
def identify_var(0, 3, 18), do: "HPBL"
|
||||||
def identify_var(0, 1, 3), do: "PWAT"
|
def identify_var(0, 1, 3), do: "PWAT"
|
||||||
|
def identify_var(0, 1, 8), do: "APCP"
|
||||||
|
def identify_var(0, 2, 2), do: "UGRD"
|
||||||
|
def identify_var(0, 2, 3), do: "VGRD"
|
||||||
|
def identify_var(0, 6, 1), do: "TCDC"
|
||||||
def identify_var(_discipline, cat, num), do: "#{cat}:#{num}"
|
def identify_var(_discipline, cat, num), do: "#{cat}:#{num}"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -37,6 +41,7 @@ defmodule Microwaveprop.Weather.Grib2.Section do
|
||||||
def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb"
|
def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb"
|
||||||
def identify_level(103, value_m), do: "#{value_m} m above ground"
|
def identify_level(103, value_m), do: "#{value_m} m above ground"
|
||||||
|
|
||||||
|
def identify_level(10, _value), do: "entire atmosphere"
|
||||||
def identify_level(200, _value), do: "entire atmosphere (considered as a single layer)"
|
def identify_level(200, _value), do: "entire atmosphere (considered as a single layer)"
|
||||||
|
|
||||||
def identify_level(type, value), do: "unknown:#{type}:#{value}"
|
def identify_level(type, value), do: "unknown:#{type}:#{value}"
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,36 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
%{var: "DPT", level: "2 m above ground"},
|
%{var: "DPT", level: "2 m above ground"},
|
||||||
%{var: "PRES", level: "surface"},
|
%{var: "PRES", level: "surface"},
|
||||||
%{var: "HPBL", level: "surface"},
|
%{var: "HPBL", level: "surface"},
|
||||||
%{var: "PWAT", level: "entire atmosphere (considered as a single layer)"}
|
%{var: "PWAT", level: "entire atmosphere (considered as a single layer)"},
|
||||||
|
%{var: "UGRD", level: "10 m above ground"},
|
||||||
|
%{var: "VGRD", level: "10 m above ground"},
|
||||||
|
%{var: "TCDC", level: "entire atmosphere"},
|
||||||
|
%{var: "APCP", level: "surface"}
|
||||||
]
|
]
|
||||||
|
|
||||||
# --- Public API ---
|
# --- Public API ---
|
||||||
|
|
||||||
|
def surface_messages, do: @surface_messages
|
||||||
|
|
||||||
|
def fetch_grid(points, valid_time, opts \\ []) do
|
||||||
|
hour_dt = nearest_hrrr_hour(valid_time)
|
||||||
|
date = DateTime.to_date(hour_dt)
|
||||||
|
hour = hour_dt.hour
|
||||||
|
|
||||||
|
with {:ok, sfc_grid} <- fetch_product_grid(date, hour, :surface, points),
|
||||||
|
{:ok, prs_grid} <- maybe_fetch_pressure_grid(date, hour, points, opts) do
|
||||||
|
merged = merge_grid_data(sfc_grid, prs_grid)
|
||||||
|
|
||||||
|
profiles =
|
||||||
|
Map.new(merged, fn {point, data} ->
|
||||||
|
profile = build_profile(data)
|
||||||
|
{point, Map.put(profile, :run_time, hour_dt)}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, profiles}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def fetch_profile(lat, lon, valid_time) do
|
def fetch_profile(lat, lon, valid_time) do
|
||||||
hour_dt = nearest_hrrr_hour(valid_time)
|
hour_dt = nearest_hrrr_hour(valid_time)
|
||||||
date = DateTime.to_date(hour_dt)
|
date = DateTime.to_date(hour_dt)
|
||||||
|
|
@ -92,6 +117,12 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def merge_grid_data(sfc_grid, prs_grid) do
|
||||||
|
Map.merge(sfc_grid, prs_grid, fn _point, sfc_data, prs_data ->
|
||||||
|
Map.merge(sfc_data, prs_data)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
def build_profile(parsed) do
|
def build_profile(parsed) do
|
||||||
sfc_temp_k = parsed["TMP:2 m above ground"]
|
sfc_temp_k = parsed["TMP:2 m above ground"]
|
||||||
sfc_dpt_k = parsed["DPT:2 m above ground"]
|
sfc_dpt_k = parsed["DPT:2 m above ground"]
|
||||||
|
|
@ -124,6 +155,10 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
surface_pressure_mb: if(sfc_pres_pa, do: sfc_pres_pa / 100.0),
|
surface_pressure_mb: if(sfc_pres_pa, do: sfc_pres_pa / 100.0),
|
||||||
hpbl_m: parsed["HPBL:surface"],
|
hpbl_m: parsed["HPBL:surface"],
|
||||||
pwat_mm: parsed["PWAT:entire atmosphere (considered as a single layer)"],
|
pwat_mm: parsed["PWAT:entire atmosphere (considered as a single layer)"],
|
||||||
|
wind_u: parsed["UGRD:10 m above ground"],
|
||||||
|
wind_v: parsed["VGRD:10 m above ground"],
|
||||||
|
cloud_cover_pct: parsed["TCDC:entire atmosphere"],
|
||||||
|
precip_mm: parsed["APCP:surface"],
|
||||||
profile: profile
|
profile: profile
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
@ -152,6 +187,36 @@ defmodule Microwaveprop.Weather.HrrrClient do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp fetch_product_grid(date, hour, product, points) do
|
||||||
|
url = hrrr_url(date, hour, product)
|
||||||
|
idx_url = url <> ".idx"
|
||||||
|
|
||||||
|
wanted =
|
||||||
|
case product do
|
||||||
|
:surface -> @surface_messages
|
||||||
|
:pressure -> pressure_messages()
|
||||||
|
end
|
||||||
|
|
||||||
|
Logger.info("HRRR grid fetching #{product} idx from #{idx_url}")
|
||||||
|
|
||||||
|
with {:ok, idx_text} <- fetch_idx(idx_url),
|
||||||
|
idx_entries = parse_idx(idx_text),
|
||||||
|
ranges = byte_ranges_for_messages(idx_entries, wanted),
|
||||||
|
_ = Logger.info("HRRR grid downloading #{length(ranges)} GRIB ranges for #{product}"),
|
||||||
|
{:ok, grib_binary} <- download_grib_ranges(url, ranges) do
|
||||||
|
Logger.info("HRRR grid #{product} downloaded #{byte_size(grib_binary)} bytes, extracting")
|
||||||
|
Extractor.extract_grid(grib_binary, points)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_fetch_pressure_grid(date, hour, points, opts) do
|
||||||
|
if Keyword.get(opts, :pressure, true) do
|
||||||
|
fetch_product_grid(date, hour, :pressure, points)
|
||||||
|
else
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp pressure_messages do
|
defp pressure_messages do
|
||||||
for level <- @pressure_levels, var <- ["TMP", "DPT", "HGT"] do
|
for level <- @pressure_levels, var <- ["TMP", "DPT", "HGT"] do
|
||||||
%{var: var, level: "#{level} mb"}
|
%{var: var, level: "#{level} mb"}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,44 @@ defmodule Microwaveprop.Weather.HrrrClientTest do
|
||||||
assert first_level["hght"] == 110.0
|
assert first_level["hght"] == 110.0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "includes wind, cloud cover, and precip fields" do
|
||||||
|
parsed = %{
|
||||||
|
"TMP:2 m above ground" => 299.0,
|
||||||
|
"DPT:2 m above ground" => 292.0,
|
||||||
|
"PRES:surface" => 101_350.0,
|
||||||
|
"HPBL:surface" => 1500.0,
|
||||||
|
"PWAT:entire atmosphere (considered as a single layer)" => 25.0,
|
||||||
|
"UGRD:10 m above ground" => 3.5,
|
||||||
|
"VGRD:10 m above ground" => -2.1,
|
||||||
|
"TCDC:entire atmosphere" => 75.0,
|
||||||
|
"APCP:surface" => 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
result = HrrrClient.build_profile(parsed)
|
||||||
|
|
||||||
|
assert result.wind_u == 3.5
|
||||||
|
assert result.wind_v == -2.1
|
||||||
|
assert result.cloud_cover_pct == 75.0
|
||||||
|
assert result.precip_mm == 1.2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "wind, cloud, and precip fields are nil when missing" do
|
||||||
|
parsed = %{
|
||||||
|
"TMP:2 m above ground" => 299.0,
|
||||||
|
"DPT:2 m above ground" => 292.0,
|
||||||
|
"PRES:surface" => 101_350.0,
|
||||||
|
"HPBL:surface" => 1500.0,
|
||||||
|
"PWAT:entire atmosphere (considered as a single layer)" => 25.0
|
||||||
|
}
|
||||||
|
|
||||||
|
result = HrrrClient.build_profile(parsed)
|
||||||
|
|
||||||
|
assert result.wind_u == nil
|
||||||
|
assert result.wind_v == nil
|
||||||
|
assert result.cloud_cover_pct == nil
|
||||||
|
assert result.precip_mm == nil
|
||||||
|
end
|
||||||
|
|
||||||
test "skips pressure levels with missing data" do
|
test "skips pressure levels with missing data" do
|
||||||
parsed = %{
|
parsed = %{
|
||||||
"TMP:1000 mb" => 298.0,
|
"TMP:1000 mb" => 298.0,
|
||||||
|
|
@ -206,4 +244,112 @@ defmodule Microwaveprop.Weather.HrrrClientTest do
|
||||||
assert length(result.profile) == 1
|
assert length(result.profile) == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "surface_messages/0" do
|
||||||
|
test "returns list of surface message descriptors" do
|
||||||
|
messages = HrrrClient.surface_messages()
|
||||||
|
|
||||||
|
assert is_list(messages)
|
||||||
|
assert length(messages) == 9
|
||||||
|
|
||||||
|
vars = Enum.map(messages, & &1.var)
|
||||||
|
assert "TMP" in vars
|
||||||
|
assert "DPT" in vars
|
||||||
|
assert "PRES" in vars
|
||||||
|
assert "HPBL" in vars
|
||||||
|
assert "PWAT" in vars
|
||||||
|
assert "UGRD" in vars
|
||||||
|
assert "VGRD" in vars
|
||||||
|
assert "TCDC" in vars
|
||||||
|
assert "APCP" in vars
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes wind messages at 10 m above ground" do
|
||||||
|
messages = HrrrClient.surface_messages()
|
||||||
|
|
||||||
|
ugrd = Enum.find(messages, &(&1.var == "UGRD"))
|
||||||
|
assert ugrd.level == "10 m above ground"
|
||||||
|
|
||||||
|
vgrd = Enum.find(messages, &(&1.var == "VGRD"))
|
||||||
|
assert vgrd.level == "10 m above ground"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes cloud cover for entire atmosphere" do
|
||||||
|
messages = HrrrClient.surface_messages()
|
||||||
|
tcdc = Enum.find(messages, &(&1.var == "TCDC"))
|
||||||
|
assert tcdc.level == "entire atmosphere"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "includes precipitation at surface" do
|
||||||
|
messages = HrrrClient.surface_messages()
|
||||||
|
apcp = Enum.find(messages, &(&1.var == "APCP"))
|
||||||
|
assert apcp.level == "surface"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "merge_grid_data/2" do
|
||||||
|
test "merges surface and pressure grids by point key" do
|
||||||
|
point_a = {32.90, -97.04}
|
||||||
|
point_b = {33.10, -96.80}
|
||||||
|
|
||||||
|
sfc_grid = %{
|
||||||
|
point_a => %{
|
||||||
|
"TMP:2 m above ground" => 299.0,
|
||||||
|
"DPT:2 m above ground" => 292.0,
|
||||||
|
"PRES:surface" => 101_350.0,
|
||||||
|
"HPBL:surface" => 1500.0,
|
||||||
|
"PWAT:entire atmosphere (considered as a single layer)" => 25.0,
|
||||||
|
"UGRD:10 m above ground" => 3.5,
|
||||||
|
"VGRD:10 m above ground" => -2.1,
|
||||||
|
"TCDC:entire atmosphere" => 75.0,
|
||||||
|
"APCP:surface" => 1.2
|
||||||
|
},
|
||||||
|
point_b => %{
|
||||||
|
"TMP:2 m above ground" => 297.0,
|
||||||
|
"DPT:2 m above ground" => 290.0,
|
||||||
|
"PRES:surface" => 101_200.0,
|
||||||
|
"HPBL:surface" => 1200.0,
|
||||||
|
"PWAT:entire atmosphere (considered as a single layer)" => 22.0,
|
||||||
|
"UGRD:10 m above ground" => 2.0,
|
||||||
|
"VGRD:10 m above ground" => -1.5,
|
||||||
|
"TCDC:entire atmosphere" => 50.0,
|
||||||
|
"APCP:surface" => 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prs_grid = %{
|
||||||
|
point_a => %{
|
||||||
|
"TMP:1000 mb" => 298.0,
|
||||||
|
"DPT:1000 mb" => 291.0,
|
||||||
|
"HGT:1000 mb" => 110.0
|
||||||
|
},
|
||||||
|
point_b => %{
|
||||||
|
"TMP:1000 mb" => 296.0,
|
||||||
|
"DPT:1000 mb" => 289.0,
|
||||||
|
"HGT:1000 mb" => 105.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = HrrrClient.merge_grid_data(sfc_grid, prs_grid)
|
||||||
|
|
||||||
|
assert map_size(merged) == 2
|
||||||
|
assert merged[point_a]["TMP:2 m above ground"] == 299.0
|
||||||
|
assert merged[point_a]["TMP:1000 mb"] == 298.0
|
||||||
|
assert merged[point_b]["TMP:2 m above ground"] == 297.0
|
||||||
|
assert merged[point_b]["TMP:1000 mb"] == 296.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles empty pressure data" do
|
||||||
|
point = {32.90, -97.04}
|
||||||
|
|
||||||
|
sfc_grid = %{
|
||||||
|
point => %{"TMP:2 m above ground" => 299.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
merged = HrrrClient.merge_grid_data(sfc_grid, %{})
|
||||||
|
|
||||||
|
assert map_size(merged) == 1
|
||||||
|
assert merged[point]["TMP:2 m above ground"] == 299.0
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue