epyt-flow 0.10.0__py3-none-any.whl → 0.11.0__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.
@@ -7,7 +7,7 @@ import pathlib
7
7
  import time
8
8
  from datetime import timedelta
9
9
  from datetime import datetime
10
- from typing import Generator, Union
10
+ from typing import Generator, Union, Optional
11
11
  from copy import deepcopy
12
12
  import shutil
13
13
  import warnings
@@ -112,7 +112,6 @@ class ScenarioSimulator():
112
112
  self._model_uncertainty = ModelUncertainty()
113
113
  self._sensor_noise = None
114
114
  self._sensor_config = None
115
- self._advanced_controls = []
116
115
  self._custom_controls = []
117
116
  self._simple_controls = []
118
117
  self._complex_controls = []
@@ -190,9 +189,6 @@ class ScenarioSimulator():
190
189
  self._sensor_noise = scenario_config.sensor_noise
191
190
  self._sensor_config = scenario_config.sensor_config
192
191
 
193
- if scenario_config.advanced_controls is not None:
194
- for control in scenario_config.advanced_controls:
195
- self.add_advanced_control(control)
196
192
  for control in scenario_config.custom_controls:
197
193
  self.add_custom_control(control)
198
194
  for control in scenario_config.simple_controls:
@@ -342,22 +338,6 @@ class ScenarioSimulator():
342
338
 
343
339
  self._sensor_config = sensor_config
344
340
 
345
- @property
346
- def advanced_controls(self) -> list:
347
- """
348
- Returns all advanced control modules.
349
-
350
- Returns
351
- -------
352
- list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`]
353
- All advanced control modules.
354
- """
355
- warnings.warn("'AdvancedControlModule' is deprecated and will be removed in a " +
356
- "future release -- use 'CustomControlModule' instead")
357
- self._adapt_to_network_changes()
358
-
359
- return deepcopy(self._advanced_controls)
360
-
361
341
  @property
362
342
  def custom_controls(self) -> list[CustomControlModule]:
363
343
  """
@@ -990,7 +970,6 @@ class ScenarioSimulator():
990
970
  return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
991
971
  general_params=general_params, sensor_config=self.sensor_config,
992
972
  memory_consumption_estimate=self.estimate_memory_consumption(),
993
- advanced_controls=None if len(self._advanced_controls) == 0 else self.advanced_controls,
994
973
  custom_controls=self.custom_controls,
995
974
  simple_controls=self.simple_controls,
996
975
  complex_controls=self.complex_controls,
@@ -1041,6 +1020,7 @@ class ScenarioSimulator():
1041
1020
  nodes_type = [self.epanet_api.TYPENODE[i] for i in self.epanet_api.getNodeTypeIndex()]
1042
1021
  nodes_coord = [self.epanet_api.api.ENgetcoord(node_idx)
1043
1022
  for node_idx in self.epanet_api.getNodeIndex()]
1023
+ nodes_comments = self.epanet_api.getNodeComment()
1044
1024
  node_tank_names = self.epanet_api.getNodeTankNameID()
1045
1025
 
1046
1026
  links_id = self.epanet_api.getLinkNameID()
@@ -1060,10 +1040,12 @@ class ScenarioSimulator():
1060
1040
 
1061
1041
  # Build graph describing the topology
1062
1042
  nodes = []
1063
- for node_id, node_elevation, node_type, node_coord in zip(nodes_id, nodes_elevation,
1064
- nodes_type, nodes_coord):
1043
+ for node_id, node_elevation, node_type, \
1044
+ node_coord, node_comment in zip(nodes_id, nodes_elevation, nodes_type, nodes_coord,
1045
+ nodes_comments):
1065
1046
  node_info = {"elevation": node_elevation,
1066
1047
  "coord": node_coord,
1048
+ "comment": node_comment,
1067
1049
  "type": node_type}
1068
1050
  if node_type == "TANK":
1069
1051
  node_tank_idx = node_tank_names.index(node_id) + 1
@@ -1072,9 +1054,10 @@ class ScenarioSimulator():
1072
1054
  nodes.append((node_id, node_info))
1073
1055
 
1074
1056
  links = []
1075
- for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, wall_coeff, loss_coeff \
1076
- in zip(links_id, links_type, links_data, links_diameter, links_length,
1077
- links_roughness_coeff, links_bulk_coeff, links_wall_coeff, links_loss_coeff):
1057
+ for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, \
1058
+ wall_coeff, loss_coeff in zip(links_id, links_type, links_data, links_diameter,
1059
+ links_length, links_roughness_coeff, links_bulk_coeff,
1060
+ links_wall_coeff, links_loss_coeff):
1078
1061
  links.append((link_id, list(link),
1079
1062
  {"type": link_type, "diameter": diameter, "length": length,
1080
1063
  "roughness_coeff": roughness_coeff,
@@ -1110,7 +1093,7 @@ class ScenarioSimulator():
1110
1093
 
1111
1094
  The default is None.
1112
1095
  """
