File size: 13,121 Bytes
695f088
 
 
 
 
71dc245
 
 
695f088
 
 
8a28d42
 
 
 
695f088
8a28d42
 
 
 
 
 
 
 
 
 
695f088
 
 
 
 
 
 
 
8a28d42
695f088
 
 
 
 
 
 
 
8a28d42
 
 
695f088
 
 
 
 
 
71dc245
695f088
 
 
 
 
 
 
 
 
71dc245
 
 
 
695f088
 
 
 
 
 
71dc245
 
 
 
 
 
 
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695f088
8a28d42
695f088
 
 
 
 
8a28d42
695f088
 
8a28d42
 
 
 
 
 
 
 
 
 
 
 
 
 
695f088
8a28d42
695f088
8a28d42
 
695f088
 
 
 
8a28d42
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""Footprint index of each Core dataset, for the Raster Viewer: where its samples are and where each one is stored.

    <core>/Core-<name>/metadata.parquet (one row per sample: grid_row_u, grid_col_r, parquet_url, parquet_row, ...)
      -> parquet_url -> part number (every URL is <base>/images/part_NNNNN.parquet; checked)
      -> rows sorted by grid_row_u, grid_col_r
      -> footprints/<name>.parquet: grid_row_u, grid_col_r, part, parquet_row, timestamp, + cloud_cover / orbit_state,
         centre_lat, centre_lon, epsg: every image is 10,680 m square in its UTM zone (epsg), centred there exactly
         (checked against the GeoTIFF bounds), so the app places it on the map without reading a band
         row groups of 65,536 rows with statistics and a page index, no value dictionaries (as the climate files):
         the app reads the two position columns for the coverage map and, for a click, the page holding the cell
      -> footprints/datasets.json: per dataset its label, the URL base of its image files, its samples
    Core-VIIRS-Nighttime-Light/INDEX.parquet on Hugging Face (one row per tile: id MT10_<cell>, year, shard, offset,
    size, in no cell order)
      -> footprints/VIIRS.parquet: grid_row_u, grid_col_r, year, shard, offset, size, sorted by cell; the app reads a
         tile with one range request into its zip shard (viirs.js)

    AlphaEarth (tge-labs/aef on source.coop): aef_index.parquet, one row per COG tile and year (81,920 m UTM squares,
    the same 34,266 squares every year) + the Core-S2L2A cells
      -> footprints/AEF.parquet: per cell grid_row_u, grid_col_r, centre_lat, centre_lon, year (its Sentinel-2 year,
         within 2017-2025), tile_row, tile_rows: lists, one entry per tile its 10,680 m window touches (the tile
         holding the centre first, then those under the window's corners in the same UTM zone; a zone's tiles share
         one 81,920 m grid): where that tile's rows are in AEF-tiles.parquet; the tile holding the
         cell centre, in the cell's own UTM zone where there is one
      -> footprints/AEF-tiles.parquet: tile, year, epsg, west, north, path: one row per tile and year, by tile

    python build_index.py /data/Major-TOM <out folder> [name ...]   (Slurm, .venv311; names: rebuild only those)
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import fsspec
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq

DATASETS = {"S2L2A": "Sentinel-2 L2A", "S2L1C": "Sentinel-2 L1C", "S1RTC": "Sentinel-1 RTC", "DEM": "COP-DEM"}
EXTRA = ["timestamp", "cloud_cover", "orbit_state"]                      # kept where a dataset has them
ROW_GROUP = 65_536
VIIRS = "https://huggingface.co/datasets/Major-TOM/Core-VIIRS-Nighttime-Light/resolve/main/"
AEF = "https://data.source.coop/tge-labs/aef/v1/annual/"
AEF_TILE = 81_920                                            # metres: every AlphaEarth COG


def index(metadata: Path, name: str) -> tuple[pa.Table, str]:
    """The footprint table of one dataset, and the URL base of its image files."""
    have = pq.read_schema(metadata).names
    table = pq.read_table(metadata, columns=["grid_row_u", "grid_col_r", "parquet_url", "parquet_row",
                                             *[c for c in EXTRA if c in have], "centre_lat", "centre_lon", "crs"])
    base = f"https://huggingface.co/datasets/Major-TOM/Core-{name}/resolve/main/images/"
    urls = table.column("parquet_url").to_pylist()
    part = [re.fullmatch(re.escape(base) + r"part_(\d{5})\.parquet", u) for u in urls]
    if not all(part):
        raise ValueError(f"{metadata}: a parquet_url outside {base}part_NNNNN.parquet")
    out = pa.table({"grid_row_u": pc.cast(table.column("grid_row_u"), pa.int16()),
                    "grid_col_r": pc.cast(table.column("grid_col_r"), pa.int16()),
                    "part": pa.array(np.array([int(m[1]) for m in part], np.int16)),
                    "parquet_row": pc.cast(table.column("parquet_row"), pa.int16()),
                    **{c: table.column(c) for c in EXTRA if c in have},
                    "centre_lat": pc.cast(table.column("centre_lat"), pa.float32()),     # 4e-6 degrees: under 1 m
                    "centre_lon": pc.cast(table.column("centre_lon"), pa.float32()),
                    "epsg": pa.array(epsg(table.column("crs").to_pylist()), pa.int16())})
    if "cloud_cover" in out.column_names:
        out = out.set_column(out.schema.get_field_index("cloud_cover"), "cloud_cover",
                             pc.cast(out.column("cloud_cover"), pa.float32()))
    return out.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending")]), base


