# -*- coding: utf-8 -*-
"""L60 ため池監視カメラ及び水位計設置箇所一覧 単独 3 研究例分析
       — 広島県 (実質広島市) のため池監視装置 70 箇所 (CSV) を
         3 つの独立した研究角度で読み解く

カバー宣言:
  本記事は DoBoX のシリーズ「ため池監視カメラ及び水位計設置箇所一覧」 1 件
  (dataset_id = 1675) を <b>単独</b>で取り上げ、
  広島県内のため池監視装置 (= カメラ + 水位計の遠隔観測点) 70 箇所を
  3 つの独立した研究角度 (RQ1 / RQ2 / RQ3) で並列に分析する。
  Phase 4 農業遺構系の続編 = ため池系の<b>運用監視側</b>。
  L59 (ため池基本情報 = 属性側) と L45 (ため池浸水想定 = 幾何側) と並ぶ
  ため池系 3 番目の角度。

  「ため池監視カメラ及び水位計」 とは:
    広島県 (実運用は広島市) が ため池の決壊予兆を検知するため設置した
    <b>遠隔監視装置</b>。具体的には:
    - <b>カメラ</b>: ため池の堤体・水面を可視光で監視 (越流・崩落の視覚検知)
    - <b>水位計</b>: 水面高を 1 分間隔等で計測 (越流前の予兆把握)
    観測値は Web (https://hiroshima.ikelog.cloud/) でリアルタイム公開。
    本データは「<b>観測所名・住所・緯度経度・観測情報URL</b>」の 5 列だけの
    シンプルな点台帳 (70 行 × 5 列, 約 8 KB)。

  2018 年西日本豪雨と監視投資の関係 (本記事の歴史的位置付け):
    2018 年 7 月 6 日の西日本豪雨で広島県内のため池<b>32 箇所が損傷</b>、うち
    <b>3 箇所が決壊</b>し下流の住宅・道路に被害。これを受けて:
    (1) 国レベル: 2019 年「ため池管理保全法」 制定 (= L59 で扱う指定制度)
    (2) 県レベル: <b>「特定農業用ため池」 指定</b> (= L59 6,754 件)
    (3) 市レベル: 広島市が<b>「ため池監視カメラ・水位計」 設置事業</b>を開始
    本データの 70 箇所は (3) の運用結果として 2020 年代に整備された。
    つまり<b>「2018 豪雨 → 法制度整備 → 監視装置投資」</b>の三段ロケットの
    最終段に当たる<b>運用フェーズの初期データ</b>として研究的に貴重。

  本データ #1675 の独自性と限界:
    - <b>70 件すべてが広島市内</b>: 緯度 34.37-34.60、経度 132.33-132.67 の範囲。
      所在は 5 区 (安佐北 30 / 安芸 19 / 安佐南 13 / 佐伯 5 / 東 3) に集中、
      中区・西区・南区はゼロ (= 都市核心部にため池無し)。
      <b>県内全域データではなく広島市の運用データ</b>であることを冒頭で明示。
    - 観測機器の<b>カメラ / 水位計の判別列なし</b>: 1 行 = 1 観測点で、
      カメラと水位計を併設した装置を 1 件として扱う構造。
      本記事は「監視装置」 を上位概念として扱い、両者を統合 1 単位で集計。
    - <b>device-chart の URL ID</b> (= 3072-3174 の整数 ID) を持ち、
      ikelog.cloud のリアルタイム観測値 (越流警報) と直結。
      装置の物理的設置順を反映する可能性 (= 早く設置された装置ほど ID が若い)。

  ため池系 3 記事 (L45 / L59 / L60) の関係:
    本記事 (L60) は<b>ため池の運用フェーズ</b>を扱う 3 本目で、L45/L59 と
    同じため池群を異なる断面で見る。
    - L45: 浸水想定区域 (Shapefile, 6,730 polygon) = <b>幾何側 = 決壊シナリオの空間</b>
    - L59: 基本情報 (CSV, 6,754 行) = <b>属性側 = 規模・指定日の台帳</b>
    - L60: 監視装置 (CSV, 70 行) = <b>運用側 = 実時間観測の現場</b>
    L60 は 70 件と件数最少だが、<b>「指定された 6,754 件のうち実際にどれが
    監視されているか」</b>という運用合理性の問いを初めて立てる。

研究の問い (3 RQ):
  RQ1 (主研究): 広島市の<b>ため池監視装置の地理分布と区別構造</b>はどう描けるか?
       70 件すべて広島市内、しかし<b>5 区 (安佐北 30 / 安芸 19 / 安佐南 13 / 佐伯 5 / 東 3) に
       不均等分布</b>。中区・西区・南区はゼロ。
       8 区別ランキング、近接装置の最近隣距離 (NN) 分布、装置 ID 範囲、
       広島市内の防災重点ため池 161 件中の<b>監視カバー率 42.2%</b>を多角度に集計。

  RQ2 (副研究 1): 監視装置と<b>防災重点ため池 (L59 + L45) の対応 — 監視ネットワークの空白</b>はどこか?
       広島市の防災重点ため池 161 件のうち、<b>68 件 (42.2%) のみ監視済</b>、
       残り <b>93 件 (57.8%) は監視装置から離れて存在</b>する空白池。
       未監視 93 件の最近隣監視点距離分布 (median 347m, max 8.3km) を
       <b>「監視ネットワーク到達半径」</b>として可視化。
       規模 (堤高・貯水量) の差はあるか? — 監視済が大規模か未監視が大規模かを検証。

  RQ3 (副研究 2): 監視装置と<b>下流人家・避難所 (L03 既知) の空間関係 — 監視優先度の妥当性</b>はどうか?
       広島市内の指定緊急避難場所 (約 1,200 件, L03 から抽出) と
       監視装置の最近隣距離を計算。
       仮説: 監視装置は<b>下流人家集中域 (= 避難所多い区)</b>にあるべきだが、
       実際は<b>安佐北 (中山間) に 30 件と最多</b>。
       中山間ため池ほど 2018 豪雨型崩落のリスクが大きいため、
       <b>「中山間優先」 の制度合理性</b>を空間統計で検証。

仮説 (5):
  H1 (区別偏在, 安佐北最多, RQ1): 監視装置は<b>中山間 (安佐北 + 安佐南 = 43 件 = 61%)</b>に集中。
       これは中山間ため池の<b>2018 豪雨型崩落リスク</b>を反映した制度運用。
       中区・西区・南区はゼロ (都市核心部にため池無し)。

  H2 (装置 ID 連番性, RQ1): 装置 ID (3072-3174) は<b>設置順</b>を反映するはず。
       早期設置 (ID 3072-3100 = 30 件) と後期設置 (3140-3174 = 30 件) を
       比較すると、<b>早期は重要度の高い大池</b>に多いはず。

  H3 (監視カバー率, RQ2): 広島市の防災重点ため池 161 件のうち、
       <b>監視済は 70 件未満 (= 半数以下)</b>。これは 2020 年代開始の
       新規事業のため、整備途上にあることを反映。

  H4 (規模 ↔ 監視, RQ2): 監視済ため池は<b>未監視より大規模</b> (堤高・貯水量とも)。
       決壊時被害が大きい池を優先監視するのは制度的合理性。
       Mann-Whitney U で中央値の差を統計検定。

  H5 (避難所最近隣, RQ3): 監視装置の<b>1km 圏に避難所が複数</b>存在するはず。
       これは「下流人家集中域に監視装置を置く」 の合理性。
       未監視ため池との比較で、監視装置選定の<b>下流リスク考慮</b>を定量化。

要件 S 準拠 (1分以内完走):
  - CSV 70 行は読込・パース 0.1 秒未満
  - L45 既処理 cache CSV から広島市分 161 件を抽出 (重い空間処理は不要)
  - shelters.json から広島市分 1,218 件をフィルタ (JSON パース < 1 秒)
  - 行政区域 GeoJSON は L15 既扱の admin_786_広島市.zip から
  - 期待実行時間: <15 秒

要件 T 準拠 (位置情報あり=地図必須):
  - RQ1: 広島市全域 70 点マップ + 区別 choropleth 風 (装置数)
  - RQ1: 8 区別棒 + 装置 ID 散布
  - RQ2: 監視済 vs 未監視 ため池マップ (赤=未監視 93 件)
  - RQ2: 最近隣監視点距離分布ヒスト + 距離 × 規模散布
  - RQ3: 監視装置 + 避難所 重ね合わせマップ
  - RQ3: 装置半径 1km 圏避難所数の棒グラフ

要件 Q 準拠: 図 8 / 表 11 (3 RQ × 多角度)。

データ仕様:
  - dataset 1675: ため池監視カメラ及び水位計設置箇所一覧 (CSV)
  - リソース (CSV, 約 8 KB, UTF-8 BOM): 70 行 × 5 列
  - 列構成 (5 列):
      観測所名 (例 流谷, 大原) /
      住所 (例 広島市東区戸坂新町一丁目) /
      緯度 / 経度 (WGS84 度) /
      観測情報URL (https://hiroshima.ikelog.cloud/#/device-chart/{id})
  - CRS: WGS84 (緯度経度) → 解析時 EPSG:6671 (平面直角第 III 系) m 単位
  - 取得日: 2026-05-10
  - ライセンス: クリエイティブ・コモンズ表示 4.0
  - 作成主体: 広島市
"""
from __future__ import annotations
import sys, time, shutil, zipfile, re, json
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from _common import ROOT, ASSETS, LESSONS, render_lesson, code, figure

import numpy as np
import pandas as pd
import geopandas as gpd
import requests
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from matplotlib.lines import Line2D
from sklearn.neighbors import BallTree
from scipy.stats import mannwhitneyu

plt.rcParams["font.family"] = "Yu Gothic"
plt.rcParams["axes.unicode_minus"] = False

t_all = time.time()
print("=== L60 ため池監視カメラ及び水位計 単独 3 研究例分析 ===", flush=True)


# =============================================================================
# 0. 定数・パス
# =============================================================================
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角座標系第 III 系 (m 単位)
SRC_CRS = "EPSG:4326"     # WGS84 (緯度経度)
EARTH_R_M = 6_371_000.0   # 地球半径 (m)、haversine → m 換算用

DATA_DIR = ROOT / "data" / "extras" / "L60_pond_monitoring"
DATA_DIR.mkdir(parents=True, exist_ok=True)

# 本データの CSV
CSV_PATH = ROOT / "data" / "extras" / "tameike_camera.csv"

# L45/L59 既処理 cache (RQ2 で使用)
L45_CACHE = ROOT / "data" / "extras" / "L45_pond_inundation" / "_cache" / "pond_inund_merge.csv"

# 行政区域 (L15 広島市別)
ADMIN_HIRO_ZIP = ROOT / "data" / "extras" / "L15_admin_zones" / "admin_786_広島市.zip"
ADMIN_PREF_ZIP = ROOT / "data" / "extras" / "L15_admin_zones" / "admin_922_広島県.zip"

# 避難所 JSON (L03 既扱)
SHELTERS_JSON = ROOT / "data" / "shelters.json"

# DoBoX
DOBOX_BASE = "https://hiroshima-dobox.jp"
HDR = {"User-Agent": "DoBoX-MDASH-textbook/1.0 (educational use)"}

# 主要日付 (制度史)
RAIN_2018 = pd.Timestamp("2018-07-06")          # 西日本豪雨 (ため池決壊 32 箇所)
LAW_TAMEIKE_KEI = pd.Timestamp("2021-05-31")    # ため池管理保全法 経過措置期限

# 広島市 8 区 CITY_CD
KU_CD_TO_NAME = {
    101: "中区", 102: "東区", 103: "南区", 104: "西区",
    105: "安佐南区", 106: "安佐北区", 107: "安芸区", 108: "佐伯区",
}


# =============================================================================
# 1. データ取得 (CSV を DoBoX から自動 DL)
# =============================================================================
print("\n[1] データ取得", flush=True)
t1 = time.time()

if not CSV_PATH.exists() or CSV_PATH.stat().st_size < 100:
    # DoBoX dataset 1675
    url = f"{DOBOX_BASE}/datasets/1675"
    print(f"  fetch resource list <- {url}")
    r = requests.get(url, headers=HDR, timeout=60)
    rids = re.findall(r"/resources/(\d+)", r.text)
    if not rids:
        raise RuntimeError("dataset 1675 にリソースが見つかりません")
    rid = rids[0]
    rh = requests.get(f"{DOBOX_BASE}/resources/{rid}", headers=HDR, timeout=60)
    m = re.search(r'href="(' + re.escape(DOBOX_BASE) + r'/resource_download/\d+)"', rh.text)
    if not m:
        raise RuntimeError(f"resource {rid} の resource_download が解決できません")
    dl = m.group(1)
    print(f"  DL <- {dl}")
    rd = requests.get(dl, headers=HDR, timeout=60)
    rd.raise_for_status()
    CSV_PATH.parent.mkdir(parents=True, exist_ok=True)
    CSV_PATH.write_bytes(rd.content)
    print(f"  saved -> {CSV_PATH.name} ({len(rd.content):,} bytes)")
else:
    print(f"  cache HIT: {CSV_PATH.name} ({CSV_PATH.stat().st_size:,} bytes)")

# 中間保存先 (L60 専用フォルダにコピー)
LOCAL_CSV = DATA_DIR / "tameike_camera.csv"
if not LOCAL_CSV.exists():
    shutil.copy(CSV_PATH, LOCAL_CSV)

print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 2. CSV 読込・前処理
# =============================================================================
print("\n[2] CSV 読込・前処理", flush=True)
t1 = time.time()

df = pd.read_csv(CSV_PATH, encoding="utf-8-sig", dtype=str)
df.columns = [c.replace("﻿", "").strip().strip('"') for c in df.columns]
n_total = len(df)
print(f"  rows: {n_total}, columns: {df.columns.tolist()}")

# 緯度経度の数値化 (元データは末尾空白あり)
df["緯度"] = pd.to_numeric(df["緯度"].astype(str).str.strip(), errors="coerce")
df["経度"] = pd.to_numeric(df["経度"].astype(str).str.strip(), errors="coerce")
n_xy = (df["緯度"].notna() & df["経度"].notna()).sum()
print(f"  緯度経度 ok: {n_xy} / {n_total}")
print(f"  緯度範囲: {df['緯度'].min():.4f} - {df['緯度'].max():.4f}")
print(f"  経度範囲: {df['経度'].min():.4f} - {df['経度'].max():.4f}")

# 装置 ID (URL 末尾 device-chart/{id} を抽出)
df["device_id"] = df["観測情報URL"].str.extract(r"/device-chart/(\d+)").astype(int)
print(f"  device_id 範囲: {df['device_id'].min()} - {df['device_id'].max()}")


# 区抽出 (住所 "広島市XX区..." から)
def get_ku(s):
    s = str(s)
    m = re.search(r"広島市([^区市町]+区)", s)
    if m:
        return m.group(1)
    return "その他"


df["区"] = df["住所"].apply(get_ku)
print(f"  区別件数: {df['区'].value_counts().to_dict()}")

# GeoDataFrame 化 (緯度経度 → EPSG:6671)
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["経度"], df["緯度"]),
    crs=SRC_CRS,
).to_crs(TARGET_CRS)
gdf["x_m"] = gdf.geometry.x
gdf["y_m"] = gdf.geometry.y

# 装置 ID コホート (前期 / 中期 / 後期 = 3 等分)
df_sorted = df.sort_values("device_id").reset_index(drop=True)
n_per_cohort = n_total // 3
cohort_labels = []
for i in range(n_total):
    if i < n_per_cohort:
        cohort_labels.append("前期")
    elif i < n_per_cohort * 2:
        cohort_labels.append("中期")
    else:
        cohort_labels.append("後期")
df_sorted["設置コホート"] = cohort_labels
df = df.merge(df_sorted[["device_id", "設置コホート"]], on="device_id", how="left")
print(f"  コホート別件数: {df['設置コホート'].value_counts().to_dict()}")

print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 3. 行政区域 (L15 広島市) 読込
# =============================================================================
print("\n[3] 行政区域 (L15 広島市) 読込", flush=True)
t1 = time.time()

admin_dir = DATA_DIR / "_admin"
admin_dir.mkdir(parents=True, exist_ok=True)

# 広島市 8 区 GeoJSON
hiro_geo = None
if ADMIN_HIRO_ZIP.exists():
    if not list(admin_dir.glob("*hiroshima*.geojson")) and not list(admin_dir.glob("341002*.geojson")):
        try:
            with zipfile.ZipFile(ADMIN_HIRO_ZIP) as zf:
                zf.extractall(admin_dir)
        except Exception as e:
            print(f"  WARN: hiro zip extract failed: {e}")
    geos = list(admin_dir.glob("341002*.geojson"))
    if geos:
        hiro_geo = geos[0]

ku_diss = None
ku_outline = None
if hiro_geo and hiro_geo.exists():
    try:
        ku_gdf = gpd.read_file(hiro_geo).to_crs(TARGET_CRS)
        if "CITY_CD" in ku_gdf.columns:
            ku_gdf["区名"] = ku_gdf["CITY_CD"].astype(int).map(KU_CD_TO_NAME).fillna("不明")
            ku_diss = ku_gdf.dissolve(by="区名", as_index=False)
            ku_outline = ku_gdf.dissolve()
            print(f"  hiroshima ku: {len(ku_gdf)} polys → {len(ku_diss)} 区 dissolve")
        else:
            ku_diss = ku_gdf
            ku_outline = ku_gdf.dissolve()
    except Exception as e:
        print(f"  WARN: hiro geojson read failed: {e}")

