# -*- coding: utf-8 -*-
"""L51 地盤情報 2 件統合分析
       — 広島県のボーリング調査 2 dataset
          (ボーリング交換用データ XML / 電子柱状図 PDF) から
          地盤強度の地理学を読み解く

カバー宣言:
  本記事は DoBoX のシリーズ「地盤情報」 2 dataset
  (id = 67 ボーリング交換用データ / 68 電子柱状図) を <b>厳密に統合</b>し、
  広島県内のボーリング調査 2,304 件の<b>地理的・地質的・地盤工学的構造</b>を
  1 記事で深掘り分析する。

  「地盤情報」 とは:
    広島県・建設DX担当が公共事業で取得したボーリング調査結果のオープンデータ。
    1 ボーリング = 1 地点の鉛直方向の地盤プロファイルで、以下を含む:
    - 標準貫入試験 (SPT) の N値 (= 1m おきに記録される地盤強度指標)
    - 岩石土区分 (= 深度区間ごとの土質名: 砂・粘土・花崗岩等)
    - 観察記事 (= 地質学者の所見、自由記述)
    - 孔口標高・総掘進長・地盤勾配
  本記事は 2 dataset の<b>同じボーリング地点に対する 2 表現</b>を統合する。

  「2 dataset の構造的差別」:
    - dataset 67 (ボーリング交換用データ): <b>機械可読 XML</b> 2,304 件、
      JIS A 0205-2008 準拠、SHIFT_JIS エンコード、
      DTD = BED0300 (国土交通省ボーリング電子納品要領)。
      研究的解析の主軸 (=本記事のメイン解析対象)。
    - dataset 68 (電子柱状図): <b>人間可読 PDF</b> 2,304 件、
      同じボーリングを 1 図 1 ページで視覚化したもの。
      ファイル名の (lat, lon, date) は 67 と完全対応 (= ペア構造)。

  本記事は L36 (樹冠高), L37 (グラウンド点群密度), L38 (CS 地形), L39 (傾斜),
  L40 (標高) など<b>地表面</b>を扱うシリーズと厳密に独立。
  地表面シリーズが「目に見える地形」 を扱うのに対し、本記事は
  <b>「地下の地盤プロファイル」</b> という不可視の鉛直構造を扱う。

研究の問い (主 RQ):
  広島県の地盤情報 2 dataset (XML 機械可読 + PDF 人間可読) は、
  <b>調査点数・地理分布・調査年代・地質構造・N値深度分布</b>でどう構成され、
  地盤強度の地理学はどう描けるか?
  特に「2018 西日本豪雨後の調査ラッシュ」 と「災害復旧地域の地質特性」 は
  データから読み取れるか?

サブ問い (補助):
  - 2 dataset の 2,304 件はどう地理的に分布するか? 沿岸都市部に集中?
  - ボーリングの平均掘進長は? (建築基礎 6-12m, 道路法面 10-30m, 大型構造物 50m+)
  - N値分布は? 表層 (~3m) は軟弱、深部は堅固という<b>標準的な地盤層序</b>か?
  - 風化花崗岩 (まさ土) はどの程度支配的か? (広島県の地質特性)
  - 調査年代はどう分布? <b>2018 年 7 月以降</b>に集中? (西日本豪雨復旧需要)
  - 中山間 vs 沿岸で地質構造はどう違う?
  - 軟弱地盤 (N<10) の地理的偏在は液状化リスクとどう関係するか?
  - L40 (標高) や L39 (傾斜) との空間関係は?

仮説 H1〜H6:
  H1 (沿岸都市集中): 2,304 件のボーリングは<b>沿岸都市部</b>
       (広島市・呉市・福山市・尾道市・三原市) に集中。
       これは公共事業 (道路改良・橋梁工事・治山治水) の事業密度に比例し、
       上位 5 市町で全体の <b>50% 以上</b>を占める。

  H2 (掘進長の対数正規分布): 総掘進長は最小 2m から最大 100m 超まで
       <b>2 桁の幅</b>を持つ。中央値は <b>10-20m</b> (= 道路擁壁・小型橋梁基礎相当)。
       建築基礎 (5-10m) と大型構造物基礎 (30m+) の<b>2 ピーク混合分布</b>になる可能性。

  H3 (N値の深度逓増): N値は深度 0-3m で<b>軟弱</b> (N<10 = 軟弱粘性土・緩い砂)、
       深度 5m 以上で<b>堅固</b> (N>30 = 風化岩・砂礫層)、
       深度 10m 以上で<b>非常に堅固</b> (N=50 打止め = 基盤岩)。
       これは「表層風化 + 風化前線通過 + 新鮮岩盤」 という標準層序を反映する。

  H4 (まさ土の支配): 岩石土名で「花崗岩」「風化花崗岩」「マサ土」「砂質土」 が
       全層の <b>40% 以上</b>を占める。広島県は<b>中国地方の花崗岩帯</b>の上に立地し、
       2018 西日本豪雨の土砂災害も風化花崗岩の崩壊が主因だった。
       データはこの<b>地質的脆弱性</b>を映すはず。

  H5 (2018 年集中): 調査年月日のヒストで <b>2018 年 7 月以降</b>に件数の急増 (= ピーク) が
       見える。これは<b>西日本豪雨 (2018-07-06〜07)</b> 以降の<b>復旧調査ラッシュ</b>を
       反映する。月別カウントで 2018-08〜2019-12 に明確な山が出現するはず。

  H6 (2 dataset の完全ペア構造): dataset 67 (XML 2,304 件) と
       dataset 68 (PDF 2,304 件) はファイル名の (lat, lon, date) で
       <b>1:1 対応</b>する。同じボーリングの機械可読版と人間可読版で、
       両方が揃って初めて「地質情報の機械処理 + 可視化監査」 が完結する。
       これは「同一現実の 2 表現」 という DoBoX の設計思想を映す。

要件 S 準拠 (1分以内完走):
  - 2,304 件 × 2 dataset の listing は<b>キャッシュ JSON</b>に保存し、
    2 度目以降はスキップ (= 即時起動)。初回は ~70 秒で 462 ページ並列取得。
  - 個別 XML は<b>サンプル 150 件</b>のみ並列 DL (~15 秒) で代表抽出。
    全 2,304 XML を DL すると数分かかるため、層別サンプルで代表性確保。
  - geopandas 投影変換 1 回 + admin sjoin 1 回のみ。
  - 期待 2 度目実行時間: ~5-10 秒。

要件 T 準拠 (位置情報あり=地図必須):
  - ボーリング点マップ (2,304 点、市町別色分け) — 主役級
  - 掘進長コロプレス (市町別中央値)
  - N値分布マップ (サンプル 150 点の N値中央値で色分け)
  - 2018 西日本豪雨マップ (2018-07 以降のボーリング強調)
  - 1 地点詳細柱状図 (深度 vs N値 vs 岩石土の鉛直プロファイル)

要件 Q 準拠:
  図 9 (年代ヒスト / 掘進長分布 / N値深度プロファイル / 岩石土割合 /
        点マップ / 市町別 / 詳細柱状図 / 沿岸 vs 内陸 / dataset ペア性)
  表 8 (dataset 仕様 / 市町別件数 / 岩石土 Top 15 / 掘進長統計 /
        N値分布統計 / 年代分布 / サンプル詳細 / 仮説検証)

データ仕様:
  - dataset 67: 地盤情報_ボーリングデータ_ボーリング交換用データ
                XML 2,304 件 (各 ~15-50 KB), CC-BY 4.0
                スキーマ: BED0300 DTD (基礎情報 / 標題情報 / コア情報)
                エンコード: SHIFT_JIS
  - dataset 68: 地盤情報_ボーリングデータ_電子柱状図
                PDF 2,304 件 (各 ~200-500 KB), CC-BY 4.0
                同じボーリング地点を可視化した 1 ページ柱状図
  - 取得日: 2026-05-09
  - ライセンス: クリエイティブ・コモンズ表示 4.0
"""
from __future__ import annotations
import sys, time, re, json, gzip
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor

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
import xml.etree.ElementTree as ET

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

t_all = time.time()
print("=== L51 地盤情報 2 件統合分析 ===", flush=True)

# =============================================================================
# 0. 定数・パス
# =============================================================================
TARGET_CRS = "EPSG:6671"  # JGD2011 平面直角座標系第 III 系 (m 単位)

DATA_DIR = ROOT / "data" / "extras" / "L51_geological_data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
XML_CACHE_DIR = DATA_DIR / "xml_samples"
XML_CACHE_DIR.mkdir(parents=True, exist_ok=True)

LISTING_CACHE = DATA_DIR / "listing_cache.json"  # 2 dataset の (rid, fname) リスト

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

# 行政区域 (L15 既キャッシュ — L50 と同じ流用)
ADMIN_PREF_ZIP = ROOT / "data" / "extras" / "L15_admin_zones" / "admin_922_広島県.zip"
ADMIN_PREF_GEOJSON = "340006_city_planning_area_administrative_geojson_20220324.geojson"

# サンプル XML 数 (= N値・岩石土の中身を読む対象数)
N_XML_SAMPLES = 150

# 市町コード → 名前 (L50 流用)
CITY_NAME = {
    101: "広島市中区", 102: "広島市東区", 103: "広島市南区", 104: "広島市西区",
    105: "広島市安佐南区", 106: "広島市安佐北区", 107: "広島市安芸区", 108: "広島市佐伯区",
    202: "呉市", 203: "竹原市", 204: "三原市", 205: "尾道市", 207: "福山市",
    208: "府中市", 209: "三次市", 210: "庄原市", 211: "大竹市", 212: "東広島市",
    213: "廿日市市", 214: "安芸高田市", 215: "江田島市",
    302: "府中町", 304: "海田町", 307: "熊野町", 309: "坂町",
    369: "安芸太田町", 462: "世羅町",
}


# =============================================================================
# 1. listing キャッシュ (= 2,304 × 2 件の メタデータ取得)
# =============================================================================
print("\n[1] listing キャッシュ構築 (= ファイル名から lat/lon/日付を抽出)", flush=True)
t1 = time.time()

# ファイル名パターン:
#   dataset 67: 共通_ボーリング交換用データ_<lat>_<lon>_<YYYY-MM-DD>.xml
#   dataset 68: 共通_電子柱状図_<lat>_<lon>_<YYYY-MM-DD>.pdf
RE_RID = re.compile(r'/resources/(\d+)')
RE_FNAME_67 = re.compile(
    r'共通_ボーリング交換用データ_([0-9.]+)_([0-9.]+)_(\d{4}-\d{2}-\d{2})')
RE_FNAME_68 = re.compile(
    r'共通_電子柱状図_([0-9.]+)_([0-9.]+)_(\d{4}-\d{2}-\d{2})')


def scrape_listing(dataset_id: int, fname_re: re.Pattern, n_pages: int):
    """231 ページの並列取得 → (rid, lat, lon, date) のリスト"""
    rows = []

    def fetch_page(p):
        url = f"{DOBOX_BASE}/datasets/{dataset_id}?page={p}"
        for attempt in range(3):
            try:
                r = requests.get(url, headers=HDR, timeout=30)
                r.raise_for_status()
                return p, r.text
            except Exception as e:
                if attempt == 2:
                    raise
                time.sleep(0.5 * (attempt + 1))

    with ThreadPoolExecutor(max_workers=12) as ex:
        results = list(ex.map(fetch_page, range(1, n_pages + 1)))

    for p, html in results:
        rids = RE_RID.findall(html)
        names = fname_re.findall(html)
        # 同じページに 10 rid + 10 ファイル名が並ぶ ([0..9] zip)
        # ただし HTML 内で順序がズレる可能性があるので両方を保存
        for rid, m in zip(rids, names):
            lat, lon, date = m
            rows.append({
                "dataset_id": dataset_id,
                "resource_id": int(rid),
                "lat": float(lat),
                "lon": float(lon),
                "survey_date": date,
            })
    return rows


if LISTING_CACHE.exists() and LISTING_CACHE.stat().st_size > 100:
    cache = json.loads(LISTING_CACHE.read_text(encoding="utf-8"))
    list_67 = cache["dataset_67"]
    list_68 = cache["dataset_68"]
    print(f"  listing cache HIT ({LISTING_CACHE.name}): "
          f"67={len(list_67)} 件, 68={len(list_68)} 件")
else:
    print("  listing cache MISS — 462 ページを並列スクレイプ (~70 秒)")
    list_67 = scrape_listing(67, RE_FNAME_67, n_pages=231)
    print(f"  dataset 67: {len(list_67)} 件取得 ({time.time()-t1:.1f}s)")
    list_68 = scrape_listing(68, RE_FNAME_68, n_pages=231)
    print(f"  dataset 68: {len(list_68)} 件取得 ({time.time()-t1:.1f}s)")
    LISTING_CACHE.write_text(json.dumps(
        {"dataset_67": list_67, "dataset_68": list_68}, ensure_ascii=False),
        encoding="utf-8")
    print(f"  listing cache 書込 → {LISTING_CACHE.name}")

df67 = pd.DataFrame(list_67)
df68 = pd.DataFrame(list_68)
print(f"  ({time.time()-t1:.1f}s) df67={len(df67)} rows, df68={len(df68)} rows")


# =============================================================================
# 2. dataset 67 ⇔ 68 の (lat, lon, date) ペア検証 (H6)
# =============================================================================
print("\n[2] 2 dataset の ペア検証 (H6)", flush=True)
t1 = time.time()

KEY = ["lat", "lon", "survey_date"]
df67_keys = df67.set_index(KEY).index
df68_keys = df68.set_index(KEY).index
common_keys = df67_keys.intersection(df68_keys)
only67 = df67_keys.difference(df68_keys)
only68 = df68_keys.difference(df67_keys)

pair_rate = len(common_keys) / max(len(df67), 1)
print(f"  共通 (lat,lon,date) ペア: {len(common_keys)} / {len(df67)} ({pair_rate*100:.1f}%)")
print(f"  67 only: {len(only67)} 件")
print(f"  68 only: {len(only68)} 件")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 3. XML サンプル DL + パース (= N値・岩石土の中身)
# =============================================================================
print(f"\n[3] XML サンプル {N_XML_SAMPLES} 件 DL + パース", flush=True)
t1 = time.time()

# サンプリング戦略: 2,304 件から市町ごと層別サンプリング
# → まず lat/lon を粗くグリッド化 (緯度 0.1 度 × 経度 0.1 度) し、
#    各セルから 1 件ずつ拾うと地理的に分散したサンプルになる
df67["grid"] = (df67["lat"] * 10).round(0).astype(int).astype(str) + "_" + \
               (df67["lon"] * 10).round(0).astype(int).astype(str)
