"""
概要:
    TFT n-チャネルトランジスタの電気特性を解析するツールです。
詳細説明:
    このスクリプトは、TFT (Thin-Film Transistor) のn-チャネルデバイスにおける
    I-V特性（伝達特性 (ID-VG) および出力特性 (ID-VD)）データを読み込み、
    解析し、結果をExcelレポートとPNGプロットとして出力します。
    コマンドラインからの使用例は以下の通りです。
    python tftanalyze.py --mode all --infile_vg transfer.csv --infile_vd output.csv

主な機能:
    CSV形式の測定データを自動的にエンコーディングを検出して読み込みます。
    伝達特性データから閾値電圧 (Vth)、移動度 (Mobility)、サブスレッショルドスイング (S) などの
    主要なデバイスパラメータを抽出します。
    出力特性データから線形領域のコンダクタンス (gd) や移動度 (mu_lin, mu_eff) を評価します。
    サビツキー・ゴレイフィルターを用いたデータの平滑化をサポートします。
    解析結果をインタラクティブなグラフ表示とPNGファイルとして保存します。
    全ての解析結果と生データ、平滑化データをExcelファイルに集約して出力します。
関連リンク:
    tftanalyze_usage
"""
import os
import sys
import argparse
import csv
import chardet
from pathlib import Path
import numpy as np
from scipy import constants
from scipy.signal import savgol_filter
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression


# --- 物理定数 ---
EPS0 = constants.epsilon_0


figsize_idvg_quad = (10, 8)

def get_args():
    """コマンドライン引数をパースします。

    詳細説明:
        この関数は、TFT解析スクリプトに必要な全てのコマンドライン引数を定義し、
        ユーザーが指定した引数をパースして返します。
        引数には、入力ファイルパス、解析モード、TFTデバイスの物理的寸法、
        誘電体定数、電流の閾値、平滑化パラメータ、プロット表示・保存設定などが含まれます。
    戻り値:
        :returns: パースされた引数を含む argparse.Namespace オブジェクト。
        :rtype: argparse.Namespace
    """
    parser = argparse.ArgumentParser(description='TFT n-channel Transfer/Output Analysis & Excel Tool (detail rev.)')
    parser.add_argument('--infile_vg', default='TFT_Vg-Id_STD-ide3 [AS220518TFT-anneal(454) ; 2022_05_25 18_00_33].csv')
    parser.add_argument('--infile_vd', default='TFT_Vd-Id-ide [AS220518TFT-anneal(455) ; 2022_05_25 18_01_11].csv')
    parser.add_argument('--mode', choices=['read', 'analyze_idvg', 'analyze_idvd', 'all'], default='all')
    parser.add_argument('--out_excel', default=None, help='Output Excel file. If omitted, a mode-dependent filename is used.')
    parser.add_argument('--L', type=float, default=50.0, help='Channel length [um]')
    parser.add_argument('--W', type=float, default=300.0, help='Channel width [um]')
    parser.add_argument('--dg', type=float, default=150.0, help='Gate insulator thickness [nm]')
    parser.add_argument('--epsg', type=float, default=3.9, help='Relative dielectric constant of gate insulator')
    parser.add_argument('--ID_S', type=float, default=1e-9, help='Reference drain current for S extraction [A]')
    parser.add_argument('--Imin', type=float, default=1e-15, help='Minimum current floor for log analysis [A]')
    parser.add_argument('--smooth_npoints', type=int, default=5)
    parser.add_argument('--lsq_order', type=int, default=2)
    parser.add_argument('--show_plot', action='store_true', default=True, help='Show plots interactively (default: True)')
    parser.add_argument('--no_show_plot', action='store_false', dest='show_plot', help='Do not show plots interactively')
    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')
    parser.add_argument('--save_plot', action='store_true', default=True, help='Save analysis plots as PNG files (default: True)')
    parser.add_argument('--no_save_plot', action='store_false', dest='save_plot', help='Do not save analysis plots')
    parser.add_argument('--plot_dir', default='tft_analysis_plots')
    parser.add_argument('--reverse_vg', action='store_true', help='Reverse VG sign after loading data. Intended for p-channel data preprocessing.')
    parser.add_argument('--idx_vg', type=int, default=0, help='Sweep segment index for VG-swept data. Incremented when VG sweep direction changes.')
    parser.add_argument('--idx_vd', type=int, default=0, help='Sweep segment index for VD-swept data. Incremented when VD sweep direction changes.')
    parser.add_argument('--read_smooth_domain', choices=['log', 'linear'], default='log',
                        help='Smoothing domain for mode=read. For output ID-VD preview, linear smoothing/linear-y plotting is forced.')
    parser.add_argument('--read_keep_edge_raw', action='store_true', default=True,
                        help='In mode=read, keep edge points raw instead of applying Savitzky-Golay near endpoints (default: True).')
    parser.add_argument('--read_smooth_edges', action='store_false', dest='read_keep_edge_raw',
                        help='Apply Savitzky-Golay smoothing also near endpoints.')
    return parser.parse_args()


def calculate_cox(dg_nm, epsg):
    """ゲート酸化膜容量 (Cox) を計算します。

    詳細説明:
        ゲート絶縁膜の厚さ (ナノメートル単位) と比誘電率から、
        単位面積あたりのゲート酸化膜容量 (F/cm^2) を計算します。
        物理定数として真空の誘電率 (EPS0) を使用します。
    引数:
        :param dg_nm: ゲート絶縁膜の厚さ [nm]。
        :type dg_nm: float
        :param epsg: ゲート絶縁膜の比誘電率。
        :type epsg: float
    戻り値:
        :returns: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
        :rtype: float
    """
    dg_m = dg_nm * 1e-9
    # F/m^2 -> F/cm^2
    return (epsg * EPS0 / dg_m) * 1e-4


def default_excel_name(mode):
    """指定された解析モードに応じたデフォルトのExcelファイル名を返します。

    詳細説明:
        異なる解析モード（'read', 'analyze_idvg', 'analyze_idvd', 'all'）に対して、
        それぞれ適切なデフォルトのExcelファイル名を決定します。
        指定されたモードが辞書にない場合は、汎用的なレポートファイル名を返します。
    引数:
        :param mode: 解析モードを示す文字列。
        :type mode: str
    戻り値:
        :returns: デフォルトのExcelファイル名。
        :rtype: str
    """
    names = {
        'read': 'tft_read_data.xlsx',
        'analyze_idvg': 'tft_analysis_idvg.xlsx',
        'analyze_idvd': 'tft_analysis_idvd.xlsx',
        'all': 'tft_analysis_all.xlsx',
    }
    return names.get(mode, 'tft_analysis_report.xlsx')


class Tee:
    """stdout/stderr をコンソールとログファイルへ同時出力する簡単な Tee。

    詳細説明:
        このクラスは、複数のファイルライクオブジェクトに書き込み操作をミラーリングするために使用されます。
        例えば、標準出力への書き込みと同時にログファイルへの書き込みを行う場合に便利です。
    引数:
        :param streams: データを書き込む対象となる一つ以上のファイルライクオブジェクト。
        :type streams: file-like objects
    """
    def __init__(self, *streams):
        self.streams = streams

    def write(self, data):
        for stream in self.streams:
            stream.write(data)
            stream.flush()

    def flush(self):
        for stream in self.streams:
            stream.flush()


def _existing_or_first_path(*paths):
    """出力名の基準にする入力ファイルを選びます。存在確認はゆるく行います。

    詳細説明:
        複数のファイルパスが与えられた場合、最初に空でないパス、
        または提供されたパスのリストの最初のパスをPathオブジェクトとして返します。
        ファイルシステムの存在チェックは行いません。
    引数:
        :param paths: 検査するファイルパスの可変引数。
        :type paths: str
    戻り値:
        :returns: 基準として選択されたファイルパスのPathオブジェクト。
        :rtype: pathlib.Path
    """
    for path in paths:
        if path:
            return Path(path)
    return Path('tft_analysis')


def prepare_output_paths(args):
    """Excel/PNG/log の保存先を入力ファイルと同じディレクトリにそろえます。

    詳細説明:
        入力ファイルパス（args.infile_vg, args.infile_vd）を基準に、
        Excelレポート、PNGプロット、ログファイルの出力ディレクトリとファイル名を決定し、
        argsオブジェクトに設定します。これにより、全ての出力が関連する入力ファイルの近くに集約されます。
    引数:
        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
                     infile_vg, infile_vd, mode, out_excel 属性を使用します。
        :type args: argparse.Namespace
    戻り値:
        :returns: 出力パスが設定された argparse.Namespace オブジェクト。
        :rtype: argparse.Namespace
    """
    if args.mode == 'analyze_idvd':
        ref = _existing_or_first_path(args.infile_vd, args.infile_vg)
    else:
        ref = _existing_or_first_path(args.infile_vg, args.infile_vd)
    ref = ref.expanduser()
    out_dir = ref.parent if str(ref.parent) not in ('', '.') else Path.cwd()
    out_dir = out_dir.resolve()
    stem = ref.stem if ref.stem else 'tft_analysis'
    args.output_dir = str(out_dir)
    args.output_stem = stem
    args.plot_dir = str(out_dir)

    if args.out_excel is None:
        args.out_excel = str(out_dir / f'{stem}_{args.mode}.xlsx')
    else:
        user_name = Path(args.out_excel).name
        user_stem = Path(user_name).stem
        user_suffix = Path(user_name).suffix or '.xlsx'
        if stem not in user_stem:
            user_name = f'{stem}_{user_stem}{user_suffix}'
        args.out_excel = str(out_dir / user_name)

    args.log_file = str(out_dir / f'{stem}_{args.mode}.log')
    return args


def plot_path(args, name):
    """入力stemつきPNG保存パスを作成します。

    詳細説明:
        コマンドライン引数 (args) から取得した出力ステムとプロットディレクトリを基に、
        指定された名前 (name) でPNGファイルの完全な保存パスを構築します。
    引数:
        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
                     output_stem および plot_dir 属性を使用します。
        :type args: argparse.Namespace
        :param name: 保存するPNGファイルの名前（拡張子なし）。
        :type name: str
    戻り値:
        :returns: 生成されたPNGファイルの完全なパス。
        :rtype: str
    """
    stem = getattr(args, 'output_stem', 'tft_analysis')
    out_dir = Path(getattr(args, 'plot_dir', '.'))
    return str(out_dir / f'{stem}_{name}.png')

def savgol_center_only(y, win, order, keep_edge_raw=True):
    """サビツキー・ゴレイフィルターを適用し、オプションで端点付近の生データを保持します。

    詳細説明:
        scipy.signal.savgol_filter は端点付近を外挿/補間することがあります。
        TFT出力曲線では、VD=0側の点数が少ないため、端点平滑化が系統的なずれのように見えることがあります。
        keep_edge_raw=True の場合、完全な中心ウィンドウを持つ点のみが平滑化された値に置き換えられ、
        端点付近のデータは元のクリップされたデータのまま維持されます。
        データ長が短すぎる場合、またはウィンドウ長が不正な場合は、元のデータが返され、
        平滑化が適用されなかったことを示すブール配列が返されます。
    引数:
        :param y: 平滑化するデータ配列。
        :type y: numpy.ndarray or list
        :param win: サビツキー・ゴレイフィルターのウィンドウ長。奇数である必要があります。
        :type win: int
        :param order: フィルターの多項式の次数。
        :type order: int
        :param keep_edge_raw: Trueの場合、完全な中心ウィンドウを持たない端点付近のデータを生データのまま保持します。
        :type keep_edge_raw: bool
    戻り値:
        :returns: 平滑化されたデータ配列と、各点が平滑化に使用されたかを示すブール配列のタプル。
        :rtype: tuple[numpy.ndarray, numpy.ndarray]
    """
    y = np.asarray(y, dtype=float)
    if len(y) < 3 or not np.isfinite(win):
        return y.copy(), np.zeros(len(y), dtype=bool)
    win_i = int(win)
    if win_i < 3 or win_i > len(y):
        return y.copy(), np.zeros(len(y), dtype=bool)
    order_i = min(int(order), win_i - 1)
    ys = savgol_filter(y, win_i, order_i, mode='interp')
    used = np.ones(len(y), dtype=bool)
    if keep_edge_raw:
        half = win_i // 2
        used[:] = False
        if len(y) > 2 * half:
            used[half:len(y)-half] = True
        out = y.copy()
        out[used] = ys[used]
        return out, used
    return ys, used


