Summary
WNTRSimulator and EpanetSimulator produce different pump schedules for [CONTROLS] AT CLOCKTIME X rules (which parse to IF SYSTEM CLOCKTIME IS X and use TimeOfDayCondition with Comparison.eq). A pump scheduled to OPEN at 1 AM opens at sim_time = 7200 s (2 AM) under WNTRSimulator rather than at the expected sim_time = 3600 s (1 AM); a pump scheduled to CLOSE at 3 PM closes at sim_time = 21600 s (6 AM) rather than at sim_time = 54000 s. The same pattern shows up for other clock-time thresholds — the rule fires at a sim_time that depends on the threshold's value rather than at the wall-clock threshold.
EpanetSimulator handles the same controls correctly.
We've been working with WNTR and wanted to share this in case it's useful, and to offer a fix if you agree it's a bug.
Example
The Net3 model bundled under wntr.library schedules pump 10 with enumerated AT TIME triggers, so it doesn't exercise the TimeOfDayCondition path. The example below loads the bundled Net3, swaps pump 10's AT TIME block for the equivalent daily-recurring AT CLOCKTIME schedule (open at 1 AM, close at 3 PM) before parsing, and runs both backends.
import tempfile
from pathlib import Path
import wntr
# Read the bundled Net3 .inp and swap pump 10's enumerated AT TIME
# schedule for the equivalent daily-recurring AT CLOCKTIME schedule.
src_text = Path(wntr.library.ModelLibrary().get_filepath("Net3")).read_text()
old_block = """Link 10 OPEN AT TIME 1
Link 10 CLOSED AT TIME 15
Link 10 OPEN AT TIME 25
Link 10 CLOSED AT TIME 39
Link 10 OPEN AT TIME 49
Link 10 CLOSED AT TIME 63
Link 10 OPEN AT TIME 73
Link 10 CLOSED AT TIME 87
Link 10 OPEN AT TIME 97
Link 10 CLOSED AT TIME 111
Link 10 OPEN AT TIME 121
Link 10 CLOSED AT TIME 135
Link 10 OPEN AT TIME 145
Link 10 CLOSED AT TIME 159"""
new_block = """Link 10 OPEN AT CLOCKTIME 1:00:00 AM
Link 10 CLOSED AT CLOCKTIME 3:00:00 PM"""
modified_inp = Path(tempfile.mkdtemp()) / "Net3_clocktime.inp"
modified_inp.write_text(src_text.replace(old_block, new_block))
def run(klass):
wn = wntr.network.WaterNetworkModel(str(modified_inp))
wn.options.time.duration = 24 * 3600
wn.options.time.report_timestep = 3600
return klass(wn).run_sim().link["status"]["10"]
ep = run(wntr.sim.EpanetSimulator)
nr = run(wntr.sim.WNTRSimulator)
print(f'{"sim_time":>8} {"EPANET":>8} {"WNTRSim":>8}')
for t in nr.index:
e, n = int(ep.loc[t]), int(nr.loc[t])
flag = " <- differ" if e != n else ""
print(f"{int(t):>8} {e:>8} {n:>8}{flag}")
Output:
sim_time EPANET WNTRSim
0 0 0
3600 1 0 <- differ (expected 1 AM open; EPANET fires, WNTRSimulator does not)
7200 1 1 (WNTRSimulator opens here, one hour late)
10800 1 1
14400 1 1
18000 1 1
21600 1 0 <- differ (WNTRSimulator closes here, ~9h before the expected 3 PM)
25200 1 0 <- differ
28800 1 0 <- differ
32400 1 0 <- differ
36000 1 0 <- differ
39600 1 0 <- differ
43200 1 0 <- differ
46800 1 0 <- differ
50400 1 0 <- differ
54000 0 0 (EPANET closes here at 3 PM, as expected)
Environment
- Operating system: macOS (Darwin 25.4.0, arm64)
- Python version: 3.12.8
- WNTR version: 1.4.0
Additional context
Where we think the issue is
In wntr/network/controls.py, TimeOfDayCondition.evaluate():
def evaluate(self):
cur_time = self._model._shifted_time
prev_time = self._model._prev_shifted_time
day = np.floor(cur_time/86400)
if day < self._first_day:
self._backtrack = None
return False
if self._repeat:
cur_time = int(cur_time - self._threshold) % 86400
prev_time = int(prev_time - self._threshold) % 86400
else:
cur_time = cur_time - self._first_day * 86400.
prev_time = prev_time - self._first_day * 86400.
if self._relation is Comparison.eq and (prev_time < self._threshold and self._threshold <= cur_time):
self._backtrack = int(cur_time - self._threshold)
return True
...
In the _repeat branch the transformation (time - threshold) % 86400 produces "seconds elapsed since the most recent occurrence of the threshold time-of-day," which we believe is the intent. The eq comparison on the next line then compares that post-transform value against self._threshold itself (the absolute seconds-of-day). Because the two values are in different reference frames, the predicate ends up firing at a sim_time that depends on the threshold's value rather than at the wall-clock threshold.
Suggested fix
Comparing time-of-day to time-of-day directly seems to give the expected behavior. A focused change to the _repeat + eq case:
if self._repeat and self._relation is Comparison.eq:
cur_tod = self._model._shifted_time % 86400
prev_tod = self._model._prev_shifted_time % 86400
if cur_tod >= prev_tod:
fired = prev_tod < self._threshold <= cur_tod
else:
# Step spans midnight; threshold may sit on either side.
fired = prev_tod < self._threshold or self._threshold <= cur_tod
if fired:
self._backtrack = int((cur_tod - self._threshold) % 86400)
return True
self._backtrack = 0
return False
We applied this locally and re-ran the example above. With the change, WNTRSimulator and EpanetSimulator agree:
- pump status matches at every reporting time
- pressures and heads match within
- flows match
- demands match
We deliberately scoped the change to _repeat + eq because that's what we exercised. The gt / lt branches use the same post-transform values and a related comparison pattern, so they may benefit from a similar adjustment, but we haven't tested them.
Offer to contribute
If the maintainers agree this is a bug, we'd be glad to open a PR with the change above. Per the developer guide we can target either main or dev — please let us know which is appropriate.
Summary
WNTRSimulatorandEpanetSimulatorproduce different pump schedules for[CONTROLS] AT CLOCKTIME Xrules (which parse toIF SYSTEM CLOCKTIME IS Xand useTimeOfDayConditionwithComparison.eq). A pump scheduled to OPEN at 1 AM opens at sim_time = 7200 s (2 AM) underWNTRSimulatorrather than at the expected sim_time = 3600 s (1 AM); a pump scheduled to CLOSE at 3 PM closes at sim_time = 21600 s (6 AM) rather than at sim_time = 54000 s. The same pattern shows up for other clock-time thresholds — the rule fires at a sim_time that depends on the threshold's value rather than at the wall-clock threshold.EpanetSimulatorhandles the same controls correctly.We've been working with WNTR and wanted to share this in case it's useful, and to offer a fix if you agree it's a bug.
Example
The
Net3model bundled underwntr.libraryschedules pump 10 with enumeratedAT TIMEtriggers, so it doesn't exercise theTimeOfDayConditionpath. The example below loads the bundled Net3, swaps pump 10'sAT TIMEblock for the equivalent daily-recurringAT CLOCKTIMEschedule (open at 1 AM, close at 3 PM) before parsing, and runs both backends.Output:
Environment
Additional context
Where we think the issue is
In
wntr/network/controls.py,TimeOfDayCondition.evaluate():In the
_repeatbranch the transformation(time - threshold) % 86400produces "seconds elapsed since the most recent occurrence of the threshold time-of-day," which we believe is the intent. Theeqcomparison on the next line then compares that post-transform value againstself._thresholditself (the absolute seconds-of-day). Because the two values are in different reference frames, the predicate ends up firing at a sim_time that depends on the threshold's value rather than at the wall-clock threshold.Suggested fix
Comparing time-of-day to time-of-day directly seems to give the expected behavior. A focused change to the
_repeat + eqcase:We applied this locally and re-ran the example above. With the change,
WNTRSimulatorandEpanetSimulatoragree:We deliberately scoped the change to
_repeat + eqbecause that's what we exercised. Thegt/ltbranches use the same post-transform values and a related comparison pattern, so they may benefit from a similar adjustment, but we haven't tested them.Offer to contribute
If the maintainers agree this is a bug, we'd be glad to open a PR with the change above. Per the developer guide we can target either
mainordev— please let us know which is appropriate.