jerlov 0.7.2__tar.gz → 0.8.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 (49) hide show
  1. {jerlov-0.7.2/jerlov.egg-info → jerlov-0.8.0}/PKG-INFO +12 -5
  2. {jerlov-0.7.2 → jerlov-0.8.0}/README.md +3 -1
  3. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/__init__.py +1 -1
  4. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/shortwave.py +22 -3
  5. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/water.py +58 -17
  6. {jerlov-0.7.2 → jerlov-0.8.0/jerlov.egg-info}/PKG-INFO +12 -5
  7. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov.egg-info/requires.txt +0 -3
  8. {jerlov-0.7.2 → jerlov-0.8.0}/pyproject.toml +9 -3
  9. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_api.py +9 -0
  10. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_depth.py +29 -0
  11. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_packaging.py +47 -0
  12. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_shortwave.py +27 -3
  13. {jerlov-0.7.2 → jerlov-0.8.0}/LICENSE +0 -0
  14. {jerlov-0.7.2 → jerlov-0.8.0}/NOTICE +0 -0
  15. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/_data.py +0 -0
  16. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/backscattering.py +0 -0
  17. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/colour.py +0 -0
  18. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/__init__.py +0 -0
  19. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/austin1986_kd.csv +0 -0
  20. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/austin1986_model.csv +0 -0
  21. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/boss2001_chi.csv +0 -0
  22. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
  23. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/cie_d65.csv +0 -0
  24. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/jerlov1968_kd.csv +0 -0
  25. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
  26. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/jerlov1976_kd.csv +0 -0
  27. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/paulson1977_shortwave.csv +0 -0
  28. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/petzold1972_stations.csv +0 -0
  29. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/petzold1972_vsf.csv +0 -0
  30. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/smart2007_b_from_c.csv +0 -0
  31. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/solonenko2015_iop.csv +0 -0
  32. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/williamson2022_aw.csv +0 -0
  33. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/williamson2022_iop.csv +0 -0
  34. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/williamson2022_measured.csv +0 -0
  35. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/data/williamson2023_depth.csv +0 -0
  36. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/petzold.py +0 -0
  37. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/py.typed +0 -0
  38. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/scene.py +0 -0
  39. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov/sources.py +0 -0
  40. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov.egg-info/SOURCES.txt +0 -0
  41. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov.egg-info/dependency_links.txt +0 -0
  42. {jerlov-0.7.2 → jerlov-0.8.0}/jerlov.egg-info/top_level.txt +0 -0
  43. {jerlov-0.7.2 → jerlov-0.8.0}/setup.cfg +0 -0
  44. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_backscattering.py +0 -0
  45. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_colour.py +0 -0
  46. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_petzold.py +0 -0
  47. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_quoted_figures.py +0 -0
  48. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_reproduces_papers.py +0 -0
  49. {jerlov-0.7.2 → jerlov-0.8.0}/tests/test_scene.py +0 -0
@@ -1,21 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jerlov
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Inherent optical properties of Jerlov water types, with provenance
5
5
  Author: T. Ishibashi
6
6
  License-Expression: Apache-2.0
7
7
  Keywords: ocean optics,Jerlov water types,inherent optical properties
8
- Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Intended Audience :: Science/Research
10
+ Classifier: Operating System :: OS Independent
10
11
  Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
11
17
  Classifier: Topic :: Scientific/Engineering :: Physics
18
+ Classifier: Typing :: Typed
12
19
  Requires-Python: >=3.10
13
20
  Description-Content-Type: text/markdown
14
21
  License-File: LICENSE
15
22
  License-File: NOTICE
16
23
  Requires-Dist: numpy>=1.22
17
- Provides-Extra: plot
18
- Requires-Dist: matplotlib; extra == "plot"
19
24
  Provides-Extra: test
20
25
  Requires-Dist: pytest; extra == "test"
21
26
  Provides-Extra: build
@@ -72,7 +77,8 @@ paper's own constant, so constants are attached to sources.
72
77
 
73
78
  ## What this package will not do
74
79
 
75
- - **Extrapolate.** Asking for a wavelength outside the data raises.
80
+ - **Extrapolate.** Asking for a wavelength outside the data raises. Values
81
+ that a source itself extrapolated are returned, but marked, and they warn.
76
82
  - **Fill a gap.** Where a published value is wrong and could not be
77
83
  recovered, the value is NaN and stays NaN through interpolation.
78
84
  - **Supply bb.** The backscattering coefficient is not determined by the
@@ -260,6 +266,7 @@ coastal water clears as you go down.
260
266
  jerlov.water_type_at_depth("I", 60.0) # 'IB'
261
267
  jerlov.water_type_at_depth("3C", 45.0) # 'II'
262
268
  jerlov.water_type_at_depth("9C", 15.0) # None: the paper declined to say
269
+ jerlov.water_type_at_depth("I", [0, 30, 60]) # array(['I', 'IA', 'IB'], dtype=object)
263
270
  ```
264
271
 
265
272
  `None` means fewer than ten measurement campaigns supported a declaration, so
@@ -47,7 +47,8 @@ paper's own constant, so constants are attached to sources.
47
47
 
48
48
  ## What this package will not do
49
49
 
50
- - **Extrapolate.** Asking for a wavelength outside the data raises.
50
+ - **Extrapolate.** Asking for a wavelength outside the data raises. Values
51
+ that a source itself extrapolated are returned, but marked, and they warn.
51
52
  - **Fill a gap.** Where a published value is wrong and could not be
52
53
  recovered, the value is NaN and stays NaN through interpolation.
53
54
  - **Supply bb.** The backscattering coefficient is not determined by the
@@ -235,6 +236,7 @@ coastal water clears as you go down.
235
236
  jerlov.water_type_at_depth("I", 60.0) # 'IB'
236
237
  jerlov.water_type_at_depth("3C", 45.0) # 'II'
237
238
  jerlov.water_type_at_depth("9C", 15.0) # None: the paper declined to say
239
+ jerlov.water_type_at_depth("I", [0, 30, 60]) # array(['I', 'IA', 'IB'], dtype=object)
238
240
  ```
239
241
 
240
242
  `None` means fewer than ten measurement campaigns supported a declaration, so
@@ -96,4 +96,4 @@ __all__ = [
96
96
  "MissingQuantityError",
97
97
  ]
98
98
 
99
- __version__ = "0.7.2"
99
+ __version__ = "0.8.0"
@@ -15,12 +15,13 @@ them from Jerlov (1968) Table XXI and reproduces R for every row.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import warnings
18
19
  from dataclasses import dataclass
19
20
 
20
21
  import numpy as np
21
22
 
22
23
  from . import _data
23
- from .water import _like_input, _require_number
24
+ from .water import ProvenanceWarning, _like_input, _require_number
24
25
 
