DHI.Services.Places — Internal Developer Guide (Core)¶
This guide explains the Places domain in DHI.Services.Places: what it is, the core types, how places relate to GIS features, how indicators read from data sources (scalars / time series) and turn into status colors, how grouping works, how the JSON-backed repository behaves, how to wire the service with GIS/time-series/scalar services, and how to use it from your code.
What is a "Place"?¶
A place is a named point of interest — a real-world location you want to monitor — pinned to a specific feature inside a GIS layer. Think of a river gauge station, a water-quality sampling point, a sewer overflow outfall, or a weather sensor mast. Each place is identified by name and an optional group (e.g., "catchment-A"), and it links to its GIS counterpart via a FeatureId that says: "look in collection /Sensors/Gauges, find the feature where StationId == "ST42"".
On top of that geographic anchor you attach indicators — rules that say "read this time series (or scalar), aggregate it over this period, and map the result onto a color palette". The result is a live, map-ready status color (an SKColor) for each place, driven entirely by your real-time or historical data. A flood-risk dashboard, for example, might have one place per river gauge station, with a FloodRisk indicator that computes the 90th-percentile peak discharge over the next 7 days and paints the station pin red, amber, or green on a map.
When to use this module¶
Use DHI.Services.Places whenever you need to attach monitoring and status logic to geographic locations and surface the results as map-ready data. Typical scenarios include:
- Flood-risk dashboards — each river gauge station is a place; an
EnsembleTimeSeriesindicator computes the 90th-percentile peak discharge over the coming 7 days and maps it to a red/amber/green color based on alert thresholds. - Water-quality monitoring networks — coastal or inland sampling stations are places; a
Scalarindicator reads the latest salinity or turbidity reading and colors the station pin on a map according to compliance thresholds. - Urban drainage monitoring — sewer overflow points are places grouped by catchment; a
TimeSeriesindicator computes average inflow over the last 24 hours, andGetFeaturesWithIndicatorStatus(group: "catchment-A")feeds the status layer directly into a GIS map component. - Multi-tenant operational portals — the
[Path]placeholder inEntityIdlets a single place definition resolve to tenant-specific time series at runtime, so one configuration serves many customers without duplicating places.
Minimal example at a glance¶
// A "place" is a named point of interest bound to a GIS feature.
// Here: river gauge station "ST42" located in a GIS feature collection at "/Sensors/Gauges".
var featureId = new FeatureId("/Sensors/Gauges", attributeKey: "StationId", attributeValue: "ST42");
var station = new Place("stations/ST42", "River Gauge ST42", featureId, group: "gauges");
places.Add(station);
// Attach a flood-risk indicator: average discharge over the last 7 days.
var source = new DataSource(DataSourceType.TimeSeries, "ts-conn", entityId: "TS:/Hydro/ST42/Discharge");
var last7Days = TimeInterval.CreateRelativeToNow(start: -7, end: 0);
var indicator = new Indicator(source, styleCode: "flood/risk", timeInterval: last7Days, aggregationType: AggregationType.Average);
places.AddIndicator("stations/ST42", "FloodRisk", indicator);
// Evaluate: get an SKColor for the current risk level.
var color = places.GetIndicatorStatus("stations/ST42", "FloodRisk");
// Or pull a full GIS FeatureCollection with indicator colors for a map layer.
var featureCollection = places.GetFeaturesWithIndicatorStatus(group: "gauges");
Quick links¶
What the Places module does¶
At a glance:
- Models places (named, grouped entities) that point to a GIS feature.
- Attaches indicators to each place. Each indicator pulls from a data source:
- Scalar (single numeric value)
- TimeSeries (aggregated over a period)
- EnsembleTimeSeries (aggregate each member, then quantile/aggregate across members)
- Translates indicator values into palette colors (threshold-based) for status mapping.
- Provides fast lookups by place / type / group, and bulk status evaluation.
- Emits feature collections with indicator statuses for map overlays.
- Ships with a JSON file repository; you can swap repositories by type.
Core layers follow the Domain Services pattern:
[Your App] ──> PlaceService<T...> ──> IPlaceRepository<TCollectionId>
│
├─> IGisService<TCollectionId>
├─> IDiscreteTimeSeriesService<TTimeSeriesId,double> (optional)
└─> IScalarService<TScalarId,TScalarFlag> (optional)
Key concepts¶
1) Place identity, grouping & feature binding¶
A place is a named, optionally grouped entity that points at a single GIS feature:
Place<TCollectionId>inheritsBaseGroupedEntity<string>→ Id, Name, Group, FullName, Metadata, Permissions.- Each place has a FeatureId
: FeatureCollectionId(typeTCollectionId) — which GIS collection to search in.AttributeKey/AttributeValue— how to find the feature inside that collection.
On add, the service verifies the collection exists and exactly one feature matches
(AttributeKey == AttributeValue).
2) Indicators & palettes¶
A place holds Dictionary<string, Indicator> keyed by indicator type (e.g., "FloodRisk").
An Indicator defines:
- DataSource (what to read, where):
DataSourceType= Scalar | TimeSeries | EnsembleTimeSeries, plus aConnectionIdand anEntityId. - AggregationType (for time series) — e.g., Average, Sum, Minimum, Maximum.
- TimeInterval (optional declarative period for time series).
- Quantile (optional; for ensemble time series).
- StyleCode + PaletteType → defines the palette thresholds and whether thresholds are lower/upper bound based.
The service evaluates indicator values → SKColor via the palette.
3) Data sources¶
DataSource is a compact triple: (Type, ConnectionId, EntityId).
ConnectionIdmust match a registered scalar or time series service.EntityIdis provider-defined (string, GUID, etc.). For time series you may embed a path placeholder:- If
EntityIdis a string that starts with[Path], the service will replace[Path]with a method argument at evaluation time. Use this when the same indicator points at different root paths per request.
- If
4) Services vs Repository¶
-
Service (
PlaceService<...>) Validates places/indicators, wires to GIS / time series / scalar providers, computes statuses, emits feature collections, provides events and a convenience API. -
Repository (
IPlaceRepository<TCollectionId>) Persistence contract for places + indicator dictionaries. The built-inPlaceRepositoryreads/writes a single JSON file and supports grouping.
Core types overview¶
1) Entities¶
public class Place<TCollectionId> : BaseGroupedEntity<string>
where TCollectionId : notnull
{
public Place(string id, string name, FeatureId<TCollectionId> featureId, string? group = null);
public Dictionary<string, Indicator> Indicators { get; }
public FeatureId<TCollectionId> FeatureId { get; }
}
public sealed class Place : Place<string>
{
public Place(string id, string name, FeatureId featureId, string? group = null);
}
Feature binding
public sealed class FeatureId<TCollectionId>
where TCollectionId : notnull
{
public TCollectionId FeatureCollectionId { get; }
public string AttributeKey { get; }
public object AttributeValue { get; }
}
2) Indicators & data sources¶
public enum DataSourceType { Scalar, TimeSeries, EnsembleTimeSeries }
public readonly struct DataSource {
public DataSource(DataSourceType type, string connectionId, object entityId);
public DataSourceType Type { get; }
public string ConnectionId { get; }
public object EntityId { get; }
}
public sealed class Indicator {
public Indicator(DataSource dataSource, string styleCode,
TimeInterval? timeInterval = null,
AggregationType? aggregationType = null,
double? quantile = null,
PaletteType paletteType = PaletteType.LowerThresholdValues);
public DataSource DataSource { get; }
public TimeInterval? TimeInterval { get; } // time-series only
public AggregationType? AggregationType { get; } // time-series only
public double? Quantile { get; } // ensemble only
public string StyleCode { get; }
public PaletteType PaletteType { get; }
public Palette GetPalette();
}
3) Time intervals (declarative periods)¶
public enum TimeIntervalType { Fixed, RelativeToNow, RelativeToDateTime, All }
public sealed class TimeInterval {
public TimeInterval(TimeIntervalType type = TimeIntervalType.All);
public static TimeInterval CreateFixed(double startOADate, double endOADate);
public static TimeInterval CreateRelativeToNow(double startDays, double endDays);
public static TimeInterval CreateRelativeToDateTime(double startDays, double endDays);
public static TimeInterval CreateAll();
public TimeIntervalType Type { get; }
public double? Start { get; } // semantics depend on Type
public double? End { get; }
public (DateTime from, DateTime to) ToPeriod(DateTime? offset = null);
}
- For Fixed,
Start/Endare OLE Automation dates. - For Relative types,
Start/Endare day offsets (negative = before). - For RelativeToDateTime, you must pass the offset datetime when converting to a period.
4) Repository contracts¶
public interface IPlaceRepository<TCollectionId> :
IRepository<Place<TCollectionId>, string>,
IDiscreteRepository<Place<TCollectionId>, string>,
IUpdatableRepository<Place<TCollectionId>, string>,
IGroupedRepository<Place<TCollectionId>>
where TCollectionId : notnull
{
Maybe<Indicator> GetIndicator(string placeId, string type, ClaimsPrincipal? user = null);
IDictionary<string, IDictionary<string, Indicator>> GetIndicators(ClaimsPrincipal? user = null);
IDictionary<string, Indicator> GetIndicatorsByPlace(string placeId, ClaimsPrincipal? user = null);
IDictionary<string, Indicator> GetIndicatorsByType(string type, ClaimsPrincipal? user = null);
IDictionary<string, Indicator> GetIndicatorsByGroupAndType(string group, string type, ClaimsPrincipal? user = null);
IDictionary<string, IDictionary<string, Indicator>> GetIndicatorsByGroup(string group, ClaimsPrincipal? user = null);
}
5) Built-in provider: JSON file repository¶
public class PlaceRepository<TCollectionId> : GroupedJsonRepository<Place<TCollectionId>>,
IPlaceRepository<TCollectionId>
where TCollectionId : notnull
{
public PlaceRepository(string filePath);
// ...Indicator aggregation helpers as per interface...
}
public sealed class PlaceRepository : PlaceRepository<string> { /* sugar */ }
- Stores all places (and their indicator dictionaries) in a single JSON file.
- Supports groups (folders) and group queries.
- Serializes
MetadataandPermissionsfrom the base entity.
6) Service¶
public class PlaceService<TTimeSeriesId, TScalarId, TScalarFlag, TCollectionId> :
BaseGroupedUpdatableDiscreteService<Place<TCollectionId>, string>
where TScalarFlag : struct
where TCollectionId : notnull
{
public PlaceService(IPlaceRepository<TCollectionId> repository,
Dictionary<string, IDiscreteTimeSeriesService<TTimeSeriesId,double>>? timeSeriesServices,
Dictionary<string, IScalarService<TScalarId,TScalarFlag>>? scalarServices,
IGisService<TCollectionId> gisService);
// CRUD (Get throws if missing; Add validates GIS + indicators)
public override Place<TCollectionId> Get(string id, ClaimsPrincipal? user = null);
public override void Add(Place<TCollectionId> place, ClaimsPrincipal? user = null);
// Indicators (per place)
public void AddIndicator(string placeId, string type, Indicator indicator, ClaimsPrincipal? user = null);
public void RemoveIndicator(string placeId, string type, ClaimsPrincipal? user = null);
public void UpdateIndicator(string placeId, string type, Indicator indicator, ClaimsPrincipal? user = null);
public Indicator GetIndicator(string placeId, string type, ClaimsPrincipal? user = null);
// Lookups
public IDictionary<string, IDictionary<string, Indicator>> GetIndicators(ClaimsPrincipal? user = null);
public IDictionary<string, Indicator> GetIndicatorsByPlace(string placeId, ClaimsPrincipal? user = null);
public IDictionary<string, Indicator> GetIndicatorsByType(string type, ClaimsPrincipal? user = null);
public IDictionary<string, Indicator> GetIndicatorsByGroupAndType(string group, string type, ClaimsPrincipal? user = null);
public IDictionary<string, IDictionary<string, Indicator>> GetIndicatorsByGroup(string group, ClaimsPrincipal? user = null);
// Status (color) evaluation
public Maybe<SKColor> GetIndicatorStatus(string placeId, string type, DateTime? offsetDateTime = null, string? path = null, ClaimsPrincipal? user = null);
public IDictionary<string, SKColor> GetIndicatorStatusByType(string type, string? group = null, DateTime? offsetDateTime = null, string? path = null, ClaimsPrincipal? user = null);
public IDictionary<string, Dictionary<string, SKColor>> GetIndicatorStatusByGroup(string group, DateTime? offsetDateTime = null, string? path = null, ClaimsPrincipal? user = null);
public IDictionary<Indicator, Maybe<SKColor>> GetIndicatorStatus(IList<Indicator> indicators, DateTime? offsetDateTime = null, string? path = null, ClaimsPrincipal? user = null);
public IDictionary<Indicator, Maybe<SKColor>> GetIndicatorStatus(IList<Indicator> indicators, DateTime from, DateTime to, string? path = null, ClaimsPrincipal? user = null);
// Mapping
public FeatureCollection GetFeatures(string? group = null, ClaimsPrincipal? user = null);
public FeatureCollection GetFeaturesWithIndicatorStatus(string? group = null, DateTime? offsetDateTime = null, string? path = null, ClaimsPrincipal? user = null);
public FeatureCollection GetFeaturesWithIndicatorStatus(DateTime from, DateTime to, string? group = null, string? path = null, ClaimsPrincipal? user = null);
// Discovery
public static Type[] GetRepositoryTypes();
public static Type[] GetRepositoryTypes(string? path);
public static Type[] GetRepositoryTypes(string path, string searchPattern);
}
public sealed class PlaceService : PlaceService<string, string, int, string> { /* sugar */ }
Status color semantics
- Scalar: fetch scalar → require numeric (double); palette color by value.
- TimeSeries: compute
(from,to)from indicator’s TimeInterval; callGetAggregatedValueusing indicator’sAggregationType. - EnsembleTimeSeries: aggregate each member over
(from,to); then compute indicator value via:Quantileif present, otherwise Aggregate across members by the sameAggregationType.
- No data → returns
Maybe.Empty<SKColor>()for that indicator.
7) Connections (instantiate from config)¶
public class PlaceServiceConnection<TTimeSeriesId, TScalarId, TScalarFlag, TCollectionId> : BaseConnection
where TScalarFlag : struct
where TCollectionId : notnull
{
public PlaceServiceConnection(string id, string name, string connectionString,
string repositoryType, string gisServiceConnectionId);
public string ConnectionString { get; }
public string RepositoryType { get; }
public string GisServiceConnectionId { get; }
public HashSet<string>? TimeSeriesServiceConnectionIds { get; set; }
public HashSet<string>? ScalarServiceConnectionIds { get; set; }
public override object Create(); // builds PlaceService<...>
public static ConnectionType CreateConnectionType<TConnection>(string? path = null);
}
public sealed class PlaceServiceConnection : PlaceServiceConnection<string,string,int,string> { /* sugar */ }
Given a RepositoryType (assembly-qualified) and a ConnectionString for that repository, plus IDs of already-registered GIS, time series, and scalar services, Create() returns a ready-to-use PlaceService.
8) JSON converters¶
The module includes custom System.Text.Json converters so APIs exchange consistent shapes without leaking internal types:
PlaceConverter/PlaceConverter<TCollectionId>FeatureIdConverter<TCollectionId>IndicatorConverterDataSourceConverterTimeIntervalConverterObjectToInferredTypeConverterPatch(temporary shim to improve object inference)
When you expose places/indicators in your API, add these converters to your
JsonSerializerOptionsfor both serialization and deserialization.
Guarding inputs (quick recap)¶
The module uses Guard.Against.* at public boundaries:
Null,NullOrEmpty, and related checks for all required inputs.TimeIntervalvalidatesEnd >= Start(when applicable), and enforces required values perTimeIntervalType.
Typical tasks (with code)¶
1) Bootstrap a JSON-backed store & service¶
using DHI.Services.Places;
using DHI.Services.GIS;
using DHI.Services.TimeSeries;
using DHI.Services.Scalars;
// 1) Repository (JSON file on disk)
var repoFile = Path.Combine(AppContext.BaseDirectory, "App_Data", "places.json");
Directory.CreateDirectory(Path.GetDirectoryName(repoFile)!);
var repo = new PlaceRepository(repoFile);
// 2) Dependencies (resolve from your IoC or service locator)
IGisService<string> gis = Resolve<IGisService<string>>("gis-conn");
// Optional: time series & scalar services keyed by connection id
var ts = new Dictionary<string, IDiscreteTimeSeriesService<string,double>> {
["ts-conn"] = Resolve<IDiscreteTimeSeriesService<string,double>>("ts-conn")
};
var scalars = new Dictionary<string, IScalarService<string,int>> {
["scalar-conn"] = Resolve<IScalarService<string,int>>("scalar-conn")
};
// 3) Service
var places = new PlaceService(repo, ts, scalars, gis);
2) Create a place (binding to a GIS feature)¶
var featureId = new FeatureId("/Admin/Locations", "StationId", "ST42");
var place = new Place("stations/ST42", "Station 42", featureId, group: "stations");
// Validates collection exists and feature is present
places.Add(place);
3) Attach indicators¶
Scalar-based indicator
var source = new DataSource(DataSourceType.Scalar, "scalar-conn", entityId: "SALINITY:ST42");
var salinity = new Indicator(source, styleCode: "water/quality/salinity");
places.AddIndicator("stations/ST42", "Salinity", salinity);
Time series-based indicator (relative period, average)
var tsSource = new DataSource(DataSourceType.TimeSeries, "ts-conn", entityId: "TS:/Hydro/ST42/Discharge");
var last7d = TimeInterval.CreateRelativeToNow(start: -7, end: 0);
var discharge = new Indicator(tsSource, "hydro/discharge", timeInterval: last7d, aggregationType: AggregationType.Average);
places.AddIndicator("stations/ST42", "Discharge", discharge);
Ensemble time series with quantile
var ensSource = new DataSource(DataSourceType.EnsembleTimeSeries, "ts-conn", entityId: "ENS:/Forecast/ST42/Level");
var p90 = new Indicator(ensSource, "flood/alert", timeInterval: last7d, aggregationType: AggregationType.Maximum, quantile: 0.90);
places.AddIndicator("stations/ST42", "FloodLevelP90", p90);
4) Evaluate statuses (colors)¶
// Single indicator at a place
var maybeColor = places.GetIndicatorStatus("stations/ST42", "Discharge");
if (maybeColor.HasValue) { var color = maybeColor.Value; /* SKColor */ }
// All “Discharge” indicators across a group
var byPlace = places.GetIndicatorStatusByType("Discharge", group: "stations");
// Bulk over explicit period
var indicators = places.GetIndicatorsByPlace("stations/ST42").Values.ToList();
var bulk = places.GetIndicatorStatus(indicators, from: DateTime.UtcNow.AddDays(-30), to: DateTime.UtcNow);
Path injection for time series
If an indicator used EntityId = "[Path]/Hydro/TS_001", pass a path argument:
var color = places.GetIndicatorStatus("stations/ST42", "Discharge", path: "/Tenants/ACME").Value;
5) Build a feature collection with statuses (for map layers)¶
// All places → features with attached "indicators" map (styleCode + color)
var fc = places.GetFeaturesWithIndicatorStatus();
// Or by group, with relative time interval evaluation
var fcStations = places.GetFeaturesWithIndicatorStatus(group: "stations");
// Or explicit period
var lastMonth = places.GetFeaturesWithIndicatorStatus(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow, group: "stations");
Each feature gets these added attributes:
placeId,fullName,name,groupLayer(derived from the feature collection path)indicators— a nested{ [type] : { styleCode, color } }dictionary
6) Update & remove indicators¶
// Update
places.UpdateIndicator("stations/ST42", "Discharge", discharge with { /* change palette/time interval */ });
// Remove
places.RemoveIndicator("stations/ST42", "Discharge");
JSON serialization (API integration)¶
When returning or accepting places/indicators via JSON, ensure your JsonSerializerOptions include the module converters:
var json = new JsonSerializerOptions {
NumberHandling = JsonNumberHandling.AllowReadingFromString,
Converters = {
new PlaceConverter(), // or PlaceConverter<TCollectionId>
new IndicatorConverter(),
new DataSourceConverter(),
new TimeIntervalConverter(),
new FeatureIdConverter<string>(),
new DHI.Services.Converters.DictionaryTypeResolverConverter<string, Place>(isNestedDictionary: true)
}
};
The converters include a type discriminator and specialized readers for enums and flexible value types (
ObjectToInferredTypeConverterPatch). Add the same set to both serializer and deserializer options.
Events & interception¶
PlaceService derives from BaseGroupedUpdatableDiscreteService<...> and raises pre/post events. In this module we use:
- Adding / Added
(Place<TCollectionId> place)— used byAdd. You can cancel in the “before” viaCancelEventArgs<T>. - Updating / Updated — fired by
Update(place)(e.g., after indicator add/remove/update). - Removing / Removed — when deleting a place (if your workflow exposes deletion).
Use these to apply cross-cutting rules (auditing, soft delete, etc.).
Connections & discovery¶
You can instantiate PlaceService from configuration using PlaceServiceConnection:
var conn = new PlaceServiceConnection(
id: "places",
name: "Places (JSON)",
connectionString: "[AppData]places.json",
repositoryType: "DHI.Services.Places.PlaceRepository, DHI.Services.Places",
gisServiceConnectionId: "gis-conn")
{
TimeSeriesServiceConnectionIds = new HashSet<string> { "ts-conn" },
ScalarServiceConnectionIds = new HashSet<string> { "scalar-conn" }
};
var service = (PlaceService)conn.Create();
ServiceLocator.Register(service, conn.Id);
To help admin UIs discover compatible repositories at runtime:
var types = PlaceService<string,string,int,string>.GetRepositoryTypes(/* optional path */);
Security hooks¶
- Entities carry Permissions (from
BaseEntity). - Repositories/services accept a
ClaimsPrincipal user; the built-in JSON repository does not enforce permissions by itself. - Enforce authorization in your API layer or implement it in a custom repository.
Errors & edge cases¶
Get(id)→ throwsKeyNotFoundExceptionif the place does not exist.Add(place)→ throws if:- GIS collection missing, or no feature matches
(AttributeKey == AttributeValue). - Another place with the same
Idalready exists. - Any indicator is invalid (see below).
- GIS collection missing, or no feature matches
- Indicator validation (during
Add/AddIndicator/UpdateIndicator):- Missing backing service (
ConnectionId) →ArgumentException. - Scalar: referenced scalar must exist and have
ValueTypeName == "System.Double". - TimeSeries/Ensemble: referenced id must exist unless the id contains
[Path](deferred). - TimeSeries:
AggregationTypeis required. If theTimeIntervalis null, use the overloads that pass an explicit(from,to)when evaluating status.
- Missing backing service (
TimeInterval.ToPeriod(...):- Requires both
StartandEndexcept forAll. - Requires
offsetDateTimeforRelativeToDateTime.
- Requires both
- Status evaluation returns empty Maybe when no data points are available (e.g., all ensemble members are
null).
Practical patterns¶
- Multi-tenant time series ids: Store
EntityId = "[Path]/tenant/seriesId", and supplypathper request. - Map overlays: Call
GetFeaturesWithIndicatorStatus(...)and pass directly to your map layer; each feature includes per-indicatorstyleCodeandcolor. - Dashboards: Use
GetIndicatorStatusByType("SomeType", group: "…")to draw compact status grids by place. - Threshold introspection: Call
GetThresholdValues(placeId)to show legends alongside statuses.
Quick reference¶
| Need… | Use… | Notes |
|---|---|---|
| Create a place bound to a GIS feature | PlaceService.Add(place) |
Verifies collection + feature existence |
| Add/update/remove an indicator | AddIndicator / UpdateIndicator / RemoveIndicator |
Validates backing services & IDs |
| Get statuses (colors) | GetIndicatorStatus* methods |
Returns Maybe<SKColor> or dictionaries |
| Build features with statuses for mapping | GetFeaturesWithIndicatorStatus(...) |
Adds placeId, fullName, name, groupLayer, indicators |
| Use relative time windows for time series | Indicator.TimeInterval (CreateRelativeToNow, etc.) |
Or use explicit (from,to) overloads |
| JSON store | PlaceRepository (JSON file path) |
Swappable via PlaceServiceConnection |
| Discover compatible repositories | PlaceService.GetRepositoryTypes(...) |
For admin tooling |
| Wire from configuration | PlaceServiceConnection |
Provide repo type, connection string, and dependency connection IDs |