epyt-flow 0.6.0__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -94,6 +94,7 @@ class ScenarioSimulator():
94
94
  self.__controls = []
95
95
  self.__system_events = []
96
96
  self.__sensor_reading_events = []
97
+ self.__running_simulation = False
97
98
 
98
99
  custom_epanet_lib = None
99
100
  custom_epanetmsx_lib = None
@@ -150,7 +151,8 @@ class ScenarioSimulator():
150
151
 
151
152
  self.epanet_api = epanet(my_f_inp_in, ph=self.__f_msx_in is None,
152
153
  customlib=custom_epanet_lib, loadfile=True,
153
- display_msg=epanet_verbose)
154
+ display_msg=epanet_verbose,
155
+ display_warnings=False)
154
156
 
155
157
  if self.__f_msx_in is not None:
156
158
  self.epanet_api.loadMSXFile(my_f_msx_in, customMSXlib=custom_epanetmsx_lib)
@@ -432,7 +434,7 @@ class ScenarioSimulator():
432
434
  new_sensor_config.quality_node_sensors = self.__sensor_config.quality_node_sensors
433
435
  new_sensor_config.quality_link_sensors = self.__sensor_config.quality_link_sensors
434
436
  new_sensor_config.pump_state_sensors = self.__sensor_config.pump_state_sensors
435
- new_sensor_config.pump_efficiecy_sensors = self.__sensor_config.pump_efficiency_sensors
437
+ new_sensor_config.pump_efficiency_sensors = self.__sensor_config.pump_efficiency_sensors
436
438
  new_sensor_config.pump_energyconsumption_sensors = self.__sensor_config.\
437
439
  pump_energyconsumption_sensors
438
440
  new_sensor_config.valve_state_sensors = self.__sensor_config.valve_state_sensors
@@ -456,8 +458,6 @@ class ScenarioSimulator():
456
458
 
457
459
  if self.__my_f_inp_in is not None:
458
460
  shutil.rmtree(pathlib.Path(self.__my_f_inp_in).parent)
459
- if self.__my_f_msx_in is not None:
460
- shutil.rmtree(pathlib.Path(self.__my_f_msx_in).parent)
461
461
 
462
462
  def __enter__(self):
463
463
  return self
@@ -744,6 +744,19 @@ class ScenarioSimulator():
744
744
  "units": qualityunit_to_id(qual_info.QualityChemUnits),
745
745
  "trace_node_id": qual_info.TraceNode}
746
746
 
747
+ def get_reporting_time_step(self) -> int:
748
+ """
749
+ Gets the reporting time steps -- i.e. time steps at which sensor readings are provided.
750
+
751
+ Is always a multiple of the hydraulic time step.
752
+
753
+ Returns
754
+ -------
755
+ `int`
756
+ Reporting time steps in seconds.
757
+ """
758
+ return self.epanet_api.getTimeReportingStep()
759
+
747
760
  def get_scenario_config(self) -> ScenarioConfig:
748
761
  """
749
762
  Gets the configuration of this scenario -- i.e. all information & elements
@@ -758,7 +771,7 @@ class ScenarioSimulator():
758
771
 
759
772
  general_params = {"hydraulic_time_step": self.get_hydraulic_time_step(),
760
773
  "quality_time_step": self.get_quality_time_step(),
761
- "reporting_time_step": self.epanet_api.getTimeReportingStep(),
774
+ "reporting_time_step": self.get_reporting_time_step(),
762
775
  "simulation_duration": self.get_simulation_duration(),
763
776
  "flow_units_id": self.get_flow_units(),
764
777
  "quality_model": self.get_quality_model(),
@@ -874,6 +887,9 @@ class ScenarioSimulator():
874
887
  """
875
888
  Randomizes all demand patterns.
876
889
  """
890
+ if self.__running_simulation is True:
891
+ raise RuntimeError("Can not change general parameters when simulation is running.")
892
+
877
893
  self.__adapt_to_network_changes()
878
894
 
879
895
  # Get all demand patterns
@@ -996,6 +1012,9 @@ class ScenarioSimulator():
996
1012
  event : :class:`~epyt_flow.simulation.events.system_event.SystemEvent`
997
1013
  System event.
998
1014
  """
