Author Archives: Julia on μβ

Multiple dispatch – an example for mathematical optimizers

By: Julia on μβ

Re-posted from: https://matbesancon.xyz/post/2019-02-24-multiple-dispatch-optimizers/

In a recent pull request on a personal project, I spent some time designing
an intuitive API for a specific problem. After reaching a satisfying result,
I realized this would never have been possible without one of the central
mechanisms of the Julia language: multiple dispatch. Feel free to read the
Julia docs on the topic
or what Wikipedia has to say
about it.

This post is a walkthrough for multiple dispatch for a case in mathematical
optimization. The first part will introduce the problem context and requires
some notion in mathematical optimization, if this stuff is scary, feel free to
skip to the rest directly.

Refresher on if-then-else constraints

I promised an example oriented towards mathematical optimization, here it is:
it is common to model constraints with two variables $(x, y)$,
$x$ continuous and $y$ binary stating:

  • $y = 0 \Rightarrow x = 0$
  • If $y = 1$, there is no specific constraint on $x$

Some examples of models with such constraint:

  • Facility location: if a wharehouse is not opened, $y = 0$, then the quantity
    served by this point has to be $x = 0$, otherwise, the quantity can go up to
    the wharehouse capacity.
  • Unit commitment (a classic problem for power systems): if a power plant
    has not been activated for a given hour, then it cannot supply any power,
    otherwise, it can supply up to its capacity.
  • Complementarity constraints: if a dual variable $\lambda$ is 0,
    then the corresponding constraint is not active (in non-degenerate cases,
    the slack variable is non-zero)

Logical constraints with such if-then-else structure cannot be handled by
established optimization solvers, at least not in an efficient way. There are
two usual ways to implement this, “big-M” type constraints and special-ordered
sets of type 1 SOS1.

A SOS1 constraint specifies that out of a set of variables or expressions,
at most one of them can be non-zero. In our case, the if-then-else constraint
can be modeled as:
$$SOS1(x,, 1-y)$$

Most solvers handling integer variables can use these $SOS1$ constraints
within a branch-and-bound procedure.

The other formulation is using an upper-bound on the $x$ variable, usually
written $M$, hence the name:

$$x \leq M \cdot y $$

If $y=0$, $x$ can be at most 0, otherwise it is bounded by $M$. If $M$
is sufficiently big, the constraint becomes inactive.
However, smaller $M$ values yield tighter formulations, solved more efficiently.
See Paul Rubin’s
detailed blog post on the subject. If we want bounds as tight as possible, it
is always preferable to choose one bound per constraint, instead of one unique
$M$ for them all, which means we need a majorant of all individual $M$.

As a rule of thumb, big-M constraints are pretty efficient if $M$ is tight,
but if we have no idea about it, SOS1 constraints may be more interesting,
see [1] for recent numerical experiments applied to bilevel problems.

Modeling if-then-else constraints

Now that the context is set, our task is to model if-then-else constraints
in the best possible way, in a modeling package for instance. We want the user
to specify something as:

function handle_ifthenelse(x, y, method, params)
    # build the constraint with method using params
end

Without a dispatch feature baked within the language, we will end up doing
it ourselves, for instance in:

function handle_ifthenelse(x, y, method, params)
    if typeof(method) == SOS1Method
        # model as SOS1Method
    elseif typeof(method) == BigMMethod
        # handle as big M with params
    else
        throw(MethodError("Method unknown"))
    end
end

NB: if you have to do that in Julia, there is a isa(x, T) function
verifying if x is a T in a more concise way, this is verifying sub-typing
instead of type equality, which is much more flexible.

The function is way longer than necessary, and will have to be modified every
time. In a more idiomatic way, what we can do is:

struct SOS1Method end
struct BigMMethod end

function handle_ifthenelse(x, y, ::SOS1Method)
    # model as SOS1Method
end

function handle_ifthenelse(x, y, ::BigMMethod, params)
    # handle as big M with params
end

Much better here, three things to notice:

  • This may look similar to pattern matching in function arguments if you are
    familiar with languages as Elixir. However, the method to use can be determined
    using static dispatch, i.e. at compile-time.
  • We don’t need to carry around params in the case of the SOS1 method,
    since we don’t use them, so we can adapt the method signature to pass only
    what is needed.
  • This code is much easier to document, each method can be documented on
    its own type, and the reader can refer to the method directly.

