roms-tools 3.4.0__py3-none-any.whl → 3.5.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 (111) hide show
  1. roms_tools/datasets/lat_lon_datasets.py +12 -0
  2. roms_tools/datasets/roms_dataset.py +140 -53
  3. roms_tools/datasets/utils.py +14 -2
  4. roms_tools/regrid.py +76 -0
  5. roms_tools/setup/boundary_forcing.py +2 -2
  6. roms_tools/setup/grid.py +17 -3
  7. roms_tools/setup/initial_conditions.py +314 -55
  8. roms_tools/setup/mask.py +2 -5
  9. roms_tools/setup/nesting.py +6 -3
  10. roms_tools/setup/surface_forcing.py +1 -2
  11. roms_tools/setup/tides.py +6 -5
  12. roms_tools/setup/utils.py +220 -142
  13. roms_tools/tests/test_datasets/test_roms_dataset.py +225 -21
  14. roms_tools/tests/test_regrid.py +120 -1
  15. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ALK/c/0/0/0/0 +0 -0
  16. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ALK/zarr.json +57 -0
  17. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ALK_ALT_CO2/c/0/0/0/0 +0 -0
  18. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ALK_ALT_CO2/zarr.json +57 -0
  19. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Cs_r/c/0 +0 -0
  20. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Cs_r/zarr.json +47 -0
  21. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Cs_w/c/0 +0 -0
  22. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Cs_w/zarr.json +47 -0
  23. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DIC/c/0/0/0/0 +0 -0
  24. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DIC/zarr.json +57 -0
  25. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DIC_ALT_CO2/c/0/0/0/0 +0 -0
  26. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DIC_ALT_CO2/zarr.json +57 -0
  27. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOC/c/0/0/0/0 +0 -0
  28. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOC/zarr.json +57 -0
  29. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOCr/c/0/0/0/0 +0 -0
  30. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOCr/zarr.json +57 -0
  31. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DON/c/0/0/0/0 +0 -0
  32. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DON/zarr.json +57 -0
  33. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DONr/c/0/0/0/0 +0 -0
  34. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DONr/zarr.json +57 -0
  35. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOP/c/0/0/0/0 +0 -0
  36. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOP/zarr.json +57 -0
  37. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOPr/c/0/0/0/0 +0 -0
  38. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/DOPr/zarr.json +57 -0
  39. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Fe/c/0/0/0/0 +0 -0
  40. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Fe/zarr.json +57 -0
  41. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Lig/c/0/0/0/0 +0 -0
  42. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/Lig/zarr.json +57 -0
  43. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/NH4/c/0/0/0/0 +0 -0
  44. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/NH4/zarr.json +57 -0
  45. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/NO3/c/0/0/0/0 +0 -0
  46. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/NO3/zarr.json +57 -0
  47. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/O2/c/0/0/0/0 +0 -0
  48. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/O2/zarr.json +57 -0
  49. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/PO4/c/0/0/0/0 +0 -0
  50. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/PO4/zarr.json +57 -0
  51. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/SiO3/c/0/0/0/0 +0 -0
  52. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/SiO3/zarr.json +57 -0
  53. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/abs_time/zarr.json +47 -0
  54. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatC/c/0/0/0/0 +0 -0
  55. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatC/zarr.json +57 -0
  56. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatChl/c/0/0/0/0 +0 -0
  57. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatChl/zarr.json +57 -0
  58. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatFe/c/0/0/0/0 +0 -0
  59. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatFe/zarr.json +57 -0
  60. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatP/c/0/0/0/0 +0 -0
  61. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatP/zarr.json +57 -0
  62. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatSi/c/0/0/0/0 +0 -0
  63. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diatSi/zarr.json +57 -0
  64. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazC/c/0/0/0/0 +0 -0
  65. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazC/zarr.json +57 -0
  66. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazChl/c/0/0/0/0 +0 -0
  67. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazChl/zarr.json +57 -0
  68. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazFe/c/0/0/0/0 +0 -0
  69. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazFe/zarr.json +57 -0
  70. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazP/c/0/0/0/0 +0 -0
  71. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/diazP/zarr.json +57 -0
  72. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ocean_time/c/0 +0 -0
  73. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ocean_time/zarr.json +47 -0
  74. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/salt/c/0/0/0/0 +0 -0
  75. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/salt/zarr.json +57 -0
  76. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spC/c/0/0/0/0 +0 -0
  77. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spC/zarr.json +57 -0
  78. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spCaCO3/c/0/0/0/0 +0 -0
  79. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spCaCO3/zarr.json +57 -0
  80. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spChl/c/0/0/0/0 +0 -0
  81. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spChl/zarr.json +57 -0
  82. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spFe/c/0/0/0/0 +0 -0
  83. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spFe/zarr.json +57 -0
  84. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spP/c/0/0/0/0 +0 -0
  85. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/spP/zarr.json +57 -0
  86. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/temp/c/0/0/0/0 +0 -0
  87. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/temp/zarr.json +57 -0
  88. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/u/c/0/0/0/0 +0 -0
  89. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/u/zarr.json +57 -0
  90. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ubar/c/0/0/0 +0 -0
  91. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/ubar/zarr.json +54 -0
  92. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/v/c/0/0/0/0 +0 -0
  93. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/v/zarr.json +57 -0
  94. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/vbar/c/0/0/0 +0 -0
  95. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/vbar/zarr.json +54 -0
  96. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/w/zarr.json +57 -0
  97. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/zarr.json +2481 -0
  98. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/zeta/c/0/0/0 +0 -0
  99. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/zeta/zarr.json +54 -0
  100. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/zooC/c/0/0/0/0 +0 -0
  101. roms_tools/tests/test_setup/test_data/initial_conditions_from_roms.zarr/zooC/zarr.json +57 -0
  102. roms_tools/tests/test_setup/test_grid.py +24 -0
  103. roms_tools/tests/test_setup/test_initial_conditions.py +128 -11
  104. roms_tools/tests/test_setup/test_validation.py +15 -0
  105. roms_tools/tests/test_utils.py +287 -0
  106. roms_tools/utils.py +177 -72
  107. {roms_tools-3.4.0.dist-info → roms_tools-3.5.0.dist-info}/METADATA +2 -3
  108. {roms_tools-3.4.0.dist-info → roms_tools-3.5.0.dist-info}/RECORD +111 -24
  109. {roms_tools-3.4.0.dist-info → roms_tools-3.5.0.dist-info}/WHEEL +1 -1
  110. {roms_tools-3.4.0.dist-info → roms_tools-3.5.0.dist-info}/licenses/LICENSE +0 -0
  111. {roms_tools-3.4.0.dist-info → roms_tools-3.5.0.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,7 @@ import xarray as xr
10
10
  from roms_tools.datasets.download import download_test_data
11
11
  from roms_tools.datasets.lat_lon_datasets import ERA5Correction
12
12
  from roms_tools.utils import (
13
+ _interpolate_generic,
13
14
  _path_list_from_input,
14
15
  generate_focused_coordinate_range,
15
16
  get_dask_chunks,
@@ -18,7 +19,13 @@ from roms_tools.utils import (
18
19
  has_gcsfs,
19
20
  interpolate_cyclic_time,
20
21
  interpolate_from_climatology,
22
+ interpolate_from_rho_to_u,
23
+ interpolate_from_rho_to_v,
24
+ interpolate_from_u_to_rho,
25
+ interpolate_from_v_to_rho,
21
26
  load_data,
27
+ rotate_velocities,
28
+ wrap_longitudes,
22
29
  )
23
30
 
24
31
 
@@ -319,3 +326,283 @@ def test_interpolate_from_real_climatology(use_dask):
319
326
 
320
327
  interpolated_field = interpolate_from_climatology(field, "time", "time", era5_times)
321
328
  assert len(interpolated_field.time) == len(era5_times)
329
+
330
+
331
+ def test_wrap_longitudes_staggered():
332
+ # Dimensions
333
+ eta_rho, xi_rho, xi_u = 3, 4, 5
334
+ eta_v = 2
335
+
336
+ # Create 2D coordinates
337
+ lon_rho = xr.DataArray(
338
+ np.linspace(0, 360, eta_rho * xi_rho).reshape(eta_rho, xi_rho),
339
+ dims=("eta_rho", "xi_rho"),
340
+ attrs={"units": "degrees_east"},
341
+ )
342
+ lon_u = xr.DataArray(
343
+ np.linspace(-190, 190, eta_rho * xi_u).reshape(eta_rho, xi_u),
344
+ dims=("eta_rho", "xi_u"),
345
+ attrs={"units": "degrees_east"},
346
+ )
347
+ lon_v = xr.DataArray(
348
+ np.linspace(-180, 180, eta_v * xi_rho).reshape(eta_v, xi_rho),
349
+ dims=("eta_v", "xi_rho"),
350
+ attrs={"units": "degrees_east"},
351
+ )
352
+
353
+ # Dummy variables
354
+ ds = xr.Dataset(
355
+ {
356
+ "dummy_rho": (("eta_rho", "xi_rho"), np.zeros((eta_rho, xi_rho))),
357
+ "dummy_u": (("eta_rho", "xi_u"), np.zeros((eta_rho, xi_u))),
358
+ "dummy_v": (("eta_v", "xi_rho"), np.zeros((eta_v, xi_rho))),
359
+ },
360
+ coords={"lon_rho": lon_rho, "lon_u": lon_u, "lon_v": lon_v},
361
+ )
362
+
363
+ # Wrap to [-180, 180]
364
+ ds_wrapped = wrap_longitudes(ds, straddle=True)
365
+
366
+ # Check values: all >180 should be shifted
367
+ assert ds_wrapped.lon_rho.max().values <= 180
368
+ assert ds_wrapped.lon_u.max().values <= 180
369
+ assert ds_wrapped.lon_v.max().values <= 180
370
+
371
+ # Wrap to [0, 360]
372
+ ds_wrapped2 = wrap_longitudes(ds, straddle=False)
373
+ assert ds_wrapped2.lon_rho.min().values >= 0
374
+ assert ds_wrapped2.lon_u.min().values >= 0
375
+ assert ds_wrapped2.lon_v.min().values >= 0
376
+
377
+ # Check attributes preserved
378
+ for name in ["lon_rho", "lon_u", "lon_v"]:
379
+ assert ds.coords[name].attrs == ds_wrapped.coords[name].attrs
380
+
381
+
382
+ # test _interpolate_generic and its wrappers
383
+
384
+ # -------------------------
385
+ # Fixtures
386
+ # -------------------------
387
+
388
+
389
+ @pytest.fixture
390
+ def sample_rho_field() -> xr.DataArray:
391
+ """Create a simple rho-point field for testing."""
392
+ data = np.arange(12, dtype=float).reshape(3, 4)
393
+ eta = np.arange(3)
394
+ xi = np.arange(4)
395
+
396
+ return xr.DataArray(
397
+ data,
398
+ dims=("eta_rho", "xi_rho"),
399
+ coords={
400
+ "lat_rho": (("eta_rho", "xi_rho"), eta[:, None] * np.ones((1, 4))),
401
+ "lon_rho": (("eta_rho", "xi_rho"), np.ones((3, 1)) * xi[None, :]),
402
+ },
403
+ )
404
+
405
+
406
+ @pytest.fixture
407
+ def sample_u_field() -> xr.DataArray:
408
+ """Create a simple u-point field for testing."""
409
+ data = np.arange(9, dtype=float).reshape(3, 3)
410
+ eta = np.arange(3)
411
+ xi = np.arange(3)
412
+
413
+ return xr.DataArray(
414
+ data,
415
+ dims=("eta_rho", "xi_u"),
416
+ coords={
417
+ "lat_u": (("eta_rho", "xi_u"), eta[:, None] * np.ones((1, 3))),
418
+ "lon_u": (("eta_rho", "xi_u"), np.ones((3, 1)) * xi[None, :]),
419
+ },
420
+ )
421
+
422
+
423
+ @pytest.fixture
424
+ def sample_v_field() -> xr.DataArray:
425
+ """Create a simple v-point field for testing."""
426
+ data = np.arange(8, dtype=float).reshape(2, 4)
427
+ eta = np.arange(2)
428
+ xi = np.arange(4)
429
+
430
+ return xr.DataArray(
431
+ data,
432
+ dims=("eta_v", "xi_rho"),
433
+ coords={
434
+ "lat_v": (("eta_v", "xi_rho"), eta[:, None] * np.ones((1, 4))),
435
+ "lon_v": (("eta_v", "xi_rho"), np.ones((2, 1)) * xi[None, :]),
436
+ },
437
+ )
438
+
439
+
440
+ # -------------------------
441
+ # Generic interpolation tests
442
+ # -------------------------
443
+
444
+
445
+ def test_interpolate_from_rho_to_u_additive(sample_rho_field: xr.DataArray):
446
+ result = _interpolate_generic(
447
+ sample_rho_field, dim_in="xi_rho", dim_out="xi_u", method="additive"
448
+ )
449
+
450
+ # One fewer point along xi
451
+ assert result.shape[1] == sample_rho_field.shape[1] - 1
452
+
453
+ expected = 0.5 * (sample_rho_field.values[:, 1:] + sample_rho_field.values[:, :-1])
454
+ np.testing.assert_allclose(result.values, expected)
455
+
456
+
457
+ def test_interpolate_from_rho_to_u_multiplicative(sample_rho_field: xr.DataArray):
458
+ result = _interpolate_generic(
459
+ sample_rho_field, dim_in="xi_rho", dim_out="xi_u", method="multiplicative"
460
+ )
461
+
462
+ expected = sample_rho_field.values[:, 1:] * sample_rho_field.values[:, :-1]
463
+ np.testing.assert_allclose(result.values, expected)
464
+
465
+
466
+ # -------------------------
467
+ # Wrapper tests
468
+ # -------------------------
469
+
470
+
471
+ def test_rho_to_u_wrapper_additive(sample_rho_field: xr.DataArray):
472
+ result = interpolate_from_rho_to_u(sample_rho_field, method="additive")
473
+
474
+ # Dimension swap
475
+ assert "xi_u" in result.dims
476
+ assert "xi_rho" not in result.dims
477
+
478
+ # Coordinates dropped
479
+ for coord in ("lat_rho", "lon_rho"):
480
+ assert coord not in result.coords
481
+
482
+ # Shape check
483
+ assert result.sizes["xi_u"] == sample_rho_field.sizes["xi_rho"] - 1
484
+
485
+
486
+ def test_rho_to_v_wrapper_additive(sample_rho_field: xr.DataArray):
487
+ result = interpolate_from_rho_to_v(sample_rho_field, method="additive")
488
+
489
+ # Dimension swap
490
+ assert "eta_v" in result.dims
491
+ assert "eta_rho" not in result.dims
492
+
493
+ # Coordinates dropped
494
+ for coord in ("lat_rho", "lon_rho"):
495
+ assert coord not in result.coords
496
+
497
+ # Shape check
498
+ assert result.sizes["eta_v"] == sample_rho_field.sizes["eta_rho"] - 1
499
+
500
+
501
+ def test_u_to_rho_wrapper_additive(sample_u_field: xr.DataArray):
502
+ result = interpolate_from_u_to_rho(sample_u_field, method="additive")
503
+
504
+ # Dimension swap
505
+ assert "xi_rho" in result.dims
506
+ assert "xi_u" not in result.dims
507
+
508
+ # Coordinates dropped
509
+ for coord in ("lat_u", "lon_u"):
510
+ assert coord not in result.coords
511
+
512
+ # Shape: one more along xi due to padding
513
+ assert result.sizes["xi_rho"] == sample_u_field.sizes["xi_u"] + 1
514
+
515
+
516
+ def test_v_to_rho_wrapper_additive(sample_v_field: xr.DataArray):
517
+ result = interpolate_from_v_to_rho(sample_v_field, method="additive")
518
+
519
+ # Dimension swap
520
+ assert "eta_rho" in result.dims
521
+ assert "eta_v" not in result.dims
522
+
523
+ # Coordinates dropped
524
+ for coord in ("lat_v", "lon_v"):
525
+ assert coord not in result.coords
526
+
527
+ # Shape: one more along eta due to padding
528
+ assert result.sizes["eta_rho"] == sample_v_field.sizes["eta_v"] + 1
529
+
530
+
531
+ # -------------------------
532
+ # Error handling
533
+ # -------------------------
534
+
535
+
536
+ def test_invalid_method_raises(
537
+ sample_rho_field: xr.DataArray,
538
+ sample_u_field: xr.DataArray,
539
+ sample_v_field: xr.DataArray,
540
+ ):
541
+ with pytest.raises(NotImplementedError):
542
+ interpolate_from_rho_to_u(sample_rho_field, method="unsupported")
543
+
544
+ with pytest.raises(NotImplementedError):
545
+ interpolate_from_rho_to_v(sample_rho_field, method="unsupported")
546
+
547
+ with pytest.raises(NotImplementedError):
548
+ interpolate_from_u_to_rho(sample_u_field, method="unsupported")
549
+
550
+ with pytest.raises(NotImplementedError):
551
+ interpolate_from_v_to_rho(sample_v_field, method="unsupported")
552
+
553
+
554
+ # Test rotate_velocities
555
+ @pytest.fixture
556
+ def sample_velocities_centered():
557
+ """Create a centered-grid velocity field with random values and grid angle."""
558
+ np.random.seed(42) # For reproducibility
559
+
560
+ eta_rho, xi_rho = 10, 15
561
+
562
+ u = xr.DataArray(
563
+ np.random.rand(eta_rho, xi_rho),
564
+ dims=("eta_rho", "xi_rho"),
565
+ coords={
566
+ "eta_rho": np.arange(eta_rho),
567
+ "xi_rho": np.arange(xi_rho),
568
+ },
569
+ )
570
+
571
+ v = xr.DataArray(
572
+ np.random.rand(eta_rho, xi_rho),
573
+ dims=("eta_rho", "xi_rho"),
574
+ coords={
575
+ "eta_rho": np.arange(eta_rho),
576
+ "xi_rho": np.arange(xi_rho),
577
+ },
578
+ )
579
+
580
+ angle = xr.DataArray(
581
+ np.random.rand(eta_rho, xi_rho) * np.pi / 2
582
+ - np.pi / 4, # random angles in [-45°, 45°]
583
+ dims=("eta_rho", "xi_rho"),
584
+ coords={
585
+ "eta_rho": np.arange(eta_rho),
586
+ "xi_rho": np.arange(xi_rho),
587
+ },
588
+ )
589
+
590
+ return u, v, angle
591
+
592
+
593
+ def test_rotate_velocities_roundtrip(sample_velocities_centered):
594
+ """Test rotation to grid and back recovers original velocities."""
595
+ u, v, angle = sample_velocities_centered
596
+
597
+ # Rotate forward: lat-lon → model grid
598
+ u_rot, v_rot = rotate_velocities(
599
+ u, v, angle, interpolate_before=False, interpolate_after=False
600
+ )
601
+
602
+ # Rotate backward: model grid → lat-lon
603
+ u_back, v_back = rotate_velocities(
604
+ u_rot, v_rot, -angle, interpolate_before=False, interpolate_after=False
605
+ )
606
+
607
+ np.testing.assert_allclose(u.values, u_back.values)
608
+ np.testing.assert_allclose(v.values, v_back.values)
roms_tools/utils.py CHANGED
@@ -477,96 +477,199 @@ def load_data(
477
477
  return ds
478
478
 
479
479
 
480
- def interpolate_from_rho_to_u(field, method="additive"):
481
- """Interpolates the given field from rho points to u points.
482
-
483
- This function performs an interpolation from the rho grid (cell centers) to the u grid
484
- (cell edges in the xi direction). Depending on the chosen method, it either averages
485
- (additive) or multiplies (multiplicative) the field values between adjacent rho points
486
- along the xi dimension. It also handles the removal of unnecessary coordinate variables
487
- and updates the dimensions accordingly.
480
+ def _interpolate_generic(
481
+ field: xr.DataArray,
482
+ dim_in: str,
483
+ dim_out: str,
484
+ method: str = "additive",
485
+ drop_coords: Sequence[str] | None = None,
486
+ pad_end: bool = False,
487
+ ) -> xr.DataArray:
488
+ """
489
+ Generic interpolation along one horizontal dimension.
488
490
 
489
491
  Parameters
490
492
  ----------
491
493
  field : xr.DataArray
492
- The input data array on the rho grid to be interpolated. It is assumed to have a dimension
493
- named "xi_rho".
494
-
495
- method : str, optional, default='additive'
496
- The method to use for interpolation. Options are:
497
- - 'additive': Average the field values between adjacent rho points.
498
- - 'multiplicative': Multiply the field values between adjacent rho points. Appropriate for
499
- binary masks.
494
+ Input array to interpolate.
495
+ dim_in : str
496
+ Dimension along which to interpolate (e.g., "xi_rho").
497
+ dim_out : str
498
+ New dimension name after interpolation (e.g., "xi_u").
499
+ method : str, default "additive"
500
+ Interpolation method:
501
+ - "additive": average adjacent points
502
+ - "multiplicative": multiply adjacent points (useful for masks)
503
+ drop_coords : Sequence[str] or None, optional
504
+ Coordinate variables to drop (e.g., ["lat_rho", "lon_rho"]).
505
+ pad_end : bool, default False
506
+ Whether to pad the last point with NaN (useful when interpolating back to rho grid).
500
507
 
501
508
  Returns
502
509
  -------
503
- field_interpolated : xr.DataArray
504
- The interpolated data array on the u grid with the dimension "xi_u".
510
+ xr.DataArray
511
+ Interpolated array with dimension `dim_out`.
505
512
  """
506
- if method == "additive":
507
- field_interpolated = 0.5 * (field + field.shift(xi_rho=1)).isel(
508
- xi_rho=slice(1, None)
513
+ if not isinstance(field, xr.DataArray):
514
+ raise TypeError(
515
+ "_interpolate_generic expects an xarray.DataArray, "
516
+ f"got {type(field).__name__}"
509
517
  )
518
+
519
+ if drop_coords:
520
+ for coord in drop_coords:
521
+ if coord in field.coords:
522
+ field = field.drop_vars(coord)
523
+
524
+ if method == "additive":
525
+ interp = 0.5 * (field + field.shift(**{dim_in: 1}))
510
526
  elif method == "multiplicative":
511
- field_interpolated = (field * field.shift(xi_rho=1)).isel(xi_rho=slice(1, None))
527
+ interp = field * field.shift(**{dim_in: 1})
512
528
  else:
513
529
  raise NotImplementedError(f"Unsupported method '{method}' specified.")
514
530
 
515
- vars_to_drop = ["lat_rho", "lon_rho", "eta_rho", "xi_rho"]
516
- for var in vars_to_drop:
517
- if var in field_interpolated.coords:
518
- field_interpolated = field_interpolated.drop_vars(var)
531
+ if pad_end:
532
+ pad_shape = {d: field.sizes[d] for d in field.dims}
533
+ pad_shape[dim_in] = 1
534
+ pad = xr.DataArray(
535
+ np.nan * np.ones(tuple(pad_shape[d] for d in field.dims)),
536
+ dims=field.dims,
537
+ )
538
+ interp = xr.concat([interp, pad], dim=dim_in)
539
+ else:
540
+ interp = interp.isel({dim_in: slice(1, None)})
541
+
542
+ interp = interp.swap_dims({dim_in: dim_out})
543
+
544
+ return interp
545
+
546
+
547
+ def interpolate_from_rho_to_u(
548
+ field: xr.DataArray, method: str = "additive"
549
+ ) -> xr.DataArray:
550
+ """Interpolate a field from rho points to u points (xi direction)."""
551
+ return _interpolate_generic(
552
+ field,
553
+ dim_in="xi_rho",
554
+ dim_out="xi_u",
555
+ method=method,
556
+ drop_coords=["lat_rho", "lon_rho", "eta_rho", "xi_rho"],
557
+ )
558
+
559
+
560
+ def interpolate_from_rho_to_v(
561
+ field: xr.DataArray, method: str = "additive"
562
+ ) -> xr.DataArray:
563
+ """Interpolate a field from rho points to v points (eta direction)."""
564
+ return _interpolate_generic(
565
+ field,
566
+ dim_in="eta_rho",
567
+ dim_out="eta_v",
568
+ method=method,
569
+ drop_coords=["lat_rho", "lon_rho", "eta_rho", "xi_rho"],
570
+ )
571
+
519
572
 
520
- field_interpolated = field_interpolated.swap_dims({"xi_rho": "xi_u"})
573
+ def interpolate_from_u_to_rho(
574
+ field: xr.DataArray, method: str = "additive"
575
+ ) -> xr.DataArray:
576
+ """Interpolate a field from u points back to rho points (xi direction)."""
577
+ return _interpolate_generic(
578
+ field,
579
+ dim_in="xi_u",
580
+ dim_out="xi_rho",
581
+ method=method,
582
+ drop_coords=["lat_u", "lon_u", "eta_rho", "xi_u"],
583
+ pad_end=True,
584
+ )
521
585
 
522
- return field_interpolated
523
586
 
587
+ def interpolate_from_v_to_rho(
588
+ field: xr.DataArray, method: str = "additive"
589
+ ) -> xr.DataArray:
590
+ """Interpolate a field from v points back to rho points (eta direction)."""
591
+ return _interpolate_generic(
592
+ field,
593
+ dim_in="eta_v",
594
+ dim_out="eta_rho",
595
+ method=method,
596
+ drop_coords=["lat_v", "lon_v", "eta_v", "xi_rho"],
597
+ pad_end=True,
598
+ )
524
599
 
525
- def interpolate_from_rho_to_v(field, method="additive"):
526
- """Interpolates the given field from rho points to v points.
527
600
 
528
- This function performs an interpolation from the rho grid (cell centers) to the v grid
529
- (cell edges in the eta direction). Depending on the chosen method, it either averages
530
- (additive) or multiplies (multiplicative) the field values between adjacent rho points
531
- along the eta dimension. It also handles the removal of unnecessary coordinate variables
532
- and updates the dimensions accordingly.
601
+ def rotate_velocities(
602
+ u: xr.DataArray,
603
+ v: xr.DataArray,
604
+ angle: xr.DataArray,
605
+ interpolate_before: bool = False,
606
+ interpolate_after: bool = False,
607
+ ) -> tuple[xr.DataArray, xr.DataArray]:
608
+ """
609
+ Rotate horizontal velocity components to align with a rotated grid.
610
+
611
+ This function rotates zonal (u) and meridional (v) velocity components
612
+ using a grid angle field. It can be used to:
613
+
614
+ 1. Rotate model velocities from the ROMS model grid to a lat-lon reference frame.
615
+ 2. Rotate lat-lon velocities onto the ROMS model grid.
616
+
617
+ Optionally, velocities can be interpolated between staggered C-grid
618
+ locations (u-, v-, and rho-points) before and/or after rotation.
533
619
 
534
620
  Parameters
535
621
  ----------
536
- field : xr.DataArray
537
- The input data array on the rho grid to be interpolated. It is assumed to have a dimension
538
- named "eta_rho".
539
-
540
- method : str, optional, default='additive'
541
- The method to use for interpolation. Options are:
542
- - 'additive': Average the field values between adjacent rho points.
543
- - 'multiplicative': Multiply the field values between adjacent rho points. Appropriate for
544
- binary masks.
622
+ u : xarray.DataArray
623
+ Zonal (east-west) velocity component defined on u-points.
624
+ v : xarray.DataArray
625
+ Meridional (north-south) velocity component defined on v-points.
626
+ angle : xarray.DataArray
627
+ Grid orientation angle in radians, defined at rho-points. This is the
628
+ ROMS grid angle: the angle between the model xi-direction and true east.
629
+ Positive values indicate that the model grid is rotated counterclockwise
630
+ relative to east (which is mathematically equivalent to rotating velocity
631
+ vectors clockwise).
632
+ The rotation transforms velocity components between earth-relative
633
+ (east/north) and grid-relative (xi/eta) coordinates. To reverse the
634
+ transformation (e.g., model → lat-lon), provide ``-angle``.
635
+ interpolate_before : bool, optional
636
+ If True, interpolate ``u`` and ``v`` to rho-points before rotation.
637
+ Default is False.
638
+ interpolate_after : bool, optional
639
+ If True, interpolate the rotated velocities back to u- and v-points
640
+ after rotation. Default is True.
545
641
 
546
642
  Returns
547
643
  -------
548
- field_interpolated : xr.DataArray
549
- The interpolated data array on the v grid with the dimension "eta_v".
644
+ u_rot, v_rot : tuple of xarray.DataArray
645
+ Rotated velocity components. If ``interpolate_after`` is True, ``u_rot``
646
+ is defined on u-points and ``v_rot`` on v-points; otherwise, both are
647
+ defined at rho-points.
648
+
649
+ Notes
650
+ -----
651
+ The rotation follows the standard ROMS convention:
652
+ - ``u_rot = u * cos(angle) + v * sin(angle)``
653
+ - ``v_rot = v * cos(angle) - u * sin(angle)``
654
+ This function is versatile and can be used for both directions of rotation:
655
+ - Lat-lon → model grid: provide the grid angle.
656
+ - Model grid → lat-lon: provide the negative of the grid angle.
550
657
  """
551
- if method == "additive":
552
- field_interpolated = 0.5 * (field + field.shift(eta_rho=1)).isel(
553
- eta_rho=slice(1, None)
554
- )
555
- elif method == "multiplicative":
556
- field_interpolated = (field * field.shift(eta_rho=1)).isel(
557
- eta_rho=slice(1, None)
558
- )
559
- else:
560
- raise NotImplementedError(f"Unsupported method '{method}' specified.")
658
+ # Interpolate to rho-points
659
+ if interpolate_before:
660
+ u = interpolate_from_u_to_rho(u)
661
+ v = interpolate_from_v_to_rho(v)
561
662
 
562
- vars_to_drop = ["lat_rho", "lon_rho", "eta_rho", "xi_rho"]
563
- for var in vars_to_drop:
564
- if var in field_interpolated.coords:
565
- field_interpolated = field_interpolated.drop_vars(var)
663
+ # Rotate velocities to grid orientation
664
+ u_rot = u * np.cos(angle) + v * np.sin(angle)
665
+ v_rot = v * np.cos(angle) - u * np.sin(angle)
566
666
 
567
- field_interpolated = field_interpolated.swap_dims({"eta_rho": "eta_v"})
667
+ # Interpolate to u- and v-points
668
+ if interpolate_after:
669
+ u_rot = interpolate_from_rho_to_u(u_rot)
670
+ v_rot = interpolate_from_rho_to_v(v_rot)
568
671
 
569
- return field_interpolated
672
+ return u_rot, v_rot
570
673
 
571
674
 
572
675
  def transpose_dimensions(da: xr.DataArray) -> xr.DataArray:
@@ -1036,13 +1139,13 @@ def get_pkg_error_msg(purpose: str, package_name: str, option_name: str) -> str:
1036
1139
 
1037
1140
  def wrap_longitudes(ds: xr.Dataset, straddle: bool) -> xr.Dataset:
1038
1141
  """
1039
- Safely adjust longitude variables for datasets that may or may not cross
1040
- the dateline. Only modifies longitude-like variables that are present.
1142
+ Safely adjust longitude coordinates for datasets that may or may not cross
1143
+ the dateline. Only modifies longitude-like coordinates that are present.
1041
1144
 
1042
1145
  Parameters
1043
1146
  ----------
1044
1147
  ds : xr.Dataset
1045
- Dataset containing longitude variables (e.g., lon_rho, lon_u, lon_v).
1148
+ Dataset containing longitude coordinates (e.g., lon_rho, lon_u, lon_v).
1046
1149
  straddle : bool
1047
1150
  - True: force longitudes into [-180, 180]
1048
1151
  - False: force longitudes into [0, 360]
@@ -1050,15 +1153,15 @@ def wrap_longitudes(ds: xr.Dataset, straddle: bool) -> xr.Dataset:
1050
1153
  Returns
1051
1154
  -------
1052
1155
  xr.Dataset
1053
- A new dataset with adjusted longitude values.
1156
+ A new dataset with adjusted longitude coordinates.
1054
1157
  """
1055
- lon_vars = ["lon_rho", "lon_u", "lon_v"]
1158
+ lon_coords = ["lon_rho", "lon_u", "lon_v"]
1056
1159
 
1057
- for lon_dim in lon_vars:
1058
- if lon_dim not in ds:
1160
+ for lon_name in lon_coords:
1161
+ if lon_name not in ds.coords:
1059
1162
  continue # skip missing coordinate
1060
1163
 
1061
- lon = ds[lon_dim]
1164
+ lon = ds.coords[lon_name]
1062
1165
 
1063
1166
  if straddle:
1064
1167
  # wrap into [-180, 180]
@@ -1068,8 +1171,10 @@ def wrap_longitudes(ds: xr.Dataset, straddle: bool) -> xr.Dataset:
1068
1171
  lon_wrapped = xr.where(lon < 0, lon + 360, lon)
1069
1172
 
1070
1173
  # preserve attributes
1071
- lon_wrapped.attrs.update(lon.attrs)
1072
- ds[lon_dim] = lon_wrapped
1174
+ lon_wrapped.attrs = lon.attrs.copy()
1175
+
1176
+ # reassign explicitly as a coordinate
1177
+ ds = ds.assign_coords({lon_name: lon_wrapped})
1073
1178
 
1074
1179
  return ds
1075
1180
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roms-tools
3
- Version: 3.4.0
3
+ Version: 3.5.0
4
4
  Summary: Tools for running and analysing UCLA-ROMS simulations
5
5
  Author-email: Nora Loose <nora.loose@gmail.com>, Thomas Nicholas <tom@cworthy.org>, Scott Eilerman <scott.eilerman@cworthy.org>
6
6
  License: Apache-2
@@ -64,7 +64,7 @@ Dynamic: license-file
64
64
 
65
65
  ## Overview
66
66
 
67
- A suite of Python tools for setting up and analyzing a [UCLA-ROMS](https://github.com/CESR-lab/ucla-roms) simulation with or without [MARBL biogeochemistry](https://marbl-ecosys.github.io/versions/latest_release/index.html).
67
+ A suite of Python tools for setting up and analyzing a [UCLA-ROMS](https://github.com/CWorthy-ocean/ucla-roms) simulation with or without [MARBL biogeochemistry](https://marbl-ecosys.github.io/versions/latest_release/index.html).
68
68
 
69
69
  ## Installation
70
70
 
@@ -152,5 +152,4 @@ We also accept contributions in the form of Pull Requests.
152
152
 
153
153
  ## See also
154
154
 
155
- - [ROMS source code](https://github.com/CESR-lab/ucla-roms)
156
155
  - [C-Star](https://github.com/CWorthy-ocean/C-Star)