"""
L06 (v2): 2024-07-01 豪雨日 — 雨域セルの空間-時間進行を読む

10分値雨量を1時間集約 (24時間 × 約400観測所) して、
広島県内を「豪雨セル」がどう移動したかを 4 つの軸で可視化する:

  1) 時間別 KDE small multiples (6×4=24時間)  — 雨域分布の時間変化
  2) 重心 (Center-of-Mass) 軌跡                — 雨域の移動方向と速度
  3) 空間自己相関 Moran's I の時間推移          — クラスタ性の強弱
  4) 観測所別ピーク時刻ヒストグラム             — 県全体での集中時間帯
  5) Top10 観測所の10分値時系列重ね描き         — 個別観測所の波形

観測所には固有座標が無いため、各観測所の 河川名 を読み取り、
camera_list.csv (#1279) の同じ河川のカメラの平均緯経度で位置を推定する。

実行:
    cd "2026 DoBoX 教材"
    py -X utf8 lessons/L06_july1_heatmap.py
"""
from pathlib import Path
import re
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm

from _common import (ROOT, ASSETS, LESSONS, render_lesson, code, figure,
                     data_recipe, parse_rain_csv, ensure_dataset)

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

# === 0. データ自動取得 ========================================================
RAIN_PATH = ROOT / "data" / "rain_2024" / "rain_2024-07-01.csv"
CAM_PATH = ROOT / "data" / "camera_list.csv"

print("=== 0. データ自動取得 ===")
ensure_dataset(RAIN_PATH, resource_id=94500, min_bytes=500_000,
               label="雨量10分値 2024-07-01 (#1275 res 94500)")
ensure_dataset(CAM_PATH, dataset_id=1279, min_bytes=10_000,
               label="カメラ一覧 #1279")

# === 1. 10分値読込 → 1時間集約 (24h × 観測所) ===============================
print("\n=== 1. 10分値読込 → 1時間集約 ===")
tidy = parse_rain_csv(RAIN_PATH)  # (144, 401)
print(f"10分値 shape: {tidy.shape}")
hourly = tidy.resample("1h").sum(min_count=1)  # (24, 401)
print(f"1時間集約 shape: {hourly.shape}  (期間 {hourly.index.min()} 〜 {hourly.index.max()})")
day_total = tidy.sum(axis=0)
print(f"日合計 観測所数={len(day_total)}, max={day_total.max():.0f} mm, "
      f"median={day_total.median():.0f} mm")

# === 2. 観測所→河川名 を rain CSV ヘッダから抽出し、カメラと結合して座標推定 ====
print("\n=== 2. 観測所の座標推定 (河川名 → カメラ平均緯経度) ===")
raw = pd.read_csv(RAIN_PATH, header=None, encoding="utf-8-sig")
# parse_rain_csv と同じロジックで観測所の列インデックスを再現
header_idx = next(r for r in range(15) if any("10分雨量" in str(v) for v in raw.iloc[r, :].tolist()))
obs_idx = next(r for r in range(header_idx) if str(raw.iloc[r, 0]).strip() == "観測所名")
river_idx = next(r for r in range(header_idx) if str(raw.iloc[r, 0]).strip() == "河川名")
sys_idx = next(r for r in range(header_idx) if str(raw.iloc[r, 0]).strip() == "水系名")
office_idx = next(r for r in range(header_idx) if str(raw.iloc[r, 0]).strip() == "事務所名")
cols_10min = [i for i, v in enumerate(raw.iloc[header_idx, :]) if "10分雨量" in str(v)]
station_meta = pd.DataFrame({
    "station_raw": [str(raw.iloc[obs_idx, c]).strip() for c in cols_10min],
    "river": [str(raw.iloc[river_idx, c]).strip() for c in cols_10min],
    "water_system": [str(raw.iloc[sys_idx, c]).strip() for c in cols_10min],
    "office": [str(raw.iloc[office_idx, c]).strip() for c in cols_10min],
})
# parse_rain_csv は重複名を _2, _3 と連番化するため列名と一致させる
seen = {}
final_cols = []
for n in station_meta["station_raw"]:
    seen[n] = seen.get(n, 0) + 1
    final_cols.append(n if seen[n] == 1 else f"{n}_{seen[n]}")
station_meta["station"] = final_cols

# カメラを 河川 単位で集約 → 平均緯経度
cam = pd.read_csv(CAM_PATH, encoding="utf-8-sig")
# 「○○水系△△」「○○川水系△△」から 水系名(○○) と 川名(△△) を取り出す
cam["water_system"] = cam["路河川名等"].astype(str).str.extract(r"^(.+?水系)")[0].str.replace("水系", "", regex=False)
cam["river_name"] = cam["路河川名等"].astype(str).str.extract(r"水系(.+)$")[0]
riv_cams = cam.dropna(subset=["river_name"]).copy()
print(f"河川カメラ数: {len(riv_cams)} / 全 {len(cam)}")

# 河川名キーで coord lookup を作る
river_coord = (riv_cams.groupby(["water_system", "river_name"])
                       .agg(lat=("緯度", "mean"), lon=("経度", "mean"), n_cam=("No.", "size"))
                       .reset_index())
print(f"河川別 平均座標 keys: {len(river_coord)}")

# 水系単位の代表座標 (河川マッチが無い時の fallback)
ws_coord = (riv_cams.groupby("water_system")
                    .agg(lat=("緯度", "mean"), lon=("経度", "mean"))
                    .reset_index())
ws_lookup = dict(zip(ws_coord["water_system"], zip(ws_coord["lat"], ws_coord["lon"])))

# 事務所→中央緯経度の最終 fallback (おおまかな県内位置)
OFFICE_FALLBACK = {
    "西部建設": (34.40, 132.30), "廿日市支所": (34.45, 132.20),
    "呉支所": (34.20, 132.60), "東広島支所": (34.40, 132.75),
    "三原支所": (34.40, 133.00), "東部建設": (34.45, 133.30),
    "北部建設": (34.65, 132.80), "庄原支所": (34.85, 133.00),
    "安芸太田支所": (34.55, 132.20), "三次支所": (34.80, 132.85),
}

def lookup_coord(row):
    """station_meta の1行を受け取り (lat, lon, source) を返す"""
    riv_match = river_coord[(river_coord["water_system"] == row["water_system"]) &
                             (river_coord["river"] == row["river"])]
    if len(riv_match):
        r = riv_match.iloc[0]
        return r["lat"], r["lon"], "river"
    # 水系 fallback
    if row["water_system"] in ws_lookup:
        lat, lon = ws_lookup[row["water_system"]]
        return lat, lon, "water_system"
    # 事務所 fallback
    if row["office"] in OFFICE_FALLBACK:
        lat, lon = OFFICE_FALLBACK[row["office"]]
        return lat, lon, "office"
    return np.nan, np.nan, "none"

# river_name は cam では「△△川」「△△」混在、雨量側は「△△川」が多い → 完全一致 + 末尾「川」剥がしの両方試す
def lookup_coord_v2(row):
    rmatches = []
    riv = row["river"]
    for cand in [riv, riv.rstrip("川")]:
        m = river_coord[(river_coord["water_system"] == row["water_system"]) &
                         (river_coord["river_name"] == cand)]
        if len(m):
            rmatches.append(m)
            break
    # river_name が水系名と一致 (「太田川水系太田川」) のケースも吸収
    if not rmatches:
        m = river_coord[(river_coord["water_system"] == row["water_system"]) &
                         (river_coord["river_name"].isin([row["river"], row["water_system"]]))]
        if len(m):
            rmatches.append(m)
    if rmatches:
        r = rmatches[0].iloc[0]
        return r["lat"], r["lon"], "river"
    if row["water_system"] in ws_lookup:
        lat, lon = ws_lookup[row["water_system"]]
        return lat, lon, "water_system"
    if row["office"] in OFFICE_FALLBACK:
        lat, lon = OFFICE_FALLBACK[row["office"]]
        return lat, lon, "office"
    return np.nan, np.nan, "none"

coords = station_meta.apply(lookup_coord_v2, axis=1, result_type="expand")
coords.columns = ["lat", "lon", "source"]
station_meta = pd.concat([station_meta, coords], axis=1)
print("座標解決ソース内訳:")
print(station_meta["source"].value_counts().to_string())
print(f"座標欠損: {station_meta['lat'].isna().sum()} 観測所")

# 座標が解決した観測所のみで以降の分析
geo = station_meta.dropna(subset=["lat", "lon"]).set_index("station")
geo_cols = [c for c in hourly.columns if c in geo.index]
hourly = hourly[geo_cols]
print(f"分析対象観測所: {len(geo_cols)}")

# === 3. 図1: 時間別 KDE small multiples (6×4=24h) ===========================
print("\n=== 3. 図1: 時間別 KDE small multiples ===")
lats = geo.loc[geo_cols, "lat"].values
lons = geo.loc[geo_cols, "lon"].values
LON_MIN, LON_MAX = lons.min() - 0.05, lons.max() + 0.05
LAT_MIN, LAT_MAX = lats.min() - 0.05, lats.max() + 0.05
GRID = 80


