Forcing
A forcing is any time-varying signal you want to drive a model with — solar irradiance, freshwater flux, CO₂ concentration, or noise. ClimateCritters represents all of these through a pair of layers: a builder layer for constructing piecewise scenarios, and a signal layer that models actually consume.
The two layers at a glance
| Layer | Classes | Role |
|---|---|---|
| Builder | ForcingElement, Hold, Ramp, Harmonic, ForcingSequence |
Compose a timeline of segments |
| Signal | Forcing |
Callable signal consumed by register_forcing |
The typical flow: build a sequence with +, call .compile() to get a Forcing, register it with the model.
import climatecritters as cc
seq = cc.forcing.Hold(100, value=0.0) + cc.forcing.Ramp(50, y0=0.0, yf=4.0) + cc.forcing.Hold(100, value=4.0)
f = seq.compile()
model.register_forcing('S', f, attachment_style='additive', timing='pre')Forcing — the signal object
Forcing wraps any time-varying signal and exposes a uniform get_forcing(t) interface. There are four ways to create one.
From a callable
The simplest case — any Python function or lambda:
# Perpetual 11-year solar cycle
solar = cc.Forcing(lambda t: 1360.0 + 5.0 * np.sin(2 * np.pi * t / 11.0))
model.register_forcing('S0', solar)From a data array
Provide values and an optional time axis; ClimateCritters interpolates:
co2 = cc.Forcing(data=co2_array, time=age_array, interpolation='cubic')Outside the time range the value is held at the first or last point. Use interpolation='linear' for piecewise-linear interpolation.
From a CSV file
Two datasets are bundled:
tsi = cc.Forcing.from_csv(dataset='vieira_tsi') # total solar irradiance
insol = cc.Forcing.from_csv(dataset='insolation') # 65°N summer insolationOr load your own:
co2 = cc.Forcing.from_csv(
file_path='data/co2_record.csv',
time_name='age_kyr',
value_name='co2_ppm',
)From a compiled sequence
Call .compile() on a ForcingSequence (see below):
f = seq.compile() # → ForcingOr equivalently pass the sequence directly to Forcing():
f = cc.Forcing(seq) # auto-compilesBuilder layer
Use the builder layer when you need a signal that changes character over time — a spin-up period, a transition, an oscillation, then a hold.
Hold — constant segment
cc.forcing.Hold(duration=100, value=280.0) # 100 time units at 280
cc.forcing.Hold(dt=50, value=1360.0) # dt is an alias for duration
cc.forcing.Hold(tf=200, value=0.0) # ends at absolute time 200Ramp — monotonic transition
# Linear ramp from 280 to 560 over 100 time units
cc.forcing.Ramp(duration=100, y0=280.0, yf=560.0, shape='linear')
# Cosine (S-curve) ramp; y0 inherits from the previous segment if omitted
cc.forcing.Ramp(duration=100, yf=560.0, shape='cosine')
# Specify amplitude instead of endpoint: yf = y0 + A
cc.forcing.Ramp(duration=100, A=280.0)Harmonic — sinusoidal segment with phase continuity
Harmonic computes the starting phase so the sine wave passes through y0 smoothly, even mid-oscillation:
cc.forcing.Harmonic(duration=20, period=1.0, A=0.5, center=0.0)If y0 is omitted it inherits the endpoint of the previous segment, keeping the signal continuous across the join.
General callable: ForcingElement
Wrap any function as a bounded segment:
cc.forcing.ForcingElement(lambda t: np.exp(-0.01 * t), duration=50.0)ForcingSequence — composing segments with +
Concatenate segments with + to build a ForcingSequence:
scenario = (
cc.forcing.Hold(duration=200, value=280.0)
+ cc.forcing.Ramp(duration=100, y0=280.0, yf=560.0, shape='cosine')
+ cc.forcing.Hold(duration=200, value=560.0)
)Call .compile() when the sequence is ready:
f = scenario.compile() # → Forcing, 500 time-unit durationCompilation is not cached — you can extend the sequence and recompile:
scenario = scenario + cc.forcing.Ramp(duration=100, yf=280.0)
f2 = scenario.compile() # f is unaffectedYou can also pass a list directly to Forcing.from_sequence:
f = cc.Forcing.from_sequence([
cc.forcing.Hold(duration=100, value=0.0),
cc.forcing.Ramp(duration=50, y0=0.0, yf=1.0),
], label='hosing')Value superposition with +
The behaviour of + depends on the types of the operands.
Two Forcing objects → value superposition
The result is an indefinite Forcing whose value is the sum of both signals at every t:
orbital = cc.Forcing(lambda t: 5.0 * np.sin(2 * np.pi * t / 100.0))
seasonal = cc.Forcing(lambda t: 1.0 * np.sin(2 * np.pi * t / 1.0))
combined = orbital + seasonal # → Forcing; value = orbital(t) + seasonal(t)
model.register_forcing('S0', combined)Bounded + Forcing → additive overlay
When one operand is a bounded element or sequence and the other is a Forcing, the result is a bounded Forcing for the duration of the bounded operand. Outside that interval the bounded part holds at its boundary value.
This is the natural way to add noise to a scenario:
rng = np.random.default_rng(42)
noise = cc.Forcing(lambda t: rng.normal(0, 5.0))
# Noise on a single element
noisy_ramp = cc.forcing.Ramp(duration=100, y0=0.0, yf=4.0) + noise
# Noise on an entire scenario
noisy_scenario = scenario + noise # → Forcing, same 500-unit duration
# Commutative — both orderings give the same result
assert np.isclose(
(noise + scenario).get_forcing(150.0),
(scenario + noise).get_forcing(150.0),
)Summary table
| Left | Right | Result | Semantic |
|---|---|---|---|
Forcing |
Forcing |
Forcing (indefinite) |
value superposition |
Forcing |
callable | Forcing (indefinite) |
value superposition |
ForcingElement |
ForcingElement |
ForcingSequence |
temporal concatenation |
ForcingSequence |
ForcingElement / ForcingSequence |
ForcingSequence |
temporal concatenation |
ForcingElement / ForcingSequence |
Forcing |
Forcing (bounded) |
additive overlay |
Forcing |
ForcingElement / ForcingSequence |
Forcing (bounded) |
additive overlay |
Registering forcings with a model
Once you have a Forcing, attach it to a model parameter or state variable with register_forcing:
model.register_forcing(
var_name, # name of the parameter or state variable
forcing_object, # Forcing, callable, or any object with get_forcing(t)
attachment_style, # 'replacement' or 'additive'
timing, # 'pre' or 'post' (inferred for parameters)
)Parameters
attachment_style |
Effect |
|---|---|
'replacement' |
Parameter is patched to forcing(t) at each dydt call |
'additive' |
forcing(t) is added to the nominal parameter value: k = k₀ + forcing(t) |
timing is always 'pre' for parameters and is set automatically.
# Replace solar constant with a time-varying signal
model.register_forcing('S0', cc.Forcing(lambda t: 1360.0 + 5.0 * np.sin(t)))
# Add centred noise to a diffusion coefficient
rng = np.random.default_rng(0)
model.register_forcing('D', cc.Forcing(lambda t: rng.normal(0, 0.01)),
attachment_style='additive')State variables
For state variables, attachment_style and timing both matter:
attachment_style |
timing |
Effect |
|---|---|---|
'replacement' |
'post' (default) |
x[i] is set to forcing(t) after each step |
'additive' |
'pre' |
forcing(t) is added to dx/dt[i] — a continuous source/sink rate |
'additive' |
'post' |
forcing(t) is added to x[i] after each step |
# Freshwater flux directly into salinity tendency
model.register_forcing('S', cc.Forcing(lambda t: 0.1),
attachment_style='additive', timing='pre')Factory functions
The climatecritters.utils.forcing module provides convenience factories. All accept an optional duration argument: omit it for an indefinite Forcing, provide it for a bounded ForcingElement:
from climatecritters.utils.forcing import (
create_forcing,
create_constant_forcing,
create_sinusoid_forcing,
create_periodic_forcing,
make_forcing_element,
)
# Indefinite signals
f_const = create_constant_forcing(1360.0)
f_sin = create_sinusoid_forcing(A=5.0, period=11.0)
f_orb = create_periodic_forcing([(100, 0.6), (41, 0.4)], desired_amplitude=25.0)
# Bounded elements (composable into a ForcingSequence)
elem = create_sinusoid_forcing(A=5.0, period=1.0, duration=50.0)
seq = cc.forcing.Hold(10, value=0.0) + elem + cc.forcing.Hold(10, value=0.0)
f = seq.compile()
# Convert an existing Forcing into a ForcingElement
obs = cc.Forcing.from_csv(dataset='insolation')
elem = make_forcing_element(obs) # duration inferred from time axis
seq = cc.forcing.Hold(50, value=0.0) + elemVisualising forcings
Every forcing object has a .plot() method that returns (fig, ax).
ForcingElement.plot() and ForcingSequence.plot()
ForcingSequence.plot() draws each segment in a distinct colour (Hold → blue, Ramp → orange, Harmonic → green, callable → red) and marks transitions with dotted vertical lines. Per-segment styling is controlled by plot_kwargs:
import matplotlib.pyplot as plt
import climatecritters as cc
scenario = (
cc.forcing.Hold(200, value=280.0,
plot_kwargs={'color': 'steelblue', 'label': 'pre-industrial'})
+ cc.forcing.Ramp(100, y0=280.0, yf=560.0, shape='cosine',
plot_kwargs={'color': 'firebrick', 'label': 'CO₂ ramp'})
+ cc.forcing.Hold(200, value=560.0,
plot_kwargs={'color': 'darkorange', 'label': '2×CO₂'})
)
fig, ax = scenario.plot()
ax.set_xlabel('time (kyr)')
ax.set_ylabel('CO₂ (ppm)')
ax.legend()Pass an existing ax to overlay multiple sequences on the same panel:
fig, ax = plt.subplots()
scenario_a.plot(ax=ax)
scenario_b.plot(ax=ax, linestyle='--')Forcing.plot()
For sequence-backed Forcing objects the time range is inferred automatically. For callable-backed objects you must supply t_span:
# Sequence-backed — inferred
fig, ax = scenario.compile().plot()
# Callable-backed — t_span required
solar = cc.Forcing(lambda t: 1360.0 + 5.0 * np.sin(t))
fig, ax = solar.plot(t_span=(0, 50), color='gold', label='solar')
# Array-backed — inferred from time axis
insol = cc.Forcing.from_csv(dataset='insolation')
fig, ax = insol.plot()All .plot() calls accept the same signature:
fig, ax = forcing.plot(
t_span=(t0, tf), # optional for sequence/array types
n=300, # evaluation points
ax=existing_ax, # optional; creates a new figure if None
color='steelblue', # any matplotlib kwargs
label='my forcing',
)For a deeper walkthrough
See the Forcing notebook for interactive examples covering all signal types, superposition, and model integration.