#!/usr/bin/env python3 """Tests for analyze_TFT_refactored.py using artificial TFT data. This file can be used in two ways. 1. Normal pytest execution with the default seed and noise level:: pytest -q -s test_analyze_TFT_refactored.py 2. Standalone execution with configurable seed and noise level:: python test_analyze_TFT_refactored.py --seed 12345 --noise-level 0.005 The standalone mode internally calls pytest after setting environment variables. It also prints a human-readable description of each test case by default. The target script is assumed to be placed next to this test file, but it can be changed by --target-script or the TFT_TARGET_SCRIPT environment variable. Artificial-data cases --------------------- Case A: Ideal noiseless data Saturation-region data follow Id = Ioff + A_sat * max(Vg - Vth, 0)^2. Linear-region data follow Id = Ioff + A_lin * max(Vg - Vth, 0). These data are used for strict numerical checks of Vth and mobility. Case B: Noisy but reproducible data The same model is used, but multiplicative Gaussian noise is added with a fixed random seed. These tests check reproducibility and that the analysis does not break. Because the maximum-slope method is intentionally sensitive to noise, these tests do not require close agreement with the true values. Case C: Edge/error data Missing columns, too-short data, low-current data that never reach the SS target current, and zero/negative current points are checked. """ from __future__ import annotations import argparse import importlib.util import os import sys from pathlib import Path from typing import NamedTuple # Use a non-interactive backend before importing the target script. os.environ.setdefault("MPLBACKEND", "Agg") import numpy as np import pandas as pd import pytest DEFAULT_SEED = 12345 DEFAULT_NOISE_LEVEL = 0.005 # Known parameters for artificial data. TRUE_VTH = 2.0 TRUE_SS = 0.4 # V/dec, used only in subthreshold artificial data. TRUE_IOFF = 1.0e-15 TRUE_MU_SAT = 10.0e-4 # 10 cm^2/Vs expressed as m^2/Vs. TRUE_MU_FE = 8.0e-4 # 8 cm^2/Vs expressed as m^2/Vs. VDS_SAT = 10.0 VDS_LIN = 0.1 VG_MIN = -5.0 VG_MAX = 15.0 N_POINTS = 201 class ArtificialData(NamedTuple): """Container for saturation and linear-region artificial Id-Vg data.""" sat: pd.DataFrame lin: pd.DataFrame def runtime_seed() -> int: """Return the seed used by tests.""" return int(os.environ.get("TFT_TEST_SEED", DEFAULT_SEED)) def runtime_noise_level() -> float: """Return the relative noise level used by Case B tests.""" return float(os.environ.get("TFT_TEST_NOISE_LEVEL", DEFAULT_NOISE_LEVEL)) def load_target_module(): """Dynamically import the target analysis script from a file path.""" default_target = Path(__file__).with_name("analyze_TFT_refactored.py") target_from_argv = None if __name__ == "__main__": # In standalone mode, the target script has to be known before argparse # runs because this module imports the target script at import time. for i, arg in enumerate(sys.argv): if arg == "--target-script" and i + 1 < len(sys.argv): target_from_argv = sys.argv[i + 1] break if arg.startswith("--target-script="): target_from_argv = arg.split("=", 1)[1] break target_path = Path( os.environ.get("TFT_TARGET_SCRIPT", target_from_argv or str(default_target)) ).resolve() if not target_path.exists(): raise FileNotFoundError( f"Target script was not found: {target_path}\n" "Set TFT_TARGET_SCRIPT or run this test next to analyze_TFT_refactored.py." ) spec = importlib.util.spec_from_file_location("analyze_TFT_refactored_under_test", target_path) if spec is None or spec.loader is None: raise ImportError(f"Could not create import specification for {target_path}") module = importlib.util.module_from_spec(spec) # dataclass needs sys.modules[__module__] to exist during dynamic import. sys.modules[spec.name] = module spec.loader.exec_module(module) return module tft = None if __name__ == "__main__" else load_target_module() TEST_DETAILS: dict[str, dict[str, object]] = { "test_case_a_ideal_data_recovers_vth_and_mobility": { "case": "Case A", "title": "理想データでしきい値電圧と移動度を再現できるか", "purpose": "ノイズなしの人工TFTデータを使い、解析式に対応する既知パラメータを正しく回収できるか確認します。", "data": [ f"Vth={TRUE_VTH:g} V, muSAT={TRUE_MU_SAT * 1.0e4:g} cm^2/Vs, " f"muFE={TRUE_MU_FE * 1.0e4:g} cm^2/Vs", f"Vg={VG_MIN:g}..{VG_MAX:g} V, 点数={N_POINTS}", ], "checks": [ "SAT/LINのVthが真値に近い", "muSAT/muFEが真値に近い", "Ionが正の値になる", ], }, "test_case_b_noisy_data_are_reproducible": { "case": "Case B", "title": "ノイズ付きデータがseed固定で再現できるか", "purpose": "乱数seedを固定したとき、同じノイズ付き人工データが完全に再生成できることを確認します。", "data": [ "multiplicative Gaussian noise", "seedは --seed または TFT_TEST_SEED で指定", "noise_levelは --noise-level または TFT_TEST_NOISE_LEVEL で指定", ], "checks": [ "SATデータのId配列が一致", "LINデータのId配列が一致", ], }, "test_case_b_noisy_data_do_not_break_analysis": { "case": "Case B", "title": "ノイズ付きデータでも解析が破綻しないか", "purpose": "最大傾き法がノイズに敏感であることを前提に、厳密一致ではなく有限値・正値・範囲内かを確認します。", "data": [ "Case Bと同じノイズ付き人工データ", "seed/noise_levelは実行時に変更可能", ], "checks": [ "SAT/LINのVthが有限値", "VthがVg範囲内", "mobilityとIonが正の値", ], }, "test_cli_creates_expected_output_files": { "case": "CLI/output", "title": "コマンドライン処理で期待ファイルが生成されるか", "purpose": "人工データをExcelに保存し、main()経由で解析して、CSV・PNG・ログが作られるか確認します。", "data": [ "pytestのtmp_pathに一時Excel入力を作成", "出力先も一時ディレクトリを使用", ], "checks": [ "main()の終了コードが0", "解析CSV・summary CSV・log CSV・PNGが存在", "出力ファイルが空でない", "summaryに必要列がある", ], }, "test_case_c_missing_required_columns_raise_value_error": { "case": "Case C", "title": "必須列がない入力で明確にValueErrorになるか", "purpose": "Id列などが欠けた不正入力に対し、曖昧に失敗せず、分かりやすい例外を出すか確認します。", "data": [ "Vg, Vdのみを持ちId列がないDataFrame", ], "checks": [ "SAT解析でValueError", "LIN解析でValueError", "メッセージに missing required columns を含む", ], }, "test_case_c_short_data_raise_value_error_for_vth": { "case": "Case C", "title": "点数不足データでVth計算が明確に失敗するか", "purpose": "rolling slopeを計算できない短いデータに対し、原因が分かる例外を返すか確認します。", "data": [ "3点のみのDataFrame", "rolling_window=7", ], "checks": [ "SAT解析でValueError", "LIN解析でValueError", "メッセージに No valid slope values を含む", ], }, "test_case_c_low_current_sets_ss_and_von_to_nan": { "case": "Case C", "title": "ターゲット電流未達でSS/VonがNaNになるか", "purpose": "低電流のままでtarget_currentに到達しない場合、無理にSS/Vonを計算しないことを確認します。", "data": [ "全点が target_current の 1e-3 倍", ], "checks": [ "SATのSS/VonがNaN", "LINのSS/VonがNaN", ], }, "test_case_c_zero_and_negative_currents_are_ignored_for_log_and_sqrt": { "case": "Case C", "title": "ゼロ・負電流がlog/sqrt処理で除外されるか", "purpose": "Id<=0の点をlogId/rootId計算に使わず、NaNとして扱えるか確認します。", "data": [ "人工SATデータの一部を 0, -1e-12, -1 に置換", ], "checks": [ "該当点のId_rm/logId/rootIdがNaN", "残りのデータでmobilityが正に求まる", ], }, "test_case_c_missing_input_file_returns_nonzero_exit_code": { "case": "Case C", "title": "入力ファイル欠損時に非ゼロ終了コードを返すか", "purpose": "CLI実行時にExcel入力が存在しない場合、正常終了と誤認しない終了コードになるか確認します。", "data": [ "存在しないlin/sat Excelパス", ], "checks": [ "main()の終了コードが0以外", ], }, } def show_test_details_enabled() -> bool: """Return whether human-readable test descriptions should be printed. This function intentionally uses only an environment variable so that the test file works as a normal single-file pytest test. Pytest hook functions and custom options are not reliable when they are defined only inside a test module; they are normally placed in conftest.py or a plugin. """ return os.environ.get("TFT_TEST_SHOW_DETAILS", "1") not in { "0", "false", "False", "no", "NO" } def _print_line(message: str = "") -> None: """Print one line immediately. With ``pytest -s`` or ``--capture=no``, these lines are shown in the console. Without ``-s``, pytest captures them and usually shows them only when a test fails. """ print(message, flush=True) def _print_test_detail(test_name: str) -> None: """Print the purpose, data, and checks for one test function.""" if not show_test_details_enabled(): return detail = TEST_DETAILS.get(test_name) if detail is None: return title = f"{detail['case']} | {detail['title']}" _print_line() _print_line("-" * 78) _print_line(title) _print_line("-" * 78) _print_line(f"目的: {detail['purpose']}") data_lines = detail.get("data", []) if data_lines: _print_line("データ条件:") for line in data_lines: _print_line(f" - {line}") check_lines = detail.get("checks", []) if check_lines: _print_line("確認項目:") for line in check_lines: _print_line(f" - {line}") @pytest.fixture(scope="session", autouse=True) def _print_test_plan_once(): """Print the whole test-plan header once per pytest session. This autouse fixture is discovered even when this file is run directly by pytest, unlike pytest hook functions placed in an ordinary test module. """ if show_test_details_enabled(): target_path = Path( os.environ.get( "TFT_TARGET_SCRIPT", Path(__file__).with_name("analyze_TFT_refactored.py"), ) ).resolve() _print_line() _print_line("=" * 78) _print_line("TFT解析テスト: 実行内容") _print_line("=" * 78) _print_line(f"対象スクリプト: {target_path}") _print_line(f"seed: {runtime_seed()}") _print_line(f"noise_level: {runtime_noise_level()}") _print_line("テスト分類: Case A=理想データ, Case B=ノイズ/再現性, Case C=異常系・エッジケース") yield @pytest.fixture(autouse=True) def _print_each_test_detail(request: pytest.FixtureRequest): """Print test details before each test and a compact result after it.""" test_name = request.node.name.split("[")[0] _print_test_detail(test_name) try: yield except BaseException: if show_test_details_enabled(): _print_line(f"結果: [FAIL] {test_name}") raise else: if show_test_details_enabled(): _print_line(f"結果: [PASS] {test_name}") def make_vg_grid() -> np.ndarray: """Create the common Vg grid used by artificial transfer curves.""" return np.linspace(VG_MIN, VG_MAX, N_POINTS) def apply_relative_noise(values: np.ndarray, noise_level: float, rng: np.random.Generator) -> np.ndarray: """Apply multiplicative Gaussian noise and keep current values positive. Args: values: Clean current values. noise_level: Standard deviation of relative Gaussian noise. For example, 0.005 means approximately 0.5% relative noise. rng: NumPy random-number generator. Returns: Positive noisy current values. """ if noise_level <= 0: return values.copy() factor = 1.0 + rng.normal(loc=0.0, scale=noise_level, size=len(values)) # Keep the artificial current positive. This avoids testing negative-current # handling in Case B; that is handled separately in Case C. factor = np.clip(factor, 0.01, None) return np.clip(values * factor, 1.0e-20, None) def make_sat_current(vg: np.ndarray, config, noise_level: float = 0.0, seed: int = DEFAULT_SEED) -> np.ndarray: """Generate artificial saturation-region Id-Vg current. For Vg < Vth, a simple subthreshold exponential is used. For Vg >= Vth, the ideal square-law saturation model is used. """ a_sat = config.channel_width * config.cox * TRUE_MU_SAT / (2.0 * config.channel_length) id_sub = TRUE_IOFF * 10.0 ** ((vg - TRUE_VTH) / TRUE_SS) id_on = TRUE_IOFF + a_sat * np.maximum(vg - TRUE_VTH, 0.0) ** 2 clean = np.where(vg < TRUE_VTH, id_sub, id_on) return apply_relative_noise(clean, noise_level, np.random.default_rng(seed)) def make_lin_current(vg: np.ndarray, config, noise_level: float = 0.0, seed: int = DEFAULT_SEED) -> np.ndarray: """Generate artificial linear-region Id-Vg current. For Vg < Vth, a simple subthreshold exponential is used. For Vg >= Vth, the low-Vd linear-region model is used. """ a_lin = config.channel_width / config.channel_length * config.cox * TRUE_MU_FE * VDS_LIN id_sub = TRUE_IOFF * 10.0 ** ((vg - TRUE_VTH) / TRUE_SS) id_on = TRUE_IOFF + a_lin * np.maximum(vg - TRUE_VTH, 0.0) clean = np.where(vg < TRUE_VTH, id_sub, id_on) return apply_relative_noise(clean, noise_level, np.random.default_rng(seed + 1)) def make_artificial_data(config, noise_level: float = 0.0, seed: int = DEFAULT_SEED) -> ArtificialData: """Create paired saturation and linear-region artificial Id-Vg DataFrames.""" vg = make_vg_grid() sat = pd.DataFrame( { "Vg": vg, "Vd": np.full_like(vg, VDS_SAT), "Id": make_sat_current(vg, config, noise_level=noise_level, seed=seed), } ) lin = pd.DataFrame( { "Vg": vg, "Vd": np.full_like(vg, VDS_LIN), "Id": make_lin_current(vg, config, noise_level=noise_level, seed=seed), } ) return ArtificialData(sat=sat, lin=lin) def make_low_current_data(config) -> ArtificialData: """Create data that never reach the target current used for SS/Von.""" vg = make_vg_grid() low_id = np.full_like(vg, config.target_current * 1.0e-3, dtype=float) sat = pd.DataFrame({"Vg": vg, "Vd": np.full_like(vg, VDS_SAT), "Id": low_id}) lin = pd.DataFrame({"Vg": vg, "Vd": np.full_like(vg, VDS_LIN), "Id": low_id}) return ArtificialData(sat=sat, lin=lin) def write_excel_inputs(data: ArtificialData, directory: Path) -> tuple[Path, Path]: """Write artificial input Excel files and return (lin_path, sat_path).""" lin_path = directory / "IdVg-Vd0.1.xlsx" sat_path = directory / "IdVg-Vd10.xlsx" data.lin.to_excel(lin_path, index=False) data.sat.to_excel(sat_path, index=False) return lin_path, sat_path # ----------------------------------------------------------------------------- # Case A: ideal data # ----------------------------------------------------------------------------- def test_case_a_ideal_data_recovers_vth_and_mobility() -> None: """Ideal data should recover the known Vth and mobility values.""" config = tft.AnalysisConfig() data = make_artificial_data(config, noise_level=0.0, seed=runtime_seed()) sat_result = tft.analyze_idvg_sat(data.sat, config) lin_result = tft.analyze_idvg_lin(data.lin, config) assert sat_result.vth == pytest.approx(TRUE_VTH, abs=0.05) assert lin_result.vth == pytest.approx(TRUE_VTH, abs=0.05) assert sat_result.mobility == pytest.approx(TRUE_MU_SAT, rel=0.02) assert lin_result.mobility == pytest.approx(TRUE_MU_FE, rel=0.02) assert sat_result.ion > 0 assert lin_result.ion > 0 # ----------------------------------------------------------------------------- # Case B: noisy reproducible data # ----------------------------------------------------------------------------- def test_case_b_noisy_data_are_reproducible() -> None: """Noisy artificial data should be exactly reproducible for a fixed seed.""" config = tft.AnalysisConfig() seed = runtime_seed() noise_level = runtime_noise_level() data1 = make_artificial_data(config, noise_level=noise_level, seed=seed) data2 = make_artificial_data(config, noise_level=noise_level, seed=seed) np.testing.assert_allclose(data1.sat["Id"].to_numpy(), data2.sat["Id"].to_numpy()) np.testing.assert_allclose(data1.lin["Id"].to_numpy(), data2.lin["Id"].to_numpy()) def test_case_b_noisy_data_do_not_break_analysis() -> None: """Noisy data should still produce finite positive summary values. The original maximum-slope method can be very sensitive to noise, so this test intentionally checks robustness rather than strict agreement with the true parameters. """ config = tft.AnalysisConfig() data = make_artificial_data(config, noise_level=runtime_noise_level(), seed=runtime_seed()) sat_result = tft.analyze_idvg_sat(data.sat, config) lin_result = tft.analyze_idvg_lin(data.lin, config) assert np.isfinite(sat_result.vth) assert np.isfinite(lin_result.vth) assert VG_MIN <= sat_result.vth <= VG_MAX assert VG_MIN <= lin_result.vth <= VG_MAX assert sat_result.mobility > 0 assert lin_result.mobility > 0 assert sat_result.ion > 0 assert lin_result.ion > 0 # ----------------------------------------------------------------------------- # CLI/output test # ----------------------------------------------------------------------------- def test_cli_creates_expected_output_files(tmp_path: Path) -> None: """The command-line workflow should read Excel files and create outputs.""" config = tft.AnalysisConfig() data = make_artificial_data(config, noise_level=0.0, seed=runtime_seed()) lin_path, sat_path = write_excel_inputs(data, tmp_path) outdir = tmp_path / "result" exit_code = tft.main( [ "--lin", str(lin_path), "--sat", str(sat_path), "--outdir", str(outdir), ] ) assert exit_code == 0 expected_files = [ "IdVg-Vd10_analyze.csv", "IdVg-Vd0.1_analyze.csv", "output.csv", "output_log.csv", "rootIdVg.png", "IdVg_LIN.png", ] for filename in expected_files: path = outdir / filename assert path.exists(), f"Missing output file: {path}" assert path.stat().st_size > 0, f"Empty output file: {path}" summary = pd.read_csv(outdir / "output.csv") for column in ["Vth_SAT", "muSAT", "SS_SAT", "Ion_SAT", "Vth_LIN", "muFE", "SS_LIN", "Ion_LIN"]: assert column in summary.columns # ----------------------------------------------------------------------------- # Case C: edge/error data # ----------------------------------------------------------------------------- def test_case_c_missing_required_columns_raise_value_error() -> None: """Missing required columns should raise clear ValueError exceptions.""" config = tft.AnalysisConfig() missing_id = pd.DataFrame({"Vg": [0.0, 1.0, 2.0], "Vd": [0.1, 0.1, 0.1]}) with pytest.raises(ValueError, match="missing required columns"): tft.analyze_idvg_sat(missing_id, config) with pytest.raises(ValueError, match="missing required columns"): tft.analyze_idvg_lin(missing_id, config) def test_case_c_short_data_raise_value_error_for_vth() -> None: """Too-short data should fail clearly because rolling slopes are unavailable.""" config = tft.AnalysisConfig(rolling_window=7) short = pd.DataFrame( { "Vg": [0.0, 1.0, 2.0], "Vd": [VDS_LIN, VDS_LIN, VDS_LIN], "Id": [1.0e-15, 1.0e-12, 1.0e-10], } ) with pytest.raises(ValueError, match="No valid slope values"): tft.analyze_idvg_sat(short, config) with pytest.raises(ValueError, match="No valid slope values"): tft.analyze_idvg_lin(short, config) def test_case_c_low_current_sets_ss_and_von_to_nan() -> None: """If target current is never reached, SS and Von should be NaN.""" config = tft.AnalysisConfig() data = make_low_current_data(config) sat_result = tft.analyze_idvg_sat(data.sat, config) lin_result = tft.analyze_idvg_lin(data.lin, config) assert np.isnan(sat_result.ss) assert np.isnan(sat_result.von) assert np.isnan(lin_result.ss) assert np.isnan(lin_result.von) def test_case_c_zero_and_negative_currents_are_ignored_for_log_and_sqrt() -> None: """Zero/negative Id values should become NaN in Id_rm, logId, and rootId.""" config = tft.AnalysisConfig() data = make_artificial_data(config, noise_level=0.0, seed=runtime_seed()) sat_df = data.sat.copy() bad_indices = [5, 20, 40] sat_df.loc[bad_indices[0], "Id"] = 0.0 sat_df.loc[bad_indices[1], "Id"] = -1.0e-12 sat_df.loc[bad_indices[2], "Id"] = -1.0 result = tft.analyze_idvg_sat(sat_df, config) assert result.dataframe.loc[bad_indices, "Id_rm"].isna().all() assert result.dataframe.loc[bad_indices, "logId"].isna().all() assert result.dataframe.loc[bad_indices, "rootId"].isna().all() assert result.mobility > 0 def test_case_c_missing_input_file_returns_nonzero_exit_code(tmp_path: Path) -> None: """main() should return a nonzero code when input files do not exist.""" outdir = tmp_path / "result" exit_code = tft.main( [ "--lin", str(tmp_path / "missing_lin.xlsx"), "--sat", str(tmp_path / "missing_sat.xlsx"), "--outdir", str(outdir), ] ) assert exit_code != 0 # ----------------------------------------------------------------------------- # Standalone wrapper # ----------------------------------------------------------------------------- def parse_wrapper_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse arguments for standalone execution of this test file.""" parser = argparse.ArgumentParser( description="Run pytest-based tests for analyze_TFT_refactored.py." ) parser.add_argument( "--seed", type=int, default=DEFAULT_SEED, help=f"Random seed for noisy artificial data. Default: {DEFAULT_SEED}", ) parser.add_argument( "--noise-level", type=float, default=DEFAULT_NOISE_LEVEL, help=f"Relative noise level for Case B artificial data. Default: {DEFAULT_NOISE_LEVEL}", ) parser.add_argument( "--target-script", default=os.environ.get("TFT_TARGET_SCRIPT", str(Path(__file__).with_name("analyze_TFT_refactored.py"))), help="Path to analyze_TFT_refactored.py. Default: next to this test file.", ) parser.add_argument( "--show-details", type=int, default=1, choices=[0, 1], help="Print detailed descriptions of test purpose/data/checks. 1=yes, 0=no. Default: 1", ) parser.add_argument( "--pytest-args", default="-q -s", help="Extra arguments passed to pytest as a single string. Default: -q -s", ) return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: """Run pytest with seed/noise options supplied through environment variables.""" args = parse_wrapper_args(argv) os.environ["TFT_TEST_SEED"] = str(args.seed) os.environ["TFT_TEST_NOISE_LEVEL"] = str(args.noise_level) os.environ["TFT_TARGET_SCRIPT"] = str(Path(args.target_script).resolve()) os.environ["TFT_TEST_SHOW_DETAILS"] = str(args.show_details) pytest_args = args.pytest_args.split() + [str(Path(__file__).resolve())] return int(pytest.main(pytest_args)) if __name__ == "__main__": raise SystemExit(main())