DHI.Services.Models — Internal Developer Guide¶
DHI.Services.Models is an integration layer for running “scenario-based” simulations against different modeling engines in a consistent way. You plug in your model-specific adapter (a model reader) and the package gives you:
- a standard contract for model parameters, input time series, output time series, and available simulations
- services to register readers, create/validate scenarios, execute them via a worker, and fetch results
- repositories (JSON by default) to persist reader and scenario definitions
In practice: it lets your app treat many different model implementations the same way—swap model engines, storage providers, or execution backends without changing your controllers/UI.
1) What this module does¶
At a glance:
- Describe & register model readers (your adapters) that expose:
- Parameters (name -> .NET type)
- Input time series (key -> description) + value retrieval
- Output time series (key -> description) + value retrieval for a simulation
- Available simulations for a scenario
- Persist “reader definitions” (which adapter type to use, friendly name, etc.) in a repository (JSON by default).
- Create & validate scenarios (parameters + input TS bindings), execute them via pluggable workers, and fetch output time series for a simulation.
- Swap persistence by changing repositories (JSON, DB, cloud) using the same interfaces.
The module is split into three layers:
[ Controllers / Apps ]
│
Services (ModelDataReaderService, ScenarioService)
│
Repositories (IModelDataReaderRepository, IScenarioRepository)
│
Entities / Contracts (IModelInputReader, IModelOutputReader, Scenario, Simulation)
Use this package when you have one or more simulation/model engines (hydro, sediment, ML surrogate, etc.) and you want a single, uniform way to: configure runs (Scenario), execute them (IScenarioWorker), and read inputs/outputs (IModelInputReader/IModelOutputReader)—while keeping storage (JSON/DB) and execution backend interchangeable.
2) Core concepts¶
2.1 “Model reader” adapters¶
A model reader is your bridge to a model’s metadata and data:
public interface IModelInputReader
{
IDictionary<string, Type> GetParameterList();
TParameter GetParameterValue<TParameter>(string parameterId);
IDictionary<string, string> GetInputTimeSeriesList();
Task<ITimeSeriesData<double>> GetInputTimeSeriesValues(string timeSeriesKey);
}
public interface IModelOutputReader
{
IDictionary<string, string> GetOutputTimeSeriesList();
IEnumerable<Simulation> GetSimulations(string scenarioId);
Task<Maybe<ITimeSeriesData<double>>> GetOutputTimeSeriesValues(Guid simulationId, string timeSeriesKey);
}
You implement both for your model. The module wraps them with:
public class ModelDataReader<TReader> : BaseNamedEntity<string>, IModelDataReader
where TReader : IModelInputReader, IModelOutputReader
{
public string TypeName => typeof(TReader).FullName; // discriminator
public string ModelType { get; set; } // free-form, UI label friendly
// … methods delegate to TReader instance with guard rails & friendly errors
}
IModelDataReaderis what services and repositories store/serve. It’s a “facade” combining both input/output reader facets.
2.2 Scenarios & simulations¶
- Scenario (
Scenario) captures what to run:Id,Name,ModelDataReaderIdParameterValues(e.g.,simulationStart,simulationEnd, …)InputTimeSeriesValues(mapping of input TS keys -> TS identifiers in your telemetry/TS service)- optional
Metadata(free-form)
- Simulation (
Simulation) captures a run of a scenario:Id(Guid),ScenarioId,Name/ShortName, timestamps (Requested,Start,End, …), progress, etc.
2.3 Services¶
ModelDataReaderService– CRUD over registered readers.ScenarioService– CRUD over scenarios with validation, execution via anIScenarioWorker, and helpers to fetch input/output data through the scenario’s reader.
All
Base*Serviceclasses raiseAdding/Added,Updating/Updated,Deleting/Deletedevents so you can intercept.
2.4 Repositories¶
IModelDataReaderRepository– persist readers + optional “pass-through” scenario/simulation listing.IScenarioRepository– persist scenarios + model-specific simulation queries (latest run, lists, associations…).
Default JSON-backed repos exist:
ModelDataReaderRepository(JSON; includes the converter + auto-registration)ScenarioRepository(JSON; “shape” present, specialized methods are stubs you can implement or override in a provider).
2.5 Polymorphic JSON for readers¶
The system uses a type discriminator (TypeName) + a registry to deserialize IModelDataReader:
IModelDataReaderConverter– System.Text.Json converter that readsTypeNameand asksModelDataReaderTypeRegistryfor the correct wrapper type (ModelDataReader<TReader>) to instantiate.ModelDataReaderJsonHelper.AutoRegisterTypesFromJson(json)– scans your JSON, loads assemblies if needed, and registers the mappings automatically before deserializing.
Why? So the JSON file can hold heterogeneous readers (different TReader types) transparently.
3) The main types (cheat sheet)¶
3.1 Entities & contracts¶
IModelInputReader/IModelOutputReader– implement these for your model.IModelDataReader– persisted shape (inheritsINamedEntity<string>, hasTypeName,ModelType).ModelDataReader<TReader>– wraps your reader and enforces guards.Scenario– what to execute (parameters & input bindings).Simulation– a run (status/timestamps/group/progress).
3.2 Repositories¶
public interface IModelDataReaderRepository :
IRepository<IModelDataReader, string>,
IDiscreteRepository<IModelDataReader, string>,
IUpdatableRepository<IModelDataReader, string>
{
IEnumerable<Scenario> GetScenarios(string id, ClaimsPrincipal user = null);
IEnumerable<Simulation> GetSimulations(string id, ClaimsPrincipal user = null);
}
public interface IScenarioRepository :
IRepository<Scenario, string>,
IDiscreteRepository<Scenario, string>,
IUpdatableRepository<Scenario, string>
{
Simulation[] GetSimulations(string scenarioId, DateTime? from = null, DateTime? to = null, ClaimsPrincipal user = null);
Simulation GetLatestSimulation(string scenarioId, ClaimsPrincipal user = null);
// … plus model-object helpers (association, schematics, properties, etc.)
}
You can implement DB/cloud providers by implementing these interfaces and reusing the services unchanged.
3.3 Services & connections¶
public class ModelDataReaderService :
BaseUpdatableDiscreteService<IModelDataReader, string> { /* + type discovery */ }
public class ScenarioService :
BaseUpdatableDiscreteService<Scenario, string>
{
// Validates scenarios against the selected reader (parameter names/types, input TS keys)
// Executes via IScenarioWorker
// Fetches input/output data via the selected reader
}
public interface IScenarioWorker
{
Guid Execute(Scenario scenario); // return the new Simulation Id
void Cancel(Guid simulationId);
}
Connection helpers (instantiate services from strings, for config-driven hosts):
public class ModelDataReaderServiceConnection : BaseConnection
// new(id, name, repositoryType, connectionString)
public class ScenarioServiceConnection : BaseConnection
// new(id, name, repoType, connStr, modelRepoType, modelConnStr, workerType)
Both call
Activator.CreateInstance(...)for you and return a ready-to-use service. UseServiceLocator.Register(service, connectionId)(orServices.Configure(...)) to expose them to the rest of your app.
4) JSON shapes (what’s on disk for the default repos)¶
4.1 ModelDataReaderRepository file¶
It’s a dictionary: id -> IModelDataReader (polymorphic). Each value must include:
"$type": the wrapper type, e.g.DHI.Services.Models.ModelDataReader1[[MyNamespace.MyReader, MyReaderAssembly]], DHI.Services.Models`"TypeName": the inner reader’s full name (typeof(MyReader).FullName)- standard
BaseNamedEntityfields:"Id","Name" - optional
"ModelType"
Example
{
"hydro-reader-01": {
"$type": "DHI.Services.Models.ModelDataReader`1[[Acme.Models.Hydro.HydroReader, Acme.Models]], DHI.Services.Models",
"Id": "hydro-reader-01",
"Name": "Hydrodynamic v2024",
"TypeName": "Acme.Models.Hydro.HydroReader",
"ModelType": "3D Hydro"
},
"sediment-reader": {
"$type": "DHI.Services.Models.ModelDataReader`1[[Acme.Models.Sediment.SedReader, Acme.Models]], DHI.Services.Models",
"Id": "sediment-reader",
"Name": "Sediment v7",
"TypeName": "Acme.Models.Sediment.SedReader",
"ModelType": "Sediment"
}
}
The first time
ModelDataReaderRepositorystarts, it callsAutoRegisterTypesFromJson(...)to populate the type registry from this file (reading$typeandTypeName). You can also pre-register by code (see section 6.2).
4.2 ScenarioRepository file¶
Also a dictionary: id -> Scenario. Shape is straightforward:
{
"scenario-001": {
"Id": "scenario-001",
"Name": "Spring Flood 2024",
"ModelDataReaderId": "hydro-reader-01",
"ParameterValues": {
"simulationStart": "2024-03-01T00:00:00Z",
"simulationEnd": "2024-03-10T00:00:00Z",
"meshLevel": 3
},
"InputTimeSeriesValues": {
"boundary-north": "telemetry/bnd/north",
"precip": "telemetry/grids/precip-01h"
},
"Metadata": {
"owner": "alice",
"tag": "flood-ops"
}
}
}
5) Typical tasks (with code)¶
5.1 Register repositories & services (JSON-backed)¶
using DHI.Services;
using DHI.Services.Models;
// Ensure files exist
var modelRepoPath = Path.Combine(env.ContentRootPath, "App_Data", "modelReaders.json");
var scenarioRepoPath = Path.Combine(env.ContentRootPath, "App_Data", "scenarios.json");
File.WriteAllText(modelRepoPath, File.Exists(modelRepoPath) ? File.ReadAllText(modelRepoPath) : "{}");
File.WriteAllText(scenarioRepoPath, File.Exists(scenarioRepoPath) ? File.ReadAllText(scenarioRepoPath) : "{}");
// Services
var modelRepo = new ModelDataReaderRepository(modelRepoPath);
var scenarioRepo = new ScenarioRepository(scenarioRepoPath);
// Worker (your implementation)
IScenarioWorker worker = new MyScenarioWorker();
// Optional: a TS service if you want input-TS existence checks
DiscreteTimeSeriesService? tsService = null;
var modelSvc = new ModelDataReaderService(modelRepo);
var scenarioSvc = new ScenarioService(scenarioRepo, modelSvc, worker, tsService);
// Optional: expose via ServiceLocator (to share via connectionId)
ServiceLocator.Register(modelSvc, "models");
ServiceLocator.Register(scenarioSvc, "scenarios");
5.2 Implement a custom reader¶
public sealed class HydroReader : IModelInputReader, IModelOutputReader
{
public IDictionary<string, Type> GetParameterList() => new Dictionary<string, Type>
{
["simulationStart"] = typeof(DateTime),
["simulationEnd"] = typeof(DateTime),
["meshLevel"] = typeof(int)
};
public TParameter GetParameterValue<TParameter>(string parameterId)
{
// Look up defaults from a config, file, or DB
object value = parameterId switch
{
"meshLevel" => 3,
_ => throw new KeyNotFoundException(parameterId)
};
return (TParameter)Convert.ChangeType(value, typeof(TParameter));
}
public IDictionary<string, string> GetInputTimeSeriesList() => new Dictionary<string, string>
{
["boundary-north"] = "Northern open boundary head",
["precip"] = "Hourly precipitation"
};
public Task<ITimeSeriesData<double>> GetInputTimeSeriesValues(string key)
{
// Fetch from TS storage; create ITimeSeriesData<double>
throw new NotImplementedException();
}
public IDictionary<string, string> GetOutputTimeSeriesList() => new Dictionary<string, string>
{
["discharge-L1"] = "Discharge at location 1",
["discharge-L2"] = "Discharge at location 2"
};
public IEnumerable<Simulation> GetSimulations(string scenarioId)
{
// Discover past runs for this scenario
return Enumerable.Empty<Simulation>();
}
public async Task<Maybe<ITimeSeriesData<double>>> GetOutputTimeSeriesValues(Guid simulationId, string key)
{
// Optionally return Empty if not present
return Maybe.Empty<ITimeSeriesData<double>>();
}
}
5.3 Register a reader instance¶
Option A – JSON-first (preferred in hosted apps)
- Add an entry to
modelReaders.jsonlike in section 4.1. - Start the app;
ModelDataReaderRepositorywill auto-register the wrappers and deserialize.
Option B – programmatic
using DHI.Services.Models.Converters;
// Ensure the registry knows the mapping:
ModelDataReaderTypeRegistry.Register("Acme.Models.Hydro.HydroReader",
typeof(ModelDataReader<HydroReader>));
// Build the wrapper entity and persist
var reader = new ModelDataReader<HydroReader>("hydro-reader-01", "Hydrodynamic v2024")
{
ModelType = "3D Hydro"
};
modelSvc.Add(reader); // persisted through ModelDataReaderRepository
TypeNameis automaticallytypeof(HydroReader).FullName.
5.4 Create & validate a scenario¶
var s = new Scenario(
id: "scenario-001",
name: "Spring Flood 2024",
modelDataReaderId: "hydro-reader-01",
parameterValues: new Dictionary<string, object>
{
["simulationStart"] = new DateTime(2024,3,1,0,0,0, DateTimeKind.Utc),
["simulationEnd"] = new DateTime(2024,3,10,0,0,0, DateTimeKind.Utc),
["meshLevel"] = 3 // numeric conversion is allowed among numeric types
},
inputTimeSeriesValues: new Dictionary<string,string>
{
["boundary-north"] = "telemetry/bnd/north",
["precip"] = "telemetry/grids/precip-01h"
}
);
scenarioSvc.Add(s); // Validates parameter names/types and input TS keys against the reader
Validation rules (from ScenarioService.ValidateScenario):
- Reader must exist (
ModelDataReaderId). - Every
ParameterValueskey must exist in the reader’sGetParameterList(). - Values are type-checked against the expected type. If the value is a
JsonElement, it’s deserialized to the expected type. Numeric-to-numeric conversions are allowed (byte↔int↔double, etc.). - Every
InputTimeSeriesValueskey must exist in the reader’sGetInputTimeSeriesList().* - If a
DiscreteTimeSeriesServiceis injected, it is consulted to verify the bound TS identifiers.
*Note: the current code checks the optional TS service as if (_timeSeriesService != null && _timeSeriesService.Exists(id)) throw ...; — this looks inverted (should likely be !Exists) and may be corrected in a future patch.
5.5 Execute and retrieve data¶
// Execute -> returns the new Simulation Id (worker decides how/where)
Guid simId = scenarioSvc.Execute("scenario-001");
// Output time series
var discharge = await scenarioSvc.GetSimulationData("scenario-001", simId, "discharge-L1");
// Input time series (as modeled by the reader)
var precip = await scenarioSvc.GetInputTimeSeriesData("scenario-001", "precip");
5.6 Derived scenarios (from an existing simulation)¶
If your repository implements IDerivedScenarioFactory, ScenarioService.CreateAndAdd(...) can clone a scenario with parameters derived from a simulation:
var derived = scenarioSvc.CreateAndAdd(
derivedScenarioName: "Spring Flood 2024 – ALT Mesh",
scenarioId: "scenario-001",
simulationId: simId,
parameters: new Parameters { /* any knobs for your factory */ }
);
6) Provider model & discovery¶
6.1 Swap repositories without changing services¶
Implement either interface (or both):
IModelDataReaderRepository-> persist readers (and optionally expose their scenarios/simulations).IScenarioRepository-> persist scenarios (and implement model-specific simulation queries).
Wire up:
var modelSvc = new ModelDataReaderService(new MySqlModelReaderRepository(connString));
var scenarioSvc = new ScenarioService(new CosmosScenarioRepository(conn2), modelSvc, worker);
6.2 Type discovery (for admin UIs)¶
The services expose helpers that scan assemblies for compatible provider types:
ModelDataReaderService.GetRepositoryTypes(); // IModelDataReaderRepository implementers
ScenarioService.GetRepositoryTypes(searchPath);
ScenarioService.GetWorkerTypes(searchPath); // IScenarioWorker implementers
Great for building “Add connection” UIs.
7) Connections (config-first wiring)¶
Use connection objects when you want to create services from strings (e.g., JSON config).
// Model readers
var mConn = new ModelDataReaderServiceConnection(
id: "models-json",
name: "Models (JSON)",
repositoryType: typeof(ModelDataReaderRepository).AssemblyQualifiedName!,
connectionString: "[AppData]modelReaders.json"
);
ServiceLocator.Register((ModelDataReaderService)mConn.Create(), mConn.Id);
// Scenarios
var sConn = new ScenarioServiceConnection(
id: "scenarios-json",
name: "Scenarios (JSON)",
repositoryType: typeof(ScenarioRepository).AssemblyQualifiedName!,
connectionString: "[AppData]scenarios.json",
modelDataReaderRepositoryType: typeof(ModelDataReaderRepository).AssemblyQualifiedName!,
modelDataReaderConnectionString: "[AppData]modelReaders.json",
workerType: typeof(MyScenarioWorker).AssemblyQualifiedName!
);
ServiceLocator.Register((ScenarioService)sConn.Create(), sConn.Id);
If you use
[AppData], set:AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(env.ContentRootPath, "App_Data"));
8) Errors & edge cases¶
- Missing reader type mapping ->
IModelDataReaderConverterthrows (TypeNamenot found). Fix by ensuring:- JSON includes both
$typeandTypeName, or - You pre-register:
ModelDataReaderTypeRegistry.Register("Full.Name.Of.Reader", typeof(ModelDataReader<ReaderType>)).
- JSON includes both
- Parameter/type mismatch ->
ArgumentExceptionwith a clear message; numeric cross-casts are attempted when both sides are numeric. - Unknown input/output keys ->
ArgumentException(inputs) orKeyNotFoundException(outputs). - Output value retrieval – readers return
Maybe<ITimeSeriesData<double>>;ModelDataReader<T>throws if empty (enforces “caller expects data”). - Clone –
IModelDataReaderinheritsDHI.Services.ICloneablebutModelDataReader<T>.Clone()currently throwsNotImplementedException. Don’t callClone<T>()on readers until implemented. - Thread safety – the JSON repos use internal locks; good for dev/test. Use DB/cloud providers for concurrency/transactions.
9) Quick reference (when to use what)¶
| You need… | Use… | Notes |
|---|---|---|
| Register multiple model adapters | Implement IModelInputReader+IModelOutputReader; persist as ModelDataReader<T> |
Give each one a unique Id + Name |
| CRUD over adapters | ModelDataReaderService + (ModelDataReaderRepository or custom repo) |
JSON repo auto-registers types |
| Scenarios with validation & execution | ScenarioService + IScenarioRepository + IScenarioWorker |
Reader-driven validation of parameters and input TS keys |
| Execute & get results | ScenarioService.Execute + GetSimulationData |
Reader must enumerate simulations & serve outputs |
| Config-driven startup | ModelDataReaderServiceConnection, ScenarioServiceConnection |
Create services from strings; register in ServiceLocator |
| Discover providers/workers for UIs | GetRepositoryTypes(...), GetWorkerTypes(...) |
Scans assemblies for compatible types |
10) End-to-end sample¶
// 1) Readers (JSON)
var modelSvc = (ModelDataReaderService)
new ModelDataReaderServiceConnection(
id: "models",
name: "Readers (JSON)",
repositoryType: typeof(ModelDataReaderRepository).AssemblyQualifiedName!,
connectionString: "[AppData]modelReaders.json"
).Create();
// 2) Scenarios (JSON) + Worker
var scenarioSvc = (ScenarioService)
new ScenarioServiceConnection(
id: "scenarios",
name: "Scenarios (JSON)",
repositoryType: typeof(ScenarioRepository).AssemblyQualifiedName!,
connectionString: "[AppData]scenarios.json",
modelDataReaderRepositoryType: typeof(ModelDataReaderRepository).AssemblyQualifiedName!,
modelDataReaderConnectionString: "[AppData]modelReaders.json",
workerType: typeof(MyScenarioWorker).AssemblyQualifiedName!
).Create();
// 3) Ensure one reader is present (JSON-first or programmatic)
if (!modelSvc.Exists("hydro-reader-01"))
{
ModelDataReaderTypeRegistry.Register(
"Acme.Models.Hydro.HydroReader",
typeof(ModelDataReader<HydroReader>)
);
modelSvc.Add(new ModelDataReader<HydroReader>("hydro-reader-01", "Hydro v2024"));
}
// 4) Create a scenario (validated against reader)
var scenario = new Scenario("scenario-001", "Spring Flood 2024", "hydro-reader-01",
new Dictionary<string, object>
{
["simulationStart"] = new DateTime(2024,3,1,0,0,0,DateTimeKind.Utc),
["simulationEnd"] = new DateTime(2024,3,10,0,0,0,DateTimeKind.Utc),
["meshLevel"] = 3
},
new Dictionary<string,string>
{
["boundary-north"] = "telemetry/bnd/north",
["precip"] = "telemetry/grids/precip-01h"
}
);
scenarioSvc.Add(scenario);
// 5) Execute + read output
var simId = scenarioSvc.Execute(scenario.Id);
var discharge = await scenarioSvc.GetSimulationData(scenario.Id, simId, "discharge-L1");
11) Practical tips¶
- Parameter evolution: readers should keep
GetParameterList()stable and add with defaults;GetParameterValue<T>()is a good place for sane defaults. - Time series keys: use descriptive, stable keys; treat the values (TS identifiers) as environment-specific.
- Workers: return the
Simulation.Idas soon as a run is accepted; update status/progress through your own job system (or extend the repo). - Discovery: surface
GetRepositoryTypes(...)andGetWorkerTypes(...)in your admin UI to let users pick providers from installed assemblies. - Validation messages:
ScenarioServicethrows actionable, parameterized exceptions; bubble them to UI as-is.