jerlov 0.2.1__tar.gz → 0.3.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 (43) hide show
  1. {jerlov-0.2.1/jerlov.egg-info → jerlov-0.3.0}/PKG-INFO +30 -10
  2. {jerlov-0.2.1 → jerlov-0.3.0}/README.md +29 -9
  3. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/__init__.py +15 -1
  4. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/_data.py +10 -0
  5. jerlov-0.3.0/jerlov/backscattering.py +215 -0
  6. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/colour.py +12 -10
  7. jerlov-0.3.0/jerlov/data/boss2001_chi.csv +13 -0
  8. {jerlov-0.2.1 → jerlov-0.3.0/jerlov.egg-info}/PKG-INFO +30 -10
  9. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/SOURCES.txt +3 -0
  10. {jerlov-0.2.1 → jerlov-0.3.0}/pyproject.toml +1 -1
  11. jerlov-0.3.0/tests/test_backscattering.py +167 -0
  12. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_colour.py +8 -3
  13. jerlov-0.3.0/tests/test_packaging.py +237 -0
  14. jerlov-0.2.1/tests/test_packaging.py +0 -78
  15. {jerlov-0.2.1 → jerlov-0.3.0}/LICENSE +0 -0
  16. {jerlov-0.2.1 → jerlov-0.3.0}/NOTICE +0 -0
  17. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/__init__.py +0 -0
  18. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/austin1986_kd.csv +0 -0
  19. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/austin1986_model.csv +0 -0
  20. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
  21. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/cie_d65.csv +0 -0
  22. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1968_kd.csv +0 -0
  23. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
  24. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1976_kd.csv +0 -0
  25. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/paulson1977_shortwave.csv +0 -0
  26. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/smart2007_b_from_c.csv +0 -0
  27. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/solonenko2015_iop.csv +0 -0
  28. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2022_iop.csv +0 -0
  29. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2022_measured.csv +0 -0
  30. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2023_depth.csv +0 -0
  31. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/scene.py +0 -0
  32. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/shortwave.py +0 -0
  33. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/sources.py +0 -0
  34. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/water.py +0 -0
  35. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/dependency_links.txt +0 -0
  36. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/requires.txt +0 -0
  37. {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/top_level.txt +0 -0
  38. {jerlov-0.2.1 → jerlov-0.3.0}/setup.cfg +0 -0
  39. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_api.py +0 -0
  40. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_depth.py +0 -0
  41. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_reproduces_papers.py +0 -0
  42. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_scene.py +0 -0
  43. {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_shortwave.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jerlov
3
- Version: 0.2.1
3
+ Version: 0.3.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
@@ -203,6 +203,25 @@ Two silent errors are checked rather than assumed:
203
203
  - **Gamut.** Underwater colours often fall outside sRGB. Clipping changes
204
204
  them, so `GamutWarning` says so.
205
205
 
206
+ ## If you measured backscattering
207
+
208
+ `Water.bb` refuses to guess a backscattering ratio. If you have an instrument
209
+ you do not have to:
210
+
211
+ ```python
212
+ r = jerlov.bb_from_vsf(beta=0.0021, angle_deg=140, wavelength_nm=532)
213
+ r.bb, r.particulate, r.water # 1/m
214
+ r.chi_p, r.quoted_error_percent # 1.18, 3.5
215
+ ```
216
+
217
+ Boss & Pegau (2001). The pure sea water terms are analytic and are checked
218
+ against the definition of bb rather than transcribed; only the particle
219
+ conversion is tabulated. Angles outside 90-170 degrees are refused, and 170
220
+ warns: its quoted spread is **34.8 percent** against 3-6 in the middle.
221
+
222
+ This gives the bb of the water your instrument was in. It still gives no bb
223
+ for a Jerlov water type, because nothing does.
224
+
206
225
  ## The type changes with depth
207
226
 
208
227
  The classification is defined on the top 10 m, but clarity does not stay put.
@@ -257,21 +276,22 @@ rather than substituting a neighbour.
257
276
  ## Examples
258
277
 
259
278
  ```
260
- python examples/sources_disagree.py
261
- python examples/synthetic_underwater_images.py
279
+ python examples/sources_disagree.py why the source is an argument
280
+ python examples/from_one_measurement.py from an instrument reading
281
+ python examples/synthetic_underwater_images.py appearance at range and depth
282
+ python examples/solar_heating.py for ocean circulation models
283
+ python examples/what_an_eye_sees.py any spectral sensitivity
262
284
  ```
263
285
 
264
- The first shows why the source is an argument: at 510 nm the scattering
265
- coefficient of Jerlov III differs by a factor of 3.8 between the two published
266
- sets. The second synthesises how reflectance patches appear at range and
267
- depth, white-balanced two ways, and ends with a list of everything it assumed.
268
-
269
- Both are run by CI, so an example that has stopped working is a failed build.
286
+ Five scripts, each aimed at a different reader; `examples/README.md` says
287
+ which to start with. CI runs all of them on every push, so an example that has
288
+ stopped working is a failed build. Every one ends with the assumptions it
289
+ made.
270
290
 
271
291
  ## Provenance and design
272
292
 
273
293
  `DATA.md` records, for every shipped table, where it came from, what was
274
- verified, and what is known to be wrong with it. Seventeen entries are
294
+ verified, and what is known to be wrong with it. Eighteen entries are
275
295
  documented there: eight confirmed defects in the source literature, three
276
296
  questions the first edition of Jerlov settled, and the rest notes.
277
297
 
@@ -178,6 +178,25 @@ Two silent errors are checked rather than assumed:
178
178
  - **Gamut.** Underwater colours often fall outside sRGB. Clipping changes
179
179
  them, so `GamutWarning` says so.
180
180
 
181
+ ## If you measured backscattering
182
+
183
+ `Water.bb` refuses to guess a backscattering ratio. If you have an instrument
184
+ you do not have to:
185
+
186
+ ```python
187
+ r = jerlov.bb_from_vsf(beta=0.0021, angle_deg=140, wavelength_nm=532)
188
+ r.bb, r.particulate, r.water # 1/m
189
+ r.chi_p, r.quoted_error_percent # 1.18, 3.5
190
+ ```
191
+
192
+ Boss & Pegau (2001). The pure sea water terms are analytic and are checked
193
+ against the definition of bb rather than transcribed; only the particle
194
+ conversion is tabulated. Angles outside 90-170 degrees are refused, and 170
195
+ warns: its quoted spread is **34.8 percent** against 3-6 in the middle.
196
+
197
+ This gives the bb of the water your instrument was in. It still gives no bb
198
+ for a Jerlov water type, because nothing does.
199
+
181
200
  ## The type changes with depth
182
201
 
183
202
  The classification is defined on the top 10 m, but clarity does not stay put.
@@ -232,21 +251,22 @@ rather than substituting a neighbour.
232
251
  ## Examples
233
252
 
234
253
  ```
235
- python examples/sources_disagree.py
236
- python examples/synthetic_underwater_images.py
254
+ python examples/sources_disagree.py why the source is an argument
255
+ python examples/from_one_measurement.py from an instrument reading
256
+ python examples/synthetic_underwater_images.py appearance at range and depth
257
+ python examples/solar_heating.py for ocean circulation models
258
+ python examples/what_an_eye_sees.py any spectral sensitivity
237
259
  ```
238
260
 
239
- The first shows why the source is an argument: at 510 nm the scattering
240
- coefficient of Jerlov III differs by a factor of 3.8 between the two published
241
- sets. The second synthesises how reflectance patches appear at range and
242
- depth, white-balanced two ways, and ends with a list of everything it assumed.
243
-
244
- Both are run by CI, so an example that has stopped working is a failed build.
261
+ Five scripts, each aimed at a different reader; `examples/README.md` says
262
+ which to start with. CI runs all of them on every push, so an example that has
263
+ stopped working is a failed build. Every one ends with the assumptions it
264
+ made.
245
265
 
246
266
  ## Provenance and design
247
267
 
248
268
  `DATA.md` records, for every shipped table, where it came from, what was
249
- verified, and what is known to be wrong with it. Seventeen entries are
269
+ verified, and what is known to be wrong with it. Eighteen entries are
250
270
  documented there: eight confirmed defects in the source literature, three
251
271
  questions the first edition of Jerlov settled, and the rest notes.
252
272
 
@@ -4,6 +4,14 @@ Every coefficient carries its source, and values that a published table got
4
4
  wrong are flagged rather than quietly repaired.
5
5
  """
6
6
 
7
+ from .backscattering import (
8
+ AngleWarning,
9
+ Backscattering,
10
+ bb_from_vsf,
11
+ particulate_chi,
12
+ pure_water_backscattering,
13
+ pure_water_vsf,
14
+ )
7
15
  from .colour import (
8
16
  CoverageWarning,
9
17
  GamutWarning,
@@ -57,6 +65,12 @@ __all__ = [
57
65
  "water_type_at_depth",
58
66
  "shortwave_parameters",
59
67
  "solar_fraction",
68
+ "bb_from_vsf",
69
+ "particulate_chi",
70
+ "pure_water_vsf",
71
+ "pure_water_backscattering",
72
+ "Backscattering",
73
+ "AngleWarning",
60
74
  "ShortwaveParameters",
61
75
  "kd_spectrum",
62
76
  "b_from_c",
@@ -64,4 +78,4 @@ __all__ = [
64
78
  "MissingQuantityError",
65
79
  ]
66
80
 
67
- __version__ = "0.2.1"
81
+ __version__ = "0.3.0"
@@ -13,6 +13,16 @@ from importlib import resources
13
13
 
14
14
  import numpy as np
15
15
 
16
+ import numpy as np
17
+
18
+ #: numpy.trapezoid is the name from NumPy 2.0; before that it was numpy.trapz,
19
+ #: which 2.0 removed. The package claims to work from NumPy 1.22, so neither
20
+ #: can be assumed. This lives here rather than in a module about colour so
21
+ #: that anything integrating over a grid finds it: putting it in colour.py
22
+ #: meant two later modules reached for np.trapezoid and broke the oldest
23
+ #: supported NumPy.
24
+ trapezoid = getattr(np, "trapezoid", None) or np.trapz
25
+
16
26
  #: Values whose ``status`` is one of these should not be used without the
17
27
  #: caller being told. See README sections 1-6.
18
28
  QUESTIONABLE = frozenset({"suspect", "missing", "extrapolated",
@@ -0,0 +1,215 @@
1
+ """From a backscattering sensor's reading to the backscattering coefficient.
2
+
3
+ `Water.bb` refuses to guess a backscattering ratio, because the Jerlov
4
+ classification does not determine one. If you have an instrument, you do not
5
+ have to guess: a HydroScat, an ECO-BB or a VSF meter measures the volume
6
+ scattering function at one angle in the backward hemisphere, and there is a
7
+ published relation from that to bb.
8
+
9
+ bb = 2 pi chi_p(theta) [beta(theta) - beta_w(theta)] + bb_w
10
+
11
+ Boss & Pegau (2001) Eq. (10). The water terms are analytic, from Morel's
12
+ formula for the volume scattering function of pure sea water; only chi_p is
13
+ tabulated, from 41 measured scattering functions.
14
+
15
+ This does not tell you the bb of a Jerlov water type. Nothing does. It tells
16
+ you the bb of the water your instrument was in.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ import warnings
23
+ from dataclasses import dataclass
24
+
25
+ import numpy as np
26
+
27
+ from . import _data
28
+ from .water import _like_input
29
+
30
+ CITATION = (
31
+ "Boss, E. and Pegau, W. S. (2001), 'Relationship of light scattering at "
32
+ "an angle in the backward direction to the backscattering coefficient', "
33
+ "Appl. Opt. 40, 5503-5507"
34
+ )
35
+ DOI = "10.1364/AO.40.005503"
36
+
37
+ #: Depolarisation ratio of pure sea water. Morel gives a range of 0.07 to
38
+ #: 0.11 and suggests 0.09, which is what Boss & Pegau use.
39
+ DEPOLARISATION_RATIO = 0.09
40
+
41
+ #: The angles the tabulation covers. Outside this the relation is not defined.
42
+ MIN_ANGLE_DEG, MAX_ANGLE_DEG = 90.0, 170.0
43
+
44
+ #: Above this the tabulated error exceeds 10 percent and a warning is raised.
45
+ _ERROR_WARN_PERCENT = 10.0
46
+
47
+
48
+ class AngleWarning(UserWarning):
49
+ """The conversion is poorly constrained at the requested angle.
50
+
51
+ The shape of the volume scattering function varies most steeply near 90
52
+ and 180 degrees, so a single-angle measurement there pins bb least well.
53
+ Both Boss & Pegau (2001) and Maffione & Dana (1997) recommend 110 to 160
54
+ degrees.
55
+ """
56
+
57
+
58
+ def _ratio() -> float:
59
+ return (1 - DEPOLARISATION_RATIO) / (1 + DEPOLARISATION_RATIO)
60
+
61
+
62
+ def pure_water_vsf(angle_deg, wavelength_nm, salinity_psu: float = 37.0):
63
+ """Volume scattering function of pure sea water, 1/(m sr).
64
+
65
+ Morel's formula as given by Boss & Pegau Eqs. (4) and (5)::
66
+
67
+ A(lambda, S) = 1.38 (lambda / 500 nm)^-4.32 (1 + 0.3 S / 37) 1e-4
68
+ beta_w(theta) = A [1 + cos^2(theta) (1 - delta) / (1 + delta)]
69
+
70
+ The amplitude carries about 15 percent uncertainty, which the authors give
71
+ as the agreement between measurement and theory.
72
+ """
73
+ angle = np.atleast_1d(np.asarray(angle_deg, dtype=float))
74
+ amplitude = (1.38 * (np.asarray(wavelength_nm, dtype=float) / 500.0) ** -4.32
75
+ * (1 + 0.3 * salinity_psu / 37.0) * 1e-4)
76
+ out = amplitude * (1 + _ratio() * np.cos(np.radians(angle)) ** 2)
77
+ return _like_input(np.atleast_1d(out), angle_deg)
78
+
79
+
80
+ def pure_water_backscattering(wavelength_nm, salinity_psu: float = 37.0):
81
+ """``bb_w``, the backscattering coefficient of pure sea water, 1/m.
82
+
83
+ Obtained by integrating :func:`pure_water_vsf` over the backward
84
+ hemisphere, which for this angular shape is analytic.
85
+ """
86
+ r = _ratio()
87
+ amplitude = (1.38 * (np.asarray(wavelength_nm, dtype=float) / 500.0) ** -4.32
88
+ * (1 + 0.3 * salinity_psu / 37.0) * 1e-4)
89
+ # integral of (1 + r cos^2) sin over 90..180 degrees is 1 + r/3
90
+ out = 2.0 * math.pi * amplitude * (1 + r / 3.0)
91
+ return _like_input(np.atleast_1d(out), wavelength_nm)
92
+
93
+
94
+ def _chi_table():
95
+ rows = [r for r in _data._rows("boss2001_chi.csv")
96
+ if r["source"] == "boss2001"]
97
+ angles = np.array([float(r["angle_deg"]) for r in rows])
98
+ order = np.argsort(angles)
99
+ return (angles[order],
100
+ np.array([float(rows[i]["chi"]) for i in order]),
101
+ np.array([float(rows[i]["percent_error"]) for i in order]))
102
+
103
+
104
+ def particulate_chi(angle_deg):
105
+ """``chi_p`` at one angle, interpolated within Boss & Pegau Table 1.
106
+
107
+ Refuses outside 90 to 170 degrees, the range their measurements cover.
108
+ """
109
+ angle = float(angle_deg)
110
+ if not MIN_ANGLE_DEG <= angle <= MAX_ANGLE_DEG:
111
+ raise ValueError(
112
+ f"chi_p is tabulated from {MIN_ANGLE_DEG:g} to {MAX_ANGLE_DEG:g} "
113
+ f"degrees and {angle:g} is outside that. Boss & Pegau made no "
114
+ "measurement beyond 170 degrees, and this package does not "
115
+ "extrapolate."
116
+ )
117
+ angles, values, _ = _chi_table()
118
+ return float(np.interp(angle, angles, values))
119
+
120
+
121
+ def _quoted_error(angle_deg: float) -> float:
122
+ angles, _, errors = _chi_table()
123
+ return float(np.interp(angle_deg, angles, errors))
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Backscattering:
128
+ """``bb`` recovered from a single-angle measurement."""
129
+
130
+ bb: float | np.ndarray
131
+ """Total backscattering coefficient, 1/m."""
132
+ particulate: float | np.ndarray
133
+ """The particle contribution, ``bb - bb_w``."""
134
+ water: float | np.ndarray
135
+ """``bb_w``, from Morel's formula rather than from your instrument."""
136
+ angle_deg: float
137
+ chi_p: float
138
+ quoted_error_percent: float
139
+ """Boss & Pegau's own spread for chi_p at this angle. Not a total error."""
140
+
141
+ def __repr__(self) -> str: # pragma: no cover - convenience only
142
+ return (f"<Backscattering bb={np.mean(self.bb):.5f} 1/m at "
143
+ f"{self.angle_deg:g} deg, chi_p={self.chi_p:.3f} "
144
+ f"+-{self.quoted_error_percent:.1f}%>")
145
+
146
+
147
+ def bb_from_vsf(beta, angle_deg: float, wavelength_nm, *,
148
+ salinity_psu: float = 37.0) -> Backscattering:
149
+ """Convert a measured volume scattering function to ``bb``.
150
+
151
+ Parameters
152
+ ----------
153
+ beta:
154
+ The measured volume scattering function at ``angle_deg``, in
155
+ 1/(m sr). This is what the instrument reports once calibrated; it
156
+ includes scattering by the water itself.
157
+ angle_deg:
158
+ The instrument's nominal scattering angle. 90 to 170 degrees.
159
+ wavelength_nm, salinity_psu:
160
+ Needed for the pure sea water terms, which are subtracted and then
161
+ added back as ``bb_w``.
162
+
163
+ Notes
164
+ -----
165
+ Boss & Pegau's water-removal route, their Eq. (10). Removing the water
166
+ first matters because chi differs between water and particles everywhere
167
+ except near 118 degrees, where the two curves cross; that is why
168
+ instruments cluster near 120.
169
+
170
+ The quoted error on the result is the spread of chi_p alone. The amplitude
171
+ of the water term carries a further 15 percent, and your instrument's own
172
+ calibration is on top of both.
173
+ """
174
+ angle = float(angle_deg)
175
+ chi_p = particulate_chi(angle)
176
+ error = _quoted_error(angle)
177
+ if error > _ERROR_WARN_PERCENT:
178
+ warnings.warn(
179
+ f"chi_p at {angle:g} degrees carries a quoted spread of "
180
+ f"{error:.1f}%, against 3-6% between 100 and 160 degrees. The "
181
+ "scattering function varies steeply here and a single-angle "
182
+ "measurement pins bb poorly.",
183
+ AngleWarning,
184
+ stacklevel=2,
185
+ )
186
+
187
+ scalar = np.ndim(beta) == 0 and np.ndim(wavelength_nm) == 0
188
+ beta = np.atleast_1d(np.asarray(beta, dtype=float))
189
+ if np.any(beta < 0):
190
+ raise ValueError("a volume scattering function cannot be negative")
191
+ beta_w = np.atleast_1d(pure_water_vsf(angle, wavelength_nm, salinity_psu))
192
+ bb_w = np.atleast_1d(pure_water_backscattering(wavelength_nm, salinity_psu))
193
+
194
+ particulate = 2.0 * math.pi * chi_p * (beta - beta_w)
195
+ if np.any(particulate < 0):
196
+ warnings.warn(
197
+ "the measured scattering is below that of pure sea water alone, "
198
+ "so the particle contribution came out negative. Check the "
199
+ "calibration, the wavelength and the salinity.",
200
+ AngleWarning,
201
+ stacklevel=2,
202
+ )
203
+
204
+ def shape(values):
205
+ values = np.atleast_1d(values)
206
+ return float(values[0]) if scalar else values
207
+
208
+ return Backscattering(
209
+ bb=shape(particulate + bb_w),
210
+ particulate=shape(particulate),
211
+ water=shape(np.broadcast_to(bb_w, particulate.shape)),
212
+ angle_deg=angle,
213
+ chi_p=chi_p,
214
+ quoted_error_percent=error,
215
+ )
@@ -22,9 +22,8 @@ import numpy as np
22
22
 
23
23
  from . import _data
24
24
 
25
- # numpy.trapezoid is the name from NumPy 2.0; before that it was numpy.trapz.
26
- # The package claims to work from NumPy 1.22, so it must not assume either.
27
- _trapezoid = getattr(np, "trapezoid", None) or np.trapz
25
+ #: Kept as a module-level name because tests and examples import it.
26
+ _trapezoid = _data.trapezoid
28
27
 
29
28
  #: sRGB primaries and white point, IEC 61966-2-1.
30
29
  SRGB_PRIMARIES = np.array([
@@ -151,15 +150,18 @@ def integrate_response(spectrum, wavelengths, response, response_wavelengths,
151
150
  if wavelengths.size < 2:
152
151
  raise ValueError("at least two wavelengths are needed to integrate")
153
152
 
154
- worst = min(
155
- _coverage(wavelengths, response_wavelengths, response[:, k])
156
- for k in range(response.shape[1])
157
- )
153
+ covered = [_coverage(wavelengths, response_wavelengths, response[:, k])
154
+ for k in range(response.shape[1])]
155
+ worst = min(covered)
158
156
  if worst < 0.999:
157
+ which = (f"channel {covered.index(worst)} of {len(covered)}"
158
+ if len(covered) > 1 else "it")
159
159
  warnings.warn(
160
- f"the spectrum spans {wavelengths[0]:g}-{wavelengths[-1]:g} nm and "
161
- f"covers only {worst:.1%} of the {name}; the integral is over the "
162
- "overlap and is biased by what was left out",
160
+ f"the spectrum spans {wavelengths[0]:g}-{wavelengths[-1]:g} nm; "
161
+ f"of the {name}, {which} is only {worst:.1%} covered "
162
+ f"(all channels: {', '.join(f'{c:.1%}' for c in covered)}). "
163
+ "The integral is over the overlap and is biased by what was left "
164
+ "out",
163
165
  CoverageWarning,
164
166
  stacklevel=2,
165
167
  )
@@ -0,0 +1,13 @@
1
+ source,angle_deg,chi,quantity,percent_error,status,note
2
+ boss2001,90,0.71,chi_p,4.3,ok,"the water contribution to the measured signal is largest here, so removing it matters most"
3
+ boss2001,100,0.9,chi_p,2.6,ok,
4
+ boss2001,110,1.03,chi_p,3.1,ok,
5
+ boss2001,120,1.12,chi_p,4.2,ok,
6
+ boss2001,130,1.17,chi_p,3.3,ok,
7
+ boss2001,140,1.18,chi_p,3.5,ok,
8
+ boss2001,150,1.13,chi_p,4.2,ok,
9
+ boss2001,160,1.0,chi_p,6.4,ok,
10
+ boss2001,170,0.62,chi_p,34.8,suspect,"the volume scattering function varies most steeply here, and no measurement in the set went beyond 170 deg"
11
+ oishi1990,120,1.14,chi,,other_definition,"chi including water, quoted by Maffione & Dana (1997) from Oishi (1990), Appl. Opt. 29, 4658"
12
+ oishi1990,140,1.08,chi,,other_definition,as above
13
+ maffione1997,140,1.08,chi,9.0,other_definition,"chi including water, Maffione & Dana (1997), Appl. Opt. 36, 6057, from Mie computations; the 9 percent is their standard deviation"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jerlov
3
- Version: 0.2.1
3
+ Version: 0.3.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
@@ -203,6 +203,25 @@ Two silent errors are checked rather than assumed:
203
203
  - **Gamut.** Underwater colours often fall outside sRGB. Clipping changes
204
204
  them, so `GamutWarning` says so.
205
205
 
206
+ ## If you measured backscattering
207
+
208
+ `Water.bb` refuses to guess a backscattering ratio. If you have an instrument
209
+ you do not have to:
210
+
211
+ ```python
212
+ r = jerlov.bb_from_vsf(beta=0.0021, angle_deg=140, wavelength_nm=532)
213
+ r.bb, r.particulate, r.water # 1/m
214
+ r.chi_p, r.quoted_error_percent # 1.18, 3.5
215
+ ```
216
+
217
+ Boss & Pegau (2001). The pure sea water terms are analytic and are checked
218
+ against the definition of bb rather than transcribed; only the particle
219
+ conversion is tabulated. Angles outside 90-170 degrees are refused, and 170
220
+ warns: its quoted spread is **34.8 percent** against 3-6 in the middle.
221
+
222
+ This gives the bb of the water your instrument was in. It still gives no bb
223
+ for a Jerlov water type, because nothing does.
224
+
206
225
  ## The type changes with depth
207
226
 
208
227
  The classification is defined on the top 10 m, but clarity does not stay put.
@@ -257,21 +276,22 @@ rather than substituting a neighbour.
257
276
  ## Examples
258
277
 
259
278
  ```
260
- python examples/sources_disagree.py
261
- python examples/synthetic_underwater_images.py
279
+ python examples/sources_disagree.py why the source is an argument
280
+ python examples/from_one_measurement.py from an instrument reading
281
+ python examples/synthetic_underwater_images.py appearance at range and depth
282
+ python examples/solar_heating.py for ocean circulation models
283
+ python examples/what_an_eye_sees.py any spectral sensitivity
262
284
  ```
263
285
 
264
- The first shows why the source is an argument: at 510 nm the scattering
265
- coefficient of Jerlov III differs by a factor of 3.8 between the two published
266
- sets. The second synthesises how reflectance patches appear at range and
267
- depth, white-balanced two ways, and ends with a list of everything it assumed.
268
-
269
- Both are run by CI, so an example that has stopped working is a failed build.
286
+ Five scripts, each aimed at a different reader; `examples/README.md` says
287
+ which to start with. CI runs all of them on every push, so an example that has
288
+ stopped working is a failed build. Every one ends with the assumptions it
289
+ made.
270
290
 
271
291
  ## Provenance and design
272
292
 
273
293
  `DATA.md` records, for every shipped table, where it came from, what was
274
- verified, and what is known to be wrong with it. Seventeen entries are
294
+ verified, and what is known to be wrong with it. Eighteen entries are
275
295
  documented there: eight confirmed defects in the source literature, three
276
296
  questions the first edition of Jerlov settled, and the rest notes.
277
297
 
@@ -4,6 +4,7 @@ README.md
4
4
  pyproject.toml
5
5
  jerlov/__init__.py
6
6
  jerlov/_data.py
7
+ jerlov/backscattering.py
7
8
  jerlov/colour.py
8
9
  jerlov/scene.py
9
10
  jerlov/shortwave.py
@@ -17,6 +18,7 @@ jerlov.egg-info/top_level.txt
17
18
  jerlov/data/__init__.py
18
19
  jerlov/data/austin1986_kd.csv
19
20
  jerlov/data/austin1986_model.csv
21
+ jerlov/data/boss2001_chi.csv
20
22
  jerlov/data/cie1931_2deg_cmf.csv
21
23
  jerlov/data/cie_d65.csv
22
24
  jerlov/data/jerlov1968_kd.csv
@@ -29,6 +31,7 @@ jerlov/data/williamson2022_iop.csv
29
31
  jerlov/data/williamson2022_measured.csv
30
32
  jerlov/data/williamson2023_depth.csv
31
33
  tests/test_api.py
34
+ tests/test_backscattering.py
32
35
  tests/test_colour.py
33
36
  tests/test_depth.py
34
37
  tests/test_packaging.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jerlov"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Inherent optical properties of Jerlov water types, with provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,167 @@
1
+ """Turning a single-angle measurement into bb, and the limits of doing so."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import warnings
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ import jerlov
12
+ from jerlov._data import trapezoid
13
+ from jerlov.backscattering import AngleWarning, DEPOLARISATION_RATIO
14
+
15
+
16
+ # -- the analytic half ----------------------------------------------------
17
+
18
+
19
+ def test_pure_water_backscattering_is_the_integral_of_its_own_vsf():
20
+ """bb_w must be what Eq. (3) gives when applied to Eq. (4)."""
21
+ for nm in (440.0, 532.0, 650.0):
22
+ theta = np.radians(np.linspace(90.0, 180.0, 90001))
23
+ vsf = jerlov.pure_water_vsf(np.degrees(theta), nm)
24
+ integrated = 2 * math.pi * float(trapezoid(vsf * np.sin(theta), theta))
25
+ assert jerlov.pure_water_backscattering(nm) == pytest.approx(
26
+ integrated, rel=1e-6
27
+ )
28
+
29
+
30
+ def test_chi_w_gives_the_same_answer_at_every_angle():
31
+ """The water conversion is exact, so it cannot depend on where you look."""
32
+ r = (1 - DEPOLARISATION_RATIO) / (1 + DEPOLARISATION_RATIO)
33
+ nm = 532.0
34
+ expected = jerlov.pure_water_backscattering(nm)
35
+ for angle in (90.0, 110.0, 130.0, 150.0, 170.0):
36
+ chi_w = (1 + r / 3) / (1 + r * math.cos(math.radians(angle)) ** 2)
37
+ recovered = 2 * math.pi * jerlov.pure_water_vsf(angle, nm) * chi_w
38
+ assert recovered == pytest.approx(expected, rel=1e-9)
39
+
40
+
41
+ def test_pure_water_scattering_rises_steeply_towards_the_blue():
42
+ """Morel's exponent is -4.32, close to Rayleigh's -4."""
43
+ blue = jerlov.pure_water_backscattering(400.0)
44
+ red = jerlov.pure_water_backscattering(700.0)
45
+ assert blue / red == pytest.approx((700.0 / 400.0) ** 4.32, rel=1e-9)
46
+
47
+
48
+ def test_fresher_water_scatters_less():
49
+ assert (jerlov.pure_water_backscattering(532.0, salinity_psu=0.0)
50
+ < jerlov.pure_water_backscattering(532.0, salinity_psu=37.0))
51
+
52
+
53
+ # -- the tabulated half ---------------------------------------------------
54
+
55
+
56
+ def test_chi_p_matches_the_published_table():
57
+ published = {90: 0.71, 100: 0.90, 110: 1.03, 120: 1.12, 130: 1.17,
58
+ 140: 1.18, 150: 1.13, 160: 1.00, 170: 0.62}
59
+ for angle, value in published.items():
60
+ assert jerlov.particulate_chi(angle) == pytest.approx(value)
61
+
62
+
63
+ def test_chi_p_peaks_in_the_middle_of_the_backward_hemisphere():
64
+ """Which is why both papers recommend 110-160 degrees."""
65
+ angles = np.arange(90.0, 171.0, 1.0)
66
+ values = [jerlov.particulate_chi(a) for a in angles]
67
+ assert 130.0 <= angles[int(np.argmax(values))] <= 145.0
68
+
69
+
70
+ def test_chi_w_and_chi_p_cross_near_118_degrees():
71
+ """The paper's reason that instruments settled on 120."""
72
+ r = (1 - DEPOLARISATION_RATIO) / (1 + DEPOLARISATION_RATIO)
73
+ angles = np.linspace(90.0, 170.0, 8001)
74
+ gap = [
75
+ (1 + r / 3) / (1 + r * math.cos(math.radians(a)) ** 2)
76
+ - jerlov.particulate_chi(a)
77
+ for a in angles
78
+ ]
79
+ crossing = angles[int(np.argmin(np.abs(gap)))]
80
+ assert 114.0 < crossing < 122.0, crossing
81
+
82
+
83
+ def test_angles_outside_the_measurements_are_refused():
84
+ for angle in (89.0, 171.0, 180.0, 0.0):
85
+ with pytest.raises(ValueError, match="90 to 170"):
86
+ jerlov.particulate_chi(angle)
87
+
88
+
89
+ # -- the conversion -------------------------------------------------------
90
+
91
+
92
+ def test_it_reproduces_the_published_equation():
93
+ beta, angle, nm = 0.0021, 140.0, 532.0
94
+ result = jerlov.bb_from_vsf(beta, angle, nm)
95
+ chi_p = jerlov.particulate_chi(angle)
96
+ expected = (2 * math.pi * chi_p
97
+ * (beta - jerlov.pure_water_vsf(angle, nm))
98
+ + jerlov.pure_water_backscattering(nm))
99
+ assert result.bb == pytest.approx(expected)
100
+ assert result.particulate + result.water == pytest.approx(result.bb)
101
+
102
+
103
+ def test_water_alone_gives_the_water_answer():
104
+ """Measuring pure sea water must return bb_w and no particles."""
105
+ nm = 532.0
106
+ beta = jerlov.pure_water_vsf(130.0, nm)
107
+ result = jerlov.bb_from_vsf(beta, 130.0, nm)
108
+ assert result.particulate == pytest.approx(0.0, abs=1e-12)
109
+ assert result.bb == pytest.approx(jerlov.pure_water_backscattering(nm))
110
+
111
+
112
+ def test_more_scattering_gives_more_backscattering():
113
+ previous = -1.0
114
+ for beta in (0.0005, 0.001, 0.002, 0.005):
115
+ value = jerlov.bb_from_vsf(beta, 140.0, 532.0).bb
116
+ assert value > previous
117
+ previous = value
118
+
119
+
120
+ def test_a_typical_coastal_reading_lands_in_the_reported_range():
121
+ """A sanity anchor, not a validation. Coastal bb/b is put at 0.015-0.03."""
122
+ result = jerlov.bb_from_vsf(0.0021, 140.0, 532.0, salinity_psu=35.0)
123
+ assert 0.005 < result.bb < 0.05
124
+
125
+
126
+ def test_170_degrees_warns_about_its_own_error():
127
+ with pytest.warns(AngleWarning, match="34.8%"):
128
+ jerlov.bb_from_vsf(0.002, 170.0, 532.0)
129
+
130
+
131
+ def test_the_recommended_angles_do_not_warn():
132
+ with warnings.catch_warnings():
133
+ warnings.simplefilter("error")
134
+ for angle in (110.0, 120.0, 130.0, 140.0, 150.0, 160.0):
135
+ jerlov.bb_from_vsf(0.002, angle, 532.0)
136
+
137
+
138
+ def test_a_reading_below_pure_water_warns_rather_than_passing():
139
+ with pytest.warns(AngleWarning, match="below that of pure sea water"):
140
+ jerlov.bb_from_vsf(1e-6, 140.0, 400.0)
141
+
142
+
143
+ def test_negative_scattering_is_refused():
144
+ with pytest.raises(ValueError, match="cannot be negative"):
145
+ jerlov.bb_from_vsf(-0.001, 140.0, 532.0)
146
+
147
+
148
+ def test_the_result_carries_the_quoted_error():
149
+ result = jerlov.bb_from_vsf(0.002, 140.0, 532.0)
150
+ assert result.chi_p == pytest.approx(1.18)
151
+ assert result.quoted_error_percent == pytest.approx(3.5)
152
+ assert result.angle_deg == 140.0
153
+
154
+
155
+ def test_scalar_in_scalar_out():
156
+ assert isinstance(jerlov.bb_from_vsf(0.002, 140.0, 532.0).bb, float)
157
+ array = jerlov.bb_from_vsf([0.001, 0.002], 140.0, 532.0).bb
158
+ assert isinstance(array, np.ndarray) and array.shape == (2,)
159
+
160
+
161
+ # -- what it does not do --------------------------------------------------
162
+
163
+
164
+ def test_it_says_nothing_about_a_jerlov_type():
165
+ """The point of DATA.md section 10 has not quietly gone away."""
166
+ with pytest.raises(jerlov.MissingQuantityError):
167
+ jerlov.water("III").bb(532)
@@ -73,7 +73,7 @@ def test_each_primary_lights_only_its_own_channel():
73
73
  def test_a_narrow_spectrum_warns():
74
74
  """450-650 nm looks like a full spectrum but is not."""
75
75
  wl = np.arange(450.0, 651.0, 1.0)
76
- with pytest.warns(CoverageWarning, match="covers only"):
76
+ with pytest.warns(CoverageWarning, match="covered"):
77
77
  spectrum_to_xyz(np.ones_like(wl), wl)
78
78
 
79
79
 
@@ -88,8 +88,13 @@ def test_the_warning_states_what_was_missed():
88
88
  with pytest.warns(CoverageWarning) as caught:
89
89
  spectrum_to_xyz(np.ones_like(wl), wl)
90
90
  message = str(caught[0].message)
91
- assert "500-600 nm" in message
92
- assert "CIE 1931" in message
91
+ assert "500-600 nm" in message # the span it actually had
92
+ assert "CIE 1931" in message # what it was integrated against
93
+ assert "all channels:" in message # not just the worst one
94
+ # z-bar peaks at 445 nm, so 500-600 nm barely touches it. Naming the
95
+ # channel matters: "4.1% covered" alone reads as if the whole observer
96
+ # were 4.1% covered, which would be wrong.
97
+ assert "channel 2 of 3" in message
93
98
 
94
99
 
95
100
  # -- white, the other one ------------------------------------------------
@@ -0,0 +1,237 @@
1
+ """The version is written in three files, so it drifts.
2
+
3
+ It has drifted twice: once between `pyproject.toml` and `jerlov/__init__.py`,
4
+ and once between the working tree and an installed copy. Both were found by
5
+ someone noticing a wrong number in output, which is not a method.
6
+
7
+ These checks only apply to a source tree. Running the suite against an
8
+ installed wheel skips them, because `pyproject.toml` and `CITATION.cff` are
9
+ not shipped.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+ import re
16
+
17
+ import pytest
18
+
19
+ import jerlov
20
+
21
+ ROOT = pathlib.Path(__file__).resolve().parent.parent
22
+ source_tree = pytest.mark.skipif(
23
+ not (ROOT / "pyproject.toml").exists(),
24
+ reason="not running from a source tree",
25
+ )
26
+
27
+
28
+ def _declared(path: pathlib.Path, pattern: str) -> str:
29
+ match = re.search(pattern, path.read_text(), re.MULTILINE)
30
+ assert match, f"no version found in {path.name}"
31
+ return match.group(1).strip()
32
+
33
+
34
+ @source_tree
35
+ def test_the_three_declared_versions_agree():
36
+ toml = _declared(ROOT / "pyproject.toml", r'^version = "(.+?)"')
37
+ cff = _declared(ROOT / "CITATION.cff", r"^version: (.+)$")
38
+ assert jerlov.__version__ == toml, (
39
+ f"jerlov/__init__.py says {jerlov.__version__}, "
40
+ f"pyproject.toml says {toml}"
41
+ )
42
+ assert jerlov.__version__ == cff, (
43
+ f"jerlov/__init__.py says {jerlov.__version__}, "
44
+ f"CITATION.cff says {cff}"
45
+ )
46
+
47
+
48
+ @source_tree
49
+ def test_the_version_looks_like_a_release():
50
+ assert re.fullmatch(r"\d+\.\d+\.\d+", jerlov.__version__), (
51
+ f"{jerlov.__version__!r} is not a plain release version"
52
+ )
53
+
54
+
55
+ @source_tree
56
+ def test_the_package_under_test_is_the_one_in_this_tree():
57
+ """Guards the sys.path trap that made the examples load an old install."""
58
+ imported = pathlib.Path(jerlov.__file__).resolve().parent
59
+ assert imported == ROOT / "jerlov", (
60
+ f"tests import jerlov from {imported}, not from {ROOT / 'jerlov'}; "
61
+ "run `pip install -e .` in the repository"
62
+ )
63
+
64
+
65
+ @source_tree
66
+ def test_every_shipped_table_is_listed_in_DATA_md():
67
+ """A table nobody documented is a table with no provenance."""
68
+ documented = (ROOT / "DATA.md").read_text()
69
+ for csv in sorted((ROOT / "jerlov" / "data").glob("*.csv")):
70
+ assert csv.name in documented, f"{csv.name} is not mentioned in DATA.md"
71
+
72
+
73
+ @source_tree
74
+ def test_every_shipped_table_has_a_build_script_or_is_explained():
75
+ """Each table should be regenerable; tools/README.md says so."""
76
+ scripts = " ".join(p.read_text() for p in (ROOT / "tools").glob("*.py"))
77
+ for csv in sorted((ROOT / "jerlov" / "data").glob("*.csv")):
78
+ assert csv.name in scripts, f"no build script writes {csv.name}"
79
+
80
+
81
+ # -- the documentation counts itself -------------------------------------
82
+
83
+ _SPELLED = ["zero", "one", "two", "three", "four", "five", "six", "seven",
84
+ "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen",
85
+ "fifteen", "sixteen", "seventeen", "eighteen", "nineteen",
86
+ "twenty", "twenty-one", "twenty-two", "twenty-three",
87
+ "twenty-four", "twenty-five"]
88
+ WORDS = {word: value for value, word in enumerate(_SPELLED)}
89
+ WORDS.update({word.capitalize(): value
90
+ for value, word in enumerate(_SPELLED)})
91
+
92
+
93
+ def _numbered_sections(text: str) -> int:
94
+ return len(re.findall(r"^## \d+\. ", text, re.MULTILINE))
95
+
96
+
97
+ def _confirmed_sections(text: str) -> int:
98
+ return len(re.findall(r"^## \d+\..*\(confirmed", text, re.MULTILINE))
99
+
100
+
101
+ @source_tree
102
+ def test_DATA_md_counts_its_own_sections():
103
+ """The opening summary is written by hand and has drifted three times."""
104
+ text = (ROOT / "DATA.md").read_text()
105
+ match = re.search(r"^(\w+) entries are recorded below\. (\w+) are confirmed",
106
+ text, re.MULTILINE)
107
+ assert match, "DATA.md no longer opens with a countable summary"
108
+ claimed_total, claimed_confirmed = (WORDS[g] for g in match.groups())
109
+ assert claimed_total == _numbered_sections(text)
110
+ assert claimed_confirmed == _confirmed_sections(text)
111
+
112
+
113
+ @source_tree
114
+ def test_the_zenodo_record_will_carry_what_it_should():
115
+ """`.zenodo.json` is only read at archive time, so nothing else checks it."""
116
+ import json
117
+
118
+ record = json.loads((ROOT / ".zenodo.json").read_text())
119
+ assert record["upload_type"] == "software"
120
+ assert record["license"] == "Apache-2.0"
121
+ assert record["creators"], "a record with no author is not citable"
122
+ for creator in record["creators"]:
123
+ assert creator.get("orcid"), (
124
+ f"{creator['name']} has no ORCID, so the record will not attach "
125
+ "to their publication list"
126
+ )
127
+ # Every source the package ships data from should be reachable from the
128
+ # record, not only from DATA.md.
129
+ derived = [r["identifier"] for r in record["related_identifiers"]
130
+ if r["relation"] == "isDerivedFrom"]
131
+ assert len(derived) >= 8, derived
132
+
133
+
134
+ @source_tree
135
+ def test_the_README_agrees_with_DATA_md_on_the_count():
136
+ data = (ROOT / "DATA.md").read_text()
137
+ readme = (ROOT / "README.md").read_text()
138
+ match = re.search(r"(\w+) entries are\s+documented there: (\w+) confirmed",
139
+ readme)
140
+ assert match, "the README no longer states the count"
141
+ assert WORDS[match.group(1)] == _numbered_sections(data)
142
+ assert WORDS[match.group(2)] == _confirmed_sections(data)
143
+
144
+
145
+ @source_tree
146
+ def test_the_zenodo_description_agrees_with_DATA_md():
147
+ """It has now gone stale four times, and only an archive run reads it."""
148
+ import json
149
+
150
+ record = json.loads((ROOT / ".zenodo.json").read_text())
151
+ data = (ROOT / "DATA.md").read_text()
152
+ description = record["description"]
153
+
154
+ match = re.search(r"(\w+) entries are documented, (\w+) of them confirmed",
155
+ description)
156
+ assert match, "the Zenodo description no longer states a countable summary"
157
+ assert WORDS[match.group(1)] == _numbered_sections(data), (
158
+ f".zenodo.json says {match.group(1)}, DATA.md has "
159
+ f"{_numbered_sections(data)} sections"
160
+ )
161
+ assert WORDS[match.group(2)] == _confirmed_sections(data)
162
+
163
+
164
+ @source_tree
165
+ def test_the_zenodo_record_names_every_shipped_module():
166
+ """A capability the record does not mention is one nobody will find."""
167
+ record = __import__("json").loads((ROOT / ".zenodo.json").read_text())
168
+ described = record["description"].lower()
169
+ for module, phrase in (
170
+ ("scene.py", "veiling"),
171
+ ("colour.py", "srgb"),
172
+ ("shortwave.py", "shortwave"),
173
+ ("backscattering.py", "backscattering coefficient"),
174
+ ("water.py", "scattering coefficients"),
175
+ ):
176
+ assert (ROOT / "jerlov" / module).exists()
177
+ assert phrase in described, (
178
+ f"{module} ships but the Zenodo description never mentions "
179
+ f"{phrase!r}"
180
+ )
181
+
182
+
183
+ @source_tree
184
+ def test_every_module_can_find_the_tables_it_reads():
185
+ """A missing data file should fail here, not thirteen tests later.
186
+
187
+ `jerlov/data/boss2001_chi.csv` once failed to reach a working copy, and
188
+ the first sign of it was thirteen unrelated-looking FileNotFoundErrors
189
+ deep inside the backscattering tests.
190
+ """
191
+ shipped = {p.name for p in (ROOT / "jerlov" / "data").glob("*.csv")}
192
+ wanted = set()
193
+ for module in (ROOT / "jerlov").glob("*.py"):
194
+ wanted |= set(re.findall(r'_rows\(\s*"([^"]+\.csv)"', module.read_text()))
195
+ assert wanted, "no module reads a table any more, which cannot be right"
196
+ missing = sorted(wanted - shipped)
197
+ assert not missing, (
198
+ f"these are read by jerlov/*.py but not present in jerlov/data/: "
199
+ f"{missing}. Run the matching script in tools/."
200
+ )
201
+
202
+
203
+ @source_tree
204
+ def test_every_build_script_produces_a_shipped_table():
205
+ """The other direction: a script whose output never arrived."""
206
+ shipped = {p.name for p in (ROOT / "jerlov" / "data").glob("*.csv")}
207
+ for script in sorted((ROOT / "tools").glob("build_*.py")):
208
+ written = set(re.findall(r'DATA_DIR / "([^"]+\.csv)"', script.read_text()))
209
+ written |= set(re.findall(r'report\(\s*"([^"]+\.csv)"', script.read_text()))
210
+ assert written, f"{script.name} writes no table"
211
+ missing = sorted(written - shipped)
212
+ assert not missing, (
213
+ f"{script.name} writes {missing}, which is not in jerlov/data/"
214
+ )
215
+
216
+
217
+ @source_tree
218
+ def test_nothing_reaches_for_a_numpy_2_only_name():
219
+ """`numpy.trapezoid` arrived in NumPy 2.0 and `numpy.trapz` left in it.
220
+
221
+ `jerlov._data.trapezoid` resolves whichever exists. This has now been got
222
+ wrong twice: once in colour.py, shipped in 0.1.1, and once in the
223
+ backscattering tests, caught by CI before release. Both times the local
224
+ NumPy was new enough for it to pass.
225
+ """
226
+ offenders = []
227
+ for path in list(ROOT.glob("jerlov/*.py")) + list(ROOT.glob("tests/*.py")) \
228
+ + list(ROOT.glob("tools/*.py")) + list(ROOT.glob("examples/*.py")):
229
+ text = path.read_text()
230
+ for name in ("np.trapezoid", "np.trapz", "numpy.trapezoid",
231
+ "numpy.trapz"):
232
+ if name + "(" in text:
233
+ offenders.append(f"{path.relative_to(ROOT)}: {name}")
234
+ assert not offenders, (
235
+ "use jerlov._data.trapezoid instead, which resolves whichever name "
236
+ f"the installed NumPy has: {offenders}"
237
+ )
@@ -1,78 +0,0 @@
1
- """The version is written in three files, so it drifts.
2
-
3
- It has drifted twice: once between `pyproject.toml` and `jerlov/__init__.py`,
4
- and once between the working tree and an installed copy. Both were found by
5
- someone noticing a wrong number in output, which is not a method.
6
-
7
- These checks only apply to a source tree. Running the suite against an
8
- installed wheel skips them, because `pyproject.toml` and `CITATION.cff` are
9
- not shipped.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import pathlib
15
- import re
16
-
17
- import pytest
18
-
19
- import jerlov
20
-
21
- ROOT = pathlib.Path(__file__).resolve().parent.parent
22
- source_tree = pytest.mark.skipif(
23
- not (ROOT / "pyproject.toml").exists(),
24
- reason="not running from a source tree",
25
- )
26
-
27
-
28
- def _declared(path: pathlib.Path, pattern: str) -> str:
29
- match = re.search(pattern, path.read_text(), re.MULTILINE)
30
- assert match, f"no version found in {path.name}"
31
- return match.group(1).strip()
32
-
33
-
34
- @source_tree
35
- def test_the_three_declared_versions_agree():
36
- toml = _declared(ROOT / "pyproject.toml", r'^version = "(.+?)"')
37
- cff = _declared(ROOT / "CITATION.cff", r"^version: (.+)$")
38
- assert jerlov.__version__ == toml, (
39
- f"jerlov/__init__.py says {jerlov.__version__}, "
40
- f"pyproject.toml says {toml}"
41
- )
42
- assert jerlov.__version__ == cff, (
43
- f"jerlov/__init__.py says {jerlov.__version__}, "
44
- f"CITATION.cff says {cff}"
45
- )
46
-
47
-
48
- @source_tree
49
- def test_the_version_looks_like_a_release():
50
- assert re.fullmatch(r"\d+\.\d+\.\d+", jerlov.__version__), (
51
- f"{jerlov.__version__!r} is not a plain release version"
52
- )
53
-
54
-
55
- @source_tree
56
- def test_the_package_under_test_is_the_one_in_this_tree():
57
- """Guards the sys.path trap that made the examples load an old install."""
58
- imported = pathlib.Path(jerlov.__file__).resolve().parent
59
- assert imported == ROOT / "jerlov", (
60
- f"tests import jerlov from {imported}, not from {ROOT / 'jerlov'}; "
61
- "run `pip install -e .` in the repository"
62
- )
63
-
64
-
65
- @source_tree
66
- def test_every_shipped_table_is_listed_in_DATA_md():
67
- """A table nobody documented is a table with no provenance."""
68
- documented = (ROOT / "DATA.md").read_text()
69
- for csv in sorted((ROOT / "jerlov" / "data").glob("*.csv")):
70
- assert csv.name in documented, f"{csv.name} is not mentioned in DATA.md"
71
-
72
-
73
- @source_tree
74
- def test_every_shipped_table_has_a_build_script_or_is_explained():
75
- """Each table should be regenerable; tools/README.md says so."""
76
- scripts = " ".join(p.read_text() for p in (ROOT / "tools").glob("*.py"))
77
- for csv in sorted((ROOT / "jerlov" / "data").glob("*.csv")):
78
- assert csv.name in scripts, f"no build script writes {csv.name}"
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