roms-tools 2.6.2__py3-none-any.whl → 2.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. roms_tools/__init__.py +1 -0
  2. roms_tools/analysis/roms_output.py +11 -77
  3. roms_tools/analysis/utils.py +0 -66
  4. roms_tools/constants.py +2 -0
  5. roms_tools/download.py +46 -3
  6. roms_tools/plot.py +22 -5
  7. roms_tools/setup/cdr_forcing.py +1126 -0
  8. roms_tools/setup/datasets.py +742 -87
  9. roms_tools/setup/grid.py +42 -4
  10. roms_tools/setup/river_forcing.py +11 -84
  11. roms_tools/setup/tides.py +81 -411
  12. roms_tools/setup/utils.py +241 -37
  13. roms_tools/tests/test_setup/test_cdr_forcing.py +772 -0
  14. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/.zmetadata +53 -1
  15. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/river_tracer/.zattrs +1 -1
  16. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_long_name/.zarray +20 -0
  17. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_long_name/.zattrs +6 -0
  18. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_long_name/0 +0 -0
  19. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_unit/.zarray +20 -0
  20. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_unit/.zattrs +6 -0
  21. roms_tools/tests/test_setup/test_data/river_forcing_no_climatology.zarr/tracer_unit/0 +0 -0
  22. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/.zmetadata +53 -1
  23. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/river_tracer/.zattrs +1 -1
  24. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_long_name/.zarray +20 -0
  25. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_long_name/.zattrs +6 -0
  26. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_long_name/0 +0 -0
  27. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_unit/.zarray +20 -0
  28. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_unit/.zattrs +6 -0
  29. roms_tools/tests/test_setup/test_data/river_forcing_with_bgc.zarr/tracer_unit/0 +0 -0
  30. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/.zattrs +1 -2
  31. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/.zmetadata +27 -5
  32. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/ntides/.zarray +20 -0
  33. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/ntides/.zattrs +5 -0
  34. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/ntides/0 +0 -0
  35. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/omega/.zattrs +1 -3
  36. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/pot_Im/0.0.0 +0 -0
  37. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/pot_Re/0.0.0 +0 -0
  38. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/ssh_Im/0.0.0 +0 -0
  39. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/ssh_Re/0.0.0 +0 -0
  40. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/u_Im/0.0.0 +0 -0
  41. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/u_Re/0.0.0 +0 -0
  42. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/v_Im/0.0.0 +0 -0
  43. roms_tools/tests/test_setup/test_data/tidal_forcing.zarr/v_Re/0.0.0 +0 -0
  44. roms_tools/tests/test_setup/test_datasets.py +103 -1
  45. roms_tools/tests/test_setup/test_tides.py +112 -47
  46. roms_tools/utils.py +115 -1
  47. {roms_tools-2.6.2.dist-info → roms_tools-2.7.0.dist-info}/METADATA +1 -1
  48. {roms_tools-2.6.2.dist-info → roms_tools-2.7.0.dist-info}/RECORD +51 -33
  49. {roms_tools-2.6.2.dist-info → roms_tools-2.7.0.dist-info}/WHEEL +1 -1
  50. {roms_tools-2.6.2.dist-info → roms_tools-2.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {roms_tools-2.6.2.dist-info → roms_tools-2.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,772 @@
1
+ import pytest
2
+ from copy import deepcopy
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from roms_tools import CDRVolumePointSource, Grid
6
+ from roms_tools.constants import NUM_TRACERS
7
+ import xarray as xr
8
+ import numpy as np
9
+ import logging
10
+ from roms_tools.setup.utils import get_tracer_defaults
11
+ from conftest import calculate_file_hash
12
+
13
+ try:
14
+ import xesmf # type: ignore
15
+ except ImportError:
16
+ xesmf = None
17
+
18
+ # Fixtures
19
+ @pytest.fixture
20
+ def iceland_test_grid():
21
+ """Returns a grid surrouding Iceland."""
22
+ return Grid(
23
+ nx=18, ny=18, size_x=800, size_y=800, center_lon=-18, center_lat=65, rot=0, N=3
24
+ )
25
+
26
+
27
+ @pytest.fixture
28
+ def test_grid_that_straddles():
29
+ """Returns a grid that straddles the prime meridian."""
30
+ return Grid(
31
+ nx=18, ny=18, size_x=800, size_y=800, center_lon=0, center_lat=65, rot=0, N=3
32
+ )
33
+
34
+
35
+ @pytest.fixture
36
+ def start_end_times():
37
+ """Returns test start and end times."""
38
+ start_time = datetime(2022, 1, 1)
39
+ end_time = datetime(2022, 12, 31)
40
+ return start_time, end_time
41
+
42
+
43
+ @pytest.fixture
44
+ def empty_cdr_point_source_without_grid(start_end_times):
45
+ """Returns an empty CDR point source without a grid."""
46
+ start_time, end_time = start_end_times
47
+ return CDRVolumePointSource(
48
+ start_time=start_time,
49
+ end_time=end_time,
50
+ )
51
+
52
+
53
+ @pytest.fixture
54
+ def empty_cdr_point_source_with_grid(iceland_test_grid, start_end_times):
55
+ """Returns an empty CDR point source with the Iceland test grid."""
56
+ start_time, end_time = start_end_times
57
+ return CDRVolumePointSource(
58
+ grid=iceland_test_grid,
59
+ start_time=start_time,
60
+ end_time=end_time,
61
+ )
62
+
63
+
64
+ @pytest.fixture
65
+ def empty_cdr_point_source_with_grid_that_straddles(
66
+ test_grid_that_straddles, start_end_times
67
+ ):
68
+ """Returns an empty CDR point source with a grid straddling the prime meridian."""
69
+ start_time, end_time = start_end_times
70
+ return CDRVolumePointSource(
71
+ grid=test_grid_that_straddles,
72
+ start_time=start_time,
73
+ end_time=end_time,
74
+ )
75
+
76
+
77
+ @pytest.fixture
78
+ def valid_release_params():
79
+ """Returns a dictionary with valid parameters for a CDR point source release within
80
+ the Iceland test domain."""
81
+ return {
82
+ "lat": 66.0,
83
+ "lon": -25.0,
84
+ "depth": 50.0,
85
+ "volume_fluxes": 100.0,
86
+ }
87
+
88
+
89
+ @pytest.fixture
90
+ def cdr_point_source_with_two_releases(
91
+ iceland_test_grid, start_end_times, valid_release_params
92
+ ):
93
+ """Returns a CDR point source with one release."""
94
+ start_time, end_time = start_end_times
95
+ cdr = CDRVolumePointSource(
96
+ grid=iceland_test_grid, start_time=start_time, end_time=end_time
97
+ )
98
+ cdr.add_release(name="release1", **valid_release_params)
99
+
100
+ release_params = deepcopy(valid_release_params)
101
+ release_params["times"] = [
102
+ datetime(2022, 1, 1),
103
+ datetime(2022, 1, 3),
104
+ datetime(2022, 1, 5),
105
+ ]
106
+ release_params["volume_fluxes"] = [1.0, 2.0, 3.0]
107
+ release_params["tracer_concentrations"] = {"DIC": [10.0, 20.0, 30.0]}
108
+ cdr.add_release(name="release2", **release_params)
109
+
110
+ return cdr
111
+
112
+
113
+ # Tests
114
+ @pytest.mark.parametrize(
115
+ "cdr_forcing_fixture",
116
+ [
117
+ "empty_cdr_point_source_without_grid",
118
+ "empty_cdr_point_source_with_grid",
119
+ ],
120
+ )
121
+ def test_cdr_point_source_init(cdr_forcing_fixture, start_end_times, request):
122
+ """Tests the initialization of CDR point source fixtures."""
123
+ cdr = request.getfixturevalue(cdr_forcing_fixture)
124
+ start_time, end_time = start_end_times
125
+ assert cdr.start_time == start_time
126
+ assert cdr.end_time == end_time
127
+
128
+ # Check that dataset is empty but has right dimensions and variables
129
+ assert isinstance(cdr.ds, xr.Dataset)
130
+
131
+ # Check dimension lengths
132
+ assert cdr.ds.time.size == 0
133
+ assert cdr.ds.ncdr.size == 0
134
+ assert cdr.ds.ntracers.size == NUM_TRACERS
135
+
136
+ # Check coordinate and variable lengths
137
+ assert cdr.ds.release_name.size == 0
138
+ assert cdr.ds.tracer_name.size == NUM_TRACERS
139
+ assert cdr.ds.tracer_unit.size == NUM_TRACERS
140
+ assert cdr.ds.tracer_long_name.size == NUM_TRACERS
141
+ assert cdr.ds.cdr_time.size == 0
142
+ assert cdr.ds.cdr_lon.size == 0
143
+ assert cdr.ds.cdr_lat.size == 0
144
+ assert cdr.ds.cdr_dep.size == 0
145
+ assert cdr.ds.cdr_hsc.size == 0
146
+ assert cdr.ds.cdr_vsc.size == 0
147
+ assert cdr.ds.cdr_volume.size == 0
148
+ assert cdr.ds.cdr_tracer.size == 0
149
+
150
+ # Check that release dictionary is empty except for tracer metadata
151
+ assert set(cdr.releases.keys()) == {"_tracer_metadata"}
152
+
153
+
154
+ @pytest.mark.parametrize(
155
+ "cdr_forcing_fixture",
156
+ [
157
+ "empty_cdr_point_source_without_grid",
158
+ "empty_cdr_point_source_with_grid",
159
+ ],
160
+ )
161
+ def test_add_release(cdr_forcing_fixture, valid_release_params, request):
162
+ """Test some basic features of the `add_release` method for updating forcing dataset
163
+ and dictionary."""
164
+
165
+ cdr = request.getfixturevalue(cdr_forcing_fixture)
166
+ release_params = deepcopy(valid_release_params)
167
+ times = [
168
+ cdr.start_time,
169
+ datetime(2022, 1, 5),
170
+ cdr.end_time,
171
+ ]
172
+ release_params["times"] = times
173
+
174
+ volume_fluxes = [100.0, 200.0, 150.0]
175
+ release_params["volume_fluxes"] = volume_fluxes
176
+
177
+ tracer_concentrations = {
178
+ "ALK": [2300.0, 2350.0, 2400.0],
179
+ "DIC": [2100.0, 2150.0, 2200.0],
180
+ "temp": 10.0,
181
+ "salt": 35.0,
182
+ }
183
+ release_params["tracer_concentrations"] = tracer_concentrations
184
+
185
+ # Add release
186
+ cdr.add_release(name="release", **release_params)
187
+
188
+ # Check dimension lengths
189
+ assert cdr.ds.time.size == len(times)
190
+ assert cdr.ds.ncdr.size == 1
191
+ assert cdr.ds.ntracers.size == NUM_TRACERS
192
+
193
+ # Check coordinate and variable lengths
194
+ assert cdr.ds.release_name.size == 1
195
+ assert "release" in cdr.ds["release_name"].values
196
+ assert cdr.ds.tracer_name.size == NUM_TRACERS
197
+ assert cdr.ds.tracer_unit.size == NUM_TRACERS
198
+ assert cdr.ds.tracer_long_name.size == NUM_TRACERS
199
+ assert cdr.ds.cdr_time.size == len(times)
200
+ assert cdr.ds.cdr_lon.size == 1
201
+ assert cdr.ds.cdr_lat.size == 1
202
+ assert cdr.ds.cdr_dep.size == 1
203
+ assert cdr.ds.cdr_hsc.size == 1
204
+ assert cdr.ds.cdr_vsc.size == 1
205
+
206
+ # Check cdr_volume shape and values
207
+ assert cdr.ds.cdr_volume.shape == (len(times), 1)
208
+ np.testing.assert_allclose(cdr.ds.cdr_volume[:, 0], volume_fluxes, rtol=1e-3)
209
+
210
+ # Check tracer concentration shape
211
+ assert cdr.ds.cdr_tracer.shape == (len(times), NUM_TRACERS, 1)
212
+
213
+ # Check tracer concentration values for known tracers
214
+ tracer_index = {name: i for i, name in enumerate(cdr.ds.tracer_name.values)}
215
+ for tracer, expected in tracer_concentrations.items():
216
+ i = tracer_index[tracer]
217
+ if isinstance(expected, list):
218
+ np.testing.assert_allclose(cdr.ds.cdr_tracer[:, i, 0], expected)
219
+ else:
220
+ np.testing.assert_allclose(cdr.ds.cdr_tracer[:, i, 0], expected)
221
+
222
+ assert "release" in cdr.releases.keys()
223
+
224
+
225
+ def test_merge_multiple_releases(start_end_times, valid_release_params):
226
+ """Test merging multiple releases in the dataset, including endpoint filling,
227
+ timestamp adjustment, and interpolation."""
228
+
229
+ start_time, end_time = start_end_times
230
+ cdr = CDRVolumePointSource(start_time=start_time, end_time=end_time)
231
+ dic_index = 9
232
+
233
+ # add first release
234
+ release_params1 = deepcopy(valid_release_params)
235
+ release_params1["times"] = [
236
+ datetime(2022, 1, 1), # overall start time
237
+ datetime(2022, 1, 3),
238
+ datetime(2022, 1, 5),
239
+ ]
240
+ release_params1["volume_fluxes"] = [1.0, 2.0, 3.0]
241
+ release_params1["tracer_concentrations"] = {"DIC": [10.0, 20.0, 30.0]}
242
+ cdr.add_release(name="release1", **release_params1)
243
+
244
+ # check time
245
+ expected_times = [
246
+ datetime(2022, 1, 1), # overall start time
247
+ datetime(2022, 1, 3),
248
+ datetime(2022, 1, 5),
249
+ datetime(2022, 12, 31), # overall end time
250
+ ]
251
+ assert np.array_equal(
252
+ cdr.ds["time"].values, np.array(expected_times, dtype="datetime64[ns]")
253
+ )
254
+
255
+ # check first release
256
+ ncdr_index = 0
257
+
258
+ assert cdr.ds["cdr_lon"].isel(ncdr=ncdr_index).values == release_params1["lon"]
259
+ assert cdr.ds["cdr_lat"].isel(ncdr=ncdr_index).values == release_params1["lat"]
260
+ assert cdr.ds["cdr_dep"].isel(ncdr=ncdr_index).values == release_params1["depth"]
261
+ assert cdr.ds["cdr_hsc"].isel(ncdr=ncdr_index).values == 0.0
262
+ assert cdr.ds["cdr_vsc"].isel(ncdr=ncdr_index).values == 0.0
263
+
264
+ expected_volume_fluxes = [
265
+ 1.0,
266
+ 2.0,
267
+ 3.0,
268
+ 0.0, # volume flux set to zero at endpoint
269
+ ]
270
+
271
+ assert np.allclose(
272
+ cdr.ds["cdr_volume"].isel(ncdr=ncdr_index).values,
273
+ np.array(expected_volume_fluxes),
274
+ )
275
+
276
+ expected_dics = [
277
+ 10.0,
278
+ 20.0,
279
+ 30.0,
280
+ 30.0, # tracer concenctration extrapolated to endpoint
281
+ ]
282
+ assert np.allclose(
283
+ cdr.ds["cdr_tracer"].isel(ncdr=ncdr_index, ntracers=dic_index).values,
284
+ np.array(expected_dics),
285
+ )
286
+
287
+ # add second release
288
+ release_params2 = deepcopy(valid_release_params)
289
+ release_params2["lon"] = release_params2["lon"] - 1
290
+ release_params2["lat"] = release_params2["lat"] - 1
291
+ release_params2["depth"] = release_params2["depth"] - 1
292
+
293
+ release_params2["times"] = [
294
+ datetime(2022, 1, 2),
295
+ datetime(2022, 1, 4),
296
+ datetime(2022, 1, 5),
297
+ ]
298
+ release_params2["volume_fluxes"] = [2.0, 4.0, 10.0]
299
+ release_params2["tracer_concentrations"] = {"DIC": [20.0, 40.0, 100.0]}
300
+ cdr.add_release(name="release2", **release_params2)
301
+
302
+ # check time again
303
+ expected_times = [
304
+ datetime(2022, 1, 1), # overall start time
305
+ datetime(2022, 1, 2),
306
+ datetime(2022, 1, 3),
307
+ datetime(2022, 1, 4),
308
+ datetime(2022, 1, 5),
309
+ datetime(2022, 12, 31), # overall end time
310
+ ]
311
+ assert np.array_equal(
312
+ cdr.ds["time"].values, np.array(expected_times, dtype="datetime64[ns]")
313
+ )
314
+
315
+ # check first release again
316
+ ncdr_index = 0
317
+
318
+ assert cdr.ds["cdr_lon"].isel(ncdr=ncdr_index).values == release_params1["lon"]
319
+ assert cdr.ds["cdr_lat"].isel(ncdr=ncdr_index).values == release_params1["lat"]
320
+ assert cdr.ds["cdr_dep"].isel(ncdr=ncdr_index).values == release_params1["depth"]
321
+ assert cdr.ds["cdr_hsc"].isel(ncdr=ncdr_index).values == 0.0
322
+ assert cdr.ds["cdr_vsc"].isel(ncdr=ncdr_index).values == 0.0
323
+
324
+ expected_volume_fluxes = [
325
+ 1.0,
326
+ 1.5,
327
+ 2.0,
328
+ 2.5,
329
+ 3.0,
330
+ 0.0, # volume flux set to zero at endpoint
331
+ ]
332
+
333
+ assert np.allclose(
334
+ cdr.ds["cdr_volume"].isel(ncdr=ncdr_index).values,
335
+ np.array(expected_volume_fluxes),
336
+ )
337
+
338
+ expected_dics = [
339
+ 10.0,
340
+ 15.0,
341
+ 20.0,
342
+ 25.0,
343
+ 30.0,
344
+ 30.0, # tracer concenctration extrapolated to endpoint
345
+ ]
346
+ assert np.allclose(
347
+ cdr.ds["cdr_tracer"].isel(ncdr=ncdr_index, ntracers=dic_index).values,
348
+ np.array(expected_dics),
349
+ )
350
+ # check second release
351
+ ncdr_index = 1
352
+
353
+ assert cdr.ds["cdr_lon"].isel(ncdr=ncdr_index).values == release_params2["lon"]
354
+ assert cdr.ds["cdr_lat"].isel(ncdr=ncdr_index).values == release_params2["lat"]
355
+ assert cdr.ds["cdr_dep"].isel(ncdr=ncdr_index).values == release_params2["depth"]
356
+ assert cdr.ds["cdr_hsc"].isel(ncdr=ncdr_index).values == 0.0
357
+ assert cdr.ds["cdr_vsc"].isel(ncdr=ncdr_index).values == 0.0
358
+
359
+ expected_volume_fluxes = [
360
+ 0.0, # volume flux set to zero at startpoint
361
+ 2.0,
362
+ 3.0,
363
+ 4.0,
364
+ 10.0,
365
+ 0.0, # volume flux set to zero at endpoint
366
+ ]
367
+ assert np.allclose(
368
+ cdr.ds["cdr_volume"].isel(ncdr=ncdr_index).values,
369
+ np.array(expected_volume_fluxes),
370
+ )
371
+
372
+ expected_dics = [
373
+ 20.0, # tracer concenctration extrapolated to startpoint
374
+ 20.0,
375
+ 30.0,
376
+ 40.0,
377
+ 100.0,
378
+ 100.0, # tracer concenctration extrapolated to endpoint
379
+ ]
380
+ assert np.allclose(
381
+ cdr.ds["cdr_tracer"].isel(ncdr=ncdr_index, ntracers=dic_index).values,
382
+ np.array(expected_dics),
383
+ )
384
+
385
+
386
+ def test_cdr_point_source_init_invalid_times():
387
+ """Test that initializing a CDR point source with the same start and end time raises
388
+ a ValueError."""
389
+ start_time = datetime(2022, 5, 1)
390
+ end_time = datetime(2022, 5, 1)
391
+ with pytest.raises(
392
+ ValueError, match="`start_time` must be earlier than `end_time`"
393
+ ):
394
+ CDRVolumePointSource(start_time=start_time, end_time=end_time)
395
+
396
+
397
+ @pytest.mark.parametrize(
398
+ "cdr_forcing_fixture",
399
+ [
400
+ "empty_cdr_point_source_without_grid",
401
+ "empty_cdr_point_source_with_grid",
402
+ ],
403
+ )
404
+ def test_add_duplicate_release(cdr_forcing_fixture, valid_release_params, request):
405
+ """Test that adding a duplicate release raises a ValueError."""
406
+ cdr = request.getfixturevalue(cdr_forcing_fixture)
407
+ release_params = deepcopy(valid_release_params)
408
+ cdr.add_release(name="release_1", **release_params)
409
+ with pytest.raises(
410
+ ValueError, match="A release with the name 'release_1' already exists."
411
+ ):
412
+ cdr.add_release(name="release_1", **release_params)
413
+
414
+
415
+ @pytest.mark.parametrize(
416
+ "cdr_forcing_fixture",
417
+ [
418
+ "empty_cdr_point_source_without_grid",
419
+ "empty_cdr_point_source_with_grid",
420
+ ],
421
+ )
422
+ def test_invalid_release_params(cdr_forcing_fixture, valid_release_params, request):
423
+ """Test that invalid release parameters raise the appropriate ValueErrors."""
424
+ cdr = request.getfixturevalue(cdr_forcing_fixture)
425
+
426
+ # Test invalid latitude
427
+ invalid_params = deepcopy(valid_release_params)
428
+ invalid_params["lat"] = 100.0
429
+ with pytest.raises(ValueError, match="Latitude must be between -90 and 90."):
430
+ cdr.add_release(name="release_1", **invalid_params)
431
+
432
+ # Test invalid depth (negative value)
433
+ invalid_params = deepcopy(valid_release_params)
434
+ invalid_params["depth"] = -10.0
435
+ with pytest.raises(ValueError, match="Depth must be a non-negative number."):
436
+ cdr.add_release(name="release_1", **invalid_params)
437
+
438
+ # Test times not being datetime objects
439
+ invalid_params = deepcopy(valid_release_params)
440
+ invalid_params["times"] = [
441
+ "2023-01-01",
442
+ "2023-02-01",
443
+ ] # Invalid times format (strings)
444
+ with pytest.raises(
445
+ ValueError,
446
+ match="If 'times' is provided, all entries must be datetime objects.",
447
+ ):
448
+ cdr.add_release(name="release_1", **invalid_params)
449
+
450
+ # Test times being not monotonically increasing
451
+ invalid_params = deepcopy(valid_release_params)
452
+ invalid_params["times"] = [
453
+ datetime(2022, 1, 1),
454
+ datetime(2022, 2, 1),
455
+ datetime(2022, 1, 15), # Out of order date
456
+ datetime(2022, 3, 1),
457
+ ]
458
+ with pytest.raises(
459
+ ValueError, match="The 'times' list must be strictly monotonically increasing."
460
+ ):
461
+ cdr.add_release(name="release_1", **invalid_params)
462
+
463
+ # Test times being not strictly monotonically increasing
464
+ invalid_params = deepcopy(valid_release_params)
465
+ invalid_params["times"] = [
466
+ datetime(2022, 1, 1),
467
+ datetime(2022, 2, 1),
468
+ datetime(2022, 2, 1), # Duplicated time
469
+ datetime(2022, 3, 1),
470
+ ]
471
+ with pytest.raises(
472
+ ValueError, match="The 'times' list must be strictly monotonically increasing."
473
+ ):
474
+ cdr.add_release(name="release_1", **invalid_params)
475
+
476
+ # Test first time earlier than self.start_time
477
+ invalid_params = deepcopy(valid_release_params)
478
+ invalid_params["times"] = [
479
+ datetime(2000, 1, 1),
480
+ datetime(2022, 2, 1),
481
+ ] # Earlier than self.start_time
482
+ with pytest.raises(ValueError, match="First entry"):
483
+ cdr.add_release(name="release_1", **invalid_params)
484
+
485
+ # Test last time later than self.end_time
486
+ invalid_params = deepcopy(valid_release_params)
487
+ invalid_params["times"] = [
488
+ datetime(2022, 1, 1),
489
+ datetime(2025, 1, 1),
490
+ ] # Later than self.end_time
491
+ with pytest.raises(ValueError, match="Last entry"):
492
+ cdr.add_release(name="release_1", **invalid_params)
493
+
494
+ # Test invalid volume_fluxes: not a float/int or list of float/int
495
+ invalid_params = deepcopy(valid_release_params)
496
+ invalid_params["volume_fluxes"] = ["not", "valid"]
497
+ with pytest.raises(ValueError, match="Invalid 'volume_fluxes' input"):
498
+ cdr.add_release(name="release_invalid_volume", **invalid_params)
499
+
500
+ # Test invalid tracer_concentrations: not a float/int or list of float/int
501
+ invalid_params = deepcopy(valid_release_params)
502
+ invalid_params["tracer_concentrations"] = {"ALK": ["not", "valid"]}
503
+ with pytest.raises(ValueError, match="Invalid tracer concentration for 'ALK'"):
504
+ cdr.add_release(name="release_invalid_tracer", **invalid_params)
505
+
506
+ # Test mismatch between times and volume fluxes length
507
+ invalid_params = deepcopy(valid_release_params)
508
+ invalid_params["times"] = [datetime(2022, 1, 1), datetime(2022, 1, 2)] # Two times
509
+ invalid_params["volume_fluxes"] = [100] # Only one volume flux entry
510
+ with pytest.raises(ValueError, match="The length of `volume_fluxes` "):
511
+ cdr.add_release(name="release_1", **invalid_params)
512
+
513
+ # Test mismatch between times and tracer_concentrations length
514
+ invalid_params = deepcopy(valid_release_params)
515
+ invalid_params["times"] = [datetime(2022, 1, 1), datetime(2022, 1, 2)] # Two times
516
+ invalid_params["tracer_concentrations"] = {
517
+ "ALK": [1]
518
+ } # Only one tracer concentration
519
+ with pytest.raises(ValueError, match="The length of tracer 'ALK'"):
520
+ cdr.add_release(name="release_1", **invalid_params)
521
+
522
+ # Test invalid volume flux (negative)
523
+ invalid_params = deepcopy(valid_release_params)
524
+ invalid_params["volume_fluxes"] = -100 # Invalid volume flux
525
+ with pytest.raises(ValueError, match="Volume flux must be non-negative"):
526
+ cdr.add_release(name="release_1", **invalid_params)
527
+
528
+ # Test volume flux as list with negative values
529
+ invalid_params = deepcopy(valid_release_params)
530
+ invalid_params["times"] = [cdr.start_time, cdr.end_time]
531
+ invalid_params["volume_fluxes"] = [10, -5] # Invalid volume fluxes in list
532
+ with pytest.raises(
533
+ ValueError, match="All entries in `volume_fluxes` must be non-negative"
534
+ ):
535
+ cdr.add_release(name="release_1", **invalid_params)
536
+
537
+ # Test invalid tracer concentration (negative)
538
+ invalid_params = deepcopy(valid_release_params)
539
+ invalid_params["tracer_concentrations"] = {"ALK": -1}
540
+ with pytest.raises(ValueError, match="The concentration of tracer"):
541
+ cdr.add_release(name="release_1", **invalid_params)
542
+
543
+ # Test tracer_concentration as list with negative values
544
+ invalid_params = deepcopy(valid_release_params)
545
+ invalid_params["times"] = [cdr.start_time, cdr.end_time]
546
+ invalid_params["tracer_concentrations"] = {
547
+ "ALK": [10, -5]
548
+ } # Invalid concentration in list
549
+ with pytest.raises(ValueError, match="All entries in "):
550
+ cdr.add_release(name="release_1", **invalid_params)
551
+
552
+
553
+ def test_warning_no_grid(
554
+ empty_cdr_point_source_without_grid, valid_release_params, caplog
555
+ ):
556
+ """Test warning if no grid is provided."""
557
+ with caplog.at_level(logging.WARNING):
558
+ empty_cdr_point_source_without_grid.add_release(
559
+ name="release_1", **valid_release_params
560
+ )
561
+
562
+ assert "Grid not provided"
563
+
564
+
565
+ @pytest.mark.parametrize(
566
+ "cdr_forcing_fixture",
567
+ [
568
+ "empty_cdr_point_source_with_grid",
569
+ "empty_cdr_point_source_with_grid_that_straddles",
570
+ ],
571
+ )
572
+ def test_invalid_release_longitude(cdr_forcing_fixture, valid_release_params, request):
573
+ """Test that error is raised if release location is outside grid."""
574
+
575
+ cdr = request.getfixturevalue(cdr_forcing_fixture)
576
+
577
+ # Release location outside of domain
578
+ invalid_params = deepcopy(valid_release_params)
579
+ invalid_params["lon"] = -30
580
+ invalid_params["lat"] = 60
581
+ with pytest.raises(ValueError, match="outside of the grid domain"):
582
+ cdr.add_release(name="release_1", **invalid_params)
583
+
584
+ # Release location outside of domain
585
+ invalid_params = deepcopy(valid_release_params)
586
+ invalid_params["lon"] = 360 - 30
587
+ invalid_params["lat"] = 60
588
+ with pytest.raises(ValueError, match="outside of the grid domain"):
589
+ cdr.add_release(name="release_1", **invalid_params)
590
+
591
+
592
+ def test_invalid_release_location(
593
+ empty_cdr_point_source_with_grid, valid_release_params
594
+ ):
595
+ """Test that error is raised if release location is outside grid or on land."""
596
+ # Release location too close to boundary of Iceland domain; lat_rho[0, 0] = 60.97, lon_rho[0, 0] = 334.17
597
+ invalid_params = deepcopy(valid_release_params)
598
+ invalid_params["lon"] = 334.17
599
+ invalid_params["lat"] = 60.97
600
+ with pytest.raises(ValueError, match="too close to the grid boundary"):
601
+ empty_cdr_point_source_with_grid.add_release(name="release_1", **invalid_params)
602
+
603
+ # Release location lies on land
604
+ invalid_params = deepcopy(valid_release_params)
605
+ invalid_params["lon"] = -20.0
606
+ invalid_params["lat"] = 64.5
607
+ with pytest.raises(ValueError, match="on land"):
608
+ empty_cdr_point_source_with_grid.add_release(name="release_1", **invalid_params)
609
+
610
+ # Release location lies below seafloor
611
+ invalid_params = deepcopy(valid_release_params)
612
+ invalid_params["depth"] = 4000
613
+ with pytest.raises(ValueError, match="below the seafloor"):
614
+ empty_cdr_point_source_with_grid.add_release(name="release_1", **invalid_params)
615
+
616
+
617
+ def test_add_release_tracer_zero_fill(start_end_times, valid_release_params):
618
+ """Test that zero fill of tracer concentrations works as expected."""
619
+ start_time, end_time = start_end_times
620
+ cdr = CDRVolumePointSource(start_time=start_time, end_time=end_time)
621
+ release_params = deepcopy(valid_release_params)
622
+ release_params["fill_values"] = "zero"
623
+ cdr.add_release(name="filled_release", **release_params)
624
+ defaults = get_tracer_defaults()
625
+ # temp
626
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=0) == defaults["temp"]).all()
627
+ # salt
628
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=1) == defaults["salt"]).all()
629
+ # all other tracers should be zero
630
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=slice(2, None)) == 0.0).all()
631
+
632
+
633
+ def test_add_release_tracer_auto_fill(start_end_times, valid_release_params):
634
+ """Test that auto fill of tracer concentrations works as expected."""
635
+ start_time, end_time = start_end_times
636
+ # Check that the tracer concentrations are auto-filled where missing
637
+ cdr = CDRVolumePointSource(start_time=start_time, end_time=end_time)
638
+ release_params = deepcopy(valid_release_params)
639
+ release_params["fill_values"] = "auto"
640
+ cdr.add_release(name="filled_release", **release_params)
641
+
642
+ defaults = get_tracer_defaults()
643
+ # temp
644
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=0) == defaults["temp"]).all()
645
+ # salt
646
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=1) == defaults["salt"]).all()
647
+ # ALK
648
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=11) == defaults["ALK"]).all()
649
+ # all other tracers should also be equal to the tracer default values, so not equal to zero
650
+ assert (cdr.ds["cdr_tracer"].isel(ntracers=slice(2, None)) > 0.0).all()
651
+
652
+
653
+ def test_add_release_invalid_fill(start_end_times, valid_release_params):
654
+ """Test that invalid fill method of tracer concentrations raises error."""
655
+ start_time, end_time = start_end_times
656
+ cdr = CDRVolumePointSource(start_time=start_time, end_time=end_time)
657
+ release_params = deepcopy(valid_release_params)
658
+ release_params["fill_values"] = "zero_fill"
659
+
660
+ with pytest.raises(ValueError, match="Invalid fill_values option"):
661
+
662
+ cdr.add_release(name="filled_release", **release_params)
663
+
664
+
665
+ def test_plot_error_when_no_grid(start_end_times, valid_release_params):
666
+ """Test that error is raised if plotting without a grid."""
667
+ start_time, end_time = start_end_times
668
+ cdr = CDRVolumePointSource(start_time=start_time, end_time=end_time)
669
+ release_params = deepcopy(valid_release_params)
670
+ cdr.add_release(name="release1", **release_params)
671
+
672
+ with pytest.raises(ValueError, match="A grid must be provided for plotting"):
673
+ cdr.plot_location_top_view("all")
674
+
675
+ with pytest.raises(ValueError, match="A grid must be provided for plotting"):
676
+ cdr.plot_location_side_view("release1")
677
+
678
+
679
+ def test_plot(cdr_point_source_with_two_releases):
680
+ """Test that plotting method run without error."""
681
+
682
+ cdr_point_source_with_two_releases.plot_volume_flux()
683
+ cdr_point_source_with_two_releases.plot_tracer_concentration("ALK")
684
+ cdr_point_source_with_two_releases.plot_tracer_concentration("DIC")
685
+
686
+ cdr_point_source_with_two_releases.plot_location_top_view()
687
+
688
+
689
+ @pytest.mark.skipif(xesmf is None, reason="xesmf required")
690
+ def test_plot_side_view(cdr_point_source_with_two_releases):
691
+ """Test that plotting method run without error."""
692
+
693
+ cdr_point_source_with_two_releases.plot_location_side_view("release1")
694
+
695
+
696
+ def test_plot_more_errors(cdr_point_source_with_two_releases):
697
+ """Test that error is raised on bad plot args or ambiguous release."""
698
+
699
+ with pytest.raises(ValueError, match="Multiple releases found"):
700
+ cdr_point_source_with_two_releases.plot_location_side_view()
701
+
702
+ with pytest.raises(ValueError, match="Invalid release"):
703
+ cdr_point_source_with_two_releases.plot_location_side_view(release="fake")
704
+
705
+ with pytest.raises(ValueError, match="Invalid releases"):
706
+ cdr_point_source_with_two_releases.plot_location_top_view(releases=["fake"])
707
+
708
+ with pytest.raises(ValueError, match="should be a string"):
709
+ cdr_point_source_with_two_releases.plot_location_top_view(releases=4)
710
+
711
+ with pytest.raises(ValueError, match="list must be strings"):
712
+ cdr_point_source_with_two_releases.plot_location_top_view(releases=[4])
713
+
714
+
715
+ def test_cdr_forcing_save(cdr_point_source_with_two_releases, tmp_path):
716
+ """Test save method."""
717
+
718
+ for file_str in ["test_cdr_forcing", "test_cdr_forcing.nc"]:
719
+ # Create a temporary filepath using the tmp_path fixture
720
+ for filepath in [tmp_path / file_str, str(tmp_path / file_str)]:
721
+
722
+ saved_filenames = cdr_point_source_with_two_releases.save(filepath)
723
+ # Check if the .nc file was created
724
+ filepath = Path(filepath).with_suffix(".nc")
725
+ assert saved_filenames == [filepath]
726
+ assert filepath.exists()
727
+ # Clean up the .nc file
728
+ filepath.unlink()
729
+
730
+
731
+ def test_roundtrip_yaml(cdr_point_source_with_two_releases, tmp_path):
732
+ """Test that creating a CDRVolumePointSource object, saving its parameters to yaml
733
+ file, and re-opening yaml file creates the same object."""
734
+
735
+ # Create a temporary filepath using the tmp_path fixture
736
+ file_str = "test_yaml"
737
+ for filepath in [
738
+ tmp_path / file_str,
739
+ str(tmp_path / file_str),
740
+ ]: # test for Path object and str
741
+
742
+ cdr_point_source_with_two_releases.to_yaml(filepath)
743
+
744
+ cdr_forcing_from_file = CDRVolumePointSource.from_yaml(filepath)
745
+
746
+ assert cdr_point_source_with_two_releases == cdr_forcing_from_file
747
+
748
+ filepath = Path(filepath)
749
+ filepath.unlink()
750
+
751
+
752
+ def test_files_have_same_hash(cdr_point_source_with_two_releases, tmp_path):
753
+ """Test that saving the same CDR forcing configuration to NetCDF twice results in
754
+ reproducible file hashes."""
755
+
756
+ yaml_filepath = tmp_path / "test_yaml.yaml"
757
+ filepath1 = tmp_path / "test1.nc"
758
+ filepath2 = tmp_path / "test2.nc"
759
+
760
+ cdr_point_source_with_two_releases.to_yaml(yaml_filepath)
761
+ cdr_point_source_with_two_releases.save(filepath1)
762
+ cdr_from_file = CDRVolumePointSource.from_yaml(yaml_filepath)
763
+ cdr_from_file.save(filepath2)
764
+
765
+ hash1 = calculate_file_hash(filepath1)
766
+ hash2 = calculate_file_hash(filepath2)
767
+
768
+ assert hash1 == hash2, f"Hashes do not match: {hash1} != {hash2}"
769
+
770
+ yaml_filepath.unlink()
771
+ filepath1.unlink()
772
+ filepath2.unlink()