Newer
Older
TIASShot / TIASshot / HwTest / HwTestJudge.cs
#if HWTEST
using System;
using System.Collections.Generic;
using System.Linq;

namespace TIASshot {

    /// <summary>
    /// 実機テストの判定しきい値と手順のパラメータ(HWTEST 限定).
    /// 【暫定値】判定しきい値は最初の実機実行で実測してから決め直す(PLAN_06).
    /// 結果ファイルには必ず測定値を出すため,暫定値で FAIL しても測定値から決め直せる.
    /// </summary>
    internal static class HwTestThresholds {

        // ---- 判定しきい値(暫定) ----

        /// <summary>preview: フレームレートの下限(fps).DFK 33UX178 の実測 16.86〜17.71 fps に対して余裕を持たせた値</summary>
        public const double MinPreviewFps = 10.0;

        /// <summary>
        /// exposure-gain: 露光を 1 段(時間で半分)下げたときの平均輝度の比(下げた / 基準)の上限.
        /// 消灯値(黒の下限)は light より前のため未測定で,単純比で見る(黒の下限の分だけ比は 1 に寄る.DFK 33UX178 の実測は 1/60→1/64 s で 0.94 ≒ 60/64)
        /// </summary>
        public const double ExposureLowerMaxRatio = 0.8;

        /// <summary>exposure-gain: 露光を 1 段(時間で 2 倍)上げたときの平均輝度の比(上げた / 基準)の下限(単純比)</summary>
        public const double ExposureRaiseMinRatio = 1.25;

        /// <summary>exposure-gain: ゲインを上げ下げしたときに期待する平均輝度の変化量の下限(8bit 階調)</summary>
        public const double MinBrightnessDelta = 3.0;

        /// <summary>飽和とみなす画素値(8bit 階調).画素のいずれかのチャネルがこれ以上なら飽和画素として数える</summary>
        public const int SaturatedPixelLevel = 254;

        /// <summary>
        /// exposure-gain: 飽和画素の割合(0〜1)がこれを超えたフレームは飽和とみなす(暫定 1%).
        /// 平均輝度だけでは明るい色票の飽和を見逃すため(DFK 33UX178 の実測: 平均 227.8 で明るい色票が飽和)
        /// </summary>
        public const double MaxSaturatedFraction = 0.01;

        /// <summary>exposure-gain: これ以下の平均輝度は真っ暗とみなし,下げる向きの確認を省く(8bit 階調)</summary>
        public const double DarkLevel = 5.0;

        /// <summary>exposure-gain: 基準にする平均輝度の中間の範囲の下限(8bit 階調.暫定).これ未満は暗すぎるとして露光を上げる向きに探す</summary>
        public const double MidBrightnessLow = 30.0;

        /// <summary>exposure-gain: 基準にする平均輝度の中間の範囲の上限(8bit 階調.暫定).これを超えると明るすぎるとして露光を下げる向きに探す</summary>
        public const double MidBrightnessHigh = 220.0;

        /// <summary>exposure-gain: 元の値に戻した後の平均輝度と最初の平均輝度の差の許容(超えたら WARN.判定には使わない)</summary>
        public const double RestoreTolerance = 10.0;

        /// <summary>exposure-gain: 露光・ゲインを元の値に戻したときの読み戻しの相対誤差の許容(超えたら FAIL)</summary>
        public const double RestoreRelativeTolerance = 0.01;

        /// <summary>
        /// exposure-gain: ゲインの読み戻しの絶対誤差の許容(設定範囲の幅に対する割合).
        /// ゲインの元の値は 0(dB)のことがあり,相対誤差だけでは判定できないため
        /// </summary>
        public const double GainRestoreRangeFraction = 0.001;

        /// <summary>light: 点灯時と消灯時の平均輝度の差の下限(8bit 階調).DFK 33UX178 の実測は約 196</summary>
        public const double MinLightDelta = 100.0;

        /// <summary>close-preview: 閉じる処理(後始末)の所要時間の上限.DFK 33UX178 の実測は 144 ms</summary>
        public static readonly TimeSpan CloseLimit = TimeSpan.FromMilliseconds(1000);

        /// <summary>close-preview: 閉じた後の観測時間内に届いてよいフレーム数の上限(停止処理中に流れていた分)</summary>
        public const int MaxFramesAfterClose = 2;

        /// <summary>reconnect: 再接続後に届くべきフレーム数の下限</summary>
        public const int ReconnectMinFrames = 5;

        // ---- 手順のパラメータ ----

        /// <summary>最初のフレームが届くまでの待機の上限</summary>
        public static readonly TimeSpan FirstFrameTimeout = TimeSpan.FromSeconds(10);

        /// <summary>preview: フレームレートを測る前の慣らし時間</summary>
        public static readonly TimeSpan PreviewWarmup = TimeSpan.FromSeconds(1);

        /// <summary>preview: フレームレートを測る時間</summary>
        public static readonly TimeSpan PreviewMeasure = TimeSpan.FromSeconds(3);

        /// <summary>設定(露光・ゲイン・光源)を変えた後,フレームに反映されるのを待つ時間</summary>
        public static readonly TimeSpan SettleTime = TimeSpan.FromMilliseconds(1500);

        /// <summary>平均輝度の 1 回の測定に使うフレーム数</summary>
        public const int SamplesPerMeasurement = 8;

        /// <summary>平均輝度の 1 回の測定の待機の上限</summary>
        public static readonly TimeSpan SampleTimeout = TimeSpan.FromSeconds(15);

        /// <summary>平均輝度を求めるときの間引き(縦横とも何画素ごとに読むか)</summary>
        public const int PixelStep = 4;

        /// <summary>exposure-gain: 露光が 2^n の段(HwTestValueScale.Log2Step)のとき 1 段で動かす段数(±1 = 時間で 2 倍・半分)</summary>
        public const double ExposureLogStep = 1.0;

        /// <summary>exposure-gain: 基準の明るさを中間に寄せるために露光を動かす段数の上限(元の設定の測定は含めない)</summary>
        public const int BaselineSearchMaxSteps = 12;

        /// <summary>exposure-gain: 露光が線形の量(HwTestValueScale.Linear)のとき下げる倍率</summary>
        public const double ExposureDownFactor = 0.5;

        /// <summary>exposure-gain: 露光が線形の量(HwTestValueScale.Linear)のとき上げる倍率</summary>
        public const double ExposureUpFactor = 2.0;

        /// <summary>exposure-gain: ゲインを上げ下げする量(設定範囲の幅に対する割合)</summary>
        public const double GainStepFraction = 0.1;

        /// <summary>close-preview: 閉じた後にフレームが止まったかを見る時間</summary>
        public static readonly TimeSpan AfterCloseObserve = TimeSpan.FromSeconds(1);

        /// <summary>reconnect: 再接続後のフレームの待機の上限</summary>
        public static readonly TimeSpan ReconnectFrameTimeout = TimeSpan.FromSeconds(15);

        // ---- タイムアウト(各項目・全体) ----

        public static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(60);
        public static readonly TimeSpan PreviewTimeout = TimeSpan.FromSeconds(60);
        public static readonly TimeSpan ExposureGainTimeout = TimeSpan.FromSeconds(180);
        public static readonly TimeSpan LightTimeout = TimeSpan.FromSeconds(90);
        public static readonly TimeSpan ClosePreviewTimeout = TimeSpan.FromSeconds(30);
        public static readonly TimeSpan ReconnectTimeout = TimeSpan.FromSeconds(60);
        public static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(30);

        /// <summary>実機テスト全体の上限(チャート不要の検査.各項目の上限の合計より長くする)</summary>
        public static readonly TimeSpan OverallTimeout = TimeSpan.FromMinutes(10);

        // ==== チャートが必要な検査(--with-chart.PLAN_06 フェーズ 2).すべて暫定(最初の実機実行で決め直す) ====

        /// <summary>calibrate: 接続から自動校正の完了(変換行列の算出)までの待機の上限(暫定.2026-10-08 の実機の実測に合わせて 120 秒から縮めた)</summary>
        public static readonly TimeSpan CalibrationWait = TimeSpan.FromSeconds(60);

        /// <summary>calibrate: 校正の完了から撮影ボタンが有効になるまでの待機の上限(EnableShots は UI スレッドへの投稿)</summary>
        public static readonly TimeSpan ShotsEnabledWait = TimeSpan.FromSeconds(5);

        /// <summary>calibrate: 校正後のフレームの複製が届くまでの待機の上限</summary>
        public static readonly TimeSpan CalibratedFrameWait = TimeSpan.FromSeconds(15);

        /// <summary>
        /// calibrate: 校正後のフレームを変換した色票と参照値(tcc_srgb)の差の絶対値の平均の上限(8bit 階調.暫定).
        /// セルフテスト(合成チャート・JPEG)の上限は 6.2026-10-08 の実機の実測(全次元で平均 1.57 以下)に合わせて 10 から縮めた
        /// </summary>
        public const double MaxCalibrationMeanError = 5.0;

