Coverage for wsimod\nodes\tanks.py: 35%
184 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-30 14:52 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-30 14:52 +0000
1"""Module for defining tanks."""
3from typing import Any, Dict
5from wsimod.arcs.arcs import AltQueueArc, DecayArcAlt
6from wsimod.core import constants
7from wsimod.core.core import DecayObj, WSIObj
10class Tank(WSIObj):
11 """"""
13 def __init__(self, capacity=0, area=1, datum=10, initial_storage=0):
14 """A standard storage object.
16 Args:
17 capacity (float, optional): Volumetric tank capacity. Defaults to 0.
18 area (float, optional): Area of tank. Defaults to 1.
19 datum (float, optional): Datum of tank base (not currently used in any
20 functions). Defaults to 10.
21 initial_storage (optional): Initial storage for tank.
22 float: Tank will be initialised with zero pollutants and the float
23 as volume
24 dict: Tank will be initialised with this VQIP
25 Defaults to 0 (i.e., no volume, no pollutants).
26 """
27 # Set parameters
28 self.capacity = capacity
29 self.area = area
30 self.datum = datum
31 self.initial_storage = initial_storage
33 WSIObj.__init__(self) # Not sure why I do this rather than super()
35 # TODO I don't think the outer if statement is needed
36 if "initial_storage" in dir(self):
37 if isinstance(self.initial_storage, dict):
38 # Assume dict is VQIP describing storage
39 self.storage = self.copy_vqip(self.initial_storage)
40 self.storage_ = self.copy_vqip(
41 self.initial_storage
42 ) # Lagged storage for mass balance
43 else:
44 # Assume number describes initial stroage
45 self.storage = self.v_change_vqip(
46 self.empty_vqip(), self.initial_storage
47 )
48 self.storage_ = self.v_change_vqip(
49 self.empty_vqip(), self.initial_storage
50 ) # Lagged storage for mass balance
51 else:
52 self.storage = self.empty_vqip()
53 self.storage_ = self.empty_vqip() # Lagged storage for mass balance
55 def apply_overrides(self, overrides: Dict[str, Any] = {}):
56 """Apply overrides to the tank.
58 Enables a user to override any of the following parameters:
59 area, capacity, datum.
61 Args:
62 overrides (dict, optional): Dictionary of overrides. Defaults to {}.
63 """
64 self.capacity = overrides.pop("capacity", self.capacity)
65 self.area = overrides.pop("area", self.area)
66 self.datum = overrides.pop("datum", self.datum)
67 if len(overrides) > 0:
68 print(f"No override behaviour defined for: {overrides.keys()}")
70 def ds(self):
71 """Should be called by parent object to get change in storage.
73 Returns:
74 (dict): Change in storage
75 """
76 return self.ds_vqip(self.storage, self.storage_)
78 def pull_ponded(self):
79 """Pull any volume that is above the tank's capacity.
81 Returns:
82 ponded (vqip): Amount of ponded water that has been removed from the
83 tank
85 Examples:
86 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
87 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 10,
88 'phosphate' : 0.2})
89 >>> print(my_tank.storage)
90 {'volume' : 10, 'phosphate' : 0.2}
91 >>> print(my_tank.pull_ponded())
92 {'volume' : 1, 'phosphate' : 0.02}
93 >>> print(my_tank.storage)
94 {'volume' : 9, 'phosphate' : 0.18}
95 """
96 # Get amount
97 ponded = max(self.storage["volume"] - self.capacity, 0)
98 # Pull from tank
99 ponded = self.pull_storage({"volume": ponded})
100 return ponded
102 def get_avail(self, vqip=None):
103 """Get minimum of the amount of water in storage and vqip (if provided).
105 Args:
106 vqip (dict, optional): Maximum water required (only 'volume' is used).
107 Defaults to None.
109 Returns:
110 reply (dict): Water available
112 Examples:
113 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
114 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 10,
115 'phosphate' : 0.2})
116 >>> print(my_tank.storage)
117 {'volume' : 10, 'phosphate' : 0.2}
118 >>> print(my_tank.get_avail())
119 {'volume' : 10, 'phosphate' : 0.2}
120 >>> print(my_tank.get_avail({'volume' : 1}))
121 {'volume' : 1, 'phosphate' : 0.02}
122 """
123 reply = self.copy_vqip(self.storage)
124 if vqip is None:
125 # Return storage
126 return reply
127 else:
128 # Adjust storage pollutants to match volume in vqip
129 reply = self.v_change_vqip(reply, min(reply["volume"], vqip["volume"]))
130 return reply
132 def get_excess(self, vqip=None):
133 """Get difference between current storage and tank capacity.
135 Args:
136 vqip (dict, optional): Maximum capacity required (only 'volume' is
137 used). Defaults to None.
139 Returns:
140 (dict): Difference available
142 Examples:
143 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
144 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 5,
145 'phosphate' : 0.2})
146 >>> print(my_tank.get_excess())
147 {'volume' : 4, 'phosphate' : 0.16}
148 >>> print(my_tank.get_excess({'volume' : 2}))
149 {'volume' : 2, 'phosphate' : 0.08}
150 """
151 vol = max(self.capacity - self.storage["volume"], 0)
152 if vqip is not None:
153 vol = min(vqip["volume"], vol)
155 # Adjust storage pollutants to match volume in vqip
156 # TODO the v_change_vqip in the reply here is a weird default (if a VQIP is not
157 # provided)
158 return self.v_change_vqip(self.storage, vol)
160 def push_storage(self, vqip, force=False):
161 """Push water into tank, updating the storage VQIP. Force argument can be used
162 to ignore tank capacity.
164 Args:
165 vqip (dict): VQIP amount to be pushed
166 force (bool, optional): Argument used to cause function to ignore tank
167 capacity, possibly resulting in pooling. Defaults to False.
169 Returns:
170 reply (dict): A VQIP of water not successfully pushed to the tank
172 Examples:
173 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
174 >>> constants.POLLUTANTS = ['phosphate']
175 >>> constants.NON_ADDITIVE_POLLUTANTS = []
176 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 5,
177 'phosphate' : 0.2})
178 >>> my_push = {'volume' : 10, 'phosphate' : 0.5}
179 >>> reply = my_tank.push_storage(my_push)
180 >>> print(reply)
181 {'volume' : 6, 'phosphate' : 0.3}
182 >>> print(my_tank.storage)
183 {'volume': 9.0, 'phosphate': 0.4}
184 >>> print(my_tank.push_storage(reply, force = True))
185 {'phosphate': 0, 'volume': 0}
186 >>> print(my_tank.storage)
187 {'volume': 15.0, 'phosphate': 0.7}
188 """
189 if force:
190 # Directly add request to storage
191 self.storage = self.sum_vqip(self.storage, vqip)
192 return self.empty_vqip()
194 # Check whether request can be met
195 excess = self.get_excess()["volume"]
197 # Adjust accordingly
198 reply = max(vqip["volume"] - excess, 0)
199 reply = self.v_change_vqip(vqip, reply)
200 entered = self.v_change_vqip(vqip, vqip["volume"] - reply["volume"])
202 # Update storage
203 self.storage = self.sum_vqip(self.storage, entered)
205 return reply
207 def pull_storage(self, vqip):
208 """Pull water from tank, updating the storage VQIP. Pollutants are removed from
209 tank in proportion to 'volume' in vqip (pollutant values in vqip are ignored).
211 Args:
212 vqip (dict): VQIP amount to be pulled, (only 'volume' key is needed)
214 Returns:
215 reply (dict): A VQIP water successfully pulled from the tank
217 Examples:
218 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
219 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 5,
220 'phosphate' : 0.2})
221 >>> print(my_tank.pull_storage({'volume' : 6}))
222 {'volume': 5.0, 'phosphate': 0.2}
223 >>> print(my_tank.storage)
224 {'volume': 0, 'phosphate': 0}
225 """
226 # Pull from Tank by volume (taking pollutants in proportion)
227 if self.storage["volume"] == 0:
228 return self.empty_vqip()
230 # Adjust based on available volume
231 reply = min(vqip["volume"], self.storage["volume"])
233 # Update reply to vqip (in proportion to concentration in storage)
234 reply = self.v_change_vqip(self.storage, reply)
236 # Extract from storage
237 self.storage = self.extract_vqip(self.storage, reply)
239 return reply
241 def pull_pollutants(self, vqip):
242 """Pull water from tank, updating the storage VQIP. Pollutants are removed from
243 tank in according to their values in vqip.
245 Args:
246 vqip (dict): VQIP amount to be pulled
248 Returns:
249 vqip (dict): A VQIP water successfully pulled from the tank
251 Examples:
252 >>> constants.ADDITIVE_POLLUTANTS = ['phosphate']
253 >>> my_tank = Tank(capacity = 9, initial_storage = {'volume' : 5,
254 'phosphate' : 0.2})
255 >>> print(my_tank.pull_pollutants({'volume' : 2, 'phosphate' : 0.15}))
256 {'volume': 2.0, 'phosphate': 0.15}
257 >>> print(my_tank.storage)
258 {'volume': 3, 'phosphate': 0.05}
259 """
260 # Adjust based on available mass
261 for pol in constants.ADDITIVE_POLLUTANTS + ["volume"]:
262 vqip[pol] = min(self.storage[pol], vqip[pol])
264 # Extract from storage
265 self.storage = self.extract_vqip(self.storage, vqip)
266 return vqip
268 def get_head(self, datum=None, non_head_storage=0):
269 """Area volume calculation for head calcuations. Datum and storage that does not
270 contribute to head can be specified.
272 Args:
273 datum (float, optional): Value to add to pressure head in tank.
274 Defaults to None.
275 non_head_storage (float, optional): Amount of storage that does
276 not contribute to generation of head. The tank must exceed
277 this value to generate any pressure head. Defaults to 0.
279 Returns:
280 head (float): Total head in tank
282 Examples:
283 >>> my_tank = Tank(datum = 10, initial_storage = 5, capacity = 10, area = 2)
284 >>> print(my_tank.get_head())
285 12.5
286 >>> print(my_tank.get_head(non_head_storage = 1))
287 12
288 >>> print(my_tank.get_head(non_head_storage = 1, datum = 0))
289 2
290 """
291 # If datum not provided use object datum
292 if datum is None:
293 datum = self.datum
295 # Calculate pressure head generating storage
296 head_storage = max(self.storage["volume"] - non_head_storage, 0)
298 # Perform head calculation
299 head = head_storage / self.area + datum
301 return head
303 def evaporate(self, evap):
304 """Wrapper for v_distill_vqip to apply a volumetric subtraction from tank
305 storage. Volume removed from storage and no change in pollutant values.
307 Args:
308 evap (float): Volume to evaporate
310 Returns:
311 evap (float): Volumetric amount of evaporation successfully removed
312 """
313 avail = self.get_avail()["volume"]
315 evap = min(evap, avail)
316 self.storage = self.v_distill_vqip(self.storage, evap)
317 return evap
319 ##Old function no longer needed (check it is not used anywhere and remove)
320 def push_total(self, vqip):
321 """
323 Args:
324 vqip:
326 Returns:
328 """
329 self.storage = self.sum_vqip(self.storage, vqip)
330 return self.empty_vqip()
332 ##Old function no longer needed (check it is not used anywhere and remove)
333 def push_total_c(self, vqip):
334 """
336 Args:
337 vqip:
339 Returns:
341 """
342 # Push vqip to storage where pollutants are given as a concentration rather
343 # than storage
344 vqip = self.concentration_to_total(self.vqip)
345 self.storage = self.sum_vqip(self.storage, vqip)
346 return self.empty_vqip()
348 def end_timestep(self):
349 """Function to be called by parent object, tracks previously timestep's
350 storage."""
351 self.storage_ = self.copy_vqip(self.storage)
353 def reinit(self):
354 """Set storage to an empty VQIP."""
355 self.storage = self.empty_vqip()
356 self.storage_ = self.empty_vqip()
359class ResidenceTank(Tank):
360 """"""
362 def __init__(self, residence_time=2, **kwargs):
363 """A tank that has a residence time property that limits storage pulled from the
364 'pull_outflow' function.
366 Args:
367 residence_time (float, optional): Residence time, in theory given
368 in timesteps, in practice it just means that storage /
369 residence time can be pulled each time pull_outflow is called.
370 Defaults to 2.
371 """
372 self.residence_time = residence_time
373 super().__init__(**kwargs)
375 def apply_overrides(self, overrides: Dict[str, Any] = {}):
376 """Apply overrides to the residencetank.
378 Enables a user to override any of the following parameters:
379 residence_time.
381 Args:
382 overrides (dict, optional): Dictionary of overrides. Defaults to {}.
383 """
384 self.residence_time = overrides.pop("residence_time", self.residence_time)
385 super().apply_overrides(overrides)
387 def pull_outflow(self):
388 """Pull storage by residence time from the tank, updating tank storage.
390 Returns:
391 outflow (dict): A VQIP with volume of pulled volume and pollutants
392 proportionate to the tank's pollutants
393 """
394 # Calculate outflow
395 outflow = self.storage["volume"] / self.residence_time
396 # Update pollutant amounts
397 outflow = self.v_change_vqip(self.storage, outflow)
398 # Remove from tank
399 outflow = self.pull_storage(outflow)
400 return outflow
403class DecayTank(Tank, DecayObj):
404 """"""
406 def __init__(self, decays={}, parent=None, **kwargs):
407 """A tank that has DecayObj functions. Decay occurs in end_timestep, after
408 updating state variables. In this sense, decay is occurring at the very
409 beginning of the timestep.
411 Args:
412 decays (dict): A dict of dicts containing a key for each pollutant that
413 decays and, within that, a key for each parameter (a constant and
414 exponent)
415 parent (object): An object that can be used to read temperature data from
416 """
417 # Store parameters
418 self.parent = parent
420 # Initialise Tank
421 Tank.__init__(self, **kwargs)
423 # Initialise decay object
424 DecayObj.__init__(self, decays)
426 # Update timestep and ds functions
427 self.end_timestep = self.end_timestep_decay
428 self.ds = self.decay_ds
430 def apply_overrides(self, overrides: Dict[str, Any] = {}):
431 """Apply overrides to the decaytank.
433 Enables a user to override any of the following parameters:
434 decays.
436 Args:
437 overrides (dict, optional): Dictionary of overrides. Defaults to {}.
438 """
439 self.decays.update(overrides.pop("decays", {}))
440 super().apply_overrides(overrides)
442 def end_timestep_decay(self):
443 """Update state variables and call make_decay."""
444 self.total_decayed = self.empty_vqip()
445 self.storage_ = self.copy_vqip(self.storage)
447 self.storage = self.make_decay(self.storage)
449 def decay_ds(self):
450 """Track storage and amount decayed.
452 Returns:
453 ds (dict): A VQIP of change in storage and total decayed
454 """
455 ds = self.ds_vqip(self.storage, self.storage_)
456 ds = self.sum_vqip(ds, self.total_decayed)
457 return ds
460class QueueTank(Tank):
461 """"""
463 def __init__(self, number_of_timesteps=0, **kwargs):
464 """A tank with an internal queue arc, whose queue must be completed before
465 storage is available for use. The storage that has completed the queue is under
466 the 'active_storage' property.
468 Args:
469 number_of_timesteps (int, optional): Built in delay for the internal
470 queue - it is always added to the queue time, although delay can be
471 provided with pushes only. Defaults to 0.
472 """
473 # Set parameters
474 self.number_of_timesteps = number_of_timesteps
476 super().__init__(**kwargs)
477 self.end_timestep = self._end_timestep
478 self.active_storage = self.copy_vqip(self.storage)
480 # TODO enable queue to be initialised not empty
481 self.out_arcs = {}
482 self.in_arcs = {}
483 # Create internal queue arc
484 self.internal_arc = AltQueueArc(
485 in_port=self, out_port=self, number_of_timesteps=self.number_of_timesteps
486 )
487 # TODO should mass balance call internal arc (is this arc called in arc mass
488 # balance?)
490 def apply_overrides(self, overrides: Dict[str, Any] = {}):
491 """Apply overrides to the queuetank.
493 Enables a user to override any of the following parameters:
494 number_of_timesteps.
496 Args:
497 overrides (dict, optional): Dictionary of overrides. Defaults to {}.
498 """
499 self.number_of_timesteps = overrides.pop(
500 "number_of_timesteps", self.number_of_timesteps
501 )
502 self.internal_arc.number_of_timesteps = self.number_of_timesteps
503 super().apply_overrides(overrides)
505 def get_avail(self):
506 """Return the active_storage of the tank.
508 Returns:
509 (dict): VQIP of active_storage
510 """
511 return self.copy_vqip(self.active_storage)
513 def push_storage(self, vqip, time=0, force=False):
514 """Push storage into QueueTank, applying travel time, unless forced.
516 Args:
517 vqip (dict): A VQIP of the amount to push
518 time (int, optional): Number of timesteps to spend in queue, in addition
519 to number_of_timesteps property of internal_arc. Defaults to 0.
520 force (bool, optional): Force property that will ignore tank capacity
521 and ignore travel time. Defaults to False.
523 Returns:
524 reply (dict): A VQIP of water that could not be received by the tank
525 """
526 if force:
527 # Directly add request to storage, skipping queue
528 self.storage = self.sum_vqip(self.storage, vqip)
529 self.active_storage = self.sum_vqip(self.active_storage, vqip)
530 return self.empty_vqip()
532 # Push to QueueTank
533 reply = self.internal_arc.send_push_request(vqip, force=force, time=time)
534 # Update storage
535 # TODO storage won't be accurately tracking temperature..
536 self.storage = self.sum_vqip(
537 self.storage, self.v_change_vqip(vqip, vqip["volume"] - reply["volume"])
538 )
539 return reply
541 def pull_storage(self, vqip):
542 """Pull storage from the QueueTank, only water in active_storage is available.
543 Returning water pulled and updating tank states. Pollutants are removed from
544 tank in proportion to 'volume' in vqip (pollutant values in vqip are ignored).
546 Args:
547 vqip (dict): VQIP amount to pull, only 'volume' property is used
549 Returns:
550 reply (dict): VQIP amount that was pulled
551 """
552 # Adjust based on available volume
553 reply = min(vqip["volume"], self.active_storage["volume"])
555 # Update reply to vqip
556 reply = self.v_change_vqip(self.active_storage, reply)
558 # Extract from active_storage
559 self.active_storage = self.extract_vqip(self.active_storage, reply)
561 # Extract from storage
562 self.storage = self.extract_vqip(self.storage, reply)
564 return reply
566 def pull_storage_exact(self, vqip):
567 """Pull storage from the QueueTank, only water in active_storage is available.
568 Pollutants are removed from tank in according to their values in vqip.
570 Args:
571 vqip (dict): A VQIP amount to pull
573 Returns:
574 reply (dict): A VQIP amount successfully pulled
575 """
576 # Adjust based on available
577 reply = self.copy_vqip(vqip)
578 for pol in ["volume"] + constants.ADDITIVE_POLLUTANTS:
579 reply[pol] = min(reply[pol], self.active_storage[pol])
581 # Pull from QueueTank
582 self.active_storage = self.extract_vqip(self.active_storage, reply)
584 # Extract from storage
585 self.storage = self.extract_vqip(self.storage, reply)
586 return reply
588 def push_check(self, vqip=None, tag="default"):
589 """Wrapper for get_excess but applies comparison to volume in VQIP.
590 Needed to enable use of internal_arc, which assumes it is connecting nodes .
591 rather than tanks.
592 NOTE: this is intended only for use with the internal_arc. Pushing to
593 QueueTanks should use 'push_storage'.
595 Args:
596 vqip (dict, optional): VQIP amount to push. Defaults to None.
597 tag (str, optional): Tag, see Node, don't think it should actually be
598 used for a QueueTank since there are no handlers. Defaults to
599 'default'.
601 Returns:
602 excess (dict): a VQIP amount of excess capacity
603 """
604 # TODO does behaviour for volume = None need to be defined?
605 excess = self.get_excess()
606 if vqip is not None:
607 excess["volume"] = min(vqip["volume"], excess["volume"])
608 return excess
610 def push_set(self, vqip, tag="default"):
611 """Behaves differently from normal push setting, it assumes sufficient tank
612 capacity and receives VQIPs that have reached the END of the internal_arc.
613 NOTE: this is intended only for use with the internal_arc. Pushing to
614 QueueTanks should use 'push_storage'.
616 Args:
617 vqip (dict): VQIP amount to push
618 tag (str, optional): Tag, see Node, don't think it should actually be
619 used for a QueueTank since there are no handlers. Defaults to
620 'default'.
622 Returns:
623 (dict): Returns empty VQIP, indicating all water received (since it
624 assumes capacity was checked before entering the internal arc)
625 """
626 # Update active_storage (since it has reached the end of the internal_arc)
627 self.active_storage = self.sum_vqip(self.active_storage, vqip)
629 return self.empty_vqip()
631 def _end_timestep(self):
632 """Wrapper for end_timestep that also ends the timestep in the internal_arc."""
633 self.internal_arc.end_timestep()
634 self.internal_arc.update_queue()
635 self.storage_ = self.copy_vqip(self.storage)
637 def reinit(self):
638 """Zeros storages and arc."""
639 self.internal_arc.reinit()
640 self.storage = self.empty_vqip()
641 self.storage_ = self.empty_vqip()
642 self.active_storage = self.empty_vqip()
645class DecayQueueTank(QueueTank):
646 """"""
648 def __init__(self, decays={}, parent=None, number_of_timesteps=1, **kwargs):
649 """Adds a DecayAltArc in QueueTank to enable decay to occur within the
650 internal_arc queue.
652 Args:
653 decays (dict): A dict of dicts containing a key for each pollutant and,
654 within that, a key for each parameter (a constant and exponent)
655 parent (object): An object that can be used to read temperature data from
656 number_of_timesteps (int, optional): Built in delay for the internal
657 queue - it is always added to the queue time, although delay can be
658 provided with pushes only. Defaults to 0.
659 """
660 # Initialise QueueTank
661 super().__init__(number_of_timesteps=number_of_timesteps, **kwargs)
662 # Replace internal_arc with a DecayArcAlt
663 self.internal_arc = DecayArcAlt(
664 in_port=self,
665 out_port=self,
666 number_of_timesteps=number_of_timesteps,
667 parent=parent,
668 decays=decays,
669 )
671 self.end_timestep = self._end_timestep
673 def apply_overrides(self, overrides: Dict[str, Any] = {}):
674 """Apply overrides to the decayqueuetank.
676 Enables a user to override any of the following parameters:
677 number_of_timesteps, decays.
679 Args:
680 overrides (dict, optional): Dictionary of overrides. Defaults to {}.
681 """
682 self.number_of_timesteps = overrides.pop(
683 "number_of_timesteps", self.number_of_timesteps
684 )
685 self.internal_arc.number_of_timesteps = self.number_of_timesteps
686 self.internal_arc.decays.update(overrides.pop("decays", {}))
687 super().apply_overrides(overrides)
689 def _end_timestep(self):
690 """End timestep wrapper that removes decayed pollutants and calls internal
691 arc."""
692 # TODO Should the active storage decay if decays are given (probably.. though
693 # that sounds like a nightmare)?
694 self.storage = self.extract_vqip(self.storage, self.internal_arc.total_decayed)
695 self.storage_ = self.copy_vqip(self.storage)
696 self.internal_arc.end_timestep()