paddocktimeseries 0.8.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.
Files changed (50) hide show
  1. PaddockTS/FractionalCover/__init__.py +1 -0
  2. PaddockTS/FractionalCover/_models/__init__.py +0 -0
  3. PaddockTS/FractionalCover/_models/fcModel_256x128x256.tflite +0 -0
  4. PaddockTS/FractionalCover/_models/fcModel_256x64x256.tflite +0 -0
  5. PaddockTS/FractionalCover/_models/fcModel_32x32x32.tflite +0 -0
  6. PaddockTS/FractionalCover/_models/fcModel_64x64x64.tflite +0 -0
  7. PaddockTS/FractionalCover/_unmix.py +45 -0
  8. PaddockTS/FractionalCover/check_if_valid_fractional_cover_exists.py +49 -0
  9. PaddockTS/FractionalCover/compute_fractional_cover.py +208 -0
  10. PaddockTS/PaddockSegmentation/__init__.py +0 -0
  11. PaddockTS/PaddockSegmentation/_presegment.py +130 -0
  12. PaddockTS/PaddockSegmentation/check_if_valid_paddocks_exists.py +60 -0
  13. PaddockTS/PaddockSegmentation/check_if_valid_preseg_exists.py +51 -0
  14. PaddockTS/PaddockSegmentation/get_paddocks.py +232 -0
  15. PaddockTS/Phenology/__init__.py +0 -0
  16. PaddockTS/Phenology/_phenolopy.py +2525 -0
  17. PaddockTS/Phenology/estimate_phenology.py +151 -0
  18. PaddockTS/Phenology/make_paddock_time_series.py +233 -0
  19. PaddockTS/Phenology/make_smoothed_paddock_time_series.py +157 -0
  20. PaddockTS/Phenology/make_yearly_paddock_time_series.py +103 -0
  21. PaddockTS/Plotting/__init__.py +24 -0
  22. PaddockTS/Plotting/calendar_plot.py +523 -0
  23. PaddockTS/Plotting/fractional_cover_paddocks_video.py +157 -0
  24. PaddockTS/Plotting/fractional_cover_video.py +117 -0
  25. PaddockTS/Plotting/make_pdf.py +230 -0
  26. PaddockTS/Plotting/ozwald_plot.py +138 -0
  27. PaddockTS/Plotting/phenology_plot.py +200 -0
  28. PaddockTS/Plotting/sentinel2_paddocks_video.py +141 -0
  29. PaddockTS/Plotting/sentinel2_video.py +107 -0
  30. PaddockTS/Plotting/silo_plot.py +95 -0
  31. PaddockTS/Plotting/terrain_derivatives.py +213 -0
  32. PaddockTS/Plotting/terrain_tiles_plot.py +207 -0
  33. PaddockTS/__init__.py +0 -0
  34. PaddockTS/check_status.py +15 -0
  35. PaddockTS/daesim_forcing.py +146 -0
  36. PaddockTS/get_outputs.py +688 -0
  37. PaddockTS/paths.py +110 -0
  38. PaddockTS/run_environmental.py +180 -0
  39. PaddockTS/status.py +12 -0
  40. PaddockTS/utils.py +79 -0
  41. PaddockTS/viewer/__init__.py +13 -0
  42. PaddockTS/viewer/__main__.py +18 -0
  43. PaddockTS/viewer/app.py +149 -0
  44. PaddockTS/viewer/scan.py +196 -0
  45. PaddockTS/viewer/serve.py +80 -0
  46. paddocktimeseries-0.8.0.dist-info/METADATA +459 -0
  47. paddocktimeseries-0.8.0.dist-info/RECORD +50 -0
  48. paddocktimeseries-0.8.0.dist-info/WHEEL +5 -0
  49. paddocktimeseries-0.8.0.dist-info/licenses/LICENSE +21 -0
  50. paddocktimeseries-0.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ from .compute_fractional_cover import compute_fractional_cover
File without changes
@@ -0,0 +1,45 @@
1
+ """Fractional cover unmixing.
2
+
3
+ Inference logic and the bundled tflite model files in `_models/` are adapted
4
+ from the fractionalcover3 package by Robert Denham, distributed under the
5
+ MIT License. See PaddockTS/LICENSES/fractionalcover3.LICENSE.
6
+ """
7
+ from importlib import resources
8
+
9
+ import numpy as np
10
+ from tensorflow import lite as tflite
11
+
12
+
13
+ _MODELS = (
14
+ "fcModel_32x32x32.tflite",
15
+ "fcModel_64x64x64.tflite",
16
+ "fcModel_256x64x256.tflite",
17
+ "fcModel_256x128x256.tflite",
18
+ )
19
+
20
+
21
+ def get_model(n: int = 2):
22
+ """Load the n-th bundled fractional cover tflite model (1-indexed, 1-4)."""
23
+ path = resources.files(__package__) / "_models" / _MODELS[n - 1]
24
+ return tflite.Interpreter(model_path=str(path))
25
+
26
+
27
+ def unmix_fractional_cover(surface_reflectance, fc_model, in_null=0, out_null=0):
28
+ """Unmix a 6-band surface reflectance array into bare/green/non-green fractions.
29
+
30
+ The blue band is dropped before inference (the model was trained on the
31
+ remaining 5 bands). Returns a (3, nrows, ncols) float32 array.
32
+ """
33
+ inshape = surface_reflectance[1:].shape
34
+ ref_data = np.reshape(surface_reflectance[1:], (inshape[0], -1)).T
35
+
36
+ input_details = fc_model.get_input_details()
37
+ output_details = fc_model.get_output_details()
38
+ fc_model.resize_tensor_input(input_details[0]["index"], ref_data.shape)
39
+ fc_model.allocate_tensors()
40
+ fc_model.set_tensor(input_details[0]["index"], ref_data.astype(np.float32))
41
+ fc_model.invoke()
42
+ fc_layers = fc_model.get_tensor(output_details[0]["index"]).T
43
+ output_fc = np.reshape(fc_layers, (3, inshape[1], inshape[2]))
44
+ output_fc[output_fc == in_null] = out_null
45
+ return output_fc
@@ -0,0 +1,49 @@
1
+ """Cache-validity check for the computed fractional-cover zarr.
2
+
3
+ A valid cache requires both the zarr directory and a ``_SUCCESS`` marker
4
+ file *inside* it (written by :func:`compute_fractional_cover` only after
5
+ the zarr write completes). Without the marker the zarr is treated as a
6
+ partial / interrupted write and recomputed.
7
+ """
8
+
9
+ from os.path import exists
10
+
11
+
12
+ def check_if_valid_fractional_cover_exists(fractional_cover_path: str) -> bool:
13
+ """Return True iff a successfully-written fractional-cover zarr is on disk."""
14
+ return exists(fractional_cover_path) and exists(f'{fractional_cover_path}/_SUCCESS')
15
+
16
+
17
+ def test_no_zarr_no_marker():
18
+ import tempfile
19
+ with tempfile.TemporaryDirectory() as tmpdir:
20
+ return check_if_valid_fractional_cover_exists(f'{tmpdir}/missing.zarr') is False
21
+
22
+
23
+ def test_zarr_without_marker():
24
+ import tempfile, os
25
+ with tempfile.TemporaryDirectory() as tmpdir:
26
+ zarr_path = f'{tmpdir}/data.zarr'
27
+ os.makedirs(zarr_path)
28
+ return check_if_valid_fractional_cover_exists(zarr_path) is False
29
+
30
+
31
+ def test_zarr_with_marker():
32
+ import tempfile, os
33
+ with tempfile.TemporaryDirectory() as tmpdir:
34
+ zarr_path = f'{tmpdir}/data.zarr'
35
+ os.makedirs(zarr_path)
36
+ open(f'{zarr_path}/_SUCCESS', 'w').close()
37
+ return check_if_valid_fractional_cover_exists(zarr_path) is True
38
+
39
+
40
+ def test():
41
+ return all([
42
+ test_no_zarr_no_marker(),
43
+ test_zarr_without_marker(),
44
+ test_zarr_with_marker(),
45
+ ])
46
+
47
+
48
+ if __name__ == '__main__':
49
+ print(test())
@@ -0,0 +1,208 @@
1
+ """Spectral unmixing of Sentinel-2 reflectance into bg / pv / npv fractions.
2
+
3
+ The model is a small TFLite MLP adapted from
4
+ `fractionalcover3 <https://github.com/jrsrp/fractionalcover3>`_ by
5
+ Robert Denham (MIT-licensed; see
6
+ ``PaddockTS/LICENSES/fractionalcover3.LICENSE``). Four model variants
7
+ ship with the package, indexed ``n=1..4`` from least to most complex;
8
+ ``n=4`` is the default and most accurate.
9
+
10
+ Output bands:
11
+
12
+ - ``bg`` — bare ground fraction
13
+ - ``pv`` — green (photosynthetic) vegetation fraction
14
+ - ``npv`` — non-green (non-photosynthetic) vegetation fraction
15
+
16
+ Fractions are produced per-pixel per-timestep and persisted to
17
+ ``Paths(troi).fractional_cover`` as Zarr v2. Input reflectance comes
18
+ from the machine-wide pysentinel2 cube (cloud-masked, on read).
19
+ """
20
+
21
+ import os
22
+ import warnings
23
+
24
+ os.environ.setdefault('TF_CPP_MIN_LOG_LEVEL', '3')
25
+ warnings.filterwarnings('ignore', module='tensorflow')
26
+ warnings.filterwarnings('ignore', module='keras')
27
+
28
+ import numpy as np
29
+ import xarray as xr
30
+ from os import makedirs
31
+ from os.path import exists
32
+ from datetime import datetime
33
+ from xarray import Dataset
34
+ from troi import Troi
35
+ from PaddockTS.paths import Paths
36
+ from PaddockTS.FractionalCover.check_if_valid_fractional_cover_exists import check_if_valid_fractional_cover_exists
37
+
38
+
39
+ BANDS = ['nbart_blue', 'nbart_green', 'nbart_red', 'nbart_nir_1', 'nbart_swir_2', 'nbart_swir_3']
40
+
41
+
42
+ def compute_fractional_cover(troi: Troi, ds_sentinel2=None, model_n: int = 4, correction: bool = False):
43
+ """Run the TFLite unmixing model over every Sentinel-2 timestep.
44
+
45
+ Stacks the six Sentinel-2 SR bands into a ``(time, band, y, x)``
46
+ tensor, scales reflectance, and invokes the chosen model variant
47
+ once per timestep. The result is written to
48
+ ``Paths(troi).fractional_cover`` and returned as an xarray Dataset
49
+ with ``bg``, ``pv``, ``npv`` data variables on dims
50
+ ``(time, y, x)``.
51
+
52
+ Args:
53
+ troi: The :class:`troi.Troi`.
54
+ ds_sentinel2: Optional in-memory Sentinel-2 dataset. If ``None``,
55
+ the cloud-masked window is read from the machine-wide
56
+ pysentinel2 cube (downloading only what's missing). Must
57
+ contain the six bands in :data:`BANDS`.
58
+ model_n: Which bundled model variant to use (``1..4``). Higher
59
+ ``n`` is more accurate but slower; ``n=4`` is the default.
60
+ correction: If ``True``, apply per-band sensor calibration
61
+ factors (gains and offsets fitted in the upstream
62
+ fractionalcover3 work) instead of the simple ``* 0.0001``
63
+ DN-to-reflectance scaling. Use only when your inputs match
64
+ the calibration assumptions of the original model.
65
+
66
+ Returns:
67
+ xarray.Dataset: Dataset with variables ``bg``, ``pv``, ``npv``
68
+ on dims ``(time, y, x)``. Also persisted to
69
+ ``Paths(troi).fractional_cover``.
70
+ """
71
+ from PaddockTS.FractionalCover._unmix import unmix_fractional_cover, get_model
72
+
73
+ fractional_cover_path = Paths(troi).fractional_cover
74
+ if check_if_valid_fractional_cover_exists(fractional_cover_path):
75
+ try:
76
+ return xr.open_zarr(fractional_cover_path, chunks=None, decode_coords='all')
77
+ except Exception as e:
78
+ print(f'Fractional-cover cache at {fractional_cover_path} unreadable ({e}); recomputing')
79
+
80
+ if ds_sentinel2 is None:
81
+ from pysentinel2.cube import Cube
82
+ ds = Cube(config=troi.config).get_ds_troi(troi, clean=True)
83
+ else:
84
+ ds = ds_sentinel2
85
+
86
+ if correction:
87
+ factors = (np.array([0.9551, 1.0582, 0.9871, 1.0187, 0.9528, 0.9688]) +
88
+ np.array([-0.0022, 0.0031, 0.0064, 0.012, 0.0079, -0.0042])
89
+ ).astype(np.float32)[:, np.newaxis, np.newaxis]
90
+ else:
91
+ factors = np.float32(0.0001)
92
+
93
+ # Stream in small time batches, appending each to the zarr as it's
94
+ # unmixed. Materialising the whole window at once — six bands x every
95
+ # timestep as float — needs >10 GB for a multi-year troi and OOMs
96
+ # 8 GB machines; a 32-frame batch stays around 200 MB regardless of
97
+ # the time range. The model is loaded once, not per timestep.
98
+ #
99
+ # Unmixing runs serially on purpose: 4 threads with per-thread
100
+ # interpreters measured 615.2 s vs 618.3 s serial on an 8-year window
101
+ # (340% CPU for zero wall gain — the matmul is memory-bandwidth
102
+ # bound). If this step ever needs to be faster, batch several frames
103
+ # into one Invoke rather than adding threads.
104
+ model = get_model(n=model_n)
105
+ makedirs(os.path.dirname(fractional_cover_path), exist_ok=True)
106
+ timestamp = datetime.utcnow().isoformat() + 'Z'
107
+
108
+ n_time = ds.sizes['time']
109
+ batch = 32
110
+ for t0 in range(0, n_time, batch):
111
+ sub = ds.isel(time=slice(t0, t0 + batch))
112
+ inref = np.stack([sub[b].values for b in BANDS], axis=1).astype(np.float32)
113
+ # Cleaned cube bands are int16 with masked pixels at the nodata
114
+ # sentinel; convert to NaN so the unmixing propagates missingness
115
+ # instead of ingesting -999 as reflectance.
116
+ for bi, b in enumerate(BANDS):
117
+ nodata = ds[b].attrs.get('nodata')
118
+ if nodata is not None:
119
+ inref[:, bi][inref[:, bi] == float(nodata)] = np.nan
120
+ inref[inref == 0] = np.nan
121
+ inref *= factors
122
+ fractions = np.empty((inref.shape[0], 3, inref.shape[2], inref.shape[3]),
123
+ dtype=np.float32)
124
+ for i in range(inref.shape[0]):
125
+ fractions[i] = unmix_fractional_cover(inref[i], fc_model=model)
126
+
127
+ coords = {'time': sub.time, 'y': ds.y, 'x': ds.x}
128
+ batch_ds = xr.Dataset({
129
+ 'bg': xr.DataArray(fractions[:, 0], dims=['time', 'y', 'x'], coords=coords),
130
+ 'pv': xr.DataArray(fractions[:, 1], dims=['time', 'y', 'x'], coords=coords),
131
+ 'npv': xr.DataArray(fractions[:, 2], dims=['time', 'y', 'x'], coords=coords),
132
+ })
133
+ if t0 == 0:
134
+ batch_ds = batch_ds.assign_attrs(fractional_cover_computed_at=timestamp)
135
+ batch_ds.to_zarr(fractional_cover_path, mode='w', zarr_format=2,
136
+ encoding={v: {'chunks': (1, ds.sizes['y'], ds.sizes['x'])}
137
+ for v in ('bg', 'pv', 'npv')})
138
+ else:
139
+ batch_ds.to_zarr(fractional_cover_path, append_dim='time', zarr_format=2)
140
+
141
+ # Touch _SUCCESS *after* the zarr write completes; its presence is what
142
+ # the next call uses as the cache-validity check.
143
+ with open(f'{fractional_cover_path}/_SUCCESS', 'w') as f:
144
+ f.write(timestamp)
145
+ # Re-open from disk: a lazy, zarr-backed view rather than the in-RAM
146
+ # batches — downstream consumers slice what they need.
147
+ return xr.open_zarr(fractional_cover_path, chunks=None, decode_coords='all')
148
+
149
+
150
+ def _temp_troi():
151
+ import tempfile
152
+ from datetime import date
153
+ from troi import Config
154
+ tmpdir = tempfile.mkdtemp(prefix='paddockts_fc_test_')
155
+ cfg = Config(out_dir=tmpdir, tmp_dir=tmpdir)
156
+ return Troi(
157
+ bbox=[148.36265, -33.52606, 148.38265, -33.50606],
158
+ start=date(2024, 1, 1), end=date(2024, 1, 21),
159
+ stub=f'fc_{os.path.basename(tmpdir)}', config=cfg,
160
+ )
161
+
162
+
163
+ def test_compute_writes_zarr_and_marker():
164
+ """First call computes, writes fractional_cover.zarr, and touches _SUCCESS."""
165
+ q = _temp_troi()
166
+ ds = compute_fractional_cover(q)
167
+ fc = Paths(q).fractional_cover
168
+ if not exists(fc):
169
+ return False
170
+ if not exists(f'{fc}/_SUCCESS'):
171
+ return False
172
+ return all(name in ds.data_vars for name in ('bg', 'pv', 'npv'))
173
+
174
+
175
+ def test_repeated_call_uses_cache():
176
+ """Second call with same troi reuses the zarr (no rewrite)."""
177
+ q = _temp_troi()
178
+ compute_fractional_cover(q)
179
+ fc = Paths(q).fractional_cover
180
+ mtime_before = os.path.getmtime(fc)
181
+ compute_fractional_cover(q)
182
+ mtime_after = os.path.getmtime(fc)
183
+ return mtime_before == mtime_after
184
+
185
+
186
+ def test_missing_marker_triggers_recompute():
187
+ """A cache with the zarr present but no _SUCCESS file is recomputed."""
188
+ q = _temp_troi()
189
+ compute_fractional_cover(q)
190
+ fc = Paths(q).fractional_cover
191
+ marker = f'{fc}/_SUCCESS'
192
+ os.remove(marker)
193
+ mtime_before = os.path.getmtime(fc)
194
+ compute_fractional_cover(q)
195
+ mtime_after = os.path.getmtime(fc)
196
+ return exists(marker) and mtime_after > mtime_before
197
+
198
+
199
+ def test():
200
+ return all([
201
+ test_compute_writes_zarr_and_marker(),
202
+ test_repeated_call_uses_cache(),
203
+ test_missing_marker_triggers_recompute(),
204
+ ])
205
+
206
+
207
+ if __name__ == '__main__':
208
+ print(test())
File without changes
@@ -0,0 +1,130 @@
1
+ import numpy as np
2
+ import xarray as xr
3
+ from numpy.typing import NDArray
4
+ from datetime import datetime
5
+ from os import makedirs
6
+ from os.path import exists, dirname
7
+ from troi import Troi
8
+ from PaddockTS.paths import Paths
9
+ from PaddockTS.PaddockSegmentation.check_if_valid_preseg_exists import check_if_valid_preseg_exists
10
+
11
+
12
+ def completion(arr):
13
+ """Gap-fill NaN values in a (H, W, T) time series by forward-filling then backfilling."""
14
+ mask = np.isnan(arr)
15
+ idx = np.where(~mask, np.arange(mask.shape[-1]), 0)
16
+ np.maximum.accumulate(idx, axis=-1, out=idx)
17
+ i, j = np.meshgrid(
18
+ np.arange(idx.shape[0]), np.arange(idx.shape[1]), indexing="ij"
19
+ )
20
+ dat = arr[i[:, :, np.newaxis], j[:, :, np.newaxis], idx]
21
+ if np.isnan(np.sum(dat[:, :, 0])):
22
+ fill = np.nanmean(dat, axis=-1)
23
+ for t in range(dat.shape[-1]):
24
+ m = np.isnan(dat[:, :, t])
25
+ if m.any():
26
+ dat[m, t] = fill[m]
27
+ else:
28
+ break
29
+ return dat
30
+
31
+
32
+ def fourier_mean(x, n=3, step=5):
33
+ """Extract n Fourier band means from a (H, W, T) time series -> (H, W, n)."""
34
+ spectra = np.abs(np.fft.fft(x, axis=2))
35
+ result = np.empty((x.shape[0], x.shape[1], n), dtype=np.float32)
36
+ for k in range(n):
37
+ result[:, :, k] = np.mean(spectra[:, :, 1 + k * step:(k + 1) * step + 1], axis=2)
38
+ return result
39
+
40
+
41
+ def compute_ndwi_fourier(ds: xr.Dataset) -> NDArray[np.float32]:
42
+ """Compute NDWI from sentinel-2 bands, gap-fill, then Fourier summary -> (H, W, 3)."""
43
+ green = ds["nbart_green"].transpose("y", "x", "time").values.astype(np.float32)
44
+ nir = ds["nbart_nir_1"].transpose("y", "x", "time").values.astype(np.float32)
45
+
46
+ # 0 = unobserved; negative = the cleaned cube's int16 nodata sentinel
47
+ # (-999). Both are missing.
48
+ green[green <= 0] = np.nan
49
+ nir[nir <= 0] = np.nan
50
+ green /= 10000.0
51
+ nir /= 10000.0
52
+
53
+ ndwi_obs = (green - nir) / (green + nir)
54
+ ndwi_obs[~np.isfinite(ndwi_obs)] = np.nan
55
+
56
+ ndwi = completion(ndwi_obs)
57
+ return fourier_mean(ndwi).astype(np.float32)
58
+
59
+
60
+ def rescale_uint8(im: NDArray[np.float32]) -> NDArray[np.uint8]:
61
+ """Per-band min-max normalization to [0, 255] uint8."""
62
+ if im.ndim == 2:
63
+ im = im[:, :, None]
64
+ out = np.empty_like(im, dtype=np.uint8)
65
+ for i in range(im.shape[2]):
66
+ band = im[:, :, i]
67
+ finite = np.isfinite(band)
68
+ vmin, vmax = np.nanmin(band), np.nanmax(band)
69
+ if not np.any(finite) or vmax <= vmin:
70
+ out[:, :, i] = 0
71
+ continue
72
+ scaled = np.clip((band - vmin) / (vmax - vmin), 0, 1)
73
+ scaled[~finite] = 0.0
74
+ out[:, :, i] = (scaled * 255).astype(np.uint8)
75
+ return out
76
+
77
+
78
+ def save_geotiff(ds: xr.Dataset, uint8_image: NDArray[np.uint8], path: str) -> None:
79
+ """Save a 3-band uint8 image as a georeferenced GeoTIFF using CRS/transform from ds."""
80
+ import rioxarray # noqa: F401
81
+
82
+ if uint8_image.ndim == 2:
83
+ uint8_image = np.repeat(uint8_image[:, :, None], 3, axis=2)
84
+ elif uint8_image.shape[2] == 1:
85
+ uint8_image = np.repeat(uint8_image, 3, axis=2)
86
+ elif uint8_image.shape[2] == 2:
87
+ uint8_image = np.concatenate([uint8_image, uint8_image[:, :, :1]], axis=2)
88
+ elif uint8_image.shape[2] > 3:
89
+ uint8_image = uint8_image[:, :, :3]
90
+
91
+ da = xr.DataArray(
92
+ uint8_image,
93
+ coords={"y": ds.y.values, "x": ds.x.values, "band": np.arange(1, 4)},
94
+ dims=("y", "x", "band"),
95
+ )
96
+ da.rio.set_spatial_dims(x_dim="x", y_dim="y", inplace=True)
97
+ if ds.rio.crs is not None:
98
+ da.rio.write_crs(ds.rio.crs, inplace=True)
99
+ try:
100
+ da.rio.write_transform(ds.rio.transform(), inplace=True)
101
+ except Exception:
102
+ pass
103
+ da.transpose("band", "y", "x").rio.to_raster(path)
104
+
105
+
106
+ def presegment(troi: Troi, ds_sentinel2=None) -> str:
107
+ """
108
+ Create a 3-band uint8 NDWI Fourier GeoTIFF from Sentinel-2 data.
109
+ Returns the path to the saved preseg GeoTIFF.
110
+ """
111
+ preseg_path = Paths(troi).preseg
112
+ if check_if_valid_preseg_exists(preseg_path):
113
+ return preseg_path
114
+
115
+ if ds_sentinel2 is None:
116
+ from pysentinel2.cube import Cube
117
+ ds = Cube(config=troi.config).get_ds_troi(troi, clean=True)
118
+ else:
119
+ ds = ds_sentinel2
120
+ features = compute_ndwi_fourier(ds)
121
+ uint8_image = rescale_uint8(features)
122
+
123
+ makedirs(dirname(preseg_path), exist_ok=True)
124
+ save_geotiff(ds, uint8_image, preseg_path)
125
+ # Touch ``<tif>._SUCCESS`` *after* the TIFF write completes; its presence
126
+ # is what the next call uses as the cache-validity check.
127
+ with open(f'{preseg_path}._SUCCESS', 'w') as f:
128
+ f.write(datetime.utcnow().isoformat() + 'Z')
129
+ print(f"Saved preseg to {preseg_path}")
130
+ return preseg_path
@@ -0,0 +1,60 @@
1
+ """Cache-validity check for the SAM-segmented paddocks GeoPackage.
2
+
3
+ A valid cache requires both the ``.gpkg`` file and a ``.gpkg._SUCCESS``
4
+ sentinel sitting beside it (written by :func:`get_paddocks` only after
5
+ the GeoPackage write completes). The marker is a *sibling file* rather
6
+ than an inside-the-store file because GeoPackages are single-file
7
+ SQLite databases, not directories — so the zarr-style "marker inside
8
+ the dir" convention doesn't apply.
9
+
10
+ """
11
+
12
+ from os.path import exists
13
+
14
+
15
+ def check_if_valid_paddocks_exists(paddocks_path: str) -> bool:
16
+ """Return True iff a successfully-written paddocks GeoPackage is on disk."""
17
+ return exists(paddocks_path) and exists(f'{paddocks_path}._SUCCESS')
18
+
19
+
20
+ def test_no_file_no_marker():
21
+ import tempfile
22
+ with tempfile.TemporaryDirectory() as tmpdir:
23
+ return check_if_valid_paddocks_exists(f'{tmpdir}/missing.gpkg') is False
24
+
25
+
26
+ def test_file_without_marker():
27
+ import tempfile
28
+ with tempfile.TemporaryDirectory() as tmpdir:
29
+ gpkg = f'{tmpdir}/p.gpkg'
30
+ open(gpkg, 'w').close()
31
+ return check_if_valid_paddocks_exists(gpkg) is False
32
+
33
+
34
+ def test_file_with_marker():
35
+ import tempfile
36
+ with tempfile.TemporaryDirectory() as tmpdir:
37
+ gpkg = f'{tmpdir}/p.gpkg'
38
+ open(gpkg, 'w').close()
39
+ open(f'{gpkg}._SUCCESS', 'w').close()
40
+ return check_if_valid_paddocks_exists(gpkg) is True
41
+
42
+
43
+ def test_marker_without_file():
44
+ import tempfile
45
+ with tempfile.TemporaryDirectory() as tmpdir:
46
+ open(f'{tmpdir}/p.gpkg._SUCCESS', 'w').close()
47
+ return check_if_valid_paddocks_exists(f'{tmpdir}/p.gpkg') is False
48
+
49
+
50
+ def test():
51
+ return all([
52
+ test_no_file_no_marker(),
53
+ test_file_without_marker(),
54
+ test_file_with_marker(),
55
+ test_marker_without_file(),
56
+ ])
57
+
58
+
59
+ if __name__ == '__main__':
60
+ print(test())
@@ -0,0 +1,51 @@
1
+ """Cache-validity check for the preseg GeoTIFF.
2
+
3
+ A valid cache requires both the ``.tif`` file and a ``.tif._SUCCESS``
4
+ sentinel sitting beside it (written by :func:`presegment` only after the
5
+ TIFF write completes). The marker is a *sibling file* because GeoTIFFs
6
+ are single files, not directory stores.
7
+
8
+ Mirrors :func:`PaddockTS.PaddockSegmentation.check_if_valid_paddocks_exists`.
9
+ """
10
+
11
+ from os.path import exists
12
+
13
+
14
+ def check_if_valid_preseg_exists(preseg_path: str) -> bool:
15
+ """Return True iff a successfully-written preseg TIFF is on disk."""
16
+ return exists(preseg_path) and exists(f'{preseg_path}._SUCCESS')
17
+
18
+
19
+ def test_no_file_no_marker():
20
+ import tempfile
21
+ with tempfile.TemporaryDirectory() as tmpdir:
22
+ return check_if_valid_preseg_exists(f'{tmpdir}/missing.tif') is False
23
+
24
+
25
+ def test_file_without_marker():
26
+ import tempfile
27
+ with tempfile.TemporaryDirectory() as tmpdir:
28
+ tif = f'{tmpdir}/p.tif'
29
+ open(tif, 'w').close()
30
+ return check_if_valid_preseg_exists(tif) is False
31
+
32
+
33
+ def test_file_with_marker():
34
+ import tempfile
35
+ with tempfile.TemporaryDirectory() as tmpdir:
36
+ tif = f'{tmpdir}/p.tif'
37
+ open(tif, 'w').close()
38
+ open(f'{tif}._SUCCESS', 'w').close()
39
+ return check_if_valid_preseg_exists(tif) is True
40
+
41
+
42
+ def test():
43
+ return all([
44
+ test_no_file_no_marker(),
45
+ test_file_without_marker(),
46
+ test_file_with_marker(),
47
+ ])
48
+
49
+
50
+ if __name__ == '__main__':
51
+ print(test())