Просмотр исходного кода

feat(d2-02-t3): CalibrationCoordinator标定协作状态机(逐孔Pending/Running/合格/伪峰/失败+进度+中止+重标,可注入runner)+单测(TDD)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 дней назад
Родитель
Сommit
abb14ef7dd

+ 190 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/CalibrationCoordinatorTests.cs

@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using IvfTl.AutoFocus.Calib;
+using IvfTl.ControlHost.Debug;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    /// <summary>
+    /// Task3.1 标定协作器单测(TDD)。用假 calibrateOne 委托驱动纯状态机,不碰真相机。
+    /// 后台线程编排用 synchronous=true 选项同步执行做确定性断言。
+    /// </summary>
+    public class CalibrationCoordinatorTests
+    {
+        // 合格阈值与现网一致(>1.2)。
+        const double Threshold = 1.2;
+
+        static WellCalib Good(int well) => new WellCalib
+        {
+            Well = well, CircleFound = true, PeakRatio = 2.0,
+            FocusZ = 1000 + well, Exposure = 500 + well, CenterOffsetPct = 1.5 + well, Note = "ok"
+        };
+
+        static WellCalib Weak(int well) => new WellCalib
+        {
+            Well = well, CircleFound = true, PeakRatio = 1.0, FocusZ = 7, Note = "弱峰"
+        };
+
+        CalibrationCoordinator New(Func<int, WellCalib> runner)
+            => new CalibrationCoordinator(runner, Threshold, log: _ => { });
+
+        // 1. 全合格 → 每孔 Qualified、completed==total、IsRunning 最终 false。
+        [Fact]
+        public void Start_AllPass_AllQualified()
+        {
+            var wells = new List<int> { 1, 2, 3 };
+            var co = New(Good);
+            co.Start(wells, synchronous: true);
+
+            var p = co.GetProgress();
+            Assert.False(p.IsRunning);
+            Assert.Equal(3, p.Total);
+            Assert.Equal(3, p.Completed);
+            Assert.Null(p.CurrentWell);
+            Assert.All(p.Wells, w => Assert.Equal(WellCalibState.Qualified, w.State));
+        }
+
+        // 2. 某孔 PeakRatio<=阈值 → 该孔 FakePeak,其余仍 Qualified。
+        [Fact]
+        public void Start_WeakPeak_MarksFakePeak()
+        {
+            var wells = new List<int> { 1, 2, 3 };
+            var co = New(w => w == 2 ? Weak(w) : Good(w));
+            co.Start(wells, synchronous: true);
+
+            var p = co.GetProgress();
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.Equal(WellCalibState.FakePeak, p.Wells.Single(x => x.WellSn == 2).State);
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 3).State);
+            Assert.Equal(3, p.Completed);
+        }
+
+        // 3. calibrateOne 抛异常的孔 → Failed,不影响后续孔继续。
+        [Fact]
+        public void Start_RunnerThrows_MarksFailed_ContinuesRest()
+        {
+            var wells = new List<int> { 1, 2, 3 };
+            var co = New(w => w == 2 ? throw new InvalidOperationException("boom") : Good(w));
+            co.Start(wells, synchronous: true);
+
+            var p = co.GetProgress();
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.Equal(WellCalibState.Failed, p.Wells.Single(x => x.WellSn == 2).State);
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 3).State);
+            Assert.Equal(3, p.Completed);
+        }
+
+        // 3b. null 结果也 → Failed。
+        [Fact]
+        public void Start_RunnerReturnsNull_MarksFailed()
+        {
+            var co = New(w => w == 1 ? null : Good(w));
+            co.Start(new List<int> { 1, 2 }, synchronous: true);
+            var p = co.GetProgress();
+            Assert.Equal(WellCalibState.Failed, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 2).State);
+        }
+
+        // 4. Stop 后当前孔跑完即停,剩余孔保持 Pending。
+        // 用后台线程 + 闸门:第1孔进入 runner 时发信号并阻塞,主线程见到后 Stop(),再放行。
+        [Fact]
+        public void Stop_FinishesCurrentWell_LeavesRestPending()
+        {
+            var wells = new List<int> { 1, 2, 3, 4 };
+            var entered = new ManualResetEventSlim(false);
+            var release = new ManualResetEventSlim(false);
+            int calls = 0;
+
+            var co = New(w =>
+            {
+                Interlocked.Increment(ref calls);
+                entered.Set();
+                release.Wait(2000);
+                return Good(w);
+            });
+
+            co.Start(wells); // 后台线程异步
+            Assert.True(entered.Wait(2000), "第1孔应进入 runner");
+            co.Stop();
+            release.Set();
+
+            // 等后台线程收尾
+            Assert.True(SpinUntil(() => !co.GetProgress().IsRunning, 2000), "Stop 后应停下");
+
+            var p = co.GetProgress();
+            Assert.Equal(1, calls); // 只跑了第1孔
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.All(p.Wells.Where(x => x.WellSn != 1),
+                       w => Assert.Equal(WellCalibState.Pending, w.State));
+        }
+
+        // 5. GetProgress 每孔字段来自 WellCalib(focusZ/exposure/peakRatio/centerOffsetPct/note)。
+        [Fact]
+        public void GetProgress_CopiesFieldsFromWellCalib()
+        {
+            var co = New(Good);
+            co.Start(new List<int> { 5 }, synchronous: true);
+            var w = co.GetProgress().Wells.Single();
+            Assert.Equal(5, w.WellSn);
+            Assert.Equal(1005, w.FocusZ);
+            Assert.Equal(505, w.Exposure);
+            Assert.Equal(2.0, w.PeakRatio);
+            Assert.Equal(6.5, w.CenterOffsetPct);
+            Assert.Equal("ok", w.Note);
+        }
+
+        // 6. Recalibrate 单孔重标:把该孔重置后再跑一次 runner。
+        [Fact]
+        public void Recalibrate_RerunsSingleWell()
+        {
+            var map = new Dictionary<int, WellCalib> { [1] = Weak(1), [2] = Good(2) };
+            var co = New(w => map[w]);
+            co.Start(new List<int> { 1, 2 }, synchronous: true);
+            Assert.Equal(WellCalibState.FakePeak, co.GetProgress().Wells.Single(x => x.WellSn == 1).State);
+
+            // 第1孔修好了再重标
+            map[1] = Good(1);
+            co.Recalibrate(1, synchronous: true);
+            var p = co.GetProgress();
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 1).State);
+            Assert.Equal(WellCalibState.Qualified, p.Wells.Single(x => x.WellSn == 2).State);
+        }
+
+        // 7. 重入保护:已在跑时再 Start 被忽略(busy),不重复跑。
+        [Fact]
+        public void Start_WhileRunning_IsIgnored()
+        {
+            var entered = new ManualResetEventSlim(false);
+            var release = new ManualResetEventSlim(false);
+            int calls = 0;
+            var co = New(w =>
+            {
+                Interlocked.Increment(ref calls);
+                entered.Set();
+                release.Wait(2000);
+                return Good(w);
+            });
+
+            co.Start(new List<int> { 1, 2 });
+            Assert.True(entered.Wait(2000));
+            bool second = co.Start(new List<int> { 1, 2 }); // 应被拒
+            Assert.False(second);
+            release.Set();
+            Assert.True(SpinUntil(() => !co.GetProgress().IsRunning, 2000));
+        }
+
+        static bool SpinUntil(Func<bool> cond, int timeoutMs)
+        {
+            var sw = System.Diagnostics.Stopwatch.StartNew();
+            while (sw.ElapsedMilliseconds < timeoutMs)
+            {
+                if (cond()) return true;
+                Thread.Sleep(10);
+            }
+            return cond();
+        }
+    }
+}

