File size: 7,896 Bytes
19ff563
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""One-time prep: HydroBASINS pour points -> CONUS virtual-point catalog.

Virtual points fill the gaps between USGS gauges so every part of CONUS has a
simulatable hindcast outlet. Pfafstetter level 07 pour points (one per
sub-basin outlet, ~1,800 km2 nominal sub-basins) give seamless coverage at
~1/3 the density of the gauge network; points that duplicate an existing
gauge (same HydroRIVERS reach, or within GAUGE_DUP_KM) are dropped because
the gauge is strictly better there (it has observations).

Each point is snapped to its HydroRIVERS reach to inherit the modeled
drainage area (UPLAND_SKM) — EF5 uses that as BASINAREA to lock the outlet
onto the right flow-accumulation cell. Coastal aggregate sub-basins carry one
pour point per outlet; points below MIN_UPLAND_KM2 (beach-scale watersheds
the MRMS+HydroSHEDS setup can't resolve) are dropped, and only the nearest
point per reach survives.

Builds gauges/virtual_points.parquet in vincewin/CREST_data, consumed by
hf_data/virtualpoints.py. Ids are "V" + HYRIV_ID (unique after the per-reach
dedupe, and can never collide with 8-digit USGS ids).

    python scripts/prep_virtual_points.py [--level 07] [--dry-run]
"""
from __future__ import annotations

import argparse
import io
import os
import sys
import tempfile
import zipfile

import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import truststore
truststore.inject_into_ssl()

from forcing_update_common import HF_REPO, hf_token                             # noqa: E402

POUR_DIR = r"E:\hydroZone\pour_points"
OUT_PATH = "gauges/virtual_points.parquet"
BBOX = (-125.0, 24.0, -66.5, 50.0)          # CONUS
MIN_UPLAND_KM2 = 100.0
GAUGE_DUP_KM = 2.0
SNAP_RADIUS_DEG = 0.05
KM_PER_DEG = 111.0
NATION_URL = ("https://www2.census.gov/geo/tiger/GENZ2023/shp/"
              "cb_2023_us_nation_5m.zip")


def conus_polygon(cache_dir: str):
    """Census national boundary clipped to the CONUS bbox (drops AK/HI/PR)."""
    import requests
    import pyogrio
    from shapely.geometry import box
    shp = os.path.join(cache_dir, "cb_nation", "cb_2023_us_nation_5m.shp")
    if not os.path.exists(shp):
        os.makedirs(os.path.dirname(shp), exist_ok=True)
        r = requests.get(NATION_URL, timeout=120)
        r.raise_for_status()
        zipfile.ZipFile(io.BytesIO(r.content)).extractall(os.path.dirname(shp))
    nat = pyogrio.read_dataframe(shp)
    return nat.geometry.iloc[0].intersection(box(*BBOX))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--level", default="07")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()
    token = hf_token()

    import pyogrio
    from shapely.prepared import prep
    from scipy.spatial import cKDTree
    from huggingface_hub import HfApi, hf_hub_download, CommitOperationAdd

    cache = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                         "_cache")
    conus = prep(conus_polygon(cache))

    shp = os.path.join(POUR_DIR, f"hybas_pour_lev{args.level}_v1.shp")
    print("reading", shp, "…", flush=True)
    df = pyogrio.read_dataframe(shp, bbox=BBOX)
    inside = np.fromiter((conus.contains(g) for g in df.geometry), bool, len(df))
    df = df[inside]
    px = df.geometry.x.to_numpy("float64")
    py = df.geometry.y.to_numpy("float64")
    hyb = df.HYBAS_ID.to_numpy("int64")
    print(f"  {inside.sum():,} pour points inside CONUS (of {len(inside):,} in bbox)")

    # -- snap to HydroRIVERS reaches (drainage area + dedupe key) --------------
    riv = pq.read_table(hf_hub_download(HF_REPO, "rivers/hydrorivers_na.parquet",
                                        repo_type="dataset", token=token))
    rid = riv.column("id").to_numpy()
    rup = riv.column("upland").to_numpy()
    rord = riv.column("ord").to_numpy()
    lats_l = riv.column("lats").to_pylist()
    lons_l = riv.column("lons").to_pylist()
    flat_lon = np.concatenate([np.asarray(a, "float64") for a in lons_l])
    flat_lat = np.concatenate([np.asarray(a, "float64") for a in lats_l])
    owner = np.repeat(np.arange(len(rid)), [len(a) for a in lats_l])
    tree = cKDTree(np.column_stack([flat_lon, flat_lat]))
    dists, vidx = tree.query(np.column_stack([px, py]), k=1,
                             distance_upper_bound=SNAP_RADIUS_DEG, workers=-1)
    ok = np.isfinite(dists)
    reach_i = np.where(ok, owner[np.clip(vidx, 0, len(owner) - 1)], -1)
    up = np.where(ok, rup[np.clip(reach_i, 0, len(rup) - 1)], 0.0)
    keep = ok & (up >= MIN_UPLAND_KM2)
    print(f"  {keep.sum():,} snapped to a reach with >= {MIN_UPLAND_KM2:.0f} km² upland")

    # -- drop duplicates: same reach (keep nearest), gauged reach, near-gauge --
    cat = pq.read_table(hf_hub_download(HF_REPO, "gauges/gagesII_9322.parquet",
                                        repo_type="dataset", token=token))
    glat = cat.column("LAT_GAGE").to_numpy().astype("float64")
    glon = cat.column("LNG_GAGE").to_numpy().astype("float64")
    gid = np.array([str(s).zfill(8) for s in cat.column("STAID").to_pylist()])
    gtree = cKDTree(np.column_stack([glon, glat]))
    snap = pq.read_table(hf_hub_download(HF_REPO, "rivers/gauge_reach.parquet",
                                         repo_type="dataset", token=token))
    gauged_reaches = set(snap.column("reach_id").to_pylist())

    order = np.argsort(dists[keep])                  # nearest snap wins its reach
    cand = np.where(keep)[0][order]
    seen: set[int] = set()
    sel: list[int] = []
    n_dup_reach = n_gauged = 0
    for i in cand:
        ri = int(reach_i[i])
        if ri in seen:
            n_dup_reach += 1
            continue
        seen.add(ri)
        if int(rid[ri]) in gauged_reaches:
            n_gauged += 1                            # a USGS gauge owns this reach
            continue
        sel.append(i)
    sel = np.array(sel)
    gd, gi = gtree.query(np.column_stack([px[sel], py[sel]]), workers=-1)
    gd_km = gd * KM_PER_DEG
    far = gd_km >= GAUGE_DUP_KM
    print(f"  dropped: {n_dup_reach:,} same-reach dups, {n_gauged:,} on gauged "
          f"reaches, {(~far).sum():,} within {GAUGE_DUP_KM:.0f} km of a gauge")
    sel, gd_km, gi = sel[far], gd_km[far], gi[far]
    print(f"  -> {len(sel):,} virtual points")

    # -- gauge-local timezone for the hydrograph axis --------------------------
    from timezonefinder import TimezoneFinder
    tf = TimezoneFinder(in_memory=True)
    tzs = [tf.timezone_at(lng=float(px[i]), lat=float(py[i])) or "America/Chicago"
           for i in sel]

    ri = reach_i[sel]
    tbl = pa.table({
        "vp": [f"V{int(rid[r])}" for r in ri],
        "lat": py[sel],
        "lon": px[sel],
        "area_km2": rup[ri].astype("float32"),
        "reach_id": rid[ri].astype("int64"),
        "hybas_id": hyb[sel],
        "ord": rord[ri].astype("int8"),
        "near_gid": [gid[j] for j in gi],
        "near_km": gd_km.astype("float32"),
        "tz": tzs,
        "level": np.full(len(sel), int(args.level), "int8"),
    })
    a = rup[ri]
    print(f"  area km²: p10={np.percentile(a, 10):.0f} med={np.median(a):.0f} "
          f"p90={np.percentile(a, 90):.0f} max={a.max():.0f}")

    if args.dry_run:
        print("[dry-run: no upload]")
        return 0
    tmp = os.path.join(tempfile.mkdtemp(), "virtual_points.parquet")
    pq.write_table(tbl, tmp, compression="zstd")
    print("parquet:", round(os.path.getsize(tmp) / 1e6, 2), "MB")
    api = HfApi(token=token)
    api.create_commit(repo_id=HF_REPO, repo_type="dataset",
                      operations=[CommitOperationAdd(OUT_PATH, tmp)],
                      commit_message=f"virtual points lev{args.level}: "
                                     f"{len(sel):,} CONUS pour points")
    print("uploaded", OUT_PATH)
    return 0


if __name__ == "__main__":
    sys.exit(main())