def add_read_columns_grouped(df, xcol, groupcol, args):
    """データフレームに電流のクリッピングと平滑化された電流の列を追加します。

    詳細説明:
        この関数は、読み込み/プレビューモードのために、各グループ（またはデータ全体）に対して、
        以下の列を追加します:
        ID_abs_floor: IDの絶対値を args.Imin でクリッピングした値。
        ID_smooth_linear: 線形スケールで平滑化されたID。
        logID_smooth: 対数スケールで平滑化された log10(abs(ID))。
        ID_smooth_log: logID_smooth を元に対数スケールで平滑化されたID。
        ID_smooth: args.read_smooth_domain に応じて ID_smooth_linear または ID_smooth_log。
        savgol_used_linear, savgol_used_log, savgol_used: Savitzky-Golay平滑化が適用された点を示すブールマスク。

        重要な点:
        伝達特性 (ID-VG) のプレビューは、デフォルトで log10(abs(ID)) の平滑化後に逆変換します。
        出力特性 (ID-VD) のプレビューは、線形電流平滑化と線形Y軸プロットを強制します。
        デフォルトでは、端点付近ではSavitzky-Golayフィルターの完全な中心ウィンドウがないため、
        平滑化は無効化されます。これにより、VD=0付近の人工的なずれを回避します。
    引数:
        :param df: 処理対象のDataFrame。
        :type df: pandas.DataFrame
        :param xcol: データのX軸となる列名（例: 'VG', 'VD'）。
        :type xcol: str
        :param groupcol: グループ化に使用する列名（例: 'VD', 'VG'）。この列が存在しない場合、データ全体が単一のグループとして扱われます。
        :type groupcol: str
        :param args: コマンドライン引数を含むオブジェクト。Imin, lsq_order, smooth_npoints,
                     read_keep_edge_raw, read_smooth_domain 属性を使用します。
        :type args: argparse.Namespace
    戻り値:
        :returns: クリップおよび平滑化された電流列が追加されたDataFrame。
        :rtype: pandas.DataFrame
    """

    def _add_cols(tmp):
        tmp = tmp.sort_values(xcol).copy()
        tmp['ID_abs_floor'] = tmp['ID'].abs().clip(lower=args.Imin)
        order = int(getattr(args, 'lsq_order', 2))
        win = valid_savgol_window(len(tmp), args.smooth_npoints, order) if len(tmp) >= 3 else np.nan
        keep_edge_raw = bool(getattr(args, 'read_keep_edge_raw', True))
        if len(tmp) >= 3 and np.isfinite(win):
            win_i = int(win)
            lin, used_lin = savgol_center_only(tmp['ID_abs_floor'].to_numpy(), win_i, order, keep_edge_raw)
            tmp['ID_smooth_linear'] = np.clip(lin, args.Imin, None)
            log_id = np.log10(tmp['ID_abs_floor'].to_numpy())
            log_s, used_log = savgol_center_only(log_id, win_i, order, keep_edge_raw)
            tmp['logID_smooth'] = log_s
            tmp['ID_smooth_log'] = np.clip(np.power(10.0, tmp['logID_smooth']), args.Imin, None)
            tmp['savgol_used_linear'] = used_lin
            tmp['savgol_used_log'] = used_log
        else:
            tmp['ID_smooth_linear'] = tmp['ID_abs_floor']
            tmp['logID_smooth'] = np.log10(tmp['ID_abs_floor'])
            tmp['ID_smooth_log'] = tmp['ID_abs_floor']
            tmp['savgol_used_linear'] = False
            tmp['savgol_used_log'] = False
        if getattr(args, 'read_smooth_domain', 'log') == 'linear':
            tmp['ID_smooth'] = tmp['ID_smooth_linear']
            tmp['read_smooth_domain'] = 'linear current'
            tmp['savgol_used'] = tmp['savgol_used_linear']
        else:
            tmp['ID_smooth'] = tmp['ID_smooth_log']
            tmp['read_smooth_domain'] = 'log10 current'
            tmp['savgol_used'] = tmp['savgol_used_log']
        tmp['read_x'] = tmp[xcol]
        tmp['savgol_window'] = win
        tmp['savgol_order'] = order
        tmp['edge_raw_kept'] = keep_edge_raw
        return tmp

    if groupcol not in df.columns:
        tmp = _add_cols(df)
        tmp['read_group'] = 'all'
        return tmp

    out = []
    for gv in sorted(df[groupcol].dropna().unique()):
        tmp = df[np.isclose(df[groupcol], gv, atol=1e-3)].copy()
        if tmp.empty:
            continue
        tmp = _add_cols(tmp)
        tmp['read_group'] = f'{groupcol}={gv:g}'
        out.append(tmp)
    return pd.concat(out, ignore_index=False) if out else pd.DataFrame()

def plot_read_data(df_read, xcol, groupcol, title, args, fname_base, yscale='log'):
    """読み込み/プレビューモードで平滑化されたデータをプロットします。

    詳細説明:
        生のクリップされたデータ (ID_abs_floor) と平滑化されたデータ (ID_smooth) を
        X軸 (xcol) に対してプロットします。
        groupcol が指定されている場合、データはグループごとにプロットされ、凡例に表示されます。
        Y軸はオプションで対数スケールに設定できます。
        生成されたプロットは、args.save_plot がTrueの場合、指定されたディレクトリにPNGファイルとして保存されます。
    引数:
        :param df_read: 読み込み/プレビュー用に処理されたデータを含むDataFrame。
        :type df_read: pandas.DataFrame
        :param xcol: X軸としてプロットする列名（例: 'VG', 'VD'）。
        :type xcol: str
        :param groupcol: データをグループ化するための列名（例: 'VD', 'VG'）。
                         この列が存在しない場合、データ全体が単一のグループとして扱われます。
        :type groupcol: str
        :param title: プロットのタイトル。
        :type title: str
        :param args: コマンドライン引数を含むオブジェクト。save_plot と plot_dir 属性を使用します。
        :type args: argparse.Namespace
        :param fname_base: 保存するPNGファイル名のベース。
        :type fname_base: str
        :param yscale: Y軸のスケール（'log'または'linear'）。Noneの場合、線形スケール。デフォルトは'log'。
        :type yscale: str or None
    戻り値:
        :returns: 生成されたMatplotlibのFigureオブジェクト。
        :rtype: matplotlib.figure.Figure
    """
    fig, ax = plt.subplots(1, 1, figsize=(8, 6))
    if groupcol in df_read.columns:
        groups = sorted(df_read[groupcol].dropna().unique())
        for gv in groups:
            tmp = df_read[np.isclose(df_read[groupcol], gv, atol=1e-3)].sort_values(xcol)
            ax.plot(tmp[xcol], tmp['ID_abs_floor'], '.', ms=3, alpha=0.35)
            ax.plot(tmp[xcol], tmp['ID_smooth'], '-', lw=1.6, label=f'{groupcol}={gv:g} ({tmp["read_smooth_domain"].iloc[0]})')
    else:
        tmp = df_read.sort_values(xcol)
        ax.plot(tmp[xcol], tmp['ID_abs_floor'], '.', ms=3, alpha=0.35, label='clipped')
        ax.plot(tmp[xcol], tmp['ID_smooth'], '-', lw=1.8, label=f'smoothed ({tmp["read_smooth_domain"].iloc[0]})')
    if yscale is not None:
        ax.set_yscale(yscale)
    ax.set_xlabel(f'{xcol} [V]')
    ax.set_ylabel(r'$|I_D|$ clipped/smoothed [A]')
    ax.set_title(title)
    ax.grid(True, alpha=0.15)
    ax.legend(fontsize='x-small', ncol=2)
    fig.tight_layout()
    if args.save_plot:
        os.makedirs(args.plot_dir, exist_ok=True)
        fname = plot_path(args, fname_base)
        fig.savefig(fname, dpi=200, bbox_inches='tight')
        print(f'  plot saved: {fname}')
    return fig


def run_read_mode(args):
    """読み込み/プレビューモードの処理を実行します。

    詳細説明:
        指定された入力ファイル（伝達特性と出力特性）を読み込み、Imin で電流をクリッピングし、
        Savitzky-Golayフィルターで平滑化します。その後、処理されたデータをプロットし、
        結果の概要をコンソールに出力します。
        伝達特性 (ID-VG) データは対数電流平滑化がデフォルトですが、
        出力特性 (ID-VD) データは線形電流平滑化と線形Y軸プロットが強制されます。
        処理されたデータフレームとサマリー情報は、Excelエクスポートのために返されます。
    引数:
        :param args: コマンドライン引数を含むオブジェクト。
                     infile_vg, infile_vd, reverse_vg, idx_vg, idx_vd, Imin,
                     read_smooth_domain, save_plot, plot_dir 属性を使用します。
        :type args: argparse.Namespace
    戻り値:
        :returns:
            MatplotlibのFigureオブジェクトのリスト。
            読み込まれた/処理されたデータフレームをタグ（'vg'または'vd'）で格納した辞書。
            読み込み処理のサマリー情報を含む辞書（各グループごと）のリスト。
        :rtype: tuple[list[matplotlib.figure.Figure], dict[str, pandas.DataFrame], list[dict]]
    """
    figures = []
    read_tables = {}
    read_summary = []
    targets = []
    if args.infile_vg:
        targets.append(('vg', args.infile_vg, 'VG', 'VD', 'Read preview: transfer-like ID-VG data'))
    if args.infile_vd:
        targets.append(('vd', args.infile_vd, 'VD', 'VG', 'Read preview: output ID-VD data'))
    if not targets:
        print('WARNING: no input file is specified. Use --infile_vg and/or --infile_vd.')
        return figures, read_tables, read_summary
    for tag, path, xcol, groupcol, title in targets:
        df = detect_and_load(path, reverse_vg=args.reverse_vg)
        if df is None:
            continue
        if xcol not in df.columns:
            print(f'WARNING: {path} does not have required x column {xcol}; skipped.')
            continue
        idx_select = args.idx_vg if xcol == 'VG' else args.idx_vd
        idx_label = 'idx_vg' if xcol == 'VG' else 'idx_vd'
        selected_groups = []
        if groupcol in df.columns:
            for gv in sorted(df[groupcol].dropna().unique()):
                gdf = df[np.isclose(df[groupcol], gv, atol=1e-3)].copy()
                sg = select_sweep_segment(gdf, xcol, idx_select, idx_label, sort_after=True)
                if not sg.empty:
                    selected_groups.append(sg)
            df = pd.concat(selected_groups, ignore_index=False) if selected_groups else pd.DataFrame()
        else:
            df = select_sweep_segment(df, xcol, idx_select, idx_label, sort_after=True)
        # Transfer ID-VG is best previewed with log-current smoothing.
        # Output ID-VD should be previewed as a linear ID plot with linear-current smoothing.
        print("xcol=", xcol)
        if xcol == 'VD':
            local_args = argparse.Namespace(**vars(args))
            local_args.read_smooth_domain = 'linear'
            df_read = add_read_columns_grouped(df, xcol, groupcol, local_args)
            read_yscale = 'linear'
        else:
            df_read = add_read_columns_grouped(df, xcol, groupcol, args)
            read_yscale = 'log'
        if df_read.empty:
            print(f'WARNING: no readable data after preprocessing: {path}')
            continue
        read_tables[tag] = df_read
        print(f"\n{'='*20} READ/PREVIEW {tag.upper()} {'='*20}")
        print(f'File: {path}')
        print(f'Rows: {len(df_read)} | x={xcol} | group={groupcol if groupcol in df_read.columns else "none"}')
        print(f'Imin clipping floor = {args.Imin:.4e} A')
        print(f'read smoothing domain = {args.read_smooth_domain}')
        if groupcol in df_read.columns:
            for gv in sorted(df_read[groupcol].dropna().unique()):
                tmp = df_read[np.isclose(df_read[groupcol], gv, atol=1e-3)]
                read_summary.append({
                    'dataset': tag, 'file': path, 'xcol': xcol, 'groupcol': groupcol, 'group_value': gv,
                    'n_points': len(tmp), 'x_min': tmp[xcol].min(), 'x_max': tmp[xcol].max(),
                    'ID_abs_floor_min': tmp['ID_abs_floor'].min(), 'ID_abs_floor_max': tmp['ID_abs_floor'].max(),
                    'ID_smooth_min': tmp['ID_smooth'].min(), 'ID_smooth_max': tmp['ID_smooth'].max(),
                    'ID_smooth_log_min': tmp['ID_smooth_log'].min(), 'ID_smooth_log_max': tmp['ID_smooth_log'].max(),
                    'ID_smooth_linear_min': tmp['ID_smooth_linear'].min(), 'ID_smooth_linear_max': tmp['ID_smooth_linear'].max(),
                    'read_smooth_domain': tmp['read_smooth_domain'].iloc[0],
                    'savgol_window': tmp['savgol_window'].iloc[0],
                    'savgol_order': tmp['savgol_order'].iloc[0],
                    'n_savgol_used': int(tmp['savgol_used'].sum()),
                    'edge_raw_kept': bool(tmp['edge_raw_kept'].iloc[0]),
                })
                print(f'  {groupcol}={gv:8.4g} | n={len(tmp):4d} | {xcol}=({tmp[xcol].min():.4g}, {tmp[xcol].max():.4g}) | '
                      f'ID_smooth=({tmp["ID_smooth"].min():.4e}, {tmp["ID_smooth"].max():.4e}) | '
                      f'win={tmp["savgol_window"].iloc[0]}, order={tmp["savgol_order"].iloc[0]}, '
                      f'smoothed points={int(tmp["savgol_used"].sum())}/{len(tmp)}, edge_raw={tmp["edge_raw_kept"].iloc[0]}')
        print("  read_yscale=", read_yscale)                      
        figures.append(plot_read_data(df_read, xcol, groupcol, title, args, f'read_{tag}', yscale=read_yscale))
    return figures, read_tables, read_summary