        /// <summary>
        /// calibrate: 同じく白色票を除いた色票の差の絶対値の最大の上限(8bit 階調.暫定.セルフテストは 15).
        /// 2026-10-08 の実機の実測(白色票以外は 3.5 以下)に合わせた
        /// </summary>
        public const double MaxCalibrationMaxError = 10.0;

        /// <summary>
        /// calibrate: 白色票(参照値から PatchColorError.WhitePatchIndex で決める.tcc_srgb.csv では 13 番)の差の絶対値の最大の上限(8bit 階調).
        /// 【暫定】17 次元の変換で白色票が 255 に頭打ちになる(白飛び)疑いがあり(2026-10-08 の実測 27.35),その調査が済むまでの値
        /// </summary>
        public const double MaxCalibrationWhiteMaxError = 40.0;

        /// <summary>shot: 連続撮影の枚数(暫定)</summary>
        public const int ShotMultiCount = 3;

        /// <summary>shot: 連続撮影の間隔(ms)(暫定)</summary>
        public const int ShotMultiIntervalMs = 1000;

        /// <summary>shot: 連続撮影の撮影時刻の間隔と設定値の差の許容(ms)(暫定.2026-10-08 の実機の実測に合わせて 200 ms から縮めた)</summary>
        public const double ShotIntervalToleranceMs = 100.0;

        /// <summary>shot・close-saving: 撮影から保存(保存スレッドの終了)までの待機の上限(2026-10-09 に 90 秒から延ばした.重いアプリが動いていると保存が 1 枚あたり約 11 秒に遅くなり,close-saving の 10 枚の再試行で 90 秒を超えうるため)</summary>
        public static readonly TimeSpan SaveWait = TimeSpan.FromSeconds(180);

        /// <summary>shot・close-saving: 保存の完了後,保存スレッドからの投稿(ShowMessage・EnableShots)を処理させる時間</summary>
        public static readonly TimeSpan AfterSaveSettle = TimeSpan.FromMilliseconds(500);

        /// <summary>close-saving: 保存中に閉じる検査で撮る連続撮影の枚数(間隔 0.保存に時間がかかるようにする)(暫定)</summary>
        public const int CloseSavingShotCount = 5;

        /// <summary>close-saving: 保存中の状態を作れなかったときに 1 回だけ再試行する連続撮影の枚数(暫定)</summary>
        public const int CloseSavingRetryShotCount = 10;

        /// <summary>close-saving: 保存中の状態を作る試行の回数の上限(最初の 1 回 + 再試行 1 回)</summary>
        public const int CloseSavingMaxAttempts = 2;

        /// <summary>close-calibrating: 再接続から校正中(チャートを検出してホワイトバランスを調整中)になるまでの待機の上限(暫定)</summary>
        public static readonly TimeSpan CalibratingWait = TimeSpan.FromSeconds(60);

        /// <summary>close-calibrating: 校正中になってから閉じる処理を呼ぶまでの時間(校正の途中で閉じるため.Calib/Frames 100 枚・約 15 fps で約 7 秒続く)</summary>
        public static readonly TimeSpan CalibratingBeforeClose = TimeSpan.FromMilliseconds(500);

        public static readonly TimeSpan CalibrateTimeout = TimeSpan.FromSeconds(180);
        public static readonly TimeSpan ShotTimeout = TimeSpan.FromSeconds(240);
        public static readonly TimeSpan CloseSavingTimeout = TimeSpan.FromSeconds(300);
        public static readonly TimeSpan CloseCalibratingTimeout = TimeSpan.FromSeconds(150);

        /// <summary>実機テスト全体の上限(チャートが必要な検査.各項目の上限の合計(約 16 分)より長くする)</summary>
        public static readonly TimeSpan OverallTimeoutWithChart = TimeSpan.FromMinutes(20);

        // ==== タイムアウト時の記録(止まった場所の調査用.判定には使わない) ====

        /// <summary>タイムアウト時: 1 スレッドのマネージドスタックの取得の上限(Suspend が戻らない等でウォッチドッグ自体が止まらないため)</summary>
        public static readonly TimeSpan HangStackTimeout = TimeSpan.FromSeconds(10);

        /// <summary>
        /// タイムアウト時: ミニダンプの書き出しの上限.全メモリのダンプ(1〜3 GB 程度)を SSD に書く時間に余裕を持たせつつ,
        /// ランナー(run_hwtest.py)の打ち切り(全体の上限 + 120 秒)より十分短くする
        /// </summary>
        public static readonly TimeSpan HangDumpTimeout = TimeSpan.FromSeconds(60);

        /// <summary>全メモリのダンプにするプライベートメモリの上限(バイト).これを超えたら小さいダンプにする(ダンプが数 GB を超えないため)</summary>
        public const long FullDumpMaxPrivateBytes = 3L * 1024 * 1024 * 1024;

        /// <summary>全メモリのダンプに必要な空き容量の余裕(バイト).ダンプの大きさはプライベートメモリ+読み込んだモジュールの分になるため,その分を見込む</summary>
        public const long FullDumpDiskMargin = 1L * 1024 * 1024 * 1024;

        /// <summary>小さいダンプ(スレッド・スタックと参照先のメモリのみ.数十 MB)を書くのに必要な空き容量(バイト)</summary>
        public const long SmallDumpMinFreeBytes = 256L * 1024 * 1024;
    }

    /// <summary>
    /// 色票内の画素の値の集計(HWTEST 限定.HwTestJudge.SummarizePatchPixels の結果).
    /// 配列はチャネルの順(0 = B・1 = G・2 = R).画素が無い場合は値がすべて NaN.
    /// NaN の画素は平均を NaN にする(最小・最大・割合には数えない).
    /// </summary>
    internal sealed class HwTestPatchPixelStats {
        /// <summary>画素の数</summary>
        public int Pixels;
        /// <summary>チャネルごとの平均</summary>
        public readonly double[] Mean = new double[3];
        /// <summary>チャネルごとの最小</summary>
        public readonly double[] Min = new double[3];
        /// <summary>チャネルごとの最大</summary>
        public readonly double[] Max = new double[3];
        /// <summary>いずれかのチャネルが 255 以上の画素の割合(8bit 画像では 255 に頭打ちになった画素=白飛び)</summary>
        public double FractionAnyAtOrAboveMax;
        /// <summary>いずれかのチャネルが 0 以下の画素の割合(8bit 画像では 0 に頭打ちになった画素=黒つぶれ)</summary>
        public double FractionAnyAtOrBelowMin;
        /// <summary>いずれかのチャネルが 255 を超えた画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0)</summary>
        public double FractionAnyAboveMax;
        /// <summary>いずれかのチャネルが 0 未満の画素の割合(8bit に切り詰める前の値で意味がある.8bit 画像では常に 0)</summary>
        public double FractionAnyBelowMin;
    }

    /// <summary>撮影ログ(Info.csv)の撮影の 1 行(HWTEST 限定)</summary>
    internal struct HwTestShotLogRow {
        /// <summary>撮影の番号(1 始まり)</summary>
        public int Shot;
        /// <summary>撮影開始からの時刻(ms)</summary>
        public long TimeMs;
        /// <summary>備考(撮影の中止の理由等.無ければ空)</summary>
        public string Note;
    }

    /// <summary>保存中に閉じる検査(close-saving)で保存中の状態を作れたかの判断(HWTEST 限定)</summary>
    internal enum HwTestSavingAttempt {
        /// <summary>保存中の状態を作れた(閉じる処理の取り消しを検査する)</summary>
        Ready,
        /// <summary>作れなかったので枚数を増やしてもう 1 回試す</summary>
        Retry,
        /// <summary>作れなかった(試行の上限).WARN にして取り消しの検査を省く</summary>
        GiveUp,
    }

    /// <summary>タイムアウト時に書くミニダンプの種類(HWTEST 限定.HwTestJudge.ChooseDumpKind の結果)</summary>
    internal enum HwTestDumpKind {
        /// <summary>書かない(空き容量が足りない)</summary>
        None,
        /// <summary>小さいダンプ(スレッド・スタックと参照先のメモリ.マネージドのヒープは含まないため SOS の !clrstack は不完全になりうる)</summary>
        Small,
        /// <summary>全メモリのダンプ(マネージドのスタック・ヒープを後から Visual Studio・WinDbg(SOS)で見られる)</summary>
        Full,
    }

    /// <summary>ウォッチドッグの判定結果(HWTEST 限定)</summary>
    internal enum HwTestOverdue {
        /// <summary>上限内</summary>
        None,
        /// <summary>実行中の項目が項目の上限を超えた</summary>
        Item,
        /// <summary>全体の上限を超えた</summary>
        Overall,
    }

