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

search_cif_db_tkcif.py をダウンロード

search_cif_db_tkcif.py
search_cif_db_tkcif.py
  1#!/usr/bin/env python
  2# -*- coding: utf-8 -*-
  3"""
  4概要:
  5    CIFファイルを再帰的に検索し、その組成情報をSQLite3データベースに格納するツールです。
  6    格納されたデータベースを利用して、組成式からCIFデータを検索することができます。
  7詳細説明:
  8    このスクリプトは、指定されたルートディレクトリ以下のCIFファイルを読み込み、
  9    pymatgenとtkcifライブラリを使用して構造と組成情報を解析します。
 10    解析された情報はSQLite3データベースに保存され、後で高速に検索できるようになります。
 11    特に、組成式、還元組成式、元素のリスト、空間群、格子定数などのメタデータが格納されます。
 12
 13    主な機能:
 14    - indexモード: 指定されたディレクトリからCIFファイルをスキャンし、データベースを構築または更新します。
 15      コマンド例:
 16        python search_cif_db_tkcif.py --mode index  --root ./COD --db cif_index.sqlite
 17    - searchモード: データベースに対して、組成式に基づいてCIFデータを検索します。
 18      検索モードには、exact (還元組成式が完全に一致)、elements (構成元素が完全に一致)、
 19      contains (指定元素が部分集合として含まれる) の3種類のマッチング方式があります。
 20      コマンド例:
 21        python search_cif_db_tkcif.py --mode search --db cif_index.sqlite --formula BaTiO3
 22        python search_cif_db_tkcif.py --mode search --db cif_index.sqlite --formula TiBaO3 --match exact
 23        python search_cif_db_tkcif.py --mode search --db cif_index.sqlite --formula BaTiO3 --match elements
 24        python search_cif_db_tkcif.py --mode search --db cif_index.sqlite --formula BaTiO3 --match contains
 25    - infoモード: データベースの統計情報を表示します。
 26      コマンド例:
 27        python search_cif_db_tkcif.py --mode info   --db cif_index.sqlite
 28
 29    データベースのカラムは、ソースデータベース名、マテリアルID、CIFファイルのパス、組成式、
 30    還元組成式、匿名組成式、JSON形式の組成情報、元素リスト、元素数、サイト数、
 31    空間群記号、空間群番号、体積、原子あたりの体積、密度、格子定数 (a, b, c, alpha, beta, gamma)、
 32    使用されたバックエンド、正規化の有無、処理ステータス、エラーメッセージを含みます。
 33
 34関連リンク:
 35    search_cif_db_tkcif_usage
 36"""
 37
 38from __future__ import annotations
 39
 40import argparse
 41import json
 42import re
 43import sqlite3
 44import sys
 45import traceback
 46import warnings
 47from pathlib import Path
 48from typing import Any
 49
 50try:
 51    from pymatgen.core import Composition
 52except Exception:
 53    print("ERROR: failed to import pymatgen Composition")
 54    traceback.print_exc()
 55    print("\nInstall example:")
 56    print("  pip install pymatgen")
 57    input("\nPress ENTER to terminate>>\n")
 58    sys.exit(1)
 59
 60try:
 61    from tkcif.tkcif_reader import read_structure
 62except Exception:
 63    print("ERROR: failed to import tkcif.tkcif_reader")
 64    traceback.print_exc()
 65    input("\nPress ENTER to terminate>>\n")
 66    sys.exit(1)
 67
 68# 大量 CIF 処理では pymatgen/ASE の warning が多すぎるので標準では抑制する。
 69warnings.simplefilter("ignore")
 70
 71
 72SCHEMA_COLUMNS: dict[str, str] = {
 73    "source_db": "TEXT",
 74    "material_id": "TEXT",
 75    "cif_path": "TEXT UNIQUE",
 76    "formula": "TEXT",
 77    "reduced_formula": "TEXT",
 78    "anonymous_formula": "TEXT",
 79    "composition_json": "TEXT",
 80    "elements": "TEXT",
 81    "nelements": "INTEGER",
 82    "nsites": "INTEGER",
 83    "sg_symbol": "TEXT",
 84    "sg_number": "INTEGER",
 85    "volume": "REAL",
 86    "volume_per_atom": "REAL",
 87    "density": "REAL",
 88    "a": "REAL",
 89    "b": "REAL",
 90    "c": "REAL",
 91    "alpha": "REAL",
 92    "beta": "REAL",
 93    "gamma": "REAL",
 94    "backend": "TEXT",
 95    "normalized": "INTEGER",
 96    "status": "TEXT",
 97    "error": "TEXT",
 98}
 99
