Skip to content

Two springs in series: a small acausal system of systems

This worked example uses two linear springs connected in series to introduce acausal systems-of-systems modelling in Bilt.

The physical arrangement is:

fixed support
    │
    ├── spring 1 ── joint ── spring 2 ── applied load
    │

More schematically:

flowchart LR
    support[Fixed support] --- s1["Spring 1: k₁, x₁"]
    s1 --- joint[Intermediate joint]
    joint --- s2["Spring 2: k₂, x₂"]
    s2 --- load["Applied load: F"]

Because the springs are in series:

  • the same force passes through both springs;
  • each spring can extend by a different amount;
  • the total extension is the sum of the two spring extensions.

So:

F₁ = F₂ = F

x_total = x₁ + x₂

and each spring obeys Hooke's law:

F = k × x

The useful part of the example is how Bilt composes those relationships.

We will:

  • define one reusable Spring model;
  • use it twice inside a larger DoubleSpring implementation;
  • connect each child to ordinary values on the parent;
  • ask the same equations for different quantities.

The requests below can be decomposed into independent child-system evaluations and ordinary parent equations. Other requests may require a simultaneous solve; the default causalization analyser supports coupled linear blocks across subsystem boundaries.

1. Start with one spring

A spring has three boundary values: stiffness, extension, and force. We declare their dimensions rather than fixing particular units. Stiffness has dimension force per length, extension has dimension length, and force has dimension force.

The system and equations use scalar quantity declarations. These accept scalar quantities or compatible unit-carrying tensors, allowing the same relationships to be reused over named domains. In this example we supply stiffness in N/m, extension in m, and force in N:

from typing import Annotated

from bilt import Implementation, Interface, Quantity, equation, field, system
from bilt.units import meter, newton
from bilt.units.dimensions import FORCE, LENGTH


Stiffness = Annotated[Quantity, FORCE / LENGTH]
Length = Annotated[Quantity, LENGTH]
Force = Annotated[Quantity, FORCE]


@system
class Spring(metaclass=Interface):
    stiffness: Stiffness = field()
    extension: Length = field()
    force: Force = field()

Hooke's law is an equation:

@equation
def hookes_law(
    stiffness: Stiffness,
    extension: Length,
    force: Force,
) -> bool:
    return force == stiffness * extension


linear_spring = Implementation(
    Spring,
    [hookes_law],
)

Because this is an equation, none of the three values is permanently designated as the output.

Given stiffness and extension, Bilt can find force:

k + x → F

Given force and stiffness, the same equation can find extension:

F + k → x

That is the acausal part of the component model.

2. Build a larger system

The parent system exposes the values we want to use when working with two springs:

@system
class DoubleSpring(metaclass=Interface):
    first_stiffness: Stiffness = field()
    second_stiffness: Stiffness = field()

    first_extension: Length = field()
    second_extension: Length = field()
    total_extension: Length = field()

    force: Force = field()

The two Spring occurrences are not public fields. They belong to this implementation of DoubleSpring.

3. Connect the first spring

Parent values are connected to the first child independently:

@equation
def first_stiffness_connection(
    first_stiffness: Stiffness,
    first: Spring,
) -> bool:
    return first_stiffness == first.stiffness


@equation
def first_extension_connection(
    first_extension: Length,
    first: Spring,
) -> bool:
    return first_extension == first.extension


@equation
def first_force_connection(
    force: Force,
    first: Spring,
) -> bool:
    return force == first.force

Conceptually:

parent stiffness  ↔  first.stiffness
parent extension  ↔  first.extension
parent force      ↔  first.force

Each equation touches only one child system.

4. Connect the second spring

The second spring uses the same pattern:

@equation
def second_stiffness_connection(
    second_stiffness: Stiffness,
    second: Spring,
) -> bool:
    return second_stiffness == second.stiffness


@equation
def second_extension_connection(
    second_extension: Length,
    second: Spring,
) -> bool:
    return second_extension == second.extension


@equation
def second_force_connection(
    force: Force,
    second: Spring,
) -> bool:
    return force == second.force

The same parent force value is connected independently to each spring.

flowchart LR
    force["Series force F"] --> s1["Spring 1: F = k₁ x₁"]
    force --> s2["Spring 2: F = k₂ x₂"]

    s1 --> x1["Extension x₁"]
    s2 --> x2["Extension x₂"]

    x1 --> sum["x_total = x₁ + x₂"]
    x2 --> sum
    sum --> total["Total extension"]

This diagram mirrors the mechanics: one common series force acts through both springs, while the two extensions are added on the parent system.

No equation directly contains both spring subsystems.

5. Add the extensions on the parent

The total extension is an ordinary parent equation:

@equation
def total_extension_relation(
    first_extension: Length,
    second_extension: Length,
    total_extension: Length,
) -> bool:
    return total_extension == first_extension + second_extension

This equation is also acausal.

For example:

first extension + second extension → total extension

but also:

total extension + first extension → second extension

6. Put the implementation together

double_spring = Implementation(
    DoubleSpring,
    [
        first_stiffness_connection,
        first_extension_connection,
        first_force_connection,
        second_stiffness_connection,
        second_extension_connection,
        second_force_connection,
        total_extension_relation,
    ],
    internal_implementations={
        "first": linear_spring,
        "second": linear_spring,
    },
)

The hierarchy is therefore:

DoubleSpring
│
├── parent equations
│   └── total extension relation
│
└── internal implementations
    ├── first  → Spring
    └── second → Spring