def _normalize_tft_column_name(name):
    """測定CSVの列名を解析用の標準名へそろえます。

    詳細説明:
        例: VG(V) -> VG, Id -> ID。
        既存の解析コードは VG, VD, ID などの大文字列名を仮定しているため、
        読み込み直後にここで正規化します。
    引数:
        :param name: 正規化する列名。
        :type name: str
    戻り値:
        :returns: 標準化された列名。
        :rtype: str
    """
    s = str(name).strip().replace('\ufeff', '')
    if '(' in s:
        s = s.split('(', 1)[0]
    if '[' in s:
        s = s.split('[', 1)[0]
    return s.strip().upper()


def _to_numeric_dataframe(df):
    """全列を可能な範囲で数値化します。

    引数:
        :param df: 数値化するDataFrame。
        :type df: pandas.DataFrame
    戻り値:
        :returns: 全列が数値化されたDataFrame。
        :rtype: pandas.DataFrame
    """
    out = df.copy()
    for col in out.columns:
        out[col] = pd.to_numeric(out[col], errors='coerce')
    return out


def _float_meta(metadata, key):
    """4155/4156系CSVメタデータからfloat値を取り出します。

    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
        :param key: 取得するメタデータのキー。
        :type key: str
    戻り値:
        :returns: 取得されたfloat値。変換できない場合はNone。
        :rtype: float or None
    """
    vals = metadata.get(key)
    if not vals:
        return None
    try:
        return float(str(vals[0]).strip())
    except Exception:
        return None


def _int_meta(metadata, key):
    """4155/4156系CSVメタデータからint値を取り出します。

    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
        :param key: 取得するメタデータのキー。
        :type key: str
    戻り値:
        :returns: 取得されたint値。変換できない場合や有効な数値でない場合はNone。
        :rtype: int or None
    """
    val = _float_meta(metadata, key)
    if val is None or not np.isfinite(val):
        return None
    return int(round(val))


def _infer_smu_sweep_variable(metadata, sweep_role):
    """Primary/Secondary掃引に対応する電圧名を推定します。

    詳細説明:
        Keysight/Agilent 4155系CSVでは、通常
        Channel.VName = VD, VS, VG と Channel.Func = VAR2, CONST, VAR1
        のようなメタデータがあり、VAR1がPrimary、VAR2がSecondaryに対応します。
    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
        :param sweep_role: 掃引の役割（'Primary'または'Secondary'）。
        :type sweep_role: str
    戻り値:
        :returns: 推定された電圧名（例: 'VG', 'VD'）。推定できない場合はNone。
        :rtype: str or None
    """
    target = {'Primary': 'VAR1', 'Secondary': 'VAR2'}.get(sweep_role)
    if target is None:
        return None
    vnames = metadata.get('TestParameter.Channel.VName', [])
    funcs = metadata.get('TestParameter.Channel.Func', [])
    for vname, func in zip(vnames, funcs):
        if str(func).strip().upper() == target:
            return _normalize_tft_column_name(vname)
    # 実データで一番多い組み合わせへのフォールバック
    return 'VG' if sweep_role == 'Primary' else 'VD'


def _infer_sweep_values(metadata, role):
    """Primary/Secondary掃引の値リストをメタデータから推定します。

    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
        :param role: 掃引の役割（'Primary'または'Secondary'）。
        :type role: str
    戻り値:
        :returns: 推定された掃引値のリスト。
        :rtype: list[float]
    """
    prefix = f'TestParameter.Measurement.{role}'
    start = _float_meta(metadata, prefix + '.Start')
    stop = _float_meta(metadata, prefix + '.Stop')
    step = _float_meta(metadata, prefix + '.Step')
    count = _int_meta(metadata, prefix + '.Count')

    if role == 'Secondary':
        if start is None or step is None or count is None or count <= 0:
            return []
        return [start + i * step for i in range(count)]

    if start is None or stop is None or step is None or step == 0:
        return []
    n = int(round(abs((stop - start) / step))) + 1
    if n <= 0:
        return []
    if stop >= start:
        return [start + i * abs(step) for i in range(n)]
    return [start - i * abs(step) for i in range(n)]


def _infer_primary_branch_points(metadata):
    """Primary掃引1枝あたりの点数をメタデータから推定します。

    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
    戻り値:
        :returns: Primary掃引1枝あたりの点数。推定できない場合はNone。
        :rtype: int or None
    """
    vals = _infer_sweep_values(metadata, 'Primary')
    return len(vals) if len(vals) > 1 else None


def _is_double_primary_sweep(metadata):
    """Primary掃引がダブル掃引（往復掃引）であるか判定します。

    引数:
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
    戻り値:
        :returns: ダブル掃引である場合はTrue、そうでない場合はFalse。
        :rtype: bool
    """
    vals = metadata.get('TestParameter.Measurement.Primary.Locus', [])
    return bool(vals) and str(vals[0]).strip().lower() == 'double'


def _add_4155_inferred_columns(df, metadata):
    """4155系DataName/DataValue CSVに不足しがちな掃引列を補います。

    詳細説明:
        DataName/DataValueブロックにはPrimary変数、ID、IGだけが保存され、
        Secondary変数（今回の例ではVD）が各行に書かれない場合があります。
        その場合、メタデータのSecondary Start/Step/Countから各行のVDを復元します。
    引数:
        :param df: 処理対象のDataFrame。
        :type df: pandas.DataFrame
        :param metadata: メタデータを含む辞書。
        :type metadata: dict
    戻り値:
        :returns: 掃引列が補完されたDataFrame。
        :rtype: pandas.DataFrame
    """
    out = df.copy()
    if out.empty:
        return out

    primary_col = _infer_smu_sweep_variable(metadata, 'Primary')
    secondary_col = _infer_smu_sweep_variable(metadata, 'Secondary')
    half_n = _infer_primary_branch_points(metadata)
    n = len(out)

    if half_n is not None and half_n > 0:
        sweep_id = np.arange(n) // half_n
    elif primary_col in out.columns:
        x = out[primary_col].to_numpy(dtype=float)
        dx = np.diff(x)
        sign = np.sign(dx)
        for i in range(1, len(sign)):
            if sign[i] == 0:
                sign[i] = sign[i - 1]
        change = np.where(sign[1:] * sign[:-1] < 0)[0] + 1
        starts = [0] + (change + 1).tolist()
        sweep_id = np.zeros(n, dtype=int)
        for sid, a in enumerate(starts):
            b = starts[sid + 1] if sid + 1 < len(starts) else n
            sweep_id[a:b] = sid
    else:
        sweep_id = np.zeros(n, dtype=int)

    out['sweep_id'] = sweep_id.astype(int)

    directions = {}
    if primary_col in out.columns:
        for sid in sorted(out['sweep_id'].dropna().unique()):
            sub = out[out['sweep_id'] == sid]
            if sub.empty:
                continue
            x0 = sub[primary_col].iloc[0]
            x1 = sub[primary_col].iloc[-1]
            directions[sid] = 'forward' if x1 >= x0 else 'reverse'
        out['sweep_direction'] = out['sweep_id'].map(directions)

    secondary_values = _infer_sweep_values(metadata, 'Secondary')
    branches_per_secondary = 2 if _is_double_primary_sweep(metadata) else 1
    if secondary_col and secondary_values:
        values_by_sweep = {}
        for sid in sorted(out['sweep_id'].dropna().unique()):
            idx = int(sid) // branches_per_secondary
            values_by_sweep[int(sid)] = secondary_values[idx] if idx < len(secondary_values) else np.nan
        inferred = out['sweep_id'].map(values_by_sweep)
        # 列がない場合、またはDataName側の列が空の場合に補完。既存列がある場合はNaNのみ埋める。
        if secondary_col not in out.columns:
            out[secondary_col] = inferred
        else:
            out[secondary_col] = out[secondary_col].where(out[secondary_col].notna(), inferred)

    return out


def read_4155_dataname_datavalue_csv(filepath, encoding='utf-8-sig'):
    """Keysight/Agilent 4155系の DataName/DataValue CSV を読み込みます。

    詳細説明:
        戻り値は (df, metadata) です。DataValue行が見つからない場合は
        (None, metadata) を返し、通常CSV読み込みへフォールバックできるようにします。
    引数:
        :param filepath: 読み込むCSVファイルのパス。
        :type filepath: str
        :param encoding: ファイルの文字コード。デフォルトは'utf-8-sig'。
        :type encoding: str
    戻り値:
        :returns: データを含むDataFrameとメタデータの辞書のタプル。
                  DataValue行が見つからない場合は (None, metadata)。
        :rtype: tuple[pandas.DataFrame or None, dict]
    """
    metadata = {}
    columns = None
    data_rows = []

    with open(filepath, 'r', encoding=encoding, errors='replace', newline='') as f:
        reader = csv.reader(f)
        for row in reader:
            row = [str(v).strip() for v in row]
            if not row or all(v == '' for v in row):
                continue
            tag = row[0]
            if tag == 'DataName':
                columns = [_normalize_tft_column_name(v) for v in row[1:]]
            elif tag == 'DataValue':
                if columns is None:
                    raise ValueError('DataValue appeared before DataName.')
                vals = row[1:]
                if len(vals) < len(columns):
                    vals = vals + [''] * (len(columns) - len(vals))
                data_rows.append(vals[:len(columns)])
            else:
                if len(row) >= 2:
                    metadata[f'{row[0]}.{row[1]}'] = row[2:]

    if columns is None or not data_rows:
        return None, metadata

    df = pd.DataFrame(data_rows, columns=columns)
    df = _to_numeric_dataframe(df)
    df = _add_4155_inferred_columns(df, metadata)
    return df, metadata


