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())