Cherry on top, any user can define their own technique by importing our function
and defining a new behavior:

import OtherPackage # where the function is defined

struct MyNewMethod end

function handle_ifthenelse(x, y, ::MyNewMethod)
    # define a new method for ifthenelse, much more efficient
end

Handling big M in an elegant way

We have seen how to dispatch on the technique, but still we are missing one
point: handling the params in big-M formulations. If you have pairs of $(x_j,y_j)$,
then users may want:

$$ x_j \leq M_j \cdot y_j,, \forall j $$

Or:
$$ x_j \leq M \cdot y_j,, \forall j $$

The first formulation requires a vector of M values, and the second one
requires a scalar. One default option would be to adapt to the most general one:
if several M values are given, build a vector, if there is only one, repeat it
for each $j$. One way to do it using dynamic typing:

struct BigMMethod end

function handle_ifthenelse(x, y, ::BigMMethod, M::Union{Real,AbstractVector{<:Real}})
    if M isa Real
        # handle with one unique M
    else
        # it is a vector
        # handle with each M[j]
    end
end

Note that we can constrain the type of M to be either a scalar or a Vector
using Union type. Still, this type verification can be done using dispatch,
and we can handle the multiple cases:

struct BigMMethod end

"""
Use one unique big M value
"""
function handle_ifthenelse(x, y, ::BigMMethod, M::Real)
    # handle with one unique M
end

"""
Use a vector of big M value
"""
function handle_ifthenelse(x, y, ::BigMMethod, Mvec::AbstractVector)
    # handle with each Mvec[j]
end

This solution is fine, and resolving most things at compile-time.
Also, note that we are defining one signature as a convenience way redirecting
to another.

Polishing our design: enriched types

The last solution is great, we are dispatching on our algorithm and parameter
types. However, in a realistic research or development work, many more
decisions are taken such as algorithms options, number types, various parameters.
We will likely end up with something similar to:

function do_science(x, y, z,
                    ::Alg1, params_alg_1,
                    ::Alg2, params_alg_2,
                    ::Alg3, # algortithm 3 does not need parameters
                    ::Alg4, params_alg_4)
    # do something with params_alg_1 for Alg1
    # do something with params_alg_2 for Alg2
    # ...
end

Requiring users to pass all arguments and types in the correct order.
A long chain of positional arguments like this end makes for error-prone
and cumbersome interfaces. Can we change this? We created all our types as
empty structures struct A end and use it just to dispatch. Instead,
we could store adapted parameters within the corresponding type:

struct Alg1
    coefficient::Float64
    direction::Vector{Float64}
end

# define other types

function do_science(x, y, z, a1::Alg1, a2::Alg2, ::Alg3, a4::Alg4)
    # do something with params_alg_1 for Alg1
    # a1.coefficient, a1.direction...
    # do something with Alg2
    # ...
end

Getting back to our initial use case of BigMMethod, we need to store
the $M$ value(s) in the structure:

struct BigMMethod
    M::Union{Float64, Vector{Float64}}
end

This seems fine, however, the Julia compiler cannot know the type of the M
field at compile-time, instead, we can use a type parameter here:

struct BigMMethod{MT<:Union{Real, AbstractVector{<:Real}}}
    M::MT
    BigMMethod(M::MT) where {MT} = new{MT}(M)
end

When constructing the BigMMethod with this definition, it can be specialized
on MT, the type of M, two examples of valid definitions are:

BigMMethod(3.0)
# result: BigMMethod{Float64}(3.0)

BigMMethod(3)
# result: BigMMethod{Int}(3)

BigMMethod([3.0, 5.0])
# result BigMMethod{Vector{Float64}}([3.0, 5.0])

The advantage is we can now specialize the handle_ifthenelse
signature on the type parameter of M, as below:

"""
Use one unique big M value
"""
function handle_ifthenelse(x, y, bm::BigMMethod{<:Real})
    # handle with one unique M bm.M
end