def _read_text_lines_with_detected_encoding(filepath):
    """文字コードをゆるく推定してCSVを行単位で読み込みます。

    引数:
        :param filepath: 読み込むCSVファイルのパス。
        :type filepath: str
    戻り値:
        :returns: ファイルの行リストと推定された文字コードのタプル。
        :rtype: tuple[list[str], str]
    """
    with open(filepath, 'rb') as f:
        rawdata = f.read(50000)
    encoding = chardet.detect(rawdata)['encoding'] or 'utf-8-sig'
    with open(filepath, 'r', encoding=encoding, errors='replace') as f:
        lines = f.readlines()
    return lines, encoding


def detect_and_load(filepath, reverse_vg=False):
    """CSVファイルからTFT解析用データを自動検出して読み込みます。

    詳細説明:
        対応形式:
        1. 既存対応の「列ヘッダ行 + 数値データ行」形式
        2. Keysight/Agilent 4155系の DataName / DataValue 形式

        後者では、DataValueブロックにVDやVGなどのSecondary掃引列が明示されない場合でも、
        メタデータから VD または VG を復元して既存解析コードへ渡します。
    引数:
        :param filepath: 読み込むCSVファイルのパス。
        :type filepath: str
        :param reverse_vg: Trueの場合、VGの符号を反転します。pチャネルデータの前処理を意図しています。
        :type reverse_vg: bool
    戻り値:
        :returns: 読み込まれたデータを含むDataFrame。ファイルが見つからないかデータが読み込めない場合はNone。
        :rtype: pandas.DataFrame or None
    """
    if not os.path.exists(filepath):
        print(f'WARNING: file not found: {filepath}')
        return None

    lines, encoding = _read_text_lines_with_detected_encoding(filepath)

    # まず4155/4156系 DataName/DataValue 形式として読み込む。
    # この形式ではDataNameの次行がDataValueで始まるため、従来のヘッダ検出では拾えない。
    try:
        df_4155, metadata = read_4155_dataname_datavalue_csv(filepath, encoding=encoding)
    except Exception as exc:
        df_4155, metadata = None, {}
        print(f'WARNING: failed to parse DataName/DataValue block as 4155-style CSV: {exc}')

    if df_4155 is not None and {'VG', 'ID'}.issubset(df_4155.columns):
        df = df_4155.dropna(subset=['VG', 'ID']).copy()
        if reverse_vg and 'VG' in df.columns:
            df['VG'] = -df['VG']
        msg_cols = ', '.join(df.columns)
        print(f'INFO: loaded 4155-style DataName/DataValue CSV: rows={len(df)}, columns={msg_cols}')
        if 'VD' in df.columns:
            vals = sorted(pd.Series(df['VD']).dropna().unique())
            print('INFO: inferred/detected VD values: ' + ', '.join(f'{v:g}' for v in vals))
        return df

    # 従来形式: VG/IDを含むヘッダ行の直後から数値データが始まるCSV。
    data_list, header, data_start_idx = [], None, -1
    for i, line in enumerate(lines):
        parts = [p.strip() for p in line.split(',')]
        if not parts or parts[0] == '':
            continue
        upper_parts = [_normalize_tft_column_name(p) for p in parts]
        if 'VG' in upper_parts and 'ID' in upper_parts:
            if i + 1 < len(lines):
                try:
                    float([p.strip() for p in lines[i + 1].split(',')][0])
                    header = upper_parts
                    data_start_idx = i + 1
                    break
                except Exception:
                    continue

    if data_start_idx != -1:
        for line in lines[data_start_idx:]:
            parts = [p.strip() for p in line.split(',')]
            if parts and len(parts) >= len(header):
                try:
                    float(parts[0])
                    data_list.append(parts[:len(header)])
                except Exception:
                    continue

    if header is None or not data_list:
        print(f'WARNING: no VG/ID data block found: {filepath}')
        return None

    df = pd.DataFrame(data_list, columns=header).apply(pd.to_numeric, errors='coerce').dropna(subset=['VG', 'ID'])
    if reverse_vg and 'VG' in df.columns:
        df['VG'] = -df['VG']
    return df


def valid_savgol_window(n, requested, order):
    """指定されたデータ点数に対して有効な奇数のSavitzky-Golayウィンドウ長を返します。

    詳細説明:
        サビツキー・ゴレイフィルターのウィンドウ長は、多項式の次数よりも大きく、
        かつ奇数である必要があります。また、データ点数を超えることはできません。
        この関数は、与えられた制約 (n, requested, order) に基づいて、
        これらの条件を満たす最小かつ有効な奇数のウィンドウ長を計算して返します。
    引数:
        :param n: データ点の総数。
        :type n: int
        :param requested: 要求されたSavitzky-Golayウィンドウ長。
        :type requested: int
        :param order: Savitzky-Golayフィルターの多項式の次数。
        :type order: int
    戻り値:
        :returns: 有効な奇数のSavitzky-Golayウィンドウ長。
        :rtype: int
    """
    win = max(int(requested), order + 2)
    if win % 2 == 0:
        win += 1
    if win > n:
        win = n if n % 2 == 1 else n - 1
    if win <= order:
        win = order + 2 if (order + 2) % 2 == 1 else order + 3
    return max(3, win)


def nearest_row_by_current(df, target_id):
    """データフレーム内で指定された目標電流 (target_id) に最も近いID_smoothを持つ行を検索します。

    詳細説明:
        データフレーム df の ID_smooth 列を基に、目標電流値 target_id との絶対差が最小となる行を特定します。
        見つかった行のインデックスとその行全体のデータを返します。
        データフレームが空の場合、または ID_smooth 列が存在しない場合は、Noneを返します。
    引数:
        :param df: 検索対象のDataFrame。ID_smooth列が必要です。
        :type df: pandas.DataFrame
        :param target_id: 検索する目標電流値 [A]。
        :type target_id: float
    戻り値:
        :returns: 目標電流に最も近い行のインデックスと、その行のPandas Series。
                  データフレームが空の場合は (None, None) を返します。
        :rtype: tuple[int or None, pandas.Series or None]
    """
    if df.empty:
        return None, None
    idx = (df['ID_smooth'] - target_id).abs().idxmin()
    return idx, df.loc[idx]



def add_sweep_index(df, col, idx_col):
    """指定された列の掃引方向の変化に基づいて、掃引セグメントのインデックスを追加します。

    詳細説明:
        データフレームの指定された列 (col) の値の連続的な変化を分析し、
        掃引方向が反転するたびに掃引セグメントのインデックスを1つ増やします。
        これにより、多方向掃引データ（例: VGの往復掃引）を個別のセグメントに分割できます。
        結果のインデックスは新しい列 (idx_col) としてデータフレームに追加されます。
    引数:
        :param df: 処理対象のDataFrame。
        :type df: pandas.DataFrame
        :param col: 掃引方向を検出する基準となる列名（例: 'VG', 'VD'）。
        :type col: str
        :param idx_col: 生成される掃引インデックス列の名前。
        :type idx_col: str
    戻り値:
        :returns: 掃引セグメントインデックス列が追加されたDataFrame。
        :rtype: pandas.DataFrame
    """
    out = df.copy()
    vals = out[col].to_numpy(dtype=float)
    seg = []
    current_seg = 0
    prev_sign = 0
    prev_val = np.nan
    for v in vals:
        sign = 0
        if np.isfinite(v) and np.isfinite(prev_val):
            dv = v - prev_val
            if abs(dv) > 1e-12:
                sign = 1 if dv > 0 else -1
        if sign != 0:
            if prev_sign != 0 and sign != prev_sign:
                current_seg += 1
            prev_sign = sign
        seg.append(current_seg)
        prev_val = v
    out[idx_col] = seg
    return out


def select_sweep_segment(df, col, idx, label, sort_after=True, verbose=False):
    """掃引方向の変化インデックスに基づいて、DataFrameから特定の掃引セグメントを選択します。

    詳細説明:
        この関数は、add_sweep_index を使用して、指定された列 (col) の掃引セグメントインデックスを計算し、
        その後、要求されたインデックス (idx) に対応するセグメントのみをフィルタリングして返します。
        要求されたインデックスが存在しない場合、利用可能な最初のセグメントがフォールバックとして選択されます。
        オプションで、選択されたセグメントを col 列でソートできます。
    引数:
        :param df: 処理対象のDataFrame。
        :type df: pandas.DataFrame or None
        :param col: 掃引セグメントを識別する基準となる列名（例: 'VG', 'VD'）。
        :type col: str
        :param idx: 選択する掃引セグメントのインデックス。
        :type idx: int
        :param label: 警告メッセージなどで使用するインデックスのラベル（例: 'idx_vg'）。
        :type label: str
        :param sort_after: Trueの場合、選択後に col 列でDataFrameをソートします。デフォルトはTrue。
        :type sort_after: bool
        :param verbose: Trueの場合、選択されたセグメントに関する詳細情報を出力します。デフォルトはFalse。
        :type verbose: bool
    戻り値:
        :returns: 選択された掃引セグメントを含むDataFrame。元のDataFrameがNoneまたは空の場合、
                  または col 列がない場合は、元のDataFrameまたはNoneを返します。
        :rtype: pandas.DataFrame or None
    """
    if df is None or df.empty or col not in df.columns:
        return df.copy() if df is not None else df
    idx_col = f'idx_{col.lower()}_sweep'
    work = add_sweep_index(df, col, idx_col)
    available = sorted(work[idx_col].dropna().unique())
    if idx not in available:
        print(f'WARNING: requested {label}={idx}, but available {label} values are {available}; fallback to first segment.')
        idx = available[0] if available else 0
    selected = work[work[idx_col] == idx].copy()
    if sort_after and not selected.empty:
        selected = selected.sort_values(col)
    if verbose and not selected.empty:
        print(f'  selected {label}={idx}: n={len(selected)}, {col}=({selected[col].min():.4g}, {selected[col].max():.4g})')
    return selected

def build_analysis_points(res):
    """伝達特性解析の主要点をロングフォーマットで返します。

    詳細説明:
        res 辞書からVth, Smin, mu_max などの主要な解析ポイントを抽出し、
        Excelサマリーシートに適したリスト形式で提供します。
        各ポイントは、そのVG, ID, 導関数、移動度などの詳細な情報とともに辞書として格納されます。
    引数:
        :param res: 伝達特性解析結果を含む辞書。df (解析対象のDataFrame) および
                    idx_ で始まるキー (各ポイントのインデックス) が必要です。
        :type res: dict
    戻り値:
        :returns: 各解析ポイントのデータを格納した辞書のリスト。
        :rtype: list[dict]
    """
    keys = [
        ('Vth_lin_ID_max_slope', res.get('idx_lin_slope_max'), 'Linear-region Vth from tangent at max d(ID)/dVG'),
        ('Vth_sat_sqrtID_max_slope', res.get('idx_sat_slope_max'), 'Saturation-region Vth from tangent at max d(sqrt(ID))/dVG'),
        ('Smin_max_log_slope', res.get('idx_smin'), 'Minimum S = inverse max dlog10(ID)/dVG in subthreshold region'),
        ('S_at_ID_S', res.get('idx_ids'), 'S at the current closest to ID_S'),
        ('mu_lin_max', res.get('idx_mu_lin_max'), 'Maximum linear-field-effect mobility muFE'),
        ('mu_sat_max', res.get('idx_mu_sat_max'), 'Maximum saturation mobility profile muSAT_prof'),
        ('Ioff', res.get('idx_ioff'), 'Off-current reference point'),
    ]
    rows = []
    df = res['df']
    for name, idx, note in keys:
        if idx is None or (isinstance(idx, float) and np.isnan(idx)) or idx not in df.index:
            continue
        r = df.loc[idx]
        rows.append({
            'VD': res['VD'],
            'point': name,
            'VG': r.get('VG', np.nan),
            'ID': r.get('ID', np.nan),
            'ID_smooth': r.get('ID_smooth', np.nan),
            'logID': r.get('logID', np.nan),
            'sqrtID': r.get('sqrtID', np.nan),
            'dlogID_dVG': r.get('dlogID', np.nan),
            'S_V_per_dec': r.get('S_val', np.nan),
            'gm_A_per_V': r.get('gm', np.nan),
            'dsqrtID_dVG': r.get('dsqrtID', np.nan),
            'mu_lin_cm2_Vs': r.get('muFE', np.nan),
            'mu_sat_cm2_Vs': r.get('muSAT_prof', np.nan),
            'note': note,
        })
    return rows




def region_check_saturation(vd, vg, vth, factor=3.0):
    """飽和領域動作の条件をチェックします。

    詳細説明:
        トランジスタが飽和領域で動作しているかどうかを判断するために、
        指定されたVG、VD、Vth、および安全係数 (factor) を使用して
        VD >= factor * (VG - Vth) の条件を確認します。
        VG-Vthが正でない場合、または条件が満たされない場合は警告が生成されます。
    引数:
        :param vd: ドレイン電圧 [V]。
        :type vd: float
        :param vg: ゲート電圧 [V]。
        :type vg: float
        :param vth: 閾値電圧 [V]。
        :type vth: float
        :param factor: 飽和領域を保証するための安全係数。デフォルトは3.0。
        :type factor: float
    戻り値:
        :returns: 飽和領域チェックの結果を含む辞書。
                  ok (bool), warning (str), VD_abs (float), VG_minus_Vth (float),
                  ratio (float), criterion (str) を含みます。
        :rtype: dict
    """
    vd_eff = abs(float(vd))
    overdrive = float(vg) - float(vth)
    overdrive_pos = max(overdrive, 0.0)
    criterion = f"VD >= {factor:g}*(VG-Vth)"
    if overdrive_pos <= 0:
        return {'ok': False, 'warning': 'VG - Vth <= 0; mobility point is not clearly in the on-state.',
                'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': np.nan, 'criterion': criterion}
    ratio = vd_eff / overdrive_pos
    ok = ratio >= factor
    warning = '' if ok else f"Possible non-saturation: VD/(VG-Vth)={ratio:.3g} < {factor:g}."
    return {'ok': ok, 'warning': warning, 'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': ratio, 'criterion': criterion}


def region_check_linear(vd, vg, vth, factor=3.0):
    """線形領域動作の条件をチェックします。

    詳細説明:
        トランジスタが線形領域で動作しているかどうかを判断するために、
        指定されたVG、VD、Vth、および安全係数 (factor) を使用して
        VG - Vth >= factor * VD の条件を確認します。
        VDが正でない場合、VG-Vthが負の場合、または条件が満たされない場合は警告が生成されます。
    引数:
        :param vd: ドレイン電圧 [V]。
        :type vd: float
        :param vg: ゲート電圧 [V]。
        :type vg: float
        :param vth: 閾値電圧 [V]。
        :type vth: float
        :param factor: 線形領域を保証するための安全係数。デフォルトは3.0。
        :type factor: float
    戻り値:
        :returns: 線形領域チェックの結果を含む辞書。
                  ok (bool), warning (str), VD_abs (float), VG_minus_Vth (float),
                  ratio (float), criterion (str) を含みます。
        :rtype: dict
    """
    vd_eff = abs(float(vd))
    overdrive = float(vg) - float(vth)
    criterion = f"VG-Vth >= {factor:g}*VD"
    if vd_eff <= 0:
        return {'ok': False, 'warning': 'VD <= 0; linear-region mobility is not meaningful.',
                'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': np.nan, 'criterion': criterion}
    ratio = overdrive / vd_eff
    ok = (overdrive > 0) and (ratio >= factor)
    if overdrive <= 0:
        warning = 'VG - Vth <= 0; mobility point is below threshold.'
    elif not ok:
        warning = f"Possible non-linear-region point: (VG-Vth)/VD={ratio:.3g} < {factor:g}."
    else:
        warning = ''
    return {'ok': ok, 'warning': warning, 'VD_abs': vd_eff, 'VG_minus_Vth': overdrive, 'ratio': ratio, 'criterion': criterion}



def classify_transfer_region(linear_check, saturation_check):
    """線形/飽和/中間領域の推奨を返します。

    詳細説明:
        線形領域チェックと飽和領域チェックの結果に基づいて、
        現在解析中の動作点が線形、飽和、またはどちらでもない中間領域のどれに属するかを分類します。
        分類結果と、それに関連する推奨/警告メッセージを返します。
    引数:
        :param linear_check: region_check_linear 関数からの結果辞書。
        :type linear_check: dict
        :param saturation_check: region_check_saturation 関数からの結果辞書。
        :type saturation_check: dict
    戻り値:
        :returns: 推奨される領域タイプと、関連する警告/説明メッセージのタプル。
        :rtype: tuple[str, str]
    """
    lin_ok = bool(linear_check.get('ok', False))
    sat_ok = bool(saturation_check.get('ok', False))
    if lin_ok and not sat_ok:
        return 'linear', 'Use linear-region mobility/Vth. Condition VG-Vth >= factor*VD is satisfied.'
    if sat_ok and not lin_ok:
        return 'saturation', 'Use saturation-region mobility/Vth. Condition VD >= factor*(VG-Vth) is satisfied.'
    if lin_ok and sat_ok:
        return 'ambiguous', 'Both linear and saturation checks passed at their own extraction points; inspect plots.'
    return 'intermediate', 'WARNING: neither linear nor saturation condition is sufficiently satisfied.'
def analyze_vg_core(df_full, vd_val, args, cox):
    """特定VDスライスのID-VGから、線形法と飽和法の両方でVth/移動度を抽出します。

    詳細説明:
        ID-VGデータに基づいて、線形領域（最大相互コンダクタンス gm から）と
        飽和領域（最大 d(sqrt(ID))/dVg から）の閾値電圧 (Vth) と移動度を抽出します。
        サブスレッショルドスイング (S) やオフ電流 (Ioff) も計算します。
        抽出されたポイントの動作領域チェックも行い、推奨される抽出方法を提示します。
    引数:
        :param df_full: 全てのVG-ID測定データを含むDataFrame。
        :type df_full: pandas.DataFrame
        :param vd_val: 解析対象のドレイン電圧 [V]。
        :type vd_val: float
        :param args: コマンドライン引数を含むオブジェクト。smooth_npoints, lsq_order, Imin,
                     L, W, region_factor, ID_S 属性を使用します。
        :type args: argparse.Namespace
        :param cox: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
        :type cox: float
    戻り値:
        :returns: 特定VDスライスにおける詳細な解析結果を含む辞書。データが不十分な場合はNone。
        :rtype: dict or None
    """
    df_vd = df_full[np.isclose(df_full['VD'], vd_val, atol=1e-3)].copy()
    df_vd = select_sweep_segment(df_vd, 'VG', args.idx_vg, 'idx_vg', sort_after=True)
    if len(df_vd) < 5:
        return None

    win = valid_savgol_window(len(df_vd), args.smooth_npoints, args.lsq_order)
    vg_step = df_vd['VG'].diff().dropna().median()
    if not np.isfinite(vg_step) or vg_step == 0:
        vg_step = 1.0

    df_vd['ID_abs_floor'] = df_vd['ID'].abs().clip(lower=args.Imin)
    df_vd['ID_smooth'] = savgol_filter(df_vd['ID_abs_floor'], win, 1)
    df_vd['ID_smooth'] = np.clip(df_vd['ID_smooth'], args.Imin, None)
    df_vd['logID'] = np.log10(df_vd['ID_smooth'])
    df_vd['sqrtID'] = np.sqrt(df_vd['ID_smooth'])
    df_vd['dlogID'] = savgol_filter(df_vd['logID'], win, args.lsq_order, deriv=1, delta=vg_step)
    df_vd['gm'] = savgol_filter(df_vd['ID_smooth'], win, args.lsq_order, deriv=1, delta=vg_step)
    df_vd['dsqrtID'] = savgol_filter(df_vd['sqrtID'], win, args.lsq_order, deriv=1, delta=vg_step)

    vd_eff = max(abs(vd_val), 1e-12)
    df_vd['muFE'] = (args.L / (args.W * cox * vd_eff)) * df_vd['gm']
    df_vd['muSAT_prof'] = (2 * args.L / (args.W * cox)) * (df_vd['dsqrtID'] ** 2)
    df_vd['S_val'] = np.where(df_vd['dlogID'] > 1e-6, 1.0 / df_vd['dlogID'], np.nan)

    gm_valid = df_vd['gm'].replace([np.inf, -np.inf], np.nan).where(df_vd['gm'] > 0)
    idx_lin_slope_max = gm_valid.idxmax() if gm_valid.notna().any() else None
    if idx_lin_slope_max is not None:
        row_lin = df_vd.loc[idx_lin_slope_max]
        v_lin_slope = row_lin['VG']
        vth_lin = v_lin_slope - row_lin['ID_smooth'] / row_lin['gm'] if row_lin['gm'] > 0 else np.nan
        mu_lin_slope = row_lin['muFE']
        id_lin_slope = row_lin['ID_smooth']
        gm_max = row_lin['gm']
    else:
        v_lin_slope = vth_lin = mu_lin_slope = id_lin_slope = gm_max = np.nan

    dsqrt_valid = df_vd['dsqrtID'].replace([np.inf, -np.inf], np.nan).where(df_vd['dsqrtID'] > 0)
    idx_sat_slope_max = dsqrt_valid.idxmax() if dsqrt_valid.notna().any() else None
    if idx_sat_slope_max is not None:
        row_sat = df_vd.loc[idx_sat_slope_max]
        v_sat_slope = row_sat['VG']
        vth_sat = v_sat_slope - row_sat['sqrtID'] / row_sat['dsqrtID'] if row_sat['dsqrtID'] > 0 else np.nan
        mu_sat_slope = row_sat['muSAT_prof']
        id_sat_slope = row_sat['ID_smooth']
        sqrtID_sat_slope = row_sat['sqrtID']
        dsqrtID_max = row_sat['dsqrtID']
    else:
        v_sat_slope = vth_sat = mu_sat_slope = id_sat_slope = sqrtID_sat_slope = dsqrtID_max = np.nan

    idx_mu_lin_max = df_vd['muFE'].replace([np.inf, -np.inf], np.nan).idxmax()
    idx_mu_sat_max = df_vd['muSAT_prof'].replace([np.inf, -np.inf], np.nan).idxmax()
    mu_lin_max = df_vd.loc[idx_mu_lin_max, 'muFE']
    mu_sat_max = df_vd.loc[idx_mu_sat_max, 'muSAT_prof']
    VG_mu_lin_max = df_vd.loc[idx_mu_lin_max, 'VG']
    ID_mu_lin_max = df_vd.loc[idx_mu_lin_max, 'ID_smooth']
    VG_mu_sat_max = df_vd.loc[idx_mu_sat_max, 'VG']
    ID_mu_sat_max = df_vd.loc[idx_mu_sat_max, 'ID_smooth']

    lin_region = region_check_linear(vd_eff, VG_mu_lin_max, vth_lin, args.region_factor)
    sat_region = region_check_saturation(vd_eff, VG_mu_sat_max, vth_sat, args.region_factor)
    recommended_method, recommended_warning = classify_transfer_region(lin_region, sat_region)
    if recommended_method == 'linear':
        vth_rec, mu_rec = vth_lin, mu_lin_max
    elif recommended_method == 'saturation':
        vth_rec, mu_rec = vth_sat, mu_sat_max
    else:
        vth_rec = np.nan
        mu_rec = np.nan

    vth_for_subthreshold = vth_rec
    if not np.isfinite(vth_for_subthreshold):
        vth_for_subthreshold = vth_sat if np.isfinite(vth_sat) else vth_lin

    off_mask = (df_vd['VG'] < vth_for_subthreshold - 5) & (df_vd['dlogID'].abs() < 0.1)
    if any(off_mask):
        ioff = df_vd[off_mask]['ID_smooth'].mean()
        idx_ioff = df_vd.loc[off_mask, 'ID_smooth'].idxmin()
    else:
        idx_ioff = df_vd['ID_smooth'].idxmin()
        ioff = df_vd.loc[idx_ioff, 'ID_smooth']

    mask_s = (df_vd['VG'] < vth_for_subthreshold) & (df_vd['ID_smooth'] > ioff * 3) & (df_vd['dlogID'] > 0.05)
    df_s = df_vd[mask_s]
    s_min, vg_smin, id_smin, von, idx_smin = np.nan, np.nan, np.nan, np.nan, None
    if not df_s.empty:
        idx_smin = df_s['S_val'].idxmin()
        s_min = df_s.loc[idx_smin, 'S_val']
        vg_smin = df_s.loc[idx_smin, 'VG']
        id_smin = df_s.loc[idx_smin, 'ID_smooth']
        von = vg_smin + s_min * (np.log10(args.Imin) - np.log10(id_smin))

    idx_ids, row_ids = nearest_row_by_current(df_vd, args.ID_S)
    legacy_vth = vth_rec if np.isfinite(vth_rec) else vth_sat
    legacy_mu = mu_rec if np.isfinite(mu_rec) else mu_sat_max

    res = {
        'VD': vd_val,
        'Vth': legacy_vth,
        'mu_max': legacy_mu,
        'mu_type': recommended_method,
        'recommended_method': recommended_method,
        'recommended_warning': recommended_warning,
        'recommended_Vth': vth_rec,
        'recommended_mu': mu_rec,
        'Vth_lin': vth_lin,
        'v_lin_slope': v_lin_slope,
        'ID_lin_slope': id_lin_slope,
        'gm_max': gm_max,
        'mu_lin_slope': mu_lin_slope,
        'mu_lin_max': mu_lin_max,
        'VG_mu_lin_max': VG_mu_lin_max,
        'ID_mu_lin_max': ID_mu_lin_max,
        'lin_region_ok': lin_region['ok'],
        'lin_region_warning': lin_region['warning'],
        'lin_region_ratio_VGminusVth_over_VD': lin_region['ratio'],
        'lin_region_criterion': lin_region['criterion'],
        'lin_region_VG_minus_Vth': lin_region['VG_minus_Vth'],
        'Vth_sat': vth_sat,
        'v_sat_slope': v_sat_slope,
        'ID_sat_slope': id_sat_slope,
        'sqrtID_sat_slope': sqrtID_sat_slope,
        'dsqrtID_max': dsqrtID_max,
        'mu_sat_slope': mu_sat_slope,
        'mu_sat_max': mu_sat_max,
        'VG_mu_sat_max': VG_mu_sat_max,
        'ID_mu_sat_max': ID_mu_sat_max,
        'sat_region_ok': sat_region['ok'],
        'sat_region_warning': sat_region['warning'],
        'sat_region_ratio_VD_over_VGminusVth': sat_region['ratio'],
        'sat_region_criterion': sat_region['criterion'],
        'sat_region_VG_minus_Vth': sat_region['VG_minus_Vth'],
        'Ioff': ioff,
        'Smin': s_min,
        'VG_Smin': vg_smin,
        'id_smin': id_smin,
        'Von': von,
        'VG_ID_S': row_ids['VG'] if row_ids is not None else np.nan,
        'ID_S_target': args.ID_S,
        'Imin_target': args.Imin,
        'ID_S_val': row_ids['ID_smooth'] if row_ids is not None else np.nan,
        'S_ID_S': row_ids['S_val'] if row_ids is not None else np.nan,
        'savgol_window': win,
        'vg_step': vg_step,
        'idx_vg_selected': args.idx_vg,
        'idx_lin_slope_max': idx_lin_slope_max,
        'idx_sat_slope_max': idx_sat_slope_max,
        'idx_slope_max': idx_sat_slope_max,
        'idx_smin': idx_smin,
        'idx_ids': idx_ids,
        'idx_mu_lin_max': idx_mu_lin_max,
        'idx_mu_sat_max': idx_mu_sat_max,
        'idx_mu_max': idx_mu_lin_max if recommended_method == 'linear' else idx_mu_sat_max,
        'idx_ioff': idx_ioff,
        'df': df_vd,
    }
    res['analysis_points'] = build_analysis_points(res)
    return res


