Cooling coffee
A cup of coffee whose temperature relaxes to that of the room, T' = -k (T - T_room).
- Name:
cooling_coffee - Field:
physics/heat - Formalism: ordinary differential equations
- References: Newton 1701; Meadows 2008
Factory
@register_model(
field='physics/heat',
tags=('heat', 'ode', 'balancing feedback', 'stocks and flows'),
references=('Newton 1701', 'Meadows 2008'),
defaults={'until': 60.0, 'dt': 0.5},
)
def cooling_coffee(T: float = 90.0, *, room: float = 20.0, k: float = 0.1) -> System:
"""Return a cup of coffee whose temperature relaxes to that of the room, `T' = -k (T - T_room)`.
The heat flow is proportional to the discrepancy between the coffee and the room, a balancing feedback loop, so
hot coffee cools and iced coffee warms, both exponentially with the time constant `1 / k`.
Args:
T:
The initial temperature of the coffee.
room:
The temperature of the room.
k:
The rate of heat exchange.
Returns:
system:
The model, with the temperature `coffee.T`.
Examples:
>>> from skcomplex.models.physics.heat import cooling_coffee
>>> cooling_coffee(T=5.0).elements[0].T
5.0
"""
cooling = RelaxationInteraction(
'cooling',
target='coffee',
state='T',
goal=lambda s: s.target.room,
time=lambda s: 1 / s.target.k,
)
return System(elements=[CoffeeCupElement('coffee', T=T, room=room, k=k)], interactions=[cooling])
Components
class CoffeeCupElement(Element):
"""Cup of coffee at the temperature `T` in a room at the temperature `room`, exchanging heat at the rate `k`.
Args:
label:
The label of the cup.
visual:
How the cup is drawn, or `None` for none.
T:
The initial temperature of the coffee.
room:
The temperature of the room.
k:
The rate of heat exchange.
Examples:
>>> from skcomplex.models.physics.heat import CoffeeCupElement
>>> CoffeeCupElement('coffee', k=0.2).k
0.2
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
T: Annotated[float, StateSpec()] = 90.0,
room: Annotated[float, ParameterSpec()] = 20.0,
k: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 0.1,
) -> None:
super().__init__(label, visual=visual)
self.T = T
self.room = room
self.k = k
class RelaxationInteraction(RateInteraction):
"""Relaxation `(goal(ns) - x) / time(ns)` of a state `x` of a target towards a goal, with optional noise.
With a constant goal and time and an additive noise of amplitude `sigma`, the state follows the Ornstein-Uhlenbeck
process `dx = (goal - x) / time dt + sigma dW`, whose stationary variance is `sigma**2 time / 2`. Without noise it
is first-order smoothing, or adaptive expectation, towards the goal. A goal or a time function takes the namespace
of the interaction, as for `RateInteraction`, and a number stands for a constant. A goal that is a number applies
to every component of a vector state.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose state relaxes.
state:
The name of the state of the target that relaxes.
goal:
A function of the namespace that returns the goal, or a number.
time:
A function of the namespace that returns the relaxation time, or a positive number.
noise:
A function of the namespace that returns the amplitude of the noise, a number, or `None` for no noise.
Raises:
ValidationError:
The state is not a name, or a function is neither callable nor a number.
Examples:
>>> from skcomplex.core.elements import CompartmentElement
>>> from skcomplex.core.interactions import RelaxationInteraction
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> smoothing = RelaxationInteraction('smoothing', target='belief', state='n', goal=10.0, time=0.5)
>>> system = System(elements=[CompartmentElement('belief', n=0.0)], interactions=[smoothing])
>>> trajectory = Simulation(system, dt=0.01).check().prepare().run(until=5.0).trajectory_
>>> round(float(trajectory.values('belief.n')[-1]), 3)
10.0
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str = 'x',
goal: Callable[[SimpleNamespace], object] | float = 0.0,
time: Callable[[SimpleNamespace], object] | float = 1.0,
noise: Callable[[SimpleNamespace], object] | float | None = None,
) -> None:
super().__init__(label, target=target, state=state, noise=noise)
self.goal = goal
self.time = time
def _check_functions(self) -> None:
"""Raise an error for a state that is not a name, or a function that is neither callable nor a number."""
super()._check_functions()
for name in ('goal', 'time'):
value = getattr(self, name)
if not (callable(value) or isinstance(value, int | float)):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function of the namespace or a number.'
raise ValidationError(error_msg)
def _get_goal(self, namespace: SimpleNamespace) -> object:
"""Return the goal, the hook a subclass overrides to derive it from its parameters."""
return self._evaluate(self.goal, namespace)
def _get_time(self, namespace: SimpleNamespace) -> object:
"""Return the relaxation time, the hook a subclass overrides to derive it from its parameters."""
return self._evaluate(self.time, namespace)
def _get_rate(self, namespace: SimpleNamespace) -> object:
"""Return the relaxation `(goal - x) / time` of the state.
Args:
namespace:
The namespace of the symbols the rate can use.
Returns:
rate:
The rate of the state.
"""
state = getattr(self._get_member(namespace, 'target'), self.state)
goal = self._broadcast(self._get_goal(namespace), state)
return (goal - state) / self._get_time(namespace)
Equations
coffee.T: diff(T_coffee(t), t) == k_coffee*(room_coffee - T_coffee(t))
