ソースを参照

feat(d2-02-t3): 标定协作端点/debug/calibrate/*+CalibrationManager接真实引擎(借现有DebugSession lease,逐孔注入范围+CalibrateWell+落scene=0基准)

- HouseBin.ReadWellFocusRange private→internal,补 internal 阈值/tlSn 取数口;ivf_tl_Com 加 InternalsVisibleTo(ivf_tl_ControlHost) 仅协作复用不全 public
- AppData(control) 加 public GetHouseBin(houseSn) 复用既有 HouseSnToHouseBin 映射
- CalibrationManager 按 sid 管 CalibrationCoordinator:取该舱 HouseBin/阈值??1.2/tlSn,calibrateOne 闭包逐孔读 ReadWellFocusRange 注入范围+中心DB优先硬件EEPROM兜底+new CalibrationEngine(s.Lease.Serial,Camera).CalibrateWell+SaveCalibration scene=0;全程复用 operate DebugSession lease,绝不再 Acquire/Release
- ControlHttpServer 加 /debug/calibrate/{start,progress,recalibrate,stop} 4 端点(照 /debug/command 形态,POST,sid 校验,_calib==null→Fail)
- Program.cs 装配 CalibrationManager(debugMgr+sn=>AppData.GetHouseBin+log) 注入 ControlHttpServer
- 加 CalibrationManagerTests 4 例(sid 校验路径),ControlHost.Tests 71/71 通过,control.sln Release 0 错

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 日 前
コミット
5e98d9526e

+ 66 - 0
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/CalibrationManagerTests.cs

@@ -0,0 +1,66 @@
+using System;
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using Xunit;
+
+namespace IvfTl.ControlHost.Tests
+{
+    /// <summary>
+    /// D2-02 Task3.2a:CalibrationManager 的 sid 校验路径(不触真实硬件标定)。
+    /// 真实标定要硬件,这里只断言会话校验/字典管理的纯逻辑分支:
+    ///   · 不存在的 sid → StartCalibrate / GetProgress / Recalibrate 返回 SESSION_EXPIRED;
+    ///   · Stop 未知 sid 幂等返回 Okay;
+    ///   · houseBinOf 委托在 SESSION_EXPIRED 路径上绝不被调用(closure 未进入)。
+    /// </summary>
+    public class CalibrationManagerTests
+    {
+        private DateTime _now = new DateTime(2026, 6, 25, 0, 0, 0);
+
+        private (CalibrationManager calib, DebugSessionManager debug, bool[] houseBinTouched) New()
+        {
+            var serial = new FakeSerial();
+            var gate = new FakeGate(7, serial) { CanAcquire = true };
+            var debug = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { });
+            var touched = new bool[1];
+            // houseBinOf:SESSION_EXPIRED 路径下绝不应被调用;被调用则置标志,便于断言。
+            var calib = new CalibrationManager(debug, sn => { touched[0] = true; return null; }, log: _ => { });
+            return (calib, debug, touched);
+        }
+
+        [Fact]
+        public void StartCalibrate_Unknown_Sid_Returns_SESSION_EXPIRED_Without_Touching_HouseBin()
+        {
+            var (calib, _, touched) = New();
+            var r = calib.StartCalibrate("not-a-session", null);
+            Assert.False(r.Ok);
+            Assert.Equal("SESSION_EXPIRED", r.Code);
+            Assert.False(touched[0]);   // 会话校验先于取 HouseBin,委托不被触发
+        }
+
+        [Fact]
+        public void GetProgress_Unknown_Sid_Returns_SESSION_EXPIRED()
+        {
+            var (calib, _, _) = New();
+            var r = calib.GetProgress("nope");
+            Assert.False(r.Ok);
+            Assert.Equal("SESSION_EXPIRED", r.Code);
+        }
+
+        [Fact]
+        public void Recalibrate_Unknown_Sid_Returns_SESSION_EXPIRED()
+        {
+            var (calib, _, _) = New();
+            var r = calib.Recalibrate("nope", 3);
+            Assert.False(r.Ok);
+            Assert.Equal("SESSION_EXPIRED", r.Code);
+        }
+
+        [Fact]
+        public void Stop_Unknown_Sid_Is_Idempotent_Ok()
+        {
+            var (calib, _, _) = New();
+            Assert.True(calib.Stop("nope").Ok);
+            Assert.True(calib.Stop(null).Ok);
+        }
+    }
+}

+ 8 - 1
ivf_tl_operate_2.0/control/ivf_tl_Com/HouseBin.cs

@@ -391,6 +391,12 @@ namespace ivf_tl_Com
 
         private TLSetting TLSetting = null;
 
+        /// <summary>Phase3(D2-02 Task3.2a):标定协作合格阈值取数口(TLSetting 私有,供 ControlHost.CalibrationManager 读)。缺则 null,调用方兜底 1.2。</summary>
+        internal decimal? FocusPeakRatioThreshold => TLSetting?.focusPeakRatioThreshold;
+
+        /// <summary>Phase3(D2-02 Task3.2a):标定落库的 tlSn 取数口(TLSetting 优先,缺则回退 Dish.tlSn)。</summary>
+        internal string CalibTlSn => TLSetting?.tlSn ?? Dish?.tlSn;
+
         /// <summary>
         /// 正常拍照位置
         /// </summary>
@@ -1657,8 +1663,9 @@ namespace ivf_tl_Com
         ///   · 曝光:设备级 focusExposureMin/focusExposureMax(可空则兜底 10/800,与引擎现值一致)。
         /// 找不到 well 配置时:中心回退为 0(记日志),半幅走设备级/引擎默认,绝不崩。
         /// ⚠ Phase 3 标定协作(调试页一键基准等)也复用本方法取范围——改这里要同时顾及采集对焦与标定两处,勿改一处忘另一处。
+        /// Phase3(D2-02 Task3.2a):由 private 改 internal,供同程序集外的 ControlHost.CalibrationManager 逐 well 取范围(标定协作复用)。
         /// </summary>
-        private FocusRangeConfig ReadWellFocusRange(int wellSn)
+        internal FocusRangeConfig ReadWellFocusRange(int wellSn)
         {
             var ws = _wellSettings != null ? _wellSettings.FirstOrDefault(x => x.wellSn == wellSn) : null;
             if (ws == null)

+ 8 - 0
ivf_tl_operate_2.0/control/ivf_tl_Com/ivf_tl_Com.csproj

@@ -7,6 +7,14 @@
     <Platforms>AnyCPU;x64</Platforms>
   </PropertyGroup>
 
+  <ItemGroup>
+    <!-- D2-02 Task3.2a:把 HouseBin 的标定协作内部接口(ReadWellFocusRange / 峰比阈值 / tlSn)暴露给 ControlHost.CalibrationManager,
+         不对外完全 public(仅协作复用)。 -->
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
+      <_Parameter1>ivf_tl_ControlHost</_Parameter1>
+    </AssemblyAttribute>
+  </ItemGroup>
+
   <ItemGroup>
     <ProjectReference Include="..\ivf_tl_CameraHelper\ivf_tl_CameraHelper.csproj" />
     <ProjectReference Include="..\ivf_tl_Entity\ivf_tl_Entity.csproj" />

+ 6 - 0
ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs

@@ -1636,6 +1636,12 @@ namespace ivf_tl_Control
             };
         }
 
+        /// <summary>
+        /// Phase3(D2-02 Task3.2a):按 houseSn 取该舱 HouseBin 的公开访问器,供 ControlHost.CalibrationManager
+        /// 标定协作注入(取 per-well 范围 ReadWellFocusRange + 峰比阈值 + tlSn + AutofocusStore)。复用既有私有映射,无对应舱返回 null。
+        /// </summary>
+        public HouseBin GetHouseBin(int houseSn) => HouseSnToHouseBin(houseSn);
+
         private HouseBin HouseSnToHouseBin(int housesn)
         {
             HouseBin result = null;

+ 49 - 1
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs

@@ -24,6 +24,7 @@ namespace IvfTl.ControlHost
         private readonly Func<int, bool> _serialResumeHandler; // /serial/resume(归还:恢复采集)
         private readonly Action<string> _log;
         private readonly DebugSessionManager _debug;
+        private readonly CalibrationManager _calib;
         private HttpListener _listener;
         private CancellationTokenSource _cts;
 
@@ -35,7 +36,8 @@ namespace IvfTl.ControlHost
             Func<int, bool> serialPauseHandler,
             Func<int, bool> serialResumeHandler,
             Action<string> log,
-            DebugSessionManager debug = null)
+            DebugSessionManager debug = null,
+            CalibrationManager calib = null)
         {
             _port = port;
             _pingProvider = pingProvider;
@@ -45,6 +47,7 @@ namespace IvfTl.ControlHost
             _serialResumeHandler = serialResumeHandler;
             _log = log ?? (_ => { });
             _debug = debug;
+            _calib = calib;
         }
 
         public void Start()
@@ -139,6 +142,51 @@ namespace IvfTl.ControlHost
                         body = JsonConvert.SerializeObject(r);
                     }
                     break;
+                case "/debug/calibrate/start":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        var jo = ReadBody(ctx);
+                        string sid = jo?["sessionId"]?.ToString();
+                        // wells 可空(默认 1..16);给了则解析为 int 列表(忽略非法项)。
+                        System.Collections.Generic.List<int> wells = null;
+                        if (jo?["wells"] is JArray arr)
+                        {
+                            wells = new System.Collections.Generic.List<int>();
+                            foreach (var t in arr) { if (int.TryParse(t?.ToString(), out int w)) wells.Add(w); }
+                        }
+                        var r = _calib != null ? _calib.StartCalibrate(sid, wells) : DebugCommandResult.Fail("SESSION_EXPIRED", "calib 未装配");
+                        code = r.Ok ? 200 : (r.Code == "SESSION_EXPIRED" ? 410 : 200);
+                        body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/calibrate/progress":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        string sid = ReadField(ctx, "sessionId");
+                        var r = _calib != null ? _calib.GetProgress(sid) : DebugCommandResult.Fail("SESSION_EXPIRED", "calib 未装配");
+                        code = r.Ok ? 200 : (r.Code == "SESSION_EXPIRED" ? 410 : 200);
+                        body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/calibrate/recalibrate":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        var jo = ReadBody(ctx);
+                        string sid = jo?["sessionId"]?.ToString();
+                        int wellSn = jo?["wellSn"] != null && int.TryParse(jo["wellSn"].ToString(), out int wv) ? wv : -1;
+                        var r = _calib != null ? _calib.Recalibrate(sid, wellSn) : DebugCommandResult.Fail("SESSION_EXPIRED", "calib 未装配");
+                        code = r.Ok ? 200 : (r.Code == "SESSION_EXPIRED" ? 410 : 200);
+                        body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/calibrate/stop":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        string sid = ReadField(ctx, "sessionId");
+                        var r = _calib != null ? _calib.Stop(sid) : DebugCommandResult.Okay();
+                        code = 200; body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
                 case "/debug/preview/stream":
                     if (method != "GET") { code = 405; body = Err("method not allowed"); break; }
                     {

+ 131 - 0
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/CalibrationManager.cs

@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using IvfTl.AutoFocus.Calib;
+using ivf_tl_Com;
+
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>
+    /// 调试页"16 孔标定协作"管理器(D2-02 Task3.2a)。按 sid 持有每个调试会话的 CalibrationCoordinator,
+    /// 把 Task3.1 的纯逻辑协作器接到真实硬件/引擎/落库:
+    ///   · 标定全程复用 operate 那个 DebugSession 借到的 lease(HardwareUser.OperateDebug)——control 采集该舱已暂停,
+    ///     这里【绝不再 Acquire/Release lease】,只读 s.Lease.Serial / s.Lease.Camera;
+    ///   · 逐 well 注入 per-well 范围(HouseBin.ReadWellFocusRange) + 中心(DB优先,缺失回退硬件EEPROM),
+    ///     调 CalibrationEngine.CalibrateWell,结果落 scene=0(调试页=出厂基准)。
+    /// 线程安全:_byS id 用 ConcurrentDictionary;单次标定的并发由 CalibrationCoordinator 内部 _lock 保证。
+    /// </summary>
+    public sealed class CalibrationManager
+    {
+        private readonly DebugSessionManager _debug;
+        // 按 houseSn 取该舱 HouseBin 的注入委托(Program.cs 传 sn => AppData.Instance.GetHouseBin(sn))。
+        // 用委托而非直接静态依赖,便于单测注入假对象;真实 HouseBin 提供 ReadWellFocusRange/阈值/tlSn/AutofocusStore。
+        private readonly Func<int, HouseBin> _houseBinOf;
+        private readonly Action<string> _log;
+
+        // 按 sid 管协作器:一个调试会话一份。Stop 后从字典移除。
+        private readonly ConcurrentDictionary<string, CalibrationCoordinator> _bySid
+            = new ConcurrentDictionary<string, CalibrationCoordinator>();
+
+        public CalibrationManager(DebugSessionManager debug, Func<int, HouseBin> houseBinOf, Action<string> log = null)
+        {
+            _debug = debug ?? throw new ArgumentNullException(nameof(debug));
+            _houseBinOf = houseBinOf ?? throw new ArgumentNullException(nameof(houseBinOf));
+            _log = log ?? (_ => { });
+        }
+
+        /// <summary>
+        /// 起一次 16 孔标定。校验 sid → 取该舱 HouseBin/阈值 → 构造协作器(calibrateOne 闭包接真实引擎) → Start。
+        /// wells 为空默认 1..16。返回 Okay(无 result) / Fail(SESSION_EXPIRED|NO_HANDLE|BUSY)。
+        /// </summary>
+        public DebugCommandResult StartCalibrate(string sid, IReadOnlyList<int> wells)
+        {
+            if (!_debug.TryGet(sid, out var s))
+                return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
+            if (s.Lease?.Serial == null || s.Lease?.Camera == null)
+                return DebugCommandResult.Fail("NO_HANDLE", "借用串口/相机句柄为空");
+
+            var houseBin = _houseBinOf(s.HouseSn);
+            if (houseBin == null)
+                return DebugCommandResult.Fail("NO_HANDLE", $"舱{s.HouseSn}无 HouseBin");
+
+            // 合格阈值:tl_setting.focusPeakRatioThreshold ?? 1.2(与现网 CalibrationEngine 弱峰判定一致)。
+            double peakThreshold = (double)(houseBin.FocusPeakRatioThreshold ?? 1.2m);
+            string tlSn = houseBin.CalibTlSn;
+            string port = houseBin.House?.housePort;
+            string ccdSn = houseBin.House?.ccdSn;
+
+            var targets = (wells != null && wells.Count > 0)
+                ? wells.ToList()
+                : Enumerable.Range(1, 16).ToList();
+
+            // calibrateOne 闭包(逐 well 在协作器后台线程跑):范围注入 + 中心兜底 + CalibrateWell + 落 scene=0。
+            Func<int, WellCalib> calibrateOne = well =>
+            {
+                var range = houseBin.ReadWellFocusRange(well);
+                // DB优先 + 硬件兜底:DB 中心缺失(≤0)时回读硬件 EEPROM,避免中心=0 致 Z 粗扫错过真实焦面(沿用采集对焦 path B 范式)。
+                int hCenter = range.HCenter > 0 ? range.HCenter : Math.Max(0, s.Lease.Serial.ReadWellHorizontalPosWait(well));
+                int vCenter = range.VCenter > 0 ? range.VCenter : Math.Max(0, s.Lease.Serial.ReadWellFocusZeroWait(well));
+
+                var engine = new CalibrationEngine(s.Lease.Serial, s.Lease.Camera)
+                {
+                    Log = msg => _log($"[calib][舱{s.HouseSn}][well{well}]{msg}"),
+                    HFineRange = range.HHalf,       // 水平微调半幅(围绕 HCenter)
+                    ZCoarseCenter = vCenter,        // Z 粗扫中心=该 well 清晰位(取代引擎固定 90000)
+                    ZCoarseHalf = range.VHalf,      // Z 粗扫半幅
+                    ExpLo = range.ExpLo,            // 曝光二分下限
+                    ExpHi = range.ExpHi,            // 曝光二分上限
+                };
+                var wc = engine.CalibrateWell(well, hCenter, vCenter);
+
+                // 调试页一键标定 = 出厂基准 → scene=0(upsert)。存储失败不崩标定(CalibrationStore 内部已吞异常)。
+                try
+                {
+                    houseBin.AutofocusStore?.SaveCalibration(wc, tlSn, s.HouseSn, well, scene: 0,
+                        port: port, ccdSn: ccdSn);
+                }
+                catch (Exception ex) { _log($"[calib][舱{s.HouseSn}][well{well}] 落库失败(已忽略): {ex.Message}"); }
+
+                return wc;
+            };
+
+            var coordinator = new CalibrationCoordinator(calibrateOne, peakThreshold,
+                msg => _log($"[calib][舱{s.HouseSn}]{msg}"));
+            _bySid[sid] = coordinator;
+            coordinator.Start(targets);
+            _log($"[calib] 起标定 sid={sid} 舱{s.HouseSn} wells=[{string.Join(",", targets)}] 阈值={peakThreshold:F2}");
+            return DebugCommandResult.Okay();
+        }
+
+        /// <summary>轮询进度:返回 CalibProgress 快照(无对应 sid → SESSION_EXPIRED)。</summary>
+        public DebugCommandResult GetProgress(string sid)
+        {
+            if (sid != null && _bySid.TryGetValue(sid, out var c))
+                return DebugCommandResult.Okay(c.GetProgress());
+            return DebugCommandResult.Fail("SESSION_EXPIRED", "无对应标定任务(会话不存在或未起标定)");
+        }
+
+        /// <summary>单孔重标(转调协作器 Recalibrate)。无对应 sid → SESSION_EXPIRED;批量跑中协作器自身忽略。</summary>
+        public DebugCommandResult Recalibrate(string sid, int wellSn)
+        {
+            if (sid != null && _bySid.TryGetValue(sid, out var c))
+            {
+                bool ok = c.Recalibrate(wellSn);
+                return DebugCommandResult.Okay(ok);
+            }
+            return DebugCommandResult.Fail("SESSION_EXPIRED", "无对应标定任务(会话不存在或未起标定)");
+        }
+
+        /// <summary>中止标定并从字典移除该 sid 的协作器(lease 仍由 operate 的 DebugSession 持有,这里不还)。</summary>
+        public DebugCommandResult Stop(string sid)
+        {
+            if (sid != null && _bySid.TryRemove(sid, out var c))
+            {
+                c.Stop();
+                _log($"[calib] 停标定 sid={sid}");
+            }
+            return DebugCommandResult.Okay();
+        }
+    }
+}

+ 7 - 1
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs

@@ -51,6 +51,12 @@ namespace IvfTl.ControlHost
                     ttlMs: 10000,
                     log: msg => Log4netHelper.WriteLog(msg),
                     cultivationOf: houseSn => { try { return ivf_tl_Control.AppData.Instance.GetCultivation(houseSn); } catch { return (false, 0); } });
+                // D2-02 Task3.2a:标定协作管理器(按 sid 管 CalibrationCoordinator,逐 well 接真实引擎/落库)。
+                // 取 HouseBin 用注入委托(避免直接静态依赖,便于测试);标定全程复用 debug 会话的 lease,不另借硬件。
+                var calibMgr = new IvfTl.ControlHost.Debug.CalibrationManager(
+                    debugMgr,
+                    houseSn => { try { return ivf_tl_Control.AppData.Instance.GetHouseBin(houseSn); } catch { return null; } },
+                    msg => Log4netHelper.WriteLog(msg));
                 _http = new ControlHttpServer(
                     hostArgs.Port,
                     BuildStatus,        // /ping 轻量
@@ -58,7 +64,7 @@ namespace IvfTl.ControlHost
                     HandleShutdown,     // /shutdown 受护栏停机
                     HandleSerialPause,  // /serial/pause 借串口让路
                     HandleSerialResume, // /serial/resume 归还恢复
-                    msg => Log4netHelper.WriteLog(msg), debugMgr);
+                    msg => Log4netHelper.WriteLog(msg), debugMgr, calibMgr);
                 _http.Start();
 
                 // 3) 账号守卫(对齐 operate 空账号跳过逻辑)。