1015
+ if self.__running_simulation is True:
1016
+ raise RuntimeError("Can not add events when simulation is running.")
1017
+
999
1018
  self.__adapt_to_network_changes()
1000
1019
 
1001
1020
  if not isinstance(event, SystemEvent):
@@ -1015,6 +1034,9 @@ class ScenarioSimulator():
1015
1034
  sensor_fault_event : :class:`~epyt_flow.simulation.events.sensor_faults.SensorFault`
1016
1035
  Sensor fault specifications.
1017
1036
  """
1037
+ if self.__running_simulation is True:
1038
+ raise RuntimeError("Can not add events when simulation is running.")
1039
+
1018
1040
  self.__adapt_to_network_changes()
1019
1041
 
1020
1042
  sensor_fault_event.validate(self.__sensor_config)
@@ -1035,6 +1057,9 @@ class ScenarioSimulator():
1035
1057
  sensor_reading_attack : :class:`~epyt_flow.simulation.events.sensor_reading_attack.SensorReadingAttack`
1036
1058
  Sensor fault specifications.
1037
1059
  """
1060
+ if self.__running_simulation is True:
1061
+ raise RuntimeError("Can not add events when simulation is running.")
1062
+
1038
1063
  self.__adapt_to_network_changes()
1039
1064
 
1040
1065
  sensor_reading_attack.validate(self.__sensor_config)