For the executable documentation below, the same model is loaded from a temporary Python module. This gives Bilt a real source file for inspect.getsource() during the markdown-exec build.

7. Evaluate a compatible spring state

Start by supplying both stiffnesses and the first spring's extension. The shared force then determines the second extension:

known: first stiffness, second stiffness, first extension
want:  common force, total extension

The resulting compatible state is:

Spring 1: 100 N/m × 0.075 m = 7.5 N
Spring 2: 300 N/m × 0.025 m = 7.5 N

so both springs carry the same series force. We calculate the second extension rather than supply it as an additional constraint; specifying both extensions and both stiffnesses would overconstrain this model under structural validation.

forward_case = DoubleSpring(
    first_stiffness=100.0 * newton / meter,
    second_stiffness=300.0 * newton / meter,
    first_extension=0.075 * meter,
)

with double_spring:
    force = forward_case.force
    total_extension = forward_case.total_extension

print(f"Force:           {force.convert_to(newton).value:.3f} N")
print(f"Total extension: {total_extension.convert_to(meter).value:.3f} m")

Force: 7.500 N Total extension: 0.100 m

The result is:

Force:           7.500 N
Total extension: 0.100 m

The force comes from a child spring equation, while total extension comes from the parent equation.

Visualise the operating point

extension_m = np.linspace(0.0, 0.10, 100)
first_stiffness_n_per_m = forward_case.first_stiffness.convert_to(newton / meter).value
second_stiffness_n_per_m = forward_case.second_stiffness.convert_to(newton / meter).value

plt.plot(
    extension_m,
    first_stiffness_n_per_m * extension_m,
    label=f"Spring 1: k = {first_stiffness_n_per_m:g} N/m",
)
plt.plot(
    extension_m,
    second_stiffness_n_per_m * extension_m,
    label=f"Spring 2: k = {second_stiffness_n_per_m:g} N/m",
)

plt.scatter(
    [forward_case.first_extension.convert_to(meter).value],
    [force.convert_to(newton).value],
)
plt.scatter(
    [forward_case.second_extension.convert_to(meter).value],
    [force.convert_to(newton).value],
)

plt.xlabel("Extension (m)")
plt.ylabel("Force (N)")
plt.title("Two spring force-extension relationships")
plt.legend()
render_plot()
2026-11-03T14:37:49.657411 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/

The plot uses numerical values explicitly converted to N/m, m, and N, so they match the labels. The same force intersects the two spring curves at different extensions because their stiffnesses differ.

8. Use the same model in another direction

Now supply the common force and the two stiffnesses, but do not supply either spring extension:

reverse_case = DoubleSpring(
    first_stiffness=100.0 * newton / meter,
    second_stiffness=300.0 * newton / meter,
    force=7.5 * newton,
)

with double_spring:
    first_extension = reverse_case.first_extension
    second_extension = reverse_case.second_extension
    total_extension = reverse_case.total_extension

print(f"First extension:  {first_extension.convert_to(meter).value:.3f} m")
print(f"Second extension: {second_extension.convert_to(meter).value:.3f} m")
print(f"Total extension:  {total_extension.convert_to(meter).value:.3f} m")

First extension: 0.075 m Second extension: 0.025 m Total extension: 0.100 m

Bilt can use Hooke's law in the opposite direction inside each child:

known force + stiffness
        ↓
solve spring extension

The parent can then add the two extensions:

first extension + second extension
                ↓
          total extension

So the complete route is:

flowchart LR
    force["Known series force F"] --> spring1["Spring 1: known k₁"]
    force --> spring2["Spring 2: known k₂"]

    spring1 --> x1["Solve x₁ = F / k₁"]
    spring2 --> x2["Solve x₂ = F / k₂"]

    x1 --> total["Add extensions: x_total = x₁ + x₂"]
    x2 --> total

The model is still describing the same two springs in series. Bilt is simply using Hooke's law in the opposite direction inside each spring.

The component equations have not changed. Only the known values and requested values have.

9. A request that requires a coupled solve

A tempting query is:

known:
    total extension
    first stiffness
    second stiffness

requested:
    common force

For two springs in series, that requires Bilt to determine the shared force and both unknown spring extensions together.

That is a genuinely coupled solve across the two child systems.

The default causalization analyser supports this linear coupled formulation. The worked calculations above use decomposable routes, demonstrating that the same modelling API supports both kinds of request.

10. Explore the model yourself

Try changing the common force:

force = 10.0 * newton

or either stiffness:

first_stiffness = 150.0 * newton / meter
second_stiffness = 250.0 * newton / meter

Then ask again for:

spring_system = DoubleSpring(
    first_stiffness=first_stiffness,
    second_stiffness=second_stiffness,
    force=force,
)

with double_spring:
    total_extension = spring_system.total_extension

You can also go back to the forward form and provide the first spring's extension instead of the force.

To explore locally, edit the values while zensical serve is running and save the Markdown file. Zensical will rebuild the page and rerun the executable blocks.

11. What this example demonstrates

The spring example separates three ideas clearly.

Reusable component physics

Spring
    force = stiffness × extension

One implementation is reused twice.

Systems-of-systems composition

Each child connects independently to parent values:

parent value
    ↕
child boundary

and the parent owns the relationship between the two extensions:

total extension = first extension + second extension

Acausal evaluation

The same Hooke's-law equation supports both:

stiffness + extension → force

and:

force + stiffness → extension

No transforms or manually inverted equations are needed.

This is a small example of the same pattern used by the larger two-shaft turbomachinery model.