"""
Use a vector of big M value
"""
function handle_ifthenelse(x, y, bm::BigMMethod{<:AbstractVector})
    # handle with each bm.M[j]
end

The advantage is a strictly identical signature, whatever the method and
its parameters, users will always call it with:
handle_ifthenelse(x, y, bm::BigMMethod{<:AbstractVector})

Conclusion: avoiding a clarity-flexibility trade-off

In this simple but commonly encountered example, we leveraged multiple dispatch,
the ability to choose a function implementation depending on the type of its
arguments. This helped us define a homogeneous interface for specifying a type
of constraint, specializing on the method (SOS1 or big M) and on the data
available (one M or a vector of M values).

Performance bonus, this design is providing the Julia compiler with strong type
information while remaining flexible for the user. In Julia terminology,
this property is called type stability.
We would not have benefitted from this property if we had used reflection-based
design (with typeof() and isa).

This idea of using big-M as an example did not come up in the abstract but is
a simplification of the design used in the
BilevelOptimization.jl
package. Remember I mentioned complementarity constraints, it is exactly this
use case.

If you are interested in more examples of multiple dispatch and hands-on
use cases for the Julia type system, check out
these
two
articles.
Feel free to reach out any way you prefer, Twitter,
email.


Edit 1: thanks BYP for sharp proofreading and constructive critics.

Edit 2: Thanks Mathieu Tanneau for pointing out the alternative solution of
indicator constraints instead of big M, as documented in Gurobi, CPLEX.

Edit 3: For more info on big M constraints and underlying issues, you can read
Thiago Serra’s post, which includes nice visualizations of the problem space.


Sources:

[1] Henrik Carøe Bylling’s thesis, KU, http://web.math.ku.dk/noter/filer/phd19hb.pdf

Winter warm-up: toy models for heat exchangers

By: Julia on μβ

Re-posted from: https://matbesancon.github.io/post/2018-12-27-heat-exchanger/

Enjoying the calm of the frozen eastern French countryside for the last week of 2018,
I was struck by nostalgia while reading a SIAM news article [1] on a
near-reversible heat exchange between two flows and decided to dust off my
thermodynamics books (especially [2]).

Research in mathematical optimization was not the
obvious path I was on a couple years ago. The joint bachelor-master’s program
I followed in France was in process engineering, a discipline crossing
transfer phenomena (heat exchange, fluid mechanics, thermodynamics), control,
knowledge of the matter transformations at hand
(chemical, biochemical, nuclear reactions) and industrial engineering
(see note at the end of this page).

Hypotheses Throughout the article, we will use a set of flow hypotheses
which build up the core of our model for heat exchange.
These can seem odd but are pretty common in process engineering and
realistic in many applications.

  1. The two flows advance in successive “layers”.
  2. Each layer has a homogeneous temperature; we therefore ignore boundary layer effects.
  3. Successive layers do not exchange matter nor heat. The rationale behind this
    is that the temperature difference between fluids is significantly higher than between layers.
  4. Pressure losses in the exchanger does not release a significant heat compared to
    the fluid heat exchange.
  5. The fluid and wall properties are constant with temperature.

Starting simple: parallel flow heat exchange

In this model, both flows enter the exchanger on the same side, one at a
hot temperature, the other at a cold temperature. Heat is exchanged along the
exchanger wall, proportional at any point to the difference in temperature
between the two fluids. We therefore study the evolution of two variables
$u_1(x)$ and $u_2(x)$ in an interval $x \in [0,L]$ with $L$ the length of
the exchanger.

In any layer $[x, x + \delta x]$, the heat exchange is equal to:
$$\delta \dot{Q} = h \cdot (u_2(x) – u_1(x)) \cdot \delta x$$
with $h$ a coefficient depending on the wall heat exchange properties.

Moreover, the variation in internal energy of the hot flow is equal to
$\delta \dot{Q}$ and is also expressed as:

$$ c_2 \cdot \dot{m}_2 \cdot (u_2(x+\delta x) – u_2(x)) $$
$c_2$ is the calorific capacity of the hot flow and $\dot{m}_2$ its
mass flow rate. The you can check that the given expression is a power.
The same expressions apply to the cold flow.
Let us first assume the following:

