jerlov 0.3.1__tar.gz → 0.3.3__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.3.1/jerlov.egg-info → jerlov-0.3.3}/PKG-INFO +1 -1
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/__init__.py +1 -1
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/_data.py +46 -8
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/backscattering.py +8 -4
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/colour.py +2 -2
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/sources.py +1 -1
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/water.py +114 -33
- {jerlov-0.3.1 → jerlov-0.3.3/jerlov.egg-info}/PKG-INFO +1 -1
- {jerlov-0.3.1 → jerlov-0.3.3}/pyproject.toml +1 -1
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_api.py +111 -2
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_backscattering.py +20 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_packaging.py +31 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_reproduces_papers.py +2 -2
- {jerlov-0.3.1 → jerlov-0.3.3}/LICENSE +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/NOTICE +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/README.md +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/__init__.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/austin1986_kd.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/austin1986_model.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/boss2001_chi.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/cie_d65.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/jerlov1968_kd.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/jerlov1976_kd.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/paulson1977_shortwave.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/smart2007_b_from_c.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/solonenko2015_iop.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/williamson2022_iop.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/williamson2022_measured.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/data/williamson2023_depth.csv +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/py.typed +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/scene.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov/shortwave.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov.egg-info/SOURCES.txt +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov.egg-info/dependency_links.txt +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov.egg-info/requires.txt +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/jerlov.egg-info/top_level.txt +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/setup.cfg +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_colour.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_depth.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_quoted_figures.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_scene.py +0 -0
- {jerlov-0.3.1 → jerlov-0.3.3}/tests/test_shortwave.py +0 -0
|
@@ -8,13 +8,13 @@ silently cleaned up on the way in.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import csv
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
11
13
|
from functools import lru_cache
|
|
12
14
|
from importlib import resources
|
|
13
15
|
|
|
14
16
|
import numpy as np
|
|
15
17
|
|
|
16
|
-
import numpy as np
|
|
17
|
-
|
|
18
18
|
#: numpy.trapezoid is the name from NumPy 2.0; before that it was numpy.trapz,
|
|
19
19
|
#: which 2.0 removed. The package claims to work from NumPy 1.22, so neither
|
|
20
20
|
#: can be assumed. This lives here rather than in a module about colour so
|
|
@@ -23,8 +23,38 @@ import numpy as np
|
|
|
23
23
|
#: supported NumPy.
|
|
24
24
|
trapezoid = getattr(np, "trapezoid", None) or np.trapz
|
|
25
25
|
|
|
26
|
+
_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) + os.sep
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def caller_stacklevel() -> int:
|
|
30
|
+
"""The ``stacklevel`` that points a warning at the caller's own code.
|
|
31
|
+
|
|
32
|
+
Call it from the function that calls ``warnings.warn``. A fixed number
|
|
33
|
+
is right only for one call path: ``Water.c`` reaches the flag check one
|
|
34
|
+
frame deeper than ``Water.a`` does, and ``Scene`` deeper still, so a
|
|
35
|
+
constant pointed some warnings at this package instead of at the line
|
|
36
|
+
that asked for the value.
|
|
37
|
+
"""
|
|
38
|
+
frame = sys._getframe(1)
|
|
39
|
+
level = 1
|
|
40
|
+
while frame is not None and frame.f_code.co_filename.startswith(_PACKAGE_DIR):
|
|
41
|
+
frame = frame.f_back
|
|
42
|
+
level += 1
|
|
43
|
+
return level
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _frozen(array: np.ndarray) -> np.ndarray:
|
|
47
|
+
"""Make a cached array read-only.
|
|
48
|
+
|
|
49
|
+
The loaders below are cached, so every caller gets the same array
|
|
50
|
+
objects. Writing into one would silently change the table for every
|
|
51
|
+
later caller in the process.
|
|
52
|
+
"""
|
|
53
|
+
array.setflags(write=False)
|
|
54
|
+
return array
|
|
55
|
+
|
|
26
56
|
#: Values whose ``status`` is one of these should not be used without the
|
|
27
|
-
#: caller being told. See
|
|
57
|
+
#: caller being told. See DATA.md sections 1-6.
|
|
28
58
|
QUESTIONABLE = frozenset({"suspect", "missing", "extrapolated",
|
|
29
59
|
"model_extrapolation", "reconstructed"})
|
|
30
60
|
|
|
@@ -75,8 +105,8 @@ def spectrum(
|
|
|
75
105
|
)
|
|
76
106
|
order = np.argsort(wl)
|
|
77
107
|
return (
|
|
78
|
-
np.asarray(wl, dtype=float)[order],
|
|
79
|
-
np.asarray(values, dtype=float)[order],
|
|
108
|
+
_frozen(np.asarray(wl, dtype=float)[order]),
|
|
109
|
+
_frozen(np.asarray(values, dtype=float)[order]),
|
|
80
110
|
tuple(statuses[i] for i in order),
|
|
81
111
|
)
|
|
82
112
|
|
|
@@ -104,7 +134,15 @@ def austin_model() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
|
104
134
|
m = np.array([float(r["M_slope"]) for r in rows])
|
|
105
135
|
kw = np.array([float(r["Kw_pure_seawater_per_m"]) for r in rows])
|
|
106
136
|
order = np.argsort(wl)
|
|
107
|
-
return wl[order], m[order], kw[order]
|
|
137
|
+
return _frozen(wl[order]), _frozen(m[order]), _frozen(kw[order])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@lru_cache(maxsize=None)
|
|
141
|
+
def austin_model_status() -> tuple[str, ...]:
|
|
142
|
+
"""The ``status`` of each row of :func:`austin_model`, in the same order."""
|
|
143
|
+
rows = sorted(_rows("austin1986_model.csv"),
|
|
144
|
+
key=lambda r: float(r["wavelength_nm"]))
|
|
145
|
+
return tuple(r["status"] for r in rows)
|
|
108
146
|
|
|
109
147
|
|
|
110
148
|
@lru_cache(maxsize=None)
|
|
@@ -119,5 +157,5 @@ def b_from_c_ratio() -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
|
119
157
|
for r in rows
|
|
120
158
|
if r["statistic"] == stat
|
|
121
159
|
}
|
|
122
|
-
out[stat] = np.array([by_wl[w] for w in wl])
|
|
123
|
-
return np.array(wl), out
|
|
160
|
+
out[stat] = _frozen(np.array([by_wl[w] for w in wl]))
|
|
161
|
+
return _frozen(np.array(wl)), out
|
|
@@ -70,11 +70,15 @@ def pure_water_vsf(angle_deg, wavelength_nm, salinity_psu: float = 37.0):
|
|
|
70
70
|
The amplitude carries about 15 percent uncertainty, which the authors give
|
|
71
71
|
as the agreement between measurement and theory.
|
|
72
72
|
"""
|
|
73
|
-
angle = np.
|
|
73
|
+
angle = np.asarray(angle_deg, dtype=float)
|
|
74
74
|
amplitude = (1.38 * (np.asarray(wavelength_nm, dtype=float) / 500.0) ** -4.32
|
|
75
75
|
* (1 + 0.3 * salinity_psu / 37.0) * 1e-4)
|
|
76
76
|
out = amplitude * (1 + _ratio() * np.cos(np.radians(angle)) ** 2)
|
|
77
|
-
|
|
77
|
+
# Angle and wavelength broadcast against each other; the answer is a
|
|
78
|
+
# float only when neither of them was an array.
|
|
79
|
+
if np.ndim(out) == 0:
|
|
80
|
+
return float(out)
|
|
81
|
+
return out
|
|
78
82
|
|
|
79
83
|
|
|
80
84
|
def pure_water_backscattering(wavelength_nm, salinity_psu: float = 37.0):
|
|
@@ -181,7 +185,7 @@ def bb_from_vsf(beta, angle_deg: float, wavelength_nm, *,
|
|
|
181
185
|
"scattering function varies steeply here and a single-angle "
|
|
182
186
|
"measurement pins bb poorly.",
|
|
183
187
|
AngleWarning,
|
|
184
|
-
stacklevel=
|
|
188
|
+
stacklevel=_data.caller_stacklevel(),
|
|
185
189
|
)
|
|
186
190
|
|
|
187
191
|
scalar = np.ndim(beta) == 0 and np.ndim(wavelength_nm) == 0
|
|
@@ -198,7 +202,7 @@ def bb_from_vsf(beta, angle_deg: float, wavelength_nm, *,
|
|
|
198
202
|
"so the particle contribution came out negative. Check the "
|
|
199
203
|
"calibration, the wavelength and the salinity.",
|
|
200
204
|
AngleWarning,
|
|
201
|
-
stacklevel=
|
|
205
|
+
stacklevel=_data.caller_stacklevel(),
|
|
202
206
|
)
|
|
203
207
|
|
|
204
208
|
def shape(values):
|
|
@@ -163,7 +163,7 @@ def integrate_response(spectrum, wavelengths, response, response_wavelengths,
|
|
|
163
163
|
"The integral is over the overlap and is biased by what was left "
|
|
164
164
|
"out",
|
|
165
165
|
CoverageWarning,
|
|
166
|
-
stacklevel=
|
|
166
|
+
stacklevel=_data.caller_stacklevel(),
|
|
167
167
|
)
|
|
168
168
|
|
|
169
169
|
resampled = np.stack(
|
|
@@ -202,7 +202,7 @@ def xyz_to_srgb(xyz, *, clip: bool = True) -> np.ndarray:
|
|
|
202
202
|
"the colour lies outside the sRGB gamut"
|
|
203
203
|
+ (" and has been clipped" if clip else ""),
|
|
204
204
|
GamutWarning,
|
|
205
|
-
stacklevel=
|
|
205
|
+
stacklevel=_data.caller_stacklevel(),
|
|
206
206
|
)
|
|
207
207
|
if clip:
|
|
208
208
|
linear = np.clip(linear, 0.0, 1.0)
|
|
@@ -55,7 +55,7 @@ HALTRIN1999 = ScatteringConstants(
|
|
|
55
55
|
#: Solonenko & Mobley (2015) Eqs. (8a)-(8d). The small-particle coefficient
|
|
56
56
|
#: 1.513 does not match Haltrin's 1.151302; the digit appears to have been
|
|
57
57
|
#: dropped in transcription. Their published tables were computed with 1.513,
|
|
58
|
-
#: so this value is required to reproduce them. See
|
|
58
|
+
#: so this value is required to reproduce them. See DATA.md section 6.
|
|
59
59
|
SOLONENKO2015_SCATTERING = ScatteringConstants(
|
|
60
60
|
bw_coeff=0.00583,
|
|
61
61
|
bw_exponent=4.322,
|
|
@@ -44,6 +44,25 @@ def _like_input(result: np.ndarray, original) -> np.ndarray | float:
|
|
|
44
44
|
return result
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _support(grid: np.ndarray, query: np.ndarray) -> list[tuple[int, ...]]:
|
|
48
|
+
"""The indices of the samples of ``grid`` each query's answer rests on.
|
|
49
|
+
|
|
50
|
+
A query that lands exactly on a sample rests on that sample alone: it is
|
|
51
|
+
not interpolated, so its neighbours are no part of the answer. Otherwise
|
|
52
|
+
it rests on the samples either side. The NaN check and the provenance
|
|
53
|
+
warnings all use this, so that they cannot disagree about what a value
|
|
54
|
+
depends on.
|
|
55
|
+
"""
|
|
56
|
+
n = grid.size
|
|
57
|
+
out: list[tuple[int, ...]] = []
|
|
58
|
+
for i, w_query in zip(np.searchsorted(grid, query), query):
|
|
59
|
+
if i < n and grid[i] == w_query:
|
|
60
|
+
out.append((int(i),))
|
|
61
|
+
else:
|
|
62
|
+
out.append((max(int(i) - 1, 0), min(int(i), n - 1)))
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
class Water:
|
|
48
67
|
"""Absorption and scattering coefficients as functions of wavelength.
|
|
49
68
|
|
|
@@ -75,7 +94,9 @@ class Water:
|
|
|
75
94
|
source: Source | None = None,
|
|
76
95
|
flags: dict[str, tuple[str, ...]] | None = None,
|
|
77
96
|
) -> None:
|
|
78
|
-
|
|
97
|
+
# Copied, so that neither the caller's arrays nor the packaged tables
|
|
98
|
+
# can change underneath this object, and it cannot change them.
|
|
99
|
+
self.wavelengths = np.array(wavelengths, dtype=float)
|
|
79
100
|
if self.wavelengths.ndim != 1 or self.wavelengths.size == 0:
|
|
80
101
|
raise ValueError("wavelengths must be a non-empty 1-D array")
|
|
81
102
|
if np.any(np.diff(self.wavelengths) <= 0):
|
|
@@ -85,7 +106,7 @@ class Water:
|
|
|
85
106
|
for key, value in (("a", a), ("b", b), ("Kd", kd)):
|
|
86
107
|
if value is None:
|
|
87
108
|
continue
|
|
88
|
-
arr = np.
|
|
109
|
+
arr = np.array(value, dtype=float)
|
|
89
110
|
if arr.shape != self.wavelengths.shape:
|
|
90
111
|
raise ValueError(f"{key} must have the same shape as wavelengths")
|
|
91
112
|
self._series[key] = arr
|
|
@@ -127,33 +148,25 @@ class Water:
|
|
|
127
148
|
f"wavelength outside the range of the data ({lo:g}-{hi:g} nm). "
|
|
128
149
|
"This package does not extrapolate."
|
|
129
150
|
)
|
|
130
|
-
self.
|
|
151
|
+
support = _support(self.wavelengths, query)
|
|
152
|
+
self._warn_if_flagged(quantity, support)
|
|
131
153
|
values = self._series[quantity]
|
|
132
154
|
out = np.interp(query, self.wavelengths, values)
|
|
133
155
|
# np.interp happily bridges a NaN-free path around a NaN, so check the
|
|
134
|
-
# samples the answer actually rests on.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
idx = np.searchsorted(self.wavelengths, query)
|
|
138
|
-
for k, (i, w_query) in enumerate(zip(idx, query)):
|
|
139
|
-
exact = i < self.wavelengths.size and self.wavelengths[i] == w_query
|
|
140
|
-
if exact:
|
|
141
|
-
if np.isnan(values[i]):
|
|
142
|
-
out[k] = np.nan
|
|
143
|
-
continue
|
|
144
|
-
left, right = max(i - 1, 0), min(i, values.size - 1)
|
|
145
|
-
if np.any(np.isnan(values[left:right + 1])):
|
|
156
|
+
# samples the answer actually rests on.
|
|
157
|
+
for k, samples in enumerate(support):
|
|
158
|
+
if any(np.isnan(values[j]) for j in samples):
|
|
146
159
|
out[k] = np.nan
|
|
147
160
|
return _like_input(out, wl)
|
|
148
161
|
|
|
149
|
-
def _warn_if_flagged(self, quantity: str,
|
|
162
|
+
def _warn_if_flagged(self, quantity: str,
|
|
163
|
+
support: list[tuple[int, ...]]) -> None:
|
|
150
164
|
statuses = self._flags.get(quantity)
|
|
151
165
|
if not statuses:
|
|
152
166
|
return
|
|
153
167
|
hit: set[str] = set()
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
for j in (max(i - 1, 0), min(i, len(statuses) - 1)):
|
|
168
|
+
for samples in support:
|
|
169
|
+
for j in samples:
|
|
157
170
|
status = statuses[j]
|
|
158
171
|
if status in _data.QUESTIONABLE:
|
|
159
172
|
hit.add(f"{status} at {self.wavelengths[j]:g} nm")
|
|
@@ -161,9 +174,9 @@ class Water:
|
|
|
161
174
|
warnings.warn(
|
|
162
175
|
f"{quantity} for Jerlov {self.name} rests on flagged values: "
|
|
163
176
|
+ "; ".join(sorted(hit))
|
|
164
|
-
+ ". See
|
|
177
|
+
+ ". See DATA.md for what is known about them.",
|
|
165
178
|
ProvenanceWarning,
|
|
166
|
-
stacklevel=
|
|
179
|
+
stacklevel=_data.caller_stacklevel(),
|
|
167
180
|
)
|
|
168
181
|
|
|
169
182
|
def a(self, wl):
|
|
@@ -188,7 +201,7 @@ class Water:
|
|
|
188
201
|
``backscatter_ratio`` is bb/b and has no default. It is not determined
|
|
189
202
|
by the Jerlov classification: deriving it from the particle
|
|
190
203
|
concentrations of Solonenko & Mobley and of Williamson & Hollins gives
|
|
191
|
-
answers that differ by up to a factor of 31. See
|
|
204
|
+
answers that differ by up to a factor of 31. See DATA.md section 10.
|
|
192
205
|
|
|
193
206
|
Reported ranges are roughly 0.005-0.01 for open ocean and 0.015-0.03
|
|
194
207
|
for coastal water; the Petzold average-particle phase function gives
|
|
@@ -199,7 +212,7 @@ class Water:
|
|
|
199
212
|
"bb is not determined by the water type. Pass "
|
|
200
213
|
"backscatter_ratio=... explicitly (bb/b; roughly 0.005-0.01 "
|
|
201
214
|
"for open ocean, 0.015-0.03 for coastal water). "
|
|
202
|
-
"See
|
|
215
|
+
"See DATA.md section 10."
|
|
203
216
|
)
|
|
204
217
|
if not 0.0 < backscatter_ratio < 0.5:
|
|
205
218
|
raise ValueError("backscatter_ratio must lie in (0, 0.5)")
|
|
@@ -330,6 +343,10 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
|
|
|
330
343
|
return None
|
|
331
344
|
|
|
332
345
|
|
|
346
|
+
#: Austin & Petzold (1986) state their model holds below this K(490), 1/m.
|
|
347
|
+
AUSTIN_KD490_LIMIT = 0.16
|
|
348
|
+
|
|
349
|
+
|
|
333
350
|
def kd_spectrum(kd, wavelength_nm: float, at):
|
|
334
351
|
"""Reconstruct a Kd spectrum from a single measured value.
|
|
335
352
|
|
|
@@ -340,38 +357,96 @@ def kd_spectrum(kd, wavelength_nm: float, at):
|
|
|
340
357
|
Parameters
|
|
341
358
|
----------
|
|
342
359
|
kd:
|
|
343
|
-
Measured Kd in 1/m.
|
|
360
|
+
Measured Kd in 1/m. A scalar, or an array of measurements all made at
|
|
361
|
+
``wavelength_nm`` (several stations, say); each is reconstructed
|
|
362
|
+
separately.
|
|
344
363
|
wavelength_nm:
|
|
345
|
-
Wavelength at which ``kd`` was measured.
|
|
364
|
+
Wavelength at which ``kd`` was measured. A single value.
|
|
346
365
|
at:
|
|
347
366
|
Wavelength(s) at which to evaluate the spectrum.
|
|
348
367
|
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
A float for scalar ``kd`` and ``at``. Otherwise an array of shape
|
|
371
|
+
``kd.shape + at.shape``: one spectrum per measurement.
|
|
372
|
+
|
|
349
373
|
Notes
|
|
350
374
|
-----
|
|
351
|
-
The authors state the model holds for K(490) < 0.16 1/m
|
|
375
|
+
The authors state the model holds for K(490) < 0.16 1/m, and a
|
|
376
|
+
:class:`ProvenanceWarning` is raised for any measurement whose K(490),
|
|
377
|
+
measured or implied by the model, is not below that. Accuracy is about
|
|
352
378
|
8 percent at wavelengths up to 590 nm and degrades to about 31 percent at
|
|
353
|
-
670 nm
|
|
379
|
+
670 nm. M below 365 nm is itself extrapolated, and a result that rests on
|
|
380
|
+
it also warns.
|
|
354
381
|
"""
|
|
355
382
|
wl, m, kw = _data.austin_model()
|
|
356
383
|
lo, hi = float(wl[0]), float(wl[-1])
|
|
384
|
+
if np.ndim(wavelength_nm) != 0:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
"wavelength_nm must be a single wavelength; for measurements at "
|
|
387
|
+
"different wavelengths, call kd_spectrum once for each"
|
|
388
|
+
)
|
|
389
|
+
wavelength_nm = float(wavelength_nm)
|
|
357
390
|
query = _as_array(at)
|
|
358
391
|
for value, label in ((wavelength_nm, "wavelength_nm"), (query, "at")):
|
|
359
392
|
if np.any(np.asarray(value) < lo) or np.any(np.asarray(value) > hi):
|
|
360
393
|
raise ValueError(f"{label} outside the model range ({lo:g}-{hi:g} nm)")
|
|
394
|
+
kd_values = np.asarray(kd, dtype=float)
|
|
361
395
|
|
|
362
396
|
m1 = float(np.interp(wavelength_nm, wl, m))
|
|
363
397
|
kw1 = float(np.interp(wavelength_nm, wl, kw))
|
|
364
|
-
|
|
398
|
+
below = kd_values < kw1
|
|
399
|
+
if np.any(below):
|
|
400
|
+
shown = ", ".join(f"{v:g}" for v in np.atleast_1d(kd_values)[
|
|
401
|
+
np.atleast_1d(below)][:5])
|
|
365
402
|
warnings.warn(
|
|
366
|
-
f"Kd={
|
|
403
|
+
f"Kd={shown} is below the pure sea water value {kw1:g} at "
|
|
367
404
|
f"{wavelength_nm:g} nm, which is not physically possible. "
|
|
368
405
|
"Austin & Petzold (1986) reported exactly this problem in "
|
|
369
406
|
"Jerlov's own type I values.",
|
|
370
407
|
ProvenanceWarning,
|
|
371
|
-
stacklevel=
|
|
408
|
+
stacklevel=_data.caller_stacklevel(),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
kd490 = (float(np.interp(490.0, wl, m)) / m1 * (kd_values - kw1)
|
|
412
|
+
+ float(np.interp(490.0, wl, kw)))
|
|
413
|
+
outside = kd490 >= AUSTIN_KD490_LIMIT
|
|
414
|
+
if np.any(outside):
|
|
415
|
+
shown = ", ".join(f"{v:.3g}" for v in np.atleast_1d(kd490)[
|
|
416
|
+
np.atleast_1d(outside)][:5])
|
|
417
|
+
warnings.warn(
|
|
418
|
+
f"K(490) = {shown} 1/m"
|
|
419
|
+
+ (" (implied by the model)" if wavelength_nm != 490.0 else "")
|
|
420
|
+
+ f" is not below {AUSTIN_KD490_LIMIT:g} 1/m, the limit Austin & "
|
|
421
|
+
"Petzold (1986) give for their model. The reconstruction is "
|
|
422
|
+
"outside the range it was fitted to.",
|
|
423
|
+
ProvenanceWarning,
|
|
424
|
+
stacklevel=_data.caller_stacklevel(),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
statuses = _data.austin_model_status()
|
|
428
|
+
flagged: set[str] = set()
|
|
429
|
+
for samples in _support(wl, np.concatenate(([wavelength_nm], query))):
|
|
430
|
+
for j in samples:
|
|
431
|
+
if statuses[j] in _data.QUESTIONABLE:
|
|
432
|
+
flagged.add(f"{statuses[j]} at {wl[j]:g} nm")
|
|
433
|
+
if flagged:
|
|
434
|
+
warnings.warn(
|
|
435
|
+
"the result rests on values of M that Austin & Petzold (1986) "
|
|
436
|
+
"flag as " + "; ".join(sorted(flagged)) + ", and say should be "
|
|
437
|
+
"used with caution",
|
|
438
|
+
ProvenanceWarning,
|
|
439
|
+
stacklevel=_data.caller_stacklevel(),
|
|
372
440
|
)
|
|
373
|
-
|
|
374
|
-
|
|
441
|
+
|
|
442
|
+
ratio = np.interp(query, wl, m) / m1
|
|
443
|
+
result = (ratio * (kd_values[..., None] - kw1)
|
|
444
|
+
+ np.interp(query, wl, kw))
|
|
445
|
+
if np.ndim(at) == 0:
|
|
446
|
+
result = result[..., 0]
|
|
447
|
+
if np.ndim(result) == 0:
|
|
448
|
+
return float(result)
|
|
449
|
+
return result
|
|
375
450
|
|
|
376
451
|
|
|
377
452
|
def b_from_c(c, wavelength_nm, *, bw, cw, bound: str = "average"):
|
|
@@ -395,5 +470,11 @@ def b_from_c(c, wavelength_nm, *, bw, cw, bound: str = "average"):
|
|
|
395
470
|
f"wavelength outside the measured range ({wl[0]:g}-{wl[-1]:g} nm)"
|
|
396
471
|
)
|
|
397
472
|
ratio = np.interp(query, wl, ratios[bound])
|
|
473
|
+
if np.ndim(wavelength_nm) == 0:
|
|
474
|
+
ratio = ratio[0]
|
|
398
475
|
result = (np.asarray(c, dtype=float) - cw) * ratio + bw
|
|
399
|
-
|
|
476
|
+
# c and the wavelength broadcast against each other; the answer is a
|
|
477
|
+
# float only when neither of them was an array.
|
|
478
|
+
if np.ndim(result) == 0:
|
|
479
|
+
return float(result)
|
|
480
|
+
return result
|
|
@@ -63,7 +63,7 @@ def test_missing_values_stay_missing():
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def test_flagged_wavelengths_warn():
|
|
66
|
-
"""Jerlov IA's b is inconsistent with its own Table 3; see
|
|
66
|
+
"""Jerlov IA's b is inconsistent with its own Table 3; see DATA.md section 4."""
|
|
67
67
|
w = jerlov.water("IA", source="solonenko2015")
|
|
68
68
|
with pytest.warns(ProvenanceWarning, match="suspect"):
|
|
69
69
|
w.b(550)
|
|
@@ -112,7 +112,10 @@ def test_kd_spectrum_reproduces_austin_table6():
|
|
|
112
112
|
"austin1986_kd.csv", "II", None, "Kd_downwelling_per_m"
|
|
113
113
|
)
|
|
114
114
|
k475 = float(kd[np.where(wl == 475)[0][0]])
|
|
115
|
-
|
|
115
|
+
# Table VI starts at 350 nm, where the paper itself flags M as
|
|
116
|
+
# extrapolated, so reproducing it must say so.
|
|
117
|
+
with pytest.warns(ProvenanceWarning, match="extrapolated at 350 nm"):
|
|
118
|
+
predicted = jerlov.kd_spectrum(k475, 475, wl)
|
|
116
119
|
assert np.max(np.abs(100 * (predicted - kd) / kd)) < 0.5
|
|
117
120
|
|
|
118
121
|
|
|
@@ -213,3 +216,109 @@ def test_interpolating_across_a_gap_still_gives_nan():
|
|
|
213
216
|
assert np.isnan(w.a(650)) # a published value that is wrong
|
|
214
217
|
assert np.isnan(w.a(660)) # and interpolation across it
|
|
215
218
|
assert np.isnan(w.a(675))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_b_from_c_keeps_every_c_at_a_single_wavelength():
|
|
222
|
+
"""An array of c at one wavelength used to come back as its first value."""
|
|
223
|
+
c = np.array([0.5, 1.0, 2.0])
|
|
224
|
+
out = jerlov.b_from_c(c, 488.0, bw=0.003, cw=0.02)
|
|
225
|
+
assert out.shape == (3,)
|
|
226
|
+
for value, single in zip(out, c):
|
|
227
|
+
assert value == pytest.approx(
|
|
228
|
+
jerlov.b_from_c(float(single), 488.0, bw=0.003, cw=0.02)
|
|
229
|
+
)
|
|
230
|
+
assert isinstance(jerlov.b_from_c(0.5, 488.0, bw=0.003, cw=0.02), float)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_a_returned_water_cannot_corrupt_the_packaged_table():
|
|
234
|
+
"""The loaders are cached; a caller's in-place edit must stay local."""
|
|
235
|
+
w = jerlov.water("III")
|
|
236
|
+
before = jerlov.water("III").wavelengths.copy()
|
|
237
|
+
w.wavelengths *= 2.0
|
|
238
|
+
assert np.array_equal(jerlov.water("III").wavelengths, before)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_the_cached_tables_are_read_only():
|
|
242
|
+
from jerlov import _data
|
|
243
|
+
|
|
244
|
+
wl, m, kw = _data.austin_model()
|
|
245
|
+
with pytest.raises(ValueError):
|
|
246
|
+
wl[0] = 0.0
|
|
247
|
+
wl, values, _ = _data.spectrum(
|
|
248
|
+
"williamson2022_iop.csv", "III", "a", "value_per_m"
|
|
249
|
+
)
|
|
250
|
+
with pytest.raises(ValueError):
|
|
251
|
+
values[0] = 0.0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_measurements_are_copied_not_shared():
|
|
255
|
+
wl = np.array([400.0, 500.0, 600.0])
|
|
256
|
+
a = np.array([0.1, 0.2, 0.3])
|
|
257
|
+
mine = Water.from_measurements(wl, a=a)
|
|
258
|
+
a[1] = 99.0
|
|
259
|
+
assert mine.a(500.0) == pytest.approx(0.2)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.parametrize("method", ["kd", "c"])
|
|
263
|
+
def test_a_provenance_warning_points_at_the_callers_line(method):
|
|
264
|
+
"""Not at water.py: `c` reaches the check one frame deeper than `kd`."""
|
|
265
|
+
source = "jerlov1976" if method == "kd" else "williamson2022"
|
|
266
|
+
w = jerlov.water("9C" if method == "kd" else "III", source=source)
|
|
267
|
+
query = 349.5 if method == "kd" else 305.0
|
|
268
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
269
|
+
warnings.simplefilter("always")
|
|
270
|
+
getattr(w, method)(query)
|
|
271
|
+
flagged = [c for c in caught if issubclass(c.category, ProvenanceWarning)]
|
|
272
|
+
assert flagged
|
|
273
|
+
assert all(c.filename == __file__ for c in flagged)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_a_flag_on_a_neighbour_does_not_warn_at_an_exact_sample():
|
|
277
|
+
"""350 nm is sound; only 349 nm is missing. The answer at 350 rests on
|
|
278
|
+
350 alone, as the NaN check already knew."""
|
|
279
|
+
w = jerlov.water("9C", source="jerlov1976")
|
|
280
|
+
with warnings.catch_warnings():
|
|
281
|
+
warnings.simplefilter("error", ProvenanceWarning)
|
|
282
|
+
assert np.isfinite(w.kd(350.0))
|
|
283
|
+
with pytest.warns(ProvenanceWarning, match="missing at 349 nm"):
|
|
284
|
+
w.kd(349.5)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_kd_spectrum_takes_several_measurements_at_once():
|
|
288
|
+
kd = np.array([0.03, 0.06, 0.1])
|
|
289
|
+
at = np.array([440.0, 550.0, 650.0])
|
|
290
|
+
together = jerlov.kd_spectrum(kd, 490, at)
|
|
291
|
+
assert together.shape == (3, 3)
|
|
292
|
+
for row, single in zip(together, kd):
|
|
293
|
+
assert np.allclose(row, jerlov.kd_spectrum(float(single), 490, at))
|
|
294
|
+
at_one = jerlov.kd_spectrum(kd, 490, 550.0)
|
|
295
|
+
assert at_one.shape == (3,)
|
|
296
|
+
assert np.allclose(at_one, together[:, 1])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_kd_spectrum_wants_one_measurement_wavelength():
|
|
300
|
+
with pytest.raises(ValueError, match="single wavelength"):
|
|
301
|
+
jerlov.kd_spectrum(0.06, [490, 500], 550)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_kd_spectrum_warns_outside_the_fitted_range():
|
|
305
|
+
"""Austin & Petzold: the model holds for K(490) < 0.16 1/m."""
|
|
306
|
+
with pytest.warns(ProvenanceWarning, match="0.16"):
|
|
307
|
+
jerlov.kd_spectrum(0.2, 490, 550)
|
|
308
|
+
# Measured elsewhere, the K(490) the model implies is what counts.
|
|
309
|
+
with pytest.warns(ProvenanceWarning, match="implied by the model"):
|
|
310
|
+
jerlov.kd_spectrum(0.3, 440, 550)
|
|
311
|
+
with warnings.catch_warnings():
|
|
312
|
+
warnings.simplefilter("error", ProvenanceWarning)
|
|
313
|
+
jerlov.kd_spectrum(0.15, 490, 550)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_kd_spectrum_warns_where_m_is_extrapolated():
|
|
317
|
+
with pytest.warns(ProvenanceWarning, match="extrapolated at 355 nm"):
|
|
318
|
+
jerlov.kd_spectrum(0.06, 490, 355.0)
|
|
319
|
+
with pytest.warns(ProvenanceWarning, match="extrapolated at 360 nm"):
|
|
320
|
+
jerlov.kd_spectrum(0.06, 490, 362.0)
|
|
321
|
+
# 365 nm is the first sound value of M, and rests on it alone.
|
|
322
|
+
with warnings.catch_warnings():
|
|
323
|
+
warnings.simplefilter("error", ProvenanceWarning)
|
|
324
|
+
jerlov.kd_spectrum(0.06, 490, [365.0, 550.0])
|
|
@@ -165,3 +165,23 @@ def test_it_says_nothing_about_a_jerlov_type():
|
|
|
165
165
|
"""The point of DATA.md section 10 has not quietly gone away."""
|
|
166
166
|
with pytest.raises(jerlov.MissingQuantityError):
|
|
167
167
|
jerlov.water("III").bb(532)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_one_angle_many_wavelengths_gives_one_value_per_wavelength():
|
|
171
|
+
"""A scalar angle used to return the first wavelength's value alone."""
|
|
172
|
+
nms = [450.0, 532.0, 700.0]
|
|
173
|
+
vsf = jerlov.pure_water_vsf(124.0, nms)
|
|
174
|
+
assert np.shape(vsf) == (3,)
|
|
175
|
+
for value, nm in zip(vsf, nms):
|
|
176
|
+
assert value == pytest.approx(jerlov.pure_water_vsf(124.0, nm))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_an_array_of_wavelengths_matches_one_call_per_wavelength():
|
|
180
|
+
"""The water term must be subtracted at each wavelength, not the first."""
|
|
181
|
+
nms = [450.0, 532.0, 700.0]
|
|
182
|
+
together = jerlov.bb_from_vsf([0.001] * 3, 124.0, nms)
|
|
183
|
+
for k, nm in enumerate(nms):
|
|
184
|
+
alone = jerlov.bb_from_vsf(0.001, 124.0, nm)
|
|
185
|
+
assert together.bb[k] == pytest.approx(alone.bb)
|
|
186
|
+
assert together.particulate[k] == pytest.approx(alone.particulate)
|
|
187
|
+
assert together.water[k] == pytest.approx(alone.water)
|
|
@@ -273,3 +273,34 @@ def test_type_hints_reach_the_caller():
|
|
|
273
273
|
"py.typed exists but is not listed as package data, so it will not "
|
|
274
274
|
"be installed"
|
|
275
275
|
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@source_tree
|
|
279
|
+
def test_every_cited_section_exists():
|
|
280
|
+
"""A reference that leads nowhere is worse than none.
|
|
281
|
+
|
|
282
|
+
Error messages and docstrings pointed readers at "README section 10",
|
|
283
|
+
but the README has no numbered sections; the numbered ones are in
|
|
284
|
+
DATA.md. Every "DATA.md section N" cited in the code must exist there,
|
|
285
|
+
and nothing may cite a numbered README section.
|
|
286
|
+
"""
|
|
287
|
+
numbered = {
|
|
288
|
+
int(n) for n in re.findall(r"^## (\d+)\. ",
|
|
289
|
+
(ROOT / "DATA.md").read_text(), re.M)
|
|
290
|
+
}
|
|
291
|
+
offenders = []
|
|
292
|
+
for path in sorted(list(ROOT.glob("jerlov/*.py"))
|
|
293
|
+
+ list(ROOT.glob("jerlov/data/*.csv"))
|
|
294
|
+
+ list(ROOT.glob("tests/*.py"))
|
|
295
|
+
+ list(ROOT.glob("examples/*.py"))):
|
|
296
|
+
text = path.read_text()
|
|
297
|
+
where = path.relative_to(ROOT)
|
|
298
|
+
for match in re.finditer(r"README (?:sections? )?\d", text):
|
|
299
|
+
if path.name != "test_packaging.py":
|
|
300
|
+
offenders.append(f"{where}: '{match.group(0)}'")
|
|
301
|
+
for first, last in re.findall(
|
|
302
|
+
r"DATA\.md sections? (\d+)(?:\s*(?:-|and)\s*(\d+))?", text):
|
|
303
|
+
for n in {int(first), int(last or first)}:
|
|
304
|
+
if n not in numbered:
|
|
305
|
+
offenders.append(f"{where}: DATA.md section {n}")
|
|
306
|
+
assert not offenders, f"references to nothing: {offenders}"
|
|
@@ -82,7 +82,7 @@ def test_solonenko_b_follows_from_its_own_table3(water_type):
|
|
|
82
82
|
"""Eq. (8) with the paper's own constants must give the shipped b.
|
|
83
83
|
|
|
84
84
|
Jerlov I and IA are excluded: their Table 3 entries are not consistent
|
|
85
|
-
with their b column. See
|
|
85
|
+
with their b column. See DATA.md section 4.
|
|
86
86
|
"""
|
|
87
87
|
wl, b, statuses = series("solonenko2015_iop.csv", water_type, "b")
|
|
88
88
|
_, cl, cs, _ = SM_TABLE3[water_type]
|
|
@@ -97,7 +97,7 @@ def test_solonenko_b_follows_from_its_own_table3(water_type):
|
|
|
97
97
|
|
|
98
98
|
@pytest.mark.parametrize("water_type", ["I", "IA"])
|
|
99
99
|
def test_solonenko_table3_is_inconsistent_for_the_clearest_types(water_type):
|
|
100
|
-
"""Guard the known defect of
|
|
100
|
+
"""Guard the known defect of DATA.md section 4, so a fix is noticed."""
|
|
101
101
|
wl, b, _ = series("solonenko2015_iop.csv", water_type, "b")
|
|
102
102
|
_, cl, cs, _ = SM_TABLE3[water_type]
|
|
103
103
|
predicted = scattering(SOLONENKO2015_SCATTERING, wl, cs, cl)
|
|
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
|
|
File without changes
|