# 広島県境 (背景用、L15 #922)
pref_outline = None
if ADMIN_PREF_ZIP.exists():
    pref_dir = DATA_DIR / "_pref"
    pref_dir.mkdir(parents=True, exist_ok=True)
    if not list(pref_dir.rglob("*.geojson")):
        try:
            with zipfile.ZipFile(ADMIN_PREF_ZIP) as zf:
                zf.extractall(pref_dir)
        except Exception:
            pass
    pgs = list(pref_dir.rglob("*.geojson"))
    if pgs:
        try:
            pref_gdf = gpd.read_file(pgs[0]).to_crs(TARGET_CRS)
            pref_outline = pref_gdf.dissolve()
        except Exception as e:
            print(f"  WARN: pref geojson failed: {e}")

print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 4. RQ1 分析: 監視装置の地理分布と区別構造
# =============================================================================
print("\n[4] RQ1: 監視装置の構造研究", flush=True)
t1 = time.time()

# (a) 区別ランキング
ku_rank = df.groupby("区").size().reset_index(name="件数")
ku_rank["シェア_%"] = (ku_rank["件数"] / n_total * 100).round(2)
ku_rank = ku_rank.sort_values("件数", ascending=False).reset_index(drop=True)
ku_rank.to_csv(ASSETS / "L60_ku_ranking.csv", index=False, encoding="utf-8-sig")

top_ku = ku_rank.iloc[0]
top1_ku = top_ku["区"]
top1_ku_n = int(top_ku["件数"])
top1_ku_share = float(top_ku["シェア_%"])

# 中山間 vs 都市部 (本記事独自定義)
INLAND_KU = {"安佐北区", "安佐南区", "安芸区", "佐伯区"}  # 中山間+郊外
URBAN_KU = {"中区", "東区", "南区", "西区"}              # 都市核心部
df["立地区分"] = df["区"].apply(
    lambda k: "中山間" if k in INLAND_KU else ("都市部" if k in URBAN_KU else "その他")
)
n_inland = int((df["立地区分"] == "中山間").sum())
n_urban = int((df["立地区分"] == "都市部").sum())
n_other = int((df["立地区分"] == "その他").sum())

# (b) 最近隣装置距離 (NN) 分布 (haversine)
mon_rad = np.deg2rad(df[["緯度", "経度"]].values)
tree_mon = BallTree(mon_rad, metric="haversine")
nn_d, nn_i = tree_mon.query(mon_rad, k=2)  # k=2 で自身を除外
nn_dist_m = nn_d[:, 1] * EARTH_R_M  # m
df["NN距離_m"] = nn_dist_m
nn_median = float(np.median(nn_dist_m))
nn_mean = float(np.mean(nn_dist_m))
nn_min = float(np.min(nn_dist_m))
nn_max = float(np.max(nn_dist_m))
nn_p25 = float(np.percentile(nn_dist_m, 25))
nn_p75 = float(np.percentile(nn_dist_m, 75))

# (c) 装置 ID コホート × 区
cohort_ku = pd.crosstab(df["設置コホート"], df["区"]).reindex(["前期", "中期", "後期"])
cohort_ku.to_csv(ASSETS / "L60_cohort_by_ku.csv", encoding="utf-8-sig")

# (d) device_id 配置: 散布
id_min = int(df["device_id"].min())
id_max = int(df["device_id"].max())
id_range = id_max - id_min + 1

print(f"  Top 区: {top1_ku} = {top1_ku_n} ({top1_ku_share:.1f}%)")
print(f"  中山間 / 都市部 / その他: {n_inland} / {n_urban} / {n_other}")
print(f"  NN 距離 (m): median={nn_median:.0f}, mean={nn_mean:.0f}, "
      f"min={nn_min:.0f}, max={nn_max:.0f}")
print(f"  device_id 範囲: {id_min}-{id_max} ({id_range} ID 幅 / 70 装置)")
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 5. RQ2 分析: 監視ネットワークの空白
# =============================================================================
print("\n[5] RQ2: 監視ネットワーク空白研究", flush=True)
t1 = time.time()

# L45 cache から広島市分のため池を抽出
if not L45_CACHE.exists():
    raise FileNotFoundError(f"L45 cache not found: {L45_CACHE} — L45 を先に実行してください")

l45 = pd.read_csv(L45_CACHE, dtype={"ため池番号": str})
hiro_ponds = l45[l45["所管市町"] == "広島市"].copy()
hiro_ponds["緯度"] = pd.to_numeric(hiro_ponds["緯度"], errors="coerce")
hiro_ponds["経度"] = pd.to_numeric(hiro_ponds["経度"], errors="coerce")
hiro_ponds["堤高_m"] = pd.to_numeric(hiro_ponds["堤高（m）"], errors="coerce")
hiro_ponds["貯水量_千m3"] = pd.to_numeric(hiro_ponds["貯水量（千m3）"], errors="coerce")
hiro_ponds = hiro_ponds.dropna(subset=["緯度", "経度"]).reset_index(drop=True)
n_hiro_ponds = len(hiro_ponds)
print(f"  広島市内 防災重点ため池 (L59 由来): {n_hiro_ponds} 件")

# 各ため池に最近隣の監視装置を割り当て (haversine)
ponds_rad = np.deg2rad(hiro_ponds[["緯度", "経度"]].values)
nearest_d, nearest_i = tree_mon.query(ponds_rad, k=1)
hiro_ponds["最近隣監視点_m"] = nearest_d[:, 0] * EARTH_R_M
hiro_ponds["最近隣監視装置"] = df.iloc[nearest_i[:, 0]]["観測所名"].values
hiro_ponds["最近隣device_id"] = df.iloc[nearest_i[:, 0]]["device_id"].values

# 「同一池」 とみなす閾値: 100m 以内 (実装テストで 70/70 が < 100m)
SAME_POND_THRESHOLD_M = 100.0
hiro_ponds["監視済"] = hiro_ponds["最近隣監視点_m"] < SAME_POND_THRESHOLD_M
n_monitored_ponds = int(hiro_ponds["監視済"].sum())
n_unmonitored_ponds = n_hiro_ponds - n_monitored_ponds
cover_rate = n_monitored_ponds / n_hiro_ponds * 100
print(f"  監視済 (< {SAME_POND_THRESHOLD_M:.0f}m): {n_monitored_ponds} / {n_hiro_ponds} = {cover_rate:.1f}%")
print(f"  未監視: {n_unmonitored_ponds} 件")

# 監視装置側から見た「同一池」 マッチ — カメラ→ため池
cam_rad = np.deg2rad(df[["緯度", "経度"]].values)
tree_pond = BallTree(ponds_rad, metric="haversine")
cam_to_pond_d, cam_to_pond_i = tree_pond.query(cam_rad, k=1)
df["対応ため池_m"] = cam_to_pond_d[:, 0] * EARTH_R_M
df["対応ため池名"] = hiro_ponds.iloc[cam_to_pond_i[:, 0]]["ため池名称"].values
df["対応ため池番号"] = hiro_ponds.iloc[cam_to_pond_i[:, 0]]["ため池番号"].values
df["対応貯水量_千m3"] = hiro_ponds.iloc[cam_to_pond_i[:, 0]]["貯水量_千m3"].values
df["対応堤高_m"] = hiro_ponds.iloc[cam_to_pond_i[:, 0]]["堤高_m"].values
n_cam_matched = int((df["対応ため池_m"] < SAME_POND_THRESHOLD_M).sum())
print(f"  装置→ため池 マッチ (< {SAME_POND_THRESHOLD_M:.0f}m): {n_cam_matched} / {n_total}")

# 区別カバー率
ku_cover = []
for ku in sorted(set(hiro_ponds.get("所管市町", pd.Series(dtype=str))) | {"広島市"}):
    pass

# ため池に区情報を付与 (緯度経度を区 polygon に sjoin、ku_diss があれば)
if ku_diss is not None and "区名" in ku_diss.columns:
    ponds_gdf = gpd.GeoDataFrame(
        hiro_ponds,
        geometry=gpd.points_from_xy(hiro_ponds["経度"], hiro_ponds["緯度"]),
        crs=SRC_CRS,
    ).to_crs(TARGET_CRS)
    ponds_with_ku = gpd.sjoin(
        ponds_gdf, ku_diss[["区名", "geometry"]],
        how="left", predicate="within"
    )
    hiro_ponds["区"] = ponds_with_ku["区名"].fillna("その他").values
else:
    # フォールバック: 緯度経度ベースで簡易判定
    hiro_ponds["区"] = "不明"

ku_cover_df = hiro_ponds.groupby("区").agg(
    全件数=("ため池番号", "count"),
    監視済=("監視済", "sum"),
).reset_index()
ku_cover_df["未監視"] = ku_cover_df["全件数"] - ku_cover_df["監視済"]
ku_cover_df["カバー率_%"] = (ku_cover_df["監視済"] / ku_cover_df["全件数"] * 100).round(2)
ku_cover_df = ku_cover_df.sort_values("全件数", ascending=False).reset_index(drop=True)
ku_cover_df.to_csv(ASSETS / "L60_ku_coverage.csv", index=False, encoding="utf-8-sig")
print(f"  区別カバー率:\n{ku_cover_df.to_string(index=False)}")

# 未監視ため池の最近隣監視点距離分布
unmon = hiro_ponds[~hiro_ponds["監視済"]].copy()
unmon_dist = unmon["最近隣監視点_m"].values
unmon_med = float(np.median(unmon_dist))
unmon_p75 = float(np.percentile(unmon_dist, 75))
unmon_max = float(unmon_dist.max())
print(f"  未監視ため池の最近隣監視点距離: median={unmon_med:.0f}m, p75={unmon_p75:.0f}m, max={unmon_max:.0f}m")

# H4 検証: 監視済 vs 未監視 の規模差 (Mann-Whitney U)
mon_cap = hiro_ponds[hiro_ponds["監視済"]]["貯水量_千m3"].dropna().values
unmon_cap = hiro_ponds[~hiro_ponds["監視済"]]["貯水量_千m3"].dropna().values
mon_dam = hiro_ponds[hiro_ponds["監視済"]]["堤高_m"].dropna().values
unmon_dam = hiro_ponds[~hiro_ponds["監視済"]]["堤高_m"].dropna().values
mon_cap_med = float(np.median(mon_cap)) if len(mon_cap) else 0.0
unmon_cap_med = float(np.median(unmon_cap)) if len(unmon_cap) else 0.0
mon_dam_med = float(np.median(mon_dam)) if len(mon_dam) else 0.0
unmon_dam_med = float(np.median(unmon_dam)) if len(unmon_dam) else 0.0

if len(mon_cap) > 5 and len(unmon_cap) > 5:
    u_cap, p_cap = mannwhitneyu(mon_cap, unmon_cap, alternative="greater")
else:
    u_cap, p_cap = (np.nan, np.nan)
if len(mon_dam) > 5 and len(unmon_dam) > 5:
    u_dam, p_dam = mannwhitneyu(mon_dam, unmon_dam, alternative="greater")
else:
    u_dam, p_dam = (np.nan, np.nan)

print(f"  規模 (中央値): 監視済 cap={mon_cap_med:.2f} vs 未監視 cap={unmon_cap_med:.2f} 千m³ (U={u_cap}, p={p_cap})")
print(f"  規模 (中央値): 監視済 dam={mon_dam_med:.2f} vs 未監視 dam={unmon_dam_med:.2f} m (U={u_dam}, p={p_dam})")

# 未監視 93 件の詳細表 (個別識別用)
unmon_detail = unmon[
    ["ため池番号", "ため池名称", "区", "堤高_m", "貯水量_千m3", "緯度", "経度", "最近隣監視点_m"]
].copy().reset_index(drop=True)
unmon_detail.columns = ["ため池番号", "ため池名称", "区", "堤高_m", "貯水量_千m3", "緯度", "経度", "最近隣監視点_m"]
unmon_detail["最近隣監視点_m"] = unmon_detail["最近隣監視点_m"].round(0).astype(int)
unmon_detail["堤高_m"] = unmon_detail["堤高_m"].round(2)
unmon_detail["貯水量_千m3"] = unmon_detail["貯水量_千m3"].round(3)
unmon_detail["緯度"] = unmon_detail["緯度"].round(5)
unmon_detail["経度"] = unmon_detail["経度"].round(5)
unmon_detail = unmon_detail.sort_values("最近隣監視点_m", ascending=False).reset_index(drop=True)
unmon_detail.to_csv(ASSETS / "L60_unmonitored_ponds.csv", index=False, encoding="utf-8-sig")
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 6. RQ3 分析: 下流リスクとの一致
# =============================================================================
print("\n[6] RQ3: 下流リスクとの一致研究", flush=True)
t1 = time.time()

# 避難所データ読込 (L03 既扱)
shelters_all = []
if SHELTERS_JSON.exists():
    with open(SHELTERS_JSON, encoding="utf-8") as f:
        sd = json.load(f)
    for it in sd.get("items", []):
        try:
            lat = float(it.get("latitude") or "nan")
            lon = float(it.get("longitude") or "nan")
            mname = it.get("municipalityName") or ""
            if not (np.isnan(lat) or np.isnan(lon)) and "広島市" in mname:
                shelters_all.append({
                    "facilityId": it.get("facilityId"),
                    "name": it.get("name"),
                    "ku": mname.replace("広島市", "").strip() or "不明",
                    "lat": lat,
                    "lon": lon,
                    "floodFlg": it.get("floodShFlg") or "0",
                    "sedimentFlg": it.get("sedimentDisasterShFlg") or "0",
                    "capacity": it.get("capacity"),
                })
        except Exception:
            continue
shel_df = pd.DataFrame(shelters_all)
n_shelters = len(shel_df)
n_flood_shelters = int((shel_df["floodFlg"] == "1").sum()) if n_shelters else 0
n_sediment_shelters = int((shel_df["sedimentFlg"] == "1").sum()) if n_shelters else 0
print(f"  広島市内 避難所 (L03 抽出): {n_shelters} 件 "
      f"(洪水対応 {n_flood_shelters} / 土砂災害対応 {n_sediment_shelters})")

# 監視装置 → 1km 圏避難所数 / 最近隣避難所距離
RADIUS_M = 1000.0
shel_rad = np.deg2rad(shel_df[["lat", "lon"]].values) if n_shelters else np.zeros((0, 2))
tree_shel = BallTree(shel_rad, metric="haversine") if n_shelters else None

if tree_shel is not None:
    # 各装置の 1km 圏内避難所数
    radius_rad = RADIUS_M / EARTH_R_M
    n_within = tree_shel.query_radius(cam_rad, r=radius_rad, count_only=True)
    df["避難所1km圏数"] = n_within
    # 最近隣避難所距離
    s_d, s_i = tree_shel.query(cam_rad, k=1)
    df["最近隣避難所_m"] = s_d[:, 0] * EARTH_R_M

    # 同様に未監視ため池でも計算
    unmon_rad = np.deg2rad(unmon[["緯度", "経度"]].values)
    unmon_within = tree_shel.query_radius(unmon_rad, r=radius_rad, count_only=True)
    unmon_s_d, _ = tree_shel.query(unmon_rad, k=1)
    unmon["避難所1km圏数"] = unmon_within
    unmon["最近隣避難所_m"] = unmon_s_d[:, 0] * EARTH_R_M

    # 監視済ため池でも
    mon_p = hiro_ponds[hiro_ponds["監視済"]].copy()
    mon_p_rad = np.deg2rad(mon_p[["緯度", "経度"]].values)
    if len(mon_p):
        mon_within = tree_shel.query_radius(mon_p_rad, r=radius_rad, count_only=True)
        mon_p_s_d, _ = tree_shel.query(mon_p_rad, k=1)
        mon_p["避難所1km圏数"] = mon_within
        mon_p["最近隣避難所_m"] = mon_p_s_d[:, 0] * EARTH_R_M
    else:
        mon_p["避難所1km圏数"] = []
        mon_p["最近隣避難所_m"] = []

    cam_within_mean = float(df["避難所1km圏数"].mean())
    cam_within_med = float(df["避難所1km圏数"].median())
    cam_shel_med = float(df["最近隣避難所_m"].median())
    cam_shel_mean = float(df["最近隣避難所_m"].mean())
    cam_shel_max = float(df["最近隣避難所_m"].max())

    unmon_within_mean = float(unmon["避難所1km圏数"].mean())
    unmon_within_med = float(unmon["避難所1km圏数"].median())
    unmon_shel_med = float(unmon["最近隣避難所_m"].median())

    mon_p_within_mean = float(mon_p["避難所1km圏数"].mean()) if len(mon_p) else 0.0
    mon_p_shel_med = float(mon_p["最近隣避難所_m"].median()) if len(mon_p) else 0.0
else:
    cam_within_mean = cam_within_med = cam_shel_med = cam_shel_mean = cam_shel_max = 0.0
    unmon_within_mean = unmon_within_med = unmon_shel_med = 0.0
    mon_p_within_mean = mon_p_shel_med = 0.0
    mon_p = unmon.iloc[:0].copy()

print(f"  装置 1km 圏避難所数: mean={cam_within_mean:.1f}, median={cam_within_med:.0f}")
print(f"  装置 最近隣避難所距離: mean={cam_shel_mean:.0f}m, median={cam_shel_med:.0f}m, max={cam_shel_max:.0f}m")
print(f"  未監視ため池 1km 圏避難所数 mean: {unmon_within_mean:.1f}")
print(f"  監視済ため池 1km 圏避難所数 mean: {mon_p_within_mean:.1f}")