$$c_2 \cdot \dot{m}_2 = c_1 \cdot \dot{m}_1$$

import DifferentialEquations
const DiffEq = DifferentialEquations
using Plots

function parallel_exchanger(du,u,p,x)
    h = p[1] # heat exchange coefficient
    Q = h * (u[1]-u[2])
    du[1] = -Q
    du[2] = Q
end

function parallel_solution(L, p)
    problem = DiffEq.ODEProblem(
      parallel_exchanger, # function describing the dynamics of the system
      u₀,                 # initial conditions u0
      (0., L),            # region overwhich the solution is built, x ∈ [0,L]
      p,                  # parameters, here the aggregated transfer constant h
    )
    return DiffEq.solve(problem, DiffEq.Tsit5())
end

plot(parallel_solution([0.0,100.0], 50.0, (0.05)))

$$ u_1(x) = T_{eq} \cdot (1 – e^{-h\cdot x}) $$
$$ u_2(x) = (100 – T_{eq}) \cdot e^{-h\cdot x} + T_{eq} $$

With $T_{eq}$ the limit temperature, trivially 50°C with equal flows.

(Full disclaimer: I’m a bit rusty and had to double-check for errors)

This model is pretty simple, its performance is however low from
a practical perspective. First on the purpose itself, we can compute for two
fluids the equilibrium temperature. This temperature can be adjusted
by the ratio of two mass flow rates but will remain a weighted average.
Suppose the goal of the exchange is to heat the cold fluid, the necessary
mass flow $\dot{m}_2$ tends to $\infty$ as the targeted temperature tends to
$u_2(L)$, and this is independent of the performance of the heat exchanger
itself, represented by the coefficient $h$. Here is the extended model using
the flow rate ratio to adjust the temperature profiles:

import DifferentialEquations
const DiffEq = DifferentialEquations

function ratio_exchanger(du,u,p,x)
    h = p[1] # heat exchange coefficient
    r = p[2] # ratio of mass flow rate 2 / mass flow rate 1
    Q = h * (u[1]-u[2])
    du[1] = -Q
    du[2] = Q / r
end

function ratio_solution(u₀, L, p)
    problem = DiffEq.ODEProblem(
      ratio_exchanger, # function describing the dynamics of the system
      u₀,              # initial conditions u0
      (0., L),         # region overwhich the solution is built, x ∈ [0,L]
      p,               # parameters, here the aggregated transfer constant h
    )
    return DiffEq.solve(problem, DiffEq.Tsit5())
end

for (idx,r) in enumerate((1.0, 5.0, 10.0, 500.0))
    plot(ratio_solution([0.0,100.0], 50.0, (0.05, r)))
    xlabel!("x (m)")
    ylabel!("T °C")
    title!("Parallel flow with ratio $r")
    savefig("parallel_ratio_$(idx).pdf")
end




This model has an analytical closed-form solution given by:
$$ T_{eq} = \frac{100\cdot \dot{m}_2}{\dot{m}_1 + \dot{m}_2} = 100\cdot\frac{r}{1+r} $$
$$ u_1(x) = T_{eq} \cdot (1 – e^{-h\cdot x}) $$
$$ u_2(x) = (100 – T_{eq}) \cdot e^{-h\cdot x \cdot r} + T_{eq} $$

Opposite flow model

This model is trickier because we don’t consider the dynamics of the system
along one dimension anymore. The two fluids flowing in opposite directions
are two interdependent systems. We won’t go through the analytical solution
but use a similar discretization as in article [1].

This model takes $n$ discrete cells, each considered at a given temperature.
Two cells of the cold and hot flows are considered to have exchanged heat
after crossing.


[3]

Applying the energy conservation principle, the gain of internal energy
between cell $k$ and $k+1$ for the cold flow is equal to the loss of
internal energy of the hot flow from cell $k+1$ to cell $k$. These differences
come from heat exchanged, expressed as:

$$\dot{Q}_k = h \cdot \Delta x \cdot (u_{2,k+1} – u_{1,k}) $$
$$\dot{Q}_k = \dot{m}_1 \cdot c_1 \cdot (u_{1,k+1} – u_{1,k}) $$
$$\dot{Q}_k = \dot{m}_2 \cdot c_2 \cdot (u_{2,k+1} – u_{2,k}) $$

