Skip to content

DHI.Services.TimeSeries.WebApi — Internal Developer Guide

This module exposes the Time Series domain over HTTP. It wraps repository + service implementations behind a clean, JWT-protected REST API, adds a small set of analysis endpoints, and publishes SignalR notifications for write operations.


What you get (at a glance)

  • Two controllers at api/timeseries/{connectionId}:
    • TimeSeriesController — CRUD & read endpoints (IDs, values, vectors, etc.)
    • TimeSeriesAnalysisController — analytics (min/max/sum/avg, percentiles, smoothing, reduce, resample, trendlines, duration curves).
  • JWT auth + role policies:
    • EditorsOnly → can add/update/delete time series and values.
    • Everybody else must be authenticated to read.
  • Versioning: api-version=1 (defaults to v1).
  • Serialization: optimized JSON with built-in TimeSeries converters (array-of-pairs, optional flags).
  • Pluggable backends: connectionId selects the service instance registered at startup (CSV repo, Daylight, in-memory, etc.).
  • SignalR hub (/notificationhub) events for writes.

Architecture & key concepts

Connection model ({connectionId})

The API is multi-tenant by design. Each request specifies a connectionId, which the controllers use to resolve a backing service instance:

var svc = Services.Get<ITimeSeriesService<string,double>>(connectionId);

You (or hosting code) register those services at startup:

ServiceLocator.Register(
  new GroupedDiscreteTimeSeriesService<string,double>(
    new DHI.Services.TimeSeries.CSV.TimeSeriesRepository("[AppData]csv".Resolve())),
  "csv"
);
ServiceLocator.Register(
  new CoreTimeSeriesService(new DHI.Services.TimeSeries.Daylight.TimeSeriesRepository()),
  "daylight"
);

So calls to /api/timeseries/csv/... hit the CSV repository; /api/timeseries/daylight/... hits the generated Daylight series, etc.

There’s also a set of Connection classes (e.g., DiscreteTimeSeriesServiceConnection) that can instantiate repositories by reflection using a RepositoryType and a ConnectionString. They resolve [AppData] paths and unwrap TargetInvocationException to rethrow the inner error with the original stack.

Authentication & authorization

  • JWT Bearer is required for all endpoints ([Authorize] on both controllers).
  • Policies:
    • EditorsOnly ⇒ requires a GroupSid claim with value "Editors".
    • AdministratorsOnly ⇒ requires GroupSid: "Administrators" (not used by default endpoints, but available).
  • Add an Authorization: Bearer <token> header on every request.

Serialization defaults

SerializerOptionsDefault.Options is used to plug custom JSON converters:

  • Time series data is serialized as:
    [ ["2024-01-01T00:00:00Z", 1.23], ["2024-01-01T01:00:00Z", null] ]
    
  • Flags (if present) add a 3rd element per tuple: ["ts", value, flag].
  • EnumerationConverter handles domain enumerations (e.g., TimeSeriesDataType by display name).
  • DefaultIgnoreCondition = WhenWritingNull.
  • CamelCase is off by default in the sample host (commented out). If you need camelCase, enable it globally.

NaN handling Set in configuration: Serialization:UseNullForNaNtrue recommended (emits null instead of "NaN").


Running the sample host

The sample Startup shows a typical configuration:

  • JWT: issuer/audience/public RSA key.
  • API versioning: query string api-version and header.
  • Swagger: auto-discovery and XML comments.
  • SignalR: hub at /notificationhub.
  • App_Data resolution**: AppDomain.CurrentDomain.SetData("DataDirectory", <ContentRoot>/App_Data) and string extensions resolve [AppData]path to that folder.

Registered connections in the sample:

  • csv: read-only grouped CSV repository.
  • daylight: generated sunrise/sunset step series.
  • myTsConnection: in-memory updatable store (good for testing).
  • daylight-ts: core service over Daylight (alias).
  • csv2: same CSV service but with logger injection.

DTOs you’ll see

TimeSeriesDTO (for add/update)

{
  "fullName": "stations/r1/level.csv;WaterLevel",
  "dataType": "Instantaneous",   // optional; display name
  "dimension": "Length",         // optional
  "quantity": "WaterLevel",      // optional
  "unit": "m",                   // optional
  "data": [ ["2025-01-01T00:00:00Z", 1.23], ... ] // optional initial values
}