# 区別 避難所密度 vs 装置密度 の相関
ku_shel = shel_df.groupby("ku").size().reset_index(name="避難所数") if n_shelters else pd.DataFrame(columns=["ku", "避難所数"])
ku_shel = ku_shel.rename(columns={"ku": "区"})
ku_combined = ku_rank.merge(ku_shel, on="区", how="left").fillna({"避難所数": 0})
ku_combined["避難所数"] = ku_combined["避難所数"].astype(int)
ku_combined.to_csv(ASSETS / "L60_ku_combined.csv", index=False, encoding="utf-8-sig")

# 装置数 vs 避難所数 Pearson r
if (ku_combined["件数"].std() > 0) and (ku_combined["避難所数"].std() > 0):
    r_dev_shel = float(ku_combined[["件数", "避難所数"]].corr().iloc[0, 1])
else:
    r_dev_shel = float("nan")
print(f"  区別 装置数 × 避難所数 Pearson r = {r_dev_shel:.3f}")

# H5 関連: 装置の 1km 圏内避難所数の分布
n_dev_with_5plus = int((df["避難所1km圏数"] >= 5).sum()) if "避難所1km圏数" in df.columns else 0
share_5plus = n_dev_with_5plus / n_total * 100
print(f"  1km 圏に 5+ 避難所をもつ装置: {n_dev_with_5plus} / {n_total} = {share_5plus:.0f}%")

print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 7. 図描画
# =============================================================================
print("\n[7] 図描画", flush=True)
t1 = time.time()


def draw_hiro(ax, lw=0.6, color="#888"):
    if ku_outline is not None:
        ku_outline.boundary.plot(ax=ax, color=color, linewidth=lw)
    if ku_diss is not None and len(ku_diss) > 1:
        ku_diss.boundary.plot(ax=ax, color=color, linewidth=lw * 0.5, alpha=0.5)


# ---- 図 1 (RQ1): 広島市全域 70 点マップ + 区別棒 ---------------------------
print("  fig1 全市マップ + 区ランキング", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14, 7))

ax = axes[0]
draw_hiro(ax, lw=0.8, color="#777")
sc = ax.scatter(
    gdf["x_m"], gdf["y_m"],
    c=gdf["device_id"], cmap="viridis", s=55, alpha=0.85,
    edgecolors="black", linewidths=0.4,
)
cbar = plt.colorbar(sc, ax=ax, shrink=0.7)
cbar.set_label("device_id (= 設置順の代理)", fontsize=9)
ax.set_title(f"広島市内 ため池監視装置 {n_total} 箇所\n"
             f"(色 = device_id, ID 範囲 {id_min}-{id_max})", fontsize=11)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
ax.set_xlabel("→ 東")
ax.set_ylabel("↑ 北")

# 区名ラベル (区polygonの重心に)
if ku_diss is not None and "区名" in ku_diss.columns:
    for _, row in ku_diss.iterrows():
        c = row.geometry.centroid
        ax.annotate(row["区名"], xy=(c.x, c.y),
                    fontsize=8, color="#333", ha="center", weight="bold",
                    bbox=dict(boxstyle="round,pad=0.18",
                              facecolor="white", alpha=0.65, edgecolor="none"))

ax = axes[1]
all_ku = ["安佐北区", "安芸区", "安佐南区", "佐伯区", "東区",
          "中区", "南区", "西区"]
plot_data = []
for k in all_ku:
    n = int((df["区"] == k).sum())
    plot_data.append((k, n))
labels, counts = zip(*plot_data)
colors = ["#d73027" if k in INLAND_KU else "#4575b4" for k in labels]
ax.barh(range(len(labels)), counts, color=colors, alpha=0.85)
ax.set_yticks(range(len(labels)))
ax.set_yticklabels(labels, fontsize=10)
ax.invert_yaxis()
ax.set_xlabel("監視装置数")
ax.set_title("広島市 8 区別 監視装置数 (赤=中山間 / 青=都市部)", fontsize=11)
for i, v in enumerate(counts):
    ax.text(v + 0.4, i, f"{v}", va="center", fontsize=9)
ax.grid(axis="x", alpha=0.3)
ax.set_xlim(0, max(counts) * 1.15 + 1)

