jerlov 0.2.2__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.2/jerlov.egg-info → jerlov-0.3.0}/PKG-INFO +21 -2
- {jerlov-0.2.2 → jerlov-0.3.0}/README.md +20 -1
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/__init__.py +15 -1
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/_data.py +10 -0
- jerlov-0.3.0/jerlov/backscattering.py +215 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/colour.py +2 -3
- jerlov-0.3.0/jerlov/data/boss2001_chi.csv +13 -0
- {jerlov-0.2.2 → jerlov-0.3.0/jerlov.egg-info}/PKG-INFO +21 -2
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov.egg-info/SOURCES.txt +3 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/pyproject.toml +1 -1
- jerlov-0.3.0/tests/test_backscattering.py +167 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_packaging.py +95 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/LICENSE +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/NOTICE +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/__init__.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/austin1986_kd.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/austin1986_model.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/cie_d65.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/jerlov1968_kd.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/jerlov1976_kd.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/paulson1977_shortwave.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/smart2007_b_from_c.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/solonenko2015_iop.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/williamson2022_iop.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/williamson2022_measured.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/data/williamson2023_depth.csv +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/scene.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/shortwave.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/sources.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov/water.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov.egg-info/dependency_links.txt +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov.egg-info/requires.txt +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/jerlov.egg-info/top_level.txt +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/setup.cfg +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_api.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_colour.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_depth.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_reproduces_papers.py +0 -0
- {jerlov-0.2.2 → jerlov-0.3.0}/tests/test_scene.py +0 -0
- {jerlov-0.2.2 → 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.
|
|
@@ -272,7 +291,7 @@ made.
|
|
|
272
291
|
## Provenance and design
|
|
273
292
|
|
|
274
293
|
`DATA.md` records, for every shipped table, where it came from, what was
|
|
275
|
-
verified, and what is known to be wrong with it.
|
|
294
|
+
verified, and what is known to be wrong with it. Eighteen entries are
|
|
276
295
|
documented there: eight confirmed defects in the source literature, three
|
|
277
296
|
questions the first edition of Jerlov settled, and the rest notes.
|
|
278
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.
|
|
@@ -247,7 +266,7 @@ made.
|
|
|
247
266
|
## Provenance and design
|
|
248
267
|
|
|
249
268
|
`DATA.md` records, for every shipped table, where it came from, what was
|
|
250
|
-
verified, and what is known to be wrong with it.
|
|
269
|
+
verified, and what is known to be wrong with it. Eighteen entries are
|
|
251
270
|
documented there: eight confirmed defects in the source literature, three
|
|
252
271
|
questions the first edition of Jerlov settled, and the rest notes.
|
|
253
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([
|
|
@@ -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.
|
|
@@ -272,7 +291,7 @@ made.
|
|
|
272
291
|
## Provenance and design
|
|
273
292
|
|
|
274
293
|
`DATA.md` records, for every shipped table, where it came from, what was
|
|
275
|
-
verified, and what is known to be wrong with it.
|
|
294
|
+
verified, and what is known to be wrong with it. Eighteen entries are
|
|
276
295
|
documented there: eight confirmed defects in the source literature, three
|
|
277
296
|
questions the first edition of Jerlov settled, and the rest notes.
|
|
278
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)
|
|
@@ -140,3 +140,98 @@ def test_the_README_agrees_with_DATA_md_on_the_count():
|
|
|
140
140
|
assert match, "the README no longer states the count"
|
|
141
141
|
assert WORDS[match.group(1)] == _numbered_sections(data)
|
|
142
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
|
+
)
|
|
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
|
|
File without changes
|