    /// <summary>
    /// 露光・ゲインの上げ下げに対する平均輝度の変化の向きの判定結果(HWTEST 限定).
    /// </summary>
    internal sealed class HwTestDirectionResult {
        /// <summary>下げる向きを確認したか(設定が変わらなかった・元が真っ暗・下げた側が飽和の場合は確認しない)</summary>
        public bool LowerChecked;
        /// <summary>下げたときに十分暗くなったか</summary>
        public bool LowerOk;
        /// <summary>上げる向きを確認したか(設定が変わらなかった・元が飽和・上げた側が飽和の場合は確認しない)</summary>
        public bool RaiseChecked;
        /// <summary>上げたときに十分明るくなったか</summary>
        public bool RaiseOk;
        /// <summary>合格か(基準(HwTestDirectionCriteria.RequireAllChecked)に従う)</summary>
        public bool Pass;
        /// <summary>判定の説明(結果ファイルに出す)</summary>
        public string Summary;
    }

    /// <summary>
    /// 露光・ゲインの上げ下げの判定の基準(HWTEST 限定).
    /// 比で見る(UseRatio)場合は 下げた / 基準 ≦ LowerLimit・上げた / 基準 ≧ RaiseLimit,
    /// 差で見る場合は 基準 − 下げた ≧ LowerLimit・上げた − 基準 ≧ RaiseLimit を期待どおりとする.
    /// </summary>
    internal sealed class HwTestDirectionCriteria {
        /// <summary>比で見るか(false なら差で見る)</summary>
        public bool UseRatio;
        /// <summary>下げる向きの限度(比なら上限,差なら下限)</summary>
        public double LowerLimit;
        /// <summary>上げる向きの限度(比・差とも下限)</summary>
        public double RaiseLimit;
        /// <summary>基準の平均輝度がこれ以下なら真っ暗とみなし,下げる向き(比なら上げる向きも)を確認しない</summary>
        public double DarkLevel;
        /// <summary>飽和画素の割合がこれを超えたら飽和とみなす</summary>
        public double MaxSaturatedFraction;
        /// <summary>
        /// true: 確認した向きがすべて期待どおりで合格(少なくとも一方を確認できること).
        /// false: 一方の向きだけでも期待どおりなら合格(範囲の端で変化が小さくなりうるゲイン用)
        /// </summary>
        public bool RequireAllChecked;
    }

    /// <summary>1 フレームの測定値(平均輝度と飽和画素の割合)(HWTEST 限定)</summary>
    internal struct HwTestFrameStats {
        /// <summary>平均輝度(0〜255)</summary>
        public double Mean;
        /// <summary>飽和画素の割合(0〜1)</summary>
        public double SaturatedFraction;

        public HwTestFrameStats(double mean, double saturatedFraction) {
            Mean = mean;
            SaturatedFraction = saturatedFraction;
        }
    }

    /// <summary>複数フレームをまとめた 1 回の測定値(HWTEST 限定)</summary>
    internal struct HwTestMeasurement {
        /// <summary>平均輝度の平均(フレームが無ければ NaN)</summary>
        public double Mean;
        /// <summary>飽和画素の割合の最大(各フレームのうち最も飽和したもの.判定に使う.フレームが無ければ NaN)</summary>
        public double SaturatedFraction;
        /// <summary>飽和画素の割合の平均(参考.フレームが無ければ NaN)</summary>
        public double MeanSaturatedFraction;
        /// <summary>フレーム数</summary>
        public int Count;

        /// <summary>測定できなかった(フレームが無い)測定値</summary>
        public static readonly HwTestMeasurement NotMeasured = new HwTestMeasurement {
            Mean = double.NaN, SaturatedFraction = double.NaN, MeanSaturatedFraction = double.NaN, Count = 0,
        };
    }

    /// <summary>基準の明るさ探し(exposure-gain)の 1 段ごとの判断(HWTEST 限定)</summary>
    internal enum HwTestBaselineSearchDecision {
        /// <summary>平均輝度が中間の範囲に入り,飽和もしていない(この設定を基準にする)</summary>
        Found,
        /// <summary>同じ向きにもう 1 段動かす</summary>
        Continue,
        /// <summary>測定できなかった(平均輝度が NaN)</summary>
        StopNotMeasured,
        /// <summary>設定が範囲の端で変わらなくなった</summary>
        StopRangeEnd,
        /// <summary>段数の上限に達した</summary>
        StopMaxSteps,
        /// <summary>中間の範囲を飛び越えた(明るすぎ → 暗すぎ,またはその逆)</summary>
        StopOvershoot,
    }

    /// <summary>基準の明るさ探しの 1 段(設定値とその平均輝度・飽和画素の割合)(HWTEST 限定)</summary>
    internal struct HwTestBaselineSample {
        public double Setting;
        public double Mean;
        public double SaturatedFraction;

        public HwTestBaselineSample(double setting, double mean, double saturatedFraction) {
            Setting = setting;
            Mean = mean;
            SaturatedFraction = saturatedFraction;
        }
    }

    /// <summary>
    /// 実機テストの判断ロジック(HWTEST 限定.SDK・UI・スレッドに依存しない純粋関数.層①でテストする).
    /// </summary>
    internal static class HwTestJudge {

        /// <summary>フレームレート(fps).時間が 0 以下なら 0</summary>
        public static double FrameRate(long frames, TimeSpan elapsed) {
            if (elapsed <= TimeSpan.Zero || frames <= 0) return 0;
            return frames / elapsed.TotalSeconds;
        }

        /// <summary>フレームレートが下限以上か</summary>
        public static bool MeetsMinFrameRate(double fps, double minFps) {
            return !double.IsNaN(fps) && fps >= minFps;
        }

        /// <summary>
        /// 現在値に倍率を掛けた設定値を範囲内に収めて返す(露光の上げ下げに使う).
        /// 現在値が 0 以下で倍率が 1 を超える場合は,範囲の幅の 5% だけ最小値から上げた値を返す(0 に掛けても変わらないため).
        /// </summary>
        /// <exception cref="ArgumentException">最小値が最大値より大きい・非有限値を含む場合</exception>
        public static double ScaleSetting(double current, double min, double max, double factor) {
            ValidateRange(current, min, max);
            if (!IsFinite(factor) || factor <= 0) {
                throw new ArgumentException($"倍率が不正です: {factor}", nameof(factor));
            }
            double target;
            if (current > 0) {
                target = current * factor;
            } else if (factor > 1) {
                target = min + (max - min) * 0.05;
            } else {
                target = current;
            }
            return Clamp(target, min, max);
        }

        /// <summary>
        /// 露光を 1 段動かした設定値を範囲内に収めて返す.
        /// 2^n の段(Log2Step)は現在値に最も近い 2^n(log2 を四捨五入)から logStep 段動かした 2^(n±logStep)(時間で 2 倍・半分),
        /// 線形の量(Linear)は ScaleSetting で downFactor / upFactor を掛ける.
        /// </summary>
        /// <param name="direction">正なら上げる(明るくする)向き,負なら下げる(暗くする)向き.0 は不可</param>
        /// <exception cref="ArgumentException">direction が 0・範囲が不正・非有限値を含む・未知の種類の場合,Log2Step で現在値が 0 以下の場合</exception>
        public static double NextExposure(double current, double min, double max, HwTestValueScale scale, int direction,
                double logStep, double downFactor, double upFactor) {
            ValidateRange(current, min, max);
            if (direction == 0) throw new ArgumentException("向きが 0 です", nameof(direction));
            switch (scale) {
                case HwTestValueScale.Log2Step:
                    if (!IsFinite(logStep) || logStep <= 0) {
                        throw new ArgumentException($"段の量が不正です: {logStep}", nameof(logStep));
                    }
                    var n = NearestLog2Exponent(current);
                    return Clamp(Math.Pow(2, n + (direction > 0 ? logStep : -logStep)), min, max);
                case HwTestValueScale.Linear:
                    return ScaleSetting(current, min, max, direction > 0 ? upFactor : downFactor);
                default:
                    throw new ArgumentException($"未知の値の種類です: {scale}", nameof(scale));
            }
        }

