Skip to content

Commit ee2663b

Browse files
committed
TST: improve tests
1 parent 1b5d9fe commit ee2663b

3 files changed

Lines changed: 316 additions & 2 deletions

File tree

rocketpy/environment/tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def mask_and_clean_dataset(*args):
225225
return data_array
226226

227227

228-
def find_longitude_index(longitude, lon_list):
228+
def find_longitude_index(longitude, lon_list): # pylint: disable=too-many-statements
229229
"""Finds the index of the given longitude in a list of longitudes.
230230
231231
Parameters
@@ -344,7 +344,7 @@ def _coord_value(source, index):
344344
return latitude, lat_index
345345

346346

347-
def find_time_index(datetime_date, time_array):
347+
def find_time_index(datetime_date, time_array): # pylint: disable=too-many-statements
348348
"""Finds the index of the given datetime in a netCDF4 time array.
349349
350350
Parameters

tests/unit/environment/test_environment.py

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,234 @@ def test_weather_model_mapping_exposes_legacy_aliases():
311311

312312
assert mapping.get("GFS_LEGACY")["temperature"] == "tmpprs"
313313
assert mapping.get("gfs_legacy")["temperature"] == "tmpprs"
314+
315+
316+
def test_dictionary_matches_dataset_rejects_missing_projection(example_plain_env):
317+
"""Reject mapping when projection key is declared but variable is missing."""
318+
# Arrange
319+
mapping = {
320+
"time": "time",
321+
"latitude": "y",
322+
"longitude": "x",
323+
"projection": "LambertConformal_Projection",
324+
"level": "isobaric",
325+
"temperature": "Temperature_isobaric",
326+
"geopotential_height": "Geopotential_height_isobaric",
327+
"geopotential": None,
328+
"u_wind": "u-component_of_wind_isobaric",
329+
"v_wind": "v-component_of_wind_isobaric",
330+
}
331+
dataset = _DummyDataset(
332+
[
333+
"time",
334+
"y",
335+
"x",
336+
"isobaric",
337+
"Temperature_isobaric",
338+
"Geopotential_height_isobaric",
339+
"u-component_of_wind_isobaric",
340+
"v-component_of_wind_isobaric",
341+
]
342+
)
343+
344+
# Act
345+
is_compatible = example_plain_env._Environment__dictionary_matches_dataset(
346+
mapping, dataset
347+
)
348+
349+
# Assert
350+
assert not is_compatible
351+
352+
353+
def test_dictionary_matches_dataset_accepts_geopotential_only(example_plain_env):
354+
"""Accept mapping when geopotential exists and geopotential height is absent."""
355+
# Arrange
356+
mapping = {
357+
"time": "time",
358+
"latitude": "latitude",
359+
"longitude": "longitude",
360+
"level": "level",
361+
"temperature": "t",
362+
"geopotential_height": None,
363+
"geopotential": "z",
364+
"u_wind": "u",
365+
"v_wind": "v",
366+
}
367+
dataset = _DummyDataset(
368+
[
369+
"time",
370+
"latitude",
371+
"longitude",
372+
"level",
373+
"t",
374+
"z",
375+
"u",
376+
"v",
377+
]
378+
)
379+
380+
# Act
381+
is_compatible = example_plain_env._Environment__dictionary_matches_dataset(
382+
mapping, dataset
383+
)
384+
385+
# Assert
386+
assert is_compatible
387+
388+
389+
def test_resolve_dictionary_warns_when_falling_back(example_plain_env):
390+
"""Emit warning and return a built-in mapping when fallback is required."""
391+
# Arrange
392+
incompatible_mapping = {
393+
"time": "bad_time",
394+
"latitude": "bad_lat",
395+
"longitude": "bad_lon",
396+
"level": "bad_level",
397+
"temperature": "bad_temp",
398+
"geopotential_height": "bad_height",
399+
"geopotential": None,
400+
"u_wind": "bad_u",
401+
"v_wind": "bad_v",
402+
}
403+
dataset = _DummyDataset(
404+
[
405+
"time",
406+
"lat",
407+
"lon",
408+
"isobaric",
409+
"Temperature_isobaric",
410+
"Geopotential_height_isobaric",
411+
"u-component_of_wind_isobaric",
412+
"v-component_of_wind_isobaric",
413+
]
414+
)
415+
416+
# Act
417+
with pytest.warns(UserWarning, match="Falling back to built-in mapping"):
418+
resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
419+
incompatible_mapping, dataset
420+
)
421+
422+
# Assert
423+
assert resolved == example_plain_env._Environment__weather_model_map.get("GFS")
424+
425+
426+
def test_resolve_dictionary_returns_original_when_no_compatible_builtin(
427+
example_plain_env,
428+
):
429+
"""Return original mapping unchanged when no built-in mapping can match."""
430+
# Arrange
431+
original_mapping = {
432+
"time": "a",
433+
"latitude": "b",
434+
"longitude": "c",
435+
"level": "d",
436+
"temperature": "e",
437+
"geopotential_height": "f",
438+
"geopotential": None,
439+
"u_wind": "g",
440+
"v_wind": "h",
441+
}
442+
dataset = _DummyDataset(["foo", "bar"])
443+
444+
# Act
445+
resolved = example_plain_env._Environment__resolve_dictionary_for_dataset(
446+
original_mapping, dataset
447+
)
448+
449+
# Assert
450+
assert resolved is original_mapping
451+
452+
453+
@pytest.mark.parametrize(
454+
"model_type,file_name,error_message",
455+
[
456+
(
457+
"Forecast",
458+
"hiresw",
459+
"HIRESW latest-model shortcut is currently unavailable",
460+
),
461+
(
462+
"Ensemble",
463+
"gefs",
464+
"GEFS latest-model shortcut is currently unavailable",
465+
),
466+
],
467+
)
468+
def test_set_atmospheric_model_blocks_deactivated_shortcuts_case_insensitive(
469+
example_plain_env,
470+
model_type,
471+
file_name,
472+
error_message,
473+
):
474+
"""Reject deactivated shortcut aliases regardless of input string case."""
475+
# Arrange
476+
environment = example_plain_env
477+
478+
# Act / Assert
479+
with pytest.raises(ValueError, match=error_message):
480+
environment.set_atmospheric_model(type=model_type, file=file_name)
481+
482+
483+
def test_validate_dictionary_uses_case_insensitive_file_shortcut(example_plain_env):
484+
"""Infer built-in mapping from file shortcut even when shortcut is lowercase."""
485+
# Arrange
486+
environment = example_plain_env
487+
488+
# Act
489+
mapping = environment._Environment__validate_dictionary("gfs", None)
490+
491+
# Assert
492+
assert mapping == environment._Environment__weather_model_map.get("GFS")
493+
494+
495+
def test_validate_dictionary_raises_type_error_for_invalid_dictionary(
496+
example_plain_env,
497+
):
498+
"""Raise TypeError when no valid dictionary can be inferred."""
499+
# Arrange
500+
environment = example_plain_env
501+
502+
# Act / Assert
503+
with pytest.raises(TypeError, match="Please specify a dictionary"):
504+
environment._Environment__validate_dictionary("not_a_model", None)
505+
506+
507+
def test_set_atmospheric_model_normalizes_shortcut_case_for_forecast(example_plain_env):
508+
"""Normalize shortcut name before lookup and process forecast data."""
509+
# Arrange
510+
environment = example_plain_env
511+
512+
environment._Environment__atm_type_file_to_function_map = {
513+
"forecast": {
514+
"GFS": lambda: "fake-dataset",
515+
},
516+
"ensemble": {},
517+
}
518+
519+
called_arguments = {}
520+
521+
def fake_process_forecast_reanalysis(dataset, dictionary):
522+
called_arguments["dataset"] = dataset
523+
called_arguments["dictionary"] = dictionary
524+
525+
environment.process_forecast_reanalysis = fake_process_forecast_reanalysis
526+
527+
# Act
528+
environment.set_atmospheric_model(type="Forecast", file="gfs")
529+
530+
# Assert
531+
assert called_arguments["dataset"] == "fake-dataset"
532+
assert called_arguments[
533+
"dictionary"
534+
] == environment._Environment__weather_model_map.get("GFS")
535+
536+
537+
def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env):
538+
"""Raise ValueError for unknown atmospheric model selector."""
539+
# Arrange
540+
environment = example_plain_env
541+
542+
# Act / Assert
543+
with pytest.raises(ValueError, match="Unknown model type"):
544+
environment.set_atmospheric_model(type="unknown_type")
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import pytest
2+
3+
from rocketpy.environment import fetchers
4+
5+
6+
@pytest.mark.parametrize(
7+
"fetcher,expected_url",
8+
[
9+
(
10+
fetchers.fetch_gfs_file_return_dataset,
11+
"https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/GFS/Global_0p25deg/Best",
12+
),
13+
(
14+
fetchers.fetch_nam_file_return_dataset,
15+
"https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/NAM/CONUS_12km/Best",
16+
),
17+
(
18+
fetchers.fetch_rap_file_return_dataset,
19+
"https://thredds.ucar.edu/thredds/dodsC/grib/NCEP/RAP/CONUS_13km/Best",
20+
),
21+
],
22+
)
23+
def test_fetcher_returns_dataset_on_first_attempt(fetcher, expected_url, monkeypatch):
24+
"""Return dataset immediately when the first OPeNDAP attempt succeeds."""
25+
# Arrange
26+
calls = []
27+
sentinel_dataset = object()
28+
29+
def fake_dataset(url):
30+
calls.append(url)
31+
return sentinel_dataset
32+
33+
monkeypatch.setattr(fetchers.netCDF4, "Dataset", fake_dataset)
34+
35+
# Act
36+
dataset = fetcher(max_attempts=3, base_delay=2)
37+
38+
# Assert
39+
assert dataset is sentinel_dataset
40+
assert calls == [expected_url]
41+
42+
43+
def test_fetch_gfs_retries_then_succeeds(monkeypatch):
44+
"""Retry GFS fetch after OSError and return data once endpoint responds."""
45+
# Arrange
46+
attempt_counter = {"count": 0}
47+
sleep_calls = []
48+
49+
def fake_dataset(_):
50+
attempt_counter["count"] += 1
51+
if attempt_counter["count"] < 3:
52+
raise OSError("temporary failure")
53+
return "gfs-dataset"
54+
55+
monkeypatch.setattr(fetchers.netCDF4, "Dataset", fake_dataset)
56+
monkeypatch.setattr(fetchers.time, "sleep", sleep_calls.append)
57+
58+
# Act
59+
dataset = fetchers.fetch_gfs_file_return_dataset(max_attempts=3, base_delay=2)
60+
61+
# Assert
62+
assert dataset == "gfs-dataset"
63+
assert sleep_calls == [2, 4]
64+
65+
66+
def test_fetch_rap_raises_runtime_error_after_max_attempts(monkeypatch):
67+
"""Raise RuntimeError when all RAP attempts fail with OSError."""
68+
# Arrange
69+
sleep_calls = []
70+
71+
def always_fails(_):
72+
raise OSError("endpoint down")
73+
74+
monkeypatch.setattr(fetchers.netCDF4, "Dataset", always_fails)
75+
monkeypatch.setattr(fetchers.time, "sleep", sleep_calls.append)
76+
77+
# Act / Assert
78+
with pytest.raises(
79+
RuntimeError, match="Unable to load latest weather data for RAP"
80+
):
81+
fetchers.fetch_rap_file_return_dataset(max_attempts=2, base_delay=2)
82+
83+
assert sleep_calls == [2, 4]

0 commit comments

Comments
 (0)