lightspeed-series 0.0.1__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.
lightspeed/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Fast, zoomable plots of very large series.
2
+
3
+ A series is reduced once into min/max level-of-detail pyramids, cached in
4
+ an HDF5 file, and queried at the resolution the view needs.
5
+ """
6
+
7
+ from .arraysource import ArraySource, HdfArraySource, SourceIdentity
8
+ from .cache import CacheHandle, SourceSpec, build_or_get_cache, close_cache, open_existing_cache
9
+ from .cachedir import resolve_cache_dir
10
+ from .constants import DEFAULT_CHUNK_SIZE
11
+ from .exceptions import (
12
+ CacheCorruptError,
13
+ InvalidSourceSpecError,
14
+ NonMonotonicXError,
15
+ LightspeedError,
16
+ UnsupportedQueryError,
17
+ )
18
+ from .mpl import ZoomSync, plot_envelope, update_envelope_artists
19
+ from .query import QueryResult, full_x_range, query_range
20
+
21
+ __all__ = [
22
+ "ArraySource",
23
+ "CacheCorruptError",
24
+ "CacheHandle",
25
+ "DEFAULT_CHUNK_SIZE",
26
+ "HdfArraySource",
27
+ "InvalidSourceSpecError",
28
+ "NonMonotonicXError",
29
+ "LightspeedError",
30
+ "QueryResult",
31
+ "SourceIdentity",
32
+ "SourceSpec",
33
+ "UnsupportedQueryError",
34
+ "ZoomSync",
35
+ "build_or_get_cache",
36
+ "close_cache",
37
+ "full_x_range",
38
+ "open_existing_cache",
39
+ "plot_envelope",
40
+ "query_range",
41
+ "resolve_cache_dir",
42
+ "update_envelope_artists",
43
+ ]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Iterator, Protocol, runtime_checkable
6
+
7
+ import h5py
8
+ import numpy as np
9
+
10
+
11
+ @runtime_checkable
12
+ class ArraySource(Protocol):
13
+ """Disk-backed, random-seek 1-D numeric array data."""
14
+
15
+ @property
16
+ def length(self) -> int: ...
17
+
18
+ @property
19
+ def dtype(self) -> np.dtype: ...
20
+
21
+ def read_range(self, start: int, end: int) -> np.ndarray: ...
22
+
23
+ def iter_blocks(self, block_size: int) -> Iterator[np.ndarray]:
24
+ """Sequential streaming pass over the whole array, in order."""
25
+ ...
26
+
27
+ def close(self) -> None: ...
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SourceIdentity:
32
+ """Enough information to detect whether an on-disk array has changed."""
33
+
34
+ path: Path
35
+ mtime_ns: int
36
+ size: int
37
+
38
+
39
+ class HdfArraySource:
40
+ """Concrete ArraySource backed by one h5py Dataset in one HDF5 file.
41
+
42
+ A 1-D dataset as it stands, or one column of a 2-D (rows x columns)
43
+ dataset. Reading a column pulls each chunk the rows span in full and
44
+ keeps one column of it, so a first pass over a wide dataset costs its
45
+ whole width in I/O; the cache built from it is per column, and later
46
+ passes read from that.
47
+ """
48
+
49
+ def __init__(self, hdf5_path: Path, dataset_path: str, column: int | None = None) -> None:
50
+ self.hdf5_path = Path(hdf5_path)
51
+ self.dataset_path = dataset_path
52
+ self.column = column
53
+ self._file = h5py.File(self.hdf5_path, "r")
54
+ self._dataset = self._file[dataset_path]
55
+ if column is None:
56
+ if self._dataset.ndim != 1:
57
+ raise ValueError(f"{self.hdf5_path}:{dataset_path} is {self._dataset.ndim}-D; a column must be named")
58
+ else:
59
+ if self._dataset.ndim != 2:
60
+ raise ValueError(f"{self.hdf5_path}:{dataset_path} is {self._dataset.ndim}-D; column applies to a 2-D dataset")
61
+ if not 0 <= column < self._dataset.shape[1]:
62
+ raise ValueError(
63
+ f"{self.hdf5_path}:{dataset_path} has {self._dataset.shape[1]} columns; no column {column}"
64
+ )
65
+
66
+ @property
67
+ def length(self) -> int:
68
+ return int(self._dataset.shape[0])
69
+
70
+ @property
71
+ def dtype(self) -> np.dtype:
72
+ return self._dataset.dtype
73
+
74
+ def read_range(self, start: int, end: int) -> np.ndarray:
75
+ if self.column is None:
76
+ return np.asarray(self._dataset[start:end])
77
+ return np.asarray(self._dataset[start:end, self.column])
78
+
79
+ def iter_blocks(self, block_size: int) -> Iterator[np.ndarray]:
80
+ length = self.length
81
+ for offset in range(0, length, block_size):
82
+ yield self.read_range(offset, min(offset + block_size, length))
83
+
84
+ def identity(self) -> SourceIdentity:
85
+ resolved = self.hdf5_path.resolve()
86
+ stat = resolved.stat()
87
+ return SourceIdentity(path=resolved, mtime_ns=stat.st_mtime_ns, size=stat.st_size)
88
+
89
+ def close(self) -> None:
90
+ self._file.close()
91
+
92
+ def __enter__(self) -> "HdfArraySource":
93
+ return self
94
+
95
+ def __exit__(self, *exc_info: object) -> None:
96
+ self.close()
lightspeed/cache.py ADDED
@@ -0,0 +1,531 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import hashlib
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ import h5py
12
+ import numpy as np
13
+
14
+ from .arraysource import ArraySource, HdfArraySource
15
+ from .cachedir import cache_lock, resolve_cache_dir
16
+ from .constants import (
17
+ DEFAULT_CHUNK_SIZE,
18
+ HASH_STREAM_BLOCK,
19
+ MIN_LEVEL_BUCKETS,
20
+ SCHEMA_VERSION,
21
+ aligned_stream_block,
22
+ is_power_of_two,
23
+ )
24
+ from .exceptions import CacheCorruptError, InvalidSourceSpecError, NonMonotonicXError
25
+ from .hashing import StreamingHasher
26
+ from .memo import MemoEntry, lookup_memo, update_memo
27
+ from .pyramid import LevelArrays, LevelZeroBuilder, cascade_levels
28
+ from .status import MASK_DTYPE, StatusMap, kind_bits, masked_values, sample_kinds
29
+ from .timing import TimingProbeLike
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SourceSpec:
34
+ """Describes where a raw series lives and how to interpret its x-axis.
35
+
36
+ Every field is stated: a series is either on a uniform grid or has its x
37
+ in a dataset of its own, and `x0`/`dx` map what the file holds onto the
38
+ axis it is drawn on: `x = x0 + i * dx` for a uniform grid (i the sample
39
+ index), `x = x0 + coord * dx` for an irregular one (coord the value read
40
+ from `x_dataset`). The cache only ever stores what the file holds, so the
41
+ mapping can change - a time axis shown in days rather than in the file's
42
+ microseconds - without the cache being rebuilt. `SourceSpec.uniform` and
43
+ `SourceSpec.irregular` say which kind without spelling out the other's
44
+ fields.
45
+ """
46
+
47
+ hdf5_path: Path
48
+ y_dataset: str
49
+ x_dataset: str | None
50
+ irregular_x: bool
51
+ x0: float
52
+ dx: float
53
+ # Column of a 2-D dataset for y and x respectively; None for a 1-D one.
54
+ y_column: int | None = None
55
+ x_column: int | None = None
56
+ # A status code per sample, and what each code means (see status.py);
57
+ # None for a series whose only missing samples are its NaNs.
58
+ status_dataset: str | None = None
59
+ status_column: int | None = None
60
+ status_map: StatusMap | None = None
61
+
62
+ def __post_init__(self) -> None:
63
+ if (self.status_dataset is None) != (self.status_map is None):
64
+ raise InvalidSourceSpecError("a status dataset and a status map come together")
65
+ if self.irregular_x and self.x_dataset is None:
66
+ raise InvalidSourceSpecError("irregular_x=True requires an x_dataset")
67
+ if not self.irregular_x and self.x_dataset is not None:
68
+ raise InvalidSourceSpecError("x_dataset is only used when irregular_x=True")
69
+ if not self.dx > 0:
70
+ raise InvalidSourceSpecError(f"dx must be positive, got {self.dx}")
71
+
72
+ @classmethod
73
+ def uniform(
74
+ cls, hdf5_path: str | Path, y_dataset: str, *, x0: float, dx: float, y_column: int | None = None
75
+ ) -> "SourceSpec":
76
+ """A series sampled at `x0 + i * dx`."""
77
+ return cls(
78
+ hdf5_path=Path(hdf5_path), y_dataset=y_dataset, x_dataset=None, irregular_x=False, x0=x0, dx=dx,
79
+ y_column=y_column,
80
+ )
81
+
82
+ @classmethod
83
+ def irregular(
84
+ cls,
85
+ hdf5_path: str | Path,
86
+ y_dataset: str,
87
+ x_dataset: str,
88
+ *,
89
+ x0: float = 0.0,
90
+ dx: float = 1.0,
91
+ y_column: int | None = None,
92
+ x_column: int | None = None,
93
+ ) -> "SourceSpec":
94
+ """A series whose x values are read from `x_dataset`, which must be sorted.
95
+
96
+ `x0` and `dx` place those values on the axis: `x = x0 + coord * dx`.
97
+ """
98
+ return cls(
99
+ hdf5_path=Path(hdf5_path), y_dataset=y_dataset, x_dataset=x_dataset, irregular_x=True, x0=x0, dx=dx,
100
+ y_column=y_column, x_column=x_column,
101
+ )
102
+
103
+ def with_status(self, status_dataset: str, status_map: StatusMap, *, column: int | None = None) -> "SourceSpec":
104
+ """This series, with `status_dataset` (or its `column`) saying what each sample is."""
105
+ return replace(self, status_dataset=status_dataset, status_column=column, status_map=status_map)
106
+
107
+ @property
108
+ def y_selector(self) -> str:
109
+ """The y array as one string, for messages: `/values[3]` or `/y`."""
110
+ return _selector(self.y_dataset, self.y_column)
111
+
112
+ @property
113
+ def cache_selector(self) -> str:
114
+ """What the cache is of: the y array, and its status array and the meaning of its codes, if any."""
115
+ if self.status_dataset is None or self.status_map is None:
116
+ return self.y_selector
117
+ return f"{self.y_selector}|status={_selector(self.status_dataset, self.status_column)}@{self.status_map.fingerprint()}"
118
+
119
+ def open_status(self) -> ArraySource | None:
120
+ if self.status_dataset is None:
121
+ return None
122
+ return HdfArraySource(self.hdf5_path, self.status_dataset, self.status_column)
123
+
124
+ def bits_and_values(self, y_block: np.ndarray, status_block: np.ndarray | None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
125
+ """(per-sample kinds, missing bits, values NaN wherever not VALID) for a block of samples."""
126
+ kinds = sample_kinds(y_block, status_block, self.status_map)
127
+ return kinds, kind_bits(kinds), masked_values(y_block, kinds)
128
+
129
+ def x_of_coord(self, coord: np.ndarray | float) -> np.ndarray | float:
130
+ """Axis position of a raw coordinate (irregular) or sample index (uniform)."""
131
+ return self.x0 + coord * self.dx
132
+
133
+ def coord_of_x(self, x: float) -> float:
134
+ """Inverse of `x_of_coord`: what the file's x holds at axis position `x`."""
135
+ return (x - self.x0) / self.dx
136
+
137
+ def open_y(self) -> ArraySource:
138
+ return HdfArraySource(self.hdf5_path, self.y_dataset, self.y_column)
139
+
140
+ def open_x(self) -> ArraySource | None:
141
+ if not self.irregular_x:
142
+ return None
143
+ return HdfArraySource(self.hdf5_path, self.x_dataset, self.x_column)
144
+
145
+
146
+ def _selector(dataset: str, column: int | None) -> str:
147
+ return dataset if column is None else f"{dataset}[{column}]"
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class CacheHandle:
152
+ """An open cache file plus enough metadata to answer queries without re-opening."""
153
+
154
+ path: Path
155
+ content_hash: str
156
+ source_length: int
157
+ chunk_size: int
158
+ irregular_x: bool
159
+ n_levels: int
160
+ dtype: np.dtype
161
+ x_dtype: np.dtype | None
162
+ _h5: h5py.File = field(repr=False, compare=False)
163
+ # Per-handle memo of small arrays read from the cache file (see
164
+ # query.level0_x_bounds); the handle is frozen but the dict is not.
165
+ _memo: dict = field(default_factory=dict, repr=False, compare=False)
166
+
167
+ def level_group(self, level: int) -> h5py.Group:
168
+ return self._h5[f"/levels/{level}"]
169
+
170
+
171
+ def build_or_get_cache(
172
+ spec: SourceSpec,
173
+ *,
174
+ cache_dir: Path | None = None,
175
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
176
+ force: bool = False,
177
+ stream_block: int = HASH_STREAM_BLOCK,
178
+ timing: TimingProbeLike | None = None,
179
+ progress: Callable[[int, int], None] | None = None,
180
+ ) -> CacheHandle:
181
+ """Opens the cache for `spec`, building it first if there is none.
182
+
183
+ A build streams the whole source once; `progress(samples_done, source_length)`
184
+ is called after each block and once more when the cache is written, so a
185
+ caller can show how far along a long build is. A cache hit reports nothing.
186
+
187
+ The cache directory is shared between processes - two windows on one
188
+ package, say - so a build takes a lock named for the series. A second
189
+ process wanting the same cache waits for the first, reporting the wait as
190
+ a build at zero, and then finds the cache in the memo rather than making
191
+ it again.
192
+ """
193
+ if not is_power_of_two(chunk_size):
194
+ raise InvalidSourceSpecError(f"chunk_size must be a power of two, got {chunk_size}")
195
+
196
+ resolved_cache_dir = resolve_cache_dir(cache_dir)
197
+ source_path = spec.hdf5_path.resolve()
198
+ stat = source_path.stat()
199
+ mtime_ns = stat.st_mtime_ns
200
+ size = stat.st_size
201
+
202
+ def memoised() -> CacheHandle | None:
203
+ if force:
204
+ return None
205
+ return _open_memoised_cache(resolved_cache_dir, source_path, spec, mtime_ns, size, chunk_size, timing)
206
+
207
+ handle = memoised()
208
+ if handle is not None:
209
+ return handle
210
+
211
+ lock = cache_lock(resolved_cache_dir, _build_lock_name(source_path, spec))
212
+ waited = _acquire_build_lock(lock, spec, progress)
213
+ try:
214
+ handle = memoised()
215
+ if handle is not None:
216
+ if waited and progress is not None:
217
+ progress(handle.source_length, handle.source_length)
218
+ return handle
219
+ return _build_cache(
220
+ spec,
221
+ resolved_cache_dir,
222
+ source_path,
223
+ mtime_ns=mtime_ns,
224
+ size=size,
225
+ chunk_size=chunk_size,
226
+ stream_block=stream_block,
227
+ timing=timing,
228
+ progress=progress,
229
+ )
230
+ finally:
231
+ lock.release()
232
+
233
+
234
+ def _build_lock_name(source_path: Path, spec: SourceSpec) -> str:
235
+ key = f"{source_path}::{spec.cache_selector}".encode("utf-8", "surrogateescape")
236
+ return "build-" + hashlib.sha256(key).hexdigest()[:16]
237
+
238
+
239
+ def _acquire_build_lock(lock: Any, spec: SourceSpec, progress: Callable[[int, int], None] | None) -> bool:
240
+ """Take the build lock; returns whether another process was holding it.
241
+
242
+ A wait is reported as a build at zero, so the caller's indicator shows
243
+ the series being prepared - by whoever is preparing it.
244
+ """
245
+ from filelock import Timeout
246
+
247
+ try:
248
+ lock.acquire(timeout=0)
249
+ return False
250
+ except Timeout:
251
+ pass
252
+ if progress is not None:
253
+ y_array = spec.open_y()
254
+ try:
255
+ progress(0, y_array.length)
256
+ finally:
257
+ y_array.close()
258
+ lock.acquire()
259
+ return True
260
+
261
+
262
+ def _open_memoised_cache(
263
+ cache_dir: Path,
264
+ source_path: Path,
265
+ spec: SourceSpec,
266
+ mtime_ns: int,
267
+ size: int,
268
+ chunk_size: int,
269
+ timing: TimingProbeLike | None,
270
+ ) -> CacheHandle | None:
271
+ """The cache the memo records for this source as it is now, if it is there and sound."""
272
+ entry = lookup_memo(cache_dir, source_path, spec.cache_selector, mtime_ns, size, chunk_size)
273
+ if entry is None:
274
+ return None
275
+ dest = cache_dir / f"{entry.content_hash}.h5"
276
+ if not dest.exists():
277
+ return None
278
+ hit_start = timing.start() if timing is not None else None
279
+ try:
280
+ handle = open_existing_cache(cache_dir, entry.content_hash)
281
+ except CacheCorruptError:
282
+ return None
283
+ if timing is not None and hit_start is not None:
284
+ timing.log(
285
+ "lightspeed.cache.hit",
286
+ hit_start,
287
+ (
288
+ ("source_length", handle.source_length),
289
+ ("chunk_size", handle.chunk_size),
290
+ ("n_levels", handle.n_levels),
291
+ ("cache_bytes", dest.stat().st_size),
292
+ ("content_hash", handle.content_hash),
293
+ ),
294
+ )
295
+ return handle
296
+
297
+
298
+ def _build_cache(
299
+ spec: SourceSpec,
300
+ resolved_cache_dir: Path,
301
+ source_path: Path,
302
+ *,
303
+ mtime_ns: int,
304
+ size: int,
305
+ chunk_size: int,
306
+ stream_block: int,
307
+ timing: TimingProbeLike | None,
308
+ progress: Callable[[int, int], None] | None,
309
+ ) -> CacheHandle:
310
+ """Stream the source, write its pyramid to the cache and record it in the memo."""
311
+ start = timing.start() if timing is not None else None
312
+
313
+ block = aligned_stream_block(stream_block, chunk_size)
314
+ hasher = StreamingHasher()
315
+ builder = LevelZeroBuilder(chunk_size, spec.irregular_x)
316
+
317
+ y_array = spec.open_y()
318
+ x_array = spec.open_x()
319
+ status_array = spec.open_status()
320
+ try:
321
+ source_length = y_array.length
322
+ dtype = y_array.dtype
323
+ x_dtype = x_array.dtype if x_array is not None else None
324
+ if status_array is not None:
325
+ if status_array.length != source_length:
326
+ raise InvalidSourceSpecError(
327
+ f"{spec.hdf5_path}: status {spec.status_dataset!r} has {status_array.length} samples, "
328
+ f"{spec.y_selector} has {source_length}"
329
+ )
330
+ if status_array.dtype.kind not in "iu":
331
+ raise InvalidSourceSpecError(
332
+ f"{spec.hdf5_path}: status {spec.status_dataset!r} is {status_array.dtype}, not an integer type"
333
+ )
334
+ # The same data read with codes meaning something else is another cache.
335
+ hasher.update(np.frombuffer(spec.status_map.fingerprint().encode("ascii"), dtype=np.uint8))
336
+
337
+ x_blocks = x_array.iter_blocks(block) if x_array is not None else None
338
+ status_blocks = status_array.iter_blocks(block) if status_array is not None else None
339
+ samples_done = 0
340
+ # Every range query searches the x bounds, so x has to be sorted;
341
+ # the one pass over it that the build makes is where to be sure.
342
+ previous_last: float | None = None
343
+ for y_block in y_array.iter_blocks(block):
344
+ x_block = next(x_blocks) if x_blocks is not None else None
345
+ status_block = next(status_blocks) if status_blocks is not None else None
346
+ hasher.update(y_block)
347
+ if x_block is not None:
348
+ _require_non_decreasing(x_block, previous_last, samples_done, spec)
349
+ if x_block.shape[0]:
350
+ previous_last = float(x_block[-1])
351
+ hasher.update(x_block)
352
+ if status_block is not None:
353
+ hasher.update(status_block)
354
+ _, bits, values = spec.bits_and_values(y_block, status_block)
355
+ builder.feed(values, x_block, bits)
356
+ else:
357
+ builder.feed(y_block, x_block)
358
+ samples_done += y_block.shape[0]
359
+ if progress is not None:
360
+ progress(samples_done, source_length)
361
+ finally:
362
+ y_array.close()
363
+ if x_array is not None:
364
+ x_array.close()
365
+ if status_array is not None:
366
+ status_array.close()
367
+
368
+ level0 = builder.finish()
369
+ content_hash = hasher.hexdigest()
370
+
371
+ levels = cascade_levels(level0, MIN_LEVEL_BUCKETS)
372
+
373
+ dest_path = resolved_cache_dir / f"{content_hash}.h5"
374
+ _write_cache_file(
375
+ dest_path,
376
+ spec=spec,
377
+ chunk_size=chunk_size,
378
+ content_hash=content_hash,
379
+ source_length=source_length,
380
+ levels=levels,
381
+ dtype=dtype,
382
+ x_dtype=x_dtype,
383
+ )
384
+
385
+ update_memo(
386
+ resolved_cache_dir,
387
+ source_path,
388
+ spec.cache_selector,
389
+ MemoEntry(
390
+ mtime_ns=mtime_ns,
391
+ size=size,
392
+ chunk_size=chunk_size,
393
+ content_hash=content_hash,
394
+ irregular_x=spec.irregular_x,
395
+ ),
396
+ )
397
+
398
+ if progress is not None:
399
+ progress(source_length, source_length)
400
+
401
+ if timing is not None and start is not None:
402
+ timing.log(
403
+ "lightspeed.cache.build",
404
+ start,
405
+ (
406
+ ("source_length", source_length),
407
+ ("chunk_size", chunk_size),
408
+ ("n_levels", len(levels)),
409
+ ("cache_bytes", dest_path.stat().st_size),
410
+ ("content_hash", content_hash),
411
+ ),
412
+ )
413
+
414
+ return open_existing_cache(resolved_cache_dir, content_hash)
415
+
416
+
417
+ def _require_non_decreasing(x_block: np.ndarray, previous_last: float | None, offset: int, spec: SourceSpec) -> None:
418
+ """Raise NonMonotonicXError at the first sample that steps backwards."""
419
+ if x_block.shape[0] == 0:
420
+ return
421
+ if previous_last is not None and x_block[0] < previous_last:
422
+ bad = offset
423
+ else:
424
+ steps = np.flatnonzero(x_block[1:] < x_block[:-1])
425
+ if steps.size == 0:
426
+ return
427
+ bad = offset + int(steps[0]) + 1
428
+ raise NonMonotonicXError(
429
+ f"{spec.hdf5_path}:{spec.x_dataset} is not sorted: sample {bad} is less than the one before it; "
430
+ f"a large-series x array must be non-decreasing"
431
+ )
432
+
433
+
434
+ def open_existing_cache(cache_dir: Path, content_hash: str) -> CacheHandle:
435
+ path = Path(cache_dir) / f"{content_hash}.h5"
436
+ if not path.is_file():
437
+ raise FileNotFoundError(path)
438
+
439
+ handle_file = h5py.File(path, "r")
440
+ try:
441
+ attrs = handle_file.attrs
442
+ if attrs.get("content_hash") != content_hash:
443
+ raise CacheCorruptError(f"{path} content_hash attribute does not match filename")
444
+ if int(attrs.get("schema_version", -1)) != SCHEMA_VERSION:
445
+ raise CacheCorruptError(f"{path} has an unsupported schema_version")
446
+
447
+ irregular_x = bool(attrs["irregular_x"])
448
+ n_levels = int(attrs["n_levels"])
449
+ for level in range(n_levels):
450
+ if f"/levels/{level}/y_min" not in handle_file or f"/levels/{level}/y_max" not in handle_file:
451
+ raise CacheCorruptError(f"{path} is missing level {level} data")
452
+ if irregular_x and (
453
+ f"/levels/{level}/x_min" not in handle_file or f"/levels/{level}/x_max" not in handle_file
454
+ ):
455
+ raise CacheCorruptError(f"{path} is missing level {level} x data")
456
+ if f"/levels/{level}/missing" not in handle_file:
457
+ raise CacheCorruptError(f"{path} is missing level {level} missing-sample data")
458
+
459
+ return CacheHandle(
460
+ path=path,
461
+ content_hash=content_hash,
462
+ source_length=int(attrs["source_length"]),
463
+ chunk_size=int(attrs["chunk_size"]),
464
+ irregular_x=irregular_x,
465
+ n_levels=n_levels,
466
+ dtype=np.dtype(attrs["dtype"]),
467
+ x_dtype=np.dtype(attrs["x_dtype"]) if irregular_x else None,
468
+ _h5=handle_file,
469
+ )
470
+ except Exception:
471
+ handle_file.close()
472
+ raise
473
+
474
+
475
+ def close_cache(handle: CacheHandle) -> None:
476
+ handle._h5.close()
477
+
478
+
479
+ def _write_cache_file(
480
+ dest_path: Path,
481
+ *,
482
+ spec: SourceSpec,
483
+ chunk_size: int,
484
+ content_hash: str,
485
+ source_length: int,
486
+ levels: list[LevelArrays],
487
+ dtype: np.dtype,
488
+ x_dtype: np.dtype | None,
489
+ ) -> None:
490
+ fd, tmp_name = tempfile.mkstemp(prefix="cache-", suffix=".h5.tmp", dir=dest_path.parent)
491
+ os.close(fd)
492
+ try:
493
+ with h5py.File(tmp_name, "w") as handle:
494
+ handle.attrs["schema_version"] = SCHEMA_VERSION
495
+ handle.attrs["content_hash"] = content_hash
496
+ handle.attrs["source_length"] = source_length
497
+ handle.attrs["chunk_size"] = chunk_size
498
+ handle.attrs["irregular_x"] = np.uint8(1 if spec.irregular_x else 0)
499
+ handle.attrs["dtype"] = str(dtype)
500
+ if spec.irregular_x and x_dtype is not None:
501
+ handle.attrs["x_dtype"] = str(x_dtype)
502
+ handle.attrs["n_levels"] = len(levels)
503
+ handle.attrs["source_path_hint"] = str(spec.hdf5_path)
504
+ handle.attrs["dataset_path_hint"] = spec.cache_selector
505
+ handle.attrs["built_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
506
+
507
+ for level_index, level in enumerate(levels):
508
+ group = handle.create_group(f"/levels/{level_index}")
509
+ group.create_dataset("y_min", data=level.y_min, compression="lzf")
510
+ group.create_dataset("y_max", data=level.y_max, compression="lzf")
511
+ missing = level.missing if level.missing is not None else np.zeros(len(level.y_min), dtype=MASK_DTYPE)
512
+ group.create_dataset("missing", data=missing.astype(MASK_DTYPE, copy=False), compression="lzf")
513
+ if spec.irregular_x:
514
+ group.create_dataset("x_min", data=level.x_min, compression="lzf")
515
+ group.create_dataset("x_max", data=level.x_max, compression="lzf")
516
+
517
+ try:
518
+ os.replace(tmp_name, dest_path)
519
+ except PermissionError:
520
+ # Windows refuses to replace a file another process has open.
521
+ # The file there is named by the same content hash, so it holds
522
+ # what was just built; the new copy is not needed.
523
+ if not dest_path.is_file():
524
+ raise
525
+ os.remove(tmp_name)
526
+ except BaseException:
527
+ try:
528
+ os.remove(tmp_name)
529
+ except OSError:
530
+ pass
531
+ raise