earthdiff 0.2.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.
earthdiff/xlsx.py ADDED
@@ -0,0 +1,183 @@
1
+ """A small, dependency-free .xlsx writer (Office Open XML) for the change report, and the workbook earthdiff writes.
2
+
3
+ Sheets: summary (what was compared, totals, warnings), changes (one row per polygon, with a plain-language
4
+ description and a map link) and scenes. Numbers are real numbers in Excel, so they can be sorted, filtered and summed."""
5
+ from __future__ import annotations
6
+
7
+ import io
8
+ import re
9
+ import zipfile
10
+ from typing import Any, Iterable
11
+ from xml.sax.saxutils import escape
12
+
13
+ from .i18n import CAUSE_SHORT, STRENGTH, class_label, describe
14
+
15
+ _BAD = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
16
+ _SHEET_BAD = re.compile(r"[\[\]:*?/\\]") # characters Excel does not allow in sheet names
17
+ H, NUM2, NUM3, LINK, WRAP = 1, 2, 3, 4, 5 # cell styles
18
+
19
+
20
+ class Link:
21
+ def __init__(self, url: str, text: str):
22
+ self.url, self.text = url, text
23
+
24
+
25
+ class Styled:
26
+ def __init__(self, value: Any, style: int):
27
+ self.value, self.style = value, style
28
+
29
+
30
+ def _col(i: int) -> str:
31
+ s = ""
32
+ i += 1
33
+ while i:
34
+ i, r = divmod(i - 1, 26)
35
+ s = chr(65 + r) + s
36
+ return s
37
+
38
+
39
+ def _cell(ref: str, v: Any, style: int = 0) -> str:
40
+ if isinstance(v, Styled):
41
+ return _cell(ref, v.value, v.style)
42
+ st = f' s="{style}"' if style else ""
43
+ if v is None or v == "":
44
+ return f'<c r="{ref}"{st}/>' if style else ""
45
+ if isinstance(v, bool):
46
+ v = str(v)
47
+ if isinstance(v, (int, float)):
48
+ if v != v or v in (float("inf"), float("-inf")): # NaN / inf: leave the cell empty
49
+ return ""
50
+ return f'<c r="{ref}"{st}><v>{v}</v></c>'
51
+ if isinstance(v, Link):
52
+ f = 'HYPERLINK("%s","%s")' % (v.url.replace('"', ""), v.text.replace('"', ""))
53
+ return f'<c r="{ref}" s="{LINK}" t="str"><f>{escape(f)}</f><v>{escape(v.text)}</v></c>'
54
+ text = escape(_BAD.sub("", str(v)))
55
+ return f'<c r="{ref}" t="inlineStr"{st}><is><t xml:space="preserve">{text}</t></is></c>'
56
+
57
+
58
+ def _sheet(rows: list[list[Any]], widths: list[float] | None, header: bool, styles: list[int] | None) -> str:
59
+ out = []
60
+ for r, row in enumerate(rows, start=1):
61
+ cells = []
62
+ for c, v in enumerate(row):
63
+ style = H if (header and r == 1) else ((styles[c] if styles and c < len(styles) else 0) if not isinstance(v, Styled) else 0)
64
+ cells.append(_cell(f"{_col(c)}{r}", v, style))
65
+ out.append(f'<row r="{r}">{"".join(cells)}</row>')
66
+ cols = "".join(f'<col min="{i + 1}" max="{i + 1}" width="{w}" customWidth="1"/>' for i, w in enumerate(widths or []))
67
+ ncol = max((len(r) for r in rows), default=1)
68
+ view = '<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>' if header else ""
69
+ filt = f'<autoFilter ref="A1:{_col(ncol - 1)}{len(rows)}"/>' if header and len(rows) > 1 else ""
70
+ return ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
71
+ '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
72
+ f'{view}{"<cols>" + cols + "</cols>" if cols else ""}<sheetData>{"".join(out)}</sheetData>{filt}</worksheet>')
73
+
74
+
75
+ STYLES = ('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
76
+ '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
77
+ '<numFmts count="2"><numFmt numFmtId="164" formatCode="0.00"/><numFmt numFmtId="165" formatCode="0.000"/></numFmts>'
78
+ '<fonts count="3"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font>'
79
+ '<font><u/><color rgb="FF0563C1"/><sz val="11"/><name val="Calibri"/></font></fonts>'
80
+ '<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>'
81
+ '<fill><patternFill patternType="solid"><fgColor rgb="FFDCE6F2"/><bgColor indexed="64"/></patternFill></fill></fills>'
82
+ '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
83
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
84
+ '<cellXfs count="6"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
85
+ '<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
86
+ '<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
87
+ '<xf numFmtId="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
88
+ '<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
89
+ '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment wrapText="1" vertical="top"/></xf></cellXfs>'
90
+ '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>')
91
+
92
+
93
+ def workbook_bytes(sheets: Iterable[dict[str, Any]]) -> bytes:
94
+ """``sheets``: dicts with name, rows (list of lists), optional widths, header (bool), styles (per column)."""
95
+ sheets = list(sheets)
96
+ buf = io.BytesIO()
97
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
98
+ over = "".join(f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
99
+ for i in range(1, len(sheets) + 1))
100
+ z.writestr("[Content_Types].xml",
101
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
102
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>'
103
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
104
+ f'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>{over}</Types>')
105
+ z.writestr("_rels/.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
106
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
107
+ clean = [escape(_SHEET_BAD.sub(" ", s["name"])[:31]) for s in sheets] # outside the f-string: Python 3.11 allows no backslash there
108
+ names = "".join(f'<sheet name="{n}" sheetId="{i}" r:id="rId{i}"/>' for i, n in enumerate(clean, start=1))
109
+ z.writestr("xl/workbook.xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
110
+ f'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{names}</sheets></workbook>')
111
+ rels = "".join(f'<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>'
112
+ for i in range(1, len(sheets) + 1))
113
+ rels += f'<Relationship Id="rId{len(sheets) + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
114
+ z.writestr("xl/_rels/workbook.xml.rels", f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>')
115
+ z.writestr("xl/styles.xml", STYLES)
116
+ for i, s in enumerate(sheets, start=1):
117
+ z.writestr(f"xl/worksheets/sheet{i}.xml", _sheet(s["rows"], s.get("widths"), s.get("header", True), s.get("styles")))
118
+ return buf.getvalue()
119
+
120
+
121
+ TXT = {
122
+ "en": {"summary": "Summary", "changes": "Changes", "scenes": "Scenes", "item": "Item", "value": "Value",
123
+ "name": "Name", "before": "Before (baseline)", "after": "After", "area": "Area (km²)", "res": "Pixel size (m)",
124
+ "comparable": "Comparable share of the area (%)", "scenes_n": "Scenes used (before + after)", "mmu": "Smallest change mapped (ha)",
125
+ "sens": "Sensitivity", "total": "Total", "polys": "polygons", "cause": "Vegetation loss, likely cause (a hint)", "warn": "Warning",
126
+ "note": "Note", "note_v": "Automated screening from free Sentinel-2 imagery: verify important findings with higher-resolution data or in the field.",
127
+ "data": "Data", "made": "Made with", "cols": ["#", "What changed", "Description", "Area (ha)", "Strength", "Confidence (0-1)", "Likely cause", "Seasonal",
128
+ "NDVI before", "NDVI after", "MNDWI before", "MNDWI after", "BSI before", "BSI after", "dNBR", "When (largest drop)", "Latitude", "Longitude", "Map"],
129
+ "scols": ["Period", "Scene", "Date", "Scene cloud (%)", "Cloud-free share of the area (%)"], "yes": "yes", "map": "open map"},
130
+ "tr": {"summary": "Özet", "changes": "Değişimler", "scenes": "Sahneler", "item": "Konu", "value": "Değer",
131
+ "name": "Ad", "before": "Önce (karşılaştırma)", "after": "Sonra", "area": "Alan (km²)", "res": "Piksel boyutu (m)",
132
+ "comparable": "Alanın karşılaştırılabilir payı (%)", "scenes_n": "Kullanılan sahneler (önce + sonra)", "mmu": "Haritalanan en küçük değişim (ha)",
133
+ "sens": "Duyarlılık", "total": "Toplam", "polys": "poligon", "cause": "Bitki kaybı, olası neden (ipucu)", "warn": "Uyarı",
134
+ "note": "Not", "note_v": "Ücretsiz Sentinel-2 görüntüleriyle otomatik tarama: önemli bulguları daha yüksek çözünürlüklü veriyle ya da arazide doğrulayın.",
135
+ "data": "Veri", "made": "Üreten", "cols": ["#", "Ne değişti", "Açıklama", "Alan (ha)", "Güç", "Güven (0-1)", "Olası neden", "Mevsimsel",
136
+ "NDVI önce", "NDVI sonra", "MNDWI önce", "MNDWI sonra", "BSI önce", "BSI sonra", "dNBR", "Ne zaman (en büyük düşüş)", "Enlem", "Boylam", "Harita"],
137
+ "scols": ["Dönem", "Sahne", "Tarih", "Sahne bulut (%)", "Alanın bulutsuz payı (%)"], "yes": "evet", "map": "haritada aç"},
138
+ }
139
+
140
+
141
+ def report_workbook(lang: str, meta: dict, totals: dict, warnings: list[str], geojson: dict, scenes: list[dict],
142
+ extras: dict | None, attribution: str, terrain_attribution: str | None = None) -> bytes:
143
+ lang = "tr" if lang == "tr" else "en"
144
+ t = TXT[lang]
145
+ summary: list[list[Any]] = [[t["item"], t["value"]],
146
+ [t["name"], meta.get("name")], [t["before"], meta.get("before_label")], [t["after"], meta.get("after_label")],
147
+ [t["area"], Styled(meta.get("aoi_km2"), NUM2)], [t["res"], meta.get("resolution_m")],
148
+ [t["comparable"], Styled(round(100 * float(meta.get("valid_fraction", 0)), 1), NUM2)],
149
+ [t["scenes_n"], f'{meta.get("scenes_before")} + {meta.get("scenes_after")}'],
150
+ [t["mmu"], meta.get("mmu_ha")], [t["sens"], meta.get("sensitivity")]]
151
+ for key, tt in totals.items():
152
+ summary.append([f'{t["total"]}: {class_label(key, lang)} (ha)', Styled(round(float(tt.get("area_ha", 0)), 2), NUM2)])
153
+ summary.append([f'{t["total"]}: {class_label(key, lang)} ({t["polys"]})', int(tt.get("polygons", 0))])
154
+ cause = (extras or {}).get("cause")
155
+ if cause:
156
+ for k, v in cause.get("totals", {}).items():
157
+ summary.append([f'{t["cause"]}: {CAUSE_SHORT[lang].get(k, k)} (ha)', Styled(round(float(v.get("area_ha", 0)), 2), NUM2)])
158
+ for w in warnings:
159
+ summary.append([t["warn"], Styled(w, WRAP)])
160
+ summary.append([t["note"], Styled(t["note_v"], WRAP)])
161
+ summary.append([t["data"], Styled(attribution + ("; " + terrain_attribution if terrain_attribution else ""), WRAP)])
162
+ summary.append([t["made"], f'earthdiff {meta.get("version")} ({meta.get("generated")})'])
163
+
164
+ feats = sorted(geojson.get("features", []), key=lambda f: -float(f["properties"].get("area_ha", 0)))
165
+ changes: list[list[Any]] = [t["cols"]]
166
+ for f in feats:
167
+ p = f["properties"]
168
+ lat, lon = p.get("centroid_lat"), p.get("centroid_lon")
169
+ url = f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}"
170
+ changes.append([p.get("id"), class_label(p.get("class", ""), lang), describe(p, lang), p.get("area_ha"),
171
+ STRENGTH[lang].get(p.get("confidence_level"), p.get("confidence_level")), p.get("confidence"),
172
+ CAUSE_SHORT[lang].get(p.get("likely_cause"), p.get("likely_cause") or ""), t["yes"] if p.get("likely_seasonal") is True else "",
173
+ p.get("ndvi_before"), p.get("ndvi_after"), p.get("mndwi_before"), p.get("mndwi_after"), p.get("bsi_before"), p.get("bsi_after"),
174
+ p.get("dnbr"), (f'{p["event_before"]} - {p["event_after"]}' if p.get("event_after") else ""), lat, lon, Link(url, t["map"])])
175
+ sc_rows: list[list[Any]] = [t["scols"]]
176
+ for s in scenes:
177
+ sc_rows.append([s.get("period"), s.get("id"), str(s.get("date", ""))[:10], s.get("cloud_cover"), round(100 * float(s.get("valid_fraction", 0)), 1)])
178
+ return workbook_bytes([
179
+ {"name": t["summary"], "rows": summary, "widths": [44, 90], "header": True},
180
+ {"name": t["changes"], "rows": changes, "widths": [6, 22, 70, 10, 10, 10, 14, 10, 10, 10, 11, 11, 10, 10, 8, 24, 11, 11, 13],
181
+ "styles": [0, WRAP, WRAP, NUM2, 0, NUM2, 0, 0, NUM3, NUM3, NUM3, NUM3, NUM3, NUM3, NUM3, 0, 0, 0, 0]},
182
+ {"name": t["scenes"], "rows": sc_rows, "widths": [34, 44, 12, 14, 26]},
183
+ ])
earthdiff/yearly.py ADDED
@@ -0,0 +1,109 @@
1
+ """In-between years for the report timeline.
2
+
3
+ When the before and after windows are years apart, the timeline would jump straight from one to the other. For every
4
+ year in between, this module takes the same season as the after window, finds the clearest Sentinel-2 pass and reads
5
+ only a small true-colour frame (the same size as the other timeline frames, from the COG overviews). These frames are
6
+ for looking only: they do not change the analysis, which still compares the before and after composites."""
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from datetime import date, timedelta
11
+ from typing import Any, Callable
12
+
13
+ import numpy as np
14
+ from affine import Affine
15
+
16
+ from .aoi import Grid
17
+ from .composite import to_reflectance, valid_from_scl
18
+ from .periods import Period
19
+ from .render import to_mercator, true_color, webp_bytes
20
+ from .report import data_url
21
+
22
+ MAX_BETWEEN_YEARS = 10 # at most this many in-between frames (spread evenly when the gap is longer)
23
+ CANDIDATES_PER_YEAR = 3 # passes checked per year (least cloudy first), the clearest one is used
24
+ MIN_CLEAR = 0.5 # a pass must show at least half of the area cloud-free to be used
25
+ WIDEN_DAYS = 31 # a year with no clear pass is retried with its window widened by this much on each side
26
+ FIRST_DATA = date(2016, 11, 1) # Sentinel-2 L2A COGs start around then
27
+
28
+
29
+ def between_periods(before: Period, after: Period) -> list[Period]:
30
+ """The after window shifted back by 1, 2, ... years, as long as it lies strictly between the two windows."""
31
+ out: list[Period] = []
32
+ k = 1
33
+ while k < 60:
34
+ p = after.shift_years(-k)
35
+ if p.end < before.end or p.start <= before.end:
36
+ break
37
+ if p.end < after.start:
38
+ out.append(p)
39
+ k += 1
40
+ out.reverse()
41
+ out = [p for p in out if p.end >= FIRST_DATA] # Sentinel-2 L2A COGs start in 2016-2017: earlier years would only cost searches
42
+ if len(out) > MAX_BETWEEN_YEARS:
43
+ idx = sorted({round(i * (len(out) - 1) / (MAX_BETWEEN_YEARS - 1)) for i in range(MAX_BETWEEN_YEARS)})
44
+ out = [out[i] for i in idx]
45
+ return out
46
+
47
+
48
+ def coarse_grid(grid: Grid, step: int) -> Grid:
49
+ return Grid(grid.epsg, grid.transform * Affine.scale(step), math.ceil(grid.width / step), math.ceil(grid.height / step), grid.res * step)
50
+
51
+
52
+ def between_frames(geom, grid: Grid, mask: np.ndarray, before: Period, after: Period, backend: str, max_cloud: float,
53
+ step: int, search, reader, say: Callable[[str], None] | None = None, with_nbr: bool = True) -> dict[str, Any]:
54
+ """Frames for the timeline and, with ``with_nbr``, a series point per year (NBR, NDVI, brightness) for ``history.py``."""
55
+ say = say or (lambda m: None)
56
+ periods = between_periods(before, after)
57
+ if not periods:
58
+ return {"frames": [], "series": []}
59
+ g = coarse_grid(grid, step)
60
+ sub = mask[::step, ::step]
61
+ n = max(1, int(sub.sum()))
62
+ say(f"in-between years: one small frame for each of {len(periods)} year(s) ({periods[0].start.year}-{periods[-1].start.year})")
63
+ frames: list[dict[str, Any]] = []
64
+ series: list[dict[str, Any]] = []
65
+ def pick(p: Period, k: int):
66
+ try:
67
+ cands = sorted(search(backend, geom, p, max_cloud), key=lambda s: s.cloud_cover)[:k]
68
+ except Exception: # a failed search only costs one frame
69
+ return None
70
+ best = None
71
+ for sc in cands:
72
+ try:
73
+ valid = valid_from_scl(reader(sc, "scl", g), g.res) & sub
74
+ except Exception:
75
+ continue
76
+ share = float(valid.sum()) / n
77
+ if share >= MIN_CLEAR and (best is None or share > best[1]):
78
+ best = (sc, share, valid)
79
+ return best
80
+
81
+ for p in periods:
82
+ best = pick(p, CANDIDATES_PER_YEAR)
83
+ if best is None:
84
+ # nothing clear enough (cloud, smoke of the very fire we are after...): widen the window by a month on each side, inside the gap
85
+ wide = Period(max(p.start - timedelta(days=WIDEN_DAYS), before.end + timedelta(days=1)), min(p.end + timedelta(days=WIDEN_DAYS), after.start - timedelta(days=1)))
86
+ if wide.start <= wide.end and (wide.start, wide.end) != (p.start, p.end):
87
+ best = pick(wide, CANDIDATES_PER_YEAR + 3)
88
+ if best is None:
89
+ continue
90
+ sc, share, valid = best
91
+ try:
92
+ bands = {}
93
+ for b in ("red", "green", "blue") + (("nir", "swir22") if with_nbr else ()):
94
+ dn = reader(sc, b, g)
95
+ scale, offset = sc.radiometry.get(b, (1e-4, 0.0))
96
+ refl = to_reflectance(dn, scale, offset)
97
+ refl[(dn == 0) | ~valid] = np.nan
98
+ bands[b] = refl
99
+ except Exception:
100
+ if not all(b in bands for b in ("red", "green", "blue")):
101
+ continue
102
+ img, _ = to_mercator(true_color(bands, sub), g, max_dim=None)
103
+ if with_nbr and "nir" in bands and "swir22" in bands:
104
+ from .history import point
105
+
106
+ series.append(point("between", sc.date[:10], bands))
107
+ frames.append({"period": "between", "date": sc.date[:10], "scene": sc.id, "clear_fraction": round(share, 3),
108
+ "img": data_url(webp_bytes(img, quality=80), "image/webp")})
109
+ return {"frames": frames, "series": series}