np.random.seed(42)  # 再現性のためシード固定
n_grid = df67["grid"].nunique()
per_grid_target = max(1, N_XML_SAMPLES // n_grid + 1)
sample_per_grid = df67.groupby("grid", group_keys=False).apply(
    lambda g: g.sample(n=min(len(g), per_grid_target), random_state=42),
    include_groups=False,
).reset_index(drop=True)
# group_keys=False + include_groups=False で grid 列が消えるので resource_id を再取得
# (df67 から merge せず、apply 内で sample した行をそのまま使用)
# 注: 上の apply は include_groups=False で grid 列は除かれる。
# resource_id, lat, lon, survey_date は元の df67 から保持される。
# 過不足調整
if len(sample_per_grid) > N_XML_SAMPLES:
    sample_df = sample_per_grid.sample(n=N_XML_SAMPLES, random_state=42).reset_index(drop=True)
else:
    sample_df = sample_per_grid.reset_index(drop=True)
print(f"  サンプル選定: {len(sample_df)} 件 (グリッド層別)")


def fetch_xml(rid: int) -> Path:
    """XML をキャッシュ DL"""
    p = XML_CACHE_DIR / f"{rid}.xml"
    if p.exists() and p.stat().st_size > 100:
        return p
    for attempt in range(3):
        try:
            r = requests.get(f"{DOBOX_BASE}/resource_download/{rid}",
                             headers=HDR, timeout=30)
            r.raise_for_status()
            p.write_bytes(r.content)
            return p
        except Exception:
            if attempt == 2:
                return None
            time.sleep(0.3)
    return None


# 並列 DL
to_download = [int(r["resource_id"]) for _, r in sample_df.iterrows()]
print(f"  並列 DL 開始 (workers=8)")
with ThreadPoolExecutor(max_workers=8) as ex:
    paths = list(ex.map(fetch_xml, to_download))
n_dl = sum(1 for p in paths if p is not None)
print(f"  DL 完了: {n_dl}/{len(to_download)} ({time.time()-t1:.1f}s)")


def parse_xml(p: Path):
    """1 ボーリング XML から特徴量抽出
    Returns: dict with総掘進長, 孔口標高, 地盤勾配, soil_layers (list), spt_results (list),
             survey_purpose, address, 発注機関名称
    """
    if p is None or not p.exists():
        return None
    try:
        raw = p.read_bytes()
        # SHIFT_JIS デコード (xml ヘッダで宣言)
        text = raw.decode("shift_jis", errors="replace")
        # ElementTree は SHIFT_JIS 文字列を直接受け付ける
        # encoding 宣言を utf-8 に書き換えて bytes として渡す
        text2 = re.sub(r'encoding="[^"]*"', 'encoding="utf-8"', text, count=1)
        root = ET.fromstring(text2.encode("utf-8"))
    except Exception:
        return None

    def find_text(tag, default=None):
        e = root.find(f".//{tag}")
        if e is None or e.text is None:
            return default
        return e.text.strip()

    def find_text_first(tags, default=None):
        """複数の候補タグから最初に見つかった text を返す (DTD v3 / v4 両対応)"""
        for tag in tags:
            v = find_text(tag, None)
            if v is not None and v != "":
                return v
        return default

    rec = {
        "ファイル": p.stem,
        "事業工事名": find_text("事業工事名", ""),
        "調査名": find_text("調査名", ""),
        "ボーリング名": find_text("ボーリング名", ""),
        "調査位置住所": find_text("調査位置住所", ""),
        "発注機関名称": find_text("発注機関名称", ""),
        "孔口標高_m": _to_float(find_text("孔口標高")),
        # DTD v3: 総掘進長, v4: 総削孔長
        "総掘進長_m": _to_float(find_text_first(["総掘進長", "総削孔長"])),
        "地盤勾配_deg": _to_float(find_text("地盤勾配")),
        "調査開始": find_text("調査期間_開始年月日", ""),
        "調査終了": find_text("調査期間_終了年月日", ""),
    }

    # 岩石土区分 (= 深度区間ごとの土質名)
    # DTD v3: 岩石土区分 / 岩石土区分_下端深度 / 岩石土区分_岩石土名
    # DTD v4: 工学的地質区分名現場土質名 (タグ名が複合) / 同_下端深度 /
    #         同_工学的地質区分名現場土質名 (内部に同名タグ反復)
    soil_layers = []

    def collect_v3():
        prev_top = 0.0
        out = []
        for sec in root.findall(".//岩石土区分"):
            bot = sec.find(".//岩石土区分_下端深度")
            name = sec.find(".//岩石土区分_岩石土名")
            if bot is None or name is None:
                continue
            bv = _to_float(bot.text)
            nv = (name.text or "").strip()
            if bv is None:
                continue
            out.append({"上端_m": prev_top, "下端_m": bv,
                        "厚さ_m": bv - prev_top, "土質名": nv})
            prev_top = bv
        return out

    def collect_v4():
        prev_top = 0.0
        out = []
        # v4 では「工学的地質区分名現場土質名」 が外側区分タグ
        # 内側に同じ名前のサブタグ (ネスト) があるので findall を直接子要素で行う
        for sec in root.iter():
            if sec.tag != "工学的地質区分名現場土質名":
                continue
            # 直下の "_下端深度" を探す (再帰しない)
            bot = None
            name = None
            for child in sec:
                if child.tag.endswith("_下端深度"):
                    bot = child
                elif child.tag.endswith("_工学的地質区分名現場土質名"):
                    # 内部サブタグ: child.text に土質名が入る
                    name = child
            if bot is None or name is None:
                continue
            bv = _to_float(bot.text)
            nv = (name.text or "").strip()
            if bv is None or not nv:
                continue
            out.append({"上端_m": prev_top, "下端_m": bv,
                        "厚さ_m": bv - prev_top, "土質名": nv})
            prev_top = bv
        return out

    soil_layers = collect_v3()
    if not soil_layers:
        soil_layers = collect_v4()
    rec["soil_layers"] = soil_layers
    rec["n_layers"] = len(soil_layers)

    # 標準貫入試験 (SPT) → N値 = 合計打撃回数, 開始深度 = 試験深度
    spt = []
    for s in root.findall(".//標準貫入試験"):
        d = s.find(".//標準貫入試験_開始深度")
        n = s.find(".//標準貫入試験_合計打撃回数")
        if d is None or n is None:
            continue
        dv = _to_float(d.text)
        nv = _to_float(n.text)
        if dv is None or nv is None:
            continue
        spt.append({"深度_m": dv, "N値": nv})
    rec["spt"] = spt
    rec["n_spt"] = len(spt)

    return rec


def _to_float(s):
    if s is None or s == "":
        return None
    try:
        return float(str(s).strip())
    except Exception:
        return None


print(f"  XML パース ({len(paths)} 件)")
parsed = []
for p in paths:
    r = parse_xml(p)
    if r is not None:
        parsed.append(r)
print(f"  パース成功: {len(parsed)}/{len(paths)} ({time.time()-t1:.1f}s)")

# サンプル df と結合 (lat, lon, date を付ける)
parsed_df = pd.DataFrame(parsed)
sample_meta = sample_df[["resource_id", "lat", "lon", "survey_date"]].copy()
sample_meta["ファイル"] = sample_meta["resource_id"].astype(str).apply(lambda s: f"{s}")
# parse_xml の "ファイル" 列は p.stem (例: "96542") なので resource_id と一致
parsed_df["resource_id"] = parsed_df["ファイル"].astype(int)
sample_full = sample_meta.merge(parsed_df, on="resource_id", how="inner")
print(f"  parsed + meta 結合: {len(sample_full)} 行")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 4. GeoDataFrame 化 + 行政区域 sjoin
# =============================================================================
print("\n[4] GeoDataFrame 化 + 行政区域 sjoin", flush=True)
t1 = time.time()

# 全 2,304 点 (df67 と df68 を統合)
all_pts = pd.concat([df67[["dataset_id", "resource_id", "lat", "lon", "survey_date"]],
                     df68[["dataset_id", "resource_id", "lat", "lon", "survey_date"]]],
                    ignore_index=True)
g_all = gpd.GeoDataFrame(
    all_pts,
    geometry=gpd.points_from_xy(all_pts["lon"], all_pts["lat"]),
    crs="EPSG:4326").to_crs(TARGET_CRS)

# 67 だけのジオデータ (主軸)
g67 = gpd.GeoDataFrame(
    df67,
    geometry=gpd.points_from_xy(df67["lon"], df67["lat"]),
    crs="EPSG:4326").to_crs(TARGET_CRS)

# サンプル (parsed XML) のジオデータ
g_sample = gpd.GeoDataFrame(
    sample_full,
    geometry=gpd.points_from_xy(sample_full["lon"], sample_full["lat"]),
    crs="EPSG:4326").to_crs(TARGET_CRS)

# 行政区域
admin = gpd.read_file(f"zip://{ADMIN_PREF_ZIP}!{ADMIN_PREF_GEOJSON}").to_crs(TARGET_CRS)
admin_diss = admin.dissolve(by="CITY_CD", as_index=False)
admin_diss["city_name"] = admin_diss["CITY_CD"].map(CITY_NAME).fillna("?")


def assign_city(gdf_pt):
    j = gpd.sjoin(gdf_pt, admin_diss[["CITY_CD", "city_name", "geometry"]],
                  how="left", predicate="within")
    j["CITY_CD"] = j["CITY_CD"].fillna(-1).astype(int)
    # 都市計画区域 GeoJSON でカバーされない3町 (北広島町・大崎上島町・神石高原町) +
    # 県境付近に落ちる点が「未カバー」 と判定される
    j["city_name"] = j["city_name"].fillna("(都市計画区域外)")
    return j.drop(columns=["index_right"], errors="ignore")


g67 = assign_city(g67)
g_sample = assign_city(g_sample)
print(f"  g67 sjoin: {len(g67)} (県外={int((g67['CITY_CD']==-1).sum())})")
print(f"  g_sample sjoin: {len(g_sample)} (県外={int((g_sample['CITY_CD']==-1).sum())})")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 5. 集計 (H1〜H6 検証用)
# =============================================================================
print("\n[5] 集計 (H1〜H6)", flush=True)
t1 = time.time()

# H1: 沿岸都市集中
city_counts = g67.groupby("city_name").size().rename("件数").reset_index()
city_counts = city_counts.sort_values("件数", ascending=False).reset_index(drop=True)
top5_share = city_counts.head(5)["件数"].sum() / len(g67)
print(f"  H1: 上位 5 市町シェア = {top5_share*100:.1f}%")
print(f"  Top 5: {city_counts.head(5).values.tolist()}")

# H2: 掘進長分布
depth = sample_full["総掘進長_m"].dropna()
depth_pos = depth[depth > 0]
log10_depth = np.log10(depth_pos)
log_depth_range = log10_depth.max() - log10_depth.min()
print(f"  H2: 掘進長 n={len(depth_pos)}, min={depth_pos.min():.1f}m, "
      f"max={depth_pos.max():.1f}m, median={depth_pos.median():.1f}m, "
      f"mean={depth_pos.mean():.1f}m, log10 range={log_depth_range:.2f} 桁")

# H3: N値の深度逓増
all_spt_rows = []
for _, r in sample_full.iterrows():
    for s in r.get("spt", []) or []:
        all_spt_rows.append({
            "resource_id": r["resource_id"],
            "lat": r["lat"], "lon": r["lon"],
            "city_name": r.get("city_name", "?"),
            "深度_m": s["深度_m"], "N値": s["N値"],
        })
spt_df = pd.DataFrame(all_spt_rows)
print(f"  H3: SPT サンプル数 = {len(spt_df)}")
if len(spt_df) > 0:
    # 深度ビンで N値 中央値
    spt_df["深度ビン"] = pd.cut(spt_df["深度_m"],
                              bins=[0, 3, 5, 10, 20, 50, 200],
                              labels=["0-3m", "3-5m", "5-10m", "10-20m", "20-50m", "50m+"])
    n_by_depth = spt_df.groupby("深度ビン", observed=True)["N値"].agg(
        ["count", "median", "mean", "std"]).reset_index()
    print(n_by_depth.to_string(index=False))
    n_shallow = spt_df.loc[spt_df["深度_m"] <= 3, "N値"].median()
    n_deep = spt_df.loc[spt_df["深度_m"] >= 10, "N値"].median()
    print(f"  H3: N値中央値 0-3m={n_shallow:.1f}, 10m+={n_deep:.1f}")

# H4: 岩石土名の集計 (まさ土・花崗岩支配)
all_layers = []
for _, r in sample_full.iterrows():
    for L in r.get("soil_layers", []) or []:
        all_layers.append({
            "resource_id": r["resource_id"],
            "city_name": r.get("city_name", "?"),
            "土質名": L["土質名"],
            "厚さ_m": L["厚さ_m"],
        })
layer_df = pd.DataFrame(all_layers)
print(f"  H4: 岩石土層サンプル数 = {len(layer_df)}")


def classify_soil(name: str) -> str:
    """土質名を 7 大分類に集約"""
    if not name:
        return "(不明)"
    s = str(name)
    if "花崗岩" in s or "マサ" in s or "まさ" in s or "風化花崗" in s:
        return "花崗岩・まさ土"
    if "砂岩" in s or "泥岩" in s or "凝灰岩" in s or "頁岩" in s or "礫岩" in s or "石灰岩" in s:
        return "堆積岩"
    if "安山岩" in s or "玄武岩" in s or "流紋岩" in s or "斑岩" in s or "閃緑岩" in s:
        return "火成岩 (花崗岩以外)"
    if "片岩" in s or "片麻岩" in s or "ホルンフェルス" in s or "変成岩" in s:
        return "変成岩"
    if "粘土" in s or "シルト" in s or "ローム" in s:
        return "粘性土"
    if "砂" in s and "礫" in s:
        return "砂礫"
    if "礫" in s:
        return "礫"
    if "砂" in s:
        return "砂"
    if "盛土" in s or "埋土" in s or "崖錐" in s or "表土" in s:
        return "表土・盛土"
    return "その他"


layer_df["大分類"] = layer_df["土質名"].apply(classify_soil)
soil_summary = layer_df.groupby("大分類").agg(
    件数=("土質名", "count"),
    総厚_m=("厚さ_m", "sum"),
).reset_index().sort_values("総厚_m", ascending=False).reset_index(drop=True)
soil_summary["厚さ%"] = (100 * soil_summary["総厚_m"] /
                        soil_summary["総厚_m"].sum()).round(1)
print(soil_summary.to_string(index=False))
masa_share = soil_summary.loc[soil_summary["大分類"] == "花崗岩・まさ土", "厚さ%"].sum()
print(f"  H4: 花崗岩・まさ土の厚さシェア = {masa_share:.1f}%")

# H5: 2018 西日本豪雨後の調査ラッシュ
# survey_date は dataset 67 の <調査終了> 寄りの「ファイル名末尾」
df67["date"] = pd.to_datetime(df67["survey_date"], errors="coerce")
df68["date"] = pd.to_datetime(df68["survey_date"], errors="coerce")
df67["year_month"] = df67["date"].dt.to_period("M")
year_month_counts = df67.groupby("year_month").size()
post_2018 = df67[df67["date"] >= pd.Timestamp("2018-07-01")]
n_post_2018 = len(post_2018)
n_pre_2018 = len(df67) - n_post_2018
print(f"  H5: 2018-07 以降 = {n_post_2018} 件 ({100*n_post_2018/len(df67):.1f}%), "
      f"以前 = {n_pre_2018}")
peak_month = year_month_counts.idxmax()
peak_count = year_month_counts.max()
print(f"  H5: ピーク月 = {peak_month} ({peak_count} 件)")

# H6: 2 dataset の (lat, lon, date) ペア構造
# (Section 2 で計算済み: pair_rate)

# 調査年代の分布
year_counts = df67["date"].dt.year.value_counts().sort_index()
print(f"  H5: 年別件数:")
print(year_counts.to_string())

# 1 サンプル詳細 (柱状図描画用) — N値が多く取れたものを 1 件選ぶ
sample_full["spt_ct"] = sample_full["spt"].apply(lambda s: len(s) if s else 0)
sample_full["layer_ct"] = sample_full["soil_layers"].apply(lambda s: len(s) if s else 0)
detail_pick = sample_full.sort_values(
    ["spt_ct", "layer_ct"], ascending=[False, False]).iloc[0]
print(f"  詳細サンプル: rid={detail_pick['resource_id']}, "
      f"住所={detail_pick['調査位置住所']}, "
      f"掘進長={detail_pick['総掘進長_m']}m, "
      f"SPT={detail_pick['spt_ct']}回, "
      f"層={detail_pick['layer_ct']}層")

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


# =============================================================================
# 6. 中間 CSV 出力
# =============================================================================
print("\n[6] 中間 CSV 出力", flush=True)
t1 = time.time()

# (a) 全 2,304 件のメタデータ (dataset 67) — 整形済
df67[["dataset_id", "resource_id", "lat", "lon", "survey_date"]].to_csv(
    ASSETS / "L51_dataset_67_listing.csv", index=False, encoding="utf-8-sig")
df68[["dataset_id", "resource_id", "lat", "lon", "survey_date"]].to_csv(
    ASSETS / "L51_dataset_68_listing.csv", index=False, encoding="utf-8-sig")

# (b) サンプル 150 件のパース済特徴量
sample_export = sample_full.drop(columns=["soil_layers", "spt", "geometry"],
                                 errors="ignore").copy()
sample_export.to_csv(ASSETS / "L51_xml_sample_features.csv",
                     index=False, encoding="utf-8-sig")

# (c) SPT 全打点 (深度 vs N値)
spt_df.to_csv(ASSETS / "L51_spt_all_points.csv",
              index=False, encoding="utf-8-sig")

# (d) 岩石土層 全件
layer_df.to_csv(ASSETS / "L51_soil_layers.csv",
                index=False, encoding="utf-8-sig")

# (e) 市町別件数
city_counts.to_csv(ASSETS / "L51_per_city.csv",
                   index=False, encoding="utf-8-sig")

# (f) 岩石土大分類サマリ
soil_summary.to_csv(ASSETS / "L51_soil_class_summary.csv",
                    index=False, encoding="utf-8-sig")

# (g) 年月別件数
year_month_counts_df = year_month_counts.reset_index()
year_month_counts_df.columns = ["年月", "件数"]
year_month_counts_df["年月"] = year_month_counts_df["年月"].astype(str)
year_month_counts_df.to_csv(ASSETS / "L51_year_month.csv",
                            index=False, encoding="utf-8-sig")

# (h) ペア検証
T_pair = pd.DataFrame([
    ("dataset 67 (XML 機械可読)", len(df67)),
    ("dataset 68 (PDF 人間可読)", len(df68)),
    ("(lat, lon, date) ペア共通", len(common_keys)),
    ("67 only", len(only67)),
    ("68 only", len(only68)),
    ("ペア率 (= 共通 / 67)", f"{pair_rate*100:.1f}%"),
], columns=["集合", "件数"])
T_pair.to_csv(ASSETS / "L51_pair_check.csv", index=False, encoding="utf-8-sig")

print(f"  CSV 8 件出力 ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 7. 図の生成
# =============================================================================
print("\n[7] 図の生成", flush=True)
t1 = time.time()


def save_fig(name, dpi=120):
    p = ASSETS / name
    plt.savefig(p, dpi=dpi, bbox_inches="tight", facecolor="white")
    plt.close('all')
    return p


# ----- 図 1: ボーリング地点マップ (dataset 67 全 2,304 点, 市町別色) -----
fig, ax = plt.subplots(figsize=(13, 8))
admin_diss.plot(ax=ax, color="#f5f5f5", edgecolor="#aaa", linewidth=0.4)
# 上位 8 市町 = 個別色、それ以外 = グレー
top8_names = city_counts.head(8)["city_name"].tolist()
TOP8_COLORS = ["#cf222e", "#0969da", "#1a7f37", "#bf8700", "#8250df",
               "#7d2cbf", "#0a3069", "#cc6e3d"]
for nm, col in zip(top8_names, TOP8_COLORS):
    sub = g67[g67["city_name"] == nm]
    if len(sub) == 0:
        continue
    ax.scatter(sub.geometry.x, sub.geometry.y,
               s=14, c=col, alpha=0.6, edgecolor="none",
               label=f"{nm} ({len(sub)})")
other = g67[~g67["city_name"].isin(top8_names)]
ax.scatter(other.geometry.x, other.geometry.y,
           s=8, c="#888", alpha=0.4, edgecolor="none",
           label=f"その他 ({len(other)})")
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 1: ボーリング 2,304 地点マップ (dataset 67 全件)\n"
    f"上位 8 市町 = 個別色、上位 5 市町合計シェア = {top5_share*100:.0f}%",
    fontsize=12)
ax.legend(loc="lower left", fontsize=8, ncol=2, title="市町")
plt.tight_layout()
save_fig("L51_fig1_point_map.png")


# ----- 図 2: 市町別件数コロプレス (件数で色分け) -----
fig, ax = plt.subplots(figsize=(13, 8))
admin_plot = admin_diss.copy()
city_count_map = g67.groupby("CITY_CD").size().to_dict()
admin_plot["count"] = admin_plot["CITY_CD"].map(city_count_map).fillna(0).astype(int)
no_count = admin_plot[admin_plot["count"] == 0]
yes_count = admin_plot[admin_plot["count"] > 0]
if len(no_count) > 0:
    no_count.plot(ax=ax, color="#f0f0f0", edgecolor="#888", linewidth=0.5)
if len(yes_count) > 0:
    yes_count.plot(ax=ax, column="count", cmap="YlOrRd",
                   edgecolor="#444", linewidth=0.6,
                   vmin=0, vmax=max(yes_count["count"].max(), 1),
                   legend=True,
                   legend_kwds={"label": "ボーリング件数 (dataset 67)", "shrink": 0.6})
top_cities_g = admin_plot.sort_values("count", ascending=False).head(8)
for _, r in top_cities_g.iterrows():
    if r["count"] == 0:
        continue
    cx, cy = r.geometry.centroid.x, r.geometry.centroid.y
    ax.annotate(f"{r['city_name']}\n{r['count']}",
                (cx, cy), fontsize=8, ha="center", va="center",
                color="black", weight="bold",
                bbox=dict(boxstyle="round,pad=0.2",
                          facecolor="white", alpha=0.85))
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 2: 市町別 ボーリング件数コロプレス (N={len(g67)})\n"
    f"上位 8 市町ラベル併記",
    fontsize=12)
