mdsview 0.2.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.
- mdsview/__init__.py +3 -0
- mdsview/animation.py +154 -0
- mdsview/assets/logo.png +0 -0
- mdsview/catalog.py +98 -0
- mdsview/cli.py +521 -0
- mdsview/cli_help.py +93 -0
- mdsview/colormaps.py +87 -0
- mdsview/errors.py +25 -0
- mdsview/grid.py +299 -0
- mdsview/gui.py +1773 -0
- mdsview/gui_theme.py +215 -0
- mdsview/io.py +147 -0
- mdsview/meta.py +92 -0
- mdsview/ops.py +341 -0
- mdsview/plotting.py +347 -0
- mdsview/samples/__init__.py +1 -0
- mdsview/samples/generate.py +477 -0
- mdsview/slices.py +46 -0
- mdsview/validate.py +54 -0
- mdsview-0.2.0.dist-info/METADATA +248 -0
- mdsview-0.2.0.dist-info/RECORD +25 -0
- mdsview-0.2.0.dist-info/WHEEL +5 -0
- mdsview-0.2.0.dist-info/entry_points.txt +2 -0
- mdsview-0.2.0.dist-info/licenses/LICENSE +21 -0
- mdsview-0.2.0.dist-info/top_level.txt +1 -0
mdsview/__init__.py
ADDED
mdsview/animation.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Timestep playback and GIF export helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io as pyio
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
import numpy as np
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from . import io, plotting
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_playback_clim(
|
|
18
|
+
data_dir: str,
|
|
19
|
+
prefix: str,
|
|
20
|
+
iterations: list[int],
|
|
21
|
+
level: int,
|
|
22
|
+
*,
|
|
23
|
+
user_vmin: float | None,
|
|
24
|
+
user_vmax: float | None,
|
|
25
|
+
lock_scale: bool,
|
|
26
|
+
symmetric: bool = False,
|
|
27
|
+
max_samples: int = 64,
|
|
28
|
+
anchor_iteration: int | None = None,
|
|
29
|
+
) -> tuple[float | None, float | None]:
|
|
30
|
+
"""Colour limits for playback/GIF; optionally fixed across frames."""
|
|
31
|
+
if not lock_scale:
|
|
32
|
+
return user_vmin, user_vmax
|
|
33
|
+
|
|
34
|
+
if user_vmin is not None and user_vmax is not None:
|
|
35
|
+
return user_vmin, user_vmax
|
|
36
|
+
if symmetric and user_vmax is not None:
|
|
37
|
+
return -user_vmax, user_vmax
|
|
38
|
+
|
|
39
|
+
if anchor_iteration is not None:
|
|
40
|
+
field2d = io.read_level_slice(data_dir, prefix, anchor_iteration, level)
|
|
41
|
+
return plotting.resolved_clim(
|
|
42
|
+
field2d, user_vmin, user_vmax, symmetric=symmetric,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
sample_iters = _subsample_iterations(iterations, max_samples)
|
|
46
|
+
|
|
47
|
+
if symmetric:
|
|
48
|
+
peak = 0.0
|
|
49
|
+
for itr in sample_iters:
|
|
50
|
+
sl = io.read_level_slice(data_dir, prefix, itr, level)
|
|
51
|
+
peak = max(peak, float(np.nanmax(np.abs(sl))))
|
|
52
|
+
limit = peak or 1.0
|
|
53
|
+
return -limit, limit
|
|
54
|
+
|
|
55
|
+
vmin, vmax = np.inf, -np.inf
|
|
56
|
+
for itr in sample_iters:
|
|
57
|
+
sl = io.read_level_slice(data_dir, prefix, itr, level)
|
|
58
|
+
finite = sl[np.isfinite(sl)]
|
|
59
|
+
if finite.size:
|
|
60
|
+
vmin = min(vmin, float(np.min(finite)))
|
|
61
|
+
vmax = max(vmax, float(np.max(finite)))
|
|
62
|
+
if not np.isfinite(vmin):
|
|
63
|
+
return user_vmin, user_vmax
|
|
64
|
+
return vmin, vmax
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _subsample_iterations(iterations: list[int], max_samples: int) -> list[int]:
|
|
68
|
+
if max_samples <= 0 or len(iterations) <= max_samples:
|
|
69
|
+
return list(iterations)
|
|
70
|
+
idx = np.linspace(0, len(iterations) - 1, max_samples, dtype=int)
|
|
71
|
+
return [iterations[int(i)] for i in idx]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def save_playback_gif(
|
|
75
|
+
data_dir: str,
|
|
76
|
+
prefix: str,
|
|
77
|
+
iterations: list[int],
|
|
78
|
+
level: int,
|
|
79
|
+
output_path: str,
|
|
80
|
+
*,
|
|
81
|
+
cmap: str,
|
|
82
|
+
vmin: float | None,
|
|
83
|
+
vmax: float | None,
|
|
84
|
+
lock_scale: bool = True,
|
|
85
|
+
fps: float = 2.0,
|
|
86
|
+
coord_mode: str = "centers",
|
|
87
|
+
overlay_grid: bool = False,
|
|
88
|
+
progress: Callable[[int, int], None] | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Render a timestep loop to an animated GIF (one frame at a time on disk)."""
|
|
91
|
+
if not iterations:
|
|
92
|
+
raise ValueError("No iterations to animate")
|
|
93
|
+
|
|
94
|
+
clim_vmin, clim_vmax = resolve_playback_clim(
|
|
95
|
+
data_dir,
|
|
96
|
+
prefix,
|
|
97
|
+
iterations,
|
|
98
|
+
level,
|
|
99
|
+
user_vmin=vmin,
|
|
100
|
+
user_vmax=vmax,
|
|
101
|
+
lock_scale=lock_scale,
|
|
102
|
+
symmetric=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
duration_ms = max(int(1000 / max(fps, 0.1)), 50)
|
|
106
|
+
total = len(iterations)
|
|
107
|
+
field_shape = io.field_info(data_dir, prefix).shape
|
|
108
|
+
|
|
109
|
+
with tempfile.TemporaryDirectory(prefix="mdsview_gif_") as tmpdir:
|
|
110
|
+
frame_paths: list[str] = []
|
|
111
|
+
fig, ax = plt.subplots(figsize=(7, 5))
|
|
112
|
+
try:
|
|
113
|
+
for i, iteration in enumerate(iterations):
|
|
114
|
+
field2d = io.read_level_slice(data_dir, prefix, iteration, level)
|
|
115
|
+
title = plotting.format_field_title(
|
|
116
|
+
prefix, iteration, level=level, shape=field_shape,
|
|
117
|
+
)
|
|
118
|
+
plotting.draw_slice_on_ax(
|
|
119
|
+
ax,
|
|
120
|
+
field2d,
|
|
121
|
+
data_dir,
|
|
122
|
+
title=title,
|
|
123
|
+
cmap=cmap,
|
|
124
|
+
vmin=clim_vmin,
|
|
125
|
+
vmax=clim_vmax,
|
|
126
|
+
clear=True,
|
|
127
|
+
colorbar_label=prefix,
|
|
128
|
+
coord_mode=coord_mode,
|
|
129
|
+
overlay_grid=overlay_grid,
|
|
130
|
+
)
|
|
131
|
+
path = os.path.join(tmpdir, f"frame_{i:06d}.png")
|
|
132
|
+
fig.savefig(path, dpi=120, bbox_inches="tight")
|
|
133
|
+
frame_paths.append(path)
|
|
134
|
+
if progress:
|
|
135
|
+
progress(i + 1, total)
|
|
136
|
+
finally:
|
|
137
|
+
plt.close(fig)
|
|
138
|
+
|
|
139
|
+
first = Image.open(frame_paths[0]).convert("P", palette=Image.ADAPTIVE, colors=256)
|
|
140
|
+
rest = [
|
|
141
|
+
Image.open(p).convert("P", palette=first.palette)
|
|
142
|
+
for p in frame_paths[1:]
|
|
143
|
+
]
|
|
144
|
+
first.save(
|
|
145
|
+
output_path,
|
|
146
|
+
save_all=True,
|
|
147
|
+
append_images=rest,
|
|
148
|
+
duration=duration_ms,
|
|
149
|
+
loop=0,
|
|
150
|
+
disposal=2,
|
|
151
|
+
)
|
|
152
|
+
for im in rest:
|
|
153
|
+
im.close()
|
|
154
|
+
first.close()
|
mdsview/assets/logo.png
ADDED
|
Binary file
|
mdsview/catalog.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Single-pass indexing of MITgcm run directories (.meta filenames only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from .meta import MetaSummary, read_meta_summary
|
|
12
|
+
|
|
13
|
+
_ITER_META_RE = re.compile(r"^(.+)\.(\d{10})(?:\.\d{3}\.\d{3})?$")
|
|
14
|
+
|
|
15
|
+
_catalog_cache: dict[str, RunCatalog] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RunCatalog:
|
|
20
|
+
"""Prefix list, iteration numbers, and lazy shape metadata for one run folder."""
|
|
21
|
+
|
|
22
|
+
data_dir: str
|
|
23
|
+
prefixes: list[str]
|
|
24
|
+
iterations: dict[str, list[int]]
|
|
25
|
+
_sample_meta: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
_shapes: dict[str, tuple[int, ...]] = field(default_factory=dict)
|
|
27
|
+
_meta_summaries: dict[str, MetaSummary] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def scan(cls, data_dir: str) -> RunCatalog:
|
|
31
|
+
data_dir = os.path.abspath(data_dir)
|
|
32
|
+
iters_map: dict[str, list[int]] = defaultdict(list)
|
|
33
|
+
best_iter: dict[str, int | None] = {}
|
|
34
|
+
sample_meta: dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
for meta_path in glob.glob(os.path.join(data_dir, "*.meta")):
|
|
37
|
+
base = os.path.basename(meta_path)[:-5]
|
|
38
|
+
match = _ITER_META_RE.match(base)
|
|
39
|
+
if match:
|
|
40
|
+
prefix, itr_str = match.group(1), match.group(2)
|
|
41
|
+
itr = int(itr_str)
|
|
42
|
+
iters_map[prefix].append(itr)
|
|
43
|
+
prev = best_iter.get(prefix)
|
|
44
|
+
if prev is None or itr >= prev:
|
|
45
|
+
best_iter[prefix] = itr
|
|
46
|
+
sample_meta[prefix] = meta_path
|
|
47
|
+
else:
|
|
48
|
+
iters_map[base]
|
|
49
|
+
sample_meta[base] = meta_path
|
|
50
|
+
best_iter.setdefault(base, None)
|
|
51
|
+
|
|
52
|
+
for prefix, itr_list in iters_map.items():
|
|
53
|
+
if itr_list:
|
|
54
|
+
itr_list.sort()
|
|
55
|
+
|
|
56
|
+
prefixes = sorted(iters_map)
|
|
57
|
+
return cls(
|
|
58
|
+
data_dir=data_dir,
|
|
59
|
+
prefixes=prefixes,
|
|
60
|
+
iterations=dict(iters_map),
|
|
61
|
+
_sample_meta=sample_meta,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def iters_for(self, prefix: str) -> list[int]:
|
|
65
|
+
return self.iterations.get(prefix, [])
|
|
66
|
+
|
|
67
|
+
def iter_count(self, prefix: str) -> int:
|
|
68
|
+
return len(self.iterations.get(prefix, []))
|
|
69
|
+
|
|
70
|
+
def meta_summary(self, prefix: str) -> MetaSummary:
|
|
71
|
+
if prefix not in self._meta_summaries:
|
|
72
|
+
itr_list = self.iterations.get(prefix, [])
|
|
73
|
+
sample_iter = itr_list[-1] if itr_list else None
|
|
74
|
+
self._meta_summaries[prefix] = read_meta_summary(
|
|
75
|
+
self.data_dir, prefix, sample_iter
|
|
76
|
+
)
|
|
77
|
+
return self._meta_summaries[prefix]
|
|
78
|
+
|
|
79
|
+
def shape(self, prefix: str) -> tuple[int, ...]:
|
|
80
|
+
if prefix not in self._shapes:
|
|
81
|
+
self._shapes[prefix] = self.meta_summary(prefix).shape
|
|
82
|
+
return self._shapes[prefix]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_catalog(data_dir: str, *, refresh: bool = False) -> RunCatalog:
|
|
86
|
+
data_dir = os.path.abspath(data_dir)
|
|
87
|
+
if refresh:
|
|
88
|
+
_catalog_cache.pop(data_dir, None)
|
|
89
|
+
if data_dir not in _catalog_cache:
|
|
90
|
+
_catalog_cache[data_dir] = RunCatalog.scan(data_dir)
|
|
91
|
+
return _catalog_cache[data_dir]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def invalidate_catalog(data_dir: str | None = None) -> None:
|
|
95
|
+
if data_dir is None:
|
|
96
|
+
_catalog_cache.clear()
|
|
97
|
+
else:
|
|
98
|
+
_catalog_cache.pop(os.path.abspath(data_dir), None)
|