@@ -1055,6 +1080,9 @@ class ScenarioSimulator():
1055
1080
  event : :class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`
1056
1081
  Sensor reading event.
1057
1082
  """
1083
+ if self.__running_simulation is True:
1084
+ raise RuntimeError("Can not add events when simulation is running.")
1085
+
1058
1086
  self.__adapt_to_network_changes()
1059
1087
 
1060
1088
  event.validate(self.__sensor_config)
@@ -1134,11 +1162,21 @@ class ScenarioSimulator():
1134
1162
  """
1135
1163
  self.set_sensors(SENSOR_TYPE_NODE_PRESSURE, sensor_locations)
1136
1164
 
1137
- def place_pressure_sensors_everywhere(self) -> None:
1165
+ def place_pressure_sensors_everywhere(self, junctions_only: bool = False) -> None:
1138
1166
  """
1139
1167
  Places a pressure sensor at every node in the network.
1168
+
1169
+ Parameters
1170
+ ----------
1171
+ junctions_only : `bool`, optional
1172
+ If True, pressure sensors are only placed at junctions but not at tanks and reservoirs.
1173
+
1174
+ The default is False.
1140
1175
  """
1141
- self.set_pressure_sensors(self.__sensor_config.nodes)
1176
+ if junctions_only is True:
1177
+ self.set_pressure_sensors(self.epanet_api.getNodeJunctionNameID())
1178
+ else:
1179
+ self.set_pressure_sensors(self.__sensor_config.nodes)
1142
1180
 
1143
1181
  def set_flow_sensors(self, sensor_locations: list[str]) -> None:
1144
1182
  """
@@ -1442,6 +1480,9 @@ class ScenarioSimulator():
1442
1480
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
1443
1481
  Quality simulation results as SCADA data.
1444
1482
  """
1483
+ if self.__running_simulation is True:
1484
+ raise RuntimeError("A simulation is already running.")
1485
+
1445
1486
  if self.__f_msx_in is None:
1446
1487
  raise ValueError("No .msx file specified")
1447
1488
 
@@ -1506,6 +1547,9 @@ class ScenarioSimulator():
1506
1547
  Generator containing the current EPANET-MSX simulation results as SCADA data
1507
1548
  (i.e. species concentrations).
1508
1549
  """
1550
+ if self.__running_simulation is True:
1551
+ raise RuntimeError("A simulation is already running.")
1552
+
1509
1553
  if self.__f_msx_in is None:
1510
1554
  raise ValueError("No .msx file specified")
1511
1555
 
@@ -1522,6 +1566,8 @@ class ScenarioSimulator():
1522
1566
 
1523
1567
  self.epanet_api.initializeMSXQualityAnalysis(ToolkitConstants.EN_NOSAVE)
1524
1568
 
1569
+ self.__running_simulation = True
1570
+
1525
1571
  bulk_species_idx = self.epanet_api.getMSXSpeciesIndex(self.__sensor_config.bulk_species)
1526
1572
  surface_species_idx = self.epanet_api.getMSXSpeciesIndex(
1527
1573
  self.__sensor_config.surface_species)
@@ -1598,63 +1644,75 @@ class ScenarioSimulator():
1598
1644
 
1599
1645
  if reporting_time_start == 0:
1600
1646
  if return_as_dict is True:
1601
- yield {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
1602
- "bulk_species_link_concentration_raw": bulk_species_link_concentrations,
1603
- "surface_species_concentration_raw": surface_species_concentrations,
1604
- "sensor_readings_time": np.array([0])}
1647
+ data = {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
1648
+ "bulk_species_link_concentration_raw": bulk_species_link_concentrations,
1649
+ "surface_species_concentration_raw": surface_species_concentrations,
1650
+ "sensor_readings_time": np.array([0])}
1605
1651
  else:
1606
- yield ScadaData(sensor_config=self.__sensor_config,
1607
- bulk_species_node_concentration_raw=bulk_species_node_concentrations,
1608
- bulk_species_link_concentration_raw=bulk_species_link_concentrations,
1609
- surface_species_concentration_raw=surface_species_concentrations,
1610
- sensor_readings_time=np.array([0]),
1611
- sensor_reading_events=self.__sensor_reading_events,
1612
- sensor_noise=self.__sensor_noise,
1613
- frozen_sensor_config=frozen_sensor_config)
1652
+ data = ScadaData(sensor_config=self.__sensor_config,
1653
+ bulk_species_node_concentration_raw=bulk_species_node_concentrations,
1654
+ bulk_species_link_concentration_raw=bulk_species_link_concentrations,
1655
+ surface_species_concentration_raw=surface_species_concentrations,
1656
+ sensor_readings_time=np.array([0]),
1657
+ sensor_reading_events=self.__sensor_reading_events,
1658
+ sensor_noise=self.__sensor_noise,
1659
+ frozen_sensor_config=frozen_sensor_config)
1614
1660
 
1615
- # Run step-by-step simulation
1616
- tleft = 1
1617
- while tleft > 0:
1618
1661
  if support_abort is True: # Can the simulation be aborted? If so, handle it.
1619
1662
  abort = yield
1620
- if abort is not False:
1621
- break
1663
+ if abort is True:
1664
+ return None
1622
1665
 
1666
+ yield data
1667
+
1668
+ # Run step-by-step simulation
1669
+ tleft = 1
1670
+ total_time = 0
1671
+ while tleft > 0:
1623
1672
  # Compute current time step
1624
1673
  total_time, tleft = self.epanet_api.stepMSXQualityAnalysisTimeLeft()
1625
1674
 
1626
- if verbose is True:
1627
- try:
1628
- next(progress_bar)
1629
- except StopIteration:
1630
- pass
1631
-
1632
1675
  # Fetch data at regular time intervals
1633
1676
  if total_time % hyd_time_step == 0:
1677
+ if verbose is True:
1678
+ try:
1679
+ next(progress_bar)
1680
+ except StopIteration:
1681
+ pass
1682
+
1634
1683
  bulk_species_node_concentrations, bulk_species_link_concentrations, \
1635
1684
  surface_species_concentrations = __get_concentrations()
1636
1685
 
1637
1686
  # Report results in a regular time interval only!
1638
1687
  if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
1639
1688
  if return_as_dict is True:
1640
- yield {"bulk_species_node_concentration_raw":
1641
- bulk_species_node_concentrations,
1642
- "bulk_species_link_concentration_raw":
1643
- bulk_species_link_concentrations,
1644
- "surface_species_concentration_raw": surface_species_concentrations,
1645
- "sensor_readings_time": np.array([total_time])}
1689
+ data = {"bulk_species_node_concentration_raw":
1690
+ bulk_species_node_concentrations,
1691
+ "bulk_species_link_concentration_raw":
1692
+ bulk_species_link_concentrations,
1693
+ "surface_species_concentration_raw": surface_species_concentrations,
1694
+ "sensor_readings_time": np.array([total_time])}
1646
1695
  else:
1647
- yield ScadaData(sensor_config=self.__sensor_config,
1648
- bulk_species_node_concentration_raw=
1649
- bulk_species_node_concentrations,
1650
- bulk_species_link_concentration_raw=
1651
- bulk_species_link_concentrations,
1652
- surface_species_concentration_raw=
1653
- surface_species_concentrations,
1654
- sensor_readings_time=np.array([total_time]),
1655
- sensor_reading_events=self.__sensor_reading_events,
1656
- sensor_noise=self.__sensor_noise,
1657
- frozen_sensor_config=frozen_sensor_config)
1696
+ data = ScadaData(sensor_config=self.__sensor_config,
1697
+ bulk_species_node_concentration_raw=
1698
+ bulk_species_node_concentrations,
1699
+ bulk_species_link_concentration_raw=
1700
+ bulk_species_link_concentrations,
1701
+ surface_species_concentration_raw=
1702
+ surface_species_concentrations,
1703
+ sensor_readings_time=np.array([total_time]),
1704
+ sensor_reading_events=self.__sensor_reading_events,
1705
+ sensor_noise=self.__sensor_noise,
1706
+ frozen_sensor_config=frozen_sensor_config)
1707
+
1708
+ if support_abort is True: # Can the simulation be aborted? If so, handle it.
1709
+ abort = yield
1710
+ if abort is not False:
1711
+ break
1712
+
1713
+ yield data
1714
+
1715
+ self.__running_simulation = False
1658
1716
 
1659
1717
  def run_basic_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
1660
1718
  frozen_sensor_config: bool = False) -> ScadaData:
@@ -1681,6 +1739,9 @@ class ScenarioSimulator():
1681
1739
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
1682
1740
  Quality simulation results as SCADA data.
1683
1741
  """
1742
+ if self.__running_simulation is True:
1743
+ raise RuntimeError("A simulation is already running.")
1744
+
1684
1745
  result = None
1685
1746
 
1686
1747
  # Run simulation step-by-step
@@ -1741,6 +1802,9 @@ class ScenarioSimulator():
1741
1802
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
1742
1803
  Generator with the current simulation results/states as SCADA data.
1743
1804
  """
1805
+ if self.__running_simulation is True:
1806
+ raise RuntimeError("A simulation is already running.")
1807
+
1744
1808
  requested_time_step = self.epanet_api.getTimeHydraulicStep()
1745
1809
  reporting_time_start = self.epanet_api.getTimeReportingStart()
1746
1810
  reporting_time_step = self.epanet_api.getTimeReportingStep()
@@ -1761,11 +1825,6 @@ class ScenarioSimulator():
1761
1825
  tstep = 1
1762
1826
  first_itr = True
1763
1827
  while tstep > 0:
1764
- if support_abort is True: # Can the simulation be aborted? If so, handle it.
1765
- abort = yield
1766
- if abort is not False:
1767
- break
1768
-
1769
1828
  if first_itr is True: # Fix current time in the first iteration
1770
1829
  tstep = 0
1771
1830
  first_itr = False
@@ -1788,27 +1847,36 @@ class ScenarioSimulator():
1788
1847
  # Yield results in a regular time interval only!
1789
1848
  if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
1790
1849
  if return_as_dict is True:
1791
- yield {"node_quality_data_raw": quality_node_data,
1792
- "link_quality_data_raw": quality_link_data,
1793
- "sensor_readings_time": np.array([total_time])}
1850
+ data = {"node_quality_data_raw": quality_node_data,
1851
+ "link_quality_data_raw": quality_link_data,
1852
+ "sensor_readings_time": np.array([total_time])}
1794
1853
  else:
1795
- yield ScadaData(sensor_config=self.__sensor_config,
1796
- node_quality_data_raw=quality_node_data,
1797
- link_quality_data_raw=quality_link_data,
1798
- sensor_readings_time=np.array([total_time]),
1799
- sensor_reading_events=self.__sensor_reading_events,
1800
- sensor_noise=self.__sensor_noise,
1801
- frozen_sensor_config=frozen_sensor_config)
1854
+ data = ScadaData(sensor_config=self.__sensor_config,
1855
+ node_quality_data_raw=quality_node_data,
1856
+ link_quality_data_raw=quality_link_data,
1857
+ sensor_readings_time=np.array([total_time]),
1858
+ sensor_reading_events=self.__sensor_reading_events,
1859
+ sensor_noise=self.__sensor_noise,
1860
+ frozen_sensor_config=frozen_sensor_config)
1861
+
1862
+ if support_abort is True: # Can the simulation be aborted? If so, handle it.
1863
+ abort = yield
1864
+ if abort is True:
1865
+ break
1866
+
1867
+ yield data
1802
1868
 
1803
1869
  # Next
1804
1870
  tstep = self.epanet_api.nextQualityAnalysisStep()
1805
1871
 
1806
1872
  self.epanet_api.closeHydraulicAnalysis()
1807
1873
 
1808
- def run_simulation(self, hyd_export: str = None, verbose: bool = False,
1809
- frozen_sensor_config: bool = False) -> ScadaData:
1874
+ def run_hydraulic_simulation(self, hyd_export: str = None, verbose: bool = False,
1875
+ frozen_sensor_config: bool = False) -> ScadaData:
1810
1876
  """
1811
- Runs the simulation of this scenario.
1877
+ Runs the hydraulic simulation of this scenario (incl. basic quality if set).
1878
+
1879
+ Note that this function does not call EPANET-MSX even if an .msx file was provided.
1812
1880
 
1813
1881
  Parameters
1814
1882
  ----------
@@ -1834,16 +1902,15 @@ class ScenarioSimulator():
1834
1902
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
1835
1903
  Simulation results as SCADA data (i.e. sensor readings).
1836
1904
  """
1905
+ if self.__running_simulation is True:
1906
+ raise RuntimeError("A simulation is already running.")
1907
+
1837
1908
  self.__adapt_to_network_changes()
1838
1909
 
1839
1910
  result = None
1840
1911
 
1841
- hyd_export_old = hyd_export
1842
- if self.__f_msx_in is not None:
1843
- hyd_export = os.path.join(get_temp_folder(), f"epytflow_MSX_{uuid.uuid4()}.hyd")
1844
-
1845
1912
  # Run hydraulic simulation step-by-step
1846
- gen = self.run_simulation_as_generator
1913
+ gen = self.run_hydraulic_simulation_as_generator
1847
1914
  for scada_data in gen(hyd_export=hyd_export,
1848
1915
  verbose=verbose,
1849
1916
  return_as_dict=True,
@@ -1865,32 +1932,18 @@ class ScenarioSimulator():
1865
1932
  sensor_noise=self.__sensor_noise,
1866
1933
  frozen_sensor_config=frozen_sensor_config)
1867
1934
 
1868
- # If necessary, run advanced quality simulation utilizing the computed hydraulics
1869
- if self.f_msx_in is not None:
1870
- gen = self.run_advanced_quality_simulation
1871
- result_msx = gen(hyd_file_in=hyd_export,
1872
- verbose=verbose,
1873
- frozen_sensor_config=frozen_sensor_config)
1874
- result.join(result_msx)
1875
-
1876
- if hyd_export_old is not None:
1877
- shutil.copyfile(hyd_export, hyd_export_old)
1878
-
1879
- try:
1880
- # temp solution
1881
- os.remove(hyd_export)
1882
- except:
1883
- warnings.warn(f"Failed to remove temporary file '{hyd_export}'")
1884
-
1885
1935
  return result
1886
1936
 
1887
- def run_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
1888
- support_abort: bool = False,
1889
- return_as_dict: bool = False,
1890
- frozen_sensor_config: bool = False,
1891
- ) -> Generator[Union[ScadaData, dict], bool, None]:
1937
+ def run_hydraulic_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
1938
+ support_abort: bool = False,
1939
+ return_as_dict: bool = False,
1940
+ frozen_sensor_config: bool = False,
1941
+ ) -> Generator[Union[ScadaData, dict], bool, None]:
1892
1942
  """
1893
- Runs the simulation of this scenario and provides the results as a generator.
1943
+ Runs the hydraulic simulation of this scenario (incl. basic quality if set) and
1944
+ provides the results as a generator.
1945
+
1946
+ Note that this function does not run EPANET-MSX, even if an .msx file was provided.
1894
1947
 
1895
1948
  Parameters
1896
1949
  ----------
@@ -1928,10 +1981,15 @@ class ScenarioSimulator():
1928
1981
  Generator with the current simulation results/states as SCADA data
1929
1982
  (i.e. sensor readings).
1930
1983
  """
1984
+ if self.__running_simulation is True:
1985
+ raise RuntimeError("A simulation is already running.")
1986
+
1931
1987
  self.__adapt_to_network_changes()
1932
1988
 
1933
1989
  self.__prepare_simulation()
1934
1990
 
1991
+ self.__running_simulation = True
1992
+
1935
1993
  self.epanet_api.openHydraulicAnalysis()
1936
1994
  self.epanet_api.openQualityAnalysis()
1937
1995
  self.epanet_api.initializeHydraulicAnalysis(ToolkitConstants.EN_SAVE)
@@ -1958,11 +2016,11 @@ class ScenarioSimulator():
1958
2016
  first_itr = False
1959
2017
 
1960
2018
  if verbose is True:
1961
- #if (total_time + tstep) % requested_time_step == 0:
1962
- try:
1963
- next(progress_bar)
1964
- except StopIteration:
1965
- pass
2019
+ if (total_time + tstep) % requested_time_step == 0:
2020
+ try:
2021
+ next(progress_bar)
2022
+ except StopIteration:
2023
+ pass
1966
2024
 
1967
2025
  # Apply system events in a regular time interval only!
1968
2026
  if (total_time + tstep) % requested_time_step == 0:
@@ -2025,7 +2083,7 @@ class ScenarioSimulator():
2025
2083
 
2026
2084
  if support_abort is True: # Can the simulation be aborted? If so, handle it.
2027
2085
  abort = yield
2028
- if abort is not False:
2086
+ if abort is True:
2029
2087
  break
2030
2088
 
2031
2089
  yield data
@@ -2041,11 +2099,77 @@ class ScenarioSimulator():
2041
2099
  self.epanet_api.closeQualityAnalysis()
2042
2100
  self.epanet_api.closeHydraulicAnalysis()
2043
2101
 
2102
+ self.__running_simulation = False
2103
+
2044
2104
  if hyd_export is not None:
2045
2105
  self.epanet_api.saveHydraulicFile(hyd_export)
2046
2106
  except Exception as ex:
2107
+ self.__running_simulation = False
2047
2108
  raise ex
2048
2109
 
2110
+ def run_simulation(self, hyd_export: str = None, verbose: bool = False,
2111
+ frozen_sensor_config: bool = False) -> ScadaData:
2112
+ """
2113
+ Runs the simulation of this scenario.
2114
+
2115
+ Parameters
2116
+ ----------
2117
+ hyd_export : `str`, optional
2118
+ Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
2119
+ can be used later for an advanced quality analysis using EPANET-MSX.
2120
+
2121
+ If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
2122
+
2123
+ The default is None.
2124
+ verbose : `bool`, optional
2125
+ If True, method will be verbose (e.g. showing a progress bar).
2126
+
2127
+ The default is False.
2128
+ frozen_sensor_config : `bool`, optional
2129
+ If True, the sensor config can not be changed and only the required sensor nodes/links
2130
+ will be stored -- this usually leads to a significant reduction in memory consumption.
2131
+
2132
+ The default is False.
2133
+
2134
+ Returns
2135
+ -------
2136
+ :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2137
+ Simulation results as SCADA data (i.e. sensor readings).
2138
+ """
2139
+ if self.__running_simulation is True:
2140
+ raise RuntimeError("A simulation is already running.")
2141
+
2142
+ self.__adapt_to_network_changes()
2143
+
2144
+ result = None
2145
+
2146
+ hyd_export_old = hyd_export
2147
+ if self.__f_msx_in is not None:
2148
+ hyd_export = os.path.join(get_temp_folder(), f"epytflow_MSX_{uuid.uuid4()}.hyd")
2149
+
2150
+ # Run hydraulic simulation step-by-step
2151
+ result = self.run_hydraulic_simulation(hyd_export=hyd_export, verbose=verbose,
2152
+ frozen_sensor_config=frozen_sensor_config)
2153
+
2154
+ # If necessary, run advanced quality simulation utilizing the computed hydraulics
2155
+ if self.f_msx_in is not None:
2156
+ gen = self.run_advanced_quality_simulation
2157
+ result_msx = gen(hyd_file_in=hyd_export,
2158
+ verbose=verbose,
2159
+ frozen_sensor_config=frozen_sensor_config)
2160
+ result.join(result_msx)
2161
+
2162
+ if hyd_export_old is not None:
2163
+ shutil.copyfile(hyd_export, hyd_export_old)
2164
+
2165
+ try:
2166
+ # temp solution
2167
+ os.remove(hyd_export)
2168
+ except:
2169
+ warnings.warn(f"Failed to remove temporary file '{hyd_export}'")
2170
+
2171
+ return result
2172
+
2049
2173
  def set_model_uncertainty(self, model_uncertainty: ModelUncertainty) -> None:
2050
2174
  """
2051
2175
  Specifies the model uncertainties.
@@ -2055,6 +2179,9 @@ class ScenarioSimulator():
2055
2179
  model_uncertainty : :class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`
