Coverage for wsimod/nodes/wtw.py: 14%
170 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-01-11 16:39 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2024-01-11 16:39 +0000
1# -*- coding: utf-8 -*-
2"""Created on Mon Nov 15 14:20:36 2021.
4@author: bdobson
5Converted to totals on 2022-05-03
6"""
7from wsimod.core import constants
8from wsimod.nodes.nodes import Node, Tank
11class WTW(Node):
12 """"""
14 def __init__(
15 self,
16 name,
17 treatment_throughput_capacity=10,
18 process_parameters={},
19 liquor_multiplier={},
20 percent_solids=0.0002,
21 ):
22 """Generic treatment processes that apply temperature a sensitive transform of
23 pollutants into liquor and solids (behaviour depends on subclass). Push requests
24 are stored in the current_input state variable, but treatment must be triggered
25 with treat_current_input. This treated water is stored in the discharge_holder
26 state variable, which will be sent different depending on FWTW/WWTW.
28 Args:
29 name (str): Node name
30 treatment_throughput_capacity (float, optional): Amount of volume per
31 timestep of water that can be treated. Defaults to 10.
32 process_parameters (dict, optional): Dict of dicts for each pollutant.
33 Top level key describes pollutant. Next level key describes the
34 constant portion of the transform and the temperature sensitive
35 exponent portion (see core.py/DecayObj for more detailed
36 explanation). Defaults to {}.
37 liquor_multiplier (dict, optional): Keys for each pollutant that
38 describes how much influent becomes liquor. Defaults to {}.
39 percent_solids (float, optional): Proportion of volume that becomes solids.
40 All pollutants that do not become effluent or liquor become solids.
41 Defaults to 0.0002.
43 Functions intended to call in orchestration:
44 None (use FWTW or WWTW subclass)
46 Key assumptions:
47 - Throughput can be modelled entirely with a set capacity.
48 - Pollutant reduction for the entire treatment process can be modelled
49 primarily with a single (temperature sensitive) transformation for
50 each pollutant.
51 - Liquor and solids are tracked and calculated with proportional
52 multiplier parameters.
54 Input data and parameter requirements:
55 - `treatment_throughput_capacity`
56 _Units_: cubic metres/timestep
57 - `process_parameters` contains the constant (non temperature
58 sensitive) and exponent (temperature sensitive) transformations
59 applied to treated water for each pollutant.
60 _Units_: -
61 - `liquor_multiplier` and `percent_solids` describe the proportion of
62 throughput that goes to liquor/solids.
63 """
64 # Set/Default parameters
65 self.treatment_throughput_capacity = treatment_throughput_capacity
66 if len(process_parameters) > 0:
67 self.process_parameters = process_parameters
68 else:
69 self.process_parameters = {
70 x: {"constant": 0.01, "exponent": 1.001}
71 for x in constants.ADDITIVE_POLLUTANTS
72 }
73 if len(liquor_multiplier) > 0:
74 self.liquor_multiplier = liquor_multiplier
75 else:
76 self.liquor_multiplier = {x: 0.7 for x in constants.ADDITIVE_POLLUTANTS}
77 self.liquor_multiplier["volume"] = 0.03
79 self.percent_solids = percent_solids
81 # Update args
82 super().__init__(name)
84 self.process_parameters["volume"] = {
85 "constant": 1 - self.percent_solids - self.liquor_multiplier["volume"]
86 }
88 # Update handlers
89 self.push_set_handler["default"] = self.push_set_deny
90 self.push_check_handler["default"] = self.push_check_deny
92 # Initialise parameters
93 self.current_input = self.empty_vqip()
94 self.treated = self.empty_vqip()
95 self.liquor = self.empty_vqip()
96 self.solids = self.empty_vqip()
98 def get_excess_throughput(self):
99 """How much excess treatment capacity is there.
101 Returns:
102 (float): Amount of volume that can still be treated this timestep
103 """
104 return max(self.treatment_throughput_capacity - self.current_input["volume"], 0)
106 def treat_current_input(self):
107 """Run treatment processes this timestep, including temperature sensitive
108 transforms, liquoring, solids."""
109 # Treat current input
110 influent = self.copy_vqip(self.current_input)
112 # Calculate effluent, liquor and solids
113 discharge_holder = self.empty_vqip()
115 # Assume non-additive pollutants are unchanged in discharge and are
116 # proportionately mixed in liquor
117 for key in constants.NON_ADDITIVE_POLLUTANTS:
118 discharge_holder[key] = influent[key]
119 self.liquor[key] = (
120 self.liquor[key] * self.liquor["volume"]
121 + influent[key] * influent["volume"] * self.liquor_multiplier["volume"]
122 ) / (
123 self.liquor["volume"]
124 + influent["volume"] * self.liquor_multiplier["volume"]
125 )
127 # TODO this should probably just be for process_parameters.keys() to avoid
128 # having to declare non changing parameters
129 # TODO should the liquoring be temperature sensitive too? As it is the solids
130 # will take the brunt of the temperature variability which maybe isn't sensible
131 for key in constants.ADDITIVE_POLLUTANTS + ["volume"]:
132 if key != "volume":
133 # Temperature sensitive transform
134 temp_factor = self.process_parameters[key]["exponent"] ** (
135 constants.DECAY_REFERENCE_TEMPERATURE - influent["temperature"]
136 )
137 else:
138 temp_factor = 1
139 # Calculate discharge
140 discharge_holder[key] = (
141 influent[key] * self.process_parameters[key]["constant"] * temp_factor
142 )
143 # Calculate liquor
144 self.liquor[key] = influent[key] * self.liquor_multiplier[key]
146 # Calculate solids volume
147 self.solids["volume"] = influent["volume"] * self.percent_solids
149 # All remaining pollutants go to solids
150 for key in constants.ADDITIVE_POLLUTANTS:
151 self.solids[key] = influent[key] - discharge_holder[key] - self.liquor[key]
153 # Blend with any existing discharge
154 self.treated = self.sum_vqip(self.treated, discharge_holder)
156 if self.treated["volume"] > self.current_input["volume"]:
157 print("more treated than input")
159 def end_timestep(self):
160 """"""
161 # Reset state variables
162 self.current_input = self.empty_vqip()
163 self.treated = self.empty_vqip()
166class WWTW(WTW):
167 """"""
169 def __init__(
170 self,
171 stormwater_storage_capacity=10,
172 stormwater_storage_area=1,
173 stormwater_storage_elevation=10,
174 **kwargs,
175 ):
176 """A wastewater treatment works wrapper for WTW. Contains a temporary stormwater
177 storage tank. Liquor is combined with current_effluent and re- treated while
178 solids leave the model.
180 Args:
181 stormwater_storage_capacity (float, optional): Capacity of stormwater tank.
182 Defaults to 10.
183 stormwater_storage_area (float, optional): Area of stormwater tank.
184 Defaults to 1.
185 stormwater_storage_elevation (float, optional): Datum of stormwater tank.
186 Defaults to 10.
188 Functions intended to call in orchestration:
189 calculate_discharge
191 make_discharge
193 Key assumptions:
194 - See `wtw.py/WTW` for treatment.
195 - When `treatment_throughput_capacity` is exceeded, water is first sent
196 to a stormwater storage tank before denying pushes. Leftover water
197 in this tank aims to be treated in subsequent timesteps.
198 - Can be pulled from to simulate active wastewater effluent use.
200 Input data and parameter requirements:
201 - See `wtw.py/WTW` for treatment.
202 - Stormwater tank `capacity`, `area`, and `datum`.
203 _Units_: cubic metres, squared metres, metres
204 """
205 # Set parameters
206 self.stormwater_storage_capacity = stormwater_storage_capacity
207 self.stormwater_storage_area = stormwater_storage_area
208 self.stormwater_storage_elevation = stormwater_storage_elevation
210 # Update args
211 super().__init__(**kwargs)
213 self.end_timestep = self.end_timestep_
215 # Update handlers
216 self.pull_set_handler["default"] = self.pull_set_reuse
217 self.pull_check_handler["default"] = self.pull_check_reuse
218 self.push_set_handler["Sewer"] = self.push_set_sewer
219 self.push_check_handler["Sewer"] = self.push_check_sewer
220 self.push_check_handler["default"] = self.push_check_sewer
221 self.push_set_handler["default"] = self.push_set_sewer
223 # Create tank
224 self.stormwater_tank = Tank(
225 capacity=self.stormwater_storage_capacity,
226 area=self.stormwater_storage_area,
227 datum=self.stormwater_storage_elevation,
228 )
230 # Initialise states
231 self.liquor_ = self.empty_vqip()
232 self.previous_input = self.empty_vqip()
233 self.current_input = self.empty_vqip() # TODO is this not done in WTW?
235 # Mass balance
236 self.mass_balance_out.append(lambda: self.solids) # Assume these go to landfill
237 self.mass_balance_ds.append(lambda: self.stormwater_tank.ds())
238 self.mass_balance_ds.append(
239 lambda: self.ds_vqip(self.liquor, self.liquor_)
240 ) # Change in liquor
242 def calculate_discharge(self):
243 """Clear stormwater tank if possible, and call treat_current_input."""
244 # Run WWTW model
246 # Try to clear stormwater
247 # TODO (probably more tidy to use push_set_sewer? though maybe less
248 # computationally efficient)
249 excess = self.get_excess_throughput()
250 if (self.stormwater_tank.get_avail()["volume"] > constants.FLOAT_ACCURACY) & (
251 excess > constants.FLOAT_ACCURACY
252 ):
253 to_pull = min(excess, self.stormwater_tank.get_avail()["volume"])
254 to_pull = self.v_change_vqip(self.stormwater_tank.storage, to_pull)
255 cleared_stormwater = self.stormwater_tank.pull_storage(to_pull)
256 self.current_input = self.sum_vqip(self.current_input, cleared_stormwater)
258 # Run processes
259 self.current_input = self.sum_vqip(self.current_input, self.liquor)
260 self.treat_current_input()
262 def make_discharge(self):
263 """Discharge treated effluent."""
264 reply = self.push_distributed(self.treated)
265 self.treated = self.empty_vqip()
266 if reply["volume"] > constants.FLOAT_ACCURACY:
267 _ = self.stormwater_tank.push_storage(reply, force=True)
268 print("WWTW couldnt push")
270 def push_check_sewer(self, vqip=None):
271 """Check throughput and stormwater tank capacity.
273 Args:
274 vqip (dict, optional): A VQIP that can be used to limit the volume in
275 the return value (only volume key is used). Defaults to None.
277 Returns:
278 (dict): excess
279 """
280 # Get excess
281 excess_throughput = self.get_excess_throughput()
282 excess_tank = self.stormwater_tank.get_excess()
283 # Combine tank and throughput
284 vol = excess_tank["volume"] + excess_throughput
285 # Update volume
286 if vqip is None:
287 vqip = self.empty_vqip()
288 else:
289 vol = min(vol, vqip["volume"])
291 return self.v_change_vqip(vqip, vol)
293 def push_set_sewer(self, vqip):
294 """Receive water, first try to update current_input, and then stormwater tank.
296 Args:
297 vqip (dict): A VQIP amount to be treated and then stored
299 Returns:
300 (dict): A VQIP amount of water that was not treated
301 """
302 # Receive water from sewers
303 vqip = self.copy_vqip(vqip)
304 # Check if can directly be treated
305 sent_direct = self.get_excess_throughput()
307 sent_direct = min(sent_direct, vqip["volume"])
309 sent_direct = self.v_change_vqip(vqip, sent_direct)
311 self.current_input = self.sum_vqip(self.current_input, sent_direct)
313 if sent_direct["volume"] == vqip["volume"]:
314 # If all added to input, no problem
315 return self.empty_vqip()
317 # Next try temporary storage
318 vqip = self.v_change_vqip(vqip, vqip["volume"] - sent_direct["volume"])
320 vqip = self.stormwater_tank.push_storage(vqip)
322 if vqip["volume"] < constants.FLOAT_ACCURACY:
323 return self.empty_vqip()
324 else:
325 # TODO what to do here ???
326 return vqip
328 def pull_set_reuse(self, vqip):
329 """Enables WWTW to receive pulls of the treated water (i.e., for wastewater
330 reuse or satisfaction of environmental flows). Intended to be called in between
331 calculate_discharge and make_discharge.
333 Args:
334 vqip (dict): A VQIP amount to be pulled (only 'volume' key is used)
336 Returns:
337 reply (dict): Amount of water that has been pulled
338 """
339 # Satisfy request with treated (volume)
340 reply_vol = min(vqip["volume"], self.treated["volume"])
341 # Update pollutants
342 reply = self.v_change_vqip(self.treated, reply_vol)
343 # Update treated
344 self.treated = self.v_change_vqip(
345 self.treated, self.treated["volume"] - reply_vol
346 )
347 return reply
349 def pull_check_reuse(self, vqip=None):
350 """Pull check available water. Simply returns the previous timestep's treated
351 throughput. This is of course inaccurate (which may lead to slightly longer
352 calulcations), but it is much more flexible. This hasn't been recently tested so
353 it might be that returning treated would be fine (and more accurate!).
355 Args:
356 vqip (dict, optional): A VQIP that can be used to limit the volume in
357 the return value (only volume key is used). Defaults to None.
359 Returns:
360 (dict): A VQIP amount of water available. Currently just the previous
361 timestep's treated throughput
362 """
363 # Respond to request of water for reuse/MRF
364 return self.copy_vqip(self.treated)
366 def end_timestep_(self):
367 """End timestep function to update state variables."""
368 self.liquor_ = self.copy_vqip(self.liquor)
369 self.previous_input = self.copy_vqip(self.current_input)
370 self.current_input = self.empty_vqip()
371 self.solids = self.empty_vqip()
372 self.stormwater_tank.end_timestep()
375class FWTW(WTW):
376 """"""
378 def __init__(
379 self,
380 service_reservoir_storage_capacity=10,
381 service_reservoir_storage_area=1,
382 service_reservoir_storage_elevation=10,
383 service_reservoir_initial_storage=0,
384 data_input_dict={},
385 **kwargs,
386 ):
387 """A freshwater treatment works wrapper for WTW. Contains service reservoirs
388 that treated water is released to and pulled from. Cannot allow deficit (thus
389 any deficit is satisfied by water entering the model 'via other means'). Liquor
390 and solids are sent to sewers.
392 Args:
393 service_reservoir_storage_capacity (float, optional): Capacity of service
394 reservoirs. Defaults to 10.
395 service_reservoir_storage_area (float, optional): Area of service
396 reservoirs. Defaults to 1.
397 service_reservoir_storage_elevation (float, optional): Datum of service
398 reservoirs. Defaults to 10.
399 service_reservoir_initial_storage (float or dict, optional): initial
400 storage of service reservoirs (see nodes.py/Tank for details).
401 Defaults to 0.
402 data_input_dict (dict, optional): Dictionary of data inputs relevant for
403 the node (though I don't think it is used). Defaults to {}.
405 Functions intended to call in orchestration:
406 treat_water
408 Key assumptions:
409 - See `wtw.py/WTW` for treatment.
410 - Stores treated water in a service reservoir tank, with a single tank
411 per `FWTW` node.
412 - Aims to satisfy a throughput that would top up the service reservoirs
413 until full.
414 - Currently, will not allow a deficit, thus introducing water from
415 'other measures' if pulls cannot fulfil demand. Behaviour under a
416 deficit should be determined and validated before introducing.
418 Input data and parameter requirements:
419 - See `wtw.py/WTW` for treatment.
420 - Service reservoir tank `capacity`, `area`, and `datum`.
421 _Units_: cubic metres, squared metres, metres
422 """
423 # Default parameters
424 self.service_reservoir_storage_capacity = service_reservoir_storage_capacity
425 self.service_reservoir_storage_area = service_reservoir_storage_area
426 self.service_reservoir_storage_elevation = service_reservoir_storage_elevation
427 self.service_reservoir_initial_storage = service_reservoir_initial_storage
428 # TODO don't think data_input_dict is used
429 self.data_input_dict = data_input_dict
431 # Update args
432 super().__init__(**kwargs)
433 self.end_timestep = self.end_timestep_
435 # Update handlers
436 self.pull_set_handler["default"] = self.pull_set_fwtw
437 self.pull_check_handler["default"] = self.pull_check_fwtw
439 self.push_set_handler["default"] = self.push_set_deny
440 self.push_check_handler["default"] = self.push_check_deny
442 # Initialise parameters
443 self.total_deficit = self.empty_vqip()
444 self.total_pulled = self.empty_vqip()
445 self.previous_pulled = self.empty_vqip()
446 self.unpushed_sludge = self.empty_vqip()
448 # Create tanks
449 self.service_reservoir_tank = Tank(
450 capacity=self.service_reservoir_storage_capacity,
451 area=self.service_reservoir_storage_area,
452 datum=self.service_reservoir_storage_elevation,
453 initial_storage=self.service_reservoir_initial_storage,
454 )
455 # self.service_reservoir_tank.storage['volume'] =
456 # self.service_reservoir_inital_storage
457 # self.service_reservoir_tank.storage_['volume'] =
458 # self.service_reservoir_inital_storage
460 # Mass balance
461 self.mass_balance_in.append(lambda: self.total_deficit)
462 self.mass_balance_ds.append(lambda: self.service_reservoir_tank.ds())
463 self.mass_balance_out.append(lambda: self.unpushed_sludge)
465 def treat_water(self):
466 """Pulls water, aiming to fill service reservoirs, calls WTW
467 treat_current_input, avoids deficit, sends liquor and solids to sewers."""
468 # Calculate how much water is needed
469 target_throughput = self.service_reservoir_tank.get_excess()
470 target_throughput = min(
471 target_throughput["volume"], self.treatment_throughput_capacity
472 )
474 # Pull water
475 throughput = self.pull_distributed({"volume": target_throughput})
477 # Calculate deficit (assume is equal to difference between previous treated
478 # throughput and current throughput)
479 # TODO think about this a bit more
480 deficit = max(target_throughput - throughput["volume"], 0)
481 # deficit = max(self.previous_pulled['volume'] - throughput['volume'], 0)
482 deficit = self.v_change_vqip(self.previous_pulled, deficit)
484 # Introduce deficit
485 self.current_input = self.sum_vqip(throughput, deficit)
487 # Track deficit
488 self.total_deficit = self.sum_vqip(self.total_deficit, deficit)
490 if self.total_deficit["volume"] > constants.FLOAT_ACCURACY:
491 print(
492 "Service reservoirs not filled at {0} on {1}".format(self.name, self.t)
493 )
495 # Run treatment processes
496 self.treat_current_input()
498 # Discharge liquor and solids to sewers
499 push_back = self.sum_vqip(self.liquor, self.solids)
500 rejected = self.push_distributed(push_back, of_type="Sewer")
501 self.unpushed_sludge = self.sum_vqip(self.unpushed_sludge, rejected)
502 if rejected["volume"] > constants.FLOAT_ACCURACY:
503 print("nowhere for sludge to go")
505 # Send water to service reservoirs
506 excess = self.service_reservoir_tank.push_storage(self.treated)
507 _ = self.service_reservoir_tank.push_storage(excess, force=True)
508 if excess["volume"] > 0:
509 print("excess treated water")
511 def pull_check_fwtw(self, vqip=None):
512 """Pull checks query service reservoirs.
514 Args:
515 vqip (dict, optional): A VQIP that can be used to limit the volume in
516 the return value (only volume key is used). Defaults to None.
518 Returns:
519 (dict): A VQIP of availability in service reservoirs
520 """
521 return self.service_reservoir_tank.get_avail(vqip)
523 def pull_set_fwtw(self, vqip):
524 """Pull treated water from service reservoirs.
526 Args:
527 vqip (dict): a VQIP amount to pull
529 Returns:
530 pulled (dict): A VQIP amount that was successfully pulled
531 """
532 # Pull
533 pulled = self.service_reservoir_tank.pull_storage(vqip)
534 # Update total_pulled this timestep
535 self.total_pulled = self.sum_vqip(self.total_pulled, pulled)
536 return pulled
538 def end_timestep_(self):
539 """Update state variables."""
540 self.service_reservoir_tank.end_timestep()
541 self.total_deficit = self.empty_vqip()
542 self.previous_pulled = self.copy_vqip(self.total_pulled)
543 self.total_pulled = self.empty_vqip()
544 self.treated = self.empty_vqip()
545 self.unpushed_sludge = self.empty_vqip()
547 def reinit(self):
548 """Call tank reinit."""
549 self.service_reservoir_tank.reinit()