Server maps it to a TimeSeries<string,double> using FullName → (Group,Name) and Enumeration.FromDisplayName<TimeSeriesDataType>(). Id is the full name string.

TimeSeriesDataDTO (for setting values)

{
  "dateTimes": ["2025-01-01T00:00:00Z","2025-01-01T01:00:00Z"],
  "values":    [1.1, 1.2],
  "flags":     [null, {"q": "OK"}]   // optional; if provided, aligns with DateTimes/Values
}

Maps to either TimeSeriesData<double> or TimeSeriesDataWFlag<double,object>.

ComponentsDTO (for vectors)

{ "x": "u_series_id", "y": "v_series_id" }

Endpoint map

Base route for both controllers: /api/timeseries/{connectionId} All endpoints require Authorization. Unless otherwise noted, timestamps are ISO-8601 (Z recommended). If from/to are omitted, the operation defaults to the entire available range.

Read & management (TimeSeriesController)

Verb & Path Returns Notes
GET /{id} TimeSeries<string,double> Full entity (metadata + optional data if repo returns it).
GET /ids string[] All IDs (flat).
GET /fullnames?group=... string[] Recursive full names; only for grouped repos.
GET /count int Total number of series.
GET /{id}/datetimes DateTime[] sorted unique All timestamps for the series.
GET /{id}/datetime/first DateTime? First timestamp or null.
GET /{id}/datetime/last DateTime? Last timestamp or null.
GET /{id}/value/{date} ["ts", value] Exact timestamp; null value if missing.
GET /{id}/value/first ["ts", value] First value.
GET /{id}/value/last ["ts", value] Last value.
GET /{id}/value/firstafter/{date} ["ts", value] First value strictly after date.
GET /{id}/value/lastbefore/{date} ["ts", value] Last value strictly before date.
GET /{id}/values?from=&to= [[ts,val], ...] If both omitted, returns all values; with range, windows the values.
POST /list/values [[id, [[ts,val],...]], ...] OR aligned grid Body: string[] ids. If distinctdatetime=true, returns an aligned table: one row per distinct time, first column is the timestamp, subsequent columns are the values (or null).
POST /vectors [[ts, {"X":..,"Y":..,"Size":..,"Direction":..}], ...] Body: { "x": "...", "y": "..." }. Range optional.
POST /list/vectors object keyed by pair Body: [{ "x": "...", "y": "..." }, ...]. Range optional.
POST / (EditorsOnly) TimeSeries Adds a new time series. Only for updatable repos.
PUT / (EditorsOnly) TimeSeries Updates metadata and/or data.
PUT /{id}/values (EditorsOnly) TimeSeries Merges/appends values.
DELETE /{id} (EditorsOnly) 204 Deletes a series.
DELETE /group/{group} (EditorsOnly) 204 Deletes all series in group (grouped, updatable repos only).
DELETE /{id}/values?from=&to= (EditorsOnly) 204 Deletes values entirely or by range.

Analysis (TimeSeriesAnalysisController)

All of these accept optional from/to. If both are omitted, the operation uses the full range.

Verb & Path Returns Notes
GET /{id}/{stat} number or null Aggregates over range (ignoring nulls). stat = min | max | sum | average
POST /list/{stat} { id: number } Batch for multiple IDs (body: string[]). stat = min | max | sum | average
GET /{id}/{stat}/period/{period} [[ts,val], ...] Period grouping (Hourly, Daily, Monthly, Yearly). stat = min | max | sum | average
GET /{id}/percentile/{percentage} number or null percentage 1..100 (inclusive), computed over non-null values only.
POST /list/percentile/{percentage} { id: number } Batch percentile.
GET /{id}/movingaverage?window=5 [[ts,val], ...] Window is by point count (odd or even allowed). Nulls inside a window are excluded from the denominator.
GET /{id}/reduced?tolerance=2&minimumCount=3000 [[ts,val], ...] Douglas–Peucker reduction.
POST /list/reduced { id: [[ts,val], ...] } Batch reduction.
GET /{id}/smoothed?window=5&order=2 [[ts,val], ...] Savitzky–Golay smoothing. window must be odd and > order+1 (order 0..5).
POST /list/smoothed { id: [[ts,val], ...] } Batch smoothing.
GET /{id}/resampled?timeSpan=01:00:00 [[ts,val], ...] Resample by TimeSpan. TimeSeries DataType controls interpolation; StepAccumulated sums.
GET /{id}/resampled/{stat} number or null stat = max
GET /{id}/resampled/lineartrendline?timeSpan=... { slope, offset, trendline } trendline is an ITimeSeriesData.
GET /{id}/resampled/period/{period} [[ts,val], ...] Resample by Period (Hourly, Daily, Monthly, Yearly).
GET /{id}/resampled/period/{period}/{stat} number or null stat = max
GET /{id}/resampled/period/{period}/lineartrendline { slope, offset, trendline } Trendline after period resampling.
GET /{id}/durationcurve?durationInHours=...&numberOfIntervals=10&minNumberOfValues=100 [[ts,val], ...] Threshold vs exceedance-duration curve. Throws on too few or constant values.
GET /{id}/durationcurve/extreme [[P,value], ...] Fixed extreme exceedance probabilities.
GET /{id}/standarddeviation number or null Welford single-pass over non-nulls.
GET /{id}/standarddeviation/period/{period} number or null SD per period groups.
POST /list/standarddeviation { id: number } Batch SD.

