From b4e10cf28682ce4cdf139d4daaf47c5fe669883a Mon Sep 17 00:00:00 2001 From: michaeltryby Date: Mon, 1 Dec 2025 15:46:40 -0500 Subject: [PATCH 1/6] WIP test_series --- swmm-toolkit/tests/test_series.py | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 swmm-toolkit/tests/test_series.py diff --git a/swmm-toolkit/tests/test_series.py b/swmm-toolkit/tests/test_series.py new file mode 100644 index 00000000..e8aeebfa --- /dev/null +++ b/swmm-toolkit/tests/test_series.py @@ -0,0 +1,96 @@ +from datetime import datetime, timedelta +import os +import pytest +from swmm.toolkit import solver, output, shared_enum + +DATA_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data") +INPUT_FILE = os.path.join(DATA_PATH, "test_Example1.inp") +REPORT_FILE = os.path.join(DATA_PATH, "temp_align.rpt") +OUTPUT_FILE = os.path.join(DATA_PATH, "temp_align.out") + +REPORT_STEP_SECONDS = 3600 # Example1 + +def _curr_dt(): + y, m, d, hh, mm, ss = solver.simulation_get_current_datetime() + return datetime(y, m, d, hh, mm, ss) + +def build_link_flow_solver_tuples_aligned(): + tuples = [] + solver.swmm_open(INPUT_FILE, REPORT_FILE, OUTPUT_FILE) + try: + solver.swmm_start(0) + # After start callback + # period_end = _curr_dt() + # value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) + # tuples.append((period_end, value)) + + while True: + # Before step callback + # + time_left = solver.swmm_stride(REPORT_STEP_SECONDS) + # After step callback + # + if time_left == 0: + break + # Value for the interval that just ended; align to its period-end timestamp + period_end = _curr_dt() - timedelta(seconds=REPORT_STEP_SECONDS) + value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) + tuples.append((period_end, value)) + + # Before end callback + period_end = _curr_dt() - timedelta(seconds=REPORT_STEP_SECONDS) + value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) + tuples.append((period_end, value)) + + solver.swmm_end() + # After end callback + # + + finally: + solver.swmm_close() + # After close callback + # + return tuples + +def build_link_flow_output_tuples(): + EPOCH_SWMM = datetime(1899, 12, 30) + h = output.init() + output.open(h, os.path.join(DATA_PATH, "test_Example1.out")) + try: + start_days = output.get_start_date(h) + rpt = output.get_times(h, shared_enum.Time.REPORT_STEP) + n = output.get_times(h, shared_enum.Time.NUM_PERIODS) + start_dt = EPOCH_SWMM + timedelta(days=start_days) + vals = output.get_link_series(h, 0, shared_enum.LinkAttribute.FLOW_RATE, 0, n - 1) + tuples = [(start_dt + timedelta(seconds=i * rpt), float(vals[i])) for i in range(n)] + finally: + output.close(h) + return tuples + +def test_compare_aligned_series(): + s = build_link_flow_solver_tuples_aligned() + o = build_link_flow_output_tuples() + + # times must match + solver_times = [t.strftime("%Y-%m-%d %H:%M:%S") for t, _ in s] + output_times = [t.strftime("%Y-%m-%d %H:%M:%S") for t, _ in o] + assert solver_times == output_times, ( + "Time axes differ.\n" + f"Solver times: {solver_times[:5]} ...\n" + f"Output times: {output_times[:5]} ..." + ) + + # values should match within tolerance + import numpy as np + + solver_vals = np.array([v for _, v in s]) + output_vals = np.array([v for _, v in o]) + + assert np.allclose(solver_vals, output_vals, rtol=1e-6, atol=1e-9), ( + "Solver and output values differ. " + "See zipped output for details:\n" + + "\n".join( + f"{t1.strftime('%Y-%m-%d %H:%M:%S')} | {v1:.6f} || {t2.strftime('%Y-%m-%d %H:%M:%S')} | {v2:.6f} | diff={v1-v2:.2e}" + for (t1, v1), (t2, v2) in list(zip(s, o))[:10] + ) + ) From 169623debaef26f2facff725b9dbf98ac609e85e Mon Sep 17 00:00:00 2001 From: michaeltryby Date: Fri, 12 Dec 2025 09:15:18 -0500 Subject: [PATCH 2/6] Switch to min cdd for comparison --- swmm-toolkit/tests/test_series.py | 50 +++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/swmm-toolkit/tests/test_series.py b/swmm-toolkit/tests/test_series.py index e8aeebfa..23246084 100644 --- a/swmm-toolkit/tests/test_series.py +++ b/swmm-toolkit/tests/test_series.py @@ -10,6 +10,45 @@ REPORT_STEP_SECONDS = 3600 # Example1 + +def cdd(t, r): + import math + + if t == r: + return 10.0 + + tmp = abs(t - r) + if tmp < 1.0e-7: + tmp = 1.0e-7 + elif tmp > 2.0: + tmp = 1.0 + + tmp = -math.log10(tmp) + if tmp < 0.0: + tmp = 0.0 + + return tmp + +def check_cdd_float(test: list[float], ref: list[float], cdd_tol: int) -> bool: + """ + Checks minimum correct decimal digits between two float sequences. Fails if lengths differ. + """ + import math + + if len(test) != len(ref): + return False + + min_cdd = 10.0 + + for t, r in zip(test, ref): + tmp = cdd(t, r) + + if tmp < min_cdd: + min_cdd = tmp + + return math.floor(min_cdd) >= cdd_tol + + def _curr_dt(): y, m, d, hh, mm, ss = solver.simulation_get_current_datetime() return datetime(y, m, d, hh, mm, ss) @@ -81,16 +120,15 @@ def test_compare_aligned_series(): ) # values should match within tolerance - import numpy as np - - solver_vals = np.array([v for _, v in s]) - output_vals = np.array([v for _, v in o]) + solver_vals = [v for _, v in s] + output_vals = [v for _, v in o] - assert np.allclose(solver_vals, output_vals, rtol=1e-6, atol=1e-9), ( + assert check_cdd_float(solver_vals, output_vals, 1), ( "Solver and output values differ. " "See zipped output for details:\n" + "\n".join( - f"{t1.strftime('%Y-%m-%d %H:%M:%S')} | {v1:.6f} || {t2.strftime('%Y-%m-%d %H:%M:%S')} | {v2:.6f} | diff={v1-v2:.2e}" + f"{t1.strftime('%Y-%m-%d %H:%M:%S')} | {v1:.6f} || {t2.strftime('%Y-%m-%d %H:%M:%S')} | {v2:.6f} | cdd={cdd(v1, v2):.2f}" for (t1, v1), (t2, v2) in list(zip(s, o))[:10] ) ) + From fdd07dba6670105697eac3536860bfa503298426 Mon Sep 17 00:00:00 2001 From: michaeltryby Date: Wed, 21 Jan 2026 16:47:13 -0500 Subject: [PATCH 3/6] Add date and time functions and tests to output module --- swmm-toolkit/src/swmm/toolkit/output.i | 28 ++++++++- swmm-toolkit/src/swmm/toolkit/output_rename.i | 4 ++ swmm-toolkit/swmm-solver | 2 +- swmm-toolkit/tests/test_output.py | 59 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/swmm-toolkit/src/swmm/toolkit/output.i b/swmm-toolkit/src/swmm/toolkit/output.i index 1d43bbcd..680a7e1c 100644 --- a/swmm-toolkit/src/swmm/toolkit/output.i +++ b/swmm-toolkit/src/swmm/toolkit/output.i @@ -61,7 +61,14 @@ and return a (possibly) different pointer */ %apply int *OUTPUT { int *version, - int *time + int *time, + int *year, + int *month, + int *day, + int *hour, + int *minute, + int *second, + int *dayOfWeek } %cstring_output_allocate_size(char **elementName, int *size, SMO_freeMemory(*$1)); @@ -84,6 +91,23 @@ and return a (possibly) different pointer */ } +/* TYPEMAPS FOR MEMORY MANAGEMNET OF DOUBLE ARRAYS */ +%typemap(in, numinputs=0)double **double_out (double *temp), int *int_dim (int temp){ + $1 = &temp; +} +%typemap(argout) (double **double_out, int *int_dim) { + if (*$1) { + PyObject *o = PyList_New(*$2); + double* temp = *$1; + for(int i=0; i<*$2; i++) { + PyList_SetItem(o, i, PyFloat_FromDouble((double)temp[i])); + } + $result = SWIG_AppendOutput($result, o); + SMO_freeMemory(*$1); + } +} + + /* TYPEMAPS FOR MEMORY MANAGEMENT OF INT ARRAYS */ %typemap(in, numinputs=0)int **int_out (int *temp), int *int_dim (int temp){ $1 = &temp; @@ -151,6 +175,8 @@ and return a (possibly) different pointer */ %ignore SMO_clearError; %ignore SMO_checkError; +%noexception SMO_decodeDate; + %include "swmm_output.h" %exception; diff --git a/swmm-toolkit/src/swmm/toolkit/output_rename.i b/swmm-toolkit/src/swmm/toolkit/output_rename.i index b3566514..d75a03b2 100644 --- a/swmm-toolkit/src/swmm/toolkit/output_rename.i +++ b/swmm-toolkit/src/swmm/toolkit/output_rename.i @@ -20,6 +20,10 @@ %rename(get_times) SMO_getTimes; %rename(get_elem_name) SMO_getElementName; +%rename(get_date_time) SMO_getDateTime; +%rename(get_date_series) SMO_getDateSeries; +%rename(decode_date) SMO_decodeDate; + %rename(get_subcatch_series) SMO_getSubcatchSeries; %rename(get_node_series) SMO_getNodeSeries; %rename(get_link_series) SMO_getLinkSeries; diff --git a/swmm-toolkit/swmm-solver b/swmm-toolkit/swmm-solver index 9d7a3f9f..ebbce382 160000 --- a/swmm-toolkit/swmm-solver +++ b/swmm-toolkit/swmm-solver @@ -1 +1 @@ -Subproject commit 9d7a3f9f2b9bbf34b663c74088640e99caec5b38 +Subproject commit ebbce382c46876e701a3c47cb73684fdbe005e21 diff --git a/swmm-toolkit/tests/test_output.py b/swmm-toolkit/tests/test_output.py index b1f4954c..5edbf893 100644 --- a/swmm-toolkit/tests/test_output.py +++ b/swmm-toolkit/tests/test_output.py @@ -85,6 +85,65 @@ def test_getelementname(handle): assert output.get_elem_name(handle, shared_enum.ElementType.NODE, 1) == "10" + +def test_getdatetime(handle): + date0 = output.get_date_time(handle, 0) + date1 = output.get_date_time(handle, 1) + assert isinstance(date0, (float, np.floating)) + + step_seconds = output.get_times(handle, shared_enum.Time.REPORT_STEP) + step_days = step_seconds / 86400.0 + + # consecutive timestamps differ by exactly one report step (in days) + assert np.isclose(date1 - date0, step_days) + + # first timestamp should be strictly after the saved start date anchor + assert date0 > output.get_start_date(handle) + + +def test_getdateseries(handle): + start, end = 0, 5 + dates = output.get_date_series(handle, start, end) + + assert len(dates) == end - start + 1 + + step_days = output.get_times(handle, shared_enum.Time.REPORT_STEP) / 86400.0 + diffs = np.diff(dates) + + # monotonic and evenly spaced by report step + assert np.allclose(diffs, step_days) + assert np.isclose(dates[-1], dates[0] + (end - start) * step_days) + + +def test_decodedate(handle): + # decoded components are plausible + date0 = output.get_date_time(handle, 0) + y, m, d, hh, mm, ss, dow = output.decode_date(date0) + + assert 1 <= m <= 12 + assert 1 <= d <= 31 + assert 0 <= hh <= 23 + assert 0 <= mm <= 59 + assert 0 <= ss <= 59 + assert 1 <= dow <= 7 + + # consecutive decode respects the report step + date1 = output.get_date_time(handle, 1) + y1, m1, d1, hh1, mm1, ss1, dow1 = output.decode_date(date1) + + step_seconds = output.get_times(handle, shared_enum.Time.REPORT_STEP) + step_hours = (step_seconds // 3600) % 24 + + # minutes/seconds remain constant for steps divisible by 60s + if step_seconds % 60 == 0: + assert mm1 == mm + assert ss1 == ss + + # hour advances by step_hours modulo 24 (day rollover allowed) + assert ((hh1 - hh) % 24) == step_hours + + + def test_getsubcatchseries(handle): ref_array = np.array([0.0, From 0f46e7c66fd0bd650bfb1ddd4d025d0e3d2f517e Mon Sep 17 00:00:00 2001 From: michaeltryby Date: Wed, 11 Feb 2026 10:24:53 -0500 Subject: [PATCH 4/6] Improve function naming, documentation, fix typos --- swmm-toolkit/src/swmm/toolkit/output.i | 4 +-- swmm-toolkit/tests/test_series.py | 40 +++++++++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/swmm-toolkit/src/swmm/toolkit/output.i b/swmm-toolkit/src/swmm/toolkit/output.i index 680a7e1c..1daff53e 100644 --- a/swmm-toolkit/src/swmm/toolkit/output.i +++ b/swmm-toolkit/src/swmm/toolkit/output.i @@ -74,7 +74,7 @@ and return a (possibly) different pointer */ %cstring_output_allocate_size(char **elementName, int *size, SMO_freeMemory(*$1)); -/* TYPEMAPS FOR MEMORY MANAGEMNET OF FLOAT ARRAYS */ +/* TYPEMAPS FOR MEMORY MANAGEMENT OF FLOAT ARRAYS */ %typemap(in, numinputs=0)float **float_out (float *temp), int *int_dim (int temp){ $1 = &temp; } @@ -91,7 +91,7 @@ and return a (possibly) different pointer */ } -/* TYPEMAPS FOR MEMORY MANAGEMNET OF DOUBLE ARRAYS */ +/* TYPEMAPS FOR MEMORY MANAGEMENT OF DOUBLE ARRAYS */ %typemap(in, numinputs=0)double **double_out (double *temp), int *int_dim (int temp){ $1 = &temp; } diff --git a/swmm-toolkit/tests/test_series.py b/swmm-toolkit/tests/test_series.py index 23246084..71187b94 100644 --- a/swmm-toolkit/tests/test_series.py +++ b/swmm-toolkit/tests/test_series.py @@ -11,7 +11,12 @@ REPORT_STEP_SECONDS = 3600 # Example1 -def cdd(t, r): +def _correct_decimal_digits(t, r): + """ + Correct Decimal Digits (CDD) is computed as a bounded form of + ``-log10(abs(test_value - ref_value))``, which approximates how many + decimal digits of ``test_value`` agree with ``ref_value``. + """ import math if t == r: @@ -29,9 +34,30 @@ def cdd(t, r): return tmp + def check_cdd_float(test: list[float], ref: list[float], cdd_tol: int) -> bool: """ - Checks minimum correct decimal digits between two float sequences. Fails if lengths differ. + Check the minimum number of correct decimal digits (CDD) between two + float sequences. This function finds the minimum CDD over all element + pairs in ``test`` and ``ref``, then checks whether ``floor(min_cdd)`` + is greater than or equal to ``cdd_tol``. + + Parameters + ---------- + test : list[float] + Sequence of test values to be compared. + ref : list[float] + Sequence of reference values used as the expected results. + cdd_tol : int + Required minimum number of correct decimal digits (integer threshold) + that the minimum CDD over all pairs must meet or exceed. + + Returns + ------- + bool + ``True`` if ``test`` and ``ref`` have the same length and + ``floor(min_cdd) >= cdd_tol``; ``False`` otherwise (including if the + sequences differ in length). """ import math @@ -41,7 +67,7 @@ def check_cdd_float(test: list[float], ref: list[float], cdd_tol: int) -> bool: min_cdd = 10.0 for t, r in zip(test, ref): - tmp = cdd(t, r) + tmp = _correct_decimal_digits(t, r) if tmp < min_cdd: min_cdd = tmp @@ -49,7 +75,7 @@ def check_cdd_float(test: list[float], ref: list[float], cdd_tol: int) -> bool: return math.floor(min_cdd) >= cdd_tol -def _curr_dt(): +def _get_current_datetime(): y, m, d, hh, mm, ss = solver.simulation_get_current_datetime() return datetime(y, m, d, hh, mm, ss) @@ -59,7 +85,7 @@ def build_link_flow_solver_tuples_aligned(): try: solver.swmm_start(0) # After start callback - # period_end = _curr_dt() + # period_end = _get_current_datetime # value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) # tuples.append((period_end, value)) @@ -72,12 +98,12 @@ def build_link_flow_solver_tuples_aligned(): if time_left == 0: break # Value for the interval that just ended; align to its period-end timestamp - period_end = _curr_dt() - timedelta(seconds=REPORT_STEP_SECONDS) + period_end = _get_current_datetime() - timedelta(seconds=REPORT_STEP_SECONDS) value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) tuples.append((period_end, value)) # Before end callback - period_end = _curr_dt() - timedelta(seconds=REPORT_STEP_SECONDS) + period_end = _get_current_datetime() - timedelta(seconds=REPORT_STEP_SECONDS) value = solver.link_get_result(0, shared_enum.LinkResult.FLOW) tuples.append((period_end, value)) From 27b42c95b58037d88783e9fd1b95d7ad9aabdc20 Mon Sep 17 00:00:00 2001 From: karosc Date: Thu, 26 Feb 2026 11:28:21 -0500 Subject: [PATCH 5/6] set release version --- swmm-toolkit/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swmm-toolkit/pyproject.toml b/swmm-toolkit/pyproject.toml index e49732e0..564d84a5 100644 --- a/swmm-toolkit/pyproject.toml +++ b/swmm-toolkit/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "swmm-toolkit" -version = "0.16.2" +version = "0.17.0" description = "PySWMM SWMM Python Toolkit" readme = { file = "README.md", content-type = "text/markdown" } license = "CC0-1.0 AND (MIT OR Apache-2.0)" From af909e453e896ebd5208ab1312f12273d17439c2 Mon Sep 17 00:00:00 2001 From: karosc Date: Thu, 26 Feb 2026 11:44:46 -0500 Subject: [PATCH 6/6] set release version --- swmm-toolkit/src/swmm/toolkit/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swmm-toolkit/src/swmm/toolkit/__init__.py b/swmm-toolkit/src/swmm/toolkit/__init__.py index 45988951..52c670fa 100644 --- a/swmm-toolkit/src/swmm/toolkit/__init__.py +++ b/swmm-toolkit/src/swmm/toolkit/__init__.py @@ -18,7 +18,7 @@ __credits__ = "Colleen Barr" __license__ = "CC0 1.0 Universal" -__version__ = "0.16.2" +__version__ = "0.17.0" __date__ = "December 4, 2025" __maintainer__ = "Michael Tryby"