def annotate_vline(ax, x, label, ymin=None, ymax=None):
    """Matplotlibのプロットに垂直線とテキストアノテーションを追加します。

    詳細説明:
        指定されたX座標 (x) に垂直線 (axvline) を引き、
        その線の近くにテキストラベル (label) を回転させて配置します。
        Y軸の範囲 (ymin, ymax) が指定されていない場合、現在のプロットのY軸範囲が使用されます。
    引数:
        :param ax: プロット対象のMatplotlib Axesオブジェクト。
        :type ax: matplotlib.axes.Axes
        :param x: 垂直線を引くX座標。
        :type x: float
        :param label: 垂直線の横に表示するテキストラベル。
        :type label: str
        :param ymin: 垂直線が描画されるY軸の下限。Noneの場合、現在のY軸の下限が使用されます。
        :type ymin: float or None
        :param ymax: 垂直線が描画されるY軸の上限。Noneの場合、現在のY軸の上限が使用されます。
        :type ymax: float or None
    戻り値:
        :returns: なし
        :rtype: None
    """
    ax.axvline(x, linestyle=':', linewidth=1, alpha=0.8)
    if ymin is None or ymax is None:
        ymin, ymax = ax.get_ylim()
    ax.text(x, ymax, label, rotation=90, va='top', ha='right', fontsize=8)


def plot_idvg_quad(res, args):
    """伝達特性解析結果を 2x2 サブプロットとして可視化します。

    詳細説明:
        ID-VGデータに基づいて計算された様々なデバイス特性（ID-VG曲線、線形抽出、飽和抽出、移動度プロファイル）を
        2x2のサブプロットとして表示します。各プロットには、Vth、Smin、移動度最大値などの主要な解析ポイントが
        アノテーションとして表示されます。プロットはファイルに保存することも可能です。
    引数:
        :param res: analyze_vg_core 関数によって生成された、単一VDスライスの解析結果を含む辞書。
        :type res: dict
        :param args: コマンドライン引数を含むオブジェクト。save_plot および plot_dir 属性を使用します。
        :type args: argparse.Namespace
    戻り値:
        :returns: 生成されたMatplotlibのFigureオブジェクト。
        :rtype: matplotlib.figure.Figure
    """
    df = res['df']
    fig, axes = plt.subplots(2, 2, figsize=figsize_idvg_quad)
    fig.suptitle(f"n-ch Transfer Analysis (VD = {res['VD']} V)", fontsize=14)

    ax = axes[0, 0]
    ax.plot(df['VG'], df['ID_smooth'], '-', lw=2, label='smoothed ID')
    ax.set_yscale('log')
    ax.set_xlabel(r'$V_G$ [V]')
    ax.set_ylabel(r'$I_D$ [A]')
    ax.set_title('ID-VG / subthreshold')
    ax.grid(True, alpha=0.15)
    if np.isfinite(res['Smin']):
        v_p = np.linspace(res['Von'], res['VG_Smin'], 30)
        id_p = 10 ** (np.log10(res['id_smin']) + (v_p - res['VG_Smin']) / res['Smin'])
        ax.plot(v_p, id_p, '--', label=f"Smin={res['Smin']:.3g} V/dec")
        ax.scatter(res['VG_Smin'], res['id_smin'], marker='o', s=55, label=f"Smin VG={res['VG_Smin']:.2f}")
        ax.scatter(res['Von'], args.Imin, marker='x', s=70, label=f"Von={res['Von']:.2f} V")
        annotate_vline(ax, res['VG_Smin'], 'Smin')
    if np.isfinite(res['VG_ID_S']):
        ax.scatter(res['VG_ID_S'], res['ID_S_val'], marker='s', s=50, label=f"ID_S S={res['S_ID_S']:.3g}")
        annotate_vline(ax, res['VG_ID_S'], 'ID_S')
    if np.isfinite(res['Vth_lin']):
        annotate_vline(ax, res['Vth_lin'], f"Vth_lin={res['Vth_lin']:.2f}")
    if np.isfinite(res['Vth_sat']):
        annotate_vline(ax, res['Vth_sat'], f"Vth_sat={res['Vth_sat']:.2f}")
    ax.legend(fontsize='x-small')

    ax = axes[0, 1]
    ax.plot(df['VG'], df['ID_smooth'], '-', lw=2, label=r'$I_D$')
    if np.isfinite(res['Vth_lin']) and np.isfinite(res['gm_max']):
        vg_fit = np.linspace(min(res['Vth_lin'], df['VG'].min()), df['VG'].max(), 80)
        id_fit = res['gm_max'] * (vg_fit - res['Vth_lin'])
        ax.plot(vg_fit, id_fit, '--', label=f"linear tangent: Vth={res['Vth_lin']:.2f} V")
        ax.scatter(res['v_lin_slope'], res['ID_lin_slope'], marker='^', s=60,
                   label=f"max gm VG={res['v_lin_slope']:.2f}")
        annotate_vline(ax, res['Vth_lin'], 'Vth_lin')
        annotate_vline(ax, res['v_lin_slope'], 'max gm')
    txt = (f"linear check at mu_lin max:\n"
           f"(VG-Vth)/VD={res['lin_region_ratio_VGminusVth_over_VD']:.3g}\n"
           f"criterion: {res['lin_region_criterion']}")
    if not res['lin_region_ok']:
        txt = 'WARNING\n' + txt
    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
            bbox=dict(boxstyle='round', alpha=0.15))
    ax.set_xlabel(r'$V_G$ [V]')
    ax.set_ylabel(r'$I_D$ [A]')
    ax.set_title('Linear extraction: ID-VG tangent')
    ax.legend(fontsize='x-small')
    ax.grid(True, alpha=0.15)

    ax = axes[1, 0]
    ax.plot(df['VG'], df['sqrtID'], '-', lw=2, label=r'$\sqrt{I_D}$')
    if np.isfinite(res['Vth_sat']) and np.isfinite(res['dsqrtID_max']):
        vg_fit = np.linspace(min(res['Vth_sat'], df['VG'].min()), df['VG'].max(), 80)
        ax.plot(vg_fit, res['dsqrtID_max'] * (vg_fit - res['Vth_sat']), '--',
                label=f"sat tangent: Vth={res['Vth_sat']:.2f} V")
        ax.scatter(res['v_sat_slope'], res['sqrtID_sat_slope'], marker='^', s=60,
                   label=f"max slope VG={res['v_sat_slope']:.2f}")
        annotate_vline(ax, res['Vth_sat'], 'Vth_sat')
        annotate_vline(ax, res['v_sat_slope'], 'max slope')
    txt = (f"sat check at mu_sat max:\n"
           f"VD/(VG-Vth)={res['sat_region_ratio_VD_over_VGminusVth']:.3g}\n"
           f"criterion: {res['sat_region_criterion']}")
    if not res['sat_region_ok']:
        txt = 'WARNING\n' + txt
    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
            bbox=dict(boxstyle='round', alpha=0.15))
    ax.set_xlabel(r'$V_G$ [V]')
    ax.set_ylabel(r'$\sqrt{I_D}$ [A$^{0.5}$]')
    ax.set_title('Saturation extraction: sqrt(ID)-VG tangent')
    ax.legend(fontsize='x-small')
    ax.grid(True, alpha=0.15)

    ax = axes[1, 1]
    ax.plot(df['VG'], df['muFE'], '-', lw=2, label=r'$\mu_{lin}$ from $dI_D/dV_G$')
    ax.plot(df['VG'], df['muSAT_prof'], '--', lw=2, label=r'$\mu_{sat}$ from $d\sqrt{I_D}/dV_G$')
    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}")
    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}")
    if np.isfinite(res['v_lin_slope']):
        ax.scatter(res['v_lin_slope'], res['mu_lin_slope'], marker='^', s=55, label=f"mu_lin_slope={res['mu_lin_slope']:.3g}")
    if np.isfinite(res['v_sat_slope']):
        ax.scatter(res['v_sat_slope'], res['mu_sat_slope'], marker='v', s=55, label=f"mu_sat_slope={res['mu_sat_slope']:.3g}")
    if np.isfinite(res['Vth_lin']):
        annotate_vline(ax, res['Vth_lin'], 'Vth_lin')
    if np.isfinite(res['Vth_sat']):
        annotate_vline(ax, res['Vth_sat'], 'Vth_sat')
    txt = f"recommended: {res['recommended_method']}\n{res['recommended_warning']}"
    if res['recommended_method'] in ('intermediate', 'ambiguous'):
        txt = 'WARNING\n' + txt
    ax.text(0.02, 0.98, txt, transform=ax.transAxes, va='top', ha='left', fontsize=8,
            bbox=dict(boxstyle='round', alpha=0.15))
    ax.set_xlabel(r'$V_G$ [V]')
    ax.set_ylabel(r'Mobility [cm$^2$/Vs]')
    ax.set_title('Mobility profiles: linear and saturation formulas')
    ax.legend(fontsize='x-small')
    ax.grid(True, alpha=0.15)

    fig.tight_layout()
    if args.save_plot:
        os.makedirs(args.plot_dir, exist_ok=True)
        fname = plot_path(args, f"idvg_VD_{res['VD']:g}".replace('.', 'p'))
        fig.savefig(fname, dpi=200, bbox_inches='tight')
        print(f'  plot saved: {fname}')
    return fig