        /// <summary>
        /// 基準探しの前に露光をきりのよい段に合わせた値を返す.
        /// 2^n の段(Log2Step)は現在値に最も近い 2^n(log2 を四捨五入).それが範囲外なら範囲内で最も近い 2^n,
        /// 範囲内に 2^n が 1 つも無ければ現在値をそのまま返す.線形の量(Linear)は現在値をそのまま返す(合わせない).
        /// </summary>
        /// <exception cref="ArgumentException">範囲が不正・非有限値を含む・未知の種類の場合,Log2Step で現在値が 0 以下の場合</exception>
        public static double AlignExposure(double current, double min, double max, HwTestValueScale scale) {
            ValidateRange(current, min, max);
            switch (scale) {
                case HwTestValueScale.Linear:
                    return current;
                case HwTestValueScale.Log2Step:
                    var n = NearestLog2Exponent(current);
                    if (max <= 0) return current;
                    // 範囲の端がちょうど 2^n のとき log の丸め誤差で 1 段ずれないよう,わずかに内側へ寄せる
                    var nMax = Math.Floor(Math.Log(max, 2) + 1e-9);
                    var nMin = min > 0 ? Math.Ceiling(Math.Log(min, 2) - 1e-9) : double.NegativeInfinity;
                    if (nMin > nMax) return current;   // 範囲内に 2^n が無い
                    if (n > nMax) n = nMax;
                    if (n < nMin) n = nMin;
                    return Clamp(Math.Pow(2, n), min, max);
                default:
                    throw new ArgumentException($"未知の値の種類です: {scale}", nameof(scale));
            }
        }

        /// <summary>値に最も近い 2^n の n(log2 の四捨五入.読み戻しの微小な丸めに影響されない)</summary>
        private static double NearestLog2Exponent(double value) {
            if (value <= 0) throw new ArgumentException($"2^n の段にできない値です(0 以下): {value}", nameof(value));
            return Math.Round(Math.Log(value, 2), MidpointRounding.AwayFromZero);
        }

        /// <summary>平均輝度が中間の範囲(low 以上 high 以下)に入っているか.NaN は false</summary>
        public static bool IsMidBrightness(double mean, double low, double high) {
            return !double.IsNaN(mean) && mean >= low && mean <= high;
        }

        /// <summary>飽和画素の割合がしきい値を超えているか(飽和とみなすか).NaN(測定できない)は false</summary>
        public static bool IsSaturated(double saturatedFraction, double maxSaturatedFraction) {
            return !double.IsNaN(saturatedFraction) && saturatedFraction > maxSaturatedFraction;
        }

        /// <summary>基準にできる明るさか(平均輝度が中間の範囲に入り,かつ飽和画素の割合がしきい値以下)</summary>
        public static bool IsBaselineCandidate(double mean, double saturatedFraction, double low, double high, double maxSaturatedFraction) {
            return IsMidBrightness(mean, low, high) && !double.IsNaN(saturatedFraction)
                && !IsSaturated(saturatedFraction, maxSaturatedFraction);
        }

        /// <summary>
        /// 基準の明るさを中間に寄せるために露光を動かす向き.high を超える・飽和している(飽和画素の割合がしきい値超え)なら -1(下げる),
        /// low 未満なら +1(上げる),基準にできる明るさ・測定できない(NaN)なら 0(動かさない).
        /// </summary>
        public static int BaselineSearchDirection(double mean, double saturatedFraction, double low, double high, double maxSaturatedFraction) {
            if (double.IsNaN(mean)) return 0;
            if (mean > high || IsSaturated(saturatedFraction, maxSaturatedFraction)) return -1;
            if (mean < low) return 1;
            return 0;
        }

        /// <summary>
        /// 基準の明るさ探しで 1 段動かして測った後の判断.
        /// 基準にできる明るさ(中間の範囲かつ飽和していない)になれば Found.
        /// 測定できない・設定が変わらない(範囲の端)・中間の範囲を飛び越えた・段数の上限に達したら Stop*.
        /// それ以外は Continue(同じ向きにもう 1 段).
        /// </summary>
        /// <param name="mean">動かした後の平均輝度</param>
        /// <param name="saturatedFraction">動かした後の飽和画素の割合</param>
        /// <param name="direction">探している向き(-1: 下げる,+1: 上げる)</param>
        /// <param name="settingChanged">動かした設定が実際に変わったか(読み戻しの比較)</param>
        /// <param name="stepsDone">これまでに動かした段数(今回を含む.1 以上)</param>
        /// <param name="maxSteps">段数の上限</param>
        public static HwTestBaselineSearchDecision DecideBaselineSearch(double mean, double saturatedFraction, int direction, bool settingChanged,
                int stepsDone, int maxSteps, double low, double high, double maxSaturatedFraction) {
            if (!settingChanged) return HwTestBaselineSearchDecision.StopRangeEnd;
            if (double.IsNaN(mean) || double.IsNaN(saturatedFraction)) return HwTestBaselineSearchDecision.StopNotMeasured;
            if (IsBaselineCandidate(mean, saturatedFraction, low, high, maxSaturatedFraction)) return HwTestBaselineSearchDecision.Found;
            var tooBright = mean > high || IsSaturated(saturatedFraction, maxSaturatedFraction);
            if ((direction < 0 && mean < low) || (direction > 0 && tooBright)) return HwTestBaselineSearchDecision.StopOvershoot;
            if (stepsDone >= maxSteps) return HwTestBaselineSearchDecision.StopMaxSteps;
            return HwTestBaselineSearchDecision.Continue;
        }

        /// <summary>
        /// 基準の明るさ探しで中間に寄せられなかったときに基準にする段を選ぶ.
        /// 飽和していない段を飽和している段より優先し,その中で平均輝度が中間の範囲に最も近い段(同じ近さなら先の段).
        /// 測定できなかった(平均輝度が NaN)段は選ばない.選べる段が無ければ -1.
        /// </summary>
        public static int ChooseBaselineIndex(IReadOnlyList<HwTestBaselineSample> samples, double low, double high, double maxSaturatedFraction) {
            if (samples == null) return -1;
            var best = -1;
            var bestSaturated = true;
            var bestDistance = double.PositiveInfinity;
            for (var i = 0; i < samples.Count; i++) {
                var mean = samples[i].Mean;
                if (double.IsNaN(mean)) continue;
                var saturated = IsSaturated(samples[i].SaturatedFraction, maxSaturatedFraction);
                var distance = mean < low ? low - mean : mean > high ? mean - high : 0;
                var better = best < 0
                    || (bestSaturated && !saturated)
                    || (bestSaturated == saturated && distance < bestDistance);
                if (better) {
                    best = i;
                    bestSaturated = saturated;
                    bestDistance = distance;
                }
            }
            return best;
        }

        /// <summary>
        /// 現在値に範囲の幅の割合を足した設定値を範囲内に収めて返す(ゲインの上げ下げに使う.負の割合で下げる).
        /// </summary>
        /// <exception cref="ArgumentException">最小値が最大値より大きい・非有限値を含む場合</exception>
        public static double StepSetting(double current, double min, double max, double fractionOfRange) {
            ValidateRange(current, min, max);
            if (!IsFinite(fractionOfRange)) {
                throw new ArgumentException($"割合が不正です: {fractionOfRange}", nameof(fractionOfRange));
            }
            return Clamp(current + (max - min) * fractionOfRange, min, max);
        }

        /// <summary>設定値が変わったか(カメラから読み戻した値どうしの比較.整数値の丸めを考慮して相対誤差で見る)</summary>
        public static bool IsChanged(double before, double after) {
            var scale = Math.Max(1e-6, Math.Max(Math.Abs(before), Math.Abs(after)));
            return Math.Abs(after - before) / scale > 1e-4;
        }

        /// <summary>
        /// 設定値が期待した値と同じか(元の値に戻したときの読み戻しの確認).
        /// 差が「相対誤差の許容 × 大きい方の絶対値」と「絶対誤差の許容」の大きい方以内なら同じとみなす.
        /// NaN を含む場合は false.
        /// </summary>
        /// <param name="expected">期待した値(元の値)</param>
        /// <param name="actual">読み戻した値</param>
        /// <param name="relativeTolerance">相対誤差の許容(0 以上.例 0.01 = 1%)</param>
        /// <param name="absoluteTolerance">絶対誤差の許容(0 以上.元の値が 0 になりうる設定用)</param>
        public static bool IsSameSetting(double expected, double actual, double relativeTolerance, double absoluteTolerance) {
            if (double.IsNaN(expected) || double.IsNaN(actual)) return false;
            var tolerance = Math.Max(Math.Max(0, relativeTolerance) * Math.Max(Math.Abs(expected), Math.Abs(actual)), Math.Max(0, absoluteTolerance));
            return Math.Abs(actual - expected) <= tolerance;
        }

        /// <summary>
        /// 複数フレームの測定値をまとめる(平均輝度は平均,飽和画素の割合は最大(判定用)と平均(参考)).空なら NotMeasured.
        /// </summary>
        public static HwTestMeasurement Summarize(IReadOnlyList<HwTestFrameStats> frames) {
            if (frames == null || frames.Count == 0) return HwTestMeasurement.NotMeasured;
            double sumMean = 0, sumSat = 0, maxSat = double.NegativeInfinity;
            foreach (var f in frames) {
                sumMean += f.Mean;
                sumSat += f.SaturatedFraction;
                if (f.SaturatedFraction > maxSat) maxSat = f.SaturatedFraction;
            }
            return new HwTestMeasurement {
                Mean = sumMean / frames.Count,
                SaturatedFraction = maxSat,
                MeanSaturatedFraction = sumSat / frames.Count,
                Count = frames.Count,
            };
        }