+ 225 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/CalibrationCoordinator.cs

@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using IvfTl.AutoFocus.Calib;
+
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>
+    /// 单 well 标定状态(spec M2-05,与 operate WellCalibUiItem 语义一致):
+    /// 待标定/标定中/合格(绿)/伪峰(橙)/失败(红)。
+    /// </summary>
+    public enum WellCalibState { Pending, Running, Qualified, FakePeak, Failed }
+
+    /// <summary>
+    /// 单 well 进度快照 DTO(供 operate 轮询渲染 16 格;字段来自 WellCalib)。
+    /// </summary>
+    public sealed class WellCalibProgress
+    {
+        public int WellSn;
+        public WellCalibState State;
+        public int FocusZ;
+        public int Exposure;
+        public double PeakRatio;
+        public double CenterOffsetPct;
+        public bool CircleFound;
+        public string Note = "";
+    }
+
+    /// <summary>
+    /// 整次标定任务进度快照(协作器 GetProgress 返回的不可变拷贝,轮询安全)。
+    /// </summary>
+    public sealed class CalibProgress
+    {
+        public int Total;
+        public int Completed;          // 已出结果(Qualified/FakePeak/Failed)的孔数
+        public int? CurrentWell;       // 当前正在标定的孔(无则 null)
+        public bool IsRunning;
+        public IReadOnlyList<WellCalibProgress> Wells = Array.Empty<WellCalibProgress>();
+    }
+
+    /// <summary>
+    /// 调试页"16 孔标定"协作器:状态机 + 逐孔编排 + 进度 + 中止 + 单孔重标。
+    /// 纯逻辑(单孔标定抽象成可注入委托 calibrateOne),真实硬件/HTTP/推流接线见 Task3.2。
+    /// 线程安全:进度读写全程持 _lock;后台线程逐孔跑,operate 轮询 GetProgress 取拷贝。
+    /// </summary>
+    public sealed class CalibrationCoordinator
+    {
+        // 标定单孔:给 wellSn,返回该孔结果。真实实现=注入 per-well 范围 + CalibrationEngine.CalibrateWell(Task3.2 接);测试=假委托。
+        private readonly Func<int, WellCalib> _calibrateOne;
+        // 合格阈值(真实=tl_setting.focusPeakRatioThreshold??1.2)。
+        private readonly double _peakThreshold;
+        private readonly Action<string> _log;
+
+        private readonly object _lock = new object();
+        // 孔状态表(按 wells 顺序),状态/结果均在 _lock 下读写。
+        private readonly List<WellCalibProgress> _wells = new List<WellCalibProgress>();
+        private int? _currentWell;
+        private bool _running;
+        private volatile bool _stop;
+        private Thread _worker;
+
+        public CalibrationCoordinator(Func<int, WellCalib> calibrateOne, double peakThreshold, Action<string> log = null)
+        {
+            _calibrateOne = calibrateOne ?? throw new ArgumentNullException(nameof(calibrateOne));
+            _peakThreshold = peakThreshold;
+            _log = log ?? (_ => { });
+        }
+
+        /// <summary>
+        /// 起一次标定:置每孔 Pending,逐孔跑。默认后台线程异步(不阻塞调用方);
+        /// synchronous=true 则在当前线程同步跑完(供单测确定性断言)。
+        /// 重入保护:已在跑则忽略返回 false。
+        /// </summary>
+        public bool Start(IReadOnlyList<int> wells, bool synchronous = false)
+        {
+            if (wells == null || wells.Count == 0) return false;
+            lock (_lock)
+            {
+                if (_running) { _log("[calib] 已在标定中,忽略重复 Start"); return false; }
+                _running = true;
+                _stop = false;
+                _currentWell = null;
+                _wells.Clear();
+                foreach (var w in wells)
+                    _wells.Add(new WellCalibProgress { WellSn = w, State = WellCalibState.Pending });
+            }
+
+            if (synchronous) { RunLoop(); return true; }
+            _worker = new Thread(RunLoop) { IsBackground = true, Name = "CalibrationCoordinator" };
+            _worker.Start();
+            return true;
+        }
+
+        /// <summary>置中止标志,当前孔跑完即停(剩余孔保持 Pending)。</summary>
+        public void Stop()
+        {
+            _stop = true;
+            _log("[calib] 收到中止请求");
+        }
+
+        /// <summary>
+        /// 单孔重标:把该孔置 Pending 再跑一次单孔内核(与逐孔编排共用 CalibrateOneWell)。
+        /// 已在批量跑则忽略返回 false。synchronous 同 Start。
+        /// </summary>
+        public bool Recalibrate(int wellSn, bool synchronous = false)
+        {
+            lock (_lock)
+            {
+                if (_running) { _log("[calib] 标定中,忽略单孔重标"); return false; }
+                var target = _wells.FirstOrDefault(x => x.WellSn == wellSn);
+                if (target == null) { target = new WellCalibProgress { WellSn = wellSn }; _wells.Add(target); }
+                target.State = WellCalibState.Pending;
+                _running = true;
+                _stop = false;
+            }
+
+            void Run()
+            {
+                try { CalibrateOneWell(wellSn); }
+                finally { lock (_lock) { _running = false; _currentWell = null; } }
+            }
+
+            if (synchronous) { Run(); return true; }
+            _worker = new Thread(Run) { IsBackground = true, Name = "CalibrationCoordinator.Recalibrate" };
+            _worker.Start();
+            return true;
+        }
+
+        /// <summary>返回进度快照(深拷贝,轮询安全)。</summary>
+        public CalibProgress GetProgress()
+        {
+            lock (_lock)
+            {
+                var snap = _wells.Select(w => new WellCalibProgress
+                {
+                    WellSn = w.WellSn, State = w.State, FocusZ = w.FocusZ, Exposure = w.Exposure,
+                    PeakRatio = w.PeakRatio, CenterOffsetPct = w.CenterOffsetPct,
+                    CircleFound = w.CircleFound, Note = w.Note
+                }).ToList();
+                int completed = snap.Count(w => w.State == WellCalibState.Qualified
+                                             || w.State == WellCalibState.FakePeak
+                                             || w.State == WellCalibState.Failed);
+                return new CalibProgress
+                {
+                    Total = snap.Count,
+                    Completed = completed,
+                    CurrentWell = _currentWell,
+                    IsRunning = _running,
+                    Wells = snap
+                };
+            }
+        }
+
+        // 后台/同步逐孔编排:每孔间检查中止;单孔 try/catch 内核保证不崩整任务。
+        private void RunLoop()
+        {
+            try
+            {
+                List<int> order;
+                lock (_lock) { order = _wells.Select(w => w.WellSn).ToList(); }
+                foreach (var well in order)
+                {
+                    if (_stop) { _log("[calib] 已中止,停止逐孔"); break; }
+                    CalibrateOneWell(well);
+                }
+            }
+            finally
+            {
+                lock (_lock) { _running = false; _currentWell = null; }
+            }
+        }
+
+        // 单孔执行内核(Start 逐孔 / Recalibrate 共用):置 Running→调委托→据结果置 Qualified/FakePeak/Failed + 存快照。
+        private void CalibrateOneWell(int well)
+        {
+            lock (_lock)
+            {
+                _currentWell = well;
+                var item0 = _wells.First(x => x.WellSn == well);
+                item0.State = WellCalibState.Running;
+            }
+
+            WellCalibState state;
+            WellCalib wc = null;
+            try
+            {
+                wc = _calibrateOne(well);
+                if (wc == null)
+                {
+                    state = WellCalibState.Failed;
+                    _log($"[calib] well{well} 结果为空 → 失败");
+                }
+                else
+                {
+                    // 合格判定(与现网一致):圆检出且峰比 > 阈值 → 合格;否则峰弱 → 伪峰。
+                    bool qualified = wc.CircleFound && wc.PeakRatio > _peakThreshold;
+                    state = qualified ? WellCalibState.Qualified : WellCalibState.FakePeak;
+                    _log($"[calib] well{well} ratio={wc.PeakRatio:F2} circle={wc.CircleFound} → {state}");
+                }
+            }
+            catch (Exception ex)
+            {
+                state = WellCalibState.Failed;
+                _log($"[calib] well{well} 标定异常 → 失败: {ex.Message}");
+            }
+
+            lock (_lock)
+            {
+                var item = _wells.First(x => x.WellSn == well);
+                item.State = state;
+                if (wc != null)
+                {
+                    item.FocusZ = wc.FocusZ;
+                    item.Exposure = wc.Exposure;
+                    item.PeakRatio = wc.PeakRatio;
+                    item.CenterOffsetPct = wc.CenterOffsetPct;
+                    item.CircleFound = wc.CircleFound;
+                    item.Note = wc.Note ?? "";
+                }
+                _currentWell = null;
+            }
+        }
+    }
+}