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