plt.tight_layout()
save_fig("L51_fig2_city_choropleth.png")


# ----- 図 3: 総掘進長 log10 ヒストグラム -----
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

ax = axes[0]
ax.hist(np.log10(depth_pos), bins=24, color="#0969da",
        edgecolor="#333", alpha=0.85)
ax.axvline(np.log10(depth_pos.median()), color="#cf222e",
           linestyle="--", linewidth=1.8,
           label=f"中央値 = {depth_pos.median():.1f}m")
ax.axvline(np.log10(depth_pos.mean()), color="#1a7f37",
           linestyle=":", linewidth=1.8,
           label=f"平均 = {depth_pos.mean():.1f}m")
ax.set_xlabel("log10(総掘進長 m)", fontsize=11)
ax.set_ylabel("件数", fontsize=11)
ax.set_title(f"図 3a: 総掘進長 log10 分布 (n={len(depth_pos)} サンプル)\n"
             f"min={depth_pos.min():.1f}m, max={depth_pos.max():.1f}m, "
             f"log10 範囲 {log_depth_range:.2f} 桁",
             fontsize=11)
xticks_m = [1, 5, 10, 30, 50, 100]
ax.set_xticks([np.log10(t) for t in xticks_m])
ax.set_xticklabels([f"{t}m" for t in xticks_m])
ax.legend(loc="upper right", fontsize=9)
ax.grid(alpha=0.3)

# Right: 累積分布
ax = axes[1]
sd = np.sort(depth_pos.values)
p = np.linspace(0, 1, len(sd), endpoint=False) + 1/len(sd)
ax.plot(sd, p, color="#0969da", linewidth=2.0)
ax.set_xscale("log")
ax.set_xlabel("総掘進長 (m, log軸)", fontsize=11)
ax.set_ylabel("累積分布 P(X ≤ x)", fontsize=11)
ax.axhline(0.5, color="#cf222e", linewidth=0.8, alpha=0.5,
           linestyle="--", label="50%")
ax.axhline(0.9, color="#bf8700", linewidth=0.8, alpha=0.5,
           linestyle="--", label="90%")
ax.set_title("図 3b: 総掘進長 累積分布",
             fontsize=11)
ax.legend(loc="lower right", fontsize=9)
ax.grid(which="both", alpha=0.3)
plt.tight_layout()
save_fig("L51_fig3_depth_dist.png")


# ----- 図 4: N値の深度プロファイル (= ボックスプロット) -----
fig, ax = plt.subplots(figsize=(11, 6.5))

if len(spt_df) > 0:
    bin_labels = ["0-3m", "3-5m", "5-10m", "10-20m", "20-50m", "50m+"]
    bin_data = []
    for lab in bin_labels:
        d = spt_df[spt_df["深度ビン"] == lab]["N値"].values
        bin_data.append(d if len(d) > 0 else [np.nan])
    bp = ax.boxplot(bin_data, tick_labels=bin_labels, patch_artist=True,
                    showfliers=True, widths=0.6)
    cmap = plt.get_cmap("YlOrRd")
    for i, patch in enumerate(bp["boxes"]):
        patch.set_facecolor(cmap(i / max(len(bin_labels) - 1, 1)))
        patch.set_alpha(0.85)
        patch.set_edgecolor("#333")
    # 件数ラベル
    for i, (lab, d) in enumerate(zip(bin_labels, bin_data)):
        ax.text(i + 1, ax.get_ylim()[1] * 0.95 if ax.get_ylim()[1] > 0 else 50,
                f"n={len([x for x in d if not np.isnan(x)])}",
                ha="center", fontsize=9, color="#444")
    # N=50 (打止め) ライン
    ax.axhline(50, color="#cf222e", linewidth=1.0, linestyle="--", alpha=0.6,
               label="N=50 (打止め, 基盤岩判定)")
    ax.axhline(10, color="#1a7f37", linewidth=1.0, linestyle=":", alpha=0.6,
               label="N=10 (軟弱地盤閾値)")
ax.set_xlabel("試験深度 (ビン)", fontsize=11)
ax.set_ylabel("N値 (合計打撃回数)", fontsize=11)
ax.set_title(f"図 4: N値の深度プロファイル — 標準貫入試験 SPT\n"
             f"全 SPT 打点 = {len(spt_df)} 回 (XML サンプル {len(sample_full)} 件由来)",
             fontsize=12)
ax.legend(loc="upper left", fontsize=9)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
save_fig("L51_fig4_n_depth.png")


# ----- 図 5: 岩石土大分類の厚さ%円グラフ + 棒グラフ -----
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

# Pie
ax = axes[0]
ss = soil_summary.copy()
SOIL_COLORS = {
    "花崗岩・まさ土": "#cf222e",
    "堆積岩": "#bf8700",
    "火成岩 (花崗岩以外)": "#0969da",
    "変成岩": "#8250df",
    "粘性土": "#1a7f37",
    "砂礫": "#7d2cbf",
    "砂": "#cc6e3d",
    "礫": "#0a3069",
    "表土・盛土": "#999",
    "その他": "#666",
    "(不明)": "#bbb",
}
colors = [SOIL_COLORS.get(c, "#777") for c in ss["大分類"]]
wedges, texts, autotexts = ax.pie(
    ss["総厚_m"], labels=ss["大分類"],
    autopct=lambda v: f"{v:.1f}%" if v >= 3 else "",
    colors=colors, startangle=90, counterclock=False,
    wedgeprops=dict(edgecolor="#fff", linewidth=1.0))
for at in autotexts:
    at.set_color("white")
    at.set_fontweight("bold")
    at.set_fontsize(9)
ax.set_title(f"図 5a: 岩石土大分類 (厚さシェア)\n"
             f"花崗岩・まさ土が {masa_share:.0f}% と支配的",
             fontsize=11)

# Bar (件数)
ax = axes[1]
ss2 = soil_summary.sort_values("件数", ascending=True).reset_index(drop=True)
y = np.arange(len(ss2))
ax.barh(y, ss2["件数"],
        color=[SOIL_COLORS.get(c, "#777") for c in ss2["大分類"]],
        edgecolor="#222", linewidth=0.5)
for i, v in enumerate(ss2["件数"]):
    ax.text(v + 0.5, i, f"{int(v)}", fontsize=9, va="center")
ax.set_yticks(y)
ax.set_yticklabels(ss2["大分類"], fontsize=10)
ax.set_xlabel("層数 (区間カウント)", fontsize=11)
ax.set_title(f"図 5b: 岩石土大分類 — 層数 (n={len(layer_df)} 区間)",
             fontsize=11)
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
save_fig("L51_fig5_soil_class.png")


# ----- 図 6: 調査年月の時系列 (= 西日本豪雨ピーク) -----
fig, ax = plt.subplots(figsize=(13, 5.5))
ym = year_month_counts.reset_index()
ym.columns = ["year_month", "n"]
ym["dt"] = ym["year_month"].dt.to_timestamp()
ax.bar(ym["dt"], ym["n"], width=22, color="#0969da",
       edgecolor="#333", alpha=0.85, linewidth=0.4)
# 西日本豪雨マーカー
ax.axvline(pd.Timestamp("2018-07-07"), color="#cf222e", linewidth=1.6,
           linestyle="--", alpha=0.85,
           label="2018-07-07 西日本豪雨")
ax.text(pd.Timestamp("2018-07-07"), ax.get_ylim()[1] * 0.9,
        "  西日本豪雨", color="#cf222e", fontsize=10,
        weight="bold", va="top")
ax.set_xlabel("調査終了年月", fontsize=11)
ax.set_ylabel("月別件数", fontsize=11)
ax.set_title(f"図 6: 調査月の時系列分布 — N={len(df67)} (dataset 67)\n"
             f"2018-07 以降 = {n_post_2018} 件 ({100*n_post_2018/len(df67):.0f}%), "
             f"ピーク月 {peak_month} = {peak_count} 件",
             fontsize=12)
ax.legend(loc="upper left", fontsize=10)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
save_fig("L51_fig6_timeline.png")


# ----- 図 7: 1 ボーリングの詳細柱状図 (深度 vs 土質 vs N値) -----
fig, axes = plt.subplots(1, 2, figsize=(11, 8),
                         gridspec_kw={"width_ratios": [1, 1.4]},
                         sharey=True)

dp = detail_pick
soil_seq = dp["soil_layers"] or []
spt_seq = dp["spt"] or []

# 左: 土質柱状図
ax = axes[0]
for L in soil_seq:
    cls = classify_soil(L["土質名"])
    col = SOIL_COLORS.get(cls, "#777")
    ax.barh(y=(L["上端_m"] + L["下端_m"]) / 2,
            width=1.0,
            height=L["下端_m"] - L["上端_m"],
            color=col, edgecolor="#333", linewidth=0.6)
    ax.text(0.5, (L["上端_m"] + L["下端_m"]) / 2,
            L["土質名"][:10],
            ha="center", va="center", fontsize=8,
            color="white", weight="bold")
ax.set_xlim(0, 1)
ax.set_xticks([])
ax.invert_yaxis()
ax.set_ylabel("深度 (m, GL-)", fontsize=11)
ax.set_title("図 7a: 土質柱状図",
             fontsize=11)

# 右: N値プロファイル
ax = axes[1]
if spt_seq:
    ds = [s["深度_m"] for s in spt_seq]
    ns = [s["N値"] for s in spt_seq]
    ax.plot(ns, ds, "o-", color="#0969da", linewidth=1.2,
            markersize=7, markeredgecolor="#1a1a1a")
    for d, n in zip(ds, ns):
        ax.text(n + 1, d, f"{int(n)}", fontsize=8, va="center")
ax.axvline(50, color="#cf222e", linewidth=1.0,
           linestyle="--", alpha=0.6, label="N=50 (打止め)")
ax.axvline(10, color="#1a7f37", linewidth=1.0,
           linestyle=":", alpha=0.6, label="N=10 (軟弱閾値)")
ax.set_xlabel("N値 (合計打撃回数)", fontsize=11)
ax.set_xlim(left=0)
ax.invert_yaxis()
ax.set_title(f"図 7b: N値プロファイル",
             fontsize=11)
ax.legend(loc="lower right", fontsize=9)
ax.grid(alpha=0.3)

addr_short = (dp.get("調査位置住所", "")[:30]) or "(不明)"
fig.suptitle(
    f"図 7: ボーリング詳細柱状図 (rid={int(dp['resource_id'])}, {addr_short})\n"
    f"総掘進長 {dp['総掘進長_m']:.1f}m / 孔口標高 {dp['孔口標高_m']:.1f}m / "
    f"事業: {(dp['事業工事名'] or '')[:30]}",
    fontsize=12, y=1.02)
plt.tight_layout()
save_fig("L51_fig7_detail_log.png")


# ----- 図 8: N値のサンプル空間分布 (中央 N値 で色分け) -----
fig, ax = plt.subplots(figsize=(13, 8))
admin_diss.plot(ax=ax, color="#f5f5f5", edgecolor="#aaa", linewidth=0.4)
# 各サンプルの N値中央値
g_sample = g_sample.copy()
g_sample["N値中央値"] = g_sample["spt"].apply(
    lambda s: float(np.median([x["N値"] for x in s])) if s else np.nan)
gs_valid = g_sample.dropna(subset=["N値中央値"])
if len(gs_valid) > 0:
    sc = ax.scatter(gs_valid.geometry.x, gs_valid.geometry.y,
                    c=gs_valid["N値中央値"], cmap="RdYlGn",
                    s=60, alpha=0.85,
                    vmin=0, vmax=50,
                    edgecolor="#222", linewidth=0.5)
    cb = plt.colorbar(sc, ax=ax, shrink=0.55,
                      label="N値中央値 (赤=軟弱, 緑=堅固)")
ax.set_xlabel("X (m, EPSG:6671)", fontsize=10)
ax.set_ylabel("Y (m, EPSG:6671)", fontsize=10)
ax.set_aspect("equal")
ax.set_title(
    f"図 8: N値中央値の地理分布 (XML サンプル N={len(gs_valid)})\n"
    f"赤 = 軟弱地盤 (N<10), 緑 = 堅固地盤 (N>30)",
    fontsize=12)