100
101DEFAULT_ERROR_RECORD_VALUES: dict[str, Any] = {
102    "formula": "",
103    "reduced_formula": "",
104    "anonymous_formula": "",
105    "composition_json": "{}",
106    "elements": "",
107    "nelements": -1,
108    "nsites": -1,
109    "sg_symbol": "",
110    "sg_number": -1,
111    "volume": -1.0,
112    "volume_per_atom": -1.0,
113    "density": -1.0,
114    "a": -1.0,
115    "b": -1.0,
116    "c": -1.0,
117    "alpha": -1.0,
118    "beta": -1.0,
119    "gamma": -1.0,
120    "backend": "",
121    "normalized": 0,
122}
123
124
125def guess_db_name(path: Path) -> str:
126    """
127    概要:
128        ファイルのパスからデータベース名を推定します。
129    詳細説明:
130        パスの要素を小文字に変換し、"pcod", "tcod", "cod" のいずれかが含まれる場合、
131        その名前をデータベース名として返します。
132        これらの名前が見つからない場合は "unknown" を返します。
133    引数:
134        :param path: CIFファイルのパス。
135        :type path: pathlib.Path
136    戻り値:
137        :returns: 推定されたデータベース名 (例: "cod", "tcod", "pcod", "unknown")。
138        :rtype: str
139    """
140    parts = [p.lower() for p in path.parts]
141    for name in ("pcod", "tcod", "cod"):
142        if name in parts:
143            return name
144    return "unknown"
145
146
147def guess_material_id(path: Path) -> str:
148    """
149    概要:
150        CIFファイルのファイル名からマテリアルIDを推定します。
151    詳細説明:
152        ファイル名から最初に見つかる数字列をマテリアルIDとして抽出します。
153        例えば、"1234567.cif" から "1234567" を返します。
154        数字が見つからない場合は、ファイル名全体 (拡張子なし) を返します。
155    引数:
156        :param path: CIFファイルのパス。
157        :type path: pathlib.Path
158    戻り値:
159        :returns: 推定されたマテリアルID。
160        :rtype: str
161    """
162    m = re.search(r"(\d+)", path.stem)
163    return m.group(1) if m else path.stem
164
165
166def composition_to_sorted_json(comp: Composition) -> str:
167    """
168    概要:
169        pymatgen.core.Composition オブジェクトを、元素名でソートされたJSON文字列に変換します。
170    詳細説明:
171        Composition オブジェクトの各元素と量の情報を取得し、元素のシンボル (文字列) でソートします。
172        その後、JSON形式の文字列として出力します。
173        pymatgen Composition.as_dict() のキーは Element オブジェクトではなく文字列として扱われます。
174    引数:
175        :param comp: 変換する組成オブジェクト。
176        :type comp: pymatgen.core.Composition
177    戻り値:
178        :returns: 元素名でソートされた組成情報のJSON文字列。
179        :rtype: str
180    """
181    d = {str(el): float(amount) for el, amount in comp.as_dict().items()}
182    return json.dumps(dict(sorted(d.items())), ensure_ascii=False, sort_keys=True)
183
184
185def normalize_formula(formula: str) -> str:
186    """
187    概要:
188        与えられた組成式をpymatgen.core.Compositionを使用して標準化(還元)します。
189    詳細説明:
190        例えば、"BaTiO3" や "Ba Ti O3" のような式を pymatgen が認識する標準形式に変換し、
191        さらに組成が最も簡単な整数比になるように還元します。
192    引数:
193        :param formula: 標準化する組成式文字列。
194        :type formula: str
195    戻り値:
196        :returns: 標準化された還元組成式文字列。
197        :rtype: str
198    """
199    comp = Composition(formula)
200    return comp.reduced_formula
201
202
203def composition_info_from_structure(path: Path) -> dict[str, Any]:
204    """
205    概要:
206        CIFファイルから構造情報を読み込み、検索用の組成および構造メタデータを作成します。
207    詳細説明:
208        指定されたCIFファイルを tkcif.tkcif_reader.read_structure を使って読み込み、
209        pymatgen.core.Structure オブジェクトを生成します。
210        そこから組成式、還元組成式、匿名組成式、元素リスト、サイト数、空間群情報、
211        格子定数、体積、原子あたりの体積、密度などを抽出し、辞書形式で返します。
212        空間群情報の取得に失敗した場合は、空文字列や -1 を返します。
213    引数:
214        :param path: CIFファイルのパス。
215        :type path: pathlib.Path
216    戻り値:
217        :returns: 検索用のメタデータを含む辞書。
218        :rtype: dict
219    """
220    structure, read_info = read_structure(path, return_info=True)
221
222    comp = structure.composition
223    red_comp = comp.reduced_composition
224
225    elements = sorted([el.symbol for el in red_comp.elements])
226    element_set = ",".join(elements)
227
228    try:
229        sg_symbol, sg_number_raw = structure.get_space_group_info()
230        sg_number = int(sg_number_raw)
231    except Exception:
232        sg_symbol = ""
233        sg_number = -1
234
235    nsites = len(structure)
236    volume = float(structure.volume)
237    volume_per_atom = volume / nsites if nsites > 0 else -1.0
238
239    try:
240        density = float(structure.density)
241    except Exception:
242        density = -1.0
243
244    return {
245        "formula": comp.formula,
246        "reduced_formula": red_comp.reduced_formula,
247        "anonymous_formula": red_comp.anonymized_formula,
248        "composition_json": composition_to_sorted_json(red_comp),
249        "elements": element_set,
250        "nelements": len(elements),
251        "nsites": nsites,
252        "sg_symbol": sg_symbol,
253        "sg_number": sg_number,
254        "volume": volume,
255        "volume_per_atom": volume_per_atom,
256        "density": density,
257        "a": float(structure.lattice.a),
258        "b": float(structure.lattice.b),
259        "c": float(structure.lattice.c),
260        "alpha": float(structure.lattice.alpha),
261        "beta": float(structure.lattice.beta),
262        "gamma": float(structure.lattice.gamma),
263        "backend": getattr(read_info, "backend", ""),
264        "normalized": int(bool(getattr(read_info, "normalized", False))),
265    }
266
267
268def get_existing_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
269    """
270    概要:
271        指定されたSQLiteテーブルに存在するカラム名を取得します。
272    詳細説明:
273        PRAGMA table_info SQLコマンドを実行し、テーブルのスキーマ情報を取得します。
274        結果からカラム名のみを抽出し、集合として返します。
275    引数:
276        :param conn: SQLiteデータベース接続オブジェクト。
277        :type conn: sqlite3.Connection
278        :param table_name: カラム情報を取得するテーブルの名前。
279        :type table_name: str
280    戻り値:
281        :returns: 既存のカラム名の集合。
282        :rtype: set
283    """
284    rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
285    return {str(row[1]) for row in rows}
286
287
288def create_schema(conn: sqlite3.Connection) -> None:
289    """
290    概要:
291        SQLiteデータベースのスキーマを新規作成または既存のスキーマを更新します。
292    詳細説明:
293        cif_index テーブルが存在しない場合は作成し、必要なカラムとインデックスを追加します。
294        テーブルが既に存在する場合は、SCHEMA_COLUMNS に定義されているがまだ存在しないカラムがあれば追加します。
295        これにより、新しいバージョンのスキーマに既存のデータベースをアップグレードできます。
296        その後、検索パフォーマンス向上のために複数のインデックスを作成します。
297    引数:
298        :param conn: SQLiteデータベース接続オブジェクト。
299        :type conn: sqlite3.Connection
300    戻り値:
301        :returns: なし
302        :rtype: None
303    """
304    column_defs = ",\n        ".join(
305        f"{name} {dtype}" for name, dtype in SCHEMA_COLUMNS.items()
306    )
307    conn.execute(f"""
308    CREATE TABLE IF NOT EXISTS cif_index (
309        id INTEGER PRIMARY KEY AUTOINCREMENT,
310        {column_defs}
311    )
312    """)
313
314    existing = get_existing_columns(conn, "cif_index")
315    for name, dtype in SCHEMA_COLUMNS.items():
316        if name not in existing:
317            conn.execute(f"ALTER TABLE cif_index ADD COLUMN {name} {dtype}")
318
319    conn.execute("CREATE INDEX IF NOT EXISTS idx_formula ON cif_index(reduced_formula)")
320    conn.execute("CREATE INDEX IF NOT EXISTS idx_elements ON cif_index(elements)")
321    conn.execute("CREATE INDEX IF NOT EXISTS idx_source_db ON cif_index(source_db)")
322    conn.execute("CREATE INDEX IF NOT EXISTS idx_backend ON cif_index(backend)")
323    conn.execute("CREATE INDEX IF NOT EXISTS idx_sg_number ON cif_index(sg_number)")
324    conn.execute("CREATE INDEX IF NOT EXISTS idx_volume_per_atom ON cif_index(volume_per_atom)")
325    conn.commit()
326
327
328def upsert_record(conn: sqlite3.Connection, rec: dict[str, Any]) -> None:
329    """
330    概要:
331        データベースにレコードを挿入または更新(UPSERT)します。
332    詳細説明:
333        指定されたレコード辞書recの内容に基づいて、cif_indexテーブルにデータを挿入します。
334        cif_path カラムは UNIQUE 制約を持つため、同じパスのレコードが既に存在する場合は
335        既存のレコードが新しいデータで置き換えられます(INSERT OR REPLACE)。
336    引数:
337        :param conn: SQLiteデータベース接続オブジェクト。
338        :type conn: sqlite3.Connection
339        :param rec: データベースに挿入または更新するレコードデータを含む辞書。
340        :type rec: dict
341    戻り値:
342        :returns: なし
343        :rtype: None
344    """
345    cols = list(SCHEMA_COLUMNS.keys())
346    col_sql = ", ".join(cols)
347    val_sql = ", ".join(f":{c}" for c in cols)
348    conn.execute(
349        f"""
350        INSERT OR REPLACE INTO cif_index ({col_sql})
351        VALUES ({val_sql})
352        """,
353        rec,
354    )
355
356
357def build_index(
358    root: Path,
359    db_path: Path,
360    pattern: str = "*.cif",
361    store_errors: int = 1,
362    commit_interval: int = 200,
363) -> None:
364    """
365    概要:
366        指定されたルートディレクトリ以下のCIFファイルをスキャンし、データベースインデックスを構築します。
367    詳細説明:
368        再帰的にCIFファイルを検索し、各ファイルから組成および構造情報を抽出します。
369        抽出された情報はSQLiteデータベースに格納され、検索可能なインデックスが作成されます。
370        ファイル処理中にエラーが発生した場合、store_errors が1であればエラー情報もデータベースに記録されます。
371        commit_interval ごとにトランザクションがコミットされ、進行状況が出力されます。
372        最後に、処理の要約と、使用されたバックエンドの統計情報が表示されます。
373    引数:
374        :param root: CIFファイルを検索するルートディレクトリのパス。
375        :type root: pathlib.Path
376        :param db_path: SQLiteデータベースファイルのパス。
377        :type db_path: pathlib.Path
378        :param pattern: 検索するCIFファイルのパターン (例: "*.cif")。
379        :type pattern: str
380        :param store_errors: エラーが発生したレコードをデータベースに保存するかどうか (0: 保存しない, 1: 保存する)。
381        :type store_errors: int
382        :param commit_interval: データベースにコミットする間隔 (処理されたファイル数)。
383        :type commit_interval: int
384    戻り値:
385        :returns: なし
386        :rtype: None
387    """
388    conn = sqlite3.connect(str(db_path))
389    create_schema(conn)
390
391    files = list(root.rglob(pattern))
392    print(f"Found CIF files: {len(files)}")
393
394    n_ok = 0
395    n_err = 0
396    backend_counts: dict[str, int] = {}
397
398    for i, path in enumerate(files, start=1):
399        path = path.resolve()
400
401        rec_base: dict[str, Any] = {
402            "source_db": guess_db_name(path),
403            "material_id": guess_material_id(path),
404            "cif_path": str(path),
405        }
406
407        try:
408            info = composition_info_from_structure(path)
409            rec = {
410                **rec_base,
411                **info,
412                "status": "ok",
413                "error": "",
414            }
415            upsert_record(conn, rec)
416            n_ok += 1
417            backend = str(rec.get("backend", "")) or "unknown"
418            backend_counts[backend] = backend_counts.get(backend, 0) + 1
419
420        except Exception as exc:
421            n_err += 1
422            print(f"[ERROR] {path}: {exc}")
423
424            if store_errors:
425                rec = {
426                    **rec_base,
427                    **DEFAULT_ERROR_RECORD_VALUES,
428                    "status": "error",
429                    "error": str(exc),
430                }
431                upsert_record(conn, rec)
432
433        if i % commit_interval == 0:
434            conn.commit()
435            print(f"Indexed {i}/{len(files)}  ok={n_ok}  error={n_err}")
436
437    conn.commit()
438    conn.close()
439
440    print(f"Done. ok={n_ok}, error={n_err}")
441    if backend_counts:
442        print("\nBackend summary:")
443        for backend, count in sorted(backend_counts.items(), key=lambda x: (-x[1], x[0])):
444            print(f"  {backend:24s} {count}")
445    print(f"\nDB: {db_path}")
446
447
448def make_subset_condition_for_contains(target_elements: list[str]) -> tuple[str, list[Any]]:
449    """
450    概要:
451        データベースの elements カラムが指定された元素リストの部分集合である条件を生成します。
452    詳細説明:
453        この関数は、SQLのLIKE演算子を使用して、ある元素集合 (target_elements) の中に
454        データベースレコードの元素集合が含まれるかを判定するためのWHERE句とパラメータを生成します。
455        elementsカラムは、DB作成時にソートされたカンマ区切りの文字列として格納されているため、
456        例として、targetが Ba,O,Ti の場合、DBレコードの elements が Ba,O や Ti,O などであればヒットします。
457        要素の境界を明確にするために、カンマで囲まれた文字列に対する LIKE 検索を行います。
458    引数:
459        :param target_elements: 検索対象となる元素のソート済みリスト。
460        :type target_elements: list
461    戻り値:
462        :returns: WHERE句の条件文字列と、その条件にバインドするパラメータのリストのタプル。
463        :rtype: tuple
464    """
465    target_string = "," + ",".join(target_elements) + ","
466    return "? LIKE '%,' || elements || ',%'", [target_string]
467
468
469def search_by_formula(
470    db_path: Path,
471    formula: str,
472    source_db: str = "",
473    match: str = "exact",
474    limit: int = 100,
475    output_json: int = 0,
476) -> None:
477    """
478    概要:
479        指定された組成式とマッチングモードに基づいて、データベースからCIFデータを検索します。
480    詳細説明:
481        pymatgen.core.Composition を用いて検索対象の組成式を正規化し、
482        指定されたマッチングモード (exact, elements, contains) に従ってデータベースをクエリします。
483        検索結果は、source_db、material_id、reduced_formula、elements、空間群、格子定数などの情報を含みます。
484        結果は、元素数、ソースデータベース、還元組成式、マテリアルIDの順にソートされ、指定された件数に制限されます。
485        output_json が1の場合、結果はJSON形式で出力されます。それ以外の場合は、人間が読みやすい形式で出力されます。
486    引数:
487        :param db_path: SQLiteデータベースファイルのパス。
488        :type db_path: pathlib.Path
489        :param formula: 検索する組成式。
490        :type formula: str
491        :param source_db: 検索対象のソースデータベース名 (例: "cod", "tcod")。空文字列の場合はすべてのデータベースを検索します。
492        :type source_db: str
493        :param match: マッチングモード。"exact" (還元組成式が完全に一致), "elements" (構成元素が完全に一致),
494                      "contains" (指定元素集合が部分集合として含まれる) のいずれかを指定します。
495        :type match: str
496        :param limit: 検索結果の最大件数。
497        :type limit: int
498        :param output_json: 結果をJSON形式で出力するかどうか (0: 通常出力, 1: JSON出力)。
499        :type output_json: int
500    戻り値:
501        :returns: なし
502        :rtype: None
503    例外:
504        :raises ValueError: 未知のマッチングモードが指定された場合に発生します。
505    """
506    target_comp = Composition(formula).reduced_composition
507    target_formula = target_comp.reduced_formula
508    target_elements = sorted([el.symbol for el in target_comp.elements])
509    target_element_set = ",".join(target_elements)
510
511    conn = sqlite3.connect(str(db_path))
512    conn.row_factory = sqlite3.Row
513
514    params: list[Any] = []
515    where = ["status = 'ok'"]
516
517    if source_db:
518        where.append("source_db = ?")
519        params.append(source_db)
520
521    if match == "exact":
522        # BaTiO3 と TiBaO3 はどちらも BaTiO3 に正規化される。
523        where.append("reduced_formula = ?")
524        params.append(target_formula)
525
526    elif match == "elements":
527        # 元素集合が完全一致:Ba-Ti-O系のみ。
528        where.append("elements = ?")
529        params.append(target_element_set)
530
531    elif match == "contains":
532        # 指定元素集合の部分集合を許す。
533        # BaTiO3検索で Ba, Ti, O, BaO, TiO2, BaTiO3 などがヒット。
534        cond, cond_params = make_subset_condition_for_contains(target_elements)
535        where.append(cond)
536        params.extend(cond_params)
537
538    else:
539        raise ValueError(f"Unknown match mode: {match}")
540
541    sql = f"""
542    SELECT
543        source_db, material_id, reduced_formula, formula,
544        composition_json, elements, sg_symbol, sg_number, nsites,
545        a, b, c, alpha, beta, gamma, volume, volume_per_atom, density,
546        backend, normalized, cif_path
547    FROM cif_index
548    WHERE {' AND '.join(where)}
549    ORDER BY nelements DESC, source_db, reduced_formula, material_id
550    LIMIT ?
551    """
552    params.append(limit)
553
554    rows = [dict(r) for r in conn.execute(sql, params).fetchall()]
555    conn.close()
556
557    if output_json:
558        print(json.dumps(rows, ensure_ascii=False, indent=2))
559        return
560
561    print(f"query formula   : {formula}")
562    print(f"reduced formula : {target_formula}")
563    print(f"elements        : {target_element_set}")
564    print(f"match           : {match}")
565    print(f"hits            : {len(rows)}")
566    print()
567
568    for r in rows:
569        print(
570            f"[{r['source_db']}] {r['material_id']}  "
571            f"{r['reduced_formula']}  "
572            f"elements={r['elements']}  "
573            f"SG={r['sg_symbol']}({r['sg_number']})  "
574            f"nsites={r['nsites']}  "
575            f"backend={r.get('backend', '')}"
576        )
577        print(f"  a,b,c = {r['a']:.6g}, {r['b']:.6g}, {r['c']:.6g}")
578        print(f"  V/atom = {r.get('volume_per_atom', -1):.6g} A^3, density = {r.get('density', -1):.6g} g/cm^3")
579        print(f"  path   = {r['cif_path']}")
580        print()
581
582
583def show_info(db_path: Path) -> None:
584    """
585    概要:
586        指定されたデータベースの統計情報を表示します。
587    詳細説明:
588        データベースに格納されているCIFレコードの総数、ステータスごとの内訳 (成功/エラー)、
589        ソースデータベースごとの内訳、使用されたバックエンドごとの内訳、
590        および正規化の有無ごとの内訳を出力します。
591        これらの情報は、データベースの状態やインデックス作成の品質を把握するのに役立ちます。
592    引数:
593        :param db_path: SQLiteデータベースファイルのパス。
594        :type db_path: pathlib.Path
595    戻り値:
596        :returns: なし
597        :rtype: None
598    """
599    conn = sqlite3.connect(str(db_path))
600    cur = conn.cursor()
601
602    print("Total:")
603    for row in cur.execute("""
604        SELECT status, COUNT(*) FROM cif_index GROUP BY status
605    """):
606        print(f"  {row[0]}: {row[1]}")
607
608    print("\nBy source_db:")
609    for row in cur.execute("""
610        SELECT source_db, status, COUNT(*)
611        FROM cif_index
612        GROUP BY source_db, status
613        ORDER BY source_db, status
614    """):
615        print(f"  {row[0]:8s} {row[1]:8s} {row[2]}")
616
617    existing_cols = get_existing_columns(conn, "cif_index")
618    if "backend" in existing_cols:
619        print("\nBy backend:")
620        for row in cur.execute("""
621            SELECT backend, status, COUNT(*)
622            FROM cif_index
623            GROUP BY backend, status
624            ORDER BY status, backend
625        """):
626            backend = row[0] if row[0] else "(none)"
627            print(f"  {backend:24s} {row[1]:8s} {row[2]}")
628
629    if "normalized" in existing_cols:
630        print("\nNormalized:")
631        for row in cur.execute("""
632            SELECT normalized, status, COUNT(*)
633            FROM cif_index
634            GROUP BY normalized, status
635            ORDER BY normalized, status
636        """):
637            print(f"  normalized={row[0]} {row[1]:8s} {row[2]}")
638
639    conn.close()
640
641
642def main() -> None:
643    """
644    概要:
645        スクリプトのエントリポイント関数です。
646    詳細説明:
647        コマンドライン引数を解析し、指定されたモード (index, search, info) に応じて
648        適切な処理関数を呼び出します。
649        --mode index はCIFファイルのインデックスを構築し、
650        --mode search はデータベースから組成式で検索し、
651        --mode info はデータベースの統計情報を表示します。
652        検索モード (--mode search) の場合、--formula 引数が必須です。
653    引数:
654        なし
655    戻り値:
656        :returns: なし
657        :rtype: None
658    """
659    parser = argparse.ArgumentParser()
660
661    parser.add_argument("--mode", type=str, default="search",
662                        choices=["index", "search", "info"])
663
664    parser.add_argument("--root", type=str, default=".")
665    parser.add_argument("--db", type=str, default="cif_index.sqlite")
666    parser.add_argument("--pattern", type=str, default="*.cif")
667
668    parser.add_argument("--formula", type=str, default="")
669    parser.add_argument("--match", type=str, default="exact",
670                        choices=["exact", "elements", "contains"],
671                        help=(
672                            "exact: 還元組成一致, "
673                            "elements: 元素集合一致, "
674                            "contains: 指定元素集合の部分集合も許す"
675                        ))
676    parser.add_argument("--source-db", type=str, default="",
677                        help="cod, tcod, pcod など。空なら全DB検索。")
678
679    parser.add_argument("--store-errors", type=int, default=1, choices=[0, 1])
680    parser.add_argument("--json", type=int, default=0, choices=[0, 1])
681    parser.add_argument("--limit", type=int, default=100)
682
683    args = parser.parse_args()
684
685    db_path = Path(args.db)
686
687    if args.mode == "index":
688        build_index(
689            root=Path(args.root),
690            db_path=db_path,
691            pattern=args.pattern,
692            store_errors=args.store_errors,
693        )
694
695    elif args.mode == "search":
696        if not args.formula:
697            print("ERROR: --formula is required for --mode search")
698            sys.exit(1)
699
700        search_by_formula(
701            db_path=db_path,
702            formula=args.formula,
703            source_db=args.source_db,
704            match=args.match,
705            limit=args.limit,
706            output_json=args.json,
707        )
708
709    elif args.mode == "info":
710        show_info(db_path)
711
712
713if __name__ == "__main__":
714    main()