File size: 7,217 Bytes
a74054f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Diagnose the confluence count in a saved reach graph: real tributary
junctions, or a data artifact (e.g. the same physical reach digitized
twice, or a tile-boundary duplication in BD TOPO).

Works two ways:
  1. Against your EXISTING risle_nodes.csv/risle_edges.csv (no rerun
     needed) -- checks whether each flagged confluence's incoming edges
     have suspiciously similar distance_km/elevation_drop_m, which is
     the signature of two near-identical tronçons rather than two
     genuinely different ones.
  2. Conclusively, once you rerun scripts/build_reach_graphs.py with the
     cleabs fix -- if two incoming edges at the same node share the same
     cleabs (tronçon ID), that's definitive: same tronçon counted twice,
     not two different ones.

Usage:
    python -m scripts.diagnose_confluences --data-root datasets --basin risle
"""
import argparse
from pathlib import Path

import pandas as pd


def diagnose(nodes_path: Path, edges_path: Path, n_samples: int = 10) -> None:
    nodes_df = pd.read_csv(nodes_path)
    edges_df = pd.read_csv(edges_path)

    has_is_confluence = "is_confluence" in nodes_df.columns
    has_toponym = "toponym" in edges_df.columns
    has_cleabs = "cleabs" in edges_df.columns

    if has_is_confluence:
        confluence_codes = set(nodes_df[nodes_df["is_confluence"]]["station_code"])
        confluences = edges_df[edges_df["target"].isin(confluence_codes)]["target"].value_counts()
        raw_in_degree = edges_df["target"].value_counts()
        raw_confluences = raw_in_degree[raw_in_degree >= 2]
        print(f"{len(confluence_codes)} REAL confluence(s) -- different named river, independent "
              f"upstream source, not a split that rejoins downstream -- out of "
              f"{len(raw_confluences)} node(s) with raw in-degree >= 2 "
              f"({len(nodes_df)} total nodes, {len(edges_df)} edges)")
        print(f"  The gap ({len(raw_confluences) - len(confluence_codes)} nodes) is same-river "
              f"segmentation and/or braided channels that split and rejoin -- both excluded "
              f"from the count above, using the authoritative is_confluence column computed "
              f"with full graph topology (this script can't re-derive the split-rejoin check "
              f"from the flat CSV alone, which is exactly why that column exists).")
    elif has_toponym:
        name_counts = edges_df.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique()
        confluences = name_counts[name_counts > 1]
        raw_in_degree = edges_df["target"].value_counts()
        raw_confluences = raw_in_degree[raw_in_degree >= 2]
        print(f"{len(confluences)} REAL confluence(s) (different named river actually joining), "
              f"out of {len(raw_confluences)} node(s) with raw in-degree >= 2 "
              f"({len(nodes_df)} total nodes, {len(edges_df)} edges)")
        print(f"  The gap ({len(raw_confluences) - len(confluences)} nodes) is same-river fine "
              f"tronçon segmentation, not real branching -- this is now excluded from the count.")
        print("  NOTE: this file predates the split-rejoin fix (no is_confluence column) -- a "
              "braided channel with differently-named branches that rejoin downstream would "
              "still be miscounted here as a real confluence. Re-run "
              "scripts/build_reach_graphs.py for the corrected count.")
    else:
        in_degree = edges_df["target"].value_counts()
        confluences = in_degree[in_degree >= 2]
        print(f"{len(confluences)} node(s) with in-degree >= 2, out of {len(nodes_df)} total nodes "
              f"and {len(edges_df)} edges")
        print()
        print("NOTE: this edges.csv doesn't have a 'toponym' column -- it was generated before "
              "the name-based confluence fix. Every number above uses the old, less reliable "
              "in-degree >= 2 rule. Re-run scripts/build_reach_graphs.py for a corrected count "
              "and for this script to give a definitive per-node answer instead of a proxy.")
    print()

    sample = confluences.head(n_samples)
    n_confirmed_duplicate = 0
    n_suspicious = 0

    for node in sample.index:
        incoming = edges_df[edges_df["target"] == node]
        cols = ["source", "target", "distance_km", "elevation_drop_m"]
        cols += [c for c in ("toponym", "cleabs") if c in incoming.columns]
        print(f"Node {node}: {len(incoming)} incoming edge(s)")
        print(incoming[cols].to_string(index=False))

        if has_toponym:
            n_names = incoming["toponym"].nunique()
            if n_names > 1:
                print(f"  -> REAL: {n_names} distinct named river(s) join here.")
            else:
                print(f"  -> Same toponym on every incoming edge -- not counted as a real "
                      f"confluence (this shouldn't appear in the sample above; if it does, "
                      f"something's inconsistent between this script and build_reach_graph.py).")
        elif has_cleabs and incoming["cleabs"].notna().all():
            if incoming["cleabs"].nunique() < len(incoming):
                print("  -> CONFIRMED DUPLICATE: two+ incoming edges share the same cleabs "
                      "(same physical tronçon counted more than once).")
                n_confirmed_duplicate += 1
        else:
            dists = incoming["distance_km"].dropna()
            if len(dists) >= 2 and (dists.max() - dists.min()) < 0.05:
                print("  -> SUSPICIOUS: incoming edges have near-identical distance_km "
                      "(within 50m) -- consistent with the same reach being counted twice, "
                      "not two distinct tributaries joining.")
                n_suspicious += 1
        print()

    print("=" * 60)
    if has_toponym:
        print(f"All {len(sample)} sampled nodes have >1 distinct named river joining -- "
              f"that's the definition being used, so this should always be 100%. If any "
              f"printed 'Same toponym' above, report it as a bug.")
    elif has_cleabs:
        print(f"Of {len(sample)} sampled confluences: {n_confirmed_duplicate} confirmed "
              f"duplicate-tronçon artifacts, {len(sample) - n_confirmed_duplicate} have "
              f"genuinely distinct incoming tronçons (real confluence candidates).")
    else:
        print(f"Of {len(sample)} sampled confluences: {n_suspicious} look suspicious "
              f"(near-identical incoming edge distances). Re-run scripts/build_reach_graphs.py "
              f"for a conclusive, name-based answer instead of this proxy signal.")


def main() -> None:
    parser = argparse.ArgumentParser(description="Diagnose reach-graph confluence counts")
    parser.add_argument("--data-root", type=Path, default=Path("datasets"))
    parser.add_argument("--basin", choices=["eure", "risle"], required=True)
    parser.add_argument("--n-samples", type=int, default=10)
    args = parser.parse_args()

    graph_dir = args.data_root / "reach_graph"
    diagnose(graph_dir / f"{args.basin}_nodes.csv", graph_dir / f"{args.basin}_edges.csv",
              n_samples=args.n_samples)


if __name__ == "__main__":
    main()