eval-ac 0.3.1__py3-none-any.whl
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.
- eval_ac/__init__.py +7 -0
- eval_ac/__main__.py +6 -0
- eval_ac/analysis.py +326 -0
- eval_ac/cli.py +234 -0
- eval_ac/convert_abscal_his.py +315 -0
- eval_ac/evaluate_absolute_calibration.py +14 -0
- eval_ac/plotting.py +264 -0
- eval_ac/utils/__init__.py +1 -0
- eval_ac/utils/attributes.py +170 -0
- eval_ac-0.3.1.dist-info/METADATA +306 -0
- eval_ac-0.3.1.dist-info/RECORD +15 -0
- eval_ac-0.3.1.dist-info/WHEEL +5 -0
- eval_ac-0.3.1.dist-info/entry_points.txt +2 -0
- eval_ac-0.3.1.dist-info/licenses/LICENSE +21 -0
- eval_ac-0.3.1.dist-info/top_level.txt +1 -0
eval_ac/__init__.py
ADDED
eval_ac/__main__.py
ADDED
eval_ac/analysis.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Selection, quality control and drift analysis of the calibration history.
|
|
2
|
+
|
|
3
|
+
Typical usage::
|
|
4
|
+
|
|
5
|
+
from eval_ac.convert_abscal_his import read_abscal_his
|
|
6
|
+
from eval_ac.analysis import select_cal_type, calibration_drift, \
|
|
7
|
+
drift_exceedances
|
|
8
|
+
|
|
9
|
+
ds = select_cal_type(read_abscal_his('ABSCAL.HIS'))
|
|
10
|
+
drift = calibration_drift(ds)
|
|
11
|
+
for row in drift_exceedances(drift):
|
|
12
|
+
print(row)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import xarray as xr
|
|
19
|
+
|
|
20
|
+
from eval_ac.convert_abscal_his import TIME_REFERENCE
|
|
21
|
+
|
|
22
|
+
#: Calibration types stored in ``cal_type_1`` and ``cal_type_2``.
|
|
23
|
+
CAL_TYPE_NONE = 0
|
|
24
|
+
CAL_TYPE_LN2 = 1
|
|
25
|
+
CAL_TYPE_SKY_TIPPING = 2
|
|
26
|
+
|
|
27
|
+
#: Value of ``calibration_flag`` for channels that were not calibrated.
|
|
28
|
+
FLAG_NOT_CALIBRATED = 0
|
|
29
|
+
|
|
30
|
+
#: Recommended maximum interval between two absolute calibrations in days:
|
|
31
|
+
#: "every 5 to 6 months of operation or after transportation"
|
|
32
|
+
#: (RPG manual RPG-MWR-STD-SW, section 3.1.3).
|
|
33
|
+
MAX_CALIBRATION_AGE_DAYS = 183
|
|
34
|
+
|
|
35
|
+
#: Valid range of the non-linearity factor, 0.9 <= alpha < 1
|
|
36
|
+
#: (RPG manual RPG-MWR-STD-SW, section 3.1.3.1, equation 1).
|
|
37
|
+
ALPHA_RANGE = (0.9, 1.0)
|
|
38
|
+
|
|
39
|
+
#: Variables with one value per calibration entry and channel.
|
|
40
|
+
CHANNEL_VARIABLES = ('gain', 'temp_noise', 'temp_sys', 'alpha')
|
|
41
|
+
|
|
42
|
+
#: Default number of calibrations before the latest one that form the
|
|
43
|
+
#: reference of the drift analysis.
|
|
44
|
+
DEFAULT_N_REFERENCE = 5
|
|
45
|
+
|
|
46
|
+
#: Default thresholds of the drift analysis in percent. They are about the
|
|
47
|
+
#: 99th percentile of the deviations found in the example data of a HATPRO
|
|
48
|
+
#: (2018-2026) and should be adapted to the instrument.
|
|
49
|
+
DEFAULT_THRESHOLDS = {
|
|
50
|
+
'gain': 10.0,
|
|
51
|
+
'temp_noise': 2.5,
|
|
52
|
+
'temp_sys': 2.5,
|
|
53
|
+
'alpha': 0.5,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def receiver_masks(data_set: xr.Dataset) -> dict:
|
|
58
|
+
"""Returns a boolean channel mask for each receiver number."""
|
|
59
|
+
if 'receiver' not in data_set.coords:
|
|
60
|
+
return {1: np.ones(data_set.sizes['freq'], dtype=bool)}
|
|
61
|
+
receiver = data_set['receiver'].values
|
|
62
|
+
return {int(number): receiver == number for number in np.unique(receiver)}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def channel_cal_type(data_set: xr.Dataset) -> xr.DataArray:
|
|
66
|
+
"""Returns the calibration type of each entry and channel.
|
|
67
|
+
|
|
68
|
+
Channels of receiver 1 get ``cal_type_1``, channels of receiver 2 get
|
|
69
|
+
``cal_type_2``.
|
|
70
|
+
"""
|
|
71
|
+
cal_type = np.empty((data_set.sizes['n_samples'],
|
|
72
|
+
data_set.sizes['freq']), dtype=np.int32)
|
|
73
|
+
for number, mask in receiver_masks(data_set).items():
|
|
74
|
+
cal_type[:, mask] = data_set[f'cal_type_{number}'].values[:, None]
|
|
75
|
+
return xr.DataArray(cal_type, dims=('n_samples', 'freq'))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def select_cal_type(data_set: xr.Dataset,
|
|
79
|
+
cal_type: int = CAL_TYPE_LN2) -> xr.Dataset:
|
|
80
|
+
"""Keeps only the calibrations of the given type.
|
|
81
|
+
|
|
82
|
+
Entries in which no receiver has the requested calibration type are
|
|
83
|
+
removed. If only one receiver of an entry has the requested type, the
|
|
84
|
+
values of the other receiver are set to NaN.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
data_set: Dataset as returned by
|
|
88
|
+
:func:`eval_ac.convert_abscal_his.read_abscal_his`.
|
|
89
|
+
cal_type: Calibration type to keep (default: liquid nitrogen).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dataset with the selected calibrations.
|
|
93
|
+
"""
|
|
94
|
+
valid = channel_cal_type(data_set) == cal_type
|
|
95
|
+
keep = valid.any('freq').values
|
|
96
|
+
selected = data_set.isel(n_samples=keep)
|
|
97
|
+
valid = valid.isel(n_samples=keep)
|
|
98
|
+
for var in CHANNEL_VARIABLES:
|
|
99
|
+
if var in selected:
|
|
100
|
+
selected[var] = selected[var].where(valid.values,
|
|
101
|
+
drop=False)
|
|
102
|
+
selected[var].attrs = data_set[var].attrs
|
|
103
|
+
return selected
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def not_calibrated(data_set: xr.Dataset) -> np.ndarray:
|
|
107
|
+
"""Returns True for every entry and channel that was not calibrated."""
|
|
108
|
+
if 'calibration_flag' not in data_set:
|
|
109
|
+
return np.zeros((data_set.sizes['n_samples'],
|
|
110
|
+
data_set.sizes['freq']), dtype=bool)
|
|
111
|
+
return data_set['calibration_flag'].values == FLAG_NOT_CALIBRATED
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def valid_samples(values: np.ndarray) -> np.ndarray:
|
|
115
|
+
"""Returns the indices of the entries with at least one value."""
|
|
116
|
+
return np.flatnonzero(~np.all(np.isnan(values), axis=1))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _receiver_drift(values, flagged, n_reference):
|
|
120
|
+
"""Drift of one variable and one receiver.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple ``(reference, deviation, latest, n_used)``.
|
|
124
|
+
"""
|
|
125
|
+
# the latest entry is defined without the flags, so that a latest
|
|
126
|
+
# calibration without any calibrated channel is not silently skipped
|
|
127
|
+
samples = valid_samples(values)
|
|
128
|
+
values = np.where(flagged, np.nan, values)
|
|
129
|
+
n_channels = values.shape[1]
|
|
130
|
+
if samples.size == 0:
|
|
131
|
+
return (np.full(n_channels, np.nan), np.full(values.shape, np.nan),
|
|
132
|
+
-1, np.zeros(n_channels, dtype=np.int32))
|
|
133
|
+
latest = samples[-1]
|
|
134
|
+
reference_values = values[samples[-1 - n_reference:-1]]
|
|
135
|
+
with warnings.catch_warnings():
|
|
136
|
+
warnings.simplefilter('ignore', RuntimeWarning) # all-NaN channels
|
|
137
|
+
reference = np.nanmedian(reference_values, axis=0) \
|
|
138
|
+
if reference_values.size else np.full(n_channels, np.nan)
|
|
139
|
+
deviation = 100 * (values - reference) / reference
|
|
140
|
+
n_used = np.sum(~np.isnan(reference_values), axis=0).astype(np.int32)
|
|
141
|
+
return reference, deviation, latest, n_used
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def calibration_drift(data_set: xr.Dataset,
|
|
145
|
+
variables=CHANNEL_VARIABLES,
|
|
146
|
+
n_reference: int = DEFAULT_N_REFERENCE) -> xr.Dataset:
|
|
147
|
+
"""Compares each calibration with the median of the previous ones.
|
|
148
|
+
|
|
149
|
+
For each receiver, the reference is the median of the ``n_reference``
|
|
150
|
+
calibrations before the latest one. Channels that were not calibrated
|
|
151
|
+
(``calibration_flag == 0``) are neither used for the reference nor
|
|
152
|
+
evaluated.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
data_set: Dataset, typically after :func:`select_cal_type`.
|
|
156
|
+
variables: Variables to analyse.
|
|
157
|
+
n_reference: Number of calibrations forming the reference.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
:class:`xarray.Dataset` with, for each analysed variable ``var``,
|
|
161
|
+
``var_reference`` (freq) with the reference value and
|
|
162
|
+
``var_deviation`` (n_samples, freq) with the deviation of every entry
|
|
163
|
+
from the reference in percent. In addition, ``latest_sample`` (freq)
|
|
164
|
+
holds the position of the latest entry of each channel and
|
|
165
|
+
``n_reference_used`` (freq) the number of values in the reference.
|
|
166
|
+
"""
|
|
167
|
+
flagged = not_calibrated(data_set)
|
|
168
|
+
n_samples, n_freq = data_set.sizes['n_samples'], data_set.sizes['freq']
|
|
169
|
+
latest = np.full(n_freq, -1, dtype=np.int32)
|
|
170
|
+
n_used = np.zeros(n_freq, dtype=np.int32)
|
|
171
|
+
drift = xr.Dataset(coords={name: data_set.coords[name]
|
|
172
|
+
for name in data_set.coords})
|
|
173
|
+
|
|
174
|
+
for var in variables:
|
|
175
|
+
reference = np.full(n_freq, np.nan)
|
|
176
|
+
deviation = np.full((n_samples, n_freq), np.nan)
|
|
177
|
+
for mask in receiver_masks(data_set).values():
|
|
178
|
+
(reference[mask], deviation[:, mask], latest[mask],
|
|
179
|
+
n_used[mask]) = _receiver_drift(
|
|
180
|
+
data_set[var].values[:, mask].astype(float),
|
|
181
|
+
flagged[:, mask], n_reference)
|
|
182
|
+
long_name = data_set[var].attrs.get('long_name', var)
|
|
183
|
+
drift[f'{var}_reference'] = ('freq', reference, {
|
|
184
|
+
'long_name': f'{long_name}, median of {n_reference} previous '
|
|
185
|
+
'calibrations',
|
|
186
|
+
'units': data_set[var].attrs.get('units', '')})
|
|
187
|
+
drift[f'{var}_deviation'] = (('n_samples', 'freq'), deviation, {
|
|
188
|
+
'long_name': f'deviation of {long_name} from reference',
|
|
189
|
+
'units': '%'})
|
|
190
|
+
|
|
191
|
+
drift['latest_sample'] = ('freq', latest, {
|
|
192
|
+
'long_name': 'position of the latest calibration along n_samples'})
|
|
193
|
+
drift['n_reference_used'] = ('freq', n_used, {
|
|
194
|
+
'long_name': 'number of calibrations in the reference'})
|
|
195
|
+
drift.attrs['n_reference'] = n_reference
|
|
196
|
+
return drift
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _latest_per_receiver(data_set: xr.Dataset, variable='gain'):
|
|
200
|
+
"""Yields ``(receiver number, channel mask, position of latest entry)``.
|
|
201
|
+
|
|
202
|
+
Receivers without any entry are skipped.
|
|
203
|
+
"""
|
|
204
|
+
for number, mask in receiver_masks(data_set).items():
|
|
205
|
+
samples = valid_samples(data_set[variable].values[:, mask]
|
|
206
|
+
.astype(float))
|
|
207
|
+
if samples.size:
|
|
208
|
+
yield number, mask, samples[-1]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def latest_not_calibrated(data_set: xr.Dataset, variable='gain') -> list:
|
|
212
|
+
"""Lists the channels of the latest calibration with flag 0.
|
|
213
|
+
|
|
214
|
+
The latest calibration is determined per receiver.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
List of dictionaries with the keys ``freq`` and ``receiver``.
|
|
218
|
+
"""
|
|
219
|
+
flagged = not_calibrated(data_set)
|
|
220
|
+
rows = []
|
|
221
|
+
for number, mask, latest in _latest_per_receiver(data_set, variable):
|
|
222
|
+
for freq in data_set['freq'].values[mask][flagged[latest, mask]]:
|
|
223
|
+
rows.append({'freq': float(freq), 'receiver': number})
|
|
224
|
+
return rows
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def receiver_time(data_set: xr.Dataset, number: int) -> np.ndarray:
|
|
228
|
+
"""Returns the calibration times of receiver ``number``.
|
|
229
|
+
|
|
230
|
+
Uses ``time_of_rec_<number>`` (seconds since 2001-01-01, or already
|
|
231
|
+
decoded when read from a NetCDF file) and falls back to the coordinate
|
|
232
|
+
``time``.
|
|
233
|
+
"""
|
|
234
|
+
name = f'time_of_rec_{number}'
|
|
235
|
+
if name not in data_set:
|
|
236
|
+
return data_set['time'].values
|
|
237
|
+
values = data_set[name].values
|
|
238
|
+
if np.issubdtype(values.dtype, np.datetime64):
|
|
239
|
+
return values
|
|
240
|
+
return TIME_REFERENCE + values.astype('timedelta64[s]')
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def calibration_age(data_set: xr.Dataset, now=None) -> list:
|
|
244
|
+
"""Returns the age of the latest calibration of each receiver.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
data_set: Dataset, typically after :func:`select_cal_type`.
|
|
248
|
+
now: Reference time (anything accepted by ``numpy.datetime64``,
|
|
249
|
+
default: current UTC time).
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
List of dictionaries with the keys ``receiver``, ``time``
|
|
253
|
+
(``numpy.datetime64``) and ``age_days``.
|
|
254
|
+
"""
|
|
255
|
+
now = np.datetime64('now' if now is None else now, 's')
|
|
256
|
+
rows = []
|
|
257
|
+
for number, _, latest in _latest_per_receiver(data_set):
|
|
258
|
+
time = receiver_time(data_set, number)[latest].astype(
|
|
259
|
+
'datetime64[s]')
|
|
260
|
+
rows.append({'receiver': number, 'time': time,
|
|
261
|
+
'age_days': float((now - time) / np.timedelta64(1, 'D'))})
|
|
262
|
+
return rows
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def alpha_out_of_range(data_set: xr.Dataset, valid_range=ALPHA_RANGE) -> list:
|
|
266
|
+
"""Lists channels of the latest calibration with alpha out of range.
|
|
267
|
+
|
|
268
|
+
Channels that were not calibrated (flag 0) are not checked.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
data_set: Dataset, typically after :func:`select_cal_type`.
|
|
272
|
+
valid_range: Tuple ``(low, high)``; valid is ``low <= alpha < high``.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of dictionaries with the keys ``freq``, ``receiver`` and
|
|
276
|
+
``alpha``.
|
|
277
|
+
"""
|
|
278
|
+
low, high = valid_range
|
|
279
|
+
flagged = not_calibrated(data_set)
|
|
280
|
+
rows = []
|
|
281
|
+
for number, mask, latest in _latest_per_receiver(data_set, 'alpha'):
|
|
282
|
+
alpha = data_set['alpha'].values[latest, mask].astype(float)
|
|
283
|
+
with np.errstate(invalid='ignore'):
|
|
284
|
+
bad = ~((alpha >= low) & (alpha < high))
|
|
285
|
+
bad &= ~flagged[latest, mask] & ~np.isnan(alpha)
|
|
286
|
+
for freq, value in zip(data_set['freq'].values[mask][bad],
|
|
287
|
+
alpha[bad]):
|
|
288
|
+
rows.append({'freq': float(freq), 'receiver': number,
|
|
289
|
+
'alpha': float(value)})
|
|
290
|
+
return rows
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def drift_exceedances(drift: xr.Dataset, thresholds=None) -> list:
|
|
294
|
+
"""Lists the channels of the latest calibration exceeding a threshold.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
drift: Dataset as returned by :func:`calibration_drift`.
|
|
298
|
+
thresholds: Dictionary ``{variable: threshold in percent}``. Missing
|
|
299
|
+
variables use :data:`DEFAULT_THRESHOLDS`.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of dictionaries with the keys ``variable``, ``freq``,
|
|
303
|
+
``receiver``, ``deviation`` and ``threshold``, sorted by variable
|
|
304
|
+
and frequency.
|
|
305
|
+
"""
|
|
306
|
+
thresholds = {**DEFAULT_THRESHOLDS, **(thresholds or {})}
|
|
307
|
+
latest = drift['latest_sample'].values
|
|
308
|
+
rows = []
|
|
309
|
+
for var, threshold in thresholds.items():
|
|
310
|
+
if f'{var}_deviation' not in drift:
|
|
311
|
+
continue
|
|
312
|
+
deviation = drift[f'{var}_deviation'].values
|
|
313
|
+
for channel, sample in enumerate(latest):
|
|
314
|
+
if sample < 0:
|
|
315
|
+
continue
|
|
316
|
+
value = deviation[sample, channel]
|
|
317
|
+
if abs(value) > threshold: # NaN never exceeds
|
|
318
|
+
rows.append({
|
|
319
|
+
'variable': var,
|
|
320
|
+
'freq': float(drift['freq'].values[channel]),
|
|
321
|
+
'receiver': int(drift['receiver'].values[channel])
|
|
322
|
+
if 'receiver' in drift.coords else 1,
|
|
323
|
+
'deviation': float(value),
|
|
324
|
+
'threshold': float(threshold),
|
|
325
|
+
})
|
|
326
|
+
return rows
|
eval_ac/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Command line interface of eval_ac.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
eval-ac ABSCAL.HIS --netcdf abscal.nc --plot results_ln2_cal.png
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from eval_ac import __version__
|
|
12
|
+
|
|
13
|
+
#: Exit code if the quality report contains a warning and
|
|
14
|
+
#: ``--fail-on-warning`` is given.
|
|
15
|
+
EXIT_WARNING = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _threshold(text: str) -> tuple:
|
|
19
|
+
"""Parses 'VARIABLE=PERCENT'."""
|
|
20
|
+
# pylint: disable=import-outside-toplevel
|
|
21
|
+
from eval_ac.analysis import DEFAULT_THRESHOLDS
|
|
22
|
+
try:
|
|
23
|
+
name, value = text.split('=')
|
|
24
|
+
value = float(value)
|
|
25
|
+
except ValueError as error:
|
|
26
|
+
raise argparse.ArgumentTypeError(
|
|
27
|
+
f'expected VARIABLE=PERCENT, got {text!r}') from error
|
|
28
|
+
if name not in DEFAULT_THRESHOLDS:
|
|
29
|
+
raise argparse.ArgumentTypeError(
|
|
30
|
+
f'unknown variable {name!r}, choose from '
|
|
31
|
+
f'{", ".join(DEFAULT_THRESHOLDS)}')
|
|
32
|
+
return name, value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
"""Creates the argument parser."""
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog='eval-ac',
|
|
39
|
+
description='Read the ABSCAL.HIS file of an RPG microwave radiometer '
|
|
40
|
+
'(e.g. HATPRO) and plot the history of the absolute '
|
|
41
|
+
'calibration with liquid nitrogen.')
|
|
42
|
+
parser.add_argument('input', help='path of the ABSCAL.HIS file')
|
|
43
|
+
parser.add_argument('-n', '--netcdf', metavar='FILE',
|
|
44
|
+
help='write the converted data (all calibration '
|
|
45
|
+
'types) to this NetCDF file')
|
|
46
|
+
parser.add_argument('-p', '--plot', metavar='FILE',
|
|
47
|
+
default='results_ln2_cal.png',
|
|
48
|
+
help='save the history plot to this file '
|
|
49
|
+
'(default: %(default)s)')
|
|
50
|
+
parser.add_argument('-d', '--drift-plot', metavar='FILE',
|
|
51
|
+
default='results_ln2_drift.png',
|
|
52
|
+
help='save the drift plot to this file '
|
|
53
|
+
'(default: %(default)s)')
|
|
54
|
+
parser.add_argument('--no-plot', action='store_true',
|
|
55
|
+
help='do not create any plot')
|
|
56
|
+
parser.add_argument('--show', action='store_true',
|
|
57
|
+
help='show the plots in a window')
|
|
58
|
+
parser.add_argument('--all-cal-types', action='store_true',
|
|
59
|
+
help='use all calibrations instead of only those '
|
|
60
|
+
'with liquid nitrogen')
|
|
61
|
+
|
|
62
|
+
drift = parser.add_argument_group('drift analysis')
|
|
63
|
+
drift.add_argument('--n-reference', type=int, default=5, metavar='N',
|
|
64
|
+
help='number of calibrations before the latest one '
|
|
65
|
+
'forming the reference (default: %(default)s)')
|
|
66
|
+
drift.add_argument('-t', '--threshold', type=_threshold, action='append',
|
|
67
|
+
default=[], metavar='VARIABLE=PERCENT',
|
|
68
|
+
help='drift threshold in percent, e.g. gain=8; can be '
|
|
69
|
+
'given several times (defaults: gain=10, '
|
|
70
|
+
'temp_noise=2.5, temp_sys=2.5, alpha=0.5)')
|
|
71
|
+
|
|
72
|
+
checks = parser.add_argument_group('quality checks')
|
|
73
|
+
checks.add_argument('--max-age', type=float, default=183, metavar='DAYS',
|
|
74
|
+
help='warn if the latest calibration is older '
|
|
75
|
+
'(default: %(default)s days; RPG recommends an '
|
|
76
|
+
'absolute calibration every 5 to 6 months)')
|
|
77
|
+
checks.add_argument('--fail-on-warning', action='store_true',
|
|
78
|
+
help=f'exit with code {EXIT_WARNING} if the report '
|
|
79
|
+
'contains a warning (drift, age, flag 0, alpha '
|
|
80
|
+
'out of range)')
|
|
81
|
+
|
|
82
|
+
parser.add_argument('--version', action='version',
|
|
83
|
+
version=f'%(prog)s {__version__}')
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _report_age(data_set, max_age):
|
|
88
|
+
"""Prints the date of the latest calibration; returns True on warning."""
|
|
89
|
+
# pylint: disable=import-outside-toplevel
|
|
90
|
+
from eval_ac.analysis import calibration_age
|
|
91
|
+
warned = False
|
|
92
|
+
for row in calibration_age(data_set):
|
|
93
|
+
date = str(row['time'].astype('datetime64[D]'))
|
|
94
|
+
line = (f'receiver {row["receiver"]}: latest calibration {date} '
|
|
95
|
+
f'({row["age_days"]:.0f} days ago)')
|
|
96
|
+
if row['age_days'] > max_age:
|
|
97
|
+
print(f'warning: {line}, older than {max_age:g} days')
|
|
98
|
+
warned = True
|
|
99
|
+
else:
|
|
100
|
+
print(line)
|
|
101
|
+
return warned
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _report_channels(data_set, rows, message, value=None):
|
|
105
|
+
"""Prints a list of channels; returns True if there are any."""
|
|
106
|
+
if not rows:
|
|
107
|
+
return False
|
|
108
|
+
units = data_set['freq'].attrs.get('units', '')
|
|
109
|
+
print(f'warning: {len(rows)} channel(s) of the latest calibration '
|
|
110
|
+
f'{message}:')
|
|
111
|
+
for row in rows:
|
|
112
|
+
extra = f' {value(row)}' if value else ''
|
|
113
|
+
print(f' {row["freq"]:7.2f} {units} receiver {row["receiver"]}'
|
|
114
|
+
f'{extra}')
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _report(data_set, drift, args) -> bool:
|
|
119
|
+
"""Prints the quality report of the latest calibration.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if the report contains a warning.
|
|
123
|
+
"""
|
|
124
|
+
# pylint: disable=import-outside-toplevel
|
|
125
|
+
from eval_ac import analysis
|
|
126
|
+
|
|
127
|
+
warnings = [
|
|
128
|
+
_report_age(data_set, args.max_age),
|
|
129
|
+
_report_channels(data_set, analysis.latest_not_calibrated(data_set),
|
|
130
|
+
'were not calibrated (flag 0)'),
|
|
131
|
+
_report_channels(
|
|
132
|
+
data_set, analysis.alpha_out_of_range(data_set),
|
|
133
|
+
'have a non-linearity factor outside 0.9 <= alpha < 1',
|
|
134
|
+
lambda row: f'alpha = {row["alpha"]:.4f}'),
|
|
135
|
+
]
|
|
136
|
+
exceedances = analysis.drift_exceedances(drift, dict(args.threshold))
|
|
137
|
+
if exceedances:
|
|
138
|
+
warnings.append(_report_channels(
|
|
139
|
+
data_set, exceedances, 'exceed the drift threshold',
|
|
140
|
+
lambda row: f'{row["variable"]:<11} {row["deviation"]:+7.2f} % '
|
|
141
|
+
f'(threshold ±{row["threshold"]:g} %)'))
|
|
142
|
+
else:
|
|
143
|
+
print('drift: all channels of the latest calibration are within '
|
|
144
|
+
'the thresholds')
|
|
145
|
+
return any(warnings)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load(args):
|
|
149
|
+
"""Reads the file and selects the calibration type.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Dataset, or None after printing an error message.
|
|
153
|
+
"""
|
|
154
|
+
# pylint: disable=import-outside-toplevel
|
|
155
|
+
from eval_ac import analysis
|
|
156
|
+
from eval_ac.convert_abscal_his import read_abscal_his, write_netcdf
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
data_set = read_abscal_his(args.input)
|
|
160
|
+
except (OSError, ValueError) as error:
|
|
161
|
+
print(f'eval-ac: error: {error}', file=sys.stderr)
|
|
162
|
+
return None
|
|
163
|
+
print(f'read {data_set.sizes["n_samples"]} calibration entries '
|
|
164
|
+
f'from {args.input}')
|
|
165
|
+
|
|
166
|
+
if args.netcdf:
|
|
167
|
+
write_netcdf(data_set, args.netcdf)
|
|
168
|
+
print(f'wrote {args.netcdf}')
|
|
169
|
+
|
|
170
|
+
if not args.all_cal_types:
|
|
171
|
+
data_set = analysis.select_cal_type(data_set, analysis.CAL_TYPE_LN2)
|
|
172
|
+
print(f'{data_set.sizes["n_samples"]} of them are calibrations with '
|
|
173
|
+
'liquid nitrogen')
|
|
174
|
+
if data_set.sizes['n_samples'] == 0:
|
|
175
|
+
print('eval-ac: error: no calibrations with liquid nitrogen '
|
|
176
|
+
'found (use --all-cal-types)', file=sys.stderr)
|
|
177
|
+
return None
|
|
178
|
+
return data_set
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _save_plots(args, data_set, drift, thresholds):
|
|
182
|
+
"""Creates, saves and optionally shows the plots."""
|
|
183
|
+
# pylint: disable=import-outside-toplevel
|
|
184
|
+
import matplotlib.pyplot as plt
|
|
185
|
+
from eval_ac.plotting import plot_calibration_history, plot_drift
|
|
186
|
+
|
|
187
|
+
figures = [
|
|
188
|
+
(plot_calibration_history(data_set), args.plot),
|
|
189
|
+
(plot_drift(drift, thresholds=thresholds), args.drift_plot),
|
|
190
|
+
]
|
|
191
|
+
for figure, filename in figures:
|
|
192
|
+
figure.savefig(filename)
|
|
193
|
+
print(f'wrote {filename}')
|
|
194
|
+
if args.show:
|
|
195
|
+
plt.show()
|
|
196
|
+
for figure, _ in figures:
|
|
197
|
+
plt.close(figure)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main(argv=None) -> int:
|
|
201
|
+
"""Runs the command line interface.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
argv: Command line arguments (default: ``sys.argv[1:]``).
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Exit code (0 on success, 1 on error, 2 on a warning with
|
|
208
|
+
``--fail-on-warning``).
|
|
209
|
+
"""
|
|
210
|
+
args = build_parser().parse_args(argv)
|
|
211
|
+
|
|
212
|
+
# pylint: disable=import-outside-toplevel
|
|
213
|
+
import matplotlib
|
|
214
|
+
if not args.show:
|
|
215
|
+
matplotlib.use('Agg') # no display needed, e.g. on servers
|
|
216
|
+
from eval_ac import analysis
|
|
217
|
+
|
|
218
|
+
data_set = _load(args)
|
|
219
|
+
if data_set is None:
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
drift = analysis.calibration_drift(data_set, n_reference=args.n_reference)
|
|
223
|
+
warned = _report(data_set, drift, args)
|
|
224
|
+
|
|
225
|
+
if not args.no_plot:
|
|
226
|
+
_save_plots(args, data_set, drift, dict(args.threshold))
|
|
227
|
+
|
|
228
|
+
if args.fail_on_warning and warned:
|
|
229
|
+
return EXIT_WARNING
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == '__main__':
|
|
234
|
+
sys.exit(main())
|