Skip to content

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 EnsembleTimeSeries indicator 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 Scalar indicator 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 TimeSeries indicator computes average inflow over the last 24 hours, and GetFeaturesWithIndicatorStatus(group: "catchment-A") feeds the status layer directly into a GIS map component.
  • Multi-tenant operational portals — the [Path] placeholder in EntityId lets 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");

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> inherits BaseGroupedEntity<string>Id, Name, Group, FullName, Metadata, Permissions.
  • Each place has a FeatureId:
    • FeatureCollectionId (type TCollectionId) — 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 a ConnectionId and an EntityId.
  • 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).

  • ConnectionId must match a registered scalar or time series service.
  • EntityId is provider-defined (string, GUID, etc.). For time series you may embed a path placeholder:
    • If EntityId is 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.

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-in PlaceRepository reads/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/End are OLE Automation dates.
  • For Relative types, Start/End are 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 Metadata and Permissions from 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; call GetAggregatedValue using indicator’s AggregationType.
  • EnsembleTimeSeries: aggregate each member over (from,to); then compute indicator value via:
    • Quantile if present, otherwise Aggregate across members by the same AggregationType.
  • 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>
  • IndicatorConverter
  • DataSourceConverter
  • TimeIntervalConverter
  • ObjectToInferredTypeConverterPatch (temporary shim to improve object inference)

When you expose places/indicators in your API, add these converters to your JsonSerializerOptions for 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.
  • TimeInterval validates End >= Start (when applicable), and enforces required values per TimeIntervalType.

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 by Add. You can cancel in the “before” via CancelEventArgs<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)throws KeyNotFoundException if the place does not exist.
  • Add(place) → throws if:
    • GIS collection missing, or no feature matches (AttributeKey == AttributeValue).
    • Another place with the same Id already exists.
    • Any indicator is invalid (see below).
  • 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: AggregationType is required. If the TimeInterval is null, use the overloads that pass an explicit (from,to) when evaluating status.
  • TimeInterval.ToPeriod(...):
    • Requires both Start and End except for All.
    • Requires offsetDateTime for RelativeToDateTime.
  • 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 supply path per request.
  • Map overlays: Call GetFeaturesWithIndicatorStatus(...) and pass directly to your map layer; each feature includes per-indicator styleCode and color.
  • 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