Skip to content

DHI.Services.Meshes – Internal Guide

What this module is

The DHI.Services.Meshes module exposes a read-only query surface over spatial meshes with time-varying items (e.g., water level, salinity). It lets you:

  • Discover available meshes and their metadata (items, date range, projection, bounding box).
  • Sample time series at a point.
  • Compute spatio-temporal aggregations (whole mesh or within polygons, optionally grouped by period).
  • Pull raw mesh + element values for a timestamp to derive contour lines as a feature collection.

The layer is storage-agnostic. Providers (DFS(U), NetCDF, DB, cloud services, etc.) implement the repository contract; services add validation, grouping helpers, and convenience APIs.


Big picture (how it hangs together)

MeshInfo<TId> (entity: grouped + named)
        ▲
        │ IEnumerable<MeshInfo<TId>>
        │
IMeshRepository<TId>  (storage providers implement this)
        ▲
        │ wraps repo, adds guard checks, selection helpers, conversion (Maybe<T> → nullable)
        │
MeshService<TId> / GroupedMeshService<TId>

Supporting spatial/time series types:
- Spatial: Point, Polygon, BoundingBox, Feature/FeatureCollection, CRS
- TimeSeries: ITimeSeriesData<T>, DataPoint<T>, DateRange, Period, AggregationType
  • Grouped vs non-grouped: MeshInfo<TId> inherits BaseGroupedEntity<TId> → every mesh belongs to an optional Group and has FullName = Group/Name. Use grouped repository/service variants when you need fast group queries and lists of full names.

The entity: MeshInfo<TId>

A lightweight catalog entry describing a mesh:

[Serializable]
public class MeshInfo<TId> : BaseGroupedEntity<TId>
{
    public MeshInfo(TId id, string name, string? group, IEnumerable<Item> items, DateRange dateRange) : base(id, name, group) { ... }

    public IEnumerable<Item> Items { get; }     // what can be queried, e.g. "WaterLevel"
    public DateRange DateRange { get; }         // global time coverage
    public string? Projection { get; set; }     // CRS/proj string (optional)
    public BoundingBox? BoundingBox { get; set; } // spatial extent (optional)
}

Because it inherits BaseGroupedEntity<TId>, you also get:

  • Id, Name, Group, FullName
  • Metadata, Permissions, Added/Updated

Permissions are available on the entity via the core library; the Meshes services accept an optional ClaimsPrincipal user and pass it to the repos so providers can enforce access if needed.


Repository contracts

IMeshRepository<TId> (core provider surface)

Implement this to back the mesh queries with your storage:

  • Catalog

    • IEnumerable<MeshInfo<TId>> GetAll(...)
    • Maybe<MeshInfo<TId>> Get(id, ...)
    • bool Contains(id, ...), int Count(...), IEnumerable<TId> GetIds(...)
  • Availability

    • IEnumerable<DateTime> GetDateTimes(TId id, ...) Return distinct, sorted datetimes you can serve for any item of that mesh.
  • Sampling at a point

    • ITimeSeriesData<double> GetValues(TId id, string item, Point point, DateRange range, ...) Timeseries at a location for a single item.
    • Dictionary<string, ITimeSeriesData<double>> GetValues(TId id, Point point, DateRange range, ...) Timeseries for all items at that location (keyed by item name).
  • Spatio-temporal aggregation

    • Whole mesh: ITimeSeriesData<double> GetAggregatedValues(TId id, AggregationType agg, string item, DateRange range, ...)
    • Within a polygon: ITimeSeriesData<double> GetAggregatedValues(TId id, AggregationType agg, string item, Polygon polygon, DateRange range, ...)
    • Multiple polygons: IEnumerable<ITimeSeriesData<double>> GetAggregatedValues(TId id, AggregationType agg, string item, IEnumerable<Polygon> polygons, DateRange range, ...)
    • At an instant (not a range): Maybe<double> GetAggregatedValue(TId id, AggregationType agg, string item, DateTime when, ...) Maybe<double> GetAggregatedValue(TId id, AggregationType agg, string item, Polygon polygon, DateTime when, ...) IEnumerable<Maybe<double>> GetAggregatedValues(TId id, AggregationType agg, string item, IEnumerable<Polygon> polygons, DateTime when, ...)
  • Raw mesh for contours

    • (Mesh mesh, float[] elementData) GetMeshData(TId id, string item, DateTime? when = null, ...) Return the triangular mesh and element-centered values for item at when (or a default snapshot if null).

Temporal grouping: BaseMeshRepository<TId> already provides helpers that take a Period (daily, monthly, yearly, …) and aggregates by period by re-aggregating the returned time series:

