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 ADDED
@@ -0,0 +1,3 @@
1
+ """mdsview: MITgcm MDS binary visualization and analysis."""
2
+
3
+ __version__ = "0.2.0"
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()
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)