From c1ebd16837aab70d4057a052b6aa649ee6d51a9d Mon Sep 17 00:00:00 2001 From: Eric Pohl Date: Thu, 9 Oct 2025 10:14:24 -0400 Subject: [PATCH 1/4] Construct DateTime objects with Local DateTimeKind --- ISOv4Plugin/Mappers/TimeLogMapper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ISOv4Plugin/Mappers/TimeLogMapper.cs b/ISOv4Plugin/Mappers/TimeLogMapper.cs index 4922ace..5c336a7 100644 --- a/ISOv4Plugin/Mappers/TimeLogMapper.cs +++ b/ISOv4Plugin/Mappers/TimeLogMapper.cs @@ -150,7 +150,7 @@ private class BinaryWriter { // ATTENTION: CoordinateMultiplier and ZMultiplier also exist in Import\SpatialRecordMapper.cs! private const double CoordinateMultiplier = 0.0000001; private const double ZMultiplier = 0.001; // In ISO the PositionUp value is specified in mm. - private readonly DateTime _januaryFirst1980 = new DateTime(1980, 1, 1); + private readonly DateTime _januaryFirst1980 = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local); private readonly IEnumeratedValueMapper _enumeratedValueMapper; private readonly INumericValueMapper _numericValueMapper; @@ -753,7 +753,7 @@ internal static Dictionary ReadImplementGeometryValues(IEnumerable ReadImplementGeometryValues(string filePath, ISOTime templateTime, IEnumerable desiredDLVIndices, int version, IList errors) { From 5624d23d0f305cbf3a10093e4d421bf314ac7560 Mon Sep 17 00:00:00 2001 From: Eric Pohl Date: Thu, 9 Oct 2025 10:34:01 -0400 Subject: [PATCH 2/4] Consolidate "first day of 1980" fields --- ISOv4Plugin/Mappers/TimeLogMapper.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ISOv4Plugin/Mappers/TimeLogMapper.cs b/ISOv4Plugin/Mappers/TimeLogMapper.cs index 5c336a7..13a39bb 100644 --- a/ISOv4Plugin/Mappers/TimeLogMapper.cs +++ b/ISOv4Plugin/Mappers/TimeLogMapper.cs @@ -33,6 +33,8 @@ internal TimeLogMapper(TaskDataMapper taskDataMapper) : base(taskDataMapper, "TL { } + private static readonly DateTime _firstDayOf1980 = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local); + #region Export private Dictionary _dataLogValueOrdersByWorkingDataID; public IEnumerable ExportTimeLogs(IEnumerable operationDatas, string dataPath) @@ -150,7 +152,6 @@ private class BinaryWriter { // ATTENTION: CoordinateMultiplier and ZMultiplier also exist in Import\SpatialRecordMapper.cs! private const double CoordinateMultiplier = 0.0000001; private const double ZMultiplier = 0.001; // In ISO the PositionUp value is specified in mm. - private readonly DateTime _januaryFirst1980 = new DateTime(1980, 1, 1, 0, 0, 0, DateTimeKind.Local); private readonly IEnumeratedValueMapper _enumeratedValueMapper; private readonly INumericValueMapper _numericValueMapper; @@ -193,7 +194,7 @@ private void WriteSpatialRecord(SpatialRecord spatialRecord, List m var millisecondsSinceMidnight = (UInt32)new TimeSpan(0, spatialRecord.Timestamp.Hour, spatialRecord.Timestamp.Minute, spatialRecord.Timestamp.Second, spatialRecord.Timestamp.Millisecond).TotalMilliseconds; memoryStream.Write(BitConverter.GetBytes(millisecondsSinceMidnight), 0, 4); - var daysSinceJanOne1980 = (UInt16)(spatialRecord.Timestamp - (_januaryFirst1980)).TotalDays; + var daysSinceJanOne1980 = (UInt16)(spatialRecord.Timestamp - _firstDayOf1980).TotalDays; memoryStream.Write(BitConverter.GetBytes(daysSinceJanOne1980), 0, 2); //Position @@ -753,8 +754,6 @@ internal static Dictionary ReadImplementGeometryValues(IEnumerable ReadImplementGeometryValues(string filePath, ISOTime templateTime, IEnumerable desiredDLVIndices, int version, IList errors) { Dictionary output = new Dictionary(); From 59d26e07e3fb035871e97ad16b9545cd09a9b46a Mon Sep 17 00:00:00 2001 From: ericpohl Date: Fri, 5 Dec 2025 11:47:08 -0500 Subject: [PATCH 3/4] More messing with timezones --- ISOv4Plugin/ExtensionMethods/XmlExtensions.cs | 33 ++++++++++++++-- .../Import/SpatialRecordMapper.cs | 39 ++++++++++++------- ISOv4Plugin/Mappers/TaskDataMapper.cs | 1 + ISOv4Plugin/Mappers/TimeLogMapper.cs | 8 +++- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/ISOv4Plugin/ExtensionMethods/XmlExtensions.cs b/ISOv4Plugin/ExtensionMethods/XmlExtensions.cs index e99fac4..93ee1b9 100644 --- a/ISOv4Plugin/ExtensionMethods/XmlExtensions.cs +++ b/ISOv4Plugin/ExtensionMethods/XmlExtensions.cs @@ -6,12 +6,15 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Xml; namespace AgGateway.ADAPT.ISOv4Plugin.ExtensionMethods { public static class XmlExtensions { + private static readonly Regex _timezoneOffsetRegex = new Regex(@"(\+|-)\d\d:\d\d|Z$", RegexOptions.Compiled); + public static XmlNodeList LoadActualNodes(this XmlNode xmlNode, string externalNodeTag, string baseFolder) { if (string.Equals(xmlNode.Name, externalNodeTag, StringComparison.OrdinalIgnoreCase)) @@ -112,14 +115,36 @@ public static uint GetXmlNodeValueAsUInt(this XmlNode xmlNode, string xPath) public static DateTime? GetXmlNodeValueAsNullableDateTime(this XmlNode xmlNode, string xPath) { string value = GetXmlNodeValue(xmlNode, xPath); - DateTime outValue; - if (DateTime.TryParse(value, out outValue)) + if (value == null) { - return outValue; + return null; + } + + // The value has timezone info, parse as DateTimeOffset and convert to UTC DateTime + // Otherwise, parse as local DateTime + if (_timezoneOffsetRegex.IsMatch(value)) + { + DateTimeOffset dto; + if (DateTimeOffset.TryParse(value, out dto)) + { + return dto.UtcDateTime; + } + else + { + return null; + } } else { - return null; + DateTime outValue; + if (DateTime.TryParse(value, out outValue)) + { + return outValue; + } + else + { + return null; + } } } diff --git a/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs b/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs index 5b2e90e..4d72f66 100644 --- a/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs +++ b/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs @@ -212,30 +212,39 @@ private DateTime ToUtc(DateTime dateTime) if (dateTime.Kind == DateTimeKind.Utc) return dateTime; - DateTime utc; - if (dateTime.Kind == DateTimeKind.Local) + if (_taskDataMapper.TimezoneOffset.HasValue) { - utc = dateTime.ToUniversalTime(); - } - else if (dateTime.Kind == DateTimeKind.Unspecified && _taskDataMapper.GPSToLocalDelta.HasValue) - { - utc = new DateTime(dateTime.AddHours(-_taskDataMapper.GPSToLocalDelta.Value).Ticks, DateTimeKind.Utc); - } - else - { - // Nothing left to try; return original value - utc = dateTime; + // Convert from local time to UTC using the timezone offset. + var localTime = new DateTimeOffset(dateTime.Year, dateTime.Month, dateTime.Day, + dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, + _taskDataMapper.TimezoneOffset.Value); + DateTime utc = localTime.UtcDateTime; + return utc; } - return utc; + // Return original value + return dateTime; } private DateTime? Offset(DateTime? input) { - if (_effectiveTimeZoneOffset.HasValue && input.HasValue) + if (!input.HasValue) + return null; + + if (input.Value.Kind == DateTimeKind.Utc) + return input; + + if (_effectiveTimeZoneOffset.HasValue) { - return input.Value.AddHours(_effectiveTimeZoneOffset.Value); + var dateTime = input.Value; + var localTime = new DateTimeOffset(dateTime.Year, dateTime.Month, dateTime.Day, + dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, + TimeSpan.FromHours(_effectiveTimeZoneOffset.Value)); + DateTime utc = localTime.UtcDateTime; + return utc; + //return input.Value.AddHours(_effectiveTimeZoneOffset.Value); } + return input; } } diff --git a/ISOv4Plugin/Mappers/TaskDataMapper.cs b/ISOv4Plugin/Mappers/TaskDataMapper.cs index 1a39d86..53dac69 100644 --- a/ISOv4Plugin/Mappers/TaskDataMapper.cs +++ b/ISOv4Plugin/Mappers/TaskDataMapper.cs @@ -78,6 +78,7 @@ public TaskDataMapper(string dataPath, Properties properties, int? taskDataVersi internal Dictionary DDIs { get; private set; } internal DeviceOperationTypes DeviceOperationTypes { get; private set; } internal double? GPSToLocalDelta { get; set; } + internal TimeSpan? TimezoneOffset { get; set; } CodedCommentListMapper _commentListMapper; public CodedCommentListMapper CommentListMapper diff --git a/ISOv4Plugin/Mappers/TimeLogMapper.cs b/ISOv4Plugin/Mappers/TimeLogMapper.cs index 13a39bb..eadd4b9 100644 --- a/ISOv4Plugin/Mappers/TimeLogMapper.cs +++ b/ISOv4Plugin/Mappers/TimeLogMapper.cs @@ -330,8 +330,12 @@ protected IEnumerable ImportTimeLog(ISOTask loggedTask, ISOTimeLo var firstRecord = isoRecords.FirstOrDefault(r => r.GpsUtcDateTime.HasValue && r.GpsUtcDate != ushort.MaxValue && r.GpsUtcDate != 0); if (firstRecord != null) { - //Local - UTC = Delta. This value will be rough based on the accuracy of the clock settings but will expose the ability to derive the UTC times from the exported local times. - TaskDataMapper.GPSToLocalDelta = (firstRecord.TimeStart - firstRecord.GpsUtcDateTime.Value).TotalHours; + //Local - UTC = Delta. This value will be rough based on the accuracy of the clock settings + // but will expose the ability to derive the UTC times from the exported local times. + TimeSpan offset = firstRecord.TimeStart - firstRecord.GpsUtcDateTime.Value; + // Round offset to nearest minute for use in timezone offset + TaskDataMapper.TimezoneOffset = TimeSpan.FromMinutes(Math.Round(offset.TotalMinutes)); + TaskDataMapper.GPSToLocalDelta = TaskDataMapper.TimezoneOffset.Value.TotalHours; } } } From cd09b479b38bd46c67d053282f99eacf0c978437 Mon Sep 17 00:00:00 2001 From: ericpohl Date: Fri, 5 Dec 2025 12:08:58 -0500 Subject: [PATCH 4/4] Cleanup --- .../Import/SpatialRecordMapper.cs | 50 ++++++------------- ISOv4Plugin/Mappers/TaskDataMapper.cs | 2 +- ISOv4Plugin/Mappers/TimeLogMapper.cs | 1 - 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs b/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs index 4d72f66..a77f896 100644 --- a/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs +++ b/ISOv4Plugin/Mappers/LoggedDataMappers/Import/SpatialRecordMapper.cs @@ -1,14 +1,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using AgGateway.ADAPT.ApplicationDataModel.LoggedData; using AgGateway.ADAPT.ApplicationDataModel.Representations; using AgGateway.ADAPT.ISOv4Plugin.ExtensionMethods; using AgGateway.ADAPT.ISOv4Plugin.ObjectModel; using AgGateway.ADAPT.ISOv4Plugin.ISOModels; -using AgGateway.ADAPT.Representation.UnitSystem; -using AgGateway.ADAPT.ISOv4Plugin.Representation; namespace AgGateway.ADAPT.ISOv4Plugin.Mappers { @@ -27,7 +24,7 @@ public class SpatialRecordMapper : ISpatialRecordMapper private readonly IWorkingDataMapper _workingDataMapper; private readonly ISectionMapper _sectionMapper; private readonly TaskDataMapper _taskDataMapper; - private double? _effectiveTimeZoneOffset; + private TimeSpan? _effectiveTimeZoneOffset; public SpatialRecordMapper(IRepresentationValueInterpolator representationValueInterpolator, ISectionMapper sectionMapper, IWorkingDataMapper workingDataMapper, TaskDataMapper taskDataMapper) { @@ -52,7 +49,7 @@ public IEnumerable Map(IEnumerable isoSpatialRows, pan.AllocationStamp.Start.Value.Minute == firstSpatialRow.TimeStart.Minute && pan.AllocationStamp.Start.Value.Second == firstSpatialRow.TimeStart.Second) { - _effectiveTimeZoneOffset = (firstSpatialRow.TimeStart - pan.AllocationStamp.Start.Value).TotalHours; + _effectiveTimeZoneOffset = firstSpatialRow.TimeStart - pan.AllocationStamp.Start.Value; } } } @@ -191,23 +188,26 @@ private void SetNumericMeterValue(ISOSpatialRow isoSpatialRow, NumericWorkingDat /// private bool GovernsTimestamp(ISOProductAllocation p, SpatialRecord spatialRecord) { - DateTime? allocationStart = Offset(p.AllocationStamp.Start); - DateTime? allocationStop = p.AllocationStamp.Stop != null ? Offset(p.AllocationStamp.Stop) : null; - DateTime spatialRecordTimestampUtc = ToUtc(spatialRecord.Timestamp); + DateTime? allocationStartUtc = ToUtc(p.AllocationStamp.Start, _effectiveTimeZoneOffset); + DateTime? allocationStopUtc = p.AllocationStamp.Stop != null ? + ToUtc(p.AllocationStamp.Stop, _effectiveTimeZoneOffset) : null; + DateTime spatialRecordTimestampUtc = ToUtc(spatialRecord.Timestamp, _taskDataMapper.TimezoneOffset); - return - ToUtc(allocationStart) <= spatialRecordTimestampUtc && - (p.AllocationStamp.Stop == null || ToUtc(allocationStop) >= spatialRecordTimestampUtc); + var returnVal = + allocationStartUtc <= spatialRecordTimestampUtc && + (p.AllocationStamp.Stop == null || allocationStopUtc >= spatialRecordTimestampUtc); + + return returnVal; } // Comparing DateTime values with different Kind values leads to inaccurate results. // Convert DateTimes to UTC if possible before comparing them - private DateTime? ToUtc(DateTime? nullableDateTime) + private DateTime? ToUtc(DateTime? nullableDateTime, TimeSpan? timezoneOffset) { - return nullableDateTime.HasValue ? ToUtc(nullableDateTime.Value) : nullableDateTime; + return nullableDateTime.HasValue ? ToUtc(nullableDateTime.Value, timezoneOffset) : nullableDateTime; } - private DateTime ToUtc(DateTime dateTime) + private DateTime ToUtc(DateTime dateTime, TimeSpan? timezoneOffset) { if (dateTime.Kind == DateTimeKind.Utc) return dateTime; @@ -225,27 +225,5 @@ private DateTime ToUtc(DateTime dateTime) // Return original value return dateTime; } - - private DateTime? Offset(DateTime? input) - { - if (!input.HasValue) - return null; - - if (input.Value.Kind == DateTimeKind.Utc) - return input; - - if (_effectiveTimeZoneOffset.HasValue) - { - var dateTime = input.Value; - var localTime = new DateTimeOffset(dateTime.Year, dateTime.Month, dateTime.Day, - dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond, - TimeSpan.FromHours(_effectiveTimeZoneOffset.Value)); - DateTime utc = localTime.UtcDateTime; - return utc; - //return input.Value.AddHours(_effectiveTimeZoneOffset.Value); - } - - return input; - } } } diff --git a/ISOv4Plugin/Mappers/TaskDataMapper.cs b/ISOv4Plugin/Mappers/TaskDataMapper.cs index 53dac69..99517d7 100644 --- a/ISOv4Plugin/Mappers/TaskDataMapper.cs +++ b/ISOv4Plugin/Mappers/TaskDataMapper.cs @@ -77,7 +77,7 @@ public TaskDataMapper(string dataPath, Properties properties, int? taskDataVersi internal RepresentationMapper RepresentationMapper { get; private set; } internal Dictionary DDIs { get; private set; } internal DeviceOperationTypes DeviceOperationTypes { get; private set; } - internal double? GPSToLocalDelta { get; set; } + internal double? GPSToLocalDelta => TimezoneOffset?.TotalHours; internal TimeSpan? TimezoneOffset { get; set; } CodedCommentListMapper _commentListMapper; diff --git a/ISOv4Plugin/Mappers/TimeLogMapper.cs b/ISOv4Plugin/Mappers/TimeLogMapper.cs index eadd4b9..6bbe43d 100644 --- a/ISOv4Plugin/Mappers/TimeLogMapper.cs +++ b/ISOv4Plugin/Mappers/TimeLogMapper.cs @@ -335,7 +335,6 @@ protected IEnumerable ImportTimeLog(ISOTask loggedTask, ISOTimeLo TimeSpan offset = firstRecord.TimeStart - firstRecord.GpsUtcDateTime.Value; // Round offset to nearest minute for use in timezone offset TaskDataMapper.TimezoneOffset = TimeSpan.FromMinutes(Math.Round(offset.TotalMinutes)); - TaskDataMapper.GPSToLocalDelta = TaskDataMapper.TimezoneOffset.Value.TotalHours; } } }