dpolcat 0.2.0.dev1__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.
dpolcat/processor.py ADDED
@@ -0,0 +1,777 @@
1
+ import marimo
2
+
3
+ __generated_with = "0.25.1"
4
+ app = marimo.App(width="medium")
5
+
6
+ with app.setup:
7
+ import marimo as mo
8
+
9
+ from collections import defaultdict
10
+ from functools import partial
11
+ import importlib.metadata
12
+ from pathlib import Path
13
+ import xml.etree.ElementTree as ET
14
+
15
+ import bokeh.models
16
+ import dask
17
+ from dask.diagnostics import ProgressBar
18
+ import geopandas as gpd
19
+ import numpy as np
20
+ import odc.stac
21
+ from osgeo import gdal
22
+ import pandas as pd
23
+ import pystac_client
24
+ import planetary_computer
25
+ import shapely.geometry as sg
26
+ import xarray
27
+ import holoviews as hv
28
+ import hvplot.pandas
29
+ import hvplot.xarray
30
+ import rioxarray
31
+ import zarr
32
+
33
+ from dpolcat.pc_rio_driver import PCRioDriver
34
+ import dpolcat as dp
35
+ import dpolcat.utils
36
+
37
+
38
+ gdal.UseExceptions()
39
+
40
+ hv.extension("bokeh")
41
+
42
+ # Increase output size to support heavy plots
43
+ # See https://github.com/marimo-team/marimo/issues/3376#issuecomment-3115395805
44
+ mo._runtime.context.get_context().marimo_config["runtime"][
45
+ "output_max_bytes"
46
+ ] = 100000000
47
+
48
+
49
+ @app.cell(hide_code=True)
50
+ def _():
51
+ mo.md(r"""
52
+ Luke McQuade, October 2026
53
+
54
+ # *dpolcat*: Processor
55
+
56
+ This is a notebook/module for creating Sentinel-1 *dpolcat* time series/datacubes for a user-supplied area of interest (AoI) and time period. The main steps of the pipeline:
57
+ - Find STAC items matching the AoI, time period, and selected orbit.
58
+ - Create a lazy Xarray dataset, grouped by datatake
59
+ - Apply speckle filtering (optional)
60
+ - Perform dpolcat categorization
61
+
62
+ This is written directly to a ZARR store. This can be appended to, for example, when new items are available. Lazy Dask processing is employed, meaning no data loading or processing is performed until the final writing step. I.e., between this the STAC search and this, the steps in the notebook just build up a Dask task graph.
63
+
64
+ Convention: This is a parameterized notebook intended to be run externally (see [marimo.App.run]( https://docs.marimo.io/api/app/#marimo.App.run)). Variables beginning with `p_` can be injected when running the notebook this way, e.g., `dpolcat.processor.app.run({"p_aoi": my_aoi, "p_date_range": my_date_range, ...})`. Be careful renaming/changing these! The most important are:
65
+
66
+ | Parameter | Description |
67
+ | - | - |
68
+ | `p_headless` | Whether to enable headless execution. Should be `True` when running externally. |
69
+ | `p_aoi` | The area of interest as a GeoDataFrame containing a single polygon. |
70
+ | `p_date_range` | The date range as a string with format `{from_date}/{to_date}`, e.g., "2020-01-01/2020-03-31" |
71
+ | `p_sat_direction` | Selected orbit direction ("ascending" or "descending") |
72
+ | `p_sat_rel_orbit` | Selected relative orbit number (integer) |
73
+ | `p_speckle_filter` | Speckle filter to apply, "Frost" (default) or "Unfiltered". |
74
+ | `p_speckle_filter_window` | Speckle filter window size, default 5. |
75
+ | `p_out_zarr_path` | Path to the ZARR store. |
76
+ | `p_write_intermediate_data` | Whether to write intermediate backscatter data also (defaults to False). |
77
+ """)
78
+ return
79
+
80
+
81
+ @app.cell
82
+ def _():
83
+ p_headless = False
84
+ return (p_headless,)
85
+
86
+
87
+ @app.cell
88
+ def _():
89
+ mo.md(r"""
90
+ ## Search and Select Sentinel-1 RTC data
91
+ """)
92
+ return
93
+
94
+
95
+ @app.cell
96
+ def _():
97
+ # Sample AoI bounding boxes, with format:
98
+ # [lon_min, lat_min, lon_max, lat_max]
99
+ sample_aois = {
100
+ "SalzburgCity": [13.02, 47.76, 13.09, 47.83],
101
+ # Salzburg and Salzach Valley
102
+ "SalzburgSouth": [12.92, 47.50, 13.19, 47.86],
103
+ "Tenerife": [-16.96, 27.92, -16.05, 28.61],
104
+ "GranCanaria": [-15.84, 27.72, -15.34, 28.19],
105
+ # Eastern part of Lake Titicaca, La Paz, and some of the Cordillera Real
106
+ "Titicaca": [-69.41, -16.71, -67.87, -15.61],
107
+ "LaPaz": [-68.224, -16.603, -68.0542, -16.430],
108
+ # Inverness and surroundings to the East
109
+ "Inverness": [-4.32, 57.34, -3.63, 57.63],
110
+ "London": [-0.599, 51.231, 0.347, 51.734],
111
+ # Sentinel-2 footprint around Graz
112
+ "33TWN": [14.999, 46.856, 16.467, 47.853],
113
+ "Duisburg": [6.695, 51.407, 6.847, 51.462],
114
+ }
115
+
116
+ ui_aoi = mo.ui.dropdown(options=sample_aois.keys(), value=list(sample_aois.keys())[0])
117
+ ui_date_range = mo.ui.date_range(
118
+ start="2015-01-01", value=("2025-05-01", "2025-05-31")
119
+ )
120
+
121
+ mo.md(f"""
122
+ Area of interest: {ui_aoi} <br>
123
+ Search dates: {ui_date_range} <br>
124
+ """)
125
+ return sample_aois, ui_aoi, ui_date_range
126
+
127
+
128
+ @app.cell
129
+ def _(ui_aoi):
130
+ p_aoi_name = ui_aoi.value
131
+ return (p_aoi_name,)
132
+
133
+
134
+ @app.cell
135
+ def _(p_aoi_name, sample_aois):
136
+ _bbox = sample_aois[p_aoi_name]
137
+ p_aoi = gpd.GeoDataFrame(geometry=[sg.box(*_bbox)], crs="EPSG:4326")
138
+ return (p_aoi,)
139
+
140
+
141
+ @app.cell
142
+ def _(p_aoi):
143
+ bbox = p_aoi.total_bounds
144
+ return (bbox,)
145
+
146
+
147
+ @app.cell
148
+ def _(ui_date_range):
149
+ _d1, _d2 = ui_date_range.value
150
+ p_date_range = f"{_d1.year}-{_d1.month:02}-{_d1.day:02}/{_d2.year}-{_d2.month:02}-{_d2.day:02}"
151
+ return (p_date_range,)
152
+
153
+
154
+ @app.cell(hide_code=True)
155
+ def _():
156
+ mo.md(r"""
157
+ Find best matching acquisitions - those which share the same orbit/track* and, when combined, cover the entirety of the AoI.
158
+
159
+ *This is crucial, as different acquisition geometries give incomparable results.
160
+ """)
161
+ return
162
+
163
+
164
+ @app.cell
165
+ def _(p_aoi, p_date_range):
166
+ stac_items_gdf = dpolcat.utils.find_stac_items(p_aoi, date_range=p_date_range)
167
+ df_coverages = dpolcat.utils.calc_coverages(stac_items_gdf, aoi=p_aoi)
168
+ return df_coverages, stac_items_gdf
169
+
170
+
171
+ @app.cell
172
+ def _(df_coverages):
173
+ ui_orbit_selector = mo.ui.table(df_coverages.reset_index(), selection="single")
174
+ ui_orbit_selector
175
+ return (ui_orbit_selector,)
176
+
177
+
178
+ @app.cell
179
+ def _(ui_orbit_selector):
180
+ mo.stop(
181
+ len(ui_orbit_selector.value) == 0
182
+ or ui_orbit_selector.value["full_scenes"].values[0] == 0,
183
+ "Selected an entry with at least 1 full scene.",
184
+ )
185
+ p_sat_direction = ui_orbit_selector.value["sat:orbit_state"].values[0]
186
+ p_sat_rel_orbit = ui_orbit_selector.value["sat:relative_orbit"].values[0]
187
+ return p_sat_direction, p_sat_rel_orbit
188
+
189
+
190
+ @app.cell
191
+ def filter_items_by_orbit(p_sat_direction, p_sat_rel_orbit, stac_items_gdf):
192
+ sel_items_gdf = dpolcat.utils.select_by_orbit(
193
+ stac_items_gdf,
194
+ orbit_direction=p_sat_direction,
195
+ relative_orbit=p_sat_rel_orbit,
196
+ )
197
+ sel_items = sel_items_gdf["_item"].values
198
+ return sel_items, sel_items_gdf
199
+
200
+
201
+ @app.cell
202
+ def _(sel_items_gdf):
203
+ sel_items_gdf
204
+ return
205
+
206
+
207
+ @app.cell
208
+ def get_items_epsg(sel_items):
209
+ _epsgs = set([int(item.properties["proj:code"][5:]) for item in sel_items])
210
+ assert len(_epsgs) == 1, "items are of different CRSs, this is not supported."
211
+ epsg_num = int(_epsgs.pop())
212
+ return (epsg_num,)
213
+
214
+
215
+ @app.cell
216
+ def plot_footprints(p_aoi, sel_items_gdf):
217
+ # Visualize the AoI and item footprints. Note: AoI geometry may appear a little off due to reprojection compat issue (https://github.com/oceanmodeling/stofs-event-dashboard/pull/36).
218
+ (
219
+ sel_items_gdf.hvplot(geo=True, alpha=0.2, color="orange")
220
+ * p_aoi.hvplot(geo=True, tiles=True, alpha=0.2, color="blue")
221
+ ).opts(tools=["wheel_zoom"])
222
+ return
223
+
224
+
225
+ @app.cell
226
+ def _():
227
+ mo.md(r"""
228
+ ## Processing
229
+ """)
230
+ return
231
+
232
+
233
+ @app.cell
234
+ def _():
235
+ ui_existing_zarr_path = mo.ui.text(
236
+ label="Use existing Zarr store (optional):",
237
+ full_width=True,
238
+ placeholder="data/my_existing_dpolcat_store.zarr",
239
+ )
240
+ ui_existing_zarr_path
241
+ return (ui_existing_zarr_path,)
242
+
243
+
244
+ @app.cell
245
+ def _(ui_existing_zarr_path):
246
+ p_out_zarr_path = ui_existing_zarr_path.value
247
+ return (p_out_zarr_path,)
248
+
249
+
250
+ @app.cell
251
+ def _():
252
+ ui_speckle_filter = mo.ui.dropdown(options=["Unfiltered", "Frost"], value="Frost")
253
+ ui_speckle_filter
254
+ return (ui_speckle_filter,)
255
+
256
+
257
+ @app.cell
258
+ def _(ui_speckle_filter):
259
+ p_speckle_filter = ui_speckle_filter.value
260
+ return (p_speckle_filter,)
261
+
262
+
263
+ @app.cell
264
+ def _():
265
+ p_speckle_filter_window = 5
266
+ return (p_speckle_filter_window,)
267
+
268
+
269
+ @app.cell
270
+ def _():
271
+ ui_write_intermediate_data = mo.ui.switch(label="Write intermediate (backscatter) data")
272
+ ui_write_intermediate_data
273
+ return (ui_write_intermediate_data,)
274
+
275
+
276
+ @app.cell
277
+ def _(ui_write_intermediate_data):
278
+ p_write_intermediate_data = ui_write_intermediate_data.value
279
+ return (p_write_intermediate_data,)
280
+
281
+
282
+ @app.cell
283
+ def _(p_headless, sel_items):
284
+ # Only show run button once valid items have been chosen
285
+ _ = sel_items
286
+
287
+ if not p_headless:
288
+ ui_run = mo.ui.run_button(label="⚙️ Perform processing")
289
+ else:
290
+ # In headless mode, trigger processing with a fake pressed button.
291
+ ui_run = mo.ui.button(value=True)
292
+
293
+ mo.md(f"""
294
+ ### Begin processing
295
+ {ui_run}
296
+ """
297
+ )
298
+ # ui_run
299
+ return (ui_run,)
300
+
301
+
302
+ @app.cell
303
+ def _():
304
+ resolution = 10 # Native to data source
305
+ return (resolution,)
306
+
307
+
308
+ @app.cell
309
+ def _():
310
+ # Chunk size, for both reading (Dask) and writing (Zarr).
311
+ # Chosen for a mix of time series and single-scene operations.
312
+ DASK_CHUNKS = {"time": 10, "y": 1024, "x": 1024}
313
+ ZARR_CHUNKS = (10, 1024, 1024) # ⚠️ be very careful changing this!
314
+ return DASK_CHUNKS, ZARR_CHUNKS
315
+
316
+
317
+ @app.cell
318
+ def _(DASK_CHUNKS, bbox, epsg_num, resolution, sel_items, ui_run):
319
+ mo.stop(
320
+ not ui_run.value,
321
+ mo.md(
322
+ "Click the [button above](#begin-processing) to start processing."
323
+ ),
324
+ )
325
+
326
+ # Create lazy-loading dataset
327
+ ds = odc.stac.load(
328
+ sel_items,
329
+ bands=["vv", "vh"],
330
+ bbox=bbox,
331
+ crs=f"EPSG:{epsg_num}",
332
+ resolution=resolution,
333
+ chunks={}, # Use default for now, we set this properly after re-sorting.
334
+ groupby="s1:datatake_id",
335
+ nodata=np.nan,
336
+ driver=PCRioDriver(),
337
+ with_properties=[
338
+ {
339
+ "key": "s1:datatake_id",
340
+ "dtype": "uint32",
341
+ "fuser": lambda s: int(s[0]),
342
+ }
343
+ ],
344
+ )
345
+
346
+ # Sort by time (until now, it's sorted by datatake_id, which isn't chronologically ordered).
347
+ # We also need to re-chunk because of this.
348
+ ds = ds.sortby("time").chunk(DASK_CHUNKS)
349
+
350
+ # Add original item IDs as coordinates
351
+ _ids_by_dt = defaultdict(list)
352
+ for _item in sel_items:
353
+ _ids_by_dt[int(_item.properties["s1:datatake_id"])].append(_item.id)
354
+ _item_ids = [
355
+ ",".join(sorted(_ids_by_dt[d]))
356
+ for d in ds["s1_datatake_id"].values.astype(int)
357
+ ]
358
+ # Convert item id string to an object, otherwise it gets a fixed-length type
359
+ # in the Zarr that breaks if appended data isn't exactly the same shape.
360
+ _item_ids = np.array(_item_ids, dtype=object)
361
+
362
+ ds = ds.assign_coords(item_ids=("time", _item_ids))
363
+
364
+ vv_lin = ds.vv
365
+ vh_lin = ds.vh
366
+ return vh_lin, vv_lin
367
+
368
+
369
+ @app.cell
370
+ def preprocess(p_speckle_filter, p_speckle_filter_window, vh_lin, vv_lin):
371
+ # Apply speckle filtering and scaling
372
+
373
+ if p_speckle_filter == "Frost":
374
+ FROST_DAMPING_FACTOR = 2.0 # COULDDO: Parameterize if needed
375
+ vv_filt = dpolcat.utils.frost_xr(
376
+ vv_lin,
377
+ nanny=True,
378
+ damping_factor=FROST_DAMPING_FACTOR,
379
+ win_size=p_speckle_filter_window,
380
+ )
381
+ vh_filt = dpolcat.utils.frost_xr(
382
+ vh_lin,
383
+ nanny=True,
384
+ damping_factor=FROST_DAMPING_FACTOR,
385
+ win_size=p_speckle_filter_window,
386
+ )
387
+ vv_db = dp.linear_to_db(vv_filt)
388
+ vh_db = dp.linear_to_db(vh_filt)
389
+ else:
390
+ vv_db = dp.linear_to_db(vv_lin)
391
+ vh_db = dp.linear_to_db(vh_lin)
392
+ return FROST_DAMPING_FACTOR, vh_db, vv_db
393
+
394
+
395
+ @app.cell(hide_code=True)
396
+ def _():
397
+ mo.md(r"""
398
+ ### Metadata
399
+ """)
400
+ return
401
+
402
+
403
+ @app.function
404
+ def get_dpolcat_version():
405
+ return importlib.metadata.version("dpolcat")
406
+
407
+
408
+ @app.cell
409
+ def _(
410
+ FROST_DAMPING_FACTOR,
411
+ p_sat_direction,
412
+ p_sat_rel_orbit,
413
+ p_speckle_filter,
414
+ p_speckle_filter_window,
415
+ ):
416
+ def add_common_metadata(da, epsg_num):
417
+ _da = da.rio.write_crs(epsg_num)
418
+ _wkt = _da.rio.crs.to_wkt()
419
+ _dpolcat_version = get_dpolcat_version()
420
+ _speckle_filter_string = {
421
+ "Frost": f"{p_speckle_filter} (win_size={p_speckle_filter_window}, damping_factor={FROST_DAMPING_FACTOR})",
422
+ "Unfiltered": "Unfiltered",
423
+ }[p_speckle_filter]
424
+ _da = _da.assign_attrs(
425
+ {
426
+ "sat:orbit_state": p_sat_direction,
427
+ "sat:relative_orbit": p_sat_rel_orbit,
428
+ "_CRS": {"wkt": _wkt}, # Required for GDAL compat.
429
+ "dpolcat:speckle_filter": _speckle_filter_string,
430
+ "dpolcat:version": _dpolcat_version,
431
+ }
432
+ )
433
+
434
+ return _da
435
+
436
+ return (add_common_metadata,)
437
+
438
+
439
+ @app.cell
440
+ def _():
441
+ mo.md(r"""
442
+ ### Categorization
443
+ """)
444
+ return
445
+
446
+
447
+ @app.function
448
+ def new_zarr_path(aoi_name):
449
+ timestamp = pd.Timestamp.now("UTC").strftime("%Y%m%dT%H%M%S")
450
+ path = f"data/dpolcat-{aoi_name}_{timestamp}.zarr"
451
+ return path
452
+
453
+
454
+ @app.cell
455
+ def _(
456
+ ZARR_CHUNKS,
457
+ add_common_metadata,
458
+ epsg_num,
459
+ p_aoi_name,
460
+ p_out_zarr_path,
461
+ p_sat_direction,
462
+ p_sat_rel_orbit,
463
+ p_write_intermediate_data,
464
+ ui_run,
465
+ vh_db,
466
+ vv_db,
467
+ ):
468
+ mo.stop(
469
+ not ui_run.value,
470
+ mo.md(
471
+ "Click the [button above](#begin-processing) to start processing."
472
+ ),
473
+ )
474
+
475
+ # Categorization Dask pipeline; write to Zarr
476
+ # --------------
477
+ categorized = dp.xr_categorize(vv_db, vh_db)
478
+ categorized = categorized.rio.write_crs(epsg_num)
479
+ categorized.attrs.pop("nodata", None) # Remove old 'nan' nodata
480
+ # For some reason this breaks plotting later:
481
+ # categorized = categorized.rio.write_nodata(0)
482
+
483
+ out_zarr_path = p_out_zarr_path or new_zarr_path(p_aoi_name)
484
+ zarr_group_path = f"/{p_sat_direction}_{p_sat_rel_orbit}"
485
+
486
+ layers = {"dpolcat": categorized}
487
+ if p_write_intermediate_data:
488
+ layers |= {"vv_db": vv_db, "vh_db": vh_db}
489
+
490
+ try:
491
+ zarr.open_group(out_zarr_path, path=zarr_group_path, mode="r")
492
+ _group_exists = True
493
+ except FileNotFoundError, zarr.errors.GroupNotFoundError:
494
+ _group_exists = False
495
+
496
+ _time_encoding = {
497
+ "time": {
498
+ "units": "microseconds since 1970-01-01",
499
+ "dtype": "int64",
500
+ }
501
+ }
502
+
503
+ writes = [
504
+ add_common_metadata(da, epsg_num=epsg_num)
505
+ .to_dataset(name=name)
506
+ .to_zarr(
507
+ out_zarr_path,
508
+ group=f"{zarr_group_path}/{name}",
509
+ mode="a",
510
+ append_dim="time" if _group_exists else None,
511
+ zarr_format=3,
512
+ consolidated=False,
513
+ encoding=(
514
+ None
515
+ if _group_exists
516
+ else _time_encoding | {name: {"chunks": ZARR_CHUNKS}}
517
+ ),
518
+ compute=False,
519
+ align_chunks=True,
520
+ )
521
+ for name, da in layers.items()
522
+ ]
523
+ with ProgressBar(minimum=1):
524
+ dask.compute(*writes)
525
+
526
+ # Add overviews
527
+ # -------------
528
+ OVERVIEW_LEVELS = [2, 4, 8, 16]
529
+
530
+ # Use this even for continuous data (better physicality and nodata handling)
531
+ OVERVIEW_RESAMPLING = "NEAREST"
532
+
533
+ _gdal_ds = gdal.OpenEx(
534
+ out_zarr_path,
535
+ gdal.OF_MULTIDIM_RASTER | gdal.OF_UPDATE,
536
+ open_options=["USE_CONSOLIDATED_METADATA=NO"],
537
+ )
538
+ _gdal_root = _gdal_ds.GetRootGroup()
539
+
540
+ for _layer in layers:
541
+ _gdal_arr = _gdal_root.OpenMDArrayFromFullname(
542
+ f"{zarr_group_path}/{_layer}/{_layer}"
543
+ )
544
+ _gdal_arr.BuildOverviews(OVERVIEW_RESAMPLING, OVERVIEW_LEVELS)
545
+
546
+ # A note on the Zarr structure: the way multiscales/overviews work means
547
+ # another level of nesting is needed whenever more than 1 variable is present
548
+ # Expect something like, e.g.:
549
+ # /{direction}_{relorbit}/dpolcat/dpolcat (native resolution)
550
+ # /{direction}_{relorbit}/dpolcat/ovr_2x/dpolcat (half resolution)
551
+ # /{direction}_{relorbit}/dpolcat/ovr_4x/dpolcat (quarter resolution)
552
+ # /{direction}_{relorbit}/dpolcat/ovr_*x/dpolcat (etc)
553
+ # /{direction}_{relorbit}/vv_db/vv_db
554
+ # /{direction}_{relorbit}/vv_db/ovr_2x/vv_db
555
+ # etc
556
+
557
+ # Close GDAL items & finalize
558
+ # ----------
559
+ del _gdal_arr, _gdal_root, _gdal_ds
560
+
561
+ zarr.consolidate_metadata(out_zarr_path)
562
+
563
+ result = xarray.open_groups(
564
+ out_zarr_path, engine="zarr", chunks={}, decode_coords="all"
565
+ )
566
+ return OVERVIEW_LEVELS, layers, out_zarr_path, result, zarr_group_path
567
+
568
+
569
+ @app.cell
570
+ def _(result, zarr_group_path):
571
+ # Results as a more friendly format
572
+ result_ds = xarray.Dataset(
573
+ {"dpolcat": result[f"{zarr_group_path}/dpolcat"]["dpolcat"]}
574
+ )
575
+ if result.get(f"{zarr_group_path}/vv_db") and result.get(
576
+ f"{zarr_group_path}/vh_db"
577
+ ):
578
+ result_ds["vv_db"] = result[f"{zarr_group_path}/vv_db"]["vv_db"]
579
+ result_ds["vh_db"] = result[f"{zarr_group_path}/vh_db"]["vh_db"]
580
+ return (result_ds,)
581
+
582
+
583
+ @app.cell(hide_code=True)
584
+ def _():
585
+ mo.md(r"""
586
+ ## Visualization
587
+ """)
588
+ return
589
+
590
+
591
+ @app.cell
592
+ def _():
593
+ ui_viz_enabled = mo.ui.switch(label="Viz enabled")
594
+ ui_viz_enabled
595
+ return (ui_viz_enabled,)
596
+
597
+
598
+ @app.cell
599
+ def _(ui_viz_enabled):
600
+ mo.stop(not ui_viz_enabled.value)
601
+
602
+ preview_resolution = 40
603
+ return (preview_resolution,)
604
+
605
+
606
+ @app.cell
607
+ def _(preview_resolution, resolution, result_ds):
608
+ _step = preview_resolution // resolution
609
+ result_preview = result_ds.isel(x=slice(None, None, _step), y=slice(None, None, _step))
610
+ # SHOULDDO: load using multiscale, and maybe use datashader rasterize
611
+
612
+ # result_preview = rioxarray.open_rasterio(
613
+ # out_zarr_path
614
+ # )
615
+
616
+ # # Have to re-add time coordinates
617
+ # result_preview = result_preview.rename(band="time").assign_coords(
618
+ # time=result.time.values
619
+ # )
620
+ return (result_preview,)
621
+
622
+
623
+ @app.cell
624
+ def _(p_write_intermediate_data, result_preview):
625
+ # Result preview.
626
+
627
+ def plot_backscatter(da):
628
+ return da.hvplot.image(
629
+ x="x", y="y", cmap="gray", data_aspect=1.0, clim=(-35, 15)
630
+ )
631
+
632
+ plot_width = 280
633
+
634
+ # Setup linked crosshairs,
635
+ # via https://discourse.holoviz.org/t/linked-crosshair/4326/2
636
+ _width = bokeh.models.Span(dimension="width")
637
+ _height = bokeh.models.Span(dimension="height")
638
+ linked_crosshair = bokeh.models.CrosshairTool(overlay=[_width, _height])
639
+
640
+ _dpolcat = result_preview["dpolcat"].astype("str")
641
+ _dpolcat_plot = _dpolcat.hvplot(
642
+ x="x",
643
+ y="y",
644
+ data_aspect=1.0,
645
+ cmap=dpolcat.utils.make_cmap(),
646
+ frame_width=plot_width,
647
+ tools=[linked_crosshair],
648
+ )
649
+
650
+ if p_write_intermediate_data:
651
+ _plot = (
652
+ _dpolcat_plot
653
+ + dpolcat.utils.make_legend(size=250).opts(width=350)
654
+ + plot_backscatter(result_preview["vv_db"]).opts(
655
+ frame_width=plot_width, tools=[linked_crosshair]
656
+ )
657
+ + plot_backscatter(result_preview["vh_db"]).opts(
658
+ frame_width=plot_width, tools=[linked_crosshair]
659
+ )
660
+ ).cols(2)
661
+ else:
662
+ _plot = (_dpolcat_plot + dpolcat.utils.make_legend(size=250)).cols(2)
663
+ _plot
664
+ return
665
+
666
+
667
+ @app.cell
668
+ def _(result_preview, ui_viz_enabled):
669
+ mo.stop(not ui_viz_enabled.value)
670
+
671
+ _bins = list(range(0, dp.NUM_CATEGORIES + 1))
672
+ result_preview["dpolcat"].hvplot.hist(bins=_bins).opts(
673
+ tools=["wheel_zoom"]
674
+ )
675
+ return
676
+
677
+
678
+ @app.cell(hide_code=True)
679
+ def _():
680
+ mo.md(r"""
681
+ ### Create .VRTs
682
+
683
+ Create .VRT files for the Zarr assets that provide overview (multiscale) support, e.g., for QGIS.
684
+
685
+ /// attention
686
+
687
+ These point to the absolute path of the .zarr store.
688
+ So, if this is moved, the VRTs will have to be updated/recreated.
689
+ """)
690
+ return
691
+
692
+
693
+ @app.cell
694
+ def _():
695
+ ui_vrt = mo.ui.run_button(label="Create .VRTs")
696
+ ui_vrt
697
+ return (ui_vrt,)
698
+
699
+
700
+ @app.cell
701
+ def _(OVERVIEW_LEVELS, layers, out_zarr_path, ui_vrt, zarr_group_path):
702
+ mo.stop(not ui_vrt.value, "Click the button above to create .VRT files.")
703
+
704
+ def zarr_layer_vrt(zarr_path, layer, levels, vrt_path=None):
705
+ """Creates .VRT containing overviews for each layer."""
706
+ # Mostly Claude (Opus 5.5)
707
+ zarr_path = Path(zarr_path).absolute()
708
+ src = f'ZARR:"{zarr_path}":{zarr_group_path}/{layer}'
709
+ vrt_path = vrt_path or f"data/{zarr_path.stem}_{layer}.vrt"
710
+ gdal.Translate(
711
+ vrt_path,
712
+ f"{src}/{layer}",
713
+ format="VRT",
714
+ )
715
+ tree = ET.parse(vrt_path)
716
+ for band in tree.getroot().iter("VRTRasterBand"):
717
+ for f in levels:
718
+ ovr = ET.SubElement(band, "Overview")
719
+ ET.SubElement(
720
+ ovr, "SourceFilename"
721
+ ).text = f"{src}/ovr_{f}x/{layer}"
722
+ ET.SubElement(ovr, "SourceBand").text = band.get("band")
723
+ tree.write(vrt_path)
724
+ return vrt_path
725
+
726
+ vrts = {
727
+ layer: zarr_layer_vrt(out_zarr_path, layer, OVERVIEW_LEVELS)
728
+ for layer in layers
729
+ }
730
+ return (vrts,)
731
+
732
+
733
+ @app.cell
734
+ def _(vrts):
735
+ vrts
736
+ return
737
+
738
+
739
+ @app.cell(hide_code=True)
740
+ def _():
741
+ mo.md(r"""
742
+ ### COG export
743
+ """)
744
+ return
745
+
746
+
747
+ @app.cell
748
+ def _():
749
+ ui_export = mo.ui.run_button(label="Export COGs")
750
+ ui_export
751
+ return (ui_export,)
752
+
753
+
754
+ @app.cell
755
+ def _(aoi_name, result_ds, ui_export):
756
+ mo.stop(not ui_export.value, "Click the button above to export.")
757
+
758
+ exported_cogs = []
759
+ for timeslice in result_ds["dpolcat"]:
760
+ _date = str(timeslice["time"].values)[:10]
761
+ _filename = f"data/dpolcat-{aoi_name}-{_date}.tif"
762
+ _da = timeslice
763
+ # _da = timeslice.astype(np.uint8).rename("dpolcat")
764
+ _da = _da.rio.write_nodata(0)
765
+ _da.rio.to_raster(_filename, compute=True)
766
+ exported_cogs.append(_filename)
767
+ return (exported_cogs,)
768
+
769
+
770
+ @app.cell
771
+ def _(exported_cogs):
772
+ exported_cogs
773
+ return
774
+
775
+
776
+ if __name__ == "__main__":
777
+ app.run()