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:
connectionIdselects 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 aRepositoryTypeand aConnectionString. They resolve[AppData]paths and unwrapTargetInvocationExceptionto rethrow the inner error with the original stack.
Authentication & authorization¶
- JWT Bearer is required for all endpoints (
[Authorize]on both controllers). - Policies:
EditorsOnly⇒ requires aGroupSidclaim with value"Editors".AdministratorsOnly⇒ requiresGroupSid: "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.,
TimeSeriesDataTypeby 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:UseNullForNaN → true 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-versionand 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]pathto 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 (Zrecommended). Iffrom/toare 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
nullif 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 likeFullNameString.FromUrl(id)to decode. - Dates in path: Endpoints using
{date:datetime}expect ISO-8601; includeZfor UTC or an explicit offset. In query string, the same applies.
Examples¶
Replace
TOKENwith a valid JWT andBASEwith 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/todefaults: when omitted, APIs use the entire available range; when one is missing, the missing side is treated asDateTime.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).StepAccumulatedadds 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
nullif no data. - Smoothing windows:
windowmust be odd andwindow > order + 1;orderin[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/yinComponentsDTO, invalidwindow/order). - Some endpoints annotate
204in Swagger but practically return200withnullfor “no data” cases.
- Performance:
- Prefer resample or reduce before transporting massive series.
ResponseCompressionis enabled in the host—keepAccept-Encoding: gzipon clients.
- Time zones:
- Send UTC (
Z) when possible. Repositories like Daylight may convert between zones as part of data generation.
- Send UTC (
- ID encoding:
- Always URL-encode path segments. Typical ID looks like
stations/r1/level.csv;WaterLevel→stations%2Fr1%2Flevel.csv%3BWaterLevel.
- Always URL-encode path segments. Typical ID looks like
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();