ITimeSeriesData<double> GetAggregatedValues(TId id, AggregationType agg, string item, Period period, DateRange range, ...);
ITimeSeriesData<double> GetAggregatedValues(TId id, AggregationType agg, string item, Polygon polygon, Period period, DateRange range, ...);

It calls your range method then groups points using data.GroupBy(period) and reduces per group (min/max/avg/sum).

Implementor notes

  • If a value is unavailable, return Maybe.Empty<double>() rather than throwing.
  • Ensure consistent units across items and timestamps.
  • Keep GetDateTimes inexpensive (e.g., index/cache in provider).

Group-aware repo: IGroupedMeshRepository<TId>

Adds:

  • bool ContainsGroup(string group, ...)
  • IEnumerable<MeshInfo<TId>> GetByGroup(string group, ...)
  • Convenience:
    • IEnumerable<string> GetFullNames(string group, ...) (defaults to Group/Name)
    • IEnumerable<string> GetFullNames(...) for all meshes

Use this when your catalog is logically partitioned (tenant/project/scenario).


Service layer

MeshService<TId> (read-only)

A thin, guard-heavy wrapper over IMeshRepository<TId>:

  • Verifies the mesh exists (Exists(id)).
  • Validates arguments (Guard.Against.*).
  • Converts Maybe<double>double? using the core Maybe operator (maybe | null).
  • Adds contouring convenience via GetContours(...).

Constructor options:

public MeshService(IMeshRepository<TId> repo)
public MeshService(IMeshRepository<TId> repo, ILogger logger)

GroupedMeshService<TId> (read-only + grouping)

Same surface as MeshService<TId>, but wraps an IGroupedMeshRepository<TId> and also exposes grouped read APIs via its base BaseGroupedDiscreteService:

  • GroupExists(string group)
  • GetByGroup(string group)
  • GetFullNames(...)

Neither service is “updatable” — meshes are read-only in this module. If you need CRUD for the catalog, implement an updatable repo & a matching updatable service in your provider package.


Contours (how GetContours works)

Public surface (on both services):

IFeatureCollection GetContours(TId id, string item, IEnumerable<double> thresholds, DateTime? when = null, ClaimsPrincipal? user = null)

Pipeline:

  1. Validate id, item, thresholds, and (if supplied) that when ∈ GetDateTimes(id).
  2. Fetch raw mesh + element data: _repository.GetMeshData(id, item, when) → (Mesh mesh, float[] elementData).
  3. Boundary handling: set boundary element values to min(thresholds) so banding closes at the domain edge.
  4. Build node values from element centers: mesh.GenerateNodeTable() → adjacency; var elements = new ElementCentres(mesh.X, mesh.Y, mesh.Z, mesh.ElementTable); var nodeData = mesh.CalculateNodeValues(elements.Xe, elements.Ye, elementData);
  5. Create MeshConnection (edges + element adjacency, boundary detection).
  6. Generate contours: var c = new Mesh2Contours(mesh, elementData, nodeData, thresholds.ToList(), meshConnection);
  7. Convert contours to polygons (exterior + holes), tagged with "Value" attribute, inside a FeatureCollection<string> (id & name derived from ${id}-${item}).

Output is serializable to GeoJSON via the converters included in this module.

Triangular meshes only. Mesh.GenerateNodeTable() explicitly targets tri meshes (3 nodes/element). If you need quads/mixed elements, this code will need extending.


Spatial & temporal helpers used internally

  • Temporal grouping (BaseMeshRepository.GetGroupedValues): for each group (by Period), it reduces to a single DataPoint<double>:
    • Maximum → max of present values
    • Minimum → min
    • Average → average (ignoring nulls)
    • Sum → sum (ignoring nulls)
  • Mesh geometry
    • Mesh: X[], Y[], Z[] (node topography); ElementTable (tri connectivity), Code[], Projection.
    • MeshConnection: builds unique edges, maps edges to elements, and finds boundary elements (those with an edge used by only one element).
    • ElementCentres: computes (Xe, Ye, Ze) for elements.
    • Mesh.CalculateNodeValues(...): builds node values from surrounding element centers using a local plane fit with fallback to inverse-distance when ill-conditioned.
  • Contour cleanup
    • Contours: turns polyline loops into valid Polygon/MultiPolygon (via NTS polygonizer), fixes winding (CCW), removes duplicate coordinates, splits into separate rings.

JSON / GeoJSON converters (when emitting responses)

