ziptif 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ # pixi environments
2
+ .pixi
3
+ *.egg-info
4
+ *.log
5
+ outputs/
6
+ *.mypy_cache/
7
+ *.ruff_cache/
8
+ __pycache__/
9
+ .vscode/
10
+ *.*~
11
+ __marimo__/
12
+ *.out
13
+ *.png
14
+ .aider*
ziptif-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: ziptif
3
+ Version: 0.1.0
4
+ Summary: ZipTif file format for Satellite Image Time Series.
5
+ Author: Centre Borelli
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: fire>=0.7.1
9
+ Requires-Dist: polars>=1.33.1
10
+ Requires-Dist: tifffile>=2025.9.20
11
+ Requires-Dist: xarray>=2024.0.0
12
+ Requires-Dist: zstd>=1.5.7.2
13
+ Description-Content-Type: text/markdown
14
+
15
+ File format to store multimodal Satellite Image Time Series.
16
+
17
+ Reading a partial subset (site, time, space, spectral, modality) is optimized for training neural networks.
18
+ The format is composed of a zip containing tiffs (sorted for optimal reading speed), and a binary index file (`.ziptif`).
19
+
20
+ The reader supports lazy concatenation datasets along the 'site' and 'modality' axis, thus allowing arbitrarly large training dataset.
21
+
22
+ Tested with Sentinel-1 and Sentinel-2 for now. Functions to create a dataset make a few assumptions that are not yet documented here.
23
+
ziptif-0.1.0/README.md ADDED
@@ -0,0 +1,9 @@
1
+ File format to store multimodal Satellite Image Time Series.
2
+
3
+ Reading a partial subset (site, time, space, spectral, modality) is optimized for training neural networks.
4
+ The format is composed of a zip containing tiffs (sorted for optimal reading speed), and a binary index file (`.ziptif`).
5
+
6
+ The reader supports lazy concatenation datasets along the 'site' and 'modality' axis, thus allowing arbitrarly large training dataset.
7
+
8
+ Tested with Sentinel-1 and Sentinel-2 for now. Functions to create a dataset make a few assumptions that are not yet documented here.
9
+
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "ziptif"
3
+ license = "MIT"
4
+ readme = "README.md"
5
+ description = "ZipTif file format for Satellite Image Time Series."
6
+ requires-python = ">= 3.11"
7
+ version = "0.1.0"
8
+ dependencies = [
9
+ "tifffile>=2025.9.20"
10
+ , "zstd>=1.5.7.2"
11
+ , "polars>=1.33.1"
12
+ , "fire>=0.7.1"
13
+ , "xarray>=2024.0.0"]
14
+ authors = [
15
+ { name = "Centre Borelli" },
16
+ ]
17
+
18
+ [build-system]
19
+ build-backend = "hatchling.build"
20
+ requires = ["hatchling"]
21
+
22
+ [tool.hatch.build.targets.sdist]
23
+ only-include = ["src/ziptif"]
24
+
25
+ [tool.pixi.workspace]
26
+ channels = ["conda-forge"]
27
+ platforms = ["linux-64", "osx-arm64"]
28
+
29
+ [tool.pixi.pypi-dependencies]
30
+ ziptif = { path = ".", editable = true }
31
+
32
+ [tool.pixi.tasks]
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest"]
36
+
37
+ [tool.pixi.environments]
38
+ default = { features = [] }
39
+ dev = { features = ["dev"] }
@@ -0,0 +1,5 @@
1
+ __all__ = [
2
+ "ziptif",
3
+ "loader",
4
+ "xarray_backend",
5
+ ]
@@ -0,0 +1,50 @@
1
+ import abc
2
+ from dataclasses import dataclass
3
+ from typing import Protocol, override
4
+
5
+
6
+ class IBuffer(Protocol):
7
+ @abc.abstractmethod
8
+ def read_at(self, offset: int, length: int) -> bytes: ...
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class OffsetedBuffer(IBuffer):
13
+ buffer: bytes
14
+ offset: int
15
+
16
+ @override
17
+ def read_at(self, offset: int, length: int) -> bytes:
18
+ offset_in_buffer = offset - self.offset
19
+ return self.buffer[offset_in_buffer : offset_in_buffer + length]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class MultiOffsetedBuffer(IBuffer):
24
+ buffers: list[OffsetedBuffer]
25
+
26
+ def __post_init__(self):
27
+ # assert that buffers are sorted by offset
28
+ sorted_buffers = sorted(self.buffers, key=lambda b: b.offset)
29
+ if sorted_buffers != self.buffers:
30
+ raise ValueError("Buffers must be sorted by offset")
31
+
32
+ @override
33
+ def read_at(self, offset: int, length: int) -> bytes:
34
+ result = b""
35
+ remaining = length
36
+ current_offset = offset
37
+
38
+ for buf in self.buffers:
39
+ if buf.offset <= current_offset < buf.offset + len(buf.buffer):
40
+ to_read = min(
41
+ remaining, len(buf.buffer) - (current_offset - buf.offset)
42
+ )
43
+ result += buf.read_at(current_offset, to_read)
44
+ remaining -= to_read
45
+ current_offset += to_read
46
+
47
+ if remaining == 0:
48
+ return result
49
+
50
+ raise ValueError(f"Offset {offset} out of range for length {length}")
@@ -0,0 +1,295 @@
1
+ """
2
+ Buffer caching for ziptif format.
3
+
4
+ Implements LRU caching at the byte-range level to avoid redundant disk reads
5
+ when accessing overlapping or repeated data regions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ from ziptif.buffer import OffsetedBuffer
16
+
17
+ if TYPE_CHECKING:
18
+ from ziptif.fs import ZipsReader
19
+
20
+
21
+ @dataclass
22
+ class CachedRange:
23
+ """A cached byte range from a zip file."""
24
+
25
+ offset: int
26
+ buffer: bytes
27
+ last_access: float
28
+ size: int
29
+
30
+ def __post_init__(self):
31
+ assert self.size == len(self.buffer)
32
+
33
+ def contains(self, offset: int, length: int) -> bool:
34
+ """Check if this cached range fully contains the requested range."""
35
+ return self.offset <= offset and offset + length <= self.offset + self.size
36
+
37
+ def overlaps(self, offset: int, length: int) -> bool:
38
+ """Check if this cached range overlaps with the requested range."""
39
+ return not (offset + length <= self.offset or offset >= self.offset + self.size)
40
+
41
+ def get_overlap(self, offset: int, length: int) -> tuple[int, int] | None:
42
+ """
43
+ Get the overlapping portion of this cache with the requested range.
44
+ Returns (overlap_offset, overlap_length) or None if no overlap.
45
+ """
46
+ if not self.overlaps(offset, length):
47
+ return None
48
+
49
+ overlap_start = max(self.offset, offset)
50
+ overlap_end = min(self.offset + self.size, offset + length)
51
+ return overlap_start, overlap_end - overlap_start
52
+
53
+
54
+ class BufferCache:
55
+ """
56
+ LRU cache for byte ranges read from zip files.
57
+
58
+ The cache works at the (zip_id, offset, length) level. When a read is requested,
59
+ the cache determines which parts are already available and only reads missing
60
+ byte ranges from disk.
61
+ """
62
+
63
+ def __init__(self, max_size_bytes: int = 1_000_000_000):
64
+ """
65
+ Initialize buffer cache.
66
+
67
+ Parameters
68
+ ----------
69
+ max_size_bytes : int
70
+ Maximum cache size in bytes (default: 1GB)
71
+ """
72
+ self._cached_ranges: dict[str, list[CachedRange]] = {}
73
+ self._max_size = max_size_bytes
74
+ self._current_size = 0
75
+ self._lock = threading.Lock()
76
+
77
+ def _evict_lru(self) -> None:
78
+ """Evict least recently used cache entries until under size limit."""
79
+ while self._current_size > self._max_size:
80
+ # Find the LRU entry across all zip files
81
+ oldest_zip_id = None
82
+ oldest_idx = None
83
+ oldest_time = float("inf")
84
+
85
+ for zip_id, ranges in self._cached_ranges.items():
86
+ for idx, cached_range in enumerate(ranges):
87
+ if cached_range.last_access < oldest_time:
88
+ oldest_time = cached_range.last_access
89
+ oldest_zip_id = zip_id
90
+ oldest_idx = idx
91
+
92
+ if oldest_zip_id is None:
93
+ break
94
+
95
+ # Remove the oldest entry
96
+ removed = self._cached_ranges[oldest_zip_id].pop(oldest_idx)
97
+ self._current_size -= removed.size
98
+
99
+ # Clean up empty lists
100
+ if not self._cached_ranges[oldest_zip_id]:
101
+ del self._cached_ranges[oldest_zip_id]
102
+
103
+ def _add_to_cache(self, zip_id: str, offset: int, buffer: bytes) -> None:
104
+ """Add a new range to the cache."""
105
+ cached_range = CachedRange(
106
+ offset=offset, buffer=buffer, last_access=time.time(), size=len(buffer)
107
+ )
108
+
109
+ if zip_id not in self._cached_ranges:
110
+ self._cached_ranges[zip_id] = []
111
+
112
+ self._cached_ranges[zip_id].append(cached_range)
113
+ self._current_size += len(buffer)
114
+
115
+ # Evict if over limit
116
+ if self._current_size > self._max_size:
117
+ self._evict_lru()
118
+
119
+ def _get_cached_buffer(self, zip_id: str, offset: int, length: int) -> bytes | None:
120
+ """
121
+ Try to get a buffer from cache that fully contains the requested range.
122
+ Updates last_access time if found.
123
+ """
124
+ if zip_id not in self._cached_ranges:
125
+ return None
126
+
127
+ current_time = time.time()
128
+ for cached_range in self._cached_ranges[zip_id]:
129
+ if cached_range.contains(offset, length):
130
+ # Update access time
131
+ cached_range.last_access = current_time
132
+ # Extract the requested portion
133
+ start_in_cache = offset - cached_range.offset
134
+ return cached_range.buffer[start_in_cache : start_in_cache + length]
135
+
136
+ return None
137
+
138
+ def _compute_missing_ranges(
139
+ self, zip_id: str, requested_ranges: list[tuple[int, int]]
140
+ ) -> list[tuple[int, int]]:
141
+ """
142
+ Compute which byte ranges need to be read from disk.
143
+
144
+ For each requested range, subtract any overlapping cached ranges
145
+ to find the missing parts.
146
+ """
147
+ if zip_id not in self._cached_ranges:
148
+ return requested_ranges
149
+
150
+ missing_ranges: list[tuple[int, int]] = []
151
+
152
+ for req_offset, req_length in requested_ranges:
153
+ # Start with the full requested range
154
+ uncovered: list[tuple[int, int]] = [(req_offset, req_length)]
155
+
156
+ # Subtract each overlapping cached range
157
+ for cached_range in self._cached_ranges[zip_id]:
158
+ overlap = cached_range.get_overlap(req_offset, req_length)
159
+ if overlap is None:
160
+ continue
161
+
162
+ overlap_offset, overlap_length = overlap
163
+ overlap_end = overlap_offset + overlap_length
164
+
165
+ # Update last access time for this cached range
166
+ cached_range.last_access = time.time()
167
+
168
+ # Split uncovered ranges around the overlap
169
+ new_uncovered: list[tuple[int, int]] = []
170
+ for unc_offset, unc_length in uncovered:
171
+ unc_end = unc_offset + unc_length
172
+
173
+ # Part before overlap
174
+ if unc_offset < overlap_offset:
175
+ new_uncovered.append(
176
+ (unc_offset, min(overlap_offset, unc_end) - unc_offset)
177
+ )
178
+
179
+ # Part after overlap
180
+ if unc_end > overlap_end:
181
+ new_uncovered.append(
182
+ (
183
+ max(overlap_end, unc_offset),
184
+ unc_end - max(overlap_end, unc_offset),
185
+ )
186
+ )
187
+
188
+ uncovered = new_uncovered
189
+
190
+ missing_ranges.extend(uncovered)
191
+
192
+ return missing_ranges
193
+
194
+ def _assemble_from_cache(self, zip_id: str, offset: int, length: int) -> bytes:
195
+ """
196
+ Try to assemble the requested buffer from potentially multiple cached ranges.
197
+ """
198
+ result = bytearray(length)
199
+ current_time = time.time()
200
+
201
+ for cached_range in self._cached_ranges[zip_id]:
202
+ overlap = cached_range.get_overlap(offset, length)
203
+ if overlap is None:
204
+ continue
205
+
206
+ overlap_offset, overlap_length = overlap
207
+ # Update access time
208
+ cached_range.last_access = current_time
209
+
210
+ # Copy the overlapping portion
211
+ start_in_result = overlap_offset - offset
212
+ start_in_cache = overlap_offset - cached_range.offset
213
+ result[start_in_result : start_in_result + overlap_length] = (
214
+ cached_range.buffer[start_in_cache : start_in_cache + overlap_length]
215
+ )
216
+
217
+ return bytes(result)
218
+
219
+ def get_buffers(
220
+ self,
221
+ zip_id: str,
222
+ requested_ranges: list[tuple[int, int]],
223
+ zips_reader: ZipsReader,
224
+ ) -> list[OffsetedBuffer]:
225
+ """
226
+ Get buffers covering all requested ranges.
227
+
228
+ Uses cached data where available and reads missing ranges from disk.
229
+ All newly read data is added to the cache.
230
+
231
+ Parameters
232
+ ----------
233
+ zip_id : str
234
+ Identifier for the zip file
235
+ requested_ranges : list[tuple[int, int]]
236
+ List of (offset, length) tuples to read. Must be sorted by offset.
237
+ zips_reader : ZipsReader
238
+ Reader to use for fetching missing data from disk
239
+
240
+ Returns
241
+ -------
242
+ list[OffsetedBuffer]
243
+ List of buffers covering all requested ranges, sorted by offset
244
+ """
245
+ with self._lock:
246
+ # Compute what needs to be read from disk
247
+ missing_ranges = self._compute_missing_ranges(zip_id, requested_ranges)
248
+
249
+ # Read missing ranges from disk using batched read
250
+ if missing_ranges:
251
+ # TODO: unlock here?
252
+ read_result = zips_reader.batch_read_at({zip_id: missing_ranges})
253
+ multi_buffer = read_result[zip_id]
254
+
255
+ # Extract and cache individual ranges from the result
256
+ for offset, length in missing_ranges:
257
+ buffer = multi_buffer.read_at(offset, length)
258
+ self._add_to_cache(zip_id, offset, buffer)
259
+
260
+ # Now assemble the result from cache
261
+ result_buffers: list[OffsetedBuffer] = []
262
+ for req_offset, req_length in requested_ranges:
263
+ # Try to assemble from cache (possibly multiple ranges)
264
+ cached_buffer = self._assemble_from_cache(
265
+ zip_id, req_offset, req_length
266
+ )
267
+
268
+ # Cache the assembled buffer for faster future access
269
+ if req_offset not in [
270
+ cr.offset for cr in self._cached_ranges.get(zip_id, [])
271
+ ]:
272
+ self._add_to_cache(zip_id, req_offset, cached_buffer)
273
+
274
+ result_buffers.append(
275
+ OffsetedBuffer(buffer=cached_buffer, offset=req_offset)
276
+ )
277
+
278
+ return result_buffers
279
+
280
+ def clear(self) -> None:
281
+ """Clear all cached data."""
282
+ with self._lock:
283
+ self._cached_ranges.clear()
284
+ self._current_size = 0
285
+
286
+ def get_stats(self) -> dict[str, int]:
287
+ """Get cache statistics."""
288
+ with self._lock:
289
+ total_ranges = sum(len(ranges) for ranges in self._cached_ranges.values())
290
+ return {
291
+ "current_size_bytes": self._current_size,
292
+ "max_size_bytes": self._max_size,
293
+ "num_zip_files": len(self._cached_ranges),
294
+ "total_cached_ranges": total_ranges,
295
+ }