Add Mimir-owned Move calibration sessions

This commit is contained in:
Meta Vens 2026-07-14 00:35:47 +01:00
commit c01ef5096c
4 changed files with 391 additions and 38 deletions

View file

@ -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<string> 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<MoveVisibilityObservation>();
var seenFrames = new HashSet<string>(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<string> cameraIds,
IReadOnlyCollection<MoveVisibilityObservation> 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<MoveCrossCameraCorrespondence>()
.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<int> 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<int> 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

View file

@ -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");

View file

@ -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<MimirMoveFusionCameraCalibration> 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<MimirMoveFusionCameraCalibration> 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<MimirMoveControllerPoseDocument> Poses,

View file

@ -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<string> wandIds,
IEnumerable<string> 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<string>();
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;
}
}