Watch out the sense of the last equation since the heat exchange is
a loss for the hot flow. Again we use the simplifying assumption of
equality of the quantities:
$$ \dot{m}_i \cdot c_i $$

Our model only depends on the number of discretization steps $n$
and transfer coefficient $h$.

function discrete_crossing(n, h; itermax = 50000)
    u1 = Matrix{Float64}(undef, itermax, n)
    u2 = Matrix{Float64}(undef, itermax, n)
    u1[:,1] .= 0.0
    u1[1,:] .= 0.0
    u2[:,n] .= 100.0
    u2[1,:] .= 100.0
    for iter in 2:itermax
        for k in 1:n-1
            δq = h * (u2[iter-1, k+1] - u1[iter-1, k]) * (50.0/n)
            u2[iter, k]   = u2[iter-1, k+1] - δq
            u1[iter, k+1] = u1[iter-1, k]   + δq
        end
    end
    (u1,u2)
end
const (a1, a2) = discrete_crossing(500, 0.1)
const x0 = range(0.0, length = 500, stop = L)

p = plot(x0, a1[end,:], label = "u1 final", legend = :topleft)
plot!(p, x0, a2[end,:], label = "u2 final")
for iter in (100, 500)
    global p
    plot!(p, x0, a1[iter,:], label = "u1 $(iter)")
    plot!(p, x0, a2[iter,:], label = "u2 $(iter)")
end
xlabel!("x (m)")

We can observe the convergence of the solution at different iterations:

After convergence, we observe a parallel temperature profiles along the
exchanger, the difference between the two flows at any point being reduced
to $\epsilon$ mentioned in article [1]. The two differences between our model
and theirs are:

  • The discretization grid is slightly different since we consider the exchange
    to happen between cell $k$ and cell $k+1$ at the node between them, while they
    consider an exchange between $k-1$ and $k+1$ at cell $k$.
  • They consider two flow unit which just crossed reach the same temperature,
    while we consider a heat exchange limited by the temperature difference
    (the two flows do not reach identical temperatures but tend towards it).

Finally we can change the ratio:
$$\frac{\dot{m}_1\cdot c_1}{\dot{m}_2\cdot c_2}$$ for the counterflow model
as we did in the parallel case.

function discrete_crossing(n, h, ratio; itermax = 50000)
    u1 = Matrix{Float64}(undef, itermax, n)
    u2 = Matrix{Float64}(undef, itermax, n)
    u1[:,1] .= 0.0
    u1[1,:] .= 0.0
    u2[:,n] .= 100.0
    u2[1,:] .= 100.0
    for iter in 2:itermax
        for k in 1:n-1
            δq = h * (u2[iter-1, k+1] - u1[iter-1, k]) * 50.0 / n
            u2[iter, k]   = u2[iter-1, k+1] - δq * ratio
            u1[iter, k+1] = u1[iter-1, k]   + δq
        end
    end
    (u1,u2)
end

Julia tip: note that we do not define a new function for this but
create a method for the function discrete_crossing defined above
with a new signature (n, h, ratio).

We can plot the result:

const x0 = range(0.0, length = 500, stop = L)
p = plot(x0, a1[end,:], label = "u1 ratio 1.0", legend = :bottomright)
plot!(p, x0, a2[end,:], label = "u2 ratio 1.0")
for ratio in (0.1,0.5)
    global p
    (r1, r2) = discrete_crossing(500, 0.1, ratio)
    plot!(p, x0, r1[end,:], label = "u1 ratio $(ratio)")
    plot!(p, x0, r2[end,:], label = "u2 ratio $(ratio)")
end
xlabel!("x (m)")

Conclusion

To keep this post short, we will not show the influence of all parameters.
Some key effects to consider:

  • Increasing $h$ increases the gap between the flow temperatures
  • Increasing the number of steps does not change the result for a step size
    small enough
  • Increasing the exchanger length reduces the gap
  • A ratio of 1 minimizes the temperature difference at every point
    (and thus minimizes the entropy). This very low entropy creation is a positive
    sign for engineers from a thermodynamics point of view: we are not “degrading”
    the “quality” of available energy to perform this heat exchange or in other
    terms, we are not destroying exergy.

