ebas-plot 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,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: ebas-plot
3
+ Version: 0.1.0
4
+ Summary: Plotting utilities for EBAS datasets served via THREDDS/OPeNDAP.
5
+ Author-email: Lise Eder Murberg <lemu@nilu.no>, Marthe Brevig <mbre@nilu.no>, Jonathan Elias Holme <jhol@nilu.no>
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://git.nilu.no/ebas/ebas-web/ebas-web-plotting
8
+ Project-URL: Source, https://git.nilu.no/ebas/ebas-web/ebas-web-plotting
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: netCDF4
22
+ Requires-Dist: numpy
23
+ Requires-Dist: pandas
24
+ Requires-Dist: plotly
25
+ Requires-Dist: pydap
26
+ Requires-Dist: requests
27
+ Requires-Dist: xarray
28
+ Dynamic: license-file
29
+
30
+ # ebas-plot
31
+
32
+ Plotting utilities for EBAS datasets served via THREDDS/OPeNDAP.
33
+
34
+ ## Installation
35
+
36
+ For normal use, install the latest released version from the public Python
37
+ Package Index (PyPI):
38
+
39
+ ```bash
40
+ python3 -m pip install ebas-plot
41
+ ```
42
+
43
+ This installs a fixed release, such as `0.1.0`, together with its required
44
+ dependencies. It is the recommended command for users after a version has been
45
+ published through GitLab CI.
46
+
47
+ For testing changes that have not yet been published, install the current code
48
+ directly from the `python-package` development branch:
49
+
50
+ ```bash
51
+ python3 -m pip install "git+https://git.nilu.no/ebas/ebas-web/ebas-web-plotting.git@python-package"
52
+ ```
53
+
54
+ This bypasses PyPI and installs the latest commit on that branch. It requires
55
+ Git to be installed and permission to access `git.nilu.no`; use it only when
56
+ you need unreleased changes.
57
+
58
+ ## Usage
59
+
60
+ ### Command line
61
+
62
+ ```bash
63
+ ebas-plot <url>
64
+ ```
65
+
66
+ `<url>` can be a local EBAS NetCDF file or a remote EBAS dataset:
67
+
68
+ - `/path/to/dataset.nc`
69
+ - `https://doi.nilu.no/doi/8HWP-FRNN`
70
+ - `https://doi.org/10.48597/8HWP-FRNN`
71
+ - `https://thredds.nilu.no/thredds/dodsC/ebas_doi/...`
72
+ - `https://thredds.nilu.no/thredds/fileServer/ebas_doi/...`
73
+
74
+ The plot type is selected automatically: datasets with a `d_D` dimension are
75
+ shown as PNSD heatmaps; other supported EBAS datasets are shown as time series.
76
+
77
+ Choose the time-series rendering explicitly with `--mode line` or
78
+ `--mode scatter`:
79
+
80
+ ```bash
81
+ ebas-plot --mode scatter /path/to/dataset.nc
82
+ ```
83
+
84
+ Use `--var_plots` to open one plot per physical quantity rather than one
85
+ combined time-series plot:
86
+
87
+ ```bash
88
+ ebas-plot --var_plots https://doi.nilu.no/doi/8HWP-FRNN
89
+ ```
90
+
91
+ ### As a library
92
+
93
+ ```python
94
+ from ebas_plot import open_dataset, plot, plot_pnsd, plot_timeseries
95
+
96
+ # Auto-detect a remote dataset's plot type.
97
+ fig = plot("https://doi.nilu.no/doi/8HWP-FRNN")
98
+ fig.show()
99
+
100
+ # Open a local EBAS NetCDF file and select scatter points.
101
+ ds = open_dataset("/path/to/dataset.nc")
102
+ fig = plot_timeseries(ds, mode="scatter")
103
+ fig.show()
104
+
105
+ # Produce one time-series figure per physical quantity.
106
+ figures = plot("https://doi.nilu.no/doi/8HWP-FRNN", var_plots=True, mode="line")
107
+ for figure in figures:
108
+ figure.show()
109
+ ```
110
+
111
+ ## Releases
112
+
113
+ The `python-package` branch is the release branch. GitLab CI builds the source
114
+ and wheel distributions for its commits. To publish a new version, update
115
+ `__version__` in `ebas_plot.py`, push the branch, and run the manual `publish`
116
+ job. Configure the protected GitLab CI/CD variable `PYPI_TOKEN` with a PyPI
117
+ project-scoped API token before publishing.
118
+
119
+ ## License
120
+
121
+ Copyright (C) 2026 Lise Eder Murberg, Marthe Brevig, and Jonathan Elias Holme.
122
+
123
+ This project is licensed under the GNU Affero General Public License, version
124
+ 3 or later (AGPL-3.0-or-later). The full license text is available at
125
+ https://www.gnu.org/licenses/agpl-3.0.html.
@@ -0,0 +1,7 @@
1
+ ebas_plot.py,sha256=HuRbjOgGLGunHqQWBFmLocyHMoAuumkLHsi4herzeMs,35201
2
+ ebas_plot-0.1.0.dist-info/licenses/LICENSE,sha256=7ZpO8LOz0uwP0ZXf1VdifRWnVKK6wPAICJ9ULsrndDs,840
3
+ ebas_plot-0.1.0.dist-info/METADATA,sha256=TR897rdRB_NSyl5EU7cnfW-cftWV33B0RLxd3mSK0TU,4006
4
+ ebas_plot-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ ebas_plot-0.1.0.dist-info/entry_points.txt,sha256=pOXlW0kxym2YdjkQw2HxLtHgXoaLOgQBzxZ78uVovn0,45
6
+ ebas_plot-0.1.0.dist-info/top_level.txt,sha256=EcUPwQUC64wFbj_S3eIgWmItisCrkva69sjFY-5ywd4,10
7
+ ebas_plot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ebas-plot = ebas_plot:main
@@ -0,0 +1,17 @@
1
+ ebas-plot
2
+ Copyright (C) 2026 Lise Eder Murberg, Marthe Brevig, and Jonathan Elias Holme
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU Affero General Public License as published by the Free
6
+ Software Foundation, either version 3 of the License, or (at your option) any
7
+ later version.
8
+
9
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY
10
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11
+ PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
12
+
13
+ You should have received a copy of the GNU Affero General Public License along
14
+ with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ The complete GNU Affero General Public License version 3 is available at
17
+ <https://www.gnu.org/licenses/agpl-3.0.html>.
@@ -0,0 +1 @@
1
+ ebas_plot
ebas_plot.py ADDED
@@ -0,0 +1,873 @@
1
+ import re
2
+
3
+ import xarray as xr
4
+ import plotly.graph_objects as go
5
+ import numpy as np
6
+ import requests
7
+ from netCDF4 import num2date
8
+ import pandas as pd
9
+
10
+ __version__ = "0.1.0"
11
+
12
+
13
+ class DatasetAccessError(Exception):
14
+ """OPeNDAP data endpoint denied or failed access; carries the HTTP status that caused it."""
15
+
16
+ def __init__(self, status_code: int, message: str):
17
+ super().__init__(message)
18
+ self.status_code = status_code
19
+
20
+ _CUSTOM_COLORS_WAVELENGTH = [
21
+ "#9400D3", # DarkViolet
22
+ "#0000FF", # Blue
23
+ "#00FF00", # Lime
24
+ "#FFD700", # Gold
25
+ "#FF0000", # Red
26
+ "#8B0000", # DarkRed
27
+ "#000000", # Black
28
+ "#FF1493", # DeepPink
29
+ "#00CED1", # DarkTurquoise
30
+ "#FF8C00", # DarkOrange
31
+ ]
32
+
33
+ _CUSTOM_COLORS_PNSD = [
34
+ "#000CF2", "#0026D8", "#003FBF", "#0059A5", "#00728C", "#008C72",
35
+ "#00A559", "#00BF3F", "#00D826", "#00F20C", "#0CFF00", "#26FF00",
36
+ "#3FFF00", "#59FF00", "#72FF00", "#8CFF00", "#A5FF00", "#BFFF00",
37
+ "#D8FF00", "#F2FF00", "#FFF200", "#FFD800", "#FFBF00", "#FFA500",
38
+ "#FF8C00", "#FF7200", "#FF5900", "#FF3F00", "#FF2600", "#FF0C00",
39
+ ]
40
+
41
+ # NILU Primary and additional colors for variables (17 total)
42
+ _CUSTOM_COLORS_VARIABLES = [
43
+ "#053F53", # Dark Teal
44
+ "#9FD2FE", # Light Blue
45
+ "#FF891D", # Orange
46
+ "#0A8AB6", # Blue
47
+ "#A74FFF", # Purple
48
+ "#2D7756", # Dark Green
49
+ "#E93CAC", # Pink
50
+ "#E9C649", # Yellow
51
+ "#840F5A", # Dark Magenta
52
+ "#01C399", # Turquoise
53
+ "#C00000", # Dark Red
54
+ "#6F6F6F", # Gray
55
+ "#F2A3C5", # Light Pink
56
+ "#54FEDB", # Light Turquoise
57
+ "#5B3F9B", # Indigo
58
+ "#7FB800", # Lime Green
59
+ "#D45500", # Burnt Orange
60
+ ]
61
+
62
+ _AUXILIARY = {"v_pressure", "v_relative_humidity", "v_temperature", "v_pH", "v_conductivity"}
63
+ _STATISTICS = {
64
+ "_prec1587", "_perc1587", "_perc8413", "_prec8413",
65
+ "_uncertainty", "_stddev", "_det_lim", "_ExpUnc2s",
66
+ "_precision", "_max", "_min", "_median",
67
+ } # ancillary statistics; excluded from plots (not primary mean values)
68
+ # Only these d_* dimensions are allowed in timeseries plots, d_D is in spesific PNSD plot.
69
+ _ALLOWED_D_DIMS = {"d_Wavelength", "d_Tower_inlet_height"}
70
+
71
+ _RESOLUTION_UNIT_DAYS = {"mn": 1 / 1440, "h": 1 / 24, "d": 1, "w": 7, "mo": 30, "y": 365} # used for converting resolution codes to days
72
+ _RESOLUTION_CODE_RE = re.compile(r"^(\d+)(mn|mo|[hdwy])$")
73
+
74
+ _MONTH_ONLY_THRESHOLD = pd.Timedelta(days=32) # spans >= this are shown as month-only in hover text
75
+
76
+ def validate_dataset(path: str) -> xr.Dataset | None:
77
+ """Open a local netCDF file, validate it against EBAS plotting requirements, and return the dataset.
78
+
79
+ Prints ERROR for missing required fields (returns None) and WARNING for missing optional fields.
80
+ """
81
+ import os
82
+ if not path.endswith(".nc"):
83
+ print(f"ERROR: expected a .nc file, got: {path}")
84
+ return None
85
+ if not os.path.isfile(path):
86
+ print(f"ERROR: file not found: {path}")
87
+ return None
88
+ try:
89
+ ds = xr.open_dataset(path)
90
+ except Exception as exc:
91
+ print(f"ERROR: could not open file: {exc}")
92
+ return None
93
+
94
+ errors: list[str] = []
95
+ warnings: list[str] = []
96
+
97
+ # --- Required ---
98
+ if "time" not in ds.coords and "time" not in ds.dims:
99
+ errors.append("Missing required 'time' dimension/coordinate.")
100
+
101
+ plot_vars = _plot_vars(ds)
102
+ if not plot_vars:
103
+ errors.append("No plottable variables found — need at least one 'v_*' float variable with a 'time' dimension.")
104
+ else:
105
+ for name in plot_vars:
106
+ if not ds[name].attrs.get("ebas_unit") and not ds[name].attrs.get("units"):
107
+ warnings.append(f"'{name}' has no 'ebas_unit' or 'units' attribute — unit label will be blank.")
108
+
109
+ # --- Optional ---
110
+ if "time_bnds" not in ds:
111
+ warnings.append("Missing 'time_bnds' — hover text will show timestamps instead of sample periods.")
112
+ if not ds.attrs.get("title") and not ds.attrs.get("ebas_station_name") and not ds.attrs.get("site_name"):
113
+ warnings.append("Missing 'title'/'ebas_station_name'/'site_name' — plot title will be 'Unknown Site'.")
114
+ if not ds.attrs.get("ebas_resolution_code"):
115
+ warnings.append("Missing 'ebas_resolution_code' — resolution will be omitted from hover text.")
116
+ if not ds.attrs.get("ebas_matrix"):
117
+ warnings.append("Missing 'ebas_matrix' — auxiliary variables (temperature, pressure, etc.) will be excluded.")
118
+
119
+ for msg in errors:
120
+ print(f"ERROR: {msg}")
121
+ for msg in warnings:
122
+ print(f"WARNING: {msg}")
123
+
124
+ return None if errors else ds
125
+
126
+
127
+ def _doi_to_opendap_url(doi: str) -> str:
128
+ """Convert a DOI identifier like '8HWP-FRNN' to the THREDDS OPeNDAP URL."""
129
+ s = doi.replace("-", "")
130
+ return f"https://thredds.nilu.no/thredds/dodsC/ebas_doi/{s[0:2]}/{s[2:4]}/{s[4:6]}/{doi}.nc"
131
+
132
+
133
+ def open_dataset(url: str, token: str | None = None) -> xr.Dataset:
134
+ """Open an EBAS dataset from a local .nc path, DOI, or THREDDS URL."""
135
+ if not url.startswith("http"): # local file
136
+ ds = validate_dataset(url)
137
+ if ds is None:
138
+ raise ValueError(f"Dataset failed validation: {url}")
139
+ return ds
140
+ elif url.startswith("https://doi.nilu.no/doi/") or url.startswith("https://doi.org/"): # DOI link
141
+ opendap_url = _doi_to_opendap_url(url.split("/")[-1])
142
+ elif url.startswith("https://thredds.nilu.no/thredds/fileServer/ebas_doi/"): # Download link, convert to OPeNDAP
143
+ opendap_url = url.replace("fileServer", "dodsC")
144
+ elif url.startswith("https://thredds.nilu.no/thredds/dodsC/ebas_doi/"): # direct OPeNDAP link
145
+ opendap_url = url
146
+ else:
147
+ raise ValueError(f"Unsupported dataset URL: {url}")
148
+
149
+ # netCDF4's DAP client doesn't surface THREDDS's 401/403 quickly, silently retrying
150
+ # for 40-120s -- probe the data endpoint ourselves so access issues fail fast.
151
+ headers = {"Authorization": f"Bearer {token}"} if token else None
152
+ try:
153
+ probe = requests.get(f"{opendap_url}.dods?time[0:1:0]", headers=headers, timeout=5)
154
+ except requests.exceptions.RequestException as exc:
155
+ raise DatasetAccessError(503, f"OPeNDAP server unreachable: {exc}") from exc
156
+ if probe.status_code in (401, 403):
157
+ raise DatasetAccessError(probe.status_code, "Dataset is restricted")
158
+ if probe.status_code >= 500:
159
+ raise DatasetAccessError(503, f"OPeNDAP server error ({probe.status_code})")
160
+
161
+ if token:
162
+ session = requests.Session()
163
+ session.headers["Authorization"] = f"Bearer {token}"
164
+ return xr.open_dataset(opendap_url, engine="pydap", session=session)
165
+
166
+ try:
167
+ return xr.open_dataset(opendap_url, engine="netcdf4")
168
+ except RuntimeError:
169
+ # Driver-level I/O failure -- retrying with decode_times=False would just double the load.
170
+ raise
171
+ except Exception as exc:
172
+ print(f"Standard open failed ({exc}); retrying with decode_times=False")
173
+ return xr.open_dataset(opendap_url, engine="netcdf4", decode_times=False)
174
+
175
+
176
+ def get_plot_time(ds: xr.Dataset):
177
+ """Return time axis values, decoding numeric time when possible."""
178
+ time_da = ds["time"]
179
+ if np.issubdtype(time_da.dtype, np.datetime64):
180
+ return time_da.values
181
+ units = time_da.attrs.get("units")
182
+ calendar = time_da.attrs.get("calendar", "standard")
183
+ if units:
184
+ try:
185
+ return np.array(num2date(time_da.values, units=units, calendar=calendar))
186
+ except Exception as exc:
187
+ print(f"Could not decode numeric time ({exc}); using raw values.")
188
+ return time_da.values
189
+
190
+
191
+ def _hex_to_rgba(hex_color: str, alpha: float) -> str:
192
+ """Convert a hex color string to an rgba() string with the given opacity."""
193
+ h = hex_color[1:]
194
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
195
+ return f"rgba({r},{g},{b},{alpha:.2f})"
196
+
197
+
198
+ def _dataset_title(ds: xr.Dataset) -> str:
199
+ """Return a plot title from dataset attributes."""
200
+ title = (ds.attrs.get("title") or "").replace("_", " ")
201
+ station = ds.attrs.get("ebas_station_name", ds.attrs.get("site_name", ""))
202
+ if title:
203
+ if ds.attrs.get("ebas_data_level", "") not in ("3a", "3b"): # OBS check for NOx when we have combined products!
204
+ return title
205
+ if "equivalent black carbon" in title:
206
+ return "Equivalent black carbon at " + station
207
+ return station or "Unknown Site"
208
+
209
+
210
+ def _var_mean(ds: xr.Dataset, name: str) -> float:
211
+ """Mean of finite values, used as a sort key."""
212
+ vals = ds[name].values.ravel()
213
+ finite = vals[np.isfinite(vals)]
214
+ return float(finite.mean()) if len(finite) else 0.0
215
+
216
+
217
+ def _var_unit(ds: xr.Dataset, name: str) -> str:
218
+ """Return the preferred unit label for a variable."""
219
+ return ds[name].attrs.get("ebas_unit", ds[name].attrs.get("units", ""))
220
+
221
+
222
+ def _unit_score(unit: str) -> int:
223
+ """Rank a unit for default visibility: mol/mol (3) > plain mass /m3 (2) > element-specific (1)."""
224
+ u = unit.lower().replace("µ", "u")
225
+ if "mol/mol" in u:
226
+ return 3
227
+ if u.endswith("/m3") and " " not in u:
228
+ return 2
229
+ return 1
230
+
231
+
232
+ def _sampling_resolution(ds: xr.Dataset, time_step: pd.Timedelta) -> pd.Timedelta:
233
+ """Return the dataset's nominal sampling resolution as a timedelta, using the EBAS resolution code."""
234
+ m = _RESOLUTION_CODE_RE.match(ds.attrs.get("ebas_resolution_code", "").strip().lower())
235
+ if not m:
236
+ return time_step
237
+ n, unit = int(m.group(1)), m.group(2)
238
+ return pd.Timedelta(days=n * _RESOLUTION_UNIT_DAYS[unit])
239
+
240
+
241
+ def _default_plot_mode(ds: xr.Dataset, time_step: pd.Timedelta) -> str:
242
+ """Return 'line' or 'scatter': the mode to use when the caller doesn't specify one.
243
+
244
+ A single time step can only be shown as a scatter point. Otherwise, weekly-or-coarser
245
+ declared resolutions default to scatter (typical of passive samplers/campaigns), as
246
+ does data that's actually sparser than declared (e.g. a '1d' code but samples that are
247
+ really >3 days apart on average) -- everything else defaults to line.
248
+ """
249
+ if ds.sizes.get("time", 0) == 1:
250
+ return "scatter"
251
+ resolution = _sampling_resolution(ds, time_step)
252
+ if resolution >= pd.Timedelta(weeks=1) or time_step > pd.Timedelta(days=3):
253
+ return "scatter"
254
+ return "line"
255
+
256
+
257
+ def _scatter_hovertexts(x_time, ds: xr.Dataset) -> list[str]:
258
+ """Build per-point hover strings e.g. 'Aug 2006 \u2013 Oct 2006 (3mo)' or '12 \u2013 19 Nov 2012 (1w)'."""
259
+ if "time_bnds" in ds:
260
+ bnds = ds["time_bnds"].values
261
+ starts, ends = bnds[:, 0], bnds[:, 1]
262
+ else:
263
+ starts = ends = x_time
264
+ res = ds.attrs.get("ebas_resolution_code", "")
265
+ suffix = f" ({res})" if res else ""
266
+
267
+ def _fmt(s, e) -> str:
268
+ start, end = pd.Timestamp(s), pd.Timestamp(e)
269
+ if end - start >= _MONTH_ONLY_THRESHOLD: # coarse/campaign samples
270
+ return f"{start:%b %Y} \u2013 {end:%b %Y}{suffix}"
271
+ # Sub-monthly: exact days
272
+ same_month = start.month == end.month and start.year == end.year
273
+ start_fmt = f"{start:%-d}" if same_month else f"{start:%-d %b}"
274
+ return f"{start_fmt} \u2013 {end:%-d %b %Y}{suffix}"
275
+
276
+ return [_fmt(s, e) for s, e in zip(starts, ends)]
277
+
278
+
279
+ def _pnsd_var(ds: xr.Dataset) -> str | None:
280
+ """Return the name of the particle number size distribution variable, or None."""
281
+ return next((n for n in ds.data_vars if n.startswith("v_") and "d_D" in ds[n].dims), None)
282
+
283
+
284
+ def _dimension_with_prefix(da: xr.DataArray, prefix: str) -> str | None:
285
+ """Return a dimension named prefix or its EBAS-suffixed form, e.g. d_Wavelength_d0."""
286
+ return next((dim for dim in da.dims if dim == prefix or dim.startswith(f"{prefix}_")), None)
287
+
288
+
289
+ def _plot_vars(ds: xr.Dataset) -> list[str]:
290
+ """Return measurement variable names to plot for a timeseries dataset.
291
+
292
+ Excludes auxiliary variables, percentile statistics, all-missing variables,
293
+ and variables whose extra (non-time) dimensions contain any d_* dimension
294
+ that is not in _ALLOWED_D_DIMS (e.g. d_D for PNSD would be excluded).
295
+ EBAS-suffixed dimension names such as d_Wavelength_d0 are treated as their
296
+ base name.
297
+
298
+ For data level 3a/3b datasets that contain equivalent black carbon,
299
+ only eBC variables are returned.
300
+ """
301
+ is_met_dataset = ds.attrs.get("ebas_matrix", "").strip().lower() == "met"
302
+
303
+ result = []
304
+ for name in ds.data_vars:
305
+ if not name.startswith("v_"):
306
+ continue
307
+ if "time" not in ds[name].dims:
308
+ continue
309
+ if name in _AUXILIARY and not is_met_dataset:
310
+ continue
311
+ if any(name.endswith(suffix) for suffix in _STATISTICS):
312
+ continue
313
+ if any(
314
+ d.startswith("d_")
315
+ and not any(d == allowed or d.startswith(f"{allowed}_") for allowed in _ALLOWED_D_DIMS)
316
+ for d in ds[name].dims
317
+ ):
318
+ continue
319
+ if not np.isfinite(ds[name].values).any():
320
+ continue
321
+ result.append(name)
322
+
323
+ # For level 3a/3b datasets with eBC, only show the eBC variable
324
+ data_level = ds.attrs.get("ebas_data_level", "")
325
+ if data_level in ("3a", "3b"):
326
+ ebc_vars = [n for n in result if "equivalent_black_carbon" in n]
327
+ if ebc_vars:
328
+ return ebc_vars
329
+
330
+ return result
331
+
332
+
333
+ def _pretty_var_name(name: str, units: str = "") -> str:
334
+ """Return a human-readable label from a variable name."""
335
+ s = name[2:] if name.startswith("v_") else name
336
+ for suffix in ("_amean", "_mean"):
337
+ if s.endswith(suffix):
338
+ s = s[:-len(suffix)]
339
+ break
340
+ if units:
341
+ unit_key = units.replace("/", "_per_").replace(" ", "_").replace(".", "").lower()
342
+ idx = s.lower().find(unit_key)
343
+ if idx > 0:
344
+ s = s[:idx].rstrip("_")
345
+ s = s.replace("_", " ")
346
+ return s[:1].upper() + s[1:]
347
+
348
+
349
+ def _group_vars_by_physical_quantity(ds: xr.Dataset, plot_vars: list[str]) -> list[tuple[str, list[str]]]:
350
+ """Group plot variables by physical quantity (same base name, possibly different units).
351
+
352
+ Returns an ordered list of (group_label, [var_names]) where group_label is
353
+ the human-readable name of the physical quantity (e.g. 'Methane').
354
+ Variables that share the same pretty name after stripping unit descriptors
355
+ and stat suffixes are considered the same physical quantity.
356
+ """
357
+ groups: dict[str, list[str]] = {}
358
+ for name in plot_vars:
359
+ key = _pretty_var_name(name, _var_unit(ds, name))
360
+ groups.setdefault(key, []).append(name)
361
+ return list(groups.items())
362
+
363
+
364
+ def plot_pnsd(ds: xr.Dataset) -> go.Figure:
365
+ """Plot particle number size distribution (PNSD) as a heatmap."""
366
+ var_name = _pnsd_var(ds)
367
+ if var_name is None:
368
+ raise ValueError(
369
+ "No PNSD variable found (expected a 'v_*' variable with a 'd_D' dimension). "
370
+ f"Available: {list(ds.data_vars)}"
371
+ )
372
+ if "time" not in ds.coords and "time" not in ds.dims:
373
+ raise ValueError(
374
+ "Missing time coordinate/dimension 'time'. "
375
+ f"Dataset coordinates: {list(ds.coords)}"
376
+ )
377
+
378
+ da = ds[var_name].transpose("d_D", "time")
379
+ z_raw = np.asarray(da.values, dtype=float)
380
+ z_raw[z_raw <= 0] = np.nan
381
+ z = np.log10(z_raw)
382
+ x_time = get_plot_time(ds)
383
+ y_d = ds["d_D"].values
384
+
385
+ # Fixed logarithmic scale from -1.0 to 5.0 with 0.2 interval; APS instruments cap lower (max 3.0).
386
+ log_min = -1.0
387
+ log_max = 3.0 if ds.attrs.get("ebas_instrument_type", "").strip().lower() == "aps" else 5.0
388
+ tick_interval = 0.2
389
+
390
+ tickvals1 = np.arange(log_min, log_max + tick_interval, tick_interval)
391
+ ticktext1 = [f'{val:.1f}' for val in tickvals1]
392
+
393
+ # Build discrete Plotly colorscale from global palette
394
+ n = len(_CUSTOM_COLORS_PNSD)
395
+ plotly_discrete_colorscale = [
396
+ [i / (n - 1), c] for i, c in enumerate(_CUSTOM_COLORS_PNSD)
397
+ ]
398
+
399
+ fig = go.Figure(go.Heatmap(
400
+ x=x_time,
401
+ y=y_d,
402
+ z=z,
403
+ zmin=log_min,
404
+ zmax=log_max,
405
+ zsmooth='best',
406
+ colorscale=plotly_discrete_colorscale,
407
+ showscale=True,
408
+ colorbar=dict(
409
+ orientation='h',
410
+ x=0.5,
411
+ y=1.02,
412
+ xanchor='center',
413
+ yanchor='bottom',
414
+ len=1,
415
+ thickness=15,
416
+ title=dict(
417
+ text=r'log10(dN/dlogDp) [1/cm3]',
418
+ font=dict(size=12, family='Arial, sans-serif'),
419
+ side='right'
420
+ ),
421
+ tickvals=tickvals1,
422
+ ticktext=ticktext1,
423
+ tick0=log_min,
424
+ dtick=tick_interval,
425
+ ),
426
+ )
427
+ )
428
+ fig.update_layout(
429
+ title=_dataset_title(ds),
430
+ xaxis_title="Time",
431
+ yaxis_title="Diameter (D)",
432
+ yaxis_type="log",
433
+ template="plotly_white",
434
+ hovermode="x unified",
435
+ autosize=True,
436
+ height=450,
437
+ margin=dict(l=60, r=20, t=80, b=60),
438
+ modebar=dict(remove=["lasso2d", "select2d"]),
439
+ )
440
+ return fig
441
+
442
+
443
+ def _isolated_points_mask(y: np.ndarray) -> np.ndarray:
444
+ """Return a bool mask of finite values that have no finite neighbor on either side."""
445
+ finite = np.isfinite(y)
446
+ left_finite = np.concatenate(([False], finite[:-1]))
447
+ right_finite = np.concatenate((finite[1:], [False]))
448
+ return finite & ~left_finite & ~right_finite
449
+
450
+
451
+ def _fill_small_gaps(y: np.ndarray, max_gap_steps: int) -> np.ndarray:
452
+ """Linearly interpolate over NaN runs of <= max_gap_steps for line plots; keep longer runs as NaN.
453
+
454
+ Filled values are not real measurements, only used so Plotly draws a connecting line across the gap.
455
+ """
456
+ if max_gap_steps <= 0:
457
+ return y
458
+ return pd.Series(y, dtype=float).interpolate(
459
+ method="linear", limit=max_gap_steps, limit_area="inside"
460
+ ).to_numpy()
461
+
462
+
463
+ def _time_valid_mask(da: xr.DataArray, arr: np.ndarray) -> np.ndarray:
464
+ """Return which time steps contain at least one finite value."""
465
+ if arr.ndim == 1:
466
+ return np.isfinite(arr)
467
+ time_axis = list(da.dims).index("time")
468
+ return np.any(np.isfinite(arr), axis=tuple(i for i in range(arr.ndim) if i != time_axis))
469
+
470
+
471
+ def _valid_time_indices(da: xr.DataArray, arr: np.ndarray) -> np.ndarray | None:
472
+ """Return valid time indices for floating point data, else None."""
473
+ if not np.issubdtype(arr.dtype, np.floating):
474
+ return None
475
+ return np.flatnonzero(_time_valid_mask(da, arr))
476
+
477
+
478
+ def _gap_fill_threshold(
479
+ ds: xr.Dataset, plot_vars: list[str], time_step: pd.Timedelta,
480
+ var_data: dict[str, np.ndarray] | None = None,
481
+ ) -> int:
482
+ """Return how many consecutive NaN steps to interpolate over based on sampling frequency for line plots."""
483
+ gaps = []
484
+ for name in plot_vars:
485
+ arr = var_data[name] if var_data is not None and name in var_data else ds[name].values
486
+ idx = _valid_time_indices(ds[name], arr)
487
+ if idx is not None and len(idx) > 1:
488
+ gaps.append(np.diff(idx))
489
+ if not gaps:
490
+ return 0
491
+ median_iv = np.median(np.concatenate(gaps)) * time_step
492
+ max_gap = 6 * time_step if time_step <= pd.Timedelta(hours=1) else 3 * median_iv # HERE is where the threshold is chosen based on the sampling frequency (6*1h and 3*all other)
493
+ return max(1, round(max_gap / time_step))
494
+
495
+
496
+ def _break_temporal_gaps(
497
+ x_time: np.ndarray, y: np.ndarray, max_gap_steps: int, time_step: pd.Timedelta
498
+ ) -> tuple[np.ndarray, np.ndarray]:
499
+ """Insert a NaN sentinel at each x_time jump > max_gap_steps so Plotly breaks the line."""
500
+ if max_gap_steps <= 0:
501
+ return x_time, y
502
+ gap_idx = np.where((pd.Series(pd.to_datetime(x_time)).diff() / time_step).values > max_gap_steps + 0.5)[0]
503
+ if not len(gap_idx):
504
+ return x_time, y
505
+ return (np.insert(x_time, gap_idx, x_time[gap_idx - 1]),
506
+ np.insert(y.astype(float), gap_idx, np.nan))
507
+
508
+
509
+ def _add_timeseries_trace(
510
+ fig: go.Figure,
511
+ *,
512
+ x_time,
513
+ y: np.ndarray,
514
+ name: str,
515
+ color: str,
516
+ legendgroup: str | None,
517
+ use_scatter: bool,
518
+ hovertexts,
519
+ max_gap_steps: int,
520
+ time_step: pd.Timedelta,
521
+ iso_marker_size: int,
522
+ visible: bool | str = True,
523
+ ) -> None:
524
+ """Emit a single trace per variable. No-op if no finite data.
525
+
526
+ In line mode, isolated finite points get a small marker on the same trace.
527
+ """
528
+ if not np.isfinite(y).any():
529
+ return
530
+
531
+ if use_scatter:
532
+ kw: dict = dict(x=x_time, y=y, mode="markers",
533
+ marker=dict(color=color, size=6),
534
+ hovertemplate="%{y:.4g}<br>%{customdata}<extra></extra>", customdata=hovertexts)
535
+ else:
536
+ y_line = _fill_small_gaps(y, max_gap_steps)
537
+ x_line, y_line = _break_temporal_gaps(x_time, y_line, max_gap_steps, time_step)
538
+ isolated_points = _isolated_points_mask(y_line)
539
+ kw = dict(x=x_line, y=y_line, mode="lines",
540
+ line=dict(color=color, width=2),
541
+ hovertemplate="%{y:.4g}<extra></extra>")
542
+ if isolated_points.any():
543
+ kw.update(mode="lines+markers",
544
+ marker=dict(color=color,
545
+ size=np.where(isolated_points, iso_marker_size, 0).tolist(),
546
+ symbol="circle"))
547
+
548
+ kw.update(name=name, connectgaps=False)
549
+ if visible != True:
550
+ kw["visible"] = visible
551
+ if legendgroup is not None:
552
+ kw["legendgroup"] = legendgroup
553
+ kw["legendgrouptitle_text"] = legendgroup
554
+ fig.add_trace(go.Scatter(**kw))
555
+
556
+
557
+ def _has_line_segments(
558
+ ds: xr.Dataset, plot_vars: list[str], max_gap_steps: int, var_data: dict[str, np.ndarray],
559
+ ) -> bool:
560
+ """Return True if at least one variable has two consecutive valid samples within max_gap_steps.
561
+
562
+ Used to fail fast when line mode is requested/selected but the data is so sparse or
563
+ irregular that no connecting line segment would actually be drawn.
564
+ """
565
+ for name in plot_vars:
566
+ idx = _valid_time_indices(ds[name], var_data[name])
567
+ if idx is not None and len(idx) > 1 and np.any(np.diff(idx) <= max_gap_steps):
568
+ return True
569
+ return False
570
+
571
+
572
+ def plot_timeseries(
573
+ ds: xr.Dataset,
574
+ *,
575
+ subset_vars: list[str] | None = None,
576
+ title: str | None = None,
577
+ var_color_offsets: dict[str, int] | None = None,
578
+ wl_color_offsets: dict[str, int] | None = None,
579
+ mode: str | None = None,
580
+ ) -> go.Figure:
581
+ """Plot timeseries variables in a single plot, grouped by variable in the legend.
582
+
583
+ Args:
584
+ subset_vars: If provided, only plot these variable names instead of all
585
+ plottable variables from the dataset.
586
+ title: Override the plot title. Defaults to the dataset title.
587
+ var_color_offsets: Per-variable index into the VARIABLES/WAVELENGTH color palette.
588
+ When provided, overrides the auto-incremented counter for each named variable.
589
+ wl_color_offsets: Per-variable starting offset into WAVELENGTH palette for
590
+ variables with a d_Wavelength dimension.
591
+ mode: 'line' or 'scatter' to force a plot mode; if None, it's chosen automatically
592
+ via `_default_plot_mode`. Line mode raises a ValueError if the data is too
593
+ sparse/irregular to draw any connecting line segment (see `_has_line_segments`).
594
+ """
595
+ if mode is not None and mode not in ("line", "scatter"):
596
+ raise ValueError(f"Invalid mode: {mode!r}. Expected 'line', 'scatter', or None.")
597
+
598
+ if "time" not in ds.coords and "time" not in ds.dims:
599
+ raise ValueError(
600
+ "Missing time coordinate/dimension 'time'. "
601
+ f"Dataset coordinates: {list(ds.coords)}"
602
+ )
603
+
604
+ plot_vars = subset_vars if subset_vars is not None else _plot_vars(ds)
605
+ if not plot_vars:
606
+ raise ValueError(
607
+ "No plottable variables found (expected 'v_*' mean variables). "
608
+ f"Available: {list(ds.data_vars)}"
609
+ )
610
+
611
+ # Pre-load all variable data once to avoid redundant OPeNDAP reads during sort, gap computation, and trace emission.
612
+ _var_data: dict[str, np.ndarray] = {n: ds[n].values for n in plot_vars}
613
+
614
+ # Detect whether all variables share the same unit; if so use flat legend style
615
+ all_units = [_var_unit(ds, n) for n in plot_vars]
616
+ multi_unit = len(set(all_units)) > 1
617
+
618
+ # For multi-unit groups, only show the highest-ranked unit by default; others are legend-only.
619
+ quantity_groups = _group_vars_by_physical_quantity(ds, plot_vars)
620
+ if multi_unit:
621
+ _preferred_var = {
622
+ max(vs, key=lambda n: _unit_score(_var_unit(ds, n)))
623
+ for _, vs in quantity_groups
624
+ }
625
+ else:
626
+ _preferred_var = set(plot_vars)
627
+
628
+ def _mean_pre(n: str) -> float:
629
+ arr = _var_data[n].ravel()
630
+ f = arr[np.isfinite(arr)]
631
+ return float(f.mean()) if len(f) else 0.0
632
+
633
+ # Sort physical-quantity groups (preferred first, then by descending mean of their best
634
+ # variant), but keep each group's own unit variants adjacent -- Plotly requires traces
635
+ # sharing a legendgroup to be contiguous in fig.data, or legend click/isolate misbehaves.
636
+ def _group_sort_key(group: tuple[str, list[str]]) -> tuple[int, float]:
637
+ _, names = group
638
+ best = max(names, key=_mean_pre)
639
+ return (0 if best in _preferred_var else 1, -_mean_pre(best))
640
+
641
+ plot_vars = [
642
+ n for _, names in sorted(quantity_groups, key=_group_sort_key)
643
+ for n in sorted(names, key=lambda n: 0 if n in _preferred_var else 1)
644
+ ]
645
+
646
+ x_time = get_plot_time(ds)
647
+ fig = go.Figure()
648
+
649
+ _iso_marker_size = 4
650
+
651
+ _time_step = pd.Series(pd.to_datetime(x_time)).diff().median()
652
+ use_scatter = (mode or _default_plot_mode(ds, _time_step)) == "scatter"
653
+ _max_gap_steps = _gap_fill_threshold(ds, plot_vars, _time_step, _var_data)
654
+ if not use_scatter and not _has_line_segments(ds, plot_vars, _max_gap_steps, _var_data):
655
+ raise ValueError(
656
+ "Line mode was requested/selected, but the data is too sparse or irregular "
657
+ "to draw any connecting line -- use mode='scatter' instead."
658
+ )
659
+ hovertexts = _scatter_hovertexts(x_time, ds)
660
+
661
+ # Determine plot mode
662
+ has_wavelength = any(_dimension_with_prefix(ds[n], "d_Wavelength") is not None for n in plot_vars)
663
+ var_color_idx = 0
664
+ wl_color_offset = 0 # advances through _CUSTOM_COLORS_WAVELENGTH per wl variable
665
+
666
+ def _var_color(name: str, palette: list[str]) -> str:
667
+ """Pick the next color for a plain/tower variable, honoring var_color_offsets if given."""
668
+ nonlocal var_color_idx
669
+ idx = var_color_offsets[name] if var_color_offsets and name in var_color_offsets else var_color_idx
670
+ if var_color_offsets is None:
671
+ var_color_idx += 1
672
+ return palette[idx % len(palette)]
673
+
674
+ def _wl_colors(name: str, n_values: int) -> list[str]:
675
+ """Pick n_values consecutive wavelength colors, honoring wl_color_offsets if given."""
676
+ nonlocal wl_color_offset
677
+ eff = wl_color_offsets[name] if wl_color_offsets and name in wl_color_offsets else wl_color_offset
678
+ if wl_color_offsets is None:
679
+ wl_color_offset += n_values
680
+ return [_CUSTOM_COLORS_WAVELENGTH[(eff + i) % len(_CUSTOM_COLORS_WAVELENGTH)] for i in range(n_values)]
681
+
682
+ for name in plot_vars:
683
+ da = ds[name]
684
+ units = _var_unit(ds, name)
685
+ pretty = _pretty_var_name(name, units)
686
+ group_title = f"{pretty} [{units}]" if units else pretty
687
+
688
+ wavelength_dim = _dimension_with_prefix(da, "d_Wavelength")
689
+ if wavelength_dim:
690
+ wl_values = da.coords[wavelength_dim].values
691
+ for color, wl in zip(_wl_colors(name, len(wl_values)), wl_values):
692
+ y = da.sel({wavelength_dim: wl}).values.copy()
693
+ _add_timeseries_trace(fig, x_time=x_time, y=y, name=f"{float(wl):.1f} nm",
694
+ color=color, legendgroup=group_title,
695
+ use_scatter=use_scatter, hovertexts=hovertexts,
696
+ max_gap_steps=_max_gap_steps, time_step=_time_step,
697
+ iso_marker_size=_iso_marker_size)
698
+
699
+ elif "d_Tower_inlet_height" in da.dims:
700
+ base_color = _var_color(name, _CUSTOM_COLORS_VARIABLES)
701
+ height_values = da.coords["d_Tower_inlet_height"].values
702
+ n_heights = len(height_values)
703
+ opacities = [1.0 - (i / max(n_heights - 1, 1)) * 0.65 for i in range(n_heights)]
704
+ for h_idx, height in enumerate(height_values):
705
+ color = _hex_to_rgba(base_color, opacities[h_idx])
706
+ y = da.sel(d_Tower_inlet_height=height).values.copy()
707
+ _add_timeseries_trace(fig, x_time=x_time, y=y, name=f"h\u1d35\u2099\u1d35={height:.0f}m",
708
+ color=color, legendgroup=group_title,
709
+ use_scatter=use_scatter, hovertexts=hovertexts,
710
+ max_gap_steps=_max_gap_steps, time_step=_time_step,
711
+ iso_marker_size=_iso_marker_size)
712
+
713
+ else:
714
+ wl = None
715
+ if "d_Wavelength" in da.coords:
716
+ wl = float(da.coords["d_Wavelength"].values)
717
+ elif "wavelength" in da.attrs:
718
+ try:
719
+ wl = float(da.attrs["wavelength"])
720
+ except (TypeError, ValueError):
721
+ pass
722
+
723
+ palette = _CUSTOM_COLORS_WAVELENGTH if has_wavelength else _CUSTOM_COLORS_VARIABLES
724
+ color = _var_color(name, palette)
725
+
726
+ trace_name = f"{wl:.1f} nm" if isinstance(wl, float) else (
727
+ (f"[{units}]" if units else pretty) if multi_unit else group_title
728
+ )
729
+ lg = pretty if (multi_unit and not isinstance(wl, float)) else None
730
+ visible = True if name in _preferred_var else "legendonly"
731
+
732
+ y = _var_data[name].copy()
733
+ _add_timeseries_trace(fig, x_time=x_time, y=y, name=trace_name,
734
+ color=color, legendgroup=lg, visible=visible,
735
+ use_scatter=use_scatter, hovertexts=hovertexts,
736
+ max_gap_steps=_max_gap_steps, time_step=_time_step,
737
+ iso_marker_size=_iso_marker_size)
738
+
739
+ n_traces = len(fig.data)
740
+ legend_rows = max(1, -(-n_traces // 3)) # ceil(n_traces / 3)
741
+ bottom_margin = max(80, 30 + legend_rows * 28)
742
+
743
+ # Approximate 7px per character at font size 11; min 120px.
744
+ all_labels = [t.name or "" for t in fig.data] + [
745
+ t.legendgrouptitle.text for t in fig.data
746
+ if getattr(t, "legendgrouptitle", None) and t.legendgrouptitle.text
747
+ ]
748
+ entry_width = max(80, max((len(l) for l in all_labels), default=10) * 7)
749
+
750
+ fig.update_layout(
751
+ title=title if title is not None else _dataset_title(ds),
752
+ xaxis_title="Time",
753
+ template="plotly_white",
754
+ hovermode="x unified",
755
+ showlegend=True,
756
+ autosize=True,
757
+ height=400 + bottom_margin,
758
+ legend=dict(
759
+ groupclick="toggleitem",
760
+ itemclick="toggle",
761
+ itemdoubleclick="toggleothers",
762
+ orientation="h",
763
+ yanchor="top",
764
+ y=-0.2,
765
+ xanchor="left",
766
+ x=0,
767
+ font=dict(size=11),
768
+ entrywidth=entry_width,
769
+ entrywidthmode="pixels",
770
+ ),
771
+ margin=dict(l=60, r=20, t=50, b=bottom_margin),
772
+ modebar=dict(remove=["lasso2d", "select2d"]),
773
+ )
774
+ return fig
775
+
776
+
777
+ def plot_timeseries_per_var(ds: xr.Dataset, *, mode: str | None = None) -> list[go.Figure]:
778
+ """Return one figure per physical quantity, preserving combined-plot colors.
779
+
780
+ Variables with the same base name but different units are grouped together.
781
+ Falls back to a single combined figure when there is only one group.
782
+ """
783
+ plot_vars = _plot_vars(ds)
784
+ if not plot_vars:
785
+ raise ValueError(
786
+ "No plottable variables found (expected 'v_*' mean variables). "
787
+ f"Available: {list(ds.data_vars)}"
788
+ )
789
+ groups = _group_vars_by_physical_quantity(ds, plot_vars)
790
+ if len(groups) <= 1:
791
+ return [plot_timeseries(ds, mode=mode)]
792
+
793
+ # Walk the same sorted order as the combined plot to build per-variable color offsets.
794
+ all_units = {_var_unit(ds, n) for n in plot_vars}
795
+ preferred = (
796
+ {max(vs, key=lambda n: _unit_score(_var_unit(ds, n)))
797
+ for _, vs in groups}
798
+ if len(all_units) > 1 else set(plot_vars)
799
+ )
800
+ vc, wc = 0, 0
801
+ vc_map: dict[str, int] = {}
802
+ wc_map: dict[str, int] = {}
803
+ def _group_sort_key(group: tuple[str, list[str]]) -> tuple[int, float]:
804
+ _, names = group
805
+ best = max(names, key=lambda n: _var_mean(ds, n))
806
+ return (0 if best in preferred else 1, -_var_mean(ds, best))
807
+
808
+ ordered_vars = [
809
+ n for _, names in sorted(groups, key=_group_sort_key)
810
+ for n in sorted(names, key=lambda n: 0 if n in preferred else 1)
811
+ ]
812
+
813
+ for name in ordered_vars:
814
+ wavelength_dim = _dimension_with_prefix(ds[name], "d_Wavelength")
815
+ if wavelength_dim:
816
+ wc_map[name] = wc
817
+ wc += len(ds[name].coords[wavelength_dim].values)
818
+ else:
819
+ vc_map[name] = vc
820
+ vc += 1
821
+
822
+ station = ds.attrs.get("ebas_station_name", ds.attrs.get("site_name", ""))
823
+ return [
824
+ plot_timeseries(ds, subset_vars=vars_,
825
+ title=f"{lbl} at {station}" if station else lbl,
826
+ var_color_offsets={n: vc_map[n] for n in vars_ if n in vc_map},
827
+ wl_color_offsets={n: wc_map[n] for n in vars_ if n in wc_map},
828
+ mode=mode)
829
+ for lbl, vars_ in groups
830
+ ]
831
+
832
+
833
+ def plot(url: str, var_plots: bool = False, mode: str | None = None) -> "go.Figure | list[go.Figure]":
834
+ """Open a dataset and dispatch to the appropriate plot function.
835
+
836
+ Args:
837
+ var_plots: When True, return a list of figures — one per physical
838
+ quantity — instead of a single combined figure.
839
+ mode: 'line' or 'scatter' to force a plot mode on standard timeseries plots
840
+ (ignored for PNSD heatmap plots). If None, it's chosen automatically.
841
+ """
842
+ ds = open_dataset(url)
843
+ if "d_D" in ds.dims or "d_D" in ds.coords:
844
+ return plot_pnsd(ds)
845
+ if var_plots:
846
+ return plot_timeseries_per_var(ds, mode=mode)
847
+ return plot_timeseries(ds, mode=mode)
848
+
849
+
850
+ def main() -> None:
851
+ """Run the command-line interface."""
852
+ import sys
853
+ args = sys.argv[1:]
854
+ var_plots = "--var_plots" in args
855
+ args = [a for a in args if a != "--var_plots"]
856
+ mode = None
857
+ if "--mode" in args:
858
+ i = args.index("--mode")
859
+ if i + 1 >= len(args):
860
+ raise ValueError("--mode requires 'line' or 'scatter'")
861
+ mode = args[i + 1]
862
+ args = args[:i] + args[i + 2:]
863
+ url = args[0] if args else "https://doi.nilu.no/doi/4VU2-MCJG"
864
+ result = plot(url, var_plots=var_plots, mode=mode)
865
+ if isinstance(result, list):
866
+ for fig in result:
867
+ fig.show()
868
+ else:
869
+ result.show()
870
+
871
+
872
+ if __name__ == "__main__":
873
+ main()