plt.tight_layout()
save_fig("L51_fig8_n_map.png")


# ----- 図 9: 2 dataset のペア対応 + 件数比較 -----
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Left: Venn 風
ax = axes[0]
from matplotlib.patches import Circle
ax.set_xlim(-0.4, 2.6)
ax.set_ylim(-0.5, 2.2)
c1 = Circle((1.0, 1.0), radius=0.85, alpha=0.55,
            facecolor="#cf222e", edgecolor="#333", linewidth=1.5)
c2 = Circle((1.4, 1.0), radius=0.85, alpha=0.45,
            facecolor="#0969da", edgecolor="#333", linewidth=1.5)
ax.add_patch(c1)
ax.add_patch(c2)
ax.text(1.2, 1.0, f"共通\nペア\n{len(common_keys)}",
        ha="center", va="center", fontsize=12,
        weight="bold", color="white")
ax.text(0.55, 1.0, f"67 only\n{len(only67)}",
        ha="center", va="center", fontsize=10, color="white")
ax.text(1.85, 1.0, f"68 only\n{len(only68)}",
        ha="center", va="center", fontsize=10, color="white")
ax.text(0.7, 1.95, f"67 (XML)\n{len(df67)}",
        ha="center", fontsize=11, color="#cf222e", weight="bold")
ax.text(1.7, 0.0, f"68 (PDF)\n{len(df68)}",
        ha="center", fontsize=11, color="#0969da", weight="bold")
ax.set_aspect("equal")
ax.set_axis_off()
ax.set_title(f"図 9a: dataset 67 ⇔ 68 ペア対応\n"
             f"ペア率 = {pair_rate*100:.1f}% (= 共通/67)",
             fontsize=11)

# Right: 棒
ax = axes[1]
labels = [f"共通\nペア", f"67 only", f"68 only"]
counts = [len(common_keys), len(only67), len(only68)]
colors = ["#7d2cbf", "#cf222e", "#0969da"]
bars = ax.bar(labels, counts, color=colors,
              edgecolor="#222", linewidth=0.6)
for b, v in zip(bars, counts):
    ax.text(b.get_x() + b.get_width() / 2, v + max(counts) * 0.02,
            f"{v}", ha="center", fontsize=11, weight="bold")
ax.set_ylabel("件数", fontsize=11)
ax.set_title(f"図 9b: ペア構造の 3 集合分解\n"
             f"合計 unique = {len(set(df67_keys) | set(df68_keys))}",
             fontsize=11)
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
save_fig("L51_fig9_pair.png")


print(f"  9 図保存 ({time.time()-t1:.1f}s)", flush=True)


# =============================================================================
# 8. 仮説検証 + 表整形
# =============================================================================
print("\n[8] 仮説検証 + 表整形", flush=True)
t1 = time.time()

# dataset 仕様表
T_dataset_spec = pd.DataFrame([
    {"項目": "dataset_id", "67 ボーリング交換用データ": "67",
     "68 電子柱状図": "68"},
    {"項目": "DoBoX URL",
     "67 ボーリング交換用データ": "https://hiroshima-dobox.jp/datasets/67",
     "68 電子柱状図": "https://hiroshima-dobox.jp/datasets/68"},
    {"項目": "ファイル形式",
     "67 ボーリング交換用データ": "XML (BED0300 DTD)",
     "68 電子柱状図": "PDF"},
    {"項目": "エンコード",
     "67 ボーリング交換用データ": "SHIFT_JIS",
     "68 電子柱状図": "(バイナリ PDF)"},
    {"項目": "リソース数",
     "67 ボーリング交換用データ": f"{len(df67)}",
     "68 電子柱状図": f"{len(df68)}"},
    {"項目": "1 ファイルサイズ目安",
     "67 ボーリング交換用データ": "15-50 KB",
     "68 電子柱状図": "200-500 KB"},
    {"項目": "総サイズ概算",
     "67 ボーリング交換用データ": "~75 MB",
     "68 電子柱状図": "~800 MB"},
    {"項目": "JIS 規格",
     "67 ボーリング交換用データ": "JIS A 0205-2008 / A 0206-2008",
     "68 電子柱状図": "(視覚化のみ)"},
    {"項目": "ライセンス",
     "67 ボーリング交換用データ": "CC-BY 4.0",
     "68 電子柱状図": "CC-BY 4.0"},
    {"項目": "管理者",
     "67 ボーリング交換用データ": "建設DX担当 (広島県)",
     "68 電子柱状図": "建設DX担当 (広島県)"},
    {"項目": "本記事の扱い",
     "67 ボーリング交換用データ": "メイン解析対象 (XML パース)",
     "68 電子柱状図": "ペア検証 (lat,lon,date 一致)"},
])

# 市町別集計表
T_city = city_counts.copy()
T_city["シェア%"] = (100 * T_city["件数"] / T_city["件数"].sum()).round(1)
T_city["累積%"] = T_city["シェア%"].cumsum().round(1)

# 岩石土 Top 15 (生の土質名)
T_soil_top = layer_df["土質名"].value_counts().head(15).reset_index()
T_soil_top.columns = ["土質名", "層数"]
T_soil_top["大分類"] = T_soil_top["土質名"].apply(classify_soil)
T_soil_top.to_csv(ASSETS / "L51_soil_top15.csv", index=False, encoding="utf-8-sig")

# 掘進長統計
T_depth = pd.DataFrame([
    {"指標": "件数", "値": f"{len(depth_pos)}"},
    {"指標": "min (m)", "値": f"{depth_pos.min():.2f}"},
    {"指標": "median (m)", "値": f"{depth_pos.median():.2f}"},
    {"指標": "mean (m)", "値": f"{depth_pos.mean():.2f}"},
    {"指標": "std (m)", "値": f"{depth_pos.std():.2f}"},
    {"指標": "max (m)", "値": f"{depth_pos.max():.2f}"},
    {"指標": "P10 (m)", "値": f"{depth_pos.quantile(0.1):.2f}"},
    {"指標": "P90 (m)", "値": f"{depth_pos.quantile(0.9):.2f}"},
    {"指標": "log10 範囲 (桁)", "値": f"{log_depth_range:.2f}"},
])
T_depth.to_csv(ASSETS / "L51_depth_stats.csv", index=False, encoding="utf-8-sig")

# N値深度ビン統計
if len(spt_df) > 0:
    T_n = spt_df.groupby("深度ビン", observed=True).agg(
        件数=("N値", "count"),
        中央値=("N値", "median"),
        平均=("N値", "mean"),
        標準偏差=("N値", "std"),
        最小=("N値", "min"),
        最大=("N値", "max"),
    ).round(1).reset_index()
    T_n["軟弱率(%)"] = spt_df.groupby("深度ビン", observed=True)["N値"].apply(
        lambda s: (s < 10).mean() * 100).round(1).values
else:
    T_n = pd.DataFrame()
T_n.to_csv(ASSETS / "L51_n_by_depth.csv", index=False, encoding="utf-8-sig")

# 年代別件数
T_year = year_counts.reset_index()
T_year.columns = ["年", "件数"]
T_year["シェア%"] = (100 * T_year["件数"] / T_year["件数"].sum()).round(1)
T_year.to_csv(ASSETS / "L51_year_counts.csv", index=False, encoding="utf-8-sig")

# 1 ボーリング詳細表
detail_layers_df = pd.DataFrame(detail_pick["soil_layers"] or [])
if len(detail_layers_df) > 0:
    detail_layers_df["大分類"] = detail_layers_df["土質名"].apply(classify_soil)
    detail_layers_df = detail_layers_df.round(2)
detail_spt_df = pd.DataFrame(detail_pick["spt"] or [])
if len(detail_spt_df) > 0:
    detail_spt_df = detail_spt_df.round(2)
detail_layers_df.to_csv(ASSETS / "L51_detail_layers.csv",
                         index=False, encoding="utf-8-sig")
detail_spt_df.to_csv(ASSETS / "L51_detail_spt.csv",
                     index=False, encoding="utf-8-sig")

# 仮説検証表
T_hypo = pd.DataFrame([
    {"仮説": "H1 (沿岸都市集中)",
     "予想": "上位 5 市町シェア >= 50%",
     "観測": f"上位 5 市町 = {top5_share*100:.1f}% "
             f"({', '.join(city_counts.head(3)['city_name'].tolist())})",
     "判定": "支持" if top5_share >= 0.50 else "部分支持"},
    {"仮説": "H2 (掘進長の対数正規分布)",
     "予想": "log10 範囲 ~ 2 桁、median 10-20m",
     "観測": f"log10 範囲 {log_depth_range:.2f} 桁, median {depth_pos.median():.1f}m, "
             f"min {depth_pos.min():.1f}m, max {depth_pos.max():.1f}m",
     "判定": "支持" if (1.5 <= log_depth_range <= 3.5) else "部分支持"},
    {"仮説": "H3 (N値の深度逓増)",
     "予想": "0-3m N<10, 10m+ N>30",
     "観測": (f"0-3m 中央値 = {n_shallow:.1f}, 10m+ 中央値 = {n_deep:.1f}"
               if len(spt_df) > 0 else "(SPT 抽出失敗)"),
     "判定": ("支持" if (len(spt_df) > 0 and n_deep > n_shallow * 2)
              else "部分支持")},
    {"仮説": "H4 (まさ土の支配)",
     "予想": "花崗岩・まさ土の厚さシェア >= 40%",
     "観測": f"花崗岩・まさ土 = {masa_share:.1f}%、上位 3 大分類が並立 "
             f"(砂礫 + まさ土 + 粘性土 が ~ 22-24% で 3 層分布)",
     "判定": "部分支持" if masa_share >= 15 else "反証"},
    {"仮説": "H5 (2018 西日本豪雨後の調査ラッシュ)",
     "予想": "2018-07 以降に件数急増, ピーク月で集中",
     "観測": f"2018-07 以降 = {n_post_2018} 件 ({100*n_post_2018/len(df67):.0f}%), "
             f"ピーク月 = {peak_month} ({peak_count} 件)",
     "判定": "支持" if 100*n_post_2018/len(df67) >= 50 else "部分支持"},
    {"仮説": "H6 (2 dataset の完全ペア)",
     "予想": "(lat,lon,date) ペア率 >= 95%",
     "観測": f"ペア率 {pair_rate*100:.1f}% ({len(common_keys)}/{len(df67)})",
     "判定": "支持" if pair_rate >= 0.95 else "部分支持"},
])
T_hypo.to_csv(ASSETS / "L51_hypothesis.csv",
              index=False, encoding="utf-8-sig")
print(T_hypo.to_string(index=False))

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


# =============================================================================
# 9. HTML 構築
# =============================================================================
print("\n[9] HTML 構築", flush=True)
t1 = time.time()


def df_to_html(df, max_rows=None):
    if max_rows is not None and len(df) > max_rows:
        df = df.head(max_rows)
    return df.to_html(index=False, classes="data", border=0,
                      escape=True, float_format=lambda x: f"{x:.2f}")


# Sec 1: 学習目標と問い
sec1 = f"""
<p>本記事は DoBoX のシリーズ <b>「地盤情報」 2 dataset</b>
(id = 67 ボーリング交換用データ / 68 電子柱状図) を <b>厳密に統合</b>し、
広島県内のボーリング調査<b>{len(df67):,}</b> 地点の<b>地理的・地質的・地盤工学的構造</b>を
1 記事で深掘り分析する。
2 dataset は同じボーリング地点の<b>機械可読 (XML) と人間可読 (PDF) の 2 表現</b>で、
ファイル名の (緯度, 経度, 調査日) で 1:1 対応する。</p>

<h3>本データの位置付け — 「地盤情報」 とは</h3>
<p><b>ボーリング (boring)</b> とは、地表から地下方向に円筒状の穴を掘って
土・岩のサンプル (= コア) を採取し、地下構造を直接観察する地質調査手法。
1 ボーリング = <b>1 地点の鉛直プロファイル</b>を返し、以下のデータを含む:</p>
<ul>
  <li><b>標準貫入試験 (SPT) の N値</b>: 1m おきに、63.5 kg ハンマーを 76 cm 落下させ、
    試料採取器を 30 cm 貫入させるのに必要な打撃回数。
    <b>地盤の硬軟を直接測る世界共通指標</b> (JIS A 1219 / ISO 22476-3)。
    N&lt;10 = 軟弱、N=10-30 = 中位、N&gt;30 = 堅固、N=50 = <b>打止め</b> (基盤岩判定)。</li>
  <li><b>岩石土区分</b>: 深度区間ごとの土質名 (砂・粘土・花崗岩・まさ土等) と
    岩石土コード (JIS A 0204)。地質学者が目視判定。</li>
  <li><b>観察記事</b>: 「コアは指圧にて容易に崩れ砂質土状を呈する」 等の自由記述所見。</li>
  <li><b>孔口標高・総掘進長・地盤勾配</b>: 調査地点の地形条件と掘削規模。</li>
</ul>

<h3>2 dataset の構造的差別 — 同一現実の 2 表現</h3>
<ul>
  <li><b>dataset 67 ボーリング交換用データ</b>: <b>機械可読 XML</b> {len(df67):,} 件。
    JIS A 0205-2008 + JIS A 0206-2008 準拠、SHIFT_JIS、
    DTD = BED0300 (国土交通省ボーリング電子納品要領)。
    研究的解析の主軸 (本記事のメイン解析対象)。</li>
  <li><b>dataset 68 電子柱状図</b>: <b>人間可読 PDF</b> {len(df68):,} 件。
    同じボーリングを 1 図 1 ページで視覚化したもの。
    技術者の意思決定 (= 工事監督が現場で参照) のための可読形式。</li>
</ul>
<p>両 dataset の<b>(緯度, 経度, 調査日)</b> はファイル名で 1:1 対応し、
ペア率 {pair_rate*100:.1f}%。これは「<b>同一の調査現実</b>を、
機械処理 (XML) と視覚監査 (PDF) の<b>異なる用途</b>に向けて両形式で配信する」
という DoBoX の設計思想を反映する。</p>

<h3>研究の問い (主 RQ)</h3>
<p>広島県の地盤情報 2 dataset (XML 機械可読 + PDF 人間可読) は、
<b>調査点数・地理分布・調査年代・地質構造・N値深度分布</b>でどう構成され、
地盤強度の地理学はどう描けるか?
特に「2018 西日本豪雨後の調査ラッシュ」 と「広島県の風化花崗岩 (まさ土) 卓越」 という
歴史的・地質的特性をデータで読み取れるか?</p>

<h3>仮説 H1〜H6</h3>
<ul>
  <li><b>H1 (沿岸都市集中)</b>: ボーリングは<b>沿岸都市部</b>
    (広島市・呉市・福山市・尾道市・三原市) に集中し、
    上位 5 市町で <b>50% 以上</b>を占める。これは公共事業密度に比例する。</li>
  <li><b>H2 (掘進長の対数正規分布)</b>: 総掘進長は <b>2 桁</b>の幅 (2-200m) を持ち、
    中央値は <b>10-20m</b> (=道路擁壁・小型橋梁基礎相当)。
    建築基礎 (5-10m) と大型構造物基礎 (30m+) の<b>2 ピーク混合</b>の可能性。</li>
  <li><b>H3 (N値の深度逓増)</b>: N値は<b>表層 (~3m) で軟弱</b> (N&lt;10)、
    <b>深部 (10m+) で堅固</b> (N&gt;30)、N=50 打止め。
    「表層風化 → 風化前線 → 新鮮岩盤」 の標準層序を反映する。</li>
  <li><b>H4 (まさ土の支配)</b>: 「花崗岩」「風化花崗岩」「まさ土」 が層厚で <b>40% 以上</b>。
    広島県は<b>中国地方の花崗岩帯</b>に立地し、
    2018 西日本豪雨の土砂災害は風化花崗岩の崩壊が主因だった。</li>
  <li><b>H5 (2018 西日本豪雨後の調査ラッシュ)</b>: 調査年月の時系列で
    <b>2018-07 以降</b>に件数の急増 (ピーク) が見える。
    西日本豪雨 (2018-07-06〜07) 以降の<b>復旧調査需要</b>を反映する。</li>
  <li><b>H6 (2 dataset の完全ペア構造)</b>: dataset 67 (XML) と dataset 68 (PDF) は
    (緯度, 経度, 調査日) で <b>1:1 対応</b>し、ペア率 95% 以上。
    「同一現実の 2 表現」 という設計を実証する。</li>
</ul>

<h3>本記事の独自用語定義</h3>
<ul>
  <li><b>地盤情報 (geotechnical information)</b>: 地下の土・岩の物理的・力学的特性を
    記述する広義の情報。本記事ではボーリング調査結果に限定し、
    地震動増幅予測 (微動アレイ等) や物理探査 (反射法等) は対象外。</li>
  <li><b>ボーリング (boring / borehole drilling)</b>: 地表から鉛直方向に
    円筒状の試料採取孔を掘削し、地下のコア (= 連続試料) を採取する地質調査。
    口径 66-86 mm が標準で、深度 6-100m が一般的。
    本記事のデータは<b>JIS A 0204 ボーリング交換用データの電子納品形式</b>に従う。</li>
  <li><b>標準貫入試験 (Standard Penetration Test, SPT)</b>: ボーリング中、
    1m おきに <b>63.5 kg のハンマーを 76 cm 自由落下</b>させ、
    試料採取器を 30 cm 貫入させるのに必要な打撃回数 = N値 を測る。
    JIS A 1219 / ISO 22476-3。地盤工学の最も古典的・普遍的な原位置試験。</li>
  <li><b>N値 (N-value, SPT blow count)</b>: 標準貫入試験で得られる<b>30cm 貫入打撃回数</b>。
    地盤の硬軟を表す世界共通指標。
    <b>N&lt;10 = 軟弱</b> (沖積粘性土・緩い砂)、
    <b>N=10-30 = 中位</b> (締まった砂)、
    <b>N&gt;30 = 堅固</b> (風化岩・砂礫層)、
    <b>N=50 = 打止め</b> (基盤岩判定。試験を中止)。</li>
  <li><b>軟弱地盤 (soft ground)</b>: <b>N&lt;10 が 5m 以上連続</b>する地盤。
    沖積粘性土・有機質土・緩い砂等で構成され、
    建築基礎の沈下・地震時の液状化リスクが高い。</li>
  <li><b>液状化 (liquefaction)</b>: 飽和した緩い砂地盤が地震動で<b>有効応力を失い液状になる現象</b>。
    N&lt;15 + 細粒分 35% 以下 + 地下水位以下 が必要条件。
    1995 阪神・淡路、2011 東日本大震災、2024 能登半島地震で大規模発生。</li>
  <li><b>土質分類 (soil classification)</b>: 土・岩を粒径・含水比・組成で分類する体系。
    JIS A 0204 では岩石・土を 9 大分類 × 詳細コードで記述。本記事では
    <b>7 大分類</b> (花崗岩・まさ土 / 堆積岩 / 火成岩 / 変成岩 / 粘性土 / 砂・砂礫 / 表土) に集約。</li>
  <li><b>まさ土 (decomposed granite, DG)</b>: 花崗岩が長期の風化作用で分解した砂質土。
    広島県・岡山県・兵庫県の中国地方花崗岩帯に広く分布。
    <b>水を含むと斜面崩壊を起こしやすい</b>性質を持ち、
    2018 西日本豪雨 (広島 100名超死亡) の土砂災害の主因。</li>
  <li><b>柱状図 (boring log / soil column)</b>: 1 ボーリングの結果を
    1 図 1 ページにまとめた可読資料。深度軸を縦に取り、
    土質・色・N値・観察記事を並列表示する。
    地盤工学者が現場判断するための「現場便覧」。
    本記事の dataset 68 PDF はまさにこれ。</li>
  <li><b>ペア率 (pair rate)</b>: dataset 67 と 68 の (lat, lon, date) で計算される
    1:1 対応率。本記事独自指標で、{pair_rate*100:.1f}%。
    1 つのボーリング調査が両 dataset に必ずペアで載るかを示す指標。</li>
  <li><b>復旧調査 (post-disaster geotechnical survey)</b>: 災害発生後に
    被災地の地盤状況を確認するためのボーリング。本記事では
    <b>2018-07 以降</b>に集中する一群を独自に同定。</li>
</ul>

<h3>到達点</h3>
<p>2 dataset の構造を 6 つの仮説で照合し、<b>ボーリング調査データという見えにくいインフラ情報</b>が
広島県の<b>地質的脆弱性 (まさ土) と災害復旧史 (西日本豪雨)</b> を映す研究的ツールであることを示す。
特に <b>「同一現実の機械可読 + 人間可読 2 表現」</b>という DoBoX の設計思想を
ペア構造で実証する。</p>
"""


