DHI.Spatial – Internal Developer Guide¶
This module provides a lightweight spatial core used across services for geometry, features, meshes, and spatially-aware data sources. It's deliberately minimal: easy to embed, no heavy dependencies, and suitable as a building block for MIKE providers and Web APIs.¶
What's in the box¶
- Geometry model:
Point,LineString,Polygon,Multi*types,GeometryCollection, WKT parsing/serialization,Position,BoundingBox, and CRS helpers. - Feature model:
Feature,FeatureCollection,Attribute,Association. - Spatial data model:
Node,Element,Mesh,SpatialDefinition*(EqD2 and NonEqD2 implemented), plus contracts for data sources (IDataSource<TItemId>). - Utilities: EPSG:3857 ("Google / Web Mercator") conversions, tolerant string ops, numeric parsing.
Many interfaces are designed so you can plug in MIKE/DFS-backed implementations without taking a dependency here.
Geometry layer¶
Core types¶
Position– mutable coordinate class withX,Y, and optionalZ.ToWKT()/FromWKT(string)(supports xyz)ProjectTo(Position origin, double dx, double dy)-> local, grid-index-like coordinatesToGoogle()(lon/lat -> EPSG:3857 meters)
BoundingBox– axis-aligned rectangle with helpers:Parse("xmin, ymin, xmax, ymax")Intersects(BoundingBox),Contains(Position)GoogleToLonLat(),LonLatToGoogle()GetPositions()returns LL, LR, UR, UL corners
CoordinateReferenceSystem– holder for a CRSTypeand arbitraryProperties.
Geometry objects¶
All geometries implement:
* IGeometry -> Type (e.g., "Polygon"), Coordinates (dynamic; strongly-typed collections in practice), CRS, ToWKT().
* Concrete types:
* Point -> Coordinates: Position
* LineString -> List<Position>
* Polygon -> List<List<Position>> (outer ring + zero or more inner rings)
* MultiPoint, MultiLineString, MultiPolygon -> nested lists of Position
* GeometryCollection -> List<IGeometry>
WKT parsing/formatting¶
Geometry.FromWKT(string)supports:POINT [Z],LINESTRING [Z],POLYGON [Z],MULTIPOINT,MULTILINESTRING,MULTIPOLYGON
GeometryCollectionhasToWKT()(noFromWKT).- Throws
NotSupportedExceptionfor types not listed above; throws with friendly messages on malformed WKT.
Web Mercator helpers (EPSG:3857)¶
DHI.Spatial.ExtensionMethods:
* LonToGoogleX(), LatToGoogleY(), GoogleXToLon(), GoogleYToLat()
* IsGoogle("EPSG:3857") (or "EPSG:900913")
* SameAs(string, string) (case-insensitive)
* Numeric helpers: ToDouble(string), ToFloat(double)
Tip: prefer storing geometry in a well-defined CRS (WKT in CRS) and convert on IO boundaries. The helpers are simple and assume WGS84 input for lon/lat.
Feature model¶
Feature=Geometry+ bags for:AttributeValues: Dictionary<string, object>Associations: IList<IAssociation>(e.g., link to a domain entity)
FeatureCollection=Features+ schema-levelAttributes(IAttribute: name, display name, type, length, default).Association<T>carries the associated CLR type and id. Serialization hints:FeatureCollectionandFeatureexposeShouldSerialize*()to omit empty arrays when JSON-serializing.
Mesh & spatial definitions¶
The mesh model abstracts discretized spatial domains:
* INode<TId> -> Id, Position
* IElement<TId, TNodeId> -> Id, IList<INode<TNodeId>>
* IMesh<TElementId, TNodeId> -> Elements, Nodes (deduplicated via node equality by Id)
Concrete implementations:
* Node<TId> and Element<TId,TNodeId> (value semantics by Id).
* Mesh<TElementId,TNodeId> (holds elements; Nodes computed from elements).
Spatial definitions (ISpatialDefinition)¶
A spatial definition describes a domain compactly and can create a mesh:
* Type, DimensionCount
* ElementCount() / ElementCount(int dimension)
* ToMesh() -> IMesh<int,int>
Implemented:
* SpatialDefinitionEqD2 (2D equidistant grid):
* ctor: (x0, y0, dx, dy, nx, ny)
* ToMesh() creates a structured quadrilateral mesh with:
* Element IDs: 1..(nx*ny) left->right, bottom->top
* Node IDs: 1..((nx+1)*(ny+1)) left->right, bottom->top
Upper nodes are offset by nx+1 from the lower nodes.
* SpatialDefinitionNonEqD2 (2D non-equidistant grid):
* ctor: (xCoordinates, yCoordinates) — node coordinate arrays, each strictly increasing, length nx+1 and ny+1 respectively.
* ToMesh() creates the same quadrilateral structure as EqD2 but derives node coordinates directly from the provided arrays instead of a uniform step.
* ElementCount() returns nx * ny; ElementCount(int dimension) returns nx (1) or ny (2).
Not yet implemented (throw NotImplementedException):
SpatialDefinitionCurveLinearD2.
Element & node numbering (EqD2)¶
For cell (ix, iy) (0-based), lower-left node id = iy*(nx+1) + ix + 1. The four corners are:
LL = base
LR = base + 1
UR = base + (nx + 1) + 1
UL = base + (nx + 1)
ToMesh() follows exactly this scheme.
Example (nx=2, ny=2):
Nodes (ids):
Element 1 nodes: 1–2–5–4
Element 4 nodes: 5–6–9–8
Data sources¶
IDataSource<TItemId> defines a contract for time-varying spatial values:
public interface IDataSource<TItemId>
{
IList<DateTime> DateTimes { get; }
DateTime? FirstDateTime { get; }
DateTime? LastDateTime { get; }
IEnumerable<Item<TItemId>> Items { get; }
IEnumerable<string> ItemIds { get; }
bool ContainsDateTime(DateTime dateTime);
bool ContainsItem(string itemId);
IMesh<int,int> Mesh { get; } // typically derived from SpatialDefinition
ISpatialDefinition SpatialDefinition { get; }
double[] Get(TItemId itemId, DateTime dateTime, IGeometry geometry);
IDictionary<TItemId, IDictionary<DateTime, double>> Get(
IDictionary<TItemId, IEnumerable<DateTime>> ids,
IGeometry geometry);
double[] GetFirstAfter(TItemId itemId, DateTime dateTime, IGeometry geometry);
double[] GetLastBefore(TItemId itemId, DateTime dateTime, IGeometry geometry);
}
BaseDataSource<TItemId> provides a concrete base with several members already implemented:
* Mesh — delegates to SpatialDefinition.ToMesh().
* FirstDateTime / LastDateTime — derived from DateTimes.
* ItemIds — derived from Items.
* ContainsDateTime / ContainsItem — simple lookups.
* GetFirstAfter / GetLastBefore — scan DateTimes and delegate to Get.
You must implement DateTimes, Items, SpatialDefinition, and both Get overloads. Everything else is ready to use.
Recommended semantics (for implementers)¶
Because the interface leaves some freedom, keep these conventions consistent within our codebase:
* Items: scalar variables (e.g., "WaterLevel", "U-velocity"). Use Item<TId> to carry Quantity, Unit, PhysicalDimension.
* Get(itemId, dateTime, geometry):
* Point -> return a single-element array: sampled value at the point (interpolated if relevant).
* Polygon / LineString / Multi* -> return an array of one value per intersecting element, ordered by element id.
Aggregation (average/min/max) should be part of the calling service, unless your data source is explicitly "aggregated".
* GetFirstAfter / GetLastBefore:
* Same shape as Get, but time snapped to first > or last < the given dateTime.
* Bulk Get(ids, geometry):
* Return a dictionary ItemId -> (DateTime -> AggregateValue).
For non-point geometries, use average over intersecting elements unless a different rule is documented in the source.
If your data product has a well-defined aggregation (e.g., area-weighted average per polygon), document it on the repository/service that wraps the data source.
Minimal concrete example (EqD2 grid)¶
public sealed class GridDataSource : BaseDataSource<string>
{
private readonly SpatialDefinitionEqD2 _grid;
private readonly List<DateTime> _times;
private readonly Dictionary<(string item,int t,int elem), double> _data;
public GridDataSource(SpatialDefinitionEqD2 grid,
IEnumerable<DateTime> dateTimes,
IEnumerable<Item<string>> items)
{
_grid = grid;
_times = dateTimes.OrderBy(t => t).ToList();
Items = items.ToArray();
ItemIds = Items.Select(i => i.Id);
_data = new();
}
public override ISpatialDefinition SpatialDefinition => _grid;
public override IEnumerable<Item<string>> Items { get; }
public override IEnumerable<string> ItemIds { get; }
public override IList<DateTime> DateTimes => _times;
public override DateTime? FirstDateTime => _times.FirstOrDefault();
public override DateTime? LastDateTime => _times.LastOrDefault();
public override bool ContainsDateTime(DateTime dt) => _times.BinarySearch(dt) >= 0;
public override bool ContainsItem(string id) => ItemIds.Contains(id);
public void Set(string item, DateTime dt, int elementId, double value)
{
var ti = _times.IndexOf(dt);
_data[(item, ti, elementId)] = value;
}
public override double[] Get(string item, DateTime dateTime, IGeometry geometry)
{
var ti = _times.IndexOf(dateTime);
if (ti < 0) throw new ArgumentOutOfRangeException(nameof(dateTime));
var elementIds = IntersectingElementIds(geometry);
return elementIds.Select(eid => _data.TryGetValue((item, ti, eid), out var v) ? v : double.NaN)
.ToArray();
}
public override IDictionary<string, IDictionary<DateTime, double>> Get(
IDictionary<string, IEnumerable<DateTime>> ids, IGeometry geometry)
{
var elem = IntersectingElementIds(geometry).ToArray();
var result = new Dictionary<string, IDictionary<DateTime, double>>();
foreach (var (item, dts) in ids)
{
result[item] = dts.ToDictionary(
dt => dt,
dt => elem.Average(eid =>
{
var ti = _times.IndexOf(dt);
return _data.TryGetValue((item, ti, eid), out var v) ? v : double.NaN;
}));
}
return result;
}
public override double[] GetFirstAfter(string item, DateTime dt, IGeometry g)
=> Get(item, _times.First(t => t > dt), g);
public override double[] GetLastBefore(string item, DateTime dt, IGeometry g)
=> Get(item, _times.Last(t => t < dt), g);
private IEnumerable<int> IntersectingElementIds(IGeometry g)
{
// Simplest: map point to nearest cell by (x0,dx,y0,dy).
// For polygons, compute bbox and include all elements whose cell-centroid falls inside.
// Implementations may use robust polygon/mesh intersection.
var mesh = Mesh;
if (g is Point pt) return new[] { FindElementIdForPoint(pt.Coordinates) };
if (g is Polygon poly) return FindElementIdsForPolygon(poly.Coordinates);
// Add other cases as needed
throw new NotSupportedException($"Geometry {g.Type} not supported.");
}
private int FindElementIdForPoint(Position p) { /* … */ throw new NotImplementedException(); }
private IEnumerable<int> FindElementIdsForPolygon(IList<List<Position>> rings) { /* … */ throw new NotImplementedException(); }
}
SpatialDefinitionEqD2 anchors all spatial queries.
Working with features¶
Build a FeatureCollection with attributes¶
var fc = new FeatureCollection();
fc.Attributes.Add(new Attribute("Value", typeof(double), 1));
fc.Attributes.Add(new Attribute("Quality", typeof(string), 16));
var poly = new Polygon();
poly.Coordinates.Add(new List<Position> {
new(0,0), new(10,0), new(10,10), new(0,10), new(0,0)
});
var f = new Feature(poly);
f.AttributeValues["Value"] = 12.34;
f.AttributeValues["Quality"] = "Good";
f.Associations.Add(new Association<MyDomain>("ABC-123"));
fc.Features.Add(f);
Parse WKT -> Geometry -> Feature¶
var g = Geometry.FromWKT("LINESTRING (0 0, 10 0, 10 5)");
var f = new Feature(g);
¶
var g = Geometry.FromWKT("LINESTRING (0 0, 10 0, 10 5)");
var f = new Feature(g);
Design notes¶
- Equality semantics:
Node<TId>andElement<TId,TNodeId>compare equal byIdonly. If you create duplicate ids, they'll dedupe inMesh.Nodes.
- SpatialDefinition coverage:
EqD2andNonEqD2are both fully implemented.CurveLinearD2is a stub; calling its methods throws.
- BaseDataSource:
DateTimes,Items,SpatialDefinition, and bothGetoverloads are abstract and must be implemented. All other members have default implementations in the base class.
- Geometry coercion:
BaseGeometry.Coordinatesisdynamicfor flexibility; in your code, cast to the concrete type's expected collection (e.g.,List<Position>).
- CRS handling:
- CRS object is not enforced or interpreted by the core. It's a carrier; projection math is up to you (or your service).
- Web Mercator conversions:
- Helpers assume WGS84 lon/lat inputs. Guard against invalid latitudes |lat| > 85 in production code to avoid infinities.
Patterns to reuse¶
- EqD2 centroids
For a cell
(ix, iy): centroid =(x0 + (ix + 0.5)*dx, y0 + (iy + 0.5)*dy). - Polygon containment (simple) Quick filtering: use bounding boxes to preselect elements, then a point-in-polygon test on centroids before more expensive clipping.
- Area-weighted aggregation
If you need polygon averages on unstructured meshes, precompute
(elementIndex, weight)pairs once per polygon (weight = intersected area / polygon area), then apply across timesteps (see DFSU codebase for inspiration).
Quick recipes¶
Create a 100×100 m grid (20×10) and produce its mesh¶
var def = new SpatialDefinitionEqD2(x0: 500000, y0: 6200000, dx: 100, dy: 100, nx: 20, ny: 10);
var mesh = def.ToMesh(); // IMesh<int,int>
var elem = mesh.Elements[0]; // Element 1 (lower-left)
var nodeIds = elem.Nodes.Select(n => n.Id); // e.g., 1,2,23,22
Convert Google tile bbox to lon/lat¶
var web = new BoundingBox(1.23e6, 6.54e6, 1.24e6, 6.55e6);
var geo = web.GoogleToLonLat();
WKT roundtrip¶
var poly = Geometry.FromWKT("POLYGON ((0 0, 5 0, 5 2, 0 2, 0 0))");
var wkt = poly.ToWKT();
¶
var poly = Geometry.FromWKT("POLYGON ((0 0, 5 0, 5 2, 0 2, 0 0))");
var wkt = poly.ToWKT();
Extending the module¶
- Implement more spatial definitions
Add
SpatialDefinitionCurveLinearD2:- Provide
ElementCount,ElementCount(int), andToMesh()with sensible node/element id schemes. - Keep node id monotonic and stable across
ToMesh()calls to aid caching.
- Provide
- Richer geometry parsing
Support
GEOMETRYCOLLECTIONinGeometry.FromWKT(delegate to existing parsers inside). - Robust spatial operations If you need precise intersections/containment, wrap bindings to NetTopologySuite in a higher layer; keep this core dependency-light.
- DataSource helpers
Provide common utilities (centroid lookup, bbox index, polygon rasterization) in a
DHI.Spatial.Data.Helpersnamespace to standardize howGet(...)is implemented across sources.
Reference: API surface (by area)¶
Geometry & helpers¶
Position,BoundingBox,CoordinateReferenceSystemPoint,LineString,Polygon,MultiPoint,MultiLineString,MultiPolygon,GeometryCollectionGeometry.FromWKT,Geometry.TrimForOuterParenthesis,Geometry.SplitArrayExtensionMethods(Google/EPSG:3857, parsing)
Feature model¶
IAttribute,AttributeIFeature,FeatureIFeatureCollection,FeatureCollectionIAssociation,Association<T>
Mesh & spatial definitions¶
INode<TId>,Node<TId>IElement<TId,TNodeId>,Element<TId,TNodeId>IMesh<TElementId,TNodeId>,Mesh<TElementId,TNodeId>ISpatialDefinition,SpatialDefinitionTypeSpatialDefinitionEqD2,SpatialDefinitionNonEqD2,SpatialDefinitionCurveLinearD2(stub)
Data sources¶
IItem<TId>,Item<TId>,ItemIDataSource<TItemId>BaseDataSource<TItemId>