plt.suptitle(
    f"L60 RQ1 図 1: 広島市 ため池監視装置 {n_total} 箇所 — "
    f"中山間 4 区 ({n_inland}, {n_inland/n_total*100:.0f}%) に偏在 / 都市核心 3 区はゼロ",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig1_monitor_map.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 2 (RQ1): 最近隣装置距離分布 + device_id ヒスト ----------------------
print("  fig2 NN 距離 + device_id", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

ax = axes[0]
ax.hist(nn_dist_m, bins=20, color="#4575b4", edgecolor="white")
ax.axvline(nn_median, color="red", linestyle="--", linewidth=1.2,
           label=f"中央値 {nn_median:.0f} m")
ax.axvline(nn_mean, color="orange", linestyle="--", linewidth=1.2,
           label=f"平均 {nn_mean:.0f} m")
ax.set_xlabel("最近隣装置距離 (m, haversine)")
ax.set_ylabel("装置数")
ax.set_title(f"装置同士の最近隣距離 (NN) 分布\n"
             f"レンジ {nn_min:.0f}-{nn_max:.0f}m, IQR=[{nn_p25:.0f}-{nn_p75:.0f}m]", fontsize=10)
ax.legend(fontsize=9)
ax.grid(alpha=0.3)

ax = axes[1]
# device_id × y (緯度) の散布で「設置順 ↔ 地理」 を読む
ax.scatter(df["device_id"], df["緯度"],
           c=df["区"].map({k: i for i, k in enumerate(all_ku)}),
           cmap="tab10", s=40, alpha=0.85, edgecolors="black", linewidths=0.4)
ax.set_xlabel("device_id (= 設置順の代理)")
ax.set_ylabel("緯度")
ax.set_title(f"device_id × 緯度 散布図 — 設置順と地理の対応", fontsize=10)
# 区色の凡例
present_ku = [k for k in all_ku if (df["区"] == k).sum() > 0]
import matplotlib.cm as mpl_cm
cmap = mpl_cm.get_cmap("tab10")
handles = [Patch(facecolor=cmap(all_ku.index(k) / 10), label=k) for k in present_ku]
ax.legend(handles=handles, fontsize=8, loc="upper right")
ax.grid(alpha=0.3)

plt.suptitle(
    f"L60 RQ1 図 2: 装置の最近隣距離 (median {nn_median:.0f}m) と "
    f"device_id 散布 (設置順 ↔ 区)",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig2_nn_id.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 3 (RQ1): 区別 small multiples マップ -------------------------------
print("  fig3 区別 small multiples", flush=True)
present_ku_with_data = [k for k in all_ku if (df["区"] == k).sum() > 0]
n_panels = len(present_ku_with_data)
ncols = 3
nrows = (n_panels + ncols - 1) // ncols
fig, axes = plt.subplots(nrows, ncols, figsize=(13, 4.2 * nrows))
axes = np.array(axes).flatten() if n_panels > 1 else np.array([axes]).flatten()

for i, ku in enumerate(present_ku_with_data):
    ax = axes[i]
    sub = gdf[gdf["区"] == ku]
    draw_hiro(ax, lw=0.5, color="#bbb")
    ax.scatter(sub["x_m"], sub["y_m"], c="#d73027", s=70,
               alpha=0.85, edgecolors="black", linewidths=0.5)
    # 区polygon フォーカス
    if ku_diss is not None and "区名" in ku_diss.columns:
        target = ku_diss[ku_diss["区名"] == ku]
        if len(target) > 0:
            target.boundary.plot(ax=ax, color="#222", linewidth=1.0)
            xmin, ymin, xmax, ymax = target.total_bounds
            mx = (xmax - xmin) * 0.1 + 1500
            my = (ymax - ymin) * 0.1 + 1500
            ax.set_xlim(xmin - mx, xmax + mx)
            ax.set_ylim(ymin - my, ymax + my)
    ax.set_title(f"{ku} ({len(sub)} 装置)", fontsize=11)
    ax.set_aspect("equal")
    ax.set_xticks([])
    ax.set_yticks([])

# 余白埋め
for j in range(n_panels, len(axes)):
    axes[j].axis("off")

plt.suptitle(
    f"L60 RQ1 図 3: {n_panels} 区別 装置配置 small multiples — "
    f"安佐北・安芸の北東部に集中、佐伯・東は散在",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig3_ku_small_mults.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 4 (RQ2): 監視済 vs 未監視 ため池マップ -----------------------------
print("  fig4 監視済 vs 未監視 マップ", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14, 7))

ax = axes[0]
draw_hiro(ax, lw=0.7, color="#888")

ponds_gdf = gpd.GeoDataFrame(
    hiro_ponds,
    geometry=gpd.points_from_xy(hiro_ponds["経度"], hiro_ponds["緯度"]),
    crs=SRC_CRS,
).to_crs(TARGET_CRS)
ponds_gdf["x_m"] = ponds_gdf.geometry.x
ponds_gdf["y_m"] = ponds_gdf.geometry.y

mon_mask = ponds_gdf["監視済"]
ax.scatter(ponds_gdf[~mon_mask]["x_m"], ponds_gdf[~mon_mask]["y_m"],
           c="#d73027", s=18, alpha=0.85,
           label=f"未監視 {n_unmonitored_ponds} 池 (赤)",
           edgecolors="none")
ax.scatter(ponds_gdf[mon_mask]["x_m"], ponds_gdf[mon_mask]["y_m"],
           c="#4575b4", s=22, alpha=0.85,
           label=f"監視済 {n_monitored_ponds} 池 (青)",
           edgecolors="none")
# 監視装置 (黒星)
ax.scatter(gdf["x_m"], gdf["y_m"], c="black", marker="*", s=60,
           alpha=0.95, label=f"監視装置 {n_total} 箇所 (黒★)",
           edgecolors="white", linewidths=0.6)
ax.set_title(f"広島市 防災重点ため池 {n_hiro_ponds} 件\n"
             f"監視カバー率 {cover_rate:.1f}% ({n_monitored_ponds}/{n_hiro_ponds})", fontsize=11)
ax.legend(fontsize=9, loc="upper right")
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])

# 右: 未監視ため池の最近隣監視点距離 ヒスト
ax = axes[1]
unmon_dist_arr = unmon["最近隣監視点_m"].values
ax.hist(unmon_dist_arr, bins=np.linspace(0, max(unmon_dist_arr) * 1.05, 25),
        color="#d73027", edgecolor="white", alpha=0.85)
ax.axvline(unmon_med, color="black", linestyle="--", linewidth=1.2,
           label=f"中央値 {unmon_med:.0f} m")
ax.axvline(1000, color="orange", linestyle=":", linewidth=1.5,
           label=f"1km 圏 ({(unmon_dist_arr <= 1000).sum()} 池)")
ax.axvline(unmon_p75, color="purple", linestyle="--", linewidth=1,
           label=f"P75 {unmon_p75:.0f} m")
ax.set_xlabel("未監視ため池 → 最近隣監視装置 距離 (m)")
ax.set_ylabel("ため池数")
ax.set_title(f"未監視 {n_unmonitored_ponds} 池の最近隣監視点距離\n"
             f"max {unmon_max:.0f}m = {unmon_max/1000:.1f}km", fontsize=10)
ax.legend(fontsize=9)
ax.grid(alpha=0.3)

plt.suptitle(
    f"L60 RQ2 図 4: 監視ネットワーク空白 — 未監視 {n_unmonitored_ponds} 池の地理 + 距離分布",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig4_coverage_map.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 5 (RQ2): 区別カバー率 + 規模比較箱ひげ -----------------------------
print("  fig5 区別カバー率 + 規模箱ひげ", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

ax = axes[0]
ku_plot = ku_cover_df.copy()
y = range(len(ku_plot))
colors = ["#d73027" if r < 100 else "#4575b4" for r in ku_plot["カバー率_%"]]
ax.barh(y, ku_plot["カバー率_%"], color=colors, alpha=0.85)
ax.set_yticks(y)
ax.set_yticklabels(
    [f"{k} (n={int(n)})" for k, n in zip(ku_plot["区"], ku_plot["全件数"])],
    fontsize=9
)
ax.invert_yaxis()
ax.set_xlabel("監視カバー率 (%)")
ax.set_xlim(0, max(ku_plot["カバー率_%"].max() * 1.15, 50))
for i, (r, miss) in enumerate(zip(ku_plot["カバー率_%"], ku_plot["未監視"])):
    label = f"{r:.0f}%"
    if miss > 0:
        label += f" (未 {int(miss)})"
    ax.text(r + 0.5, i, label, va="center", fontsize=8)
ax.set_title("区別 監視カバー率 (赤=未監視あり / 全区で未監視)", fontsize=11)
ax.grid(axis="x", alpha=0.3)

# 右: 監視済 vs 未監視 貯水量箱ひげ (log10)
ax = axes[1]
data_compare = []
labels_cmp = []
if len(mon_cap):
    data_compare.append(np.log10(np.maximum(mon_cap, 0.001)))
    labels_cmp.append(f"監視済\n(n={len(mon_cap)})")
if len(unmon_cap):
    data_compare.append(np.log10(np.maximum(unmon_cap, 0.001)))
    labels_cmp.append(f"未監視\n(n={len(unmon_cap)})")
bp = ax.boxplot(
    data_compare, tick_labels=labels_cmp, patch_artist=True,
    widths=0.55, showfliers=True,
)
for patch, color in zip(bp["boxes"], ["#4575b4", "#d73027"]):
    patch.set_facecolor(color)
    patch.set_alpha(0.7)
ax.set_ylabel("log10 貯水量 (千 m³)")
ax.set_title(f"監視済 vs 未監視 規模比較\n"
             f"中央: {mon_cap_med:.2f} vs {unmon_cap_med:.2f} 千m³ "
             f"(p={p_cap:.3f})", fontsize=10)
ax.grid(axis="y", alpha=0.3)

plt.suptitle(
    f"L60 RQ2 図 5: 区別カバー率 + 規模差 (Mann-Whitney p={p_cap:.3f})",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig5_coverage_size.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 6 (RQ2): 装置→対応ため池の規模分布 散布 ---------------------------
print("  fig6 装置 vs ため池規模", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# 左: device_id vs 対応貯水量 (大規模池が早く監視されたか)
ax = axes[0]
ok = df.dropna(subset=["対応貯水量_千m3"]).copy()
ok = ok[ok["対応貯水量_千m3"] > 0]
sc = ax.scatter(
    ok["device_id"], np.log10(ok["対応貯水量_千m3"]),
    c=ok["区"].map({k: i for i, k in enumerate(all_ku)}),
    cmap="tab10", s=45, alpha=0.85, edgecolors="black", linewidths=0.4,
)
ax.set_xlabel("device_id (= 設置順)")
ax.set_ylabel("log10 対応ため池貯水量 (千 m³)")
# 設置コホート境界の縦線
boundaries = sorted(df.groupby("設置コホート")["device_id"].max().tolist())[:-1]
for b in boundaries:
    ax.axvline(b + 0.5, color="grey", linestyle=":", alpha=0.5)
ax.set_title("device_id × 対応ため池の log10 貯水量", fontsize=10)
ax.grid(alpha=0.3)

# 右: コホート別 規模箱ひげ
ax = axes[1]
cohorts = ["前期", "中期", "後期"]
data_co = []
for c in cohorts:
    sub = df[df["設置コホート"] == c].dropna(subset=["対応貯水量_千m3"])
    sub = sub[sub["対応貯水量_千m3"] > 0]
    data_co.append(np.log10(sub["対応貯水量_千m3"]))
bp2 = ax.boxplot(
    data_co, tick_labels=cohorts, patch_artist=True,
    widths=0.55, showfliers=True,
)
for patch, color in zip(bp2["boxes"], ["#4575b4", "#fdae61", "#d73027"]):
    patch.set_facecolor(color)
    patch.set_alpha(0.7)
ax.set_ylabel("log10 対応ため池貯水量 (千 m³)")
ax.set_title("設置コホート × 規模 — H2 検証 (前期に大規模か?)", fontsize=10)
ax.grid(axis="y", alpha=0.3)
co_meds = []
for i, c in enumerate(cohorts, 1):
    sub = df[df["設置コホート"] == c].dropna(subset=["対応貯水量_千m3"])
    sub = sub[sub["対応貯水量_千m3"] > 0]
    if len(sub):
        m = float(sub["対応貯水量_千m3"].median())
        co_meds.append((c, m))
        ax.text(i, np.log10(m) + 0.05, f"med {m:.1f}", ha="center",
                fontsize=8, color="#333")

plt.suptitle(
    f"L60 RQ2 図 6: 装置設置順 × 対応ため池規模 (H2: 前期に大規模池が監視されたか)",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig6_id_size.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 7 (RQ3): 監視装置 + 避難所 重ね合わせマップ ------------------------
print("  fig7 監視装置 + 避難所", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14, 7))

ax = axes[0]
draw_hiro(ax, lw=0.7, color="#888")
# 避難所 (背景, 灰色小点)
if n_shelters:
    shel_gdf = gpd.GeoDataFrame(
        shel_df, geometry=gpd.points_from_xy(shel_df["lon"], shel_df["lat"]),
        crs=SRC_CRS,
    ).to_crs(TARGET_CRS)
    shel_gdf["x_m"] = shel_gdf.geometry.x
    shel_gdf["y_m"] = shel_gdf.geometry.y
    ax.scatter(shel_gdf["x_m"], shel_gdf["y_m"], c="#bbb", s=4, alpha=0.5,
               label=f"避難所 {n_shelters}")
# 装置 (赤大点)
ax.scatter(gdf["x_m"], gdf["y_m"], c="#d73027", marker="*", s=80,
           alpha=0.95, label=f"監視装置 {n_total}",
           edgecolors="black", linewidths=0.5)
ax.set_title(f"広島市内 監視装置 {n_total} + 避難所 {n_shelters}\n"
             f"装置 1km 圏内 平均避難所数 = {cam_within_mean:.1f}", fontsize=11)
ax.legend(fontsize=9)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])

# 右: 装置 1km 圏内の避難所数ヒスト
ax = axes[1]
ax.hist(df["避難所1km圏数"].values, bins=np.arange(0, df["避難所1km圏数"].max() + 2) - 0.5,
        color="#4575b4", edgecolor="white")
ax.axvline(cam_within_med, color="red", linestyle="--", linewidth=1.2,
           label=f"中央値 {cam_within_med:.0f}")
ax.axvline(cam_within_mean, color="orange", linestyle="--", linewidth=1.2,
           label=f"平均 {cam_within_mean:.1f}")
ax.set_xlabel("装置の 1km 圏内 避難所数")
ax.set_ylabel("装置数")
ax.set_title(f"装置 1km 圏避難所数分布 — H5 検証\n"
             f"5+ 圏内の装置: {n_dev_with_5plus} ({share_5plus:.0f}%)", fontsize=10)
ax.legend(fontsize=9)
ax.grid(alpha=0.3)

plt.suptitle(
    f"L60 RQ3 図 7: 監視装置 + 避難所 重ね合わせ — 下流リスクとの空間関係",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig7_shelters.png", dpi=130, bbox_inches="tight")
plt.close(fig)


# ---- 図 8 (RQ3): 区別 装置数 vs 避難所数 + 比較 ----------------------------
print("  fig8 区別 装置 vs 避難所", flush=True)
fig, axes = plt.subplots(1, 2, figsize=(14, 5.5))

# 左: 区別 装置数 vs 避難所数 (グループ棒)
ax = axes[0]
sorted_combined = ku_combined.sort_values("件数", ascending=False).reset_index(drop=True)
y = np.arange(len(sorted_combined))
w = 0.35
ax.barh(y - w/2, sorted_combined["件数"], w,
        color="#d73027", alpha=0.85, label="監視装置")
# 避難所数を別軸 (装置数とスケール違うので別軸表記)
ax2 = ax.twiny()
ax2.barh(y + w/2, sorted_combined["避難所数"], w,
         color="#4575b4", alpha=0.85, label="避難所")
ax.set_yticks(y)
ax.set_yticklabels(sorted_combined["区"], fontsize=9)
ax.invert_yaxis()
ax.set_xlabel("監視装置数 (赤)", color="#d73027")
ax2.set_xlabel("避難所数 (青)", color="#4575b4")
ax.tick_params(axis="x", colors="#d73027")
ax2.tick_params(axis="x", colors="#4575b4")
ax.set_title(f"区別 装置数 vs 避難所数 (Pearson r = {r_dev_shel:.2f})", fontsize=11)
ax.grid(axis="x", alpha=0.3)

# 右: 監視済 vs 未監視 ため池の 1km 圏避難所数 比較
ax = axes[1]
data_shel = []
labels_shel = []
if len(mon_p):
    data_shel.append(mon_p["避難所1km圏数"].values)
    labels_shel.append(f"監視済\n(n={len(mon_p)})")
if len(unmon):
    data_shel.append(unmon["避難所1km圏数"].values)
    labels_shel.append(f"未監視\n(n={len(unmon)})")
bp3 = ax.boxplot(
    data_shel, tick_labels=labels_shel, patch_artist=True,
    widths=0.55, showfliers=True,
)
for patch, color in zip(bp3["boxes"], ["#4575b4", "#d73027"]):
    patch.set_facecolor(color)
    patch.set_alpha(0.7)
ax.set_ylabel("ため池 1km 圏内 避難所数")
ax.set_title(f"監視済 vs 未監視ため池 1km 圏避難所数\n"
             f"中央: {mon_p['避難所1km圏数'].median() if len(mon_p) else 0:.1f} vs "
             f"{unmon['避難所1km圏数'].median() if len(unmon) else 0:.1f}", fontsize=10)
ax.grid(axis="y", alpha=0.3)

plt.suptitle(
    f"L60 RQ3 図 8: 区別 装置 vs 避難所 + ため池1km圏避難所数比較 — 下流リスクとの一致",
    fontsize=12, y=1.00
)
plt.tight_layout()
plt.savefig(ASSETS / "L60_fig8_ku_shel.png", dpi=130, bbox_inches="tight")
plt.close(fig)

print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 8. 表データ作成 (HTML 出力用)
# =============================================================================
print("\n[8] 表データ作成", flush=True)
t1 = time.time()


def df_to_html(d):
    return d.to_html(index=False, classes="", border=0, escape=False,
                     na_rep="-").replace(' style="text-align: right;"', "")


# 表 1 データセット仕様
T_dataset = pd.DataFrame([
    ("dataset_id", "1675"),
    ("公式名", "ため池監視カメラ及び水位計設置箇所一覧"),
    ("形式", "CSV (UTF-8 BOM)"),
    ("件数", f"{n_total} 行 × 5 列"),
    ("サイズ", f"{CSV_PATH.stat().st_size:,} byte"),
    ("CRS", "WGS84 (緯度経度) → 解析時 EPSG:6671 m 単位"),
    ("座標カバレッジ",
     f"緯度 {df['緯度'].min():.3f}-{df['緯度'].max():.3f} / "
     f"経度 {df['経度'].min():.3f}-{df['経度'].max():.3f}"),
    ("カバー範囲", "広島市 5 区 (中区・南区・西区はゼロ)"),
    ("装置 ID 範囲", f"{id_min} - {id_max} (= ID 幅 {id_range})"),
    ("ライセンス", "CC-BY 4.0"),
    ("作成主体", "広島市"),
    ("L59 連携 (広島市分)",
     f"防災重点ため池 {n_hiro_ponds} 件中 {n_monitored_ponds} 件 ({cover_rate:.1f}%) を監視"),
    ("L03 連携 (広島市分)",
     f"避難所 {n_shelters} 件 / 装置 1km 圏内 平均 {cam_within_mean:.1f} 避難所"),
], columns=["項目", "値"])

# 表 2 全体サマリ
T_overall = pd.DataFrame([
    ("監視装置総数", f"{n_total} 箇所"),
    ("カバー区数", f"{(df['区'] != 'その他').nunique()} 区 / 8 区中 5 区"),
    ("Top 区", f"{top1_ku} {top1_ku_n} 箇所 ({top1_ku_share:.1f}%)"),
    ("中山間 4 区 計", f"{n_inland} 箇所 ({n_inland/n_total*100:.0f}%)"),
    ("都市核心 3 区計", f"{n_urban} 箇所 (= 0 ため池無し)"),
    ("最近隣装置距離 (装置同士)",
     f"中央 {nn_median:.0f} m / 平均 {nn_mean:.0f} / 最大 {nn_max:.0f}"),
    ("広島市内 防災重点ため池 (L59)", f"{n_hiro_ponds} 件"),
    ("監視カバー率", f"{cover_rate:.1f}% ({n_monitored_ponds} / {n_hiro_ponds})"),
    ("未監視ため池", f"{n_unmonitored_ponds} 件 (中央距離 {unmon_med:.0f}m, 最大 {unmon_max/1000:.1f}km)"),
    ("規模 (中央 千m³): 監視済 vs 未監視",
     f"{mon_cap_med:.2f} vs {unmon_cap_med:.2f} (= {mon_cap_med/max(unmon_cap_med,0.001):.2f}倍)"),
    ("Mann-Whitney U 規模差 p (片側)",
     f"{p_cap:.4f} ({'監視済が大規模' if p_cap < 0.05 else '有意差なし'})"),
    ("装置 1km 圏 避難所数 (中央)",
     f"{cam_within_med:.0f} (平均 {cam_within_mean:.1f})"),
    ("区別 装置数 × 避難所数 r", f"{r_dev_shel:.3f}"),
], columns=["指標", "値"])
T_overall.to_csv(ASSETS / "L60_overall.csv", index=False, encoding="utf-8-sig")

# 表 3 区別装置数ランキング (8 区全部)
T_ku_rank = pd.DataFrame({
    "区": labels,
    "件数": counts,
    "立地区分": ["中山間" if k in INLAND_KU else "都市部" for k in labels],
})
T_ku_rank["シェア_%"] = (T_ku_rank["件数"] / n_total * 100).round(2)

# 表 4 設置コホート × 区
T_cohort = cohort_ku.fillna(0).astype(int).reset_index()

# 表 5 区別カバー率 (RQ2)
T_ku_cover = ku_cover_df.copy()

# 表 6 監視済 vs 未監視 規模比較
T_size_comp = pd.DataFrame([
    ("件数", n_monitored_ponds, n_unmonitored_ponds),
    ("中央堤高 (m)", round(mon_dam_med, 2), round(unmon_dam_med, 2)),
    ("中央貯水量 (千m³)", round(mon_cap_med, 2), round(unmon_cap_med, 2)),
    ("Mann-Whitney U 統計量", round(u_cap, 1) if not np.isnan(u_cap) else "-",
     "(片側 H1: 監視済 > 未監視)"),
    ("p 値 (貯水量, 片側)",
     f"{p_cap:.4f}" if not np.isnan(p_cap) else "-",
     "監視済が大規模" if (not np.isnan(p_cap) and p_cap < 0.05) else "有意差なし"),
    ("p 値 (堤高, 片側)",
     f"{p_dam:.4f}" if not np.isnan(p_dam) else "-",
     "監視済が大規模" if (not np.isnan(p_dam) and p_dam < 0.05) else "有意差なし"),
], columns=["指標", "監視済", "未監視"])

# 表 7 装置 → 対応ため池 (上位 device_id 範囲別 5 件抜粋)
samp_ids = sorted(df["device_id"].unique())[:5]
T_dev_examples = df[df["device_id"].isin(samp_ids)][
    ["device_id", "観測所名", "区", "対応ため池名", "対応ため池_m",
     "対応貯水量_千m3", "対応堤高_m", "設置コホート"]
].copy().sort_values("device_id")
T_dev_examples["対応ため池_m"] = T_dev_examples["対応ため池_m"].round(0).astype(int)
T_dev_examples["対応貯水量_千m3"] = T_dev_examples["対応貯水量_千m3"].round(2)
T_dev_examples["対応堤高_m"] = T_dev_examples["対応堤高_m"].round(2)

# 表 8 未監視ため池 上位 (距離降順 = 監視ネットワーク到達半径の外延)
T_unmon = unmon_detail.head(15).copy()

# 表 9 区別 避難所 vs 装置
T_shel_dev = ku_combined.sort_values("件数", ascending=False).reset_index(drop=True).copy()
T_shel_dev["避難所_装置比"] = (T_shel_dev["避難所数"] /
                                  T_shel_dev["件数"].replace(0, np.nan)).round(1)

# 表 10 装置 1km 圏 避難所数 上位 + 下位
df_shel_sorted = df.sort_values("避難所1km圏数", ascending=False)
T_dev_shel_top = df_shel_sorted.head(5)[
    ["観測所名", "区", "避難所1km圏数", "最近隣避難所_m", "対応ため池名"]
].copy()
T_dev_shel_bot = df_shel_sorted.tail(5)[
    ["観測所名", "区", "避難所1km圏数", "最近隣避難所_m", "対応ため池名"]
].copy()
T_dev_shel_top["最近隣避難所_m"] = T_dev_shel_top["最近隣避難所_m"].round(0).astype(int)
T_dev_shel_bot["最近隣避難所_m"] = T_dev_shel_bot["最近隣避難所_m"].round(0).astype(int)

# 表 11 仮説検証
def jud(cond, ok="強支持", fail="反証", part="部分支持"):
    return ok if cond else fail


T_hypo = pd.DataFrame([
    ("H1 中山間偏在 (中山間 ≥ 60%)",
     f"観測 中山間 {n_inland}/{n_total} ({n_inland/n_total*100:.0f}%)",
     jud(n_inland / n_total >= 0.6, "強支持", "部分支持"),
     f"H1 {jud(n_inland/n_total>=0.6,'強支持','部分支持')}: "
     f"中山間 4 区 ({n_inland} = {n_inland/n_total*100:.0f}%) に偏在、"
     f"都市核心 3 区はゼロ。2018 豪雨型崩落リスクの地理が制度に反映"),
    ("H2 装置 ID コホート性 (前期=大規模)",
     f"前期中央 {co_meds[0][1] if len(co_meds)>=1 else 'NA':.2f} vs "
     f"後期中央 {co_meds[2][1] if len(co_meds)>=3 else 'NA':.2f} 千m³"
     if len(co_meds) >= 3 else "コホート不足",
     jud(len(co_meds) >= 3 and co_meds[0][1] > co_meds[2][1], "強支持", "反証"),
     ("H2 強支持: 前期 → 中期 → 後期の中央貯水量が "
      f"{co_meds[0][1]:.2f} → {co_meds[1][1]:.2f} → {co_meds[2][1]:.2f} 千m³ と"
      "<b>単調減少</b>。device_id は設置順の代理として機能し、<b>大規模池が早く監視された</b>"
      "制度設計が確認された (前期/後期で規模 "
      f"{co_meds[0][1]/max(co_meds[2][1], 0.001):.1f} 倍差)")
     if (len(co_meds) >= 3 and co_meds[0][1] > co_meds[2][1])
     else
     ("H2 反証: 前期 → 後期に明確な順序差なし。device_id は管理 ID で"
      "<b>設置順を反映しない</b>可能性が高い。物理設置時系列を読むには別データが必要")),
    ("H3 監視カバー率 (< 50%)",
     f"観測 {cover_rate:.1f}% ({n_monitored_ponds}/{n_hiro_ponds})",
     jud(cover_rate < 50, "強支持", "反証"),
     f"H3 {jud(cover_rate<50,'強支持','反証')}: 監視カバー率 {cover_rate:.1f}% で "
     f"半数以下。広島市内ですら未整備が過半数 = 整備途上の事業"),
    ("H4 規模 ↔ 監視 (監視済 > 未監視, p<0.05)",
     f"監視済中央 {mon_cap_med:.2f} vs 未監視 {unmon_cap_med:.2f} 千m³, p={p_cap:.3f}",
     jud(not np.isnan(p_cap) and p_cap < 0.05, "強支持", "反証"),
     f"H4 {jud(not np.isnan(p_cap) and p_cap<0.05,'強支持','反証')}: "
     f"Mann-Whitney 片側 p = {p_cap:.3f}. 監視済が "
     f"{'有意に' if not np.isnan(p_cap) and p_cap < 0.05 else '統計的に有意でないが'}"
     f"大規模な傾向 ({mon_cap_med/max(unmon_cap_med,0.001):.2f} 倍)"),
    ("H5 避難所 1km 圏 ≥ 5 (装置の半数以上)",
     f"観測 {n_dev_with_5plus}/{n_total} ({share_5plus:.0f}%) の装置が 1km 圏 5+ 避難所",
     jud(n_dev_with_5plus / n_total >= 0.5, "強支持", "部分支持"),
     f"H5 {jud(n_dev_with_5plus/n_total>=0.5,'強支持','部分支持')}: "
     f"装置 1km 圏 5+ 避難所 = {n_dev_with_5plus}/{n_total} ({share_5plus:.0f}%). "
     f"区別装置数 × 避難所数 r={r_dev_shel:.2f} = "
     f"{'正相関' if r_dev_shel > 0.3 else ('弱い' if r_dev_shel > 0 else '負相関')}。"
     f"監視装置は中山間集中 = 避難所多寡とは別の論理 (= 池の所在による配置)"),
], columns=["仮説", "観測値", "判定", "解釈"])
T_hypo.to_csv(ASSETS / "L60_hypothesis_check.csv", index=False, encoding="utf-8-sig")

print(f"  生成テーブル: 11 表")
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 9. 中間ファイル ZIP 化 (ダウンロード用)
# =============================================================================
print("\n[9] 中間ファイル ZIP 化", flush=True)
t1 = time.time()
zip_path = ASSETS / "L60_pond_monitoring_raw.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
    zf.write(CSV_PATH, arcname="tameike_camera.csv")
print(f"  生 zip: {zip_path.name} ({zip_path.stat().st_size:,} byte)")
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 10. HTML 生成
# =============================================================================
print("\n[10] HTML 生成", flush=True)
t1 = time.time()

# Sec 1: 学習目標と問い
sec1 = f"""
<p>本記事は DoBoX のシリーズ「<b>ため池監視カメラ及び水位計設置箇所一覧</b>」 1 件
(dataset_id = <a href="https://hiroshima-dobox.jp/datasets/1675">1675</a>) を <b>単独</b>で取り上げ、
広島県内の<b>ため池監視装置 (= カメラ + 水位計の遠隔観測点) {n_total} 箇所</b>
(CSV, 5 列, UTF-8 BOM, 約 8 KB) を
3 つの独立した研究角度 (RQ1 / RQ2 / RQ3) で並列に分析する。
本データは<b>2018 年西日本豪雨を契機とした防災投資</b>の象徴で、
広島市が運用する<b>ikelog.cloud (リアルタイム観測 Web)</b> と直結している。
Phase 4 農業遺構系の<b>運用フェーズ側</b>で、L59 (属性側) と L45 (幾何側) と並ぶ
ため池系 3 番目の研究角度。</p>

<p class="note"><b>L59 (基本情報) / L45 (浸水想定) との関係</b>: L59 は<b>属性台帳 (CSV 6,754 件)</b>を、
L45 は<b>浸水想定 polygon (Shapefile 6,730 件)</b> を扱った。本記事 L60 は<b>監視装置 (CSV {n_total} 箇所)</b>を扱い、
<b>「指定された 6,754 池のうち実際に監視されているのはどれか」</b>という運用合理性の問いを初めて立てる。
3 記事は同じため池群の<b>属性 ⇄ 幾何 ⇄ 運用</b>の三角構造を完成させる。
ただし<b>本データは広島市内 70 件のみ</b> (= 県内全域ではない) という限界は本研究の前提として明示する。</p>

<h3>独自用語の定義</h3>
<ul class="kv">
  <li><b>ため池監視装置</b> (本記事独自統合): カメラ + 水位計を併設した遠隔観測点。
      本データの 1 行 = 1 観測点で、両者を区別せず<b>「監視装置」</b>として 1 単位で扱う。
      観測値は <code>https://hiroshima.ikelog.cloud/</code> でリアルタイム公開。</li>
  <li><b>監視ネットワーク</b> (本記事独自用語): 広島市内 {n_total} 箇所の監視装置を結んだ
      <b>仮想的な観測網</b>。各装置の最近隣距離 (median {nn_median:.0f}m) は
      <b>監視カバー範囲の細かさ</b>を示す指標。</li>
  <li><b>監視空白</b> (本記事独自用語): 防災重点ため池のうち最近隣監視装置から
      <b>{SAME_POND_THRESHOLD_M:.0f}m 以上離れて存在</b>するため池。
      本記事では同一池の判定閾値を {SAME_POND_THRESHOLD_M:.0f}m に設定 (実装テストで装置→ため池 max
      距離 = 96m に対して安全側の閾値)。</li>
  <li><b>監視カバー率</b> (本記事 RQ2 定義): 「広島市の防災重点ため池のうち監視済の割合」。
      観測値 <b>{cover_rate:.1f}%</b> ({n_monitored_ponds}/{n_hiro_ponds})。
      残り {n_unmonitored_ponds} 件は監視ネットワーク到達範囲外。</li>
  <li><b>装置 ID コホート</b> (本記事独自用語): device_id を 3 等分した前期/中期/後期。
      ID 範囲 {id_min}-{id_max} = ID 幅 {id_range} は装置の管理 ID で、
      <b>必ずしも設置順とは限らない</b>が、ID の連続性が見えれば管理体系の手がかりになる。</li>
  <li><b>下流人家リスク</b> (本記事 RQ3 定義): 監視装置の 1km 圏内に存在する避難所数。
      避難所 = 周辺住民の集中域の代理指標。装置 1km 圏の中央避難所数 <b>{cam_within_med:.0f}</b>。</li>
  <li><b>立地区分</b> (本記事独自定義): 広島市 8 区を<b>中山間 (安佐北・安佐南・安芸・佐伯) /
      都市部 (中・東・南・西)</b>の 2 区分に分類。
      中山間 {n_inland} 装置 / 都市部 {n_urban} 装置 = 中山間に偏在。</li>
</ul>

<h3>研究の問い (3 RQ)</h3>
<ul>
  <li><b>RQ1 (主研究)</b>: 広島市の<b>ため池監視装置の地理分布と区別構造</b>はどう描けるか?
      {n_total} 件すべて広島市内、しかし<b>5 区 ({top1_ku} {top1_ku_n} ({top1_ku_share:.0f}%) +
      他 4 区)</b>に偏在、中区・西区・南区はゼロ。
      最近隣装置距離 (median {nn_median:.0f}m) と装置 ID 連番性を読み解く。</li>
  <li><b>RQ2 (副研究 1)</b>: <b>監視ネットワークの空白</b>はどこか?
      広島市の防災重点ため池 {n_hiro_ponds} 件のうち<b>{cover_rate:.1f}% のみ監視済</b>、
      残り {n_unmonitored_ponds} 件は監視装置から離れて存在する空白池。
      未監視 {n_unmonitored_ponds} 件の最近隣監視点距離 (median {unmon_med:.0f}m, max {unmon_max/1000:.1f}km) を
      可視化し、規模差 (Mann-Whitney U) で監視優先度の制度合理性を検証。</li>
  <li><b>RQ3 (副研究 2)</b>: 監視装置と<b>下流人家・避難所の空間関係 — 監視優先度の妥当性</b>はどうか?
      広島市内の指定緊急避難場所 {n_shelters} 件と監視装置の最近隣距離・1km 圏避難所数を計算。
      区別 装置数 × 避難所数の Pearson r = {r_dev_shel:.2f} で
      <b>「中山間優先 vs 都市部避難所多」</b>の制度設計を空間統計で読む。</li>
</ul>

<h3>仮説 H1〜H5</h3>
<ol>
  <li><b>H1 (中山間偏在 ≥ 60%, RQ1)</b>: 監視装置は<b>中山間 4 区 (安佐北 + 安佐南 + 安芸 + 佐伯)</b>に
      合計 60% 以上が集中。これは中山間ため池の<b>2018 豪雨型崩落リスク</b>を反映した制度運用。</li>
  <li><b>H2 (装置 ID 連番性, RQ1)</b>: 装置 ID (3072-3174) は<b>設置順を反映する管理 ID</b>であり、
      前期コホート (= 早期設置) は重要度の高い大規模池に多いはず。</li>
  <li><b>H3 (監視カバー率 &lt; 50%, RQ2)</b>: 広島市の防災重点ため池 161 件中、
      <b>監視済は半数以下</b>。これは 2020 年代開始の新規事業のため整備途上にあることを反映。</li>
  <li><b>H4 (規模 ↔ 監視, RQ2)</b>: 監視済ため池は<b>未監視より大規模</b>。
      Mann-Whitney U 片側検定で p &lt; 0.05 の有意差を予想。
      決壊時被害が大きい池を優先監視するのは制度的合理性。</li>
  <li><b>H5 (避難所 1km 圏 ≥ 5 が装置の半数以上, RQ3)</b>: 監視装置の<b>1km 圏に 5 以上の避難所</b>が
      存在するのは装置の半数以上。これは「下流人家集中域に監視装置を置く」 の合理性。
      ただし監視装置の中山間集中と避難所の都市部集中という制度的緊張関係も予想される。</li>
</ol>

<h3>到達点</h3>
<p>本記事を読み終えた学習者は次の 3 点を体感できる:</p>
<ol>
  <li>1 つの「シンプルな点台帳 CSV ({n_total} 行 × 5 列)」 から、
      <b>地理 (緯度経度) + 設置順 (device_id) + 行政 (広島市 8 区) + 運用 (URL)</b>という
      4 軸を多角度に読む方法を体感する。
      <b>5 列しかないデータでも、適切な独自解析と既存データ (L45/L59/L03) との結合で
      豊かな研究が可能</b>であることを実証。</li>
  <li>L59 (属性側) と L45 (幾何側) と本記事 (運用側) の<b>三角構造</b>を体感する。
      同じため池群を 3 つの異なる断面から見ることで、
      <b>「制度→投資→運用」 の三段ロケットがデータに刻まれた痕跡</b>を読む研究の作法を学ぶ。</li>
  <li>BallTree (haversine) を使った<b>最近隣空間検索</b>と Mann-Whitney U の
      <b>分布差検定</b>を組み合わせ、空間 + 統計の両面から仮説検証する研究プロセスを体感する。
      <b>「どこに監視を置くか」</b>という防災行政の意思決定に研究的視点で迫る教材として機能する。</li>
</ol>
"""

# Sec 2: 使用データ
sec2 = f"""
<p>DoBoX のシリーズ「<b>ため池監視カメラ及び水位計設置箇所一覧</b>」 1 件のみを単独で扱う。
リソースは CSV <b>1 ファイル</b>のシンプル構造 (UTF-8 BOM、約 8 KB)。</p>

{df_to_html(T_dataset)}
<p><b>この表から読み取れること</b>: dataset 1675 は<b>シンプルな CSV 単一ファイル</b>で 8 KB と小さい。
{n_total} 行 × 5 列だが、緯度経度・装置 ID (URL 末尾) を全件保有。
解析時は EPSG:6671 (平面直角座標系第 III 系) に変換して距離を m 単位で扱う。
公式名には「広島県」 が含まれないが、住所はすべて広島市内なので<b>実質広島市データ</b>。
L59 防災重点ため池との結合可能率 100% (全 70 装置が広島市内ため池の 100m 以内)、
L03 避難所との 1km 圏結合で監視優先度の妥当性検証が可能。</p>

<h3>データの構造 (5 列の意味)</h3>
<ul>
  <li><b>観測所名</b>: 例「流谷」「大原」「桜山」 — ため池の固有名称と対応。
      L59 ため池基本情報の「ため池名称」 と<b>名称完全一致 49 件</b>、
      位置一致 (100m 以内) では <b>{n_cam_matched}/{n_total} 件</b>。</li>
  <li><b>住所</b>: 「広島市XX区YY町」 形式。
      区名抽出により広島市 8 区への分類が可能。</li>
  <li><b>緯度・経度</b>: WGS84 度。元データは末尾空白あり (要 strip)。
      緯度範囲 {df['緯度'].min():.3f}-{df['緯度'].max():.3f}、
      経度範囲 {df['経度'].min():.3f}-{df['経度'].max():.3f}。</li>
  <li><b>観測情報URL</b>: <code>https://hiroshima.ikelog.cloud/#/device-chart/&#123;ID&#125;</code>
      形式。末尾の整数 ID (= device_id) は装置の管理識別子 (3072-3174 の範囲, 整数 ID 幅 {id_range})。
      ikelog.cloud で<b>リアルタイム水位・カメラ映像</b>が確認可能。</li>
</ul>

<h3>関連データセットとの対応</h3>
<ul>
  <li><b>L59 #62 ため池基本情報</b> (CSV 6,754 行): 同じため池群の属性台帳。
      広島市分 161 件と本データ 70 件を結合し<b>監視カバー率 {cover_rate:.1f}%</b> を算出。</li>
  <li><b>L45 #63 ため池浸水想定区域 Shapefile</b>: 同じため池群の決壊シナリオ polygon。
      本記事 RQ2 では L45 の事前処理 cache (広島市分 161 件) を再利用。</li>
  <li><b>L03 避難所</b> (DoBoX 公開避難所一覧): 広島市内 {n_shelters} 件を本記事 RQ3 で使用。
      装置の 1km 圏避難所数を BallTree (haversine) で計測。</li>
  <li><b>L02 #1279 県内カメラ</b>: 道路・河川等の県管理カメラ 351 台 (= 別データ)。
      本データは<b>市管理のため池専用</b>で対象が異なる (合体禁止)。</li>
</ul>
"""

# Sec 3: ダウンロード
sec3 = f"""
<p>本レッスンの再現に必要な全データ・中間 CSV・図 PNG・スクリプトを以下から直接 DL できる:</p>

<h3>生データ (DoBoX 直リンク)</h3>
<ul>
  <li><a href="https://hiroshima-dobox.jp/datasets/1675" target="_blank">DoBoX dataset 1675 (ため池監視カメラ及び水位計設置箇所一覧)</a></li>
  <li><a href="../data/extras/tameike_camera.csv" download>CSV: tameike_camera.csv ({CSV_PATH.stat().st_size:,} byte, {n_total} 行)</a></li>
  <li>参考: <a href="https://hiroshima-dobox.jp/datasets/62" target="_blank">dataset 62 (ため池基本情報, L59 既扱)</a></li>
  <li>参考: <a href="https://hiroshima-dobox.jp/datasets/63" target="_blank">dataset 63 (ため池浸水想定 Shapefile, L45 既扱)</a></li>
</ul>

<h3>本記事の中間 CSV (再現用)</h3>
<ul>
  <li><a href="assets/L60_pond_monitoring_raw.zip" download>L60_pond_monitoring_raw.zip</a> — 元 CSV のコピー</li>
  <li><a href="assets/L60_overall.csv" download>L60_overall.csv</a> — 全体サマリ (13 指標)</li>
  <li><a href="assets/L60_ku_ranking.csv" download>L60_ku_ranking.csv</a> — 区別装置数ランキング</li>
  <li><a href="assets/L60_cohort_by_ku.csv" download>L60_cohort_by_ku.csv</a> — 設置コホート × 区</li>
  <li><a href="assets/L60_ku_coverage.csv" download>L60_ku_coverage.csv</a> — 区別 監視カバー率</li>
  <li><a href="assets/L60_unmonitored_ponds.csv" download>L60_unmonitored_ponds.csv</a> — 未監視 {n_unmonitored_ponds} 件詳細</li>
  <li><a href="assets/L60_ku_combined.csv" download>L60_ku_combined.csv</a> — 区別 装置 vs 避難所</li>
  <li><a href="assets/L60_hypothesis_check.csv" download>L60_hypothesis_check.csv</a> — 仮説検証表</li>
</ul>

<h3>図 PNG (8 枚) と Python スクリプト</h3>
<ul>
  <li><a href="assets/L60_fig1_monitor_map.png" download>図 1 (RQ1) 広島市マップ + 区別ランキング</a></li>
  <li><a href="assets/L60_fig2_nn_id.png" download>図 2 (RQ1) 最近隣装置距離 + device_id 散布</a></li>
  <li><a href="assets/L60_fig3_ku_small_mults.png" download>図 3 (RQ1) 区別 small multiples マップ</a></li>
  <li><a href="assets/L60_fig4_coverage_map.png" download>図 4 (RQ2) 監視済 vs 未監視 マップ + 距離分布</a></li>
  <li><a href="assets/L60_fig5_coverage_size.png" download>図 5 (RQ2) 区別カバー率 + 規模箱ひげ</a></li>
  <li><a href="assets/L60_fig6_id_size.png" download>図 6 (RQ2) device_id × 規模 散布 + コホート箱ひげ</a></li>
  <li><a href="assets/L60_fig7_shelters.png" download>図 7 (RQ3) 監視装置 + 避難所 重ね合わせ</a></li>
  <li><a href="assets/L60_fig8_ku_shel.png" download>図 8 (RQ3) 区別 装置 vs 避難所 + ため池1km圏</a></li>
  <li><a href="L60_pond_monitoring.py" download>L60_pond_monitoring.py</a> — 再現スクリプト</li>
</ul>

<h3>個別取得 (PowerShell, このレッスンだけ)</h3>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 lessons/L60_pond_monitoring.py</code></pre>
<p class="tnote">CSV は本スクリプトが DoBoX dataset 1675 から自動 DL する (キャッシュ済なら再利用)。
L45 cache (広島市分 161 ため池の事前処理結果) と L03 shelters.json も内部で読込。</p>

<h3>一括取得 (全レッスン共通, 推奨)</h3>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 data\\fetch_all.py
py -X utf8 lessons/L60_pond_monitoring.py</code></pre>
"""

# Sec 4: RQ1 (構造分析)
sec4_intro = f"""
<h3>RQ1 の狙い</h3>
<p>{n_total} 件の監視装置を<b>区別 / 立地 / 装置 ID / 最近隣距離</b>で多角度に集計し、
<b>「広島市の監視装置はどこに、どう散らばっているか」</b> を立体的に描く。
特に<b>{top1_ku} {top1_ku_n} ({top1_ku_share:.0f}%)</b> の偏在と
<b>都市核心 3 区ゼロ</b>という極端な分布の意味を、立地区分と装置 ID から読み解く。</p>

<h3>手法 (前置き解説)</h3>
<ul>
  <li><b>緯度経度 → GeoDataFrame 化</b>: WGS84 で読み込み、解析用に EPSG:6671 (平面直角第 III 系) に投影変換。
      距離を m 単位で扱うため。</li>
  <li><b>住所からの区抽出</b>: 正規表現 <code>広島市([^区市町]+区)</code> で「広島市XX区」 から
      区名を取り出す。8 区 (中・東・南・西・安佐北・安佐南・安芸・佐伯) を全網羅。</li>
  <li><b>立地区分</b>: 中山間 (安佐北・安佐南・安芸・佐伯) と都市部 (中・東・南・西) の 2 区分。
      これは<b>本記事独自定義</b>で、東京の都心 vs 郊外と類似の分け方。</li>
  <li><b>BallTree + haversine 距離</b>: scikit-learn の BallTree は<b>球面上の最近隣検索</b>に最適。
      <code>metric='haversine'</code> で緯度経度を球面距離 (rad) で計算、地球半径 (6,371,000m) を掛けて
      m に変換。最近隣装置距離 (NN) の分布を見れば<b>監視ネットワークの密度</b>が読める。</li>
  <li><b>装置 ID = device_id</b>: 観測情報 URL 末尾の <code>/device-chart/&#123;ID&#125;</code> から正規表現で抽出。
      ID 範囲 ({id_min}-{id_max}) を 3 等分した前期/中期/後期コホートで時間軸を擬似的に作る。</li>
</ul>

<h3>入出力の Before/After 例</h3>
<table>
<tr><th>段階</th><th>1 件のデータの中身</th><th>サイズ</th></tr>
<tr><td>(0) CSV 1 行 (raw)</td><td>"流谷","広島市東区戸坂新町一丁目",34.423818,132.495138,"https://...device-chart/3094"</td><td>5 列</td></tr>
<tr><td>(1) 数値型 cast (空白 strip)</td><td>緯度=34.4238, 経度=132.4951</td><td>5 列 + 2 派生</td></tr>
<tr><td>(2) URL から device_id 抽出</td><td>+ device_id=3094</td><td>+ 1 派生</td></tr>
<tr><td>(3) 区抽出 (正規表現)</td><td>+ 区="東区"</td><td>+ 1 派生</td></tr>
<tr><td>(4) 立地区分付与</td><td>+ 立地区分="都市部"</td><td>+ 1 派生</td></tr>
<tr><td>(5) GeoDataFrame 化 → EPSG:6671</td><td>+ x_m=29986, y_m=-174468</td><td>+ 2 派生</td></tr>
<tr><td>(6) BallTree NN 計算</td><td>+ NN距離_m=87 (= 最近隣装置まで 87m)</td><td>+ 1 派生</td></tr>
<tr><td>(7) コホート付与</td><td>+ 設置コホート="中期"</td><td>+ 1 派生</td></tr>
</table>
<p class="tnote">(0)-(7) を全 {n_total} 行に適用 → groupby で集計 → 図化。BallTree は 70 行なら 1 ms で完了。</p>
"""

sec4_code = code(r'''
# 1. CSV 読込 (UTF-8 BOM + 末尾空白 strip)
df = pd.read_csv(CSV_PATH, encoding="utf-8-sig", dtype=str)
df.columns = [c.replace("﻿", "").strip().strip('"') for c in df.columns]
df["緯度"] = pd.to_numeric(df["緯度"].astype(str).str.strip(), errors="coerce")
df["経度"] = pd.to_numeric(df["経度"].astype(str).str.strip(), errors="coerce")

# 2. URL → device_id (正規表現抽出)
df["device_id"] = df["観測情報URL"].str.extract(r"/device-chart/(\d+)").astype(int)

# 3. 住所 → 区 (正規表現)
def get_ku(s):
    m = re.search(r"広島市([^区市町]+区)", str(s))
    return m.group(1) if m else "その他"

df["区"] = df["住所"].apply(get_ku)

# 4. 立地区分 (中山間 vs 都市部)
INLAND_KU = {"安佐北区", "安佐南区", "安芸区", "佐伯区"}
URBAN_KU  = {"中区", "東区", "南区", "西区"}
df["立地区分"] = df["区"].apply(
    lambda k: "中山間" if k in INLAND_KU else
              ("都市部" if k in URBAN_KU else "その他")
)

# 5. GeoDataFrame 化 (WGS84 → EPSG:6671)
gdf = gpd.GeoDataFrame(
    df,
    geometry=gpd.points_from_xy(df["経度"], df["緯度"]),
    crs="EPSG:4326",
).to_crs("EPSG:6671")

# 6. BallTree (haversine) で最近隣装置距離 (NN)
from sklearn.neighbors import BallTree
EARTH_R_M = 6_371_000.0
mon_rad = np.deg2rad(df[["緯度", "経度"]].values)
tree = BallTree(mon_rad, metric="haversine")
nn_d, _ = tree.query(mon_rad, k=2)              # k=2 → 自身 + 最近隣
df["NN距離_m"] = nn_d[:, 1] * EARTH_R_M          # rad → m
print(f"NN 中央値: {np.median(df['NN距離_m']):.0f} m")

# 7. 区別ランキング
ku_rank = (
    df.groupby("区").size().reset_index(name="件数")
      .sort_values("件数", ascending=False).reset_index(drop=True)
)
ku_rank["シェア_%"] = (ku_rank["件数"] / len(df) * 100).round(2)
print(ku_rank)
''')

sec4_fig1_text = """
<h3>図 1: なぜこの図か (RQ1)</h3>
<p>「監視装置がどこに、どんな順序で散らばっているか」 を 1 枚で読みたい。
<b>左の点マップ</b>で全 70 点の地理分布と装置 ID (色) を直感的に把握し、
<b>右の区ランキング棒</b>で件数の偏りと立地区分 (中山間=赤 / 都市部=青) の対比を読む。
点マップは「面で見る」、棒グラフは「順序で見る」 の<b>2 つの認知モードを 1 枚に束ねる</b>のが狙い。</p>
"""
sec4_fig1_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左マップ: 監視装置は<b>広島市の北東 (安佐北・安芸) に偏在</b>、
      色 (device_id) は北部・東部に若い ID (青〜緑) が多く、南部・西部に古い ID (黄〜赤) が混在。
      <b>装置 ID と地理は単純な順序関係を持たない</b> = ID は管理 ID で設置順とは限らない可能性。</li>
  <li>右棒グラフ: <b>{top1_ku} {top1_ku_n} ({top1_ku_share:.0f}%) で圧倒的 1 位</b>、
      次に安芸区 19、安佐南区 13 と続く。
      <b>中区・南区・西区はゼロ</b> (都市核心部にため池無し)。</li>
  <li>立地区分の色 (青=都市部 / 赤=中山間) を見ると、<b>中山間 4 区が上位 4 位を独占</b>。
      これは<b>2018 豪雨型崩落リスク</b>が中山間ため池に集中することを反映した制度運用。</li>
  <li><b>H1 (中山間 ≥ 60%) {'強支持' if n_inland/n_total >= 0.6 else '部分支持'}</b>:
      中山間 {n_inland}/{n_total} = {n_inland/n_total*100:.0f}% で
      {'60% を超え強支持' if n_inland/n_total >= 0.6 else '部分支持'}。
      都市核心 3 区はゼロ = ため池そのものが存在しない地理を反映。</li>
</ul>
"""

sec4_fig2_text = """
<h3>図 2: なぜこの図か (RQ1)</h3>
<p>監視ネットワークの密度を見たいので、<b>最近隣装置距離 (NN) のヒスト</b>と
<b>device_id × 緯度の散布</b>を並べた。
NN 分布は「監視点同士がどれくらい近くにあるか」 を読む基本指標。
device_id × 緯度散布は「設置順と地理が連動するか」 を視覚的に検証する。
点を区で色分けすれば「どの区がどの ID 帯に対応するか」 も同時に読める。</p>
"""
sec4_fig2_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左 (NN ヒスト): 中央値 <b>{nn_median:.0f} m</b>、平均 {nn_mean:.0f} m、最大 {nn_max:.0f} m。
      装置同士の最近隣距離は<b>200-1000 m</b> に集中 (IQR = [{nn_p25:.0f}, {nn_p75:.0f}] m)。
      これは「同一池複数装置 (= 数 m〜数十 m 隣接)」 と「別池の装置 (数 km)」 の中間に
      <b>「同じ谷の隣接池監視」</b>のクラスタが多い証拠。</li>
  <li>右 (device_id × 緯度散布): 装置 ID は<b>緯度と単純な相関を示さず</b>、
      色 (区) で見ても各区の ID は<b>ほぼ全範囲に分散</b>。
      これは<b>device_id が設置順を反映する管理 ID とは限らない</b>ことを示唆 (例えば
      装置タイプや管轄部署別の ID 体系の可能性)。</li>
  <li>NN 距離が短い装置 (例えば 100m 以下) は<b>同一谷地内の連続池</b>を示す可能性。
      上池・中池・下池のような階段田農業の典型構造を反映している可能性が高い。</li>
</ul>
"""

sec4_fig3_text = """
<h3>図 3: なぜこの図か (RQ1)</h3>
<p>区ごとの装置配置パターンを見たいので、<b>装置のある 5 区の small multiples マップ</b>を選んだ。
全市マップでは点が密集すぎて区別の特徴が見えないが、区単位で<b>同じ縮尺で並置</b>すれば
「安佐北は北部山地に分散」「安芸は南部にクラスタ」 など、地域固有の点分布パターンを比較できる。</p>
"""
sec4_fig3_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li><b>安佐北区 (Top 1, 30 装置)</b>: 区全域に分散、特に北部山地と南部丘陵地の両方に。
      これは「ため池の中山間典型」 で、棚田農業の遺産を多く抱える区。</li>
  <li><b>安芸区 (Top 2, 19 装置)</b>: 区南部の阿戸町・畑賀町に集中。
      ここは 2018 豪雨で土砂災害が多発した地域で、<b>監視投資が集中的に展開された</b>痕跡。</li>
  <li><b>安佐南区 (Top 3, 13 装置)</b>: 区中央部の祇園・長楽寺付近にクラスタ、上池・中池・下池の名前が並ぶ。</li>
  <li><b>佐伯区・東区</b>: 各 5・3 件と少数。佐伯区は西部山地、東区は北部に散在。</li>
  <li>中山間 4 区がいずれも谷地形に沿って装置が並ぶ = <b>地形依存の配置</b>がこの可視化で
      最も鮮明に読める。「装置がどこにあるか」 は「ため池がどこにあるか」 の写像であり、
      <b>谷地形の分布</b>が監視装置の地理を決める根本要因。</li>
</ul>
"""

# Sec 5: RQ2 (監視ネットワーク空白)
sec5_intro = f"""
<h3>RQ2 の狙い</h3>
<p>L59 (基本情報, 6,754 件) と L45 (浸水想定, 6,730 polygon) の連携で
<b>広島市内の防災重点ため池 161 件</b>を取り出し、本データの 70 装置との<b>結合カバレッジ</b>を測る。
具体的に:</p>
<ol>
  <li>各ため池の最近隣監視装置距離を haversine で計算し、{SAME_POND_THRESHOLD_M:.0f}m 以内なら「監視済」 と判定</li>
  <li>未監視ため池の最近隣監視点距離分布から<b>監視ネットワーク到達半径</b>を可視化</li>
  <li>監視済 vs 未監視の規模 (堤高・貯水量) を Mann-Whitney U で検定し<b>監視優先度の制度合理性</b>を検証</li>
</ol>
<p>これは単なる「カバー率の集計」 ではなく、<b>「下流被害ポテンシャルの大きい池が監視されている」</b>という
制度設計仮説を統計的に検証する研究。</p>

<h3>手法 (前置き解説)</h3>
<ul>
  <li><b>L45 既処理 cache を読込</b>: <code>data/extras/L45_pond_inundation/_cache/pond_inund_merge.csv</code>
      から広島市分 161 件を抽出。L45 で計算済みの「ため池番号 → 浸水想定面積」 マッピングを再利用。</li>
  <li><b>BallTree (haversine) で双方向 NN</b>:
      ため池→最近隣装置 距離と、装置→最近隣ため池 距離の両方を計算。
      閾値 <b>{SAME_POND_THRESHOLD_M:.0f}m</b> 以内を「同一池」 とみなす (装置→ため池の max 距離 = 96m に対する
      安全側の閾値)。</li>
  <li><b>spatial join (sjoin)</b>: ため池の点 polygon と区 polygon を「点 in polygon」 で結合し、
      区別カバー率を集計。</li>
  <li><b>Mann-Whitney U 検定</b>: 監視済と未監視の貯水量・堤高の中央値が異なるかを片側検定。
      <b>正規分布を仮定しないノンパラメトリック検定</b>で、log-normal 分布のため池規模に最適。
      <code>scipy.stats.mannwhitneyu(x, y, alternative='greater')</code> で
      x の中央値が y より大きい (= 監視済 > 未監視) 仮説を検定。</li>
</ul>

<h3>入出力の Before/After 例</h3>
<table>
<tr><th>段階</th><th>1 件のデータの中身</th><th>件数</th></tr>
<tr><td>(0) L45 cache から広島市抽出</td><td>"341000001","長尾池","広島市",緯度,経度,堤高,貯水量,...</td><td>{n_hiro_ponds}</td></tr>
<tr><td>(1) ため池 BallTree query (k=1)</td><td>+ 最近隣監視点_m=2.6, 最近隣装置="長尾池"</td><td>{n_hiro_ponds}</td></tr>
<tr><td>(2) {SAME_POND_THRESHOLD_M:.0f}m 閾値で監視済判定</td><td>+ 監視済=True (この池は監視済)</td><td>監視済 {n_monitored_ponds} / 未監視 {n_unmonitored_ponds}</td></tr>
<tr><td>(3) sjoin で区付与</td><td>+ 区="東区"</td><td>{n_hiro_ponds}</td></tr>
<tr><td>(4) Mann-Whitney U</td><td>監視済中央 {mon_cap_med:.2f} vs 未監視 {unmon_cap_med:.2f} → p={p_cap:.3f}</td><td>2 群</td></tr>
</table>
"""

sec5_code = code(r'''
# 1. L45 既処理 cache から広島市分のため池を抽出
l45 = pd.read_csv(L45_CACHE, dtype={"ため池番号": str})
hiro_ponds = l45[l45["所管市町"] == "広島市"].copy()
hiro_ponds["緯度"] = pd.to_numeric(hiro_ponds["緯度"], errors="coerce")
hiro_ponds["経度"] = pd.to_numeric(hiro_ponds["経度"], errors="coerce")
hiro_ponds = hiro_ponds.dropna(subset=["緯度", "経度"])

# 2. BallTree (haversine) でため池 → 最近隣装置距離
from sklearn.neighbors import BallTree
EARTH_R_M = 6_371_000.0
mon_rad   = np.deg2rad(df[["緯度", "経度"]].values)
ponds_rad = np.deg2rad(hiro_ponds[["緯度", "経度"]].values)
tree_mon  = BallTree(mon_rad, metric="haversine")
nearest_d, nearest_i = tree_mon.query(ponds_rad, k=1)
hiro_ponds["最近隣監視点_m"] = nearest_d[:, 0] * EARTH_R_M

# 3. 100m 閾値で「監視済」 判定 (= 同一池)
SAME_POND_THRESHOLD_M = 100.0
hiro_ponds["監視済"] = hiro_ponds["最近隣監視点_m"] < SAME_POND_THRESHOLD_M

n_monitored = hiro_ponds["監視済"].sum()
cover_rate  = n_monitored / len(hiro_ponds) * 100
print(f"監視カバー率: {cover_rate:.1f}% ({n_monitored}/{len(hiro_ponds)})")

# 4. 区別カバー率 (sjoin で区付与)
ponds_gdf = gpd.GeoDataFrame(
    hiro_ponds,
    geometry=gpd.points_from_xy(hiro_ponds["経度"], hiro_ponds["緯度"]),
    crs="EPSG:4326",
).to_crs("EPSG:6671")
ponds_with_ku = gpd.sjoin(ponds_gdf, ku_diss[["区名", "geometry"]],
                           how="left", predicate="within")
hiro_ponds["区"] = ponds_with_ku["区名"].fillna("その他").values

ku_cover = (
    hiro_ponds.groupby("区")
              .agg(全件数=("ため池番号", "count"),
                   監視済=("監視済", "sum"))
              .reset_index()
)
ku_cover["カバー率_%"] = (ku_cover["監視済"] / ku_cover["全件数"] * 100).round(1)
print(ku_cover)

# 5. Mann-Whitney U で監視済 vs 未監視 の規模差検定 (片側)
from scipy.stats import mannwhitneyu
mon_cap   = hiro_ponds[hiro_ponds["監視済"]]["貯水量_千m3"].dropna()
unmon_cap = hiro_ponds[~hiro_ponds["監視済"]]["貯水量_千m3"].dropna()
u, p = mannwhitneyu(mon_cap, unmon_cap, alternative="greater")
print(f"H4: 監視済が大規模 → U={u:.0f}, p={p:.4f}")
''')

sec5_fig4_text = f"""
<h3>図 4: なぜこの図か (RQ2)</h3>
<p>「監視ネットワークの空白がどこにあるか」 を地図と分布で同時に読みたい。
<b>左マップ</b>は防災重点ため池 {n_hiro_ponds} 件を監視済 (青) と未監視 (赤) で塗り分け、
監視装置 (黒★) を重ねて<b>「装置がない場所のため池」</b>を浮かび上がらせる。
<b>右ヒスト</b>は未監視 {n_unmonitored_ponds} 池の最近隣監視点距離分布で、
1km 圏オレンジ点線・中央値黒破線・P75 紫破線の 3 マーカーで「どこまで監視ネットが届くか」 を読む。</p>
"""
sec5_fig4_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左マップ: 未監視 {n_unmonitored_ponds} 池 (赤) は<b>広島市全域</b>に散らばるが、特に
      <b>北部・南西部・東部の谷頭部</b>に集中。
      監視装置 (黒★) のクラスタから外れた池が未監視として浮かび上がる。
      これは「監視ネットの到達範囲外」 が物理的にどこかを直感的に示す。</li>
  <li>右ヒスト: 未監視 {n_unmonitored_ponds} 池の最近隣監視点距離は
      <b>中央値 {unmon_med:.0f} m</b>、P75 {unmon_p75:.0f}m、最大 {unmon_max:.0f}m
      ({unmon_max/1000:.1f}km)。
      <b>1km 圏内の未監視池</b>は {(unmon_dist_arr <= 1000).sum()} 件 ({(unmon_dist_arr <= 1000).sum()/n_unmonitored_ponds*100:.0f}%) で、
      残り {(unmon_dist_arr > 1000).sum()} 件は監視ネットから 1km 以上離れている。</li>
  <li><b>H3 (監視カバー率 < 50%) {'強支持' if cover_rate < 50 else '反証'}</b>:
      観測 {cover_rate:.1f}% で
      {'半数以下、整備途上を反映' if cover_rate < 50 else '半数を超え整備が進んでいる'}。
      未監視 {n_unmonitored_ponds} 池のうち {(unmon_dist_arr > 1000).sum()} 池は
      <b>「監視ネットから 1km 以上離れた孤立池」</b>として制度的に最重要な未整備候補。</li>
</ul>
"""

sec5_fig5_text = f"""
<h3>図 5: なぜこの図か (RQ2)</h3>
<p>「監視カバー率を区別に可視化」 と「監視済 vs 未監視の規模差」 を 1 枚で見たい。
左の<b>区別カバー率棒</b>は色 (赤=未監視あり / 青=完全監視) で 100% 達成区を識別。
右の<b>規模箱ひげ</b>は log10 貯水量で監視済 vs 未監視を比較し、
Mann-Whitney U の p 値も併記して<b>仮説 H4 (大規模が優先監視) の統計的妥当性</b>を読む。</p>
"""
sec5_fig5_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左棒: <b>全区が 100% 未満</b> = 全区で未監視ため池が残る = 整備途上の事業。
      最高カバー率の区でも 50% 級、最低区は 20% 未満。
      <b>「特定の区だけ未整備」 ではなく「広島市全体で整備途上」</b>。</li>
  <li>右箱: 監視済中央 <b>{mon_cap_med:.2f} 千m³</b> vs 未監視中央 <b>{unmon_cap_med:.2f}</b>
      = <b>{mon_cap_med/max(unmon_cap_med, 0.001):.2f} 倍</b>。
      Mann-Whitney U p = <b>{p_cap:.4f}</b>
      ({'p < 0.05 で有意 = 監視済が大規模' if not np.isnan(p_cap) and p_cap < 0.05 else 'p ≥ 0.05 で有意差なし'})。</li>
  <li><b>H4 (監視済 > 未監視, p<0.05) {'強支持' if not np.isnan(p_cap) and p_cap < 0.05 else '反証'}</b>:
      {'制度設計が決壊時被害の大きい大規模池を優先監視している証拠' if not np.isnan(p_cap) and p_cap < 0.05 else
       '統計的には監視済と未監視の規模に有意差なし = 規模以外の選定基準 (= 立地・下流人家・アクセス容易性等) が支配的'}。
      仮に有意差ありなら、未整備池は「中規模だが下流被害なし」 として後回しの可能性。
      仮に有意差なしなら、規模単独では監視優先度を予測できず、立地が決定要因。</li>
</ul>
"""

sec5_fig6_text = f"""
<h3>図 6: なぜこの図か (RQ2)</h3>
<p>装置設置順 (= device_id) と対応ため池の規模に何らかの関係があるか?
左の<b>device_id × log10 貯水量散布</b>と右の<b>コホート別箱ひげ</b>で
H2 (前期コホートに大規模池が集まる) を視覚的に検証する。
散布図のコホート境界 (灰点線) が「前期/中期/後期」 を区切り、
箱ひげの中央値で順序関係を読む。</p>
"""
_h2_supported = (len(co_meds) >= 3 and co_meds[0][1] > co_meds[2][1])
_co_seq = " → ".join(f"{m:.2f}" for _, m in co_meds) if len(co_meds) >= 3 else "NA"
sec6_fig6_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左散布: device_id を横軸、対応ため池の log10 貯水量を縦軸にとると、
      ID 全範囲で規模が散らばるが、{'<b>左寄り (前期 ID) ほど規模が大きい弱い右下がり傾向</b>が読める' if _h2_supported else '<b>明確な順序関係なし</b>'}。
      コホート境界 (灰点線) で前期/中期/後期に分けると見やすい。</li>
  <li>右コホート箱ひげ: 前期 → 中期 → 後期の中央規模 (千m³) は <b>{_co_seq}</b>。
      {'単調減少 = 前期コホートが最大規模を監視した' if _h2_supported else 'ほぼ同等で順序関係なし'}。</li>
  <li><b>H2 (装置 ID 連番性) {'強支持' if _h2_supported else '反証'}</b>:
      {'device_id は設置順の代理として機能し、<b>大規模池が早く監視された</b>制度合理性を支持。これは「決壊時被害の大きい池を優先監視」 という H4 と整合し、装置 ID から制度的優先順位の歴史が読み取れる' if _h2_supported else 'device_id は<b>設置順を反映しない管理 ID</b>と判断。装置タイプや管轄部署別の ID 体系の可能性。'}</li>
</ul>
"""

# Sec 6: RQ3 (下流リスク)
sec6_intro = f"""
<h3>RQ3 の狙い</h3>
<p>監視装置と<b>下流人家・避難所</b>の空間関係を測り、<b>「監視優先度の妥当性」</b>を検証する。
仮説: 監視装置は<b>下流人家集中域 (= 避難所多い区)</b>にあるべきだが、
実際は<b>中山間集中</b>。両者の<b>制度的緊張関係</b>を空間統計で読む。</p>
<ol>
  <li>L03 既扱の避難所 JSON から広島市分 {n_shelters} 件を抽出 ({n_flood_shelters} 件が洪水対応)</li>
  <li>各監視装置の 1km 圏避難所数を BallTree で計算 (= 下流人家リスクの代理指標)</li>
  <li>監視済 vs 未監視のため池でも同様に避難所数を計算し、選定の妥当性を比較</li>
  <li>区別 装置数 × 避難所数の Pearson r で「制度的緊張関係」 を定量化</li>
</ol>

<h3>手法 (前置き解説)</h3>
<ul>
  <li><b>L03 shelters.json を読込</b>: <code>data/shelters.json</code> から広島市内 {n_shelters} 件を抽出。
      <code>floodShFlg='1'</code> で洪水対応避難所 ({n_flood_shelters} 件) を特定可能。</li>
  <li><b>BallTree query_radius</b>: 1km = 1,000 m を地球半径で割って rad に変換し
      (1000/6,371,000 = {1000/EARTH_R_M:.7f} rad)、
      <code>tree.query_radius(points, r=radius_rad, count_only=True)</code> で各点の
      r 半径内の点数を高速計算。</li>
  <li><b>Pearson r</b>: 区別の (装置数, 避難所数) 8 点で線形相関を計算。
      正なら「装置多 = 避難所多」 (= 都市部に両方多)、負なら「装置多 ⇄ 避難所少」 (= 中山間優先)。</li>
</ul>

<h3>入出力の Before/After 例</h3>
<table>
<tr><th>段階</th><th>1 件のデータの中身</th><th>件数</th></tr>
<tr><td>(0) shelters.json (raw)</td><td>{{"name":"袋町小学校", "lat":34.39, "lon":132.46, ...}}</td><td>4,065 全件</td></tr>
<tr><td>(1) 広島市フィルタ</td><td>"municipalityName":"広島市中区" 系のみ抽出</td><td>{n_shelters}</td></tr>
<tr><td>(2) 1km 圏 BallTree query</td><td>+ 装置 "流谷" の 1km 圏 = N 避難所</td><td>装置 70 行 × 1 列</td></tr>
<tr><td>(3) 区別装置 vs 避難所集計</td><td>{{"安佐北区": (装置 30, 避難所 153), ...}}</td><td>5 区</td></tr>
<tr><td>(4) Pearson r 計算</td><td>r = {r_dev_shel:.3f}</td><td>scalar</td></tr>
</table>
"""

sec6_code = code(r'''
# 1. shelters.json を読込, 広島市分のみ抽出
import json
with open(SHELTERS_JSON, encoding="utf-8") as f:
    sd = json.load(f)
shel = pd.DataFrame([
    {"name": it["name"],
     "ku": it["municipalityName"].replace("広島市", "").strip(),
     "lat": float(it["latitude"] or "nan"),
     "lon": float(it["longitude"] or "nan"),
     "floodFlg": it.get("floodShFlg") or "0"}
    for it in sd["items"]
    if "広島市" in (it.get("municipalityName") or "")
       and it.get("latitude") and it.get("longitude")
])

# 2. BallTree で 1km 圏避難所数 / 最近隣避難所距離
from sklearn.neighbors import BallTree
EARTH_R_M = 6_371_000.0
RADIUS_M  = 1000.0
shel_rad  = np.deg2rad(shel[["lat", "lon"]].values)
cam_rad   = np.deg2rad(df[["緯度", "経度"]].values)
tree_shel = BallTree(shel_rad, metric="haversine")
df["避難所1km圏数"] = tree_shel.query_radius(
    cam_rad, r=RADIUS_M / EARTH_R_M, count_only=True
)
nearest_d, _ = tree_shel.query(cam_rad, k=1)
df["最近隣避難所_m"] = nearest_d[:, 0] * EARTH_R_M

# 3. 区別 装置数 vs 避難所数
ku_dev = df.groupby("区").size().reset_index(name="装置数")
ku_shel = shel.groupby("ku").size().reset_index(name="避難所数").rename(
    columns={"ku": "区"})
ku_combined = ku_dev.merge(ku_shel, on="区", how="left").fillna(0)

# 4. Pearson r で「装置多 ⇄ 避難所多」 を測定
r_dev_shel = ku_combined[["装置数", "避難所数"]].corr().iloc[0, 1]
print(f"装置数 × 避難所数 Pearson r = {r_dev_shel:.3f}")

# 5. H5: 1km 圏 5+ 避難所をもつ装置の割合
n_5plus = (df["避難所1km圏数"] >= 5).sum()
print(f"1km 圏 5+ 避難所: {n_5plus}/{len(df)} ({n_5plus/len(df)*100:.0f}%)")
''')

sec6_fig7_text = f"""
<h3>図 7: なぜこの図か (RQ3)</h3>
<p>監視装置 (赤★) と避難所 (灰小点) を<b>1 枚の重ね合わせマップ</b>に投入し、
<b>「装置の周りにどれくらい避難所があるか」</b>を視覚的に読む (左)。
さらに<b>装置 1km 圏避難所数のヒスト</b> (右) で個別装置の下流リスク代理指標を分布として読む。
1km 圏 5 避難所以上を持つ装置は H5 の重要閾値。</p>
"""
sec6_fig7_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左マップ: 装置 (赤★) は北東部 (安佐北・安芸) に偏在、避難所 (灰) は中区・東区など
      <b>都市核心部に高密度</b>。両者は<b>地理的に分離</b>。
      これは「装置 = 中山間の谷地形」 「避難所 = 都市核心の住宅密集」 という
      <b>異なる立地ロジック</b>を視覚的に示す。</li>
  <li>右ヒスト: 装置 1km 圏避難所数は中央値 {cam_within_med:.0f}、平均 {cam_within_mean:.1f}。
      最大 {df['避難所1km圏数'].max() if '避難所1km圏数' in df.columns else 'NA'} だが
      0 圏内の装置も多数 (= 山間部)。
      <b>5+ 避難所 1km 圏</b>を持つ装置: {n_dev_with_5plus} / {n_total} = {share_5plus:.0f}%。</li>
  <li><b>H5 (1km 圏 ≥ 5 避難所が装置の半数以上) {'強支持' if n_dev_with_5plus/n_total >= 0.5 else '部分支持'}</b>:
      観測 {share_5plus:.0f}%。{'多数の装置が下流人家集中域にある = 立地合理性ある' if n_dev_with_5plus/n_total >= 0.5 else
       '装置の半数以下しか下流人家集中域に立地せず、中山間ため池の崩落リスク優先という別ロジックが支配的'}。</li>
</ul>
"""

sec6_fig8_text = f"""
<h3>図 8: なぜこの図か (RQ3)</h3>
<p>「装置多 ⇄ 避難所多」 の関係を区単位で見たい (左) と、
監視済 vs 未監視ため池の<b>1km 圏避難所数の差</b>を見たい (右)。
左のグループ棒は装置数 (赤) と避難所数 (青) を共通軸 (区) で並べ、
Pearson r で全体の傾向を読む。
右の箱ひげは「監視済が下流リスク高い池に置かれているか」 を直接検証する。</p>
"""
sec6_fig8_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>左棒: 装置数の Top 区 ({top1_ku}, {top1_ku_n}) は避難所数も上位だが、
      <b>避難所数 1 位は通常 中区など都市核心部</b>。
      Pearson r = <b>{r_dev_shel:.2f}</b> = {'正相関だが中程度' if 0 < r_dev_shel < 0.5 else
                                                ('強い正相関' if r_dev_shel >= 0.5 else
                                                 ('負相関' if r_dev_shel < 0 else '相関なし'))}。
      これは<b>「装置と避難所の地理ロジックが異なる」</b>ことを定量的に示す。</li>
  <li>右箱: 監視済ため池の 1km 圏避難所数 (中央 {mon_p['避難所1km圏数'].median() if len(mon_p) else 0:.1f})
      vs 未監視 (中央 {unmon['避難所1km圏数'].median() if len(unmon) else 0:.1f})。
      {'監視済の方が下流人家リスク高い池 = 制度設計の妥当性ある' if (len(mon_p) and len(unmon) and mon_p['避難所1km圏数'].median() > unmon['避難所1km圏数'].median())
       else '監視済と未監視で 1km 圏避難所数に有意な差はなく、立地以外の選定基準が支配的'}。</li>
  <li>本図全体は<b>「監視装置 = 中山間ため池リスク優先」 という制度ロジックが
      都市部の避難所多寡と独立に運用されている</b>ことを示す。
      これは「下流人家がない谷頭部の池でも、決壊すれば下流の少数住民に致命的」 という
      ため池決壊の<b>上下流非対称性</b>を反映した制度設計と読める。</li>
</ul>
"""

# Sec 7: 仮説検証総合
sec7 = (
    "<p>本記事の 5 仮説と観測結果の照合:</p>"
    + df_to_html(T_hypo)
    + "<h3>3 RQ × 3 結論</h3>"
    + "<ul>"
    + f"<li><b>RQ1 結論</b>: 広島市の監視装置 {n_total} 箇所は "
      f"<b>5 区 ({top1_ku} {top1_ku_n} ({top1_ku_share:.0f}%) + 安芸 19 + 安佐南 13 + 佐伯 5 + 東 3)</b>に分布、"
      f"<b>中山間 4 区 {n_inland} ({n_inland/n_total*100:.0f}%)</b> に偏在。"
      f"中区・南区・西区はゼロ (都市核心ためため池無し)。"
      f"装置同士の最近隣距離は<b>中央 {nn_median:.0f}m</b> で「同一谷の隣接池監視」 のクラスタを示唆。"
      f"device_id ({id_min}-{id_max}) は単調ではないが、コホート (前期/中期/後期) に "
      f"3 等分すると中央貯水量が <b>"
      + (" → ".join(f"{m:.2f}" for _, m in co_meds) if len(co_meds) >= 3 else "NA")
      + f"</b> 千m³ と単調減少 = 大規模池が早く監視された制度設計を反映。</li>"
    + f"<li><b>RQ2 結論</b>: 広島市内 防災重点ため池 {n_hiro_ponds} 件のうち "
      f"<b>{n_monitored_ponds} 件 ({cover_rate:.1f}%) のみ監視済</b>、未監視 {n_unmonitored_ponds} 件は "
      f"中央距離 {unmon_med:.0f}m, 最大 {unmon_max/1000:.1f}km 離れた監視ネットワーク到達範囲外。"
      f"監視済 vs 未監視の規模差は Mann-Whitney p = {p_cap:.3f} で "
      f"{'監視済が大規模 (制度設計の妥当性確認)' if not np.isnan(p_cap) and p_cap < 0.05 else '統計的有意差なし (= 規模以外の選定基準が支配的)'}。"
      f"<b>整備途上の事業</b>として残課題が大きい。</li>"
    + f"<li><b>RQ3 結論</b>: 監視装置の 1km 圏内に避難所 5+ ある装置 = "
      f"<b>{n_dev_with_5plus}/{n_total} ({share_5plus:.0f}%)</b>。"
      f"区別 装置数 × 避難所数 Pearson r = <b>{r_dev_shel:.2f}</b> で "
      f"{'弱い正相関 (両者は別の地理ロジック)' if abs(r_dev_shel) < 0.5 else '強い相関'}。"
      f"監視装置は<b>「中山間ため池リスク優先」 のロジック</b>で運用されており、"
      f"避難所多寡と独立。これは「下流人家がない谷頭部の池でも、決壊すれば下流の少数住民に致命的」 という"
      f"<b>ため池決壊の上下流非対称性</b>を反映した制度設計と解釈できる。</li>"
    + "</ul>"
    + "<h3>制度史的位置付け</h3>"
    + "<p>本データ (#1675) は<b>「2018 西日本豪雨 → ため池管理保全法 (2019) → 監視装置整備 (2020s)」</b>の"
    "<b>三段ロケットの最終段</b>として 2024 年時点でスナップショットされた運用台帳である。"
    "L59 (属性側) で見た「ため池管理保全法経過措置期限 R3.5.31 一斉指定」 が制度設計の<b>第一段</b>、"
    "L45 (幾何側) で見た「浸水想定 SHP 整備 99.6%」 が<b>第二段</b>、"
    f"そして本記事 L60 で見た「監視装置設置 {n_total} 箇所 (広島市内カバー率 {cover_rate:.1f}%)」 が<b>第三段</b>。"
    "<b>制度的整備の先進度は L59 > L45 > L60</b> の順で、"
    "監視装置はまだ最も初期段階にある。"
    "本データの研究的価値は<b>「2018 豪雨後の防災投資が運用フェーズでどこまで進んだか」</b>を"
    "ベースライン記録する素材としての位置にある。</p>"
)

# Sec 8: 発展課題
sec8 = f"""
<h3>結果 X → 新仮説 Y → 課題 Z (3 RQ × 1 課題以上)</h3>

<h4>発展課題 1 (RQ1 由来): 監視装置の Voronoi 担当面積で「監視ネット密度」 を可視化</h4>
<ul>
  <li><b>結果 X</b>: 装置同士の最近隣距離は中央 {nn_median:.0f}m だが、
      <b>装置の「担当面積」</b>はこの分析からは見えない。</li>
  <li><b>新仮説 Y</b>: Voronoi 図で各装置の担当領域 (= 最近隣装置から半分の距離まで) を計算すると、
      <b>中山間ほど担当面積が大きく (装置疎)、都市部に近いほど小さい (装置密)</b> はず。
      これは<b>監視ネット密度の地理的勾配</b>として読める。</li>
  <li><b>課題 Z</b>: <code>scipy.spatial.Voronoi</code> で 70 装置の Voronoi セルを計算し、
      各セルの面積を <code>shapely.area</code> で算出。
      面積中央値・面積分布のヒストグラムを描き、区別の中央 Voronoi 面積で「監視ネットの粗密」 を地図化。
      <b>L02 (県内カメラ Voronoi) と同じ手法</b>を本データに応用可能。</li>
</ul>

<h4>発展課題 2 (RQ1 由来): リアルタイム観測値 (ikelog.cloud) のスクレイピングで時系列分析</h4>
<ul>
  <li><b>結果 X</b>: 本データは<b>静的な設置位置一覧</b>だが、
      装置 URL からは<b>リアルタイム水位・カメラ画像</b>がアクセス可能。
      これを蓄積すれば<b>降水イベントごとのため池水位応答</b>を分析できる。</li>
  <li><b>新仮説 Y</b>: 大雨警報時のため池水位上昇速度は<b>規模 (貯水量) に反比例</b>するはず。
      小規模池ほど集水域からの流入で急上昇、大規模池はゆっくり上昇。
      水位計時系列があれば<b>越流予測モデル</b>に発展可能。</li>
  <li><b>課題 Z</b>: <code>requests</code> + JSON API で 1 装置の時系列水位を 1 時間ごとに取得、
      24 時間分蓄積。雨量データ (DoBoX rainfall) と結合し、
      <b>応答関数 (impulse response)</b> をため池規模で比較。
      <b>機械学習による越流予測</b>の前段階データ整備として展開。</li>
</ul>

<h4>発展課題 3 (RQ2 由来): 未監視 {n_unmonitored_ponds} 件の優先度スコアリング</h4>
<ul>
  <li><b>結果 X</b>: 未監視 {n_unmonitored_ponds} 池は監視ネットから 1km 以上離れた池が多数。
      しかし「次に監視装置を増設すべき優先度」 は規模だけでは決まらない。</li>
  <li><b>新仮説 Y</b>: 未監視池のうち、<b>(1) 規模が大きい + (2) 1km 圏避難所多 + (3) 既存装置から遠い</b> の
      3 条件を満たす池は監視優先度が最も高いはず。これは
      <b>「決壊時被害大 + 下流人家あり + 監視ネット未到達」</b>の三重リスク。</li>
  <li><b>課題 Z</b>: 未監視 {n_unmonitored_ponds} 池の各々について、
      (1) 貯水量 z-score, (2) 1km 圏避難所数 z-score, (3) 最近隣装置距離 z-score
      の 3 軸を Min-Max 正規化し合計スコアを計算。
      上位 10 件を「次に監視装置を設置すべき池」 として地図化。
      <b>防災行政の事業計画書に直結する応用研究</b>。</li>
</ul>

<h4>発展課題 4 (RQ3 由来): 装置 1km 圏人口の精緻化 (250m メッシュ人口)</h4>
<ul>
  <li><b>結果 X</b>: 本記事は避難所数を下流リスクの代理指標として使用したが、
      <b>避難所数 ≠ 人口</b>。実際の下流人口は別データが必要。</li>
  <li><b>新仮説 Y</b>: 国勢調査 250m メッシュ人口データから装置 1km 圏内人口を計算すれば、
      避難所数より精密な下流リスクが測れる。<b>装置 1km 圏人口 ≥ 5,000 人</b>の装置は
      避難所多寡を超える「都市部リスク監視装置」 として位置付けられる。</li>
  <li><b>課題 Z</b>: e-Stat の<b>250m メッシュ人口 (令和 2 年国勢調査)</b>を取得 (政府統計の総合窓口 API)、
      装置 1km 圏のメッシュを <code>geopandas.sjoin</code> で抽出し総人口を集計。
      <b>装置 1km 圏人口分布</b>のヒストグラム + 区別箱ひげで都市部 vs 中山間の差を定量化。
      L23 (DID 境界) との重ね合わせもさらに高度な分析が可能。</li>
</ul>

<h4>発展課題 5 (展望): 県内全域への展開 — 他市町のため池監視装置データ調査</h4>
<ul>
  <li><b>結果 X</b>: 本データは<b>広島市内 70 件のみ</b>で、県内他市町 (東広島・福山等) の
      監視装置情報は欠落。L59 で見た東広島 1,768 件・福山 1,077 件の防災重点ため池が
      どこまで監視されているか、現状不明。</li>
  <li><b>新仮説 Y</b>: 他市町でも<b>独自の監視装置設置事業</b>が進行中の可能性が高い。
      DoBoX に未掲載でも、各市町 HP・補正予算書等を辿れば把握可能。</li>
  <li><b>課題 Z</b>: 各市町の HP から「ため池監視カメラ」「水位計設置」 等のキーワードで
      予算書・整備計画を機械的に収集。設置数を集計し L60 のデータと統合した
      <b>県内全域の監視マップ</b>を作成。
      <b>政策評価研究 + データジャーナリズム</b>の境界研究として展開可能。</li>
</ul>

<h4>発展課題 6 (歴史): ため池系 3 記事 (L45 / L59 / L60) の統合エッセイ</h4>
<ul>
  <li><b>結果 X</b>: ため池系 3 記事は同じため池群を<b>属性 / 幾何 / 運用</b>の三角構造で記述したが、
      <b>3 つを統合した総合的物語</b>はまだ書かれていない。</li>
  <li><b>新仮説 Y</b>: 3 つを総合すると<b>「2018 豪雨 → 法制度 → 浸水想定 → 監視装置」</b>の
      4 段階の防災進化が読み取れる。これは「災害 → 制度 → 知識 → 運用」 という
      <b>防災学一般の定型パターン</b>に当てはまる。</li>
  <li><b>課題 Z</b>: L45 / L59 / L60 の主要結論を時系列に並べ、<b>「広島県のため池防災進化史」</b>を
      30 ページのエッセイとして執筆。
      個別池の事例 (= 2018 豪雨で決壊した 3 池の系譜) を追跡し、属性 → 幾何 → 運用の
      4 軸で時間断面を切る。
      <b>地理学 + 災害史 + 政策評価</b>の境界領域研究の総合形。</li>
</ul>
"""

# 統合
sections = [
    ("学習目標と問い", sec1),
    ("使用データ", sec2),
    ("ダウンロード", sec3),
    (f"【RQ1】 監視装置の地理分布と区別構造 — {n_total} 箇所 / 中山間 4 区に {n_inland/n_total*100:.0f}% 偏在",
     sec4_intro
     + "<h3>実装コード (CSV 読込 + 区抽出 + GeoDataFrame + BallTree NN)</h3>"
     + sec4_code
     + sec4_fig1_text
     + figure("assets/L60_fig1_monitor_map.png",
              f"図 1 (RQ1): 広島市内 {n_total} 装置マップ (色=device_id) + 8 区別ランキング棒")
     + sec4_fig1_read
     + sec4_fig2_text
     + figure("assets/L60_fig2_nn_id.png",
              "図 2 (RQ1): 装置同士の最近隣距離ヒスト + device_id × 緯度散布")
     + sec4_fig2_read
     + sec4_fig3_text
     + figure("assets/L60_fig3_ku_small_mults.png",
              "図 3 (RQ1): 装置のある 5 区別 small multiples マップ")
     + sec4_fig3_read
     + "<h3>表: 全体サマリ (3 RQ 統合)</h3>"
     + df_to_html(T_overall)
     + f"<p><b>この表から読み取れること</b>: 全 {n_total} 装置の基本統計、"
       f"中山間 {n_inland} 装置 ({n_inland/n_total*100:.0f}%)、"
       f"防災重点ため池 {n_hiro_ponds} 件への監視カバー率 {cover_rate:.1f}%、"
       f"装置 1km 圏避難所数 中央 {cam_within_med:.0f} 等、3 RQ の核心指標が "
       "13 行に集約された統合サマリ。</p>"
     + "<h3>表: 区別装置数ランキング (8 区全部)</h3>"
     + df_to_html(T_ku_rank)
     + f"<p><b>この表から読み取れること</b>: <b>{top1_ku} {top1_ku_n} ({top1_ku_share:.1f}%)</b> 単独で Top 1、"
       "中山間 4 区 (赤帯立地) がすべて上位 4 位を独占。"
       "中区・南区・西区は 0 装置 = 都市核心部にため池そのものが存在しない地理を反映。"
       "<b>立地 = 中山間 / 都市部</b>の単純な分類で監視装置の地理パターンが説明される。</p>"
     + "<h3>表: 設置コホート × 区</h3>"
     + df_to_html(T_cohort)
     + "<p><b>この表から読み取れること</b>: 装置 ID を 3 等分した前期/中期/後期コホートと区のクロス。"
       "コホートと区の対応関係は弱く、各区で全コホートに分散して設置されている。"
       f"ただし対応ため池の中央貯水量で見ると <b>{' → '.join(f'{m:.2f}' for _, m in co_meds) if len(co_meds) >= 3 else 'NA'}</b> 千m³ と"
       "単調減少 = 大規模池が早く監視された制度設計が読み取れる (H2 強支持)。</p>"
    ),
    (f"【RQ2】 監視ネットワークの空白 — {n_hiro_ponds} 池中 {n_unmonitored_ponds} 池が監視ネット外",
     sec5_intro
     + "<h3>実装コード (L45 cache 読込 + BallTree 双方向 NN + Mann-Whitney U)</h3>"
     + sec5_code
     + sec5_fig4_text
     + figure("assets/L60_fig4_coverage_map.png",
              f"図 4 (RQ2): 監視済 vs 未監視 ため池マップ + 未監視 {n_unmonitored_ponds} 池の最近隣監視点距離分布")
     + sec5_fig4_read
     + sec5_fig5_text
     + figure("assets/L60_fig5_coverage_size.png",
              "図 5 (RQ2): 区別 監視カバー率 + 監視済 vs 未監視 規模箱ひげ (Mann-Whitney U)")
     + sec5_fig5_read
     + sec5_fig6_text
     + figure("assets/L60_fig6_id_size.png",
              "図 6 (RQ2): device_id × 対応ため池 log10 貯水量 散布 + コホート別箱ひげ")
     + sec6_fig6_read
     + "<h3>表: 区別 監視カバー率</h3>"
     + df_to_html(T_ku_cover)
     + f"<p><b>この表から読み取れること</b>: 各区の防災重点ため池数・監視済・未監視・カバー率。"
       f"<b>全区が 100% 未満</b> = 整備途上の事業。"
       f"全市カバー率 {cover_rate:.1f}% は実質「全区問題」 で、特定区だけの未整備ではない。"
       "未監視 93 件は<b>下流人家リスクが相対的に低い谷頭部の池</b>として後回しの可能性。</p>"
     + "<h3>表: 監視済 vs 未監視 規模比較</h3>"
     + df_to_html(T_size_comp)
     + f"<p><b>この表から読み取れること</b>: 監視済 {n_monitored_ponds} 池 vs 未監視 {n_unmonitored_ponds} 池の"
       f"中央規模比較。Mann-Whitney U 片側検定で "
       f"{'貯水量 p < 0.05 で有意 = 監視済が大規模' if not np.isnan(p_cap) and p_cap < 0.05 else '統計的有意差なし'}。"
       f"{'制度設計が決壊時被害の大きい大規模池を優先監視している証拠 (H4 強支持)' if not np.isnan(p_cap) and p_cap < 0.05 else '規模以外 (= 立地・下流人家・アクセス) が選定基準として支配的'}。</p>"
     + f"<h3>表: 装置 → 対応ため池 (device_id 上位 5 件抜粋)</h3>"
     + df_to_html(T_dev_examples)
     + f"<p><b>この表から読み取れること</b>: 早期 device_id (前期コホート) の装置がどんなため池を監視しているか。"
       f"対応貯水量・堤高は装置ごとに大きく異なり、<b>device_id と規模に明確な順序関係なし</b>。</p>"
     + f"<h3>表: 未監視ため池 距離降順 Top 15 (= 監視ネット外延)</h3>"
     + df_to_html(T_unmon)
     + f"<p><b>この表から読み取れること</b>: 監視ネットから最も離れたため池の Top 15。"
       f"これらは<b>「次に監視装置を増設すべき候補池」</b>として防災行政の優先度評価に直結。"
       f"距離順だが、規模 (貯水量) も大きい池が混在 = <b>「孤立 + 大規模」</b>の二重リスク池が浮かぶ。</p>"
    ),
    (f"【RQ3】 下流リスクとの一致 — 装置 1km 圏避難所中央 {cam_within_med:.0f} / 区別 r = {r_dev_shel:.2f}",
     sec6_intro
     + "<h3>実装コード (shelters.json 読込 + BallTree query_radius + Pearson r)</h3>"
     + sec6_code
     + sec6_fig7_text
     + figure("assets/L60_fig7_shelters.png",
              f"図 7 (RQ3): 監視装置 {n_total} + 避難所 {n_shelters} 重ね合わせマップ + 装置 1km 圏避難所数ヒスト")
     + sec6_fig7_read
     + sec6_fig8_text
     + figure("assets/L60_fig8_ku_shel.png",
              "図 8 (RQ3): 区別 装置 vs 避難所 グループ棒 + 監視済/未監視ため池 1km 圏避難所数比較")
     + sec6_fig8_read
     + "<h3>表: 区別 装置 vs 避難所</h3>"
     + df_to_html(T_shel_dev)
     + f"<p><b>この表から読み取れること</b>: 各区の装置数 vs 避難所数の対比。"
       f"避難所数は装置数の数倍〜数十倍 (= 避難所は都市部に多い)。"
       f"避難所/装置比 = <b>{T_shel_dev['避難所_装置比'].median():.1f} 倍 (中央値)</b>で、"
       f"区によって大きく異なる。区別 装置数 × 避難所数 Pearson r = <b>{r_dev_shel:.2f}</b>。</p>"
     + "<h3>表: 1km 圏避難所数 上位 5 装置 (= 都市部隣接装置)</h3>"
     + df_to_html(T_dev_shel_top)
     + "<p><b>この表から読み取れること</b>: 装置の 1km 圏内に最も多く避難所がある装置 Top 5。"
       "これらは<b>都市部に近いため池の監視装置</b>で、決壊時の下流被害が即座に住宅地に及ぶリスクが高い。"
       "下流人家リスク優先という制度合理性の証拠候補。</p>"
     + "<h3>表: 1km 圏避難所数 下位 5 装置 (= 山間部装置)</h3>"
     + df_to_html(T_dev_shel_bot)
     + "<p><b>この表から読み取れること</b>: 装置の 1km 圏内に避難所が最も少ない装置 Bottom 5。"
       "これらは<b>谷頭部・山間部のため池の監視装置</b>で、近隣に住民集中がない。"
       "それでも監視されているのは<b>「中山間ため池の崩落リスク優先」</b>という別の制度ロジック (= 2018 豪雨型崩落)。</p>"
    ),
    ("仮説検証総合", sec7),
    ("発展課題", sec8),
]


html = render_lesson(
    num=60,
    title="ため池監視カメラ及び水位計設置箇所一覧 単独 3 研究例分析 — "
          "70 装置から 2018 豪雨後の防災投資 × 運用フェーズを読む",
    tags=["L60", "ため池", "監視カメラ", "水位計", "ikelog.cloud", "RQ×3", "Format B",
          "BallTree", "haversine", "Mann-Whitney", "L45/L59連携", "L03連携",
          "2018西日本豪雨", "ため池管理保全法", "広島市"],
    time="35 分",
    level="中級",
    data_label=f"DoBoX dataset 1675 (CSV {n_total} 行 × 5 列, 8 KB) + L45/L59/L03 連携",
    sections=sections,
    script_filename="L60_pond_monitoring.py",
)

OUT_HTML = LESSONS / "L60_pond_monitoring.html"
OUT_HTML.write_text(html, encoding="utf-8")

print(f"  HTML: {OUT_HTML.name} ({len(html):,} chars)")
print(f"  ({time.time()-t1:.2f}s)", flush=True)


# =============================================================================
# 終了
# =============================================================================
print(f"\n=== 完了: 全体 {time.time() - t_all:.2f}s ===")
print(f"出力:")
print(f"  HTML: lessons/L60_pond_monitoring.html")
print(f"  Python: lessons/L60_pond_monitoring.py")
print(f"  図: assets/L60_fig1-8_*.png (8 枚)")
print(f"  CSV: assets/L60_*.csv (8 ファイル)")
print(f"  ZIP: assets/L60_pond_monitoring_raw.zip")
