Historical contacts showed a skew-T log-P diagram that stopped at 700 mb
because HrrrClient and Era5Client only fetched pressure-level data down
to the top of the boundary layer. The chart canvas already ran up to
100 mb, so the trace clipped mid-atmosphere.
Two complementary fixes:
1. Extend @pressure_levels in HrrrClient and Era5Client with
650/600/550/500/450/400/350/300/250/200/150/100 mb so new fetches
cover the full troposphere + lower stratosphere.
2. Prefer the native hybrid-sigma profile for the contact-detail
skew-T when one has been backfilled for the contact's hour. The
native profile already stores all 50 hybrid levels up to ~19 km,
so historical contacts covered by the native backfill get a full
trace without re-hitting S3. A new HrrrNativeProfile.to_skew_t_profile/1
converts the parallel arrays into the %{"pres","tmpc","dwpc","hght"}
list shape the renderer expects, deriving dewpoint from SPFH via the
Magnus inverse. Weather.find_nearest_native_profile/3 mirrors
find_nearest_hrrr/3 for the lookup.
225 lines
7.9 KiB
Elixir
225 lines
7.9 KiB
Elixir
defmodule Microwaveprop.Weather.Era5ClientTest do
|
|
# async: false — tests mutate the global ERA5_CDS_API_KEY env var to match
|
|
# production's lookup path. Running concurrently with Era5BatchClientTest
|
|
# (which *deletes* the env var to exercise the missing-key error path)
|
|
# leaks state in both directions.
|
|
use ExUnit.Case, async: false
|
|
|
|
alias Microwaveprop.Weather.Era5Client
|
|
|
|
setup do
|
|
# A real key — stubs don't care about value, only presence.
|
|
System.put_env("ERA5_CDS_API_KEY", "test-key")
|
|
on_exit(fn -> System.delete_env("ERA5_CDS_API_KEY") end)
|
|
:ok
|
|
end
|
|
|
|
describe "submit_job/2" do
|
|
test "returns {:ok, job_id} when CDS accepts the request" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
assert conn.method == "POST"
|
|
assert String.ends_with?(conn.request_path, "/reanalysis-era5-single-levels/execution")
|
|
Req.Test.json(conn, %{"jobID" => "cds-job-123", "status" => "accepted"})
|
|
end)
|
|
|
|
assert {:ok, "cds-job-123"} =
|
|
Era5Client.submit_job("reanalysis-era5-single-levels", %{"foo" => "bar"})
|
|
end
|
|
|
|
test "returns {:error, _} when CDS returns 400" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
conn
|
|
|> Plug.Conn.put_resp_content_type("application/json")
|
|
|> Plug.Conn.resp(400, ~s({"detail":"bad request"}))
|
|
end)
|
|
|
|
assert {:error, reason} = Era5Client.submit_job("reanalysis-era5-single-levels", %{})
|
|
assert reason =~ "HTTP 400"
|
|
end
|
|
|
|
test "returns {:error, _} when API key is missing" do
|
|
System.delete_env("ERA5_CDS_API_KEY")
|
|
assert {:error, reason} = Era5Client.submit_job("reanalysis-era5-single-levels", %{})
|
|
assert reason =~ "ERA5_CDS_API_KEY"
|
|
end
|
|
end
|
|
|
|
describe "check_status/1" do
|
|
test "returns :running while CDS job is accepted" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
assert conn.method == "GET"
|
|
assert String.ends_with?(conn.request_path, "/jobs/cds-job-123")
|
|
Req.Test.json(conn, %{"status" => "accepted"})
|
|
end)
|
|
|
|
assert :running = Era5Client.check_status("cds-job-123")
|
|
end
|
|
|
|
test "returns :running while CDS job is running" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
Req.Test.json(conn, %{"status" => "running"})
|
|
end)
|
|
|
|
assert :running = Era5Client.check_status("cds-job-123")
|
|
end
|
|
|
|
test "returns {:done, {:url, href}} when CDS job completes with an asset href" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
case conn.request_path do
|
|
"/api/retrieve/v1/jobs/cds-job-123" ->
|
|
Req.Test.json(conn, %{"status" => "successful"})
|
|
|
|
"/api/retrieve/v1/jobs/cds-job-123/results" ->
|
|
Req.Test.json(conn, %{
|
|
"asset" => %{"value" => %{"href" => "https://example.com/file.grib"}}
|
|
})
|
|
end
|
|
end)
|
|
|
|
assert {:done, {:url, "https://example.com/file.grib"}} =
|
|
Era5Client.check_status("cds-job-123")
|
|
end
|
|
|
|
test "returns {:failed, _} when CDS reports the job failed" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
assert conn.request_path == "/api/retrieve/v1/jobs/cds-job-123"
|
|
Req.Test.json(conn, %{"status" => "failed", "message" => "input out of range"})
|
|
end)
|
|
|
|
assert {:failed, reason} = Era5Client.check_status("cds-job-123")
|
|
assert reason =~ "input out of range"
|
|
end
|
|
|
|
test "returns :not_found when CDS returns 404 for the job" do
|
|
# 404 means the CDS job was deleted or never existed. This is terminal —
|
|
# retrying will always 404 — so callers should treat it as a distinct
|
|
# state from the generic {:error, _} retryable bucket.
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
conn
|
|
|> Plug.Conn.put_resp_content_type("application/json")
|
|
|> Plug.Conn.resp(
|
|
404,
|
|
~s({"detail":"job cds-job-123 deleted","status":404,"title":"job not found"})
|
|
)
|
|
end)
|
|
|
|
assert :not_found = Era5Client.check_status("cds-job-123")
|
|
end
|
|
|
|
test "returns {:rejected, reason} when CDS marks the job as rejected" do
|
|
# CDS rejects submits when the user's in-flight queue is over the cap
|
|
# (150 jobs). The rejected job still gets a jobID and a GET /jobs/:id
|
|
# returns a body with status=rejected. This is terminal — re-polling
|
|
# will never transition to successful — so callers should treat it
|
|
# like {:failed, _} and re-submit the tile-month.
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"jobID" => "cds-job-123",
|
|
"status" => "rejected",
|
|
"metadata" => %{"origin" => "api"}
|
|
})
|
|
end)
|
|
|
|
assert {:rejected, reason} = Era5Client.check_status("cds-job-123")
|
|
assert is_binary(reason)
|
|
end
|
|
end
|
|
|
|
describe "delete_job/1" do
|
|
test "sends DELETE to the CDS jobs endpoint and returns :ok on 204" do
|
|
test_pid = self()
|
|
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
send(test_pid, {:delete_request, conn.method, conn.request_path})
|
|
Plug.Conn.resp(conn, 204, "")
|
|
end)
|
|
|
|
assert :ok = Era5Client.delete_job("cds-job-123")
|
|
assert_received {:delete_request, "DELETE", "/api/retrieve/v1/jobs/cds-job-123"}
|
|
end
|
|
|
|
test "also treats 200/202/404 as success (CDS sometimes returns 200 or the job may already be gone)" do
|
|
for status <- [200, 202, 404] do
|
|
Req.Test.stub(Era5Client, fn conn -> Plug.Conn.resp(conn, status, "") end)
|
|
assert :ok = Era5Client.delete_job("cds-job-#{status}")
|
|
end
|
|
end
|
|
|
|
test "returns {:error, _} for unexpected statuses" do
|
|
Req.Test.stub(Era5Client, fn conn -> Plug.Conn.resp(conn, 500, "server error") end)
|
|
assert {:error, reason} = Era5Client.delete_job("cds-job-500")
|
|
assert reason =~ "HTTP 500"
|
|
end
|
|
end
|
|
|
|
describe "download_source_to_file/3" do
|
|
test "writes a {:body, binary} source directly to the target path" do
|
|
path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib")
|
|
|
|
try do
|
|
assert :ok = Era5Client.download_source_to_file({:body, "grib-payload"}, nil, path)
|
|
assert File.read!(path) == "grib-payload"
|
|
after
|
|
File.rm(path)
|
|
end
|
|
end
|
|
|
|
test "streams a {:url, href} source through Req into the target path" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
assert conn.method == "GET"
|
|
|
|
conn
|
|
|> Plug.Conn.put_resp_content_type("application/octet-stream")
|
|
|> Plug.Conn.resp(200, "streamed-grib-data")
|
|
end)
|
|
|
|
path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib")
|
|
|
|
try do
|
|
assert :ok =
|
|
Era5Client.download_source_to_file(
|
|
{:url, "https://example.com/file.grib"},
|
|
"test-key",
|
|
path
|
|
)
|
|
|
|
assert File.read!(path) == "streamed-grib-data"
|
|
after
|
|
File.rm(path)
|
|
end
|
|
end
|
|
|
|
test "removes partial file and returns {:error, _} on HTTP error" do
|
|
Req.Test.stub(Era5Client, fn conn ->
|
|
Plug.Conn.resp(conn, 503, "")
|
|
end)
|
|
|
|
path = Path.join(System.tmp_dir!(), "era5_test_#{System.unique_integer([:positive])}.grib")
|
|
|
|
assert {:error, reason} =
|
|
Era5Client.download_source_to_file(
|
|
{:url, "https://example.com/file.grib"},
|
|
"test-key",
|
|
path
|
|
)
|
|
|
|
assert reason =~ "HTTP 503"
|
|
refute File.exists?(path)
|
|
end
|
|
end
|
|
|
|
describe "pressure_levels/0" do
|
|
test "covers upper troposphere and lower stratosphere for the skew-T plot" do
|
|
levels = Enum.map(Era5Client.pressure_levels(), &String.to_integer/1)
|
|
|
|
# Boundary-layer resolution near the surface stays intact.
|
|
assert 1000 in levels
|
|
assert 700 in levels
|
|
|
|
# Upper-air levels needed to fill out the skew-T above 700 mb.
|
|
for level <- [500, 300, 200, 100] do
|
|
assert level in levels, "expected #{level} mb in Era5Client.pressure_levels/0"
|
|
end
|
|
end
|
|
end
|
|
end
|