diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5aa597f..5b5ea3b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,7 +26,7 @@
-
+
diff --git a/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs b/src/EncDotNet.S100.Datasets.Pipelines/S111DatasetProcessor.cs
index bf685ac..5886d36 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
{
@@ -138,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;
}
}
@@ -242,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)
{
@@ -260,6 +273,19 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon
_stationSelectedTime = selectedTime;
+ // Resolve PC schemes if available (DCF 3 with catalogue loaded)
+ CoverageColorScheme? colorScheme = null;
+ CoverageSymbolScheme? symbolScheme = 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");
var features = new List(ds.Stations.Count);
@@ -302,21 +328,98 @@ private DatasetResult RenderStationSeries(S111StationSeriesDataset ds, RenderCon
feature["Latitude"] = station.Latitude;
feature["Longitude"] = station.Longitude;
- var arrowColour = ColorByMagnitude(speed);
+ Color arrowColour;
+ double symbolScale;
+ string? svgSource = null;
+ string? symbolRef = null;
- // 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
+ if (colorScheme is not null)
{
- SymbolType = SymbolType.Triangle,
- Fill = new Brush(arrowColour),
- Outline = new Pen(arrowColour, 1.0),
- SymbolScale = SymbolScaleForSpeed(speed),
- SymbolRotation = -direction,
- });
+ // 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 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;
+ // Clamp to reasonable visual range
+ symbolScale = Math.Clamp(symbolScale, 0.20, 2.0);
+ }
+ else
+ {
+ symbolScale = 0.30; // minimum visible
+ }
+ }
+ else
+ {
+ 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);
+ 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 ?? "";
+ }
+ if (string.IsNullOrEmpty(svgSource))
+ svgSource = null;
+ }
+ }
+ else
+ {
+ // Fallback (DCF 8): hardcoded palette
+ arrowColour = ColorByMagnitude(speed);
+ symbolScale = SymbolScaleForSpeed(speed);
+ }
+
+ // Symbol orientation: Mapsui rotation is counter-clockwise
+ // from east; compass bearing is clockwise from north. Negate
+ // to convert.
+ if (svgSource is not null)
+ {
+ // 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);
}
@@ -332,7 +435,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
@@ -344,6 +448,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();
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);
+ }
+}