/* * Copyright 2018 The Cartographer Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ using CartographerSharp.Sensor; using System.Globalization; namespace CartographerSharp.Mapping.Internal; /// /// Synchronizes TimedPointCloudData from different sensors. Input needs only be /// monotonous in 'TimedPointCloudData::time', output is monotonous in per-point /// timing. Up to one message per sensor is buffered, so a delay of the period of /// the slowest sensor may be introduced, which can be alleviated by passing /// subdivisions. /// public class RangeDataCollator(IEnumerable expectedRangeSensorIds) { private const double kDefaultIntensityValue = 0.0; private readonly HashSet _expectedSensorIds = [.. expectedRangeSensorIds]; private readonly Dictionary _idToPendingData = []; private long _currentStart = long.MinValue; // Universal Time Scale ticks private long _currentEnd = long.MinValue; // Universal Time Scale ticks // Debug: Track per-sensor timestamps to detect out-of-order data private readonly Dictionary _lastSensorTimestamp = []; private static readonly object _collatorLogLock = new(); private static readonly string _collatorLogPath = "collator.log"; private long _lastOutputTime = long.MinValue; private static void LogCollator(string message) { lock (_collatorLogLock) { try { var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); var line = $"{timestamp}|{message}"; File.AppendAllText(_collatorLogPath, line + Environment.NewLine); } catch { /* Ignore logging errors */ } } } /// /// If timed_point_cloud_data has incomplete intensity data, we will fill the /// missing intensities with kDefaultIntensityValue. /// public TimedPointCloudOriginData AddRangeData(string sensorId, TimedPointCloudData timedPointCloudData) { if (!_expectedSensorIds.Contains(sensorId)) { throw new ArgumentException($"Unexpected sensor ID: {sensorId}", nameof(sensorId)); } // DEBUG: Timestamp validation - check if input is monotonic per sensor var currentTime = timedPointCloudData.Time; var tickMs = currentTime / TimeSpan.TicksPerMillisecond; if (_lastSensorTimestamp.TryGetValue(sensorId, out var lastTime)) { if (currentTime < lastTime) { var diffMs = (lastTime - currentTime) / (double)TimeSpan.TicksPerMillisecond; LogCollator($"WARNING|sensor={sensorId}|TIME_REVERSAL|prev_tick={lastTime / TimeSpan.TicksPerMillisecond}|curr_tick={tickMs}|diff={diffMs:F3}ms"); } else { var deltaMs = (currentTime - lastTime) / (double)TimeSpan.TicksPerMillisecond; // Log normal data flow (can comment out for less verbose logging) // LogCollator($"INFO|sensor={sensorId}|tick={tickMs}|delta={deltaMs:F3}ms|points={timedPointCloudData.Ranges.Count}"); } } _lastSensorTimestamp[sensorId] = currentTime; // Fill missing intensities // Match C++: timed_point_cloud_data.intensities.resize( // timed_point_cloud_data.ranges.size(), kDefaultIntensityValue); // This resizes to exactly ranges.size(), filling with kDefaultIntensityValue if needed, // or truncating if intensities is larger than ranges if (timedPointCloudData.Intensities.Count != timedPointCloudData.Ranges.Count) { var intensities = new List(timedPointCloudData.Intensities); // Resize to match ranges.Count exactly if (intensities.Count < timedPointCloudData.Ranges.Count) { // Fill missing with default value while (intensities.Count < timedPointCloudData.Ranges.Count) { intensities.Add(kDefaultIntensityValue); } } else if (intensities.Count > timedPointCloudData.Ranges.Count) { // Truncate if larger intensities.RemoveRange(timedPointCloudData.Ranges.Count, intensities.Count - timedPointCloudData.Ranges.Count); } timedPointCloudData.Intensities = intensities; } if (_idToPendingData.TryGetValue(sensorId, out TimedPointCloudData value)) { _currentStart = _currentEnd; _currentEnd = value.Time; var result = CropAndMerge(); _idToPendingData[sensorId] = timedPointCloudData; return result; } _idToPendingData[sensorId] = timedPointCloudData; if (_expectedSensorIds.Count != _idToPendingData.Count) { return new TimedPointCloudOriginData(0, [], []); } _currentStart = _currentEnd; // We have messages from all sensors, move forward to oldest. var oldestTimestamp = _idToPendingData.Values.Min(d => d.Time); _currentEnd = oldestTimestamp; return CropAndMerge(); } private TimedPointCloudOriginData CropAndMerge() { var result = new TimedPointCloudOriginData(_currentEnd, [], []); // DEBUG: Check if output time is monotonic var outputTickMs = _currentEnd / TimeSpan.TicksPerMillisecond; if (_lastOutputTime != long.MinValue && _currentEnd < _lastOutputTime) { var diffMs = (_lastOutputTime - _currentEnd) / (double)TimeSpan.TicksPerMillisecond; LogCollator($"WARNING|OUTPUT_TIME_REVERSAL|prev_output={_lastOutputTime / TimeSpan.TicksPerMillisecond}|curr_output={outputTickMs}|diff={diffMs:F3}ms|start={_currentStart / TimeSpan.TicksPerMillisecond}"); } _lastOutputTime = _currentEnd; var warnedForDroppedPoints = false; // Use ToList() to create a snapshot for iteration, but we'll modify _idToPendingData during iteration var sensorIds = _idToPendingData.Keys.ToList(); foreach (var sensorId in sensorIds) { if (!_idToPendingData.TryGetValue(sensorId, out var data)) { continue; // Already removed } var ranges = data.Ranges; var intensities = data.Intensities; // Find overlap range (matching C++ line 69-80) var overlapBegin = 0; while (overlapBegin < ranges.Count) { // Convert seconds to ticks: use double for precision, then round to long // This matches C++: data.time + common::FromSeconds((*overlap_begin).time) var pointTime = data.Time + (long)Math.Round(ranges[overlapBegin].Time * TimeSpan.TicksPerSecond); if (pointTime >= _currentStart) { break; } overlapBegin++; } var overlapEnd = overlapBegin; while (overlapEnd < ranges.Count) { // Convert seconds to ticks: use double for precision, then round to long // This matches C++: data.time + common::FromSeconds((*overlap_end).time) var pointTime = data.Time + (long)Math.Round(ranges[overlapEnd].Time * TimeSpan.TicksPerSecond); if (pointTime > _currentEnd) { break; } overlapEnd++; } if (overlapBegin > 0 && !warnedForDroppedPoints) { // Log warning about dropped points (matching C++ line 81-84) warnedForDroppedPoints = true; } // Copy overlapping range (matching C++ line 88-106) if (overlapBegin < overlapEnd) { var originIndex = result.Origins.Count; result.Origins.Add(data.Origin); // CRITICAL FIX: Apply time correction to point_time.time (match C++ line 91-103) // C++: const double time_correction = static_cast(common::ToSeconds(data.time - current_end_)); // C++: point.point_time.time += time_correction; // Time correction converts the difference between data.Time and currentEnd from ticks to seconds var timeCorrection = ((data.Time - _currentEnd) / 10_000_000.0); // Convert ticks to seconds (10 million ticks per second) for (int i = overlapBegin; i < overlapEnd; i++) { // Apply time correction to point time // Create new TimedRangefinderPoint with corrected time var correctedPointTime = ranges[i].Time + timeCorrection; var correctedPoint = new TimedRangefinderPoint( ranges[i].Position, correctedPointTime); var rangeMeasurement = new TimedPointCloudOriginData.RangeMeasurement( correctedPoint, intensities[i], originIndex); result.Ranges.Add(rangeMeasurement); } } // CRITICAL FIX: Drop buffered points until overlap_end (matching C++ line 108-121) // This prevents reprocessing of already-processed points if (overlapEnd == ranges.Count) { // All points processed, remove entry _idToPendingData.Remove(sensorId); } else if (overlapEnd == 0) { // No points processed, keep entry as is // Continue to next sensor } else { // Some points processed, keep only unprocessed points var remainingRanges = new TimedPointCloud(); var remainingIntensities = new List(); for (int i = overlapEnd; i < ranges.Count; i++) { remainingRanges.Add(ranges[i]); remainingIntensities.Add(intensities[i]); } _idToPendingData[sensorId] = new TimedPointCloudData( data.Time, data.Origin, remainingRanges, remainingIntensities ); } } // CRITICAL FIX: Sort ranges by time (match C++ line 124-128) // C++: std::sort(result.ranges.begin(), result.ranges.end(), // [](const auto& a, const auto& b) { return a.point_time.time < b.point_time.time; }); // This ensures output is monotonous in per-point timing as documented if (result.Ranges.Count > 0) { result.Ranges = [.. result.Ranges.OrderBy(r => r.PointTime.Time)]; } return result; } }