Build and solve a model in Python

Seven objects cover almost every model: Node, Material, Section, Member, MemberSet, NodalSupport and NodalLoad. You assemble them, call run_analysis(), and read a results bundle keyed by load case.

The core objects

Everything is imported from fers_core. A model is a FERS instance holding member sets; loads hang off load cases rather than off the model directly, so the same geometry can carry several independent load cases.

  • Node — an X, Y, Z point. Y is up. Nodes own their support condition.
  • Material — E-modulus, shear modulus, density, yield stress. All SI: pascals and kg/m³.
  • Section — area and second moments of area, referencing a material.
  • Member — a 2-node beam element with six degrees of freedom per node, referencing a section.
  • MemberSet — the group you add to the model; also the unit design checks are applied to.
  • NodalSupport — the boundary condition assigned to a node.
  • NodalLoad — a force or moment applied to a node within a load case.
from fers_core import (
    FERS, Node, Member, Section, Material,
    MemberSet, NodalSupport, NodalLoad
)

Define geometry, material and section

Coordinates are in metres and material properties in pascals. The section below is an IPE 180 taken from the FERS steel section library.

Note the axis convention, because it is the single most common source of a wrong answer: i_z is the strong axis and i_y the weak one. A beam bending in its usual plane is governed by i_z.

# Nodes (x, y, z coordinates in metres)
node1 = Node(0, 0, 0)
node2 = Node(5, 0, 0)

# Material (Steel S235)
steel = Material(
    name="Steel S235",
    e_mod=210e9,     # Pa
    g_mod=80.769e9,  # Pa
    density=7850,    # kg/m³
    yield_stress=235e6  # Pa
)

# Cross-section (IPE 180), values from the FERS steel section library.
# Axis convention: i_z is the STRONG axis (bending in the local x-y plane),
# i_y the weak axis — see ferscloud.com/conventions
section = Section(
    name="IPE 180",
    material=steel,
    i_y=1.009e-6,  # m⁴ (weak axis)
    i_z=13.17e-6,  # m⁴ (strong axis)
    j=0.0477e-6,   # m⁴
    area=0.00240   # m²
)

Full axis, sign and unit conventions are on the conventions page. The JSON contract takes angles in degrees, not radians.

Create the member, apply a support and a load

A bare NodalSupport() is fully fixed. For a pin or a roller, pass explicit displacement_conditions and rotation_conditions — see the worked examples for both.

Load directions are global unit vectors, so (0, 1, 0) with a negative magnitude is a downward force.

# Create beam element
beam = Member(start_node=node1, end_node=node2, section=section)

# Fixed support at node1
node1.nodal_support = NodalSupport()

# Organise members into a set and add to model
model = FERS()
model.add_member_set(MemberSet(members=[beam]))

# Load case with a −1 kN point load at node2
lc = model.create_load_case(name="Gravity")
NodalLoad(node=node2, load_case=lc, magnitude=-1000, direction=(0, 1, 0))

Run the analysis and read results

run_analysis() solves every load case. Results come back on model.resultsbundle, keyed by load-case name, with displacements and reactions keyed by node id as a string.

The comments show the actual output for these exact inputs.

model.run_analysis()

results = model.resultsbundle.loadcases["Gravity"]

dy = results.displacement_nodes["2"].dy
Vy = results.reaction_nodes["1"].nodal_forces.fy
Mz = results.reaction_nodes["1"].nodal_forces.mz

print(f"Tip deflection : {dy*1000:.3f} mm")   # -15.065 mm
print(f"Reaction Vy    : {Vy:.1f} N")         # 1000.0 N
print(f"Reaction Mz    : {Mz:.1f} Nm")        # 5000.0 Nm

Hand check: a 5 m cantilever bending about its strong axis gives δ = PL³ / 3EI_z = 1000·5³ / (3 · 210×10⁹ · 13.17×10⁻⁶) = 15.07 mm, with a 1 kN reaction and a 5 kN·m fixing moment from equilibrium. The solver returns 15.065 mm.

Read, save and reload results

