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

tftanalyze.py をダウンロード

tftanalyze.py
tftanalyze.py
   1"""
   2概要:
   3    TFT n-チャネルトランジスタの電気特性を解析するツールです。
   4詳細説明:
   5    このスクリプトは、TFT (Thin-Film Transistor) のn-チャネルデバイスにおける
   6    I-V特性(伝達特性 (ID-VG) および出力特性 (ID-VD))データを読み込み、
   7    解析し、結果をExcelレポートとPNGプロットとして出力します。
   8    コマンドラインからの使用例は以下の通りです。
   9    python tftanalyze.py --mode all --infile_vg transfer.csv --infile_vd output.csv
  10
  11主な機能:
  12    CSV形式の測定データを自動的にエンコーディングを検出して読み込みます。
  13    伝達特性データから閾値電圧 (Vth)、移動度 (Mobility)、サブスレッショルドスイング (S) などの
  14    主要なデバイスパラメータを抽出します。
  15    出力特性データから線形領域のコンダクタンス (gd) や移動度 (mu_lin, mu_eff) を評価します。
  16    サビツキー・ゴレイフィルターを用いたデータの平滑化をサポートします。
  17    解析結果をインタラクティブなグラフ表示とPNGファイルとして保存します。
  18    全ての解析結果と生データ、平滑化データをExcelファイルに集約して出力します。
  19関連リンク:
  20    tftanalyze_usage
  21"""
  22import os
  23import sys
  24import argparse
  25import csv
  26import chardet
  27from pathlib import Path
  28import numpy as np
  29from scipy import constants
  30from scipy.signal import savgol_filter
  31import pandas as pd
  32import matplotlib.pyplot as plt
  33from sklearn.linear_model import LinearRegression
  34
  35
  36# --- 物理定数 ---
  37EPS0 = constants.epsilon_0
  38
  39
  40figsize_idvg_quad = (10, 8)
  41
  42def get_args():
  43    """コマンドライン引数をパースします。
  44
  45    詳細説明:
  46        この関数は、TFT解析スクリプトに必要な全てのコマンドライン引数を定義し、
  47        ユーザーが指定した引数をパースして返します。
  48        引数には、入力ファイルパス、解析モード、TFTデバイスの物理的寸法、
  49        誘電体定数、電流の閾値、平滑化パラメータ、プロット表示・保存設定などが含まれます。
  50    戻り値:
  51        :returns: パースされた引数を含む argparse.Namespace オブジェクト。
  52        :rtype: argparse.Namespace
  53    """
  54    parser = argparse.ArgumentParser(description='TFT n-channel Transfer/Output Analysis & Excel Tool (detail rev.)')
  55    parser.add_argument('--infile_vg', default='TFT_Vg-Id_STD-ide3 [AS220518TFT-anneal(454) ; 2022_05_25 18_00_33].csv')
  56    parser.add_argument('--infile_vd', default='TFT_Vd-Id-ide [AS220518TFT-anneal(455) ; 2022_05_25 18_01_11].csv')
  57    parser.add_argument('--mode', choices=['read', 'analyze_idvg', 'analyze_idvd', 'all'], default='all')
  58    parser.add_argument('--out_excel', default=None, help='Output Excel file. If omitted, a mode-dependent filename is used.')
  59    parser.add_argument('--L', type=float, default=50.0, help='Channel length [um]')
  60    parser.add_argument('--W', type=float, default=300.0, help='Channel width [um]')
  61    parser.add_argument('--dg', type=float, default=150.0, help='Gate insulator thickness [nm]')
  62    parser.add_argument('--epsg', type=float, default=3.9, help='Relative dielectric constant of gate insulator')
  63    parser.add_argument('--ID_S', type=float, default=1e-9, help='Reference drain current for S extraction [A]')
  64    parser.add_argument('--Imin', type=float, default=1e-15, help='Minimum current floor for log analysis [A]')
  65    parser.add_argument('--smooth_npoints', type=int, default=5)
  66    parser.add_argument('--lsq_order', type=int, default=2)
  67    parser.add_argument('--show_plot', action='store_true', default=True, help='Show plots interactively (default: True)')
  68    parser.add_argument('--no_show_plot', action='store_false', dest='show_plot', help='Do not show plots interactively')
  69    parser.add_argument('--region_factor', type=float, default=3.0, help='Safety factor for region checks: saturation requires VD >= factor*(VG-Vth); linear requires VG-Vth >= factor*VD')
  70    parser.add_argument('--save_plot', action='store_true', default=True, help='Save analysis plots as PNG files (default: True)')
  71    parser.add_argument('--no_save_plot', action='store_false', dest='save_plot', help='Do not save analysis plots')
  72    parser.add_argument('--plot_dir', default='tft_analysis_plots')
  73    parser.add_argument('--reverse_vg', action='store_true', help='Reverse VG sign after loading data. Intended for p-channel data preprocessing.')
  74    parser.add_argument('--idx_vg', type=int, default=0, help='Sweep segment index for VG-swept data. Incremented when VG sweep direction changes.')
  75    parser.add_argument('--idx_vd', type=int, default=0, help='Sweep segment index for VD-swept data. Incremented when VD sweep direction changes.')
  76    parser.add_argument('--read_smooth_domain', choices=['log', 'linear'], default='log',
  77                        help='Smoothing domain for mode=read. For output ID-VD preview, linear smoothing/linear-y plotting is forced.')
  78    parser.add_argument('--read_keep_edge_raw', action='store_true', default=True,
  79                        help='In mode=read, keep edge points raw instead of applying Savitzky-Golay near endpoints (default: True).')
  80    parser.add_argument('--read_smooth_edges', action='store_false', dest='read_keep_edge_raw',
  81                        help='Apply Savitzky-Golay smoothing also near endpoints.')
  82    return parser.parse_args()
  83
  84
  85def calculate_cox(dg_nm, epsg):
  86    """ゲート酸化膜容量 (Cox) を計算します。
  87
  88    詳細説明:
  89        ゲート絶縁膜の厚さ (ナノメートル単位) と比誘電率から、
  90        単位面積あたりのゲート酸化膜容量 (F/cm^2) を計算します。
  91        物理定数として真空の誘電率 (EPS0) を使用します。
  92    引数:
  93        :param dg_nm: ゲート絶縁膜の厚さ [nm]。
  94        :type dg_nm: float
  95        :param epsg: ゲート絶縁膜の比誘電率。
  96        :type epsg: float
  97    戻り値:
  98        :returns: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
  99        :rtype: float
 100    """
 101    dg_m = dg_nm * 1e-9
 102    # F/m^2 -> F/cm^2
 103    return (epsg * EPS0 / dg_m) * 1e-4
 104
 105
 106def default_excel_name(mode):
 107    """指定された解析モードに応じたデフォルトのExcelファイル名を返します。
 108
 109    詳細説明:
 110        異なる解析モード('read', 'analyze_idvg', 'analyze_idvd', 'all')に対して、
 111        それぞれ適切なデフォルトのExcelファイル名を決定します。
 112        指定されたモードが辞書にない場合は、汎用的なレポートファイル名を返します。
 113    引数:
 114        :param mode: 解析モードを示す文字列。
 115        :type mode: str
 116    戻り値:
 117        :returns: デフォルトのExcelファイル名。
 118        :rtype: str
 119    """
 120    names = {
 121        'read': 'tft_read_data.xlsx',
 122        'analyze_idvg': 'tft_analysis_idvg.xlsx',
 123        'analyze_idvd': 'tft_analysis_idvd.xlsx',
 124        'all': 'tft_analysis_all.xlsx',
 125    }
 126    return names.get(mode, 'tft_analysis_report.xlsx')
 127
 128
 129class Tee:
 130    """stdout/stderr をコンソールとログファイルへ同時出力する簡単な Tee。
 131
 132    詳細説明:
 133        このクラスは、複数のファイルライクオブジェクトに書き込み操作をミラーリングするために使用されます。
 134        例えば、標準出力への書き込みと同時にログファイルへの書き込みを行う場合に便利です。
 135    引数:
 136        :param streams: データを書き込む対象となる一つ以上のファイルライクオブジェクト。
 137        :type streams: file-like objects
 138    """
 139    def __init__(self, *streams):
 140        self.streams = streams
 141
 142    def write(self, data):
 143        for stream in self.streams:
 144            stream.write(data)
 145            stream.flush()
 146
 147    def flush(self):
 148        for stream in self.streams:
 149            stream.flush()
 150
 151
 152def _existing_or_first_path(*paths):
 153    """出力名の基準にする入力ファイルを選びます。存在確認はゆるく行います。
 154
 155    詳細説明:
 156        複数のファイルパスが与えられた場合、最初に空でないパス、
 157        または提供されたパスのリストの最初のパスをPathオブジェクトとして返します。
 158        ファイルシステムの存在チェックは行いません。
 159    引数:
 160        :param paths: 検査するファイルパスの可変引数。
 161        :type paths: str
 162    戻り値:
 163        :returns: 基準として選択されたファイルパスのPathオブジェクト。
 164        :rtype: pathlib.Path
 165    """
 166    for path in paths:
 167        if path:
 168            return Path(path)
 169    return Path('tft_analysis')
 170
 171
 172def prepare_output_paths(args):
 173    """Excel/PNG/log の保存先を入力ファイルと同じディレクトリにそろえます。
 174
 175    詳細説明:
 176        入力ファイルパス(args.infile_vg, args.infile_vd)を基準に、
 177        Excelレポート、PNGプロット、ログファイルの出力ディレクトリとファイル名を決定し、
 178        argsオブジェクトに設定します。これにより、全ての出力が関連する入力ファイルの近くに集約されます。
 179    引数:
 180        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
 181                     infile_vg, infile_vd, mode, out_excel 属性を使用します。
 182        :type args: argparse.Namespace
 183    戻り値:
 184        :returns: 出力パスが設定された argparse.Namespace オブジェクト。
 185        :rtype: argparse.Namespace
 186    """
 187    if args.mode == 'analyze_idvd':
 188        ref = _existing_or_first_path(args.infile_vd, args.infile_vg)
 189    else:
 190        ref = _existing_or_first_path(args.infile_vg, args.infile_vd)
 191    ref = ref.expanduser()
 192    out_dir = ref.parent if str(ref.parent) not in ('', '.') else Path.cwd()
 193    out_dir = out_dir.resolve()
 194    stem = ref.stem if ref.stem else 'tft_analysis'
 195    args.output_dir = str(out_dir)
 196    args.output_stem = stem
 197    args.plot_dir = str(out_dir)
 198
 199    if args.out_excel is None:
 200        args.out_excel = str(out_dir / f'{stem}_{args.mode}.xlsx')
 201    else:
 202        user_name = Path(args.out_excel).name
 203        user_stem = Path(user_name).stem
 204        user_suffix = Path(user_name).suffix or '.xlsx'
 205        if stem not in user_stem:
 206            user_name = f'{stem}_{user_stem}{user_suffix}'
 207        args.out_excel = str(out_dir / user_name)
 208
 209    args.log_file = str(out_dir / f'{stem}_{args.mode}.log')
 210    return args
 211
 212
 213def plot_path(args, name):
 214    """入力stemつきPNG保存パスを作成します。
 215
 216    詳細説明:
 217        コマンドライン引数 (args) から取得した出力ステムとプロットディレクトリを基に、
 218        指定された名前 (name) でPNGファイルの完全な保存パスを構築します。
 219    引数:
 220        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
 221                     output_stem および plot_dir 属性を使用します。
 222        :type args: argparse.Namespace
 223        :param name: 保存するPNGファイルの名前(拡張子なし)。
 224        :type name: str
 225    戻り値:
 226        :returns: 生成されたPNGファイルの完全なパス。
 227        :rtype: str
 228    """
 229    stem = getattr(args, 'output_stem', 'tft_analysis')
 230    out_dir = Path(getattr(args, 'plot_dir', '.'))
 231    return str(out_dir / f'{stem}_{name}.png')
 232
 233def savgol_center_only(y, win, order, keep_edge_raw=True):
 234    """サビツキー・ゴレイフィルターを適用し、オプションで端点付近の生データを保持します。
 235
 236    詳細説明:
 237        scipy.signal.savgol_filter は端点付近を外挿/補間することがあります。
 238        TFT出力曲線では、VD=0側の点数が少ないため、端点平滑化が系統的なずれのように見えることがあります。
 239        keep_edge_raw=True の場合、完全な中心ウィンドウを持つ点のみが平滑化された値に置き換えられ、
 240        端点付近のデータは元のクリップされたデータのまま維持されます。
 241        データ長が短すぎる場合、またはウィンドウ長が不正な場合は、元のデータが返され、
 242        平滑化が適用されなかったことを示すブール配列が返されます。
 243    引数:
 244        :param y: 平滑化するデータ配列。
 245        :type y: numpy.ndarray or list
 246        :param win: サビツキー・ゴレイフィルターのウィンドウ長。奇数である必要があります。
 247        :type win: int
 248        :param order: フィルターの多項式の次数。
 249        :type order: int
 250        :param keep_edge_raw: Trueの場合、完全な中心ウィンドウを持たない端点付近のデータを生データのまま保持します。
 251        :type keep_edge_raw: bool
 252    戻り値:
 253        :returns: 平滑化されたデータ配列と、各点が平滑化に使用されたかを示すブール配列のタプル。
 254        :rtype: tuple[numpy.ndarray, numpy.ndarray]
 255    """
 256    y = np.asarray(y, dtype=float)
 257    if len(y) < 3 or not np.isfinite(win):
 258        return y.copy(), np.zeros(len(y), dtype=bool)
 259    win_i = int(win)
 260    if win_i < 3 or win_i > len(y):
 261        return y.copy(), np.zeros(len(y), dtype=bool)
 262    order_i = min(int(order), win_i - 1)
 263    ys = savgol_filter(y, win_i, order_i, mode='interp')
 264    used = np.ones(len(y), dtype=bool)
 265    if keep_edge_raw:
 266        half = win_i // 2
 267        used[:] = False
 268        if len(y) > 2 * half:
 269            used[half:len(y)-half] = True
 270        out = y.copy()
 271        out[used] = ys[used]
 272        return out, used
 273    return ys, used
 274
 275
 276def add_read_columns_grouped(df, xcol, groupcol, args):
 277    """データフレームに電流のクリッピングと平滑化された電流の列を追加します。
 278
 279    詳細説明:
 280        この関数は、読み込み/プレビューモードのために、各グループ(またはデータ全体)に対して、
 281        以下の列を追加します:
 282        ID_abs_floor: IDの絶対値を args.Imin でクリッピングした値。
 283        ID_smooth_linear: 線形スケールで平滑化されたID。
 284        logID_smooth: 対数スケールで平滑化された log10(abs(ID))。
 285        ID_smooth_log: logID_smooth を元に対数スケールで平滑化されたID。
 286        ID_smooth: args.read_smooth_domain に応じて ID_smooth_linear または ID_smooth_log。
 287        savgol_used_linear, savgol_used_log, savgol_used: Savitzky-Golay平滑化が適用された点を示すブールマスク。
 288
 289        重要な点:
 290        伝達特性 (ID-VG) のプレビューは、デフォルトで log10(abs(ID)) の平滑化後に逆変換します。
 291        出力特性 (ID-VD) のプレビューは、線形電流平滑化と線形Y軸プロットを強制します。
 292        デフォルトでは、端点付近ではSavitzky-Golayフィルターの完全な中心ウィンドウがないため、
 293        平滑化は無効化されます。これにより、VD=0付近の人工的なずれを回避します。
 294    引数:
 295        :param df: 処理対象のDataFrame。
 296        :type df: pandas.DataFrame
 297        :param xcol: データのX軸となる列名(例: 'VG', 'VD')。
 298        :type xcol: str
 299        :param groupcol: グループ化に使用する列名(例: 'VD', 'VG')。この列が存在しない場合、データ全体が単一のグループとして扱われます。
 300        :type groupcol: str
 301        :param args: コマンドライン引数を含むオブジェクト。Imin, lsq_order, smooth_npoints,
 302                     read_keep_edge_raw, read_smooth_domain 属性を使用します。
 303        :type args: argparse.Namespace
 304    戻り値:
 305        :returns: クリップおよび平滑化された電流列が追加されたDataFrame。
 306        :rtype: pandas.DataFrame
 307    """
 308
 309    def _add_cols(tmp):
 310        tmp = tmp.sort_values(xcol).copy()
 311        tmp['ID_abs_floor'] = tmp['ID'].abs().clip(lower=args.Imin)
 312        order = int(getattr(args, 'lsq_order', 2))
 313        win = valid_savgol_window(len(tmp), args.smooth_npoints, order) if len(tmp) >= 3 else np.nan
 314        keep_edge_raw = bool(getattr(args, 'read_keep_edge_raw', True))
 315        if len(tmp) >= 3 and np.isfinite(win):
 316            win_i = int(win)
 317            lin, used_lin = savgol_center_only(tmp['ID_abs_floor'].to_numpy(), win_i, order, keep_edge_raw)
 318            tmp['ID_smooth_linear'] = np.clip(lin, args.Imin, None)
 319            log_id = np.log10(tmp['ID_abs_floor'].to_numpy())
 320            log_s, used_log = savgol_center_only(log_id, win_i, order, keep_edge_raw)
 321            tmp['logID_smooth'] = log_s
 322            tmp['ID_smooth_log'] = np.clip(np.power(10.0, tmp['logID_smooth']), args.Imin, None)
 323            tmp['savgol_used_linear'] = used_lin
 324            tmp['savgol_used_log'] = used_log
 325        else:
 326            tmp['ID_smooth_linear'] = tmp['ID_abs_floor']
 327            tmp['logID_smooth'] = np.log10(tmp['ID_abs_floor'])
 328            tmp['ID_smooth_log'] = tmp['ID_abs_floor']
 329            tmp['savgol_used_linear'] = False
 330            tmp['savgol_used_log'] = False
 331        if getattr(args, 'read_smooth_domain', 'log') == 'linear':
 332            tmp['ID_smooth'] = tmp['ID_smooth_linear']
 333            tmp['read_smooth_domain'] = 'linear current'
 334            tmp['savgol_used'] = tmp['savgol_used_linear']
 335        else:
 336            tmp['ID_smooth'] = tmp['ID_smooth_log']
 337            tmp['read_smooth_domain'] = 'log10 current'
 338            tmp['savgol_used'] = tmp['savgol_used_log']
 339        tmp['read_x'] = tmp[xcol]
 340        tmp['savgol_window'] = win
 341        tmp['savgol_order'] = order
 342        tmp['edge_raw_kept'] = keep_edge_raw
 343        return tmp
 344
 345    if groupcol not in df.columns:
 346        tmp = _add_cols(df)
 347        tmp['read_group'] = 'all'
 348        return tmp
 349
 350    out = []
 351    for gv in sorted(df[groupcol].dropna().unique()):
 352        tmp = df[np.isclose(df[groupcol], gv, atol=1e-3)].copy()
 353        if tmp.empty:
 354            continue
 355        tmp = _add_cols(tmp)
 356        tmp['read_group'] = f'{groupcol}={gv:g}'
 357        out.append(tmp)
 358    return pd.concat(out, ignore_index=False) if out else pd.DataFrame()
 359
 360def plot_read_data(df_read, xcol, groupcol, title, args, fname_base, yscale='log'):
 361    """読み込み/プレビューモードで平滑化されたデータをプロットします。
 362
 363    詳細説明:
 364        生のクリップされたデータ (ID_abs_floor) と平滑化されたデータ (ID_smooth) を
 365        X軸 (xcol) に対してプロットします。
 366        groupcol が指定されている場合、データはグループごとにプロットされ、凡例に表示されます。
 367        Y軸はオプションで対数スケールに設定できます。
 368        生成されたプロットは、args.save_plot がTrueの場合、指定されたディレクトリにPNGファイルとして保存されます。
 369    引数:
 370        :param df_read: 読み込み/プレビュー用に処理されたデータを含むDataFrame。
 371        :type df_read: pandas.DataFrame
 372        :param xcol: X軸としてプロットする列名(例: 'VG', 'VD')。
 373        :type xcol: str
 374        :param groupcol: データをグループ化するための列名(例: 'VD', 'VG')。
 375                         この列が存在しない場合、データ全体が単一のグループとして扱われます。
 376        :type groupcol: str
 377        :param title: プロットのタイトル。
 378        :type title: str
 379        :param args: コマンドライン引数を含むオブジェクト。save_plot と plot_dir 属性を使用します。
 380        :type args: argparse.Namespace
 381        :param fname_base: 保存するPNGファイル名のベース。
 382        :type fname_base: str
 383        :param yscale: Y軸のスケール('log'または'linear')。Noneの場合、線形スケール。デフォルトは'log'。
 384        :type yscale: str or None
 385    戻り値:
 386        :returns: 生成されたMatplotlibのFigureオブジェクト。
 387        :rtype: matplotlib.figure.Figure
 388    """
 389    fig, ax = plt.subplots(1, 1, figsize=(8, 6))
 390    if groupcol in df_read.columns:
 391        groups = sorted(df_read[groupcol].dropna().unique())
 392        for gv in groups:
 393            tmp = df_read[np.isclose(df_read[groupcol], gv, atol=1e-3)].sort_values(xcol)
 394            ax.plot(tmp[xcol], tmp['ID_abs_floor'], '.', ms=3, alpha=0.35)
 395            ax.plot(tmp[xcol], tmp['ID_smooth'], '-', lw=1.6, label=f'{groupcol}={gv:g} ({tmp["read_smooth_domain"].iloc[0]})')
 396    else:
 397        tmp = df_read.sort_values(xcol)
 398        ax.plot(tmp[xcol], tmp['ID_abs_floor'], '.', ms=3, alpha=0.35, label='clipped')
 399        ax.plot(tmp[xcol], tmp['ID_smooth'], '-', lw=1.8, label=f'smoothed ({tmp["read_smooth_domain"].iloc[0]})')
 400    if yscale is not None:
 401        ax.set_yscale(yscale)
 402    ax.set_xlabel(f'{xcol} [V]')
 403    ax.set_ylabel(r'$|I_D|$ clipped/smoothed [A]')
 404    ax.set_title(title)
 405    ax.grid(True, alpha=0.15)
 406    ax.legend(fontsize='x-small', ncol=2)
 407    fig.tight_layout()
 408    if args.save_plot:
 409        os.makedirs(args.plot_dir, exist_ok=True)
 410        fname = plot_path(args, fname_base)
 411        fig.savefig(fname, dpi=200, bbox_inches='tight')
 412        print(f'  plot saved: {fname}')
 413    return fig
 414
 415
 416def run_read_mode(args):
 417    """読み込み/プレビューモードの処理を実行します。
 418
 419    詳細説明:
 420        指定された入力ファイル(伝達特性と出力特性)を読み込み、Imin で電流をクリッピングし、
 421        Savitzky-Golayフィルターで平滑化します。その後、処理されたデータをプロットし、
 422        結果の概要をコンソールに出力します。
 423        伝達特性 (ID-VG) データは対数電流平滑化がデフォルトですが、
 424        出力特性 (ID-VD) データは線形電流平滑化と線形Y軸プロットが強制されます。
 425        処理されたデータフレームとサマリー情報は、Excelエクスポートのために返されます。
 426    引数:
 427        :param args: コマンドライン引数を含むオブジェクト。
 428                     infile_vg, infile_vd, reverse_vg, idx_vg, idx_vd, Imin,
 429                     read_smooth_domain, save_plot, plot_dir 属性を使用します。
 430        :type args: argparse.Namespace
 431    戻り値:
 432        :returns:
 433            MatplotlibのFigureオブジェクトのリスト。
 434            読み込まれた/処理されたデータフレームをタグ('vg'または'vd')で格納した辞書。
 435            読み込み処理のサマリー情報を含む辞書(各グループごと)のリスト。
 436        :rtype: tuple[list[matplotlib.figure.Figure], dict[str, pandas.DataFrame], list[dict]]
 437    """
 438    figures = []
 439    read_tables = {}
 440    read_summary = []
 441    targets = []
 442    if args.infile_vg:
 443        targets.append(('vg', args.infile_vg, 'VG', 'VD', 'Read preview: transfer-like ID-VG data'))
 444    if args.infile_vd:
 445        targets.append(('vd', args.infile_vd, 'VD', 'VG', 'Read preview: output ID-VD data'))
 446    if not targets:
 447        print('WARNING: no input file is specified. Use --infile_vg and/or --infile_vd.')
 448        return figures, read_tables, read_summary
 449    for tag, path, xcol, groupcol, title in targets:
 450        df = detect_and_load(path, reverse_vg=args.reverse_vg)
 451        if df is None:
 452            continue
 453        if xcol not in df.columns:
 454            print(f'WARNING: {path} does not have required x column {xcol}; skipped.')
 455            continue
 456        idx_select = args.idx_vg if xcol == 'VG' else args.idx_vd
 457        idx_label = 'idx_vg' if xcol == 'VG' else 'idx_vd'
 458        selected_groups = []
 459        if groupcol in df.columns:
 460            for gv in sorted(df[groupcol].dropna().unique()):
 461                gdf = df[np.isclose(df[groupcol], gv, atol=1e-3)].copy()
 462                sg = select_sweep_segment(gdf, xcol, idx_select, idx_label, sort_after=True)
 463                if not sg.empty:
 464                    selected_groups.append(sg)
 465            df = pd.concat(selected_groups, ignore_index=False) if selected_groups else pd.DataFrame()
 466        else:
 467            df = select_sweep_segment(df, xcol, idx_select, idx_label, sort_after=True)
 468        # Transfer ID-VG is best previewed with log-current smoothing.
 469        # Output ID-VD should be previewed as a linear ID plot with linear-current smoothing.
 470        print("xcol=", xcol)
 471        if xcol == 'VD':
 472            local_args = argparse.Namespace(**vars(args))
 473            local_args.read_smooth_domain = 'linear'
 474            df_read = add_read_columns_grouped(df, xcol, groupcol, local_args)
 475            read_yscale = 'linear'
 476        else:
 477            df_read = add_read_columns_grouped(df, xcol, groupcol, args)
 478            read_yscale = 'log'
 479        if df_read.empty:
 480            print(f'WARNING: no readable data after preprocessing: {path}')
 481            continue
 482        read_tables[tag] = df_read
 483        print(f"\n{'='*20} READ/PREVIEW {tag.upper()} {'='*20}")
 484        print(f'File: {path}')
 485        print(f'Rows: {len(df_read)} | x={xcol} | group={groupcol if groupcol in df_read.columns else "none"}')
 486        print(f'Imin clipping floor = {args.Imin:.4e} A')
 487        print(f'read smoothing domain = {args.read_smooth_domain}')
 488        if groupcol in df_read.columns:
 489            for gv in sorted(df_read[groupcol].dropna().unique()):
 490                tmp = df_read[np.isclose(df_read[groupcol], gv, atol=1e-3)]
 491                read_summary.append({
 492                    'dataset': tag, 'file': path, 'xcol': xcol, 'groupcol': groupcol, 'group_value': gv,
 493                    'n_points': len(tmp), 'x_min': tmp[xcol].min(), 'x_max': tmp[xcol].max(),
 494                    'ID_abs_floor_min': tmp['ID_abs_floor'].min(), 'ID_abs_floor_max': tmp['ID_abs_floor'].max(),
 495                    'ID_smooth_min': tmp['ID_smooth'].min(), 'ID_smooth_max': tmp['ID_smooth'].max(),
 496                    'ID_smooth_log_min': tmp['ID_smooth_log'].min(), 'ID_smooth_log_max': tmp['ID_smooth_log'].max(),
 497                    'ID_smooth_linear_min': tmp['ID_smooth_linear'].min(), 'ID_smooth_linear_max': tmp['ID_smooth_linear'].max(),
 498                    'read_smooth_domain': tmp['read_smooth_domain'].iloc[0],
 499                    'savgol_window': tmp['savgol_window'].iloc[0],
 500                    'savgol_order': tmp['savgol_order'].iloc[0],
 501                    'n_savgol_used': int(tmp['savgol_used'].sum()),
 502                    'edge_raw_kept': bool(tmp['edge_raw_kept'].iloc[0]),
 503                })
 504                print(f'  {groupcol}={gv:8.4g} | n={len(tmp):4d} | {xcol}=({tmp[xcol].min():.4g}, {tmp[xcol].max():.4g}) | '
 505                      f'ID_smooth=({tmp["ID_smooth"].min():.4e}, {tmp["ID_smooth"].max():.4e}) | '
 506                      f'win={tmp["savgol_window"].iloc[0]}, order={tmp["savgol_order"].iloc[0]}, '
 507                      f'smoothed points={int(tmp["savgol_used"].sum())}/{len(tmp)}, edge_raw={tmp["edge_raw_kept"].iloc[0]}')
 508        print("  read_yscale=", read_yscale)                      
 509        figures.append(plot_read_data(df_read, xcol, groupcol, title, args, f'read_{tag}', yscale=read_yscale))
 510    return figures, read_tables, read_summary
 511
 512
 513def _normalize_tft_column_name(name):
 514    """測定CSVの列名を解析用の標準名へそろえます。
 515
 516    詳細説明:
 517        例: VG(V) -> VG, Id -> ID。
 518        既存の解析コードは VG, VD, ID などの大文字列名を仮定しているため、
 519        読み込み直後にここで正規化します。
 520    引数:
 521        :param name: 正規化する列名。
 522        :type name: str
 523    戻り値:
 524        :returns: 標準化された列名。
 525        :rtype: str
 526    """
 527    s = str(name).strip().replace('\ufeff', '')
 528    if '(' in s:
 529        s = s.split('(', 1)[0]
 530    if '[' in s:
 531        s = s.split('[', 1)[0]
 532    return s.strip().upper()
 533
 534
 535def _to_numeric_dataframe(df):
 536    """全列を可能な範囲で数値化します。
 537
 538    引数:
 539        :param df: 数値化するDataFrame。
 540        :type df: pandas.DataFrame
 541    戻り値:
 542        :returns: 全列が数値化されたDataFrame。
 543        :rtype: pandas.DataFrame
 544    """
 545    out = df.copy()
 546    for col in out.columns:
 547        out[col] = pd.to_numeric(out[col], errors='coerce')
 548    return out
 549
 550
 551def _float_meta(metadata, key):
 552    """4155/4156系CSVメタデータからfloat値を取り出します。
 553
 554    引数:
 555        :param metadata: メタデータを含む辞書。
 556        :type metadata: dict
 557        :param key: 取得するメタデータのキー。
 558        :type key: str
 559    戻り値:
 560        :returns: 取得されたfloat値。変換できない場合はNone。
 561        :rtype: float or None
 562    """
 563    vals = metadata.get(key)
 564    if not vals:
 565        return None
 566    try:
 567        return float(str(vals[0]).strip())
 568    except Exception:
 569        return None
 570
 571
 572def _int_meta(metadata, key):
 573    """4155/4156系CSVメタデータからint値を取り出します。
 574
 575    引数:
 576        :param metadata: メタデータを含む辞書。
 577        :type metadata: dict
 578        :param key: 取得するメタデータのキー。
 579        :type key: str
 580    戻り値:
 581        :returns: 取得されたint値。変換できない場合や有効な数値でない場合はNone。
 582        :rtype: int or None
 583    """
 584    val = _float_meta(metadata, key)
 585    if val is None or not np.isfinite(val):
 586        return None
 587    return int(round(val))
 588
 589
 590def _infer_smu_sweep_variable(metadata, sweep_role):
 591    """Primary/Secondary掃引に対応する電圧名を推定します。
 592
 593    詳細説明:
 594        Keysight/Agilent 4155系CSVでは、通常
 595        Channel.VName = VD, VS, VG と Channel.Func = VAR2, CONST, VAR1
 596        のようなメタデータがあり、VAR1がPrimary、VAR2がSecondaryに対応します。
 597    引数:
 598        :param metadata: メタデータを含む辞書。
 599        :type metadata: dict
 600        :param sweep_role: 掃引の役割('Primary'または'Secondary')。
 601        :type sweep_role: str
 602    戻り値:
 603        :returns: 推定された電圧名(例: 'VG', 'VD')。推定できない場合はNone。
 604        :rtype: str or None
 605    """
 606    target = {'Primary': 'VAR1', 'Secondary': 'VAR2'}.get(sweep_role)
 607    if target is None:
 608        return None
 609    vnames = metadata.get('TestParameter.Channel.VName', [])
 610    funcs = metadata.get('TestParameter.Channel.Func', [])
 611    for vname, func in zip(vnames, funcs):
 612        if str(func).strip().upper() == target:
 613            return _normalize_tft_column_name(vname)
 614    # 実データで一番多い組み合わせへのフォールバック
 615    return 'VG' if sweep_role == 'Primary' else 'VD'
 616
 617
 618def _infer_sweep_values(metadata, role):
 619    """Primary/Secondary掃引の値リストをメタデータから推定します。
 620
 621    引数:
 622        :param metadata: メタデータを含む辞書。
 623        :type metadata: dict
 624        :param role: 掃引の役割('Primary'または'Secondary')。
 625        :type role: str
 626    戻り値:
 627        :returns: 推定された掃引値のリスト。
 628        :rtype: list[float]
 629    """
 630    prefix = f'TestParameter.Measurement.{role}'
 631    start = _float_meta(metadata, prefix + '.Start')
 632    stop = _float_meta(metadata, prefix + '.Stop')
 633    step = _float_meta(metadata, prefix + '.Step')
 634    count = _int_meta(metadata, prefix + '.Count')
 635
 636    if role == 'Secondary':
 637        if start is None or step is None or count is None or count <= 0:
 638            return []
 639        return [start + i * step for i in range(count)]
 640
 641    if start is None or stop is None or step is None or step == 0:
 642        return []
 643    n = int(round(abs((stop - start) / step))) + 1
 644    if n <= 0:
 645        return []
 646    if stop >= start:
 647        return [start + i * abs(step) for i in range(n)]
 648    return [start - i * abs(step) for i in range(n)]
 649
 650
 651def _infer_primary_branch_points(metadata):
 652    """Primary掃引1枝あたりの点数をメタデータから推定します。
 653
 654    引数:
 655        :param metadata: メタデータを含む辞書。
 656        :type metadata: dict
 657    戻り値:
 658        :returns: Primary掃引1枝あたりの点数。推定できない場合はNone。
 659        :rtype: int or None
 660    """
 661    vals = _infer_sweep_values(metadata, 'Primary')
 662    return len(vals) if len(vals) > 1 else None
 663
 664
 665def _is_double_primary_sweep(metadata):
 666    """Primary掃引がダブル掃引(往復掃引)であるか判定します。
 667
 668    引数:
 669        :param metadata: メタデータを含む辞書。
 670        :type metadata: dict
 671    戻り値:
 672        :returns: ダブル掃引である場合はTrue、そうでない場合はFalse。
 673        :rtype: bool
 674    """
 675    vals = metadata.get('TestParameter.Measurement.Primary.Locus', [])
 676    return bool(vals) and str(vals[0]).strip().lower() == 'double'
 677
 678
 679def _add_4155_inferred_columns(df, metadata):
 680    """4155系DataName/DataValue CSVに不足しがちな掃引列を補います。
 681
 682    詳細説明:
 683        DataName/DataValueブロックにはPrimary変数、ID、IGだけが保存され、
 684        Secondary変数(今回の例ではVD)が各行に書かれない場合があります。
 685        その場合、メタデータのSecondary Start/Step/Countから各行のVDを復元します。
 686    引数:
 687        :param df: 処理対象のDataFrame。
 688        :type df: pandas.DataFrame
 689        :param metadata: メタデータを含む辞書。
 690        :type metadata: dict
 691    戻り値:
 692        :returns: 掃引列が補完されたDataFrame。
 693        :rtype: pandas.DataFrame
 694    """
 695    out = df.copy()
 696    if out.empty:
 697        return out
 698
 699    primary_col = _infer_smu_sweep_variable(metadata, 'Primary')
 700    secondary_col = _infer_smu_sweep_variable(metadata, 'Secondary')
 701    half_n = _infer_primary_branch_points(metadata)
 702    n = len(out)
 703
 704    if half_n is not None and half_n > 0:
 705        sweep_id = np.arange(n) // half_n
 706    elif primary_col in out.columns:
 707        x = out[primary_col].to_numpy(dtype=float)
 708        dx = np.diff(x)
 709        sign = np.sign(dx)
 710        for i in range(1, len(sign)):
 711            if sign[i] == 0:
 712                sign[i] = sign[i - 1]
 713        change = np.where(sign[1:] * sign[:-1] < 0)[0] + 1
 714        starts = [0] + (change + 1).tolist()
 715        sweep_id = np.zeros(n, dtype=int)
 716        for sid, a in enumerate(starts):
 717            b = starts[sid + 1] if sid + 1 < len(starts) else n
 718            sweep_id[a:b] = sid
 719    else:
 720        sweep_id = np.zeros(n, dtype=int)
 721
 722    out['sweep_id'] = sweep_id.astype(int)
 723
 724    directions = {}
 725    if primary_col in out.columns:
 726        for sid in sorted(out['sweep_id'].dropna().unique()):
 727            sub = out[out['sweep_id'] == sid]
 728            if sub.empty:
 729                continue
 730            x0 = sub[primary_col].iloc[0]
 731            x1 = sub[primary_col].iloc[-1]
 732            directions[sid] = 'forward' if x1 >= x0 else 'reverse'
 733        out['sweep_direction'] = out['sweep_id'].map(directions)
 734
 735    secondary_values = _infer_sweep_values(metadata, 'Secondary')
 736    branches_per_secondary = 2 if _is_double_primary_sweep(metadata) else 1
 737    if secondary_col and secondary_values:
 738        values_by_sweep = {}
 739        for sid in sorted(out['sweep_id'].dropna().unique()):
 740            idx = int(sid) // branches_per_secondary
 741            values_by_sweep[int(sid)] = secondary_values[idx] if idx < len(secondary_values) else np.nan
 742        inferred = out['sweep_id'].map(values_by_sweep)
 743        # 列がない場合、またはDataName側の列が空の場合に補完。既存列がある場合はNaNのみ埋める。
 744        if secondary_col not in out.columns:
 745            out[secondary_col] = inferred
 746        else:
 747            out[secondary_col] = out[secondary_col].where(out[secondary_col].notna(), inferred)
 748
 749    return out
 750
 751
 752def read_4155_dataname_datavalue_csv(filepath, encoding='utf-8-sig'):
 753    """Keysight/Agilent 4155系の DataName/DataValue CSV を読み込みます。
 754
 755    詳細説明:
 756        戻り値は (df, metadata) です。DataValue行が見つからない場合は
 757        (None, metadata) を返し、通常CSV読み込みへフォールバックできるようにします。
 758    引数:
 759        :param filepath: 読み込むCSVファイルのパス。
 760        :type filepath: str
 761        :param encoding: ファイルの文字コード。デフォルトは'utf-8-sig'。
 762        :type encoding: str
 763    戻り値:
 764        :returns: データを含むDataFrameとメタデータの辞書のタプル。
 765                  DataValue行が見つからない場合は (None, metadata)。
 766        :rtype: tuple[pandas.DataFrame or None, dict]
 767    """
 768    metadata = {}
 769    columns = None
 770    data_rows = []
 771
 772    with open(filepath, 'r', encoding=encoding, errors='replace', newline='') as f:
 773        reader = csv.reader(f)
 774        for row in reader:
 775            row = [str(v).strip() for v in row]
 776            if not row or all(v == '' for v in row):
 777                continue
 778            tag = row[0]
 779            if tag == 'DataName':
 780                columns = [_normalize_tft_column_name(v) for v in row[1:]]
 781            elif tag == 'DataValue':
 782                if columns is None:
 783                    raise ValueError('DataValue appeared before DataName.')
 784                vals = row[1:]
 785                if len(vals) < len(columns):
 786                    vals = vals + [''] * (len(columns) - len(vals))
 787                data_rows.append(vals[:len(columns)])
 788            else:
 789                if len(row) >= 2:
 790                    metadata[f'{row[0]}.{row[1]}'] = row[2:]
 791
 792    if columns is None or not data_rows:
 793        return None, metadata
 794
 795    df = pd.DataFrame(data_rows, columns=columns)
 796    df = _to_numeric_dataframe(df)
 797    df = _add_4155_inferred_columns(df, metadata)
 798    return df, metadata
 799
 800
 801def _read_text_lines_with_detected_encoding(filepath):
 802    """文字コードをゆるく推定してCSVを行単位で読み込みます。
 803
 804    引数:
 805        :param filepath: 読み込むCSVファイルのパス。
 806        :type filepath: str
 807    戻り値:
 808        :returns: ファイルの行リストと推定された文字コードのタプル。
 809        :rtype: tuple[list[str], str]
 810    """
 811    with open(filepath, 'rb') as f:
 812        rawdata = f.read(50000)
 813    encoding = chardet.detect(rawdata)['encoding'] or 'utf-8-sig'
 814    with open(filepath, 'r', encoding=encoding, errors='replace') as f:
 815        lines = f.readlines()
 816    return lines, encoding
 817
 818
 819def detect_and_load(filepath, reverse_vg=False):
 820    """CSVファイルからTFT解析用データを自動検出して読み込みます。
 821
 822    詳細説明:
 823        対応形式:
 824        1. 既存対応の「列ヘッダ行 + 数値データ行」形式
 825        2. Keysight/Agilent 4155系の DataName / DataValue 形式
 826
 827        後者では、DataValueブロックにVDやVGなどのSecondary掃引列が明示されない場合でも、
 828        メタデータから VD または VG を復元して既存解析コードへ渡します。
 829    引数:
 830        :param filepath: 読み込むCSVファイルのパス。
 831        :type filepath: str
 832        :param reverse_vg: Trueの場合、VGの符号を反転します。pチャネルデータの前処理を意図しています。
 833        :type reverse_vg: bool
 834    戻り値:
 835        :returns: 読み込まれたデータを含むDataFrame。ファイルが見つからないかデータが読み込めない場合はNone。
 836        :rtype: pandas.DataFrame or None
 837    """
 838    if not os.path.exists(filepath):
 839        print(f'WARNING: file not found: {filepath}')
 840        return None
 841
 842    lines, encoding = _read_text_lines_with_detected_encoding(filepath)
 843
 844    # まず4155/4156系 DataName/DataValue 形式として読み込む。
 845    # この形式ではDataNameの次行がDataValueで始まるため、従来のヘッダ検出では拾えない。
 846    try:
 847        df_4155, metadata = read_4155_dataname_datavalue_csv(filepath, encoding=encoding)
 848    except Exception as exc:
 849        df_4155, metadata = None, {}
 850        print(f'WARNING: failed to parse DataName/DataValue block as 4155-style CSV: {exc}')
 851
 852    if df_4155 is not None and {'VG', 'ID'}.issubset(df_4155.columns):
 853        df = df_4155.dropna(subset=['VG', 'ID']).copy()
 854        if reverse_vg and 'VG' in df.columns:
 855            df['VG'] = -df['VG']
 856        msg_cols = ', '.join(df.columns)
 857        print(f'INFO: loaded 4155-style DataName/DataValue CSV: rows={len(df)}, columns={msg_cols}')
 858        if 'VD' in df.columns:
 859            vals = sorted(pd.Series(df['VD']).dropna().unique())
 860            print('INFO: inferred/detected VD values: ' + ', '.join(f'{v:g}' for v in vals))
 861        return df
 862
 863    # 従来形式: VG/IDを含むヘッダ行の直後から数値データが始まるCSV。
 864    data_list, header, data_start_idx = [], None, -1
 865    for i, line in enumerate(lines):
 866        parts = [p.strip() for p in line.split(',')]
 867        if not parts or parts[0] == '':
 868            continue
 869        upper_parts = [_normalize_tft_column_name(p) for p in parts]
 870        if 'VG' in upper_parts and 'ID' in upper_parts:
 871            if i + 1 < len(lines):
 872                try:
 873                    float([p.strip() for p in lines[i + 1].split(',')][0])
 874                    header = upper_parts
 875                    data_start_idx = i + 1
 876                    break
 877                except Exception:
 878                    continue
 879
 880    if data_start_idx != -1:
 881        for line in lines[data_start_idx:]:
 882            parts = [p.strip() for p in line.split(',')]
 883            if parts and len(parts) >= len(header):
 884                try:
 885                    float(parts[0])
 886                    data_list.append(parts[:len(header)])
 887                except Exception:
 888                    continue
 889
 890    if header is None or not data_list:
 891        print(f'WARNING: no VG/ID data block found: {filepath}')
 892        return None
 893
 894    df = pd.DataFrame(data_list, columns=header).apply(pd.to_numeric, errors='coerce').dropna(subset=['VG', 'ID'])
 895    if reverse_vg and 'VG' in df.columns:
 896        df['VG'] = -df['VG']
 897    return df
 898
 899
 900def valid_savgol_window(n, requested, order):
 901    """指定されたデータ点数に対して有効な奇数のSavitzky-Golayウィンドウ長を返します。
 902
 903    詳細説明:
 904        サビツキー・ゴレイフィルターのウィンドウ長は、多項式の次数よりも大きく、
 905        かつ奇数である必要があります。また、データ点数を超えることはできません。
 906        この関数は、与えられた制約 (n, requested, order) に基づいて、
 907        これらの条件を満たす最小かつ有効な奇数のウィンドウ長を計算して返します。
 908    引数:
 909        :param n: データ点の総数。
 910        :type n: int
 911        :param requested: 要求されたSavitzky-Golayウィンドウ長。
 912        :type requested: int
 913        :param order: Savitzky-Golayフィルターの多項式の次数。
 914        :type order: int
 915    戻り値:
 916        :returns: 有効な奇数のSavitzky-Golayウィンドウ長。
 917        :rtype: int
 918    """
 919    win = max(int(requested), order + 2)
 920    if win % 2 == 0:
 921        win += 1
 922    if win > n:
 923        win = n if n % 2 == 1 else n - 1
 924    if win <= order:
 925        win = order + 2 if (order + 2) % 2 == 1 else order + 3
 926    return max(3, win)
 927
 928
 929def nearest_row_by_current(df, target_id):
 930    """データフレーム内で指定された目標電流 (target_id) に最も近いID_smoothを持つ行を検索します。
 931
 932    詳細説明:
 933        データフレーム df の ID_smooth 列を基に、目標電流値 target_id との絶対差が最小となる行を特定します。
 934        見つかった行のインデックスとその行全体のデータを返します。
 935        データフレームが空の場合、または ID_smooth 列が存在しない場合は、Noneを返します。
 936    引数:
 937        :param df: 検索対象のDataFrame。ID_smooth列が必要です。
 938        :type df: pandas.DataFrame
 939        :param target_id: 検索する目標電流値 [A]。
 940        :type target_id: float
 941    戻り値:
 942        :returns: 目標電流に最も近い行のインデックスと、その行のPandas Series。
 943                  データフレームが空の場合は (None, None) を返します。
 944        :rtype: tuple[int or None, pandas.Series or None]
 945    """
 946    if df.empty:
 947        return None, None
 948    idx = (df['ID_smooth'] - target_id).abs().idxmin()
 949    return idx, df.loc[idx]
 950
 951
 952
 953def add_sweep_index(df, col, idx_col):
 954    """指定された列の掃引方向の変化に基づいて、掃引セグメントのインデックスを追加します。
 955
 956    詳細説明:
 957        データフレームの指定された列 (col) の値の連続的な変化を分析し、
 958        掃引方向が反転するたびに掃引セグメントのインデックスを1つ増やします。
 959        これにより、多方向掃引データ(例: VGの往復掃引)を個別のセグメントに分割できます。
 960        結果のインデックスは新しい列 (idx_col) としてデータフレームに追加されます。
 961    引数:
 962        :param df: 処理対象のDataFrame。
 963        :type df: pandas.DataFrame
 964        :param col: 掃引方向を検出する基準となる列名(例: 'VG', 'VD')。
 965        :type col: str
 966        :param idx_col: 生成される掃引インデックス列の名前。
 967        :type idx_col: str
 968    戻り値:
 969        :returns: 掃引セグメントインデックス列が追加されたDataFrame。
 970        :rtype: pandas.DataFrame
 971    """
 972    out = df.copy()
 973    vals = out[col].to_numpy(dtype=float)
 974    seg = []
 975    current_seg = 0
 976    prev_sign = 0
 977    prev_val = np.nan
 978    for v in vals:
 979        sign = 0
 980        if np.isfinite(v) and np.isfinite(prev_val):
 981            dv = v - prev_val
 982            if abs(dv) > 1e-12:
 983                sign = 1 if dv > 0 else -1
 984        if sign != 0:
 985            if prev_sign != 0 and sign != prev_sign:
 986                current_seg += 1
 987            prev_sign = sign
 988        seg.append(current_seg)
 989        prev_val = v
 990    out[idx_col] = seg
 991    return out
 992
 993
 994def select_sweep_segment(df, col, idx, label, sort_after=True, verbose=False):
 995    """掃引方向の変化インデックスに基づいて、DataFrameから特定の掃引セグメントを選択します。
 996
 997    詳細説明:
 998        この関数は、add_sweep_index を使用して、指定された列 (col) の掃引セグメントインデックスを計算し、
 999        その後、要求されたインデックス (idx) に対応するセグメントのみをフィルタリングして返します。
1000        要求されたインデックスが存在しない場合、利用可能な最初のセグメントがフォールバックとして選択されます。
1001        オプションで、選択されたセグメントを col 列でソートできます。
1002    引数:
1003        :param df: 処理対象のDataFrame。
1004        :type df: pandas.DataFrame or None
1005        :param col: 掃引セグメントを識別する基準となる列名(例: 'VG', 'VD')。
1006        :type col: str
1007        :param idx: 選択する掃引セグメントのインデックス。
1008        :type idx: int
1009        :param label: 警告メッセージなどで使用するインデックスのラベル(例: 'idx_vg')。
1010        :type label: str
1011        :param sort_after: Trueの場合、選択後に col 列でDataFrameをソートします。デフォルトはTrue。
1012        :type sort_after: bool
1013        :param verbose: Trueの場合、選択されたセグメントに関する詳細情報を出力します。デフォルトはFalse。
1014        :type verbose: bool
1015    戻り値:
1016        :returns: 選択された掃引セグメントを含むDataFrame。元のDataFrameがNoneまたは空の場合、
1017                  または col 列がない場合は、元のDataFrameまたはNoneを返します。
1018        :rtype: pandas.DataFrame or None
1019    """
1020    if df is None or df.empty or col not in df.columns:
1021        return df.copy() if df is not None else df
1022    idx_col = f'idx_{col.lower()}_sweep'
1023    work = add_sweep_index(df, col, idx_col)
1024    available = sorted(work[idx_col].dropna().unique())
1025    if idx not in available:
1026        print(f'WARNING: requested {label}={idx}, but available {label} values are {available}; fallback to first segment.')
1027        idx = available[0] if available else 0
1028    selected = work[work[idx_col] == idx].copy()
1029    if sort_after and not selected.empty:
1030        selected = selected.sort_values(col)
1031    if verbose and not selected.empty:
1032        print(f'  selected {label}={idx}: n={len(selected)}, {col}=({selected[col].min():.4g}, {selected[col].max():.4g})')
1033    return selected
1034
1035def build_analysis_points(res):
1036    """伝達特性解析の主要点をロングフォーマットで返します。
1037
1038    詳細説明:
1039        res 辞書からVth, Smin, mu_max などの主要な解析ポイントを抽出し、
1040        Excelサマリーシートに適したリスト形式で提供します。
1041        各ポイントは、そのVG, ID, 導関数、移動度などの詳細な情報とともに辞書として格納されます。
1042    引数:
1043        :param res: 伝達特性解析結果を含む辞書。df (解析対象のDataFrame) および
1044                    idx_ で始まるキー (各ポイントのインデックス) が必要です。
1045        :type res: dict
1046    戻り値:
1047        :returns: 各解析ポイントのデータを格納した辞書のリスト。
1048        :rtype: list[dict]
1049    """
1050    keys = [
1051        ('Vth_lin_ID_max_slope', res.get('idx_lin_slope_max'), 'Linear-region Vth from tangent at max d(ID)/dVG'),
1052        ('Vth_sat_sqrtID_max_slope', res.get('idx_sat_slope_max'), 'Saturation-region Vth from tangent at max d(sqrt(ID))/dVG'),
1053        ('Smin_max_log_slope', res.get('idx_smin'), 'Minimum S = inverse max dlog10(ID)/dVG in subthreshold region'),
1054        ('S_at_ID_S', res.get('idx_ids'), 'S at the current closest to ID_S'),
1055        ('mu_lin_max', res.get('idx_mu_lin_max'), 'Maximum linear-field-effect mobility muFE'),
1056        ('mu_sat_max', res.get('idx_mu_sat_max'), 'Maximum saturation mobility profile muSAT_prof'),
1057        ('Ioff', res.get('idx_ioff'), 'Off-current reference point'),
1058    ]
1059    rows = []
1060    df = res['df']
1061    for name, idx, note in keys:
1062        if idx is None or (isinstance(idx, float) and np.isnan(idx)) or idx not in df.index:
1063            continue
1064        r = df.loc[idx]
1065        rows.append({
1066            'VD': res['VD'],
1067            'point': name,
1068            'VG': r.get('VG', np.nan),
1069            'ID': r.get('ID', np.nan),
1070            'ID_smooth': r.get('ID_smooth', np.nan),
1071            'logID': r.get('logID', np.nan),
1072            'sqrtID': r.get('sqrtID', np.nan),
1073            'dlogID_dVG': r.get('dlogID', np.nan),
1074            'S_V_per_dec': r.get('S_val', np.nan),
1075            'gm_A_per_V': r.get('gm', np.nan),
1076            'dsqrtID_dVG': r.get('dsqrtID', np.nan),
1077            'mu_lin_cm2_Vs': r.get('muFE', np.nan),
1078            'mu_sat_cm2_Vs': r.get('muSAT_prof', np.nan),
1079            'note': note,
1080        })
1081    return rows
1082
1083
1084
1085
1086def region_check_saturation(vd, vg, vth, factor=3.0):
1087    """飽和領域動作の条件をチェックします。
1088
1089    詳細説明:
1090        トランジスタが飽和領域で動作しているかどうかを判断するために、
1091        指定されたVG、VD、Vth、および安全係数 (factor) を使用して
1092        VD >= factor * (VG - Vth) の条件を確認します。
1093        VG-Vthが正でない場合、または条件が満たされない場合は警告が生成されます。
1094    引数:
1095        :param vd: ドレイン電圧 [V]。
1096        :type vd: float
1097        :param vg: ゲート電圧 [V]。
1098        :type vg: float
1099        :param vth: 閾値電圧 [V]。
1100        :type vth: float
1101        :param factor: 飽和領域を保証するための安全係数。デフォルトは3.0。
1102        :type factor: float
1103    戻り値:
1104        :returns: 飽和領域チェックの結果を含む辞書。
1105                  ok (bool), warning (str), VD_abs (float), VG_minus_Vth (float),
1106                  ratio (float), criterion (str) を含みます。
1107        :rtype: dict
1108    """
1109    vd_eff = abs(float(vd))
1110    overdrive = float(vg) - float(vth)
1111    overdrive_pos = max(overdrive, 0.0)
1112    criterion = f"VD >= {factor:g}*(VG-Vth)"
1113    if overdrive_pos <= 0:
1114        return {'ok': False, 'warning': 'VG - Vth <= 0; mobility point is not clearly in the on-state.',
1115                'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': np.nan, 'criterion': criterion}
1116    ratio = vd_eff / overdrive_pos
1117    ok = ratio >= factor
1118    warning = '' if ok else f"Possible non-saturation: VD/(VG-Vth)={ratio:.3g} < {factor:g}."
1119    return {'ok': ok, 'warning': warning, 'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': ratio, 'criterion': criterion}
1120
1121
1122def region_check_linear(vd, vg, vth, factor=3.0):
1123    """線形領域動作の条件をチェックします。
1124
1125    詳細説明:
1126        トランジスタが線形領域で動作しているかどうかを判断するために、
1127        指定されたVG、VD、Vth、および安全係数 (factor) を使用して
1128        VG - Vth >= factor * VD の条件を確認します。
1129        VDが正でない場合、VG-Vthが負の場合、または条件が満たされない場合は警告が生成されます。
1130    引数:
1131        :param vd: ドレイン電圧 [V]。
1132        :type vd: float
1133        :param vg: ゲート電圧 [V]。
1134        :type vg: float
1135        :param vth: 閾値電圧 [V]。
1136        :type vth: float
1137        :param factor: 線形領域を保証するための安全係数。デフォルトは3.0。
1138        :type factor: float
1139    戻り値:
1140        :returns: 線形領域チェックの結果を含む辞書。
1141                  ok (bool), warning (str), VD_abs (float), VG_minus_Vth (float),
1142                  ratio (float), criterion (str) を含みます。
1143        :rtype: dict
1144    """
1145    vd_eff = abs(float(vd))
1146    overdrive = float(vg) - float(vth)
1147    criterion = f"VG-Vth >= {factor:g}*VD"
1148    if vd_eff <= 0:
1149        return {'ok': False, 'warning': 'VD <= 0; linear-region mobility is not meaningful.',
1150                'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': np.nan, 'criterion': criterion}
1151    ratio = overdrive / vd_eff
1152    ok = (overdrive > 0) and (ratio >= factor)
1153    if overdrive <= 0:
1154        warning = 'VG - Vth <= 0; mobility point is below threshold.'
1155    elif not ok:
1156        warning = f"Possible non-linear-region point: (VG-Vth)/VD={ratio:.3g} < {factor:g}."
1157    else:
1158        warning = ''
1159    return {'ok': ok, 'warning': warning, 'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': ratio, 'criterion': criterion}
1160
1161
1162
1163def classify_transfer_region(linear_check, saturation_check):
1164    """線形/飽和/中間領域の推奨を返します。
1165
1166    詳細説明:
1167        線形領域チェックと飽和領域チェックの結果に基づいて、
1168        現在解析中の動作点が線形、飽和、またはどちらでもない中間領域のどれに属するかを分類します。
1169        分類結果と、それに関連する推奨/警告メッセージを返します。
1170    引数:
1171        :param linear_check: region_check_linear 関数からの結果辞書。
1172        :type linear_check: dict
1173        :param saturation_check: region_check_saturation 関数からの結果辞書。
1174        :type saturation_check: dict
1175    戻り値:
1176        :returns: 推奨される領域タイプと、関連する警告/説明メッセージのタプル。
1177        :rtype: tuple[str, str]
1178    """
1179    lin_ok = bool(linear_check.get('ok', False))
1180    sat_ok = bool(saturation_check.get('ok', False))
1181    if lin_ok and not sat_ok:
1182        return 'linear', 'Use linear-region mobility/Vth. Condition VG-Vth >= factor*VD is satisfied.'
1183    if sat_ok and not lin_ok:
1184        return 'saturation', 'Use saturation-region mobility/Vth. Condition VD >= factor*(VG-Vth) is satisfied.'
1185    if lin_ok and sat_ok:
1186        return 'ambiguous', 'Both linear and saturation checks passed at their own extraction points; inspect plots.'
1187    return 'intermediate', 'WARNING: neither linear nor saturation condition is sufficiently satisfied.'
1188def analyze_vg_core(df_full, vd_val, args, cox):
1189    """特定VDスライスのID-VGから、線形法と飽和法の両方でVth/移動度を抽出します。
1190
1191    詳細説明:
1192        ID-VGデータに基づいて、線形領域(最大相互コンダクタンス gm から)と
1193        飽和領域(最大 d(sqrt(ID))/dVg から)の閾値電圧 (Vth) と移動度を抽出します。
1194        サブスレッショルドスイング (S) やオフ電流 (Ioff) も計算します。
1195        抽出されたポイントの動作領域チェックも行い、推奨される抽出方法を提示します。
1196    引数:
1197        :param df_full: 全てのVG-ID測定データを含むDataFrame。
1198        :type df_full: pandas.DataFrame
1199        :param vd_val: 解析対象のドレイン電圧 [V]。
1200        :type vd_val: float
1201        :param args: コマンドライン引数を含むオブジェクト。smooth_npoints, lsq_order, Imin,
1202                     L, W, region_factor, ID_S 属性を使用します。
1203        :type args: argparse.Namespace
1204        :param cox: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
1205        :type cox: float
1206    戻り値:
1207        :returns: 特定VDスライスにおける詳細な解析結果を含む辞書。データが不十分な場合はNone。
1208        :rtype: dict or None
1209    """
1210    df_vd = df_full[np.isclose(df_full['VD'], vd_val, atol=1e-3)].copy()
1211    df_vd = select_sweep_segment(df_vd, 'VG', args.idx_vg, 'idx_vg', sort_after=True)
1212    if len(df_vd) < 5:
1213        return None
1214
1215    win = valid_savgol_window(len(df_vd), args.smooth_npoints, args.lsq_order)
1216    vg_step = df_vd['VG'].diff().dropna().median()
1217    if not np.isfinite(vg_step) or vg_step == 0:
1218        vg_step = 1.0
1219
1220    df_vd['ID_abs_floor'] = df_vd['ID'].abs().clip(lower=args.Imin)
1221    df_vd['ID_smooth'] = savgol_filter(df_vd['ID_abs_floor'], win, 1)
1222    df_vd['ID_smooth'] = np.clip(df_vd['ID_smooth'], args.Imin, None)
1223    df_vd['logID'] = np.log10(df_vd['ID_smooth'])
1224    df_vd['sqrtID'] = np.sqrt(df_vd['ID_smooth'])
1225    df_vd['dlogID'] = savgol_filter(df_vd['logID'], win, args.lsq_order, deriv=1, delta=vg_step)
1226    df_vd['gm'] = savgol_filter(df_vd['ID_smooth'], win, args.lsq_order, deriv=1, delta=vg_step)
1227    df_vd['dsqrtID'] = savgol_filter(df_vd['sqrtID'], win, args.lsq_order, deriv=1, delta=vg_step)
1228
1229    vd_eff = max(abs(vd_val), 1e-12)
1230    df_vd['muFE'] = (args.L / (args.W * cox * vd_eff)) * df_vd['gm']
1231    df_vd['muSAT_prof'] = (2 * args.L / (args.W * cox)) * (df_vd['dsqrtID'] ** 2)
1232    df_vd['S_val'] = np.where(df_vd['dlogID'] > 1e-6, 1.0 / df_vd['dlogID'], np.nan)
1233
1234    gm_valid = df_vd['gm'].replace([np.inf, -np.inf], np.nan).where(df_vd['gm'] > 0)
1235    idx_lin_slope_max = gm_valid.idxmax() if gm_valid.notna().any() else None
1236    if idx_lin_slope_max is not None:
1237        row_lin = df_vd.loc[idx_lin_slope_max]
1238        v_lin_slope = row_lin['VG']
1239        vth_lin = v_lin_slope - row_lin['ID_smooth'] / row_lin['gm'] if row_lin['gm'] > 0 else np.nan
1240        mu_lin_slope = row_lin['muFE']
1241        id_lin_slope = row_lin['ID_smooth']
1242        gm_max = row_lin['gm']
1243    else:
1244        v_lin_slope = vth_lin = mu_lin_slope = id_lin_slope = gm_max = np.nan
1245
1246    dsqrt_valid = df_vd['dsqrtID'].replace([np.inf, -np.inf], np.nan).where(df_vd['dsqrtID'] > 0)
1247    idx_sat_slope_max = dsqrt_valid.idxmax() if dsqrt_valid.notna().any() else None
1248    if idx_sat_slope_max is not None:
1249        row_sat = df_vd.loc[idx_sat_slope_max]
1250        v_sat_slope = row_sat['VG']
1251        vth_sat = v_sat_slope - row_sat['sqrtID'] / row_sat['dsqrtID'] if row_sat['dsqrtID'] > 0 else np.nan
1252        mu_sat_slope = row_sat['muSAT_prof']
1253        id_sat_slope = row_sat['ID_smooth']
1254        sqrtID_sat_slope = row_sat['sqrtID']
1255        dsqrtID_max = row_sat['dsqrtID']
1256    else:
1257        v_sat_slope = vth_sat = mu_sat_slope = id_sat_slope = sqrtID_sat_slope = dsqrtID_max = np.nan
1258
1259    idx_mu_lin_max = df_vd['muFE'].replace([np.inf, -np.inf], np.nan).idxmax()
1260    idx_mu_sat_max = df_vd['muSAT_prof'].replace([np.inf, -np.inf], np.nan).idxmax()
1261    mu_lin_max = df_vd.loc[idx_mu_lin_max, 'muFE']
1262    mu_sat_max = df_vd.loc[idx_mu_sat_max, 'muSAT_prof']
1263    VG_mu_lin_max = df_vd.loc[idx_mu_lin_max, 'VG']
1264    ID_mu_lin_max = df_vd.loc[idx_mu_lin_max, 'ID_smooth']
1265    VG_mu_sat_max = df_vd.loc[idx_mu_sat_max, 'VG']
1266    ID_mu_sat_max = df_vd.loc[idx_mu_sat_max, 'ID_smooth']
1267
1268    lin_region = region_check_linear(vd_eff, VG_mu_lin_max, vth_lin, args.region_factor)
1269    sat_region = region_check_saturation(vd_eff, VG_mu_sat_max, vth_sat, args.region_factor)
1270    recommended_method, recommended_warning = classify_transfer_region(lin_region, sat_region)
1271    if recommended_method == 'linear':
1272        vth_rec, mu_rec = vth_lin, mu_lin_max
1273    elif recommended_method == 'saturation':
1274        vth_rec, mu_rec = vth_sat, mu_sat_max
1275    else:
1276        vth_rec = np.nan
1277        mu_rec = np.nan
1278
1279    vth_for_subthreshold = vth_rec
1280    if not np.isfinite(vth_for_subthreshold):
1281        vth_for_subthreshold = vth_sat if np.isfinite(vth_sat) else vth_lin
1282
1283    off_mask = (df_vd['VG'] < vth_for_subthreshold - 5) & (df_vd['dlogID'].abs() < 0.1)
1284    if any(off_mask):
1285        ioff = df_vd[off_mask]['ID_smooth'].mean()
1286        idx_ioff = df_vd.loc[off_mask, 'ID_smooth'].idxmin()
1287    else:
1288        idx_ioff = df_vd['ID_smooth'].idxmin()
1289        ioff = df_vd.loc[idx_ioff, 'ID_smooth']
1290
1291    mask_s = (df_vd['VG'] < vth_for_subthreshold) & (df_vd['ID_smooth'] > ioff * 3) & (df_vd['dlogID'] > 0.05)
1292    df_s = df_vd[mask_s]
1293    s_min, vg_smin, id_smin, von, idx_smin = np.nan, np.nan, np.nan, np.nan, None
1294    if not df_s.empty:
1295        idx_smin = df_s['S_val'].idxmin()
1296        s_min = df_s.loc[idx_smin, 'S_val']
1297        vg_smin = df_s.loc[idx_smin, 'VG']
1298        id_smin = df_s.loc[idx_smin, 'ID_smooth']
1299        von = vg_smin + s_min * (np.log10(args.Imin) - np.log10(id_smin))
1300
1301    idx_ids, row_ids = nearest_row_by_current(df_vd, args.ID_S)
1302    legacy_vth = vth_rec if np.isfinite(vth_rec) else vth_sat
1303    legacy_mu = mu_rec if np.isfinite(mu_rec) else mu_sat_max
1304
1305    res = {
1306        'VD': vd_val,
1307        'Vth': legacy_vth,
1308        'mu_max': legacy_mu,
1309        'mu_type': recommended_method,
1310        'recommended_method': recommended_method,
1311        'recommended_warning': recommended_warning,
1312        'recommended_Vth': vth_rec,
1313        'recommended_mu': mu_rec,
1314        'Vth_lin': vth_lin,
1315        'v_lin_slope': v_lin_slope,
1316        'ID_lin_slope': id_lin_slope,
1317        'gm_max': gm_max,
1318        'mu_lin_slope': mu_lin_slope,
1319        'mu_lin_max': mu_lin_max,
1320        'VG_mu_lin_max': VG_mu_lin_max,
1321        'ID_mu_lin_max': ID_mu_lin_max,
1322        'lin_region_ok': lin_region['ok'],
1323        'lin_region_warning': lin_region['warning'],
1324        'lin_region_ratio_VGminusVth_over_VD': lin_region['ratio'],
1325        'lin_region_criterion': lin_region['criterion'],
1326        'lin_region_VG_minus_Vth': lin_region['VG_minus_Vth'],
1327        'Vth_sat': vth_sat,
1328        'v_sat_slope': v_sat_slope,
1329        'ID_sat_slope': id_sat_slope,
1330        'sqrtID_sat_slope': sqrtID_sat_slope,
1331        'dsqrtID_max': dsqrtID_max,
1332        'mu_sat_slope': mu_sat_slope,
1333        'mu_sat_max': mu_sat_max,
1334        'VG_mu_sat_max': VG_mu_sat_max,
1335        'ID_mu_sat_max': ID_mu_sat_max,
1336        'sat_region_ok': sat_region['ok'],
1337        'sat_region_warning': sat_region['warning'],
1338        'sat_region_ratio_VD_over_VGminusVth': sat_region['ratio'],
1339        'sat_region_criterion': sat_region['criterion'],
1340        'sat_region_VG_minus_Vth': sat_region['VG_minus_Vth'],
1341        'Ioff': ioff,
1342        'Smin': s_min,
1343        'VG_Smin': vg_smin,
1344        'id_smin': id_smin,
1345        'Von': von,
1346        'VG_ID_S': row_ids['VG'] if row_ids is not None else np.nan,
1347        'ID_S_target': args.ID_S,
1348        'Imin_target': args.Imin,
1349        'ID_S_val': row_ids['ID_smooth'] if row_ids is not None else np.nan,
1350        'S_ID_S': row_ids['S_val'] if row_ids is not None else np.nan,
1351        'savgol_window': win,
1352        'vg_step': vg_step,
1353        'idx_vg_selected': args.idx_vg,
1354        'idx_lin_slope_max': idx_lin_slope_max,
1355        'idx_sat_slope_max': idx_sat_slope_max,
1356        'idx_slope_max': idx_sat_slope_max,
1357        'idx_smin': idx_smin,
1358        'idx_ids': idx_ids,
1359        'idx_mu_lin_max': idx_mu_lin_max,
1360        'idx_mu_sat_max': idx_mu_sat_max,
1361        'idx_mu_max': idx_mu_lin_max if recommended_method == 'linear' else idx_mu_sat_max,
1362        'idx_ioff': idx_ioff,
1363        'df': df_vd,
1364    }
1365    res['analysis_points'] = build_analysis_points(res)
1366    return res
1367
1368
1369def annotate_vline(ax, x, label, ymin=None, ymax=None):
1370    """Matplotlibのプロットに垂直線とテキストアノテーションを追加します。
1371
1372    詳細説明:
1373        指定されたX座標 (x) に垂直線 (axvline) を引き、
1374        その線の近くにテキストラベル (label) を回転させて配置します。
1375        Y軸の範囲 (ymin, ymax) が指定されていない場合、現在のプロットのY軸範囲が使用されます。
1376    引数:
1377        :param ax: プロット対象のMatplotlib Axesオブジェクト。
1378        :type ax: matplotlib.axes.Axes
1379        :param x: 垂直線を引くX座標。
1380        :type x: float
1381        :param label: 垂直線の横に表示するテキストラベル。
1382        :type label: str
1383        :param ymin: 垂直線が描画されるY軸の下限。Noneの場合、現在のY軸の下限が使用されます。
1384        :type ymin: float or None
1385        :param ymax: 垂直線が描画されるY軸の上限。Noneの場合、現在のY軸の上限が使用されます。
1386        :type ymax: float or None
1387    戻り値:
1388        :returns: なし
1389        :rtype: None
1390    """
1391    ax.axvline(x, linestyle=':', linewidth=1, alpha=0.8)
1392    if ymin is None or ymax is None:
1393        ymin, ymax = ax.get_ylim()
1394    ax.text(x, ymax, label, rotation=90, va='top', ha='right', fontsize=8)
1395
1396
1397def plot_idvg_quad(res, args):
1398    """伝達特性解析結果を 2x2 サブプロットとして可視化します。
1399
1400    詳細説明:
1401        ID-VGデータに基づいて計算された様々なデバイス特性(ID-VG曲線、線形抽出、飽和抽出、移動度プロファイル)を
1402        2x2のサブプロットとして表示します。各プロットには、Vth、Smin、移動度最大値などの主要な解析ポイントが
1403        アノテーションとして表示されます。プロットはファイルに保存することも可能です。
1404    引数:
1405        :param res: analyze_vg_core 関数によって生成された、単一VDスライスの解析結果を含む辞書。
1406        :type res: dict
1407        :param args: コマンドライン引数を含むオブジェクト。save_plot および plot_dir 属性を使用します。
1408        :type args: argparse.Namespace
1409    戻り値:
1410        :returns: 生成されたMatplotlibのFigureオブジェクト。
1411        :rtype: matplotlib.figure.Figure
1412    """
1413    df = res['df']
1414    fig, axes = plt.subplots(2, 2, figsize=figsize_idvg_quad)
1415    fig.suptitle(f"n-ch Transfer Analysis (VD = {res['VD']} V)", fontsize=14)
1416
1417    ax = axes[0, 0]
1418    ax.plot(df['VG'], df['ID_smooth'], '-', lw=2, label='smoothed ID')
1419    ax.set_yscale('log')
1420    ax.set_xlabel(r'$V_G$ [V]')
1421    ax.set_ylabel(r'$I_D$ [A]')
1422    ax.set_title('ID-VG / subthreshold')
1423    ax.grid(True, alpha=0.15)
1424    if np.isfinite(res['Smin']):
1425        v_p = np.linspace(res['Von'], res['VG_Smin'], 30)
1426        id_p = 10 ** (np.log10(res['id_smin']) + (v_p - res['VG_Smin']) / res['Smin'])
1427        ax.plot(v_p, id_p, '--', label=f"Smin={res['Smin']:.3g} V/dec")
1428        ax.scatter(res['VG_Smin'], res['id_smin'], marker='o', s=55, label=f"Smin VG={res['VG_Smin']:.2f}")
1429        ax.scatter(res['Von'], args.Imin, marker='x', s=70, label=f"Von={res['Von']:.2f} V")
1430        annotate_vline(ax, res['VG_Smin'], 'Smin')
1431    if np.isfinite(res['VG_ID_S']):
1432        ax.scatter(res['VG_ID_S'], res['ID_S_val'], marker='s', s=50, label=f"ID_S S={res['S_ID_S']:.3g}")
1433        annotate_vline(ax, res['VG_ID_S'], 'ID_S')
1434    if np.isfinite(res['Vth_lin']):
1435        annotate_vline(ax, res['Vth_lin'], f"Vth_lin={res['Vth_lin']:.2f}")
1436    if np.isfinite(res['Vth_sat']):
1437        annotate_vline(ax, res['Vth_sat'], f"Vth_sat={res['Vth_sat']:.2f}")
1438    ax.legend(fontsize='x-small')
1439
1440    ax = axes[0, 1]
1441    ax.plot(df['VG'], df['ID_smooth'], '-', lw=2, label=r'$I_D$')
1442    if np.isfinite(res['Vth_lin']) and np.isfinite(res['gm_max']):
1443        vg_fit = np.linspace(min(res['Vth_lin'], df['VG'].min()), df['VG'].max(), 80)
1444        id_fit = res['gm_max'] * (vg_fit - res['Vth_lin'])
1445        ax.plot(vg_fit, id_fit, '--', label=f"linear tangent: Vth={res['Vth_lin']:.2f} V")
1446        ax.scatter(res['v_lin_slope'], res['ID_lin_slope'], marker='^', s=60,
1447                   label=f"max gm VG={res['v_lin_slope']:.2f}")
1448        annotate_vline(ax, res['Vth_lin'], 'Vth_lin')
1449        annotate_vline(ax, res['v_lin_slope'], 'max gm')
1450    txt = (f"linear check at mu_lin max:\n"
1451           f"(VG-Vth)/VD={res['lin_region_ratio_VGminusVth_over_VD']:.3g}\n"
1452           f"criterion: {res['lin_region_criterion']}")
1453    if not res['lin_region_ok']:
1454        txt = 'WARNING\n' + txt
1455    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
1456            bbox=dict(boxstyle='round', alpha=0.15))
1457    ax.set_xlabel(r'$V_G$ [V]')
1458    ax.set_ylabel(r'$I_D$ [A]')
1459    ax.set_title('Linear extraction: ID-VG tangent')
1460    ax.legend(fontsize='x-small')
1461    ax.grid(True, alpha=0.15)
1462
1463    ax = axes[1, 0]
1464    ax.plot(df['VG'], df['sqrtID'], '-', lw=2, label=r'$\sqrt{I_D}$')
1465    if np.isfinite(res['Vth_sat']) and np.isfinite(res['dsqrtID_max']):
1466        vg_fit = np.linspace(min(res['Vth_sat'], df['VG'].min()), df['VG'].max(), 80)
1467        ax.plot(vg_fit, res['dsqrtID_max'] * (vg_fit - res['Vth_sat']), '--',
1468                label=f"sat tangent: Vth={res['Vth_sat']:.2f} V")
1469        ax.scatter(res['v_sat_slope'], res['sqrtID_sat_slope'], marker='^', s=60,
1470                   label=f"max slope VG={res['v_sat_slope']:.2f}")
1471        annotate_vline(ax, res['Vth_sat'], 'Vth_sat')
1472        annotate_vline(ax, res['v_sat_slope'], 'max slope')
1473    txt = (f"sat check at mu_sat max:\n"
1474           f"VD/(VG-Vth)={res['sat_region_ratio_VD_over_VGminusVth']:.3g}\n"
1475           f"criterion: {res['sat_region_criterion']}")
1476    if not res['sat_region_ok']:
1477        txt = 'WARNING\n' + txt
1478    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
1479            bbox=dict(boxstyle='round', alpha=0.15))
1480    ax.set_xlabel(r'$V_G$ [V]')
1481    ax.set_ylabel(r'$\sqrt{I_D}$ [A$^{0.5}$]')
1482    ax.set_title('Saturation extraction: sqrt(ID)-VG tangent')
1483    ax.legend(fontsize='x-small')
1484    ax.grid(True, alpha=0.15)
1485
1486    ax = axes[1, 1]
1487    ax.plot(df['VG'], df['muFE'], '-', lw=2, label=r'$\mu_{lin}$ from $dI_D/dV_G$')
1488    ax.plot(df['VG'], df['muSAT_prof'], '--', lw=2, label=r'$\mu_{sat}$ from $d\sqrt{I_D}/dV_G$')
1489    ax.scatter(res['VG_mu_lin_max'], res['mu_lin_max'], marker='o', s=60, label=f"mu_lin max={res['mu_lin_max']:.3g}")
1490    ax.scatter(res['VG_mu_sat_max'], res['mu_sat_max'], marker='s', s=60, label=f"mu_sat max={res['mu_sat_max']:.3g}")
1491    if np.isfinite(res['v_lin_slope']):
1492        ax.scatter(res['v_lin_slope'], res['mu_lin_slope'], marker='^', s=55, label=f"mu_lin_slope={res['mu_lin_slope']:.3g}")
1493    if np.isfinite(res['v_sat_slope']):
1494        ax.scatter(res['v_sat_slope'], res['mu_sat_slope'], marker='v', s=55, label=f"mu_sat_slope={res['mu_sat_slope']:.3g}")
1495    if np.isfinite(res['Vth_lin']):
1496        annotate_vline(ax, res['Vth_lin'], 'Vth_lin')
1497    if np.isfinite(res['Vth_sat']):
1498        annotate_vline(ax, res['Vth_sat'], 'Vth_sat')
1499    txt = f"recommended: {res['recommended_method']}\n{res['recommended_warning']}"
1500    if res['recommended_method'] in ('intermediate', 'ambiguous'):
1501        txt = 'WARNING\n' + txt
1502    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
1503            bbox=dict(boxstyle='round', alpha=0.15))
1504    ax.set_xlabel(r'$V_G$ [V]')
1505    ax.set_ylabel(r'Mobility [cm$^2$/Vs]')
1506    ax.set_title('Mobility profiles: linear and saturation formulas')
1507    ax.legend(fontsize='x-small')
1508    ax.grid(True, alpha=0.15)
1509
1510    fig.tight_layout()
1511    if args.save_plot:
1512        os.makedirs(args.plot_dir, exist_ok=True)
1513        fname = plot_path(args, f"idvg_VD_{res['VD']:g}".replace('.', 'p'))
1514        fig.savefig(fname, dpi=200, bbox_inches='tight')
1515        print(f'  plot saved: {fname}')
1516    return fig
1517
1518
1519plot_idvg_triple = plot_idvg_quad
1520
1521
1522def print_transfer_report(res):
1523    """伝達特性解析結果をコンソールに整形して出力します。
1524
1525    詳細説明:
1526        analyze_vg_core 関数によって生成された伝達特性の解析結果辞書 (res) から、
1527        線形領域と飽和領域の閾値電圧 (Vth)、移動度 (mu)、サブスレッショルドスイング (Smin)、
1528        オフ電流 (Ioff) などの主要なデバイスパラメータを抽出し、
1529        コンソールに分かりやすい形式で詳細なレポートを出力します。
1530        また、各移動度抽出点における動作領域の適合性チェック結果も表示します。
1531    引数:
1532        :param res: 伝達特性解析結果を含む辞書。
1533        :type res: dict
1534    戻り値:
1535        :returns: なし
1536        :rtype: None
1537    """
1538    print(f"VD = {res['VD']:8.3g} V  (n-ch assumption)")
1539    print("  Linear-region extraction from ID-VG")
1540    print(f"    Vth_lin          : {res['Vth_lin']:10.4g} V")
1541    print(f"    tangent point    : VG={res['v_lin_slope']:10.4g} V, ID={res['ID_lin_slope']:10.4e} A, "
1542          f"gm={res['gm_max']:10.4e} A/V")
1543    print(f"    mu_lin_slope     : {res['mu_lin_slope']:10.4g} cm^2/Vs")
1544    print(f"    mu_lin_max       : {res['mu_lin_max']:10.4g} cm^2/Vs at VG={res['VG_mu_lin_max']:10.4g} V, "
1545          f"ID={res['ID_mu_lin_max']:10.4e} A")
1546    print(f"    linear check     : {res['lin_region_criterion']}; VG_mu_lin_max - Vth_lin = "
1547          f"{res['lin_region_VG_minus_Vth']:.4g} V, (VG-Vth)/VD = "
1548          f"{res['lin_region_ratio_VGminusVth_over_VD']:.4g}, OK={res['lin_region_ok']}")
1549    if not res['lin_region_ok']:
1550        print(f"    WARNING          : {res['lin_region_warning']}")
1551
1552    print("  Saturation-region extraction from sqrt(ID)-VG")
1553    print(f"    Vth_sat          : {res['Vth_sat']:10.4g} V")
1554    print(f"    tangent point    : VG={res['v_sat_slope']:10.4g} V, ID={res['ID_sat_slope']:10.4e} A, "
1555          f"sqrtID={res['sqrtID_sat_slope']:10.4e}, d sqrtID/dVG={res['dsqrtID_max']:10.4e}")
1556    print(f"    mu_sat_slope     : {res['mu_sat_slope']:10.4g} cm^2/Vs")
1557    print(f"    mu_sat_max       : {res['mu_sat_max']:10.4g} cm^2/Vs at VG={res['VG_mu_sat_max']:10.4g} V, "
1558          f"ID={res['ID_mu_sat_max']:10.4e} A")
1559    print(f"    saturation check : {res['sat_region_criterion']}; VG_mu_sat_max - Vth_sat = "
1560          f"{res['sat_region_VG_minus_Vth']:.4g} V, VD/(VG-Vth) = "
1561          f"{res['sat_region_ratio_VD_over_VGminusVth']:.4g}, OK={res['sat_region_ok']}")
1562    if not res['sat_region_ok']:
1563        print(f"    WARNING          : {res['sat_region_warning']}")
1564
1565    print(f"  Recommended method : {res['recommended_method']}")
1566    print(f"  Recommendation     : {res['recommended_warning']}")
1567    if res['recommended_method'] in ('intermediate', 'ambiguous'):
1568        print("  WARNING            : mobility/Vth representative value is not recommended for this VD slice.")
1569    else:
1570        print(f"  Recommended Vth    : {res['recommended_Vth']:10.4g} V")
1571        print(f"  Recommended mu     : {res['recommended_mu']:10.4g} cm^2/Vs")
1572
1573    print(f"  Ioff               : {res['Ioff']:10.4e} A")
1574    if np.isfinite(res['Smin']):
1575        print(f"  Smin               : {res['Smin']:10.4g} V/dec at VG={res['VG_Smin']:10.4g} V, "
1576              f"ID={res['id_smin']:10.4e} A")
1577        print(f"  Von from Smin      : {res['Von']:10.4g} V  at Imin={res['Imin_target']:10.4e} A")
1578    else:
1579        print("  Smin               : not found")
1580    print(f"  S at ID_S          : {res['S_ID_S']:10.4g} V/dec at target ID={res['ID_S_target']:10.4e} A, "
1581          f"nearest VG={res['VG_ID_S']:10.4g} V, ID={res['ID_S_val']:10.4e} A")
1582    print(f"  Savitzky-Golay     : window={res['savgol_window']}")
1583    print('-' * 72)
1584
1585
1586
1587def interpolate_id_at_vd(df_vg, vd_target):
1588    """単一のVG出力曲線内で、要求されたVD値におけるIDの絶対値を線形補間します。
1589
1590    詳細説明:
1591        与えられたデータフレーム (df_vg) が特定のVGでのID-VDデータを含んでいると仮定し、
1592        vd_target における abs(ID) の絶対値を線形補間によって推定します。
1593        vd_target が測定範囲外の場合、またはデータが不足している場合は np.nan を返します。
1594        補間はVDとID列をソートした後に行われます。
1595    引数:
1596        :param df_vg: 単一のVG値におけるID-VDデータを含むDataFrame。VDとID列が必要です。
1597        :type df_vg: pandas.DataFrame
1598        :param vd_target: 補間したい目標ドレイン電圧 [V]。
1599        :type vd_target: float
1600    戻り値:
1601        :returns: vd_target における補間された abs(ID) の絶対値。データが利用できない場合や範囲外の場合は np.nan。
1602        :rtype: float
1603    """
1604    if df_vg.empty or not np.isfinite(vd_target):
1605        return np.nan
1606    tmp = df_vg[["VD", "ID"]].dropna().sort_values("VD")
1607    if tmp.empty:
1608        return np.nan
1609    vd = tmp["VD"].to_numpy(dtype=float)
1610    id_abs = np.abs(tmp["ID"].to_numpy(dtype=float))
1611    if vd_target < np.nanmin(vd) or vd_target > np.nanmax(vd):
1612        return np.nan
1613    return float(np.interp(vd_target, vd, id_abs))
1614
1615def analyze_idvd_logic(df, args, cox):
1616    """TFTの出力特性 (ID-VD) を解析し、デバイスの線形領域特性と移動度を評価します。
1617
1618    詳細説明:
1619        入力されたID-VDデータフレームから、以下の解析を実行します:
1620        1. 最大のVD値における伝達特性スライス (df_full) を用いて、参照閾値電圧 (vth_ref) を抽出します。
1621        2. 各VG値におけるID-VD曲線に対して、線形領域(低いVD)での伝達コンダクタンス (gd) を線形回帰で計算します。
1622        3. 低いVDスライスにおけるID-VGデータから、相互コンダクタンス (gm) とS値を計算します。
1623        4. 抽出されたgdとgm、および vth_ref を用いて、実効移動度 (mu_eff) と線形移動度 (mu_lin) を導出します。
1624        5. 各移動度抽出点に対して線形領域条件のチェック (region_check_linear) を行い、警告情報を記録します。
1625        6. 解析結果の概要をコンソールに出力し、出力曲線と移動度プロットを生成します。
1626    引数:
1627        :param df: ID-VDデータを含むDataFrame。VG, VD, ID列が必要です。
1628        :type df: pandas.DataFrame
1629        :param args: コマンドライン引数を含むオブジェクト。
1630                     idx_vg, idx_vd, Imin, L, W, region_factor, save_plot, plot_dir 属性を使用します。
1631        :type args: argparse.Namespace
1632        :param cox: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
1633        :type cox: float
1634    戻り値:
1635        :returns: 各VGにおける解析結果の辞書リストと、生成されたMatplotlibのFigureオブジェクトのタプル。
1636                  有効なデータがない場合は ([], None) を返します。
1637        :rtype: tuple[list[dict], matplotlib.figure.Figure or None]
1638    """
1639    max_vd = df['VD'].max()
1640    res_v_ref = analyze_vg_core(df, max_vd, args, cox)
1641    vth_ref = res_v_ref['Vth'] if res_v_ref else 0.0
1642    positive_vd = sorted(df[df['VD'] > 0]['VD'].unique())
1643    if not positive_vd:
1644        print('WARNING: no positive VD data found for n-ch output analysis.')
1645        return [], None
1646    low_vd = positive_vd[0]
1647    df_low = df[np.isclose(df['VD'], low_vd, atol=1e-3)].copy()
1648    df_low = select_sweep_segment(df_low, 'VG', args.idx_vg, 'idx_vg', sort_after=True)
1649    df_low['gm'] = df_low['ID'].abs().diff() / df_low['VG'].diff()
1650    df_low['logID'] = np.log10(df_low['ID'].abs().clip(lower=args.Imin))
1651    df_low['S_val'] = 1.0 / (df_low['logID'].diff() / df_low['VG'].diff())
1652
1653    summary, unique_vgs = [], sorted(df['VG'].unique())
1654    fig, axes = plt.subplots(1, 2, figsize=(15, 6))
1655    print(f"\n{'=' * 20} ID-VD OUTPUT ANALYSIS {'=' * 20}")
1656    print(f"Using Vth_ref = {vth_ref:.4g} V from VD={max_vd:.4g} V transfer-like slice")
1657    print(f"Low-VD slice for gm = {low_vd:.4g} V")
1658    print(f"Selected sweep segments: idx_vg={args.idx_vg}, idx_vd={args.idx_vd}")
1659
1660    for vg in unique_vgs:
1661        df_vg_all = df[np.isclose(df['VG'], vg, atol=1e-3)].copy()
1662        df_vg = select_sweep_segment(df_vg_all, 'VD', args.idx_vd, 'idx_vd', sort_after=True)
1663        if len(df_vg) < 3:
1664            continue
1665        fit_df = df_vg[df_vg['VD'] <= low_vd + 2]
1666        if len(fit_df) < 2:
1667            continue
1668        gd = LinearRegression().fit(fit_df['VD'].values.reshape(-1, 1), fit_df['ID'].abs().values).coef_[0]
1669        gm_row = df_low[np.isclose(df_low['VG'], vg, atol=1e-3)]
1670        gm = gm_row['gm'].values[0] if not gm_row.empty else 0.0
1671        denom = max(0.1, vg - vth_ref)
1672        mue = (gd * args.L) / (args.W * cox * denom)
1673        mul = (gm * args.L) / (args.W * cox * low_vd)
1674        s_val = gm_row['S_val'].values[0] if not gm_row.empty else np.nan
1675        lin_region = region_check_linear(low_vd, vg, vth_ref, args.region_factor)
1676        summary.append({'VG': vg, 'gd': gd, 'gm': gm, 'mu_eff': mue, 'mu_lin': mul, 'S_val': s_val,
1677                        'Vth_ref': vth_ref, 'low_VD_for_gm': low_vd, 'fit_VD_max_for_gd': low_vd + 2,
1678                        'linear_region_ok': lin_region['ok'],
1679                        'linear_region_warning': lin_region['warning'],
1680                        'linear_region_ratio_VGminusVth_over_VD': lin_region['ratio'],
1681                        'linear_region_criterion': lin_region['criterion'],
1682                        'VG_minus_Vth': lin_region['VG_minus_Vth'],
1683                        'idx_vd_selected': args.idx_vd,
1684                        'idx_vg_selected_for_gm': args.idx_vg})
1685        axes[0].plot(df_vg['VD'], df_vg['ID'].abs(), alpha=0.45)
1686        print(f"  VG={vg:8.3g} V | gd={gd:10.4e} A/V | gm={gm:10.4e} A/V | "
1687              f"mu_eff={mue:10.4g} | mu_lin={mul:10.4g} | S={s_val:10.4g} | "
1688              f"linear OK={lin_region['ok']} ratio={(lin_region['ratio'] if np.isfinite(lin_region['ratio']) else np.nan):.4g}")
1689        if not lin_region['ok']:
1690            print(f"    WARNING: {lin_region['warning']}  criterion: {lin_region['criterion']}")
1691
1692    sdf = pd.DataFrame(summary)
1693    if not sdf.empty:
1694        # Saturation-boundary markers on output curves: VD_sat = VG - Vth_ref.
1695        vd_sat_list, id_sat_list = [], []
1696        for vg in sdf['VG'].to_numpy(dtype=float):
1697            vd_sat = vg - vth_ref
1698            df_vg_all = df[np.isclose(df['VG'], vg, atol=1e-3)].copy()
1699            df_vg = select_sweep_segment(df_vg_all, 'VD', args.idx_vd, 'idx_vd', sort_after=True)
1700            id_sat = interpolate_id_at_vd(df_vg, vd_sat)
1701            vd_sat_list.append(vd_sat)
1702            id_sat_list.append(id_sat)
1703        sdf['VD_sat_boundary'] = vd_sat_list
1704        sdf['ID_at_VD_sat_boundary'] = id_sat_list
1705        for i, row in sdf.iterrows():
1706            summary[i]['VD_sat_boundary'] = row['VD_sat_boundary']
1707            summary[i]['ID_at_VD_sat_boundary'] = row['ID_at_VD_sat_boundary']
1708
1709        good_boundary = sdf[np.isfinite(sdf['ID_at_VD_sat_boundary']) & (sdf['VD_sat_boundary'] >= 0)]
1710        if not good_boundary.empty:
1711            axes[0].plot(good_boundary['VD_sat_boundary'], good_boundary['ID_at_VD_sat_boundary'],
1712                         '^--', lw=1.2, ms=6, label=r'saturation boundary $V_D=V_G-V_{th}$')
1713
1714        axes[0].set_xlabel(r'$V_D$ [V]')
1715        axes[0].set_ylabel(r'$|I_D|$ [A] (linear scale)')
1716        axes[0].set_title('Output curves')
1717        axes[0].legend(fontsize='x-small')
1718        axes[0].grid(True, alpha=0.15)
1719        axes[1].plot(sdf['VG'], sdf['mu_eff'], 'o-', label='mu_eff from gd')
1720        axes[1].plot(sdf['VG'], sdf['mu_lin'], 's--', label='mu_lin from gm')
1721        axes[1].axvline(vth_ref, linestyle=':', linewidth=1, label='Vth_ref')
1722        bad = sdf[~sdf['linear_region_ok']] if 'linear_region_ok' in sdf.columns else pd.DataFrame()
1723        if not bad.empty:
1724            axes[1].scatter(bad['VG'], bad['mu_lin'], marker='x', s=75, label='linear-region warning')
1725        axes[1].text(0.02, 0.98, f"linear check: VG-Vth >= {args.region_factor:g}*VD_low",
1726                     transform=axes[1].transAxes, va='top', ha='left', fontsize=8,
1727                     bbox=dict(boxstyle='round', alpha=0.15))
1728        axes[1].set_xlabel(r'$V_G$ [V]')
1729        axes[1].set_ylabel(r'Mobility [cm$^2$/Vs]')
1730        axes[1].legend()
1731        axes[1].grid(True, alpha=0.15)
1732    fig.tight_layout()
1733    if args.save_plot:
1734        os.makedirs(args.plot_dir, exist_ok=True)
1735        fname = plot_path(args, 'idvd_output_analysis')
1736        fig.savefig(fname, dpi=200, bbox_inches='tight')
1737        print(f'  plot saved: {fname}')
1738    return summary, fig
1739
1740
1741def make_summary_dataframe(t_summary):
1742    """伝達特性解析結果のリストから、Excel出力用のPandas DataFrameを作成します。
1743
1744    詳細説明:
1745        t_summary リスト内の各解析結果辞書から、Excelサマリーシートに適した
1746        主要なパラメータを抽出します。
1747        元のデータフレーム (df) や詳細な分析ポイント (analysis_points)、
1748        および内部的なインデックス (idx_ で始まるキー) は除外されます。
1749        結果はPandas DataFrameとして整形され、Excelへのエクスポートに適した形式で提供されます。
1750    引数:
1751        :param t_summary: 各VDスライスでの伝達特性解析結果を含む辞書のリスト。
1752        :type t_summary: list[dict]
1753    戻り値:
1754        :returns: 主要な解析結果パラメータを含むPandas DataFrame。
1755        :rtype: pandas.DataFrame
1756    """
1757    rows = []
1758    for res in t_summary:
1759        row = {k: v for k, v in res.items() if k not in ('df', 'analysis_points') and not str(k).startswith('idx_')}
1760        rows.append(row)
1761    return pd.DataFrame(rows)
1762
1763
1764def run_analysis(args):
1765    """スクリプトのメイン実行ロジックをカプセル化します。
1766
1767    詳細説明:
1768        この関数は、コマンドライン引数をパースし、ゲート酸化膜容量 (Cox) を計算します。
1769        選択された解析モード (read, analyze_idvg, analyze_idvd, all) に応じて、
1770        対応するデータ読み込み、解析、プロット生成の関数を呼び出します。
1771        すべての結果(生データ、平滑化データ、解析サマリー、詳細分析ポイント)は集約され、
1772        最終的に単一のExcelレポートファイルとPNGプロットとして出力されます。
1773        プロットは --show_plot が指定されている場合、インタラクティブに表示されます。
1774    引数:
1775        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
1776        :type args: argparse.Namespace
1777    戻り値:
1778        :returns: なし。解析結果はファイルシステムに出力されます。
1779        :rtype: None
1780    """
1781    # args is prepared by main(): output paths and logging are already configured.
1782    cox = calculate_cox(args.dg, args.epsg)
1783    t_summary, t_data, t_points, o_summary = [], [], [], []
1784    read_tables, read_summary = {}, []
1785    figures = []
1786
1787    print('TFT analysis mode: n-channel dedicated')
1788    print(f'Cox = {cox:.6e} F/cm^2  (dg={args.dg:g} nm, epsg={args.epsg:g})')
1789    if args.reverse_vg:
1790        print('VG sign reversal is enabled after data loading.')
1791
1792    if args.mode == 'read':
1793        figs, read_tables, read_summary = run_read_mode(args)
1794        figures.extend(figs)
1795
1796    if args.mode in ['analyze_idvg', 'all']:
1797        df_vg = detect_and_load(args.infile_vg, reverse_vg=args.reverse_vg)
1798        if df_vg is not None:
1799            print(f"\n{'=' * 20} DETAILED TRANSFER ANALYSIS {'=' * 20}")
1800            for vd in sorted(df_vg['VD'].unique()):
1801                if vd <= 0:
1802                    print(f'Skip VD={vd:g} V because n-ch analysis expects positive VD.')
1803                    continue
1804                res = analyze_vg_core(df_vg, vd, args, cox)
1805                if res:
1806                    print_transfer_report(res)
1807                    t_summary.append(res)
1808                    res['df']['VD_label'] = vd
1809                    t_data.append(res['df'])
1810                    t_points.extend(res['analysis_points'])
1811                    figures.append(plot_idvg_triple(res, args))
1812
1813    if args.mode in ['analyze_idvd', 'all']:
1814        df_vd = detect_and_load(args.infile_vd, reverse_vg=args.reverse_vg)
1815        if df_vd is not None:
1816            o_summary, fig = analyze_idvd_logic(df_vd, args, cox)
1817            if fig is not None:
1818                figures.append(fig)
1819
1820    with pd.ExcelWriter(args.out_excel) as writer:
1821        pd.DataFrame([{
1822            'analysis_assumption': 'n-channel TFT; positive VD; use --reverse_vg for p-channel preprocessing',
1823            'L_um': args.L,
1824            'W_um': args.W,
1825            'dg_nm': args.dg,
1826            'epsg': args.epsg,
1827            'Cox_F_per_cm2': cox,
1828            'ID_S_A': args.ID_S,
1829            'Imin_A': args.Imin,
1830            'smooth_npoints_requested': args.smooth_npoints,
1831            'lsq_order': args.lsq_order,
1832            'region_factor': args.region_factor,
1833            'idx_vg': args.idx_vg,
1834            'idx_vd': args.idx_vd,
1835            'plot_dir': args.plot_dir if args.save_plot else '',
1836        }]).to_excel(writer, sheet_name='Analysis_Settings', index=False)
1837        if read_summary:
1838            pd.DataFrame(read_summary).to_excel(writer, sheet_name='Read_Summary', index=False)
1839        for tag, rdf in read_tables.items():
1840            sheet = 'Read_Data_' + tag.upper()
1841            rdf.to_excel(writer, sheet_name=sheet[:31], index=False)
1842        if t_summary:
1843            make_summary_dataframe(t_summary).to_excel(writer, sheet_name='Summary_Transfer', index=False)
1844        if t_points:
1845            pd.DataFrame(t_points).to_excel(writer, sheet_name='Analysis_Points', index=False)
1846        if t_data:
1847            pd.concat(t_data).to_excel(writer, sheet_name='Data_Transfer_VG_Dep', index=False)
1848        if o_summary:
1849            pd.DataFrame(o_summary).to_excel(writer, sheet_name='Data_Output_VG_Dep', index=False)
1850    print(f'Report saved to {args.out_excel}')
1851
1852    # Excel and PNG files are saved before this interactive display.
1853    if args.show_plot and figures:
1854        plt.show()
1855    else:
1856        for fig in figures:
1857            plt.close(fig)
1858
1859
1860def main():
1861    """プログラムのエントリポイントです。
1862
1863    詳細説明:
1864        この関数は、コマンドライン引数をパースし、Excel、PNG、ログファイルの
1865        出力パスを準備します。標準出力と標準エラー出力をコンソールとログファイルの両方に
1866        出力するように設定した後、run_analysis 関数を呼び出して主要な解析ロジックを実行します。
1867        解析終了後、Tee 機能は解除されます。
1868    戻り値:
1869        :returns: なし
1870        :rtype: None
1871    """
1872    args = get_args()
1873    args = prepare_output_paths(args)
1874    os.makedirs(args.output_dir, exist_ok=True)
1875    original_stdout = sys.stdout
1876    original_stderr = sys.stderr
1877    with open(args.log_file, 'w', encoding='utf-8') as log_fp:
1878        sys.stdout = Tee(original_stdout, log_fp)
1879        sys.stderr = Tee(original_stderr, log_fp)
1880        try:
1881            print(f'Log file: {args.log_file}')
1882            print(f'Output directory: {args.output_dir}')
1883            print(f'Output stem: {args.output_stem}')
1884            run_analysis(args)
1885        finally:
1886            sys.stdout = original_stdout
1887            sys.stderr = original_stderr
1888
1889if __name__ == '__main__':
1890    main()