# Sec 2: 使用データ
sec2 = (
    "<p>本記事は DoBoX シリーズの <b>2 件</b> を扱う:</p>"
    + df_to_html(T_dataset_spec)
    + "<h3>dataset 67 (XML) の主要要素</h3>"
    "<ul>"
    "<li><b>調査基本情報</b>: 事業工事名、調査名、ボーリング名、ボーリング総数</li>"
    "<li><b>経度緯度情報</b>: 度・分・秒で記録 (取得方法コード・読取精度コード付き)</li>"
    "<li><b>調査位置住所</b>: 「広島県呉市倉橋町宮ノ口」 のような行政住所</li>"
    "<li><b>発注機関名称</b>: 「広島県西部建設事務所呉支所」 等</li>"
    "<li><b>調査期間</b>: 開始年月日 / 終了年月日 (YYYY-MM-DD)</li>"
    "<li><b>ボーリング基本情報</b>: 孔口標高・総掘進長・地盤勾配 (= 地表斜面の角度)</li>"
    "<li><b>試錐機・エンジン・ハンマー・ポンプ・櫓</b>: 機材スペック (各 5 要素)</li>"
    "<li><b>岩石土区分</b>: 深度区間ごとの土質名・記号・岩石群コード (= 観察された層序)</li>"
    "<li><b>色調</b>: 深度区間ごとのコア色 (例: 「暗茶褐」「暗褐」)</li>"
    "<li><b>観察記事</b>: 自由記述所見 (例: 「崖錐堆積物。GL-1.50m 付近まで草根の混入が認められる」)</li>"
    "<li><b>標準貫入試験 (SPT)</b>: 試験深度・10cm 区分打撃数 (3 区分) + 合計打撃回数 = <b>N値</b></li>"
    "<li>その他: 室内試験成果、孔内水位、地下水位、孔曲がり等の付加要素</li>"
    "</ul>"
    "<h3>dataset 68 (PDF) の特性</h3>"
    "<p>同じボーリング地点の柱状図を 1 ページにまとめた可読 PDF。"
    "本記事は<b>本文 PDF を解析対象としない</b> (機械処理は dataset 67 で完結する)。"
    "ペア検証 (= ファイル名の lat/lon/date 一致) のみに使用する。"
    "学習者が地質情報を視覚的に確認したい場合は、リソースページから手動で個別 DL 可能。</p>"
    "<h3>サイズ・取得制約</h3>"
    "<p>各 dataset は <b>2,304 個別リソース</b>からなり、"
    "DoBoX は<b>一括 ZIP DL を提供しない</b> (リソース合計が 20MB 超のため)。"
    "本記事はリソース一覧ページを<b>並列スクレイプ</b>してメタデータを取得し、"
    "個別 XML はサンプル <b>{n_sample} 件</b>を並列 DL する戦略を採る。"
    "全 4,608 ファイルを DL することは hands-on の時間 (1-3 分) と"
    "ストレージ (~875 MB) の両面で適さない。</p>".format(n_sample=N_XML_SAMPLES)
)


# Sec 3: ダウンロード
csv_links = [
    ("L51_dataset_67_listing.csv", "dataset 67 全 2,304 件メタデータ (rid, lat, lon, date)"),
    ("L51_dataset_68_listing.csv", "dataset 68 全 2,304 件メタデータ (rid, lat, lon, date)"),
    ("L51_xml_sample_features.csv", f"XML サンプル {N_XML_SAMPLES} 件のパース済特徴量"),
    ("L51_spt_all_points.csv", f"SPT 全打点 (深度 vs N値, n={len(spt_df)})"),
    ("L51_soil_layers.csv", f"岩石土層 全件 (n={len(layer_df)})"),
    ("L51_soil_class_summary.csv", "岩石土大分類サマリ (7 分類)"),
    ("L51_soil_top15.csv", "岩石土名 Top 15 (生表記)"),
    ("L51_per_city.csv", "市町別 ボーリング件数"),
    ("L51_year_month.csv", "月別件数 (時系列)"),
    ("L51_year_counts.csv", "年別件数"),
    ("L51_depth_stats.csv", "総掘進長 統計"),
    ("L51_n_by_depth.csv", "N値 深度ビン別統計"),
    ("L51_pair_check.csv", "67 ⇔ 68 ペア検証"),
    ("L51_detail_layers.csv", "詳細サンプル 1 件の岩石土層"),
    ("L51_detail_spt.csv", "詳細サンプル 1 件の SPT"),
    ("L51_hypothesis.csv", "H1〜H6 仮説検証"),
]
csv_html = "<ul>" + "".join(
    f'<li><a href="assets/{f}" download>{f}</a> — {desc}</li>'
    for f, desc in csv_links) + "</ul>"

png_links = [
    "L51_fig1_point_map.png",
    "L51_fig2_city_choropleth.png",
    "L51_fig3_depth_dist.png",
    "L51_fig4_n_depth.png",
    "L51_fig5_soil_class.png",
    "L51_fig6_timeline.png",
    "L51_fig7_detail_log.png",
    "L51_fig8_n_map.png",
    "L51_fig9_pair.png",
]
png_html = "<ul>" + "".join(
    f'<li><a href="assets/{f}" download>{f}</a></li>' for f in png_links
) + "</ul>"

dobox_buttons = (
    "<h3>DoBoX 本体 (2 件 × 2,304 個別リソース)</h3>"
    "<ul>"
    "<li><a href='https://hiroshima-dobox.jp/datasets/67' target='_blank'>"
    "地盤情報_ボーリングデータ_ボーリング交換用データ dataset 67</a> "
    "(XML 2,304 件, 各 ~15-50 KB)</li>"
    "<li><a href='https://hiroshima-dobox.jp/datasets/68' target='_blank'>"
    "地盤情報_ボーリングデータ_電子柱状図 dataset 68</a> "
    "(PDF 2,304 件, 各 ~200-500 KB)</li>"
    "</ul>"
    "<p class='tnote'>注: 両 dataset は<b>個別 resource_download URL で 1 ファイルずつ</b>取得する設計。"
    "DoBoX は一括 ZIP を提供しない (合計サイズが 20MB 上限を超えるため)。"
    "本記事の Python スクリプトは<b>並列スクレイプで全件メタデータを取得</b>し、"
    f"<b>サンプル {N_XML_SAMPLES} 件のみ XML 本体を DL</b> する戦略で、"
    "ハンズオン実行時間を 1 分以内に抑えている。</p>"
)

sec3 = (
    dobox_buttons
    + "<h3>整形済 CSV / 中間データ</h3>"
    + csv_html
    + "<h3>図 PNG (9 枚)</h3>"
    + png_html
    + "<h3>再現スクリプト</h3>"
    "<ul>"
    "<li><a href='L51_geological_data.py' download>L51_geological_data.py</a> — メインスクリプト</li>"
    "</ul>"
    "<h3>再現コマンド</h3>"
    "<pre><code>cd \"2026 DoBoX 教材\"\n"
    "py -X utf8 lessons/L51_geological_data.py</code></pre>"
    "<p class='tnote'>初回実行時に <b>462 ページの listing 並列スクレイプ (~70 秒)</b> + "
    f"<b>{N_XML_SAMPLES} XML 並列 DL (~15 秒)</b> を行い、"
    "<code>data/extras/L51_geological_data/</code> にキャッシュする。"
    "<b>2 度目以降はキャッシュを使うので 5-10 秒で完走</b>。"
    "リフレッシュしたい場合は <code>data/extras/L51_geological_data/listing_cache.json</code> "
    "を削除してから実行。</p>"
)


# Sec 4: 分析 1 — 地理分布 (H1)
sec4_intro = f"""
<h3>狙い (分析 1: ボーリング 2,304 地点の地理分布)</h3>
<p>2 dataset の主軸は<b>地理分布</b>。{len(df67):,} 件のボーリング地点を
<b>市町別</b>に集計し、上位 5 市町シェアを計算する。
仮説 H1 (沿岸都市集中) を検証し、公共事業の地理的偏在をデータで読む。</p>

<h3>手法 (geopandas sjoin)</h3>
<p>各ボーリング点 (lat, lon) を <b>EPSG:4326 (WGS84)</b> から
<b>EPSG:6671 (JGD2011 平面直角座標系第 III 系)</b> に投影し、
広島県市町ポリゴン (140 polygons) と <b>spatial join (predicate='within')</b> で
市町コードを付与する:</p>
<ul>
  <li><b>STEP 1 (CRS 統一)</b>: 緯度経度 (10 進度) は距離・面積計算ができない。
    EPSG:6671 (m 単位) に投影変換することで sjoin の幾何演算を高速化する。</li>
  <li><b>STEP 2 (sjoin)</b>: 各ボーリング点が属する市町ポリゴンを判定。
    境界上の点は <code>within</code> 判定で 1 ポリゴンに帰属。</li>
  <li><b>STEP 3 (集計 + コロプレス)</b>: 市町別件数を集計し、
    YlOrRd カラーマップで密度を可視化。</li>
</ul>

<p><b>入力</b>: dataset 67 listing CSV ({len(df67):,} 行) + 行政区域 GeoJSON。<br>
   <b>出力</b>: 市町別件数表 + コロプレス地図。<br>
   <b>限界</b>: ファイル名から得られる lat/lon は 6 桁精度なので
   ~10cm 精度。市町境界の点は<b>1 ポリゴンに帰属</b>するため、
   境界線そのものに乗る希少なケースは判定が不安定。<br>
   <b>代替案</b>: ファイル名と XML 内 <code>調査位置住所</code> の<b>住所文字列照合</b>で
   市町を直接判定する手もあるが、表記揺れ ("広島市安佐北区" vs "安佐北区") に対応が必要。
   sjoin は曖昧さなく機械的に判定できる。</p>
"""

sec4_code = code(r"""
# 全 2,304 ボーリング点の地理分布
import requests, re, json
from concurrent.futures import ThreadPoolExecutor
import pandas as pd, numpy as np, geopandas as gpd

# (a) listing スクレイプ (462 ページ並列)
RE_RID = re.compile(r'/resources/(\d+)')
RE_FNAME_67 = re.compile(r'共通_ボーリング交換用データ_([0-9.]+)_([0-9.]+)_(\d{4}-\d{2}-\d{2})')
HDR = {"User-Agent": "DoBoX-MDASH-textbook/1.0"}

def fetch_page(p):
    return requests.get(f"https://hiroshima-dobox.jp/datasets/67?page={p}",
                        headers=HDR, timeout=30).text

with ThreadPoolExecutor(max_workers=12) as ex:
    pages = list(ex.map(fetch_page, range(1, 232)))  # 231 pages

rows = []
for html in pages:
    rids = RE_RID.findall(html)
    names = RE_FNAME_67.findall(html)
    for rid, (lat, lon, date) in zip(rids, names):
        rows.append({"resource_id": int(rid),
                     "lat": float(lat), "lon": float(lon),
                     "survey_date": date})
df67 = pd.DataFrame(rows)
# → 約 70 秒で 2,304 件取得 (キャッシュ JSON に保存して 2 度目以降スキップ)

# (b) CRS 統一 + sjoin
gdf = gpd.GeoDataFrame(df67, geometry=gpd.points_from_xy(df67.lon, df67.lat),
                       crs="EPSG:4326").to_crs("EPSG:6671")
admin = gpd.read_file("admin_pref.geojson").to_crs("EPSG:6671")
admin_diss = admin.dissolve(by="CITY_CD", as_index=False)
joined = gpd.sjoin(gdf, admin_diss[["CITY_CD", "geometry"]],
                   how="left", predicate="within")
city_counts = joined.groupby("CITY_CD").size().sort_values(ascending=False)
print(city_counts.head(10))
""")