Under DHI.Services.Meshes.Converters:

  • FeatureCollectionConverter, FeatureConverter, GeometryConverter
    • Serialize our IFeatureCollection/IFeature/IGeometry to GeoJSON-style output.
    • Use DictionaryConverter<object?> for flexible properties.
    • Respect JsonIgnoreCondition.
  • DoubleConverter: writes doubles with a trailing .0 where appropriate and handles "NaN", "Infinity", "-Infinity" on read.
  • DictionaryConverter<T>: generic IDictionary<TKey, TValue> serializer handling “object-y” values.

If you’re exposing contours via Web API, add these converters to your JsonSerializerOptions in startup so IFeatureCollection renders as expected.


Implementing a provider (repository) — recipe

Start from BaseMeshRepository<TId> if you want helper methods; otherwise implement IMeshRepository<TId> from scratch.

public sealed class DfsuMeshRepository : BaseMeshRepository<string>
{
    // Catalog (scan once & cache if possible)
    public override IEnumerable<MeshInfo<string>> GetAll(ClaimsPrincipal? user = null) { ... }
    public override Maybe<MeshInfo<string>> Get(string id, ClaimsPrincipal? user = null) { ... }

    // Availability
    public override IEnumerable<DateTime> GetDateTimes(string id, ClaimsPrincipal? user = null) { ... }

    // Sampling
    public override ITimeSeriesData<double> GetValues(string id, string item, Point p, DateRange range, ClaimsPrincipal? user = null)
    {
        // 1) locate nearest element/node to p
        // 2) iterate time steps within range
        // 3) interpolate value at p (node-based or element-based, consistent with GetMeshData)
        // 4) return TimeSeriesData<double>(SortedSet<DataPoint<double>>)
    }

    public override Dictionary<string, ITimeSeriesData<double>> GetValues(string id, Point p, DateRange range, ClaimsPrincipal? user = null)
    {
        return Catalog[id].Items.ToDictionary(
            it => it.Name,
            it => GetValues(id, it.Name, p, range, user));
    }

    // Aggregation (whole mesh)
    public override ITimeSeriesData<double> GetAggregatedValues(string id, AggregationType agg, string item, DateRange range, ClaimsPrincipal? user = null)
    {
        // For each time step: compute agg(ElementValues) over all elements (or nodes), ignoring NaNs
        // Compose into ITimeSeriesData<double>
    }

    // Aggregation (polygon)
    public override ITimeSeriesData<double> GetAggregatedValues(string id, AggregationType agg, string item, Polygon polygon, DateRange range, ClaimsPrincipal? user = null)
    {
        // Precompute which elements are inside the polygon (or area-weighted intersection).
        // Then aggregate those per timestamp as above.
    }

    // Instantaneous values (Maybe<double>) — same logic, single timestamp
    public override Maybe<double> GetAggregatedValue(string id, AggregationType agg, string item, DateTime t, ClaimsPrincipal? user = null) { ... }
    public override Maybe<double> GetAggregatedValue(string id, AggregationType agg, string item, Polygon poly, DateTime t, ClaimsPrincipal? user = null) { ... }

    // Mesh snapshot for contours
    public override (Mesh mesh, float[] elementData) GetMeshData(string id, string item, DateTime? t = null, ClaimsPrincipal? user = null)
    {
        // Return tri mesh & element-centered values at timestamp t
        // NOTE: elementData.Length == mesh.ElementTable.Length
    }
}

Performance tips

  • Cache mesh topology (nodes, elements, MeshConnection) per mesh id.
  • Pre-index element → centroid and envelope for quick polygon filtering.
  • For GetDateTimes, store sorted unique timestamps once.
  • For expensive aggregations, consider memoization (e.g., monthly averages per item per mesh).

Using the services — examples

Assume DI setup:

IMeshRepository<string> repo = new DfsuMeshRepository(...);
IMeshService<string> meshSvc = new MeshService<string>(repo);
// or grouped:
IGroupedMeshRepository<string> gRepo = new MyGroupedRepo(...);
IGroupedMeshService<string> gMeshSvc = new GroupedMeshService<string>(gRepo);

1) List meshes & available times

foreach (var m in meshSvc.GetAll())
{
    Console.WriteLine($"{m.FullName} [{m.DateRange.Start} – {m.DateRange.End}] ({string.Join(",", m.Items.Select(i=>i.Name))})");
}

var times = meshSvc.GetDateTimes("mesh-001");

2) Time series at a point

var p = new DHI.Spatial.Point(12.345, 55.678);          // same CRS as mesh
var range = new DateRange(new DateTime(2025,1,1), new DateTime(2025,3,1));
var wl = meshSvc.GetValues("mesh-001", "WaterLevel", p, range);

foreach (var dp in wl) Console.WriteLine($"{dp.Time:u} → {dp.Value}");

3) Monthly averages over a polygon