Since FERS 0.1.98, member_results and displacement_nodes are read-only tables. They read like dictionaries — [], get, in, .items() — but every lookup builds a fresh object that cannot be changed, which is what keeps a large result small in memory. .copy() returns a plain dictionary of editable objects.

save_to_json writes the model with its results, and FERS.from_json reads back any file the SDK or the solver wrote, one entry at a time. For a large model, let the solver write the file with run_analysis_to_file: the result then never passes through Python as one string.

from fers_core import FERS

results = model.resultsbundle.loadcases["Gravity"]

# A read-only table: every lookup builds a fresh object
beam_result = results.member_results["1"]
print(beam_result.local_start_forces.mz)

editable = results.member_results.copy()   # a plain dict of editable objects
editable["1"].local_start_forces.mz = 0.0

model.save_to_json("cantilever.json")      # the model and its results
reloaded = FERS.from_json("cantilever.json")

# A large model: let the solver write the file itself
model.run_analysis_to_file("cantilever_results.json")
solved = FERS.from_json("cantilever_results.json")

Ask only for the results you read

result_filter keeps or drops whole blocks of results. result_requests (FERS 0.1.99) narrows a block to the members or nodes, load combinations, components and member end that a check actually reads. The values load into the usual member_results, displacement_nodes and reaction_nodes, and anything not requested reads as None, so a check can never pass on a zero that nobody computed.

Load combinations are selected by their limit_state, as design checks select them, so set it on each combination. The values themselves are on resultsbundle.selections: one array per request, shaped (result sets, ids, fields, components), with each axis listed beside it.

from fers_core import ResultBlock, ResultRequest
from fers_core.loads.enums import LimitState
from fers_core.result_requests import nodes_of
from fers_core.unity_checks import members

# Requests select load combinations by limit state, as design checks do
uls = model.create_load_combination("ULS 1", {lc: 1.35}, situation="ULS", check="ALL")
uls.limit_state = LimitState.ULS

beams = members([beam.id])
model.settings.analysis_options.result_requests = [
    ResultRequest(ResultBlock.LOCAL_END_FORCES, members=beams,
                  limit_state="ULS", end="start", components=["fy", "mz"]),
    ResultRequest(ResultBlock.NODE_DISPLACEMENTS, nodes=nodes_of(beams),
                  limit_state="ULS", components=["dy"]),
]
model.run_analysis()

uls_results = model.resultsbundle.loadcombinations["ULS 1"]
start = uls_results.member_results["1"].local_start_forces
print(start.mz)   # requested
print(start.my)   # None: not requested

selection = model.resultsbundle.selections[0]
print(selection["fields"], selection["components"])
print(selection["values"].shape)   # (result sets, ids, fields, components)

On a 4-bay racking model with 8,546 members and 31 load combinations, the result file shrank from 452 MB to 41 MB with only the values its checks read.

Let a continuous beam turn on what it crosses

A rail running over beams is continuous along its length but free to turn on every beam. A MemberHinge releases one member end, so releasing both rail ends at a beam would split the rail into two spans. Give the rail's members a member set of their own with a ScissorHinge (FERS 0.1.99): the set stays joined to itself and turns, or slides, on every member it meets outside the set, with no link member or extra node.

Each value works as a member hinge release does: None holds the axis, 0.0 frees it and a positive value is a spring. The axes are global unless the hinge names a frame with ReleaseAxes.user(x, y), scissor_hinge_nodes limits the hinge to some of the set's nodes, and a MemberHinge can take its rotations about global or user axes with rotation_axes. A model that uses any of them is written with schema_version 3, so an older solver refuses it instead of solving every connection as rigid.

from fers_core import FERS, DistributedLoad, Material, Member, MemberSet, NodalSupport, Node, ScissorHinge, Section
from fers_core.supports.supportcondition import SupportCondition as C

model = FERS()
steel = Material(name="S355", e_mod=210e9, g_mod=81e9, density=7850.0, yield_stress=355e6)
rail_section = Section(name="rail", material=steel, i_y=1e-6, i_z=1e-6, j=1e-7, area=1e-3)
beam_section = Section(name="beam", material=steel, i_y=1e-4, i_z=1e-4, j=1e-4, area=5e-3)

