wavespectra 4.7.0__tar.gz → 4.9.0__tar.gz

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 (76) hide show
  1. {wavespectra-4.7.0 → wavespectra-4.9.0}/AUTHORS.rst +4 -0
  2. {wavespectra-4.7.0/wavespectra.egg-info → wavespectra-4.9.0}/PKG-INFO +3 -1
  3. {wavespectra-4.7.0 → wavespectra-4.9.0}/pyproject.toml +7 -16
  4. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_partition.py +308 -1
  5. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/__init__.py +1 -1
  6. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/npstats.py +50 -5
  7. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/partition.py +304 -14
  8. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/specarray.py +8 -3
  9. {wavespectra-4.7.0 → wavespectra-4.9.0/wavespectra.egg-info}/PKG-INFO +3 -1
  10. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra.egg-info/requires.txt +1 -0
  11. {wavespectra-4.7.0 → wavespectra-4.9.0}/LICENSE.txt +0 -0
  12. {wavespectra-4.7.0 → wavespectra-4.9.0}/README.rst +0 -0
  13. {wavespectra-4.7.0 → wavespectra-4.9.0}/setup.cfg +0 -0
  14. {wavespectra-4.7.0 → wavespectra-4.9.0}/setup.py +0 -0
  15. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_cli.py +0 -0
  16. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_combine_hp01.py +0 -0
  17. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_cookiecutter_template.py +0 -0
  18. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_reader_registry.py +0 -0
  19. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_specarray.py +0 -0
  20. {wavespectra-4.7.0 → wavespectra-4.9.0}/tests/test_specdataset.py +0 -0
  21. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/cli.py +0 -0
  22. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/construct/__init__.py +0 -0
  23. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/construct/direction.py +0 -0
  24. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/construct/frequency.py +0 -0
  25. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/__init__.py +0 -0
  26. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/attributes.py +0 -0
  27. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/attributes.yml +0 -0
  28. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/fitting.py +0 -0
  29. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/options.py +0 -0
  30. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/select.py +0 -0
  31. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/swan.py +0 -0
  32. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/utils.py +0 -0
  33. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/core/xrstats.py +0 -0
  34. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/__init__.py +0 -0
  35. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/awac.py +0 -0
  36. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/dataset.py +0 -0
  37. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/datawell.py +0 -0
  38. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/era5.py +0 -0
  39. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/funwave.py +0 -0
  40. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/funwave_new.py +0 -0
  41. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/json.py +0 -0
  42. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/ncswan.py +0 -0
  43. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/ndbc.py +0 -0
  44. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/ndbc_ascii.py +0 -0
  45. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/netcdf.py +0 -0
  46. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/obscape.py +0 -0
  47. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/octopus.py +0 -0
  48. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/spotter.py +0 -0
  49. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/swan.py +0 -0
  50. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/triaxys.py +0 -0
  51. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/wavespectra.py +0 -0
  52. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/ww3.py +0 -0
  53. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/ww3_station.py +0 -0
  54. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/wwm.py +0 -0
  55. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/input/xwaves.py +0 -0
  56. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/__init__.py +0 -0
  57. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/funwave.py +0 -0
  58. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/json.py +0 -0
  59. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/netcdf.py +0 -0
  60. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/octopus.py +0 -0
  61. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/orcaflex.py +0 -0
  62. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/swan.py +0 -0
  63. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/ww3.py +0 -0
  64. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/output/ww3.yml +0 -0
  65. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/__init__.py +0 -0
  66. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/hanson_and_phillips_2001.py +0 -0
  67. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/specpart/specpart.c +0 -0
  68. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/specpart/specpart.h +0 -0
  69. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/specpart/specpart_wrap.c +0 -0
  70. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/partition/tracking.py +0 -0
  71. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/plot.py +0 -0
  72. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra/specdataset.py +0 -0
  73. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra.egg-info/SOURCES.txt +0 -0
  74. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra.egg-info/dependency_links.txt +0 -0
  75. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra.egg-info/entry_points.txt +0 -0
  76. {wavespectra-4.7.0 → wavespectra-4.9.0}/wavespectra.egg-info/top_level.txt +0 -0
@@ -26,9 +26,11 @@ Contributors
26
26
 
27
27
  * `Matthew Iannucci`_
28
28
  * `Carlos A. Michelén Ströfer`_
29
+ * `eddylr`_
29
30
  * `Henrique Rapizo`_
30
31
  * `John Harrington`_
31
32
  * `Jorge Perez`_
33
+ * `Joris de Vroom`_
32
34
  * `Paul Branson`_
33
35
  * `Ruben de Bruin`_
34
36
  * `Ryan Coe`_
@@ -36,9 +38,11 @@ Contributors
36
38
 
37
39
  .. _`Matthew Iannucci`: https://github.com/mpiannucci
38
40
  .. _`Carlos A. Michelén Ströfer`: https://github.com/cmichelenstrofer
41
+ .. _`eddylr`: https://github.com/eddylr
39
42
  .. _`Henrique Rapizo`: https://github.com/hrapizo
40
43
  .. _`John Harrington`: https://github.com/JohnCHarrington
41
44
  .. _`Jorge Perez`: https://github.com/jorgeperezg
45
+ .. _`Joris de Vroom`: https://github.com/jdevroom
42
46
  .. _`Paul Branson`: https://github.com/pbranson
43
47
  .. _`Ruben de Bruin`: https://github.com/RubendeBruin
44
48
  .. _`Ryan Coe`: https://github.com/ryancoe
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespectra
3
- Version: 4.7.0
3
+ Version: 4.9.0
4
4
  Summary: Library for ocean wave spectra
5
5
  Author-email: Wavespectra Developers <r.guedes@oceanum.science>
6
6
  Maintainer-email: Rafael Guedes <r.guedes@oceanum.science>, Tom Durrant <t.durrant@oceanum.science>
@@ -42,6 +42,7 @@ Classifier: Programming Language :: Python :: 3.10
42
42
  Classifier: Programming Language :: Python :: 3.11
43
43
  Classifier: Programming Language :: Python :: 3.12
44
44
  Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Programming Language :: Python :: 3.14
45
46
  Requires-Python: >=3.9
46
47
  Description-Content-Type: text/x-rst
47
48
  License-File: LICENSE.txt
@@ -49,6 +50,7 @@ License-File: AUTHORS.rst
49
50
  Requires-Dist: click
50
51
  Requires-Dist: dask
51
52
  Requires-Dist: matplotlib
53
+ Requires-Dist: numpy
52
54
  Requires-Dist: python-dateutil
53
55
  Requires-Dist: pyyaml
54
56
  Requires-Dist: sortedcontainers
