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.
- {jerlov-0.2.1/jerlov.egg-info → jerlov-0.3.0}/PKG-INFO +30 -10
- {jerlov-0.2.1 → jerlov-0.3.0}/README.md +29 -9
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/__init__.py +15 -1
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/_data.py +10 -0
- jerlov-0.3.0/jerlov/backscattering.py +215 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/colour.py +12 -10
- jerlov-0.3.0/jerlov/data/boss2001_chi.csv +13 -0
- {jerlov-0.2.1 → jerlov-0.3.0/jerlov.egg-info}/PKG-INFO +30 -10
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/SOURCES.txt +3 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/pyproject.toml +1 -1
- jerlov-0.3.0/tests/test_backscattering.py +167 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_colour.py +8 -3
- jerlov-0.3.0/tests/test_packaging.py +237 -0
- jerlov-0.2.1/tests/test_packaging.py +0 -78
- {jerlov-0.2.1 → jerlov-0.3.0}/LICENSE +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/NOTICE +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/__init__.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/austin1986_kd.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/austin1986_model.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/cie_d65.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1968_kd.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/jerlov1976_kd.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/paulson1977_shortwave.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/smart2007_b_from_c.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/solonenko2015_iop.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2022_iop.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2022_measured.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/data/williamson2023_depth.csv +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/scene.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/shortwave.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/sources.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov/water.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/dependency_links.txt +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/requires.txt +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/jerlov.egg-info/top_level.txt +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/setup.cfg +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_api.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_depth.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_reproduces_papers.py +0 -0
- {jerlov-0.2.1 → jerlov-0.3.0}/tests/test_scene.py +0 -0
- {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.
|
|
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/
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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/
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
161
|
-
f"
|
|
162
|
-
"
|
|
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.
|
|
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/
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
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
|
|
@@ -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="
|
|
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
|
|
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
|
|
File without changes
|