plot_idvg_triple = plot_idvg_quad


def print_transfer_report(res):
    """伝達特性解析結果をコンソールに整形して出力します。

    詳細説明:
        analyze_vg_core 関数によって生成された伝達特性の解析結果辞書 (res) から、
        線形領域と飽和領域の閾値電圧 (Vth)、移動度 (mu)、サブスレッショルドスイング (Smin)、
        オフ電流 (Ioff) などの主要なデバイスパラメータを抽出し、
        コンソールに分かりやすい形式で詳細なレポートを出力します。
        また、各移動度抽出点における動作領域の適合性チェック結果も表示します。
    引数:
        :param res: 伝達特性解析結果を含む辞書。
        :type res: dict
    戻り値:
        :returns: なし
        :rtype: None
    """
    print(f"VD = {res['VD']:8.3g} V  (n-ch assumption)")
    print("  Linear-region extraction from ID-VG")
    print(f"    Vth_lin          : {res['Vth_lin']:10.4g} V")
    print(f"    tangent point    : VG={res['v_lin_slope']:10.4g} V, ID={res['ID_lin_slope']:10.4e} A, "
          f"gm={res['gm_max']:10.4e} A/V")
    print(f"    mu_lin_slope     : {res['mu_lin_slope']:10.4g} cm^2/Vs")
    print(f"    mu_lin_max       : {res['mu_lin_max']:10.4g} cm^2/Vs at VG={res['VG_mu_lin_max']:10.4g} V, "
          f"ID={res['ID_mu_lin_max']:10.4e} A")
    print(f"    linear check     : {res['lin_region_criterion']}; VG_mu_lin_max - Vth_lin = "
          f"{res['lin_region_VG_minus_Vth']:.4g} V, (VG-Vth)/VD = "
          f"{res['lin_region_ratio_VGminusVth_over_VD']:.4g}, OK={res['lin_region_ok']}")
    if not res['lin_region_ok']:
        print(f"    WARNING          : {res['lin_region_warning']}")

    print("  Saturation-region extraction from sqrt(ID)-VG")
    print(f"    Vth_sat          : {res['Vth_sat']:10.4g} V")
    print(f"    tangent point    : VG={res['v_sat_slope']:10.4g} V, ID={res['ID_sat_slope']:10.4e} A, "
          f"sqrtID={res['sqrtID_sat_slope']:10.4e}, d sqrtID/dVG={res['dsqrtID_max']:10.4e}")
    print(f"    mu_sat_slope     : {res['mu_sat_slope']:10.4g} cm^2/Vs")
    print(f"    mu_sat_max       : {res['mu_sat_max']:10.4g} cm^2/Vs at VG={res['VG_mu_sat_max']:10.4g} V, "
          f"ID={res['ID_mu_sat_max']:10.4e} A")
    print(f"    saturation check : {res['sat_region_criterion']}; VG_mu_sat_max - Vth_sat = "
          f"{res['sat_region_VG_minus_Vth']:.4g} V, VD/(VG-Vth) = "
          f"{res['sat_region_ratio_VD_over_VGminusVth']:.4g}, OK={res['sat_region_ok']}")
    if not res['sat_region_ok']:
        print(f"    WARNING          : {res['sat_region_warning']}")

    print(f"  Recommended method : {res['recommended_method']}")
    print(f"  Recommendation     : {res['recommended_warning']}")
    if res['recommended_method'] in ('intermediate', 'ambiguous'):
        print("  WARNING            : mobility/Vth representative value is not recommended for this VD slice.")
    else:
        print(f"  Recommended Vth    : {res['recommended_Vth']:10.4g} V")
        print(f"  Recommended mu     : {res['recommended_mu']:10.4g} cm^2/Vs")

    print(f"  Ioff               : {res['Ioff']:10.4e} A")
    if np.isfinite(res['Smin']):
        print(f"  Smin               : {res['Smin']:10.4g} V/dec at VG={res['VG_Smin']:10.4g} V, "
              f"ID={res['id_smin']:10.4e} A")
        print(f"  Von from Smin      : {res['Von']:10.4g} V  at Imin={res['Imin_target']:10.4e} A")
    else:
        print("  Smin               : not found")
    print(f"  S at ID_S          : {res['S_ID_S']:10.4g} V/dec at target ID={res['ID_S_target']:10.4e} A, "
          f"nearest VG={res['VG_ID_S']:10.4g} V, ID={res['ID_S_val']:10.4e} A")
    print(f"  Savitzky-Golay     : window={res['savgol_window']}")
    print('-' * 72)



def interpolate_id_at_vd(df_vg, vd_target):
    """単一のVG出力曲線内で、要求されたVD値におけるIDの絶対値を線形補間します。

    詳細説明:
        与えられたデータフレーム (df_vg) が特定のVGでのID-VDデータを含んでいると仮定し、
        vd_target における abs(ID) の絶対値を線形補間によって推定します。
        vd_target が測定範囲外の場合、またはデータが不足している場合は np.nan を返します。
        補間はVDとID列をソートした後に行われます。
    引数:
        :param df_vg: 単一のVG値におけるID-VDデータを含むDataFrame。VDとID列が必要です。
        :type df_vg: pandas.DataFrame
        :param vd_target: 補間したい目標ドレイン電圧 [V]。
        :type vd_target: float
    戻り値:
        :returns: vd_target における補間された abs(ID) の絶対値。データが利用できない場合や範囲外の場合は np.nan。
        :rtype: float
    """
    if df_vg.empty or not np.isfinite(vd_target):
        return np.nan
    tmp = df_vg[["VD", "ID"]].dropna().sort_values("VD")
    if tmp.empty:
        return np.nan
    vd = tmp["VD"].to_numpy(dtype=float)
    id_abs = np.abs(tmp["ID"].to_numpy(dtype=float))
    if vd_target < np.nanmin(vd) or vd_target > np.nanmax(vd):
        return np.nan
    return float(np.interp(vd_target, vd, id_abs))

