jerlov 0.3.2__tar.gz → 0.4.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.3.2/jerlov.egg-info → jerlov-0.4.0}/PKG-INFO +1 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/__init__.py +1 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/_data.py +9 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/colour.py +16 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/scene.py +21 -2
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/shortwave.py +15 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/sources.py +1 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/water.py +114 -32
- {jerlov-0.3.2 → jerlov-0.4.0/jerlov.egg-info}/PKG-INFO +1 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/pyproject.toml +1 -1
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_api.py +56 -2
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_colour.py +10 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_depth.py +14 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_packaging.py +31 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_reproduces_papers.py +2 -2
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_scene.py +28 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_shortwave.py +8 -2
- {jerlov-0.3.2 → jerlov-0.4.0}/LICENSE +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/NOTICE +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/README.md +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/backscattering.py +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/__init__.py +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/austin1986_kd.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/austin1986_model.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/boss2001_chi.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/cie1931_2deg_cmf.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/cie_d65.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/jerlov1968_kd.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/jerlov1968_total_irradiance.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/jerlov1976_kd.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/paulson1977_shortwave.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/smart2007_b_from_c.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/solonenko2015_iop.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/williamson2022_iop.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/williamson2022_measured.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/data/williamson2023_depth.csv +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov/py.typed +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov.egg-info/SOURCES.txt +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov.egg-info/dependency_links.txt +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov.egg-info/requires.txt +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/jerlov.egg-info/top_level.txt +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/setup.cfg +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_backscattering.py +0 -0
- {jerlov-0.3.2 → jerlov-0.4.0}/tests/test_quoted_figures.py +0 -0
|
@@ -54,7 +54,7 @@ def _frozen(array: np.ndarray) -> np.ndarray:
|
|
|
54
54
|
return array
|
|
55
55
|
|
|
56
56
|
#: Values whose ``status`` is one of these should not be used without the
|
|
57
|
-
#: caller being told. See
|
|
57
|
+
#: caller being told. See DATA.md sections 1-6.
|
|
58
58
|
QUESTIONABLE = frozenset({"suspect", "missing", "extrapolated",
|
|
59
59
|
"model_extrapolation", "reconstructed"})
|
|
60
60
|
|
|
@@ -137,6 +137,14 @@ def austin_model() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
|
137
137
|
return _frozen(wl[order]), _frozen(m[order]), _frozen(kw[order])
|
|
138
138
|
|
|
139
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)
|
|
146
|
+
|
|
147
|
+
|
|
140
148
|
@lru_cache(maxsize=None)
|
|
141
149
|
def b_from_c_ratio() -> tuple[np.ndarray, dict[str, np.ndarray]]:
|
|
142
150
|
"""Return ``(wavelengths, {statistic: ratio})`` from Smart (2007) Table 1."""
|
|
@@ -136,6 +136,16 @@ def integrate_response(spectrum, wavelengths, response, response_wavelengths,
|
|
|
136
136
|
-------
|
|
137
137
|
Array of shape ``(k,)``: the integral of spectrum times each channel over
|
|
138
138
|
wavelength, in the units of the spectrum times nm.
|
|
139
|
+
|
|
140
|
+
Notes
|
|
141
|
+
-----
|
|
142
|
+
The integral is taken on the spectrum's own wavelengths, with the
|
|
143
|
+
response interpolated onto them. Sample at 5 nm or finer where you can:
|
|
144
|
+
on coarser grids the narrow peaks of a response, or of the spectrum,
|
|
145
|
+
fall between samples, and the error grows quickly. Interpolating the
|
|
146
|
+
spectrum onto the response's grid instead is no cure, since it only
|
|
147
|
+
trades this error for one in the shape assumed between the spectrum's
|
|
148
|
+
samples.
|
|
139
149
|
"""
|
|
140
150
|
spectrum = np.asarray(spectrum, dtype=float)
|
|
141
151
|
wavelengths = np.asarray(wavelengths, dtype=float)
|
|
@@ -149,6 +159,12 @@ def integrate_response(spectrum, wavelengths, response, response_wavelengths,
|
|
|
149
159
|
raise ValueError("response must be aligned with response_wavelengths")
|
|
150
160
|
if wavelengths.size < 2:
|
|
151
161
|
raise ValueError("at least two wavelengths are needed to integrate")
|
|
162
|
+
# Descending wavelengths make the integral negative and the coverage
|
|
163
|
+
# zero, so a reversed spectrum came out as a negative colour.
|
|
164
|
+
for grid, label in ((wavelengths, "wavelengths"),
|
|
165
|
+
(response_wavelengths, "response_wavelengths")):
|
|
166
|
+
if np.any(np.diff(grid) <= 0):
|
|
167
|
+
raise ValueError(f"{label} must be strictly ascending")
|
|
152
168
|
|
|
153
169
|
covered = [_coverage(wavelengths, response_wavelengths, response[:, k])
|
|
154
170
|
for k in range(response.shape[1])]
|
|
@@ -120,8 +120,27 @@ class Observation:
|
|
|
120
120
|
def contrast(self, background_radiance) -> np.ndarray:
|
|
121
121
|
"""Apparent contrast against a background seen through the same path.
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
``background_radiance`` is the radiance leaving the background, on
|
|
124
|
+
the scene's wavelengths and in the unit of the target radiance
|
|
125
|
+
(``reflectance * downwelling / pi``), not a reflectance.
|
|
126
|
+
|
|
127
|
+
With ``L_t`` and ``L_b`` the radiances leaving target and background,
|
|
128
|
+
``t`` the transmittance and ``B`` the veiling radiance at infinity::
|
|
129
|
+
|
|
130
|
+
C(r) = (L_t - L_b) t / (L_b t + B (1 - t))
|
|
131
|
+
= C_0 * L_b t / (L_b t + B (1 - t)), C_0 = (L_t - L_b) / L_b
|
|
132
|
+
|
|
133
|
+
The path radiance is common to both and cancels from the numerator,
|
|
134
|
+
but not from the denominator, so the loss of contrast is the veiling
|
|
135
|
+
light's doing rather than the transmittance's:
|
|
136
|
+
|
|
137
|
+
- With no veiling radiance (``B = 0``) the contrast is not reduced at
|
|
138
|
+
all; target and background are dimmed by the same factor.
|
|
139
|
+
- Against a background that is the water itself (``L_b = B``, a
|
|
140
|
+
horizontal line of sight into open water) it is ``C_0 * t``, the
|
|
141
|
+
classic result.
|
|
142
|
+
- It falls faster than that against a background darker than ``B``,
|
|
143
|
+
and more slowly against a brighter one.
|
|
125
144
|
"""
|
|
126
145
|
background = _as_array(background_radiance)
|
|
127
146
|
observed_background = (
|
|
@@ -63,7 +63,8 @@ def _rows():
|
|
|
63
63
|
return _data._rows("paulson1977_shortwave.csv")
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
def shortwave_parameters(water_type: str
|
|
66
|
+
def shortwave_parameters(water_type: str, *,
|
|
67
|
+
include_non_types: bool = False) -> ShortwaveParameters:
|
|
67
68
|
"""Paulson & Simpson parameters for a Jerlov water type.
|
|
68
69
|
|
|
69
70
|
Only the five oceanic types I, IA, IB, II and III were fitted; the paper
|
|
@@ -72,10 +73,23 @@ def shortwave_parameters(water_type: str) -> ShortwaveParameters:
|
|
|
72
73
|
``water_type="I_upper50"`` selects the paper's alternative fit for type I
|
|
73
74
|
over the upper 50 m, which it gives because ``ln I`` against depth changes
|
|
74
75
|
slope below that. The plain ``"I"`` row is the 100 m fit.
|
|
76
|
+
|
|
77
|
+
Table 2 also prints three rows that are not water types: the authors'
|
|
78
|
+
composite observations, their Run 1, and a Kraus (1972) value from Crater
|
|
79
|
+
Lake (keys ``"composite_observations"``, ``"run_1"`` and
|
|
80
|
+
``"kraus_1972_very_clear"``). They are refused unless
|
|
81
|
+
``include_non_types=True`` is passed, so that one cannot be mistaken for
|
|
82
|
+
a Jerlov type; their ``water_type`` is empty. See DATA.md section 14.
|
|
75
83
|
"""
|
|
76
84
|
rows = _rows()
|
|
77
85
|
for row in rows:
|
|
78
86
|
if row["key"] == water_type:
|
|
87
|
+
if row["status"] == "not_a_water_type" and not include_non_types:
|
|
88
|
+
raise KeyError(
|
|
89
|
+
f"{water_type!r} is a row of Paulson & Simpson (1977) "
|
|
90
|
+
f"Table 2 but not a Jerlov water type: {row['note']}. "
|
|
91
|
+
"Pass include_non_types=True to get it anyway."
|
|
92
|
+
)
|
|
79
93
|
return ShortwaveParameters(
|
|
80
94
|
key=row["key"],
|
|
81
95
|
water_type=row["water_type"],
|
|
@@ -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
|
|
|
@@ -129,33 +148,25 @@ class Water:
|
|
|
129
148
|
f"wavelength outside the range of the data ({lo:g}-{hi:g} nm). "
|
|
130
149
|
"This package does not extrapolate."
|
|
131
150
|
)
|
|
132
|
-
self.
|
|
151
|
+
support = _support(self.wavelengths, query)
|
|
152
|
+
self._warn_if_flagged(quantity, support)
|
|
133
153
|
values = self._series[quantity]
|
|
134
154
|
out = np.interp(query, self.wavelengths, values)
|
|
135
155
|
# np.interp happily bridges a NaN-free path around a NaN, so check the
|
|
136
|
-
# samples the answer actually rests on.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
idx = np.searchsorted(self.wavelengths, query)
|
|
140
|
-
for k, (i, w_query) in enumerate(zip(idx, query)):
|
|
141
|
-
exact = i < self.wavelengths.size and self.wavelengths[i] == w_query
|
|
142
|
-
if exact:
|
|
143
|
-
if np.isnan(values[i]):
|
|
144
|
-
out[k] = np.nan
|
|
145
|
-
continue
|
|
146
|
-
left, right = max(i - 1, 0), min(i, values.size - 1)
|
|
147
|
-
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):
|
|
148
159
|
out[k] = np.nan
|
|
149
160
|
return _like_input(out, wl)
|
|
150
161
|
|
|
151
|
-
def _warn_if_flagged(self, quantity: str,
|
|
162
|
+
def _warn_if_flagged(self, quantity: str,
|
|
163
|
+
support: list[tuple[int, ...]]) -> None:
|
|
152
164
|
statuses = self._flags.get(quantity)
|
|
153
165
|
if not statuses:
|
|
154
166
|
return
|
|
155
167
|
hit: set[str] = set()
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for j in (max(i - 1, 0), min(i, len(statuses) - 1)):
|
|
168
|
+
for samples in support:
|
|
169
|
+
for j in samples:
|
|
159
170
|
status = statuses[j]
|
|
160
171
|
if status in _data.QUESTIONABLE:
|
|
161
172
|
hit.add(f"{status} at {self.wavelengths[j]:g} nm")
|
|
@@ -163,7 +174,7 @@ class Water:
|
|
|
163
174
|
warnings.warn(
|
|
164
175
|
f"{quantity} for Jerlov {self.name} rests on flagged values: "
|
|
165
176
|
+ "; ".join(sorted(hit))
|
|
166
|
-
+ ". See
|
|
177
|
+
+ ". See DATA.md for what is known about them.",
|
|
167
178
|
ProvenanceWarning,
|
|
168
179
|
stacklevel=_data.caller_stacklevel(),
|
|
169
180
|
)
|
|
@@ -190,7 +201,7 @@ class Water:
|
|
|
190
201
|
``backscatter_ratio`` is bb/b and has no default. It is not determined
|
|
191
202
|
by the Jerlov classification: deriving it from the particle
|
|
192
203
|
concentrations of Solonenko & Mobley and of Williamson & Hollins gives
|
|
193
|
-
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.
|
|
194
205
|
|
|
195
206
|
Reported ranges are roughly 0.005-0.01 for open ocean and 0.015-0.03
|
|
196
207
|
for coastal water; the Petzold average-particle phase function gives
|
|
@@ -201,7 +212,7 @@ class Water:
|
|
|
201
212
|
"bb is not determined by the water type. Pass "
|
|
202
213
|
"backscatter_ratio=... explicitly (bb/b; roughly 0.005-0.01 "
|
|
203
214
|
"for open ocean, 0.015-0.03 for coastal water). "
|
|
204
|
-
"See
|
|
215
|
+
"See DATA.md section 10."
|
|
205
216
|
)
|
|
206
217
|
if not 0.0 < backscatter_ratio < 0.5:
|
|
207
218
|
raise ValueError("backscatter_ratio must lie in (0, 0.5)")
|
|
@@ -314,6 +325,11 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
|
|
|
314
325
|
any particular place or season. The paper says so explicitly, and gives
|
|
315
326
|
per-cell cruise and month counts for anyone who needs to judge that.
|
|
316
327
|
"""
|
|
328
|
+
depth_m = float(depth_m)
|
|
329
|
+
if not np.isfinite(depth_m):
|
|
330
|
+
# NaN fails every comparison below, so it would otherwise fall
|
|
331
|
+
# through to "no statement" and look like a deliberate answer.
|
|
332
|
+
raise ValueError(f"depth_m must be a finite number, not {depth_m!r}")
|
|
317
333
|
if depth_m < 0:
|
|
318
334
|
raise ValueError("depth_m cannot be negative")
|
|
319
335
|
rows = _data._rows("williamson2023_depth.csv")
|
|
@@ -323,15 +339,23 @@ def water_type_at_depth(surface_water_type: str, depth_m: float) -> str | None:
|
|
|
323
339
|
f"unknown water type {surface_water_type!r} "
|
|
324
340
|
f"(known: {', '.join(sorted(known))})"
|
|
325
341
|
)
|
|
326
|
-
for
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
342
|
+
own = [r for r in rows if r["surface_water_type"] == surface_water_type]
|
|
343
|
+
deepest = max(float(r["depth_max_m"]) for r in own)
|
|
344
|
+
for row in own:
|
|
345
|
+
top, bottom = float(row["depth_min_m"]), float(row["depth_max_m"])
|
|
346
|
+
# A boundary belongs to the layer below it, except at the bottom of
|
|
347
|
+
# the deepest layer, which has no layer below: 200 m is still
|
|
348
|
+
# inside the paper's profile.
|
|
349
|
+
if top <= depth_m < bottom or depth_m == bottom == deepest:
|
|
330
350
|
return row["water_type"] or None
|
|
331
351
|
# Beyond 200 m the paper makes no statement at all.
|
|
332
352
|
return None
|
|
333
353
|
|
|
334
354
|
|
|
355
|
+
#: Austin & Petzold (1986) state their model holds below this K(490), 1/m.
|
|
356
|
+
AUSTIN_KD490_LIMIT = 0.16
|
|
357
|
+
|
|
358
|
+
|
|
335
359
|
def kd_spectrum(kd, wavelength_nm: float, at):
|
|
336
360
|
"""Reconstruct a Kd spectrum from a single measured value.
|
|
337
361
|
|
|
@@ -342,38 +366,96 @@ def kd_spectrum(kd, wavelength_nm: float, at):
|
|
|
342
366
|
Parameters
|
|
343
367
|
----------
|
|
344
368
|
kd:
|
|
345
|
-
Measured Kd in 1/m.
|
|
369
|
+
Measured Kd in 1/m. A scalar, or an array of measurements all made at
|
|
370
|
+
``wavelength_nm`` (several stations, say); each is reconstructed
|
|
371
|
+
separately.
|
|
346
372
|
wavelength_nm:
|
|
347
|
-
Wavelength at which ``kd`` was measured.
|
|
373
|
+
Wavelength at which ``kd`` was measured. A single value.
|
|
348
374
|
at:
|
|
349
375
|
Wavelength(s) at which to evaluate the spectrum.
|
|
350
376
|
|
|
377
|
+
Returns
|
|
378
|
+
-------
|
|
379
|
+
A float for scalar ``kd`` and ``at``. Otherwise an array of shape
|
|
380
|
+
``kd.shape + at.shape``: one spectrum per measurement.
|
|
381
|
+
|
|
351
382
|
Notes
|
|
352
383
|
-----
|
|
353
|
-
The authors state the model holds for K(490) < 0.16 1/m
|
|
384
|
+
The authors state the model holds for K(490) < 0.16 1/m, and a
|
|
385
|
+
:class:`ProvenanceWarning` is raised for any measurement whose K(490),
|
|
386
|
+
measured or implied by the model, is not below that. Accuracy is about
|
|
354
387
|
8 percent at wavelengths up to 590 nm and degrades to about 31 percent at
|
|
355
|
-
670 nm
|
|
388
|
+
670 nm. M below 365 nm is itself extrapolated, and a result that rests on
|
|
389
|
+
it also warns.
|
|
356
390
|
"""
|
|
357
391
|
wl, m, kw = _data.austin_model()
|
|
358
392
|
lo, hi = float(wl[0]), float(wl[-1])
|
|
393
|
+
if np.ndim(wavelength_nm) != 0:
|
|
394
|
+
raise ValueError(
|
|
395
|
+
"wavelength_nm must be a single wavelength; for measurements at "
|
|
396
|
+
"different wavelengths, call kd_spectrum once for each"
|
|
397
|
+
)
|
|
398
|
+
wavelength_nm = float(wavelength_nm)
|
|
359
399
|
query = _as_array(at)
|
|
360
400
|
for value, label in ((wavelength_nm, "wavelength_nm"), (query, "at")):
|
|
361
401
|
if np.any(np.asarray(value) < lo) or np.any(np.asarray(value) > hi):
|
|
362
402
|
raise ValueError(f"{label} outside the model range ({lo:g}-{hi:g} nm)")
|
|
403
|
+
kd_values = np.asarray(kd, dtype=float)
|
|
363
404
|
|
|
364
405
|
m1 = float(np.interp(wavelength_nm, wl, m))
|
|
365
406
|
kw1 = float(np.interp(wavelength_nm, wl, kw))
|
|
366
|
-
|
|
407
|
+
below = kd_values < kw1
|
|
408
|
+
if np.any(below):
|
|
409
|
+
shown = ", ".join(f"{v:g}" for v in np.atleast_1d(kd_values)[
|
|
410
|
+
np.atleast_1d(below)][:5])
|
|
367
411
|
warnings.warn(
|
|
368
|
-
f"Kd={
|
|
412
|
+
f"Kd={shown} is below the pure sea water value {kw1:g} at "
|
|
369
413
|
f"{wavelength_nm:g} nm, which is not physically possible. "
|
|
370
414
|
"Austin & Petzold (1986) reported exactly this problem in "
|
|
371
415
|
"Jerlov's own type I values.",
|
|
372
416
|
ProvenanceWarning,
|
|
373
417
|
stacklevel=_data.caller_stacklevel(),
|
|
374
418
|
)
|
|
375
|
-
|
|
376
|
-
|
|
419
|
+
|
|
420
|
+
kd490 = (float(np.interp(490.0, wl, m)) / m1 * (kd_values - kw1)
|
|
421
|
+
+ float(np.interp(490.0, wl, kw)))
|
|
422
|
+
outside = kd490 >= AUSTIN_KD490_LIMIT
|
|
423
|
+
if np.any(outside):
|
|
424
|
+
shown = ", ".join(f"{v:.3g}" for v in np.atleast_1d(kd490)[
|
|
425
|
+
np.atleast_1d(outside)][:5])
|
|
426
|
+
warnings.warn(
|
|
427
|
+
f"K(490) = {shown} 1/m"
|
|
428
|
+
+ (" (implied by the model)" if wavelength_nm != 490.0 else "")
|
|
429
|
+
+ f" is not below {AUSTIN_KD490_LIMIT:g} 1/m, the limit Austin & "
|
|
430
|
+
"Petzold (1986) give for their model. The reconstruction is "
|
|
431
|
+
"outside the range it was fitted to.",
|
|
432
|
+
ProvenanceWarning,
|
|
433
|
+
stacklevel=_data.caller_stacklevel(),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
statuses = _data.austin_model_status()
|
|
437
|
+
flagged: set[str] = set()
|
|
438
|
+
for samples in _support(wl, np.concatenate(([wavelength_nm], query))):
|
|
439
|
+
for j in samples:
|
|
440
|
+
if statuses[j] in _data.QUESTIONABLE:
|
|
441
|
+
flagged.add(f"{statuses[j]} at {wl[j]:g} nm")
|
|
442
|
+
if flagged:
|
|
443
|
+
warnings.warn(
|
|
444
|
+
"the result rests on values of M that Austin & Petzold (1986) "
|
|
445
|
+
"flag as " + "; ".join(sorted(flagged)) + ", and say should be "
|
|
446
|
+
"used with caution",
|
|
447
|
+
ProvenanceWarning,
|
|
448
|
+
stacklevel=_data.caller_stacklevel(),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
ratio = np.interp(query, wl, m) / m1
|
|
452
|
+
result = (ratio * (kd_values[..., None] - kw1)
|
|
453
|
+
+ np.interp(query, wl, kw))
|
|
454
|
+
if np.ndim(at) == 0:
|
|
455
|
+
result = result[..., 0]
|
|
456
|
+
if np.ndim(result) == 0:
|
|
457
|
+
return float(result)
|
|
458
|
+
return result
|
|
377
459
|
|
|
378
460
|
|
|
379
461
|
def b_from_c(c, wavelength_nm, *, bw, cw, bound: str = "average"):
|
|
@@ -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
|
|
|
@@ -268,3 +271,54 @@ def test_a_provenance_warning_points_at_the_callers_line(method):
|
|
|
268
271
|
flagged = [c for c in caught if issubclass(c.category, ProvenanceWarning)]
|
|
269
272
|
assert flagged
|
|
270
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])
|
|
@@ -222,3 +222,13 @@ def test_water_reddens_nothing_and_blues_everything():
|
|
|
222
222
|
# At the surface the target is neutral; underwater it is not.
|
|
223
223
|
assert np.allclose(surface, surface[0], atol=1e-6)
|
|
224
224
|
assert underwater[2] > underwater[0], "blue should survive better than red"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_wavelengths_must_ascend():
|
|
228
|
+
"""A reversed spectrum used to integrate to a negative XYZ."""
|
|
229
|
+
wl = np.arange(360.0, 831.0, 5.0)
|
|
230
|
+
flat = np.ones_like(wl)
|
|
231
|
+
with pytest.raises(ValueError, match="strictly ascending"):
|
|
232
|
+
jerlov.spectrum_to_xyz(flat[::-1], wl[::-1])
|
|
233
|
+
with pytest.raises(ValueError, match="strictly ascending"):
|
|
234
|
+
jerlov.integrate_response(flat, wl, flat, wl[::-1])
|
|
@@ -48,6 +48,20 @@ def test_boundaries_belong_to_the_layer_below():
|
|
|
48
48
|
assert jerlov.water_type_at_depth("I", 19.999) == "I" # end of 10-20
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_the_bottom_of_the_profile_is_inside_it():
|
|
52
|
+
"""200 m closes the deepest layer; there is no layer below to own it."""
|
|
53
|
+
assert jerlov.water_type_at_depth("I", 200.0) == "IB"
|
|
54
|
+
assert jerlov.water_type_at_depth("I", 199.9) == "IB"
|
|
55
|
+
assert jerlov.water_type_at_depth("I", 200.1) is None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_depth_that_is_not_a_number_is_refused():
|
|
59
|
+
"""NaN used to fall through to None, which reads as the paper's answer."""
|
|
60
|
+
for bad in (float("nan"), float("inf")):
|
|
61
|
+
with pytest.raises(ValueError, match="finite"):
|
|
62
|
+
jerlov.water_type_at_depth("I", bad)
|
|
63
|
+
|
|
64
|
+
|
|
51
65
|
def test_unknown_type_names_the_alternatives():
|
|
52
66
|
with pytest.raises(KeyError, match="known:"):
|
|
53
67
|
jerlov.water_type_at_depth("IV", 10.0)
|
|
@@ -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)
|
|
@@ -89,6 +89,34 @@ def test_contrast_decays_with_distance():
|
|
|
89
89
|
assert contrasts[-1] < 0.01
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def test_contrast_follows_the_formula_in_its_docstring():
|
|
93
|
+
"""The docstring used to say the inherent contrast is simply reduced by
|
|
94
|
+
the transmittance. That holds only against a background as bright as the
|
|
95
|
+
water's own veiling light."""
|
|
96
|
+
s = scene()
|
|
97
|
+
target = flat(0.8) * s.downwelling / np.pi
|
|
98
|
+
background = flat(0.1) * s.downwelling / np.pi
|
|
99
|
+
inherent = (target - background) / background
|
|
100
|
+
veil = flat(0.02)
|
|
101
|
+
for r in (0.5, 2.0, 8.0):
|
|
102
|
+
t = s.transmittance(r)
|
|
103
|
+
obs = s.observe(flat(0.8), r, veiling_radiance=veil)
|
|
104
|
+
expected = inherent * background * t / (background * t + veil * (1 - t))
|
|
105
|
+
assert np.allclose(obs.contrast(background), expected)
|
|
106
|
+
# No veiling light: dimming target and background alike leaves the
|
|
107
|
+
# contrast where it was.
|
|
108
|
+
bare = s.observe(flat(0.8), r, veiling_radiance=flat(0.0))
|
|
109
|
+
assert np.allclose(bare.contrast(background), inherent)
|
|
110
|
+
# A background that is the water itself gives the classic C_0 * t.
|
|
111
|
+
water = s.observe(flat(0.8), r, veiling_radiance=background)
|
|
112
|
+
assert np.allclose(water.contrast(background), inherent * t)
|
|
113
|
+
# Darker than the water, it falls faster than that; brighter, slower.
|
|
114
|
+
assert np.all(np.abs(s.observe(flat(0.8), r, veiling_radiance=2 * background)
|
|
115
|
+
.contrast(background)) < np.abs(inherent * t))
|
|
116
|
+
assert np.all(np.abs(s.observe(flat(0.8), r, veiling_radiance=background / 2)
|
|
117
|
+
.contrast(background)) > np.abs(inherent * t))
|
|
118
|
+
|
|
119
|
+
|
|
92
120
|
def test_turbid_water_veils_faster_than_clear():
|
|
93
121
|
clear = scene("IB").observe(flat(), 3.0, veiling_radiance=flat(0.01))
|
|
94
122
|
turbid = scene("5C").observe(flat(), 3.0, veiling_radiance=flat(0.01))
|
|
@@ -96,9 +96,15 @@ def test_the_alternative_type_I_fit_is_reachable():
|
|
|
96
96
|
|
|
97
97
|
|
|
98
98
|
def test_the_rows_that_are_not_water_types_are_not_offered():
|
|
99
|
+
"""They used to come back like any type, so `solar_fraction("run_1", z)`
|
|
100
|
+
gave a Jerlov-looking answer for one cruise's run."""
|
|
99
101
|
for key in ("composite_observations", "run_1", "kraus_1972_very_clear"):
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
with pytest.raises(KeyError, match="not a Jerlov water type"):
|
|
103
|
+
jerlov.shortwave_parameters(key)
|
|
104
|
+
with pytest.raises(KeyError, match="not a Jerlov water type"):
|
|
105
|
+
jerlov.solar_fraction(key, 10.0)
|
|
106
|
+
p = jerlov.shortwave_parameters(key, include_non_types=True)
|
|
107
|
+
assert p.water_type == "" # reachable when asked for, not a type
|
|
102
108
|
assert "Not a Jerlov type" in p.note
|
|
103
109
|
|
|
104
110
|
|
|
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
|