Feel free to reach out on Twitter
or via email if you have comments or questions, I’d be glad to take both.


Note on process engineering
The term is gaining more traction in English, and should replace
chemical engineering in higher education to acknowledge the diversity of
application fields, greater than the chemical industry alone.
The German equivalent Verfahrenstechnik has been used for decades and
Génie des Procédés is now considered a norm in most French-speaking
universities and consortia.


Edit: thanks BYP for the sharp-as-ever proofreading


Sources:

[1] Levi M. A Near-perfect Heat Exchange. SIAM news. 2018 Dec;51(10):4.

[2] Borel L, Favrat D. Thermodynamique et énergétique. PPUR presses polytechniques; 2nd edition, 2011.


Image sources:
[3] Geogebra

Winter warm-up: toy models for heat exchangers

By: Julia on μβ

Re-posted from: https://matbesancon.xyz/post/2018-12-27-heat-exchanger/

Enjoying the calm of the frozen eastern French countryside for the last week of 2018,
I was struck by nostalgia while reading a SIAM news article [1] on a
near-reversible heat exchange between two flows and decided to dust off my
thermodynamics books (especially [2]).

Research in mathematical optimization was not the
obvious path I was on a couple years ago. The joint bachelor-master’s program
I followed in France was in process engineering, a discipline crossing
transfer phenomena (heat exchange, fluid mechanics, thermodynamics), control,
knowledge of the matter transformations at hand
(chemical, biochemical, nuclear reactions) and industrial engineering
(see note at the end of this page).

Hypotheses Throughout the article, we will use a set of flow hypotheses
which build up the core of our model for heat exchange.
These can seem odd but are pretty common in process engineering and
realistic in many applications.

  1. The two flows advance in successive “layers”.
  2. Each layer has a homogeneous temperature; we therefore ignore boundary layer effects.
  3. Successive layers do not exchange matter nor heat. The rationale behind this
    is that the temperature difference between fluids is significantly higher than between layers.
  4. Pressure losses in the exchanger does not release a significant heat compared to
    the fluid heat exchange.
  5. The fluid and wall properties are constant with temperature.

Starting simple: parallel flow heat exchange

In this model, both flows enter the exchanger on the same side, one at a
hot temperature, the other at a cold temperature. Heat is exchanged along the
exchanger wall, proportional at any point to the difference in temperature
between the two fluids. We therefore study the evolution of two variables
$u_1(x)$ and $u_2(x)$ in an interval $x \in [0,L]$ with $L$ the length of
the exchanger.

In any layer $[x, x + \delta x]$, the heat exchange is equal to:
$$\delta \dot{Q} = h \cdot (u_2(x) – u_1(x)) \cdot \delta x$$
with $h$ a coefficient depending on the wall heat exchange properties.

Moreover, the variation in internal energy of the hot flow is equal to
$\delta \dot{Q}$ and is also expressed as:

$$ c_2 \cdot \dot{m}_2 \cdot (u_2(x+\delta x) – u_2(x)) $$
$c_2$ is the calorific capacity of the hot flow and $\dot{m}_2$ its
mass flow rate. The you can check that the given expression is a power.
The same expressions apply to the cold flow.
Let us first assume the following:

$$c_2 \cdot \dot{m}_2 = c_1 \cdot \dot{m}_1$$

import DifferentialEquations
const DiffEq = DifferentialEquations
using Plots

function parallel_exchanger(du,u,p,x)
    h = p[1] # heat exchange coefficient
    Q = h * (u[1]-u[2])
    du[1] = -Q
    du[2] = Q
end

function parallel_solution(L, p)
    problem = DiffEq.ODEProblem(
      parallel_exchanger, # function describing the dynamics of the system
      u₀,                 # initial conditions u0
      (0., L),            # region overwhich the solution is built, x ∈ [0,L]
      p,                  # parameters, here the aggregated transfer constant h
    )
    return DiffEq.solve(problem, DiffEq.Tsit5())
end

plot(parallel_solution([0.0,100.0], 50.0, (0.05)))