25
26
  CITATION = (
26
27
  "Paulson, C. A. and Simpson, J. J. (1977), 'Irradiance measurements in "
@@ -42,15 +43,33 @@ class ShortwaveParameters:
42
43
  zeta2_m: float
43
44
  """Attenuation length of the slow term, metres. Blue-green light."""
44
45
  fit_depth_m: float | None
45
- """Depth range the row was fitted over. Not printed in the table."""
46
+ """Depth the row was fitted down to: 100 m, or 50 m for ``I_upper50``.
47
+ The paper's text says so; the table does not. None for the rows that are
48
+ not Jerlov types."""
46
49
  note: str
47
50
 
48
51
  def fraction_at(self, depth_m):
49
- """``I(z)/I(0)`` at one or more depths."""
52
+ """``I(z)/I(0)`` at one or more depths.
53
+
54
+ Below :attr:`fit_depth_m` the two exponentials are extrapolated
55
+ beyond the data they were fitted to, and a
56
+ :class:`~jerlov.ProvenanceWarning` says so. Jerlov's own Table XXI
57
+ goes deeper for the clearer types; see
58
+ :func:`jerlov1968_solar_fraction`.
59
+ """
50
60
  z = np.atleast_1d(np.asarray(depth_m, dtype=float))
51
61
  _require_number(z, "depth_m")
52
62
  if np.any(z < 0):
53
63
  raise ValueError("depth_m cannot be negative")
64
+ if self.fit_depth_m is not None and np.any(z > self.fit_depth_m):
65
+ warnings.warn(
66
+ f"{self.key}: Paulson & Simpson (1977) fitted these "
67
+ f"parameters to the upper {self.fit_depth_m:g} m; at "
68
+ f"{float(np.max(z)):g} m the two exponentials are "
69
+ "extrapolated. See DATA.md section 14.",
70
+ ProvenanceWarning,
71
+ stacklevel=_data.caller_stacklevel(),
72
+ )
54
73
  out = (self.R * np.exp(-z / self.zeta1_m)
55
74
  + (1 - self.R) * np.exp(-z / self.zeta2_m))
56
75
  return _like_input(out, depth_m)
@@ -148,6 +148,14 @@ class Water:
148
148
  self.source = source
149
149
  self._flags = dict(flags or {})
150
150
  for key, statuses in self._flags.items():
151
+ if key not in self._series:
152
+ # Ignoring it would drop the caller's doubt without a word,
153
+ # which is what flags exist to prevent; a misspelt key such
154
+ # as "kd" for "Kd" would never warn.
155
+ raise ValueError(
156
+ f"flags given for {key!r}, which this water does not "
157
+ f"carry (it has {', '.join(sorted(self._series))})"
158
+ )
151
159
  if len(statuses) != self.wavelengths.size:
152
160
  raise ValueError(
153
161
  f"flags[{key!r}] has {len(statuses)} entries; it needs one "
@@ -467,7 +475,7 @@ def water(water_type: str, source: str = "williamson2022") -> Water:
467
475
  )
468
476
 
469
477
 
470
- def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
478
+ def water_type_at_depth(surface_water_type: str, depth_m):
471
479
  """The Jerlov type that typically applies at ``depth_m``.
472
480
 
473
481
  The classification is defined on the top 10 m, but clarity changes with
@@ -480,6 +488,11 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
480
488
  wherever fewer than ten campaigns supported one. Coastal types run out
481
489
  quickly: 3C is declared only to 70 m, and 9C not at all below 10 m.
482
490
 
491
+ ``depth_m`` may be a single depth, which gives a string or ``None``, or
492
+ an array of depths, which gives an object array of the same shape::
493
+
494
+ jerlov.water_type_at_depth("I", [0, 30, 60]) # ['I', 'IA', 'IB']
495
+
483
496
  This is a lookup, not a correction applied on your behalf. To use it::
484
497
 
485
498
  deeper = jerlov.water_type_at_depth("I", 60.0) # "IB"
@@ -491,12 +504,12 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
491
504
  any particular place or season. The paper says so explicitly, and gives
492
505
  per-cell cruise and month counts for anyone who needs to judge that.
493
506
  """
494
- depth_m = float(depth_m)
495
- if not np.isfinite(depth_m):
507
+ depths = np.asarray(depth_m, dtype=float)
508
+ if not np.all(np.isfinite(depths)):
496
509
  # NaN fails every comparison below, so it would otherwise fall
497
510
  # through to "no statement" and look like a deliberate answer.
498
- raise ValueError(f"depth_m must be a finite number, not {depth_m!r}")
499
- if depth_m < 0:
511
+ raise ValueError(f"depth_m must be finite numbers, not {depth_m!r}")
512
+ if np.any(depths < 0):
500
513
  raise ValueError("depth_m cannot be negative")
501
514
  rows = _data._rows("williamson2023_depth.csv")
502
515
  known = {r["surface_water_type"] for r in rows}
@@ -505,20 +518,31 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
505
518
  f"unknown water type {surface_water_type!r} "
506
519
  f"(known: {', '.join(sorted(known))})"
507
520
  )
508
- own = [r for r in rows if r["surface_water_type"] == surface_water_type]
509
- deepest = max(float(r["depth_max_m"]) for r in own)
510
- for row in own:
511
- top, bottom = float(row["depth_min_m"]), float(row["depth_max_m"])
512
- # A boundary belongs to the layer below it, except at the bottom of
513
- # the deepest layer, which has no layer below: 200 m is still
514
- # inside the paper's profile.
515
- if top <= depth_m < bottom or depth_m == bottom == deepest:
516
- return row["water_type"] or None
517
- # Beyond 200 m the paper makes no statement at all.
518
- return None
521
+ own = [(float(r["depth_min_m"]), float(r["depth_max_m"]),
522
+ r["water_type"] or None)
523
+ for r in rows if r["surface_water_type"] == surface_water_type]
524
+ deepest = max(bottom for _, bottom, _ in own)
525
+
526
+ def at(depth: float) -> str | None:
527
+ for top, bottom, water_type in own:
528
+ # A boundary belongs to the layer below it, except at the bottom
529
+ # of the deepest layer, which has no layer below: 200 m is still
530
+ # inside the paper's profile.
531
+ if top <= depth < bottom or depth == bottom == deepest:
532
+ return water_type
533
+ # Beyond 200 m the paper makes no statement at all.
534
+ return None
535
+
536
+ if depths.ndim == 0:
537
+ return at(float(depths))
538
+ out = np.empty(depths.shape, dtype=object)
539
+ for index, depth in np.ndenumerate(depths):
540
+ out[index] = at(float(depth))
541
+ return out
519
542
 
