diff --git a/src/Mimir.BufferSmoke/Program.cs b/src/Mimir.BufferSmoke/Program.cs index a48c4c4..3884574 100644 --- a/src/Mimir.BufferSmoke/Program.cs +++ b/src/Mimir.BufferSmoke/Program.cs @@ -165,6 +165,16 @@ if (args.Any(arg => string.Equals(arg, "--move-stereo-calibration-assessment", S ParseDoubleOption(args, "--orb-radius-meters", 0.0)); } +if (args.Any(arg => string.Equals(arg, "--sensor-calibration-session-live", StringComparison.OrdinalIgnoreCase))) +{ + return RunSensorCalibrationSessionLive( + ParseStringOption(args, "--odin-cultmesh-uri", "cultmesh://127.0.0.1:17871/rendezvous/provider-catalog"), + ParseIntOption(args, "--duration-seconds", 120), + ParseStringOption(args, "--output-prefix", "artifacts/move-calibration/live-session"), + ParseRepeatedStringOption(args, "--wand-id", [ + "move-0006f523e2d1", "move-000704a39772", "move-000704a6be5f", "move-000704a800d0"])); +} + if (args.Any(arg => string.Equals(arg, "--move-fusion-smoke", StringComparison.OrdinalIgnoreCase))) { return RunMoveFusionSmoke(); @@ -2130,6 +2140,126 @@ static int RunMuninnMoveVisibilityWindowSmoke(string odinCultMeshUri, int durati } } +static int RunSensorCalibrationSessionLive( + string odinCultMeshUri, + int durationSeconds, + string outputPrefix, + IReadOnlyCollection wandIds) +{ + const string providerId = "muninn.telemetry.nightwing"; + const string streamId = "muninn:nightwing:move-evidence"; + var cameraIds = new[] { "nightwing-eye-0", "nightwing-eye-1" }; + var configuration = new MimirMoveProofRuntimeConfiguration + { + OdinCultMeshUri = odinCultMeshUri, + MuninnProviderId = providerId, + EvidenceStreamId = streamId + }; + if (!MimirOdinMoveProofEvidenceRingProvider.Instance.TryOpenEvidenceRing(configuration, out var lease, out var diagnostic) || lease is null) + throw new InvalidOperationException($"{providerId}: {diagnostic}"); + + using (lease) + { + var startedAt = DateTimeOffset.UtcNow; + var session = MimirSensorCalibrationSessions.CreateFourWandOpticalSession( + wandIds, cameraIds, startedAt, Math.Max(1, durationSeconds)); + var observations = new List(); + var seenFrames = new HashSet(StringComparer.Ordinal); + var deadline = startedAt + TimeSpan.FromSeconds(Math.Max(1, durationSeconds)); + var lastCheckpoint = DateTimeOffset.MinValue; + var evidencePath = $"{outputPrefix}.evidence.mpack"; + var sessionPath = $"{outputPrefix}.session.mpack"; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(sessionPath))!); + + while (DateTimeOffset.UtcNow < deadline && session.Phase == MimirSensorCalibrationSessionPhase.Collecting) + { + if (lease.Ring.TryAcquireLatestRead(out var frameLease)) + { + using (frameLease) + { + var frame = MimirMuninnMoveEvidenceAdapter.DeserializeStreamFrame( + frameLease.Memory[..frameLease.Handle.ByteLength]); + if (seenFrames.Add(frame.FrameId)) + { + observations.AddRange(frame.MarkerCandidates + .Where(marker => !string.IsNullOrWhiteSpace(marker.MoveId)) + .Select(marker => new MoveVisibilityObservation( + providerId, marker.CameraId, marker.MoveId, frame.FrameId, + frame.PublishedAtNs, marker.CenterXPx, marker.CenterYPx, + marker.RadiusPx, marker.Score))); + } + } + } + + var now = DateTimeOffset.UtcNow; + if ((now - lastCheckpoint).TotalSeconds >= 1.0) + { + var evidence = BuildVisibilityReceipt(startedAt, now, providerId, cameraIds, observations); + session = MimirSensorCalibrationSessions.UpdateCollection(session, evidence, 640, 480, now); + File.WriteAllBytes(evidencePath, MessagePackSerializer.Serialize(evidence)); + File.WriteAllBytes(sessionPath, MessagePackSerializer.Serialize(session)); + Console.WriteLine(JsonSerializer.Serialize(new + { + session.SessionId, + session.Phase, + elapsedSeconds = Math.Round((now - startedAt).TotalSeconds, 1), + frames = seenFrames.Count, + observations = observations.Count, + correspondences = evidence.Correspondences.Length, + sensors = session.Sensors.Select(sensor => new + { + sensor.SensorId, + sensor.ObservationCount, + sensor.OccupiedGridCells, + sensor.DistinctWandCount, + radiusRatio = sensor.ObservationCount == 0 ? 0.0 : Math.Round(sensor.MaximumRadiusPx / sensor.MinimumRadiusPx, 2), + sensor.MissingGridCells, + sensor.CollectionComplete + }), + session.MissingRequirements + })); + lastCheckpoint = now; + } + Thread.Sleep(5); + } + + var completedAt = DateTimeOffset.UtcNow; + var finalEvidence = BuildVisibilityReceipt(startedAt, completedAt, providerId, cameraIds, observations); + session = MimirSensorCalibrationSessions.UpdateCollection(session, finalEvidence, 640, 480, completedAt); + File.WriteAllBytes(evidencePath, MessagePackSerializer.Serialize(finalEvidence)); + File.WriteAllBytes(sessionPath, MessagePackSerializer.Serialize(session)); + Console.WriteLine(JsonSerializer.Serialize(new { final = true, session.SessionId, session.Phase, evidencePath, sessionPath, session.MissingRequirements })); + return session.Phase == MimirSensorCalibrationSessionPhase.Fitting ? 0 : 2; + } +} + +static MoveVisibilityWindowReceipt BuildVisibilityReceipt( + DateTimeOffset startedAt, + DateTimeOffset endedAt, + string providerId, + IReadOnlyList cameraIds, + IReadOnlyCollection observations) +{ + var observationArray = observations.ToArray(); + var correspondences = observationArray + .GroupBy(observation => (observation.FrameId, observation.MoveId)) + .Select(group => + { + var first = group.FirstOrDefault(value => value.CameraId == cameraIds[0]); + var second = group.FirstOrDefault(value => value.CameraId == cameraIds[1]); + return first is null || second is null ? null : new MoveCrossCameraCorrespondence( + group.Key.MoveId, first, second, Math.Abs(first.PublishedAtNs - second.PublishedAtNs)); + }) + .Where(value => value is not null) + .Cast() + .ToArray(); + return new MoveVisibilityWindowReceipt( + "mimir.move_visibility_window.v1", + startedAt.ToUnixTimeMilliseconds() * 1_000_000, + endedAt.ToUnixTimeMilliseconds() * 1_000_000, + [providerId], observationArray, correspondences); +} + static int RunMoveStereoCalibrationAssessment( string inputPath, string outputPath, @@ -3361,7 +3491,7 @@ static int RunMoveProofPipelineSmoke(string nativeReservoirPath) static async Task RunMoveCalibrationProtocolSmokeAsync(string outputPath) { - var protocol = MimirMoveCalibrationProtocol.CreateStarfireNightwingProtocol( + var protocol = MimirMoveCalibrationProtocol.CreateNightwingFourWandProtocol( new DateTimeOffset(2026, 6, 12, 14, 45, 0, TimeSpan.Zero)); var errors = MimirMoveCalibrationProtocol.Validate(protocol); if (errors.Length > 0) @@ -3388,7 +3518,7 @@ static async Task RunMoveCalibrationProtocolSmokeAsync(string outputPath) $"move-calibration-protocol-smoke path={outputPath} protocol={protocol.ProtocolId} requiredStreams={requiredStreams} optionalStreams={optionalStreams} phases={protocol.Phases.Length} durationSeconds={totalDuration:0.0} outputs={protocol.Outputs.Length} questOptional={protocol.StreamRequirements.Any(stream => stream.StreamId.StartsWith("quest:", StringComparison.Ordinal))}"); return requiredStreams >= 3 && - protocol.Phases.Length == 7 && + protocol.Phases.Length == 8 && protocol.Outputs.Length == 4 && protocol.Acceptance.RequiredDerivedOutputs.Length == 4 ? 0 diff --git a/src/Mimir.Runtime/Synchronization/MimirMoveCalibrationProtocol.cs b/src/Mimir.Runtime/Synchronization/MimirMoveCalibrationProtocol.cs index 763b92d..3c51bc4 100644 --- a/src/Mimir.Runtime/Synchronization/MimirMoveCalibrationProtocol.cs +++ b/src/Mimir.Runtime/Synchronization/MimirMoveCalibrationProtocol.cs @@ -56,23 +56,15 @@ public sealed record MimirMoveCalibrationAcceptance( public static class MimirMoveCalibrationProtocol { - public static MimirMoveCalibrationProtocolDocument CreateStarfireNightwingProtocol( + public static MimirMoveCalibrationProtocolDocument CreateNightwingFourWandProtocol( DateTimeOffset? createdAt = null) => new( - ProtocolId: "mimir-move-calibration-starfire-nightwing-v1", + ProtocolId: "mimir-move-calibration-nightwing-four-wand-v1", CreatedAtUtc: (createdAt ?? DateTimeOffset.UtcNow).ToString("O"), TrackingSpaceId: "mimir-stage-space", FusionAuthorityId: "mimir.runtime.move-fusion", StreamRequirements: [ - new( - "muninn:starfire:move-evidence", - "starfire", - "Muninn on Starfire", - "Move optical marker candidates plus USB controller IMU/buttons", - "CultMesh shared-memory bytes frame, same-host when Mimir runs on Starfire", - Required: true, - "Starfire owns the USB-attached Move and local camera witnesses."), new( "muninn:nightwing:move-evidence", "nightwing", @@ -81,6 +73,14 @@ public static class MimirMoveCalibrationProtocol "CultMesh bytes stream over the Verse; shared-memory on Nightwing, network bridge to Starfire", Required: true, "Nightwing owns its USB-attached Move and local camera witnesses."), + new( + "mimir:starfire:sensor-calibration-session", + "starfire", + "Mimir.Runtime", + "Bounded calibration lifecycle, coverage, fit, validation, and promotion state", + "Typed CultMesh state with CultCache persistence", + Required: true, + "Mimir owns the calibration task and its completion verdict."), new( "mimir:starfire:move-controller-poses", "starfire", @@ -128,36 +128,36 @@ public static class MimirMoveCalibrationProtocol "preflight-streams", "Stream liveness and clock edge check", 10.0, - "Hold both Moves still and visible. Confirm Starfire, Nightwing, and optional Quest pose streams are fresh.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"], + "Hold the four Moves visible. Confirm Nightwing evidence and stable identities are fresh.", + ["muninn:nightwing:move-evidence"], ["stream-freshness", "clock-skew-estimate", "controller-id-map"]), new( "dark-stillness", "IMU bias with orbs off", 8.0, "Set Move lights off. Place both Moves still on the calibration surface.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"], + ["muninn:nightwing:move-evidence"], ["gyro-bias", "accelerometer-gravity-vector", "magnetometer-baseline"]), new( "lit-stillness", "Optical centroid and static gravity alignment", 8.0, "Light one Move at a time, then both. Keep them still and visible to both camera sets.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"], + ["muninn:nightwing:move-evidence"], ["camera-marker-centroid-stability", "light-command-to-controller-association"]), new( "axis-sweeps", "Slow single-axis rotations", 24.0, "Sweep each Move slowly through pitch, yaw, and roll while keeping the orb visible.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"], + ["muninn:nightwing:move-evidence"], ["gyro-scale-fit", "accelerometer-frame-fit", "optical-angular-consistency"]), new( "figure-eight", "Magnetometer and cross-axis motion", 20.0, "Move each controller through a broad figure-eight. Leave the Quest headset/controllers stationary as the external VR reference cluster.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence"], + ["muninn:nightwing:move-evidence"], ["magnetometer-hard-soft-iron-fit", "cross-axis-coupling-check"]), new( "quest-reference", @@ -166,12 +166,19 @@ public static class MimirMoveCalibrationProtocol "Keep the Move between the two Quest controllers in front of the headset. Capture the static Quest controller/headset frame while Mimir observes the Move.", ["quest:usb:headset-pose", "quest:usb:left-controller-pose", "quest:usb:right-controller-pose"], ["external-vr-pose-frame", "quest-to-mimir-frame-fit", "quest-clock-offset-estimate"]), + new( + "wand-volume-sweep", + "Four-wand optical volume coverage", + 120.0, + "Wave all four uniquely identified wands throughout the shared camera volume until Mimir reports sufficient per-sensor coverage.", + ["muninn:nightwing:move-evidence", "mimir:starfire:sensor-calibration-session"], + ["sensor-grid-coverage", "same-frame-correspondences", "sphere-radius-range", "fit-and-held-out-partitions"]), new( "validation-pass", "Held-out tracking validation", 15.0, "Run free hand motion with both orbs visible. Mimir may publish validation poses, but not promote calibration if confidence fails.", - ["muninn:starfire:move-evidence", "muninn:nightwing:move-evidence", "mimir:starfire:move-controller-poses"], + ["muninn:nightwing:move-evidence", "mimir:starfire:move-controller-poses"], ["triangulated-position-residuals", "imu-prediction-residuals", "promotion-decision"]) ], Outputs: @@ -227,16 +234,21 @@ public static class MimirMoveCalibrationProtocol errors.Add("required-streams-too-small"); } - if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "muninn:starfire:move-evidence" && stream.Required)) - { - errors.Add("missing-starfire-muninn-move-evidence"); - } - if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "muninn:nightwing:move-evidence" && stream.Required)) { errors.Add("missing-nightwing-muninn-move-evidence"); } + if (!protocol.StreamRequirements.Any(stream => stream.StreamId == "mimir:starfire:sensor-calibration-session" && stream.Required)) + { + errors.Add("missing-mimir-calibration-session"); + } + + if (!protocol.Phases.Any(phase => phase.PhaseId == "wand-volume-sweep")) + { + errors.Add("missing-wand-volume-sweep"); + } + if (!protocol.Phases.Any(phase => phase.PhaseId == "dark-stillness")) { errors.Add("missing-imu-bias-phase"); diff --git a/src/Mimir.Runtime/Synchronization/MimirMoveFusion.cs b/src/Mimir.Runtime/Synchronization/MimirMoveFusion.cs index 3e04ba1..4ccf768 100644 --- a/src/Mimir.Runtime/Synchronization/MimirMoveFusion.cs +++ b/src/Mimir.Runtime/Synchronization/MimirMoveFusion.cs @@ -1,24 +1,27 @@ using System.Numerics; +using MessagePack; namespace Mimir.Runtime.Synchronization; +[MessagePackObject] public sealed record MimirMoveFusionCameraCalibration( - string CameraId, - ulong WitnessIdHash, - MimirVector3Snapshot PositionMeters, - MimirQuaternionSnapshot Orientation, - double FocalLengthXPx, - double FocalLengthYPx, - double PrincipalPointXPx, - double PrincipalPointYPx); + [property: Key(0)] string CameraId, + [property: Key(1)] ulong WitnessIdHash, + [property: Key(2)] MimirVector3Snapshot PositionMeters, + [property: Key(3)] MimirQuaternionSnapshot Orientation, + [property: Key(4)] double FocalLengthXPx, + [property: Key(5)] double FocalLengthYPx, + [property: Key(6)] double PrincipalPointXPx, + [property: Key(7)] double PrincipalPointYPx); +[MessagePackObject] public sealed record MimirMoveFusionRigCalibration( - string CalibrationId, - string TrackingSpaceId, - IReadOnlyList Cameras, - double GyroUnitsPerRadianPerSecond = 1.0, - double MaximumAssociationSkewMilliseconds = 20.0, - double SingleRayFallbackDepthMeters = 1.5); + [property: Key(0)] string CalibrationId, + [property: Key(1)] string TrackingSpaceId, + [property: Key(2)] IReadOnlyList Cameras, + [property: Key(3)] double GyroUnitsPerRadianPerSecond = 1.0, + [property: Key(4)] double MaximumAssociationSkewMilliseconds = 20.0, + [property: Key(5)] double SingleRayFallbackDepthMeters = 1.5); public sealed record MimirMoveFusionResult( IReadOnlyList Poses, diff --git a/src/Mimir.Runtime/Synchronization/MimirSensorCalibrationSession.cs b/src/Mimir.Runtime/Synchronization/MimirSensorCalibrationSession.cs new file mode 100644 index 0000000..4df697b --- /dev/null +++ b/src/Mimir.Runtime/Synchronization/MimirSensorCalibrationSession.cs @@ -0,0 +1,208 @@ +using GameCult.Caching; +using MessagePack; + +namespace Mimir.Runtime.Synchronization; + +public enum MimirSensorCalibrationSessionPhase +{ + Collecting, + Fitting, + Validating, + Promoted, + Rejected, + Canceled +} + +[CultDocument("mimir.sensor_calibration_session", "mimir.sensor_calibration_session.v1")] +[MessagePackObject] +public sealed record MimirSensorCalibrationSessionDocument( + [property: Key(0)] [property: CultName] string SessionId, + [property: Key(1)] string TrackingSpaceId, + [property: Key(2)] string Owner, + [property: Key(3)] string StartedAtUtc, + [property: Key(4)] double MaximumDurationSeconds, + [property: Key(5)] string[] RequiredWandIds, + [property: Key(6)] string[] SensorIds, + [property: Key(7)] MimirSensorCalibrationAcceptance Acceptance, + [property: Key(8)] MimirSensorCalibrationSessionPhase Phase, + [property: Key(9)] MimirSensorCalibrationSensorProgress[] Sensors, + [property: Key(10)] string[] MissingRequirements, + [property: Key(11)] string Detail); + +[MessagePackObject] +public sealed record MimirSensorCalibrationAcceptance( + [property: Key(0)] int GridColumns, + [property: Key(1)] int GridRows, + [property: Key(2)] int MinimumOccupiedGridCellsPerSensor, + [property: Key(3)] int MinimumObservationsPerSensor, + [property: Key(4)] int MinimumSameFrameCorrespondences, + [property: Key(5)] int MinimumDistinctWands, + [property: Key(6)] double MinimumRadiusRatio, + [property: Key(7)] double HeldOutFraction, + [property: Key(8)] double MaximumMedianReprojectionErrorPx, + [property: Key(9)] double MaximumP95ReprojectionErrorPx, + [property: Key(10)] double MaximumAssociationSkewMilliseconds); + +[MessagePackObject] +public sealed record MimirSensorCalibrationSensorProgress( + [property: Key(0)] string SensorId, + [property: Key(1)] int ObservationCount, + [property: Key(2)] int OccupiedGridCells, + [property: Key(3)] int GridCellCount, + [property: Key(4)] int DistinctWandCount, + [property: Key(5)] double MinimumRadiusPx, + [property: Key(6)] double MaximumRadiusPx, + [property: Key(7)] int[] MissingGridCells, + [property: Key(8)] bool CollectionComplete); + +[CultDocument("mimir.sensor_calibration_receipt", "mimir.sensor_calibration_receipt.v1")] +[MessagePackObject] +public sealed record MimirSensorCalibrationReceiptDocument( + [property: Key(0)] [property: CultName] string ReceiptId, + [property: Key(1)] string SessionId, + [property: Key(2)] string TrackingSpaceId, + [property: Key(3)] string CompletedAtUtc, + [property: Key(4)] MimirSensorCalibrationSessionPhase Verdict, + [property: Key(5)] int TrainingCorrespondenceCount, + [property: Key(6)] int HeldOutCorrespondenceCount, + [property: Key(7)] MimirSensorCalibrationFitResiduals Residuals, + [property: Key(8)] MimirMoveFusionRigCalibration? PromotedCalibration, + [property: Key(9)] string[] RejectionReasons, + [property: Key(10)] string Solver); + +[MessagePackObject] +public sealed record MimirSensorCalibrationFitResiduals( + [property: Key(0)] double TrainingMedianReprojectionErrorPx, + [property: Key(1)] double TrainingP95ReprojectionErrorPx, + [property: Key(2)] double HeldOutMedianReprojectionErrorPx, + [property: Key(3)] double HeldOutP95ReprojectionErrorPx, + [property: Key(4)] double MedianAssociationSkewMilliseconds, + [property: Key(5)] double MaximumAssociationSkewMilliseconds, + [property: Key(6)] double ConditionEstimate, + [property: Key(7)] int Iterations); + +public static class MimirSensorCalibrationSessions +{ + public static MimirSensorCalibrationSessionDocument CreateFourWandOpticalSession( + IEnumerable wandIds, + IEnumerable sensorIds, + DateTimeOffset? startedAt = null, + double maximumDurationSeconds = 120.0) + { + var wands = wandIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).Order().ToArray(); + var sensors = sensorIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).Order().ToArray(); + if (wands.Length != 4) throw new ArgumentException("A four-wand calibration session requires exactly four stable wand IDs.", nameof(wandIds)); + if (sensors.Length < 2) throw new ArgumentException("Optical calibration requires at least two sensors.", nameof(sensorIds)); + if (!double.IsFinite(maximumDurationSeconds) || maximumDurationSeconds <= 0.0) throw new ArgumentOutOfRangeException(nameof(maximumDurationSeconds)); + var start = startedAt ?? DateTimeOffset.UtcNow; + return new MimirSensorCalibrationSessionDocument( + $"mimir-sensor-calibration:{start.ToUnixTimeMilliseconds()}", + "mimir-stage-space", + "Mimir.Runtime", + start.ToString("O"), + maximumDurationSeconds, + wands, + sensors, + new MimirSensorCalibrationAcceptance( + GridColumns: 4, + GridRows: 3, + MinimumOccupiedGridCellsPerSensor: 9, + MinimumObservationsPerSensor: 1_000, + MinimumSameFrameCorrespondences: 500, + MinimumDistinctWands: 4, + MinimumRadiusRatio: 1.5, + HeldOutFraction: 0.2, + MaximumMedianReprojectionErrorPx: 1.5, + MaximumP95ReprojectionErrorPx: 4.0, + MaximumAssociationSkewMilliseconds: 20.0), + MimirSensorCalibrationSessionPhase.Collecting, + sensors.Select(sensor => new MimirSensorCalibrationSensorProgress( + sensor, 0, 0, 12, 0, double.NaN, double.NaN, Enumerable.Range(0, 12).ToArray(), false)).ToArray(), + ["wand-motion-not-yet-captured"], + "Wave one or two uniquely identified wands at a time through the shared sensor volume; Mimir accumulates successive passes until every wand and sensor has sufficient coverage."); + } + + public static MimirSensorCalibrationSessionDocument UpdateCollection( + MimirSensorCalibrationSessionDocument session, + MoveVisibilityWindowReceipt evidence, + int imageWidth, + int imageHeight, + DateTimeOffset? observedAt = null) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(evidence); + if (session.Phase != MimirSensorCalibrationSessionPhase.Collecting) return session; + if (imageWidth <= 0) throw new ArgumentOutOfRangeException(nameof(imageWidth)); + if (imageHeight <= 0) throw new ArgumentOutOfRangeException(nameof(imageHeight)); + + var acceptance = session.Acceptance; + var requiredWands = session.RequiredWandIds.ToHashSet(StringComparer.Ordinal); + var progress = session.SensorIds.Select(sensorId => + { + var observations = evidence.Observations.Where(value => + string.Equals(value.CameraId, sensorId, StringComparison.Ordinal) && + requiredWands.Contains(value.MoveId) && + float.IsFinite(value.CenterXPx) && float.IsFinite(value.CenterYPx) && + float.IsFinite(value.RadiusPx) && value.RadiusPx > 0.0f).ToArray(); + var occupied = observations.Select(value => GridCell( + value.CenterXPx, value.CenterYPx, imageWidth, imageHeight, + acceptance.GridColumns, acceptance.GridRows)).Distinct().Order().ToArray(); + var missing = Enumerable.Range(0, acceptance.GridColumns * acceptance.GridRows) + .Except(occupied).ToArray(); + var minimumRadius = observations.Length == 0 ? double.NaN : observations.Min(value => value.RadiusPx); + var maximumRadius = observations.Length == 0 ? double.NaN : observations.Max(value => value.RadiusPx); + var radiusRatio = observations.Length == 0 ? 0.0 : maximumRadius / minimumRadius; + var distinctWands = observations.Select(value => value.MoveId).Distinct(StringComparer.Ordinal).Count(); + var complete = observations.Length >= acceptance.MinimumObservationsPerSensor && + occupied.Length >= acceptance.MinimumOccupiedGridCellsPerSensor && + distinctWands >= acceptance.MinimumDistinctWands && + radiusRatio >= acceptance.MinimumRadiusRatio; + return new MimirSensorCalibrationSensorProgress( + sensorId, observations.Length, occupied.Length, + acceptance.GridColumns * acceptance.GridRows, distinctWands, + minimumRadius, maximumRadius, missing, complete); + }).ToArray(); + + var correspondenceCount = evidence.Correspondences.Count(value => + requiredWands.Contains(value.MoveId) && + value.AbsoluteSkewNs <= acceptance.MaximumAssociationSkewMilliseconds * 1_000_000.0 && + string.Equals(value.First.FrameId, value.Second.FrameId, StringComparison.Ordinal)); + var missingRequirements = new List(); + foreach (var sensor in progress) + { + if (sensor.ObservationCount < acceptance.MinimumObservationsPerSensor) + missingRequirements.Add($"{sensor.SensorId}:observations-{sensor.ObservationCount}-of-{acceptance.MinimumObservationsPerSensor}"); + if (sensor.OccupiedGridCells < acceptance.MinimumOccupiedGridCellsPerSensor) + missingRequirements.Add($"{sensor.SensorId}:grid-cells-{sensor.OccupiedGridCells}-of-{acceptance.MinimumOccupiedGridCellsPerSensor}"); + if (sensor.DistinctWandCount < acceptance.MinimumDistinctWands) + missingRequirements.Add($"{sensor.SensorId}:wands-{sensor.DistinctWandCount}-of-{acceptance.MinimumDistinctWands}"); + var radiusRatio = sensor.ObservationCount == 0 ? 0.0 : sensor.MaximumRadiusPx / sensor.MinimumRadiusPx; + if (radiusRatio < acceptance.MinimumRadiusRatio) + missingRequirements.Add($"{sensor.SensorId}:radius-ratio-{radiusRatio:F2}-of-{acceptance.MinimumRadiusRatio:F2}"); + } + if (correspondenceCount < acceptance.MinimumSameFrameCorrespondences) + missingRequirements.Add($"same-frame-correspondences-{correspondenceCount}-of-{acceptance.MinimumSameFrameCorrespondences}"); + + var elapsed = (observedAt ?? DateTimeOffset.UtcNow) - DateTimeOffset.Parse(session.StartedAtUtc); + var collectionComplete = progress.All(value => value.CollectionComplete) && + correspondenceCount >= acceptance.MinimumSameFrameCorrespondences; + var expired = elapsed.TotalSeconds >= session.MaximumDurationSeconds; + var phase = collectionComplete + ? MimirSensorCalibrationSessionPhase.Fitting + : expired ? MimirSensorCalibrationSessionPhase.Rejected : MimirSensorCalibrationSessionPhase.Collecting; + var detail = phase switch + { + MimirSensorCalibrationSessionPhase.Fitting => "Collection complete; Mimir owns the retained evidence and may begin fitting.", + MimirSensorCalibrationSessionPhase.Rejected => "Collection window expired before coverage requirements were met; no calibration was promoted.", + _ => "Collection active. Continue sweeping one or two wands at a time through missing image regions and depth ranges." + }; + return session with { Phase = phase, Sensors = progress, MissingRequirements = missingRequirements.ToArray(), Detail = detail }; + } + + private static int GridCell(float x, float y, int width, int height, int columns, int rows) + { + var column = Math.Clamp((int)(x * columns / width), 0, columns - 1); + var row = Math.Clamp((int)(y * rows / height), 0, rows - 1); + return row * columns + column; + } +}