var poly = new DHI.Spatial.Polygon();
poly.Coordinates.Add(new List<Position> {
    new(12.0,55.0), new(12.5,55.0), new(12.5,55.5), new(12.0,55.5), new(12.0,55.0)
});

var monthly = meshSvc.GetAggregatedValues(
    "mesh-001",
    AggregationType.Average,
    "WaterLevel",
    Period.Month,                                    // temporal grouping
    new DateRange(new DateTime(2025,1,1), new DateTime(2025,12,31)));

foreach (var dp in monthly) Console.WriteLine($"{dp.Time:yyyy-MM} → {dp.Value:F3}");

4) Instantaneous sums across many subareas

var when = new DateTime(2025, 5, 15, 6, 0, 0, DateTimeKind.Utc);
var polygons = new [] { poly1, poly2, poly3 };
var totals = meshSvc.GetAggregatedValues("mesh-001", AggregationType.Sum, "Flux", polygons, when);

foreach (var v in totals) Console.WriteLine(v.HasValue ? v.Value.ToString("F2") : "n/a");

5) Contours as GeoJSON-like features

var contours = meshSvc.GetContours(
    "mesh-001", "WaterLevel", new [] { -1.0, 0.0, 1.0 },
    dateTime: new DateTime(2025, 5, 15, 6, 0, 0, DateTimeKind.Utc));

// Serialize with provided converters to send to client
var options = new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
options.Converters.Add(new DHI.Services.Meshes.Converters.FeatureCollectionConverter());
var json = JsonSerializer.Serialize(contours, options);

Errors, guards, and nullability

The services aggressively validate inputs and fail fast:

  • Unknown mesh id → KeyNotFoundException.
  • item/point/polygon/dateRange null or empty → ArgumentNullException.
  • GetContours with a dateTime not in GetDateTimes(id)ArgumentException.
  • Aggregated “instant” methods return double? on the service (via Maybe<double>), so no exception for “no value at this time” — you get null.

Your providers should:

  • Avoid throwing for “no data”; return Maybe.Empty<double>().
  • Throw only for truly exceptional conditions (corrupt file, backend failure).

When to pick Grouped vs plain services

  • Pick GroupedMeshService<TId> when your UI/flow is group first (tenant/project/scenario), or you need GetByGroup and GetFullNames.
  • Use MeshService<TId> when you already have ids and don’t need group browsing.

Both expose the same value/aggregation/contour methods.


Best practices

  • Tri meshes only in the contouring helpers. If your provider serves quads, you must convert to tris (or extend Mesh).
  • CRS consistency: Pass Point/Polygon in the mesh’s projection (MeshInfo.Projection). Reproject at the edge if needed.
  • Threshold ordering: Mesh2Contours sorts thresholds descending internally; pass any order, but be aware highest levels are processed first.
  • Boundary elements are forced to the lowest threshold to cleanly close contour bands against the mesh boundary.
  • Time grouping: When calling the period overloads on the repo base, the second aggregation is by period over your already aggregated series — pick the same AggregationType you want for the grouped output.
  • Performance: polygon aggregations benefit from element spatial indexing in provider; cache MeshConnection and element centroids.
  • Security: the ClaimsPrincipal? user parameter is threaded through — enforce per-mesh access in your repository if required (e.g., filter GetAll, guard GetMeshData).

Appendix: key helper types (what they do)

  • Mesh Holds node coordinates (X[],Y[],Z[]), connectivity (ElementTable), Code[], Projection.

    • GenerateNodeTable() builds NodeTable (elements per node).
    • CalculateNodeValues(...) fits a local plane to surrounding element centers at each node (fallback to inverse-distance).
  • MeshConnection Builds unique edges, maps edges ↔ elements, and derives boundary elements.

  • ElementCentres Quickly computes (Xe,Ye,Ze) as the centroid across each element’s nodes.

  • Mesh2Contours For each level, identifies the set of elements above the threshold, follows boundary edges to build loops, interpolates crossing points, and collects rings into Contour polylines.

  • Contours Cleans the polyline loops into valid polygon(s): removes duplicate vertices, fixes winding (CCW), polygonizes with NTS, splits multipolygons and holes into separate Contour instances.


Extending the module

  • New providers: implement IMeshRepository<TId> (or derive from BaseMeshRepository<TId>). If you need grouping, also implement IGroupedMeshRepository<TId>.
  • Custom aggregations: add methods to your service that call repo methods and post-process ITimeSeriesData<double> (e.g., rolling means, percentiles).
  • Alternative contour logic: if your backend can produce contours natively, you can bypass BuildFeatureCollection by implementing a custom service that calls your backend and emits IFeatureCollection.