def analyze_idvd_logic(df, args, cox):
    """TFTの出力特性 (ID-VD) を解析し、デバイスの線形領域特性と移動度を評価します。

    詳細説明:
        入力されたID-VDデータフレームから、以下の解析を実行します:
        1. 最大のVD値における伝達特性スライス (df_full) を用いて、参照閾値電圧 (vth_ref) を抽出します。
        2. 各VG値におけるID-VD曲線に対して、線形領域（低いVD）での伝達コンダクタンス (gd) を線形回帰で計算します。
        3. 低いVDスライスにおけるID-VGデータから、相互コンダクタンス (gm) とS値を計算します。
        4. 抽出されたgdとgm、および vth_ref を用いて、実効移動度 (mu_eff) と線形移動度 (mu_lin) を導出します。
        5. 各移動度抽出点に対して線形領域条件のチェック (region_check_linear) を行い、警告情報を記録します。
        6. 解析結果の概要をコンソールに出力し、出力曲線と移動度プロットを生成します。
    引数:
        :param df: ID-VDデータを含むDataFrame。VG, VD, ID列が必要です。
        :type df: pandas.DataFrame
        :param args: コマンドライン引数を含むオブジェクト。
                     idx_vg, idx_vd, Imin, L, W, region_factor, save_plot, plot_dir 属性を使用します。
        :type args: argparse.Namespace
        :param cox: 単位面積あたりのゲート酸化膜容量 [F/cm^2]。
        :type cox: float
    戻り値:
        :returns: 各VGにおける解析結果の辞書リストと、生成されたMatplotlibのFigureオブジェクトのタプル。
                  有効なデータがない場合は ([], None) を返します。
        :rtype: tuple[list[dict], matplotlib.figure.Figure or None]
    """
    max_vd = df['VD'].max()
    res_v_ref = analyze_vg_core(df, max_vd, args, cox)
    vth_ref = res_v_ref['Vth'] if res_v_ref else 0.0
    positive_vd = sorted(df[df['VD'] > 0]['VD'].unique())
    if not positive_vd:
        print('WARNING: no positive VD data found for n-ch output analysis.')
        return [], None
    low_vd = positive_vd[0]
    df_low = df[np.isclose(df['VD'], low_vd, atol=1e-3)].copy()
    df_low = select_sweep_segment(df_low, 'VG', args.idx_vg, 'idx_vg', sort_after=True)
    df_low['gm'] = df_low['ID'].abs().diff() / df_low['VG'].diff()
    df_low['logID'] = np.log10(df_low['ID'].abs().clip(lower=args.Imin))
    df_low['S_val'] = 1.0 / (df_low['logID'].diff() / df_low['VG'].diff())

    summary, unique_vgs = [], sorted(df['VG'].unique())
    fig, axes = plt.subplots(1, 2, figsize=(15, 6))
    print(f"\n{'=' * 20} ID-VD OUTPUT ANALYSIS {'=' * 20}")
    print(f"Using Vth_ref = {vth_ref:.4g} V from VD={max_vd:.4g} V transfer-like slice")
    print(f"Low-VD slice for gm = {low_vd:.4g} V")
    print(f"Selected sweep segments: idx_vg={args.idx_vg}, idx_vd={args.idx_vd}")

    for vg in unique_vgs:
        df_vg_all = df[np.isclose(df['VG'], vg, atol=1e-3)].copy()
        df_vg = select_sweep_segment(df_vg_all, 'VD', args.idx_vd, 'idx_vd', sort_after=True)
        if len(df_vg) < 3:
            continue
        fit_df = df_vg[df_vg['VD'] <= low_vd + 2]
        if len(fit_df) < 2:
            continue
        gd = LinearRegression().fit(fit_df['VD'].values.reshape(-1, 1), fit_df['ID'].abs().values).coef_[0]
        gm_row = df_low[np.isclose(df_low['VG'], vg, atol=1e-3)]
        gm = gm_row['gm'].values[0] if not gm_row.empty else 0.0
        denom = max(0.1, vg - vth_ref)
        mue = (gd * args.L) / (args.W * cox * denom)
        mul = (gm * args.L) / (args.W * cox * low_vd)
        s_val = gm_row['S_val'].values[0] if not gm_row.empty else np.nan
        lin_region = region_check_linear(low_vd, vg, vth_ref, args.region_factor)
        summary.append({'VG': vg, 'gd': gd, 'gm': gm, 'mu_eff': mue, 'mu_lin': mul, 'S_val': s_val,
                        'Vth_ref': vth_ref, 'low_VD_for_gm': low_vd, 'fit_VD_max_for_gd': low_vd + 2,
                        'linear_region_ok': lin_region['ok'],
                        'linear_region_warning': lin_region['warning'],
                        'linear_region_ratio_VGminusVth_over_VD': lin_region['ratio'],
                        'linear_region_criterion': lin_region['criterion'],
                        'VG_minus_Vth': lin_region['VG_minus_Vth'],
                        'idx_vd_selected': args.idx_vd,
                        'idx_vg_selected_for_gm': args.idx_vg})
        axes[0].plot(df_vg['VD'], df_vg['ID'].abs(), alpha=0.45)
        print(f"  VG={vg:8.3g} V | gd={gd:10.4e} A/V | gm={gm:10.4e} A/V | "
              f"mu_eff={mue:10.4g} | mu_lin={mul:10.4g} | S={s_val:10.4g} | "
              f"linear OK={lin_region['ok']} ratio={(lin_region['ratio'] if np.isfinite(lin_region['ratio']) else np.nan):.4g}")
        if not lin_region['ok']:
            print(f"    WARNING: {lin_region['warning']}  criterion: {lin_region['criterion']}")

    sdf = pd.DataFrame(summary)
    if not sdf.empty:
        # Saturation-boundary markers on output curves: VD_sat = VG - Vth_ref.
        vd_sat_list, id_sat_list = [], []
        for vg in sdf['VG'].to_numpy(dtype=float):
            vd_sat = vg - vth_ref
            df_vg_all = df[np.isclose(df['VG'], vg, atol=1e-3)].copy()
            df_vg = select_sweep_segment(df_vg_all, 'VD', args.idx_vd, 'idx_vd', sort_after=True)
            id_sat = interpolate_id_at_vd(df_vg, vd_sat)
            vd_sat_list.append(vd_sat)
            id_sat_list.append(id_sat)
        sdf['VD_sat_boundary'] = vd_sat_list
        sdf['ID_at_VD_sat_boundary'] = id_sat_list
        for i, row in sdf.iterrows():
            summary[i]['VD_sat_boundary'] = row['VD_sat_boundary']
            summary[i]['ID_at_VD_sat_boundary'] = row['ID_at_VD_sat_boundary']

        good_boundary = sdf[np.isfinite(sdf['ID_at_VD_sat_boundary']) & (sdf['VD_sat_boundary'] >= 0)]
        if not good_boundary.empty:
            axes[0].plot(good_boundary['VD_sat_boundary'], good_boundary['ID_at_VD_sat_boundary'],
                         '^--', lw=1.2, ms=6, label=r'saturation boundary $V_D=V_G-V_{th}$')

        axes[0].set_xlabel(r'$V_D$ [V]')
        axes[0].set_ylabel(r'$|I_D|$ [A] (linear scale)')
        axes[0].set_title('Output curves')
        axes[0].legend(fontsize='x-small')
        axes[0].grid(True, alpha=0.15)
        axes[1].plot(sdf['VG'], sdf['mu_eff'], 'o-', label='mu_eff from gd')
        axes[1].plot(sdf['VG'], sdf['mu_lin'], 's--', label='mu_lin from gm')
        axes[1].axvline(vth_ref, linestyle=':', linewidth=1, label='Vth_ref')
        bad = sdf[~sdf['linear_region_ok']] if 'linear_region_ok' in sdf.columns else pd.DataFrame()
        if not bad.empty:
            axes[1].scatter(bad['VG'], bad['mu_lin'], marker='x', s=75, label='linear-region warning')
        axes[1].text(0.02, 0.98, f"linear check: VG-Vth >= {args.region_factor:g}*VD_low",
                     transform=axes[1].transAxes, va='top', ha='left', fontsize=8,
                     bbox=dict(boxstyle='round', alpha=0.15))
        axes[1].set_xlabel(r'$V_G$ [V]')
        axes[1].set_ylabel(r'Mobility [cm$^2$/Vs]')
        axes[1].legend()
        axes[1].grid(True, alpha=0.15)
    fig.tight_layout()
    if args.save_plot:
        os.makedirs(args.plot_dir, exist_ok=True)
        fname = plot_path(args, 'idvd_output_analysis')
        fig.savefig(fname, dpi=200, bbox_inches='tight')
        print(f'  plot saved: {fname}')
    return summary, fig


def make_summary_dataframe(t_summary):
    """伝達特性解析結果のリストから、Excel出力用のPandas DataFrameを作成します。

    詳細説明:
        t_summary リスト内の各解析結果辞書から、Excelサマリーシートに適した
        主要なパラメータを抽出します。
        元のデータフレーム (df) や詳細な分析ポイント (analysis_points)、
        および内部的なインデックス (idx_ で始まるキー) は除外されます。
        結果はPandas DataFrameとして整形され、Excelへのエクスポートに適した形式で提供されます。
    引数:
        :param t_summary: 各VDスライスでの伝達特性解析結果を含む辞書のリスト。
        :type t_summary: list[dict]
    戻り値:
        :returns: 主要な解析結果パラメータを含むPandas DataFrame。
        :rtype: pandas.DataFrame
    """
    rows = []
    for res in t_summary:
        row = {k: v for k, v in res.items() if k not in ('df', 'analysis_points') and not str(k).startswith('idx_')}
        rows.append(row)
    return pd.DataFrame(rows)


def run_analysis(args):
    """スクリプトのメイン実行ロジックをカプセル化します。

    詳細説明:
        この関数は、コマンドライン引数をパースし、ゲート酸化膜容量 (Cox) を計算します。
        選択された解析モード (read, analyze_idvg, analyze_idvd, all) に応じて、
        対応するデータ読み込み、解析、プロット生成の関数を呼び出します。
        すべての結果（生データ、平滑化データ、解析サマリー、詳細分析ポイント）は集約され、
        最終的に単一のExcelレポートファイルとPNGプロットとして出力されます。
        プロットは --show_plot が指定されている場合、インタラクティブに表示されます。
    引数:
        :param args: コマンドライン引数を含む argparse.Namespace オブジェクト。
        :type args: argparse.Namespace
    戻り値:
        :returns: なし。解析結果はファイルシステムに出力されます。
        :rtype: None
    """
    # args is prepared by main(): output paths and logging are already configured.
    cox = calculate_cox(args.dg, args.epsg)
    t_summary, t_data, t_points, o_summary = [], [], [], []
    read_tables, read_summary = {}, []
    figures = []

    print('TFT analysis mode: n-channel dedicated')
    print(f'Cox = {cox:.6e} F/cm^2  (dg={args.dg:g} nm, epsg={args.epsg:g})')
    if args.reverse_vg:
        print('VG sign reversal is enabled after data loading.')

    if args.mode == 'read':
        figs, read_tables, read_summary = run_read_mode(args)
        figures.extend(figs)

    if args.mode in ['analyze_idvg', 'all']:
        df_vg = detect_and_load(args.infile_vg, reverse_vg=args.reverse_vg)
        if df_vg is not None:
            print(f"\n{'=' * 20} DETAILED TRANSFER ANALYSIS {'=' * 20}")
            for vd in sorted(df_vg['VD'].unique()):
                if vd <= 0:
                    print(f'Skip VD={vd:g} V because n-ch analysis expects positive VD.')
                    continue
                res = analyze_vg_core(df_vg, vd, args, cox)
                if res:
                    print_transfer_report(res)
                    t_summary.append(res)
                    res['df']['VD_label'] = vd
                    t_data.append(res['df'])
                    t_points.extend(res['analysis_points'])
                    figures.append(plot_idvg_triple(res, args))

    if args.mode in ['analyze_idvd', 'all']:
        df_vd = detect_and_load(args.infile_vd, reverse_vg=args.reverse_vg)
        if df_vd is not None:
            o_summary, fig = analyze_idvd_logic(df_vd, args, cox)
            if fig is not None:
                figures.append(fig)

    with pd.ExcelWriter(args.out_excel) as writer:
        pd.DataFrame([{
            'analysis_assumption': 'n-channel TFT; positive VD; use --reverse_vg for p-channel preprocessing',
            'L_um': args.L,
            'W_um': args.W,
            'dg_nm': args.dg,
            'epsg': args.epsg,
            'Cox_F_per_cm2': cox,
            'ID_S_A': args.ID_S,
            'Imin_A': args.Imin,
            'smooth_npoints_requested': args.smooth_npoints,
            'lsq_order': args.lsq_order,
            'region_factor': args.region_factor,
            'idx_vg': args.idx_vg,
            'idx_vd': args.idx_vd,
            'plot_dir': args.plot_dir if args.save_plot else '',
        }]).to_excel(writer, sheet_name='Analysis_Settings', index=False)
        if read_summary:
            pd.DataFrame(read_summary).to_excel(writer, sheet_name='Read_Summary', index=False)
        for tag, rdf in read_tables.items():
            sheet = 'Read_Data_' + tag.upper()
            rdf.to_excel(writer, sheet_name=sheet[:31], index=False)
        if t_summary:
            make_summary_dataframe(t_summary).to_excel(writer, sheet_name='Summary_Transfer', index=False)
        if t_points:
            pd.DataFrame(t_points).to_excel(writer, sheet_name='Analysis_Points', index=False)
        if t_data:
            pd.concat(t_data).to_excel(writer, sheet_name='Data_Transfer_VG_Dep', index=False)
        if o_summary:
            pd.DataFrame(o_summary).to_excel(writer, sheet_name='Data_Output_VG_Dep', index=False)
    print(f'Report saved to {args.out_excel}')

    # Excel and PNG files are saved before this interactive display.
    if args.show_plot and figures:
        plt.show()
    else:
        for fig in figures:
            plt.close(fig)


def main():
    """プログラムのエントリポイントです。

    詳細説明:
        この関数は、コマンドライン引数をパースし、Excel、PNG、ログファイルの
        出力パスを準備します。標準出力と標準エラー出力をコンソールとログファイルの両方に
        出力するように設定した後、run_analysis 関数を呼び出して主要な解析ロジックを実行します。
        解析終了後、Tee 機能は解除されます。
    戻り値:
        :returns: なし
        :rtype: None
    """
    args = get_args()
    args = prepare_output_paths(args)
    os.makedirs(args.output_dir, exist_ok=True)
    original_stdout = sys.stdout
    original_stderr = sys.stderr
    with open(args.log_file, 'w', encoding='utf-8') as log_fp:
        sys.stdout = Tee(original_stdout, log_fp)
        sys.stderr = Tee(original_stderr, log_fp)
        try:
            print(f'Log file: {args.log_file}')
            print(f'Output directory: {args.output_dir}')
            print(f'Output stem: {args.output_stem}')
            run_analysis(args)
        finally:
            sys.stdout = original_stdout
            sys.stderr = original_stderr

if __name__ == '__main__':
    main()