Database to EPANET Update Strategy
Date: October 16, 2025 Status: Design Document Purpose: Define the strategy for applying database changes to EPANET's in-memory model before running simulations
Executive Summary
This document outlines the approach for synchronizing changes made in the database with EPANET's in-memory model structure. Currently, the system loads networks from .inp files, meaning database modifications are not reflected in simulations unless manually exported to an INP file first.
Goal: Enable database changes to flow directly to EPANET before running hydraulic and quality simulations, eliminating the need for manual INP file regeneration.
Current System Analysis
What EXISTS Today
1. Database Update Capabilities (API Layer) ✅
The following controllers successfully update the database:
| Controller | Updates | Status |
|---|---|---|
PipeController.cs | Diameter, Length, Roughness, MinorLoss, KWall (LinkProperty) | ✅ Implemented |
PumpController.cs | All pump properties via LinkProperty table | ✅ Implemented |
ValveController.cs | Diameter, ValveType, InitSetting, MinorLoss, InitStatus | ✅ Implemented |
TankController.cs | Elevation, levels, InitQual, BulkCoeff (NodeProperty), MixingModel | ✅ Implemented |
JunctionController.cs | Elevation, BaseDemand, Pattern | ✅ Implemented |
ReservoirController.cs | InitialHead, Pattern | ✅ Implemented |
PatternController.cs | Pattern multipliers | ✅ Implemented |
DataCurveController.cs | Curve X,Y points | ✅ Implemented |
OptionsController.cs | Simulation options (units, headloss, demand model, etc.) | ✅ Implemented |
TimeParametersController.cs | Duration, timesteps, report timing | ✅ Implemented |
SimpleControlsController.cs | Simple control rules | ✅ Implemented |
RuleBasedControlsController.cs | Rule-based controls | ✅ Implemented |
Example from PipeController:
[HttpPut("{id}")]
public async Task<ActionResult<PipeResponseDto>> UpdatePipe(string id, UpdatePipeRequest request)
{
var pipe = await _context.Pipes.FindAsync(id);
if (request.Diameter.HasValue) pipe.Diameter = request.Diameter.Value;
if (request.KWall.HasValue) await UpsertLinkProperty(id, 7, request.KWall.Value); // EN_KWALL
await _context.SaveChangesAsync();
return Ok(result);
}
2. EPANET Setter Functions (EpanetWrapper) ✅
The EpanetWrapper class provides setter methods:
| Method | Purpose | EPANET Function |
|---|---|---|
EN_setlinkvalue(index, param, val) | Set link property | ✅ Available |
EN_setnodevalue(index, param, val) | Set node property | ✅ Available |
EN_setdemandmodel(type, pmin, preq, pexp) | Set demand model | ✅ Available |
EN_addcontrol(...) | Add simple control | ✅ Available |
EN_addrule(ruleText) | Add rule-based control | ✅ Available |
EN_setpattern(index, values) | Set pattern multipliers | ⚠️ Needs wrapper |
EN_setcurve(index, xvals, yvals) | Set curve data | ⚠️ Needs wrapper |
EN_setoption(option, value) | Set simulation option | ⚠️ Needs wrapper |
EN_settimeparam(param, value) | Set time parameter | ⚠️ Needs wrapper |
SetValveStatus(id, open) | Set valve status | ✅ Available |
SetTankDiameter(id, value) | Set tank diameter | ✅ Available |
SetLinkLeakArea(index, value) | Set leak area | ✅ Available |
SetLinkLeakExpansion(index, value) | Set leak expansion | ✅ Available |
Example usage:
// From Program.cs - WriteDemandModel()
epanetWrapper.EN_setdemandmodel(demandModelType, pressureMin, pressureReq, pressureExp);
// From Program.cs - WriteControls()
epanetWrapper.EN_addcontrol(type, linkIndex, setting, nodeIndex, level, ref controlIndex);
3. Partial Database → EPANET Flow ⚠️
Currently implemented in Program.cs before simulation:
// Line 299-300 in Program.cs
await WriteControls(epanetWrapper, enContext, scenarioId); // ✅ Writes controls from DB
WriteDemandModel(epanetWrapper, options); // ✅ Writes demand model from DB
What's Written:
- ✅ SimpleControls (from database)
- ✅ RuleBasedControls (from database)
- ✅ Demand model settings (from Options)
What's NOT Written:
- ❌ Pipe properties (diameter, roughness, length, KWall)
- ❌ Pump properties (curves, patterns, energy cost, status, setting)
- ❌ Valve properties (diameter, type, setting, status)
- ❌ Tank properties (levels, diameter, InitQual, BulkCoeff)
- ❌ Junction properties (elevation, demand, pattern)
- ❌ Reservoir properties (head, pattern)
- ❌ Pattern multipliers
- ❌ Curve data points
- ❌ Options (units, headloss formula, etc.)
- ❌ Time parameters (duration, timesteps)
What's MISSING
Critical Gap: No Pre-Simulation Update Mechanism
Current Flow:
1. User edits pipe in database (via API) ✅
2. Database updated successfully ✅
3. User runs scenario
4. Scenario loads network from INP file ❌ (ignores database changes!)
5. Simulation runs with OLD data from INP ❌
Desired Flow:
1. User edits pipe in database (via API) ✅
2. Database updated successfully ✅
3. User runs scenario
4. Scenario loads network from INP file
5. **Apply database overrides to EPANET model** ← MISSING!
6. Simulation runs with CURRENT data from DB ✅
Problem Statement:
- Scenario.InputFile (line 187 in Program.cs) always points to a static
.inpfile epanetWrapper.Startup()loads this file, creating the initial network- Database changes are never applied to the in-memory EPANET model
- Result: Database edits have no effect on simulations
Design Solution
Architecture Overview
We need a Network Synchronization Service that applies database changes to EPANET after loading the INP file but before running the simulation.
┌─────────────────┐
│ Load INP File │ ← Existing: epanetWrapper.Startup()
└────────┬────────┘
│
▼
┌─────────────────────────────────────┐
│ NEW: NetworkSynchronizationService │ ← Proposed
│ │
│ • Queries database for overrides │
│ • Applies via EN_setlinkvalue() │
│ • Applies via EN_setnodevalue() │
│ • Updates patterns, curves, etc. │
└────────┬────────────────────────────┘
│
▼
┌─────────────────┐
│ Write Controls │ ← Existing: WriteControls()
└────────┬────────┘
│
▼
┌─────────────────┐
│ Write DemandModel│ ← Existing: WriteDemandModel()
└────────┬────────┘
│
▼
┌─────────────────┐
│ Run Simulation │ ← Existing: RunNextHQ()
└─────────────────┘
Implementation Strategy
Option A: Sync-on-Demand (Recommended)
Apply database changes every time a scenario runs, just after loading the INP file.
Pros:
- ✅ Always up-to-date with latest database state
- ✅ No need to track "dirty" flags
- ✅ Simple to implement
- ✅ Works for any scenario execution path
Cons:
- ⚠️ Slight performance overhead (query database, apply changes)
- ⚠️ May apply changes even if nothing changed
Implementation Location:
// In Program.cs, after line 232 (after Startup):
bool startupSuccess = epanetWrapper.Startup(...);
// NEW CODE:
await SyncNetworkFromDatabase(epanetWrapper, enContext);
await WriteControls(epanetWrapper, enContext, scenarioId);
WriteDemandModel(epanetWrapper, options);
Detailed Implementation Plan
Phase 1: Core Infrastructure (Week 1)
1.1 Create NetworkSynchronizationService
Location: epanetconsolecore/EpanetConsoleCore/Services/NetworkSynchronizationService.cs
public class NetworkSynchronizationService
{
private readonly EpanetWrapper _wrapper;
private readonly EN2PostgresContext _context;
private readonly HTLogger.HTLogger _logger;
public NetworkSynchronizationService(
EpanetWrapper wrapper,
EN2PostgresContext context,
HTLogger.HTLogger logger)
{
_wrapper = wrapper;
_context = context;
_logger = logger;
}
/// <summary>
/// Synchronizes all database changes to the EPANET in-memory model.
/// Called after loading INP file but before running simulation.
/// </summary>
public async Task SyncAllFromDatabaseAsync()
{
_logger.LogInformation("🔄 Synchronizing network from database...");
await SyncNodesAsync();
await SyncLinksAsync();
await SyncPatternsAsync();
await SyncCurvesAsync();
await SyncOptionsAsync();
await SyncTimeParametersAsync();
_logger.LogInformation("✅ Network synchronization complete");
}
// Individual sync methods implemented below...
}
1.2 Add Missing EpanetWrapper Methods
Location: epanetconsolecore/EpanetWrapper/EpanetWrapper/EpanetWrapper.cs
// Pattern methods
public int EN_setpattern(int index, double[] factors, int numFactors)
{
return EN.EN_setpattern(this.Project, index, factors, numFactors);
}
public int EN_setpatternvalue(int index, int period, double value)
{
return EN.EN_setpatternvalue(this.Project, index, period, value);
}
// Curve methods
public int EN_setcurve(int index, double[] xValues, double[] yValues, int numPoints)
{
return EN.EN_setcurve(this.Project, index, xValues, yValues, numPoints);
}
public int EN_setcurvevalue(int curveIndex, int pointIndex, double x, double y)
{
return EN.EN_setcurvevalue(this.Project, curveIndex, pointIndex, x, y);
}
// Option methods
public int EN_setoption(int option, double value)
{
return EN.EN_setoption(this.Project, option, value);
}
// Time parameter methods
public int EN_settimeparam(int param, long value)
{
return EN.EN_settimeparam(this.Project, param, value);
}
Phase 2: Node Synchronization (Week 1-2)
2.1 Junction Sync
private async Task SyncJunctionsAsync()
{
var junctions = await _context.Junctions.ToListAsync();
foreach (var junction in junctions)
{
int index = _wrapper.GetNodeIndex(junction.Id);
if (index <= 0)
{
_logger.LogWarning($"Junction {junction.Id} not found in EPANET model");
continue;
}
// Update elevation
if (junction.Elevation.HasValue)
{
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_ELEVATION, junction.Elevation.Value);
}
// Update base demand
if (junction.BaseDemand > 0)
{
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_BASEDEMAND, junction.BaseDemand);
}
// Update pattern
if (junction.Pattern > 0)
{
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_PATTERN, junction.Pattern);
}
}
_logger.LogInformation($"Synced {junctions.Count} junctions");
}
2.2 Tank Sync
private async Task SyncTanksAsync()
{
var tanks = await _context.Tanks.ToListAsync();
foreach (var tank in tanks)
{
int index = _wrapper.GetNodeIndex(tank.Id);
if (index <= 0) continue;
// Core properties
if (tank.Elevation.HasValue)
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_ELEVATION, tank.Elevation.Value);
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_TANKDIAM, tank.Diameter);
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_MINLEVEL, tank.MinLevel);
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_MAXLEVEL, tank.MaxLevel);
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_MINVOLUME, tank.MinVolume);
if (tank.VolCurve > 0)
_wrapper.EN_setnodevalue(index, (int)EN_NodeProperty.EN_VOLCURVE, tank.VolCurve);
}
// Sync NodeProperties for tanks (InitQual, BulkCoeff)
await SyncNodePropertiesAsync();
// Sync TankMixing
await SyncTankMixingAsync();
_logger.LogInformation($"Synced {tanks.Count} tanks");
}
2.3 NodeProperty Sync (Generic)
private async Task SyncNodePropertiesAsync()
{
var nodeProperties = await _context.NodeProperties.ToListAsync();
foreach (var prop in nodeProperties)
{
int index = _wrapper.GetNodeIndex(prop.NodeId);
if (index <= 0) continue;
_wrapper.EN_setnodevalue(index, prop.PropertyId, prop.PropertyValue);
}
_logger.LogInformation($"Synced {nodeProperties.Count} node properties");
}
Phase 3: Link Synchronization (Week 2)
3.1 Pipe Sync
private async Task SyncPipesAsync()
{
var pipes = await _context.Pipes.ToListAsync();
foreach (var pipe in pipes)
{
int index = _wrapper.GetLinkIndex(pipe.Id);
if (index <= 0) continue;
// Core pipe properties from Pipe table
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_DIAMETER, pipe.Diameter);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_LENGTH, pipe.Length);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_ROUGHNESS, pipe.Roughness);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_MINORLOSS, pipe.MinorLoss);
}
_logger.LogInformation($"Synced {pipes.Count} pipes");
}
3.2 Pump Sync
private async Task SyncPumpsAsync()
{
// Pumps have NO core properties (after model redesign)
// All properties are in LinkProperty table
// Will be handled by SyncLinkPropertiesAsync()
_logger.LogInformation("Pumps synced via LinkProperties");
}
3.3 Valve Sync
private async Task SyncValvesAsync()
{
var valves = await _context.Valves.ToListAsync();
foreach (var valve in valves)
{
int index = _wrapper.GetLinkIndex(valve.Id);
if (index <= 0) continue;
// Core valve properties
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_DIAMETER, valve.Diameter);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_VALVE_TYPE, valve.ValveType);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_INITSETTING, valve.InitSetting);
_wrapper.EN_setlinkvalue(index, (int)EN_LinkProperty.EN_MINORLOSS, valve.MinorLoss);
}
_logger.LogInformation($"Synced {valves.Count} valves");
}
3.4 LinkProperty Sync (Generic)
private async Task SyncLinkPropertiesAsync()
{
var linkProperties = await _context.LinkProperties.ToListAsync();
foreach (var prop in linkProperties)
{
int index = _wrapper.GetLinkIndex(prop.LinkId);
if (index <= 0) continue;
_wrapper.EN_setlinkvalue(index, prop.PropertyId, prop.PropertyValue);
}
_logger.LogInformation($"Synced {linkProperties.Count} link properties");
}
Phase 4: Pattern & Curve Synchronization (Week 2-3)
4.1 Pattern Sync
private async Task SyncPatternsAsync()
{
var patterns = await _context.Patterns.ToListAsync();
foreach (var pattern in patterns)
{
int index = _wrapper.GetPatternIndex(pattern.Id);
if (index <= 0)
{
// Pattern doesn't exist - add it
index = await AddPatternAsync(pattern);
}
if (index > 0 && pattern.Multipliers != null && pattern.Multipliers.Length > 0)
{
// Update pattern values
int error = _wrapper.EN_setpattern(index, pattern.Multipliers, pattern.Multipliers.Length);
if (error != 0)
{
_logger.LogError($"Failed to set pattern {pattern.Id}: EPANET error {error}");
}
}
}
_logger.LogInformation($"Synced {patterns.Count} patterns");
}
private async Task<int> AddPatternAsync(Pattern pattern)
{
// Note: EPANET 2.2 doesn't have EN_addpattern in the basic toolkit
// May need to use EN_setpattern with a new index
// Or regenerate INP file for new patterns
_logger.LogWarning($"Cannot add new pattern {pattern.Id} - not in original INP");
return -1;
}
4.2 Curve Sync
private async Task SyncCurvesAsync()
{
var curves = await _context.DataCurves
.OrderBy(c => c.Id)
.ThenBy(c => c.SequenceNumber)
.ToListAsync();
var groupedCurves = curves.GroupBy(c => c.Id);
foreach (var curveGroup in groupedCurves)
{
string curveId = curveGroup.Key;
int index = _wrapper.GetCurveIndex(curveId);
if (index <= 0)
{
_logger.LogWarning($"Curve {curveId} not found in EPANET model");
continue;
}
var points = curveGroup.OrderBy(c => c.SequenceNumber).ToList();
double[] xValues = points.Select(p => p.XValue).ToArray();
double[] yValues = points.Select(p => p.YValue).ToArray();
int error = _wrapper.EN_setcurve(index, xValues, yValues, points.Count);
if (error != 0)
{
_logger.LogError($"Failed to set curve {curveId}: EPANET error {error}");
}
}
_logger.LogInformation($"Synced {groupedCurves.Count()} curves");
}
Phase 5: Options & Time Parameters (Week 3)
5.1 Options Sync
private async Task SyncOptionsAsync()
{
var options = await _context.Options.FirstOrDefaultAsync(o => o.IsDefault);
if (options == null)
{
_logger.LogWarning("No default options found, skipping options sync");
return;
}
// Units (note: units often can't be changed after EN_open)
if (!string.IsNullOrEmpty(options.Units))
{
var unitsEnum = MapUnitsStringToEnum(options.Units);
// Note: Some options can only be set before EN_open(), not after
// May need to handle these differently
}
// Headloss formula
if (!string.IsNullOrEmpty(options.Headloss))
{
var headlossEnum = MapHeadlossStringToEnum(options.Headloss);
_wrapper.EN_setoption((int)EN_Option.EN_HEADLOSSFORM, headlossEnum);
}
// Hydraulic parameters
if (options.Trials.HasValue)
_wrapper.EN_setoption((int)EN_Option.EN_TRIALS, options.Trials.Value);
if (options.Accuracy.HasValue)
_wrapper.EN_setoption((int)EN_Option.EN_ACCURACY, options.Accuracy.Value);
// Quality tolerance
if (options.QualityTolerance.HasValue)
_wrapper.EN_setoption((int)EN_Option.EN_TOLERANCE, options.QualityTolerance.Value);
_logger.LogInformation("Synced simulation options");
}
5.2 Time Parameters Sync
private async Task SyncTimeParametersAsync()
{
var timeParams = await _context.TimeParameters.FirstOrDefaultAsync(tp => tp.IsDefault);
if (timeParams == null)
{
_logger.LogWarning("No default time parameters found, skipping time sync");
return;
}
// Duration
if (timeParams.Duration.HasValue)
_wrapper.EN_settimeparam((int)EN_TimeParameter.EN_DURATION, timeParams.Duration.Value);
// Hydraulic timestep
if (timeParams.HydraulicTimestep.HasValue)
_wrapper.EN_settimeparam((int)EN_TimeParameter.EN_HYDSTEP, timeParams.HydraulicTimestep.Value);
// Quality timestep
if (timeParams.QualityTimestep.HasValue)
_wrapper.EN_settimeparam((int)EN_TimeParameter.EN_QUALSTEP, timeParams.QualityTimestep.Value);
// Report timestep
if (timeParams.ReportTimestep.HasValue)
_wrapper.EN_settimeparam((int)EN_TimeParameter.EN_REPORTSTEP, timeParams.ReportTimestep.Value);
// Report start
if (timeParams.ReportStart.HasValue)
_wrapper.EN_settimeparam((int)EN_TimeParameter.EN_REPORTSTART, timeParams.ReportStart.Value);
_logger.LogInformation("Synced time parameters");
}
Phase 6: Integration with Program.cs (Week 3)
6.1 Modify Program.cs
Location: Line 232 in epanetconsolecore/EpanetConsoleCore/Program.cs
bool startupSuccess = epanetWrapper.Startup(doQuality, msxFile, out int epanetErrorCode, out string epanetErrorMessage);
if (!startupSuccess)
{
// ... existing error handling ...
}
// ✨ NEW CODE: Synchronize network from database
scenario.CurrentStep = "Synchronizing network from database";
scenario.ProgressPercentage = 7;
enContext.Entry(scenario).State = EntityState.Modified;
await enContext.SaveChangesAsync();
var syncService = new NetworkSynchronizationService(epanetWrapper, enContext, logger);
await syncService.SyncAllFromDatabaseAsync();
// Continue with existing code
reportTimestep = epanetWrapper.GetReportingTimestep();
// ...
Property-by-Property Mapping
Node Properties
| Database Source | PropertyId | EN_NodeProperty | Setter Method |
|---|---|---|---|
| Junction.Elevation | - | EN_ELEVATION (0) | EN_setnodevalue() |
| Junction.BaseDemand | - | EN_BASEDEMAND (1) | EN_setnodevalue() |
| Junction.Pattern | - | EN_PATTERN (2) | EN_setnodevalue() |
| Reservoir.InitialHead | - | EN_ELEVATION (0) | EN_setnodevalue() |
| Reservoir.Pattern | - | EN_PATTERN (2) | EN_setnodevalue() |
| Tank.Elevation | - | EN_ELEVATION (0) | EN_setnodevalue() |
| Tank.MinLevel | - | EN_MINLEVEL (20) | EN_setnodevalue() |
| Tank.MaxLevel | - | EN_MAXLEVEL (21) | EN_setnodevalue() |
| Tank.Diameter | - | EN_TANKDIAM (17) | EN_setnodevalue() |
| Tank.MinVolume | - | EN_MINVOLUME (18) | EN_setnodevalue() |
| Tank.VolCurve | - | EN_VOLCURVE (19) | EN_setnodevalue() |
| NodeProperty.PropertyValue | PropertyId | (varies) | EN_setnodevalue() |
| Emitter (via NodeProperty) | 3 | EN_EMITTER (3) | EN_setnodevalue() |
| InitQual (via NodeProperty) | 4 | EN_INITQUAL (4) | EN_setnodevalue() |
| TankBulkCoeff (via NodeProperty) | 23 | EN_TANK_KBULK (23) | EN_setnodevalue() |
| TankMixing.MixingModel | - | EN_MIXMODEL (15) | EN_setnodevalue() |
| TankMixing.Fraction | - | EN_MIXFRACTION (22) | EN_setnodevalue() |
Link Properties
| Database Source | PropertyId | EN_LinkProperty | Setter Method |
|---|---|---|---|
| Pipe.Diameter | - | EN_DIAMETER (0) | EN_setlinkvalue() |
| Pipe.Length | - | EN_LENGTH (1) | EN_setlinkvalue() |
| Pipe.Roughness | - | EN_ROUGHNESS (2) | EN_setlinkvalue() |
| Pipe.MinorLoss | - | EN_MINORLOSS (3) | EN_setlinkvalue() |
| Valve.Diameter | - | EN_DIAMETER (0) | EN_setlinkvalue() |
| Valve.ValveType | - | EN_VALVE_TYPE (29) | EN_setlinkvalue() |
| Valve.InitSetting | - | EN_INITSETTING (5) | EN_setlinkvalue() |
| Valve.MinorLoss | - | EN_MINORLOSS (3) | EN_setlinkvalue() |
| LinkProperty.PropertyValue | PropertyId | (varies) | EN_setlinkvalue() |
| KWall (via LinkProperty) | 7 | EN_KWALL (7) | EN_setlinkvalue() |
| InitStatus (via LinkProperty) | 4 | EN_INITSTATUS (4) | EN_setlinkvalue() |
| InitSetting (via LinkProperty) | 5 | EN_INITSETTING (5) | EN_setlinkvalue() |
| Pattern (via LinkProperty) | 15 | EN_LINKPATTERN (15) | EN_setlinkvalue() |
| PumpHCurve (via LinkProperty) | 19 | EN_PUMP_HCURVE (19) | EN_setlinkvalue() |
| PumpECurve (via LinkProperty) | 20 | EN_PUMP_ECURVE (20) | EN_setlinkvalue() |
| PumpECost (via LinkProperty) | 21 | EN_PUMP_ECOST (21) | EN_setlinkvalue() |
| PumpEPattern (via LinkProperty) | 22 | EN_PUMP_EPAT (22) | EN_setlinkvalue() |
| LeakArea (via LinkProperty) | 26 | EN_LEAK_AREA (26) | EN_setlinkvalue() |
| LeakExpansion (via LinkProperty) | 27 | EN_LEAK_EXPAN (27) | EN_setlinkvalue() |
Pattern & Curve Data
| Database Source | EPANET Function | Notes |
|---|---|---|
| Pattern.Multipliers | EN_setpattern() | Set all multipliers at once |
| DataCurve.XValue, YValue | EN_setcurve() | Set all X,Y points at once |
Options & Time Parameters
| Database Source | EN_Option | Setter Method |
|---|---|---|
| Options.Headloss | EN_HEADLOSSFORM | EN_setoption() |
| Options.Trials | EN_TRIALS | EN_setoption() |
| Options.Accuracy | EN_ACCURACY | EN_setoption() |
| Options.QualityTolerance | EN_TOLERANCE | EN_setoption() |
| TimeParameters.Duration | EN_DURATION | EN_settimeparam() |
| TimeParameters.HydraulicTimestep | EN_HYDSTEP | EN_settimeparam() |
| TimeParameters.QualityTimestep | EN_QUALSTEP | EN_settimeparam() |
| TimeParameters.ReportTimestep | EN_REPORTSTEP | EN_settimeparam() |
Error Handling Strategy
Validation Before Applying
private bool ValidateNodeProperty(string nodeId, int propertyId, double value)
{
// Validate ranges
switch ((EN_NodeProperty)propertyId)
{
case EN_NodeProperty.EN_ELEVATION:
if (value < -1000 || value > 10000)
{
_logger.LogWarning($"Node {nodeId}: Elevation {value} out of range");
return false;
}
break;
case EN_NodeProperty.EN_TANKDIAM:
if (value <= 0)
{
_logger.LogError($"Tank {nodeId}: Diameter must be positive");
return false;
}
break;
// ... other validations ...
}
return true;
}
EPANET Error Code Handling
private void ApplyLinkProperty(string linkId, int propertyId, double value)
{
int index = _wrapper.GetLinkIndex(linkId);
if (index <= 0)
{
_logger.LogWarning($"Link {linkId} not found in EPANET model");
return;
}
int error = _wrapper.EN_setlinkvalue(index, propertyId, value);
if (error != 0)
{
string errorMsg = GetEpanetErrorMessage(error);
_logger.LogError($"Failed to set {linkId} property {propertyId} to {value}: {errorMsg}");
// Decision: Continue or abort?
// Recommendation: Log error but continue (partial sync better than no sync)
}
}
Transactional Behavior
Question: Should we rollback all changes if one fails?
Recommendation: No
- EPANET doesn't support transactions
- Partial sync is better than no sync
- Log all errors and continue
- Report summary at end (X properties updated, Y failed)
Performance Considerations
The Core Trade-Off
Short-term Strategy (Recommended for MVP): Sync ALL assets every time
- ✅ Simple implementation
- ✅ Reliable (always consistent)
- ✅ No complex change tracking needed
- ⚠️ May sync thousands of unchanged values
- ⚠️ Overhead scales linearly with network size
Long-term Strategy (Future Optimization): Only sync what changed
- ✅ Much faster for large networks
- ✅ Minimal overhead when few changes
- ⚠️ Complex implementation (change tracking, dirty flags)
- ⚠️ Risk of missed updates if tracking fails
Why We Should Start with "Sync Everything"
1. Correctness Over Performance
Problem: The system currently has NO sync mechanism. Database changes are completely ignored.
Priority: Getting a working sync mechanism is more valuable than an optimized one.
Analogy: Better to have a slow car than no car at all. We can optimize later.
2. Current Architecture Constraints
Core Properties Not in Property Tables:
// When user edits pipe diameter via API:
pipe.Diameter = 15.0; // Updates Pipe table
await _context.SaveChangesAsync();
// LinkProperty table is NOT updated!
// So we MUST sync from Pipe table, not just LinkProperty
Implication: We need to sync BOTH:
- Core asset tables (Pipe, Tank, Junction, etc.)
- Property tables (LinkProperty, NodeProperty)
3. Unknown Performance Impact
We don't yet know if this is actually a problem:
- How many scenarios run per day?
- What's the typical network size?
- What's acceptable overhead (5 seconds? 30 seconds? 2 minutes?)
Recommendation: Implement simple version, measure real-world performance, optimize if needed.
Estimated Performance Impact
Theoretical Analysis
Per-Asset Overhead:
- Database read: ~0.1-0.5 ms
- EPANET setter call: ~0.05-0.1 ms
- Total per asset: ~0.15-0.6 ms
Network Size Estimates:
| Network Size | Nodes | Links | Total Assets | Sync Time (Worst Case) |
|---|---|---|---|---|
| Small | 100 | 120 | 220 | ~0.13 seconds |
| Medium | 1,000 | 1,200 | 2,200 | ~1.3 seconds |
| Large | 5,000 | 6,000 | 11,000 | ~6.6 seconds |
| Very Large | 10,000 | 12,000 | 22,000 | ~13 seconds |
| Extreme | 50,000 | 60,000 | 110,000 | ~66 seconds |
Comparison to Simulation Time:
- Small network hydraulic simulation: 1-5 seconds
- Large network hydraulic simulation: 30-120 seconds
- Sync overhead: 5-20% of simulation time
Context: If a simulation takes 60 seconds, adding 6 seconds for sync (10% overhead) is likely acceptable.
Real-World Factors
Factors that REDUCE overhead:
- Database query optimization (indexes, prepared statements)
- Batch reads (SELECT all pipes in one query vs. 10,000 individual queries)
- EPANET setter efficiency (C code is fast)
Factors that INCREASE overhead:
- Network latency to database
- Database load (concurrent users)
- Complex entity relationships (joins)
Optimization Strategies (Future Enhancements)
Strategy 1: Change Tracking with Timestamps
Implementation:
// Add ModifiedAt to ALL tables
public class Pipe : Link
{
// ... existing properties ...
public DateTime? ModifiedAt { get; set; }
}
// Track last sync time per scenario
public class Scenario
{
// ... existing properties ...
public DateTime? LastSyncAt { get; set; }
}
// Only sync changed entities
private async Task SyncPipesAsync()
{
var changedPipes = await _context.Pipes
.Where(p => !scenario.LastSyncAt.HasValue || p.ModifiedAt > scenario.LastSyncAt)
.ToListAsync();
// Update LastSyncAt after successful sync
scenario.LastSyncAt = DateTime.UtcNow;
}
Pros:
- ✅ Simple to understand
- ✅ Works with existing architecture
- ✅ Dramatic performance improvement (only sync 1-10% of entities typically)
Cons:
- ⚠️ Requires migration to add ModifiedAt columns
- ⚠️ Must update ModifiedAt in ALL update code paths
- ⚠️ Risk of missing updates if ModifiedAt not set correctly
Estimated Impact: 90-95% reduction in sync time for typical usage
Strategy 2: Dirty Flag Pattern
Implementation:
// New table to track changes
public class DirtyEntity
{
public int Id { get; set; }
public string EntityType { get; set; } // "Pipe", "Junction", etc.
public string EntityId { get; set; } // "P-123"
public DateTime DirtiedAt { get; set; }
}
// Mark dirty on update (in API controllers)
[HttpPut("{id}")]
public async Task<ActionResult> UpdatePipe(string id, UpdatePipeRequest request)
{
var pipe = await _context.Pipes.FindAsync(id);
pipe.Diameter = request.Diameter.Value;
// Mark as dirty
_context.DirtyEntities.Add(new DirtyEntity
{
EntityType = "Pipe",
EntityId = id,
DirtiedAt = DateTime.UtcNow
});
await _context.SaveChangesAsync();
}
// Sync only dirty entities
private async Task SyncPipesAsync()
{
var dirtyPipeIds = await _context.DirtyEntities
.Where(de => de.EntityType == "Pipe")
.Select(de => de.EntityId)
.ToListAsync();
var pipes = await _context.Pipes
.Where(p => dirtyPipeIds.Contains(p.Id))
.ToListAsync();
// Clear dirty flags after sync
await _context.DirtyEntities
.Where(de => de.EntityType == "Pipe")
.ExecuteDeleteAsync();
}
Pros:
- ✅ Very precise (only syncs actually changed entities)
- ✅ Can track who made change, when, etc.
- ✅ Easy to audit what changed
Cons:
- ⚠️ More complex (new table, more code)
- ⚠️ Must update dirty flags in EVERY update path
- ⚠️ Orphaned dirty flags if updates fail
Estimated Impact: 95-99% reduction in sync time
Strategy 3: Database-Driven Diff
Implementation:
// Store snapshot of last synced state
public class ScenarioSnapshot
{
public int ScenarioId { get; set; }
public string EntityType { get; set; }
public string EntityId { get; set; }
public string PropertyName { get; set; }
public string PropertyValue { get; set; } // JSON serialized
public DateTime SnapshotAt { get; set; }
}
// Compute diff
private async Task<List<Pipe>> GetChangedPipesAsync()
{
var currentPipes = await _context.Pipes.ToListAsync();
var snapshot = await _context.ScenarioSnapshots
.Where(s => s.ScenarioId == scenarioId && s.EntityType == "Pipe")
.ToListAsync();
// Compare and find differences
var changedPipes = currentPipes.Where(p =>
{
var snapshotValues = snapshot.FirstOrDefault(s => s.EntityId == p.Id);
return snapshotValues == null || HasChanged(p, snapshotValues);
}).ToList();
return changedPipes;
}
Pros:
- ✅ Works even if ModifiedAt/dirty flags fail
- ✅ Can detect changes from external sources (direct DB edits)
- ✅ Provides audit trail
Cons:
- ⚠️ Very complex
- ⚠️ High storage overhead (duplicate of all data)
- ⚠️ Slower (must compute diff every time)
Not Recommended - too complex for marginal benefit
Strategy 4: Query Optimization (No Architecture Change)
Implementation:
// Instead of loading all entities and filtering in memory
var allPipes = await _context.Pipes.ToListAsync(); // BAD
foreach (var pipe in allPipes)
{
// Process...
}
// Use efficient query projections
var pipeData = await _context.Pipes
.Select(p => new { p.Id, p.Diameter, p.Length, p.Roughness }) // GOOD
.ToListAsync();
// Or use AsNoTracking for read-only
var pipes = await _context.Pipes
.AsNoTracking() // Don't track changes
.ToListAsync();
Benefits:
- ✅ Easy to implement (just add .AsNoTracking())
- ✅ 20-30% performance improvement
- ✅ No architecture changes
Recommendation: Do this FIRST, before more complex optimizations
Recommended Phased Approach
Phase 1: Baseline Implementation (MVP - Now)
// Sync everything, no optimizations
public async Task SyncAllFromDatabaseAsync()
{
await SyncNodesAsync(); // All junctions, tanks, reservoirs
await SyncLinksAsync(); // All pipes, pumps, valves
// ...
}
When to use: Initial implementation, networks < 5,000 assets
Advantages:
- Simple, reliable, maintainable
- Fast enough for most networks (< 10 second overhead)
- Provides baseline for performance metrics
Phase 2: Basic Optimization (3-6 months later)
// Add AsNoTracking, use efficient queries
var pipes = await _context.Pipes
.AsNoTracking()
.Select(p => new { p.Id, p.Diameter, p.Length, p.Roughness, p.MinorLoss })
.ToListAsync();
Trigger: When overhead > 20% of simulation time OR user complaints
Effort: 1-2 days
Benefit: 20-30% reduction
Phase 3: Change Tracking (6-12 months later)
// Add ModifiedAt timestamps
var changedPipes = await _context.Pipes
.AsNoTracking()
.Where(p => !scenario.LastSyncAt.HasValue || p.ModifiedAt > scenario.LastSyncAt)
.ToListAsync();
Trigger: Networks > 10,000 assets OR overhead > 30 seconds
Effort: 1-2 weeks (migration, update all update paths)
Benefit: 90-95% reduction in typical scenarios
Phase 4: Advanced Optimization (12+ months later)
Only if needed for extreme networks (50,000+ assets):
- Dirty flag pattern
- Parallel processing
- Cached snapshots
Performance Monitoring
Metrics to Track:
public class SyncMetrics
{
public int NodesProcessed { get; set; }
public int LinksProcessed { get; set; }
public int PatternsProcessed { get; set; }
public int CurvesProcessed { get; set; }
public TimeSpan TotalSyncTime { get; set; }
public TimeSpan DatabaseQueryTime { get; set; }
public TimeSpan EpanetUpdateTime { get; set; }
public int ErrorCount { get; set; }
}
// Log metrics after each sync
_logger.LogInformation($"Sync completed: {metrics.NodesProcessed + metrics.LinksProcessed} assets in {metrics.TotalSyncTime.TotalSeconds:F2}s");
Alert Thresholds:
- ⚠️ Sync time > 30 seconds
- 🔴 Sync time > 60 seconds
- 🔴 Sync time > 20% of simulation time
Dashboard Metrics:
- Average sync time per scenario
- 95th percentile sync time
- Sync time vs. network size correlation
- Error rate
Decision Matrix: When to Optimize
| Network Size | Asset Count | Current Overhead | Action |
|---|---|---|---|
| Small | < 500 | < 1 second | ✅ No optimization needed |
| Medium | 500-5,000 | 1-10 seconds | ✅ Monitor, optimize if complaints |
| Large | 5,000-20,000 | 10-30 seconds | ⚠️ Consider Phase 2 optimization |
| Very Large | 20,000-50,000 | 30-60 seconds | 🔴 Implement Phase 3 (change tracking) |
| Extreme | > 50,000 | > 60 seconds | 🔴 Full optimization (Phase 3+4) |
Alternative: Scenario-Specific INP Generation
Instead of syncing in-memory, generate custom INP file per scenario:
// Generate INP from database
var inpGenerator = new InpFileGenerator(_context);
string customInp = await inpGenerator.GenerateForScenarioAsync(scenarioId);
// Load custom INP
epanetWrapper = new EpanetWrapper(customInp, rpt, out);
Pros:
- ✅ No sync overhead (standard EPANET workflow)
- ✅ INP file represents exact database state
- ✅ Can version control generated files
Cons:
- ⚠️ Requires complete INP writer (complex)
- ⚠️ File I/O overhead
- ⚠️ Temporary file management
When to consider: If sync overhead becomes unacceptable even with optimization
Conclusion
Recommended Strategy:
- Start simple - Sync everything (MVP implementation)
- Measure performance - Track metrics for 3-6 months
- Optimize if needed - Based on real-world data, not speculation
- Use phased approach - Easy optimizations first (AsNoTracking), complex ones only if necessary
Key Insight: Most networks are small-to-medium (<5,000 assets). For these, sync overhead is negligible (< 5 seconds). Only optimize when data shows it's actually a problem.
Performance is not the bottleneck today - lack of functionality is. Get the feature working, then optimize based on real usage patterns.
Testing Strategy
Unit Tests
[Test]
public async Task SyncPipes_UpdatesDiameter_Success()
{
// Arrange
var pipe = new Pipe { Id = "P1", Diameter = 12.5 };
_context.Pipes.Add(pipe);
await _context.SaveChangesAsync();
// Act
var service = new NetworkSynchronizationService(_wrapper, _context, _logger);
await service.SyncPipesAsync();
// Assert
double diameter = _wrapper.GetLinkProperty(EN_LinkProperty.EN_DIAMETER, _wrapper.GetLinkIndex("P1"));
Assert.AreEqual(12.5, diameter, 0.01);
}
Integration Tests
[Test]
public async Task FullSync_AllProperties_Success()
{
// Arrange: Load test INP file
_wrapper.Startup(...);
// Modify database
var pipe = await _context.Pipes.FindAsync("P1");
pipe.Diameter = 15.0;
await _context.SaveChangesAsync();
// Act: Full sync
var service = new NetworkSynchronizationService(_wrapper, _context, _logger);
await service.SyncAllFromDatabaseAsync();
// Assert: Verify change applied
double diameter = _wrapper.GetLinkProperty(EN_LinkProperty.EN_DIAMETER, _wrapper.GetLinkIndex("P1"));
Assert.AreEqual(15.0, diameter, 0.01);
}
Validation Tests
Test that simulations produce different results after database changes:
[Test]
public async Task Simulation_UsesUpdatedDiameter_DifferentResults()
{
// Run 1: Original diameter
var results1 = await RunSimulation();
// Modify diameter in database
var pipe = await _context.Pipes.FindAsync("P1");
pipe.Diameter = 8.0; // Smaller diameter
await _context.SaveChangesAsync();
// Run 2: With sync
await SyncNetworkFromDatabase();
var results2 = await RunSimulation();
// Assert: Velocities should be different (smaller pipe = higher velocity)
Assert.AreNotEqual(results1.PipeVelocity, results2.PipeVelocity);
Assert.Greater(results2.PipeVelocity, results1.PipeVelocity);
}
Known Limitations
1. Immutable Properties
Some EPANET properties cannot be changed after EN_open():
- ❌ Flow units (must regenerate INP)
- ❌ Node/Link topology (adding/removing elements)
- ❌ Node/Link IDs (renaming)
Workaround: These require full INP regeneration.
2. Adding New Elements
EPANET 2.2 has limited support for adding nodes/links after opening:
- ⚠️
EN_addnode()- Available in EPANET 2.2 - ⚠️
EN_addlink()- Available in EPANET 2.2 - ⚠️
EN_addpattern()- NOT available in EPANET 2.2 - ⚠️
EN_addcurve()- NOT available in EPANET 2.2
Workaround: Only sync existing elements; new elements require INP regeneration.
3. Control/Rule Ordering
Controls and rules are ORDER-DEPENDENT. If database changes their order, results may differ.
Mitigation: Store Priority or Sequence field to maintain order.
Future Enhancements
1. INP File Generation Service
Purpose: Generate complete .inp file from database
Use Cases:
- Adding new nodes/links
- Changing flow units
- Export/backup scenarios
Implementation: Separate service (not part of sync service)
2. Change Tracking
Purpose: Only sync changed properties
Implementation:
- Add
ModifiedAttimestamp to all tables - Track
LastSyncAtper scenario - Only sync entities where
ModifiedAt > LastSyncAt
Benefits:
- Faster sync for large networks
- Clear audit trail of changes
3. Dry-Run Mode
Purpose: Preview what would be changed without applying
Implementation:
public async Task<SyncReport> PreviewSyncAsync()
{
// Return list of changes without applying
// Useful for validation before running expensive simulations
}
4. Selective Sync
Purpose: Only sync specific entity types
Implementation:
public async Task SyncAsync(SyncOptions options)
{
if (options.SyncNodes) await SyncNodesAsync();
if (options.SyncLinks) await SyncLinksAsync();
if (options.SyncPatterns) await SyncPatternsAsync();
// ...
}
Migration Plan
Phase 1: Prototype (Week 1)
- ✅ Create
NetworkSynchronizationServiceskeleton - ✅ Implement pipe sync (simplest case)
- ✅ Add wrapper methods for EN_setpattern, EN_setcurve
- ✅ Test with small network
Phase 2: Core Entities (Week 2)
- ✅ Implement junction, tank, reservoir sync
- ✅ Implement valve, pump sync
- ✅ Implement LinkProperty/NodeProperty sync
- ✅ Integration testing
Phase 3: Advanced Features (Week 3)
- ✅ Implement pattern sync
- ✅ Implement curve sync
- ✅ Implement options sync
- ✅ Implement time parameters sync
- ✅ Error handling and logging
Phase 4: Integration & Testing (Week 4)
- ✅ Integrate with Program.cs
- ✅ End-to-end testing
- ✅ Performance testing
- ✅ Documentation
Phase 5: Production Deployment (Week 5)
- ✅ Code review
- ✅ Deploy to staging
- ✅ User acceptance testing
- ✅ Deploy to production
Success Criteria
Functional Requirements
- ✅ User edits pipe diameter in database via API
- ✅ User runs scenario
- ✅ Simulation reflects new diameter (different velocities/headloss)
- ✅ Results saved to database show updated behavior
Non-Functional Requirements
- ✅ Sync completes in < 5% of simulation time
- ✅ All EPANET errors logged and handled gracefully
- ✅ Partial sync failures don't crash simulation
- ✅ Clear logging of what was synced
Validation
- ✅ Unit tests: 90%+ code coverage
- ✅ Integration tests: All entity types
- ✅ E2E tests: Full scenario with database changes
- ✅ Performance tests: Large networks (10K+ elements)
Conclusion
This design provides a comprehensive strategy for synchronizing database changes to EPANET's in-memory model before simulations. The approach:
- Builds on existing infrastructure (API controllers, EpanetWrapper, Program.cs)
- Follows the model redesign (LinkProperty/NodeProperty tables)
- Handles all entity types (nodes, links, patterns, curves, options, time parameters)
- Provides clear migration path (incremental implementation)
- Includes proper error handling (validation, logging, graceful degradation)
Next Steps:
- Review and approve this design
- Begin Phase 1 implementation (prototype)
- Iterate based on testing feedback
Estimated Total Effort: 4-5 weeks for full implementation and testing