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 +43 -0
- lightspeed/arraysource.py +96 -0
- lightspeed/cache.py +531 -0
- lightspeed/cachedir.py +58 -0
- lightspeed/constants.py +26 -0
- lightspeed/exceptions.py +18 -0
- lightspeed/hashing.py +20 -0
- lightspeed/memo.py +109 -0
- lightspeed/mpl.py +447 -0
- lightspeed/py.typed +0 -0
- lightspeed/pyramid.py +225 -0
- lightspeed/query.py +260 -0
- lightspeed/status.py +120 -0
- lightspeed/timing.py +37 -0
- lightspeed_series-0.0.1.dist-info/METADATA +79 -0
- lightspeed_series-0.0.1.dist-info/RECORD +19 -0
- lightspeed_series-0.0.1.dist-info/WHEEL +5 -0
- lightspeed_series-0.0.1.dist-info/licenses/LICENSE +21 -0
- lightspeed_series-0.0.1.dist-info/top_level.txt +1 -0
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
|