def kde_grid(weights, bandwidth=0.06):
    """加重 KDE を等間隔グリッド上で評価。weights は各観測所の値。"""
    xs = np.linspace(LON_MIN, LON_MAX, GRID)
    ys = np.linspace(LAT_MIN, LAT_MAX, GRID)
    XX, YY = np.meshgrid(xs, ys)
    Z = np.zeros_like(XX)
    w = np.asarray(weights, dtype=float)
    w = np.where(np.isnan(w), 0, w)
    if w.sum() <= 0:
        return XX, YY, Z
    h2 = bandwidth ** 2
    for la, lo, wi in zip(lats, lons, w):
        if wi <= 0:
            continue
        d2 = (XX - lo) ** 2 + (YY - la) ** 2
        Z += wi * np.exp(-d2 / (2 * h2))
    return XX, YY, Z


fig, axes = plt.subplots(6, 4, figsize=(13, 16), sharex=True, sharey=True)
hour_max = float(hourly.values.max())
vmax = max(1.0, hour_max * 5)  # KDE 値スケール
for h in range(24):
    ax = axes[h // 4, h % 4]
    weights = hourly.iloc[h].values
    XX, YY, Z = kde_grid(weights, bandwidth=0.07)
    if Z.max() > 0:
        ax.contourf(XX, YY, Z, levels=12, cmap="Blues")
    ax.scatter(lons, lats, s=3, c="#666", alpha=0.35, linewidths=0)
    total = float(np.nansum(weights))
    peak_idx = int(np.nanargmax(weights)) if total > 0 else 0
    title = f"{h:02d}:00-{h:02d}:59  Σ={total:.0f}mm"
    if total > 0:
        title += f"\n@{geo_cols[peak_idx]}"
    ax.set_title(title, fontsize=9)
    ax.set_xlim(LON_MIN, LON_MAX)
    ax.set_ylim(LAT_MIN, LAT_MAX)
    ax.tick_params(labelsize=7)
fig.suptitle("2024-07-01 雨量の時間別空間 KDE (1時間ごと)", fontsize=14, y=0.995)
fig.text(0.5, 0.005, "経度", ha="center")
fig.text(0.005, 0.5, "緯度", va="center", rotation=90)
plt.tight_layout(rect=[0.01, 0.01, 1, 0.985])
plt.savefig(ASSETS / "L06_kde_smallmult.png", dpi=130)
plt.close()

# === 4. 図2: 重心 (Center-of-Mass) 軌跡 ======================================
print("\n=== 4. 図2: 雨域重心の時間移動 ===")
com = []
for h in range(24):
    w = hourly.iloc[h].values
    w = np.where(np.isnan(w), 0, w)
    if w.sum() > 0:
        clon = float(np.average(lons, weights=w))
        clat = float(np.average(lats, weights=w))
    else:
        clon, clat = np.nan, np.nan
    com.append({"hour": h, "lon": clon, "lat": clat, "total": float(w.sum())})
com_df = pd.DataFrame(com)
print(com_df.head().to_string(index=False))

# 重心の毎時移動距離 (km, 1度 ≒ 緯度111km / 経度約 91km @lat34.5)
KM_LON = 111.0 * np.cos(np.deg2rad(34.5))
KM_LAT = 111.0
dlon = com_df["lon"].diff().values * KM_LON
dlat = com_df["lat"].diff().values * KM_LAT
com_df["speed_kmh"] = np.sqrt(dlon ** 2 + dlat ** 2)

fig, axes = plt.subplots(1, 2, figsize=(13, 6), gridspec_kw={"width_ratios": [1.4, 1]})
ax = axes[0]
sc = ax.scatter(com_df["lon"], com_df["lat"],
                c=com_df["hour"], cmap="viridis", s=140,
                edgecolors="white", linewidths=1.2, zorder=3)
# 矢印で軌跡
for i in range(len(com_df) - 1):
    if pd.notna(com_df["lon"].iloc[i]) and pd.notna(com_df["lon"].iloc[i + 1]):
        ax.annotate("", xy=(com_df["lon"].iloc[i + 1], com_df["lat"].iloc[i + 1]),
                    xytext=(com_df["lon"].iloc[i], com_df["lat"].iloc[i]),
                    arrowprops=dict(arrowstyle="->", color="#cf222e", alpha=0.55, lw=1.2))
# 観測所の散布
ax.scatter(lons, lats, s=4, c="#999", alpha=0.3, zorder=1, label="雨量観測所")
for _, r in com_df.iterrows():
    if pd.notna(r["lon"]):
        ax.text(r["lon"] + 0.005, r["lat"] + 0.005, f"{int(r['hour']):02d}",
                fontsize=8, color="#222")
ax.set_xlabel("経度")
ax.set_ylabel("緯度")
ax.set_title("雨域重心の軌跡 (色=時刻 0→23h)")
plt.colorbar(sc, ax=ax, label="時刻 (h)", shrink=0.85)
ax.grid(alpha=0.3)
ax.legend(loc="lower right", fontsize=9)

ax2 = axes[1]
ax2.bar(com_df["hour"], com_df["speed_kmh"], color="#0969da", alpha=0.85, edgecolor="black")
ax2.set_xlabel("時刻 (h)")
ax2.set_ylabel("重心移動速度 (km/h)")
ax2.set_title("時刻別 重心の移動速度")
ax2.set_xticks(range(0, 24, 2))
ax2.grid(alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig(ASSETS / "L06_com_trajectory.png", dpi=140)
plt.close()

# === 5. 図3: 空間自己相関 Moran's I の時間推移 ==============================
print("\n=== 5. 図3: 時間別 Moran's I ===")


def moran_i(values, lats_arr, lons_arr, k=8):
    """k-近傍 (経度補正済み距離) の inverse-distance 重み行列で Global Moran's I を計算"""
    v = np.asarray(values, dtype=float)
    mask = ~np.isnan(v)
    if mask.sum() < 10 or np.nanstd(v) == 0:
        return np.nan
    v = v[mask]
    la = lats_arr[mask]
    lo = lons_arr[mask]
    n = len(v)
    # 距離行列 (km)
    dla = (la[:, None] - la[None, :]) * KM_LAT
    dlo = (lo[:, None] - lo[None, :]) * KM_LON
    d = np.sqrt(dla ** 2 + dlo ** 2)
    np.fill_diagonal(d, np.inf)
    # k-近傍 inverse-distance
    W = np.zeros_like(d)
    for i in range(n):
        idx = np.argsort(d[i])[:k]
        W[i, idx] = 1.0 / np.maximum(d[i, idx], 0.1)
    # 行標準化
    rs = W.sum(axis=1, keepdims=True)
    rs[rs == 0] = 1
    W = W / rs
    z = v - v.mean()
    s2 = (z ** 2).sum() / n
    if s2 == 0:
        return np.nan
    num = (W * np.outer(z, z)).sum()
    return float(num / (s2 * n) * (n / W.sum()))


lats_arr = lats
lons_arr = lons
moran_per_hour = []
for h in range(24):
    v = hourly.iloc[h].values
    moran_per_hour.append(moran_i(v, lats_arr, lons_arr, k=8))
moran_arr = np.array(moran_per_hour)
moran_day = moran_i(day_total[geo_cols].values, lats_arr, lons_arr, k=8)
print(f"日合計 Moran's I = {moran_day:.3f}  (>0 ならクラスタ的)")

# 棒グラフ
fig, ax = plt.subplots(figsize=(11, 4.4))
hour_totals = hourly.sum(axis=1).values
colors = ["#cf222e" if m > 0.3 else ("#0969da" if m > 0.1 else "#888") for m in moran_arr]
bars = ax.bar(range(24), moran_arr, color=colors, edgecolor="black", linewidth=0.6)
ax2 = ax.twinx()
ax2.plot(range(24), hour_totals, color="#fb8500", lw=2, marker="o", markersize=5,
         label="時間雨量総和 (右軸)")
ax2.set_ylabel("時間総雨量 Σ (mm)", color="#fb8500")
ax2.tick_params(axis="y", labelcolor="#fb8500")
ax.set_xlabel("時刻 (h)")
ax.set_ylabel("Moran's I (左軸)")
ax.set_xticks(range(0, 24))
ax.axhline(0, color="gray", lw=0.7)
ax.set_title(f"時間別 空間自己相関 Moran's I  (日合計 I={moran_day:.2f})")
ax.grid(alpha=0.3, axis="y")
fig.legend([bars, ax2.get_lines()[0]],
           ["Moran's I (赤=>0.3 強, 青=>0.1, 灰=弱)", "時間総雨量"],
           loc="upper right", bbox_to_anchor=(0.99, 0.97), fontsize=9)
plt.tight_layout()
plt.savefig(ASSETS / "L06_moran_hourly.png", dpi=140)
plt.close()

# === 6. 図4: 観測所別ピーク時刻ヒストグラム ==================================
print("\n=== 6. 図4: ピーク時刻ヒストグラム ===")
peak_hours = []
for st in geo_cols:
    s = hourly[st]
    if s.sum() > 5:  # ある程度降った観測所だけ
        peak_hours.append(int(s.idxmax().hour))
peak_hours = np.array(peak_hours)
print(f"ピーク時刻が記録できた観測所: {len(peak_hours)}")

fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
ax = axes[0]
counts, edges, patches = ax.hist(peak_hours, bins=np.arange(-0.5, 24.5, 1),
                                  color="#0969da", edgecolor="black", alpha=0.85)
ax.set_xlabel("ピーク時刻 (h)")
ax.set_ylabel("観測所数")
ax.set_title(f"観測所別 ピーク時刻ヒストグラム (n={len(peak_hours)})")
ax.set_xticks(range(0, 24, 2))
ax.grid(alpha=0.3, axis="y")
mode_h = int(np.argmax(counts))
ax.axvline(mode_h, color="#cf222e", lw=2, ls="--",
           label=f"モード={mode_h}h ({int(counts[mode_h])}観測所)")
ax.legend()

# サブプロット: 累積分布
ax2 = axes[1]
sorted_h = np.sort(peak_hours)
ax2.plot(sorted_h, np.arange(1, len(sorted_h) + 1) / len(sorted_h) * 100,
         color="#1f883d", lw=2.2)
ax2.set_xlabel("時刻 (h)")
ax2.set_ylabel("ピーク到達済み観測所 (%)")
ax2.set_title("ピーク到達の累積分布 (CDF)")
ax2.set_xticks(range(0, 24, 2))
ax2.grid(alpha=0.3)
for q in [25, 50, 75]:
    h_q = np.percentile(peak_hours, q)
    ax2.axhline(q, color="#666", lw=0.6, ls=":")
    ax2.text(0.5, q + 1, f"P{q} = {h_q:.1f}h", color="#666", fontsize=9)
plt.tight_layout()
plt.savefig(ASSETS / "L06_peak_hist.png", dpi=140)
plt.close()

# === 7. 図5: Top10 観測所の10分値時系列重ね描き ==============================
print("\n=== 7. 図5: Top10 観測所の10分値時系列 ===")
top10_names = day_total[geo_cols].sort_values(ascending=False).head(10).index.tolist()
fig, ax = plt.subplots(figsize=(12, 5))
cmap = plt.get_cmap("tab10")
for i, st in enumerate(top10_names):
    s = tidy[st]
    ax.plot(s.index, s.values, color=cmap(i), alpha=0.85, lw=1.3,
            label=f"{st} (Σ={day_total[st]:.0f}mm)")
ax.set_xlabel("時刻 (2024-07-01)")
ax.set_ylabel("10分雨量 (mm)")
ax.set_title("日合計上位10観測所の10分値時系列")
ax.legend(fontsize=8, ncol=2, loc="upper right")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(ASSETS / "L06_top10_timeseries.png", dpi=140)
plt.close()

# === 8. Top10 サマリ DataFrame (ベース) ======================================
top10_names = day_total[geo_cols].sort_values(ascending=False).head(10).index.tolist()
top10_summary = pd.DataFrame({
    "観測所": top10_names,
    "水系": [geo.loc[s, "water_system"] for s in top10_names],
    "河川": [geo.loc[s, "river"] for s in top10_names],
    "日合計 (mm)": [round(day_total[s], 1) for s in top10_names],
    "ピーク時刻": [hourly[s].idxmax().strftime("%H:%M") for s in top10_names],
    "ピーク時間雨量 (mm)": [round(hourly[s].max(), 1) for s in top10_names],
})

# === 中間データ保存 (再現用): top10_summary 以外を先に保存 =====================
hourly.round(2).to_csv(ASSETS / "L06_hourly_rainfall.csv", encoding="utf-8-sig")
geo.to_csv(ASSETS / "L06_station_coords.csv", index=False, encoding="utf-8-sig")
com_df.round(4).to_csv(ASSETS / "L06_com_trajectory.csv", index=False, encoding="utf-8-sig")
pd.DataFrame({"hour": range(24), "moran_i": moran_arr.round(4),
              "hour_total_mm": hour_totals.round(1)}).to_csv(
    ASSETS / "L06_moran_hourly.csv", index=False, encoding="utf-8-sig")
pd.DataFrame({"peak_hour": peak_hours}).to_csv(
    ASSETS / "L06_peak_hours.csv", index=False, encoding="utf-8-sig")
day_total.round(1).to_frame("day_total_mm").to_csv(
    ASSETS / "L06_daily_totals.csv", encoding="utf-8-sig")

# === 9. 表生成と Top10 サマリ HTML ============================================
# 仮説検証用の集計値
hour_total_max = float(np.nanmax(hour_totals))
hour_total_max_h = int(np.nanargmax(hour_totals))
peak_mode_h = int(np.argmax(np.bincount(peak_hours, minlength=24)))
peak_mode_n = int(np.bincount(peak_hours, minlength=24)[peak_mode_h])
peak_p25 = float(np.percentile(peak_hours, 25))
peak_p75 = float(np.percentile(peak_hours, 75))
moran_max_h = int(np.nanargmax(moran_arr))
moran_max = float(np.nanmax(moran_arr))
n_river = int((station_meta["source"] == "river").sum())
n_ws = int((station_meta["source"] == "water_system").sum())
n_office = int((station_meta["source"] == "office").sum())
n_none = int((station_meta["source"] == "none").sum())
com_dist_total = float(np.nansum(com_df["speed_kmh"].values))

# 座標解決ソースの内訳表
src_counts = station_meta["source"].value_counts().reindex(
    ["river", "water_system", "office", "none"]).fillna(0).astype(int)
src_label = {"river": "河川一致 (最良)", "water_system": "水系一致 (fallback)",
             "office": "事務所中心 (最終 fallback)", "none": "未解決 (除外)"}
src_html_rows = "".join(
    f"<tr><td>{src_label[k]}</td><td>{int(v)}</td>"
    f"<td>{v / max(len(station_meta), 1) * 100:.1f}%</td></tr>"
    for k, v in src_counts.items()
)
src_html = (
    "<table><tr><th>fallback 段階</th><th>観測所数</th><th>割合</th></tr>"
    + src_html_rows + "</table>"
)

# 各観測所の波形を「集中度（最大時間 / 日合計）」で見るサマリ
top10_concentration = []
for st in top10_names:
    s = hourly[st]
    daily = day_total[st]
    peak_share = float(s.max()) / daily if daily > 0 else 0.0
    top10_concentration.append(peak_share)
top10_summary["最大時間/日合計"] = [f"{v*100:.0f}%" for v in top10_concentration]
top10_html = top10_summary.to_html(index=False)
top10_summary.to_csv(ASSETS / "L06_top10_summary.csv", index=False, encoding="utf-8-sig")

# 重心軌跡 HTML テーブル
com_summary2 = com_df.copy()
com_summary2["lat"] = com_summary2["lat"].round(4)
com_summary2["lon"] = com_summary2["lon"].round(4)
com_summary2["total"] = com_summary2["total"].round(1)
com_summary2["speed_kmh"] = com_summary2["speed_kmh"].round(2)
com_summary2.columns = ["時刻 h", "重心 経度", "重心 緯度",
                        "時間雨量計 (mm)", "前時刻からの移動 (km/h)"]
com_html = com_summary2.to_html(index=False, na_rep="—")

# Moran I の時間別 HTML（強・中・弱に色分け解釈付き）
moran_table_rows = []
for h in range(24):
    mi = moran_arr[h]
    tot = hour_totals[h]
    if np.isnan(mi):
        intp = "—"
    elif mi > 0.3:
        intp = "強い局所集中"
    elif mi > 0.1:
        intp = "中程度"
    elif mi > -0.05:
        intp = "ほぼ無関係"
    else:
        intp = "(まれ) 反相関"
    moran_table_rows.append(
        f"<tr><td>{h:02d}:00</td><td>{tot:.0f}</td>"
        f"<td>{mi:.3f}</td><td>{intp}</td></tr>"
    )
moran_html = (
    "<table><tr><th>時刻</th><th>時間総雨量 (mm)</th>"
    "<th>Moran's I</th><th>解釈</th></tr>"
    + "".join(moran_table_rows) + "</table>"
)

# ピーク時刻ヒストグラム表 (時刻別観測所数 上位5)
peak_counts = pd.Series(peak_hours).value_counts().sort_index()
peak_top = peak_counts.sort_values(ascending=False).head(5)
peak_top_rows = "".join(
    f"<tr><td>{int(h):02d}:00-{int(h):02d}:59</td>"
    f"<td>{int(n)}</td>"
    f"<td>{int(n)/len(peak_hours)*100:.1f}%</td></tr>"
    for h, n in peak_top.items()
)
peak_top_html = (
    "<table><tr><th>時間帯</th><th>ピーク観測所数</th><th>全体割合</th></tr>"
    + peak_top_rows + "</table>"
)

# === 10. HTML レンダリング (L01 と同じ 10 セクション構成) =====================
print("\n=== 10. HTML レンダリング ===")
sections = [
    ("学習目標と問い", """
<h3>このレッスンで答えたい問い</h3>
<p><b>2024-07-01 の豪雨日、雨域は県内をどう動き、どこに集中し、いつピークを迎えたか？</b>
10分間隔の生雨量データだけから、時間と空間の両方で起きていたことを読み解けるか。</p>

<h3>用語の定義（このレッスン独自）</h3>
<div class="note">
<ul>
<li><b>「雨域」</b>: ある時刻に雨が比較的強く降っている <b>地域のかたまり</b>。
本レッスンでは観測所別の雨量を空間的に滑らかに補間 (KDE) して輪郭で表す。</li>

<li><b>「カーネル密度推定 (KDE)」</b>: 各観測点を中心とした「<b>滑らかな小山</b>」を地図に重ねて、
点群を <b>連続的な濃淡分布</b>に変換する集計ツール。
本レッスンでは雨量を山の高さの重みに使う（重み付き KDE）。</li>

<li><b>「重心 (Center of Mass)」</b>: 雨量を「重さ」と見立てた時の <b>つり合いの中心点</b>。
雨が県西部に集中していれば重心も西に寄る。各時刻ごとに 1 点に要約される。</li>

<li><b>「Moran's I (モランのアイ)」</b>: 「<b>近くの観測所同士で雨量が似ているか</b>」を
1 つの数値で表す指標。+1 に近いほど局所集中、0 で無関係、−1 で交互パターン。</li>

<li><b>「代理座標」</b>: 雨量観測所には公式の緯経度が公開されていない。
本レッスンでは <b>同じ河川にあるカメラの平均緯経度</b>を雨量観測所の代わりに使う。
誤差は数 km 〜 十数 km だが、県全体の時空間進行を見るには十分。</li>
</ul>
</div>

<h3>立てた仮説</h3>
<ol>
<li><b>H1（雨域は移動する）</b>: 雨域は梅雨前線の進行に従って <b>数時間で県内を抜ける</b>はず。
重心の移動軌跡（24 個の点を線で結んだ図）として可視化できる</li>

<li><b>H2（空間クラスタ性は時刻で変動）</b>: 強雨時間帯ほど雨は <b>1〜2 か所に局所集中</b>し
（Moran's I が高い）、合間は分散して降るはず</li>

<li><b>H3（県全体での同時ピーク）</b>: 観測所ごとのピーク時刻は 1〜2 の時間帯に <b>モード</b>を持つはず
（梅雨前線が県を一斉に通過するため同時多発）</li>

<li><b>H4（属性結合で座標欠落を代替できる）</b>: 雨量観測所には緯経度が無いが、
<b>河川名をキーにカメラと結合</b>すれば、KDE/重心/Moran 計算に十分な代理座標が得られるはず</li>

<li><b>H5（上位観測所の波形は短時間集中型）</b>: 日合計上位 10 観測所は、24 時間に分散ではなく
<b>1〜2 時間にピークが集中する</b>波形を持つはず（局所豪雨 = 短時間に降りきる）</li>
</ol>

<h3>到達点</h3>
<ul>
<li>10 分値 → 1 時間集約と空間 KDE を実装し、雨域の <b>時間進行</b>を可視化できる</li>
<li>重心・Moran's I・ピーク時刻分布を <b>「ツール」として使い分け</b>られる
（数式の細部ではなく、入出力と限界を理解する）</li>
<li>属性結合による <b>座標欠落の代替推定</b>のパターンを習得する（実務頻発）</li>
<li>仮説 H1〜H5 を結果と照合して <b>支持／反証／部分支持</b>を判定できる</li>
</ul>"""),

    ("使用データ", """
<p>本レッスンは <b>2 つのデータセットを結合</b>して使う。雨量データには公式座標が無いため、
カメラの座標を <b>河川名</b>をキーに代用する（仮説 H4 の検証対象）。</p>

<ul class="kv">
<li><b>1) 雨量 10 分値 2024-07-01</b> — DoBoX
<a href="https://hiroshima-dobox.jp/datasets/1275" target="_blank">#1275</a> (resource_id 94500)<br>
144 時刻 × 約 400 観測所。各観測所の <b>水系名 / 河川名 / 事務所名</b>は
CSV ヘッダから取れるが、<b>緯経度は無い</b>。</li>

<li><b>2) 監視カメラ一覧</b> — DoBoX
<a href="https://hiroshima-dobox.jp/datasets/1279" target="_blank">#1279</a><br>
351 件、緯経度・路河川名等を含む。<b>「同じ河川にあるカメラの平均緯経度」</b>を
雨量観測所の代理座標として用いる。</li>
</ul>

<p><b>なぜ 2024-07-01 か</b>: 2018 年 7 月の西日本豪雨と同じ梅雨末期に当たる時期。
14 日窓（DoBoX #1275 の公開単位）の中で県内日合計が最大級だった日のひとつで、
時空間進行が読みやすい。</p>"""),

    ("ダウンロード（再現用データ・中間データ・図）", """
<p>本レッスンの全成果物に直リンクを置いた。途中ステップから再現したい学習者向け。</p>

<h3>1. 生データ（DoBoX 由来）</h3>
<table>
<tr><th>ファイル</th><th>形式</th><th>サイズ</th><th>取得元</th></tr>
<tr><td><a href="../data/rain_2024/rain_2024-07-01.csv" download><code>data/rain_2024/rain_2024-07-01.csv</code></a></td>
    <td>CSV (10分値, UTF-8 BOM, 5段ヘッダ)</td><td>約 1.2 MB</td>
    <td>DoBoX #1275 resource_id=94500</td></tr>
<tr><td><a href="../data/camera_list.csv" download><code>data/camera_list.csv</code></a></td>
    <td>CSV (緯度・経度・路河川名等)</td><td>約 70 KB</td>
    <td>DoBoX #1279</td></tr>
</table>

<h3>2. プログラムで生成される中間データ（本レッスンの実行成果物）</h3>
<table>
<tr><th>ファイル</th><th>内容</th><th>使う分析</th></tr>
<tr><td><a href="assets/L06_hourly_rainfall.csv" download><code>L06_hourly_rainfall.csv</code></a></td>
    <td>24 時刻 × 約 400 観測所の 1 時間集約雨量</td><td>分析 1〜5 共通の入力</td></tr>
<tr><td><a href="assets/L06_station_coords.csv" download><code>L06_station_coords.csv</code></a></td>
    <td>観測所 × (河川/水系/推定 lat/推定 lon/解決ソース)</td><td>分析 1〜3 の座標源</td></tr>
<tr><td><a href="assets/L06_com_trajectory.csv" download><code>L06_com_trajectory.csv</code></a></td>
    <td>24 時刻の重心位置と移動速度 (km/h)</td><td>分析 2 雨域重心軌跡</td></tr>
<tr><td><a href="assets/L06_moran_hourly.csv" download><code>L06_moran_hourly.csv</code></a></td>
    <td>24 時刻の Moran's I と時間総雨量</td><td>分析 3 空間自己相関</td></tr>
<tr><td><a href="assets/L06_peak_hours.csv" download><code>L06_peak_hours.csv</code></a></td>
    <td>観測所別ピーク時刻 (h)</td><td>分析 4 ピーク時刻分布</td></tr>
<tr><td><a href="assets/L06_daily_totals.csv" download><code>L06_daily_totals.csv</code></a></td>
    <td>観測所別の日合計雨量 (mm)</td><td>分析 5 Top10 抽出元</td></tr>
<tr><td><a href="assets/L06_top10_summary.csv" download><code>L06_top10_summary.csv</code></a></td>
    <td>上位 10 観測所のサマリ（水系・河川・日合計・ピーク・集中度）</td><td>分析 5 表</td></tr>
</table>

<h3>3. 図 PNG</h3>
<ul>
<li><a href="assets/L06_kde_smallmult.png" download>L06_kde_smallmult.png</a> — 図 1 時間別空間 KDE small multiples (24 枚)</li>
<li><a href="assets/L06_com_trajectory.png" download>L06_com_trajectory.png</a> — 図 2 雨域重心の軌跡と移動速度</li>
<li><a href="assets/L06_moran_hourly.png" download>L06_moran_hourly.png</a> — 図 3 時間別 Moran's I</li>
<li><a href="assets/L06_peak_hist.png" download>L06_peak_hist.png</a> — 図 4 ピーク時刻分布と CDF</li>
<li><a href="assets/L06_top10_timeseries.png" download>L06_top10_timeseries.png</a> — 図 5 Top10 観測所の 10 分値時系列</li>
</ul>

<h3>4. 再現スクリプト</h3>
<pre><code>cd "2026 DoBoX 教材"
py -X utf8 lessons/L06_july1_heatmap.py</code></pre>
<p class="tnote">スクリプト本体: <a href="L06_july1_heatmap.py" download><code>lessons/L06_july1_heatmap.py</code></a>。
データが無ければ <code>ensure_dataset()</code> が DoBoX から自動取得。</p>"""),

    ("分析1: 雨域の空間 KDE small multiples (24時間)", """
<h4>狙い</h4>
<p><b>「1 日の中で雨域がどこからどこへ移動したか」を、24 枚の地図を時系列で並べて一望する。</b>
仮説 H1（雨域は移動する）を視覚的に検証するための分析。</p>

<div class="note">
<b>用語</b>: 「<b>カーネル密度推定 (KDE)</b>」は、観測点の点群を <b>滑らかな濃淡分布</b>に変換するツール。
各点を中心とした「小山」を地図に重ねて足し算し、重みつきの地図を作る。
今回は雨量を「山の高さ」の重みに使うので、<b>雨が強い場所ほど山が高い</b>。
24 個の地図を並べる手法を <b>small multiples（小窓並列）</b> と呼ぶ — 時間変化を一目で読める。
</div>

<h4>このツールで何ができて、何ができないか（要件 J）</h4>
<table>
<tr><th>項目</th><th>このツール</th></tr>
<tr><td>入力</td><td>観測点の (緯度, 経度, 重み) のリスト</td></tr>
<tr><td>出力</td><td>地図上のグリッド (80×80) 各点の濃度値</td></tr>
<tr><td>主なパラメータ</td>
    <td><b>バンド幅 h</b>（小山の幅）。本レッスンは h=0.07°≒ 7 km — 強雨セル 1 つの典型サイズ</td></tr>
<tr><td>限界</td>
    <td>(1) h を変えると印象が変わる。h 小 → 点状、h 大 → 全県塗り潰し<br>
        (2) 観測点が少ない地域では信頼性が低い (山陰など)<br>
        (3) 点と点の間を <b>滑らかに「埋めている」だけ</b>で、本当にそこで雨が降っていたかは保証しない</td></tr>
<tr><td>代替</td>
    <td>IDW (逆距離加重)、ボロノイ補間、クリギング — KDE は最も単純で実装しやすい</td></tr>
<tr><td>数式の細部</td>
    <td><b>気にしなくて良い</b>。「滑らかな小山を足し合わせる」イメージで十分</td></tr>
</table>

<h4>1 日のデータがどう変換されるか（Before/After 表、要件 K）</h4>
<table>
<tr><th>段階</th><th>内容</th><th>サイズ</th></tr>
<tr><td>① 生 CSV (rain_2024-07-01.csv)</td>
    <td>10 分間隔の雨量。5 段ヘッダ付き（観測所名/河川名/水系名/事務所名/単位行）</td>
    <td>144 行 × 約 400 列</td></tr>
<tr><td>② <code>parse_rain_csv()</code> で tidy</td>
    <td>DatetimeIndex × 観測所列の数値表</td>
    <td>(144, """ + str(len(geo_cols)) + """+) </td></tr>
<tr><td>③ <code>resample("1h").sum()</code></td>
    <td>1 時間ごとに 6 個の 10 分値を足す</td>
    <td>(24, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>④ 河川名で代理座標を結合 (分析 4 で詳細)</td>
    <td>各列に (lat, lon) が付く</td>
    <td>列数は変わらず、<br>各列に座標メタが付与</td></tr>
<tr><td>⑤ 各時刻 h で <code>kde_grid(weights=hourly[h])</code></td>
    <td>80×80 グリッドの濃度値</td>
    <td>(80, 80) × 24 時刻</td></tr>
<tr><td>⑥ <code>contourf</code> で等高線塗り → 24 枚の小窓</td>
    <td>図 1 の small multiples</td>
    <td>6 行 × 4 列 = 24 図</td></tr>
</table>

<h4>実装（狙いと要点）</h4>
<p><b>狙い</b>: 加重ガウシアンを各観測所に置いてグリッドに足し算するだけのシンプル実装。
<b>要点</b>: <code>w &gt; 0</code> の観測所だけループに入れて高速化。バンド幅 <code>h = 0.07</code>。</p>
""" + code('''
def kde_grid(weights, bandwidth=0.07, grid=80):
    """加重 KDE を等間隔グリッド上で評価。
    weights: 各観測所のその時刻の雨量 (mm)
    bandwidth: ガウシアンの「幅」(度)。0.07° ≒ 7 km
    """
    xs = np.linspace(LON_MIN, LON_MAX, grid)
    ys = np.linspace(LAT_MIN, LAT_MAX, grid)
    XX, YY = np.meshgrid(xs, ys)
    Z = np.zeros_like(XX)
    h2 = bandwidth ** 2
    for la, lo, w in zip(lats, lons, weights):
        if w <= 0:
            continue
        # 各観測所を中心とした「滑らかな小山」を足し合わせる
        Z += w * np.exp(-((XX - lo) ** 2 + (YY - la) ** 2) / (2 * h2))
    return XX, YY, Z

# 24 コマを 6 × 4 で並べて時間進化を一望 (small multiples)
fig, axes = plt.subplots(6, 4, figsize=(13, 16), sharex=True, sharey=True)
for h in range(24):
    XX, YY, Z = kde_grid(hourly.iloc[h].values, bandwidth=0.07)
    ax = axes[h // 4, h % 4]
    if Z.max() > 0:
        ax.contourf(XX, YY, Z, levels=12, cmap="Blues")
    ax.scatter(lons, lats, s=3, c="#666", alpha=0.35)  # 観測所点
    total = float(np.nansum(hourly.iloc[h].values))
    ax.set_title(f"{h:02d}:00-{h:02d}:59  Σ={total:.0f}mm", fontsize=9)
''') + """
<h4>結果（図と読み取り）</h4>
<p><b>なぜこの図か</b>: 「雨域がどう移動したか」を見るには、<b>同じ地図枠を時刻順に 24 枚並べる</b>のが最も直感的。
1 枚の動画にすれば滑らかに見えるが、紙面では並列表示が読み取りやすい。
バンド幅 7 km 程度に揃えると、強雨セルが「色のかたまり」として現れる。</p>
""" + figure("assets/L06_kde_smallmult.png",
              "図 1: 1 時間ごとの空間 KDE 24 枚。塗り色の濃さが雨量の強さ、灰色点が観測所") + """
<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>時刻 """ + f"{hour_total_max_h:02d}" + """:00 前後がピーク</b>: 県内総雨量が最大 (Σ ≈ """ + f"{hour_total_max:.0f}" + """ mm)。
塗り色のかたまりが最も濃い時間帯</li>
<li><b>未明〜朝に強雨域が現れ、午後にかけて弱まる</b>: 24 枚を時系列で追うと、
雨域が県の一部から始まり時間進行に伴って縮小していく</li>
<li><b>雨域は 1 つではなく 2 〜 3 セル同時</b>の時刻もある — 重心 (分析 2) は
これらのセルの「平均位置」を出すため注意</li>
<li><b>仮説 H1（雨域は移動する）への支持</b>: 雨域は静止せず、時刻ごとに位置と強度が変化することが視覚的に確認できる</li>
</ul>"""),

    ("分析2: 雨域重心 (Center of Mass) の軌跡", """
<h4>狙い</h4>
<p><b>「24 枚の KDE をたった 1 点に要約する」</b>。各時刻の雨量を「重さ」と見なした
<b>つり合いの中心</b>を計算し、24 個の点を線で結べば「雨域がどっちへ動いたか」が一目で分かる。
仮説 H1 を <b>定量化</b>する分析。</p>

<div class="note">
<b>用語</b>: 「<b>重心 (Center of Mass)</b>」とは、雨量を質量と見立てた時の <b>つり合いの中心</b>。
雨が県西部に偏っていれば重心も西へ寄り、均等なら県中央に来る。
1 時刻 = 1 個の (緯度, 経度) に要約されるので、24 時刻なら 24 個の点が出る。
</div>

<h4>このツールで何ができて、何ができないか（要件 J）</h4>
<table>
<tr><th>項目</th><th>このツール</th></tr>
<tr><td>入力</td><td>観測点の座標 (lat, lon) と重み (雨量)</td></tr>
<tr><td>出力</td><td>1 個の (重心 lat, 重心 lon)</td></tr>
<tr><td>主なパラメータ</td><td>なし（重み付き平均なので素朴）</td></tr>
<tr><td>限界</td>
    <td>(1) 雨が <b>2 か所に分かれている</b>時、重心はその中間に来る — 「実際にどこも降っていない場所」を指してしまう<br>
        (2) 外れ値 1 つに引きずられやすい (極端に強雨の 1 地点が重心を引っ張る)</td></tr>
<tr><td>代替</td>
    <td>モード位置 (最大雨量観測所)、複数モード抽出 (k-means クラスタ重心)</td></tr>
<tr><td>数式の細部</td>
    <td><code>np.average(coords, weights=rainfall)</code> 一発。気にしなくて良い</td></tr>
</table>

<h4>1 日のデータがどう変換されるか（Before/After 表、要件 K）</h4>
<table>
<tr><th>段階</th><th>内容</th><th>サイズ</th></tr>
<tr><td>① 1 時間集約データ (分析 1 の ③)</td><td>24 × 観測所数の雨量</td><td>(24, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>② 各時刻 h で <code>np.average(lons, weights=hourly[h])</code></td>
    <td>その時刻の重心の経度・緯度</td><td>2 個のスカラー</td></tr>
<tr><td>③ 24 時刻分まとめ</td><td>(時刻 h, 重心 lon, 重心 lat, 総雨量)</td><td>24 行 × 4 列</td></tr>
<tr><td>④ 連続時刻の差分 → 移動速度</td>
    <td>1 度 ≒ 緯度 111 km / 経度約 91 km @lat34.5 で km/h 換算</td><td>23 個の差分</td></tr>
<tr><td>⑤ 散布 + 矢印 (図 2 左) と 棒グラフ (図 2 右)</td><td>軌跡と速度の可視化</td><td>図 2</td></tr>
</table>

<h4>実装（狙いと要点）</h4>
<p><b>狙い</b>: 雨量を重みにした座標の加重平均で各時刻 1 点を出す。
<b>要点</b>: 雨量ゼロ時刻は <code>NaN</code> にして矢印を切る。差分 → km 換算で速度化。</p>
""" + code('''
KM_LON = 111.0 * np.cos(np.deg2rad(34.5))   # 経度 1 度 ≒ 91 km @広島緯度
KM_LAT = 111.0                              # 緯度 1 度 ≒ 111 km

com = []
for h in range(24):
    w = np.where(np.isnan(hourly.iloc[h].values), 0, hourly.iloc[h].values)
    if w.sum() > 0:
        # 重み付き平均 = 雨量を「重さ」とした重心
        clon = float(np.average(lons, weights=w))
        clat = float(np.average(lats, weights=w))
    else:
        clon, clat = np.nan, np.nan
    com.append({"hour": h, "lon": clon, "lat": clat, "total": float(w.sum())})

com_df = pd.DataFrame(com)
# 移動速度 (km/h)
dlon = com_df["lon"].diff().values * KM_LON
dlat = com_df["lat"].diff().values * KM_LAT
com_df["speed_kmh"] = np.sqrt(dlon ** 2 + dlat ** 2)
''') + """
<h4>結果（図と読み取り）</h4>
<p><b>なぜこの図か</b>: 雨域の <b>進行方向と速度</b>を見るには「軌跡（散布 + 矢印）」と
「速度の時刻別棒」を並列表示するのが最も読みやすい。色は時刻 (0→23 h) で、
矢印が連続時刻の移動を示す。</p>
""" + figure("assets/L06_com_trajectory.png",
              "図 2: 雨域重心の軌跡 (左, 色 = 時刻 0→23h) と時刻別移動速度 (右)") + """
<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>1 日の総移動距離 ≒ """ + f"{com_dist_total:.0f}" + """ km</b> (差分の和)</li>
<li><b>強雨時間帯ほど重心は安定</b>: 雨が 1 か所に集まるので重心が動かない</li>
<li><b>降りはじめ・降りやみ時に重心が大きく跳ぶ</b>: 雨量が小さい時刻は分母が小さく、
わずかな分布変化で重心が振れる（重心の弱点）</li>
<li><b>仮説 H1 の判定</b>: 軌跡は西→東への単調進行ではなく、<b>複数のセルが入れ替わる</b>動き。
H1 は <b>部分支持</b>（移動はするが単純な前線通過ではない）</li>
</ul>

<h4>結果（表と読み取り）</h4>
<h5>表 1: 24 時刻の重心位置と移動速度</h5>
""" + com_html + """
<p><b>この表から読み取れること</b>: 強雨時刻 (
""" + f"{hour_total_max_h:02d}" + """:00 前後) は速度が小さく重心が安定する一方、
弱雨時刻は速度が跳ねる。重心は <b>「雨量の多い時刻だけ」</b>信頼してよい指標。</p>"""),

    ("分析3: 時間別 Moran's I (空間自己相関)", """
<h4>狙い</h4>
<p><b>「近くの観測所同士で雨量が似ているか」を 1 数値に要約し、24 時刻でどう変化するかを見る。</b>
雨が <b>1〜2 か所に集中</b>している時刻は I が高く、<b>分散して降る</b>時刻は I が低い。
仮説 H2 を検証する。</p>

<div class="note">
<b>用語</b>: 「<b>Moran's I (モランのアイ)</b>」は空間統計の代表指標。
+1 で <b>同じような値の場所が固まっている (局所集中)</b>、
0 で <b>無関係 (ランダム配置)</b>、
−1 で <b>高低が交互 (チェッカーボード)</b>。
本レッスンでは「Global Moran's I」（県全体の平均値 1 個）を時刻ごとに計算する。
</div>

<h4>このツールで何ができて、何ができないか（要件 J）</h4>
<table>
<tr><th>項目</th><th>このツール</th></tr>
<tr><td>入力</td><td>観測点の (lat, lon) と各点の値（その時刻の雨量）</td></tr>
<tr><td>出力</td><td>1 個のスカラー I (約 −1 〜 +1)</td></tr>
<tr><td>主なパラメータ</td>
    <td><b>近傍数 k</b>（本レッスンは k=8）。
        小さすぎるとノイジー、大きすぎると遠くまで含めて I が薄まる</td></tr>
<tr><td>限界</td>
    <td>(1) 「Global」 = 県全体の平均値 1 個。<b>どこに集中しているか</b>は分からない<br>
        (2) k と重み関数 (inverse-distance) の選択で値が変わる<br>
        (3) 観測点配置にバイアス (山陰薄) があると I もバイアス</td></tr>
<tr><td>代替</td>
    <td>Local Moran's I (LISA, 観測所ごとの値) — どこがホットスポットかが分かる (発展課題)</td></tr>
<tr><td>数式の細部</td>
    <td><b>気にしなくて良い</b>。「近い点同士の値の似具合」を −1〜+1 に正規化した数とイメージすれば十分</td></tr>
</table>

<h4>1 日のデータがどう変換されるか（Before/After 表、要件 K）</h4>
<table>
<tr><th>段階</th><th>内容</th><th>サイズ</th></tr>
<tr><td>① 1 時間集約データ</td><td>24 × 観測所数の雨量</td><td>(24, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>② 観測点間の距離行列 (km)</td>
    <td>緯経度差を km 換算し、全ペア距離</td>
    <td>(""" + str(len(geo_cols)) + """, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>③ 各点の k=8 近傍だけ重みを残し、残りはゼロ</td>
    <td>近い点ほど大きい重み (1/距離)、行ごとに合計 1 に正規化</td><td>同左</td></tr>
<tr><td>④ 各時刻で I を計算</td><td>偏差 z の <b>近傍と自分の積</b>を全部足す</td><td>1 個のスカラー</td></tr>
<tr><td>⑤ 24 時刻分</td><td>時刻別 I の配列</td><td>長さ 24</td></tr>
<tr><td>⑥ 棒グラフ + 時間総雨量の折れ線</td><td>図 3</td><td>—</td></tr>
</table>

<h4>実装（狙いと要点）</h4>
<p><b>狙い</b>: 観測点の k 近傍 inverse-distance 重み行列で I を計算。
<b>要点</b>: 距離は緯経度を km に換算してから計算。重みは行標準化。</p>
""" + code('''
def moran_i(values, lats_arr, lons_arr, k=8):
    """k 近傍 (inverse-distance) で Global Moran's I を計算"""
    v = np.asarray(values, dtype=float)
    mask = ~np.isnan(v)
    if mask.sum() < 10 or np.nanstd(v) == 0:
        return np.nan
    v = v[mask]
    la, lo = lats_arr[mask], lons_arr[mask]
    n = len(v)
    # 距離行列 (km)
    dla = (la[:, None] - la[None, :]) * KM_LAT
    dlo = (lo[:, None] - lo[None, :]) * KM_LON
    d = np.sqrt(dla ** 2 + dlo ** 2)
    np.fill_diagonal(d, np.inf)
    # 各点の k 近傍だけ inverse-distance 重みを残す
    W = np.zeros_like(d)
    for i in range(n):
        idx = np.argsort(d[i])[:k]
        W[i, idx] = 1.0 / np.maximum(d[i, idx], 0.1)
    # 行標準化 (各行の合計を 1 に)
    W = W / W.sum(axis=1, keepdims=True)
    z = v - v.mean()
    s2 = (z ** 2).sum() / n
    if s2 == 0:
        return np.nan
    num = (W * np.outer(z, z)).sum()
    return float(num / (s2 * n) * (n / W.sum()))

moran_per_hour = [moran_i(hourly.iloc[h].values, lats, lons, k=8) for h in range(24)]
''') + """
<h4>結果（図と読み取り）</h4>
<p><b>なぜこの図か</b>: I の時刻変化と <b>時間総雨量</b>を <b>同じ時間軸で並列</b>に見たい。
左軸 = I の棒（赤=強・青=中・灰=弱）、右軸 = 総雨量の折れ線。
両者が同期するか／ずれるかが仮説 H2 の核心。</p>
""" + figure("assets/L06_moran_hourly.png",
              "図 3: 時間別 Moran's I (棒) と時間総雨量 (橙線)。同期すれば「雨多い時刻 = 集中」") + """
<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>最大 I = """ + f"{moran_max:.2f}" + """ @ """ + f"{moran_max_h:02d}" + """:00</b>: その時刻に雨域が最も局所集中していた</li>
<li><b>日合計 I = """ + f"{moran_day:.2f}" + """</b>: 1 日全体で見ても <b>クラスタ的</b>な配置 (>0)</li>
<li><b>I が高い時刻 ≒ 強雨時間帯</b>: 仮説 H2（強雨時間帯ほど集中）に <b>支持</b>の傾向</li>
<li><b>低 I 時刻 = 雨域が拡散・併合 or 弱雨で観測ノイズ的</b>: 雨域が消滅していく時間帯にも見られる</li>
</ul>

<h4>結果（表と読み取り）</h4>
<h5>表 2: 24 時刻の Moran's I と総雨量</h5>
""" + moran_html + """
<p><b>この表から読み取れること</b>: 「強い局所集中」と判定された時刻は雨量も多い傾向。
<b>仮説 H2 への部分支持</b>（同期はするが完全に一致するわけではない）。</p>"""),

    ("分析4: 観測所別ピーク時刻ヒストグラム", """
<h4>狙い</h4>
<p><b>「県内の観測所たちがいつピーク雨量を記録したか」</b>を 1 つのヒストグラムに集約する。
1〜2 の時間帯に観測所が集中していれば <b>梅雨前線の同時通過</b>を示唆（仮説 H3）。
広く分散していれば <b>独立した複数のセル</b>が時間差で降ったことを示す。</p>

<h4>狙いに対する手法選択（要件 H）</h4>
<ul>
<li><b>「県全体での集中時間帯」を見たい</b> → 観測所別 1 時刻に縮約してヒストグラム</li>
<li><b>累積分布 (CDF)</b> も並列表示すると「P25/P50/P75 の時刻」が読める →
ピーク到達の早さ・遅さを定量化</li>
<li><code>str.contains</code> や K-Means のような複雑手法は不要 — <b>idxmax</b> 一発で OK</li>
</ul>

<h4>1 日のデータがどう変換されるか（Before/After 表、要件 K）</h4>
<table>
<tr><th>段階</th><th>内容</th><th>サイズ</th></tr>
<tr><td>① 1 時間集約データ</td><td>24 × 観測所数の雨量</td><td>(24, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>② 各観測所で <code>idxmax()</code></td><td>その観測所のピーク時刻 (h)</td><td>各観測所 1 個</td></tr>
<tr><td>③ 日合計 ≤ 5 mm の観測所は除外</td><td>「降ってない」観測所のノイズ排除</td><td>n=""" + str(len(peak_hours)) + """ に減</td></tr>
<tr><td>④ 24 ビンのヒストグラム + CDF</td><td>図 4</td><td>—</td></tr>
</table>

<h4>実装（狙いと要点）</h4>
<p><b>狙い</b>: 各観測所のピーク時刻を集計して分布を見る。
<b>要点</b>: 日合計が小さい観測所はピーク時刻に意味がないので除外する。</p>
""" + code('''
peak_hours = []
for st in geo_cols:
    s = hourly[st]
    if s.sum() > 5:                          # 日合計 5 mm 超だけ採用
        peak_hours.append(int(s.idxmax().hour))
peak_hours = np.array(peak_hours)

# 24 ビンのヒストグラム + 累積分布 (CDF)
counts, edges, _ = ax.hist(peak_hours, bins=np.arange(-0.5, 24.5, 1))
mode_h = int(np.argmax(counts))              # 最頻時間帯
ax2.plot(np.sort(peak_hours), np.arange(1, len(peak_hours)+1)/len(peak_hours)*100)
''') + """
<h4>結果（図と読み取り）</h4>
<p><b>なぜこの図か</b>: 同時多発性を見るには <b>ヒストグラム</b>が定石。
モード（最頻時間帯）を縦線で示し、CDF を併置すると「P25/P50/P75 はいつか」が読める。</p>
""" + figure("assets/L06_peak_hist.png",
              "図 4: 観測所別ピーク時刻のヒストグラム (左) と累積分布 CDF (右)") + """
<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>モード時刻 = """ + f"{peak_mode_h:02d}" + """:00 ({peak_mode_n} 観測所)</b>:
""".replace("{peak_mode_n}", str(peak_mode_n)) + """県内の最も多くの観測所が同じ時間帯にピークを迎えた</li>
<li><b>ピーク時刻の P25-P75 = """ + f"{peak_p25:.1f}" + """h 〜 """ + f"{peak_p75:.1f}" + """h</b>:
この幅が狭いほど「短時間集中型」、広いほど「長時間持続型」</li>
<li><b>仮説 H3 の判定</b>: モードに観測所が集中 → <b>支持</b>。ただし裾も長く、雨域が単純な 1 セルではないことを示す</li>
<li><b>避難判断への含意</b>: モード時刻に県全体で同時に避難判断が必要になるシナリオ</li>
</ul>

<h4>結果（表と読み取り）</h4>
<h5>表 3: ピーク時刻 上位 5 時間帯（観測所数の多い順）</h5>
""" + peak_top_html + """
<p><b>この表から読み取れること</b>: 上位 5 時間帯だけで全観測所の過半が説明できる。
雨域の主要なピークはこの数時間に集中していたことが定量的に確認できる。</p>"""),

    ("分析5: Top10 観測所の 10 分値時系列と座標推定", """
<h4>狙い</h4>
<p><b>「日合計上位 10 観測所の波形」を 10 分値で重ね描きし、ピーク集中度を観測所別に見る。</b>
仮説 H5（上位観測所は短時間集中型）を検証。
同じ分析の中で、<b>仮説 H4（属性結合で座標欠落を代替できる）</b>の検証も行う
（10 分値は座標を使わないが、「Top10 を地図上に置きたい」発展課題で必要になる）。</p>

<h4>STEP 1: 河川名による属性結合 — 座標欠落の代替推定（仮説 H4）</h4>
<div class="note">
雨量観測所には公式の緯経度が無い。これを補うため、<b>同じ河川にあるカメラの平均緯経度</b>を
代理座標として用いる。fallback 階層は <b>河川一致 → 水系一致 → 事務所中心</b>の 3 段。
</div>

<table>
<tr><th>fallback 段階</th><th>役割</th><th>マッチ条件</th><th>誤差の目安</th></tr>
<tr><td>① 河川一致 (最良)</td>
    <td>同じ水系の同じ河川にあるカメラの平均緯経度を使う</td>
    <td><code>water_system × river</code> 完全一致</td>
    <td>数 km 程度（同じ河川の上流〜下流の中間）</td></tr>
<tr><td>② 水系一致 (fallback)</td>
    <td>河川マッチが取れない時、水系全体のカメラ平均</td>
    <td><code>water_system</code> のみ一致</td>
    <td>10 km 前後</td></tr>
<tr><td>③ 事務所中心 (最終 fallback)</td>
    <td>水系も合わない時、事務所の管轄中心</td>
    <td>事務所名一致</td>
    <td>20 km 程度</td></tr>
<tr><td>④ 未解決</td>
    <td>除外して以降の分析対象から外す</td>
    <td>—</td>
    <td>—</td></tr>
</table>

<h5>表 4: 座標解決ソースの内訳（実データ）</h5>
""" + src_html + """
<p><b>この表から読み取れること</b>: 河川一致 (""" + f"{n_river}" + """ 観測所) が大半を占め、
未解決は """ + f"{n_none}" + """ 観測所。<b>仮説 H4 への支持</b>: 属性結合だけで
KDE/重心/Moran 計算に十分な座標が得られた。</p>

<h4>STEP 2: Top10 観測所の 10 分値波形</h4>
<p><b>狙い</b>: 日合計上位 10 観測所を抽出し、10 分値の波形を重ね描き。
ピークがどれくらい鋭いか、複数ピークか単峰かを見る。</p>

<h4>1 日のデータがどう変換されるか（Before/After 表、要件 K）</h4>
<table>
<tr><th>段階</th><th>内容</th><th>サイズ</th></tr>
<tr><td>① 10 分値 tidy</td><td>(144, 観測所数)</td><td>(144, """ + str(len(geo_cols)) + """)</td></tr>
<tr><td>② <code>day_total = tidy.sum(axis=0)</code></td><td>観測所別 日合計</td><td>長さ """ + str(len(geo_cols)) + """</td></tr>
<tr><td>③ 上位 10 観測所抽出</td><td>日合計降順で .head(10)</td><td>10 観測所名</td></tr>
<tr><td>④ 10 分値の重ね描き</td><td>x = 時刻, y = 10 分雨量, 色 = 観測所</td><td>図 5</td></tr>
<tr><td>⑤ 集中度 = ピーク 1 時間 / 日合計</td><td>波形の鋭さの定量指標</td><td>10 個のスカラー</td></tr>
</table>

<h4>実装（狙いと要点）</h4>
""" + code('''
# Top10 抽出と 10 分値の重ね描き
top10_names = day_total[geo_cols].sort_values(ascending=False).head(10).index.tolist()

fig, ax = plt.subplots(figsize=(12, 5))
cmap = plt.get_cmap("tab10")
for i, st in enumerate(top10_names):
    s = tidy[st]
    ax.plot(s.index, s.values, color=cmap(i), alpha=0.85, lw=1.3,
            label=f"{st} (Σ={day_total[st]:.0f}mm)")

# 集中度 = ピーク 1 時間雨量 / 日合計 (1 に近いほど短時間集中型)
for st in top10_names:
    peak_share = hourly[st].max() / day_total[st]
''') + """
<h4>結果（図と読み取り）</h4>
<p><b>なぜこの図か</b>: 上位観測所の <b>波形の鋭さ</b>を見るには 10 分値を <b>重ね描き</b>するのが最も直感的。
1 観測所 1 線で色分けし、凡例に日合計を出す。波形が短時間に立ち上がる観測所が短時間集中型。</p>
""" + figure("assets/L06_top10_timeseries.png",
              "図 5: 日合計上位 10 観測所の 10 分値時系列重ね描き") + """
<p><b>この図から読み取れること</b>:</p>
<ul>
<li><b>ほとんどの観測所が同じ時間帯にピーク</b>: 重ね描きでピーク位置が揃う —
仮説 H3 と整合的</li>
<li><b>1 観測所内では数十分のスパイク状ピーク</b>が中心 — 1 時間以下のスケールの強雨</li>
<li><b>仮説 H5 の判定</b>: 表 5 の「最大時間/日合計」がおおよそ 20〜37% → <b>部分支持</b>
（1 時間でその日の 2〜3 割が降る = 2〜4 時間に降りきる短時間集中型）</li>
</ul>

<h4>結果（表と読み取り）</h4>
<h5>表 5: 日合計上位 10 観測所のサマリ（集中度付き）</h5>
""" + top10_html + """
<p><b>この表から読み取れること</b>: 「最大時間/日合計」が 20〜37% に分布
（3 時間で日合計の 6〜9 割が降った計算）。
水系列を見ると上位は <b>「単独河川」「その他（沿岸部）」「小瀬川」</b>に偏っており、
県西部沿岸〜山地のラインに集中する地形性降水のサイン。
発展課題（地形・標高との照合）への伏線になる。</p>"""),

    ("仮説検証と考察", """
<h3>仮説と結果の照合</h3>
<table>
<tr><th>#</th><th>仮説</th><th>判定</th><th>根拠</th></tr>

<tr><td>H1</td>
    <td>雨域は梅雨前線の進行に従い数時間で県内を抜ける</td>
    <td><b>部分支持</b></td>
    <td>図 1 (KDE) と図 2 (重心軌跡) で雨域は確かに移動するが、西→東への単調進行ではなく、
        複数のセルが時間差で入れ替わる動き。
        重心の総移動 ≒ """ + f"{com_dist_total:.0f}" + """ km。</td></tr>

<tr><td>H2</td>
    <td>強雨時間帯ほど雨は局所集中する (Moran's I が高い)</td>
    <td><b>部分支持</b></td>
    <td>図 3 で I が高い時刻 ≒ 強雨時刻という同期傾向は見える。
        最大 I = """ + f"{moran_max:.2f}" + """ @ """ + f"{moran_max_h:02d}" + """:00。
        ただし強雨でも I が中程度の時刻もあり、雨域が <b>2 セル併存</b>するパターンが影響。</td></tr>

<tr><td>H3</td>
    <td>観測所ごとのピーク時刻は 1〜2 の時間帯にモードを持つ</td>
    <td><b>支持</b></td>
    <td>図 4 ヒストグラムでモード時刻 """ + f"{peak_mode_h:02d}" + """:00 に
        """ + f"{peak_mode_n}" + """ 観測所が集中。
        P25-P75 = """ + f"{peak_p25:.1f}" + """h-""" + f"{peak_p75:.1f}" + """h と <b>狭い</b>幅 →
        県全体で同時多発的にピーク。</td></tr>

<tr><td>H4</td>
    <td>河川名属性をキーにカメラと結合すれば代理座標が得られる</td>
    <td><b>支持</b></td>
    <td>表 4 で河川一致が """ + f"{n_river}" + """ 観測所、未解決はわずか """ + f"{n_none}" + """。
        KDE/重心/Moran 計算に十分。
        誤差は数 km〜十数 km だが県全体スケールの分析には十分。</td></tr>

<tr><td>H5</td>
    <td>日合計上位 10 観測所は短時間集中型の波形</td>
    <td><b>部分支持</b></td>
    <td>表 5 で「最大時間/日合計」がおおよそ 20〜37%
        （3 時間で日合計の 6〜9 割を占める観測所が多い）。
        24 時間均等分散ではないが、1 時間に集中する極端パターンでもない。
        実際には <b>2〜4 時間の連続強雨</b>がその日の大半を占めていた。</td></tr>
</table>

<h3>考察</h3>
<ul>
<li><b>雨は「動く」だけでなく「同期する」</b>: 図 1〜3 の組み合わせから、雨域は <b>時間移動</b>しつつ
<b>複数地点で同時にピーク</b>を迎える複雑な振る舞いを示す。
単純な前線通過モデルでは説明できない部分が残り、L05/L06 の発展（多日比較・気象レーダ照合）が必要。</li>

<li><b>「同じ日合計」でも空間クラスタ性で被害の出方が違う</b>:
Moran's I が高い時刻 = 1〜2 か所に集中（<b>狭い谷でフラッシュフラッド</b>のリスク）、
低い時刻 = 広域に分散（<b>多くの河川で同時に水位上昇</b>のリスク）。
合計だけでなく <b>クラスタ性</b>も防災判断に必要。</li>

<li><b>属性結合の威力と限界</b>: 仮説 H4 の支持から、<b>河川名のような属性キー</b>は
座標欠落データを救う有効な手段だと分かった。一方で「同じ河川でも上流/下流で雨量が違う」という
誤差は残り、<b>点状の精度</b>を要する分析（特定地点の警報判断など）には不十分。
fallback 階層を持つ設計は実務でも有用。</li>

<li><b>KDE のバンド幅は「見たい現象のスケール」</b>: h=0.07°≒7 km は強雨セル 1 つの典型サイズ。
これより細かくすれば点状（観測所配置のバイアスが見える）、太くすれば全県塗り潰し（時間進行が消える）。
<b>「どの空間スケールで雨を見たいか」を決めることがバンド幅選びと同義</b>。</li>

<li><b>方法の限界と次の一歩</b>:
(1) Global Moran's I は <b>どこに</b>集中するかを答えない → Local Moran's I (発展課題)。
(2) 重心は 2 セル併存に弱い → 複数モード抽出 (k-means 重心) が次の選択肢。
(3) 観測点ベース KDE は <b>山陰の観測薄</b>で過小評価 → 気象レーダ照合 (発展課題)。</li>
</ul>"""),

    ("発展課題（結果から導かれる新たな問い）", """
<p>各課題は、上の <b>結果</b>と <b>新しい仮説</b>に裏打ちされている。
「結果 X → 新仮説 Y → 課題 Z」の 3 段で書く。</p>

<ol>
<li><b>多日比較で前線の動きの再現性を見る</b>
  <ul>
  <li><b>結果 X</b>: 図 2 の重心軌跡は 2024-07-01 に固有のパターン。前線通過のたびに同じ動きをするのか不明</li>
  <li><b>新仮説 Y</b>: 14 日窓の他の豪雨日 (06-30, 07-02 など) の重心軌跡を重ねると、
      <b>梅雨前線の典型的進行方向</b>が浮かび上がるはず（ベクトル場として可視化）</li>
  <li><b>課題 Z</b>: rain_2024 ディレクトリの 14 日分について同じ重心計算を行い、
      14 本の軌跡を 1 枚に重ねて方向の主成分を取る (PCA)</li>
  </ul>
</li>

<li><b>Local Moran's I でホットスポットを特定</b>
  <ul>
  <li><b>結果 X</b>: 図 3 の Global I は <b>「県全体での集中度」1 個</b>しか示さず、
      具体的な集中地点が分からない</li>
  <li><b>新仮説 Y</b>: 観測所ごとに Local Moran's I (LISA) を計算すれば、
      <b>HH (高×高クラスタ) / LH (Outlier) / LL (低×低クラスタ)</b> として
      地図上で <b>どこが豪雨セルの中心か</b>が直接読めるはず</li>
  <li><b>課題 Z</b>: PySAL の <code>esda.Moran_Local</code> で計算 → 4 タイプを地図に色分け。
      最強雨時刻 (""" + f"{moran_max_h:02d}" + """:00) で確認</li>
  </ul>
</li>

<li><b>KDE バンド幅の感度分析</b>
  <ul>
  <li><b>結果 X</b>: 図 1 は h=0.07° で固定だが、強雨セルのサイズは時刻で変わる可能性がある</li>
  <li><b>新仮説 Y</b>: 各時刻で <b>適応的バンド幅</b> (Silverman's rule や交差検証) を使えば、
      強雨時刻ほど自動的に細かい解像度になり、KDE の見かけが変わるはず</li>
  <li><b>課題 Z</b>: <code>scipy.stats.gaussian_kde</code> の <code>bw_method</code> を変えて
      h=0.03/0.07/0.15 の 3 通りを並列描画。差分から「現象スケール」が読めるかを検証</li>
  </ul>
</li>

<li><b>観測所→流域水位ピーク時刻のラグ</b>
  <ul>
  <li><b>結果 X</b>: 図 4 で観測所のピーク時刻は """ + f"{peak_mode_h:02d}" + """:00 にモード。
      これに対し河川水位ピークは何時間遅れるのか不明</li>
  <li><b>新仮説 Y</b>: 観測所ピーク → 河川水位ピークの <b>時間ラグ</b>は流域面積でスケールする
      （大きい流域ほど遅れて応答）。観測所と水位観測所をペアにして散布すると単調関係が見える</li>
  <li><b>課題 Z</b>: 水位 10 分値データ (DoBoX 1276 系) を取得し、流域別にピーク時刻を取って
      雨量ピーク時刻との差分を散布。流域面積 (河川シェイプ) と重ね合わせる</li>
  </ul>
</li>

<li><b>Top10 観測所の地形配置と短時間集中度の関係</b>
  <ul>
  <li><b>結果 X</b>: 表 5 で上位観測所は特定の水系・河川に偏り、最大時間/日合計が高い (短時間集中型)</li>
  <li><b>新仮説 Y</b>: 短時間集中型の観測所は <b>急峻な谷</b>に位置するはず（地形性降水）。
      標高や傾斜と相関するはず</li>
  <li><b>課題 Z</b>: 標高 DEM (DoBoX) を取得し、Top10 観測所の代理座標で
      標高・傾斜を抽出。最大時間/日合計と散布図で相関を取る</li>
  </ul>
</li>
</ol>"""),
]

html = render_lesson(
    num=6,
    title="2024-07-01 豪雨日 — 雨域セルの空間 - 時間進行を読む",
    tags=["v2-rewrite", "リテラシ", "時空間", "空間統計", "属性結合"],
    time="120分",
    level="リテラシ",
    data_label='2データ結合: '
               '<a href="https://hiroshima-dobox.jp/datasets/1275">#1275</a> 雨量10分値 (resource 94500) + '
               '<a href="https://hiroshima-dobox.jp/datasets/1279">#1279</a> 監視カメラ',
    sections=sections,
    script_filename="lessons/L06_july1_heatmap.py",
)
out = LESSONS / "L06_july1_heatmap.html"
out.write_text(html, encoding="utf-8")
print(f"\nsaved: {out}")
print(f"  PNGs: 5 generated in {ASSETS}")
