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>inheritsBaseGroupedEntity<TId>→ every mesh belongs to an optionalGroupand hasFullName = 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,FullNameMetadata,Permissions,Added/Updated
Permissions are available on the entity via the core library; the Meshes services accept an optional
ClaimsPrincipal userand 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 singleitem.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, ...)
- Whole mesh:
-
Raw mesh for contours
(Mesh mesh, float[] elementData) GetMeshData(TId id, string item, DateTime? when = null, ...)Return the triangular mesh and element-centered values foritematwhen(or a default snapshot ifnull).
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
GetDateTimesinexpensive (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 toGroup/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 coreMaybeoperator (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:
- Validate
id,item,thresholds, and (if supplied) thatwhen ∈ GetDateTimes(id). - Fetch raw mesh + element data:
_repository.GetMeshData(id, item, when) → (Mesh mesh, float[] elementData). - Boundary handling: set boundary element values to
min(thresholds)so banding closes at the domain edge. - 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); - Create
MeshConnection(edges + element adjacency, boundary detection). - Generate contours:
var c = new Mesh2Contours(mesh, elementData, nodeData, thresholds.ToList(), meshConnection); - Convert contours to polygons (exterior + holes), tagged with
"Value"attribute, inside aFeatureCollection<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 (byPeriod), it reduces to a singleDataPoint<double>:Maximum→ max of present valuesMinimum→ minAverage→ 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 validPolygon/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/IGeometryto GeoJSON-style output. - Use
DictionaryConverter<object?>for flexible properties. - Respect
JsonIgnoreCondition.
- Serialize our
DoubleConverter: writes doubles with a trailing.0where appropriate and handles"NaN","Infinity","-Infinity"on read.DictionaryConverter<T>: genericIDictionary<TKey, TValue>serializer handling “object-y” values.
If you’re exposing contours via Web API, add these converters to your
JsonSerializerOptionsin startup soIFeatureCollectionrenders 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/dateRangenull or empty →ArgumentNullException.GetContourswith adateTimenot inGetDateTimes(id)→ArgumentException.- Aggregated “instant” methods return
double?on the service (viaMaybe<double>), so no exception for “no value at this time” — you getnull.
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 needGetByGroupandGetFullNames. - 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/Polygonin the mesh’s projection (MeshInfo.Projection). Reproject at the edge if needed. - Threshold ordering:
Mesh2Contourssorts 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
AggregationTypeyou want for the grouped output. - Performance: polygon aggregations benefit from element spatial indexing in provider; cache
MeshConnectionand element centroids. - Security: the
ClaimsPrincipal? userparameter is threaded through — enforce per-mesh access in your repository if required (e.g., filterGetAll, guardGetMeshData).
Appendix: key helper types (what they do)¶
-
MeshHolds node coordinates (X[],Y[],Z[]), connectivity (ElementTable),Code[],Projection.GenerateNodeTable()buildsNodeTable(elements per node).CalculateNodeValues(...)fits a local plane to surrounding element centers at each node (fallback to inverse-distance).
-
MeshConnectionBuilds unique edges, maps edges ↔ elements, and derives boundary elements. -
ElementCentresQuickly computes(Xe,Ye,Ze)as the centroid across each element’s nodes. -
Mesh2ContoursFor each level, identifies the set of elements above the threshold, follows boundary edges to build loops, interpolates crossing points, and collects rings intoContourpolylines. -
ContoursCleans the polyline loops into valid polygon(s): removes duplicate vertices, fixes winding (CCW), polygonizes with NTS, splits multipolygons and holes into separateContourinstances.
Extending the module¶
- New providers: implement
IMeshRepository<TId>(or derive fromBaseMeshRepository<TId>). If you need grouping, also implementIGroupedMeshRepository<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
BuildFeatureCollectionby implementing a custom service that calls your backend and emitsIFeatureCollection.