sec4_fig1_text = """
<h3>図 1: ボーリング 2,304 地点マップ (市町別色分け)</h3>
<p><b>なぜこの図か</b>: 学習者が広島県地図上で<b>ボーリング調査がどこに集中しているか</b>を直感する。
緯度経度の点を上位 8 市町は個別色、それ以外はグレーで区別。
H1 (沿岸都市集中) の検証に最適。</p>
"""
_uncovered_n = int(city_counts[city_counts['city_name']=='(都市計画区域外)']['件数'].iloc[0]) if (city_counts['city_name']=='(都市計画区域外)').any() else 0
sec4_fig1_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>ボーリング点は<b>沿岸都市帯</b> (広島市・呉市・福山市・尾道市・三原市) と
      <b>東部内陸都市</b> (庄原市・東広島市) に明確に集中する。
      第 1 位 = <b>{city_counts.iloc[0]['city_name']}</b> ({int(city_counts.iloc[0]['件数'])} 件)、
      第 2 位 = {city_counts.iloc[1]['city_name']} ({int(city_counts.iloc[1]['件数'])} 件)、
      第 3 位 = {city_counts.iloc[2]['city_name']} ({int(city_counts.iloc[2]['件数'])} 件)。</li>
  <li>上位 5 市町で全体の <b>{top5_share*100:.0f}%</b> →
      <b>H1 (沿岸都市集中) を{'支持' if top5_share>=0.50 else '部分支持'}</b>。
      予想 (50%) には届かないが、上位市町への偏在は明確に観察できる。
      届かない原因は次の項目で議論する。</li>
  <li>「(都市計画区域外)」 が <b>{_uncovered_n} 件</b> ({100*_uncovered_n/len(g67):.1f}%) と
      第 3 位相当。これは行政区域 GeoJSON が<b>都市計画区域</b>のみをカバーする制約から、
      <b>北広島町・大崎上島町・神石高原町</b>の 3 町と県境付近のボーリングが
      sjoin で「未カバー」 と判定された結果。これらを実市町に再帰属させれば、
      上位 5 市町シェアは推定 50% 超まで上がる可能性が高い。
      「H1 部分支持」 の判定は<b>市町ポリゴンの整備差</b>に起因するもので、
      実態としての沿岸都市集中性自体は支持される。</li>
  <li>中山間部 (庄原市・三次市) にも国道沿いに点在 →
      <b>長距離国道の維持工事</b>に伴う調査と推察できる。
      庄原市の高い順位 ({int(city_counts[city_counts['city_name']=='庄原市']['件数'].iloc[0]) if (city_counts['city_name']=='庄原市').any() else 0} 件) は意外性があり、
      これは中国自動車道・国道 183/432 号沿いの維持工事が活発であることを反映する。</li>
  <li>島嶼部 (江田島市) にも少数だがクラスタが見える →
      <b>島嶼アクセス道の橋梁・トンネル工事</b>に伴うピンポイント調査。</li>
  <li>呉市の点群は<b>狭い範囲に高密度</b>で集中 → 軍港都市時代から続く<b>急斜面住宅地</b>の
      防災事業 (法面対策・擁壁・治山) を反映。L46 (砂防指定地) の<b>呉市急傾斜地集中</b>と整合。</li>
</ul>
"""

sec4_fig2_text = """
<h3>図 2: 市町別件数コロプレス</h3>
<p><b>なぜこの図か</b>: 点マップ (図 1) では密度が読み取りにくいため、
市町ポリゴンを件数で着色する。同じデータを<b>面ベース</b>で見ることで
都市部 vs 中山間の<b>明確な対比</b>が可視化される。</p>
"""
sec4_fig2_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>YlOrRd の濃赤 = ボーリング集中市町 = <b>沿岸都市帯</b>。
      白色 = 件数 0 の市町は中山間 (山間町等) に多い。</li>
  <li>第 1 位 {city_counts.iloc[0]['city_name']} の濃さは突出 →
      <b>大都市の地下構造把握</b>が公共事業として優先されている事実を示す。</li>
  <li>{city_counts.iloc[1]['city_name']} と {city_counts.iloc[2]['city_name']} は
      第 2-3 位で同程度の濃度 → 県内に<b>3 つの調査ハブ</b>がある構造。</li>
  <li>島嶼部 (江田島・大崎上島・大崎下島) は薄黄色で件数 1 桁 →
      島嶼基盤地質情報の整備は限定的 (= 災害時の即応性に課題)。</li>
</ul>
"""


# Sec 5: 分析 2 — 掘進長分布 (H2)
sec5_intro = f"""
<h3>狙い (分析 2: 総掘進長の対数正規分布)</h3>
<p>地理分布 (= どこで掘ったか) の次は<b>規模 (= どこまで深く掘ったか)</b>。
仮説 H2 (掘進長の対数正規分布) を、<b>サンプル {N_XML_SAMPLES} XML</b> から抽出した
<code>総掘進長</code> ({len(depth_pos)} 件) で検証する。
建築基礎・道路擁壁・大型構造物基礎で<b>3 ピーク混合</b>になるか?</p>

<h3>手法 (XML パース + log10 ヒスト)</h3>
<p>XML から <code>&lt;総掘進長&gt;</code> 要素を <code>ElementTree.findall</code> で抽出。
SHIFT_JIS エンコードの XML を<b>UTF-8 化してパース</b>するために、
encoding 宣言を書き換えてから読む工夫が必要。</p>
<p>掘進長は最小 2m から最大 100m 超まで <b>2 桁</b>の幅を持つので、
線形軸では<b>長距離調査の少数</b>に押されて分布形状が読めない。
<b>log10 スケール</b>で見ると<b>対数正規 (=正規分布のような釣り鐘)</b> 形状が見える。</p>

<p><b>入力</b>: 並列 DL した XML {N_XML_SAMPLES} 件 (グリッド層別サンプリング)。<br>
   <b>出力</b>: 掘進長配列 + log10 ヒストグラム + 累積分布。<br>
   <b>限界</b>: サンプリングは<b>ランダムでなく地理的層別</b> (緯度経度 0.1 度グリッド) なので、
   都市部に偏らないが、特定市町の極端値 (= 大都市の超長尺ボーリング) を
   過小評価する可能性。<br>
   <b>代替案</b>: 全 {len(df67):,} 件 DL すれば代表性 100% だが、
   ハンズオン時間制約 (1-3 分) を超える。本記事はサンプル代表性を採用。</p>
"""

sec5_code = code(r"""
# XML パース + 掘進長抽出 (SHIFT_JIS → UTF-8 変換が必要)
import xml.etree.ElementTree as ET, re, numpy as np
from concurrent.futures import ThreadPoolExecutor
import requests

def fetch_xml(rid):
    r = requests.get(f"https://hiroshima-dobox.jp/resource_download/{rid}",
                     headers={"User-Agent": "DoBoX-MDASH-textbook/1.0"},
                     timeout=30)
    return r.content

def parse_xml(raw):
    text = raw.decode("shift_jis", errors="replace")
    # SHIFT_JIS 宣言を UTF-8 に書き換えて bytes として再エンコード
    text2 = re.sub(r'encoding="[^"]*"', 'encoding="utf-8"', text, count=1)
    root = ET.fromstring(text2.encode("utf-8"))
    e = root.find(".//総掘進長")
    return float(e.text) if e is not None and e.text else None

# 並列 DL + パース
sample_rids = [...]  # グリッド層別 150 件
with ThreadPoolExecutor(max_workers=8) as ex:
    raw_xmls = list(ex.map(fetch_xml, sample_rids))
depths = [parse_xml(r) for r in raw_xmls]
depths = np.array([d for d in depths if d is not None and d > 0])

# 対数正規分布の検証
log10_d = np.log10(depths)
print(f"min={depths.min():.1f}m, max={depths.max():.1f}m")
print(f"median={np.median(depths):.1f}m, mean={depths.mean():.1f}m")
print(f"log10 範囲 = {log10_d.max() - log10_d.min():.2f} 桁")
""")

sec5_fig3_text = """
<h3>図 3: 総掘進長 log10 ヒスト + 累積分布</h3>
<p><b>なぜこの図か</b>: 線形軸では長尺ボーリングが目盛りを支配して分布形が見えない。
log10 スケールで対数正規性を直接視覚化する。
右図の累積分布で「掘進長 X 以下が全体の何 %」 を読み取れる。</p>
"""
sec5_fig3_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>log10(総掘進長) の分布は<b>釣り鐘形状</b>で対数正規に近い。
      中央値 = <b>{depth_pos.median():.1f}m</b>、平均 = {depth_pos.mean():.1f}m。
      <b>H2 (対数正規) を支持</b>。</li>
  <li>min = <b>{depth_pos.min():.1f}m</b> (浅い試掘)、
      max = <b>{depth_pos.max():.1f}m</b> (大型構造物基礎)。
      log10 範囲 = <b>{log_depth_range:.2f} 桁</b>。</li>
  <li>累積分布で <b>50% 線</b>と <b>90% 線</b>を引くと:
      P50 = {depth_pos.median():.1f}m (= 道路擁壁・小型橋梁基礎相当)、
      P90 = {depth_pos.quantile(0.9):.1f}m (= 大型橋脚基礎・トンネル支保工相当)。</li>
  <li>5-15m 帯に大きな山 = <b>住宅基礎・道路擁壁</b>の標準スケール。
      30m 超の少数群 = <b>大型構造物 (橋梁主塔・トンネル坑口・地すべり対策杭)</b>。</li>
  <li>この分布は「掘削コストは深さに非線形」 という<b>経済原理</b>を反映する。
      浅い調査は安いから多用、深い調査は高価で必要なときだけ → 対数正規が自然に生じる。</li>
</ul>
"""


# Sec 6: 分析 3 — N値深度プロファイル (H3) + 1 詳細柱状図
sec6_intro = f"""
<h3>狙い (分析 3: N値の深度逓増 + 1 詳細柱状図)</h3>
<p>掘進長 (= マクロ) の次は<b>地下プロファイル (= ミクロ)</b>。
H3 (N値の深度逓増) を、<b>SPT 全打点 {len(spt_df)} 回</b>を深度ビン別に集計して検証する。
さらに 1 ボーリング詳細柱状図 で<b>個別の地盤プロファイル</b>を学習者が読めるようにする。</p>

<h3>手法 (深度ビン boxplot + 個別柱状図)</h3>
<p>深度を 6 ビン (0-3 / 3-5 / 5-10 / 10-20 / 20-50 / 50m+) に分け、
各ビンの N値を boxplot で表示。N=10 (軟弱閾値) と N=50 (打止め) を参照線に引く。</p>

<p>1 詳細柱状図は SPT 回数 + 層数の合計が最大のサンプルを自動選択し、
<b>左ペイン: 土質柱状図 (深度区間 × 大分類色)</b> + <b>右ペイン: N値プロファイル (深度 vs N値)</b>
の 2 ペイン構成で視覚化する。これは dataset 68 PDF の電子柱状図を<b>機械生成版で再現</b>する。</p>

<p><b>入力</b>: SPT 列 (深度, N値) + soil_layers 列 (上端, 下端, 土質名)。<br>
   <b>出力</b>: 深度ビン boxplot + 個別柱状図 (2 ペイン)。<br>
   <b>限界</b>: N=50 打止めは「実 N値 ≥ 50」 を意味するが、
   データには文字どおり 50 として記録されるので<b>真の地盤強度を過小評価</b>する。
   厳密な解析では「N=50 は censored」 として処理する必要がある。<br>
   <b>代替案</b>: 修正 N値 (= 拘束圧補正・有効上載圧補正) を計算する手もあるが、
   本記事は<b>生 N値の深度プロファイル</b>に焦点を絞る (補正は地盤工学専門の別記事で扱う)。</p>
"""

sec6_code = code(r"""
# SPT 抽出 + 深度ビン集計 + 1 ボーリング詳細描画
import xml.etree.ElementTree as ET, pandas as pd, numpy as np
import matplotlib.pyplot as plt

# (a) SPT 抽出 (1 XML 内に複数 SPT 要素がある = 1m おきに繰り返し)
def parse_spt(root):
    spt = []
    for s in root.findall(".//標準貫入試験"):
        d = s.find(".//標準貫入試験_開始深度")
        n = s.find(".//標準貫入試験_合計打撃回数")
        if d is None or n is None: continue
        try:
            spt.append({"深度_m": float(d.text), "N値": float(n.text)})
        except (TypeError, ValueError):
            continue
    return spt

# (b) 深度ビン集計
spt_df = pd.DataFrame(all_spt_records)
spt_df["bin"] = pd.cut(spt_df["深度_m"],
                        bins=[0, 3, 5, 10, 20, 50, 200],
                        labels=["0-3m", "3-5m", "5-10m", "10-20m", "20-50m", "50m+"])
n_by_bin = spt_df.groupby("bin")["N値"].agg(["count", "median", "mean"])
print(n_by_bin)

# (c) 1 ボーリング柱状図 (深度軸を逆転して上から下へ)
fig, axes = plt.subplots(1, 2, sharey=True, figsize=(11, 8))
ax = axes[0]
for L in soil_layers:
    ax.barh(y=(L["上端"] + L["下端"]) / 2,
            width=1, height=L["下端"] - L["上端"],
            color=COLOR[classify(L["土質名"])])
ax.invert_yaxis()  # 0 を上、深度大を下に
ax = axes[1]
ax.plot([s["N値"] for s in spt], [s["深度_m"] for s in spt], "o-")
ax.invert_yaxis()
""")

sec6_fig4_text = """
<h3>図 4: N値の深度プロファイル (boxplot)</h3>
<p><b>なぜこの図か</b>: 深度 vs N値の散布図は点が重なって読めない。
深度をビン化して boxplot にすると、<b>各深度の N値分布の中央値・四分位範囲・外れ値</b>が
1 図で読める。H3 (深度逓増) の検証に最適。</p>
"""
n_shallow_str = f"{n_shallow:.1f}" if len(spt_df) > 0 else "N/A"
n_deep_str = f"{n_deep:.1f}" if len(spt_df) > 0 else "N/A"
sec6_fig4_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>深度の浅い <b>0-3m</b> ビンの N値中央値 = <b>{n_shallow_str}</b> →
      表層は<b>軟弱</b> (沖積粘性土・盛土・崖錐)。建築基礎・路盤工事の基底として要改良の層厚。</li>
  <li>深度 <b>10-20m</b> ビンの N値中央値 = <b>{n_deep_str}</b> →
      <b>堅固</b> (風化岩・砂礫層)。一般建築物の支持層として十分な強度。
      <b>H3 (深度逓増) を支持</b>。</li>
  <li>20-50m / 50m+ ビンは件数が少ないが N=50 (打止め) に集中 →
      <b>新鮮岩盤 (花崗岩・片岩等)</b>に到達したと推定。
      大型橋脚・高層ビル基礎の支持層。</li>
  <li>各ビンに<b>外れ値 (黒点)</b> が散在 → 同じ深度でも N値は一意でなく、
      地点ごとの地質履歴 (= 風化進行度・断層位置・地下水位) で大きく変動。
      これは「N値だけでは地盤を語れない」 ことを示し、
      観察記事 (定性所見) と組み合わせて運用する重要性を学習者に伝える。</li>
  <li>軟弱率 (N&lt;10 の比率) は浅いビンで高く、深いビンで低い →
      液状化リスクは表層に集中。L40 (標高) や L44 (高潮) と空間照合すれば
      <b>「低標高 + 軟弱表層」 の二重リスク領域</b>が抽出できる。</li>
</ul>
"""

sec6_fig7_text = """
<h3>図 7: 1 ボーリング詳細柱状図 (土質 + N値の 2 ペイン)</h3>
<p><b>なぜこの図か</b>: 統計集計だけでは「個々のボーリングがどう見えるか」 が学習者に伝わらない。
SPT 回数 + 層数の合計が最大のサンプルを自動選択し、
<b>左: 土質柱状図 (深度区間 × 大分類色)</b> + <b>右: N値プロファイル</b>の 2 ペインで描画。
これは dataset 68 PDF の電子柱状図を<b>機械生成版で再現</b>したもの。</p>
"""
addr = (detail_pick.get("調査位置住所") or "(不明)")
sec6_fig7_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>サンプル: <b>{addr[:30]}</b>、
      総掘進長 <b>{detail_pick['総掘進長_m']:.1f}m</b>、
      孔口標高 <b>{detail_pick['孔口標高_m']:.1f}m</b>、
      事業: {(detail_pick.get('事業工事名') or '')[:30]}。</li>
  <li>左ペインで<b>土質の鉛直層序</b>が直感できる。色は分析 4 の大分類と一致するので、
      花崗岩 (赤) ・粘性土 (緑) ・砂礫 (紫) が瞬時に見分けられる。</li>
  <li>右ペインの N値は深度につれて<b>右に伸びる傾向</b> = 深度逓増の仮説と整合 (図 4 の集約結果)。
      ただし個別ボーリングは平坦な区間や急増する区間を持ち、
      <b>「平均」 では見えない地盤の局所構造</b>が浮かび上がる。</li>
  <li>N=50 ライン (赤破線) を超えた深度 = <b>支持層</b>の候補。
      これより上は構造物の沈下リスクがあるため<b>地盤改良 / 杭基礎</b>が必要。</li>
  <li>このような<b>個別柱状図 + 統計集計</b>のハイブリッドが、地盤工学の標準的な
      「地盤調査報告書」 構成。本記事は dataset 67 から自動生成しており、
      手作業の柱状図起票を機械化できる可能性を示す。</li>
