Skip to content

IF SYSTEM CLOCKTIME IS X controls fire at the wrong time under WNTRSimulator #559

@zsarnoczay

Description

@zsarnoczay

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.

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions