oceanarray 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.
- oceanarray/__init__.py +20 -0
- oceanarray/_version.py +1 -0
- oceanarray/cli.py +1343 -0
- oceanarray/clock_offset.py +675 -0
- oceanarray/config/OS1_sensor_attrs.yaml +49 -0
- oceanarray/config/OS1_var_names.yaml +5 -0
- oceanarray/config/OS1_vocab_attrs.yaml +84 -0
- oceanarray/config/legacy/project_RAPID.yaml +69 -0
- oceanarray/config/legacy/rodb_keys.txt +206 -0
- oceanarray/config/legacy/rodb_keys.yaml +1012 -0
- oceanarray/config/logging.yaml +28 -0
- oceanarray/legacy/__init__.py +47 -0
- oceanarray/legacy/caldip.py +753 -0
- oceanarray/legacy/convertOS.py +527 -0
- oceanarray/legacy/find_deployment.py +489 -0
- oceanarray/legacy/mooring_rodb.py +429 -0
- oceanarray/legacy/process_rodb.py +369 -0
- oceanarray/legacy/rodb.py +330 -0
- oceanarray/logger.py +192 -0
- oceanarray/logsheet/__init__.py +60 -0
- oceanarray/logsheet/_columns.py +97 -0
- oceanarray/logsheet/_config.py +245 -0
- oceanarray/logsheet/_firmware.py +43 -0
- oceanarray/logsheet/_latex.py +128 -0
- oceanarray/logsheet/_render.py +126 -0
- oceanarray/logsheet/builders/__init__.py +13 -0
- oceanarray/logsheet/builders/caldip_download.py +195 -0
- oceanarray/logsheet/builders/caldip_setup.py +188 -0
- oceanarray/logsheet/builders/deployment_setup.py +218 -0
- oceanarray/logsheet/builders/mooring_download.py +243 -0
- oceanarray/logsheet/builders/recovery.py +157 -0
- oceanarray/logsheet/data/column_defs.yaml +686 -0
- oceanarray/logsheet/data/latex_templates/_base_instrument.tex.j2 +42 -0
- oceanarray/logsheet/data/latex_templates/caldip_download.tex.j2 +47 -0
- oceanarray/logsheet/data/latex_templates/caldip_setup.tex.j2 +70 -0
- oceanarray/logsheet/data/latex_templates/deployment_setup.tex.j2 +97 -0
- oceanarray/logsheet/data/latex_templates/mooring_download.tex.j2 +54 -0
- oceanarray/logsheet/data/latex_templates/recovery.tex.j2 +103 -0
- oceanarray/mooring_level.py +1368 -0
- oceanarray/oceanarray.mplstyle +18 -0
- oceanarray/parameters.py +270 -0
- oceanarray/plotter.py +942 -0
- oceanarray/plotters/__init__.py +97 -0
- oceanarray/plotters/_animation.py +261 -0
- oceanarray/plotters/_current.py +705 -0
- oceanarray/plotters/_diagnostic.py +667 -0
- oceanarray/plotters/_helpers.py +57 -0
- oceanarray/plotters/_primitives.py +101 -0
- oceanarray/plotters/_section.py +16 -0
- oceanarray/plotters/_timeseries.py +11 -0
- oceanarray/rapid_interp.py +739 -0
- oceanarray/read_rbr_hex.py +541 -0
- oceanarray/readers.py +303 -0
- oceanarray/report/__init__.py +5 -0
- oceanarray/report/_array.py +386 -0
- oceanarray/report/_grid.py +539 -0
- oceanarray/report/_html_helpers.py +1004 -0
- oceanarray/report/_instrument.py +853 -0
- oceanarray/report/_mooring.py +1385 -0
- oceanarray/report/_plots.py +4284 -0
- oceanarray/report/_stack.py +941 -0
- oceanarray/stage1.py +1959 -0
- oceanarray/stage2.py +1160 -0
- oceanarray/stage3.py +2494 -0
- oceanarray/time_gridding.py +958 -0
- oceanarray/tools.py +400 -0
- oceanarray/transports.py +1 -0
- oceanarray/utilities.py +538 -0
- oceanarray/validation.py +389 -0
- oceanarray/writers.py +99 -0
- oceanarray-0.1.0.dist-info/METADATA +503 -0
- oceanarray-0.1.0.dist-info/RECORD +76 -0
- oceanarray-0.1.0.dist-info/WHEEL +5 -0
- oceanarray-0.1.0.dist-info/entry_points.txt +2 -0
- oceanarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- oceanarray-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Oceanarray plotting package.
|
|
2
|
+
|
|
3
|
+
Three-tier architecture
|
|
4
|
+
-----------------------
|
|
5
|
+
Tier 1 _primitives.py Data-agnostic plot primitives (array-in / Figure-out).
|
|
6
|
+
Tier 2 _current.py etc. Domain wrappers (xr.Dataset-in / Figure-out).
|
|
7
|
+
Tier 3 report/_plots.py Thin report wrappers (path-in / base64-out).
|
|
8
|
+
|
|
9
|
+
Separate modules (not part of the Tier pipeline)
|
|
10
|
+
-------------------------------------------------
|
|
11
|
+
_animation.py Animated GIF output via matplotlib.animation + pillow.
|
|
12
|
+
Intentionally isolated so plotly/_interactive.py can be
|
|
13
|
+
added alongside it later without coupling to the static stack.
|
|
14
|
+
|
|
15
|
+
Public API
|
|
16
|
+
----------
|
|
17
|
+
Available now (pre-OdB):
|
|
18
|
+
|
|
19
|
+
plot_temperature_trajectory Lagrangian trajectory coloured by temperature
|
|
20
|
+
plot_speed_boxplot Current speed boxplot with percentile statistics
|
|
21
|
+
plot_hodograph Two-panel hodograph coloured by time
|
|
22
|
+
animate_hodograph Animated GIF hodograph (requires pillow)
|
|
23
|
+
|
|
24
|
+
Legacy functions (from plotter.py — available via backward-compat shim):
|
|
25
|
+
|
|
26
|
+
plot_qartod_summary, plot_climatology, scatter_profile_vs_PRES,
|
|
27
|
+
pcolor_timeseries_by_depth, plot_timeseries_by_depth, plot_trim_windows,
|
|
28
|
+
plot_microcat_raw, plot_aquadopp_raw, plot_microcat,
|
|
29
|
+
show_variables, show_attributes, plot_mooring_timeseries, plot_grid
|
|
30
|
+
|
|
31
|
+
As each legacy function is migrated into the appropriate sub-module, remove
|
|
32
|
+
it from plotter.py and from the shim below. See the migration checklist at
|
|
33
|
+
.claude/plotters_update-20260718.md.
|
|
34
|
+
|
|
35
|
+
Future
|
|
36
|
+
------
|
|
37
|
+
Post-OdB: fill in _timeseries.py, _section.py, _diagnostic.py, _helpers.py;
|
|
38
|
+
implement remaining Tier-1 primitives; delete plotter.py once empty.
|
|
39
|
+
May eventually be split into a separate oceanvis package.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# New functions (pre-OdB)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
from oceanarray.plotters._current import ( # noqa: F401
|
|
46
|
+
plot_temperature_trajectory,
|
|
47
|
+
plot_speed_boxplot,
|
|
48
|
+
plot_multi_aquadopp_trajectories,
|
|
49
|
+
plot_aquadopp_speed_profile,
|
|
50
|
+
plot_hodograph,
|
|
51
|
+
)
|
|
52
|
+
from oceanarray.plotters._animation import animate_hodograph # noqa: F401
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Backward-compat shim — re-exports from legacy plotter.py
|
|
56
|
+
# Remove each entry here once the function has been migrated to a sub-module
|
|
57
|
+
# and the corresponding function deleted from plotter.py.
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
from oceanarray.plotter import ( # noqa: F401
|
|
60
|
+
plot_qartod_summary,
|
|
61
|
+
plot_climatology,
|
|
62
|
+
scatter_profile_vs_PRES,
|
|
63
|
+
pcolor_timeseries_by_depth,
|
|
64
|
+
plot_timeseries_by_depth,
|
|
65
|
+
plot_trim_windows,
|
|
66
|
+
plot_microcat_raw,
|
|
67
|
+
plot_aquadopp_raw,
|
|
68
|
+
plot_microcat,
|
|
69
|
+
show_variables,
|
|
70
|
+
show_attributes,
|
|
71
|
+
plot_mooring_timeseries,
|
|
72
|
+
plot_grid,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
# New (pre-OdB)
|
|
77
|
+
"plot_temperature_trajectory",
|
|
78
|
+
"plot_speed_boxplot",
|
|
79
|
+
"plot_multi_aquadopp_trajectories",
|
|
80
|
+
"plot_aquadopp_speed_profile",
|
|
81
|
+
"plot_hodograph",
|
|
82
|
+
"animate_hodograph",
|
|
83
|
+
# Legacy shim
|
|
84
|
+
"plot_qartod_summary",
|
|
85
|
+
"plot_climatology",
|
|
86
|
+
"scatter_profile_vs_PRES",
|
|
87
|
+
"pcolor_timeseries_by_depth",
|
|
88
|
+
"plot_timeseries_by_depth",
|
|
89
|
+
"plot_trim_windows",
|
|
90
|
+
"plot_microcat_raw",
|
|
91
|
+
"plot_aquadopp_raw",
|
|
92
|
+
"plot_microcat",
|
|
93
|
+
"show_variables",
|
|
94
|
+
"show_attributes",
|
|
95
|
+
"plot_mooring_timeseries",
|
|
96
|
+
"plot_grid",
|
|
97
|
+
]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Animated plot functions for the oceanarray plotters package.
|
|
2
|
+
|
|
3
|
+
This module is intentionally separate from the static (Tier-1/2/3) pipeline.
|
|
4
|
+
It relies on ``matplotlib.animation`` and requires the ``pillow`` package as a
|
|
5
|
+
GIF writer. ``scipy`` is required for the Tukey smoothing window.
|
|
6
|
+
|
|
7
|
+
Future interactive equivalents (plotly, bokeh) should live in a companion
|
|
8
|
+
``_interactive.py`` module so the animation and interactive layers stay
|
|
9
|
+
clearly distinct.
|
|
10
|
+
|
|
11
|
+
Tier classification: Tier-2 domain functions (xr.Dataset-in / file-out).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Union
|
|
18
|
+
|
|
19
|
+
import matplotlib.colors as mcolors
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
import numpy as np
|
|
22
|
+
import xarray as xr
|
|
23
|
+
|
|
24
|
+
from oceanarray.plotters._helpers import tukey_smooth
|
|
25
|
+
from oceanarray.utilities import _nice_colorbar_bounds
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def animate_hodograph(
|
|
29
|
+
ds: xr.Dataset,
|
|
30
|
+
output_path: Union[str, Path],
|
|
31
|
+
u_var: str = "east_velocity",
|
|
32
|
+
v_var: str = "north_velocity",
|
|
33
|
+
lp_days: float = 4.0,
|
|
34
|
+
smooth_hours: float = 3.0,
|
|
35
|
+
frame_hours: float = 6.0,
|
|
36
|
+
fps: int = 20,
|
|
37
|
+
dpi: int = 100,
|
|
38
|
+
) -> Optional[Path]:
|
|
39
|
+
"""Write an animated GIF of the smoothed hodograph drawing itself through time.
|
|
40
|
+
|
|
41
|
+
Each frame advances by *frame_hours* of real deployment time and reveals
|
|
42
|
+
the smoothed velocity trajectory accumulated up to that moment, so the
|
|
43
|
+
viewer watches rotary/eddy structure build up through the record.
|
|
44
|
+
|
|
45
|
+
Signal processing (applied once to the full record before animating):
|
|
46
|
+
|
|
47
|
+
* **Raw panel** — *smooth_hours*-Tukey filter of the raw east/north signal.
|
|
48
|
+
* **Eddy panel** — *lp_days*-day rolling-mean removed, then *smooth_hours*-
|
|
49
|
+
Tukey filter to suppress instrument noise.
|
|
50
|
+
|
|
51
|
+
A timestamp header on each frame shows the real date and time.
|
|
52
|
+
|
|
53
|
+
Requires ``pillow`` (``pip install pillow``) as the GIF writer and
|
|
54
|
+
``scipy`` for the Tukey window.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
ds : xr.Dataset
|
|
59
|
+
Per-instrument dataset with east/north velocity variables and a
|
|
60
|
+
``time`` dimension.
|
|
61
|
+
output_path : str or Path
|
|
62
|
+
Destination file path; ``.gif`` extension recommended.
|
|
63
|
+
u_var : str
|
|
64
|
+
Eastward velocity variable name (m s⁻¹).
|
|
65
|
+
v_var : str
|
|
66
|
+
Northward velocity variable name (m s⁻¹).
|
|
67
|
+
lp_days : float
|
|
68
|
+
Low-pass window length in days for the eddy-component panel.
|
|
69
|
+
smooth_hours : float
|
|
70
|
+
Tukey smoothing window in hours applied to both panels.
|
|
71
|
+
frame_hours : float
|
|
72
|
+
Time step between frames in hours (default 6 h → one frame per
|
|
73
|
+
quarter-day of deployment).
|
|
74
|
+
fps : int
|
|
75
|
+
Frames per second in the output GIF.
|
|
76
|
+
dpi : int
|
|
77
|
+
Resolution of each frame in dots per inch.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
Path
|
|
82
|
+
The resolved output path on success.
|
|
83
|
+
None
|
|
84
|
+
If *u_var* or *v_var* are absent from *ds*, or if the ``pillow``
|
|
85
|
+
writer is unavailable.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
import pandas as pd
|
|
89
|
+
from matplotlib.animation import FuncAnimation
|
|
90
|
+
|
|
91
|
+
output_path = Path(output_path)
|
|
92
|
+
|
|
93
|
+
if u_var not in ds.data_vars or v_var not in ds.data_vars:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
instr_id = ds.attrs.get("id", "")
|
|
97
|
+
east_raw = ds[u_var].values.astype(float).ravel()
|
|
98
|
+
north_raw = ds[v_var].values.astype(float).ravel()
|
|
99
|
+
t = ds["time"].values
|
|
100
|
+
units = ds[u_var].attrs.get("units", "m s⁻¹")
|
|
101
|
+
|
|
102
|
+
# Sample interval (seconds)
|
|
103
|
+
dt_s = (
|
|
104
|
+
float(np.median(np.diff(t) / np.timedelta64(1, "s"))) if len(t) > 1 else 3600.0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Tukey smoothing window length (samples)
|
|
108
|
+
smooth_n = max(3, int(round(smooth_hours * 3600.0 / dt_s)))
|
|
109
|
+
|
|
110
|
+
# LP window for eddy removal (rolling mean)
|
|
111
|
+
lp_n = max(3, int(round(lp_days * 86400.0 / dt_s)))
|
|
112
|
+
|
|
113
|
+
# Raw panel: Tukey-smoothed raw signal
|
|
114
|
+
e_raw_sm = tukey_smooth(east_raw, smooth_n)
|
|
115
|
+
n_raw_sm = tukey_smooth(north_raw, smooth_n)
|
|
116
|
+
|
|
117
|
+
# Eddy panel: LP mean removed, then Tukey smoothed
|
|
118
|
+
e_lp = (
|
|
119
|
+
pd.Series(east_raw)
|
|
120
|
+
.rolling(window=lp_n, min_periods=1, center=True)
|
|
121
|
+
.mean()
|
|
122
|
+
.values
|
|
123
|
+
)
|
|
124
|
+
n_lp = (
|
|
125
|
+
pd.Series(north_raw)
|
|
126
|
+
.rolling(window=lp_n, min_periods=1, center=True)
|
|
127
|
+
.mean()
|
|
128
|
+
.values
|
|
129
|
+
)
|
|
130
|
+
e_eddy_sm = tukey_smooth(east_raw - e_lp, smooth_n)
|
|
131
|
+
n_eddy_sm = tukey_smooth(north_raw - n_lp, smooth_n)
|
|
132
|
+
|
|
133
|
+
# Frame indices — one frame per frame_hours of deployment time
|
|
134
|
+
step_n = max(1, int(round(frame_hours * 3600.0 / dt_s)))
|
|
135
|
+
N = len(east_raw)
|
|
136
|
+
frame_ends = np.arange(step_n, N + step_n, step_n, dtype=int)
|
|
137
|
+
frame_ends = frame_ends[frame_ends <= N]
|
|
138
|
+
if len(frame_ends) == 0 or frame_ends[-1] < N:
|
|
139
|
+
frame_ends = np.append(frame_ends, N)
|
|
140
|
+
n_frames = len(frame_ends)
|
|
141
|
+
|
|
142
|
+
# Time colour: permanent fraction 0→1 per sample
|
|
143
|
+
t_frac = np.linspace(0.0, 1.0, N)
|
|
144
|
+
bounds = _nice_colorbar_bounds(0.0, 1.0, n=10)
|
|
145
|
+
norm = mcolors.BoundaryNorm(bounds, ncolors=256)
|
|
146
|
+
cmap = plt.get_cmap("viridis")
|
|
147
|
+
|
|
148
|
+
# Fixed symmetric axis limits from full smoothed data
|
|
149
|
+
def _sym_lim(e: np.ndarray, n: np.ndarray) -> float:
|
|
150
|
+
vals = np.concatenate([e[np.isfinite(e)], n[np.isfinite(n)]])
|
|
151
|
+
return float(np.nanmax(np.abs(vals))) * 1.08 if vals.size > 0 else 1.0
|
|
152
|
+
|
|
153
|
+
raw_lim = _sym_lim(e_raw_sm, n_raw_sm)
|
|
154
|
+
eddy_lim = _sym_lim(e_eddy_sm, n_eddy_sm)
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------ Figure
|
|
157
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
|
|
158
|
+
|
|
159
|
+
# Fix the layout explicitly so the colorbar axes position is stable across
|
|
160
|
+
# ax.cla() calls (tight_layout after colorbar creation causes misalignment).
|
|
161
|
+
fig.subplots_adjust(left=0.07, right=0.86, top=0.88, bottom=0.11, wspace=0.35)
|
|
162
|
+
|
|
163
|
+
# Colorbar on a fixed axes to the right of ax2 — position in figure coords
|
|
164
|
+
cbar_ax = fig.add_axes([0.88, 0.13, 0.02, 0.70])
|
|
165
|
+
sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
166
|
+
sm.set_array([])
|
|
167
|
+
cb = fig.colorbar(
|
|
168
|
+
sm, cax=cbar_ax, label="Time (0 = start, 1 = end of record)", ticks=bounds
|
|
169
|
+
)
|
|
170
|
+
cb.ax.tick_params(labelsize=8)
|
|
171
|
+
|
|
172
|
+
# Static instrument label + dynamic timestamp in the headroom above panels
|
|
173
|
+
if instr_id:
|
|
174
|
+
fig.text(0.45, 0.99, instr_id, ha="center", va="top", fontsize=9, color="#555")
|
|
175
|
+
timestamp_text = fig.text(
|
|
176
|
+
0.45, 0.94, "", ha="center", va="top", fontsize=11, fontweight="bold"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _setup_ax(ax: plt.Axes, lim: float, title: str) -> None:
|
|
180
|
+
ax.set_xlim(-lim, lim)
|
|
181
|
+
ax.set_ylim(-lim, lim)
|
|
182
|
+
ax.set_aspect("equal")
|
|
183
|
+
ax.axhline(0, color="#bbb", lw=0.7, zorder=0)
|
|
184
|
+
ax.axvline(0, color="#bbb", lw=0.7, zorder=0)
|
|
185
|
+
ax.set_xlabel(f"East ({units})")
|
|
186
|
+
ax.set_ylabel(f"North ({units})")
|
|
187
|
+
ax.set_title(title, fontsize=10)
|
|
188
|
+
ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.4)
|
|
189
|
+
|
|
190
|
+
def _draw_frame(frame_idx: int) -> None:
|
|
191
|
+
k = int(frame_ends[frame_idx])
|
|
192
|
+
|
|
193
|
+
# Timestamp from the last sample in this frame
|
|
194
|
+
time_str = pd.Timestamp(t[k - 1]).strftime("%Y-%m-%d %H:%M UTC")
|
|
195
|
+
timestamp_text.set_text(time_str)
|
|
196
|
+
|
|
197
|
+
panels = [
|
|
198
|
+
(ax1, e_raw_sm, n_raw_sm, raw_lim, f"Raw ({smooth_hours:.0f}-h smoothed)"),
|
|
199
|
+
(
|
|
200
|
+
ax2,
|
|
201
|
+
e_eddy_sm,
|
|
202
|
+
n_eddy_sm,
|
|
203
|
+
eddy_lim,
|
|
204
|
+
f"Eddy ({lp_days:.0f}-day LP removed, {smooth_hours:.0f}-h smoothed)",
|
|
205
|
+
),
|
|
206
|
+
]
|
|
207
|
+
for ax, e, n, lim, title in panels:
|
|
208
|
+
ax.cla()
|
|
209
|
+
_setup_ax(ax, lim, title)
|
|
210
|
+
|
|
211
|
+
mask = np.isfinite(e[:k]) & np.isfinite(n[:k])
|
|
212
|
+
if not mask.any():
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
ax.scatter(
|
|
216
|
+
e[:k][mask],
|
|
217
|
+
n[:k][mask],
|
|
218
|
+
c=t_frac[:k][mask],
|
|
219
|
+
cmap=cmap,
|
|
220
|
+
norm=norm,
|
|
221
|
+
s=4,
|
|
222
|
+
alpha=0.8,
|
|
223
|
+
linewidths=0,
|
|
224
|
+
marker="o",
|
|
225
|
+
rasterized=True,
|
|
226
|
+
)
|
|
227
|
+
# Permanent start marker; moving end marker
|
|
228
|
+
idx = np.where(mask)[0]
|
|
229
|
+
ax.scatter(
|
|
230
|
+
e[:k][idx[0]],
|
|
231
|
+
n[:k][idx[0]],
|
|
232
|
+
s=55,
|
|
233
|
+
c="lime",
|
|
234
|
+
zorder=5,
|
|
235
|
+
marker="o",
|
|
236
|
+
edgecolors="black",
|
|
237
|
+
linewidths=0.6,
|
|
238
|
+
)
|
|
239
|
+
ax.scatter(
|
|
240
|
+
e[:k][idx[-1]],
|
|
241
|
+
n[:k][idx[-1]],
|
|
242
|
+
s=65,
|
|
243
|
+
c="red",
|
|
244
|
+
zorder=5,
|
|
245
|
+
marker="s",
|
|
246
|
+
edgecolors="black",
|
|
247
|
+
linewidths=0.6,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
anim = FuncAnimation(
|
|
251
|
+
fig, _draw_frame, frames=n_frames, interval=1000 // fps, blit=False
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
anim.save(str(output_path), writer="pillow", fps=fps, dpi=dpi)
|
|
256
|
+
except Exception: # noqa: BLE001 — broad catch at I/O boundary; caller checks return value
|
|
257
|
+
plt.close(fig)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
plt.close(fig)
|
|
261
|
+
return output_path
|