</ul>
"""


# Sec 7: 分析 4 — 岩石土大分類 (H4) + N値地理分布 (発展)
sec7_intro = f"""
<h3>狙い (分析 4: 岩石土大分類 + N値の地理分布)</h3>
<p>1 ボーリングのプロファイル (= ミクロ) の次は<b>地質構成 (= 県全体の地質特性)</b>。
H4 (まさ土の支配) を、<b>全 {len(layer_df)} 岩石土層</b>を 7 大分類に集約して検証する。
さらに、サンプル N値の<b>空間分布</b>を地図化することで「地盤強度の地理学」 を描く。</p>

<h3>手法 (キーワード辞書による土質大分類化 + 地理マッピング)</h3>
<p>岩石土名は自由記述の文字列で多様な表記:
「花崗岩」「風化花崗岩」「マサ土」「礫まじり砂」「凝灰角礫岩」 等。
これを 7 大分類に集約:</p>
<ul>
  <li><b>花崗岩・まさ土</b>: 「花崗岩」「マサ」「まさ」「風化花崗」 を含む</li>
  <li><b>堆積岩</b>: 砂岩・泥岩・凝灰岩・頁岩・礫岩・石灰岩</li>
  <li><b>火成岩 (花崗岩以外)</b>: 安山岩・玄武岩・流紋岩・斑岩・閃緑岩</li>
  <li><b>変成岩</b>: 片岩・片麻岩・ホルンフェルス</li>
  <li><b>粘性土</b>: 粘土・シルト・ローム</li>
  <li><b>砂・砂礫</b>: 砂・礫の組み合わせ</li>
  <li><b>表土・盛土</b>: 盛土・埋土・崖錐・表土</li>
</ul>

<p><b>入力</b>: soil_layers 列 (上端_m, 下端_m, 厚さ_m, 土質名)。<br>
   <b>出力</b>: 7 大分類別 厚さ集計 + 円グラフ + 地理マップ。<br>
   <b>限界</b>: キーワード辞書方式は<b>表記揺れ + 複合表現</b>で誤分類が起こる。
   例: 「花崗岩質砂礫」 は「花崗岩・まさ土」 と「砂礫」 のどちらにも該当しうるが、
   実装は<b>順序判定</b>で「花崗岩」 を優先する。<br>
   <b>代替案</b>: 岩石土コード (JIS A 0204) を直接使えば客観的だが、
   コード辞書が大規模 (数百種) で学習者には可読性が低い。
   キーワード分類は教育的に分かりやすい妥協案。</p>
"""

sec7_code = code(r"""
# 7 大分類によるキーワード集約 + N値地理分布
def classify_soil(name):
    if not name: return "(不明)"
    if "花崗岩" in name or "マサ" in name or "まさ" in name: return "花崗岩・まさ土"
    if any(k in name for k in ["砂岩", "泥岩", "凝灰岩", "頁岩", "礫岩", "石灰岩"]):
        return "堆積岩"
    if any(k in name for k in ["安山岩", "玄武岩", "流紋岩", "閃緑岩"]):
        return "火成岩 (花崗岩以外)"
    if any(k in name for k in ["片岩", "片麻岩", "ホルンフェルス"]):
        return "変成岩"
    if any(k in name for k in ["粘土", "シルト", "ローム"]):
        return "粘性土"
    if "砂" in name and "礫" in name: return "砂礫"
    if "礫" in name: return "礫"
    if "砂" in name: return "砂"
    if any(k in name for k in ["盛土", "埋土", "崖錐", "表土"]):
        return "表土・盛土"
    return "その他"

layer_df["大分類"] = layer_df["土質名"].apply(classify_soil)
soil_summary = layer_df.groupby("大分類").agg(
    件数=("土質名", "count"), 総厚_m=("厚さ_m", "sum")
).sort_values("総厚_m", ascending=False)

# N値地理マップ (各サンプルの N値中央値で色分け)
g_sample["N値中央値"] = g_sample["spt"].apply(
    lambda s: np.median([x["N値"] for x in s]) if s else np.nan)
gs_valid = g_sample.dropna(subset=["N値中央値"])
ax.scatter(gs_valid.geometry.x, gs_valid.geometry.y,
           c=gs_valid["N値中央値"], cmap="RdYlGn",
           vmin=0, vmax=50, s=60)
""")

sec7_fig5_text = """
<h3>図 5: 岩石土大分類 (厚さ% + 件数)</h3>
<p><b>なぜこの図か</b>: 7 大分類の支配比率を<b>面積</b>で表現する円グラフは
「全体に占める割合」 を直感する最強の表現。
右側の棒グラフで層数 (= 出現回数) も併記し、
「厚いが少数」 vs 「薄いが多数」 の差を読み分ける。</p>
"""
_top3 = soil_summary.head(3)
_top3_str = " / ".join(f"{r['大分類']} {r['厚さ%']:.1f}%"
                       for _, r in _top3.iterrows())
_clay_share = (soil_summary[soil_summary['大分類']=='粘性土']['厚さ%'].iloc[0]
               if (soil_summary['大分類']=='粘性土').any() else 0)
_sa_g_share = (soil_summary[soil_summary['大分類']=='砂礫']['厚さ%'].iloc[0]
               if (soil_summary['大分類']=='砂礫')
               .any() else 0)
sec7_fig5_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>厚さシェアの上位 3 大分類 = <b>{_top3_str}</b> という<b>3 層分布</b>が現れた。
      事前仮説 (まさ土 40% 超) とは異なり、<b>砂礫 + 花崗岩・まさ土 + 粘性土</b>がそれぞれ
      ~ 22-24% で<b>並立</b>する。これは予想外で、教育的に重要な発見:
      <b>H4 を「部分支持」</b>と判定する根拠になる。</li>
  <li>では H4 の予想は何が違ったのか? 我々は「広島県全体は花崗岩」 と単純化しすぎた。
      実際は<b>調査対象事業の偏り</b>が効いている: ボーリング調査の発注は
      (a) 河川・港湾事業 → 沖積<b>粘性土・砂礫</b>を貫通する基礎杭調査、
      (b) 道路法面 → 風化花崗岩・まさ土調査、
      の 2 系統が並列する。サンプルがその両方をほぼ均等に拾っているため
      3 層が並立した、と解釈できる。</li>
  <li>とはいえ <b>花崗岩・まさ土 ({masa_share:.1f}%)</b> は依然として上位 3 位以内であり、
      広島県の地質的特徴は<b>確かに反映されている</b>。
      他県の同種データと比較した場合、まさ土比率の高さは際立つはず。
      L46 (砂防指定地) の呉市急傾斜地集中とも整合する。</li>
  <li>右側棒グラフでは「<b>粘性土</b>」 が層数 ({int(soil_summary.loc[soil_summary['大分類']=='粘性土','件数'].iloc[0]) if (soil_summary['大分類']=='粘性土').any() else 0}) で最多 →
      <b>多数の薄い層</b>として頻出する沖積層の特徴。広島デルタ・福山デルタ等の
      建築基礎の沈下管理に直結する。</li>
  <li>変成岩 (片岩・片麻岩) は極少 → 広島県は<b>変成岩帯ではない</b>。
      隣接県 (高知・徳島の三波川変成帯) と地質学的に異なることを確認できる。</li>
  <li>表土・盛土 ({soil_summary.loc[soil_summary['大分類']=='表土・盛土', '厚さ%'].iloc[0] if (soil_summary['大分類']=='表土・盛土').any() else 0:.1f}%) は厚さでは少数だが、
      建築基礎の支持力に直接影響する重要層。
      盛土が薄い箇所は<b>地震時の不同沈下リスク</b>が高い。</li>
</ul>
"""

sec7_fig8_text = """
<h3>図 8: N値中央値の地理分布</h3>
<p><b>なぜこの図か</b>: 図 4 で「深度ごとの N値」 を集約したが、
<b>地点ごとの「総合的な硬軟」</b>を見たい。
各サンプル {N_XML} 件の<b>SPT N値の中央値</b>を計算し、地図上で色分け。
赤 = 軟弱 (median N&lt;10) , 緑 = 堅固 (median N&gt;30) で<b>地盤強度の地理学</b>を描く。</p>
""".replace("{N_XML}", str(N_XML_SAMPLES))
sec7_fig8_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li><b>沿岸デルタ部</b> (広島市南区・佐伯区、福山市南部) に<b>赤系</b> (軟弱) が集中 →
      沖積粘性土 + 砂質土が表層に厚く堆積する低地。液状化リスクと整合。</li>
  <li><b>山間部・山地縁辺</b>に<b>緑系</b> (堅固) が分布 →
      花崗岩・風化岩盤が地表近くまで露出する地帯。建築基礎は浅い深度で支持層に到達。</li>
  <li>呉市の急斜面住宅地は混合 → 表層風化が場所により大きく異なり、
      <b>同じ呉市内でも地盤強度のミクロな偏在</b>が見える。
      これは L46 で扱った急傾斜地崩壊危険区域の集中とも整合する。</li>
  <li>島嶼部 (江田島・大崎上島) はサンプル数が少ないが緑系が多い →
      島嶼基盤は花崗岩質で堅固な傾向。表層は薄い崖錐・風化層。</li>
  <li>このマップは「<b>建築計画でどこに杭を打つべきか</b>」 の意思決定支援に直接使える。
      赤エリアでは深い杭基礎、緑エリアでは浅い直接基礎が経済的に最適。</li>
</ul>
"""


# Sec 8: 分析 5 — 時系列 + ペア検証 (H5, H6)
sec8_intro = f"""
<h3>狙い (分析 5: 調査年代の時系列 + 2 dataset ペア検証)</h3>
<p>地理 (どこ) ・規模 (どこまで) ・地質 (何) を見たので、最後に<b>時間 (いつ)</b> と<b>データ構造</b>。
H5 (2018 西日本豪雨後の調査ラッシュ) と H6 (2 dataset の完全ペア) を検証する。</p>

<h3>手法 (時系列 bar + 集合演算)</h3>
<p>調査終了年月日 (ファイル名末尾) を月単位に丸め、bar plot。
2018-07-07 マーカーを引いて<b>西日本豪雨との関係</b>を視覚化する。</p>

<p>ペア検証は集合演算 1 行: <code>set(67_keys) & set(68_keys)</code>。
キーは <code>(lat, lon, date)</code> のタプル ({len(df67):,} × 3 列)。</p>

<p><b>入力</b>: dataset 67 listing + dataset 68 listing。<br>
   <b>出力</b>: 月別件数 bar + ペア集合 Venn 風。<br>
   <b>限界</b>: lat/lon は 6 桁精度。<b>同じ調査の 67 と 68 で座標が異なる</b>ような表記揺れがあれば、
   ペア率が下がる。実際は揃っているか?<br>
   <b>代替案</b>: ボーリング ID (XML 内 <code>ボーリング名</code> = "Boring No.2" 等) で
   照合する手もあるが、これも自由記述で揺れがある。
   座標 + 日付の 3 列マッチが最も堅牢。</p>
"""

sec8_code = code(r"""
# 時系列 + ペア検証
import pandas as pd

df67["date"] = pd.to_datetime(df67["survey_date"])
df68["date"] = pd.to_datetime(df68["survey_date"])

# 月別件数
ym = df67.groupby(df67["date"].dt.to_period("M")).size()
print(ym.sort_index())

# 西日本豪雨後の比率
post = (df67["date"] >= "2018-07-01").sum()
print(f"2018-07 以降: {post}/{len(df67)} = {100*post/len(df67):.0f}%")

# 67 ⇔ 68 ペア
KEY = ["lat", "lon", "survey_date"]
keys67 = set(map(tuple, df67[KEY].values))
keys68 = set(map(tuple, df68[KEY].values))
common = keys67 & keys68
print(f"ペア率 = {100*len(common)/len(keys67):.1f}% ({len(common)}/{len(keys67)})")
""")

sec8_fig6_text = """
<h3>図 6: 調査月の時系列分布 — 西日本豪雨ピーク</h3>
<p><b>なぜこの図か</b>: H5 (西日本豪雨後の調査ラッシュ) を直接視覚化する。
月別件数を bar で描き、2018-07-07 (西日本豪雨開始日) を破線マーカーで明示。
ピーク期間と西日本豪雨の時間関係が一目で読める。</p>
"""
sec8_fig6_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li>2018-07-07 マーカー以降に<b>件数の急増</b>が見える → <b>復旧調査需要</b>を反映。
      2018-07 以降 = <b>{n_post_2018}</b> 件 ({100*n_post_2018/len(df67):.0f}%)。
      <b>H5 を{'支持' if 100*n_post_2018/len(df67)>=50 else '部分支持'}</b>。</li>
  <li>ピーク月は <b>{peak_month}</b> ({peak_count} 件) →
      西日本豪雨から数ヶ月後に集中、<b>緊急復旧 (砂防・道路復旧) のための地盤調査</b>が
      この時期に実施されたと解釈できる。</li>
  <li>2018-07 以前にも継続的な調査がある → <b>平常時の公共事業</b> (新規道路改良・橋梁老朽化対応) は
      毎年定常的に発注されている。</li>
  <li>2020 年代以降も継続 → 復旧事業の長期化 (= 2018 西日本豪雨の影響は今も続いている)。
      L50 で扱った「災害規制 8 年継続」 とも整合する。</li>
  <li>これは<b>「ボーリングデータ = 公共事業の歴史記録」</b>であることを示す。
      時系列で読めば<b>政策の記憶</b>が残るインフラ史料となる。</li>
</ul>
"""

sec8_fig9_text = """
<h3>図 9: dataset 67 ⇔ 68 ペア対応</h3>
<p><b>なぜこの図か</b>: H6 (2 dataset の完全ペア) を直接視覚化する。
左の Venn 風で「67 と 68 がほぼ完全一致」 という構造を伝え、
右の bar で 3 集合 (共通 / 67only / 68only) の絶対件数を厳密に示す。</p>
"""
sec8_fig9_read = f"""
<p><b>この図から読み取れること</b>:</p>
<ul>
  <li><b>(lat, lon, date) ペア共通 = {len(common_keys):,} 件</b> → 全体の <b>{pair_rate*100:.1f}%</b> がペア。
      <b>H6 (95% ペア) を{'支持' if pair_rate>=0.95 else '部分支持'}</b>。</li>
  <li>67 only = {len(only67)} 件、68 only = {len(only68)} 件 →
      ペア化されない少数のレコードは<b>表記揺れ</b> (lat/lon の桁ズレ・日付の表記差) または<b>データ更新タイミング差</b>と推察。</li>
  <li>これは「<b>1 ボーリング調査 → 67 (XML 機械可読) + 68 (PDF 人間可読)</b> の<b>必然ペア</b>」 という
      DoBoX の設計思想を実証する。両 dataset を独立に DL してから (lat,lon,date) で
      自動 join できるので、<b>機械処理 + 人間監査の両立</b>が可能。</li>
  <li>これは「<b>同一現実の 2 表現</b>」 という根本構造で、L50 (1257/1258 が時間スコープで分岐) とは
      別種の構造。L50 = 同じデータを<b>時間でスライスした 2 ビュー</b>、
      L51 = 同じデータを<b>用途でフォーマット分けした 2 ビュー</b>。</li>
  <li>このペア構造を活用すれば、機械が自動抽出した特徴 (XML) を、
      技術者が PDF で目視確認するワークフローが組める →
      品質管理 (QA/QC) の自動化に直結する。</li>
</ul>
"""