        /// <summary>
        /// 画素データの平均輝度(先頭 3 チャネル(BGR)の平均.1〜2 チャネルの形式は先頭チャネル)と,
        /// 飽和画素(読んだチャネルのいずれかが saturationLevel 以上の画素)の割合を求める.
        /// rows は「間引いて読んだ行」を詰めて並べたもの(1 行 rowStride バイト,rowCount 行).
        /// 各行の中は pixelStep 画素ごとに読む.
        /// </summary>
        /// <param name="rows">間引いた行を詰めた画素データ</param>
        /// <param name="rowCount">行数</param>
        /// <param name="width">1 行の画素数</param>
        /// <param name="rowStride">rows 内の 1 行のバイト数(width * bytesPerPixel 以上)</param>
        /// <param name="bytesPerPixel">1 画素のバイト数(1〜4)</param>
        /// <param name="pixelStep">行の中で何画素ごとに読むか(1 以上)</param>
        /// <param name="saturationLevel">飽和とみなす画素値(0〜255)</param>
        /// <returns>平均輝度(0〜255)と飽和画素の割合(0〜1)</returns>
        /// <exception cref="ArgumentException">引数の組み合わせが不正な場合</exception>
        public static HwTestFrameStats FrameStats(byte[] rows, int rowCount, int width, int rowStride, int bytesPerPixel, int pixelStep,
                int saturationLevel) {
            if (rows == null) throw new ArgumentNullException(nameof(rows));
            if (rowCount <= 0 || width <= 0) throw new ArgumentException($"画素がありません({width}x{rowCount})");
            if (bytesPerPixel < 1 || bytesPerPixel > 4) throw new ArgumentException($"1 画素のバイト数が不正です: {bytesPerPixel}", nameof(bytesPerPixel));
            if (pixelStep < 1) throw new ArgumentException($"間引きが不正です: {pixelStep}", nameof(pixelStep));
            if (saturationLevel < 0 || saturationLevel > 255) throw new ArgumentException($"飽和とみなす画素値が不正です: {saturationLevel}", nameof(saturationLevel));
            if (rowStride < width * bytesPerPixel) throw new ArgumentException($"1 行のバイト数が不足しています: {rowStride} < {width * bytesPerPixel}", nameof(rowStride));
            if ((long)rowStride * rowCount > rows.Length) throw new ArgumentException($"画素データが不足しています: {rows.Length} < {(long)rowStride * rowCount}", nameof(rows));

            var channels = bytesPerPixel >= 3 ? 3 : 1;
            long sum = 0;
            long count = 0;
            long pixels = 0;
            long saturated = 0;
            for (var r = 0; r < rowCount; r++) {
                var rowStart = r * rowStride;
                for (var x = 0; x < width; x += pixelStep) {
                    var p = rowStart + x * bytesPerPixel;
                    var isSaturated = false;
                    for (var c = 0; c < channels; c++) {
                        var v = rows[p + c];
                        sum += v;
                        if (v >= saturationLevel) isSaturated = true;
                    }
                    count += channels;
                    pixels++;
                    if (isSaturated) saturated++;
                }
            }
            return new HwTestFrameStats((double)sum / count, (double)saturated / pixels);
        }

        /// <summary>露光の上げ下げの判定基準(比で見る.確認した向きはすべて期待どおりであること)</summary>
        public static HwTestDirectionCriteria ExposureCriteria() {
            return new HwTestDirectionCriteria {
                UseRatio = true,
                LowerLimit = HwTestThresholds.ExposureLowerMaxRatio,
                RaiseLimit = HwTestThresholds.ExposureRaiseMinRatio,
                DarkLevel = HwTestThresholds.DarkLevel,
                MaxSaturatedFraction = HwTestThresholds.MaxSaturatedFraction,
                RequireAllChecked = true,
            };
        }

        /// <summary>
        /// ゲインの上げ下げの判定基準(差で見る.一方の向きだけでも期待どおりなら合格).
        /// 下げる側は範囲の下端にかかって変化が小さいことがあるため(DFK 33UX178 の実測: 10→0 で差 -3.2)
        /// </summary>
        public static HwTestDirectionCriteria GainCriteria() {
            return new HwTestDirectionCriteria {
                UseRatio = false,
                LowerLimit = HwTestThresholds.MinBrightnessDelta,
                RaiseLimit = HwTestThresholds.MinBrightnessDelta,
                DarkLevel = HwTestThresholds.DarkLevel,
                MaxSaturatedFraction = HwTestThresholds.MaxSaturatedFraction,
                RequireAllChecked = false,
            };
        }

        /// <summary>
        /// 設定を下げた・上げたときの平均輝度の変化を判定する.
        /// 下げる向きは「設定が実際に変わった(loweredApplied)かつ測定でき,基準が真っ暗でなく,下げた側が飽和していない」とき確認する.
        /// 上げる向きは「設定が実際に変わった(raisedApplied)かつ測定でき,基準が飽和していない(飽和画素の割合がしきい値以下)」とき確認する
        /// (比で見る場合は基準が真っ暗でないことも要る).ただし上げた側が飽和している場合は,比・差にかかわらず確認しない
        /// (飽和した画素は明るさの変化を正しく表さず,期待どおりに見えても頭打ちで偶然越えただけのことがあるため).
        /// 合否は criteria.RequireAllChecked に従う(true: 確認した向きがすべて期待どおりで,少なくとも一方を確認できた.
        /// false: 少なくとも一方の向きが期待どおり).
        /// </summary>
        /// <exception cref="ArgumentNullException">criteria が null の場合</exception>
        public static HwTestDirectionResult JudgeDirection(HwTestMeasurement baseline, HwTestMeasurement lowered, HwTestMeasurement raised,
                bool loweredApplied, bool raisedApplied, HwTestDirectionCriteria criteria) {
            if (criteria == null) throw new ArgumentNullException(nameof(criteria));
            var b = baseline.Mean;
            var baselineMeasured = !double.IsNaN(b);
            var baselineDark = baselineMeasured && b <= criteria.DarkLevel;
            var baselineSaturated = IsSaturated(baseline.SaturatedFraction, criteria.MaxSaturatedFraction);

            var result = new HwTestDirectionResult();
            var lowerMeasurable = baselineMeasured && loweredApplied && !double.IsNaN(lowered.Mean) && !baselineDark;
            var lowerSaturated = lowerMeasurable && IsSaturated(lowered.SaturatedFraction, criteria.MaxSaturatedFraction);
            result.LowerChecked = lowerMeasurable && !lowerSaturated;
            result.LowerOk = result.LowerChecked && (criteria.UseRatio
                ? lowered.Mean / b <= criteria.LowerLimit
                : b - lowered.Mean >= criteria.LowerLimit);

            var raiseMeasurable = baselineMeasured && raisedApplied && !double.IsNaN(raised.Mean) && !baselineSaturated
                && (!criteria.UseRatio || !baselineDark);
            var raiseSaturated = raiseMeasurable && IsSaturated(raised.SaturatedFraction, criteria.MaxSaturatedFraction);
            result.RaiseChecked = raiseMeasurable && !raiseSaturated;
            result.RaiseOk = result.RaiseChecked && (criteria.UseRatio
                ? raised.Mean / b >= criteria.RaiseLimit
                : raised.Mean - b >= criteria.RaiseLimit);

            var anyChecked = result.LowerChecked || result.RaiseChecked;
            result.Pass = criteria.RequireAllChecked
                ? anyChecked && (!result.LowerChecked || result.LowerOk) && (!result.RaiseChecked || result.RaiseOk)
                : result.LowerOk || result.RaiseOk;

            var parts = new List<string> {
                lowerSaturated
                    ? DescribeSaturatedSide("下げる", b, lowered)
                    : DescribeSide("下げる", result.LowerChecked, result.LowerOk, loweredApplied, b, lowered.Mean, criteria,
                        baselineDark ? "基準が真っ暗" : null),
                raiseSaturated
                    ? DescribeSaturatedSide("上げる", b, raised)
                    : DescribeSide("上げる", result.RaiseChecked, result.RaiseOk, raisedApplied, b, raised.Mean, criteria,
                        baselineSaturated ? "基準が飽和" : baselineDark ? "基準が真っ暗" : null),
            };
            if (!anyChecked) parts.Add("どちらの向きも確認できません");
            else if (!criteria.RequireAllChecked && result.Pass && !(result.LowerOk && result.RaiseOk)) parts.Add("一方の向きが期待どおりのため合格");
            result.Summary = string.Join("/", parts);
            return result;
        }