1113
- from .scenario_visualizer import ScenarioVisualizer
1096
+ from ..visualization import ScenarioVisualizer
1114
1097
  ScenarioVisualizer(self).show_plot(export_to_file)
1115
1098
 
1116
1099
  def randomize_demands(self) -> None:
@@ -1183,7 +1166,10 @@ class ScenarioSimulator():
1183
1166
  raise ValueError(f"Inconsistent pattern shape '{pattern.shape}' " +
1184
1167
  "detected. Expected a one dimensional array!")
1185
1168
 
1186
- self.epanet_api.addPattern(pattern_id, pattern)
1169
+ pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
1170
+ if pattern_idx == 0:
1171
+ raise RuntimeError("Failed to add pattern! " +
1172
+ "Maybe pattern name contains invalid characters or is too long?")
1187
1173
 
1188
1174
  def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
1189
1175
  """
@@ -1257,25 +1243,6 @@ class ScenarioSimulator():
1257
1243
  self.epanet_api.setNodeJunctionData(node_idx, self.epanet_api.getNodeElevations(node_idx),
1258
1244
  base_demand, demand_pattern_id)
1259
1245
 
1260
- def add_advanced_control(self, control) -> None:
1261
- """
1262
- Adds an advanced control module to the scenario simulation.
1263
-
1264
- Parameters
1265
- ----------
1266
- control : :class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`
1267
- Advanced control module.
1268
- """
1269
- self._adapt_to_network_changes()
1270
-
1271
- from .scada.advanced_control import AdvancedControlModule
1272
- if not isinstance(control, AdvancedControlModule):
1273
- raise TypeError("'control' must be an instance of " +
1274
- "'epyt_flow.simulation.scada.AdvancedControlModule' not of " +
1275
- f"'{type(control)}'")
1276
-
1277
- self._advanced_controls.append(control)
1278
-
1279
1246
  def add_custom_control(self, control: CustomControlModule) -> None:
1280
1247
  """
1281
1248
  Adds a custom control module to the scenario simulation.
@@ -1934,9 +1901,6 @@ class ScenarioSimulator():
1934
1901
  for event in self._system_events:
1935
1902
  event.reset()
1936
1903
 
1937
- if self._advanced_controls is not None:
1938
- for c in self._advanced_controls:
1939
- c.init(self.epanet_api)
1940
1904
  if self._custom_controls is not None:
1941
1905
  for control in self._custom_controls:
1942
1906
  control.init(self.epanet_api)
@@ -2006,6 +1970,7 @@ class ScenarioSimulator():
2006
1970
  result[data_type] = None
2007
1971
 
2008
1972
  return ScadaData(**result,
1973
+ network_topo=self.get_topology(),
2009
1974
  sensor_config=self._sensor_config,
2010
1975
  sensor_reading_events=self._sensor_reading_events,
2011
1976
  sensor_noise=self._sensor_noise,
@@ -2159,7 +2124,7 @@ class ScenarioSimulator():
2159
2124
  "surface_species_concentration_raw": surface_species_concentrations,
2160
2125
  "sensor_readings_time": np.array([0])}
2161
2126
  else:
2162
- data = ScadaData(sensor_config=self._sensor_config,
2127
+ data = ScadaData(network_topo=self.get_topology(), sensor_config=self._sensor_config,
2163
2128
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
2164
2129
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
2165
2130
  surface_species_concentration_raw=surface_species_concentrations,
@@ -2203,7 +2168,8 @@ class ScenarioSimulator():
2203
2168
  "surface_species_concentration_raw": surface_species_concentrations,
2204
2169
  "sensor_readings_time": np.array([total_time])}
2205
2170
  else:
2206
- data = ScadaData(sensor_config=self._sensor_config,
2171
+ data = ScadaData(network_topo=self.get_topology(),
2172
+ sensor_config=self._sensor_config,
2207
2173
  bulk_species_node_concentration_raw=
2208
2174
  bulk_species_node_concentrations,
2209
2175
  bulk_species_link_concentration_raw=
@@ -2284,6 +2250,7 @@ class ScenarioSimulator():
2284
2250
  result[data_type] = np.concatenate(result[data_type], axis=0)
2285
2251
 
2286
2252
  return ScadaData(**result,
2253
+ network_topo=self.get_topology(),
2287
2254
  sensor_config=self._sensor_config,
2288
2255
  sensor_reading_events=self._sensor_reading_events,
2289
2256
  sensor_noise=self._sensor_noise,
@@ -2385,7 +2352,8 @@ class ScenarioSimulator():
2385
2352
  "link_quality_data_raw": quality_link_data,
2386
2353
  "sensor_readings_time": np.array([total_time])}
2387
2354
  else:
2388
- data = ScadaData(sensor_config=self._sensor_config,
2355
+ data = ScadaData(network_topo=self.get_topology(),
2356
+ sensor_config=self._sensor_config,
2389
2357
  node_quality_data_raw=quality_node_data,
2390
2358
  link_quality_data_raw=quality_link_data,
2391
2359
  sensor_readings_time=np.array([total_time]),
@@ -2461,6 +2429,7 @@ class ScenarioSimulator():
2461
2429
  result[data_type] = np.concatenate(result[data_type], axis=0)
2462
2430
 
2463
2431
  result = ScadaData(**result,
2432
+ network_topo=self.get_topology(),
2464
2433
  sensor_config=self._sensor_config,
2465
2434
  sensor_reading_events=self._sensor_reading_events,
2466
2435
  sensor_noise=self._sensor_noise,
@@ -2582,7 +2551,8 @@ class ScenarioSimulator():
2582
2551
  link_valve_idx = self.epanet_api.getLinkValveIndex()
2583
2552
  valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
2584
2553
 
2585
- scada_data = ScadaData(sensor_config=self._sensor_config,
2554
+ scada_data = ScadaData(network_topo=self.get_topology(),
2555
+ sensor_config=self._sensor_config,
2586
2556
  pressure_data_raw=pressure_data,
2587
2557
  flow_data_raw=flow_data,
2588
2558
  demand_data_raw=demand_data,
@@ -2623,8 +2593,6 @@ class ScenarioSimulator():
2623
2593
  yield data
2624
2594
 
2625
2595
  # Apply control modules
2626
- for control in self._advanced_controls:
2627
- control.step(scada_data)
2628
2596
  for control in self._custom_controls:
2629
2597
  control.step(scada_data)
2630
2598
 
@@ -2643,16 +2611,6 @@ class ScenarioSimulator():
2643
2611
  self.__running_simulation = False
2644
2612
  raise ex
2645
2613
 
2646
- def run_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
2647
- support_abort: bool = False,
2648
- return_as_dict: bool = False,
2649
- frozen_sensor_config: bool = False,
2650
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2651
- warnings.warn("'run_simulation_as_generator' is deprecated and will be removed in " +
2652
- "future releases -- use 'run_hydraulic_simulation_as_generator' instead")
2653
- return self.run_hydraulic_simulation_as_generator(hyd_export, verbose, support_abort,
2654
- return_as_dict, frozen_sensor_config)
2655
-
2656
2614
  def run_simulation(self, hyd_export: str = None, verbose: bool = False,
2657
2615
  frozen_sensor_config: bool = False) -> ScadaData:
2658
2616
  """
@@ -2945,6 +2903,202 @@ class ScenarioSimulator():
2945
2903
 
2946
2904
  return list(set(events_times))
2947
2905
 
2906
+ def set_pump_energy_price_pattern(self, pump_id: str, pattern: np.ndarray,
2907
+ pattern_id: Optional[str] = None) -> None:
2908
+ """
2909
+ Specifies/sets the energy price pattern of a given pump.
2910
+
2911
+ Overwrites any existing (energy price) patterns of the given pump.
2912
+
2913
+ Parameters
2914
+ ----------
2915
+ pump_id : `str`
2916
+ ID of the pump.
2917
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2918
+ Pattern of multipliers.
2919
+ pattern_id : `str`, optional
2920
+ ID of the pattern.
2921
+ If not specified, 'energy_price_{pump_id}' will be used as the pattern ID.
2922
+
2923
+ The default is None.
2924
+ """
2925
+ if not isinstance(pump_id, str):
2926
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2927
+ if pump_id not in self._sensor_config.pumps:
2928
+ raise ValueError(f"Unknown pump '{pump_id}'")
2929
+ if not isinstance(pattern, np.ndarray):
2930
+ raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
2931
+ f"but no of '{type(pattern)}'")
2932
+ if len(pattern.shape) > 1:
2933
+ raise ValueError("'pattern' must be 1-dimensional")
2934
+ if pattern_id is not None:
2935
+ if not isinstance(pattern_id, str):
2936
+ raise TypeError("'pattern_id' must be an instance of 'str' " +
2937
+ f"but not of '{type(pattern_id)}'")
2938
+ else:
2939
+ pattern_id = f"energy_price_{pump_id}"
2940
+
2941
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
2942
+ if pattern_idx != 0:
2943
+ warnings.warn(f"Overwriting existing pattern '{pattern_id}'")
2944
+
2945
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
2946
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
2947
+ if pattern_idx != 0:
2948
+ warnings.warn(f"Overwriting existing energy price pattern of pump '{pump_id}'")
2949
+
2950
+ self.add_pattern(pattern_id, pattern)
2951
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
2952
+ self.epanet_api.setLinkPumpEPat(pattern_idx)
2953
+
2954
+ def get_pump_energy_price_pattern(self, pump_id: str) -> np.ndarray:
2955
+ """
2956
+ Returns the energy price pattern of a given pump.
2957
+
2958
+ Parameters
2959
+ ----------
2960
+ pump_id : `str`
2961
+ ID of the pump.
2962
+
2963
+ Returns
2964
+ -------
2965
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2966
+ Energy price pattern. None, if none exists.
2967
+ """
2968
+ if not isinstance(pump_id, str):
2969
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2970
+ if pump_id not in self._sensor_config.pumps:
2971
+ raise ValueError(f"Unknown pump '{pump_id}'")
2972
+
2973
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
2974
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
2975
+ if pattern_idx == 0:
2976
+ return None
2977
+ else:
2978
+ pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
2979
+ return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
2980
+ for t in range(pattern_length)])
2981
+
2982
+ def get_pump_energy_price(self, pump_id: str) -> float:
2983
+ """
2984
+ Returns the energy price of a given pump.
2985
+
2986
+ Parameters
2987
+ ----------
2988
+ pump_id : `str`
2989
+ ID of the pump.
2990
+
2991
+ Returns
2992
+ -------
2993
+ `float`
2994
+ Energy price.
2995
+ """
2996
+ if not isinstance(pump_id, str):
2997
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2998
+ if pump_id not in self._sensor_config.pumps:
2999
+ raise ValueError(f"Unknown pump '{pump_id}'")
3000
+
3001
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3002
+ return self.epanet_api.getLinkPumpECost(pump_idx)
3003
+
3004
+ def set_pump_energy_price(self, pump_id, price: float) -> None:
3005
+ """
3006
+ Sets the energy price of a given pump.
3007
+
3008
+ Parameters
3009
+ ----------
3010
+ pump_id : `str`
3011
+ ID of the pump.
3012
+ price : `float`
3013
+ Energy price.
3014
+ """
3015
+ if not isinstance(pump_id, str):
3016
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3017
+ if pump_id not in self._sensor_config.pumps:
3018
+ raise ValueError(f"Unknown pump '{pump_id}'")
3019
+ if not isinstance(price, float):
3020
+ raise TypeError(f"'price' must be an instance of 'float' but not of '{type(price)}'")
3021
+ if price <= 0:
3022
+ raise ValueError("'price' must be positive")
3023
+
3024
+ pump_idx = self._sensor_config.pumps.index(pump_id) + 1
3025
+ pumps_energy_price = self.epanet_api.getLinkPumpECost()
3026
+ pumps_energy_price[pump_idx - 1] = price
3027
+
3028
+ self.epanet_api.setLinkPumpECost(pumps_energy_price)
3029
+
3030
+ def set_initial_link_status(self, link_id: str, status: int) -> None:
3031
+ """
3032
+ Sets the initial status (open or closed) of a given link.
3033
+
3034
+ Parameters
3035
+ ----------
3036
+ link_id : `str`
3037
+ ID of the link.
3038
+ status : `int`
3039
+ Initial status of the link. Must be one of the following EPANET constants:
3040
+
3041
+ - EN_CLOSED = 0
3042
+ - EN_OPEN = 1
3043
+ """
3044
+ if not isinstance(link_id, str):
3045
+ raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
3046
+ if link_id not in self._sensor_config.pumps:
3047
+ raise ValueError("Invalid link ID '{link_id}'")
3048
+ if not isinstance(status, int):
3049
+ raise TypeError(f"'status' must be an instance of 'int' but not of '{type(status)}'")
3050
+ if status not in [ActuatorConstants.EN_CLOSED, ActuatorConstants.EN_OPEN]:
3051
+ raise ValueError(f"Invalid link status '{status}'")
3052
+
3053
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3054
+ self.epanet_api.setLinkInitialStatus(link_idx, status)
3055
+
3056
+ def set_initial_pump_speed(self, pump_id: str, speed: float) -> None:
3057
+ """
3058
+ Sets the initial pump speed of a given pump.
3059
+
3060
+ Parameters
3061
+ ----------
3062
+ pump_id : `str`
3063
+ ID of the pump.
3064
+ speed : `float`
3065
+ Initial speed of the pump.
3066
+ """
3067
+ if not isinstance(pump_id, str):
3068
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3069
+ if pump_id not in self._sensor_config.pumps:
3070
+ raise ValueError("Invalid pump ID '{tank_id}'")
3071
+ if not isinstance(speed, float):
3072
+ raise TypeError(f"'speed' must be an instance of 'int' but not of '{type(speed)}'")
3073
+ if speed < 0:
3074
+ raise ValueError("'speed' can not be negative")
3075
+
3076
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3077
+ self.epanet_api.setLinkInitialSetting(pump_idx, speed)
3078
+
3079
+ def set_initial_tank_level(self, tank_id, level: int) -> None:
3080
+ """
3081
+ Sets the initial water level of a given tank.
3082
+
3083
+ Parameters
3084
+ ----------
3085
+ tank_id : `str`
3086
+ ID of the tank.
3087
+ level : `int`
3088
+ Initial water level in the tank.
3089
+ """
3090
+ if not isinstance(tank_id, str):
3091
+ raise TypeError(f"'tank_id' must be an instance of 'str' but not of '{type(tank_id)}'")
3092
+ if tank_id not in self._sensor_config.tanks:
3093
+ raise ValueError("Invalid tank ID '{tank_id}'")
3094
+ if not isinstance(level, int):
3095
+ raise TypeError(f"'level' must be an instance of 'int' but not of '{type(level)}'")
3096
+ if level < 0:
3097
+ raise ValueError("'level' can not be negative")
3098
+
3099
+ tank_idx = self.epanet_api.getNodeIndex(tank_id)
3100
+ self.epanet_api.setNodeTankInitialLevel(tank_idx, level)
3101
+
2948
3102
  def __warn_if_quality_set(self):
2949
3103
  qual_info = self.epanet_api.getQualityInfo()
2950
3104
  if qual_info.QualityCode != ToolkitConstants.EN_NONE:
@@ -3057,7 +3211,7 @@ class ScenarioSimulator():
3057
3211
  if pattern is None and pattern_id is None:
3058
3212
  raise ValueError("'pattern_id' and 'pattern' can not be None at the same time")
3059
3213
  if pattern_id is None:
3060
- pattern_id = f"quality_source_pattern_node={node_id}"
3214
+ pattern_id = f"qual_src_pat_{node_id}"
3061
3215
 
3062
3216
  node_idx = self.epanet_api.getNodeIndex(node_id)
3063
3217
 
@@ -3065,11 +3219,220 @@ class ScenarioSimulator():
3065
3219
  pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
3066
3220
  else:
3067
3221
  pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
3222
+ if pattern_idx == 0:
3223
+ raise RuntimeError("Failed to add/get pattern! " +
3224
+ "Maybe pattern name contains invalid characters or is too long?")
3068
3225
 
3069
3226
  self.epanet_api.api.ENsetnodevalue(node_idx, ToolkitConstants.EN_SOURCETYPE, source_type)
3070
3227
  self.epanet_api.setNodeSourceQuality(node_idx, source_strength)
3071
3228
  self.epanet_api.setNodeSourcePatternIndex(node_idx, pattern_idx)
3072
3229
 
3230
+ def set_initial_node_quality(self, node_id: str, initial_quality: float) -> None:
3231
+ """
3232
+ Specifies the initial quality at a given node.
3233
+ Quality represents concentration for chemicals, hours for water age,
3234
+ or percent for source tracing.
3235
+
3236
+ Parameters
3237
+ ----------
3238
+ node_id : `str`
3239
+ ID of the node.
3240
+ initial_quality : `float`
3241
+ Initial node quality.
3242
+ """
3243
+ self.set_quality_parameters(initial_quality={node_id, initial_quality})
3244
+
3245
+ def set_quality_parameters(self, initial_quality: Optional[dict[str, float]] = None,
3246
+ order_wall: Optional[int] = None, order_tank: Optional[int] = None,
3247
+ order_bulk: Optional[int] = None,
3248
+ global_wall_reaction_coefficient: Optional[float] = None,
3249
+ global_bulk_reaction_coefficient: Optional[float] = None,
3250
+ local_wall_reaction_coefficient: Optional[dict[str, float]] = None,
3251
+ local_bulk_reaction_coefficient: Optional[dict[str, float]] = None,
3252
+ local_tank_reaction_coefficient: Optional[dict[str, float]] = None,
3253
+ limiting_potential: Optional[float] = None) -> None:
3254
+ """
3255
+ Specifies some parameters of the EPANET quality analysis.
3256
+ Note that those parameters are only relevant for EPANET but not for EPANET-MSX.
3257
+
3258
+ Parameters
3259
+ ----------
3260
+ initial_quality : `dict[str, float]`, optional
3261
+ Specifies the initial quality (value in the dictionary) at nodes
3262
+ (key in the dictionary).
3263
+ Quality represents concentration for chemicals, hours for water age,
3264
+ or percent for source tracing.
3265
+
3266
+ The default is None.
3267
+ order_wall : `int`, optional
3268
+ Specifies the order of reactions occurring in the bulk fluid at pipe walls.
3269
+ Value for wall reactions must be either 0 or 1.
3270
+ If not specified, the default reaction order is 1.0.
3271
+
3272
+ The default is None.
3273
+ order_bulk : `int`, optional
3274
+ Specifies the order of reactions occurring in the bulk fluid in tanks.
3275
+ Value must be either 0 or 1.
3276
+ If not specified, the default reaction order is 1.0.
3277
+
3278
+ The default is None.
3279
+ global_wall_reaction_coefficient : `float`, optional
3280
+ Specifies the global value for all pipe wall reaction coefficients (pipes and tanks).
3281
+ If not specified, the default value is zero.
3282
+
3283
+ The default is None.
3284
+ global_bulk_reaction_coefficient : `float`, optional
3285
+ Specifies the global value for all bulk reaction coefficients (pipes and tanks).
3286
+ If not specified, the default value is zero.
3287
+
3288
+ The default is None.
3289
+ local_wall_reaction_coefficient : `dict[str, float]`, optional
3290
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3291
+
3292
+ The default is None.
3293
+ local_bulk_reaction_coefficient : `dict[str, float]`, optional
3294
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3295
+
3296
+ The default is None.
3297
+ local_tank_reaction_coefficient : `dict[str, float]`, optional
3298
+ Overrides the global reaction coefficients for specific tanks (key in dictionary).
3299
+
3300
+ The default is None.
3301
+ limiting_potential : `float`, optional
3302
+ Specifies that reaction rates are proportional to the difference between the
3303
+ current concentration and some (specified) limiting potential value.
3304
+
3305
+ The default is None.
3306
+ """
3307
+ if initial_quality is not None:
3308
+ if not isinstance(initial_quality, dict):
3309
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]' " +
3310
+ f"but not of '{type(initial_quality)}'")
3311
+ if any(not isinstance(key, str) or not isinstance(value, float)
3312
+ for key, value in initial_quality):
3313
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]'")
3314
+ for node_id, node_init_qual in initial_quality:
3315
+ if node_id not in self._sensor_config.nodes:
3316
+ raise ValueError(f"Invalid node ID '{node_id}'")
3317
+ if node_init_qual < 0:
3318
+ raise ValueError(f"{node_id}: Initial node quality can not be negative")
3319
+
3320
+ init_qual = self.epanet_api.getNodeInitialQuality()
3321
+ for node_id, node_init_qual in initial_quality:
3322
+ node_idx = self.epanet_api.getNodeIndex(node_id) - 1
3323
+ init_qual[node_idx] = node_init_qual
3324
+
3325
+ self.epanet_api.setNodeInitialQuality(init_qual)
3326
+
3327
+ if order_wall is not None:
3328
+ if not isinstance(order_wall, int):
3329
+ raise TypeError("'order_wall' must be an instance of 'int' " +
3330
+ f"but not of '{type(order_wall)}'")
3331
+ if order_wall not in [0, 1]:
3332
+ raise ValueError(f"Invalid value '{order_wall}' for order_wall")
3333
+
3334
+ self.epanet_api.setOptionsPipeWallReactionOrder(order_wall)
3335
+
3336
+ if order_bulk is not None:
3337
+ if not isinstance(order_bulk, int):
3338
+ raise TypeError("'order_bulk' must be an instance of 'int' " +
3339
+ f"but not of '{type(order_bulk)}'")
3340
+ if order_bulk not in [0, 1]:
3341
+ raise ValueError(f"Invalid value '{order_bulk}' for order_bulk")
3342
+
3343
+ self.epanet_api.setOptionsPipeBulkReactionOrder(order_bulk)
3344
+
3345
+ if order_tank is not None:
3346
+ if not isinstance(order_tank, int):
3347
+ raise TypeError("'order_tank' must be an instance of 'int' " +
3348
+ f"but not of '{type(order_tank)}'")
3349
+ if order_tank not in [0, 1]:
3350
+ raise ValueError(f"Invalid value '{order_tank}' for order_wall")
3351
+
3352
+ self.epanet_api.setOptionsTankBulkReactionOrder(order_tank)
3353
+
3354
+ if global_wall_reaction_coefficient is not None:
3355
+ if not isinstance(global_wall_reaction_coefficient, float):
3356
+ raise TypeError("'global_wall_reaction_coefficient' must be an instance of " +
3357
+ f"'float' but not of '{type(global_wall_reaction_coefficient)}'")
3358
+
3359
+ wall_reaction_coeff = self.epanet_api.getLinkWallReactionCoeff()
3360
+ for i in range(len(wall_reaction_coeff)):
3361
+ wall_reaction_coeff[i] = global_wall_reaction_coefficient
3362
+
3363
+ self.epanet_api.setLinkWallReactionCoeff(wall_reaction_coeff)
3364
+
3365
+ if global_bulk_reaction_coefficient is not None:
3366
+ if not isinstance(global_bulk_reaction_coefficient, float):
3367
+ raise TypeError("'global_bulk_reaction_coefficient' must be an instance of " +
3368
+ f"'float' but not of '{type(global_bulk_reaction_coefficient)}'")
3369
+
3370
+ bulk_reaction_coeff = self.epanet_api.getLinkBulkReactionCoeff()
3371
+ for i in range(len(bulk_reaction_coeff)):
3372
+ bulk_reaction_coeff[i] = global_bulk_reaction_coefficient
3373
+
3374
+ self.epanet_api.setLinkBulkReactionCoeff(bulk_reaction_coeff)
3375
+
3376
+ if local_wall_reaction_coefficient is not None:
3377
+ if not isinstance(local_wall_reaction_coefficient, dict):
3378
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3379
+ "of 'dict[str, float]' but not of " +
3380
+ f"'{type(local_wall_reaction_coefficient)}'")
3381
+ if any(not isinstance(key, str) or not isinstance(value, float)
3382
+ for key, value in local_wall_reaction_coefficient):
3383
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3384
+ "of 'dict[str, float]'")
3385
+ for link_id, _ in local_wall_reaction_coefficient:
3386
+ if link_id not in self._sensor_config.links:
3387
+ raise ValueError(f"Invalid link ID '{link_id}'")
3388
+
3389
+ for link_id, link_reaction_coeff in local_wall_reaction_coefficient:
3390
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3391
+ self.epanet_api.setLinkWallReactionCoeff(link_idx, link_reaction_coeff)
3392
+
3393
+ if local_bulk_reaction_coefficient is not None:
3394
+ if not isinstance(local_bulk_reaction_coefficient, dict):
3395
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3396
+ "of 'dict[str, float]' but not of " +
3397
+ f"'{type(local_bulk_reaction_coefficient)}'")
3398
+ if any(not isinstance(key, str) or not isinstance(value, float)
3399
+ for key, value in local_bulk_reaction_coefficient):
3400
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3401
+ "of 'dict[str, float]'")
3402
+ for link_id, _ in local_bulk_reaction_coefficient:
3403
+ if link_id not in self._sensor_config.links:
3404
+ raise ValueError(f"Invalid link ID '{link_id}'")
3405
+
3406
+ for link_id, link_reaction_coeff in local_bulk_reaction_coefficient:
3407
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3408
+ self.epanet_api.setLinkBulkReactionCoeff(link_idx, link_reaction_coeff)
3409
+
3410
+ if local_tank_reaction_coefficient is not None:
3411
+ if not isinstance(local_tank_reaction_coefficient, dict):
3412
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3413
+ "of 'dict[str, float]' but not of " +
3414
+ f"'{type(local_tank_reaction_coefficient)}'")
3415
+ if any(not isinstance(key, str) or not isinstance(value, float)
3416
+ for key, value in local_tank_reaction_coefficient):
3417
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3418
+ "of 'dict[str, float]'")
3419
+ for tank_id, _ in local_tank_reaction_coefficient:
3420
+ if tank_id not in self._sensor_config.tanks:
3421
+ raise ValueError(f"Invalid tank ID '{tank_id}'")
3422
+
3423
+ for tank_id, tank_reaction_coeff in local_tank_reaction_coefficient:
3424
+ tank_idx = self.epanet_api.getNodeTankIndex(tank_id)
3425
+ self.epanet_api.setNodeTankBulkReactionCoeff(tank_idx, tank_reaction_coeff)
3426
+
3427
+ if limiting_potential is not None:
3428
+ if not isinstance(limiting_potential, float):
3429
+ raise TypeError("'limiting_potential' must be an instance of 'float' " +
3430
+ f"but not of '{type(limiting_potential)}'")
3431
+ if limiting_potential < 0:
3432
+ raise ValueError("'limiting_potential' can not be negative")
3433
+
3434
+ self.epanet_api.setOptionsLimitingConcentration(limiting_potential)
3435
+
3073
3436
  def enable_sourcetracing_analysis(self, trace_node_id: str) -> None:
3074
3437
  """
3075
3438
  Set source tracing analysis -- i.e. tracks the percentage of flow from a given node
@@ -3098,6 +3461,8 @@ class ScenarioSimulator():
3098
3461
  """
3099
3462
  Adds a new external (bulk or surface) species injection source at a particular node.
3100
3463
 
3464
+ Only for EPANET-MSX scenarios.
3465
+
3101
3466
  Parameters
3102
3467
  ----------
3103
3468
  species_id : `str`
@@ -3153,3 +3518,81 @@ class ScenarioSimulator():
3153
3518
  self.epanet_api.addMSXPattern(pattern_id, pattern)
3154
3519
  self.epanet_api.setMSXSources(node_id, species_id, source_type_, source_strength,
3155
3520
  pattern_id)
3521
+
3522
+ def set_bulk_species_node_initial_concentrations(self,
3523
+ inital_conc: dict[str, list[tuple[str, float]]]
3524
+ ) -> None:
3525
+ """
3526
+ Species the initial bulk species concentration at nodes.
3527
+
3528
+ Only for EPANET-MSX scenarios.
3529
+
3530
+ Parameters
3531
+ ----------
3532
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3533
+ Initial concentration of species (key) at nodes -- i.e.
3534
+ value: list of node ID and initial concentration.
3535
+ """
3536
+ if not isinstance(inital_conc, dict) or \
3537
+ any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3538
+ for species_id, node_initial_conc in inital_conc.items()) or \
3539
+ any(not isinstance(node_initial_conc, tuple)
3540
+ for node_initial_conc in inital_conc.values()) or \
3541
+ any(not isinstance(node_id, str) or not isinstance(conc, float)
3542
+ for node_id, conc in inital_conc.values()):
3543
+ raise TypeError("'inital_conc' must be an instance of " +
3544
+ "'dict[str, list[tuple[str, float]]'")
3545
+ if any(species_id not in self.sensor_config.bulk_species
3546
+ for species_id in inital_conc.keys()):
3547
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3548
+ if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc.values()):
3549
+ raise ValueError("Unknown node ID in 'inital_conc'")
3550
+ if any(conc < 0 for _, conc in inital_conc.values()):
3551
+ raise ValueError("Initial node concentration can not be negative")
3552
+
3553
+ for species_id, node_initial_conc in inital_conc.items():
3554
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3555
+
3556
+ for node_id, initial_conc in node_initial_conc:
3557
+ node_idx = self.epanet_api.getNodeIndex(node_id)
3558
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_NODE, node_idx, species_idx,
3559
+ initial_conc)
3560
+
3561
+ def set_species_link_initial_concentrations(self,
3562
+ inital_conc: dict[str, list[tuple[str, float]]]
3563
+ ) -> None:
3564
+ """
3565
+ Species the initial (bulk or surface) species concentration at links.
3566
+
3567
+ Only for EPANET-MSX scenarios.
3568
+
3569
+ Parameters
3570
+ ----------
3571
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3572
+ Initial concentration of species (key) at links -- i.e.
3573
+ value: list of link ID and initial concentration.
3574
+ """
3575
+ if not isinstance(inital_conc, dict) or \
3576
+ any(not isinstance(species_id, str) or not isinstance(link_initial_conc, list)
3577
+ for species_id, link_initial_conc in inital_conc.items()) or \
3578
+ any(not isinstance(link_initial_conc, tuple)
3579
+ for link_initial_conc in inital_conc.values()) or \
3580
+ any(not isinstance(link_id, str) or not isinstance(conc, float)
3581
+ for link_id, conc in inital_conc.values()):
3582
+ raise TypeError("'inital_conc' must be an instance of " +
3583
+ "'dict[str, list[tuple[str, float]]'")
3584
+ if any(species_id not in self.sensor_config.bulk_species
3585
+ for species_id in inital_conc.keys()):
3586
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3587
+ if any(link_id not in self.sensor_config.links for link_id, _ in inital_conc.values()):
3588
+ raise ValueError("Unknown link ID in 'inital_conc'")
3589
+ if any(conc < 0 for _, conc in inital_conc.values()):
3590
+ raise ValueError("Initial link concentration can not be negative")
3591
+
3592
+ for species_id, link_initial_conc in inital_conc.items():
3593
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3594
+
3595
+ for link_id, initial_conc in link_initial_conc:
3596
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3597
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_LINK, link_idx, species_idx,
3598
+ initial_conc)