Important semantics (matches the TimeSeries guide you already have):

  • Aggregations ignore nulls (emit null if no usable values in scope).
  • Resampling:
    • Instantaneous/Accumulated/MeanStep* → values by interpolation per type.
    • StepAccumulated → at time t, sum of values in the open-closed interval (t-span, t]. Adds a trailing step on the axis.
  • Moving windows throw if first or last series value is null (edge windows undefined).
  • Percentiles operate on the set of non-null values.

URL encoding of IDs & dates

  • IDs commonly contain slashes and semicolons (stations/r1/level.csv;WaterLevel). Always URL-encode when placing an ID in the path. The host uses helper types like FullNameString.FromUrl(id) to decode.
  • Dates in path: Endpoints using {date:datetime} expect ISO-8601; include Z for UTC or an explicit offset. In query string, the same applies.

Examples

Replace TOKEN with a valid JWT and BASE with your server URL (e.g., https://localhost:5001).

1) List IDs (CSV connection)

curl -H "Authorization: Bearer TOKEN" \
  "$BASE/api/timeseries/csv/ids?api-version=1"

2) Read values for a month

curl -H "Authorization: Bearer TOKEN" \
  "$BASE/api/timeseries/csv/stations%2Fr1%2Flevel.csv%3BWaterLevel/values?from=2025-01-01T00:00:00Z&to=2025-02-01T00:00:00Z&api-version=1"

Response (compact array-of-pairs):

[
  ["2025-01-01T00:00:00Z", 0.91],
  ["2025-01-01T01:00:00Z", 0.92]
]

3) Alignment across multiple series

curl -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '["s1;WL","s2;WL","s3;WL"]' \
  "$BASE/api/timeseries/csv/list/values?from=2025-01-01T00:00:00Z&to=2025-01-01T03:00:00Z&distinctdatetime=true&api-version=1"

Response (distinct datetimes grid):

[
  ["2025-01-01T00:00:00Z", 0.9, 0.7, 1.1],
  ["2025-01-01T01:00:00Z", 0.92, null, 1.15],
  ["2025-01-01T02:00:00Z", null, 0.71, 1.17]
]

4) Resample hourly and compute 95th percentile

# Resample to hourly
curl -H "Authorization: Bearer TOKEN" \
  "$BASE/api/timeseries/csv/s1%3BWL/resampled?timeSpan=01:00:00&api-version=1"

# Percentile
curl -H "Authorization: Bearer TOKEN" \
  "$BASE/api/timeseries/csv/s1%3BWL/percentile/95?api-version=1"

5) Savitzky–Golay smoothing (window=11, order=3)

curl -H "Authorization: Bearer TOKEN" \
  "$BASE/api/timeseries/csv/s1%3BWL/smoothed?window=11&order=3&api-version=1"

6) Add a new series & set values (updatable store)

# Add series to the in-memory test connection
curl -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{
        "fullName": "Sensors/Temp;sensorA",
        "dataType": "Instantaneous",
        "quantity": "Temperature",
        "unit": "°C"
      }' \
  "$BASE/api/timeseries/myTsConnection?api-version=1"

# Set values
curl -X PUT -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{
        "dateTimes": ["2025-01-01T00:00:00Z","2025-01-01T01:00:00Z"],
        "values": [21.3, 21.5]
      }' \
  "$BASE/api/timeseries/myTsConnection/Sensors%2FTemp%3BsensorA/values?api-version=1"