$$ u_1(x) = T_{eq} \cdot (1 – e^{-h\cdot x}) $$
$$ u_2(x) = (100 – T_{eq}) \cdot e^{-h\cdot x} + T_{eq} $$

With $T_{eq}$ the limit temperature, trivially 50°C with equal flows.

(Full disclaimer: I’m a bit rusty and had to double-check for errors)

This model is pretty simple, its performance is however low from
a practical perspective. First on the purpose itself, we can compute for two
fluids the equilibrium temperature. This temperature can be adjusted
by the ratio of two mass flow rates but will remain a weighted average.
Suppose the goal of the exchange is to heat the cold fluid, the necessary
mass flow $\dot{m}_2$ tends to $\infty$ as the targeted temperature tends to
$u_2(L)$, and this is independent of the performance of the heat exchanger
itself, represented by the coefficient $h$. Here is the extended model using
the flow rate ratio to adjust the temperature profiles:

import DifferentialEquations
const DiffEq = DifferentialEquations

function ratio_exchanger(du,u,p,x)
    h = p[1] # heat exchange coefficient
    r = p[2] # ratio of mass flow rate 2 / mass flow rate 1
    Q = h * (u[1]-u[2])
    du[1] = -Q
    du[2] = Q / r
end

function ratio_solution(u₀, L, p)
    problem = DiffEq.ODEProblem(
      ratio_exchanger, # function describing the dynamics of the system
      u₀,              # initial conditions u0
      (0., L),         # region overwhich the solution is built, x ∈ [0,L]
      p,               # parameters, here the aggregated transfer constant h
    )
    return DiffEq.solve(problem, DiffEq.Tsit5())
end

for (idx,r) in enumerate((1.0, 5.0, 10.0, 500.0))
    plot(ratio_solution([0.0,100.0], 50.0, (0.05, r)))
    xlabel!("x (m)")
    ylabel!("T °C")
    title!("Parallel flow with ratio $r")
    savefig("parallel_ratio_$(idx).pdf")
end

This model has an analytical closed-form solution given by:
$$ T_{eq} = \frac{100\cdot \dot{m}_2}{\dot{m}_1 + \dot{m}_2} = 100\cdot\frac{r}{1+r} $$
$$ u_1(x) = T_{eq} \cdot (1 – e^{-h\cdot x}) $$
$$ u_2(x) = (100 – T_{eq}) \cdot e^{-h\cdot x \cdot r} + T_{eq} $$

Opposite flow model

This model is trickier because we don’t consider the dynamics of the system
along one dimension anymore. The two fluids flowing in opposite directions
are two interdependent systems. We won’t go through the analytical solution
but use a similar discretization as in article [1].

This model takes $n$ discrete cells, each considered at a given temperature.
Two cells of the cold and hot flows are considered to have exchanged heat
after crossing.

Applying the energy conservation principle, the gain of internal energy
between cell $k$ and $k+1$ for the cold flow is equal to the loss of
internal energy of the hot flow from cell $k+1$ to cell $k$. These differences
come from heat exchanged, expressed as:

$$\dot{Q}_k = h \cdot \Delta x \cdot (u_{2,k+1} – u_{1,k}) $$
$$\dot{Q}_k = \dot{m}_1 \cdot c_1 \cdot (u_{1,k+1} – u_{1,k}) $$
$$\dot{Q}_k = \dot{m}_2 \cdot c_2 \cdot (u_{2,k+1} – u_{2,k}) $$

Watch out the sense of the last equation since the heat exchange is
a loss for the hot flow. Again we use the simplifying assumption of
equality of the quantities:
$$ \dot{m}_i \cdot c_i $$

Our model only depends on the number of discretization steps $n$
and transfer coefficient $h$.

function discrete_crossing(n, h; itermax = 50000)
    u1 = Matrix{Float64}(undef, itermax, n)
    u2 = Matrix{Float64}(undef, itermax, n)
    u1[:,1] .= 0.0
    u1[1,:] .= 0.0
    u2[:,n] .= 100.0
    u2[1,:] .= 100.0
    for iter in 2:itermax
        for k in 1:n-1
            δq = h * (u2[iter-1, k+1] - u1[iter-1, k]) * (50.0/n)
            u2[iter, k]   = u2[iter-1, k+1] - δq
            u1[iter, k+1] = u1[iter-1, k]   + δq
        end
    end
    (u1,u2)
