diff --git a/OpcUa/Models/MonitoredNode.cs b/OpcUa/Models/MonitoredNode.cs
index b83c061..d468c0d 100644
--- a/OpcUa/Models/MonitoredNode.cs
+++ b/OpcUa/Models/MonitoredNode.cs
@@ -11,6 +11,17 @@ public class MonitoredNode
public NodeId NodeId { get; init; } = ObjectIds.RootFolder;
public string DisplayName { get; init; } = string.Empty;
public string Value { get; set; } = string.Empty;
+
+ ///
+ /// Full-precision, culture-invariant representation of the last value,
+ /// captured at the same point the display is set
+ /// (see SubscriptionManager.FormatRawValue). Used for CSV recording
+ /// so exported data is lossless and locale-independent, while
+ /// remains a culture-aware display string ("F2" for
+ /// floating point). Arrays are serialized as semicolon-joined elements.
+ ///
+ public string RawValue { get; set; } = string.Empty;
+
public DateTime? Timestamp { get; set; }
public uint StatusCode { get; set; }
public bool IsGood => StatusCode == 0; // StatusCode.Good = 0
diff --git a/OpcUa/SubscriptionManager.cs b/OpcUa/SubscriptionManager.cs
index 8227cbb..b35534a 100644
--- a/OpcUa/SubscriptionManager.cs
+++ b/OpcUa/SubscriptionManager.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Opc.Ua;
using Opc.Ua.Client;
using Opcilloscope.OpcUa.Models;
@@ -152,6 +153,7 @@ public async Task InitializeAsync()
NodeId = nodeId,
DisplayName = displayName,
Value = "(pending)",
+ RawValue = "(pending)",
StatusCode = 0 // Good
};
@@ -232,6 +234,7 @@ private async Task ReadInitialValueAsync(MonitoredNode item)
if (value != null)
{
item.Value = FormatValue(value.Value);
+ item.RawValue = FormatRawValue(value.Value);
item.Timestamp = value.SourceTimestamp;
item.StatusCode = (uint)value.StatusCode.Code;
ValueChanged?.Invoke(item);
@@ -384,6 +387,7 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue)
var newValue = FormatValue(dataValue.Value);
variable.Value = newValue;
+ variable.RawValue = FormatRawValue(dataValue.Value);
variable.Timestamp = dataValue.SourceTimestamp;
variable.StatusCode = (uint)dataValue.StatusCode.Code;
@@ -396,6 +400,11 @@ private void ProcessValueChange(MonitoredNode variable, DataValue dataValue)
ValueChanged?.Invoke(variable);
}
+ ///
+ /// Formats a value for on-screen display. Intentionally culture-aware and
+ /// truncated ("F2") for readability; never use this for data export -
+ /// use instead.
+ ///
internal static string FormatValue(object? value)
{
if (value == null) return "null";
@@ -406,6 +415,44 @@ internal static string FormatValue(object? value)
return value.ToString() ?? "null";
}
+ ///
+ /// Formats a value for data export (CSV recording): full precision,
+ /// culture-invariant. Floating point uses round-trip formatting, DateTime
+ /// uses ISO 8601 ("O"), other IFormattable types use InvariantCulture, and
+ /// arrays are serialized as their actual elements joined with ';'
+ /// (e.g. "1;2;3"), so the field never needs CSV comma-escaping for the
+ /// separator itself. Within array elements, '\' and ';' are escaped as
+ /// "\\" and "\;" so multi-element string arrays remain unambiguous.
+ ///
+ internal static string FormatRawValue(object? value)
+ {
+ if (value == null) return "null";
+ if (value is string s) return s;
+ // Round-trip floating point: default ToString is shortest round-trippable
+ // on modern .NET; pin the culture so the decimal separator is always '.'.
+ if (value is float f) return f.ToString(CultureInfo.InvariantCulture);
+ if (value is double d) return d.ToString(CultureInfo.InvariantCulture);
+ // ISO 8601 round-trip format for timestamps embedded in values.
+ if (value is DateTime dt) return dt.ToString("O", CultureInfo.InvariantCulture);
+ if (value is Array arr)
+ {
+ // Serialize the actual elements (semicolon-joined) instead of the
+ // lossy "[N items]" display placeholder. Escape '\' and ';' inside
+ // elements so ["a;b","c"] is distinguishable from ["a","b","c"].
+ var parts = new List(arr.Length);
+ foreach (var element in arr)
+ {
+ parts.Add(FormatRawValue(element).Replace("\\", "\\\\").Replace(";", "\\;"));
+ }
+ return string.Join(";", parts);
+ }
+ if (value is IFormattable formattable)
+ {
+ return formattable.ToString(null, CultureInfo.InvariantCulture);
+ }
+ return value.ToString() ?? "null";
+ }
+
public async Task ClearAsync()
{
List handles;
@@ -648,6 +695,7 @@ public void MarkAllAsStale()
foreach (var variable in staleVariables)
{
variable.Value = "(reconnecting...)";
+ variable.RawValue = "(reconnecting...)";
variable.StatusCode = StatusCodes.UncertainInitialValue;
}
}
diff --git a/README.md b/README.md
index 13d77de..c3fe8a1 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Browse, monitor, and subscribe to industrial automation data right from your ter
- **Monitor** — Subscribe to variables with `Enter`. Real-time updates via OPC UA pub/sub, not polling.
- **Inspect** — Full node attributes: Description, DataType, AccessLevel, ValueRank.
- **Scope** — Real-time multi-signal oscilloscope (up to 5 signals, 30 s sliding window).
-- **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured.
+- **Record** — Export monitored values to CSV. Zero data loss — every server-pushed sample is captured at full precision in a locale-independent format (ISO 8601 timestamps, `.` decimal separator, arrays as semicolon-joined elements).
- **Configure** — Save/load connection and subscription configs (`.cfg` JSON files).
- **Themes** — Dark (default) and light.
diff --git a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs
index e4a945a..a6e2e59 100644
--- a/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs
+++ b/Tests/Opcilloscope.Tests/OpcUa/SubscriptionManagerTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Opc.Ua;
using Opcilloscope.OpcUa;
@@ -241,6 +242,185 @@ public void FormatValue_DoubleFormatting_VariousValues(double input, string expe
}
}
+public class FormatRawValueTests
+{
+ ///
+ /// Runs an action with the given culture set as both the current and the
+ /// default thread culture, restoring the originals afterwards.
+ ///
+ private static void WithCulture(string cultureName, Action action)
+ {
+ var culture = new CultureInfo(cultureName);
+ var originalCurrent = CultureInfo.CurrentCulture;
+ var originalDefault = CultureInfo.DefaultThreadCurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ action();
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = originalCurrent;
+ CultureInfo.DefaultThreadCurrentCulture = originalDefault;
+ }
+ }
+
+ [Fact]
+ public void FormatRawValue_Null_ReturnsNullString()
+ {
+ Assert.Equal("null", SubscriptionManager.FormatRawValue(null));
+ }
+
+ [Fact]
+ public void FormatRawValue_String_ReturnsAsIs()
+ {
+ Assert.Equal("Hello, World", SubscriptionManager.FormatRawValue("Hello, World"));
+ }
+
+ [Fact]
+ public void FormatRawValue_Double_PreservesFullPrecision()
+ {
+ // Display format truncates to "2.72"; raw must keep full precision.
+ Assert.Equal("2.71828", SubscriptionManager.FormatRawValue(2.71828));
+ }
+
+ [Fact]
+ public void FormatRawValue_Float_PreservesFullPrecision()
+ {
+ Assert.Equal("3.14159", SubscriptionManager.FormatRawValue(3.14159f));
+ }
+
+ [Fact]
+ public void FormatRawValue_Double_RoundTrips()
+ {
+ var original = 1.0 / 3.0;
+
+ var text = SubscriptionManager.FormatRawValue(original);
+ var parsed = double.Parse(text, CultureInfo.InvariantCulture);
+
+ Assert.Equal(original, parsed);
+ }
+
+ [Fact]
+ public void FormatRawValue_Float_RoundTrips()
+ {
+ var original = 0.1f * 7f;
+
+ var text = SubscriptionManager.FormatRawValue(original);
+ var parsed = float.Parse(text, CultureInfo.InvariantCulture);
+
+ Assert.Equal(original, parsed);
+ }
+
+ [Theory]
+ [InlineData("fi-FI")]
+ [InlineData("de-DE")]
+ [InlineData("th-TH")]
+ public void FormatRawValue_Double_UsesDotDecimalSeparator_UnderHostileCulture(string cultureName)
+ {
+ WithCulture(cultureName, () =>
+ {
+ var result = SubscriptionManager.FormatRawValue(42.12);
+
+ // Exact ordinal comparison: '.' decimal separator, never ','.
+ // (Avoid Assert.DoesNotContain(string) here - its default
+ // comparison is culture-sensitive and th-TH collation treats
+ // punctuation as ignorable.)
+ Assert.Equal("42.12", result);
+ });
+ }
+
+ [Fact]
+ public void FormatRawValue_Decimal_UsesInvariantCulture()
+ {
+ WithCulture("de-DE", () =>
+ {
+ Assert.Equal("1234.5678", SubscriptionManager.FormatRawValue(1234.5678m));
+ });
+ }
+
+ [Fact]
+ public void FormatRawValue_DateTime_UsesIso8601RoundTripFormat()
+ {
+ WithCulture("th-TH", () =>
+ {
+ var value = new DateTime(2026, 1, 6, 14, 30, 45, 678, DateTimeKind.Utc);
+
+ var result = SubscriptionManager.FormatRawValue(value);
+
+ // ISO 8601, Gregorian year (not Buddhist 2569), ':' separators.
+ Assert.Equal("2026-01-06T14:30:45.6780000Z", result);
+ });
+ }
+
+ [Fact]
+ public void FormatRawValue_IntArray_SerializesElementsSemicolonJoined()
+ {
+ Assert.Equal("1;2;3;4;5", SubscriptionManager.FormatRawValue(new[] { 1, 2, 3, 4, 5 }));
+ }
+
+ [Fact]
+ public void FormatRawValue_DoubleArray_SerializesElementsInvariantly()
+ {
+ WithCulture("fi-FI", () =>
+ {
+ Assert.Equal("1.1;2.2;3.3", SubscriptionManager.FormatRawValue(new[] { 1.1, 2.2, 3.3 }));
+ });
+ }
+
+ [Fact]
+ public void FormatRawValue_StringArray_SerializesElements()
+ {
+ Assert.Equal("a;b;c", SubscriptionManager.FormatRawValue(new[] { "a", "b", "c" }));
+ }
+
+ [Fact]
+ public void FormatRawValue_StringArrayWithSemicolons_EscapesElementSeparators()
+ {
+ // ["a;b", "c"] must not collide with ["a", "b", "c"].
+ Assert.Equal(@"a\;b;c", SubscriptionManager.FormatRawValue(new[] { "a;b", "c" }));
+ Assert.NotEqual(
+ SubscriptionManager.FormatRawValue(new[] { "a", "b", "c" }),
+ SubscriptionManager.FormatRawValue(new[] { "a;b", "c" }));
+ }
+
+ [Fact]
+ public void FormatRawValue_StringArrayWithBackslashes_EscapesBackslashes()
+ {
+ // A literal backslash is doubled so it can't be misread as an escape.
+ Assert.Equal(@"a\\;b\\\;c", SubscriptionManager.FormatRawValue(new[] { @"a\", @"b\;c" }));
+ }
+
+ [Fact]
+ public void FormatRawValue_EmptyArray_ReturnsEmptyString()
+ {
+ Assert.Equal(string.Empty, SubscriptionManager.FormatRawValue(Array.Empty()));
+ }
+
+ [Fact]
+ public void FormatRawValue_ByteArray_SerializesElements()
+ {
+ Assert.Equal("1;2;255", SubscriptionManager.FormatRawValue(new byte[] { 1, 2, 255 }));
+ }
+
+ [Fact]
+ public void FormatRawValue_Boolean_FormatsAsTrueFalse()
+ {
+ Assert.Equal("True", SubscriptionManager.FormatRawValue(true));
+ Assert.Equal("False", SubscriptionManager.FormatRawValue(false));
+ }
+
+ [Fact]
+ public void FormatRawValue_Integer_FormatsInvariantly()
+ {
+ WithCulture("de-DE", () =>
+ {
+ Assert.Equal("1234567", SubscriptionManager.FormatRawValue(1234567));
+ });
+ }
+}
+
public class NodeIdExtensionsTests
{
[Fact]
diff --git a/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs b/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs
index 8a6e808..9f42160 100644
--- a/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs
+++ b/Tests/Opcilloscope.Tests/Utilities/ConnectionIdentifierTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Opcilloscope.Utilities;
namespace Opcilloscope.Tests.Utilities;
@@ -17,6 +18,27 @@ public void Generate_WithOpcTcpUrl_ReturnsHostPortTimestamp()
Assert.Equal("192.168.1.67-50000_20260107_1234", result);
}
+ [Fact]
+ public void Generate_UsesGregorianCalendar_UnderThaiCulture()
+ {
+ // th-TH defaults to the Buddhist calendar (2026 -> 2569); the
+ // identifier timestamp must stay Gregorian regardless of locale.
+ var originalCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("th-TH");
+ var timestamp = new DateTime(2026, 1, 7, 12, 34, 0);
+
+ var result = ConnectionIdentifier.Generate("opc.tcp://localhost:4840", timestamp);
+
+ Assert.Equal("localhost-4840_20260107_1234", result);
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = originalCulture;
+ }
+ }
+
[Fact]
public void Generate_WithLocalhost_ReturnsHostPortTimestamp()
{
diff --git a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs
index c5558e1..f23f2b7 100644
--- a/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs
+++ b/Tests/Opcilloscope.Tests/Utilities/CsvRecordingManagerTests.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using Opc.Ua;
using Opcilloscope.OpcUa.Models;
using Opcilloscope.Utilities;
@@ -506,6 +507,199 @@ public void StopRecording_FlushesQueuedRecordsBeforeClosing()
Assert.Equal(count + 1, lines.Length); // header + all records
}
+ ///
+ /// Runs an action under a hostile culture, set as both the current culture
+ /// (flows to the background writer task via ExecutionContext) and the
+ /// default thread culture (covers any thread that does not inherit it).
+ /// Restored in a finally block so other tests are unaffected.
+ ///
+ private static void WithCulture(string cultureName, Action action)
+ {
+ var culture = new CultureInfo(cultureName);
+ var originalCurrent = CultureInfo.CurrentCulture;
+ var originalDefault = CultureInfo.DefaultThreadCurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ action();
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = originalCurrent;
+ CultureInfo.DefaultThreadCurrentCulture = originalDefault;
+ }
+ }
+
+ [Fact]
+ public void RecordValue_TimestampIsIso8601_UnderFinnishCulture()
+ {
+ // fi-FI replaces the ':' custom-format placeholder with '.', which
+ // previously produced "14.30.45" instead of ISO 8601 "14:30:45".
+ WithCulture("fi-FI", () =>
+ {
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "TestNode",
+ NodeId = new NodeId(1234),
+ Value = "100",
+ Timestamp = new DateTime(2026, 1, 6, 14, 30, 45, 678)
+ };
+
+ _manager.RecordValue(node);
+ _manager.StopRecording(); // waits for the background writer to drain
+
+ var content = File.ReadAllText(filePath);
+ Assert.Contains("2026-01-06T14:30:45.678", content);
+ Assert.DoesNotContain("14.30.45", content);
+ });
+ }
+
+ [Fact]
+ public void RecordValue_TimestampUsesGregorianCalendar_UnderThaiCulture()
+ {
+ // th-TH defaults to the Buddhist calendar (2026 -> 2569), which
+ // previously leaked into the recorded year.
+ WithCulture("th-TH", () =>
+ {
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "TestNode",
+ NodeId = new NodeId(1234),
+ Value = "100",
+ Timestamp = new DateTime(2026, 1, 6, 14, 30, 45, 678)
+ };
+
+ _manager.RecordValue(node);
+ _manager.StopRecording();
+
+ var content = File.ReadAllText(filePath);
+ Assert.Contains("2026-01-06T14:30:45.678", content);
+ Assert.DoesNotContain("2569", content);
+ });
+ }
+
+ [Fact]
+ public void RecordValue_NullTimestampFallback_IsIso8601_UnderFinnishCulture()
+ {
+ WithCulture("fi-FI", () =>
+ {
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "TestNode",
+ NodeId = new NodeId(1234),
+ Value = "100",
+ Timestamp = null // DateTime.Now fallback formatted on the writer thread
+ };
+
+ _manager.RecordValue(node);
+ _manager.StopRecording();
+
+ var lines = File.ReadAllLines(filePath);
+ Assert.True(lines.Length >= 2);
+ var timestamp = lines[1].Split(',')[0];
+ // ISO 8601: 'T' separator and ':' time separators (fi-FI would emit '.')
+ Assert.Matches(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$", timestamp);
+ });
+ }
+
+ [Fact]
+ public void RecordValue_RecordsRawValue_NotTruncatedDisplayValue()
+ {
+ // Arrange - display Value is the truncated "F2" string; RawValue holds
+ // the full-precision invariant representation. The CSV must record RawValue.
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "TestNode",
+ NodeId = new NodeId(1234),
+ Value = "42.12",
+ RawValue = "42.123456789012345",
+ Timestamp = new DateTime(2026, 1, 6, 12, 30, 45, 123)
+ };
+
+ // Act
+ _manager.RecordValue(node);
+ _manager.StopRecording();
+
+ // Assert
+ var content = File.ReadAllText(filePath);
+ Assert.Contains("42.123456789012345", content);
+ }
+
+ [Fact]
+ public void RecordValue_FallsBackToDisplayValue_WhenRawValueIsEmpty()
+ {
+ // Arrange - nodes that never had a raw representation set must still record.
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "TestNode",
+ NodeId = new NodeId(1234),
+ Value = "fallback-value"
+ // RawValue left empty
+ };
+
+ // Act
+ _manager.RecordValue(node);
+ _manager.StopRecording();
+
+ // Assert
+ var content = File.ReadAllText(filePath);
+ Assert.Contains("fallback-value", content);
+ }
+
+ [Fact]
+ public void RecordValue_SnapshotsRawValueAtEnqueueTime()
+ {
+ // Arrange - the RawValue snapshot must be immutable at enqueue time,
+ // exactly like the display value snapshot.
+ var filePath = Path.Combine(_testDirectory, "test.csv");
+ _manager.StartRecording(filePath);
+ var node = new MonitoredNode
+ {
+ DisplayName = "SnapNode",
+ NodeId = new NodeId(1234),
+ Value = "1.23",
+ RawValue = "1.2345678"
+ };
+
+ // Act - enqueue, then mutate the live node (as the OPC thread would).
+ _manager.RecordValue(node);
+ node.RawValue = "9.8765432";
+ node.Value = "9.88";
+ _manager.StopRecording();
+
+ // Assert
+ var content = File.ReadAllText(filePath);
+ Assert.Contains("1.2345678", content);
+ Assert.DoesNotContain("9.8765432", content);
+ }
+
+ [Fact]
+ public void GenerateDefaultRecordingFilename_UsesGregorianCalendar_UnderThaiCulture()
+ {
+ WithCulture("th-TH", () =>
+ {
+ var filename = CsvRecordingManager.GenerateDefaultRecordingFilename(
+ "opc.tcp://localhost:4840", 3);
+
+ // The timestamp must use the Gregorian year, not Buddhist (+543).
+ var gregorianYear = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture);
+ var buddhistYear = (DateTime.Now.Year + 543).ToString(CultureInfo.InvariantCulture);
+ Assert.Contains(gregorianYear, filename);
+ Assert.DoesNotContain(buddhistYear, filename);
+ });
+ }
+
[Fact]
public void StartRecording_ClearsStaleQueueFromPreviousSession()
{
diff --git a/Utilities/ConnectionIdentifier.cs b/Utilities/ConnectionIdentifier.cs
index 32fbe00..b1eeecc 100644
--- a/Utilities/ConnectionIdentifier.cs
+++ b/Utilities/ConnectionIdentifier.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace Opcilloscope.Utilities;
///
@@ -16,7 +18,9 @@ public static class ConnectionIdentifier
/// A standardized identifier string (e.g., "192.168.1.67-50000_20260107_1234").
public static string Generate(string? endpointUrl, DateTime? timestamp = null, string timestampFormat = "yyyyMMdd_HHmm")
{
- var ts = (timestamp ?? DateTime.Now).ToString(timestampFormat);
+ // InvariantCulture pins the Gregorian calendar and separators so the
+ // identifier is stable regardless of the user's locale.
+ var ts = (timestamp ?? DateTime.Now).ToString(timestampFormat, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(endpointUrl))
return $"config_{ts}";
diff --git a/Utilities/CsvRecordingManager.cs b/Utilities/CsvRecordingManager.cs
index fb0a3c4..91235d5 100644
--- a/Utilities/CsvRecordingManager.cs
+++ b/Utilities/CsvRecordingManager.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Globalization;
using Opcilloscope.OpcUa.Models;
namespace Opcilloscope.Utilities;
@@ -6,6 +7,10 @@ namespace Opcilloscope.Utilities;
///
/// Manages CSV recording of monitored variable value changes.
/// Writes data to file in real-time as values change using a background queue.
+/// Output is culture-invariant: timestamps are ISO 8601 (Gregorian calendar,
+/// '.' decimal / ':' time separators regardless of locale) and values are the
+/// full-precision raw representation ('.' decimal separator, arrays as
+/// semicolon-joined elements) rather than the truncated UI display string.
///
public class CsvRecordingManager : IDisposable
{
@@ -85,7 +90,9 @@ public static string EnsureRecordingsDirectory()
/// A sanitized filename with .csv extension.
public static string GenerateDefaultRecordingFilename(string? connectionUrl, int variableCount)
{
- var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ // InvariantCulture pins the Gregorian calendar (e.g. th-TH defaults to
+ // the Buddhist calendar, which would shift the year by 543).
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture);
string baseName;
if (!string.IsNullOrEmpty(connectionUrl))
@@ -380,11 +387,14 @@ public void RecordValue(MonitoredNode item)
// thread mutates the live MonitoredNode in place, so queuing the
// reference would let the writer serialize a newer state than was
// sampled (duplicated/skipped rows under load).
+ // Record the full-precision, culture-invariant RawValue rather than the
+ // truncated ("F2"), culture-aware display Value. Fall back to Value for
+ // nodes that never had a raw representation set.
var snapshot = new RecordSnapshot(
item.Timestamp,
item.DisplayName,
item.NodeId.ToString(),
- item.Value,
+ string.IsNullOrEmpty(item.RawValue) ? item.Value : item.RawValue,
item.StatusString);
// Queue the snapshot for background writing (non-blocking)
@@ -464,9 +474,13 @@ private void WriteRecord(RecordSnapshot item)
try
{
- // Use ISO 8601 timestamp format with milliseconds for precision
- var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff")
- ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ // Use ISO 8601 timestamp format with milliseconds for precision.
+ // InvariantCulture is required: the ':' custom-format specifier
+ // is replaced by the culture's time separator (fi-FI uses '.')
+ // and the culture's default calendar applies (th-TH uses the
+ // Buddhist calendar), which would break the ISO 8601 contract.
+ var timestamp = item.Timestamp?.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)
+ ?? DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture);
// Escape values for CSV (handle quotes and commas)
var displayName = EscapeCsvField(item.DisplayName);