@@ -30,12 +30,14 @@ classifiers = [
30
30
  "Programming Language :: Python :: 3.11",
31
31
  "Programming Language :: Python :: 3.12",
32
32
  "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
33
34
  ]
34
35
  requires-python = ">=3.9"
35
36
  dependencies = [
36
37
  "click",
37
38
  "dask",
38
39
  "matplotlib",
40
+ "numpy",
39
41
  "python-dateutil",
40
42
  "pyyaml",
41
43
  "sortedcontainers",
@@ -99,8 +101,6 @@ docs = [
99
101
  ]
100
102
 
101
103
  [tool.setuptools.packages.find]
102
- # Explicit include so stray top-level directories created at build time
103
- # (e.g. cibuildwheel's wheelhouse/) don't break flat-layout auto-discovery.
104
104
  include = ["wavespectra*"]
105
105
 
106
106
  [tool.setuptools.package-data]
@@ -112,15 +112,9 @@ include = ["wavespectra*"]
112
112
  version = {attr = "wavespectra.__version__"}
113
113
 
114
114
  [tool.cibuildwheel]
115
- # CPython versions matching the supported range in the classifiers.
116
- build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*"]
117
- # 64-bit only: 32-bit Windows wheels would be built by default, but core
118
- # dependencies (e.g. matplotlib) no longer ship win32 wheels to test against.
115
+ build = ["cp39-*", "cp310-*", "cp311-*", "cp312-*", "cp313-*", "cp314-*"]
119
116
  archs = ["auto64"]
120
- # Skip musl-based linux; can be added later if there is demand.
121
117
  skip = ["*-musllinux*"]
122
- # Test every built wheel by running the partition test module, which
123
- # exercises the compiled specpart extension end to end.
124
118
  test-requires = ["pytest", "netCDF4"]
125
119
  test-command = "pytest {project}/tests/test_partition.py -q"
126
120
  build-verbosity = 1
@@ -128,9 +122,6 @@ build-verbosity = 1
128
122
  [tool.pytest.ini_options]
129
123
  norecursedirs = [".*", "build", "dist", "CVS", "_darcs", "{arch}", "*.egg", "venv", "broken"]
130
124
  filterwarnings = [
131
- # netCDF4-python sets the .shape attribute on numpy arrays, which numpy
132
- # 2.5 deprecated; it surfaces through xarray's netCDF4 backend whenever a
133
- # netCDF file is written. Third-party issue, nothing to fix in wavespectra.
134
125
  "ignore:Setting the shape on a NumPy array has been deprecated:DeprecationWarning",
135
126
  ]
136
127
 
@@ -143,9 +134,9 @@ indent-style = "space"
143
134
  line-ending = "auto"
144
135
 
145
136
  [tool.ruff.lint]
146
- # B015/B018: flag comparisons/expressions whose result is discarded, which in
147
- # tests almost always means a forgotten `assert`.
148
- extend-select = ["B015", "B018"]
137
+ # Pin the rule set explicitly so linting does not change with the ruff release.
138
+ # E4, E7, E9 and F were the defaults up to ruff 0.15.
139
+ select = ["E4", "E7", "E9", "F", "B015", "B018"]
149
140
 
150
141
  [tool.ruff.lint.extend-per-file-ignores]
151
142
  "wavespectra/__init__.py" = ["F401"]
@@ -173,7 +164,7 @@ extend-select = ["B015", "B018"]
173
164
  github_url = "https://github.com/wavespectra/wavespectra/"
174
165
 
175
166
  [tool.tbump.version]
176
- current = "4.7.0"
167
+ current = "4.9.0"
177
168
  regex = '''
178
169
  (?P<major>\d+)
179
170
  \.(?P<minor>\d+)
@@ -1,23 +1,31 @@
1
1
  import pytest
2
2
  from pathlib import Path
3
3
  import numpy as np
4
+ import xarray as xr
4
5
 
5
6
  import wavespectra
6
7
  from wavespectra import read_ww3
8
+ from wavespectra.construct import construct_partition
7
9
  from wavespectra.partition.partition import (
10
+ DEFAULTS,
8
11
  Partition,
9
12
  np_ptm1,
10
13
  np_ptm2,
11
14
  np_ptm3,
15
+ np_steepness,
12
16
  np_hp01,
13
17
  )
14
- from wavespectra.core.utils import waveage
18
+ from wavespectra.core.utils import waveage, create_frequencies, to_coords
15
19
  from wavespectra.core import npstats
16
20
  from wavespectra.partition.tracking import track_partitions
17
21
 
18
22
 
19
23
  HERE = Path(__file__).parent
20
24
 
25
+ # Well resolved spectral grid for constructing synthetic spectra
26
+ FREQ = to_coords(create_frequencies(0.03, 40, 1.1), "freq")
27
+ DIR = to_coords(np.arange(0, 360, 10.0), "dir")
28
+
21
29
 
22
30
  @pytest.fixture(autouse=True)
23
31
  def dataset_transforms():
@@ -374,6 +382,292 @@ class TestHP01(BasePTM):
374
382
  assert np.allclose(hs.values, self.dset.spec.hs().values, rtol=1e-2)
375
383
 
376
384
 
385
+ class TestSteepness(BasePTM):
386
+ def setup_class(self):
387
+ super().setup_class(self)
388
+ self.dpt = self.dset.isel(time=0, site=0).dpt.values
389
+ self.scut = 0.025
390
+ self.swells = 3
391
+
392
+ def _exec(self, **kwargs):
393
+ out = np_steepness(
394
+ spectrum=self.efth,
395
+ spectrum_smooth=self.efth,
396
+ freq=self.freq,
397
+ dir=self.dir,
398
+ dpt=kwargs.get("dpt", self.dpt),
399
+ scut=kwargs.get("scut", self.scut),
400
+ tail=kwargs.get("tail", False),
401
+ swells=kwargs.get("swells", self.swells),
402
+ )
403
+ return out
404
+
405
+ def test_defaults(self):
406
+ self.out = self._exec()
407
+ assert self.out.shape[0] == self.swells + 1
408
+ assert self.hs_full == pytest.approx(self.hs_from_partitions)
409
+
410
+ def test_default_swells_is_one(self):
411
+ """The default returns a single wind sea and a single swell."""
412
+ assert DEFAULTS["steepness_swells"] == 1
413
+ self.out = np_steepness(
414
+ spectrum=self.efth,
415
+ spectrum_smooth=self.efth,
416
+ freq=self.freq,
417
+ dir=self.dir,
418
+ dpt=self.dpt,
419
+ )
420
+ assert self.out.shape[0] == 2
421
+ assert self.hs_full == pytest.approx(self.hs_from_partitions)
422
+
423
+ def test_default_swells_is_one_class(self):
424
+ """The Partition wrapper defaults to one wind sea and one swell."""
425
+ dspart = self.pt.steepness(dpt=self.dset.dpt)
426
+ assert dspart.part.size == 2
427
+ hs_dspart = np.sqrt((dspart.spec.hs() ** 2).sum("part"))
428
+ assert np.allclose(hs_dspart.values, self.dset.spec.hs().values, rtol=1e-2)
429
+
430
+ def test_request_all_partitions(self):
431
+ self.out = self._exec(swells=None)
432
+ assert self.hs_full == pytest.approx(self.hs_from_partitions)
433
+
434
+ def test_request_all_partitions_class(self):
435
+ """swells=None detects the required number of partitions."""
436
+ dspart = self.pt.steepness(dpt=self.dset.dpt, swells=None)
437
+ hs_dspart = np.sqrt((dspart.spec.hs() ** 2).sum("part"))
438
+ assert np.allclose(hs_dspart.values, self.dset.spec.hs().values, rtol=1e-2)
439
+
440
+ def test_request_less_partitions(self):
441
+ swells = 1
442
+ self.out = self._exec(swells=swells)
443
+ assert len(self.out) == swells + 1
444
+
445
+ def test_partition_class(self):
446
+ swells = 2
447
+ self.out = self._exec(swells=swells)
448
+ dspart = self.pt.steepness(dpt=self.dset.dpt, swells=swells)
449
+ hs_dspart = np.sqrt(np.sum(dspart.isel(time=0, site=0).spec.hs().values ** 2))
450
+ assert hs_dspart == pytest.approx(self.hs_from_partitions, rel=1e-2)
451
+
452
+ def test_no_wind_required(self):
453
+ """The method runs from a DataArray with no wind or depth available."""
454
+ dspart = Partition(self.dset.efth).steepness(swells=2)
455
+ assert isinstance(dspart, xr.DataArray)
456
+ assert dspart.part.size == 3
457
+ assert bool((dspart.spec.hs() > 0).any())
458
+
459
+ def test_no_depth_deep_water_default(self):
460
+ """Depth is optional, the deep water approximation is used without it."""
461
+ deep = self._exec(dpt=np.nan)
462
+ prescribed = self._exec(dpt=1e5)
463
+ assert np.allclose(deep, prescribed)
464
+
465
+ def test_depth_makes_partitions_steeper(self):
466
+ """Shoaling in shallow water pushes more energy into the wind sea.
467
+
468
+ The steepness is evaluated with the local wavelength when the depth is
469
+ prescribed, so the same swell is classified as wind sea in shallow
470
+ enough water.
471
+ """
472
+ swell = construct_partition(
473
+ freq_kwargs=dict(freq=FREQ, fp=1 / 9.0, hs=2.0, gamma=3.3),
474
+ dir_kwargs=dict(dir=DIR, dm=270.0, dspr=25.0),
475
+ ).transpose("freq", "dir")
476
+
477
+ def hs_windsea(dpt):
478
+ out = np_steepness(
479
+ spectrum=swell.values,
480
+ spectrum_smooth=swell.values,
481
+ freq=FREQ.values,
482
+ dir=DIR.values,
483
+ dpt=dpt,
484
+ swells=2,
485
+ )
486
+ return npstats.hs(out[0], FREQ.values, DIR.values)
487
+
488
+ assert hs_windsea(np.nan) == 0.0
489
+ assert hs_windsea(15.0) == 0.0
490
+ assert hs_windsea(10.0) == pytest.approx(2.0, rel=1e-2)
491
+
492
+ def test_lower_threshold_grows_windsea(self):
493
+ """A lower scut threshold classifies more energy as wind sea."""
494
+ lo = self.pt.steepness(dpt=self.dset.dpt, scut=0.01, swells=2)
495
+ hi = self.pt.steepness(dpt=self.dset.dpt, scut=0.06, swells=2)
496
+ hs_sea_lo = lo.isel(part=0).spec.hs()
497
+ hs_sea_hi = hi.isel(part=0).spec.hs()
498
+ assert bool((hs_sea_lo >= hs_sea_hi).all())
499
+ assert bool((hs_sea_lo > hs_sea_hi).any())
500
+
501
+ def test_unreachable_threshold_leaves_no_windsea(self):
502
+ """Partition 0 is null when no partition reaches scut."""
503
+ dspart = self.pt.steepness(dpt=self.dset.dpt, scut=1.0, swells=2)
504
+ assert bool((dspart.isel(part=0).spec.hs() == 0).all())
505
+
506
+ def test_tail_grows_windsea(self):
507
+ """The tail correction recovers wind seas truncated by the freq grid.
508
+
509
+ The sample file frequency grid stops at 0.406 Hz so the light wind seas
510
+ in it are truncated and their steepness is underestimated.
511
+ """
512
+ assert self.freq[-1] < 0.5
513
+ notail = self.pt.steepness(dpt=self.dset.dpt, swells=2)
514
+ tail = self.pt.steepness(dpt=self.dset.dpt, swells=2, tail=True)
515
+ hs_sea_notail = notail.isel(part=0).spec.hs()
516
+ hs_sea_tail = tail.isel(part=0).spec.hs()
517
+ assert bool((hs_sea_tail >= hs_sea_notail).all())
518
+ assert bool((hs_sea_tail > hs_sea_notail).any())
519
+
520
+ def test_tail_inert_for_swells(self):
521
+ """The tail correction cannot turn a swell into a wind sea.
522
+
523
+ A watershed swell partition carries no energy at the last frequency so
524
+ the correction is inert for it.
525
+ """
526
+ efth = self._sea_and_swell()
527
+ kwargs = dict(
528
+ spectrum=efth.values,
529
+ spectrum_smooth=efth.values,
530
+ freq=FREQ.values,
531
+ dir=DIR.values,
532
+ swells=2,
533
+ )
534
+ notail = np_steepness(**kwargs, tail=False)
535
+ tail = np_steepness(**kwargs, tail=True)
536
+ assert notail[0][-1].sum() > 0 # wind sea reaches the last frequency
537
+ assert notail[1][-1].sum() == 0 # swell does not
538
+ assert np.array_equal(notail[1], tail[1])
539
+
540
+ @staticmethod
541
+ def _sea_and_swell():
542
+ """Steep 6s wind sea and gentle 14s swell, opposing directions."""
543
+ sea = construct_partition(
544
+ freq_kwargs=dict(freq=FREQ, fp=1 / 6.0, hs=2.5, gamma=3.3),
545
+ dir_kwargs=dict(dir=DIR, dm=90.0, dspr=25.0),
546
+ )
547
+ swell = construct_partition(
548
+ freq_kwargs=dict(freq=FREQ, fp=1 / 14.0, hs=1.5, gamma=3.3),
549
+ dir_kwargs=dict(dir=DIR, dm=270.0, dspr=15.0),
550
+ )
551
+ return (sea + swell).transpose("freq", "dir")
552
+
553
+ def test_sea_and_swell_are_recovered(self):
554
+ """A steep wind sea and a gentle swell are classified from steepness."""
555
+ efth = self._sea_and_swell()
556
+ out = np_steepness(
557
+ spectrum=efth.values,
558
+ spectrum_smooth=efth.values,
559
+ freq=FREQ.values,
560
+ dir=DIR.values,
561
+ swells=2,
562
+ )
563
+ hs_parts = [npstats.hs(part, FREQ.values, DIR.values) for part in out]
564
+ assert hs_parts[0] == pytest.approx(2.5, rel=1e-2)
565
+ assert hs_parts[1] == pytest.approx(1.5, rel=1e-2)
566
+ assert hs_parts[2] == 0.0
567
+
568
+ def test_smoothing(self):
569
+ swells = 2
570
+ ds_nosmoothing = self.pt.steepness(dpt=self.dset.dpt, swells=swells)
571
+ ds_smoothing = self.pt.steepness(dpt=self.dset.dpt, swells=swells, smooth=True)
572
+ assert not ds_nosmoothing.spec.hs().equals(ds_smoothing.spec.hs())
573
+
574
+
575
+ class TestSteepnessSwellMerge:
576
+ """Coverage for `swell_merge`, exercising the path where more swells are
577
+ detected than the requested `swells` slots. The ww3file.nc fixture used by
578
+ TestSteepness never detects more than 3 swells so it can't reach this path;
579
+ a synthetic four-swell spectrum with no wind sea is built here instead.
580
+ """
581
+
582
+ def setup_class(self):
583
+ self.freq = np.linspace(0.03, 0.5, 40)
584
+ self.dir = np.linspace(0, 350, 36)
585
+ self.dpt = np.nan # np_steepness's sentinel for "no depth" (deep water)
586
+
587
+ def peak(fp, hsig, dp, spread=15):
588
+ E = npstats.jonswap(self.freq, fp, hsig)
589
+ dfac = np.exp(-0.5 * ((self.dir - dp + 180) % 360 - 180) ** 2 / spread**2)
590
+ dfac = dfac / (dfac.sum() * (self.dir[1] - self.dir[0]))
591
+ return np.outer(E, dfac)
592
+
593
+ # Four distinct, gentle (sub-threshold) swell systems, all classified
594
+ # as swell rather than wind sea, so requesting fewer than 4 swell
595
+ # slots always exercises the merge path.
596
+ self.efth = (
597
+ peak(0.07, 1.8, 200)
598
+ + peak(0.09, 1.4, 260)
599
+ + peak(0.11, 1.0, 30)
600
+ + peak(0.14, 0.6, 100)
601
+ )
602
+ self.hs_full = npstats.hs(self.efth, self.freq, self.dir)
603
+
604
+ def _exec(self, swells, swell_merge):
605
+ return np_steepness(
606
+ spectrum=self.efth,
607
+ spectrum_smooth=self.efth,
608
+ freq=self.freq,
609
+ dir=self.dir,
610
+ dpt=self.dpt,
611
+ scut=0.025,
612
+ swells=swells,
613
+ swell_merge=swell_merge,
614
+ )
615
+
616
+ def _hs_from_partitions(self, out):
617
+ hs_partitions = [npstats.hs(efth, self.freq, self.dir) for efth in out]
618
+ return np.sqrt(np.sum(np.power(hs_partitions, 2)))
619
+
620
+ @pytest.mark.parametrize("swell_merge", ["sum", "hp01"])
621
+ @pytest.mark.parametrize("swells", [1, 2, 3])
622
+ def test_merge_conserves_energy_and_count(self, swells, swell_merge):
623
+ """Merging (either strategy) always yields exactly `swells` swell
624
+ partitions and loses no energy, unlike the old discard behaviour."""
625
+ out = self._exec(swells=swells, swell_merge=swell_merge)
626
+ assert out.shape[0] == swells + 1
627
+ assert self.hs_full == pytest.approx(self._hs_from_partitions(out), rel=1e-2)
628
+
629
+ def test_swells_one_merges_everything(self):
630
+ """swells=1 collapses every detected swell into the single output slot."""
631
+ out = self._exec(swells=1, swell_merge="sum")
632
+ assert out.shape[0] == 2
633
+ hs_swell = npstats.hs(out[1], self.freq, self.dir)
634
+ assert hs_swell == pytest.approx(self.hs_full, rel=1e-2)
635
+
636
+ def test_default_swell_merge_is_sum(self):
637
+ assert DEFAULTS["swell_merge"] == "sum"
638
+
639
+ def test_invalid_swell_merge_raises(self):
640
+ with pytest.raises(ValueError):
641
+ self._exec(swells=2, swell_merge="bogus")
642
+
643
+ def test_sum_and_hp01_can_disagree(self):
644
+ """The two strategies are free to group swells differently as long as
645
+ both conserve energy - sanity-check they actually differ on this
646
+ deliberately-ambiguous four-swell case rather than one silently
647
+ falling back to the other."""
648
+ hs_sum = sorted(
649
+ npstats.hs(e, self.freq, self.dir)
650
+ for e in self._exec(swells=2, swell_merge="sum")[1:]
651
+ )
652
+ hs_hp01 = sorted(
653
+ npstats.hs(e, self.freq, self.dir)
654
+ for e in self._exec(swells=2, swell_merge="hp01")[1:]
655
+ )
656
+ assert hs_sum != pytest.approx(hs_hp01)
657
+
658
+ def test_partition_class_default_merge(self):
659
+ """The Dataset-level `Partition.steepness()` wrapper defaults to the
660
+ same 'sum' merge behaviour as the numpy core function."""
661
+ ds = xr.Dataset(
662
+ {"efth": (("freq", "dir"), self.efth)},
663
+ coords={"freq": self.freq, "dir": self.dir},
664
+ )
665
+ dspart = ds.spec.partition.steepness(swells=2)
666
+ assert dspart.sizes["part"] == 3
667
+ hs_dspart = np.sqrt((dspart.spec.hs() ** 2).sum())
668
+ assert float(hs_dspart) == pytest.approx(self.hs_full, rel=1e-2)
669
+
670
+
377
671
  class TestBbox(BasePTM):
378
672
  def test_default(self):
379
673
  bboxes = [
@@ -441,6 +735,19 @@ class TestPartitionAndTrack(BasePTM):
441
735
  dspart = dspart.load()
442
736
  assert (dspart.ntracks.values > 0).all()
443
737
 
738
+ def test_track_steepness(self):
739
+ self._track("steepness", wspd=self.dset.wspd, dpt=self.dset.dpt)
740
+
741
+ def test_track_steepness_no_wind(self):
742
+ """Without wind the steepness partitions are matched as swells.
743
+
744
+ Wind variables are resolved from the dataset so partitioning from
745
+ the DataArray is required to exercise the no-wind path.
746
+ """
747
+ pt = Partition(self.dset.efth)
748
+ dspart = pt.track(method="steepness", swells=2).load()
749
+ assert (dspart.ntracks.values > 0).all()
750
+
444
751
  def test_track_hp01(self):
445
752
  self._track("hp01", wspd=self.dset.wspd, wdir=self.dset.wdir, dpt=self.dset.dpt)
446
753
 
@@ -16,7 +16,7 @@ except ImportError as exc:
16
16
  warnings.warn(f"Cannot import accessors at the main module level:\n{exc}")
17
17
 
18
18
 
19
- __version__ = "4.7.0"
19
+ __version__ = "4.9.0"
20
20
 
21
21
 
22
22
  def _import_functions(pkgname="input", prefix="read"):
@@ -3,7 +3,7 @@
3
3
  import numpy as np
4
4
  from scipy.constants import g, pi
5
5
 
6
- from wavespectra.core.utils import R2D
6
+ from wavespectra.core.utils import R2D, wavelen
7
7
 
8
8
 
9
9
  def mom1(spectrum, dir, theta=90.0):
@@ -27,19 +27,21 @@ def mom1(spectrum, dir, theta=90.0):
27
27
  return msin, mcos
28
28
 
29
29
 
30
- def dm(spectrum, dir):
30
+ def dm(spectrum, dir, freq):
31
31
  """Mean wave direction Dm.
32
32
 
33
33
  Args:
34
- - spectrum (2darray): wave spectrum array.
34
+ - spectrum (2darray): wave spectrum array E(f, d).
35
35
  - dir (1darray): wave direction array.
36
+ - freq (1darray): wave frequency array.
36
37
 
37
38
  Returns:
38
- - dm (float): Mean spectral period.
39
+ - dm (float): Mean wave direction.
39
40
 
40
41
  """
41
42
  moms, momc = mom1(spectrum, dir)
42
- dm = np.arctan2(moms.sum(axis=0), momc.sum(axis=0))
43
+ df = np.gradient(freq) if np.size(freq) > 1 else np.ones(1)
44
+ dm = np.arctan2((moms * df).sum(axis=0), (momc * df).sum(axis=0))
43
45
  dm = (270 - R2D * dm) % 360.0
44
46
  return dm
45
47
 
@@ -69,6 +71,49 @@ def hs(spectrum, freq, dir=None, tail=True):
69
71
  return 4.0 * np.sqrt(Etot)
70
72
 
71
73
 
74
+ def steepness(spectrum, freq, dpt=None, tail=False):
75
+ """Wave steepness Hm0 / L evaluated at the energy period Tm-1,0.
76
+
77
+ Args:
78
+ - spectrum (1darray): Direction-integrated wave spectrum array E(f).
79
+ - freq (1darray): Wave frequency array.
80
+ - dpt (float): Water depth, deep water approximation used by default.
81
+ - tail (bool): If True, add the contribution of an f^-5 high-frequency
82
+ tail beyond the last frequency to the spectral moments.
83
+
84
+ Returns:
85
+ - steepness (float): Wave steepness Hm0 / L, used as the wind sea
86
+ criterion in the `steepness` partitioning method.
87
+
88
+ Note:
89
+ - The steepness of a fully developed Pierson-Moskowitz sea is 0.035 in
90
+ deep water regardless of the wind speed, and it increases as the sea
91
+ gets younger, so this is a wave age proxy that requires no wind input.
92
+ - The `tail` correction is applied to both m0 and m-1 so it is
93
+ consistent with the energy period. It matters for wind sea partitions
94
+ whose peak sits close to the upper limit of the frequency grid, which
95
+ are otherwise truncated and appear less steep than they are. Swell
96
+ partitions carry no energy at the last frequency so the correction is
97
+ inert for them.
98
+ - The steepness is evaluated with the local wavelength when `dpt` is
99
+ prescribed, so shoaling waves become steeper in shallow water.
100
+
101
+ """
102
+ df = np.abs(freq[1:] - freq[:-1])
103
+ add_tail = tail and freq[-1] > 0.333
104
+ m0 = 0.5 * np.sum(df * (spectrum[1:] + spectrum[:-1]))
105
+ if add_tail:
106
+ m0 += 0.25 * spectrum[-1] * freq[-1]
107
+ if m0 < 1e-6:
108
+ return 0.0
109
+ m_1 = 0.5 * np.sum(df * (spectrum[1:] / freq[1:] + spectrum[:-1] / freq[:-1]))
110
+ if add_tail:
111
+ m_1 += spectrum[-1] / 5.0
112
+ hm0 = 4.0 * np.sqrt(m0)
113
+ fm_10 = m0 / m_1
114
+ return float(hm0 / wavelen(fm_10, dpt))
115
+
116
+
72
117
  def dpm(ipeak, momsin, momcos):
73
118
  """Mean direction at the peak wave period Dpm.
74
119
 
@@ -38,6 +38,9 @@ DEFAULTS = {
38
38
  "window": 3,
39
39
  "wscut": 0.3333,
40
40
  "agefac": 1.7,
41
+ "scut": 0.025,
42
+ "steepness_swells": 1,
43
+ "swell_merge": "sum",
41
44
  }
42
45
 
43
46
 
@@ -77,6 +80,11 @@ class Partition:
77
80
  freely propogating swell (i.e. unforced by the wind). This is similar to the
78
81
  method commonly used to generate wind-sea and swell from the WAM model.
79
82
  - ptm5: PTM5 splits spectra into wind sea and swell based on a user defined static cutoff.
83
+ - steepness: STEEPNESS works like PTM1 but identifies wind sea from the steepness Hm0 / L of each
84
+ topographic partition, evaluated at the energy period Tm-1,0, rather than from a wind speed and
85
+ direction based wave age criterion. Partitions at or above a minimum steepness are aggregated
86
+ and assigned to the wind-sea component and the remaining partitions are assigned as swell
87
+ components in order of decreasing wave height. No wind input is required.
80
88
  - hp01: HP01 partitions the spectra and merges wind-sea components as in the PTM1 method, then it combines
81
89
  adjacent swells belonging to the same wave system following the criteria outlined in Hanson and
82
90
  Phillips (2001) and Hanson et al. (2009). Useful for noisy measured spectra which the watershed
@@ -638,6 +646,169 @@ class Partition:
638
646
 
639
647
  return self._wrap_output(dsout.fillna(0.0))
640
648
 
649
+ def steepness(
650
+ self,
651
+ dpt=None,
652
+ scut=DEFAULTS["scut"],
653
+ tail=False,
654
+ swells=DEFAULTS["steepness_swells"],
655
+ swell_merge=DEFAULTS["swell_merge"],
656
+ smooth=DEFAULTS["smooth"],
657
+ freq_window=DEFAULTS["window"],
658
+ dir_window=DEFAULTS["window"],
659
+ ihmax=DEFAULTS["ihmax"],
660
+ ):
661
+ """STEEPNESS watershed partitioning.
662
+
663
+ STEEPNESS works like PTM1 but identifies wind sea from the steepness
664
+ Hm0 / L of each topographic partition, evaluated at the energy period
665
+ Tm-1,0, rather than from the wind speed and direction based wave age
666
+ criterion used by the other methods. Partitions with steepness at or
667
+ above `scut` are aggregated and assigned to the wind-sea component
668
+ (e.g., the first partition). The remaining partitions are assigned as
669
+ swell components in order of decreasing wave height, merged down to
670
+ `swells` partitions if more are detected (see `swell_merge`) so that no
671
+ swell energy is discarded. The default `swells=1` therefore returns a
672
+ single wind sea and a single swell holding all the non wind-sea energy.
673
+ Because no wind input is used, this method is useful when reliable wind
674
+ speed and direction are not available, or when the misalignment between
675
+ wave and wind direction is not a desired classification factor.
676
+
677
+ Args:
678
+ - dpt (xr.DataArray): Depth DataArray, taken from the `dpt` variable
679
+ in the underlying dataset if not provided, deep water approximation
680
+ used if not available from either source.
681
+ - scut (float): Wave steepness Hm0 / L cutoff, partitions with
682
+ steepness at or above this value are classified as wind sea.
683
+ - tail (bool): If True, account for an f^-5 high-frequency tail
684
+ beyond the last frequency when evaluating the steepness.
685
+ - swells (int): Number of swell partitions to compute, the default
686
+ of 1 merges all detected swells into a single partition. If None,
687
+ the number required to hold all swells detected from all spectra
688
+ is used, which doubles the compute time and triggers an eager
689
+ computation on dask datasets.
690
+ - swell_merge (str): How to reduce more than `swells` detected swells
691
+ down to `swells` partitions. "sum" (default) keeps the `swells - 1`
692
+ largest swells individually and adds the spectral energy of the
693
+ rest into the last (smallest) kept partition. "hp01" instead merges
694
+ excess swells onto their closest neighbour using the adjacency,
695
+ saddle-point and angle criteria from the `hp01` method (Hanson and
696
+ Phillips, 2001). Either way spectral energy is conserved rather
697
+ than discarded. Ignored if `swells` is None.
698
+ - smooth (bool): Compute watershed boundaries from smoothed spectra
699
+ as described in Portilla et al., 2009.
700
+ - freq_window (int): Size of running window for smoothing spectra.
701
+ - dir_window (int): Size of running window for smoothing spectra.
702
+ - ihmax (int): Number of discrete spectral levels in WW3 Watershed code.
703
+
704
+ Returns:
705
+ - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension
706
+ where the 0th index are the wind sea and remaining indices are the swells
707
+ sorted by descending order of Hs.
708
+
709
+ Note:
710
+ - The steepness of a fully developed Pierson-Moskowitz sea is 0.035
711
+ in deep water regardless of the wind speed, and it grows as the sea
712
+ gets younger, so a single `scut` threshold applies across the
713
+ range of wind speeds. The default of 0.025 sits below the fully
714
+ developed value to allow for the energy a topographic partition
715
+ loses at its watershed boundaries.
716
+ - The steepness of a wind sea partition is underestimated when its
717
+ peak is close to the upper limit of the frequency grid, since the
718
+ truncated energy is missing from Hm0. This affects light wind seas
719
+ on the coarse frequency grids typical of wave model output, which
720
+ may then fall below `scut` and be classified as swell. Set
721
+ `tail=True` to compensate for the truncated energy; the correction
722
+ is inert for swell partitions, which carry no energy at the last
723
+ frequency.
724
+ - The steepness is evaluated with the local wavelength when the depth
725
+ is available, so shoaling waves become steeper in shallow water and
726
+ swells may be classified as wind sea on that basis alone. Prescribe
727
+ a larger `scut` or partition from the DataArray accessor without
728
+ a `dpt` argument to classify from the deep water steepness in
729
+ shallow water applications.
730
+
731
+ References:
732
+ - Hanson and Phillips (2001).
733
+ - Portilla et al. (2009).
734
+ - Tracy et al. (2007).
735
+ - Vincent et al. (1991).
736
+ - WW3 documentation (https://github.com/NOAA-EMC/WW3).
737
+
738
+ """
739
+ dpt = self._resolve(dpt, attrs.DEPNAME)
740
+ if dpt is None:
741
+ # Plain nan sentinel so apply_ufunc can broadcast it like the other
742
+ # scalar arguments; translated back to the deep water approximation
743
+ # inside np_steepness.
744
+ dpt = np.nan
745
+ if smooth:
746
+ dset_smooth = smooth_spec(self.dset, freq_window, dir_window)
747
+ else:
748
+ dset_smooth = self.dset
749
+
750
+ args = [
751
+ self.dset,
752
+ dset_smooth,
753
+ self.dset.freq,
754
+ self.dset.dir,
755
+ dpt,
756
+ scut,
757
+ tail,
758
+ ]
759
+ input_core_dims = [
760
+ ["freq", "dir"],
761
+ ["freq", "dir"],
762
+ ["freq"],
763
+ ["dir"],
764
+ [],
765
+ [],
766
+ [],
767
+ [],
768
+ [],
769
+ [],
770
+ ]
771
+
772
+ # Detect the number of swells required if not prescribed
773
+ if swells is None:
774
+ swells = self._detect_nparts(
775
+ np_steepness,
776
+ *args,
777
+ None,
778
+ swell_merge,
779
+ ihmax,
780
+ input_core_dims=input_core_dims,
781
+ nparts_fixed=1,
782
+ )
783
+
784
+ # Partitioning full spectra
785
+ dsout = xr.apply_ufunc(
786
+ np_steepness,
787
+ *args,
788
+ swells,
789
+ swell_merge,
790
+ ihmax,
791
+ input_core_dims=input_core_dims,
792
+ output_core_dims=[["part", "freq", "dir"]],
793
+ vectorize=True,
794
+ dask="parallelized",
795
+ output_dtypes=["float32"],
796
+ dask_gufunc_kwargs={
797
+ "allow_rechunk": True,
798
+ "output_sizes": {"part": swells + 1},
799
+ },
800
+ )
801
+
802
+ # Finalise output
803
+ dsout = self._set_metadata(dsout)
804
+ parts_description = {
805
+ "part0": "wind sea",
806
+ "part1-n": "swells in descending order of hs",
807
+ }
808
+ dsout.attrs.update(parts_description)
809
+
810
+ return self._wrap_output(dsout.transpose("part", ...))
811
+
641
812
  def hp01(
642
813
  self,
643
814
  wspd=None,
@@ -936,14 +1107,14 @@ class Partition:
936
1107
 
937
1108
  Args:
938
1109
  - wspd (xr.DataArray): Wind speed DataArray, required by the ptm1
939
- and ptm2 methods and optional for hp01. Taken from the `wspd`
940
- variable in the underlying dataset if not provided.
1110
+ and ptm2 methods and optional for hp01 and steepness. Taken from
1111
+ the `wspd` variable in the underlying dataset if not provided.
941
1112
  - wdir (xr.DataArray): Wind direction DataArray, as above.
942
1113
  - dpt (xr.DataArray): Depth DataArray, as above.
943
1114
  - method (str): Partitioning method to track partitions from,
944
- one of "ptm1", "ptm2", "ptm3" or "hp01". The ptm4, ptm5 and bbox
945
- methods define partitions as fixed spectral regions whose
946
- identity is already continuous in time, so there is nothing
1115
+ one of "ptm1", "ptm2", "ptm3", "steepness" or "hp01". The ptm4,
1116
+ ptm5 and bbox methods define partitions as fixed spectral regions
1117
+ whose identity is already continuous in time, so there is nothing
947
1118
  to track.
948
1119
  - ddpm_sea_max (float): Maximum peak direction difference for wind sea
949
1120
  partitions. Default is 30 degrees.
@@ -963,8 +1134,8 @@ class Partition:
963
1134
  must span to be included in the `systems=True` output. The
964
1135
  default of 1 keeps all tracked systems.
965
1136
  - kwargs: Further arguments passed to the partitioning method, e.g.
966
- `swells`, `agefac`, `wscut`, `smooth` or the hp01 combining
967
- parameters.
1137
+ `swells`, `agefac`, `wscut`, `scut`, `smooth` or the
1138
+ hp01 combining parameters.
968
1139
 
969
1140
  Returns:
970
1141
  - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension
@@ -977,11 +1148,14 @@ class Partition:
977
1148
  in the `systems=False` output.
978
1149
 
979
1150
  Note:
980
- - Wind sea partitions (partition 0 in ptm1 and hp01, partitions 0
981
- and 1 in ptm2) are matched with wind-sea thresholds and the
982
- remaining partitions with swell thresholds. The ptm3 partitions
983
- are not classified and are all matched with swell thresholds,
984
- which makes wind inputs optional for that method.
1151
+ - Wind sea partitions (partition 0 in ptm1, hp01 and steepness,
1152
+ partitions 0 and 1 in ptm2) are matched with wind-sea thresholds
1153
+ and the remaining partitions with swell thresholds. The ptm3
1154
+ partitions are not classified and are all matched with swell
1155
+ thresholds, which makes wind inputs optional for that method.
1156
+ The steepness method classifies the wind sea without wind but the
1157
+ wind speed still defines the wind-sea matching threshold, so
1158
+ without it all its partitions are matched with swell thresholds.
985
1159
  - The time step is evaluated for each pair of consecutive spectra
986
1160
  so records with gaps or irregular sampling use matching
987
1161
  thresholds consistent with the actual time elapsed.
@@ -1014,10 +1188,15 @@ class Partition:
1014
1188
  # ptm3 partitions are not classified, tracking does not need wind
1015
1189
  wind_kwargs = {}
1016
1190
  nsea = 0
1191
+ elif method == "steepness":
1192
+ # steepness classifies the wind sea without wind, which is only
1193
+ # required to match it with wind sea thresholds when tracking
1194
+ wind_kwargs = {"dpt": dpt}
1195
+ nsea = 1 if wspd is not None else 0
1017
1196
  else:
1018
1197
  raise ValueError(
1019
- f"Cannot track partitions from method '{method}', "
1020
- "available methods are 'ptm1', 'ptm2', 'ptm3' and 'hp01'"
1198
+ f"Cannot track partitions from method '{method}', available "
1199
+ "methods are 'ptm1', 'ptm2', 'ptm3', 'steepness' and 'hp01'"
1021
1200
  )
1022
1201
 
1023
1202
  # Do the partitioning, on the spectral variable only so the tracking
@@ -1125,6 +1304,117 @@ def np_ptm1(
1125
1304
  return np.array([wsea_partition] + swell_partitions)
1126
1305
 
1127
1306
 
1307
+ def np_steepness(
1308
+ spectrum,
1309
+ spectrum_smooth,
1310
+ freq,
1311
+ dir,
1312
+ dpt=np.nan,
1313
+ scut=DEFAULTS["scut"],
1314
+ tail=False,
1315
+ swells=DEFAULTS["steepness_swells"],
1316
+ swell_merge=DEFAULTS["swell_merge"],
1317
+ ihmax=DEFAULTS["ihmax"],
1318
+ ):
1319
+ """STEEPNESS spectra partitioning on numpy arrays.
1320
+
1321
+ Args:
1322
+ - spectrum (2darray): Wave spectrum array with shape (nf, nd).
1323
+ - spectrum_smooth (2darray): Smoothed wave spectrum array with shape (nf, nd).
1324
+ - freq (1darray): Wave frequency array with shape (nf).
1325
+ - dir (1darray): Wave direction array with shape (nd).
1326
+ - dpt (float): Water depth, deep water approximation used if nan.
1327
+ - scut (float): Wave steepness Hm0 / L cutoff, evaluated at the energy
1328
+ period Tm-1,0, at or above which a partition is classified as wind sea.
1329
+ - tail (bool): If True, account for an f^-5 high-frequency tail beyond
1330
+ the last frequency when evaluating the steepness.
1331
+ - swells (int): Number of swell partitions to compute, all detected if
1332
+ None. The default of 1 merges all detected swells into a single
1333
+ partition.
1334
+ - swell_merge (str): How to reduce more than `swells` detected swells down
1335
+ to `swells` partitions: "sum" adds the spectral energy of the smallest
1336
+ excess swells into the last (smallest) kept partition; "hp01" merges
1337
+ excess swells onto their closest neighbour using the Hanson and Phillips
1338
+ (2001) adjacency criteria. Ignored if `swells` is None. Either way, no
1339
+ swell energy is discarded.
1340
+ - ihmax (int): Number of discrete spectral levels in WW3 Watershed code.
1341
+
1342
+ Returns:
1343
+ - specpart (3darray): Wave spectrum partitions sorted in decreasing order of Hs
1344
+ with shape (np, nf, nd).
1345
+
1346
+ Note:
1347
+ - The smooth spectrum `spectrum_smooth` is used to define the watershed
1348
+ boundaries which are applied to the original spectrum.
1349
+ - Unlike ptm1, ptm2 and hp01, wind sea is identified from the steepness
1350
+ of each topographic partition rather than from a wind speed and
1351
+ direction based wave age criterion, so no wind input is required.
1352
+
1353
+ """
1354
+ # Use smooth spectrum to define morphological boundaries
1355
+ watershed_map = specpart.partition(spectrum_smooth, ihmax)
1356
+ nparts = watershed_map.max()
1357
+ dpt = None if np.isnan(dpt) else dpt
1358
+ ddir = abs(dir[1] - dir[0]) if dir.size > 1 else 1.0
1359
+
1360
+ # Classify each topographic partition as wind sea or swell from its steepness
1361
+ wsea_partition = np.zeros_like(spectrum)
1362
+ swell_partitions = []
1363
+ for ipart in range(nparts):
1364
+ part = np.where(watershed_map == ipart + 1, spectrum, 0.0) # start at 1
1365
+ E = ddir * part.sum(axis=1)
1366
+ if npstats.steepness(E, freq, dpt, tail) >= scut:
1367
+ wsea_partition += part
1368
+ else:
1369
+ swell_partitions.append(part)
1370
+
1371
+ # Sort swells by Hs
1372
+ if swell_partitions:
1373
+ isort = np.argsort(
1374
+ [-npstats.hs(swell, freq, dir) for swell in swell_partitions]
1375
+ )
1376
+ swell_partitions = list(np.array(swell_partitions)[isort])
1377
+
1378
+ # Dealing with the number of swells
1379
+ if swells is None:
1380
+ # Exclude null swell partitions if the number of output swells is undefined
1381
+ swell_partitions = [swell for swell in swell_partitions if swell.sum() > 0]
1382
+ else:
1383
+ if len(swell_partitions) > swells:
1384
+ if swell_merge == "sum":
1385
+ # Keep the `swells - 1` largest swells individually, sum the
1386
+ # spectral energy of the rest into the last kept partition
1387
+ n_individual = max(swells - 1, 0)
1388
+ kept = list(swell_partitions[:n_individual])
1389
+ excess = swell_partitions[n_individual:]
1390
+ if excess:
1391
+ kept.append(np.sum(excess, axis=0))
1392
+ swell_partitions = kept
1393
+ elif swell_merge == "hp01":
1394
+ # Merge excess swells onto their closest neighbour using HP01's
1395
+ # own adjacency/saddle/angle criteria instead of a plain sum
1396
+ swell_partitions = combine_partitions_hp01(
1397
+ partitions=swell_partitions,
1398
+ freq=freq,
1399
+ dir=dir,
1400
+ swells=swells,
1401
+ combine_extra_swells=True,
1402
+ )
1403
+ else:
1404
+ raise ValueError(
1405
+ f"swell_merge must be 'sum' or 'hp01', got {swell_merge!r}"
1406
+ )
1407
+ if len(swell_partitions) < swells:
1408
+ # Extend partitions list with null spectra -- either fewer than
1409
+ # `swells` were detected in the first place, or hp01 merging
1410
+ # combined some of them down below the requested count
1411
+ n = swells - len(swell_partitions)
1412
+ for i in range(n):
1413
+ swell_partitions.append(np.zeros_like(spectrum))
1414
+
1415
+ return np.array([wsea_partition] + swell_partitions)
1416
+
1417
+
1128
1418
  def np_ptm2(
1129
1419
  spectrum,
1130
1420
  spectrum_smooth,
@@ -479,13 +479,18 @@ class SpecArray(object):
479
479
  return tm02.rename(self._my_name())
480
480
 
481
481
  def dm(self):
482
- """Mean wave direction from the 1st spectral moment Dm."""
482
+ """Mean wave direction from the 1st spectral moment Dm.
483
+
484
+ Defined from the sin and cosine components of the first directional
485
+ moment integrated over frequency and direction.
486
+
487
+ """
483
488
  if self.dir is None:
484
489
  raise ValueError("Cannot calculate dm from 1d, frequency spectra.")
485
490
  moms, momc = self.momd(1)
486
491
  dm = np.arctan2(
487
- moms.sum(dim=attrs.FREQNAME, skipna=False),
488
- momc.sum(dim=attrs.FREQNAME, skipna=False),
492
+ (moms * self.df).sum(dim=attrs.FREQNAME, skipna=False),
493
+ (momc * self.df).sum(dim=attrs.FREQNAME, skipna=False),
489
494
  )
490
495
  dm = (270 - R2D * dm) % 360.0
491
496
  dm.attrs.update(self._get_cf_attributes(self._my_name()))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wavespectra
3
- Version: 4.7.0
3
+ Version: 4.9.0
4
4
  Summary: Library for ocean wave spectra
5
5
  Author-email: Wavespectra Developers <r.guedes@oceanum.science>
6
6
  Maintainer-email: Rafael Guedes <r.guedes@oceanum.science>, Tom Durrant <t.durrant@oceanum.science>
@@ -42,6 +42,7 @@ Classifier: Programming Language :: Python :: 3.10
42
42
  Classifier: Programming Language :: Python :: 3.11
43
43
  Classifier: Programming Language :: Python :: 3.12
44
44
  Classifier: Programming Language :: Python :: 3.13
45
+ Classifier: Programming Language :: Python :: 3.14
45
46
  Requires-Python: >=3.9
46
47
  Description-Content-Type: text/x-rst
47
48
  License-File: LICENSE.txt
@@ -49,6 +50,7 @@ License-File: AUTHORS.rst
49
50
  Requires-Dist: click
50
51
  Requires-Dist: dask
51
52
  Requires-Dist: matplotlib
53
+ Requires-Dist: numpy
52
54
  Requires-Dist: python-dateutil
53
55
  Requires-Dist: pyyaml
54
56
  Requires-Dist: sortedcontainers
@@ -1,6 +1,7 @@
1
1
  click
2
2
  dask
3
3
  matplotlib
4
+ numpy
4
5
  python-dateutil
5
6
  pyyaml
6
7
  sortedcontainers
File without changes
File without changes
File without changes
File without changes