7) Vectors from U/V components

curl -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{"x":"wind_U;A","y":"wind_V;A"}' \
  "$BASE/api/timeseries/csv/vectors?from=2025-01-01T00:00:00Z&to=2025-01-01T06:00:00Z&api-version=1"

SignalR notifications (optional)

The host emits SignalR messages on write operations (if IHubContext<NotificationHub> is present):

  • Events
    • TimeSeriesAdded — after POST /
    • TimeSeriesUpdated — after PUT /
    • TimeSeriesDeleted — after DELETE /{id}
    • TimeSeriesGroupDeleted — after DELETE /group/{group}
    • TimeSeriesValuesSet — after PUT /{id}/values
  • Payload: a key/value bag:
    {
        "id": "Sensors/Temp;sensorA",
        "data": "{...serialized object...}",   // only for add/update/values
        "userName": "user-id-or-name"
    }
    
  • Hub path: /notificationhub

Client sketch (TypeScript):

import * as signalR from "@microsoft/signalr";

const conn = new signalR.HubConnectionBuilder()
  .withUrl("/notificationhub", { accessTokenFactory: () => token })
  .withAutomaticReconnect()
  .build();

conn.on("TimeSeriesValuesSet", payload => {
  console.log("Values updated:", payload);
});

await conn.start();

Behavior details

  • from/to defaults: when omitted, APIs use the entire available range; when one is missing, the missing side is treated as DateTime.MinValue / DateTime.MaxValue.
  • Resampling alignment: when resampling by Period, the first step is aligned to “nice” boundaries (full hour/day; Monday for weekly; quarter start; Jan 1 for yearly). StepAccumulated adds a trailing step.
  • Data types drive interpolation: Instantaneous / Accumulated are linear; MeanStepBackward/Forward return next/previous; StepAccumulated is summed during resample.
  • Percentiles: based on non-null values only; returns null if no data.
  • Smoothing windows: window must be odd and window > order + 1; order in [0..5].
  • Errors:
    • 401/403 for auth issues.
    • 404 when the underlying service throws not found (e.g., deleting a missing series).
    • 400 for model validation (e.g., missing x/y in ComponentsDTO, invalid window/order).
    • Some endpoints annotate 204 in Swagger but practically return 200 with null for “no data” cases.
  • Performance:
    • Prefer resample or reduce before transporting massive series.
    • ResponseCompression is enabled in the host—keep Accept-Encoding: gzip on clients.
  • Time zones:
    • Send UTC (Z) when possible. Repositories like Daylight may convert between zones as part of data generation.
  • ID encoding:
    • Always URL-encode path segments. Typical ID looks like stations/r1/level.csv;WaterLevelstations%2Fr1%2Flevel.csv%3BWaterLevel.

Adding a new connection at runtime

You can add a new repo/service pair in your host without touching the WebApi:

// Example: register a grouped updatable CSV repository
var repo = new DHI.Services.TimeSeries.CSV.UpdatableTimeSeriesRepository("[AppData]ts-store".Resolve());
var svc  = new DHI.Services.TimeSeries.GroupedUpdatableTimeSeriesService<string,double>(repo);
ServiceLocator.Register(svc, "csv-upd");

Now all routes under /api/timeseries/csv-upd/... use that service.

If you prefer declarative setup, look at the commented example using:

// Services.Configure(new ConnectionRepository("connections.json", SerializerOptionsDefault.Options), lazyCreation);

…and the provided *ServiceConnection classes (they reflect-create a repository from "RepositoryType" and "ConnectionString" values and resolve [AppData] placeholders).


Handy client snippets

C# (HttpClient)

var http = new HttpClient { BaseAddress = new Uri(BASE) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", token);

var id = Uri.EscapeDataString("stations/r1/level.csv;WaterLevel");
var url = $"/api/timeseries/csv/{id}/values?from=2025-01-01T00:00:00Z&to=2025-02-01T00:00:00Z&api-version=1";

var json = await http.GetStringAsync(url);
// deserialize to List<(DateTime ts, double? value)> or directly use the JArray

JavaScript (fetch)

const res = await fetch(`${BASE}/api/timeseries/csv/${encodeURIComponent('stations/r1/level.csv;WaterLevel')}/min?api-version=1`,
  { headers: { Authorization: `Bearer ${token}` }});
const min = await res.json();