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,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()
|