2056
2180
  Model uncertainty specifications.
2057
2181
  """
2182
+ if self.__running_simulation is True:
2183
+ raise RuntimeError("Can not set uncertainties when simulation is running.")
2184
+
2058
2185
  self.__adapt_to_network_changes()
2059
2186
 
2060
2187
  if not isinstance(model_uncertainty, ModelUncertainty):
@@ -2073,6 +2200,9 @@ class ScenarioSimulator():
2073
2200
  sensor_noise : :class:`~epyt_flow.uncertainties.sensor_noise.SensorNoise`
2074
2201
  Sensor noise specification.
2075
2202
  """
2203
+ if self.__running_simulation is True:
2204
+ raise RuntimeError("Can not set sensor noise when simulation is running.")
2205
+
2076
2206
  self.__adapt_to_network_changes()
2077
2207
 
2078
2208
  if not isinstance(sensor_noise, SensorNoise):
@@ -2155,6 +2285,9 @@ class ScenarioSimulator():
2155
2285
 
2156
2286
  The default is None.
2157
2287
  """
2288
+ if self.__running_simulation is True:
2289
+ raise RuntimeError("Can not change general parameters when simulation is running.")
2290
+
2158
2291
  self.__adapt_to_network_changes()
2159
2292
 
2160
2293
  if flow_units_id is not None:
@@ -2277,6 +2410,9 @@ class ScenarioSimulator():
2277
2410
  Sets water age analysis -- i.e. estimates the water age (in hours) at
2278
2411
  all places in the network.
2279
2412
  """