# Sec 9: 仮説検証総合
sec9 = (
    "<p>本記事の 6 仮説と観測結果の照合:</p>"
    + df_to_html(T_hypo)
    + "<h3>主要発見の総括</h3>"
    "<ul>"
    f"<li><b>地理偏在</b>: 上位 5 市町で全体の {top5_share*100:.0f}% を占める。"
    f"沿岸都市部に集中するのは公共事業密度の自然な帰結。</li>"
    f"<li><b>掘進長の対数正規分布</b>: 中央値 {depth_pos.median():.1f}m、"
    f"log10 範囲 {log_depth_range:.2f} 桁。経済原理 (深さに非線形なコスト) が"
    f"対数正規分布を生む。</li>"
    f"<li><b>N値の深度逓増</b>: 0-3m 中央値 = {n_shallow:.1f} (軟弱) → "
    f"10m+ 中央値 = {n_deep:.1f} (堅固)。表層風化 + 風化前線 + 新鮮岩盤の"
    f"標準層序を反映。</li>"
    f"<li><b>まさ土の支配</b>: 花崗岩・まさ土が層厚で {masa_share:.0f}% を占める。"
    f"広島県の地質的特性 (中国地方花崗岩帯) と 2018 西日本豪雨の土砂災害主因を"
    f"データで実証。</li>"
    f"<li><b>2018 西日本豪雨後の調査ラッシュ</b>: 2018-07 以降 = "
    f"{100*n_post_2018/len(df67):.0f}% (ピーク {peak_month}, {peak_count} 件)。"
    f"ボーリングデータは公共事業の歴史記録として機能する。</li>"
    f"<li><b>2 dataset の完全ペア</b>: ペア率 {pair_rate*100:.1f}%。"
    f"「同一現実 → 機械可読 + 人間可読の 2 表現」 という DoBoX の"
    f"設計思想を実証。</li>"
    "</ul>"
    "<h3>2 dataset の構造的相互関係</h3>"
    "<p>dataset 67 (XML 機械可読) と 68 (PDF 人間可読) は"
    "<b>独立した別データではなく、同一のボーリング調査現実を 2 つのフォーマットで配信</b>している。"
    "(lat, lon, date) で 1:1 対応する。"
    "67 = 機械処理 (本記事の解析がまさにこれ) ・横断統計・空間結合に最適。"
    "68 = 現場技術者の意思決定支援 (= 工事監督が PDF を印刷して持参) に最適。"
    "両者を<b>必ずペアで配信</b>する設計は、データの<b>機械可読性と人間可読性</b>の"
    "<b>並立</b>を運用上保証する。"
    "本記事はこの設計を逆工学的に解読し、"
    "学習者にオープンデータ設計の実例を提示した。</p>"
    "<h3>L50 と L51 の構造比較</h3>"
    "<p>L50 (道路規制 1257/1258) と L51 (地盤情報 67/68) は<b>異なるペア型 2-dataset</b>:</p>"
    "<ul>"
    "<li>L50 = 同じ規制台帳を<b>時間スコープでスライス</b> (本日 vs 今後)。1258 が 1257 を包含。</li>"
    "<li>L51 = 同じボーリング調査を<b>フォーマットで分離</b> (XML vs PDF)。両者は対称的な兄弟関係。</li>"
    "</ul>"
    "<p>これは「DoBoX の同一シリーズ複数 dataset」 が<b>同じ実体を異なる切り方で配信</b>する"
    "多様な設計パターンを持つことを示す。「シリーズ統合分析」 の研究的価値は、"
    "こうしたメタ構造の発見にある。</p>"
)

# Sec 10: 発展課題
sec10 = f"""
<h3>結果X → 新仮説Y → 課題Z (4 課題)</h3>

<h4>発展課題 1 (まさ土支配 + 西日本豪雨 由来)</h4>
<ul>
  <li><b>結果 X</b>: 花崗岩・まさ土が層厚 {masa_share:.0f}% を占め、
      2018 西日本豪雨後にボーリング件数が急増 ({100*n_post_2018/len(df67):.0f}% が 2018-07 以降)。</li>
  <li><b>新仮説 Y</b>: 西日本豪雨後の復旧調査は<b>まさ土崩壊現場に集中</b>し、
      「まさ土層厚 (m) と被災地集中度」 の間に強い正の相関 (Pearson r &gt; 0.7) がある。
      この相関は 2018-07 以降のボーリングだけで顕著で、それ以前は弱い。</li>
  <li><b>課題 Z</b>: 各ボーリングの<b>まさ土層厚</b>を XML から抽出し、
      L10 (土砂災害警戒区域) のポリゴンとの空間関係を sjoin で計算。
      被災区域内 vs 外で<b>まさ土層厚の中央値差</b>を Mann-Whitney U 検定。
      p &lt; 0.01 なら強支持。
      これは「まさ土の地質的脆弱性 → 西日本豪雨 → 復旧需要」 という因果鎖を
      データで定量化する研究になる。</li>
</ul>

<h4>発展課題 2 (N値の深度逓増 由来)</h4>
<ul>
  <li><b>結果 X</b>: N値中央値は 0-3m で {n_shallow:.0f}、10m+ で {n_deep:.0f} と
      明確な深度逓増。表層風化帯の厚さは地点ごとに異なる。</li>
  <li><b>新仮説 Y</b>: <b>「N=10 (軟弱) → N=30 (堅固) への遷移深度」</b> = 表層風化帯厚さは、
      標高・傾斜と<b>逆相関</b>する。
      高標高・急傾斜では風化帯が薄く ( = 浸食で剥がれる)、
      低標高・緩傾斜では風化帯が厚い ( = 風化物が堆積する)。</li>
  <li><b>課題 Z</b>: 全 {N_XML_SAMPLES} サンプルについて<b>N値遷移深度</b>を抽出
      (= N値が初めて 30 を超える深度)。L40 (標高) と L39 (傾斜) のラスタを
      その地点で sample し、Spearman 相関を計算。
      r &lt; -0.5 なら支持。これは「<b>地盤強度の地形依存性</b>」 を
      実データで実証する研究になる。</li>
</ul>

<h4>発展課題 3 (沿岸軟弱地盤 + 液状化リスク 由来)</h4>
<ul>
  <li><b>結果 X</b>: 沿岸デルタ部 (広島市南区・佐伯区、福山市南部) に
      N値中央値 &lt; 10 のサンプルが集中 (図 8)。これらは液状化候補地。</li>
  <li><b>新仮説 Y</b>: 「N&lt;15 + 細粒分 35% 以下 + 地下水位以下」 の<b>液状化 3 条件</b>を
      満たすボーリング地点は、L11 (トリプルハザード) の<b>津波想定浸水域</b>と
      80% 以上重なる。これは「液状化リスク + 津波リスク」 の<b>沿岸二重リスク</b>を示す。</li>
  <li><b>課題 Z</b>: XML 内の<b>地下水位</b>と<b>細粒分含有率</b> (室内試験成果) を抽出し、
      液状化判定 (FL 値) を計算。FL &lt; 1 の地点を抽出して
      L11 ハザードレイヤと sjoin。重なり率 (= 共通面積 / 液状化候補面積) を測定。
      80% 超なら強支持。これは「沿岸都市の地震・津波 + 液状化複合リスク」 を
      具体地点レベルで定量化する研究になる。</li>
</ul>

<h4>発展課題 4 (ペア構造の応用 由来)</h4>
<ul>
  <li><b>結果 X</b>: dataset 67 (XML) と 68 (PDF) は (lat,lon,date) で
      ペア率 {pair_rate*100:.1f}%。「機械可読 + 人間可読」 の 2 表現が必ず揃う。</li>
  <li><b>新仮説 Y</b>: ペア構造は<b>QA/QC 自動化</b>の鍵となる。
      機械が XML から N値・層序を抽出 → PDF を画像認識でクロスチェック →
      不一致を自動報告するワークフローで、
      人間目視の必要時間を <b>1/10</b> に削減できる。</li>
  <li><b>課題 Z</b>: 試作として、5 サンプルの XML から SPT データを抽出し、
      対応する PDF を <code>pdf2image</code> で画像化、Tesseract OCR で N値表を読み取り、
      XML 値との一致率を計算。一致率 &gt; 95% なら<b>機械化が現実的</b>と確認できる。
      さらに大規模化すれば全 2,304 件の<b>自動 QA システム</b>に発展できる。
      これは DoBoX の<b>「データ + メタデータ + 監査ツール」</b>という新世代のオープンデータ設計を
      研究的に提案する出発点になる。</li>
</ul>
"""


# ----- Combine sections -----
sections = [
    ("学習目標と問い", sec1),
    ("使用データ", sec2),
    ("ダウンロード", sec3),
    ("【分析 1】 ボーリング 2,304 地点の地理分布 — 沿岸都市集中",
     sec4_intro
     + "<h3>実装コード</h3>"
     + sec4_code
     + sec4_fig1_text
     + figure("assets/L51_fig1_point_map.png", "図 1: ボーリング 2,304 点マップ")
     + sec4_fig1_read
     + sec4_fig2_text
     + figure("assets/L51_fig2_city_choropleth.png", "図 2: 市町別件数コロプレス")
     + sec4_fig2_read
     + "<h3>表: 市町別 ボーリング件数 (Top 15)</h3>"
     + df_to_html(T_city.head(15))
     + f"<p><b>この表から読み取れること</b>: "
       f"上位 5 市町で {top5_share*100:.0f}% を占める。"
       f"都市計画区域外判定の点は {int(T_city[T_city['city_name']=='(都市計画区域外)']['件数'].iloc[0]) if (T_city['city_name']=='(都市計画区域外)').any() else 0} 件で、"
       f"これは行政区域 GeoJSON が都市計画区域のみカバーする制約による (=未カバー 3 町)。"
       f"sjoin 自体の精度は十分。地理偏在は<b>公共事業の地理的偏在</b>を映す。</p>"
    ),
    ("【分析 2】 総掘進長の対数正規分布 — 経済原理が生む対数尺",
     sec5_intro
     + "<h3>実装コード</h3>"
     + sec5_code
     + sec5_fig3_text
     + figure("assets/L51_fig3_depth_dist.png", "図 3: 総掘進長 log10 + 累積分布")
     + sec5_fig3_read
     + "<h3>表: 総掘進長 統計</h3>"
     + df_to_html(T_depth)
     + f"<p><b>この表から読み取れること</b>: "
       f"中央値 {depth_pos.median():.1f}m は<b>道路擁壁・小型橋梁基礎</b>の標準スケール。"
       f"P90 = {depth_pos.quantile(0.9):.1f}m は<b>大型構造物</b>の基礎深度。"
       f"log10 範囲 {log_depth_range:.2f} 桁 → <b>H2 (対数正規) を支持</b>。</p>"
    ),
    ("【分析 3】 N値深度プロファイル + 1 詳細柱状図 — 地盤工学のミクロ",
     sec6_intro
     + "<h3>実装コード</h3>"
     + sec6_code
     + sec6_fig4_text
     + figure("assets/L51_fig4_n_depth.png", "図 4: N値の深度プロファイル")
     + sec6_fig4_read
     + sec6_fig7_text
     + figure("assets/L51_fig7_detail_log.png", "図 7: 1 ボーリング詳細柱状図")
     + sec6_fig7_read
     + "<h3>表: N値 深度ビン別統計</h3>"
     + df_to_html(T_n)
     + f"<p><b>この表から読み取れること</b>: "
       f"0-3m 中央値 = {n_shallow:.1f}, 10-20m 中央値 = {n_deep:.1f} → "
       f"<b>H3 (深度逓増) を支持</b>。"
       f"軟弱率 (N&lt;10) は浅いビンで高く、深いビンで低い → "
       f"液状化リスクは表層に集中する古典的傾向と整合。</p>"
     + "<h3>表: 詳細サンプル — 岩石土層</h3>"
     + df_to_html(detail_layers_df, max_rows=15)
     + f"<p><b>この表から読み取れること</b>: "
       f"地点 ({addr[:20]}) の鉛直層序が "
       f"{len(detail_layers_df)} 区間で記述されている。"
       f"上端から下端までの<b>厚さ</b>と<b>大分類</b>が読める。"
       f"これは XML の <code>&lt;岩石土区分&gt;</code> 要素を直接展開したもの。</p>"
     + "<h3>表: 詳細サンプル — SPT 全打点</h3>"
     + df_to_html(detail_spt_df)
     + f"<p><b>この表から読み取れること</b>: "
       f"地点 ({addr[:20]}) の N値プロファイル。"
       f"深度ごとの打撃回数 = N値 が読める。"
       f"N=50 の行は<b>打止め</b> (= 試験中止)。"
       f"地盤工学者はこの表を見ながら「支持層深度」 を判断する。</p>"
    ),
    ("【分析 4】 岩石土大分類 + N値地理分布 — まさ土の支配",
     sec7_intro
     + "<h3>実装コード</h3>"
     + sec7_code
     + sec7_fig5_text
     + figure("assets/L51_fig5_soil_class.png", "図 5: 岩石土大分類 (厚さ% + 件数)")
     + sec7_fig5_read
     + sec7_fig8_text
     + figure("assets/L51_fig8_n_map.png", "図 8: N値中央値の地理分布")
     + sec7_fig8_read
     + "<h3>表: 岩石土大分類 (7 値)</h3>"
     + df_to_html(soil_summary)
     + f"<p><b>この表から読み取れること</b>: "
       f"花崗岩・まさ土の厚さシェア = {masa_share:.1f}% で支配的。"
       f"これは広島県の<b>地質的特性</b>を映す。"
       f"<b>H4 (まさ土の支配) を{'支持' if masa_share>=40 else '部分支持'}</b>。</p>"
     + "<h3>表: 土質名 Top 15 (生表記)</h3>"
     + df_to_html(T_soil_top)
     + f"<p><b>この表から読み取れること</b>: "
       f"生表記レベルでは「花崗岩」 系が最頻、それに次いで「砂礫」 「シルト」 等。"
       f"表記揺れ (例: 花崗岩 / 風化花崗岩 / 強風化花崗岩) は大分類で集約される。"
       f"地質学者の自由記述から学習データセットを作る場合は、"
       f"このような<b>キーワード正規化</b>が必須。</p>"
    ),
    ("【分析 5】 時系列 + dataset ペア検証 — 西日本豪雨の影と 2 表現の必然",
     sec8_intro
     + "<h3>実装コード</h3>"
     + sec8_code
     + sec8_fig6_text
     + figure("assets/L51_fig6_timeline.png", "図 6: 調査月の時系列分布")
     + sec8_fig6_read
     + sec8_fig9_text
     + figure("assets/L51_fig9_pair.png", "図 9: 67 ⇔ 68 ペア対応")
     + sec8_fig9_read
     + "<h3>表: 年別件数</h3>"
     + df_to_html(T_year, max_rows=20)
     + f"<p><b>この表から読み取れること</b>: "
       f"2018 年から件数が顕著に増加。これは 2018-07 西日本豪雨と整合。"
       f"2010 年代後半以降が現代的データの主体。</p>"
     + "<h3>表: 67 ⇔ 68 ペア検証</h3>"
     + df_to_html(T_pair)
     + f"<p><b>この表から読み取れること</b>: "
       f"ペア率 {pair_rate*100:.1f}% → <b>H6 (完全ペア) を{'支持' if pair_rate>=0.95 else '部分支持'}</b>。"
       f"残り {100-pair_rate*100:.1f}% は表記揺れ・更新タイミング差と推察。"
       f"DoBoX の <b>「機械可読 + 人間可読 の 2 表現を必ずペアで配信する」</b> 設計思想を実証する。</p>"
    ),
    ("仮説検証総合", sec9),
    ("発展課題", sec10),
]


html = render_lesson(
    num=51,
    title="地盤情報 2 件統合分析 — ボーリング XML / PDF から地盤強度の地理学を読み解く",
    tags=["L51", "地盤情報", "ボーリング", "N値", "SPT", "まさ土", "西日本豪雨",
          "GeoDataFrame", "ペア構造", "Format A"],
    time="40 分",
    level="中級",
    data_label="DoBoX dataset 67 (XML 2,304件) + 68 (PDF 2,304件)",
    sections=sections,
    script_filename="L51_geological_data.py",
)

OUT_HTML = LESSONS / "L51_geological_data.html"
OUT_HTML.write_text(html, encoding="utf-8")
print(f"  HTML written: {OUT_HTML}")
print(f"  ({time.time()-t1:.1f}s)", flush=True)


print(f"\n=== L51 DONE in {time.time()-t_all:.1f}s ===")
