Skip to content

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
}

IModelDataReader is 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, ModelDataReaderId
    • ParameterValues (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 an IScenarioWorker, and helpers to fetch input/output data through the scenario’s reader.

All Base*Service classes raise Adding/Added, Updating/Updated, Deleting/Deleted events 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 reads TypeName and asks ModelDataReaderTypeRegistry for 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.
  • IModelDataReaderpersisted shape (inherits INamedEntity<string>, has TypeName, 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. Use ServiceLocator.Register(service, connectionId) (or Services.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 BaseNamedEntity fields: "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 ModelDataReaderRepository starts, it calls AutoRegisterTypesFromJson(...) to populate the type registry from this file (reading $type and TypeName). 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)

  1. Add an entry to modelReaders.json like in section 4.1.
  2. Start the app; ModelDataReaderRepository will 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

TypeName is automatically typeof(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 ParameterValues key must exist in the reader’s GetParameterList().
  • 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 (byteintdouble, etc.).
  • Every InputTimeSeriesValues key must exist in the reader’s GetInputTimeSeriesList().*
  • If a DiscreteTimeSeriesService is 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 -> IModelDataReaderConverter throws (TypeName not found). Fix by ensuring:
    • JSON includes both $type and TypeName, or
    • You pre-register: ModelDataReaderTypeRegistry.Register("Full.Name.Of.Reader", typeof(ModelDataReader<ReaderType>)).
  • Parameter/type mismatch -> ArgumentException with a clear message; numeric cross-casts are attempted when both sides are numeric.
  • Unknown input/output keys -> ArgumentException (inputs) or KeyNotFoundException (outputs).
  • Output value retrieval – readers return Maybe<ITimeSeriesData<double>>; ModelDataReader<T> throws if empty (enforces “caller expects data”).
  • CloneIModelDataReader inherits DHI.Services.ICloneable but ModelDataReader<T>.Clone() currently throws NotImplementedException. Don’t call Clone<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.Id as soon as a run is accepted; update status/progress through your own job system (or extend the repo).
  • Discovery: surface GetRepositoryTypes(...) and GetWorkerTypes(...) in your admin UI to let users pick providers from installed assemblies.
  • Validation messages: ScenarioService throws actionable, parameterized exceptions; bubble them to UI as-is.