2413
+ if self.__running_simulation is True:
2414
+ raise RuntimeError("Can not change general parameters when simulation is running.")
2415
+
2280
2416
  self.__adapt_to_network_changes()
2281
2417
 
2282
2418
  self.__warn_if_quality_set()
@@ -2305,6 +2441,9 @@ class ScenarioSimulator():
2305
2441
 
2306
2442
  The default is MASS_UNIT_MG.
2307
2443
  """
2444
+ if self.__running_simulation is True:
2445
+ raise RuntimeError("Can not change general parameters when simulation is running.")
2446
+
2308
2447
  self.__adapt_to_network_changes()
2309
2448
 
2310
2449
  self.__warn_if_quality_set()
@@ -2349,6 +2488,9 @@ class ScenarioSimulator():
2349
2488
 
2350
2489
  The default is 1.
2351
2490
  """
2491
+ if self.__running_simulation is True:
2492
+ raise RuntimeError("Can not change general parameters when simulation is running.")
2493
+
2352
2494
  self.__adapt_to_network_changes()
2353
2495
 
2354
2496
  if self.epanet_api.getQualityInfo().QualityCode != ToolkitConstants.EN_CHEM:
@@ -2385,6 +2527,9 @@ class ScenarioSimulator():
2385
2527
  trace_node_id : `str`
