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); + } +}