        private static string DescribeSide(string label, bool isChecked, bool ok, bool applied, double baseline, double value,
                HwTestDirectionCriteria criteria, string skipReason) {
            var limit = label == "下げる" ? criteria.LowerLimit : criteria.RaiseLimit;
            if (isChecked) {
                var verdict = ok ? "期待どおり" : "期待と異なる";
                return criteria.UseRatio
                    ? $"{label}: 比 {value / baseline:0.000}({baseline:0.00}→{value:0.00}.{(label == "下げる" ? "≦" : "≧")} {limit:G3} を期待.{verdict})"
                    : $"{label}: 差 {value - baseline:+0.0;-0.0;0.0}({baseline:0.00}→{value:0.00}.{(label == "下げる" ? "−" : "+")}{limit:G3} 以上を期待.{verdict})";
            }
            if (!applied) return $"{label}: 設定が変わらなかったため確認しない";
            if (double.IsNaN(value) || double.IsNaN(baseline)) return $"{label}: 測定できなかった";
            return $"{label}: {skipReason ?? "確認できない"}のため確認しない({baseline:0.00}→{value:0.00})";
        }

        /// <summary>動かした側が飽和していて確認しない向きの説明(飽和画素の割合は判定に使う最大値)</summary>
        private static string DescribeSaturatedSide(string label, double baseline, HwTestMeasurement side) {
            return $"{label}: 飽和のため確認しない(飽和割合 {side.SaturatedFraction * 100:0.00}%.{baseline:0.00}→{side.Mean:0.00})";
        }

        /// <summary>
        /// 読み戻した値の元の値に対する相対誤差(|読み戻し − 元| / |元|).
        /// NaN を含む場合は NaN,元が 0 の場合は読み戻しも 0 なら 0・そうでなければ正の無限大.
        /// </summary>
        public static double RelativeError(double expected, double actual) {
            if (double.IsNaN(expected) || double.IsNaN(actual)) return double.NaN;
            var diff = Math.Abs(actual - expected);
            if (expected == 0) return diff == 0 ? 0 : double.PositiveInfinity;
            return diff / Math.Abs(expected);
        }

        /// <summary>
        /// 元に戻した設定値の読み戻しの説明(丸めない値(往復できる "R" 書式)と相対誤差(%)).
        /// 例: 「露光 読み戻し 0.0166666666666667(元 0.0166666666666667.相対誤差 0%)」
        /// </summary>
        public static string DescribeRestoredValue(string name, double expected, double actual) {
            var inv = System.Globalization.CultureInfo.InvariantCulture;
            var err = RelativeError(expected, actual);
            var errText = double.IsNaN(err) ? "測定できない"
                : double.IsInfinity(err) ? "—(元が 0)"
                : (err * 100).ToString("0.######", inv) + "%";
            return $"{name} 読み戻し {actual.ToString("R", inv)}(元 {expected.ToString("R", inv)}.相対誤差 {errText})";
        }

        /// <summary>点灯時と消灯時の平均輝度の差が下限以上か</summary>
        public static bool JudgeLight(double onMean, double offMean, double minDelta) {
            if (double.IsNaN(onMean) || double.IsNaN(offMean)) return false;
            return onMean - offMean >= minDelta;
        }

        /// <summary>所要時間が上限以内か</summary>
        public static bool WithinLimit(TimeSpan elapsed, TimeSpan limit) {
            return elapsed <= limit;
        }

        /// <summary>元に戻した後の平均輝度が最初の平均輝度から許容内か</summary>
        public static bool IsRestored(double baseline, double after, double tolerance) {
            if (double.IsNaN(baseline) || double.IsNaN(after)) return false;
            return Math.Abs(after - baseline) <= tolerance;
        }

        /// <summary>
        /// ウォッチドッグの判定.全体の上限を先に見る(全体を超えたら項目の状況によらず打ち切る).
        /// 実行中の項目が無い(itemLimit が null)ときは項目の上限を見ない.
        /// </summary>
        public static HwTestOverdue FindOverdue(TimeSpan overallElapsed, TimeSpan overallLimit,
                TimeSpan itemElapsed, TimeSpan? itemLimit) {
            if (overallElapsed > overallLimit) return HwTestOverdue.Overall;
            if (itemLimit.HasValue && itemElapsed > itemLimit.Value) return HwTestOverdue.Item;
            return HwTestOverdue.None;
        }

        /// <summary>
        /// 実機テストの間,カメラのフレームコールバックでの校正処理を止めるか.
        /// チャート不要の検査(--with-chart なし)ではカメラの前にチャート(または白板)をはめたまま
        /// 露光・ゲイン・光源を検査するため,校正(ホワイトバランスの調整等)が走ると測定が乱れる.そこで止める.
        /// チャートが必要な検査(--with-chart.校正そのものを検査する)では止めない.
        /// </summary>
        /// <param name="withChart">--with-chart が指定されたか</param>
        public static bool ShouldSuppressCalibration(bool withChart) {
            return !withChart;
        }

        /// <summary>
        /// 校正を止めているはずなのに校正が始まった(または済んだ)か.真なら校正の停止が効いていない(不合格).
        /// 校正を止めていない場合は常に偽(校正が走るのは想定どおり).
        /// </summary>
        /// <param name="suppressed">校正を止めているか(ShouldSuppressCalibration の結果)</param>
        /// <param name="isCalibrating">校正中か(CameraBase.IsCalibrating)</param>
        /// <param name="isCalibrated">校正済みか(CameraBase.HwTestIsCalibrated)</param>
        public static bool IsCalibrationLeaked(bool suppressed, bool isCalibrating, bool isCalibrated) {
            return suppressed && (isCalibrating || isCalibrated);
        }

        /// <summary>
        /// 実機テストの終了コード.指定機種が見つからない(実機テスト自体を行えない)ときは 2,
        /// 不合格の項目があれば 1,すべて合格なら 0(タイムアウトの 3 はウォッチドッグが返す).
        /// </summary>
        public static int DecideExitCode(bool cameraNotFound, int failedCount) {
            if (cameraNotFound) return HwTestExitCodes.Error;
            return failedCount > 0 ? HwTestExitCodes.Fail : HwTestExitCodes.Pass;
        }

        /// <summary>
        /// 作業フォルダ(%TEMP%\TIASshot-hwtest\work-*)を残すか.--hwtest-keep の指定があれば常に残す.
        /// 不合格(1)・タイムアウト(3)は証拠として残し,合格(0)と前提を満たさずに終わった場合(2: 引数不正・指定機種が見つからない等)は消す.
        /// </summary>
        /// <param name="keepRequested">--hwtest-keep が指定されたか</param>
        /// <param name="exitCode">実機テストの終了コード(HwTestExitCodes)</param>
        public static bool ShouldKeepWorkDir(bool keepRequested, int exitCode) {
            if (keepRequested) return true;
            return exitCode == HwTestExitCodes.Fail || exitCode == HwTestExitCodes.Timeout;
        }

        // ================================================================================
        // タイムアウト時の記録(スタック・ミニダンプ)
        // ================================================================================

        /// <summary>
        /// タイムアウト時のミニダンプの保存先(作業フォルダ直下の hang-&lt;日時&gt;.dmp).
        /// 作業フォルダはタイムアウト時に残る(ShouldKeepWorkDir)ため,ダンプも一緒に残る.
        /// </summary>
        /// <param name="workDir">作業フォルダ(%TEMP%\TIASshot-hwtest\work-*)</param>
        /// <param name="now">タイムアウトを検知した時刻</param>
        public static string HangDumpPath(string workDir, DateTime now) {
            return System.IO.Path.Combine(workDir, $"hang-{now:yyyyMMdd-HHmmss}.dmp");
        }

        /// <summary>
        /// タイムアウト時に書くミニダンプの種類.マネージドのスタックを後から見られるよう全メモリのダンプを優先する.
        /// プライベートメモリが FullDumpMaxPrivateBytes を超える(ダンプが数 GB を超える)か,
        /// 空き容量が「プライベートメモリ+ FullDumpDiskMargin」に満たない場合は小さいダンプにし,
        /// 小さいダンプの分(SmallDumpMinFreeBytes)も空いていなければ書かない.
        /// プライベートメモリが分からない(負)場合は大きさを見積もれないため小さいダンプにする.
        /// </summary>
        /// <param name="privateBytes">自プロセスのプライベートメモリ(バイト.不明なら負)</param>
        /// <param name="freeDiskBytes">保存先のドライブの空き容量(バイト.不明なら負)</param>
        public static HwTestDumpKind ChooseDumpKind(long privateBytes, long freeDiskBytes) {
            if (freeDiskBytes < HwTestThresholds.SmallDumpMinFreeBytes) return HwTestDumpKind.None;
            if (privateBytes < 0 || privateBytes > HwTestThresholds.FullDumpMaxPrivateBytes) return HwTestDumpKind.Small;
            // 加算のあふれは privateBytes の上限(3 GB)で起きない
            if (freeDiskBytes < privateBytes + HwTestThresholds.FullDumpDiskMargin) return HwTestDumpKind.Small;
            return HwTestDumpKind.Full;
        }