2386
2528
  ID of the node traced in the source tracing analysis.
2387
2529
  """
2530
+ if self.__running_simulation is True:
2531
+ raise RuntimeError("Can not change general parameters when simulation is running.")
2532
+
2388
2533
  self.__adapt_to_network_changes()
2389
2534
 
2390
2535
  if trace_node_id not in self.__sensor_config.nodes:
@@ -2393,3 +2538,65 @@ class ScenarioSimulator():
2393
2538
  self.__warn_if_quality_set()
2394
2539
  self.set_general_parameters(quality_model={"type": "TRACE",
2395
2540
  "trace_node_id": trace_node_id})
2541
+
2542
+ def add_species_injection_source(self, species_id: str, node_id: str, pattern: np.ndarray,
2543
+ source_type: int, pattern_id: str = None,
2544
+ source_strength: int = 1.) -> None:
2545
+ """
2546
+ Adds a new external (bulk or surface) species injection source at a particular node.
2547
+
2548
+ Parameters
2549
+ ----------
2550
+ species_id : `str`
2551
+ ID of the (bulk or surface) species.
2552
+ node_id : `str`
2553
+ ID of the node at which this external (bulk or surface) species injection source
2554
+ is placed.
2555
+ pattern : `numpy.ndarray`
2556
+ 1d source pattern.
2557
+ source_type : `int`,
2558
+ Type of the external (bulk or surface) species injection source -- must be one of
2559
+ the following EPANET toolkit constants:
2560
+
2561
+ - EN_CONCEN = 0
2562
+ - EN_MASS = 1
2563
+ - EN_SETPOINT = 2
2564
+ - EN_FLOWPACED = 3
2565
+
2566
+ Description:
2567
+
2568
+ - E_CONCEN Sets the concentration of external inflow entering a node
2569
+ - EN_MASS Injects a given mass/minute into a node
2570
+ - EN_SETPOINT Sets the concentration leaving a node to a given value
2571
+ - EN_FLOWPACED Adds a given value to the concentration leaving a node
2572
+ pattern_id : `str`, optional
2573
+ ID of the source pattern.
2574
+
2575
+ If None, a pattern_id will be generated automatically -- be aware that this
2576
+ could conflict with existing pattern IDs (in this case, an exception is raised).
2577
+
2578
+ The default is None.
2579
+ source_strength : `int`, optional
2580
+ Injection source strength -- i.e. injection = source_strength * pattern.
2581
+
2582
+ The default is 1.
2583
+ """
2584
+ source_type_ = "None"
2585
+ if source_type == ToolkitConstants.EN_CONCEN:
2586
+ source_type_ = "CONCEN"
2587
+ elif source_type == ToolkitConstants.EN_MASS:
2588
+ source_type_ = "MASS"
2589
+ elif source_type == ToolkitConstants.EN_SETPOINT:
2590
+ source_type_ = "SETPOINT"
2591
+ elif source_type == ToolkitConstants.EN_FLOWPACED:
2592
+ source_type_ = "FLOWPACED"
2593
+
2594
+ if pattern_id is None:
2595
+ pattern_id = f"{species_id}_{node_id}"
2596
+ if pattern_id in self.epanet_api.getMSXPatternsNameID():
2597
+ raise ValueError("Invalid 'pattern_id' -- " +
2598
+ f"there already exists a pattern with ID '{pattern_id}'")
2599
+
2600
+ self.epanet_api.addMSXPattern(pattern_id, pattern)
2601
+ self.epanet_api.setMSXSources(node_id, species_id, source_type_, source_strength,
2602
+ pattern_id)