guess_lattice_parameters.py ダウンロード/コピー

guess_lattice_parameters.py をダウンロード

guess_lattice_parameters.py
guess_lattice_parameters.py
   1#!/usr/bin/env python3
   2from __future__ import annotations
   3
   4import argparse
   5import itertools
   6import importlib.util
   7import io
   8import math
   9import sys
  10from dataclasses import dataclass, asdict
  11from pathlib import Path
  12from typing import Iterable
  13
  14import matplotlib
  15import numpy as np
  16import pandas as pd
  17import scipy.signal
  18from openpyxl import Workbook
  19from openpyxl.styles import Font
  20
  21import matplotlib.pyplot as plt
  22
  23
  24"""
  25概要:
  26    粉末X線回折データのためのピーク探索と格子定数推定を行うスクリプトです。
  27詳細説明:
  28    このスクリプトは、X線回折データからピークを検出し、その情報に基づいて
  29    様々な結晶系の格子定数を推定する機能を提供します。
  30    また、検出されたピークと理論的な回折線を比較し、結果をグラフやExcelファイルとして出力できます。
  31主な機能:
  32    1. ピーク探索: Savitzky-Golayフィルターと3次導関数ゼロ交差法を用いて、XRDデータからピークを検出します。
  33    2. Kα2割り当て: 検出されたピークの中からKα2ピークを識別し、対応するKα1ピークと関連付けます。
  34    3. 格子定数推定: 検出ピークの逆d二乗値に基づいて、結晶系ごとの格子定数を推定します。
  35       推定方法には、最小二乗法に基づく組み合わせ探索と、格子定数グリッド探索があります。
  36    4. 結果比較とプロット: 推定された格子定数、またはユーザーが指定した格子定数と観測ピークを比較し、
  37       理論的な回折線を重ね合わせたプロットを生成します。
  38    5. データ入出力: 生のXRDデータファイル、ピーク情報ファイル、推定結果ファイル(すべてExcel形式を推奨)の読み書きをサポートします。
  39関連リンク:
  40    guess_lattice_parameters_usage
  41"""
  42
  43
  44CU_KA1 = 1.54056
  45CU_KA2 = 1.54439
  46ANGLE_EPS = 1.0e-12
  47
  48SUPPORTED_CRYSTAL_SYSTEMS = ["hexagonal", "orthorhombic", "tetragonal", "cubic"]
  49CRYSTAL_SYSTEM_ALIASES = {
  50    "auto": "auto",
  51    "all": "auto",
  52    "any": "auto",
  53    "hex": "hexagonal",
  54    "hexagonal": "hexagonal",
  55    "orth": "orthorhombic",
  56    "ortho": "orthorhombic",
  57    "orthorhombic": "orthorhombic",
  58    "tet": "tetragonal",
  59    "tetragonal": "tetragonal",
  60    "cub": "cubic",
  61    "cubic": "cubic",
  62}
  63KNOWN_MODES = {"search", "guess", "all", "compare", "calc", "index", "seaerch"}
  64SUPPORTED_METHODS = ["combinatorial", "grid", "both"]
  65
  66
  67@dataclass
  68class Peak:
  69    """
  70    概要:
  71        検出されたピークの情報を保持するデータクラスです。
  72    属性:
  73        :param index: データ配列におけるピークのインデックス。
  74        :type index: int
  75        :param two_theta: ピークの中心角度2θ(度)。
  76        :type two_theta: float
  77        :param intensity: スムージング後のピーク強度。
  78        :type intensity: float
  79        :param intensity_raw: 元データのピーク強度。
  80        :type intensity_raw: float
  81        :param fwhm_deg: 半値全幅(度)。
  82        :type fwhm_deg: float
  83        :param inv_d2: d間隔の二乗の逆数(1 / d^2)。
  84        :type inv_d2: float
  85        :param d: d間隔(オングストローム)。
  86        :type d: float
  87        :param q: 散乱ベクトルq(A^-1)。
  88        :type q: float
  89        :param ka_role: Kα1またはKα2の役割を示す文字列("ka1", "ka2", "")。
  90        :type ka_role: str
  91        :param ka_pair_index: Kαペアの相手のピークのインデックス(存在しない場合はNone)。
  92        :type ka_pair_index: int | None
  93        :param source: ピークの検出元("detected"またはファイルパス)。
  94        :type source: str
  95    """
  96    index: int
  97    two_theta: float
  98    intensity: float
  99    intensity_raw: float
 100    fwhm_deg: float
 101    inv_d2: float
 102    d: float
 103    q: float
 104    ka_role: str = ""
 105    ka_pair_index: int | None = None
 106    source: str = "detected"
 107
 108
 109@dataclass
 110class Candidate:
 111    """
 112    概要:
 113        格子定数推定の候補結果を保持するデータクラスです。
 114    属性:
 115        :param crystal_system: 候補の結晶系(例: "cubic", "tetragonal")。
 116        :type crystal_system: str
 117        :param ls_code: 結晶系に対応する最小二乗コード。
 118        :type ls_code: int
 119        :param params: 格子定数と角度の辞書。
 120        :type params: dict[str, float]
 121        :param score_matches: マッチしたピークの数。
 122        :type score_matches: int
 123        :param score_rms_rel: 相対RMS誤差。
 124        :type score_rms_rel: float
 125        :param selected_matches: 推定に使用されたピークのマッチング詳細のリスト。
 126        :type selected_matches: list[dict]
 127        :param all_matches: 全ての観測ピークのマッチング詳細のリスト。
 128        :type all_matches: list[dict]
 129    """
 130    crystal_system: str
 131    ls_code: int
 132    params: dict[str, float]
 133    score_matches: int
 134    score_rms_rel: float
 135    selected_matches: list[dict]
 136    all_matches: list[dict]
 137
 138
 139def bragg_d(two_theta_deg: float, wavelength: float = CU_KA1) -> float:
 140    """
 141    概要:
 142        ブラッグの法則に基づいてd間隔を計算します。
 143    詳細説明:
 144        指定された2θ角度とX線波長から、ブラッグの法則 (n * lambda = 2 * d * sin(theta)) を用いて
 145        回折面間隔dを計算します。thetaはtwo_theta_degの半分です。
 146    引数:
 147        :param two_theta_deg: 回折角度2θ(度)。
 148        :type two_theta_deg: float
 149        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
 150        :type wavelength: float
 151    戻り値:
 152        :returns: 計算されたd間隔(オングストローム)。
 153        :rtype: float
 154    """
 155    theta = math.radians(two_theta_deg / 2.0)
 156    s = math.sin(theta)
 157    if s <= 0:
 158        return float("inf")
 159    return wavelength / (2.0 * s)
 160
 161
 162def inv_d2_from_two_theta(two_theta_deg: float, wavelength: float = CU_KA1) -> float:
 163    """
 164    概要:
 165        2θ角度からd間隔の二乗の逆数 (1 / d^2) を計算します。
 166    詳細説明:
 167        ブラッグの法則を用いてd間隔を計算し、その二乗の逆数を返します。
 168    引数:
 169        :param two_theta_deg: 回折角度2θ(度)。
 170        :type two_theta_deg: float
 171        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
 172        :type wavelength: float
 173    戻り値:
 174        :returns: d間隔の二乗の逆数(inv_d2)。
 175        :rtype: float
 176    """
 177    d = bragg_d(two_theta_deg, wavelength)
 178    if not math.isfinite(d) or d <= 0:
 179        return float("nan")
 180    return 1.0 / (d * d)
 181
 182
 183def two_theta_from_d(d: float, wavelength: float = CU_KA1) -> float | None:
 184    """
 185    概要:
 186        d間隔から2θ角度を計算します。
 187    詳細説明:
 188        ブラッグの法則を逆算して、指定されたd間隔とX線波長に対応する回折角度2θを計算します。
 189        2 * d * sin(theta) = wavelength の関係を使用します。
 190    引数:
 191        :param d: d間隔(オングストローム)。
 192        :type d: float
 193        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
 194        :type wavelength: float
 195    戻り値:
 196        :returns: 計算された2θ角度(度)、または回折が物理的に不可能な場合はNone。
 197        :rtype: float | None
 198    """
 199    x = wavelength / (2.0 * d)
 200    if not (0.0 < x < 1.0):
 201        return None
 202    return math.degrees(2.0 * math.asin(x))
 203
 204
 205def two_theta_from_inv_d2(inv_d2: float, wavelength: float = CU_KA1) -> float | None:
 206    """
 207    概要:
 208        d間隔の二乗の逆数 (1 / d^2) から2θ角度を計算します。
 209    詳細説明:
 210        inv_d2値からd間隔を計算し、そのd間隔とX線波長に対応する2θ角度を返します。
 211    引数:
 212        :param inv_d2: d間隔の二乗の逆数。
 213        :type inv_d2: float
 214        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
 215        :type wavelength: float
 216    戻り値:
 217        :returns: 計算された2θ角度(度)、または計算が不可能な場合はNone。
 218        :rtype: float | None
 219    """
 220    if inv_d2 <= 0:
 221        return None
 222    return two_theta_from_d(1.0 / math.sqrt(inv_d2), wavelength)
 223
 224
 225def sanitize_stem(text: str) -> str:
 226    """
 227    概要:
 228        ファイル名として安全なステム文字列を生成します。
 229    詳細説明:
 230        入力された文字列から、ファイル名に使用できない特殊文字を除去し、
 231        英数字、ハイフン、アンダースコア、ピリオドのみを含む文字列を返します。
 232        連続するアンダースコアは1つにまとめられます。
 233    引数:
 234        :param text: 処理する文字列。
 235        :type text: str
 236    戻り値:
 237        :returns: サニタイズされたファイル名ステム。
 238        :rtype: str
 239    """
 240    return "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in text).strip("_") or "output"
 241
 242
 243def read_xy_data(path: Path) -> tuple[np.ndarray, np.ndarray]:
 244    """
 245    概要:
 246        指定されたファイルからX-Yデータ(2θ-強度データ)を読み込みます。
 247    詳細説明:
 248        ファイルの種類(Excelまたはテキスト/CSV)を判別し、
 249        最初の2列をそれぞれX(2θ)とY(強度)として読み込みます。
 250        数値変換エラーや非有限値は除去され、X値でソートされます。
 251    引数:
 252        :param path: 入力データファイルへのパス。
 253        :type path: Path
 254    戻り値:
 255        :returns: X値のnumpy.ndarray、Y値のnumpy.ndarrayのタプル。
 256        :rtype: tuple
 257    例外:
 258        :raises ValueError: スプレッドシートやテキストデータに必要な列数がない場合。
 259    """
 260    suffix = path.suffix.lower()
 261    if suffix in {".xlsx", ".xls"}:
 262        df = pd.read_excel(path)
 263        if df.shape[1] < 2:
 264            raise ValueError("スプレッドシートには少なくとも2列が必要です。")
 265        x = pd.to_numeric(df.iloc[:, 0], errors="coerce").to_numpy()
 266        y = pd.to_numeric(df.iloc[:, 1], errors="coerce").to_numpy()
 267    else:
 268        df = pd.read_csv(path, sep=None, engine="python", header=None, comment="#")
 269        if df.shape[1] < 2:
 270            raise ValueError("テキストデータには少なくとも2列が必要です。")
 271        x = pd.to_numeric(df.iloc[:, 0], errors="coerce").to_numpy()
 272        y = pd.to_numeric(df.iloc[:, 1], errors="coerce").to_numpy()
 273    mask = np.isfinite(x) & np.isfinite(y)
 274    x = x[mask].astype(float)
 275    y = y[mask].astype(float)
 276    order = np.argsort(x)
 277    x = x[order]
 278    y = y[order]
 279    return x, y
 280
 281
 282def _interp_zero(x1: float, y1: float, x2: float, y2: float) -> float:
 283    """
 284    概要:
 285        2点間の線形補間によってY値がゼロになるX値を推定します。
 286    詳細説明:
 287        (x1, y1) と (x2, y2) の2点を通る直線上で、y = 0 となるxの値を計算します。
 288        y2 - y1 が極めて小さい場合は、単純に2点の中間点を返します。
 289    引数:
 290        :param x1: 最初の点のX座標。
 291        :type x1: float
 292        :param y1: 最初の点のY座標。
 293        :type y1: float
 294        :param x2: 2番目の点のX座標。
 295        :type x2: float
 296        :param y2: 2番目の点のY座標。
 297        :type y2: float
 298    戻り値:
 299        :returns: Y値がゼロとなるX値。
 300        :rtype: float
 301    """
 302    if abs(y2 - y1) <= ANGLE_EPS:
 303        return 0.5 * (x1 + x2)
 304    return float(x1 - y1 * (x2 - x1) / (y2 - y1))
 305
 306
 307def _find_zero_crossing_bounds(
 308    x: np.ndarray,
 309    ydiff3: np.ndarray,
 310    idx: int,
 311) -> tuple[tuple[int, float] | None, tuple[int, float] | None]:
 312    """
 313    概要:
 314        与えられたインデックスの周りで3次導関数のゼロ交差を見つけます。
 315    詳細説明:
 316        ピーク位置のインデックス idx を中心に、ydiff3(3次導関数)の符号が変化する点を
 317        左右方向に探索し、そのインデックスと補間されたX座標を返します。
 318    引数:
 319        :param x: X座標のnumpy配列。
 320        :type x: numpy.ndarray
 321        :param ydiff3: 3次導関数のnumpy配列。
 322        :type ydiff3: numpy.ndarray
 323        :param idx: ピーク中心のインデックス。
 324        :type idx: int
 325    戻り値:
 326        :returns: 左側のゼロ交差情報 (インデックス, X座標) と右側のゼロ交差情報のタプル。
 327                  見つからない場合はNoneが含まれます。
 328        :rtype: tuple[tuple[int, float] | None, tuple[int, float] | None]
 329    """
 330    left_info = None
 331    right_info = None
 332
 333    for i in range(idx, 0, -1):
 334        y1 = float(ydiff3[i - 1])
 335        y2 = float(ydiff3[i])
 336        if y1 == 0.0 or y1 * y2 <= 0.0:
 337            xz = _interp_zero(float(x[i - 1]), y1, float(x[i]), y2)
 338            left_info = (i, xz)
 339            break
 340
 341    for i in range(idx + 1, len(x)):
 342        y1 = float(ydiff3[i - 1])
 343        y2 = float(ydiff3[i])
 344        if y2 == 0.0 or y1 * y2 <= 0.0:
 345            xz = _interp_zero(float(x[i - 1]), y1, float(x[i]), y2)
 346            right_info = (i, xz)
 347            break
 348
 349    return left_info, right_info
 350
 351
 352def estimate_fwhm(
 353    x: np.ndarray,
 354    ysmooth: np.ndarray,
 355    idx: int,
 356    ydiff3: np.ndarray | None = None,
 357) -> float | None:
 358    """
 359    概要:
 360        ピークの半値全幅(FWHM)を推定します。
 361    詳細説明:
 362        ピークトップの周辺のデータを用いてFWHMを計算します。
 363        ydiff3(3次導関数)が提供されている場合、3次導関数のゼロ交差を境界として
 364        局所的なFWHMを優先的に推定します。これにより、隣接するピークの影響を軽減します。
 365        ydiff3が提供されない場合や、局所的な推定が不可能な場合は、
 366        ピークトップの周辺の最小強度をベースラインとしてFWHMを計算します。
 367    引数:
 368        :param x: X座標のnumpy配列。
 369        :type x: numpy.ndarray
 370        :param ysmooth: スムージングされたY座標のnumpy配列。
 371        :type ysmooth: numpy.ndarray
 372        :param idx: FWHMを推定するピークトップのインデックス。
 373        :type idx: int
 374        :param ydiff3: 3次導関数のnumpy配列。デフォルトはNone。
 375        :type ydiff3: numpy.ndarray | None
 376    戻り値:
 377        :returns: 推定されたFWHM(度)、または推定できなかった場合はNone。
 378        :rtype: float | None
 379    """
 380    ytop = float(ysmooth[idx])
 381    if not math.isfinite(ytop) or ytop <= 0.0:
 382        return None
 383
 384    left_info = None
 385    right_info = None
 386    if ydiff3 is not None:
 387        left_info, right_info = _find_zero_crossing_bounds(x, ydiff3, idx)
 388
 389    if left_info is not None and right_info is not None:
 390        il, xl0 = left_info
 391        ir, xr0 = right_info
 392        if il < idx < ir:
 393            ybg_left = float(ysmooth[max(il - 1, 0)])
 394            ybg_right = float(ysmooth[min(ir, len(ysmooth) - 1)])
 395            ybg = min(ybg_left, ybg_right)
 396            half = ybg + 0.5 * (ytop - ybg)
 397
 398            xl = None
 399            for i in range(idx, il - 1, -1):
 400                if i <= 0:
 401                    break
 402                y1 = float(ysmooth[i - 1])
 403                y2 = float(ysmooth[i])
 404                if (y1 - half) == 0.0 or (y1 - half) * (y2 - half) <= 0.0:
 405                    xl = _interp_zero(float(x[i - 1]), y1 - half, float(x[i]), y2 - half)
 406                    break
 407
 408            xr = None
 409            for i in range(idx + 1, ir + 1):
 410                if i >= len(x):
 411                    break
 412                y1 = float(ysmooth[i - 1])
 413                y2 = float(ysmooth[i])
 414                if (y2 - half) == 0.0 or (y1 - half) * (y2 - half) <= 0.0:
 415                    xr = _interp_zero(float(x[i - 1]), y1 - half, float(x[i]), y2 - half)
 416                    break
 417
 418            if xl is not None and xr is not None and xr > xl:
 419                return float(xr - xl)
 420
 421            width_like = float(xr0 - xl0)
 422            if width_like > 0.0:
 423                return width_like
 424
 425    lo = max(0, idx - 10)
 426    hi = min(len(x), idx + 11)
 427    local_bg = float(np.min(ysmooth[lo:hi]))
 428    half = local_bg + 0.5 * (ytop - local_bg)
 429
 430    il = idx
 431    while il > 0 and ysmooth[il] > half:
 432        il -= 1
 433    ir = idx
 434    while ir < len(x) - 1 and ysmooth[ir] > half:
 435        ir += 1
 436
 437    if il >= idx or ir <= idx:
 438        return None
 439
 440    if abs(float(ysmooth[il + 1]) - float(ysmooth[il])) <= ANGLE_EPS:
 441        xl = float(x[il])
 442    else:
 443        xl = float(np.interp(half, [ysmooth[il], ysmooth[il + 1]], [x[il], x[il + 1]]))
 444
 445    if abs(float(ysmooth[ir]) - float(ysmooth[ir - 1])) <= ANGLE_EPS:
 446        xr = float(x[ir])
 447    else:
 448        xr = float(np.interp(half, [ysmooth[ir - 1], ysmooth[ir]], [x[ir - 1], x[ir]]))
 449
 450    return float(xr - xl) if xr > xl else None
 451
 452
 453def peak_search_deriv3(
 454    x: np.ndarray,
 455    y: np.ndarray,
 456    nsmooth: int = 11,
 457    norder: int = 4,
 458    threshold: float = 1000.0,
 459    ydiff1_threshold: float = 1.0e-2,
 460    fwhm_min_deg: float = 0.06,
 461    fwhm_max_deg: float = 1.0,
 462    is_print: bool = True,
 463) -> tuple[list[Peak], dict[str, np.ndarray | float]]:
 464    """
 465    概要:
 466        Savitzky-Golayフィルターと3次導関数ゼロ交差法を用いてピークを探索します。
 467    詳細説明:
 468        入力X線回折データ (x, y) に対してSavitzky-Golayフィルターを適用し、
 469        スムージングされたデータおよび1次、2次、3次導関数を計算します。
 470        3次導関数のゼロ交差点をピーク候補点として抽出し、強度閾値、FWHM範囲、
 471        2次導関数の符号などの基準でフィルタリングを行い、有効なピークを特定します。
 472        重複するピークは強度に基づいてマージされます。
 473    引数:
 474        :param x: 2θ角度のnumpy配列。
 475        :type x: numpy.ndarray
 476        :param y: 強度のnumpy配列。
 477        :type y: numpy.ndarray
 478        :param nsmooth: Savitzky-Golayフィルターのウィンドウサイズ(奇数)。
 479        :type nsmooth: int
 480        :param norder: Savitzky-Golayフィルターの多項式の次数。
 481        :type norder: int
 482        :param threshold: ピーク強度の最小閾値。
 483        :type threshold: float
 484        :param ydiff1_threshold: 1次導関数の相対閾値(診断用)。
 485        :type ydiff1_threshold: float
 486        :param fwhm_min_deg: 許容されるFWHMの最小値(度)。
 487        :type fwhm_min_deg: float
 488        :param fwhm_max_deg: 許容されるFWHMの最大値(度)。
 489        :type fwhm_max_deg: float
 490        :param is_print: 診断情報をコンソールに出力するかどうか。
 491        :type is_print: bool
 492    戻り値:
 493        :returns: 検出されたPeakオブジェクトのリストと、スムージングデータや導関数などの診断情報の辞書。
 494        :rtype: tuple[list[Peak], dict[str, numpy.ndarray | float]]
 495    例外:
 496        :raises ValueError: データポイントが不足している場合。
 497    """
 498    if len(x) < max(nsmooth + 2, 7):
 499        raise ValueError("データポイントが不足しています。")
 500    if nsmooth % 2 == 0:
 501        nsmooth += 1
 502    if nsmooth <= norder:
 503        nsmooth = norder + 3 if (norder + 3) % 2 == 1 else norder + 4
 504
 505    h = float(np.median(np.diff(x)))
 506    ysmooth = scipy.signal.savgol_filter(y, nsmooth, norder, deriv=0)
 507    ydiff1 = scipy.signal.savgol_filter(y, nsmooth, norder, deriv=1) / h
 508    ydiff2 = scipy.signal.savgol_filter(ydiff1, nsmooth, norder, deriv=1) / h
 509    ydiff3 = scipy.signal.savgol_filter(ydiff2, nsmooth, norder, deriv=1) / h
 510    ydiff3 = scipy.signal.savgol_filter(ydiff3, nsmooth, norder, deriv=0)
 511
 512    diff1_ratio = np.abs(ydiff1 / np.maximum(ysmooth, 1.0e-5))
 513    max_diff1_ratio = float(np.max(diff1_ratio))
 514    diff1_ratio_th = max_diff1_ratio * ydiff1_threshold
 515
 516    peaks: list[Peak] = []
 517    last_accept_idx = -10**9
 518    visited_idx: set[int] = set()
 519
 520    if is_print:
 521        print("")
 522        print("=== ピーク探索診断 ===")
 523        print(f"nsmooth          : {nsmooth}")
 524        print(f"norder           : {norder}")
 525        print(f"threshold        : {threshold}")
 526        print(f"FWHM window      : {fwhm_min_deg} - {fwhm_max_deg} deg")
 527        print(f"|dy/dx|/y thresh : {diff1_ratio_th:.6g} (診断のみ)")
 528        print("")
 529
 530    for i in range(1, len(x)):
 531        if ydiff3[i - 1] == 0.0 or ydiff3[i - 1] * ydiff3[i] <= 0.0:
 532            lo = max(0, i - max(3, nsmooth // 2))
 533            hi = min(len(x), i + max(4, nsmooth // 2 + 1))
 534            idx = lo + int(np.argmax(ysmooth[lo:hi]))
 535            if idx in visited_idx:
 536                continue
 537            visited_idx.add(idx)
 538            if idx - last_accept_idx <= 1:
 539                continue
 540
 541            ytop = float(ysmooth[idx])
 542            d1ratio = float(abs(ydiff1[idx]) / max(ysmooth[idx], 1.0e-5))
 543            d2 = float(ydiff2[idx])
 544
 545            reason = None
 546            fwhm = estimate_fwhm(x, ysmooth, idx, ydiff3=ydiff3)
 547            if ytop < threshold:
 548                reason = "強度が閾値未満"
 549            elif d2 >= 0.0 and (fwhm is None or fwhm < fwhm_min_deg):
 550                reason = "最小値のような形状"
 551            elif fwhm is None:
 552                reason = "FWHMが利用不可"
 553            elif fwhm < fwhm_min_deg:
 554                reason = f"FWHMが小さすぎる ({fwhm:.4f})"
 555            elif fwhm > fwhm_max_deg:
 556                reason = f"FWHMが大きすぎる ({fwhm:.4f})"
 557
 558            if is_print:
 559                status = "採用" if reason is None else f"除外: {reason}"
 560                print(
 561                    f"2theta={x[idx]:8.3f}  I={ytop:10.3f}  FWHM={fwhm if fwhm is not None else float('nan'):7.4f}  "
 562                    f"|dy/dx|/y={d1ratio:10.5g}  d2={d2:10.5g}  -> {status}"
 563                )
 564
 565            if reason is not None:
 566                continue
 567
 568            inv_d2 = inv_d2_from_two_theta(float(x[idx]), CU_KA1)
 569            d = 1.0 / math.sqrt(inv_d2)
 570            q = 2.0 * math.pi / d
 571            peaks.append(
 572                Peak(
 573                    index=int(idx),
 574                    two_theta=float(x[idx]),
 575                    intensity=float(ysmooth[idx]),
 576                    intensity_raw=float(y[idx]),
 577                    fwhm_deg=float(fwhm),
 578                    inv_d2=float(inv_d2),
 579                    d=float(d),
 580                    q=float(q),
 581                )
 582            )
 583            last_accept_idx = idx
 584
 585    # ごく近い重複をマージ
 586    merged: list[Peak] = []
 587    for p in peaks:
 588        if merged and abs(p.two_theta - merged[-1].two_theta) < max(p.fwhm_deg, merged[-1].fwhm_deg) * 0.4:
 589            if p.intensity > merged[-1].intensity:
 590                merged[-1] = p
 591        else:
 592            merged.append(p)
 593
 594    info = {
 595        "ysmooth": ysmooth,
 596        "ydiff1": ydiff1,
 597        "ydiff2": ydiff2,
 598        "ydiff3": ydiff3,
 599        "max_diff1_ratio": max_diff1_ratio,
 600        "diff1_ratio_th": diff1_ratio_th,
 601    }
 602    return merged, info
 603
 604
 605def assign_ka2(peaks: list[Peak], tol_deg: float = 0.06, ratio_min: float = 0.18, ratio_max: float = 0.75) -> list[Peak]:
 606    """
 607    概要:
 608        検出されたピークにKα2の役割を割り当てます。
 609    詳細説明:
 610        Cu Kα1の波長に対応するピークに対して、Cu Kα2の波長で同じd間隔を持つピークを探索します。
 611        2θ角度の許容範囲 (tol_deg) とKα2/Kα1強度比 (ratio_min, ratio_max) に基づいてペアを特定し、
 612        それぞれのピークに "ka1" または "ka2" の役割を割り当てます。
 613        最も強いピークから順に処理し、一度Kα2として使用されたピークは再利用されません。
 614    引数:
 615        :param peaks: Peakオブジェクトのリスト。
 616        :type peaks: list[Peak]
 617        :param tol_deg: Kα2ペアを識別するための2θ角度の許容誤差(度)。
 618        :type tol_deg: float
 619        :param ratio_min: Kα2ピークとKα1ピークの強度比の最小値。
 620        :type ratio_min: float
 621        :param ratio_max: Kα2ピークとKα1ピークの強度比の最大値。
 622        :type ratio_max: float
 623    戻り値:
 624        :returns: Kα役割が割り当てられたPeakオブジェクトのリスト。
 625        :rtype: list[Peak]
 626    """
 627    for p in peaks:
 628        p.ka_role = ""
 629        p.ka_pair_index = None
 630    # 強度/幅が強い/狭い順
 631    order = sorted(range(len(peaks)), key=lambda i: peaks[i].intensity, reverse=True)
 632    used_secondary = set()
 633    for i in order:
 634        p1 = peaks[i]
 635        d = bragg_d(p1.two_theta, CU_KA1)
 636        tt2 = two_theta_from_d(d, CU_KA2)
 637        if tt2 is None:
 638            continue
 639        best_j = None
 640        best_delta = None
 641        for j, p2 in enumerate(peaks):
 642            if j == i or j in used_secondary:
 643                continue
 644            if p2.two_theta <= p1.two_theta:
 645                continue
 646            delta = abs(p2.two_theta - tt2)
 647            ratio = p2.intensity / max(p1.intensity, 1e-9)
 648            if delta <= tol_deg and ratio_min <= ratio <= ratio_max:
 649                if best_delta is None or delta < best_delta:
 650                    best_delta = delta
 651                    best_j = j
 652        if best_j is not None:
 653            peaks[i].ka_role = "ka1"
 654            peaks[i].ka_pair_index = best_j
 655            peaks[best_j].ka_role = "ka2"
 656            peaks[best_j].ka_pair_index = i
 657            used_secondary.add(best_j)
 658    return peaks
 659
 660
 661def system_rows(system: str, hmax: int = 12) -> list[tuple[tuple[int, ...], tuple[int, int, int]]]:
 662    """
 663    概要:
 664        指定された結晶系とhkl指数に対して、格子定数計算のための特徴量とhklの組み合わせを生成します。
 665    詳細説明:
 666        立方晶、正方晶、六方晶、斜方晶の各結晶系について、hmaxまでのhkl指数を生成します。
 667        各hkl組み合わせに対して、inv_d2 (1 / d^2) の計算に必要な特徴量タプル (feat) と、
 668        実際のhkl指数タプルをペアとしてリストに格納します。
 669        重複する特徴量を持つ組み合わせは除外され、特徴量の合計値でソートされます。
 670    引数:
 671        :param system: 結晶系名(例: "cubic", "hexagonal")。
 672        :type system: str
 673        :param hmax: h, k, l指数の最大値。
 674        :type hmax: int
 675    戻り値:
 676        :returns: (特徴量タプル, hklタプル) のリスト。
 677        :rtype: list[tuple[tuple[int, ...], tuple[int, int, int]]]
 678    例外:
 679        :raises ValueError: サポートされていない結晶系が指定された場合。
 680    """
 681    rows: list[tuple[tuple[int, ...], tuple[int, int, int]]] = []
 682    seen: set[tuple[int, ...]] = set()
 683    for h in range(hmax + 1):
 684        for k in range(hmax + 1):
 685            for l in range(hmax + 1):
 686                if h == 0 and k == 0 and l == 0:
 687                    continue
 688                hkl = (h, k, l)
 689                if system == "cubic":
 690                    feat = (h * h + k * k + l * l,)
 691                    hkl = tuple(sorted((h, k, l), reverse=True))
 692                elif system == "tetragonal":
 693                    feat = (h * h + k * k, l * l)
 694                    hk = tuple(sorted((h, k), reverse=True))
 695                    hkl = (hk[0], hk[1], l)
 696                elif system == "hexagonal":
 697                    feat = (h * h + h * k + k * k, l * l)
 698                elif system == "orthorhombic":
 699                    feat = (h * h, k * k, l * l)
 700                else:
 701                    raise ValueError(system)
 702                if all(v == 0 for v in feat):
 703                    continue
 704                if feat in seen:
 705                    continue
 706                seen.add(feat)
 707                rows.append((feat, hkl))
 708    rows.sort(key=lambda t: (sum(t[0]), t[0]))
 709    return rows
 710
 711
 712def params_from_beta(system: str, beta: np.ndarray) -> dict[str, float]:
 713    """
 714    概要:
 715        最小二乗法の係数 (beta) から格子定数を計算します。
 716    詳細説明:
 717        inv_d2 = beta[0]*feat[0] + beta[1]*feat[1] + ... の形式で得られた
 718        最小二乗法の係数 beta から、各結晶系に対応するa, b, c, alpha, beta, gammaの
 719        格子定数を導出します。
 720    引数:
 721        :param system: 結晶系名。
 722        :type system: str
 723        :param beta: 最小二乗法で得られた係数のnumpy配列。
 724        :type beta: numpy.ndarray
 725    戻り値:
 726        :returns: 格子定数を含む辞書。
 727        :rtype: dict[str, float]
 728    例外:
 729        :raises ValueError: サポートされていない結晶系が指定された場合。
 730    """
 731    if system == "cubic":
 732        a = 1.0 / math.sqrt(beta[0])
 733        return {"a": a, "b": a, "c": a, "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
 734    if system == "tetragonal":
 735        a = 1.0 / math.sqrt(beta[0])
 736        c = 1.0 / math.sqrt(beta[1])
 737        return {"a": a, "b": a, "c": c, "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
 738    if system == "hexagonal":
 739        a = math.sqrt(4.0 / (3.0 * beta[0]))
 740        c = 1.0 / math.sqrt(beta[1])
 741        return {"a": a, "b": a, "c": c, "alpha": 90.0, "beta": 90.0, "gamma": 120.0}
 742    if system == "orthorhombic":
 743        a = 1.0 / math.sqrt(beta[0])
 744        b = 1.0 / math.sqrt(beta[1])
 745        c = 1.0 / math.sqrt(beta[2])
 746        return {"a": a, "b": b, "c": c, "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
 747    raise ValueError(system)
 748
 749
 750def predicted_inv_d2(system: str, feat: Iterable[int], beta: np.ndarray) -> float:
 751    """
 752    概要:
 753        結晶系、特徴量、最小二乗係数から予測されるd間隔の二乗の逆数 (inv_d2) を計算します。
 754    詳細説明:
 755        与えられた結晶系、hkl指数から導出される特徴量 feat、
 756        および格子定数に相当する最小二乗係数 beta を用いて、
 757        理論的な inv_d2 値を計算します。
 758    引数:
 759        :param system: 結晶系名。
 760        :type system: str
 761        :param feat: hkl指数から導出される特徴量のイテラブル。
 762        :type feat: Iterable[int]
 763        :param beta: 最小二乗法で得られた係数のnumpy配列。
 764        :type beta: numpy.ndarray
 765    戻り値:
 766        :returns: 予測されるinv_d2値。
 767        :rtype: float
 768    例外:
 769        :raises ValueError: サポートされていない結晶系が指定された場合。
 770    """
 771    if system in {"cubic", "tetragonal", "hexagonal", "orthorhombic"}:
 772        return float(np.dot(np.asarray(tuple(feat), dtype=float), beta))
 773    raise ValueError(system)
 774
 775
 776def score_system(system: str, selected: list[Peak], all_peaks: list[Peak], hmax: int = 12) -> Candidate | None:
 777    """
 778    概要:
 779        特定の結晶系に対して、観測ピークと理論的回折線を比較し、格子定数候補を評価します。
 780    詳細説明:
 781        この関数は、与えられた結晶系に対し、選択された観測ピーク (selected) を用いて
 782        格子定数 (beta) を最小二乗法で推定します。
 783        システム行 (system_rows) から特徴量行列を構築し、観測された inv_d2 値との
 784        組み合わせでbetaを求めます。
 785        beta値が正である組み合わせの中から、マッチするピークの数と相対RMS誤差を
 786        スコアとして最適な候補を選定します。
 787        全ての観測ピーク (all_peaks) に対するマッチング情報も生成します。
 788    引数:
 789        :param system: 結晶系名。
 790        :type system: str
 791        :param selected: 格子定数推定に使用する、フィルタリングされたPeakオブジェクトのリスト。
 792        :type selected: list[Peak]
 793        :param all_peaks: 全ての観測されたPeakオブジェクトのリスト。
 794        :type all_peaks: list[Peak]
 795        :param hmax: h, k, l指数の最大値。
 796        :type hmax: int
 797    戻り値:
 798        :returns: 最適な格子定数候補を表すCandidateオブジェクト、または見つからない場合はNone。
 799        :rtype: Candidate | None
 800    """
 801    rows = system_rows(system, hmax=hmax)
 802    p = len(rows[0][0])
 803    pool = min(len(rows), 12)
 804    first_n = min(len(selected), max(p + 2, 5))
 805    if len(selected) < p:
 806        return None
 807
 808    ls_map = {"orthorhombic": 4, "tetragonal": 5, "cubic": 6, "hexagonal": 8}
 809    best = None
 810    obs = np.array([pk.inv_d2 for pk in selected], dtype=float)
 811    for obs_idx in __import__("itertools").combinations(range(first_n), p):
 812        y = np.array([obs[i] for i in obs_idx], dtype=float)
 813        for cand_idx in __import__("itertools").combinations(range(pool), p):
 814            X = np.array([rows[i][0] for i in cand_idx], dtype=float)
 815            try:
 816                beta = np.linalg.solve(X, y)
 817            except np.linalg.LinAlgError:
 818                continue
 819            if np.any(beta <= 0):
 820                continue
 821            pred_lines = np.array([predicted_inv_d2(system, feat, beta) for feat, _hkl in rows], dtype=float)
 822            selected_matches = []
 823            used = set()
 824            rels = []
 825            for pk in selected:
 826                rel = np.abs(pred_lines - pk.inv_d2) / np.maximum(pk.inv_d2, 1e-12)
 827                j = int(np.argmin(rel))
 828                if rel[j] <= 0.08 and j not in used:
 829                    used.add(j)
 830                    selected_matches.append({
 831                        "two_theta_obs_deg": pk.two_theta,
 832                        "inv_d2_obs": pk.inv_d2,
 833                        "h": rows[j][1][0],
 834                        "k": rows[j][1][1],
 835                        "l": rows[j][1][2],
 836                        "inv_d2_calc": float(pred_lines[j]),
 837                        "rel_error_inv_d2": float(rel[j]),
 838                    })
 839                    rels.append(float(rel[j]))
 840            if not selected_matches:
 841                continue
 842            rms = float(np.sqrt(np.mean(np.square(rels))))
 843            score = (len(selected_matches), -rms)
 844            if best is None or score > best["score"]:
 845                # 全てのピークを割り当て
 846                all_matches = []
 847                for pk in all_peaks:
 848                    rel = np.abs(pred_lines - pk.inv_d2) / np.maximum(pk.inv_d2, 1e-12)
 849                    j = int(np.argmin(rel))
 850                    matched = rel[j] <= 0.10
 851                    tt_calc = two_theta_from_inv_d2(float(pred_lines[j]), CU_KA1) if matched else None
 852                    rel_t = abs((tt_calc - pk.two_theta) / pk.two_theta) if (matched and tt_calc and pk.two_theta != 0) else None
 853                    all_matches.append({
 854                        "two_theta_obs_deg": pk.two_theta,
 855                        "intensity": pk.intensity,
 856                        "ka_role": pk.ka_role,
 857                        "fwhm_deg": pk.fwhm_deg,
 858                        "inv_d2_obs": pk.inv_d2,
 859                        "h": rows[j][1][0] if matched else None,
 860                        "k": rows[j][1][1] if matched else None,
 861                        "l": rows[j][1][2] if matched else None,
 862                        "inv_d2_calc": float(pred_lines[j]) if matched else None,
 863                        "rel_error_inv_d2": float(rel[j]) if matched else None,
 864                        "two_theta_calc_deg": tt_calc if matched else None,
 865                        "rel_error_2theta": rel_t if matched else None,
 866                        "matched": bool(matched),
 867                    })
 868                best = {
 869                    "score": score,
 870                    "beta": beta,
 871                    "params": params_from_beta(system, beta),
 872                    "selected_matches": selected_matches,
 873                    "all_matches": all_matches,
 874                }
 875
 876    if best is None:
 877        return None
 878    return Candidate(
 879        crystal_system=system,
 880        ls_code=ls_map[system],
 881        params=best["params"],
 882        score_matches=best["score"][0],
 883        score_rms_rel=float(-best["score"][1]),
 884        selected_matches=best["selected_matches"],
 885        all_matches=best["all_matches"],
 886    )
 887
 888
 889def import_lsq_module(path: Path):
 890    """
 891    概要:
 892        指定されたパスからlsq_latt2モジュールを動的にインポートします。
 893    詳細説明:
 894        lsq_latt2.py スクリプトを動的に読み込み、その中の関数やクラスを
 895        現在の実行コンテキストで利用できるようにします。
 896    引数:
 897        :param path: lsq_latt2.py スクリプトへのパス。
 898        :type path: Path
 899    戻り値:
 900        :returns: インポートされたモジュールオブジェクト。
 901        :rtype: module
 902    例外:
 903        :raises ImportError: モジュールをロードできない場合。
 904    """
 905    spec = importlib.util.spec_from_file_location("lsq_latt2_imported", path)
 906    if spec is None or spec.loader is None:
 907        raise ImportError(f"モジュール {path} をロードできません。")
 908    mod = importlib.util.module_from_spec(spec)
 909    sys.modules[spec.name] = mod
 910    spec.loader.exec_module(mod)
 911    return mod
 912
 913
 914def refine_with_lsq(lsq_script: Path, candidate: Candidate, matched_rows: list[dict], wavelength: float = CU_KA1):
 915    """
 916    概要:
 917        最小二乗法 (lsq_latt2) を用いて格子定数を精密化します。
 918    詳細説明:
 919        外部スクリプト lsq_latt2.py をインポートし、与えられた格子定数候補 (candidate) と
 920        マッチングされた回折ピーク情報 (matched_rows) を使って、格子定数の最小二乗精密化を実行します。
 921        精密化された格子定数と標準偏差、および各ピークの精密化結果を返します。
 922    引数:
 923        :param lsq_script: lsq_latt2.py スクリプトへのパス。
 924        :type lsq_script: Path
 925        :param candidate: 精密化のベースとなる格子定数候補。
 926        :type candidate: Candidate
 927        :param matched_rows: マッチングされた回折ピークの情報のリスト。
 928        :type matched_rows: list[dict]
 929        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
 930        :type wavelength: float
 931    戻り値:
 932        :returns: 精密化された格子定数の要約辞書と、精密化されたマッチング結果のリストのタプル。
 933                  マッチングされた観測がない場合はNoneを返します。
 934        :rtype: tuple[dict, list[dict]] | None
 935    """
 936    mod = import_lsq_module(lsq_script)
 937    obs_list = []
 938    serial = 0
 939    for row in matched_rows:
 940        if not row.get("matched"):
 941            continue
 942        serial += 1
 943        h, k, l = int(row["h"]), int(row["k"]), int(row["l"])
 944        design = mod.lattice_design_row(candidate.ls_code, h, k, l)
 945        obs_list.append(
 946            mod.Observation(
 947                serial_index=serial,
 948                h=h,
 949                k=k,
 950                l=l,
 951                raw_position=float(row["two_theta_obs_deg"]),
 952                transformed_position=float(row["inv_d2_obs"]),
 953                weight=1.0,
 954                design_row=design,
 955                wavelength=wavelength,
 956            )
 957        )
 958    if not obs_list:
 959        return None
 960
 961    fit = mod.weighted_least_squares(obs_list, io.StringIO())
 962    cell = mod.derive_cell_constants(candidate.ls_code, fit.parameters, fit.parameter_sigma)
 963
 964    refined_rows = []
 965    for obs, fitted, resid in zip(fit.kept_observations, fit.fitted_y, fit.residuals):
 966        tt_calc = two_theta_from_inv_d2(float(fitted), wavelength)
 967        rel_inv = abs(resid) / max(obs.transformed_position, 1e-12)
 968        rel_tt = abs((tt_calc - obs.raw_position) / obs.raw_position) if tt_calc is not None and obs.raw_position != 0 else None
 969        refined_rows.append({
 970            "serial": obs.serial_index,
 971            "h": obs.h,
 972            "k": obs.k,
 973            "l": obs.l,
 974            "two_theta_obs_deg": obs.raw_position,
 975            "two_theta_calc_deg": tt_calc,
 976            "rel_error_2theta": rel_tt,
 977            "inv_d2_obs": obs.transformed_position,
 978            "inv_d2_calc": float(fitted),
 979            "rel_error_inv_d2": float(rel_inv),
 980            "residual_inv_d2": float(resid),
 981        })
 982
 983    rms_inv = float(np.sqrt(np.mean([r["rel_error_inv_d2"] ** 2 for r in refined_rows]))) if refined_rows else None
 984    rms_tt = float(np.sqrt(np.mean([r["rel_error_2theta"] ** 2 for r in refined_rows if r["rel_error_2theta"] is not None]))) if refined_rows else None
 985
 986    summary = {
 987        "crystal_system": candidate.crystal_system,
 988        "ls_code": candidate.ls_code,
 989        "a": float(cell.direct_lengths[0]),
 990        "b": float(cell.direct_lengths[1]),
 991        "c": float(cell.direct_lengths[2]),
 992        "alpha": float(cell.direct_angles_deg[0]),
 993        "beta": float(cell.direct_angles_deg[1]),
 994        "gamma": float(cell.direct_angles_deg[2]),
 995        "sigma_a": float(cell.direct_length_sigma[0]),
 996        "sigma_b": float(cell.direct_length_sigma[1]),
 997        "sigma_c": float(cell.direct_length_sigma[2]),
 998        "sigma_alpha": float(cell.direct_angle_sigma_deg[0]),
 999        "sigma_beta": float(cell.direct_angle_sigma_deg[1]),
1000        "sigma_gamma": float(cell.direct_angle_sigma_deg[2]),
1001        "n_used": len(refined_rows),
1002        "rms_rel_error_inv_d2": rms_inv,
1003        "rms_rel_error_2theta": rms_tt,
1004    }
1005    return summary, refined_rows
1006
1007
1008
1009def _canonical_column_name(name: object) -> str:
1010    """
1011    概要:
1012        データフレームの列名を標準的な形式に変換します。
1013    詳細説明:
1014        列名文字列から特殊文字を除去し、小文字に変換します。
1015        例えば、"2Theta (deg)" は "two_theta_deg" に変換されます。
1016    引数:
1017        :param name: 列名オブジェクト。
1018        :type name: object
1019    戻り値:
1020        :returns: 標準化された列名文字列。
1021        :rtype: str
1022    """
1023    s = str(name).strip().lower()
1024    s = s.replace("θ", "theta")
1025    s = s.replace("°", "deg")
1026    for ch in " -/.()[]{}":
1027        s = s.replace(ch, "_")
1028    while "__" in s:
1029        s = s.replace("__", "_")
1030    return s.strip("_")
1031
1032
1033def _find_column(df: pd.DataFrame, candidates: set[str]) -> str | None:
1034    """
1035    概要:
1036        データフレーム内で候補リストに一致する列名を検索します。
1037    詳細説明:
1038        データフレーム df の列名を標準化 (canonical_column_name) した後、
1039        与えられた候補セット candidates のいずれかに一致するかどうかを調べます。
1040        最初に見つかった一致する元の列名を返します。
1041    引数:
1042        :param df: 検索対象のPandasデータフレーム。
1043        :type df: pandas.DataFrame
1044        :param candidates: 検索する列名の候補セット。
1045        :type candidates: set[str]
1046    戻り値:
1047        :returns: 一致する元の列名、または見つからない場合はNone。
1048        :rtype: str | None
1049    """
1050    for col in df.columns:
1051        if _canonical_column_name(col) in candidates:
1052            return str(col)
1053    return None
1054
1055
1056def _read_peak_dataframe(path: Path) -> pd.DataFrame:
1057    """
1058    概要:
1059        ピーク情報ファイルからPandasデータフレームを読み込みます。
1060    詳細説明:
1061        Excelファイルの場合、"peaks", "all_detected_peaks", "selected_for_indexing"
1062        のいずれかのシートを優先して読み込みます。これらのシートがない場合は最初のシートを読み込みます。
1063        CSV/テキストファイルの場合は、自動的に区切り文字を判別して読み込みます。
1064    引数:
1065        :param path: ピーク情報ファイルへのパス。
1066        :type path: Path
1067    戻り値:
1068        :returns: 読み込まれたピーク情報を含むPandasデータフレーム。
1069        :rtype: pandas.DataFrame
1070    """
1071    suffix = path.suffix.lower()
1072    if suffix in {".xlsx", ".xls"}:
1073        xls = pd.ExcelFile(path)
1074        preferred = ["peaks", "all_detected_peaks", "selected_for_indexing"]
1075        sheet = None
1076        lower_map = {str(s).lower(): s for s in xls.sheet_names}
1077        for name in preferred:
1078            if name.lower() in lower_map:
1079                sheet = lower_map[name.lower()]
1080                break
1081        if sheet is None:
1082            sheet = xls.sheet_names[0]
1083        return pd.read_excel(path, sheet_name=sheet)
1084    return pd.read_csv(path, sep=None, engine="python", comment="#")
1085
1086
1087def read_peaks_file(path: Path, wavelength: float = CU_KA1) -> list[Peak]:
1088    """
1089    概要:
1090        ピーク情報ファイルからPeakオブジェクトのリストを読み込みます。
1091    詳細説明:
1092        ExcelまたはCSV形式のピーク情報ファイルを読み込み、Pandasデータフレームとして扱います。
1093        "two_theta_deg", "2theta", "inv_d2"などの一般的な列名から2θ角度またはinv_d2値を抽出し、
1094        強度、FWHM、Kα役割などの情報をPeakオブジェクトに変換します。
1095        ファイルにKα役割の割り当てがない場合は、assign_ka2関数を呼び出して割り当てを試みます。
1096    引数:
1097        :param path: ピーク情報ファイルへのパス。
1098        :type path: Path
1099        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
1100        :type wavelength: float
1101    戻り値:
1102        :returns: 読み込まれたPeakオブジェクトのリスト。
1103        :rtype: list[Peak]
1104    例外:
1105        :raises ValueError: ファイルが空である場合、または回折角度の列が見つからない場合、
1106                            または利用可能なピークが見つからない場合。
1107    """
1108    df = _read_peak_dataframe(path)
1109    if df.empty:
1110        raise ValueError(f"ピークファイルが空です: {path}")
1111
1112    two_theta_col = _find_column(df, {
1113        "two_theta_deg", "two_theta", "twotheta", "2theta", "2theta_deg",
1114        "theta2", "angle", "angle_deg", "position", "position_deg",
1115        "two_theta_obs_deg",
1116    })
1117    # "inv_d2_A-2" の標準形式は "inv_d2_a_2" となります。
1118    inv_d2_col = _find_column(df, {
1119        "inv_d2", "inv_d2_obs", "inv_d2_calc", "inv_d2_a_2", "inv_d2_a2",
1120        "one_over_d2", "one_over_d_2", "d_star2",
1121    })
1122    intensity_col = _find_column(df, {"intensity", "i", "counts", "count", "y"})
1123    intensity_raw_col = _find_column(df, {"intensity_raw", "raw_intensity", "i_raw", "counts_raw"})
1124    fwhm_col = _find_column(df, {"fwhm_deg", "fwhm", "fwhm_degree"})
1125    role_col = _find_column(df, {"ka_role", "role", "kalpha_role"})
1126    pair_col = _find_column(df, {"ka_pair_peak_id", "pair_peak_id", "ka_pair_index"})
1127
1128    if two_theta_col is None and inv_d2_col is None:
1129        # 最終的なフォールバック: 最初の数値列を2θとして使用します。
1130        for col in df.columns:
1131            values = pd.to_numeric(df[col], errors="coerce")
1132            if values.notna().sum() > 0:
1133                two_theta_col = str(col)
1134                break
1135    if two_theta_col is None and inv_d2_col is None:
1136        raise ValueError(
1137            "ピークファイルに回折角度の列が見つかりません。 "
1138            "two_theta_deg, 2theta, angle, または inv_d2_A-2 を使用してください。"
1139        )
1140
1141    peaks: list[Peak] = []
1142    for row_index, row in df.iterrows():
1143        two_theta = float("nan")
1144        inv_d2 = float("nan")
1145        if two_theta_col is not None:
1146            two_theta = float(pd.to_numeric(row.get(two_theta_col), errors="coerce"))
1147        if inv_d2_col is not None:
1148            inv_d2 = float(pd.to_numeric(row.get(inv_d2_col), errors="coerce"))
1149
1150        if not math.isfinite(two_theta):
1151            if math.isfinite(inv_d2) and inv_d2 > 0:
1152                tt = two_theta_from_inv_d2(inv_d2, wavelength)
1153                two_theta = float(tt) if tt is not None else float("nan")
1154        if not math.isfinite(inv_d2) and math.isfinite(two_theta):
1155            inv_d2 = inv_d2_from_two_theta(two_theta, wavelength)
1156        if not (math.isfinite(two_theta) and math.isfinite(inv_d2) and inv_d2 > 0):
1157            continue
1158
1159        intensity = float(pd.to_numeric(row.get(intensity_col), errors="coerce")) if intensity_col else 1.0
1160        if not math.isfinite(intensity):
1161            intensity = 1.0
1162        intensity_raw = float(pd.to_numeric(row.get(intensity_raw_col), errors="coerce")) if intensity_raw_col else intensity
1163        if not math.isfinite(intensity_raw):
1164            intensity_raw = intensity
1165        fwhm = float(pd.to_numeric(row.get(fwhm_col), errors="coerce")) if fwhm_col else float("nan")
1166        if not math.isfinite(fwhm):
1167            fwhm = 0.0
1168
1169        d = 1.0 / math.sqrt(inv_d2)
1170        q = 2.0 * math.pi / d
1171        role = ""
1172        if role_col is not None and not pd.isna(row.get(role_col)):
1173            role = str(row.get(role_col)).strip().lower()
1174        pair_index = None
1175        if pair_col is not None and not pd.isna(row.get(pair_col)):
1176            try:
1177                pair_id = int(row.get(pair_col))
1178                if pair_id > 0:
1179                    pair_index = pair_id - 1
1180            except Exception:
1181                pair_index = None
1182
1183        peaks.append(
1184            Peak(
1185                index=int(row_index),
1186                two_theta=float(two_theta),
1187                intensity=float(intensity),
1188                intensity_raw=float(intensity_raw),
1189                fwhm_deg=float(fwhm),
1190                inv_d2=float(inv_d2),
1191                d=float(d),
1192                q=float(q),
1193                ka_role=role,
1194                ka_pair_index=pair_index,
1195                source=str(path),
1196            )
1197        )
1198
1199    peaks.sort(key=lambda p: p.two_theta)
1200    if not peaks:
1201        raise ValueError(f"'{path}'から利用可能なピークが見つかりませんでした。")
1202    if not any(p.ka_role for p in peaks):
1203        assign_ka2(peaks)
1204    return peaks
1205
1206
1207def normalize_mode(mode: str) -> str:
1208    """
1209    概要:
1210        入力されたモード文字列を標準的なモード名に変換します。
1211    詳細説明:
1212        モード文字列から前後の空白を除去し、小文字に変換します。
1213        スペルミス "seaerch" は "search" に修正され、古い "index" モードは
1214        "all" (search + guess) に変換されます。
1215        サポートされていないモードが指定された場合はエラーを発生させます。
1216    引数:
1217        :param mode: 入力されたモード文字列。
1218        :type mode: str
1219    戻り値:
1220        :returns: 標準化されたモード名。
1221        :rtype: str
1222    例外:
1223        :raises ValueError: サポートされていないモードが指定された場合。
1224    """
1225    m = mode.strip().lower()
1226    if m == "seaerch":
1227        return "search"
1228    if m == "index":
1229        # 後方互換性: 古い "index" モードは search + indexing を実行していました。
1230        return "all"
1231    if m not in {"search", "guess", "all", "compare", "calc"}:
1232        raise ValueError(f"サポートされていないモード: {mode}")
1233    return m
1234
1235
1236def parse_crystal_systems(text: str) -> list[str]:
1237    """
1238    概要:
1239        結晶系指定の文字列をパースし、サポートされている結晶系のリストを返します。
1240    詳細説明:
1241        カンマまたはセミコロンで区切られた結晶系名の文字列を処理します。
1242        "auto" や "all" が指定された場合、全てのサポートされている結晶系 ("hexagonal", "orthorhombic", "tetragonal", "cubic")
1243        のリストを返します。エイリアスもサポートされます (例: "hex" -> "hexagonal")。
1244        サポートされていない結晶系が指定された場合はエラーを発生させます。
1245    引数:
1246        :param text: 結晶系指定の文字列。
1247        :type text: str
1248    戻り値:
1249        :returns: サポートされている結晶系のリスト。
1250        :rtype: list[str]
1251    例外:
1252        :raises ValueError: サポートされていない結晶系が指定された場合。
1253    """
1254    systems: list[str] = []
1255    for raw in str(text).replace(";", ",").split(","):
1256        key = raw.strip().lower()
1257        if not key:
1258            continue
1259        if key not in CRYSTAL_SYSTEM_ALIASES:
1260            allowed = ", ".join(["auto"] + SUPPORTED_CRYSTAL_SYSTEMS)
1261            raise ValueError(f"サポートされていない結晶系: {raw}。許可されているもの: {allowed}")
1262        value = CRYSTAL_SYSTEM_ALIASES[key]
1263        if value == "auto":
1264            return list(SUPPORTED_CRYSTAL_SYSTEMS)
1265        if value not in systems:
1266            systems.append(value)
1267    return systems or list(SUPPORTED_CRYSTAL_SYSTEMS)
1268
1269
1270def default_peak_file_for_input(infile: Path) -> Path:
1271    """
1272    概要:
1273        入力ファイル名に基づいてデフォルトのピークファイル名を生成します。
1274    詳細説明:
1275        入力ファイルのステムをサニタイズし、"-peaks.xlsx" を追加した
1276        Pathオブジェクトを返します。
1277    引数:
1278        :param infile: 入力データファイルへのパス。
1279        :type infile: Path
1280    戻り値:
1281        :returns: デフォルトのピークファイルパス。
1282        :rtype: Path
1283    """
1284    stem = sanitize_stem(infile.stem)
1285    return infile.with_name(f"{stem}-peaks.xlsx")
1286
1287
1288def strip_peak_suffix(stem: str) -> str:
1289    """
1290    概要:
1291        ファイル名のステムからピーク関連のサフィックスを除去します。
1292    詳細説明:
1293        ファイル名のステム (例: "sample-peaks") から、"-peaks", "_peaks",
1294        ".peaks", "-peak", "_peak" などのサフィックスを除去した文字列を返します。
1295    引数:
1296        :param stem: ファイル名のステム文字列。
1297        :type stem: str
1298    戻り値:
1299        :returns: サフィックスが除去されたステム文字列。
1300        :rtype: str
1301    """
1302    for suffix in ("-peaks", "_peaks", ".peaks", "-peak", "_peak"):
1303        if stem.lower().endswith(suffix):
1304            return stem[: -len(suffix)]
1305    return stem
1306
1307
1308def default_guess_file_for_peak_file(peak_file: Path, base_input: Path | None = None) -> Path:
1309    """
1310    概要:
1311        ピークファイル名に基づいてデフォルトの推定結果ファイル名を生成します。
1312    詳細説明:
1313        基本入力ファイル (base_input) が指定されている場合はそれを使用し、
1314        そうでない場合はピークファイル名からピーク関連のサフィックスを除去し、
1315        "-guess.xlsx" を追加したPathオブジェクトを返します。
1316    引数:
1317        :param peak_file: ピーク情報ファイルへのパス。
1318        :type peak_file: Path
1319        :param base_input: 元の入力データファイルへのパス(オプション)。
1320        :type base_input: Path | None
1321    戻り値:
1322        :returns: デフォルトの推定結果ファイルパス。
1323        :rtype: Path
1324    """
1325    if base_input is not None:
1326        stem = sanitize_stem(base_input.stem)
1327        return base_input.with_name(f"{stem}-guess.xlsx")
1328    stem = sanitize_stem(strip_peak_suffix(peak_file.stem))
1329    return peak_file.with_name(f"{stem}-guess.xlsx")
1330
1331
1332def peaks_to_frame(peaks: list[Peak]) -> pd.DataFrame:
1333    """
1334    概要:
1335        PeakオブジェクトのリストをPandasデータフレームに変換します。
1336    詳細説明:
1337        Peakオブジェクトの各属性をデータフレームの列にマッピングし、
1338        各ピークに一意のID ("peak_id") を割り当てます。
1339    引数:
1340        :param peaks: Peakオブジェクトのリスト。
1341        :type peaks: list[Peak]
1342    戻り値:
1343        :returns: Peak情報を含むPandasデータフレーム。
1344        :rtype: pandas.DataFrame
1345    """
1346    rows = []
1347    for i, p in enumerate(peaks, 1):
1348        rows.append({
1349            "peak_id": i,
1350            "two_theta_deg": p.two_theta,
1351            "intensity": p.intensity,
1352            "intensity_raw": p.intensity_raw,
1353            "fwhm_deg": p.fwhm_deg,
1354            "d_A": p.d,
1355            "inv_d2_A-2": p.inv_d2,
1356            "q_A-1": p.q,
1357            "ka_role": p.ka_role,
1358            "ka_pair_peak_id": (p.ka_pair_index + 1) if p.ka_pair_index is not None else None,
1359        })
1360    return pd.DataFrame(rows)
1361
1362
1363def print_peak_table(peaks: list[Peak], title: str) -> None:
1364    """
1365    概要:
1366        ピーク情報を整形されたテーブル形式でコンソールに出力します。
1367    詳細説明:
1368        指定されたタイトルとともに、各ピークのID、2θ角度、強度、FWHM、Kα役割を
1369        見やすい形式で標準出力に表示します。
1370    引数:
1371        :param peaks: Peakオブジェクトのリスト。
1372        :type peaks: list[Peak]
1373        :param title: テーブルのタイトル。
1374        :type title: str
1375    """
1376    print("")
1377    print(title)
1378    print(f"{'id':>3s} {'2theta':>9s} {'I':>11s} {'FWHM':>7s} {'role':>5s}")
1379    for i, p in enumerate(peaks, 1):
1380        print(f"{i:3d} {p.two_theta:9.3f} {p.intensity:11.2f} {p.fwhm_deg:7.3f} {p.ka_role or '-':>5s}")
1381
1382
1383def save_workbook(path: Path, sheets: dict[str, pd.DataFrame]) -> None:
1384    """
1385    概要:
1386        複数のPandasデータフレームをExcelワークブックとして保存します。
1387    詳細説明:
1388        辞書で指定されたシート名とデータフレームのペアを、
1389        OpenPyXLライブラリを使用して単一のExcelファイルに書き込みます。
1390        各シートの最初の行は列ヘッダーとして太字で表示され、行が固定されます。
1391        欠損値はExcelの空セルとして扱われます。
1392    引数:
1393        :param path: 保存先のExcelファイルパス。
1394        :type path: Path
1395        :param sheets: シート名とPandasデータフレームの辞書。
1396        :type sheets: dict[str, pandas.DataFrame]
1397    """
1398    wb = Workbook()
1399    first = True
1400    for name, df in sheets.items():
1401        ws = wb.active if first else wb.create_sheet(title=name[:31])
1402        ws.title = name[:31]
1403        first = False
1404        for c, col in enumerate(df.columns, 1):
1405            cell = ws.cell(1, c, col)
1406            cell.font = Font(bold=True)
1407        for r, (_, row) in enumerate(df.iterrows(), 2):
1408            for c, val in enumerate(row, 1):
1409                ws.cell(r, c, None if (pd.isna(val) if not isinstance(val, str) else False) else val)
1410        ws.freeze_panes = "A2"
1411    wb.save(path)
1412
1413
1414
1415def _maybe_install_mplcursors(artists: list, hover_texts: list[str]) -> None:
1416    """
1417    概要:
1418        mplcursorsが利用可能な場合、Matplotlibのプロットにホバー注釈をアタッチします。
1419    詳細説明:
1420        mplcursorsライブラリがインストールされていれば、指定されたアーティストに
1421        対応するテキストがツールチップとして表示されるように設定します。
1422        ライブラリがない場合は、メッセージを出力し機能は無効化されます。
1423    引数:
1424        :param artists: Matplotlibアーティストオブジェクトのリスト。
1425        :type artists: list
1426        :param hover_texts: 各アーティストに対応するホバーテキストのリスト。
1427        :type hover_texts: list[str]
1428    """
1429    if not artists:
1430        return
1431    try:
1432        import mplcursors  # type: ignore
1433    except Exception:
1434        print("mplcursorsがインストールされていません。ホバー注釈は無効です。")
1435        print("以下でインストールできます: pip install mplcursors")
1436        return
1437
1438    text_by_artist = {artist: text for artist, text in zip(artists, hover_texts)}
1439    cursor = mplcursors.cursor(artists, hover=True)
1440
1441    @cursor.connect("add")
1442    def _on_add(sel):  # pragma: no cover - interactive callback
1443        sel.annotation.set_text(text_by_artist.get(sel.artist, ""))
1444        sel.annotation.get_bbox_patch().set(alpha=0.92)
1445
1446
1447def _peak_hover_text(p: Peak, hkl_text: str | None = None, calc_two_theta: float | None = None, resid: float | None = None) -> str:
1448    """
1449    概要:
1450        ピークのホバーテキストを生成します。
1451    詳細説明:
1452        Peakオブジェクトの情報と、オプションでhkl指数、計算された2θ、残差を用いて、
1453        Matplotlibのホバー注釈として表示する整形されたテキスト文字列を作成します。
1454    引数:
1455        :param p: Peakオブジェクト。
1456        :type p: Peak
1457        :param hkl_text: hkl指数を示す文字列(オプション)。
1458        :type hkl_text: str | None
1459        :param calc_two_theta: 計算された2θ角度(オプション)。
1460        :type calc_two_theta: float | None
1461        :param resid: 残差(オプション)。
1462        :type resid: float | None
1463    戻り値:
1464        :returns: ホバー注釈用の整形済みテキスト。
1465        :rtype: str
1466    """
1467    parts = []
1468    if hkl_text:
1469        parts.append(f"hkl: {hkl_text}")
1470    parts.append(f"2theta(obs): {p.two_theta:.5f} deg")
1471    if calc_two_theta is not None and math.isfinite(calc_two_theta):
1472        parts.append(f"2theta(calc): {calc_two_theta:.5f} deg")
1473    if resid is not None and math.isfinite(resid):
1474        parts.append(f"resid: {resid:+.5f} deg")
1475    parts.append(f"intensity: {p.intensity:.6g}")
1476    parts.append(f"FWHM: {p.fwhm_deg:.5g} deg")
1477    if p.ka_role:
1478        parts.append(f"role: {p.ka_role}")
1479    return "\n".join(parts)
1480
1481
1482def plot_results(
1483    x: np.ndarray,
1484    y: np.ndarray,
1485    info: dict[str, np.ndarray | float],
1486    peaks: list[Peak],
1487    outpath: Path | None,
1488    show: bool = False,
1489    save: bool = True,
1490) -> None:
1491    """
1492    概要:
1493        入力データ、スムージングされたデータ、検出されたピークをプロットします。
1494    詳細説明:
1495        生のXRDデータ (x, y) とスムージングされたデータ (info["ysmooth"]) を線グラフで表示し、
1496        検出されたピークを縦線で示します。Kα2ピークは異なる色で強調表示されます。
1497        グラフは画像ファイルとして保存でき、Matplotlibウィンドウに表示することも可能です。
1498        mplcursorsがインストールされていれば、ピークの縦線にホバー注釈が表示されます。
1499    引数:
1500        :param x: 2θ角度のnumpy配列。
1501        :type x: numpy.ndarray
1502        :param y: 強度のnumpy配列。
1503        :type y: numpy.ndarray
1504        :param info: ピーク探索の診断情報を含む辞書(スムージングされたデータなど)。
1505        :type info: dict[str, numpy.ndarray | float]
1506        :param peaks: 検出されたPeakオブジェクトのリスト。
1507        :type peaks: list[Peak]
1508        :param outpath: プロットを保存するファイルパス(Noneの場合は保存しない)。
1509        :type outpath: Path | None
1510        :param show: プロットウィンドウを表示するかどうか。
1511        :type show: bool
1512        :param save: プロットをファイルに保存するかどうか。
1513        :type save: bool
1514    """
1515    if not show and not save:
1516        return
1517    ys = info["ysmooth"]
1518    fig, ax = plt.subplots(figsize=(12, 7))
1519    ax.plot(x, y, lw=0.8, label="入力データ")
1520    ax.plot(x, ys, lw=1.0, label="スムージング後")
1521    hover_artists = []
1522    hover_texts = []
1523    ymin = float(np.nanmin(y)) if len(y) else 0.0
1524    ymax = float(np.nanmax([np.nanmax(y), np.nanmax(ys)])) if len(y) else 1.0
1525    for p in peaks:
1526        color = "tab:red" if p.ka_role == "ka2" else "tab:green"
1527        line = ax.axvline(p.two_theta, color=color, lw=0.9, alpha=0.85, picker=5)
1528        line.set_gid(f"peak_{p.two_theta:.5f}")
1529        hover_artists.append(line)
1530        hover_texts.append(_peak_hover_text(p))
1531        ax.text(p.two_theta, p.intensity, f"{p.two_theta:.2f}", rotation=90, va="bottom", fontsize=7)
1532    ax.set_ylim(min(ymin, 0.0), ymax * 1.05 if ymax > 0 else ymax + 1.0)
1533    ax.set_xlabel("2Theta (度)")
1534    ax.set_ylabel("強度")
1535    ax.legend()
1536    fig.tight_layout()
1537    if save and outpath is not None:
1538        fig.savefig(outpath, dpi=160)
1539    if show:
1540        _maybe_install_mplcursors(hover_artists, hover_texts)
1541        plt.show()
1542#        plt.pause(0.1)
1543#        input("\nPress ENTER to terminate>>\n")
1544    if save and outpath is not None:
1545        plt.close(fig)
1546
1547
1548def beta_from_params(system: str, params: dict[str, float]) -> np.ndarray:
1549    """
1550    概要:
1551        格子定数の辞書から最小二乗法の係数 (beta) を計算します。
1552    詳細説明:
1553        与えられた結晶系と格子定数 (a, b, c) の辞書から、
1554        inv_d2 (1 / d^2) の計算に必要な最小二乗係数 beta をnumpy配列として導出します。
1555    引数:
1556        :param system: 結晶系名。
1557        :type system: str
1558        :param params: 格子定数を含む辞書。
1559        :type params: dict[str, float]
1560    戻り値:
1561        :returns: beta係数のnumpy配列。
1562        :rtype: numpy.ndarray
1563    例外:
1564        :raises ValueError: サポートされていない結晶系が指定された場合。
1565    """
1566    system = system.lower()
1567    if system == "cubic":
1568        return np.array([1.0 / (params["a"] ** 2)], dtype=float)
1569    if system == "tetragonal":
1570        return np.array([1.0 / (params["a"] ** 2), 1.0 / (params["c"] ** 2)], dtype=float)
1571    if system == "hexagonal":
1572        return np.array([4.0 / (3.0 * params["a"] ** 2), 1.0 / (params["c"] ** 2)], dtype=float)
1573    if system == "orthorhombic":
1574        return np.array([1.0 / (params["a"] ** 2), 1.0 / (params["b"] ** 2), 1.0 / (params["c"] ** 2)], dtype=float)
1575    raise ValueError(system)
1576
1577
1578def candidate_to_summary_row(rank: int, c: Candidate, method: str = "") -> dict[str, object]:
1579    """
1580    概要:
1581        Candidateオブジェクトをサマリー行(辞書)に変換します。
1582    詳細説明:
1583        Candidateオブジェクトの情報を、Excelなどのサマリーシートに適した辞書形式に変換します。
1584        ランキング、メソッド、結晶系、マッチ数、RMS誤差、格子定数を含みます。
1585    引数:
1586        :param rank: 候補の順位。
1587        :type rank: int
1588        :param c: Candidateオブジェクト。
1589        :type c: Candidate
1590        :param method: 推定に使用されたメソッド(オプション)。
1591        :type method: str
1592    戻り値:
1593        :returns: サマリー行を表す辞書。
1594        :rtype: dict[str, object]
1595    """
1596    row: dict[str, object] = {
1597        "rank": rank,
1598        "method": method,
1599        "crystal_system": c.crystal_system,
1600        "score_matches": c.score_matches,
1601        "score_rms_rel": c.score_rms_rel,
1602    }
1603    row.update(c.params)
1604    return row
1605
1606
1607def candidate_from_summary_row(row: pd.Series) -> Candidate:
1608    """
1609    概要:
1610        サマリー行(Pandas Series)からCandidateオブジェクトを生成します。
1611    詳細説明:
1612        Excelファイルなどから読み込まれたサマリー行のデータを使って、
1613        Candidateオブジェクトを再構築します。
1614        結晶系と格子定数 (a, b, c, alpha, beta, gamma) を抽出し、
1615        不足している角度はデフォルト値で補完します。
1616    引数:
1617        :param row: サマリー行を表すPandas Series。
1618        :type row: pandas.Series
1619    戻り値:
1620        :returns: 生成されたCandidateオブジェクト。
1621        :rtype: Candidate
1622    例外:
1623        :raises ValueError: サポートされていない結晶系、または必要な格子定数が見つからない場合。
1624    """
1625    system = str(row.get("crystal_system", row.get("system", ""))).strip().lower()
1626    if system not in SUPPORTED_CRYSTAL_SYSTEMS:
1627        raise ValueError(f"候補サマリーにサポートされていない結晶系: {system}")
1628    params = {
1629        key: float(row[key])
1630        for key in ["a", "b", "c", "alpha", "beta", "gamma"]
1631        if key in row.index and pd.notna(row[key])
1632    }
1633    if "a" not in params:
1634        raise ValueError("候補サマリーには格子定数aが含まれていません。")
1635    if system in {"tetragonal", "hexagonal"} and "c" not in params:
1636        raise ValueError("候補サマリーには格子定数cが含まれていません。")
1637    if system == "orthorhombic" and not all(k in params for k in ["b", "c"]):
1638        raise ValueError("候補サマリーには格子定数bとcが含まれていません。")
1639    params.setdefault("b", params.get("a", float("nan")))
1640    params.setdefault("c", params.get("a", float("nan")))
1641    params.setdefault("alpha", 90.0)
1642    params.setdefault("beta", 90.0)
1643    params.setdefault("gamma", 120.0 if system == "hexagonal" else 90.0)
1644    ls_map = {"orthorhombic": 4, "tetragonal": 5, "cubic": 6, "hexagonal": 8}
1645    return Candidate(
1646        crystal_system=system,
1647        ls_code=ls_map[system],
1648        params=params,
1649        score_matches=int(row.get("score_matches", 0) or 0),
1650        score_rms_rel=float(row.get("score_rms_rel", float("nan"))),
1651        selected_matches=[],
1652        all_matches=[],
1653    )
1654
1655def has_explicit_lattice_params(args: argparse.Namespace) -> bool:
1656    """
1657    概要:
1658        コマンドライン引数に明示的な格子定数が指定されているか確認します。
1659    詳細説明:
1660        args.a, args.b, args.c のいずれかがNoneでない場合にTrueを返します。
1661    引数:
1662        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
1663        :type args: argparse.Namespace
1664    戻り値:
1665        :returns: 明示的な格子定数が指定されていればTrue、そうでなければFalse。
1666        :rtype: bool
1667    """
1668    return any(getattr(args, name, None) is not None for name in ["a", "b", "c"])
1669
1670
1671def candidate_from_lattice_args(args: argparse.Namespace) -> Candidate:
1672    """
1673    概要:
1674        コマンドライン引数で指定された格子定数からCandidateオブジェクトを生成します。
1675    詳細説明:
1676        --a, --b, --c, --alpha, --beta, --gamma などの引数から
1677        格子定数情報を抽出し、Candidateオブジェクトを作成します。
1678        指定された結晶系に基づいて、必要な格子定数が全て提供されているかを検証します。
1679    引数:
1680        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
1681        :type args: argparse.Namespace
1682    戻り値:
1683        :returns: 生成されたCandidateオブジェクト。
1684        :rtype: Candidate
1685    例外:
1686        :raises ValueError: 結晶系の指定が適切でない、必要な格子定数が不足している、
1687                            または格子定数が正の値でない場合。
1688    """
1689    systems = parse_crystal_systems(args.crystal_system)
1690    if len(systems) != 1:
1691        raise ValueError("--crystal-system には auto またはカンマ区切りのリストではなく、単一の結晶系が必要です。")
1692    system = systems[0]
1693    a = getattr(args, "a", None)
1694    b = getattr(args, "b", None)
1695    c = getattr(args, "c", None)
1696    if a is None:
1697        raise ValueError("明示的な格子比較には --a が必要です。")
1698
1699    params: dict[str, float] = {
1700        "a": float(a),
1701        "alpha": float(getattr(args, "alpha", 90.0) if getattr(args, "alpha", None) is not None else 90.0),
1702        "beta": float(getattr(args, "beta", 90.0) if getattr(args, "beta", None) is not None else 90.0),
1703        "gamma": float(getattr(args, "gamma", 120.0 if system == "hexagonal" else 90.0) if getattr(args, "gamma", None) is not None else (120.0 if system == "hexagonal" else 90.0)),
1704    }
1705    if system == "cubic":
1706        params["b"] = float(a)
1707        params["c"] = float(a if c is None else c)
1708        # 内部的には --b/--c が誤って供給されても立方晶として維持します。
1709        params["b"] = params["a"]
1710        params["c"] = params["a"]
1711    elif system in {"tetragonal", "hexagonal"}:
1712        if c is None:
1713            raise ValueError(f"{system} の比較には --a に加えて --c が必要です。")
1714        params["b"] = float(a)
1715        params["c"] = float(c)
1716        if system == "hexagonal":
1717            params["gamma"] = 120.0
1718    elif system == "orthorhombic":
1719        if b is None or c is None:
1720            raise ValueError("orthorhombic の比較には --a, --b, および --c が必要です。")
1721        params["b"] = float(b)
1722        params["c"] = float(c)
1723    else:
1724        raise ValueError(f"サポートされていない結晶系: {system}")
1725
1726    for key in ["a", "b", "c"]:
1727        if not math.isfinite(params[key]) or params[key] <= 0:
1728            raise ValueError(f"格子定数 {key} は正の値でなければなりません: {params[key]}")
1729
1730    ls_map = {"orthorhombic": 4, "tetragonal": 5, "cubic": 6, "hexagonal": 8}
1731    return Candidate(
1732        crystal_system=system,
1733        ls_code=ls_map[system],
1734        params=params,
1735        score_matches=0,
1736        score_rms_rel=float("nan"),
1737        selected_matches=[],
1738        all_matches=[],
1739    )
1740
1741
1742def filter_theoretical_lines_for_plot(
1743    theoretical_df: pd.DataFrame,
1744    mode: str = "near",
1745    near_deg: float = 0.5,
1746) -> pd.DataFrame:
1747    """
1748    概要:
1749        プロット表示のために理論的な回折線をフィルタリングします。
1750    詳細説明:
1751        プロットモード (mode) に応じて、理論的な回折線データフレーム (theoretical_df) をフィルタリングします。
1752        - "all": 全ての理論線を表示。
1753        - "none": 全ての理論線を表示しない。
1754        - "matched": 観測ピークにマッチした理論線のみ表示。
1755        - "near": 観測ピークの近く(near_deg以内)にある理論線のみ表示。
1756    引数:
1757        :param theoretical_df: 理論的な回折線情報を含むPandasデータフレーム。
1758        :type theoretical_df: pandas.DataFrame
1759        :param mode: フィルタリングモード("near", "matched", "all", "none")。
1760        :type mode: str
1761        :param near_deg: "near"モードで使用される2θ角度の許容範囲(度)。
1762        :type near_deg: float
1763    戻り値:
1764        :returns: フィルタリングされた理論回折線データフレーム。
1765        :rtype: pandas.DataFrame
1766    例外:
1767        :raises ValueError: サポートされていないプロットモードが指定された場合。
1768    """
1769    if theoretical_df.empty:
1770        return theoretical_df
1771    mode = str(mode).strip().lower()
1772    if mode == "all":
1773        return theoretical_df
1774    if mode == "none":
1775        return theoretical_df.iloc[0:0].copy()
1776    if mode == "matched":
1777        if "matched" not in theoretical_df.columns:
1778            return theoretical_df.iloc[0:0].copy()
1779        return theoretical_df[theoretical_df["matched"].astype(bool)].copy()
1780    if mode == "near":
1781        if "nearest_resid_two_theta_deg" not in theoretical_df.columns:
1782            if "matched" in theoretical_df.columns:
1783                return theoretical_df[theoretical_df["matched"].astype(bool)].copy()
1784            return theoretical_df.iloc[0:0].copy()
1785        resid = pd.to_numeric(theoretical_df["nearest_resid_two_theta_deg"], errors="coerce").abs()
1786        return theoretical_df[resid <= float(near_deg)].copy()
1787    raise ValueError(f"サポートされていない --plot-theory オプション: {mode}")
1788
1789
1790
1791def estimate_lattice_limit_from_peaks(
1792    peaks: list[Peak],
1793    wavelength: float = CU_KA1,
1794    factor: float = 1.2,
1795) -> tuple[float, float, float]:
1796    """
1797    概要:
1798        観測ピークに基づいて格子定数の上限を推定します。
1799    詳細説明:
1800        観測されたピークの中で最も小さい2θ角度(Kα2ピークを除く)から、
1801        対応する最大のd間隔を計算します。このd間隔に安全係数 (factor) を乗じて、
1802        格子定数推定の妥当な上限値を決定します。この上限値は、低角度での回折線が
1803        弱い、または欠落している可能性があるため、少し余裕を持たせるように設計されています。
1804    引数:
1805        :param peaks: Peakオブジェクトのリスト。
1806        :type peaks: list[Peak]
1807        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
1808        :type wavelength: float
1809        :param factor: d間隔に乗じる安全係数。
1810        :type factor: float
1811    戻り値:
1812        :returns: (最小2θ角度, その角度でのd間隔, 推定された格子定数の上限) のタプル。
1813        :rtype: tuple[float, float, float]
1814    例外:
1815        :raises ValueError: 有効なピーク角度がない場合、またはd間隔の計算が不可能な場合。
1816    """
1817    usable = [
1818        p for p in peaks
1819        if p.ka_role != "ka2" and math.isfinite(p.two_theta) and p.two_theta > 0.0
1820    ]
1821    if not usable:
1822        usable = [p for p in peaks if math.isfinite(p.two_theta) and p.two_theta > 0.0]
1823    if not usable:
1824        raise ValueError("有限の正のピーク角度が利用できないため、格子定数の上限を推定できません。")
1825    min_two_theta = min(p.two_theta for p in usable)
1826    dmax = bragg_d(min_two_theta, wavelength)
1827    if not math.isfinite(dmax) or dmax <= 0.0:
1828        raise ValueError(f"最小の2θ角度={min_two_theta}から格子定数の上限を推定できません。")
1829    return float(min_two_theta), float(dmax), float(dmax * factor)
1830
1831
1832def resolve_lattice_max_limit(args: argparse.Namespace, peaks: list[Peak]) -> float | None:
1833    """
1834    概要:
1835        格子定数推定に使用される格子定数の上限値を解決します。
1836    詳細説明:
1837        コマンドライン引数で --lattice-max が明示的に指定されている場合、その値を使用します。
1838        指定がない場合、最も低角度の非Kα2ピークのd間隔に --lattice-max-factor を乗じて上限を推定します。
1839        --lattice-max が0以下に設定されている場合、制限は無効化されます。
1840    引数:
1841        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
1842        :type args: argparse.Namespace
1843        :param peaks: 観測されたPeakオブジェクトのリスト。
1844        :type peaks: list[Peak]
1845    戻り値:
1846        :returns: 解決された格子定数上限値(オングストローム)、または制限が無効化されている場合はNone。
1847        :rtype: float | None
1848    例外:
1849        :raises ValueError: --lattice-max-factor が正の値でない場合。
1850    """
1851    explicit = getattr(args, "lattice_max", None)
1852    if explicit is not None:
1853        explicit = float(explicit)
1854        if explicit <= 0.0:
1855            print("格子定数上限 : 無効 (--lattice-max <= 0)")
1856            return None
1857        print(f"格子定数上限 : {explicit:.6g} Å (ユーザー指定)")
1858        return explicit
1859
1860    factor = float(getattr(args, "lattice_max_factor", 1.2))
1861    if factor <= 0.0:
1862        raise ValueError("--lattice-max-factor は正の値でなければなりません。")
1863    min_tt, dmax, limit = estimate_lattice_limit_from_peaks(peaks, wavelength=args.wavelength, factor=factor)
1864    print(f"最小ピーク角度: {min_tt:.6g} 度 2θ")
1865    print(f"d(最小2θ)     : {dmax:.6g} Å")
1866    print(f"格子定数上限 : {limit:.6g} Å (= d(最小2θ) x {factor:.6g})")
1867    return limit
1868
1869
1870def params_within_lattice_max(params: dict[str, float], system: str, lattice_max: float | None) -> bool:
1871    """
1872    概要:
1873        格子定数セットが指定された上限値内に収まっているかを確認します。
1874    詳細説明:
1875        lattice_max がNoneでない場合、与えられた結晶系の主要な格子定数 (a, b, c) が
1876        lattice_max の値を超えていないかを検証します。
1877    引数:
1878        :param params: 格子定数を含む辞書。
1879        :type params: dict[str, float]
1880        :param system: 結晶系名。
1881        :type system: str
1882        :param lattice_max: 格子定数の上限値(オングストローム)、またはNone。
1883        :type lattice_max: float | None
1884    戻り値:
1885        :returns: 全ての格子定数が上限値内であればTrue、そうでなければFalse。
1886        :rtype: bool
1887    """
1888    if lattice_max is None:
1889        return True
1890    keys = ["a"]
1891    if system in {"tetragonal", "hexagonal"}:
1892        keys = ["a", "c"]
1893    elif system == "orthorhombic":
1894        keys = ["a", "b", "c"]
1895    for key in keys:
1896        val = params.get(key, None)
1897        if val is None:
1898            continue
1899        try:
1900            v = float(val)
1901        except Exception:
1902            continue
1903        if math.isfinite(v) and v > lattice_max * (1.0 + 1.0e-12):
1904            return False
1905    return True
1906
1907
1908def filter_candidates_by_lattice_max(
1909    candidates: list[Candidate],
1910    lattice_max: float | None,
1911    label: str = "candidates",
1912    is_print: bool = True,
1913) -> list[Candidate]:
1914    """
1915    概要:
1916        格子定数候補を格子定数上限値でフィルタリングします。
1917    詳細説明:
1918        lattice_max がNoneでない場合、その値を超える格子定数を持つCandidateオブジェクトを
1919        候補リストから除外します。
1920        除外された候補の数とラベルは、is_printがTrueの場合にコンソールに出力されます。
1921    引数:
1922        :param candidates: Candidateオブジェクトのリスト。
1923        :type candidates: list[Candidate]
1924        :param lattice_max: 格子定数の上限値(オングストローム)、またはNone。
1925        :type lattice_max: float | None
1926        :param label: 出力メッセージに使用する候補のラベル。
1927        :type label: str
1928        :param is_print: フィルタリング情報をコンソールに出力するかどうか。
1929        :type is_print: bool
1930    戻り値:
1931        :returns: フィルタリングされたCandidateオブジェクトのリスト。
1932        :rtype: list[Candidate]
1933    """
1934    if lattice_max is None:
1935        return candidates
1936    kept = [c for c in candidates if params_within_lattice_max(c.params, c.crystal_system, lattice_max)]
1937    n_removed = len(candidates) - len(kept)
1938    if is_print and n_removed > 0:
1939        print(f"格子定数上限 ({lattice_max:.6g} Å) で除外された候補: {n_removed} {label}")
1940    return kept
1941
1942
1943def theoretical_lines_for_candidate(
1944    candidate: Candidate,
1945    hmax: int = 12,
1946    wavelength: float = CU_KA1,
1947    xmin: float | None = None,
1948    xmax: float | None = None,
1949) -> pd.DataFrame:
1950    """
1951    概要:
1952        指定された格子定数候補に基づいて理論的な回折線を計算します。
1953    詳細説明:
1954        Candidateオブジェクトの格子定数とhmaxで定義されるhkl指数範囲を用いて、
1955        それぞれのhklに対する理論的な2θ角度、d間隔、inv_d2値を計算します。
1956        結果はPandasデータフレームとして返され、オプションで2θ範囲 (xmin, xmax) でフィルタリングされます。
1957    引数:
1958        :param candidate: 格子定数候補。
1959        :type candidate: Candidate
1960        :param hmax: h, k, l指数の最大値。
1961        :type hmax: int
1962        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
1963        :type wavelength: float
1964        :param xmin: 最小2θ角度(度)のフィルタリング閾値(オプション)。
1965        :type xmin: float | None
1966        :param xmax: 最大2θ角度(度)のフィルタリング閾値(オプション)。
1967        :type xmax: float | None
1968    戻り値:
1969        :returns: 理論的な回折線情報を含むPandasデータフレーム。
1970        :rtype: pandas.DataFrame
1971    """
1972    beta = beta_from_params(candidate.crystal_system, candidate.params)
1973    rows = []
1974    for feat, (h, k, l) in system_rows(candidate.crystal_system, hmax=hmax):
1975        inv_d2 = predicted_inv_d2(candidate.crystal_system, feat, beta)
1976        tt = two_theta_from_inv_d2(inv_d2, wavelength)
1977        if tt is None or not math.isfinite(tt):
1978            continue
1979        if xmin is not None and tt < xmin:
1980            continue
1981        if xmax is not None and tt > xmax:
1982            continue
1983        d = 1.0 / math.sqrt(inv_d2)
1984        rows.append({
1985            "h": h,
1986            "k": k,
1987            "l": l,
1988            "hkl": f"{h}{k}{l}",
1989            "two_theta_calc_deg": float(tt),
1990            "d_calc_A": float(d),
1991            "inv_d2_calc": float(inv_d2),
1992        })
1993    return pd.DataFrame(rows).sort_values("two_theta_calc_deg").reset_index(drop=True)
1994
1995
1996def compare_candidate_with_peaks(
1997    candidate: Candidate,
1998    peaks: list[Peak],
1999    hmax: int = 12,
2000    wavelength: float = CU_KA1,
2001    xmin: float | None = None,
2002    xmax: float | None = None,
2003    tolerance_deg: float = 0.25,
2004) -> tuple[pd.DataFrame, pd.DataFrame]:
2005    """
2006    概要:
2007        格子定数候補と観測ピークを比較し、割り当てを行います。
2008    詳細説明:
2009        与えられた格子定数候補 (candidate) と観測ピーク (peaks) を比較し、
2010        理論的な回折線と観測ピークを割り当てます。
2011        各観測ピークに対して最も近い理論的な回折線を見つけ、
2012        指定された許容誤差 (tolerance_deg) 内であればマッチとみなします。
2013        理論的回折線のデータフレームと、観測ピークと理論線の割り当て結果のデータフレームを返します。
2014    引数:
2015        :param candidate: 格子定数候補。
2016        :type candidate: Candidate
2017        :param peaks: 観測されたPeakオブジェクトのリスト。
2018        :type peaks: list[Peak]
2019        :param hmax: h, k, l指数の最大値。
2020        :type hmax: int
2021        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
2022        :type wavelength: float
2023        :param xmin: 最小2θ角度(度)のフィルタリング閾値(オプション)。
2024        :type xmin: float | None
2025        :param xmax: 最大2θ角度(度)のフィルタリング閾値(オプション)。
2026        :type xmax: float | None
2027        :param tolerance_deg: 2θ角度の割り当て許容誤差(度)。
2028        :type tolerance_deg: float
2029    戻り値:
2030        :returns: (理論的な回折線データフレーム, 観測ピークと理論線の割り当て結果データフレーム) のタプル。
2031        :rtype: tuple[pandas.DataFrame, pandas.DataFrame]
2032    """
2033    if xmin is None and peaks:
2034        xmin = max(0.0, min(p.two_theta for p in peaks) - 2.0)
2035    if xmax is None and peaks:
2036        xmax = max(p.two_theta for p in peaks) + 2.0
2037    calc_df = theoretical_lines_for_candidate(candidate, hmax=hmax, wavelength=wavelength, xmin=xmin, xmax=xmax)
2038    if calc_df.empty:
2039        return calc_df, pd.DataFrame()
2040    calc_tt = calc_df["two_theta_calc_deg"].to_numpy(dtype=float)
2041
2042    rows = []
2043    used_calc: set[int] = set()
2044    for peak_id, p in enumerate(sorted(peaks, key=lambda pk: pk.two_theta), 1):
2045        diffs = calc_tt - p.two_theta
2046        order = np.argsort(np.abs(diffs))
2047        best_idx = None
2048        for idx in order:
2049            if int(idx) not in used_calc:
2050                best_idx = int(idx)
2051                break
2052        if best_idx is None:
2053            continue
2054        used_calc.add(best_idx)
2055        calc = calc_df.iloc[best_idx]
2056        resid = p.two_theta - float(calc["two_theta_calc_deg"])
2057        matched = abs(resid) <= tolerance_deg
2058        rows.append({
2059            "peak_id": peak_id,
2060            "two_theta_obs_deg": p.two_theta,
2061            "intensity": p.intensity,
2062            "fwhm_deg": p.fwhm_deg,
2063            "ka_role": p.ka_role,
2064            "h": int(calc["h"]),
2065            "k": int(calc["k"]),
2066            "l": int(calc["l"]),
2067            "hkl": str(calc["hkl"]),
2068            "two_theta_calc_deg": float(calc["two_theta_calc_deg"]),
2069            "resid_two_theta_deg": float(resid),
2070            "abs_resid_two_theta_deg": float(abs(resid)),
2071            "matched": bool(matched),
2072        })
2073
2074    assign_df = pd.DataFrame(rows)
2075    calc_df = calc_df.copy()
2076    if not assign_df.empty:
2077        obs_by_hkl = assign_df.set_index("hkl")
2078        calc_df["nearest_peak_id"] = calc_df["hkl"].map(obs_by_hkl["peak_id"].to_dict())
2079        calc_df["nearest_two_theta_obs_deg"] = calc_df["hkl"].map(obs_by_hkl["two_theta_obs_deg"].to_dict())
2080        calc_df["nearest_intensity"] = calc_df["hkl"].map(obs_by_hkl["intensity"].to_dict())
2081        calc_df["nearest_fwhm_deg"] = calc_df["hkl"].map(obs_by_hkl["fwhm_deg"].to_dict())
2082        calc_df["nearest_resid_two_theta_deg"] = calc_df["hkl"].map(obs_by_hkl["resid_two_theta_deg"].to_dict())
2083        calc_df["matched"] = calc_df["hkl"].map(obs_by_hkl["matched"].to_dict()).fillna(False)
2084    else:
2085        calc_df["matched"] = False
2086    return calc_df, assign_df
2087
2088
2089def plot_compare_results(
2090    x: np.ndarray | None,
2091    y: np.ndarray | None,
2092    peaks: list[Peak],
2093    theoretical_df: pd.DataFrame,
2094    assignment_df: pd.DataFrame,
2095    candidate: Candidate,
2096    xmin: float,
2097    xmax: float,
2098    outpath: Path | None,
2099    show: bool = False,
2100    save: bool = True,
2101) -> None:
2102    """
2103    概要:
2104        観測ピークと理論的な回折線の比較結果をプロットします。
2105    詳細説明:
2106        入力XRDデータ (x, y) があればそれをプロットし、観測されたピークを縦線で示します。
2107        指定された格子定数候補に基づく理論的な回折線を、観測ピークの下部に棒グラフとして重ねて表示します。
2108        理論線にはhkl指数が注釈され、マッチングされたものは強調されます。
2109        グラフは画像ファイルとして保存でき、Matplotlibウィンドウに表示することも可能です。
2110        mplcursorsがインストールされていれば、線にホバー注釈が表示されます。
2111    引数:
2112        :param x: 2θ角度のnumpy配列(生のXRDデータ、オプション)。
2113        :type x: numpy.ndarray | None
2114        :param y: 強度のnumpy配列(生のXRDデータ、オプション)。
2115        :type y: numpy.ndarray | None
2116        :param peaks: 観測されたPeakオブジェクトのリスト。
2117        :type peaks: list[Peak]
2118        :param theoretical_df: 理論的な回折線情報を含むPandasデータフレーム。
2119        :type theoretical_df: pandas.DataFrame
2120        :param assignment_df: 観測ピークと理論線の割り当て結果データフレーム。
2121        :type assignment_df: pandas.DataFrame
2122        :param candidate: 格子定数候補。
2123        :type candidate: Candidate
2124        :param xmin: プロットのX軸最小値(度)。
2125        :type xmin: float
2126        :param xmax: プロットのX軸最大値(度)。
2127        :type xmax: float
2128        :param outpath: プロットを保存するファイルパス(Noneの場合は保存しない)。
2129        :type outpath: Path | None
2130        :param show: プロットウィンドウを表示するかどうか。
2131        :type show: bool
2132        :param save: プロットをファイルに保存するかどうか。
2133        :type save: bool
2134    """
2135    if not show and not save:
2136        return
2137    fig, ax = plt.subplots(figsize=(12, 7))
2138    if x is not None and y is not None and len(x) and len(y):
2139        ax.plot(x, y, lw=0.8, label="入力XRD")
2140        ymax = float(np.nanmax(y)) if np.isfinite(y).any() else 1.0
2141    else:
2142        ymax = max([p.intensity for p in peaks] + [1.0])
2143        markerline, stemlines, baseline = ax.stem(
2144            [p.two_theta for p in peaks],
2145            [p.intensity for p in peaks],
2146            basefmt=" ",
2147            linefmt="C0-",
2148            markerfmt="C0o",
2149            label="観測ピーク",
2150        )
2151        try:
2152            plt.setp(stemlines, linewidth=1.0)
2153            plt.setp(markerline, markersize=4)
2154        except Exception:
2155            pass
2156
2157    hover_artists = []
2158    hover_texts = []
2159    assign_by_obs = {}
2160    if not assignment_df.empty:
2161        for _, row in assignment_df.iterrows():
2162            assign_by_obs[round(float(row["two_theta_obs_deg"]), 6)] = row
2163
2164    for p in sorted(peaks, key=lambda pk: pk.two_theta):
2165        key = round(float(p.two_theta), 6)
2166        row = assign_by_obs.get(key)
2167        hkl_text = None
2168        calc_tt = None
2169        resid = None
2170        if row is not None:
2171            hkl_text = f"{int(row['h'])}{int(row['k'])}{int(row['l'])}"
2172            calc_tt = float(row["two_theta_calc_deg"])
2173            resid = float(row["resid_two_theta_deg"])
2174        line = ax.axvline(p.two_theta, color="tab:green", lw=0.3, linestyle="--", alpha=0.75, picker=5)
2175        hover_artists.append(line)
2176        hover_texts.append(_peak_hover_text(p, hkl_text=hkl_text, calc_two_theta=calc_tt, resid=resid))
2177
2178    theory_height = ymax * 0.16 if ymax > 0 else 1.0
2179    for _, row in theoretical_df.iterrows():
2180        tt = float(row["two_theta_calc_deg"])
2181        matched = bool(row.get("matched", False))
2182        line = ax.vlines(tt, ymin=0, ymax=theory_height, colors="tab:red", lw=1.0 if matched else 0.7, alpha=0.85 if matched else 0.45)
2183        # mplcursorsはvlinesからのLineCollectionを受け入れます。
2184        hkl_text = f"{int(row['h'])}{int(row['k'])}{int(row['l'])}"
2185        hover = [
2186            f"hkl: {hkl_text}",
2187            f"2theta(calc): {tt:.5f} deg",
2188        ]
2189        if pd.notna(row.get("nearest_two_theta_obs_deg", np.nan)):
2190            hover.append(f"2theta(obs): {float(row['nearest_two_theta_obs_deg']):.5f} deg")
2191        if pd.notna(row.get("nearest_resid_two_theta_deg", np.nan)):
2192            hover.append(f"resid: {float(row['nearest_resid_two_theta_deg']):+.5f} deg")
2193        if pd.notna(row.get("nearest_intensity", np.nan)):
2194            hover.append(f"intensity: {float(row['nearest_intensity']):.6g}")
2195        if pd.notna(row.get("nearest_fwhm_deg", np.nan)):
2196            hover.append(f"FWHM: {float(row['nearest_fwhm_deg']):.5g} deg")
2197        hover_artists.append(line)
2198        hover_texts.append("\n".join(hover))
2199        if matched:
2200            ax.text(tt, theory_height, hkl_text, rotation=90, va="bottom", fontsize=7)
2201
2202    title_params = ", ".join(f"{k}={v:.5g}" for k, v in candidate.params.items() if k in {"a", "b", "c"})
2203    ax.set_title(f"{candidate.crystal_system}: {title_params}")
2204    ax.set_xlabel("2Theta (度)")
2205    ax.set_ylabel("強度")
2206    ax.set_xlim([xmin, xmax])
2207    ax.legend(loc="best")
2208    fig.tight_layout()
2209    if save and outpath is not None:
2210        fig.savefig(outpath, dpi=160)
2211    if show:
2212        _maybe_install_mplcursors(hover_artists, hover_texts)
2213        plt.pause(0.1)
2214        input("\n続行するにはEnterを押してください>>\n")
2215    if save and outpath is not None:
2216        plt.close(fig)
2217
2218
2219def frange_values(vmin: float, vmax: float, step: float) -> np.ndarray:
2220    """
2221    概要:
2222        指定された範囲とステップで浮動小数点数の配列を生成します。
2223    詳細説明:
2224        numpy.arange のように動作しますが、浮動小数点数の丸め誤差を考慮して
2225        vmin から vmax までの値を step 間隔で生成します。
2226    引数:
2227        :param vmin: 最小値。
2228        :type vmin: float
2229        :param vmax: 最大値。
2230        :type vmax: float
2231        :param step: ステップサイズ。
2232        :type step: float
2233    戻り値:
2234        :returns: 生成された浮動小数点数のnumpy配列。
2235        :rtype: numpy.ndarray
2236    例外:
2237        :raises ValueError: ステップが正でない、または最大値が最小値より小さい場合。
2238    """
2239    if step <= 0:
2240        raise ValueError("グリッドステップは正でなければなりません。")
2241    if vmax < vmin:
2242        raise ValueError("グリッドの最大値は最小値以上でなければなりません。")
2243    n = int(math.floor((vmax - vmin) / step + 0.5)) + 1
2244    vals = vmin + step * np.arange(max(n, 1))
2245    return vals[vals <= vmax + 1e-12]
2246
2247
2248def _param_grid_for_system(system: str, ranges: dict[str, tuple[float, float, float]]) -> Iterable[dict[str, float]]:
2249    """
2250    概要:
2251        特定の結晶系に対して格子定数のグリッドを生成します。
2252    詳細説明:
2253        与えられた結晶系 (system) と格子定数の範囲 (ranges) に基づいて、
2254        可能な格子定数の組み合わせを辞書のイテラブルとして生成します。
2255        各結晶系に応じて必要な格子定数 (a, b, c) と角度が設定されます。
2256    引数:
2257        :param system: 結晶系名。
2258        :type system: str
2259        :param ranges: 各格子定数(a, b, c)の (最小値, 最大値, ステップ) を含む辞書。
2260        :type ranges: dict[str, tuple[float, float, float]]
2261    戻り値:
2262        :returns: 格子定数辞書のイテラブル。
2263        :rtype: Iterable[dict[str, float]]
2264    例外:
2265        :raises ValueError: サポートされていない結晶系が指定された場合。
2266    """
2267    avec = frange_values(*ranges["a"])
2268    if system == "cubic":
2269        for a in avec:
2270            yield {"a": float(a), "b": float(a), "c": float(a), "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
2271        return
2272    cvec = frange_values(*ranges["c"])
2273    if system == "tetragonal":
2274        for a in avec:
2275            for c in cvec:
2276                yield {"a": float(a), "b": float(a), "c": float(c), "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
2277        return
2278    if system == "hexagonal":
2279        for a in avec:
2280            for c in cvec:
2281                yield {"a": float(a), "b": float(a), "c": float(c), "alpha": 90.0, "beta": 90.0, "gamma": 120.0}
2282        return
2283    if system == "orthorhombic":
2284        bvec = frange_values(*ranges["b"])
2285        for a in avec:
2286            for b in bvec:
2287                for c in cvec:
2288                    yield {"a": float(a), "b": float(b), "c": float(c), "alpha": 90.0, "beta": 90.0, "gamma": 90.0}
2289        return
2290    raise ValueError(system)
2291
2292
2293def _ranges_from_args_for_system(args: argparse.Namespace, system: str) -> dict[str, tuple[float, float, float]] | None:
2294    """
2295    概要:
2296        コマンドライン引数から特定の結晶系に対する格子定数範囲を構築します。
2297    詳細説明:
2298        argsオブジェクトに含まれる amin, amax, astep, bmin, bmax, bstep, cmin, cmax, cstep
2299        の値を解析し、指定された結晶系に必要な格子定数範囲の辞書を返します。
2300        必要な範囲が完全に指定されていない場合はNoneを返します。
2301    引数:
2302        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
2303        :type args: argparse.Namespace
2304        :param system: 結晶系名。
2305        :type system: str
2306    戻り値:
2307        :returns: 格子定数範囲の辞書、または不足している場合はNone。
2308        :rtype: dict[str, tuple[float, float, float]] | None
2309    """
2310    if args.amin is None or args.amax is None:
2311        return None
2312    ranges = {"a": (float(args.amin), float(args.amax), float(args.astep))}
2313    if system in {"tetragonal", "hexagonal", "orthorhombic"}:
2314        if args.cmin is None or args.cmax is None:
2315            return None
2316        ranges["c"] = (float(args.cmin), float(args.cmax), float(args.cstep))
2317    if system == "orthorhombic":
2318        if args.bmin is None or args.bmax is None:
2319            return None
2320        ranges["b"] = (float(args.bmin), float(args.bmax), float(args.bstep))
2321    return ranges
2322
2323
2324def _ranges_around_candidate(candidate: Candidate, margin: float, args: argparse.Namespace) -> dict[str, tuple[float, float, float]]:
2325    """
2326    概要:
2327        既存の格子定数候補の周囲に検索範囲を生成します。
2328    詳細説明:
2329        与えられたCandidateオブジェクトの格子定数に基づいて、
2330        指定された相対マージン (margin) とステップサイズ (astep, bstep, cstep) を使用して、
2331        新しい格子定数検索範囲の辞書を構築します。これは、グリッド検索のシードとして使用されます。
2332    引数:
2333        :param candidate: 基準となるCandidateオブジェクト。
2334        :type candidate: Candidate
2335        :param margin: 格子定数に適用する相対マージン。
2336        :type margin: float
2337        :param args: コマンドライン引数を格納したNamespaceオブジェクト(ステップサイズ用)。
2338        :type args: argparse.Namespace
2339    戻り値:
2340        :returns: 格子定数検索範囲の辞書。
2341        :rtype: dict[str, tuple[float, float, float]]
2342    """
2343    params = candidate.params
2344    ranges: dict[str, tuple[float, float, float]] = {}
2345    for key, step_name in [("a", "astep"), ("b", "bstep"), ("c", "cstep")]:
2346        if key not in params or not math.isfinite(float(params[key])):
2347            continue
2348        value = float(params[key])
2349        step = float(getattr(args, step_name))
2350        ranges[key] = (max(0.1, value * (1.0 - margin)), value * (1.0 + margin), step)
2351    if candidate.crystal_system in {"cubic"}:
2352        ranges = {"a": ranges["a"]}
2353    elif candidate.crystal_system in {"tetragonal", "hexagonal"}:
2354        ranges = {"a": ranges["a"], "c": ranges["c"]}
2355    elif candidate.crystal_system == "orthorhombic":
2356        ranges = {"a": ranges["a"], "b": ranges["b"], "c": ranges["c"]}
2357    return ranges
2358
2359
2360def _assign_peaks_for_params(
2361    peaks: list[Peak],
2362    system: str,
2363    params: dict[str, float],
2364    hmax: int,
2365    wavelength: float,
2366    tolerance_deg: float,
2367) -> tuple[list[dict], float, float, int]:
2368    """
2369    概要:
2370        与えられた格子定数パラメータに対して観測ピークを割り当て、評価します。
2371    詳細説明:
2372        特定の結晶系 (system) と格子定数 (params) に基づいて理論的な回折線を生成し、
2373        観測ピーク (peaks) をそれらの理論線に割り当てます。
2374        割り当ては2θ角度の許容誤差 (tolerance_deg) 内で行われます。
2375        割り当て結果、RMS残差 (2θと相対inv_d2)、およびマッチングされたピーク数を返します。
2376    引数:
2377        :param peaks: 観測されたPeakオブジェクトのリスト。
2378        :type peaks: list[Peak]
2379        :param system: 結晶系名。
2380        :type system: str
2381        :param params: 格子定数を含む辞書。
2382        :type params: dict[str, float]
2383        :param hmax: h, k, l指数の最大値。
2384        :type hmax: int
2385        :param wavelength: X線波長(オングストローム)。
2386        :type wavelength: float
2387        :param tolerance_deg: 2θ角度の割り当て許容誤差(度)。
2388        :type tolerance_deg: float
2389    戻り値:
2390        :returns: (割り当て結果のリスト, 2θのRMS残差, 相対inv_d2のRMS残差, マッチングされたピーク数) のタプル。
2391        :rtype: tuple[list[dict], float, float, int]
2392    """
2393    cand = Candidate(system, {"orthorhombic": 4, "tetragonal": 5, "cubic": 6, "hexagonal": 8}[system], params, 0, 0.0, [], [])
2394    calc_df = theoretical_lines_for_candidate(cand, hmax=hmax, wavelength=wavelength)
2395    if calc_df.empty:
2396        return [], float("inf"), float("inf"), 0
2397    calc_tt = calc_df["two_theta_calc_deg"].to_numpy(dtype=float)
2398    used: set[int] = set()
2399    rows = []
2400    residuals = []
2401    rels = []
2402    for pk in sorted(peaks, key=lambda p: p.two_theta):
2403        order = np.argsort(np.abs(calc_tt - pk.two_theta))
2404        chosen = None
2405        for idx in order:
2406            if int(idx) not in used:
2407                chosen = int(idx)
2408                break
2409        if chosen is None:
2410            continue
2411        used.add(chosen)
2412        calc = calc_df.iloc[chosen]
2413        resid = pk.two_theta - float(calc["two_theta_calc_deg"])
2414        matched = abs(resid) <= tolerance_deg
2415        if matched:
2416            residuals.append(float(resid))
2417            rel = abs(float(calc["inv_d2_calc"]) - pk.inv_d2) / max(pk.inv_d2, 1e-12)
2418            rels.append(float(rel))
2419        rows.append({
2420            "two_theta_obs_deg": pk.two_theta,
2421            "intensity": pk.intensity,
2422            "ka_role": pk.ka_role,
2423            "fwhm_deg": pk.fwhm_deg,
2424            "inv_d2_obs": pk.inv_d2,
2425            "h": int(calc["h"]) if matched else None,
2426            "k": int(calc["k"]) if matched else None,
2427            "l": int(calc["l"]) if matched else None,
2428            "inv_d2_calc": float(calc["inv_d2_calc"]) if matched else None,
2429            "rel_error_inv_d2": rels[-1] if matched else None,
2430            "two_theta_calc_deg": float(calc["two_theta_calc_deg"]) if matched else None,
2431            "rel_error_2theta": abs(resid) / max(pk.two_theta, 1e-12) if matched else None,
2432            "resid_two_theta_deg": float(resid) if matched else None,
2433            "matched": bool(matched),
2434        })
2435    nmatched = len(residuals)
2436    rms_tt = float(np.sqrt(np.mean(np.square(residuals)))) if residuals else float("inf")
2437    rms_rel = float(np.sqrt(np.mean(np.square(rels)))) if rels else float("inf")
2438    return rows, rms_tt, rms_rel, nmatched
2439
2440
2441def _refit_params_from_assignment(system: str, rows: list[dict]) -> dict[str, float] | None:
2442    """
2443    概要:
2444        割り当てられたピークから格子定数を再フィットします。
2445    詳細説明:
2446        割り当て結果 (rows) の中からマッチしたピークの inv_d2 値とhkl指数を用いて、
2447        最小二乗法により格子定数を再計算します。
2448        再計算された格子定数を含む辞書を返します。
2449        フィットが不可能な場合(例: マッチングされたピークが少ない、係数が負になる)はNoneを返します。
2450    引数:
2451        :param system: 結晶系名。
2452        :type system: str
2453        :param rows: 割り当て結果のリスト。
2454        :type rows: list[dict]
2455    戻り値:
2456        :returns: 再フィットされた格子定数を含む辞書、またはNone。
2457        :rtype: dict[str, float] | None
2458    """
2459    matched = [r for r in rows if r.get("matched")]
2460    if not matched:
2461        return None
2462    y = np.array([float(r["inv_d2_obs"]) for r in matched], dtype=float)
2463    feats = []
2464    for r in matched:
2465        h, k, l = int(r["h"]), int(r["k"]), int(r["l"])
2466        if system == "cubic":
2467            feats.append((h*h + k*k + l*l,))
2468        elif system == "tetragonal":
2469            feats.append((h*h + k*k, l*l))
2470        elif system == "hexagonal":
2471            feats.append((h*h + h*k + k*k, l*l))
2472        elif system == "orthorhombic":
2473            feats.append((h*h, k*k, l*l))
2474        else:
2475            raise ValueError(system)
2476    X = np.array(feats, dtype=float)
2477    if len(y) < X.shape[1]:
2478        return None
2479    coef, _, rank, _ = np.linalg.lstsq(X, y, rcond=None)
2480    if rank < X.shape[1] or np.any(coef <= 0):
2481        return None
2482    return params_from_beta(system, coef)
2483
2484
2485def score_system_grid(
2486    system: str,
2487    selected: list[Peak],
2488    all_peaks: list[Peak],
2489    ranges: dict[str, tuple[float, float, float]],
2490    hmax: int = 12,
2491    wavelength: float = CU_KA1,
2492    tolerance_deg: float = 0.25,
2493    refine: int = 2,
2494    keep: int = 50,
2495    penalty_unassigned: float = 0.5,
2496    lattice_max: float | None = None,
2497) -> list[Candidate]:
2498    """
2499    概要:
2500        グリッド検索法を用いて特定の結晶系の格子定数候補を評価します。
2501    詳細説明:
2502        指定された結晶系 (system) の格子定数範囲 (ranges) 内でグリッド検索を行い、
2503        各格子定数セットに対して観測ピーク (selected) を割り当てて評価します。
2504        割り当てられたピークから格子定数を繰り返し再フィット (refine回) し、
2505        最もフィットの良い候補を絞り込みます。
2506        未割り当てピークに対するペナルティ (penalty_unassigned) を適用し、
2507        結果はスコアに基づいてソートされ、指定された数 (keep) の上位候補が返されます。
2508        格子定数上限 (lattice_max) も考慮されます。
2509    引数:
2510        :param system: 結晶系名。
2511        :type system: str
2512        :param selected: 格子定数推定に使用する、フィルタリングされたPeakオブジェクトのリスト。
2513        :type selected: list[Peak]
2514        :param all_peaks: 全ての観測されたPeakオブジェクトのリスト。
2515        :type all_peaks: list[Peak]
2516        :param ranges: 各格子定数(a, b, c)の (最小値, 最大値, ステップ) を含む辞書。
2517        :type ranges: dict[str, tuple[float, float, float]]
2518        :param hmax: h, k, l指数の最大値。
2519        :type hmax: int
2520        :param wavelength: X線波長(オングストローム)。デフォルトはCU_KA1です。
2521        :type wavelength: float
2522        :param tolerance_deg: 2θ角度の割り当て許容誤差(度)。
2523        :type tolerance_deg: float
2524        :param refine: グリッド検索での割り当てと再フィットの反復回数。
2525        :type refine: int
2526        :param keep: 内部的に保持する候補の最大数。
2527        :type keep: int
2528        :param penalty_unassigned: 未割り当てピークに対するペナルティスコア。
2529        :type penalty_unassigned: float
2530        :param lattice_max: 格子定数の上限値(オングストローム)、またはNone。
2531        :type lattice_max: float | None
2532    戻り値:
2533        :returns: 評価され、ソートされたCandidateオブジェクトのリスト。
2534        :rtype: list[Candidate]
2535    """
2536    candidates: list[Candidate] = []
2537    ls_map = {"orthorhombic": 4, "tetragonal": 5, "cubic": 6, "hexagonal": 8}
2538    for params0 in _param_grid_for_system(system, ranges):
2539        if not params_within_lattice_max(params0, system, lattice_max):
2540            continue
2541        params = dict(params0)
2542        rows = []
2543        for _ in range(max(refine, 1)):
2544            rows, _rms_tt, _rms_rel, nmatched = _assign_peaks_for_params(
2545                selected, system, params, hmax, wavelength, tolerance_deg
2546            )
2547            if nmatched < len(beta_from_params(system, params)):
2548                rows = []
2549                break
2550            refit = _refit_params_from_assignment(system, rows)
2551            if refit is None:
2552                rows = []
2553                break
2554            params = refit
2555        if not rows:
2556            continue
2557        if not params_within_lattice_max(params, system, lattice_max):
2558            continue
2559        selected_rows, rms_tt, rms_rel, nmatched = _assign_peaks_for_params(
2560            selected, system, params, hmax, wavelength, tolerance_deg
2561        )
2562        if nmatched == 0:
2563            continue
2564        all_rows, _all_rms_tt, all_rms_rel, all_nmatched = _assign_peaks_for_params(
2565            all_peaks, system, params, hmax, wavelength, tolerance_deg
2566        )
2567        # ソート専用の private キーにペナルティ調整値を格納します。
2568        penalty_score = rms_tt + penalty_unassigned * (len(selected) - nmatched)
2569        cand = Candidate(
2570            crystal_system=system,
2571            ls_code=ls_map[system],
2572            params=params,
2573            score_matches=int(all_nmatched),
2574            score_rms_rel=float(all_rms_rel if math.isfinite(all_rms_rel) else rms_rel),
2575            selected_matches=[r for r in selected_rows if r.get("matched")],
2576            all_matches=all_rows,
2577        )
2578        cand.params = dict(cand.params)
2579        cand.params["_grid_penalty_score"] = float(penalty_score)
2580        candidates.append(cand)
2581        candidates.sort(key=lambda c: (c.params.get("_grid_penalty_score", float("inf")), -c.score_matches, c.score_rms_rel))
2582        if len(candidates) > keep:
2583            candidates = candidates[:keep]
2584    for c in candidates:
2585        c.params.pop("_grid_penalty_score", None)
2586    candidates.sort(key=lambda c: (-c.score_matches, c.score_rms_rel))
2587    return candidates
2588
2589
2590def deduplicate_candidates(candidates: list[Candidate], ndigits: int = 4) -> list[Candidate]:
2591    """
2592    概要:
2593        重複する格子定数候補をリストから除去します。
2594    詳細説明:
2595        Candidateオブジェクトの結晶系と、a, b, cの格子定数(ndigits桁で丸められたもの)
2596        に基づいて重複を判断します。
2597        リストの順序を維持しつつ、最初に出現したユニークな候補のみを返します。
2598    引数:
2599        :param candidates: Candidateオブジェクトのリスト。
2600        :type candidates: list[Candidate]
2601        :param ndigits: 格子定数を比較する際の丸め桁数。
2602        :type ndigits: int
2603    戻り値:
2604        :returns: 重複が除去されたCandidateオブジェクトのリスト。
2605        :rtype: list[Candidate]
2606    """
2607    seen = set()
2608    out = []
2609    for c in candidates:
2610        key = (c.crystal_system,) + tuple(round(float(c.params.get(k, 0.0)), ndigits) for k in ["a", "b", "c"])
2611        if key in seen:
2612            continue
2613        seen.add(key)
2614        out.append(c)
2615    return out
2616
2617
2618def guess_candidates(
2619    args: argparse.Namespace,
2620    peaks: list[Peak],
2621    crystal_systems: list[str],
2622) -> tuple[list[Candidate], list[Peak]]:
2623    """
2624    概要:
2625        与えられたピークと結晶系に基づいて格子定数候補を推定します。
2626    詳細説明:
2627        まず、最も強度の高い非Kα2ピークを args.npeak の数だけ選択します。
2628        その後、args.method に応じて「組み合わせ探索」または「グリッド検索」を実行します。
2629        グリッド検索は、明示的な範囲が指定されていない場合、組み合わせ探索で得られた
2630        上位候補をシードとして使用できます。
2631        格子定数上限 (args.resolved_lattice_max) も適用されます。
2632    引数:
2633        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
2634        :type args: argparse.Namespace
2635        :param peaks: 観測されたPeakオブジェクトのリスト。
2636        :type peaks: list[Peak]
2637        :param crystal_systems: 探索する結晶系のリスト。
2638        :type crystal_systems: list[str]
2639    戻り値:
2640        :returns: (評価されソートされたCandidateオブジェクトのリスト, 推定に使用されたピークのリスト) のタプル。
2641        :rtype: tuple[list[Candidate], list[Peak]]
2642    例外:
2643        :raises ValueError: サポートされていない推定メソッドが指定された場合。
2644    """
2645    selected = [p for p in sorted(peaks, key=lambda p: p.intensity, reverse=True) if p.ka_role != "ka2"][:args.npeak]
2646    method = args.method.lower()
2647    if method not in SUPPORTED_METHODS:
2648        raise ValueError(f"サポートされていないメソッド: {args.method}")
2649    candidates: list[Candidate] = []
2650
2651    if method in {"combinatorial", "both", "grid"}:
2652        # --method grid で明示的な範囲がない場合、これらの候補がシードになります。
2653        seed_candidates: list[Candidate] = []
2654        for system in crystal_systems:
2655            cand = score_system(system, sorted(selected, key=lambda p: p.two_theta), sorted(peaks, key=lambda p: p.two_theta), hmax=args.hmax)
2656            if cand is not None:
2657                seed_candidates.append(cand)
2658        seed_candidates.sort(key=lambda c: (-c.score_matches, c.score_rms_rel))
2659        seed_candidates = filter_candidates_by_lattice_max(
2660            seed_candidates, getattr(args, "resolved_lattice_max", None), label="組み合わせ探索シード候補"
2661        )
2662    else:
2663        seed_candidates = []
2664
2665    if method == "combinatorial":
2666        candidates = seed_candidates
2667    elif method in {"grid", "both"}:
2668        if method == "both":
2669            candidates.extend(seed_candidates)
2670        for system in crystal_systems:
2671            ranges = _ranges_from_args_for_system(args, system)
2672            if ranges is not None:
2673                grid_cands = score_system_grid(
2674                    system, selected, peaks, ranges,
2675                    hmax=args.hmax,
2676                    wavelength=args.wavelength,
2677                    tolerance_deg=args.tolerance_deg,
2678                    refine=args.grid_refine,
2679                    keep=args.keep,
2680                    penalty_unassigned=args.penalty_unassigned,
2681                    lattice_max=getattr(args, "resolved_lattice_max", None),
2682                )
2683                candidates.extend(grid_cands)
2684                continue
2685
2686            # 明示的な範囲がなく、グリッド検索では同じシステムの上位組み合わせ候補を使用します。
2687            seeds = [c for c in seed_candidates if c.crystal_system == system][:args.grid_seed_top]
2688            if not seeds and method == "grid":
2689                print(f"'{system}' の明示的なグリッド範囲もシード候補も見つかりませんでした。スキップしました。")
2690            for seed in seeds:
2691                ranges = _ranges_around_candidate(seed, args.grid_margin, args)
2692                grid_cands = score_system_grid(
2693                    system, selected, peaks, ranges,
2694                    hmax=args.hmax,
2695                    wavelength=args.wavelength,
2696                    tolerance_deg=args.tolerance_deg,
2697                    refine=args.grid_refine,
2698                    keep=args.keep,
2699                    penalty_unassigned=args.penalty_unassigned,
2700                    lattice_max=getattr(args, "resolved_lattice_max", None),
2701                )
2702                candidates.extend(grid_cands)
2703    candidates = filter_candidates_by_lattice_max(
2704        candidates, getattr(args, "resolved_lattice_max", None), label="最終候補", is_print=False
2705    )
2706    candidates.sort(key=lambda c: (-c.score_matches, c.score_rms_rel))
2707    return deduplicate_candidates(candidates), selected
2708
2709
2710def read_candidate_from_guess_file(path: Path, rank: int) -> Candidate:
2711    """
2712    概要:
2713        推定結果ファイルから特定の順位の格子定数候補を読み込みます。
2714    詳細説明:
2715        Excel形式の推定結果ファイルから "candidate_summary" シートを読み込み、
2716        指定されたランク (rank) に対応する格子定数候補をCandidateオブジェクトとして返します。
2717        ランクは1から始まります。
2718    引数:
2719        :param path: 推定結果Excelファイルへのパス。
2720        :type path: Path
2721        :param rank: 読み込む候補の順位(1から始まる)。
2722        :type rank: int
2723    戻り値:
2724        :returns: 指定された順位のCandidateオブジェクト。
2725        :rtype: Candidate
2726    例外:
2727        :raises ValueError: Excelファイルでない場合、"candidate_summary" シートがない場合、
2728                            または指定されたランクの候補が見つからない場合。
2729    """
2730    suffix = path.suffix.lower()
2731    if suffix not in {".xlsx", ".xls"}:
2732        raise ValueError("候補ランク比較にはExcel形式の推定ファイルか、ピークからの再計算が必要です。")
2733    xls = pd.ExcelFile(path)
2734    lower_map = {str(s).lower(): s for s in xls.sheet_names}
2735    if "candidate_summary" not in lower_map:
2736        raise ValueError(f"'{path}' に candidate_summary シートが見つかりませんでした。")
2737    df = pd.read_excel(path, sheet_name=lower_map["candidate_summary"])
2738    if df.empty:
2739        raise ValueError(f"'{path}' の candidate_summary が空です。")
2740    if "rank" in df.columns:
2741        row_df = df[df["rank"] == rank]
2742        if row_df.empty:
2743            raise ValueError(f"'{path}' に候補ランク {rank} が見つかりませんでした。")
2744        row = row_df.iloc[0]
2745    else:
2746        if rank < 1 or rank > len(df):
2747            raise ValueError(f"候補ランク {rank} は 1..{len(df)} の範囲外です。")
2748        row = df.iloc[rank - 1]
2749    return candidate_from_summary_row(row)
2750
2751
2752def try_read_raw_xy_for_plot(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
2753    """
2754    概要:
2755        プロットのために生のX-Yデータを読み込みを試みます。
2756    詳細説明:
2757        指定されたファイルがピーク情報や推定結果のExcelワークブックでないことを確認し、
2758        そうであれば read_xy_data を使って生のX-Yデータを読み込みます。
2759        読み込みに失敗した場合や、ファイルが特定のワークブックである場合はNoneを返します。
2760    引数:
2761        :param path: データファイルへのパス。
2762        :type path: Path
2763    戻り値:
2764        :returns: (Xデータnumpy配列, Yデータnumpy配列) のタプル、
2765                  または読み込みに失敗した場合は (None, None)。
2766        :rtype: tuple[numpy.ndarray | None, numpy.ndarray | None]
2767    """
2768    try:
2769        # ピーク/推定ワークブックを生のXRDデータとして扱わないようにします。
2770        if path.suffix.lower() in {".xlsx", ".xls"}:
2771            xls = pd.ExcelFile(path)
2772            names = {str(s).lower() for s in xls.sheet_names}
2773            if names & {"peaks", "all_detected_peaks", "candidate_summary", "theoretical_lines"}:
2774                return None, None
2775        return read_xy_data(path)
2776    except Exception:
2777        return None, None
2778
2779
2780def looks_like_raw_xrd_file(path: Path, min_rows: int = 50) -> bool:
2781    """
2782    概要:
2783        ファイルが生のXRDデータファイルのように見えるかどうかをヒューリスティックに判断します。
2784    詳細説明:
2785        ファイルがピークリストではなく、高密度な生のXRD X-Yデータファイルであるかどうかを
2786        ヒューリスティックに判定します。
2787        Excelファイルの場合、既知のピーク/推定シート名が含まれていないかを確認します。
2788        ファイルの行数、列数、最初の数列に十分な数値データが含まれているかを確認します。
2789        "two_theta_deg" のようなピークリスト特有の列名がないことも確認します。
2790    引数:
2791        :param path: ファイルパス。
2792        :type path: Path
2793        :param min_rows: 生のXRDファイルと見なすための最小行数。
2794        :type min_rows: int
2795    戻り値:
2796        :returns: ファイルが生のXRDデータファイルのように見える場合はTrue、そうでなければFalse。
2797        :rtype: bool
2798    """
2799    try:
2800        suffix = path.suffix.lower()
2801        if suffix in {".xlsx", ".xls"}:
2802            xls = pd.ExcelFile(path)
2803            names = {str(s).lower() for s in xls.sheet_names}
2804            if names & {"peaks", "all_detected_peaks", "candidate_summary", "theoretical_lines"}:
2805                return False
2806            df = pd.read_excel(path)
2807        else:
2808            df = pd.read_csv(path, sep=None, engine="python", comment="#")
2809        if df.empty or df.shape[1] < 2:
2810            return False
2811        peak_cols = {
2812            "two_theta_deg", "two_theta", "twotheta", "2theta", "2theta_deg",
2813            "theta2", "angle", "angle_deg", "position", "position_deg",
2814            "two_theta_obs_deg", "inv_d2", "inv_d2_obs", "inv_d2_a_2",
2815        }
2816        if any(_canonical_column_name(c) in peak_cols for c in df.columns):
2817            return False
2818        numeric_cols = 0
2819        for col in df.columns[:4]:
2820            if pd.to_numeric(df[col], errors="coerce").notna().sum() >= min_rows:
2821                numeric_cols += 1
2822        return len(df) >= min_rows and numeric_cols >= 2
2823    except Exception:
2824        return False
2825
2826
2827def build_argument_parser() -> argparse.ArgumentParser:
2828    """
2829    概要:
2830        コマンドライン引数を解析するためのArgumentParserオブジェクトを構築します。
2831    詳細説明:
2832        ピーク探索、格子定数推定、比較などの機能に必要な全てのコマンドライン引数を定義します。
2833        各引数には説明、型、デフォルト値、選択肢などが設定されています。
2834        引数は、入力ファイル、モード、X線波長、ピーク探索オプション、インデクシング/比較オプション、
2835        グリッド検索範囲、プロットオプションに分類されます。
2836    戻り値:
2837        :returns: 構築されたArgumentParserオブジェクト。
2838        :rtype: argparse.ArgumentParser
2839    """
2840    p = argparse.ArgumentParser(description="粉末X線回折のためのピーク探索と格子定数推定")
2841    p.add_argument(
2842        "arg1",
2843        help=(
2844            "入力データファイル、ピーク情報ファイル、または推定ワークブック。 "
2845            "レガシーな使用法 'search infile' も受け入れられます。"
2846        ),
2847    )
2848    p.add_argument(
2849        "arg2",
2850        nargs="?",
2851        help="レガシーな位置指定モードのための入力ファイル、例: 'search sample.TXT'",
2852    )
2853    p.add_argument(
2854        "--mode",
2855        type=str,
2856        default="all",
2857        choices=["search", "guess", "all", "compare", "calc"],
2858        help=(
2859            "search: ピーク探索のみ; guess: ピークファイルから格子定数を推定; "
2860            "all: 探索 + 推定 (デフォルト); compare: ランク付けされた候補と観測ピークを比較; calc: ユーザー指定の格子定数を比較"
2861        ),
2862    )
2863    p.add_argument(
2864        "--method",
2865        type=str,
2866        default="combinatorial",
2867        choices=SUPPORTED_METHODS,
2868        help=(
2869            "格子定数推定方法。combinatorial: 高速hkl組み合わせ探索; "
2870            "grid: 明示的な範囲または組み合わせシードを使用するグリッド検索; both: 両方の結果をマージ。"
2871        ),
2872    )
2873    p.add_argument(
2874        "--crystal-system", "--system",
2875        type=str,
2876        default="auto",
2877        help=(
2878            "テストする結晶系。auto/all, cubic, tetragonal, hexagonal, "
2879            "orthorhombic、またはカンマ区切りの値を指定。デフォルト: auto"
2880        ),
2881    )
2882    p.add_argument("--peak-file", type=Path, default=None, help="ピーク情報Excel/CSVファイルパス")
2883    p.add_argument("--guess-file", type=Path, default=None, help="--mode compare で使用される推定ワークブック")
2884    p.add_argument("--output-file", type=Path, default=None, help="guess/all/compare 結果の出力ワークブック")
2885    p.add_argument("--plot-file", type=Path, default=None, help="出力グラフパス")
2886    p.add_argument("--candidate-rank", type=int, default=1, help="--mode compare で使用される候補ランク")
2887    p.add_argument("--wavelength", type=float, default=CU_KA1, help=f"X線波長(オングストローム)。デフォルト: Cu Kα1 = {CU_KA1}")
2888
2889    # --mode calc または推定ワークブックなしの --mode compare 用の明示的な格子定数。
2890    p.add_argument("--a", "--lattice-a", dest="a", type=float, default=None, help="明示的な格子定数a(オングストローム)")
2891    p.add_argument("--b", "--lattice-b", dest="b", type=float, default=None, help="明示的な格子定数b(オングストローム)")
2892    p.add_argument("--c", "--lattice-c", dest="c", type=float, default=None, help="明示的な格子定数c(オングストローム)")
2893    p.add_argument("--alpha", type=float, default=None, help="明示的なα角(度); 現在は出力に保存されます")
2894    p.add_argument("--beta", type=float, default=None, help="明示的なβ角(度); 現在は出力に保存されます")
2895    p.add_argument("--gamma", type=float, default=None, help="明示的なγ角(度); 現在は出力に保存されます")
2896
2897    # 生XRD / ピーク探索オプション。
2898    p.add_argument("--xmin", type=float, default=None)
2899    p.add_argument("--xmax", type=float, default=None)
2900    p.add_argument("--threshold", type=float, default=1000.0)
2901    p.add_argument("--nsmooth", type=int, default=11)
2902    p.add_argument("--norder", type=int, default=4)
2903    p.add_argument("--ydiff1-threshold", type=float, default=1.0e-2)
2904    p.add_argument("--fwhm-min-deg", type=float, default=0.06)
2905    p.add_argument("--fwhm-max-deg", type=float, default=1.0)
2906
2907    # インデクシング / 比較オプション。
2908    p.add_argument("--npeak", type=int, default=10)
2909    p.add_argument("--hmax", type=int, default=12)
2910    p.add_argument(
2911        "--lattice-max", "--max-lattice", dest="lattice_max",
2912        type=float, default=None,
2913        help=(
2914            "推定される格子定数の上限値(オングストローム)。 "
2915            "デフォルト: d(観測された非Kα2最小2θ) x --lattice-max-factor。 "
2916            "制限を無効にするには0以下に設定。"
2917        ),
2918    )
2919    p.add_argument(
2920        "--lattice-max-factor",
2921        type=float, default=1.2,
2922        help="--lattice-max が省略された場合、d(観測された最小2θ) に乗じられる係数。デフォルト: 1.2",
2923    )
2924    p.add_argument("--tolerance-deg", type=float, default=0.25, help="グリッド/比較での2θ割り当て許容誤差")
2925    p.add_argument("--penalty-unassigned", type=float, default=0.5)
2926    p.add_argument("--keep", type=int, default=200, help="内部的に保持されるグリッド候補の数")
2927    p.add_argument("--grid-refine", type=int, default=2, help="グリッド検索での割り当て/再フィット反復回数")
2928    p.add_argument("--grid-margin", type=float, default=0.05, help="グリッド検索のための組み合わせシード周辺の相対マージン")
2929    p.add_argument("--grid-seed-top", type=int, default=3, help="ローカルグリッド検索に使用されるシステムごとの組み合わせシードの数")
2930
2931    # 明示的なグリッド範囲。省略した場合、グリッド検索は組み合わせシードを使用できます。
2932    p.add_argument("--amin", type=float, default=None)
2933    p.add_argument("--amax", type=float, default=None)
2934    p.add_argument("--astep", type=float, default=0.02)
2935    p.add_argument("--bmin", type=float, default=None)
2936    p.add_argument("--bmax", type=float, default=None)
2937    p.add_argument("--bstep", type=float, default=0.02)
2938    p.add_argument("--cmin", type=float, default=None)
2939    p.add_argument("--cmax", type=float, default=None)
2940    p.add_argument("--cstep", type=float, default=0.02)
2941
2942    p.add_argument("--lsq-script", type=Path, default=Path("lsq_latt2.py"))
2943    p.add_argument("--show", type=int, default=0, choices=[0, 1], help="Matplotlibグラフウィンドウを表示")
2944    p.add_argument("--save", "--save-plot", dest="save_plot", type=int, default=1, choices=[0, 1], help="グラフ画像を保存")
2945    p.add_argument(
2946        "--plot-theory",
2947        type=str,
2948        default="near",
2949        choices=["near", "matched", "all", "none"],
2950        help=(
2951            "比較/計算プロットで描画する計算された理論線の種類。 "
2952            "near は --plot-near-deg 内で観測ピークに割り当てられた線のみを描画; "
2953            "all は大きな単位セルでは非常に高密度になる可能性があります。デフォルト: near"
2954        ),
2955    )
2956    p.add_argument("--plot-near-deg", type=float, default=0.5, help="--plot-theory near で使用される2θウィンドウ")
2957    p.add_argument("--no-show", action="store_true", help="レガシーオプション; --show 0 と同等")
2958    return p
2959
2960def resolve_mode_and_input(args: argparse.Namespace) -> tuple[str, Path]:
2961    """
2962    概要:
2963        コマンドライン引数から実行モードと入力ファイルを解決します。
2964    詳細説明:
2965        位置指定引数 arg1 と arg2、および --mode オプションを解析し、
2966        スクリプトの実行モードと主要な入力ファイルパスを決定します。
2967        レガシーな位置指定モード ("search sample.TXT") にも対応しています。
2968    引数:
2969        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
2970        :type args: argparse.Namespace
2971    戻り値:
2972        :returns: (解決されたモード名, 入力ファイルパス) のタプル。
2973        :rtype: tuple[str, Path]
2974    例外:
2975        :raises SystemExit: 予期しない位置指定引数が指定された場合。
2976    """
2977    first = str(args.arg1)
2978    if args.arg2 is not None and first.lower() in KNOWN_MODES:
2979        mode = normalize_mode(first)
2980        infile = Path(args.arg2)
2981    else:
2982        if args.arg2 is not None:
2983            raise SystemExit(
2984                "予期しない2番目の位置指定引数です。次のいずれかを使用してください:\n"
2985                "  python guess_lattice_parameters.py sample.TXT --mode all\n"
2986                "またはレガシースタイル:\n"
2987                "  python guess_lattice_parameters.py search sample.TXT"
2988            )
2989        mode = normalize_mode(args.mode)
2990        infile = Path(args.arg1)
2991    return mode, infile
2992
2993
2994def apply_x_range(x: np.ndarray, y: np.ndarray, xmin: float | None, xmax: float | None) -> tuple[np.ndarray, np.ndarray]:
2995    """
2996    概要:
2997        X-YデータにX軸範囲のフィルタリングを適用します。
2998    詳細説明:
2999        X座標が xmin 以上 xmax 以下のデータポイントのみを保持するように
3000        numpy配列 x と y をフィルタリングします。
3001        xmin または xmax がNoneの場合、その側のフィルタリングは適用されません。
3002    引数:
3003        :param x: X座標のnumpy配列。
3004        :type x: numpy.ndarray
3005        :param y: Y座標のnumpy配列。
3006        :type y: numpy.ndarray
3007        :param xmin: 最小X値の閾値(オプション)。
3008        :type xmin: float | None
3009        :param xmax: 最大X値の閾値(オプション)。
3010        :type xmax: float | None
3011    戻り値:
3012        :returns: フィルタリングされたX値とY値のnumpy配列のタプル。
3013        :rtype: tuple[numpy.ndarray, numpy.ndarray]
3014    例外:
3015        :raises ValueError: xmin/xmax適用後にデータポイントが残らない場合。
3016    """
3017    if xmin is not None:
3018        mask = x >= xmin
3019        x, y = x[mask], y[mask]
3020    if xmax is not None:
3021        mask = x <= xmax
3022        x, y = x[mask], y[mask]
3023    if len(x) == 0:
3024        raise ValueError("xmin/xmax 適用後にデータポイントが残りません。")
3025    return x, y
3026
3027
3028
3029def run_search(args: argparse.Namespace, infile: Path) -> tuple[list[Peak], Path, Path | None]:
3030    """
3031    概要:
3032        ピーク探索モードを実行します。
3033    詳細説明:
3034        入力XRDデータファイル (infile) を読み込み、ピーク探索アルゴリズム (peak_search_deriv3)
3035        を実行します。検出されたピークにKα2の役割を割り当て、結果をコンソールに表示します。
3036        また、ピーク情報とプロットをファイルに保存します。
3037    引数:
3038        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
3039        :type args: argparse.Namespace
3040        :param infile: 入力データファイルへのパス。
3041        :type infile: Path
3042    戻り値:
3043        :returns: (検出されたPeakオブジェクトのリスト, ピーク情報ファイルパス, プロットファイルパス) のタプル。
3044        :rtype: tuple[list[Peak], Path, Path | None]
3045    """
3046    x, y = read_xy_data(infile)
3047    x, y = apply_x_range(x, y, args.xmin, args.xmax)
3048
3049    print(f"入力ファイル        : {infile}")
3050    print("モード              : search")
3051    print(f"2theta範囲          : {x.min():.3f} - {x.max():.3f}")
3052    print(f"閾値                : {args.threshold}")
3053    print(f"nsmooth             : {args.nsmooth}")
3054    print(f"norder              : {args.norder}")
3055    print(f"FWHM範囲            : {args.fwhm_min_deg} - {args.fwhm_max_deg} deg")
3056
3057    peaks, info = peak_search_deriv3(
3058        x, y,
3059        nsmooth=args.nsmooth,
3060        norder=args.norder,
3061        threshold=args.threshold,
3062        ydiff1_threshold=args.ydiff1_threshold,
3063        fwhm_min_deg=args.fwhm_min_deg,
3064        fwhm_max_deg=args.fwhm_max_deg,
3065        is_print=True,
3066    )
3067    assign_ka2(peaks)
3068
3069    print_peak_table(peaks, "検出されたピーク")
3070    n_ka2 = sum(1 for p in peaks if p.ka_role == "ka2")
3071    print("")
3072    print(f"検出されたCu Kα2ピーク : {n_ka2}")
3073    print(f"非Kα2ピーク           : {sum(1 for p in peaks if p.ka_role != 'ka2')}")
3074
3075    stem = sanitize_stem(infile.stem)
3076    plot_path = args.plot_file if args.plot_file is not None else infile.with_name(f"{stem}-peaksearch.png")
3077    peak_file = args.peak_file if args.peak_file is not None else default_peak_file_for_input(infile)
3078
3079    save_plot = bool(args.save_plot)
3080    show_plot = bool(args.show) and not bool(args.no_show)
3081    plot_results(x, y, info, peaks, plot_path, show=show_plot, save=save_plot)
3082    save_workbook(peak_file, {"peaks": peaks_to_frame(peaks)})
3083    print("")
3084    print(f"保存されたピークファイル: {peak_file}")
3085    if save_plot:
3086        print(f"保存されたプロット      : {plot_path}")
3087    return peaks, peak_file, plot_path if save_plot else None
3088
3089
3090def run_guess(args: argparse.Namespace, peak_file: Path, base_input: Path | None = None) -> Path:
3091    """
3092    概要:
3093        格子定数推定モードを実行します。
3094    詳細説明:
3095        ピーク情報ファイル (peak_file) からピークを読み込み、指定された結晶系と
3096        推定メソッド (args.method) に基づいて格子定数候補を推定します。
3097        推定された候補はコンソールに表示され、最も良い候補に対しては
3098        lsq_latt2.py スクリプトを用いた精密化が試みられます。
3099        結果はExcelワークブックとして保存されます。
3100    引数:
3101        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
3102        :type args: argparse.Namespace
3103        :param peak_file: ピーク情報ファイルへのパス。
3104        :type peak_file: Path
3105        :param base_input: 元の入力データファイルへのパス(オプション)。
3106        :type base_input: Path | None
3107    戻り値:
3108        :returns: 推定結果Excelファイルへのパス。
3109        :rtype: Path
3110    """
3111    crystal_systems = parse_crystal_systems(args.crystal_system)
3112    peaks = read_peaks_file(peak_file, wavelength=args.wavelength)
3113
3114    print("")
3115    print(f"ピークファイル      : {peak_file}")
3116    print("モード              : guess")
3117    print(f"メソッド            : {args.method}")
3118    print(f"結晶系              : {', '.join(crystal_systems)}")
3119    print(f"npeak               : {args.npeak}")
3120    print(f"hmax                : {args.hmax}")
3121
3122    print_peak_table(peaks, "ファイルから読み込まれたピーク")
3123    n_ka2 = sum(1 for p in peaks if p.ka_role == "ka2")
3124    print("")
3125    print(f"ファイル/推定内のCu Kα2ピーク : {n_ka2}")
3126    print(f"非Kα2ピーク                    : {sum(1 for p in peaks if p.ka_role != 'ka2')}")
3127    print("")
3128    args.resolved_lattice_max = resolve_lattice_max_limit(args, peaks)
3129
3130    candidates, selected = guess_candidates(args, peaks, crystal_systems)
3131    print("")
3132    print(f"推定に使用される最も強い非Kα2ピーク: {len(selected)}/{args.npeak}")
3133    print_peak_table(selected, "格子定数推定のために選択されたピーク")
3134
3135    print("")
3136    print("格子定数候補")
3137    if not candidates:
3138        print("候補が見つかりませんでした。--npeak, --hmax を増やすか、--crystal-system auto を試してください。")
3139    for i, cand in enumerate(candidates[:10], 1):
3140        print(f"{i:2d}. {cand.crystal_system:12s} マッチ数={cand.score_matches:2d} rms_rel={cand.score_rms_rel:.6e} パラメータ={cand.params}")
3141
3142    refined_summary = None
3143    refined_rows: list[dict] = []
3144    if candidates and args.lsq_script.exists():
3145        refined = refine_with_lsq(args.lsq_script, candidates[0], [r for r in candidates[0].all_matches if r["matched"]], wavelength=args.wavelength)
3146        if refined is not None:
3147            refined_summary, refined_rows = refined
3148            print("")
3149            print("精密化された格子定数")
3150            print(f"  crystal_system : {refined_summary['crystal_system']}")
3151            print(f"  a = {refined_summary['a']:.6f} ± {refined_summary['sigma_a']:.6f} Å")
3152            print(f"  b = {refined_summary['b']:.6f} ± {refined_summary['sigma_b']:.6f} Å")
3153            print(f"  c = {refined_summary['c']:.6f} ± {refined_summary['sigma_c']:.6f} Å")
3154            print(f"  alpha = {refined_summary['alpha']:.6f} ± {refined_summary['sigma_alpha']:.6f} deg")
3155            print(f"  beta  = {refined_summary['beta']:.6f} ± {refined_summary['sigma_beta']:.6f} deg")
3156            print(f"  gamma = {refined_summary['gamma']:.6f} ± {refined_summary['sigma_gamma']:.6f} deg")
3157            print(f"  rms_rel_error_inv_d2 = {refined_summary['rms_rel_error_inv_d2']:.6e}")
3158            print(f"  rms_rel_error_2theta = {refined_summary['rms_rel_error_2theta']:.6e}")
3159    elif candidates:
3160        print("")
3161        print(f"lsqスクリプトが見つからなかったため、精密化はスキップされました: {args.lsq_script}")
3162
3163    sheets: dict[str, pd.DataFrame] = {
3164        "all_detected_peaks": peaks_to_frame(peaks),
3165        "selected_for_guess": peaks_to_frame(selected),
3166    }
3167    if candidates:
3168        cand_rows = [candidate_to_summary_row(i, c, method=args.method) for i, c in enumerate(candidates, 1)]
3169        sheets["candidate_summary"] = pd.DataFrame(cand_rows)
3170        sheets["all_peaks_assignment"] = pd.DataFrame(candidates[0].all_matches)
3171        sheets["selected_assignment"] = pd.DataFrame(candidates[0].selected_matches)
3172    if refined_summary:
3173        sheets["refined_summary"] = pd.DataFrame([refined_summary])
3174        sheets["refined_matched"] = pd.DataFrame(refined_rows)
3175
3176    out_xlsx = args.output_file if args.output_file is not None else default_guess_file_for_peak_file(peak_file, base_input)
3177    save_workbook(out_xlsx, sheets)
3178    print("")
3179    print(f"保存された推定ファイル: {out_xlsx}")
3180    return out_xlsx
3181
3182
3183def _load_peaks_for_compare(args: argparse.Namespace, infile: Path) -> tuple[list[Peak], np.ndarray | None, np.ndarray | None, Path | None]:
3184    """
3185    概要:
3186        比較モードのためにピークとオプションの生のXRDデータを読み込みます。
3187    詳細説明:
3188        --peak-file が指定されていれば、そのファイルからピークを読み込みます。
3189        入力ファイルが通常のXRDデータのように見える場合は、ピーク探索を実行してピークを取得します。
3190        入力ファイルが推定ワークブックの場合、そこに含まれるピークシートを読み込みます。
3191        いずれでもない場合、ファイルがピークリストであると仮定して読み込みを試み、
3192        失敗した場合は生XRDデータと見なしてピーク探索を行います。
3193        生のXRDデータがあれば、それも一緒に返します。
3194    引数:
3195        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
3196        :type args: argparse.Namespace
3197        :param infile: 入力ファイルへのパス。
3198        :type infile: Path
3199    戻り値:
3200        :returns: (Peakオブジェクトのリスト, 生のXデータ, 生のYデータ, 使用されたピークファイルパス) のタプル。
3201        :rtype: tuple[list[Peak], numpy.ndarray | None, numpy.ndarray | None, Path | None]
3202    """
3203    if args.peak_file is not None:
3204        peaks = read_peaks_file(args.peak_file, wavelength=args.wavelength)
3205        x, y = try_read_raw_xy_for_plot(infile)
3206        return peaks, x, y, args.peak_file
3207
3208    # 高密度な2列ファイルは通常、生のXRDプロファイルであり、ピークリストではありません。
3209    if looks_like_raw_xrd_file(infile):
3210        peaks, peak_file, _plot = run_search(args, infile)
3211        x, y = try_read_raw_xy_for_plot(infile)
3212        return peaks, x, y, peak_file
3213
3214    # 入力が推定ワークブックの場合、その all_detected_peaks シートを使用します。
3215    if infile.suffix.lower() in {".xlsx", ".xls"}:
3216        try:
3217            xls = pd.ExcelFile(infile)
3218            names = {str(s).lower() for s in xls.sheet_names}
3219            if "all_detected_peaks" in names or "peaks" in names:
3220                peaks = read_peaks_file(infile, wavelength=args.wavelength)
3221                return peaks, None, None, infile
3222        except Exception:
3223            pass
3224
3225    # それ以外の場合、まずピークファイルとして解釈を試みます。失敗した場合は生XRDとして扱い、探索を実行します。
3226    try:
3227        peaks = read_peaks_file(infile, wavelength=args.wavelength)
3228        return peaks, None, None, infile
3229    except Exception:
3230        peaks, peak_file, _plot = run_search(args, infile)
3231        x, y = try_read_raw_xy_for_plot(infile)
3232        return peaks, x, y, peak_file
3233
3234
3235def run_compare(args: argparse.Namespace, infile: Path) -> Path:
3236    """
3237    概要:
3238        比較モードまたは計算モードを実行します。
3239    詳細説明:
3240        観測されたピークと、指定された格子定数候補(推定ファイルから読み込むか、
3241        コマンドライン引数で明示的に指定されたもの)を比較します。
3242        観測ピークと理論的回折線をマッチングし、結果をコンソールに表示します。
3243        また、結果のデータフレームとプロットをファイルに保存します。
3244    引数:
3245        :param args: コマンドライン引数を格納したNamespaceオブジェクト。
3246        :type args: argparse.Namespace
3247        :param infile: 入力ファイルへのパス。
3248        :type infile: Path
3249    戻り値:
3250        :returns: 比較結果Excelファイルへのパス。
3251        :rtype: Path
3252    例外:
3253        :raises ValueError: 比較に利用可能なピークがない場合、
3254                            または候補が見つからない、またはランクが無効な場合。
3255    """
3256    explicit_lattice = has_explicit_lattice_params(args)
3257    mode_label = "calc" if normalize_mode(args.mode) == "calc" or explicit_lattice else "compare"
3258    print("")
3259    print(f"入力ファイル        : {infile}")
3260    print(f"モード              : {mode_label}")
3261    if explicit_lattice:
3262        print("候補元              : 明示的な格子定数")
3263    else:
3264        print(f"候補ランク          : {args.candidate_rank}")
3265
3266    peaks, x, y, peak_file_used = _load_peaks_for_compare(args, infile)
3267    if args.xmin is not None or args.xmax is not None:
3268        peaks = [p for p in peaks if (args.xmin is None or p.two_theta >= args.xmin) and (args.xmax is None or p.two_theta <= args.xmax)]
3269    if not peaks:
3270        raise ValueError("比較に利用可能なピークがありません。")
3271
3272    guess_file = args.guess_file
3273    if guess_file is None and infile.suffix.lower() in {".xlsx", ".xls"}:
3274        try:
3275            xls = pd.ExcelFile(infile)
3276            if "candidate_summary" in {str(s).lower() for s in xls.sheet_names}:
3277                guess_file = infile
3278        except Exception:
3279            guess_file = None
3280
3281    if explicit_lattice:
3282        candidate = candidate_from_lattice_args(args)
3283    elif guess_file is not None:
3284        candidate = read_candidate_from_guess_file(guess_file, args.candidate_rank)
3285    else:
3286        crystal_systems = parse_crystal_systems(args.crystal_system)
3287        if not hasattr(args, "resolved_lattice_max"):
3288            print("")
3289            args.resolved_lattice_max = resolve_lattice_max_limit(args, peaks)
3290        candidates, _selected = guess_candidates(args, peaks, crystal_systems)
3291        if not candidates:
3292            raise ValueError("比較のための候補が見つかりませんでした。--guess-file、明示的な--a/--b/--c を指定するか、推定オプションを調整してください。")
3293        if args.candidate_rank < 1 or args.candidate_rank > len(candidates):
3294            raise ValueError(f"候補ランク {args.candidate_rank} は 1..{len(candidates)} の範囲外です。")
3295        candidate = candidates[args.candidate_rank - 1]
3296
3297    xmin = args.xmin
3298    xmax = args.xmax
3299    if xmin is None and x is not None and len(x):
3300        xmin = float(np.nanmin(x))
3301    if xmax is None and x is not None and len(x):
3302        xmax = float(np.nanmax(x))
3303
3304    theoretical_df, assignment_df = compare_candidate_with_peaks(
3305        candidate,
3306        peaks,
3307        hmax=args.hmax,
3308        wavelength=args.wavelength,
3309        xmin=xmin,
3310        xmax=xmax,
3311        tolerance_deg=args.tolerance_deg,
3312    )
3313
3314    print("")
3315    print("使用された候補")
3316    print(f"  crystal_system : {candidate.crystal_system}")
3317    for k in ["a", "b", "c", "alpha", "beta", "gamma"]:
3318        if k in candidate.params:
3319            print(f"  {k:5s} = {candidate.params[k]:.8g}")
3320
3321    if not assignment_df.empty:
3322        matched = assignment_df[assignment_df["matched"] == True]
3323        print("")
3324        print(f"比較された観測ピーク : {len(assignment_df)}")
3325        print(f"許容誤差内でマッチした数: {len(matched)}")
3326        if len(matched):
3327            rms = float(np.sqrt(np.mean(np.square(matched["resid_two_theta_deg"].to_numpy(dtype=float)))))
3328            print(f"RMS残差2θ             : {rms:.6g} deg")
3329        cols = ["peak_id", "two_theta_obs_deg", "hkl", "two_theta_calc_deg", "resid_two_theta_deg", "intensity", "fwhm_deg", "matched"]
3330        with pd.option_context("display.max_rows", 300, "display.width", 180):
3331            print(assignment_df[cols].to_string(index=False))
3332
3333    stem = sanitize_stem(strip_peak_suffix((peak_file_used or infile).stem))
3334    suffix = "calc" if explicit_lattice else f"compare-rank{args.candidate_rank}"
3335    out_xlsx = args.output_file if args.output_file is not None else (peak_file_used or infile).with_name(f"{stem}-{suffix}.xlsx")
3336    plot_path = args.plot_file if args.plot_file is not None else out_xlsx.with_suffix(".png")
3337
3338    sheets = {
3339        "candidate_used": pd.DataFrame([candidate_to_summary_row(args.candidate_rank if not explicit_lattice else 0, candidate, method="explicit" if explicit_lattice else "")]),
3340        "observed_peaks": peaks_to_frame(peaks),
3341        "theoretical_lines": theoretical_df,
3342        "observed_vs_calc": assignment_df,
3343    }
3344    save_workbook(out_xlsx, sheets)
3345
3346    save_plot = bool(args.save_plot)
3347    show_plot = bool(args.show) and not bool(args.no_show)
3348    plot_theoretical_df = filter_theoretical_lines_for_plot(
3349        theoretical_df,
3350        mode=args.plot_theory,
3351        near_deg=args.plot_near_deg if args.plot_near_deg is not None else args.tolerance_deg,
3352    )
3353    plot_compare_results(x, y, peaks, plot_theoretical_df, assignment_df, 
3354                candidate, xmin=xmin, xmax=xmax,
3355                outpath=plot_path, show=show_plot, save=save_plot)
3356
3357    print("")
3358    print(f"理論線            : 計算された線 {len(theoretical_df)}本、描画された線 {len(plot_theoretical_df)}本 (--plot-theory {args.plot_theory})")
3359    print(f"保存された比較ファイル: {out_xlsx}")
3360    if save_plot:
3361        print(f"保存されたプロット      : {plot_path}")
3362    if show_plot:
3363        print("ホバー注釈        : mplcursorsがインストールされている場合有効")
3364    return out_xlsx
3365
3366
3367def main() -> int:
3368    """
3369    概要:
3370        スクリプトのメインエントリポイントです。
3371    詳細説明:
3372        コマンドライン引数を解析し、指定されたモード(search, guess, all, compare, calc)に基づいて
3373        適切な処理関数を呼び出します。エラーが発生した場合は、エラーメッセージを標準エラー出力に表示します。
3374    戻り値:
3375        :returns: 成功した場合は0、エラーの場合は非0の終了コード。
3376        :rtype: int
3377    例外:
3378        :raises SystemExit: コマンドライン引数エラーや処理中の例外が発生した場合。
3379    """
3380    args = build_argument_parser().parse_args()
3381    try:
3382        if args.no_show:
3383            args.show = 0
3384        mode, infile = resolve_mode_and_input(args)
3385        print(f"要求されたモード    : {mode}")
3386        if mode == "search":
3387            run_search(args, infile)
3388            return 0
3389        if mode == "guess":
3390            peak_file = args.peak_file if args.peak_file is not None else infile
3391            run_guess(args, peak_file)
3392            return 0
3393        if mode in {"compare", "calc"}:
3394            if mode == "calc" and not has_explicit_lattice_params(args):
3395                raise ValueError("--mode calc には明示的な格子定数が必要です (例: --crystal-system cubic --a 3.905)")
3396            run_compare(args, infile)
3397            return 0
3398        if mode == "all":
3399            _peaks, peak_file, _plot_path = run_search(args, infile)
3400            guess_file = run_guess(args, peak_file, base_input=infile)
3401            # --mode all は探索 + 推定のみです。選択されたランクを比較するには --mode compare と --guess-file を使用してください。
3402            return 0
3403        raise SystemExit(f"サポートされていないモード: {mode}")
3404    except Exception as exc:
3405        print(f"エラー: {exc}", file=sys.stderr)
3406        raise
3407
3408    input("\n続行するにはEnterを押してください>>\n")
3409    
3410if __name__ == "__main__":
3411    raise SystemExit(main())