From bf6611a854af90185f1cbf30d608b7984ab57376 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 21:05:05 -0700 Subject: [PATCH 1/6] Bump EncDotNet.Iso8211 to 0.4.2 for nested format controls fix Fixes parsing of S-101 datasets whose DDR uses nested parenthesized format controls (e.g. (b11,(3b24)) for the C3IL field). The ISO 8211 library now correctly expands these nested groups into individual subfield format entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5aa597f..5c30866 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + From 1bac67cb39fbf95e5f3bae97f1a4494ea0a8fb07 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 21:34:38 -0700 Subject: [PATCH 2/6] Bump EncDotNet.Iso8211 to 0.4.3 for UTF-8 text encoding fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes garbled French accented characters (e.g. Île d'Orléans displayed as ??le d'Orl??ans) in S-101 datasets. The ISO 8211 library now uses UTF-8 instead of ASCII for character data decoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5c30866..5b5ea3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + From b3d4d9c0c3520d631eb3a6f25b9a7d088ec83013 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 21:52:41 -0700 Subject: [PATCH 3/6] Add S-111 DCF 3 (ungeorectified grid) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement data coding format 3 reader that maps per-node positioned values into the station-series model for rendering. DCF 3 datasets store explicit per-node lat/lon from Positioning/geometryValues and per-timestep value arrays — structurally similar to DCF 8 but with per-timestep (not per-station) organisation. Changes: - S111DatasetReader: add ReadUngeorectifiedGrid/ReadUngeorectifiedInstance methods that read positions and transpose per-timestep values into per-node SurfaceCurrentStation objects - S111DatasetReader.ReadAny: route DCF 3 through station-series path - S111StationSeriesDataset: update docs for DCF 3 reuse, remove hard-coded DataCodingFormat=8 - S111DatasetProcessor: update doc to mention DCF 3 - Add S111Dcf3FixtureBuilder and S111Dcf3ReaderTests with 3 tests Spec ref: S-100 Part 10c §10.2.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../S111DatasetProcessor.cs | 7 +- .../S111DatasetReader.cs | 206 +++++++++++++++++- .../S111StationSeriesDataset.cs | 20 +- .../Fixtures/S111Dcf3FixtureBuilder.cs | 107 +++++++++ .../S111Dcf3ReaderTests.cs | 202 +++++++++++++++++ 5 files changed, 524 insertions(+), 18 deletions(-) create mode 100644 tests/EncDotNet.S100.Datasets.S111.Tests/Fixtures/S111Dcf3FixtureBuilder.cs create mode 100644 tests/EncDotNet.S100.Datasets.S111.Tests/S111Dcf3ReaderTests.cs diff --git a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs index bf685ac..4e50319 100644 --- a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs +++ b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs @@ -22,9 +22,10 @@ namespace EncDotNet.S100.Datasets.Pipelines; /// /// Pipeline processor for S-111 surface-currents datasets. Branches /// between dcf2 (regular grid → coverage + arrow layers, see existing -/// portrayal catalogue) and dcf8 (time series at fixed stations → -/// station-arrow point layer; see S-111 Edition 2.0.0 §10.2.3 / -/// §10.2.7). +/// portrayal catalogue), dcf3 (ungeorectified grid → station-arrow +/// point layer; S-100 Part 10c §10.2.1), and dcf8 (time series at +/// fixed stations → station-arrow point layer; see S-111 Edition 2.0.0 +/// §10.2.3 / §10.2.7). /// public sealed class S111DatasetProcessor : IDatasetProcessor { diff --git a/src/EncDotNet.S100.Datasets.S111/S111DatasetReader.cs b/src/EncDotNet.S100.Datasets.S111/S111DatasetReader.cs index 758f182..aa7ca80 100644 --- a/src/EncDotNet.S100.Datasets.S111/S111DatasetReader.cs +++ b/src/EncDotNet.S100.Datasets.S111/S111DatasetReader.cs @@ -7,8 +7,10 @@ namespace EncDotNet.S100.Datasets.S111; /// /// Reads an S-111 Surface Currents dataset from an HDF5 file via the /// abstraction. Supports data coding format 2 -/// (regular grid) and data coding format 8 (time series at fixed -/// stations; S-111 Edition 2.0.0 §10.2.3 / §10.2.7). +/// (regular grid), data coding format 3 (ungeorectified grid with +/// explicit per-node positioning; S-100 Part 10c §10.2.1), and data +/// coding format 8 (time series at fixed stations; S-111 Edition 2.0.0 +/// §10.2.3 / §10.2.7). /// public static class S111DatasetReader { @@ -96,9 +98,11 @@ public static S111DatasetData ReadAny(IHdf5File file) ? (int)scGroup.ReadInt64Attribute("typeOfCurrentData") : null; - if (dataCodingFormat == 8) + if (dataCodingFormat is 3 or 8) { - var stations = ReadStationSeries(root, scGroup); + var stations = dataCodingFormat == 3 + ? ReadUngeorectifiedGrid(root, scGroup) + : ReadStationSeries(root, scGroup); DateTime? minTime = null, maxTime = null; foreach (var s in stations) { @@ -114,7 +118,7 @@ public static S111DatasetData ReadAny(IHdf5File file) IssueDate = issueDate, Metadata = metadata, SurfaceCurrentDepth = surfaceCurrentDepth, - DataCodingFormat = 8, + DataCodingFormat = dataCodingFormat, TypeOfCurrentData = typeOfCurrentData, Stations = stations, MinTime = minTime, @@ -285,6 +289,198 @@ private static SurfaceCurrentValue[] ReadValues(IHdf5Group group) $"S-111 member '{member.Name}' has unsupported kind {member.Kind}."), }; + // ------------------------------------------------------------------- + // dcf3 — ungeorectified grid (S-100 Part 10c §10.2.1) + // ------------------------------------------------------------------- + + /// + /// Reads an S-111 data coding format 3 (ungeorectified grid) + /// dataset — each node has an explicit lat/lon from + /// Positioning/geometryValues, and each Group_NNN is a + /// time step with one (speed, direction) per node. + /// + /// + /// DCF 3 is structurally a per-timestep snapshot of irregularly-positioned + /// nodes. To reuse the station-series rendering path, this method transposes + /// the data: each node becomes a whose + /// time series is assembled from the node's value at each time step. + /// + private static IReadOnlyList ReadUngeorectifiedGrid(IHdf5Group root, IHdf5Group scGroup) + { + var allStations = new List(); + + foreach (var instanceName in scGroup.GroupNames) + { + if (!instanceName.StartsWith("SurfaceCurrent.", StringComparison.Ordinal)) + continue; + + var instance = scGroup.OpenGroup(instanceName); + var instancePath = $"/SurfaceCurrent/{instanceName}"; + ReadUngeorectifiedInstance(instance, instancePath, allStations); + } + + return allStations; + } + + private static void ReadUngeorectifiedInstance( + IHdf5Group instance, + string instancePath, + List stations) + { + const string Spec = "S-100 Part 10c §10.2.1"; + + // Read per-node positions from Positioning/geometryValues under this instance. + var positions = ReadInstancePositions(instance, instancePath); + int nodeCount = positions.Count; + + // Collect time-step groups in ascending order. + var timeGroupNames = instance.GroupNames + .Where(n => n.StartsWith("Group_", StringComparison.Ordinal)) + .OrderBy(n => n, StringComparer.Ordinal) + .ToList(); + + if (timeGroupNames.Count == 0) + return; + + // Parse time info from the instance. + string firstTimeStr = instance.ReadRequiredStringAttribute( + "dateTimeOfFirstRecord", "S-111", null, instancePath, Spec); + string lastTimeStr = instance.ReadRequiredStringAttribute( + "dateTimeOfLastRecord", "S-111", null, instancePath, Spec); + DateTime firstTime = ParseTimestamp(firstTimeStr); + DateTime lastTime = ParseTimestamp(lastTimeStr); + + long intervalSeconds = instance.ReadRequiredInt64Attribute( + "timeRecordInterval", "S-111", null, instancePath, Spec); + var interval = TimeSpan.FromSeconds(intervalSeconds); + + int numberOfTimes = timeGroupNames.Count; + + // Read all time steps: speeds[t][node], directions[t][node]. + var allSpeeds = new float[numberOfTimes][]; + var allDirections = new float[numberOfTimes][]; + + for (int t = 0; t < numberOfTimes; t++) + { + var group = instance.OpenGroup(timeGroupNames[t]); + var values = ReadValues(group); + + var speeds = new float[nodeCount]; + var directions = new float[nodeCount]; + int valCount = Math.Min(values.Length, nodeCount); + for (int n = 0; n < valCount; n++) + { + speeds[n] = values[n].Speed; + directions[n] = values[n].Direction; + } + + allSpeeds[t] = speeds; + allDirections[t] = directions; + } + + // Transpose to per-node station series. + for (int n = 0; n < nodeCount; n++) + { + var nodeSpeeds = new float[numberOfTimes]; + var nodeDirections = new float[numberOfTimes]; + for (int t = 0; t < numberOfTimes; t++) + { + nodeSpeeds[t] = allSpeeds[t][n]; + nodeDirections[t] = allDirections[t][n]; + } + + var (lat, lon) = positions[n]; + + stations.Add(new SurfaceCurrentStation + { + Identifier = $"Node_{n + 1:D3}", + Latitude = lat, + Longitude = lon, + StartTime = firstTime, + EndTime = lastTime, + TimeRecordInterval = interval, + NumberOfTimes = numberOfTimes, + SpeedsMetresPerSecond = nodeSpeeds, + DirectionsDegreesTrue = nodeDirections, + }); + } + } + + /// + /// Reads node positions from Positioning/geometryValues under + /// a specific SurfaceCurrent.NN instance group (DCF 3 layout; + /// S-100 Part 10c §10.2.1). + /// + private static List<(double Lat, double Lon)> ReadInstancePositions(IHdf5Group instance, string instancePath) + { + if (!instance.GroupNames.Contains("Positioning")) + { + throw new S100DatasetSchemaException( + product: "S-111", + file: null, + groupPath: $"{instancePath}/Positioning", + attributeOrDataset: "Positioning/geometryValues", + specReference: "S-100 Part 10c §10.2.1", + message: ExceptionMessageFormatter.FormatSchema( + "S-111", null, $"{instancePath}/Positioning", "Positioning/geometryValues", + "S-100 Part 10c §10.2.1")); + } + + var posGroup = instance.OpenGroup("Positioning"); + + RawCompoundDataset raw; + try + { + raw = posGroup.ReadRawCompoundDataset("geometryValues"); + } + catch (Exception ex) + { + throw new S100DatasetSchemaException( + product: "S-111", + file: null, + groupPath: $"{instancePath}/Positioning", + attributeOrDataset: "geometryValues", + specReference: "S-100 Part 10c §10.2.1", + message: ExceptionMessageFormatter.FormatSchema( + "S-111", null, $"{instancePath}/Positioning", "geometryValues", + "S-100 Part 10c §10.2.1"), + innerException: ex); + } + + var latMember = raw.FindMember("latitude", "Latitude", "lat", "Lat") + ?? throw new S100DatasetSchemaException( + product: "S-111", + file: null, + groupPath: $"{instancePath}/Positioning/geometryValues", + attributeOrDataset: "latitude", + specReference: "S-100 Part 10c §10.2.1", + message: ExceptionMessageFormatter.FormatSchema( + "S-111", null, $"{instancePath}/Positioning/geometryValues", "latitude", + "S-100 Part 10c §10.2.1")); + + var lonMember = raw.FindMember("longitude", "Longitude", "long", "Long", "lon", "Lon") + ?? throw new S100DatasetSchemaException( + product: "S-111", + file: null, + groupPath: $"{instancePath}/Positioning/geometryValues", + attributeOrDataset: "longitude", + specReference: "S-100 Part 10c §10.2.1", + message: ExceptionMessageFormatter.FormatSchema( + "S-111", null, $"{instancePath}/Positioning/geometryValues", "longitude", + "S-100 Part 10c §10.2.1")); + + var positions = new List<(double Lat, double Lon)>(raw.RecordCount); + var span = raw.Data.AsSpan(); + for (int i = 0; i < raw.RecordCount; i++) + { + var record = span.Slice(i * raw.RecordSize, raw.RecordSize); + double lat = ReadFloatingPointMember(record, latMember); + double lon = ReadFloatingPointMember(record, lonMember); + positions.Add((lat, lon)); + } + return positions; + } + // ------------------------------------------------------------------- // dcf8 — time series at fixed stations (S-111 Edition 2.0.0 §10.2.3 / §10.2.7) // ------------------------------------------------------------------- diff --git a/src/EncDotNet.S100.Datasets.S111/S111StationSeriesDataset.cs b/src/EncDotNet.S100.Datasets.S111/S111StationSeriesDataset.cs index 7ea62e5..dab6292 100644 --- a/src/EncDotNet.S100.Datasets.S111/S111StationSeriesDataset.cs +++ b/src/EncDotNet.S100.Datasets.S111/S111StationSeriesDataset.cs @@ -3,14 +3,14 @@ namespace EncDotNet.S100.Datasets.S111; /// /// Root data model for an S-111 Surface Currents dataset encoded in /// data coding format 8 — time series at fixed stations -/// (S-111 Edition 2.0.0 §10.2.3 / §10.2.7). +/// (S-111 Edition 2.0.0 §10.2.3 / §10.2.7) or data coding +/// format 3 — ungeorectified grid (S-100 Part 10c §10.2.1). /// /// -/// dcf8 is structurally different from the regularly-gridded dcf2 path -/// modelled by : instead of a 2-D grid of values -/// per time step, each station carries an independent 1-D series of -/// (speed, direction) samples plus its own start/end timestamps -/// and sampling interval. The two shapes share the S-111 Feature +/// Both DCF 3 and DCF 8 are represented as a collection of +/// objects — each node in a DCF 3 +/// ungeorectified grid maps to a "station" with its values gathered +/// across time steps. The two shapes share the S-111 Feature /// Catalogue's SurfaceCurrent feature; the distinction is /// encoding, not taxonomy. /// @@ -35,10 +35,10 @@ public sealed class S111StationSeriesDataset public float? SurfaceCurrentDepth { get; init; } /// - /// Data coding format — always 8 for this dataset type - /// (S-100 Part 10c §10.2.1 Table). + /// Data coding format — 3 for ungeorectified grid or + /// 8 for station series (S-100 Part 10c §10.2.1 Table). /// - public int DataCodingFormat { get; init; } = 8; + public int DataCodingFormat { get; init; } /// /// Type of current data (e.g. 6 = forecast model output). See @@ -72,6 +72,6 @@ public abstract record S111DatasetData /// S-111 dcf2 — regularly-gridded surface-current coverage. public sealed record GriddedCoverage(S111Dataset Dataset) : S111DatasetData; - /// S-111 dcf8 — time series at fixed stations. + /// S-111 dcf8 — time series at fixed stations, or dcf3 — ungeorectified grid. public sealed record StationSeries(S111StationSeriesDataset Dataset) : S111DatasetData; } diff --git a/tests/EncDotNet.S100.Datasets.S111.Tests/Fixtures/S111Dcf3FixtureBuilder.cs b/tests/EncDotNet.S100.Datasets.S111.Tests/Fixtures/S111Dcf3FixtureBuilder.cs new file mode 100644 index 0000000..36a4630 --- /dev/null +++ b/tests/EncDotNet.S100.Datasets.S111.Tests/Fixtures/S111Dcf3FixtureBuilder.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using PureHDF; + +namespace EncDotNet.S100.Datasets.S111.Tests.Fixtures; + +/// +/// Helpers that author small synthetic S-111 dcf3 ("ungeorectified grid") +/// HDF5 files for reader tests. DCF 3 stores per-node positions in +/// Positioning/geometryValues and per-timestep value arrays in +/// Group_NNN/values (S-100 Part 10c §10.2.1). +/// +internal static class S111Dcf3FixtureBuilder +{ + public struct ValueRow + { + [H5Name("surfaceCurrentSpeed")] public float SurfaceCurrentSpeed; + [H5Name("surfaceCurrentDirection")] public float SurfaceCurrentDirection; + } + + public struct GeometryRow + { + [H5Name("latitude")] public float Latitude; + [H5Name("longitude")] public float Longitude; + } + + public sealed class Node + { + public required float Latitude { get; init; } + public required float Longitude { get; init; } + } + + public sealed class TimeStep + { + public required string TimePoint { get; init; } + public required ValueRow[] Values { get; init; } + } + + /// + /// Writes a minimal S-111 dcf3 file with one SurfaceCurrent.01 + /// instance. Node positions live under + /// SurfaceCurrent.01/Positioning/geometryValues; each time step + /// is a Group_NNN with a flat values compound dataset. + /// + public static string WriteFile( + string path, + IReadOnlyList nodes, + IReadOnlyList timeSteps, + string firstDateTime = "20240101T000000Z", + string lastDateTime = "20240101T030000Z", + long timeRecordInterval = 3600) + { + // Build Positioning/geometryValues + var geomRows = new GeometryRow[nodes.Count]; + for (int i = 0; i < nodes.Count; i++) + geomRows[i] = new GeometryRow { Latitude = nodes[i].Latitude, Longitude = nodes[i].Longitude }; + + var positioningGroup = new H5Group { ["geometryValues"] = geomRows }; + + // Build instance + var instance = new H5Group + { + Attributes = new() + { + ["numberOfNodes"] = (long)nodes.Count, + ["numberOfTimes"] = (long)timeSteps.Count, + ["dateTimeOfFirstRecord"] = firstDateTime, + ["dateTimeOfLastRecord"] = lastDateTime, + ["timeRecordInterval"] = timeRecordInterval, + }, + ["Positioning"] = positioningGroup, + }; + + for (int t = 0; t < timeSteps.Count; t++) + { + instance[$"Group_{t + 1:000}"] = new H5Group + { + Attributes = new() { ["timePoint"] = timeSteps[t].TimePoint }, + ["values"] = timeSteps[t].Values, + }; + } + + var file = new H5File + { + Attributes = new Dictionary + { + ["horizontalDatumValue"] = 4326, + ["geographicIdentifier"] = "Test DCF3", + ["issueDate"] = "2024-01-01", + }, + ["SurfaceCurrent"] = new H5Group + { + Attributes = new() + { + ["dataCodingFormat"] = (byte)3, + ["typeOfCurrentData"] = (long)6, + }, + ["SurfaceCurrent.01"] = instance, + }, + }; + + var options = new H5WriteOptions( + FieldNameMapper: f => f.GetCustomAttribute()?.Name); + + file.Write(path, options); + return path; + } +} diff --git a/tests/EncDotNet.S100.Datasets.S111.Tests/S111Dcf3ReaderTests.cs b/tests/EncDotNet.S100.Datasets.S111.Tests/S111Dcf3ReaderTests.cs new file mode 100644 index 0000000..a4059fd --- /dev/null +++ b/tests/EncDotNet.S100.Datasets.S111.Tests/S111Dcf3ReaderTests.cs @@ -0,0 +1,202 @@ +using System.Reflection; +using EncDotNet.S100.Datasets.S111.Tests.Fixtures; +using EncDotNet.S100.Hdf5; +using EncDotNet.S100.Hdf5.PureHdf; + +namespace EncDotNet.S100.Datasets.S111.Tests; + +/// +/// Tests for the S-111 dcf3 (ungeorectified grid) reader path +/// (S-100 Part 10c §10.2.1). +/// +public class S111Dcf3ReaderTests +{ + [Fact] + public void ReadAny_Dcf3_ThreeNodesFourTimeSteps_RoundTrips() + { + var path = Path.GetTempFileName() + ".h5"; + try + { + var nodes = new[] + { + new S111Dcf3FixtureBuilder.Node { Latitude = 46.83f, Longitude = -71.16f }, + new S111Dcf3FixtureBuilder.Node { Latitude = 46.84f, Longitude = -71.15f }, + new S111Dcf3FixtureBuilder.Node { Latitude = 46.85f, Longitude = -71.14f }, + }; + + var timeSteps = new[] + { + new S111Dcf3FixtureBuilder.TimeStep + { + TimePoint = "20240101T000000Z", + Values = + [ + new() { SurfaceCurrentSpeed = 0.3f, SurfaceCurrentDirection = 45f }, + new() { SurfaceCurrentSpeed = 1.0f, SurfaceCurrentDirection = 90f }, + new() { SurfaceCurrentSpeed = 0.1f, SurfaceCurrentDirection = 180f }, + ], + }, + new S111Dcf3FixtureBuilder.TimeStep + { + TimePoint = "20240101T010000Z", + Values = + [ + new() { SurfaceCurrentSpeed = 0.5f, SurfaceCurrentDirection = 50f }, + new() { SurfaceCurrentSpeed = 1.2f, SurfaceCurrentDirection = 95f }, + new() { SurfaceCurrentSpeed = 0.2f, SurfaceCurrentDirection = 185f }, + ], + }, + new S111Dcf3FixtureBuilder.TimeStep + { + TimePoint = "20240101T020000Z", + Values = + [ + new() { SurfaceCurrentSpeed = 0.8f, SurfaceCurrentDirection = 55f }, + new() { SurfaceCurrentSpeed = 1.1f, SurfaceCurrentDirection = 100f }, + new() { SurfaceCurrentSpeed = 0.3f, SurfaceCurrentDirection = 190f }, + ], + }, + new S111Dcf3FixtureBuilder.TimeStep + { + TimePoint = "20240101T030000Z", + Values = + [ + new() { SurfaceCurrentSpeed = 0.6f, SurfaceCurrentDirection = 60f }, + new() { SurfaceCurrentSpeed = 0.9f, SurfaceCurrentDirection = 105f }, + new() { SurfaceCurrentSpeed = 0.4f, SurfaceCurrentDirection = 195f }, + ], + }, + }; + + S111Dcf3FixtureBuilder.WriteFile(path, nodes, timeSteps); + + using var file = PureHdfFile.Open(path); + var any = S111DatasetReader.ReadAny(file); + + var stationSeries = Assert.IsType(any); + var model = stationSeries.Dataset; + + Assert.Equal(3, model.DataCodingFormat); + Assert.Equal(4326, model.HorizontalCRS); + Assert.Equal(3, model.Stations.Count); + + // Node 0 transposed to a station with 4 time steps. + var node0 = model.Stations[0]; + Assert.Equal("Node_001", node0.Identifier); + Assert.Equal(46.83, node0.Latitude, precision: 2); + Assert.Equal(-71.16, node0.Longitude, precision: 2); + Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), node0.StartTime); + Assert.Equal(new DateTime(2024, 1, 1, 3, 0, 0, DateTimeKind.Utc), node0.EndTime); + Assert.Equal(TimeSpan.FromHours(1), node0.TimeRecordInterval); + Assert.Equal(4, node0.NumberOfTimes); + Assert.Equal(0.3f, node0.SpeedsMetresPerSecond[0]); + Assert.Equal(45f, node0.DirectionsDegreesTrue[0]); + Assert.Equal(0.5f, node0.SpeedsMetresPerSecond[1]); + Assert.Equal(50f, node0.DirectionsDegreesTrue[1]); + + // Node 1. + var node1 = model.Stations[1]; + Assert.Equal("Node_002", node1.Identifier); + Assert.Equal(1.0f, node1.SpeedsMetresPerSecond[0]); + Assert.Equal(90f, node1.DirectionsDegreesTrue[0]); + + // Node 2 — last time step. + var node2 = model.Stations[2]; + Assert.Equal("Node_003", node2.Identifier); + Assert.Equal(0.4f, node2.SpeedsMetresPerSecond[3]); + Assert.Equal(195f, node2.DirectionsDegreesTrue[3]); + + Assert.Equal(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), model.MinTime); + Assert.Equal(new DateTime(2024, 1, 1, 3, 0, 0, DateTimeKind.Utc), model.MaxTime); + } + finally { File.Delete(path); } + } + + [Fact] + public void ReadAny_Dcf3_MissingPositioning_ThrowsSchemaException() + { + var path = Path.GetTempFileName() + ".h5"; + try + { + // Write a DCF 3 file manually without Positioning. + WriteDcf3WithoutPositioning(path); + + using var file = PureHdfFile.Open(path); + var ex = Assert.Throws(() => S111DatasetReader.ReadAny(file)); + + Assert.Equal("S-111", ex.Product); + Assert.Contains("Positioning", ex.AttributeOrDataset ?? ""); + } + finally { File.Delete(path); } + } + + [Fact] + public void Read_OnDcf3_ThrowsNotSupportedForGriddedCallers() + { + var path = Path.GetTempFileName() + ".h5"; + try + { + var nodes = new[] + { + new S111Dcf3FixtureBuilder.Node { Latitude = 46.83f, Longitude = -71.16f }, + }; + var timeSteps = new[] + { + new S111Dcf3FixtureBuilder.TimeStep + { + TimePoint = "20240101T000000Z", + Values = [new() { SurfaceCurrentSpeed = 0.5f, SurfaceCurrentDirection = 90f }], + }, + }; + S111Dcf3FixtureBuilder.WriteFile(path, nodes, timeSteps, + firstDateTime: "20240101T000000Z", + lastDateTime: "20240101T000000Z", + timeRecordInterval: 0); + + using var file = PureHdfFile.Open(path); + // Read() only supports dcf2; dcf3 should throw like dcf8. + var ex = Assert.Throws(() => S111DatasetReader.Read(file)); + + Assert.Equal("S-111", ex.Product); + } + finally { File.Delete(path); } + } + + private static void WriteDcf3WithoutPositioning(string path) + { + using var stream = File.Create(path); + var instance = new PureHDF.H5Group + { + Attributes = new() + { + ["numberOfNodes"] = 1L, + ["numberOfTimes"] = 1L, + ["dateTimeOfFirstRecord"] = "20240101T000000Z", + ["dateTimeOfLastRecord"] = "20240101T000000Z", + ["timeRecordInterval"] = 3600L, + }, + ["Group_001"] = new PureHDF.H5Group + { + Attributes = new() { ["timePoint"] = "20240101T000000Z" }, + ["values"] = new S111Dcf3FixtureBuilder.ValueRow[] + { + new() { SurfaceCurrentSpeed = 0.5f, SurfaceCurrentDirection = 90f }, + }, + }, + }; + + var file = new PureHDF.H5File + { + Attributes = new Dictionary { ["horizontalDatumValue"] = 4326 }, + ["SurfaceCurrent"] = new PureHDF.H5Group + { + Attributes = new() { ["dataCodingFormat"] = (byte)3 }, + ["SurfaceCurrent.01"] = instance, + }, + }; + + var options = new PureHDF.H5WriteOptions( + FieldNameMapper: f => f.GetCustomAttribute()?.Name); + file.Write(stream, options); + } +} From cc9b92e8fc466d3fc11149532fe16fa8a34c2959 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 22:06:45 -0700 Subject: [PATCH 4/6] Use portrayal catalogue for S-111 DCF 3 station rendering For DCF 3 (ungeorectified grid) datasets, resolve arrow colors and scale factors from the S-111 portrayal catalogue speed-band table instead of using the hardcoded ColorByMagnitude/SymbolScaleForSpeed palette. This makes DCF 3 rendering faithful to the IHO PC (same color bands and scaling rules as the DCF 2 regular-grid path). DCF 8 (time series at fixed stations) retains the inline palette since those datasets typically originate from tide gauges where the PC arrow bands are less appropriate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../S111DatasetProcessor.cs | 106 ++++++++++++++++-- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs index 4e50319..b203911 100644 --- a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs +++ b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs @@ -139,8 +139,17 @@ private S111DatasetProcessor( { _stationsById[station.Identifier] = station; } - // dcf8 uses an inline arrow glyph — no portrayal - // catalogue required. + + // DCF 3 (ungeorectified grid) uses the portrayal catalogue + // for PC-faithful color/symbol rendering. DCF 8 (time series + // at fixed stations) uses inline arrow glyphs — no PC required. + if (s.Dataset.DataCodingFormat == 3 + && catalogueManager.HasCatalogue("S-111")) + { + _provider = catalogueManager.GetProvider("S-111"); + _catalogue = new S111PortrayalCatalogue(_provider); + Diagnostics.CatalogueResolutionDiagnostics.Report(this, Spec, _catalogue.CatalogueRef, "portrayal"); + } break; } } @@ -243,9 +252,12 @@ private DatasetResult RenderGridded(RenderContext? context) // ---- dcf8 station series rendering --------------------------------- /// - /// Projects each station to a single point feature with an arrow + /// Projects each station/node to a single point feature with an arrow /// glyph oriented along DirectionsDegreesTrue and scaled by - /// speed magnitude. Rebuilt per . + /// speed magnitude. When the portrayal catalogue is loaded (DCF 3), + /// colors and scale factors are resolved from the PC's speed-band + /// table; otherwise (DCF 8) a hardcoded palette is used. + /// Rebuilt per . /// private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderContext? context) { @@ -261,6 +273,17 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon _stationSelectedTime = selectedTime; + // Resolve PC schemes if available (DCF 3 with catalogue loaded) + CoverageColorScheme? colorScheme = null; + CoverageSymbolScheme? symbolScheme = null; + if (_catalogue is not null) + { + _catalogue.SwitchPalette(context?.Palette ?? PaletteType.Day); + var mariner = context?.Mariner ?? MarinerSettings.Default; + colorScheme = _catalogue.ResolveColorScheme(mariner); + symbolScheme = _catalogue.ResolveSymbolScheme(mariner); + } + var nativeToMerc = _crsTransformFactory.Create($"EPSG:{ds.HorizontalCRS ?? 4326}", "EPSG:3857"); var features = new List(ds.Stations.Count); @@ -303,7 +326,45 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon feature["Latitude"] = station.Latitude; feature["Longitude"] = station.Longitude; - var arrowColour = ColorByMagnitude(speed); + Color arrowColour; + double symbolScale; + + if (colorScheme is not null) + { + // PC-faithful rendering: resolve color from speed bands + var hex = colorScheme.Resolve(speed); + arrowColour = hex is not null + ? ParseHexColor(hex) + : new Color(0x80, 0x80, 0x80); // grey fallback for out-of-range + + // Use PC symbol scheme for scaling if available + if (symbolScheme is not null) + { + var band = symbolScheme.Resolve(speed); + if (band is not null) + { + symbolScale = band.ScaleByValue + ? band.ScaleFactor * speed + : band.ScaleFactor; + // Clamp to reasonable visual range + symbolScale = Math.Clamp(symbolScale, 0.20, 2.0); + } + else + { + symbolScale = 0.30; // minimum visible + } + } + else + { + symbolScale = SymbolScaleForSpeed(speed); + } + } + else + { + // Fallback (DCF 8): hardcoded palette + arrowColour = ColorByMagnitude(speed); + symbolScale = SymbolScaleForSpeed(speed); + } // Symbol orientation in Mapsui follows screen rotation // (counter-clockwise positive); compass bearing increases @@ -315,7 +376,7 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon SymbolType = SymbolType.Triangle, Fill = new Brush(arrowColour), Outline = new Pen(arrowColour, 1.0), - SymbolScale = SymbolScaleForSpeed(speed), + SymbolScale = symbolScale, SymbolRotation = -direction, }); @@ -333,7 +394,8 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon ? new MRect(0, 0, 0, 0) : new MRect(mercMinX, mercMinY, mercMaxX, mercMaxY); - var info = $"{ds.GeographicIdentifier ?? _fileName} — {ds.Stations.Count} stations, " + + var dcfLabel = ds.DataCodingFormat == 3 ? "nodes" : "stations"; + var info = $"{ds.GeographicIdentifier ?? _fileName} — {ds.Stations.Count} {dcfLabel}, " + $"time: {selectedTime:u}"; return new DatasetResult @@ -345,6 +407,36 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon }; } + /// + /// Parses a hex color string (e.g. "#RRGGBB" or "#AARRGGBB") into + /// a Mapsui . + /// + private static Color ParseHexColor(string hex) + { + var span = hex.AsSpan(); + if (span.Length > 0 && span[0] == '#') + span = span[1..]; + + if (span.Length == 6) + { + int r = int.Parse(span[0..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + int g = int.Parse(span[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + int b = int.Parse(span[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return new Color(r, g, b); + } + + if (span.Length == 8) + { + int a = int.Parse(span[0..2], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + int r = int.Parse(span[2..4], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + int g = int.Parse(span[4..6], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + int b = int.Parse(span[6..8], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + return new Color(r, g, b, a); + } + + return new Color(0x80, 0x80, 0x80); + } + private static IReadOnlyList ComputeStationUnionTimes(S111StationSeriesDataset ds) { var set = new SortedSet(); From 11cc321e779e680c98f6e3cef2e9066b916f5026 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 22:11:33 -0700 Subject: [PATCH 5/6] Render DCF 3 nodes with PC SVG arrow symbols Replace SymbolType.Triangle with actual SVG arrow symbols (SCAROW01, SCAROW02, etc.) from the S-111 portrayal catalogue. Each speed band resolves to a distinct SVG glyph that is loaded once and cached for the render pass. The SVG is rendered via Mapsui's ImageStyle with RasterizeSvg=true, matching the DCF 2 arrow renderer's approach. Falls back to colored triangles for DCF 8 or when the catalogue symbol cannot be loaded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../S111DatasetProcessor.cs | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs index b203911..9876037 100644 --- a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs +++ b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs @@ -276,12 +276,14 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon // Resolve PC schemes if available (DCF 3 with catalogue loaded) CoverageColorScheme? colorScheme = null; CoverageSymbolScheme? symbolScheme = null; - if (_catalogue is not null) + Dictionary? svgCache = null; + if (_catalogue is not null && _provider is not null) { _catalogue.SwitchPalette(context?.Palette ?? PaletteType.Day); var mariner = context?.Mariner ?? MarinerSettings.Default; colorScheme = _catalogue.ResolveColorScheme(mariner); symbolScheme = _catalogue.ResolveSymbolScheme(mariner); + svgCache = new Dictionary(StringComparer.OrdinalIgnoreCase); } var nativeToMerc = _crsTransformFactory.Create($"EPSG:{ds.HorizontalCRS ?? 4326}", "EPSG:3857"); @@ -328,6 +330,8 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon Color arrowColour; double symbolScale; + string? svgSource = null; + string? symbolRef = null; if (colorScheme is not null) { @@ -337,12 +341,13 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon ? ParseHexColor(hex) : new Color(0x80, 0x80, 0x80); // grey fallback for out-of-range - // Use PC symbol scheme for scaling if available + // Use PC symbol scheme for scaling and SVG symbol if available if (symbolScheme is not null) { var band = symbolScheme.Resolve(speed); if (band is not null) { + symbolRef = band.SymbolRef; symbolScale = band.ScaleByValue ? band.ScaleFactor * speed : band.ScaleFactor; @@ -358,6 +363,25 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon { symbolScale = SymbolScaleForSpeed(speed); } + + // Load SVG from PC if symbol ref resolved + if (symbolRef is not null && svgCache is not null && _provider is not null) + { + if (!svgCache.TryGetValue(symbolRef, out svgSource)) + { + var item = _provider.Catalogue.Symbols + .FirstOrDefault(s => s.Id.Equals(symbolRef, StringComparison.OrdinalIgnoreCase)); + if (item is not null) + { + using var stream = _provider.FetchAssetAsync(item, "Symbols").GetAwaiter().GetResult(); + using var reader = new StreamReader(stream); + svgSource = reader.ReadToEnd(); + } + svgCache[symbolRef] = svgSource ?? ""; + } + if (string.IsNullOrEmpty(svgSource)) + svgSource = null; + } } else { @@ -366,19 +390,31 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon symbolScale = SymbolScaleForSpeed(speed); } - // Symbol orientation in Mapsui follows screen rotation - // (counter-clockwise positive); compass bearing increases - // clockwise from north, so negate. Geographic north on - // screen is "up", which corresponds to a zero-rotation - // arrow whose default orientation we treat as pointing up. - feature.Styles.Add(new SymbolStyle + // Symbol orientation: Mapsui rotation is counter-clockwise + // from east; compass bearing is clockwise from north. Negate + // to convert. + if (svgSource is not null) { - SymbolType = SymbolType.Triangle, - Fill = new Brush(arrowColour), - Outline = new Pen(arrowColour, 1.0), - SymbolScale = symbolScale, - SymbolRotation = -direction, - }); + // PC SVG arrow symbol + feature.Styles.Add(new ImageStyle + { + Image = new Image { Source = svgSource, RasterizeSvg = true }, + SymbolScale = symbolScale * 0.6, + SymbolRotation = -direction, + }); + } + else + { + // Triangle fallback + feature.Styles.Add(new SymbolStyle + { + SymbolType = SymbolType.Triangle, + Fill = new Brush(arrowColour), + Outline = new Pen(arrowColour, 1.0), + SymbolScale = symbolScale, + SymbolRotation = -direction, + }); + } features.Add(feature); } From d5fdd999ff08533d6dc2287e8f43dab58d667c60 Mon Sep 17 00:00:00 2001 From: Phillip Hoff Date: Mon, 18 May 2026 22:13:14 -0700 Subject: [PATCH 6/6] Fix SVG source URI for DCF 3 arrow symbols Mapsui's Image.Source expects a 'svg-content://' URI scheme, not raw SVG markup. Process the raw SVG through SvgProcessor (palette color resolution) and prefix with the required URI scheme, matching how MapsuiDisplayListRenderer produces symbol sources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../S111DatasetProcessor.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs index 9876037..5886d36 100644 --- a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs +++ b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs @@ -375,7 +375,12 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon { using var stream = _provider.FetchAssetAsync(item, "Symbols").GetAwaiter().GetResult(); using var reader = new StreamReader(stream); - svgSource = reader.ReadToEnd(); + var rawSvg = reader.ReadToEnd(); + // Process SVG through palette color resolver and + // wrap with the svg-content:// URI scheme that + // Mapsui's ImageStyle expects. + var processed = SvgProcessor.Process(rawSvg, _catalogue.ActivePalette); + svgSource = "svg-content://" + processed; } svgCache[symbolRef] = svgSource ?? ""; }