using Microsoft.Extensions.Options;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Localization;
using RobotNet10.Shared.Numbers;
using System.Diagnostics;
namespace RobotNet10.RobotApp.SLAM.Cartographer.Helpers;
///
/// Evaluates scan matching quality at a given pose against occupancy grid.
/// Provides reliability [0,1] and MAE metrics without running particle filter.
/// Directly evaluates the quality of pose from CartographerSharp.
///
public class ScanMatchingQualityEvaluator : IDisposable
{
#region Fields and Constructor
private readonly CartographerConfiguration _config;
private readonly ILogger _logger;
private readonly Lock _lock = new();
// Occupancy grid and distance map
private OccupancyGrid? _occupancyGrid;
private double[,]? _distanceMap; // Distance to nearest occupied cell (meters)
private double _mapResolution;
private double _mapOriginX, _mapOriginY, _mapOriginYaw;
private int _mapWidth, _mapHeight;
// Likelihood field model constants
private double _normConstHit, _denomHit, _measurementModelRandom;
// Latest scan and pose for periodic evaluation
private RangeDataPayload? _latestScan;
private Pose? _latestPose;
private string? _latestDeviceId;
private DateTime _lastScanTime = DateTime.MinValue;
// Periodic evaluation state
private bool _running;
private Timer? _periodicTimer;
private readonly TimeSpan _evaluationInterval;
// Latest metrics
private double _reliability = 0.5;
private double? _mae;
private DateTime _lastUpdateTime = DateTime.MinValue;
// Primary lidar ID for filtering
private string? _primaryLidarId;
public ScanMatchingQualityEvaluator(
IOptions configuration,
ILogger logger)
{
_config = configuration?.Value ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Get evaluation interval from configuration
_evaluationInterval = TimeSpan.FromSeconds(_config.Mcl.ReliabilityMonitoring.MonitoringIntervalSeconds);
}
#endregion
#region Public API
///
/// Start periodic scan quality evaluation
///
public void Start(OccupancyGrid occupancyGrid, Pose? initialPose, string? primaryLidarId)
{
lock (_lock)
{
if (_running)
{
_logger.LogWarning("ScanMatchingQualityEvaluator: Already running, ignoring Start call");
return;
}
try
{
_occupancyGrid = occupancyGrid ?? throw new ArgumentNullException(nameof(occupancyGrid));
_primaryLidarId = primaryLidarId;
// Build distance map from occupancy grid
BuildDistanceMap(occupancyGrid);
// Initialize likelihood field model constants
InitializeMeasurementModel();
// Create periodic timer
_periodicTimer = new Timer(
EvaluateScanQuality,
null,
_evaluationInterval,
_evaluationInterval);
_running = true;
}
catch (Exception ex)
{
_logger.LogError(ex, "ScanMatchingQualityEvaluator: Failed to start");
_running = false;
throw;
}
}
}
///
/// Stop periodic evaluation
///
public void Stop()
{
lock (_lock)
{
if (!_running)
{
return;
}
_running = false;
_periodicTimer?.Dispose();
_periodicTimer = null;
_latestScan = null;
_latestPose = null;
_latestDeviceId = null;
_occupancyGrid = null;
_distanceMap = null;
}
}
///
/// Cache latest scan and pose for periodic evaluation
///
public void OnScanReceived(string deviceId, RangeDataPayload payload, Pose? currentPose)
{
lock (_lock)
{
if (!_running)
{
return;
}
// Filter by primary lidar if specified
if (!string.IsNullOrEmpty(_primaryLidarId) && deviceId != _primaryLidarId)
{
return;
}
_latestScan = payload;
_latestPose = currentPose;
_latestDeviceId = deviceId;
_lastScanTime = DateTime.UtcNow;
}
}
///
/// Get latest evaluation metrics
///
public EvaluationMetrics GetLatestMetrics()
{
lock (_lock)
{
return new EvaluationMetrics(_reliability, _mae, _lastUpdateTime);
}
}
#endregion
#region Distance Map Setup
///
/// Build distance map from occupancy grid using Felzenszwalb-Huttenlocher distance transform
/// Distance map stores distance (in meters) to nearest occupied cell for each grid cell
///
private void BuildDistanceMap(OccupancyGrid grid)
{
_mapWidth = grid.Width;
_mapHeight = grid.Height;
_mapResolution = grid.Resolution;
_mapOriginX = grid.Origin.Position.X;
_mapOriginY = grid.Origin.Position.Y;
_mapOriginYaw = grid.Origin.Orientation.ToYawRadian();
// Create binary map: 0 = occupied (100), 1 = free
var binaryMap = new byte[_mapHeight, _mapWidth];
for (int v = 0; v < _mapHeight; v++)
{
for (int u = 0; u < _mapWidth; u++)
{
int idx = v * _mapWidth + u;
binaryMap[v, u] = grid.Data[idx] == 100 ? (byte)0 : (byte)1;
}
}
// Compute Euclidean distance transform
_distanceMap = DistanceTransformHelper.ComputeEuclidean(binaryMap, _mapWidth, _mapHeight, _mapResolution);
_logger.LogDebug("ScanMatchingQualityEvaluator: Built distance map {Width}x{Height} at {Res}m resolution",
_mapWidth, _mapHeight, _mapResolution);
}
#endregion
#region Measurement Model
///
/// Initialize measurement model constants (likelihood field model)
///
private void InitializeMeasurementModel()
{
double varHit = _config.Mcl.VarHit;
double zHit = _config.Mcl.ZHit;
double zRand = _config.Mcl.ZRand;
_normConstHit = 1.0 / Math.Sqrt(2.0 * Math.PI * varHit);
_denomHit = 2.0 * varHit;
_measurementModelRandom = zRand;
}
#endregion
#region Scan Evaluation
///
/// Timer callback: Evaluate scan quality at current pose
///
private void EvaluateScanQuality(object? state)
{
try
{
// Read cached scan and pose
RangeDataPayload? scan;
Pose? pose;
bool running;
lock (_lock)
{
running = _running;
scan = _latestScan;
pose = _latestPose;
}
if (!running || scan == null || pose == null)
{
return;
}
var stopwatch = Stopwatch.StartNew();
// Convert scan to ranges
var pointsForScan = scan.SensorFrame.Ranges
.Select(r => new Vector2(r.Position.X, r.Position.Y))
.ToList();
double angleMinDeg = scan.ActualAngleMin * (180.0 / Math.PI);
double angleMaxDeg = scan.ActualAngleMax * (180.0 / Math.PI);
// Evaluate scan at current pose
var result = EvaluateScanAtPose(pose.Value, pointsForScan, angleMinDeg, angleMaxDeg);
stopwatch.Stop();
// Update metrics
lock (_lock)
{
if (_running)
{
_reliability = result.Reliability;
_mae = result.Mae;
_lastUpdateTime = DateTime.UtcNow;
_logger.LogDebug(
"ScanMatchingQualityEvaluator: reliability={Reliability:F3}, mae={Mae:F4}m, validBeams={Beams}, elapsed={Elapsed}ms",
result.Reliability,
result.Mae,
result.ValidBeams,
stopwatch.ElapsedMilliseconds);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ScanMatchingQualityEvaluator: Evaluation failed, keeping previous metrics");
}
}
///
/// Evaluate scan quality at given pose using likelihood field model
///
private EvaluationResult EvaluateScanAtPose(Pose pose, List points, double angleMinDeg, double angleMaxDeg)
{
if (_distanceMap == null)
{
return new EvaluationResult(0.5, 1.0, 0.0, 0);
}
double totalError = 0;
double totalLikelihood = 0;
int validBeams = 0;
// Pose in map frame
double poseX = pose.Position.X;
double poseY = pose.Position.Y;
double poseYaw = pose.Orientation.ToYawRadian();
int numPoints = points.Count;
double angleRangeDeg = angleMaxDeg - angleMinDeg;
int scanStep = _config.Mcl.ScanStep;
for (int i = 0; i < numPoints; i += scanStep)
{
var point = points[i];
double range = Math.Sqrt(point.X * point.X + point.Y * point.Y);
// Skip invalid ranges
if (range < _config.TrajectoryBuilder.MinRange || range > _config.TrajectoryBuilder.MaxRange)
{
continue;
}
// Beam angle in sensor frame
double beamAngleDeg = angleMinDeg + (angleRangeDeg * i / numPoints);
double beamAngleRad = beamAngleDeg * (Math.PI / 180.0);
// Transform beam endpoint to map frame
double cosYaw = Math.Cos(poseYaw);
double sinYaw = Math.Sin(poseYaw);
double beamEndX = poseX + (point.X * cosYaw - point.Y * sinYaw);
double beamEndY = poseY + (point.X * sinYaw + point.Y * cosYaw);
// Convert to grid coordinates
double dx = beamEndX - _mapOriginX;
double dy = beamEndY - _mapOriginY;
double cosOrigin = Math.Cos(_mapOriginYaw);
double sinOrigin = Math.Sin(_mapOriginYaw);
double gridX = (dx * cosOrigin + dy * sinOrigin) / _mapResolution;
double gridY = (-dx * sinOrigin + dy * cosOrigin) / _mapResolution;
int u = (int)Math.Round(gridX);
int v = (int)Math.Round(gridY);
// Check bounds
if (u < 0 || u >= _mapWidth || v < 0 || v >= _mapHeight)
{
continue;
}
// Get distance to nearest obstacle
double dist = _distanceMap[v, u];
// Calculate error (MAE)
totalError += dist;
// Calculate likelihood using likelihood field model
double pHit = _normConstHit * Math.Exp(-(dist * dist) / _denomHit);
double likelihood = _config.Mcl.ZHit * pHit + _measurementModelRandom;
totalLikelihood += likelihood;
validBeams++;
}
// Calculate metrics
double mae = validBeams > 0 ? totalError / validBeams : 1.0;
double avgLikelihood = validBeams > 0 ? totalLikelihood / validBeams : 0.0;
// Calculate reliability from MAE and likelihood
double reliability = CalculateReliability(mae, avgLikelihood);
return new EvaluationResult(reliability, mae, avgLikelihood, validBeams);
}
///
/// Calculate reliability [0,1] from MAE and average likelihood
///
private double CalculateReliability(double mae, double avgLikelihood)
{
// MAE-based component (exponential decay)
// Good: < 0.05m → 1.0
// Poor: > 0.5m → ~0.0
double maeScore = Math.Exp(-10.0 * mae);
// Likelihood-based component (already normalized 0-1)
double likelihoodScore = Math.Clamp(avgLikelihood, 0.0, 1.0);
// Combine: MAE more important (70%), likelihood 30%
double reliability = maeScore * 0.7 + likelihoodScore * 0.3;
return Math.Clamp(reliability, 0.0, 1.0);
}
#endregion
#region IDisposable
public void Dispose()
{
Stop();
GC.SuppressFinalize(this);
}
#endregion
///
/// Evaluation result
///
private record EvaluationResult(double Reliability, double Mae, double AvgLikelihood, int ValidBeams);
///
/// Evaluation metrics (public)
///
public record EvaluationMetrics(double Reliability, double? Mae, DateTime UpdateTime);
}