        /// <summary>
        /// ミニダンプの種類に対応する MINIDUMP_TYPE のフラグ(dbghelp.h の値).
        /// Full: MiniDumpWithFullMemory(0x2) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithFullMemoryInfo(0x800) | WithThreadInfo(0x1000)
        /// (procdump -ma と同じ組み合わせ.マネージドのスタック・ヒープを SOS・Visual Studio で解析するには全メモリが要る).
        /// Small: WithDataSegs(0x1) | WithHandleData(0x4) | WithUnloadedModules(0x20) | WithIndirectlyReferencedMemory(0x40) | WithProcessThreadData(0x100) | WithThreadInfo(0x1000)
        /// (ネイティブのスタックと,スタックから参照されるメモリ.マネージドのスタックは結果ファイルの文字列で補う).
        /// None は 0(呼び出し側は書かない).
        /// </summary>
        public static uint DumpTypeFlags(HwTestDumpKind kind) {
            switch (kind) {
                case HwTestDumpKind.Full: return 0x2 | 0x4 | 0x20 | 0x800 | 0x1000;
                case HwTestDumpKind.Small: return 0x1 | 0x4 | 0x20 | 0x40 | 0x100 | 0x1000;
                default: return 0;
            }
        }

        // ================================================================================
        // チャートが必要な検査(--with-chart)
        // ================================================================================

        /// <summary>
        /// 実行する検査の ID の一覧(結果ファイルの順序).チャート不要の検査と,チャートが必要な検査(--with-chart)は別に実行する
        /// (校正が走ると露光・ゲイン・光源の測定が乱れるため.connect と cleanup は共通).
        /// </summary>
        public static string[] CheckIds(bool withChart) {
            return withChart
                ? new[] { "connect", "calibrate", "shot", "close-saving", "close-calibrating", "cleanup" }
                : new[] { "connect", "preview", "exposure-gain", "light", "close-preview", "reconnect", "cleanup" };
        }

        /// <summary>実機テスト全体の上限(チャートが必要な検査は校正・保存を待つため長い)</summary>
        public static TimeSpan OverallTimeout(bool withChart) {
            return withChart ? HwTestThresholds.OverallTimeoutWithChart : HwTestThresholds.OverallTimeout;
        }

        /// <summary>
        /// 2 つの時刻(Stopwatch のタイムスタンプ)の差(ms).どちらかが 0(まだ記録されていない)・逆順・frequency が 0 以下なら NaN.
        /// </summary>
        public static double TimestampDeltaMs(long from, long to, long frequency) {
            if (from == 0 || to == 0 || to < from || frequency <= 0) return double.NaN;
            return (to - from) * 1000.0 / frequency;
        }

        /// <summary>
        /// 校正後のフレームを変換した色票の誤差が上限内か.
        /// 差の絶対値の平均 ≦ maxMean かつ 白色票を除いた最大 ≦ maxOtherMax かつ 白色票の最大 ≦ maxWhiteMax.
        /// どれかが NaN(測定できない)なら false.
        /// </summary>
        /// <param name="meanAbs">全色票 × BGR の差の絶対値の平均(PatchColorErrorStats.MeanAbs)</param>
        /// <param name="otherMaxAbs">白色票を除いた差の絶対値の最大(PatchColorError.MaxAbsExcluding)</param>
        /// <param name="whiteMaxAbs">白色票の差の絶対値の最大(PatchColorError.PatchMaxAbs)</param>
        public static bool JudgeColorError(double meanAbs, double otherMaxAbs, double whiteMaxAbs, double maxMean, double maxOtherMax, double maxWhiteMax) {
            if (double.IsNaN(meanAbs) || double.IsNaN(otherMaxAbs) || double.IsNaN(whiteMaxAbs)) return false;
            return meanAbs <= maxMean && otherMaxAbs <= maxOtherMax && whiteMaxAbs <= maxWhiteMax;
        }

        /// <summary>sRGB 8bit 階調の下端(これ以下は黒つぶれ)</summary>
        public const double Srgb8Min = 0.0;

        /// <summary>sRGB 8bit 階調の上端(これ以上は白飛び)</summary>
        public const double Srgb8Max = 255.0;

        /// <summary>
        /// 色票内の画素の値(BGR の並び)の集計(白飛び・黒つぶれの調査用.参考値).
        /// 8bit 画像(0〜255 に切り詰め済み)でも,8bit に切り詰める前の値(範囲外を含む)でも使う.
        /// </summary>
        /// <param name="bgr">画素の値(B0, G0, R0, B1, G1, R1, ....長さは 3 の倍数)</param>
        /// <exception cref="ArgumentNullException">bgr が null の場合</exception>
        /// <exception cref="ArgumentException">長さが 3 の倍数でない場合</exception>
        public static HwTestPatchPixelStats SummarizePatchPixels(IReadOnlyList<double> bgr) {
            if (bgr == null) throw new ArgumentNullException(nameof(bgr));
            if (bgr.Count % 3 != 0) throw new ArgumentException($"画素の値の並びの長さが 3 の倍数ではありません: {bgr.Count}", nameof(bgr));
            var n = bgr.Count / 3;
            var s = new HwTestPatchPixelStats { Pixels = n };
            if (n == 0) {
                for (var j = 0; j < 3; j++) s.Mean[j] = s.Min[j] = s.Max[j] = double.NaN;
                s.FractionAnyAtOrAboveMax = s.FractionAnyAtOrBelowMin = s.FractionAnyAboveMax = s.FractionAnyBelowMin = double.NaN;
                return s;
            }
            var sum = new double[3];
            for (var j = 0; j < 3; j++) {
                s.Min[j] = double.PositiveInfinity;
                s.Max[j] = double.NegativeInfinity;
            }
            int atOrAbove = 0, atOrBelow = 0, above = 0, below = 0;
            for (var i = 0; i < n; i++) {
                bool anyAtOrAbove = false, anyAtOrBelow = false, anyAbove = false, anyBelow = false;
                for (var j = 0; j < 3; j++) {
                    var v = bgr[i * 3 + j];
                    sum[j] += v;
                    if (v < s.Min[j]) s.Min[j] = v;
                    if (v > s.Max[j]) s.Max[j] = v;
                    if (v >= Srgb8Max) anyAtOrAbove = true;
                    if (v > Srgb8Max) anyAbove = true;
                    if (v <= Srgb8Min) anyAtOrBelow = true;
                    if (v < Srgb8Min) anyBelow = true;
                }
                if (anyAtOrAbove) atOrAbove++;
                if (anyAtOrBelow) atOrBelow++;
                if (anyAbove) above++;
                if (anyBelow) below++;
            }
            for (var j = 0; j < 3; j++) s.Mean[j] = sum[j] / n;
            s.FractionAnyAtOrAboveMax = (double)atOrAbove / n;
            s.FractionAnyAtOrBelowMin = (double)atOrBelow / n;
            s.FractionAnyAboveMax = (double)above / n;
            s.FractionAnyBelowMin = (double)below / n;
            return s;
        }

        /// <summary>
        /// 撮影ログ(Info.csv)の行から撮影の行を取り出す.見出し行「Shot,Time(ms),Note」の後の各行を
        /// 「番号,時刻(ms)[,備考]」として読む(備考は RFC 4180 のエスケープのまま残す).空行は飛ばす.
        /// </summary>
        /// <param name="lines">Info.csv の全行</param>
        /// <param name="error">読めない場合の理由(見出し行が無い・数値でない行).読めたら null</param>
        /// <returns>撮影の行(読めない場合も読めた分までを返す)</returns>
        public static List<HwTestShotLogRow> ParseShotLog(IReadOnlyList<string> lines, out string error) {
            error = null;
            var rows = new List<HwTestShotLogRow>();
            if (lines == null) {
                error = "撮影ログがありません";
                return rows;
            }
            var header = -1;
            for (var i = 0; i < lines.Count; i++) {
                if (lines[i] == "Shot,Time(ms),Note") {
                    header = i;
                    break;
                }
            }
            if (header < 0) {
                error = "見出し行「Shot,Time(ms),Note」がありません";
                return rows;
            }
            var inv = System.Globalization.CultureInfo.InvariantCulture;
            for (var i = header + 1; i < lines.Count; i++) {
                var line = lines[i];
                if (string.IsNullOrWhiteSpace(line)) continue;
                var parts = line.Split(new[] { ',' }, 3);
                int shot;
                long time;
                if (parts.Length < 2
                        || !int.TryParse(parts[0], System.Globalization.NumberStyles.Integer, inv, out shot)
                        || !long.TryParse(parts[1], System.Globalization.NumberStyles.Integer, inv, out time)) {
                    error = $"{i + 1} 行目を撮影の行として読めません: {line}";
                    return rows;
                }
                rows.Add(new HwTestShotLogRow { Shot = shot, TimeMs = time, Note = parts.Length > 2 ? parts[2] : "" });
            }
            return rows;
        }