def epsg(crs: list[str]) -> np.ndarray:
    """EPSG codes from the metadata's crs strings ("EPSG:32701"; Core-S1RTC writes "EPSG:EPSG:32752")."""
    codes = np.array([int(c.rsplit(":", 1)[1]) for c in crs])
    if not np.all(((codes > 32600) & (codes <= 32660)) | ((codes > 32700) & (codes <= 32760))):
        raise ValueError("a crs outside the UTM zones (EPSG 32601-32660, 32701-32760)")
    return codes.astype(np.int16)


def viirs() -> pa.Table:
    """The VIIRS footprint table, from the dataset's INDEX.parquet: the cell from the tile id, the tile's place."""
    with fsspec.open(f"{VIIRS}INDEX.parquet", "rb", block_size=2**24) as f:
        table = pq.read_table(f, columns=["id", "year", "shard", "offset", "size"])
    parts = pc.extract_regex(table.column("id"), r"^MT10_(?P<r>\d+)(?P<u>[UD])_(?P<c>\d+)(?P<l>[LR])$")
    if parts.null_count:
        raise ValueError("a VIIRS id outside MT10_<row><U|D>_<col><L|R>")
    sign = lambda letter, plus: pc.if_else(pc.equal(pc.struct_field(parts, letter), plus), 1, -1)
    row = pc.multiply(pc.cast(pc.struct_field(parts, "r"), pa.int16()), pc.cast(sign("u", "U"), pa.int16()))
    col = pc.multiply(pc.cast(pc.struct_field(parts, "c"), pa.int16()), pc.cast(sign("l", "R"), pa.int16()))
    out = pa.table({"grid_row_u": row, "grid_col_r": col, "year": pc.cast(table.column("year"), pa.int16()),
                    "shard": table.column("shard"), "offset": pc.cast(table.column("offset"), pa.int64()),
                    "size": pc.cast(table.column("size"), pa.int32())})
    return out.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending"), ("year", "ascending")])