520
543
 
521
- @dataclass(frozen=True, eq=False) # == on arrays has no single answer
544
+ # == on arrays has no single answer; the generated repr printed every array.
545
+ @dataclass(frozen=True, eq=False, repr=False)
522
546
  class Descent:
523
547
  """Downwelling irradiance carried down through changing water types."""
524
548
 
@@ -534,6 +558,23 @@ class Descent:
534
558
  transmittance: np.ndarray
535
559
  """``Ed(depth) / Ed(0)``: ``exp(-sum(Kd_i * thickness_i))``."""
536
560
 
561
+ def __repr__(self) -> str:
562
+ # Adjacent 10 m layers of one type are shown as one span.
563
+ spans: list[list] = []
564
+ for top, bottom, water_type in self.layers:
565
+ if spans and spans[-1][2] == water_type:
566
+ spans[-1][1] = bottom
567
+ else:
568
+ spans.append([top, bottom, water_type])
569
+ path = ", ".join(f"{t} {lo:g}-{hi:g}" for lo, hi, t in spans)
570
+ path = f"{path} m" if path else "no layer crossed"
571
+ n = np.size(self.wavelengths)
572
+ return (
573
+ f"<Descent {self.surface_water_type} to {self.depth_m:g} m: "
574
+ f"{path}; Kd from {self.source}; "
575
+ f"{n} wavelength{'s' if n != 1 else ''}>"
576
+ )
577
+
537
578
 
538
579
  def descend(surface_water_type: str, depth_m: float, wavelengths, *,
539
580
  source: str = "jerlov1976") -> Descent:
@@ -1,21 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jerlov
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Inherent optical properties of Jerlov water types, with provenance
5
5
  Author: T. Ishibashi
6
6
  License-Expression: Apache-2.0
7
7
  Keywords: ocean optics,Jerlov water types,inherent optical properties
8
- Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Intended Audience :: Science/Research
10
+ Classifier: Operating System :: OS Independent
10
11
  Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
11
17
  Classifier: Topic :: Scientific/Engineering :: Physics
18
+ Classifier: Typing :: Typed
12
19
  Requires-Python: >=3.10
13
20
  Description-Content-Type: text/markdown
14
21
  License-File: LICENSE
15
22
  License-File: NOTICE
16
23
  Requires-Dist: numpy>=1.22
17
- Provides-Extra: plot
18
- Requires-Dist: matplotlib; extra == "plot"
19
24
  Provides-Extra: test
20
25
  Requires-Dist: pytest; extra == "test"
21
26
  Provides-Extra: build
@@ -72,7 +77,8 @@ paper's own constant, so constants are attached to sources.
72
77
 
73
78
  ## What this package will not do
74
79
 
75
- - **Extrapolate.** Asking for a wavelength outside the data raises.
80
+ - **Extrapolate.** Asking for a wavelength outside the data raises. Values
81
+ that a source itself extrapolated are returned, but marked, and they warn.
76
82
  - **Fill a gap.** Where a published value is wrong and could not be
77
83
  recovered, the value is NaN and stays NaN through interpolation.
78
84
  - **Supply bb.** The backscattering coefficient is not determined by the
@@ -260,6 +266,7 @@ coastal water clears as you go down.
260
266
  jerlov.water_type_at_depth("I", 60.0) # 'IB'
261
267
  jerlov.water_type_at_depth("3C", 45.0) # 'II'
262
268
  jerlov.water_type_at_depth("9C", 15.0) # None: the paper declined to say
