1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
|
"""Flood extent between two CEMS observations (PRODUCT -> MONIT01).
Cells flooded at both times stay flooded. Receding cells dry from the highest above the river
down; newly flooded cells fill from the lowest up, so the water level moves linearly in time.
Only the area seen by both images can be simulated. Areas with three observations are used
to check the method: predict the middle one from the outer two.
"""
import argparse, datetime as dt, itertools
import numpy as np, pandas as pd, geopandas as gpd, rasterio, shapely
from pathlib import Path
from rasterio.features import rasterize, shapes
from rasterio.transform import from_origin
from scipy.ndimage import distance_transform_edt
from main import DATA
from paths import OUTPUTS
from risk_model import Area
OUT = OUTPUTS / "interp"
CELL = 20 # simulation grid, m
MERGE_H = 3 # images closer than this are treated as one observation
def parse_time(date, tm):
t = dt.datetime.strptime(date, "%Y/%m/%d" if date[4] == "/" else "%d/%m/%Y")
if tm.startswith("T"):
h, m, s = map(int, tm[1:9].split(":"))
t = t.replace(hour=h, minute=m, second=s)
return t
def observations(folders):
"""Every post-event image in the folders: time, flooded area, and the footprint it actually saw."""
obs = {}
for folder in folders:
layer = lambda kind: gpd.read_file(next(folder.glob(f"*_{kind}_*.shp"))).to_crs(3035)
ev = layer("observedEventA")
ev = ev[ev.notation == "Flooded area"] # 'Flood trace' marks where water was, not where it is
foot = layer("imageFootprintA")
src = gpd.read_file(next(folder.glob("*_source_*.dbf")))
for r in src[src.eventphase == "Post-event"].itertuples():
fp = foot[foot.or_src_id == r.src_id]
if fp.empty:
continue
o = obs.setdefault((r.src_date, r.source_tm, r.source_nam),
dict(time=parse_time(r.src_date, r.source_tm), sensor=r.source_nam, wet=[], foot=[]))
o["wet"] += list(ev[ev.dmg_src_id == r.src_id].geometry)
o["foot"] += list(fp.geometry)
merged = []
for o in sorted(obs.values(), key=lambda o: o["time"]):
if merged and (o["time"] - merged[-1]["time"]).total_seconds() < MERGE_H * 3600:
merged[-1]["wet"] += o["wet"]; merged[-1]["foot"] += o["foot"]; merged[-1]["sensor"] += " + " + o["sensor"]
else:
merged.append(o)
return [dict(o, wet=shapely.union_all(o["wet"]), foot=shapely.union_all(o["foot"])) for o in merged]
class Grid:
def __init__(self, region):
x0, y0, x1, y1 = region.bounds
self.shape = (int(np.ceil((y1 - y0) / CELL)), int(np.ceil((x1 - x0) / CELL)))
self.tf = from_origin(x0, y1, CELL, CELL)
self.inside = self.raster(region)
def raster(self, geom):
if geom.is_empty:
return np.zeros(self.shape, bool)
return rasterize([geom], out_shape=self.shape, transform=self.tf, dtype="uint8").astype(bool)
def centres(self, mask):
r, c = np.nonzero(mask)
x, y = self.tf * (c + 0.5, r + 0.5)
return np.c_[x, y]
def simulate(a, b, area, region):
"""Per-cell flip time (fraction 0..1 of the interval) for every cell that changes between a and b."""
g = Grid(region)
wa, wb = g.raster(a["wet"]) & g.inside, g.raster(b["wet"]) & g.inside
flip = np.full(g.shape, np.nan, "float32")
for m, lowest_first in ((wa & ~wb, False), (~wa & wb, True)): # receding: highest dries first
if m.any():
hand = area.features(g.centres(m))["hand"].to_numpy()
rank = pd.Series(hand).rank(method="first", ascending=lowest_first).to_numpy()
flip[m] = (rank - 0.5) / len(rank)
return dict(grid=g, wa=wa, wb=wb, flip=flip)
def extent_at(sim, f):
wa, wb, flip = sim["wa"], sim["wb"], sim["flip"]
return (wa & wb) | (wa & ~wb & (flip > f)) | (~wa & wb & (flip <= f))
def morph_at(wa, wb, f):
"""Baseline: blend the signed distance to each shoreline, ignoring terrain."""
sdf = lambda m: distance_transform_edt(~m) - distance_transform_edt(m)
return ((1 - f) * sdf(wa) + f * sdf(wb)) <= 0
def iou(pred, true, inside):
p, t = pred & inside, true & inside
union = (p | t).sum()
return (p & t).sum() / union if union else np.nan
def write_outputs(sim, a, b, name, step_h):
g, hours = sim["grid"], (b["time"] - a["time"]).total_seconds() / 3600
state = np.zeros(g.shape, "uint8")
state[sim["wa"] & sim["wb"]] = 1; state[sim["wa"] & ~sim["wb"]] = 2; state[~sim["wa"] & sim["wb"]] = 3
state[~g.inside] = 255
with rasterio.open(OUT / f"{name}.tif", "w", driver="GTiff", height=g.shape[0], width=g.shape[1], count=2,
dtype="float32", crs="EPSG:3035", transform=g.tf, nodata=-1, compress="deflate") as dst:
dst.write(np.where(state == 255, -1, state.astype("float32")), 1) # 0 dry, 1 wet both, 2 receding, 3 rising
dst.write(np.where(np.isnan(sim["flip"]), -1, sim["flip"] * hours), 2) # hours after t0 the cell flips
dst.set_band_description(1, "state"); dst.set_band_description(2, "flip_hours")
rows, polys = [], []
steps = sorted(set(np.r_[np.arange(0, hours, step_h), hours]))
for h in steps:
m = extent_at(sim, h / hours)
t = a["time"] + dt.timedelta(hours=float(h))
rows.append(dict(pair=name, time=t, hours=h, flooded_km2=m.sum() * CELL ** 2 / 1e6))
geoms = [shapely.geometry.shape(s) for s, v in shapes(m.astype("uint8"), mask=m, transform=g.tf) if v]
polys.append(gpd.GeoDataFrame(dict(time=[t.isoformat()], hours=[h]), geometry=[shapely.union_all(geoms)], crs=3035))
pd.concat(polys).to_file(OUT / "extents.gpkg", layer=name, driver="GPKG")
return rows
if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
p.add_argument("--step", type=float, default=6, help="hours between simulated extents")
args = p.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
series, checks = [], []
for monit in sorted(DATA.glob("ems_*/*_MONIT01_*_vector")):
product = next(monit.parent.glob(monit.name.split("_MONIT01_")[0] + "_PRODUCT_*_vector"))
obs = observations([product, monit])
area = Area(product)
aoi = area.aoi
print(f"\n== {area.name}: " + ", ".join(f"{o['time']:%d/%m %H:%M} {o['sensor']}" for o in obs), flush=True)
# Consecutive pairs: the simulation
for i, (a, b) in enumerate(zip(obs, obs[1:])):
region = shapely.intersection_all([a["foot"], b["foot"], aoi])
if region.area < 1e6:
print(f" {a['time']:%d/%m} -> {b['time']:%d/%m}: footprints barely overlap, skipped")
continue
sim = simulate(a, b, area, region)
name = f"{area.name}_{a['time']:%d%H}_{b['time']:%d%H}"
series += write_outputs(sim, a, b, name, args.step)
print(f" {name}: overlap {region.area / 1e6:.0f} km², flooded {sim['wa'].sum() * CELL**2 / 1e6:.2f} -> "
f"{sim['wb'].sum() * CELL**2 / 1e6:.2f} km²")
# Triples: hide the middle observation and predict it from the outer two
for a, mid, b in itertools.combinations(obs, 3):
region = shapely.intersection_all([a["foot"], mid["foot"], b["foot"], aoi])
if region.area < 1e6:
continue
sim = simulate(a, b, area, region)
f = (mid["time"] - a["time"]) / (b["time"] - a["time"])
truth, inside = sim["grid"].raster(mid["wet"]), sim["grid"].inside
nearest = sim["wa"] if f < 0.5 else sim["wb"]
checks.append(dict(area=area.name, predicted=f"{mid['time']:%d/%m %H:%M}", fraction=round(f, 2),
overlap_km2=region.area / 1e6,
iou_terrain=iou(extent_at(sim, f), truth, inside),
iou_morph=iou(morph_at(sim["wa"], sim["wb"], f), truth, inside),
iou_nearest=iou(nearest, truth, inside)))
del area
pd.DataFrame(series).to_csv(OUT / "timeseries.csv", index=False)
pd.set_option("display.width", 160)
if checks:
print("\nValidation: predict the middle image from the outer two (IoU, 1 = perfect)")
print(pd.DataFrame(checks).round(3).to_string(index=False))
else:
print("\nNo area has three overlapping observations; the method could not be validated")
print(f"\nWrote {OUT}/extents.gpkg, timeseries.csv and per-pair state/flip-time rasters")
|