end
const (a1, a2) = discrete_crossing(500, 0.1)
const x0 = range(0.0, length = 500, stop = L)

p = plot(x0, a1[end,:], label = "u1 final", legend = :topleft)
plot!(p, x0, a2[end,:], label = "u2 final")
for iter in (100, 500)
    global p
    plot!(p, x0, a1[iter,:], label = "u1 $(iter)")
    plot!(p, x0, a2[iter,:], label = "u2 $(iter)")
end
xlabel!("x (m)")

We can observe the convergence of the solution at different iterations:

After convergence, we observe a parallel temperature profiles along the
exchanger, the difference between the two flows at any point being reduced
to $\epsilon$ mentioned in article [1]. The two differences between our model
and theirs are:

  • The discretization grid is slightly different since we consider the exchange
    to happen between cell $k$ and cell $k+1$ at the node between them, while they
    consider an exchange between $k-1$ and $k+1$ at cell $k$.
  • They consider two flow unit which just crossed reach the same temperature,
    while we consider a heat exchange limited by the temperature difference
    (the two flows do not reach identical temperatures but tend towards it).

Finally we can change the ratio:
$$\frac{\dot{m}_1\cdot c_1}{\dot{m}_2\cdot c_2}$$ for the counterflow model
as we did in the parallel case.

function discrete_crossing(n, h, ratio; itermax = 50000)
    u1 = Matrix{Float64}(undef, itermax, n)
    u2 = Matrix{Float64}(undef, itermax, n)
    u1[:,1] .= 0.0
    u1[1,:] .= 0.0
    u2[:,n] .= 100.0
    u2[1,:] .= 100.0
    for iter in 2:itermax
        for k in 1:n-1
            δq = h * (u2[iter-1, k+1] - u1[iter-1, k]) * 50.0 / n
            u2[iter, k]   = u2[iter-1, k+1] - δq * ratio
            u1[iter, k+1] = u1[iter-1, k]   + δq
        end
    end
    (u1,u2)
end

Julia tip: note that we do not define a new function for this but
create a method for the function discrete_crossing defined above
with a new signature (n, h, ratio).

We can plot the result:

const x0 = range(0.0, length = 500, stop = L)
p = plot(x0, a1[end,:], label = "u1 ratio 1.0", legend = :bottomright)
plot!(p, x0, a2[end,:], label = "u2 ratio 1.0")
for ratio in (0.1,0.5)
    global p
    (r1, r2) = discrete_crossing(500, 0.1, ratio)
    plot!(p, x0, r1[end,:], label = "u1 ratio $(ratio)")
    plot!(p, x0, r2[end,:], label = "u2 ratio $(ratio)")
end
xlabel!("x (m)")

Conclusion

To keep this post short, we will not show the influence of all parameters.
Some key effects to consider:

  • Increasing $h$ increases the gap between the flow temperatures
  • Increasing the number of steps does not change the result for a step size
    small enough
  • Increasing the exchanger length reduces the gap
  • A ratio of 1 minimizes the temperature difference at every point
    (and thus minimizes the entropy). This very low entropy creation is a positive
    sign for engineers from a thermodynamics point of view: we are not “degrading”
    the “quality” of available energy to perform this heat exchange or in other
    terms, we are not destroying exergy.

Feel free to reach out on Twitter
or via email if you have comments or questions, I’d be glad to take both.


Note on process engineering
The term is gaining more traction in English, and should replace
chemical engineering in higher education to acknowledge the diversity of
application fields, greater than the chemical industry alone.
The German equivalent Verfahrenstechnik has been used for decades and
Génie des Procédés is now considered a norm in most French-speaking
universities and consortia.


Edit: thanks BYP for the sharp-as-ever proofreading


Sources:

[1] Levi M. A Near-perfect Heat Exchange. SIAM news. 2018 Dec;51(10):4.

[2] Borel L, Favrat D. Thermodynamique et énergétique. PPUR presses polytechniques; 2nd edition, 2011.


Image sources:
[3] Geogebra