        /// <summary>
        /// 撮影時刻の間隔と設定値の差の絶対値の最大(ms).時刻が 2 つ未満なら NaN(間隔が無い).
        /// </summary>
        public static double MaxIntervalDeviationMs(IReadOnlyList<long> timesMs, int intervalMs) {
            if (timesMs == null || timesMs.Count < 2) return double.NaN;
            double max = 0;
            for (var i = 1; i < timesMs.Count; i++) {
                var dev = Math.Abs((timesMs[i] - timesMs[i - 1]) - (double)intervalMs);
                if (dev > max) max = dev;
            }
            return max;
        }

        /// <summary>
        /// 1 回の撮影で保存されるはずのファイル名の一覧(撮影ログ Info.csv・撮影画像 Shot{NO}・変換行列の項目数ごとの sRGB 変換画像 sRGB{CN}_{NO}).
        /// 名前の組み立ては CameraBase.SaveImages と同じ({NO} は 4 桁の 1 始まり,{CN} は 2 桁).
        /// </summary>
        /// <param name="rgbPattern">File/RgbImage(例 Shot{NO}.png)</param>
        /// <param name="srgbPattern">File/SrgbImage(例 sRGB{CN}_{NO}.jpg)</param>
        /// <param name="infoName">File/Info(例 Info.csv)</param>
        /// <param name="channels">変換行列の項目数の一覧(null は無し)</param>
        /// <param name="count">撮影枚数</param>
        /// <exception cref="ArgumentNullException">名前の書式が null の場合</exception>
        public static List<string> ExpectedShotFiles(string rgbPattern, string srgbPattern, string infoName, IEnumerable<int> channels, int count) {
            if (rgbPattern == null) throw new ArgumentNullException(nameof(rgbPattern));
            if (srgbPattern == null) throw new ArgumentNullException(nameof(srgbPattern));
            if (infoName == null) throw new ArgumentNullException(nameof(infoName));
            var names = new List<string> { infoName };
            var channelList = channels?.ToList() ?? new List<int>();
            for (var i = 0; i < count; i++) {
                var no = $"{i + 1:0000}";
                names.Add(rgbPattern.Replace("{NO}", no));
                foreach (var ch in channelList) {
                    names.Add(srgbPattern.Replace("{CN}", $"{ch:00}").Replace("{NO}", no));
                }
            }
            return names;
        }

        /// <summary>
        /// 期待したファイル名と実際のファイル名を比べ,足りないもの(missing)と余分なもの(unexpected)を返す(大文字小文字は区別しない.名前順).
        /// </summary>
        public static void CompareFileNames(IEnumerable<string> expected, IEnumerable<string> actual, out List<string> missing, out List<string> unexpected) {
            var exp = new HashSet<string>(expected ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
            var act = new HashSet<string>(actual ?? Enumerable.Empty<string>(), StringComparer.OrdinalIgnoreCase);
            missing = exp.Where(x => !act.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
            unexpected = act.Where(x => !exp.Contains(x)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
        }

        /// <summary>
        /// 保存中に閉じる検査で,連続撮影の直後に保存中の状態を作れたかの判断.
        /// 作れたら Ready,作れず試行が残っていれば Retry,残っていなければ GiveUp(FAIL ではなく WARN にする).
        /// </summary>
        /// <param name="savingObserved">連続撮影の直後に保存中(CameraBase.IsSaving)だったか</param>
        /// <param name="attempt">何回目の試行か(1 始まり)</param>
        /// <param name="maxAttempts">試行の回数の上限</param>
        public static HwTestSavingAttempt DecideSavingAttempt(bool savingObserved, int attempt, int maxAttempts) {
            if (savingObserved) return HwTestSavingAttempt.Ready;
            return attempt < maxAttempts ? HwTestSavingAttempt.Retry : HwTestSavingAttempt.GiveUp;
        }

        /// <summary>保存中に閉じる検査の試行ごとの連続撮影の枚数(1 回目は CloseSavingShotCount,2 回目以降は CloseSavingRetryShotCount)</summary>
        public static int CloseSavingShotCount(int attempt) {
            return attempt <= 1 ? HwTestThresholds.CloseSavingShotCount : HwTestThresholds.CloseSavingRetryShotCount;
        }

        /// <summary>
        /// 保存中に閉じようとしたときに,閉じるのが正しく取り消されたかを調べ,問題点を返す(空なら合格).
        /// 期待: 閉じると決まらない(e.Cancel)・表示が expectedMessage・後始末の段階が 1 つも進まない・閉じ始めのフラグが立たない・本モニタの再描画タイマーが動いたまま.
        /// </summary>
        /// <param name="closeAccepted">閉じる処理が閉じると決めたか(HwTestRequestClose の戻り値)</param>
        /// <param name="shownMessage">閉じる処理の直後のステータス表示</param>
        /// <param name="expectedMessage">期待する表示(Form1.SavingOnCloseMessage)</param>
        /// <param name="stepsAdded">閉じる処理で増えた後始末の段階の数</param>
        /// <param name="isClosing">閉じ始めのフラグ</param>
        /// <param name="refreshTimerRunning">本モニタの再描画タイマーが動作中か</param>
        public static List<string> CancelledCloseProblems(bool closeAccepted, string shownMessage, string expectedMessage,
                int stepsAdded, bool isClosing, bool refreshTimerRunning) {
            var problems = new List<string>();
            if (closeAccepted) problems.Add("保存中なのに閉じると決まりました(取り消されていません)");
            if (!string.Equals(shownMessage, expectedMessage, StringComparison.Ordinal)) {
                problems.Add($"表示が「{shownMessage}」です(期待「{expectedMessage}」)");
            }
            if (stepsAdded != 0) problems.Add($"後始末が {stepsAdded} 段階進みました(保存中は後始末を走らせない)");
            if (isClosing) problems.Add("閉じ始めのフラグが立っています");
            if (!refreshTimerRunning) problems.Add("本モニタの再描画タイマーが止まっています");
            return problems;
        }

        /// <summary>閉じる処理の後始末の段階の正しい順序(Form1.HwTestCloseSteps に記録される名前.close-preview・close-saving・close-calibrating 共通)</summary>
        public static readonly IReadOnlyList<string> CloseSequence = new[] { "closing", "timer", "camera", "light" };

        /// <summary>
        /// 閉じる処理の後始末の段階の記録のうち,startIndex 以降が「closing → timer → camera → light」(CloseSequence)の順に過不足なく並んでいるか
        /// (記録は閉じる処理のたびに足されていくため,その回の閉じる処理の前の件数を startIndex に渡す).
        /// </summary>
        public static bool IsCloseSequenceSince(IReadOnlyList<string> steps, int startIndex) {
            if (steps == null || startIndex < 0 || startIndex > steps.Count) return false;
            if (steps.Count - startIndex != CloseSequence.Count) return false;
            for (var i = 0; i < CloseSequence.Count; i++) {
                if (!string.Equals(steps[startIndex + i], CloseSequence[i], StringComparison.Ordinal)) return false;
            }
            return true;
        }

        /// <summary>後始末の段階の記録のうち startIndex 以降(その回の閉じる処理の分)</summary>
        public static List<string> StepsSince(IReadOnlyList<string> steps, int startIndex) {
            if (steps == null) return new List<string>();
            return steps.Skip(Math.Max(0, startIndex)).ToList();
        }

        private static void ValidateRange(double current, double min, double max) {
            if (!IsFinite(current) || !IsFinite(min) || !IsFinite(max)) {
                throw new ArgumentException($"設定値・範囲に非有限値が含まれます: {current}({min}〜{max})");
            }
            if (min > max) throw new ArgumentException($"範囲が不正です: {min}〜{max}");
        }

        private static bool IsFinite(double value) {
            return !double.IsNaN(value) && !double.IsInfinity(value);
        }

        /// <summary>値を min〜max に収める(NaN はそのまま返す).機種ごとのアダプタが設定値を範囲内に収めるのにも使う</summary>
        internal static double Clamp(double value, double min, double max) {
            if (value < min) return min;
            if (value > max) return max;
            return value;
        }
    }

    /// <summary>実機テストの終了コード(HWTEST 限定)</summary>
    internal static class HwTestExitCodes {
        public const int Pass = 0;
        public const int Fail = 1;
        public const int Error = 2;
        public const int Timeout = 3;
    }
}
#endif