envlib 0.1.0__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.
@@ -0,0 +1,336 @@
1
+ """Controlled vocabularies for envlib metadata fields.
2
+
3
+ Each vocabulary is a bundled JSON file in this package directory. A user-level
4
+ overlay directory (``~/.envlib/vocabularies/``) takes precedence per-file when
5
+ present — :func:`refresh` writes there, never into the installed package.
6
+
7
+ Vocabulary semantics:
8
+
9
+ - Upstream-sourced lists (``variable`` membership, ``standard_name``) are
10
+ refreshable from their APIs via :func:`refresh`.
11
+ - The envlib-curated ``(variable, feature) -> CF standard_name`` mapping inside
12
+ ``variable.json`` is hand-maintained and NEVER regenerated by refresh —
13
+ refresh only reports new/removed upstream terms for manual curation.
14
+ - envlib-defined vocabularies (``feature``, ``method``, ``processing_level``,
15
+ ``aggregation_statistic``, ``frequency_interval``, ``license``) have no
16
+ upstream source; refresh does not touch them.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import builtins
22
+ import datetime
23
+ import json
24
+ import pathlib
25
+ import re
26
+ import xml.etree.ElementTree as ET
27
+
28
+ import urllib3
29
+
30
+ BUNDLE_DIR = pathlib.Path(__file__).parent
31
+ USER_DIR = pathlib.Path('~/.envlib/vocabularies').expanduser()
32
+
33
+ FIELDS = (
34
+ 'feature',
35
+ 'variable',
36
+ 'method',
37
+ 'processing_level',
38
+ 'aggregation_statistic',
39
+ 'frequency_interval',
40
+ 'license',
41
+ 'standard_name',
42
+ )
43
+
44
+ ODM2_VARIABLENAME_URL = 'http://vocabulary.odm2.org/api/v1/variablename/?format=json'
45
+ CF_STANDARD_NAME_XML_URL = 'https://cfconventions.org/Data/cf-standard-names/current/src/cf-standard-name-table.xml'
46
+
47
+ _HTTP_OK = 200
48
+
49
+ # Per-field parsed-JSON cache and derived lookup cache (lowercased input -> canonical value).
50
+ _cache: dict = {}
51
+ _lookup_cache: dict = {}
52
+
53
+
54
+ def clear_cache():
55
+ """Clear the in-memory vocabulary caches (called automatically by :func:`refresh`)."""
56
+ _cache.clear()
57
+ _lookup_cache.clear()
58
+
59
+
60
+ def _vocab_path(field: str) -> pathlib.Path:
61
+ user_path = USER_DIR / f'{field}.json'
62
+ if user_path.exists():
63
+ return user_path
64
+ return BUNDLE_DIR / f'{field}.json'
65
+
66
+
67
+ def _check_field(field: str):
68
+ if field not in FIELDS:
69
+ msg = f'Unknown vocabulary field {field!r}; valid fields are {FIELDS}.'
70
+ raise ValueError(msg)
71
+
72
+
73
+ def _load(field: str) -> dict:
74
+ _check_field(field)
75
+ if field not in _cache:
76
+ with open(_vocab_path(field), encoding='utf-8') as f:
77
+ _cache[field] = json.load(f)
78
+ return _cache[field]
79
+
80
+
81
+ def _names(field: str) -> builtins.list[str]:
82
+ data = _load(field)
83
+ if field == 'standard_name':
84
+ return builtins.list(data['names'].keys())
85
+ if field == 'variable':
86
+ return builtins.list(data['entries'].keys())
87
+ return [entry['name'] for entry in data['entries']]
88
+
89
+
90
+ def _lookup(field: str) -> dict:
91
+ """Map of lowercased accepted input -> canonical value (includes input aliases)."""
92
+ if field not in _lookup_cache:
93
+ table = {name.lower(): name for name in _names(field)}
94
+ if field == 'frequency_interval':
95
+ for entry in _load(field)['entries']:
96
+ for alias in entry.get('aliases', []):
97
+ table[alias.lower()] = entry['name']
98
+ _lookup_cache[field] = table
99
+ return _lookup_cache[field]
100
+
101
+
102
+ def list(field: str) -> builtins.list[str]: # noqa: A001 - public API name set by the design plan
103
+ """Return the valid canonical values for a vocabulary field.
104
+
105
+ Args:
106
+ field: One of the envlib vocabulary fields (e.g. ``'variable'``, ``'feature'``).
107
+
108
+ Returns:
109
+ List of canonical values. For ``frequency_interval`` this is the canonical
110
+ codes only — input aliases (``'24h'``, ``'60min'``) are not included.
111
+ """
112
+ return _names(field)
113
+
114
+
115
+ def canonical(field: str, value: str) -> str:
116
+ """Resolve user input to the canonical vocabulary value.
117
+
118
+ Matching is case-insensitive after whitespace stripping; ``frequency_interval``
119
+ additionally resolves the closed input-alias table (``'24h'`` -> ``'day'``,
120
+ ``'60min'`` -> ``'1h'``). Raises ``ValueError`` when the value is not in the
121
+ vocabulary.
122
+ """
123
+ _check_field(field)
124
+ if not isinstance(value, str):
125
+ msg = f'{field} value must be a str, not {type(value).__name__}.'
126
+ raise TypeError(msg)
127
+ key = value.strip().lower()
128
+ table = _lookup(field)
129
+ if key not in table:
130
+ msg = f'{value!r} is not a valid {field}; see envlib.vocabularies.list({field!r}).'
131
+ raise ValueError(msg)
132
+ return table[key]
133
+
134
+
135
+ def is_valid(field: str, value: str) -> bool:
136
+ """Whether ``value`` is acceptable input for the vocabulary field (aliases included)."""
137
+ try:
138
+ canonical(field, value)
139
+ except (ValueError, TypeError):
140
+ return False
141
+ return True
142
+
143
+
144
+ def frequency_entry(code: str) -> dict:
145
+ """Return the frequency_interval table entry for a canonical or alias code.
146
+
147
+ The entry dict has keys ``name`` (canonical code), ``kind`` (``'fixed'`` |
148
+ ``'calendar'``), ``seconds`` (int, or None for calendar codes), ``aliases``.
149
+ """
150
+ canon = canonical('frequency_interval', code)
151
+ for entry in _load('frequency_interval')['entries']:
152
+ if entry['name'] == canon:
153
+ return entry
154
+ msg = f'frequency_interval table entry missing for {canon!r} (corrupt vocabulary file?).'
155
+ raise RuntimeError(msg)
156
+
157
+
158
+ def get_cf_standard_names(variable: str, feature: str) -> builtins.list[str] | None:
159
+ """CF standard_name candidates envlib considers for a (variable, feature) pair.
160
+
161
+ Args:
162
+ variable: envlib variable (CV member; raises ``ValueError`` if unknown).
163
+ feature: envlib feature (CV member; raises ``ValueError`` if unknown).
164
+
165
+ Returns:
166
+ Ordered candidate list — the FIRST entry is the curated default that
167
+ envlib auto-populates at validate/register time; an empty list means
168
+ "curated: no applicable standard name" (common for freshwater
169
+ water-quality variables); ``None`` means the pair is not yet curated
170
+ (envlib warns but does not block).
171
+ """
172
+ var = canonical('variable', variable)
173
+ feat = canonical('feature', feature)
174
+ entry = _load('variable')['entries'][var]
175
+ cf = entry.get('cf')
176
+ if cf is None:
177
+ return None
178
+ candidates = cf.get(feat)
179
+ if candidates is None:
180
+ return None
181
+ return [c['standard_name'] for c in candidates]
182
+
183
+
184
+ ###################################################
185
+ # Refresh from upstream sources
186
+
187
+
188
+ def _fetch(url: str) -> bytes:
189
+ resp = urllib3.request('GET', url, timeout=urllib3.Timeout(total=120))
190
+ if resp.status != _HTTP_OK:
191
+ msg = f'GET {url} returned HTTP {resp.status}.'
192
+ raise ConnectionError(msg)
193
+ return resp.data
194
+
195
+
196
+ def _utc_today() -> str:
197
+ return datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
198
+
199
+
200
+ def _write_vocab(target_dir: pathlib.Path, field: str, data: dict):
201
+ path = target_dir / f'{field}.json'
202
+ with open(path, 'w', encoding='utf-8') as f:
203
+ json.dump(data, f, indent=1, ensure_ascii=False)
204
+ f.write('\n')
205
+
206
+
207
+ def _snake_case(term: str) -> str:
208
+ s = re.sub(r'(?<=[a-z0-9])([A-Z])', r'_\1', term)
209
+ s = re.sub(r'_+', '_', s)
210
+ return s.lower()
211
+
212
+
213
+ def _refresh_standard_name(target_dir: pathlib.Path) -> dict:
214
+ # The CF table ships as XML from cfconventions.org over HTTPS; content is not
215
+ # attacker-controlled in the threat model here, so stdlib parsing is acceptable.
216
+ root = ET.fromstring(_fetch(CF_STANDARD_NAME_XML_URL)) # noqa: S314
217
+ version = root.findtext('version_number')
218
+ last_modified = root.findtext('last_modified')
219
+ names = {}
220
+ for entry in root.findall('entry'):
221
+ names[entry.get('id')] = entry.findtext('canonical_units') or ''
222
+ aliases = {}
223
+ for alias in root.findall('alias'):
224
+ aliases[alias.get('id')] = alias.findtext('entry_id')
225
+ data = {
226
+ '_meta': {
227
+ 'vocabulary': 'standard_name',
228
+ 'source': f'CF standard name table v{version} ({CF_STANDARD_NAME_XML_URL})',
229
+ 'cf_version': version,
230
+ 'cf_last_modified': last_modified,
231
+ 'updated': _utc_today(),
232
+ },
233
+ 'names': dict(sorted(names.items())),
234
+ 'aliases': dict(sorted(aliases.items())),
235
+ }
236
+ _write_vocab(target_dir, 'standard_name', data)
237
+ return {'cf_version': version, 'names': len(names), 'aliases': len(aliases)}
238
+
239
+
240
+ def _refresh_variable(target_dir: pathlib.Path) -> dict:
241
+ current = json.loads(json.dumps(_load('variable'))) # deep copy of the effective file
242
+ entries = current['entries']
243
+ overrides = current.get('odm2_spelling_overrides', {})
244
+
245
+ payload = json.loads(_fetch(ODM2_VARIABLENAME_URL))
246
+ # The ODM2 API wraps results as {'meta': {...}, 'objects': [...]}.
247
+ objects = payload['objects'] if isinstance(payload, dict) else payload
248
+ if isinstance(payload, dict):
249
+ total = payload.get('meta', {}).get('total_count')
250
+ if total is not None and total > len(objects):
251
+ msg = f'ODM2 API returned {len(objects)} of {total} terms (pagination?); refusing a partial refresh.'
252
+ raise RuntimeError(msg)
253
+ fetched_terms = [item['term'] for item in objects]
254
+
255
+ added = []
256
+ mismatched = []
257
+ conflicts_with_extension = []
258
+ name_sources: dict = {}
259
+ for term in fetched_terms:
260
+ name = overrides.get(term, _snake_case(term))
261
+ name_sources.setdefault(name, []).append(term)
262
+ entry = entries.get(name)
263
+ if entry is None:
264
+ entries[name] = {'source': 'odm2', 'odm2_term': term}
265
+ added.append(name)
266
+ elif entry.get('source') == 'envlib':
267
+ # ODM2 grew a term colliding with an envlib extension — manual curation
268
+ # decides whether to promote the extension; refresh never edits it.
269
+ conflicts_with_extension.append({'name': name, 'odm2_term': term})
270
+ elif entry.get('odm2_term') is None:
271
+ entry['odm2_term'] = term
272
+ elif entry['odm2_term'] != term:
273
+ mismatched.append({'name': name, 'existing': entry['odm2_term'], 'fetched': term})
274
+
275
+ collisions = {name: terms for name, terms in name_sources.items() if len(terms) > 1}
276
+ fetched_set = set(fetched_terms)
277
+ removed = [
278
+ name
279
+ for name, entry in entries.items()
280
+ if entry.get('source') == 'odm2' and entry.get('odm2_term') not in fetched_set
281
+ ]
282
+
283
+ current['entries'] = dict(sorted(entries.items()))
284
+ current['_meta']['updated'] = _utc_today()
285
+ _write_vocab(target_dir, 'variable', current)
286
+ return {
287
+ 'total': len(entries),
288
+ 'added': sorted(added),
289
+ 'removed': sorted(removed), # reported only — refresh never deletes entries
290
+ 'collisions': collisions,
291
+ 'mismatched_odm2_terms': mismatched,
292
+ 'conflicts_with_extension': conflicts_with_extension,
293
+ }
294
+
295
+
296
+ def refresh(field: str | None = None, _target_dir=None) -> dict:
297
+ """Refresh upstream-sourced vocabularies from their APIs.
298
+
299
+ Fetches the ODM2 variablename list and/or the current CF standard-name table
300
+ and writes updated vocabulary files to the user overlay directory
301
+ (``~/.envlib/vocabularies/``) — never into the installed package. For
302
+ ``variable``, only ODM2-sourced membership is updated (additively — upstream
303
+ removals are reported, never deleted); curated ``cf`` mappings and envlib
304
+ extensions are never touched.
305
+
306
+ Args:
307
+ field: ``'variable'``, ``'standard_name'``, or None for both.
308
+ _target_dir: Internal/dev hook — write somewhere other than the user
309
+ overlay dir (used to generate the bundled files).
310
+
311
+ Returns:
312
+ Report dict keyed by refreshed field (counts, added/removed terms,
313
+ snake-case collisions, extension conflicts).
314
+ """
315
+ refreshable = ('standard_name', 'variable')
316
+ fields: tuple[str, ...]
317
+ if field is None:
318
+ fields = refreshable
319
+ elif field in refreshable:
320
+ fields = (field,)
321
+ else:
322
+ _check_field(field)
323
+ msg = f'{field!r} has no upstream source to refresh from.'
324
+ raise ValueError(msg)
325
+
326
+ target_dir = pathlib.Path(_target_dir).expanduser() if _target_dir is not None else USER_DIR
327
+ target_dir.mkdir(parents=True, exist_ok=True)
328
+
329
+ report = {}
330
+ for f in fields:
331
+ if f == 'standard_name':
332
+ report[f] = _refresh_standard_name(target_dir)
333
+ else:
334
+ report[f] = _refresh_variable(target_dir)
335
+ clear_cache()
336
+ return report
@@ -0,0 +1,20 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "aggregation_statistic",
4
+ "source": "statistical subset of the CF Conventions cell_methods vocabulary; no upstream refresh",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {"name": "point", "description": "Instantaneous sampling; no aggregation over the interval."},
9
+ {"name": "mean", "description": "Arithmetic mean over the aggregation interval."},
10
+ {"name": "sum", "description": "Sum (accumulation) over the aggregation interval."},
11
+ {"name": "maximum", "description": "Maximum value within the aggregation interval."},
12
+ {"name": "minimum", "description": "Minimum value within the aggregation interval."},
13
+ {"name": "median", "description": "Median value within the aggregation interval."},
14
+ {"name": "mode", "description": "Most frequent value within the aggregation interval."},
15
+ {"name": "mid_range", "description": "Average of the maximum and minimum within the aggregation interval."},
16
+ {"name": "variance", "description": "Variance of values within the aggregation interval."},
17
+ {"name": "standard_deviation", "description": "Standard deviation of values within the aggregation interval."},
18
+ {"name": "range", "description": "Maximum minus minimum within the aggregation interval."}
19
+ ]
20
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "feature",
4
+ "source": "envlib-defined, mapped to ENVO URIs (looked up via EBI OLS4, 2026-07-07; envo_label records the ENVO term when it differs from the envlib name)",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {"name": "atmosphere", "description": "Ambient air, weather, and climate.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_01000267"},
9
+ {"name": "waterway", "description": "Rivers, streams, and canals.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_00000029", "envo_label": "watercourse"},
10
+ {"name": "still_water", "description": "Lakes, reservoirs, and ponds.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_01000617", "envo_label": "lentic water body"},
11
+ {"name": "ocean", "description": "Open ocean and seas.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_00000015"},
12
+ {"name": "groundwater", "description": "Aquifers and subsurface water.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_01001004"},
13
+ {"name": "glacier", "description": "Glaciers and ice sheets.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_00000133"},
14
+ {"name": "wetland", "description": "Swamps, marshes, and bogs.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_01001209", "envo_label": "wetland ecosystem"},
15
+ {"name": "soil", "description": "Soil and subsurface earth.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_00001998"},
16
+ {"name": "coastline", "description": "Coastal zones and estuaries.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_00000303", "envo_label": "sea coast"},
17
+ {"name": "land", "description": "General land surface and terrain.", "envo_uri": "http://purl.obolibrary.org/obo/ENVO_01001785"}
18
+ ]
19
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "frequency_interval",
4
+ "source": "envlib-defined frequency codes (finalized 2026-07-05); no upstream refresh. Only canonical codes are stored, hashed, displayed, and query-matched; aliases are accepted input synonyms only. Admission rule for future fixed codes: the duration must divide 24 h evenly. None (not an entry) is the value for irregular cadences.",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {"name": "1min", "kind": "fixed", "seconds": 60, "aliases": []},
9
+ {"name": "5min", "kind": "fixed", "seconds": 300, "aliases": []},
10
+ {"name": "10min", "kind": "fixed", "seconds": 600, "aliases": []},
11
+ {"name": "15min", "kind": "fixed", "seconds": 900, "aliases": []},
12
+ {"name": "30min", "kind": "fixed", "seconds": 1800, "aliases": []},
13
+ {"name": "1h", "kind": "fixed", "seconds": 3600, "aliases": ["60min"]},
14
+ {"name": "3h", "kind": "fixed", "seconds": 10800, "aliases": []},
15
+ {"name": "6h", "kind": "fixed", "seconds": 21600, "aliases": []},
16
+ {"name": "12h", "kind": "fixed", "seconds": 43200, "aliases": []},
17
+ {"name": "day", "kind": "fixed", "seconds": 86400, "aliases": ["24h"]},
18
+ {"name": "month", "kind": "calendar", "seconds": null, "aliases": []},
19
+ {"name": "year", "kind": "calendar", "seconds": null, "aliases": []}
20
+ ]
21
+ }
@@ -0,0 +1,63 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "license",
4
+ "source": "curated SPDX subset + envlib extensions for common non-SPDX open-access data licenses; open-access licenses only; no upstream refresh",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {
9
+ "name": "CC-BY-4.0",
10
+ "title": "Creative Commons Attribution 4.0 International",
11
+ "source": "spdx",
12
+ "url": "https://spdx.org/licenses/CC-BY-4.0.html"
13
+ },
14
+ {
15
+ "name": "CC-BY-SA-4.0",
16
+ "title": "Creative Commons Attribution Share Alike 4.0 International",
17
+ "source": "spdx",
18
+ "url": "https://spdx.org/licenses/CC-BY-SA-4.0.html"
19
+ },
20
+ {
21
+ "name": "CC-BY-NC-4.0",
22
+ "title": "Creative Commons Attribution Non Commercial 4.0 International",
23
+ "source": "spdx",
24
+ "url": "https://spdx.org/licenses/CC-BY-NC-4.0.html"
25
+ },
26
+ {
27
+ "name": "CC-BY-NC-SA-4.0",
28
+ "title": "Creative Commons Attribution Non Commercial Share Alike 4.0 International",
29
+ "source": "spdx",
30
+ "url": "https://spdx.org/licenses/CC-BY-NC-SA-4.0.html"
31
+ },
32
+ {
33
+ "name": "CC0-1.0",
34
+ "title": "Creative Commons Zero v1.0 Universal (public domain dedication)",
35
+ "source": "spdx",
36
+ "url": "https://spdx.org/licenses/CC0-1.0.html"
37
+ },
38
+ {
39
+ "name": "ODbL-1.0",
40
+ "title": "Open Data Commons Open Database License v1.0",
41
+ "source": "spdx",
42
+ "url": "https://spdx.org/licenses/ODbL-1.0.html"
43
+ },
44
+ {
45
+ "name": "CC-BY-3.0",
46
+ "title": "Creative Commons Attribution 3.0 Unported",
47
+ "source": "spdx",
48
+ "url": "https://spdx.org/licenses/CC-BY-3.0.html"
49
+ },
50
+ {
51
+ "name": "CC-BY-SA-3.0",
52
+ "title": "Creative Commons Attribution Share Alike 3.0 Unported",
53
+ "source": "spdx",
54
+ "url": "https://spdx.org/licenses/CC-BY-SA-3.0.html"
55
+ },
56
+ {
57
+ "name": "Copernicus-1.0",
58
+ "title": "Licence to Use Copernicus Products (ECMWF/Copernicus, e.g. ERA5, C3S/CAMS products)",
59
+ "source": "envlib",
60
+ "url": "https://apps.ecmwf.int/datasets/licences/copernicus/"
61
+ }
62
+ ]
63
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "method",
4
+ "source": "envlib-defined (ported from tethys for migration continuity); no upstream refresh",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {
9
+ "name": "derivation",
10
+ "description": "A method for creating results by simple calculations from field activities, sample analyses, or sensor recordings."
11
+ },
12
+ {
13
+ "name": "estimation",
14
+ "description": "A method for creating results by rough approximation or professional judgement. Does not use an analytical or numerical model."
15
+ },
16
+ {
17
+ "name": "field_activity",
18
+ "description": "A method for creating results by performing an activity in the field at or on a sampling feature. Includes manually-operated instruments (e.g., handheld probes, manual gauging, field surveys) - anything requiring a human at the measurement location."
19
+ },
20
+ {
21
+ "name": "simulation",
22
+ "description": "A method for creating results by running an analytical or numerical model. Generally more complex and/or more uncertain than derivations. Used for climate model runs, reanalyses (e.g., ERA5), and other model outputs."
23
+ },
24
+ {
25
+ "name": "sample_analysis",
26
+ "description": "A method for ex situ analysis of a sample using an instrument, typically in a laboratory, for the purpose of measuring properties of a sample."
27
+ },
28
+ {
29
+ "name": "sensor_recording",
30
+ "description": "A method for creating results by independent, automated sensor measurements without direct human interaction at the sensor during the measurement. Includes in-situ data loggers and remote sensing."
31
+ },
32
+ {
33
+ "name": "forecast",
34
+ "description": "A type of simulation to predict the future. Kept distinct from simulation so consumers can filter forecasts from historical model runs."
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "_meta": {
3
+ "vocabulary": "processing_level",
4
+ "source": "envlib-defined; no upstream refresh",
5
+ "updated": "2026-07-07"
6
+ },
7
+ "entries": [
8
+ {
9
+ "name": "raw",
10
+ "description": "As collected or generated; no quality control applied."
11
+ },
12
+ {
13
+ "name": "preliminary",
14
+ "description": "Automatically screened or near-real-time; subject to revision (e.g., ERA5T, USGS provisional, telemetered data with automated checks)."
15
+ },
16
+ {
17
+ "name": "quality_controlled",
18
+ "description": "Vetted and considered settled (e.g., consolidated ERA5, USGS approved, a council's validated archive)."
19
+ }
20
+ ]
21
+ }