Single tank mixing problem
Raw code
The raw code for this example without explanations can be found here.
Consider a single tank with an initial volume of water \(V_0\,\mathrm{l}\) and an initial amount of \(M_0\,\mathrm{g}\) of salt dissolved in it. A solution with concentration \(c(t)\,\mathrm{g}/\mathrm{l}\) of salt flows into the tank at rate \(r_0(t)\,\mathrm{l}/\mathrm{s}\) and the mixed solution flows out of the tank at a rate of \(r_1(t)\,\mathrm{l}/\mathrm{s}\).
Let \(V(t)\) be the volume of solution in the tank at time \(t\). Then \(V'(t) = r_0(t) - r_1(t)\). Furthermore, let \(M(t)\) be the amount of salt in the solution at time \(t\). The rate of change of salt in the solution is given by \(M'(t) = r_0(t) c(t) - r_1(t) M(t)/V(t)\).
Modelling flows in psymple
In psymple, the variables \(V(t)\) and \(M(t)\) can be modelled by directly implementing the two differential equations above. In that case, however, would become rigid, meaning that a new situation with more than one in-flow or out-flow would require a new model. Instead, consider what is happening at each point a pipe meets the tank.
For the in-flow pipe, there are fluxes \(V'(t) = r_0(t)\) and \(M'(t) = r_0 (t) c(t)\). Similarly for the outflow pipe, \(V'(t) = -r_1(t)\) and \(M'(t) = - r_1(t) M(t)/V(t)\).
Zero volume error
When the tank volume approaches zero, the concentration, and therefore the differential equation controlling mass, can become unpredictable. There are different options as a modeller to deal with cases like this. Here, the decision is taken to keep at least a volume \(V_m > 0\) in the tank by turning off the outflow when \(V \leqslant V_m\). In this case, the differential equation in the outflow pipe becomes
This situation is initially modelled with two VariablePortedObject instances.
First, the model for the in pipe is:
pipe_in = VariablePortedObject(
name="pipe_in",
assignments=[
("V", "r_0"),
("M", "r_0*c"),
],
)
and the model for the out pipe is:
pipe_out = VariablePortedObject(
name="pipe_out",
assignments=[
("V", "Piecewise((-r_1, V>V_m), (0, True))"), # (1)!
("M", "-r_1 * M/V"),
]
)
- The
Piecewisefunction is the implementation insympyof taking cases. The syntax consists of a list of pairs of the form(value, condition)dealt with in order.
Defining the system model
Using a CompositePortedObject, the variables of the in-flow and out-flow objects can be aggregated together. The resulting object can be viewed as the tank itself, with in-flow and out-flow variables for both mass and volume. Additionally, the parameters r_0, r_1, c and V_m are exposed and connected to the internal flow models.
from psymple.build import CompositePortedObject
tank = CompositePortedObject(
name="tank",
children=[pipe_in, pipe_out],
input_ports=["r_0", "r_1", "c", "V_m"],
variable_ports=["V", "M"],
variable_wires=[
(["pipe_in.V", "pipe_out.V"], "V"),
(["pipe_in.M", "pipe_out.M"], "M"),
],
directed_wires=[
("r_0", "pipe_in.r_0"),
("r_1", "pipe_out.r_1"),
("c", "pipe_in.c"),
("V_m", "pipe_out.V_m"),
]
)
Simulating the model
That's it! To simulate the model, define and compile a System class for tank.
- It is also possible to call
System(tank, compile=True). In this case, the commandsystem.compile()doesn't need to be called.
Before simulation, the following must be provided:
- Initial values for the variables mass
"V"and salt amount"M". These are provided using a dictionary passed to the argumentinitial_valueswhen a simulation is created. - Values for the flow rates and concentration in. This is either done using the method
system.set_parameters, or as is done here, by passing a dictionary to the argumentinput_parameterswhen creating a simulation, allowing multiple scenarios to be considered.
Constructing multiple simulations
Four simulations will be constructed. For each, initial values of \(V_0 = 1000\) and \(M_0 = 20\) are specified. The input parameters for each simulation will be:
- \(r_0 = 4 = r_1\) and \(c = 0.5\). In this case, the volume of the tank should stay constant, and the amount of salt should continually increase towards a limit.
- \(r_0 = 2\), \(r_1 = 4\) and \(c = 0.5\). In this case, the volume of the tank will decrease.
- For a more creative scenario, set \(r_0 = 4sin(t) + 4\) and \(r_1 = 4\), \(c = 0.5\). The volume of the tank will fluctuate, but stay centred around \(1000\).
- Finally, try \(r_0 = 4 = r_1\) and \(c = 0.5sin(t) + 0.5\).
In all cases, \(V_m=10\) is specified.
for name, inputs in zip(
["sim_1", "sim_2", "sim_3", "sim_4"], # (1)!
[
{"r_0": 4, "r_1": 4, "c": 0.5, "V_m": 10}, # (2)!
{"r_0": 2, "r_1": 4, "c": 0.5, "V_m": 10},
{"r_0": "4*sin(T) + 4", "r_1": 4, "c": 0.5, "V_m": 10},
{"r_0": 4, "r_1": 4, "c": "0.5*sin(T) + 0.5", "V_m": 10},
]
):
sim = system.create_simulation(
name=name,
initial_values={"V": 1000, "M": 20}, # (3)!
input_parameters=inputs,
)
sim.simulate(t_end=1000)
- These are the names for each simulation.
- For each simulation, the set of input parameters is passed as a dictionary.
- The initial values for each simulation are defined here. They can also be varied in the same way as the input parameters are varied.
Visualising the outputs
Finally, a plot of each model run can be produced by using the plot_solution method. Each simulation can be accessed from the dictionary system.simulations using the keys "sim_1", "sim_2", "sim_3" or "sim_4". The following code produces plots of both the mass and volume for the first simulation.
system.simulations["sim_1"].plot_solution({"M"})
system.simulations["sim_1"].plot_solution({"V"})
The outputs are shown below.


As expected, the mass of salt in the tank increases towards a limit of \(500 \text{g}\), while the volume stays constant.