def aef(core: Path) -> tuple[pa.Table, pa.Table]:
    """AlphaEarth: the cell table (Core-S2L2A cells -> the tile holding each centre) and the tile table."""
    import pandas as pd
    from pyproj import Transformer

    with fsspec.open(f"{AEF}aef_index.parquet", "rb", block_size=2**24) as f:
        idx = pq.read_table(f, columns=["path", "year", "crs", "utm_west", "utm_south"]).to_pandas()
    idx["epsg"] = idx.crs.str.split(":").str[-1].astype(int)
    idx["path"] = idx.path.str.replace("s3://us-west-2.opendata.source.coop/tge-labs/aef/v1/annual/", "", regex=False)
    squares = idx.drop_duplicates(["epsg", "utm_west", "utm_south"])[["epsg", "utm_west", "utm_south"]]
    squares = squares.sort_values(["epsg", "utm_south", "utm_west"]).reset_index(drop=True)
    squares["tile"] = np.arange(len(squares), dtype=np.int32)
    tiles = idx.merge(squares, on=["epsg", "utm_west", "utm_south"]).sort_values(["tile", "year"]).reset_index(drop=True)

    cells = pq.read_table(core / "Core-S2L2A" / "metadata.parquet",
                          columns=["grid_row_u", "grid_col_r", "centre_lat", "centre_lon", "crs", "timestamp"]).to_pandas()
    cells["own"] = epsg(cells.crs.tolist()).astype(int)
    cells["tile"] = -1
    for own_zone_first in (True, False):                     # a cell near a zone edge may lie in a neighbour's square
        for code, group in squares.groupby("epsg"):
            pick = (cells.tile < 0) & ((cells.own == code) if own_zone_first else (abs(cells.own - code) == 1))
            if not pick.any():
                continue
            x, y = Transformer.from_crs(4326, int(code), always_xy=True).transform(
                cells.centre_lon[pick].to_numpy(), cells.centre_lat[pick].to_numpy())
            west, south = group.utm_west.to_numpy(), group.utm_south.to_numpy()
            inside = ((x[:, None] >= west) & (x[:, None] < west + AEF_TILE) &
                      (y[:, None] >= south) & (y[:, None] < south + AEF_TILE))
            hit = inside.any(1)
            cells.loc[cells.index[pick][hit], "tile"] = group.tile.to_numpy()[inside[hit].argmax(1)]
    cells = cells[cells.tile >= 0].reset_index(drop=True)
    first = tiles.reset_index().groupby("tile")["index"].agg(["min", "count"])
    lookup = {(e, w, s): k for e, w, s, k in squares[["epsg", "utm_west", "utm_south", "tile"]].itertuples(index=False)}
    parts = [[k] for k in cells.tile]                         # tiles of each cell's window, its own first
    own = squares.set_index("tile").loc[cells.tile]
    for code in np.unique(own.epsg):
        rows = np.flatnonzero(own.epsg.to_numpy() == code)
        x, y = Transformer.from_crs(4326, int(code), always_xy=True).transform(
            cells.centre_lon.to_numpy()[rows], cells.centre_lat.to_numpy()[rows])
        w0, s0 = own.utm_west.to_numpy()[rows], own.utm_south.to_numpy()[rows]
        for dx in (-5340, 5340):
            for dy in (-5340, 5340):
                west = w0 + np.floor((x + dx - w0) / AEF_TILE) * AEF_TILE
                south = s0 + np.floor((y + dy - s0) / AEF_TILE) * AEF_TILE
                for r, wv, sv in zip(rows[(west != w0) | (south != s0)], west[(west != w0) | (south != s0)],
                                     south[(west != w0) | (south != s0)]):
                    k = lookup.get((int(code), float(wv), float(sv)))
                    if k is not None and k not in parts[r]:
                        parts[r].append(k)
    year = cells.timestamp.str[:4].astype(int).clip(2017, 2025)
    cell_table = pa.table({"grid_row_u": pa.array(cells.grid_row_u.to_numpy(np.int16)),
                           "grid_col_r": pa.array(cells.grid_col_r.to_numpy(np.int16)),
                           "centre_lat": pa.array(cells.centre_lat.to_numpy(np.float32)),
                           "centre_lon": pa.array(cells.centre_lon.to_numpy(np.float32)),
                           "year": pa.array(year.to_numpy(np.int16)),
                           "tile_row": pa.array([[int(first.at[k, "min"]) for k in ks] for ks in parts], pa.list_(pa.int32())),
                           "tile_rows": pa.array([[int(first.at[k, "count"]) for k in ks] for ks in parts], pa.list_(pa.int16()))})
    counts = np.bincount([len(ks) for ks in parts])
    print("AEF tiles per cell window:", {n: int(c) for n, c in enumerate(counts) if c}, flush=True)
    tile_table = pa.table({"tile": pa.array(tiles.tile.to_numpy(np.int32)), "year": pa.array(tiles.year.to_numpy(np.int16)),
                           "epsg": pa.array(tiles.epsg.to_numpy(np.int32)),
                           "west": pa.array(tiles.utm_west.to_numpy(np.float64)),
                           "north": pa.array((tiles.utm_south + AEF_TILE).to_numpy(np.float64)),
                           "path": pa.array(tiles.path.tolist())})
    return cell_table.sort_by([("grid_row_u", "ascending"), ("grid_col_r", "ascending")]), tile_table


def write(table: pa.Table, path: Path) -> None:
    keys = ["grid_row_u", "grid_col_r", "orbit_state", "epsg", "shard"]
    pq.write_table(table, path, row_group_size=ROW_GROUP, compression="zstd", write_statistics=True,
                   write_page_index=True, use_dictionary=[c for c in keys if c in table.column_names],
                   data_page_size=4096)


def main(core: str, out: str, *only: str) -> None:
    core, out = Path(core), Path(out)
    out.mkdir(parents=True, exist_ok=True)
    listing = json.loads((out / "datasets.json").read_text()) if only else {}
    for name in only or [*DATASETS, "VIIRS", "AEF"]:
        if name == "AEF":
            table, tile_table = aef(core)
            write(tile_table, out / "AEF-tiles.parquet")
            entry = {"label": "AlphaEarth Embeddings", "kind": "aef", "files": AEF, "tiles": "AEF-tiles.parquet",
                     "years": [2017, 2025], "link": "https://source.coop/tge-labs/aef"}
        elif name == "VIIRS":
            table = viirs()
            entry = {"label": "VIIRS Nighttime Light", "kind": "viirs", "files": VIIRS,
                     "link": "https://huggingface.co/datasets/Major-TOM/Core-VIIRS-Nighttime-Light"}
        else:
            table, base = index(core / f"Core-{name}" / "metadata.parquet", name)
            entry = {"label": DATASETS[name], "images": base}
        write(table, out / f"{name}.parquet")
        listing[name] = {**entry, "samples": table.num_rows, "columns": table.column_names}
        print(f"{name}: {table.num_rows:,} samples -> {(out / f'{name}.parquet').stat().st_size / 1e6:.1f} MB", flush=True)
    order = [*DATASETS, "VIIRS", "AEF"]
    listing = {k: listing[k] for k in sorted(listing, key=order.index)}
    (out / "datasets.json").write_text(json.dumps(listing, indent=1) + "\n")


if __name__ == "__main__":
    main(*sys.argv[1:])