turns = {"X": C.fixed(), "Y": C.free(), "Z": C.free()}
a, m, b = Node(0.0, 0.0, 0.0), Node(1.0, 0.0, 0.0), Node(2.0, 0.0, 0.0)
a.nodal_support = NodalSupport(rotation_conditions=turns)
b.nodal_support = NodalSupport(
    displacement_conditions={"X": C.free(), "Y": C.fixed(), "Z": C.fixed()}, rotation_conditions=turns
)
beam_start, beam_end = Node(1.0, 0.0, -0.5), Node(1.0, 0.0, 0.5)
beam_start.nodal_support = beam_end.nodal_support = NodalSupport()  # clamped

rail_1 = Member(start_node=a, end_node=m, section=rail_section)
rail_2 = Member(start_node=m, end_node=b, section=rail_section)
model.add_member_set(MemberSet(
    members=[rail_1, rail_2],
    scissor_hinge=ScissorHinge(rotational_release_y=0.0, rotational_release_z=0.0),
))
model.add_member_set(MemberSet(members=[
    Member(start_node=beam_start, end_node=m, section=beam_section),
    Member(start_node=m, end_node=beam_end, section=beam_section),
]))

span = model.create_load_case(name="span")
DistributedLoad(member=rail_1, load_case=span, magnitude=1000.0, end_magnitude=1000.0, direction=(0, -1, 0))
model.run_analysis()
mz = model.resultsbundle.loadcases["span"].member_results[str(rail_1.id)].local_end_forces.mz
print(f"{abs(mz):.1f} Nm")  # 62.4; a knife edge gives qL²/16 = 62.5, a rigid joint 122.6

Hand check: a rail continuous over two 1 m spans, one of them loaded, has a moment over the middle support of qL²/16 = 1000 · 1² / 16 = 62.5 N·m. The solver returns 62.4 N·m, the beam giving a little under the rail; fixed to the beam, the rail takes 122.6 N·m.

Beyond linear statics

The same model object carries the settings for the other analysis types. Second-order (P-Delta) analysis, linear buckling with a critical load factor, modal analysis and response-spectrum seismic analysis are all driven from analysis settings rather than from a different API.

Design checks are attached to member sets and evaluated after the solve, returning per-clause utilizations with the intermediate values that produced them — so a check can be read as a hand calculation rather than a single number.

  • Verify against closed-form solutions first — see accuracy benchmarks and the NAFEMS set.
  • For EN 1993-1-1 member checks, check_beam builds, solves and checks a single span in one call — see the examples.

Related pages

See also

Frequently asked questions

Why are my displacements keyed by a string?

Result dictionaries are keyed by node and member id as strings, because the results bundle round-trips through JSON. displacement_nodes["2"], not [2].

Why does changing a result raise an error?

Results are read-only since FERS 0.1.98: every lookup builds a new object, so an edit would be lost at the next lookup. Call .copy() on member_results or displacement_nodes for a plain dictionary of editable objects.

What is a MemberSet for?

It is the unit you add to the model and the unit design checks apply to. Grouping members that form one physical element — a column spliced from two members, say — lets a buckling length or a code check span the whole thing.

How do I model a pin or a roller?

Pass explicit conditions to NodalSupport. A pin fixes the three translations and leaves bending rotations free; hold the torsional rotation as well or the member has a rigid-body twist mode and the solve is singular. The simply supported example shows both.

Which second moment of area is the strong axis?

i_z. FERS treats local z as the strong bending axis throughout — the solver, the design checks and the diagrams all agree on this. Swapping i_y and i_z is the most common modelling error.

Do I need an internet connection to solve?

No. run_analysis() runs the compiled solver in your own process. Network access is only used if you call a cloud endpoint.

Can I get the deflected shape rather than just end values?

Yes, and it is load-exact rather than interpolated between end displacements — a fixed-fixed beam under a uniformly distributed load reports the true wL⁴/384EI sag. Request it in the analysis options.