269
+ jerlov.water_type_at_depth("I", [0, 30, 60]) # array(['I', 'IA', 'IB'], dtype=object)
263
270
  ```
264
271
 
265
272
  `None` means fewer than ten measurement campaigns supported a declaration, so
@@ -4,8 +4,5 @@ numpy>=1.22
4
4
  openpyxl
5
5
  colour-science
6
6
 
7
- [plot]
8
- matplotlib
9
-
10
7
  [test]
11
8
  pytest
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jerlov"
7
- version = "0.7.2"
7
+ version = "0.8.0"
8
8
  description = "Inherent optical properties of Jerlov water types, with provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -14,14 +14,20 @@ license-files = ["LICENSE", "NOTICE"]
14
14
  authors = [{name = "T. Ishibashi"}]
15
15
  keywords = ["ocean optics", "Jerlov water types", "inherent optical properties"]
16
16
  classifiers = [
17
- "Development Status :: 2 - Pre-Alpha",
17
+ "Development Status :: 4 - Beta",
18
18
  "Intended Audience :: Science/Research",
19
+ "Operating System :: OS Independent",
19
20
  "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
20
26
  "Topic :: Scientific/Engineering :: Physics",
27
+ "Typing :: Typed",
21
28
  ]
22
29
 
23
30
  [project.optional-dependencies]
24
- plot = ["matplotlib"]
25
31
  test = ["pytest"]
26
32
  build = ["openpyxl", "colour-science"]
27
33
 
@@ -338,6 +338,15 @@ def test_flags_must_cover_every_wavelength():
338
338
  Water([400.0, 500.0, 600.0], a=[0.1, 0.2, 0.3], flags={"a": ("ok",)})
339
339
 
340
340
 
341
+ def test_flags_for_a_quantity_the_water_lacks_are_refused():
342
+ """They used to be kept and never consulted, so the doubt they recorded
343
+ was lost without a word; "kd" for "Kd" was lost the same way."""
344
+ with pytest.raises(ValueError, match="flags given for 'b'.*it has a"):
345
+ Water([400.0, 500.0], a=[0.1, 0.2], flags={"b": ("suspect", "ok")})
346
+ with pytest.raises(ValueError, match="flags given for 'kd'"):
347
+ Water([400.0, 500.0], kd=[0.1, 0.2], flags={"kd": ("suspect", "ok")})
348
+
349
+
341
350
  def test_b_from_c_warns_when_c_is_below_pure_water():
342
351
  """b came out negative without a word; kd_spectrum already warned in the
343
352
  same situation."""
@@ -161,3 +161,32 @@ def test_descent_to_the_surface_is_no_attenuation():
161
161
  d = jerlov.descend("II", 0.0, [450.0, 550.0])
162
162
  assert np.all(d.transmittance == 1.0)
163
163
  assert d.layers == ()
164
+
165
+
166
+ def test_an_array_of_depths_gives_one_type_per_depth():
167
+ depths = np.array([[0.0, 30.0], [60.0, 250.0]])
168
+ types = jerlov.water_type_at_depth("I", depths)
169
+ assert types.shape == depths.shape
170
+ assert types.tolist() == [["I", "IA"], ["IB", None]]
171
+ for depth, got in zip(depths.ravel(), types.ravel()):
172
+ assert got == jerlov.water_type_at_depth("I", float(depth))
173
+ # A list is an array too; a scalar is still a plain string.
174
+ assert list(jerlov.water_type_at_depth("9C", [5, 15])) == ["9C", None]
175
+ assert isinstance(jerlov.water_type_at_depth("I", np.float64(5)), str)
176
+
177
+
178
+ def test_one_bad_depth_in_an_array_refuses_the_whole_array():
179
+ with pytest.raises(ValueError, match="finite"):
180
+ jerlov.water_type_at_depth("I", [10.0, float("nan")])
181
+ with pytest.raises(ValueError, match="cannot be negative"):
182
+ jerlov.water_type_at_depth("I", [10.0, -1.0])
183
+
184
+
185
+ def test_a_descent_prints_its_path_not_its_arrays():
186
+ """The generated repr printed every wavelength and transmittance."""
187
+ d = jerlov.descend("1C", 45.0, np.arange(400.0, 701.0, 1.0))
188
+ text = repr(d)
189
+ assert text == ("<Descent 1C to 45 m: 1C 0-20, III 20-40, II 40-45 m; "
190
+ "Kd from jerlov1976; 301 wavelengths>")
191
+ assert repr(jerlov.descend("II", 0.0, 500.0)).startswith(
192
+ "<Descent II to 0 m: no layer crossed;")
@@ -322,3 +322,50 @@ def test_examples_use_only_the_public_api():
322
322
  offenders = [p.name for p in ROOT.glob("examples/*.py")
323
323
  if "_data" in p.read_text()]
324
324
  assert not offenders, f"examples reaching into jerlov._data: {offenders}"
325
+
326
+
327
+ @source_tree
328
+ def test_the_python_classifiers_are_the_versions_CI_runs():
329
+ """PyPI shows these. A version listed but never tested is a claim, as
330
+ section 15 of DECISIONS.md found for numpy; one tested but not listed
331
+ is support nobody can see."""
332
+ toml = (ROOT / "pyproject.toml").read_text()
333
+ listed = set(re.findall(
334
+ r'"Programming Language :: Python :: (3\.\d+)"', toml))
335
+ ci = (ROOT / ".github" / "workflows" / "test.yml").read_text()
336
+ matrix = re.search(r"python-version: \[(.+?)\]", ci).group(1)
337
+ tested = set(re.findall(r'"(3\.\d+)"', matrix))
338
+ assert listed == tested, (
339
+ f"pyproject.toml lists Python {sorted(listed)}, CI tests {sorted(tested)}"
340
+ )
341
+ floor = re.search(r'^requires-python = ">=(3\.\d+)"', toml, re.M).group(1)
342
+ assert floor == min(tested, key=lambda v: int(v.split(".")[1])), (
343
+ f"requires-python says >={floor}, but CI's oldest is {min(tested)}"
344
+ )
345
+
346
+
347
+ #: Import name of each distribution an extra installs.
348
+ _IMPORTED_AS = {"pytest": "pytest", "openpyxl": "openpyxl",
349
+ "colour-science": "colour"}
350
+
351
+
352
+ @source_tree
353
+ def test_every_optional_extra_is_used_by_something():
354
+ """The `plot` extra installed matplotlib, which nothing imported."""
355
+ toml = (ROOT / "pyproject.toml").read_text()
356
+ block = re.search(
357
+ r"^\[project\.optional-dependencies\]\n(.*?)(?=^\[)", toml,
358
+ re.M | re.S).group(1)
359
+ code = "\n".join(p.read_text() for d in ("jerlov", "tests", "tools",
360
+ "examples")
361
+ for p in (ROOT / d).rglob("*.py"))
362
+ for extra, spec in re.findall(r"^(\w+) = \[(.*)\]$", block, re.M):
363
+ for dist in re.findall(r'"([A-Za-z0-9_.-]+)', spec):
364
+ assert dist in _IMPORTED_AS, (
365
+ f"extra {extra!r} installs {dist}; add its import name to "
366
+ "_IMPORTED_AS"
367
+ )
368
+ name = _IMPORTED_AS[dist]
369
+ assert re.search(rf"^\s*(import|from) {name}\b", code, re.M), (
370
+ f"extra {extra!r} installs {dist}, which nothing imports"
371
+ )
@@ -84,9 +84,9 @@ def test_the_alternative_type_I_fit_is_reachable():
84
84
  assert fifty.fit_depth_m == 50
85
85
  assert fifty.water_type == "I"
86
86
  assert fifty.R != hundred.R
87
- # The paper gives it because the profile changes slope; deeper down they
88
- # must therefore disagree.
89
- assert abs(fifty.fraction_at(80.0) - hundred.fraction_at(80.0)) > 1e-4
87
+ # The paper gives it because the profile changes slope; they must
88
+ # therefore disagree, even inside the 50 m both were fitted over.
89
+ assert abs(fifty.fraction_at(40.0) - hundred.fraction_at(40.0)) > 1e-4
90
90
 
91
91
 
92
92
  def test_the_rows_that_are_not_water_types_are_not_offered():
@@ -130,3 +130,27 @@ def test_table_xxi_is_public():
130
130
  assert len(jerlov.jerlov1968_solar_fraction(t)[0]) == 12
131
131
  with pytest.raises(KeyError, match="known:"):
132
132
  jerlov.jerlov1968_solar_fraction("IV")
133
+
134
+
135
+ def test_below_the_fitted_depth_it_warns():
136
+ """Paulson & Simpson fitted the upper 100 m, and 50 m for I_upper50."""
137
+ import warnings
138
+
139
+ with warnings.catch_warnings():
140
+ warnings.simplefilter("error")
141
+ jerlov.solar_fraction("IB", 100.0)
142
+ jerlov.shortwave_parameters("I_upper50").fraction_at([0.0, 50.0])
143
+ with pytest.warns(jerlov.ProvenanceWarning, match="upper 100 m"):
144
+ jerlov.solar_fraction("IB", 100.5)
145
+ with pytest.warns(jerlov.ProvenanceWarning, match="upper 50 m"):
146
+ jerlov.shortwave_parameters("I_upper50").fraction_at([10.0, 60.0])
147
+
148
+
149
+ def test_rows_that_are_not_types_carry_no_fit_depth_and_do_not_warn():
150
+ import warnings
151
+
152
+ p = jerlov.shortwave_parameters("run_1", include_non_types=True)
153
+ assert p.fit_depth_m is None
154
+ with warnings.catch_warnings():
155
+ warnings.simplefilter("error")
156
+ p.fraction_at(150.0)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes