API reference

Every module carries its own reasoning: why the model is what it is, what was tried and rejected, and where the approximations are.

Physics

The 11-dimensional design vector X of the EX-link mechanism.

The linkage is parametrised by

\[X = (a, c, I, x_b, y_b, x_1, e, q_1, q_2, \theta_f, \theta_r)^T\]

Rather than describing the trigonal link by its three sides b, c, d – which must satisfy the triangle inequality and leave the sign of theta_b undetermined – E is placed in the frame carried by c = AD. x_b and y_b are then free to take any value, positive or negative, and theta_b = atan2(y_b, x_b) carries its own sign. That reparametrisation is what makes the design space a plain box.

exlink.design.VARIABLE_NAMES: tuple[str, ...] = ('a', 'c', 'I', 'x_b', 'y_b', 'x_1', 'e', 'q_1', 'q_2', 'theta_f', 'theta_r')

Order of the design variables in the flat vector representation.

exlink.design.VARIABLE_DESCRIPTIONS: dict[str, str] = {'I': 'distance between crankshaft and eccentric shaft [mm]', 'a': 'length of the swing rod QA [mm]', 'c': 'side AD of the trigonal link [mm]', 'e': 'length of the piston rod EP [mm]', 'q_1': 'length of the crank fixed to the crankshaft [mm]', 'q_2': 'length of the crank fixed to the eccentric shaft [mm]', 'theta_f': 'dephasing between the two cranks at theta_1 = 0 [deg]', 'theta_r': 'orientation of the crankshaft-to-eccentric-shaft axis [deg]', 'x_1': 'lateral offset of the cylinder axis from the crankshaft [mm]', 'x_b': 'abscissa of E in the frame carried by AD [mm]', 'y_b': 'ordinate of E in the frame carried by AD [mm]'}

Human-readable description of each design variable.

exlink.design.ANGULAR_VARIABLES: frozenset[str] = frozenset({'theta_f', 'theta_r'})

The two entries of the design vector that are angles, stored in degrees.

class exlink.design.Design(a, c, I, x_b, y_b, x_1, e, q_1, q_2, theta_f, theta_r)[source]

Bases: object

A single set of mechanism dimensions.

Angles are stored in degrees because that is how they are reported and bounded; every consumer converts to radians through theta_f_rad / theta_r_rad.

a: float
c: float
I: float
x_b: float
y_b: float
x_1: float
e: float
q_1: float
q_2: float
theta_f: float
theta_r: float
names: ClassVar[tuple[str, ...]] = ('a', 'c', 'I', 'x_b', 'y_b', 'x_1', 'e', 'q_1', 'q_2', 'theta_f', 'theta_r')
classmethod from_array(values)[source]

Build a design from a flat vector ordered as VARIABLE_NAMES.

Return type:

Design

classmethod from_mapping(mapping)[source]

Build a design from a {name: value} mapping (GEMSEO input data).

Return type:

Design

to_array()[source]

Return the design as a flat vector ordered as VARIABLE_NAMES.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

to_mapping()[source]

Return the design as GEMSEO-style {name: array([value])} data.

Return type:

dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

replace(**changes)[source]

Return a copy with some variables overridden.

Return type:

Design

property theta_f_rad: float

Crank dephasing theta_f in radians.

property theta_r_rad: float

Shaft-axis orientation theta_r in radians.

property b: float

Side AE of the trigonal link, b = hypot(x_b, y_b) [mm].

property theta_b: float

Signed angle atan2(y_b, x_b) between AE and AD [rad].

Equivalent to the Carnot expression arccos((b^2 + c^2 - d^2) / (2 b c)), but with a sign.

property d: float

Side DE of the trigonal link [mm].

property r_1: float

Primitive radius of the crankshaft gear, r_1 = 2 I / 3 [mm].

property r_2: float

Primitive radius of the eccentric-shaft gear, r_2 = I / 3 [mm].

class exlink.design.Bounds(lower, upper)[source]

Bases: object

Box bounds l_b <= X <= u_b on the design vector.

lower: ndarray[tuple[Any, ...], dtype[float64]]
upper: ndarray[tuple[Any, ...], dtype[float64]]
classmethod around(design, relative=0.1, absolute_angle=20.0)[source]

Bounds centred on design, as used for the local Pareto fronts.

A MOEA seeded near a known solution wants a shrunken box, nominally 0.9 X_0 <= X <= 1.1 X_0. A purely multiplicative rule collapses on variables that may be negative or near zero (x_1, y_b, the angles), so angles get a symmetric absolute window instead.

Parameters:
  • design (Design) – The centre of the box.

  • relative (float) – Half-width as a fraction of |X_0| for length variables.

  • absolute_angle (float) – Half-width in degrees for theta_f and theta_r.

Return type:

Bounds

to_array()[source]

Return (lower, upper) as a pair of arrays.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]

clip(design)[source]

Project a design back into the box.

Return type:

Design

contains(design)[source]

Whether a design lies inside the box.

Return type:

bool

items()[source]

Iterate over (name, lower, upper) triples.

Return type:

Iterable[tuple[str, float, float]]

exlink.design.GLOBAL_BOUNDS = Bounds(lower=array([  20.,   20.,   20., -200., -200., -100.,   40.,    2.,    2.,        -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300.,  60.,  80., 180., 180.]))

Global bounds used by the global searches.

Chosen to comfortably contain every design found here while keeping each length physically sensible for a single-cylinder engine of 74 mm expansion stroke.

Physical constants and specification data for the Honda EX-link derived engine.

Unit system used throughout the package

quantity

unit

length

mm

angle

rad (degrees only at the API surface)

area

mm^2

volume

mm^3

pressure

MPa (= N/mm^2); 1 bar = 0.1 MPa

force

N

torque

N.mm

The numbers below define a single-cylinder Shell Eco-marathon engine and are held fixed throughout; only the linkage geometry and the member sections are designed.

exlink.constants.BAR = 0.1

One bar expressed in MPa.

class exlink.constants.EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659)[source]

Bases: object

Fixed data of the thermodynamic cycle and of the cylinder.

Everything that is not a design variable lives here, so that a study can be repeated with a different brief by passing another EngineSpec around instead of editing the code.

bore: float = 32.0

Piston diameter phi [mm].

piston_length: float = 16.0

Distance p from the wrist pin P to the piston crown H [mm].

piston_skirt: float = 16.0

Guided length h of the piston inside the liner [mm].

Only enters the (unused) reaction moment M_D, so it simply defaults to the piston length.

dead_volume: float = 3000.0

Clearance volume V0 at top dead centre [mm^3] (3 cc).

p_intake: float = 0.12

Intake / exhaust plenum pressure P0 [MPa] (1.2 bar).

heat_capacity_ratio: float = 1.22

Polytropic exponent gamma of compression and expansion.

explosion_ratio: float = 1.7

k = P3 / P2, the instantaneous pressure jump modelling combustion.

gear_ratio: float = 2.0

r1 / r2 between the analysis shaft’s gear and the output shaft’s.

The kinematics of exlink.kinematics are parametrised on theta_1, the shaft carrying q_1 and the large gear, and the four strokes complete in one turn of it. The second shaft therefore turns theta_2 = -2 theta_1 + theta_f: twice per cycle, or 720 deg, which is what a conventional four-stroke crankshaft does. Power is taken from that shaft, so theta_1 is the half-speed member of the pair – Honda’s own arrangement – and every engine speed the study quotes is the output shaft’s, at output_revolutions_per_cycle times the speed_rpm the analysis functions take.

pressure_angle: float = 0.3490658503988659

Gear pressure angle alpha.

20 deg is the standard involute profile and is used throughout; alpha enters only the gear-mesh separating force.

property piston_area: float

Cross-section pi phi^2 / 4 on which the gas pressure acts [mm^2].

property output_revolutions_per_cycle: float

Turns of the output shaft per four-stroke cycle [-].

Two, from gear_ratio, and the same as a conventional engine – which is what makes the comparison of section 6.3 a comparison at equal speed and equal firing rate rather than one needing a correction.

output_speed_rpm(speed_rpm)[source]

Output-shaft speed for an analysis speed theta_1 [rev/min].

Return type:

float

output_torque(torque)[source]

Torque referred from theta_1 to the output shaft [N.mm].

The virtual-work identity of exlink.loads makes M_r the whole engine torque referred to theta_1, so referring it to a shaft turning gear_ratio times faster divides it by that ratio and leaves the power untouched. Nothing physical depends on which shaft the reading is taken from; only the numbers on the datasheet do.

Return type:

float

class exlink.constants.DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0)[source]

Bases: object

Right-hand sides of the equality and inequality constraints.

The performance requirements the engine must meet, plus the well-posedness constraints that keep the linkage analysable (see exlink.kinematics).

expansion_stroke: float = 74.0

Required expansion stroke STE [mm] (equality constraint).

compression_ratio: float = 16.0

Required compression ratio epsilon (equality constraint).

max_rod_angle: float = 10.0

Upper bound on mra, the piston-rod tilt away from vertical [deg].

max_transmission: float = 0.985

Upper bound C on W = max(delta_c1, delta_c2).

C = 0.9848 keeps the transmission angle T inside [10 deg, 170 deg]; it is rounded to 0.985 here.

max_tdc_gap: float = 0.01

Upper bound on g, the gap between the two top dead centres [mm].

min_clearance: float = 10.0

Lower bound on d, trigonal-link to cylinder clearance [mm].

max_side_load: float = 0.02

Upper bound on gamma = max(D) / max(P), the piston side-load ratio.

max_bearing_load: float = 25000.0

Upper bound on the peak crankshaft bearing reaction [N].

Only meaningful once inertia is in the load path: at rest this mechanism peaks near 7.7 kN, but the reaction grows as the square of engine speed and is what a plain bearing actually has to survive.

class exlink.constants.PenaltyValues(efficiency=0.0, height=1000.0, width=1000.0)[source]

Bases: object

Values substituted when a design cannot be analysed at all.

eta(X) = 0, H(X) = 1000, B(X) = 1000 are substituted whenever the kinematic compatibility conditions (4a)/(6a) fail, or whenever lambda(theta_1) does not have four monotone phases.

efficiency: float = 0.0
height: float = 1000.0
width: float = 1000.0

Closed-form kinematics of the EX-link linkage.

The loop-closure equations are inverted analytically rather than solved with Newton-Raphson. That choice matters twice over: the evaluation is fast enough to sit inside an evolutionary algorithm, and the two arccosine arguments it exposes – delta_c1 (4a) and delta_c2 (6a) – become explicit compatibility measures. A design whose |argument| reaches 1 for some crank angle cannot turn continuously: the crankshaft only rocks. Feeding those two numbers to the optimizer as a constraint, instead of letting the analysis fail, is what makes the problem globally solvable.

Solution chain for a given crank angle theta_1:

  1. theta_2 = -2 theta_1 + theta_f (1)

  2. (A, B), the closure of the four-bar R1 Q A D R2 (3a)

  3. T = arccos((A^2 + B^2 - a^2 - c^2) / (2 a c)) (4)

  4. q = atan2(a sin T, a cos T + c) (2a)

  5. theta_T = atan2(B, A) - q and theta_a = theta_T + T (5)

  6. theta_e = arccos((q_1 sin theta_1 - a cos theta_a - b cos(theta_b + theta_T) + x_1) / e) (6)

  7. lambda = q_1 cos theta_1 + a sin theta_a + b sin(theta_b + theta_T) + e sin theta_e + p (7)

Every inverted cosine keeps the positive root, which selects the assembly mode the mechanism actually runs in.

exlink.kinematics.DEFAULT_SAMPLES = 720

Crank angles per revolution used by default.

720 samples (0.5 deg) resolve the two top dead centres finely enough that the parabolic refinement in exlink.metrics lands within a micrometre.

exlink.kinematics.crank_angles(samples=720)[source]

Return samples crank angles spanning one revolution, endpoint excluded.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

class exlink.kinematics.Kinematics(design, spec, theta_1, theta_2, theta_T, theta_a, theta_e, lam, transmission_angle, delta_c1, delta_c2, feasible, R1, R2, Q, A, D, E, P, H)[source]

Bases: object

Pose of every body over one crankshaft revolution.

All array attributes are shaped (n_angles,) unless stated otherwise; point attributes are shaped (n_angles, 2).

design: Design
spec: EngineSpec
theta_1: ndarray[tuple[Any, ...], dtype[float64]]
theta_2: ndarray[tuple[Any, ...], dtype[float64]]
theta_T: ndarray[tuple[Any, ...], dtype[float64]]
theta_a: ndarray[tuple[Any, ...], dtype[float64]]
theta_e: ndarray[tuple[Any, ...], dtype[float64]]
lam: ndarray[tuple[Any, ...], dtype[float64]]

Piston crown height lambda above the crankshaft axis [mm].

transmission_angle: ndarray[tuple[Any, ...], dtype[float64]]

T = theta_a - theta_T, the swing-rod / trigonal transmission angle.

delta_c1: float

max |cos T| over the revolution – compatibility condition (4a).

delta_c2: float

max |cos theta_e| over the revolution – compatibility condition (6a).

feasible: bool

Whether the crankshaft completes a full revolution.

False as soon as either compatibility argument reaches 1 somewhere, in which case the angles below are computed from clipped arguments and are physically meaningless.

R1: ndarray[tuple[Any, ...], dtype[float64]]
R2: ndarray[tuple[Any, ...], dtype[float64]]
Q: ndarray[tuple[Any, ...], dtype[float64]]
A: ndarray[tuple[Any, ...], dtype[float64]]
D: ndarray[tuple[Any, ...], dtype[float64]]
E: ndarray[tuple[Any, ...], dtype[float64]]
P: ndarray[tuple[Any, ...], dtype[float64]]
H: ndarray[tuple[Any, ...], dtype[float64]]
property compatibility: float

W = max(delta_c1, delta_c2), the transmission-angle measure.

property bodies: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Named polylines, each shaped (n_angles, n_points, 2).

Used by the plotting and animation helpers and by the bounding-box computation, so that both see exactly the same geometry.

exlink.kinematics.solve(design, samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), theta_1=None)[source]

Solve the linkage over one crankshaft revolution.

Parameters:
  • design (Design) – The mechanism dimensions.

  • samples (int) – Number of crank angles, ignored when theta_1 is given.

  • spec (EngineSpec) – Fixed engine data (only piston_length is used here).

  • theta_1 (ndarray[tuple[Any, ...], dtype[float64]] | None) – Explicit crank angles [rad]; defaults to a uniform revolution.

Return type:

Kinematics

Returns:

The pose of every body at every crank angle. Check Kinematics.feasible before trusting the angles: an incompatible design is still returned, with clipped arccosine arguments, so that the optimizer can read delta_c1 / delta_c2 and be steered by them.

Fitting the approximate Atkinson cycle onto the piston motion.

The brief specifies an idealised cycle: intake and exhaust losses neglected, compression and expansion adiabatic with a common exponent gamma, combustion modelled as an instantaneous pressure jump k = P3 / P2 at top dead centre, and blow-down modelled as an instantaneous fall to P0 at the end of expansion.

0 -> 1  intake       P = P0                       (TDC -> shallow BDC)
1 -> 2  compression  P = P0 (V1 / V)^gamma        (shallow BDC -> TDC)
2 -> 3  combustion   P3 = k P2                    (instantaneous, at TDC)
3 -> 5  expansion    P = P3 (V0 / V)^gamma        (TDC -> deep BDC)
5 -> 6  blow-down    P -> P0                      (instantaneous, at BDC)
6 -> 0  exhaust      P = P0                       (deep BDC -> TDC)

What makes this an Atkinson cycle is that the piston reaches top dead centre twice per crankshaft revolution while the two bottom dead centres differ: the short one sets the compression stroke, the long one the expansion stroke. So before any pressure can be assigned, lambda(theta_1) must be shown to have exactly four monotone phases – two maxima and two distinct minima. That test is find_phases(), and it is the numerical heart of the well-posedness argument: designs that fail it are penalised rather than analysed.

class exlink.cycle.Phase(value)[source]

Bases: IntEnum

The four strokes, in the order they follow one another.

EXPANSION = 0
EXHAUST = 1
INTAKE = 2
COMPRESSION = 3
exception exlink.cycle.PhaseError[source]

Bases: ValueError

Raised when lambda(theta_1) is not a valid four-phase Atkinson motion.

class exlink.cycle.Phases(labels, lam_tdc, tdc_gap, expansion_stroke, compression_stroke, expansion_strokes, compression_strokes, maxima_indices, minima_indices)[source]

Bases: object

Decomposition of one revolution into the four strokes.

labels: ndarray[tuple[Any, ...], dtype[int64]]

Phase index of every crank angle, shaped (n_angles,).

lam_tdc: float

Piston height at top dead centre (the higher of the two maxima) [mm].

tdc_gap: float

g, the gap between the two top dead centres [mm].

expansion_stroke: float

STE, from top dead centre to the deeper bottom dead centre [mm].

compression_stroke: float

STC, from top dead centre to the shallower bottom dead centre [mm].

expansion_strokes: tuple[float, float]

STE measured from each top dead centre separately [mm].

expansion_stroke is the larger of these two, and that maximum is the only physically meaningful figure – the clearance volume is set by whichever top dead centre the piston reaches highest. Both are kept because a maximum of two smooth functions is not itself smooth, and at a design whose two top dead centres nearly coincide the difference matters: §5.3 shows the reliability estimate failing by a factor of seven at exactly such a design, because it linearises one branch of a constraint the parts straddle. Having the branches separately is what lets that be diagnosed, and what a smooth reformulation would bound.

compression_strokes: tuple[float, float]

STC measured from each top dead centre separately [mm].

maxima_indices: tuple[int, int]
minima_indices: tuple[int, int]
exlink.cycle.find_phases(lam)[source]

Split one revolution of lambda(theta_1) into the four strokes.

Parameters:

lam (ndarray[tuple[Any, ...], dtype[float64]]) – Piston height at uniformly spaced crank angles over [0, 2 pi).

Return type:

Phases

Returns:

The phase decomposition and the two strokes.

Raises:

PhaseError – If the motion does not consist of exactly four monotone phases with two maxima and two minima – i.e. the design does not realise an Atkinson cycle at all (a plain Otto motion with a single up-and-down per revolution fails here, as do “stair-stepped” motions with spurious intermediate reversals).

class exlink.cycle.Thermodynamics(phases, volume, pressure, gauge_pressure, piston_force, compression_ratio, p_compression_end, p_combustion)[source]

Bases: object

In-cylinder state over one crankshaft revolution.

phases: Phases
volume: ndarray[tuple[Any, ...], dtype[float64]]

Instantaneous cylinder volume V(theta_1) [mm^3].

pressure: ndarray[tuple[Any, ...], dtype[float64]]

Absolute in-cylinder pressure P(theta_1) [MPa].

gauge_pressure: ndarray[tuple[Any, ...], dtype[float64]]

P - P0, the pressure that actually loads the piston [MPa].

piston_force: ndarray[tuple[Any, ...], dtype[float64]]

Gas force on the piston crown, (P - P0) pi phi^2 / 4 [N].

compression_ratio: float

epsilon = V1 / V0 realised by this design.

p_compression_end: float

P2 at the end of compression [MPa].

p_combustion: float

P3 = k P2 just after combustion [MPa].

exlink.cycle.solve(lam, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), phases=None)[source]

Apply the approximate Atkinson cycle to a piston motion.

Parameters:
  • lam (ndarray[tuple[Any, ...], dtype[float64]]) – Piston height at uniformly spaced crank angles over [0, 2 pi).

  • spec (EngineSpec) – Fixed engine data.

  • phases (Phases | None) – A precomputed decomposition; recomputed from lam if omitted.

Return type:

Thermodynamics

Returns:

Volume, pressure and piston force at every crank angle.

Raises:

PhaseError – Propagated from find_phases().

Quasi-static force analysis of the linkage.

Inertia forces and torques are neglected here: this is a first sizing iteration, and the masses are not known until the parts have a shape. exlink.dynamics restores them. The chain runs from the piston down to the two shafts.

Piston. With P the gas force on the crown and theta_e the piston-rod angle, C = P / sin(theta_e) is the rod load and D = P cot(theta_e) the side load carried by the liner.

Trigonal link. Force balance at A (swing rod), D (crank q_2) and E (piston rod), plus the moment about D, give the swing-rod load A and the reaction Q. The moment equation carries sin(theta_a - theta_T) in its denominator, so A blows up exactly at the critical configurations that condition (4a) is there to keep away – a second, independent reason to enforce it.

Shafts. The gear tooth load T follows from the moment balance of the eccentric shaft, and the crankshaft moment balance then yields the output torque

\[M_r = q_1 A \cos(\theta_a - \theta_1) + r_1 T \cos\alpha .\]
class exlink.loads.Loads(piston_force, rod_force, side_force, liner_moment, swing_force, trigonal_reaction, gear_force, eccentric_reaction, crank_reaction, torque)[source]

Bases: object

Internal forces and output torque over one crankshaft revolution.

piston_force: ndarray[tuple[Any, ...], dtype[float64]]

Gas force P on the piston crown [N].

rod_force: ndarray[tuple[Any, ...], dtype[float64]]

Piston-rod load C [N].

side_force: ndarray[tuple[Any, ...], dtype[float64]]

Piston side load D reacted by the liner [N].

liner_moment: ndarray[tuple[Any, ...], dtype[float64]]

Reaction moment M_D about the wrist pin [N.mm].

swing_force: ndarray[tuple[Any, ...], dtype[float64]]

Swing-rod load A [N].

trigonal_reaction: ndarray[tuple[Any, ...], dtype[float64]]

Reaction Q = (Q_x, Q_y) at D, shaped (n_angles, 2) [N].

gear_force: ndarray[tuple[Any, ...], dtype[float64]]

Gear tooth load T [N].

eccentric_reaction: ndarray[tuple[Any, ...], dtype[float64]]

Eccentric-shaft bearing reaction R_2, shaped (n_angles, 2) [N].

crank_reaction: ndarray[tuple[Any, ...], dtype[float64]]

Crankshaft bearing reaction R_1, shaped (n_angles, 2) [N].

torque: ndarray[tuple[Any, ...], dtype[float64]]

Output torque M_r on the crankshaft [N.mm].

property mean_torque: float

M_r,ave, averaged over one revolution [N.mm].

property mean_piston_force: float

P_ave, averaged over one revolution [N].

property side_load_ratio: float

gamma = max(D) / max(P), the piston side-load ratio.

exlink.loads.solve(kinematics, piston_force, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Propagate the gas load down to the crankshaft.

Parameters:
  • kinematics (Kinematics) – Pose of the linkage over one revolution.

  • piston_force (ndarray[tuple[Any, ...], dtype[float64]]) – Gas force on the piston crown at each crank angle [N].

  • spec (EngineSpec) – Fixed engine data (piston length, skirt, gear pressure angle).

Return type:

Loads

Returns:

The internal forces and the output torque.

Objectives and constraint measures of the optimization problem.

Collects, from a solved mechanism, the quantities the optimizer works with:

symbol

meaning

eta

average mechanical efficiency – maximised

H

envelope along the stroke – minimised

B

envelope across the stroke – minimised

STE

expansion stroke, must equal 74 mm

epsilon

compression ratio, must equal 16

mra

peak piston-rod tilt, at most 10 deg

W

max(delta_c1, delta_c2), at most 0.985

g

gap between the two top dead centres, at most 0.01 mm

d

trigonal-link to cylinder clearance, at least 10 mm

gamma

max(D) / max(P) side-load ratio, at most 0.02

exlink.metrics.EDGE_SAMPLES = 33

Points per trigonal-link edge used for the clearance distance.

exlink.metrics.rod_angle_deviation(kinematics)[source]

mra: peak deviation of the piston rod from vertical [deg].

The brief caps this at 10 deg to keep the rod clear of the liner and to limit the side load and its friction losses.

Return type:

float

exlink.metrics.envelope(kinematics, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

(H, B): the bounding box swept by the whole mechanism [mm].

H is measured along the stroke (y) and B across it (x), as they are defined here. The box covers every configuration of every body: the two gear primitives, all moving joints, and the piston envelope over its full travel.

Parameters:
  • kinematics (Kinematics) – A solved mechanism.

  • spec (EngineSpec) – Fixed engine data (bore and piston length).

Return type:

tuple[float, float]

Returns:

(H, B) in mm.

exlink.metrics.cylinder_clearance(kinematics, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

d: smallest distance from the trigonal link to the cylinder [mm].

The liner is modelled as the half-strip x in [x_1 - phi/2, x_1 + phi/2], y >= y_bottom, where y_bottom is the lowest point the piston skirt reaches over the revolution – the liner has to extend at least that far down. The distance is minimised over the crank revolution and over the three edges AD, DE, EA.

The requirement is d >= 10 mm. The construction below is one specific reading of it: monotone in the right direction and vanishing exactly on contact, which is what the constraint needs. Any other reasonable construction will differ in absolute value while keeping the same feasible region shape.

Return type:

float

class exlink.metrics.Metrics(efficiency, torque_pressure_ratio, lever_arm, height, width, expansion_stroke, compression_stroke, compression_ratio, rod_angle, compatibility, tdc_gap, clearance, side_load_ratio, mean_torque, mean_piston_force, valid=True, reason='')[source]

Bases: object

Every objective and constraint measure of one design.

efficiency: float

eta, the average mechanical efficiency [-].

torque_pressure_ratio: float

phi = M_r,ave / p_ave [mm^3].

lever_arm: float

M_r,ave / P_ave, the effective crank lever arm [mm].

height: float

H, envelope along the stroke [mm].

width: float

B, envelope across the stroke [mm].

expansion_stroke: float

STE [mm].

compression_stroke: float

STC [mm].

compression_ratio: float

epsilon [-].

rod_angle: float

mra [deg].

compatibility: float

W = max(delta_c1, delta_c2) [-].

tdc_gap: float

g [mm].

clearance: float

d [mm].

side_load_ratio: float

gamma = max(D) / max(P) [-].

mean_torque: float

M_r,ave [N.mm].

mean_piston_force: float

P_ave [N].

valid: bool = True

False when the design was penalised instead of analysed.

reason: str = ''

Why the design was penalised, empty when valid.

exlink.metrics.efficiency(loads, expansion_stroke, compression_stroke, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Return (eta, phi, lever_arm).

The average mechanical efficiency is defined as

\[\eta = \frac{\int_0^{2\pi} M_r \, d\theta_1} {2 (STE + STC) \frac{1}{2\pi}\int_0^{2\pi} P \, d\theta_1} = \frac{M_{r,ave}}{P_{ave}} \frac{\pi}{STE + STC},\]

a ratio of two works: the torque’s work on the crankshaft over the gas force’s work on the piston. Because it rewards a long lever arm, it grows without bound as the mechanism grows – which is exactly why H and B have to enter the problem as well.

Return type:

tuple[float, float, float]

One-call analysis of an EX-link design.

analyse() chains kinematics -> Atkinson cycle -> quasi-static loads -> metrics, and – crucially for the optimizer – never raises on a bad design. A design that cannot be analysed is penalised rather than rejected:

  • the compatibility conditions (4a)/(6a) fail, so the crankshaft only rocks; or

  • lambda(theta_1) does not have four monotone phases, so no Atkinson cycle can be fitted onto it.

In either case eta = 0, H = 1000, B = 1000 are substituted. The constraint measures that can still be computed (notably W) are returned unchanged, so a gradient-free optimizer still has a signal telling it which way to go – that signal is what makes the global search converge.

class exlink.model.SolvedAnalysis(design, spec, metrics, kinematics, thermodynamics, loads)[source]

Bases: object

An Analysis that succeeded, with none of its members optional.

The plotting and animation code needs the kinematics, the cycle and the loads, all of which are None on a penalised design. Narrowing once, at the entry point, keeps every downstream helper free of None checks and lets the type checker verify that.

design: Design
spec: EngineSpec
metrics: Metrics
kinematics: Kinematics
thermodynamics: Thermodynamics
loads: Loads
class exlink.model.Analysis(design, spec, metrics, kinematics, thermodynamics, loads)[source]

Bases: object

Everything computed about one design.

design: Design
spec: EngineSpec
metrics: Metrics
kinematics: Kinematics | None
thermodynamics: Thermodynamics | None
loads: Loads | None
property valid: bool

Whether the design could be analysed at all.

require_solved()[source]

Return a non-optional view of this analysis.

Return type:

SolvedAnalysis

Returns:

The same analysis, with every member guaranteed present.

Raises:

ValueError – If the design was penalised rather than analysed.

exlink.model.analyse(design, samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), penalty=PenaltyValues(efficiency=0.0, height=1000.0, width=1000.0))[source]

Analyse a design end to end.

Parameters:
  • design (Design) – The mechanism dimensions.

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides (used only to gate the analysis).

  • penalty (PenaltyValues) – Values substituted for an unanalysable design.

Return type:

Analysis

Returns:

The full analysis; check Analysis.valid before reading the physical quantities.

exlink.model.objectives(analysis)[source]

f(X) = (-eta, H, B)^T, the three competing objectives.

Return type:

ndarray

exlink.model.inequality_constraints(analysis, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0))[source]

c(X) <= 0, the inequality constraints.

Order: mra - 10, W - 0.985, g - 0.01, 10 - d, gamma - 0.02.

Return type:

ndarray

exlink.model.equality_constraints(analysis, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0))[source]

c_eq(X) = 0, the equality constraints.

Order: STE - 74, epsilon - 16.

Return type:

ndarray

exlink.model.INEQUALITY_NAMES: tuple[str, ...] = ('rod_angle_margin', 'compatibility_margin', 'tdc_gap_margin', 'clearance_margin', 'side_load_margin')

Names of the inequality constraints, in the order returned above.

exlink.model.EQUALITY_NAMES: tuple[str, ...] = ('stroke_error', 'compression_ratio_error')

Names of the equality constraints, in the order returned above.

Sizing and dynamics

The second iteration: inertia in the load path, parts sized against static, fatigue and buckling failure, and the fixed point that couples them.

Derivatives of the linkage’s periodic histories with respect to crank angle.

Every history the analysis produces – joint positions, link angles, piston height – is a smooth function of theta_1 sampled uniformly over exactly one revolution. For such data the Fourier (spectral) derivative is exact to machine precision, where a finite difference on the same grid would be accurate only to O(h^2). That matters because accelerations are second derivatives, and a finite-difference second derivative on a 0.5 deg grid loses about six digits – enough noise to pollute the inertia forces and, through them, the sizing loop.

Two cases arise:

  • Periodic histories – joint coordinates, lambda – return to their starting value after one revolution and can be differentiated directly.

  • Ramped histories – link angles – may accumulate a whole number of turns per revolution, as theta_2 = -2 theta_1 + theta_f does. Such a history is a linear ramp plus a periodic part; ramp_derivative() splits the two, differentiates the periodic part spectrally, and adds the ramp’s slope back.

exlink.derivatives.spectral_derivative(values, order=1)[source]

Differentiate a 2 pi-periodic history sampled uniformly.

Parameters:
  • values (ndarray[tuple[Any, ...], dtype[float64]]) – Samples over [0, 2 pi), endpoint excluded, shaped (n,) or (..., n) – the last axis is the crank angle.

  • order (int) – Derivative order; 1 for d/dtheta_1, 2 for d^2/dtheta_1^2.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

The derivative, same shape as values.

Raises:

ValueError – If order is negative.

exlink.derivatives.winding_number(angles)[source]

Whole turns an angle history accumulates over one revolution.

Parameters:

angles (ndarray[tuple[Any, ...], dtype[float64]]) – An angle history over [0, 2 pi), possibly wrapped.

Return type:

float

Returns:

The slope k of the linear ramp, so that angles - k theta_1 is periodic. -2 for the eccentric shaft, 0 for a link that rocks.

exlink.derivatives.ramp_derivative(angles, order=1)[source]

Differentiate an angle history that may accumulate whole turns.

Parameters:
  • angles (ndarray[tuple[Any, ...], dtype[float64]]) – An angle history over [0, 2 pi), possibly wrapped.

  • order (int) – Derivative order.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

d^order angles / d theta_1^order.

exlink.derivatives.rate_and_acceleration(values, speed)[source]

Convert crank-angle derivatives into time derivatives.

The mechanism is analysed at constant crankshaft speed, so d/dt = Omega d/dtheta_1 and d^2/dt^2 = Omega^2 d^2/dtheta_1^2 exactly – no theta_1-dot-dot term.

Parameters:
  • values (ndarray[tuple[Any, ...], dtype[float64]]) – A periodic history over one revolution.

  • speed (float) – Crankshaft speed Omega [rad/s].

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

(first, second) time derivatives.

Material data and fatigue-strength corrections for component sizing.

Unit system

The structural code uses the consistent set mm, N, MPa, tonne, s, so that F = m a needs no conversion factor: one tonne accelerated at one mm/s^2 is one newton. Steel density is therefore 7.85e-9 t/mm^3. Masses are exposed in kilograms at the API surface, where a reader expects them.

Fatigue model

Rotating-bending endurance limit corrected by Marin factors, then a Goodman line, which is the standard textbook treatment (Shigley) and the right level of fidelity for a first sizing iteration:

\[\frac{\sigma_a}{S_e} + \frac{\sigma_m}{S_u} \le \frac{1}{n_f}\]

with S_e = k_a k_b k_c k_d k_e S_e' and S_e' = 0.5 S_u. The size factor k_b depends on the diameter being solved for, which is one reason the sizing of each member is a small root-find rather than a closed form.

exlink.materials.STEEL_DENSITY = 7.85e-09

Density of steel in the consistent unit system [tonne/mm^3].

class exlink.materials.Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0)[source]

Bases: object

An isotropic metal, described well enough to size a part against it.

name: str = '42CrMo4 quenched and tempered'
density: float = 7.85e-09

[tonne/mm^3]

youngs_modulus: float = 210000.0

E [MPa].

yield_strength: float = 700.0

S_y [MPa].

ultimate_strength: float = 900.0

S_u [MPa].

surface_factor_a: float = 4.51
surface_factor_b: float = -0.265

k_a = a S_u^b for a machined surface (Shigley, MPa units).

reliability_factor: float = 0.814

k_e for 99 % reliability.

temperature_factor: float = 1.0

k_d; 1.0 at ambient.

property density_kg_per_mm3: float

Density in kg/mm^3, for reporting masses in kilograms.

property endurance_limit_uncorrected: float

S_e' = 0.5 S_u [MPa], capped at 700 MPa as usual for steels.

property surface_factor: float

k_a, the machined-surface correction.

size_factor(diameter)[source]

k_b, the size correction for a solid round section.

Parameters:

diameter (ndarray[tuple[Any, ...], dtype[float64]] | float) – Section diameter [mm].

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

k_b, elementwise.

endurance_limit(diameter, load_factor=1.0)[source]

Corrected endurance limit S_e [MPa].

Parameters:
  • diameter (ndarray[tuple[Any, ...], dtype[float64]] | float) – Section diameter [mm], entering through k_b.

  • load_factor (float) – k_c; 1.0 bending, 0.85 axial, 0.59 torsion. Use 1.0 where bending dominates, which it does for every link here.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

S_e, elementwise in diameter.

class exlink.materials.SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0)[source]

Bases: object

Design factors applied to each failure mode.

static: float = 1.5

n_y against first yield.

fatigue: float = 2.0

n_f on the Goodman line.

buckling: float = 3.0

n_b against Euler buckling of members in compression.

Higher than the others because Euler buckling is sudden, and because a real link has imperfections the ideal formula ignores.

exlink.materials.goodman_utilisation(alternating, mean, endurance, ultimate)[source]

Goodman utilisation; <= 1 / n_f is safe.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

sigma_a / S_e + max(sigma_m, 0) / S_u.

A compressive mean stress is not credited as beneficial: the Goodman line is truncated at sigma_m = 0, which is the conservative convention and matters here because the connecting members swing between tension and compression every revolution.

Hollow sections, and the four shape factors that is all they are.

Every member in this study was a solid round bar, and section 7.2 named that the largest single modelling conservatism: a tube of the same outer diameter carries nearly the same bending for appreciably less mass, because the metal near the neutral axis contributes to weight and almost nothing else. That conservatism is not neutral here. Section 6.1’s finding is a balance between the load a member must carry and the inertia its own mass creates, so removing mass from the members moves the balance – and it moves it in the direction the finding is most sensitive to.

What a tube changes, and what it does not

Write k = d_i / d_o for the bore ratio and R^2 for r_o^2 + r_i^2. Against a solid bar of the same outer diameter:

quantity

formula

factor

area

pi d^2 (1 - k^2) / 4

1 - k^2

section mod.

pi d^3 (1 - k^4) / 32

1 - k^4

second mom.

pi d^4 (1 - k^4) / 64

1 - k^4

transverse I

m (3 R^2 + L^2) / 12

1 + k^2 on R^2

Four constants, and the whole of the change. Nothing about the form of any stress, utilisation or derivative moves: every quantity that was proportional to a power of d is still proportional to the same power, scaled. That is why the analytic derivatives of exlink.dynamics_jacobian survive untouched in their structure – the logarithmic derivatives d(1/A)/dd = -2/(A d) and d(1/Z)/dd = -3/(Z d) are independent of the shape factor, which cancels.

Which members may be hollow

Not all of them. The two crank throws are cantilevered off their shafts and are, on any real engine, solid forgings continuous with the web; boring them would be a manufacturing fiction. The rods and the trigonal link are exactly what is made from tube in practice. So the bore ratio is applied by member kind, and HOLLOW_KINDS names the two kinds it applies to.

Why a wall thickness floor is needed

With k fixed, the wall thickness d (1 - k) / 2 shrinks with the diameter, and a lightly loaded member sized down to a few millimetres would be asked to have a wall of tenths. Section.min_wall puts a floor under it by raising the outer diameter of any member whose wall would fall below it, which is the manufacturing constraint a real drawing would carry and is also what stops the optimizer discovering free mass at small diameters.

exlink.sections.HOLLOW_KINDS: frozenset[str] = frozenset({'link', 'truss'})

Member kinds a bore may be put through; see the module docstring.

exlink.sections.MAX_BORE_RATIO = 0.95

Largest d_i / d_o accepted [-].

Beyond this the section is foil and the beam theory used here – which ignores local buckling of the wall entirely – stops being an approximation of anything. Practical engine tubing sits near 0.7 to 0.85.

exlink.sections.DEFAULT_MIN_WALL = 1.5

Thinnest wall the members may be drawn with [mm].

class exlink.sections.Section(bore_ratio=0.0, min_wall=1.5)[source]

Bases: object

The member cross-section shape, as a bore ratio and a wall floor.

bore_ratio: float = 0.0

d_i / d_o for the members allowed a bore; zero is a solid bar.

min_wall: float = 1.5

Thinnest wall accepted [mm].

property solid: bool

Whether this is the solid round bar the study started from.

ratios(kinds)[source]

The bore ratio of each member, zero where a bore is not allowed.

Parameters:

kinds (Sequence[str]) – One member kind per member, in the member order.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

(n_members,) bore ratios.

minimum_diameter(kinds)[source]

Smallest outer diameter each member may be drawn at [mm].

A wall of min_wall at the member’s own bore ratio; zero constraint on a solid member, which has no wall.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.sections.SOLID = Section(bore_ratio=0.0, min_wall=1.5)

The solid round bar of sections 6.1 to 6.5.

exlink.sections.TUBULAR = Section(bore_ratio=0.6, min_wall=1.5)

The tube section 6.6 measures, and it is a measured choice, not a habit.

Two effects fight over the bore ratio on this engine and the winner changes sides part-way:

  • raising k takes metal out of the members, which is the point;

  • raising k also raises the wall floor’s outer diameter, 2 t / (1 - k), and past some k the floor – not the load – is what sizes the light members, so the section grows back. At k = 0.75 and a 1.5 mm wall no member may be drawn below 12 mm, and most of this linkage’s are smaller than that when solid.

Optimising range over k and speed together lands at k = 0.579 and 2276 rpm. 0.6 is that answer to one figure, and it is inside the 0.5 to 0.7 band tube stock is actually drawn in.

exlink.sections.area_factor(ratios)[source]

1 - k^2: cross-sectional area against a solid bar.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.sections.modulus_factor(ratios)[source]

1 - k^4: section modulus, and second moment, against a solid bar.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.sections.gyration_factor(ratios)[source]

1 + k^2: the radial term of the transverse inertia, per unit mass.

A tube’s mass sits further from its own axis than a bar’s does, so per kilogram it has more transverse inertia even though it has less in absolute terms.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Dynamic load analysis: the quasi-static chain with inertia restored.

The geometric problem stops short of this of necessity: to have the masses of the pieces one must know their shape, which needs a first design. This module is that second iteration.

Why sequential elimination cannot simply be extended

Without inertia every rod is a two-force member: the forces at its two ends are equal, opposite and collinear with the rod. That is what lets exlink.loads eliminate unknowns one body at a time, from the piston down to the crankshaft.

Add inertia and that collapses. A rod with mass has a distributed d’Alembert load along it, so its end forces are neither collinear nor equal, and no body can be solved before its neighbours. The whole mechanism has to be solved simultaneously.

Assembling the whole system

Counting with Gruebler over 7 links (6 moving plus ground), 8 lower pairs (7 revolutes and the piston’s prismatic guide) and 1 higher pair (the gear mesh):

M = 3(7 - 1) - 2(8) - 1 = 1

One degree of freedom, so the load problem is statically determinate: exactly 18 scalar unknowns against 6 bodies x 3 equilibrium equations. Assembling and solving that 18x18 system at every crank angle is what solve() does.

The determinant of that matrix is the same quantity condition (4a) protects: at a critical configuration the mechanism gains a degree of freedom, the matrix goes singular, and the internal forces blow up. The conditioning is reported in DynamicLoads.conditioning so the connection is visible.

Constant crankshaft speed

The mechanism is analysed at constant Omega: d/dt = Omega d/dtheta_1 exactly, with no angular acceleration of the crankshaft. Two consequences worth stating, because they simplify the bookkeeping and are easy to get wrong:

  • Both shaft assemblies turn at constant rate (theta_2 = -2 theta_1 + theta_f), so neither has an angular acceleration and neither contributes an inertia couple.

  • The shafts and gears are concentric with their own axes, so their centres of mass do not move and they exert no inertia force at all. Only the offset crank throws do, which is why the bodies below are the crank arms rather than whole shaft assemblies.

exlink.dynamics.DEFAULT_SPEED_RPM = 1500.0

Speed of the half-speed shaft used by default [rev/min].

This is theta_1’s rate, the quantity the analysis is parametrised on; the crankshaft turns twice as fast, so the default is 3000 rev/min at the shaft power is taken from – see output_revolutions_per_cycle.

Chosen because it is where the problem is interesting. A Shell Eco-marathon engine runs slowly by design, and for this linkage the structural mass grows roughly as the cube of the acceleration level (see exlink.coupled): quoted at the crankshaft, the published geometry needs 0.25 kg of links at rest, 1.0 kg at 2000 rev/min, 8.4 kg at 3000 rev/min, and cannot be built at all much above 4000. 3000 therefore exercises the coupling hard while leaving sensible designs reachable.

exlink.dynamics.rpm_to_rad_per_s(rpm)[source]

Convert rev/min to rad/s.

Return type:

float

class exlink.dynamics.Member(name, start, end, kind, length_attribute)[source]

Bases: object

One sized structural member, spanning two joints of the linkage.

name: str

Identifier, also the sizing output key.

start: str

Joint at the member’s first end.

end: str

Joint at its second end.

kind: str

"link" for a free two-node member (swing rod, piston rod), "cantilever" for a crank throw built into its shaft, or "truss" for a side of the trigonal link.

length_attribute: str

Name of the Design property giving its length.

exlink.dynamics.MEMBERS: tuple[Member, ...] = (Member(name='crank_1', start='Q', end='R1', kind='cantilever', length_attribute='q_1'), Member(name='swing_rod', start='Q', end='A', kind='link', length_attribute='a'), Member(name='trigonal_ad', start='A', end='D', kind='truss', length_attribute='c'), Member(name='trigonal_ae', start='A', end='E', kind='truss', length_attribute='b'), Member(name='trigonal_de', start='D', end='E', kind='truss', length_attribute='d'), Member(name='crank_2', start='D', end='R2', kind='cantilever', length_attribute='q_2'), Member(name='piston_rod', start='E', end='P', kind='link', length_attribute='e'))

The members whose cross-sections are sized, in a stable order.

Cranks are listed loaded-end first: a crank throw is a cantilever whose root is the shaft, so the pin force at its tip is what sizes it.

exlink.dynamics.BODY_MEMBERS: dict[str, tuple[str, ...]] = {'crank_1': ('crank_1',), 'crank_2': ('crank_2',), 'piston_rod': ('piston_rod',), 'swing_rod': ('swing_rod',), 'trigonal': ('trigonal_ad', 'trigonal_ae', 'trigonal_de')}

Which members make up each moving body of the mechanism.

exlink.dynamics.BODY_NAMES: tuple[str, ...] = ('crank_1', 'swing_rod', 'trigonal', 'crank_2', 'piston_rod', 'piston')

The six moving bodies, in the order their equations appear in the system.

class exlink.dynamics.MassProperties(member_mass, body_mass, body_com, body_inertia, body_angle)[source]

Bases: object

Inertial description of every moving body, at every crank angle.

All in the consistent unit system (mm, N, MPa, tonne, s).

member_mass: dict[str, float]

Mass of each sized member [tonne].

body_mass: dict[str, float]

Total mass of each moving body [tonne].

body_com: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Centre-of-mass trajectory of each body, shaped (n_angles, 2) [mm].

body_inertia: dict[str, float]

Rotational inertia about the body’s own centre of mass [tonne mm^2].

body_angle: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Orientation history of each body [rad], for its angular acceleration.

property total_mass_kg: float

Total moving mass [kg].

class exlink.dynamics.DynamicLoads(kinematics, speed, mass_properties, reaction, liner_force, liner_moment, gear_force, torque, joint_acceleration, body_acceleration, body_angular_acceleration, gas_force, matrix, conditioning)[source]

Bases: object

Joint reactions and output torque over one revolution, with inertia.

kinematics: Kinematics
speed: float

Crankshaft speed Omega [rad/s].

mass_properties: MassProperties
reaction: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Joint force histories, each (n_angles, 2) [N].

Keys are R1, Q, A, D, R2, E, P; each is the force transmitted forward along the chain, from the body nearer the crankshaft to the body nearer the piston.

liner_force: ndarray[tuple[Any, ...], dtype[float64]]

Side force the cylinder liner applies to the piston [N].

liner_moment: ndarray[tuple[Any, ...], dtype[float64]]

Reaction moment on the piston from the liner [N.mm].

gear_force: ndarray[tuple[Any, ...], dtype[float64]]

Gear tooth load T along the line of action [N].

torque: ndarray[tuple[Any, ...], dtype[float64]]

Output torque on the crankshaft [N.mm].

joint_acceleration: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Acceleration of each joint, (n_angles, 2) [mm/s^2].

body_acceleration: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Centre-of-mass acceleration of each body [mm/s^2].

body_angular_acceleration: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Angular acceleration of each body [rad/s^2].

gas_force: ndarray[tuple[Any, ...], dtype[float64]]

The applied gas force on the piston crown [N], as solved with.

matrix: ndarray[tuple[Any, ...], dtype[float64]]

The assembled equilibrium matrix, (n_angles, 18, 18).

Kept so that exlink.dynamics_jacobian can differentiate the solve without reassembling or re-factorising it.

conditioning: float

Worst 2-norm condition number of the 18x18 equilibrium matrix.

Large values mean the mechanism is near a configuration where it gains a degree of freedom – exactly what the W constraint keeps it away from.

property mean_torque: float

M_r,ave over one revolution [N.mm].

exlink.dynamics.mass_properties(kinematics, diameters, density, piston_mass, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Assemble body masses, centres of mass and inertias from member sections.

Every member is a round bar – solid, or bored to section.bore_ratio where its kind allows it – so its mass and inertia follow from its length; a body’s centre of mass is the mass-weighted mean of its members’ midpoints, and its inertia the parallel-axis sum.

Parameters:
  • kinematics (Kinematics) – A solved mechanism, supplying the joint trajectories.

  • diameters (dict[str, float]) – Outer diameter of each member of MEMBERS [mm].

  • density (float) – Material density [tonne/mm^3].

  • piston_mass (float) – Mass of the piston assembly [tonne].

  • spec (EngineSpec) – Fixed engine data.

  • section (Section) – The cross-section shape; solid round bars by default.

Return type:

MassProperties

Returns:

The mass properties of all six moving bodies.

exlink.dynamics.solve(kinematics, gas_force, properties, speed, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Solve the mechanism’s equilibrium including inertia.

Parameters:
  • kinematics (Kinematics) – A solved mechanism.

  • gas_force (ndarray[tuple[Any, ...], dtype[float64]]) – Gas force on the piston crown at each crank angle [N], positive downwards.

  • properties (MassProperties) – Body masses, centres of mass and inertias.

  • speed (float) – Crankshaft speed Omega [rad/s]. Zero recovers the quasi-static result of exlink.loads exactly.

  • spec (EngineSpec) – Fixed engine data.

Return type:

DynamicLoads

Returns:

Joint reactions, gear load and output torque over one revolution.

Sizing every member against static yield, fatigue and buckling.

Each link is taken as a solid round bar and its diameter is solved for: the smallest section that satisfies all three failure modes over the whole revolution.

Internal loads

For a member spanning two joints, the internal force and moment at a section a fraction s along it follow from the free body of the piece [0, s]: the force applied at its first end, plus its own distributed d’Alembert load. Because a rigid body’s acceleration varies linearly along any straight line through it, that load is linear in s and both integrals are closed form — see internal_loads().

The trigonal link is the one place an idealisation is needed. It is a single rigid part, so as a frame it is three times statically indeterminate. It is treated here as a pin-jointed triangle: each side carries an axial force from joint equilibrium (which is determinate), and bends only under its own distributed inertia as a simply supported beam. That is the standard first-cut decomposition; it captures the axial load exactly and under-estimates bending at the corners, which is noted rather than hidden.

Failure modes

static

Peak absolute fibre stress against S_y / n_y. The state is uniaxial (axial plus bending), so von Mises reduces to |sigma|.

fatigue

Goodman, evaluated per extreme fibre so that the alternating and mean components are taken at a fixed material point rather than at whichever fibre happens to be worst at each instant.

buckling

Euler, for the peak compressive axial load, with an end-fixity factor of 1 for pin-ended links and 2 for a crank throw cantilevered off its shaft.

All three utilisations fall as the diameter grows, so the required diameter is found by bisection — robust, derivative-free, and needed anyway because the fatigue size factor k_b itself depends on the diameter being solved for.

exlink.sizing.STATIONS = 9

Sections evaluated along each member, ends included.

exlink.sizing.MIN_DIAMETER = 2.0

Smallest diameter considered [mm]; below this a link is not manufacturable.

exlink.sizing.MAX_DIAMETER = 250.0

Largest diameter considered [mm]; hitting it means the member cannot be sized.

exlink.sizing.BISECTION_STEPS = 60

Bisection iterations; 60 halvings of the bracket reach machine precision.

exlink.sizing.END_FIXITY: dict[str, float] = {'cantilever': 2.0, 'link': 1.0, 'truss': 1.0}

End-fixity factor K in the Euler load, by member kind.

exlink.sizing.internal_loads(start, end, end_force, mass, start_acceleration, end_acceleration, stations=9)[source]

Axial force and bending moment along a uniform straight member.

Equilibrium of the piece between the first end and a section at fraction s, carrying the applied end force and its own share of the distributed inertia load. With a(s) = a_1 + s (a_2 - a_1) and r(s) linear, the two integrals evaluate in closed form to

F(s) = m [a_1 s + (a_2 - a_1) s^2 / 2] - F_1
M(s) = -m [(dr x a_1)_z s^2 / 2 + (dr x da)_z s^3 / 6] + s (dr x F_1)_z
Parameters:
  • start (ndarray[tuple[Any, ...], dtype[float64]]) – First-end positions, (n_angles, 2) [mm].

  • end (ndarray[tuple[Any, ...], dtype[float64]]) – Second-end positions, (n_angles, 2) [mm].

  • end_force (ndarray[tuple[Any, ...], dtype[float64]]) – Force applied to the member at its first end, (n_angles, 2) [N].

  • mass (float) – Member mass [tonne].

  • start_acceleration (ndarray[tuple[Any, ...], dtype[float64]]) – Acceleration of the first end, (n_angles, 2) [mm/s^2].

  • end_acceleration (ndarray[tuple[Any, ...], dtype[float64]]) – Acceleration of the second end [mm/s^2].

  • stations (int) – Number of sections, spread evenly including both ends.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

(axial, bending), each (n_angles, stations), in N and N.mm. Axial is positive in tension.

exlink.sizing.member_loads(loads, stations=9)[source]

Internal axial force and bending moment for every sized member.

Parameters:
  • loads (DynamicLoads) – A solved dynamic load case.

  • stations (int) – Sections evaluated along each member.

Return type:

dict[str, tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]]

Returns:

{member name: (axial, bending)}, each array (n_angles, stations).

class exlink.sizing.MemberSizing(name, diameter, static_utilisation, fatigue_utilisation, buckling_utilisation, peak_stress, mass, critical_mode)[source]

Bases: object

Sizing outcome for one member.

name: str
diameter: float

Required section diameter [mm].

static_utilisation: float

Peak stress over S_y / n_y; <= 1 is safe.

fatigue_utilisation: float

Goodman utilisation times n_f; <= 1 is safe.

buckling_utilisation: float

Peak compression over the Euler load divided by n_b; <= 1 is safe.

peak_stress: float

Largest absolute fibre stress at the sized diameter [MPa].

mass: float

Member mass at the sized diameter [tonne].

critical_mode: str

Which of the three modes set the diameter.

property mass_kg: float

Member mass [kg].

exlink.sizing.member_lengths(design)[source]

Length of each sized member, in the order of MEMBERS [mm].

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.sizing.MEMBER_FIXITY: ndarray[tuple[Any, ...], dtype[float64]] = array([2., 1., 1., 1., 1., 2., 1.])

Euler end-fixity factor of each member, in the order of MEMBERS.

exlink.sizing.MEMBER_KINDS: tuple[str, ...] = ('cantilever', 'link', 'truss', 'truss', 'truss', 'cantilever', 'link')

Kind of each member, in the order of MEMBERS; decides which may be bored.

exlink.sizing.MEMBER_IS_SLENDER: ndarray[tuple[Any, ...], dtype[bool]] = array([False,  True,  True,  True,  True, False,  True])

Whether a member’s slenderness is worth policing, in the order of MEMBERS.

Only the connecting links. A crank throw is routinely shorter than the pin diameter it carries – 8 mm of throw on a 19 mm crankpin is an ordinary engine, not a modelling failure – so applying a slenderness limit to it would reject sound designs. The beam model over-predicts stress for such a stubby part, which errs the safe way.

exlink.sizing.size_from_arrays(axial, bending, lengths, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), fixity=None, names=None, ratios=None, floor=None)[source]

Solve for the smallest safe diameter, from raw internal-load arrays.

Split out from size_members() so that the GEMSEO structural discipline can be driven by the coupling variables directly, without having to reconstruct a DynamicLoads – and so that exlink.slidercrank can size a different mechanism through exactly the same yield, fatigue and buckling checks. Comparing two mechanisms is only meaningful if the structural model is literally the same code, so the member list is a parameter rather than a constant.

Parameters:
Return type:

dict[str, MemberSizing]

Returns:

{member name: MemberSizing}.

exlink.sizing.size_members(loads, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), stations=9, section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Solve for the smallest safe diameter of every member.

Parameters:
  • loads (DynamicLoads) – A solved dynamic load case.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • stations (int) – Sections evaluated along each member.

  • section (Section) – The cross-section shape; solid round bars by default.

Return type:

dict[str, MemberSizing]

Returns:

{member name: MemberSizing}.

exlink.sizing.piston_mass(thermodynamics, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), skirt_thickness=2.5)[source]

Size the piston crown against peak gas pressure and return its mass.

The crown is treated as a clamped circular plate of radius phi / 2 under uniform pressure, whose peak stress is 3 p r^2 / (4 t^2); the skirt is a thin cylinder of the given wall thickness.

The piston sits outside the sizing loop: its thickness follows from the gas pressure alone, which no structural choice changes. Its mass still matters, because it is the largest reciprocating mass in the mechanism.

Parameters:
  • thermodynamics (Thermodynamics) – The solved cycle, for the peak gauge pressure.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • spec (EngineSpec) – Fixed engine data.

  • skirt_thickness (float) – Wall thickness of the skirt [mm].

Return type:

tuple[float, float]

Returns:

(crown_thickness_mm, mass_tonne).

exlink.sizing.piston_mass_from_pressure(peak_pressure, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), skirt_thickness=2.5)[source]

Size the piston from a peak gauge pressure alone.

The cycle-model-agnostic half of piston_mass(), so that an Otto slider-crank and an Atkinson linkage get identically-sized pistons for the same peak pressure.

Parameters:
  • peak_pressure (float) – Peak in-cylinder gauge pressure [MPa].

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • spec (EngineSpec) – Fixed engine data.

  • skirt_thickness (float) – Wall thickness of the skirt [mm].

Return type:

tuple[float, float]

Returns:

(crown_thickness_mm, mass_tonne).

The sizing / dynamics fixed point, and why it is a fixed point at all.

Adding inertia to the load analysis closes a loop that the quasi-static study never had:

section diameters --> member masses --> inertia forces --> internal loads
        ^                                                        |
        +--------------- sizing against yield, ------------------+
                         fatigue and buckling

Neither half can be evaluated first. The loads cannot be computed without the masses, and the masses cannot be chosen without the loads. That is a genuine multidisciplinary coupling and it has to be solved, not sequenced.

Does it converge?

Scaling tells you what to expect. A bending-critical member needs d ~ F^(1/3), so its mass goes as m ~ d^2 ~ F^(2/3); the inertia force it then generates is F ~ m a. Composing, m ~ (C a) m^(2/3): the loop gain is sub-linear, so a fixed point exists at m = (C a)^3 and plain iteration reaches it – but slowly, and with a mass that grows as the cube of the acceleration level. Under-relaxation is provided for the stiff cases, and the cubic sensitivity is why the answer is so sensitive to engine speed.

The result is that speed, which does not enter the geometric problem at all, becomes one of the strongest drivers here.

exlink.coupled.INITIAL_DIAMETER = 8.0

Diameter every member starts from [mm].

exlink.coupled.DEFAULT_TOLERANCE = 1e-06

Convergence tolerance on the largest diameter change between sweeps [mm].

exlink.coupled.DEFAULT_RELAXATION = 1.0

Under-relaxation factor; below 1 damps the loop at the cost of more sweeps.

class exlink.coupled.CoupledResult(diameters, sizing, loads, mass_properties, piston_crown_thickness, piston_mass, speed, iterations, residual, converged, history)[source]

Bases: object

Outcome of the sizing / dynamics fixed point.

diameters: dict[str, float]

Converged section diameter of each member [mm].

sizing: dict[str, MemberSizing]
loads: DynamicLoads
mass_properties: MassProperties
piston_crown_thickness: float
piston_mass: float

[tonne]

speed: float

Crankshaft speed Omega [rad/s].

iterations: int
residual: float

Largest diameter change in the final sweep [mm].

converged: bool
history: list[float]

Residual after each sweep, for diagnosing a stiff or runaway case.

property total_mass_kg: float

Total moving mass, piston included [kg].

property saturated: bool

Whether any member was driven to the diameter ceiling.

A saturated member means the mechanism cannot be built to survive its own inertia at this speed – the loop has run away rather than settled.

property feasible: bool

Whether the fixed point settled on a buildable set of sections.

property peak_bearing_load: float

Largest crankshaft bearing reaction over the revolution [N].

worst_utilisation()[source]

The member closest to its allowable, and by how much.

Sizing drives every member to a utilisation of 1, so this is a check that the solve did its job rather than a design margin.

Return type:

tuple[str, float]

exlink.coupled.solve_coupled(kinematics, thermodynamics, speed_rpm=1500.0, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), initial_diameters=None, tolerance=1e-06, max_iterations=200, relaxation=1.0, section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Solve the sizing / dynamics coupling by relaxed fixed-point iteration.

This is the reference implementation: a plain Gauss-Seidel sweep, easy to read and to check against. exlink.disciplines exposes the same coupling to GEMSEO as two disciplines joined by an MDA, and tests/test_coupled.py checks the two agree.

Parameters:
  • kinematics (Kinematics) – A solved mechanism.

  • thermodynamics (Thermodynamics) – Its solved cycle, supplying the gas load.

  • speed_rpm (float) – Crankshaft speed [rev/min]. Zero recovers purely quasi-static sizing, with no coupling at all.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • spec (EngineSpec) – Fixed engine data.

  • initial_diameters (dict[str, float] | None) – Starting sections [mm]; a warm start from a nearby design cuts the sweep count sharply.

  • tolerance (float) – Convergence tolerance on the diameter change [mm].

  • max_iterations (int) – Sweep limit.

  • relaxation (float) – Under-relaxation in (0, 1].

  • section (Section) – The member cross-section; solid round bars by default. A bore changes both sides of this fixed point at once – less mass to accelerate, less section to carry it – so it is passed in here rather than applied afterwards.

Return type:

CoupledResult

Returns:

The converged sections and the load case that goes with them. Check CoupledResult.feasible – a design whose inertia outruns its structure will exit either unconverged or saturated at the ceiling.

Raises:

ValueError – If relaxation is outside (0, 1].

exlink.coupled.solve_for_design(design, speed_rpm=1500.0, samples=360, **kwargs)[source]

Convenience wrapper: analyse a design, then solve its sizing coupling.

Parameters:
  • design (Design) – The mechanism dimensions.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • samples (int) – Crank angles per revolution.

  • **kwargs (Any) – Forwarded to solve_coupled().

Return type:

CoupledResult

Returns:

The coupled result.

Raises:

ValueError – If the design cannot be analysed kinematically at all.

Derivatives

Exact derivatives of the whole chain, which is what makes a gradient-based optimizer applicable to a feasible set this thin.

Exact derivatives of the analysis chain with respect to the design vector.

Why this exists

The feasible set of this problem is a sliver. At the reference design the two equality constraints leave a band 0.1 mm wide on STE and 0.1 wide on epsilon, inside a box whose sides are tens of millimetres, and W and gamma sit within 0.4 % and 7 % of their bounds. A derivative-free method cannot work in that: every trial step of a sensible size lands outside, so COBYLA returns its starting point unchanged however large its budget.

The whole analysis chain is closed form, so its derivatives are too. This module propagates them forward through the same chain exlink.kinematics evaluates, carrying d/dX alongside each quantity. That turns the problem over to SLSQP, which handles thin feasible sets by construction.

Two ideas do the work

Forward-mode chaining. Each intermediate carries an array of shape (n_angles, 11) holding its derivative with respect to the design vector. Differentiating the closed forms is mechanical; the result is exact, and one pass produces all eleven components at once.

The envelope theorem. Most metrics are extrema over the crank angle – W is a maximum of |cos T|, STE a difference of extrema of lambda, H and B extremes of a point cloud. For a maximum attained at theta*,

\[\frac{d}{dX}\max_\theta f(X, \theta) = \frac{\partial f}{\partial X} \Big|_{\theta^*}\]

because the term through the moving maximiser carries partial f / partial theta = 0. So no derivative of the location of the extremum is needed – only the partial derivative evaluated there.

Angles in the design vector are stored in degrees, so their columns carry the pi/180 factor.

exlink.jacobian.DEGREES = 0.017453292519943295

Chain factor for the two design variables held in degrees.

class exlink.jacobian.KinematicJacobian(theta_1, cos_transmission, cos_rod, theta_T, theta_a, theta_e, lam, joints)[source]

Bases: object

Derivatives of the kinematic solution with respect to X.

Every array is shaped (n_angles, 11): one row per crank angle, one column per design variable, in the order of exlink.design.VARIABLE_NAMES.

theta_1: ndarray[tuple[Any, ...], dtype[float64]]
cos_transmission: ndarray[tuple[Any, ...], dtype[float64]]

d(cos T)/dX – the argument of condition (4a).

cos_rod: ndarray[tuple[Any, ...], dtype[float64]]

d(cos theta_e)/dX – the argument of condition (6a).

theta_T: ndarray[tuple[Any, ...], dtype[float64]]
theta_a: ndarray[tuple[Any, ...], dtype[float64]]
theta_e: ndarray[tuple[Any, ...], dtype[float64]]
lam: ndarray[tuple[Any, ...], dtype[float64]]

d lambda / dX, the piston height.

joints: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d r_J / dX for each joint, shaped (n_angles, 2, 11).

exlink.jacobian.kinematic_jacobian(design, kinematics, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Differentiate the closed-form kinematics, exactly.

Follows exlink.kinematics.solve() step for step, carrying the derivative of each intermediate alongside it.

Parameters:
  • design (Design) – The mechanism dimensions the solution was computed at.

  • kinematics (object) – The corresponding Kinematics.

  • spec (EngineSpec) – Fixed engine data.

Return type:

KinematicJacobian

Returns:

The derivatives of every kinematic quantity with respect to X.

exlink.jacobian.ANALYTIC_OUTPUTS: tuple[str, ...] = ('expansion_stroke', 'compression_ratio', 'stroke_error', 'compression_ratio_error', 'expansion_stroke_1', 'expansion_stroke_2', 'compression_ratio_1', 'compression_ratio_2', 'tdc_gap_margin', 'compatibility', 'compatibility_margin', 'rod_angle', 'rod_angle_margin', 'side_load_ratio', 'side_load_margin')

Every constraint that is tight at a typical design.

These are the ones a derivative-free method cannot navigate and the ones whose finite-difference gradients are worst, because each is an extremum over the crank angle: the grid point attaining it switches as the design moves, so a difference quotient straddles the switch and reports nonsense. The envelope theorem gives them exactly instead.

The four numbered rows are the two strokes measured from each top dead centre rather than from the higher of the two. expansion_stroke and stroke_error are derivatives of a maximum, which exist only away from the tie – and §5.3 shows a design of this study sitting 0.107 um from it, where the reported derivative is that of a branch the parts do not stay on.

efficiency, height, width and clearance are left to finite differences: they are smooth, none of them is tight, and the first carries moving-boundary terms from the combustion pressure jump that would need the crank angle of top dead centre differentiated as well.

exlink.jacobian.gas_force_jacobian(analysis, jacobian, d_lam_tdc, d_compression_stroke, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

d P_gas / dX, the gas force on the piston crown [N].

The gauge pressure depends on the design only through lambda, its value at top dead centre and the compression stroke; differentiating the two adiabats gives the rest. Intake and exhaust sit at plenum pressure, so both the gauge force and its derivative vanish there.

Split out so that exlink.dynamics_jacobian can reuse it: the gas load is the one applied force in the equilibrium system, so its derivative enters the right-hand side of the 18x18 solve as well as the side-load ratio.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

(n_angles, 11).

exlink.jacobian.metric_jacobian(design, analysis, jacobian, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Exact derivatives of the tight metrics, by the envelope theorem.

Parameters:
Return type:

dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

{output name: d(output)/dX}, each shaped (11,), covering ANALYTIC_OUTPUTS.

Exact derivatives through the sizing / dynamics coupling.

Without these, the coupled problem has to be differentiated by differencing the whole MDA: eleven converged fixed points per gradient, each some fifty sweeps, which measured at ten to twenty minutes for a short optimizer run. With them, GEMSEO assembles the coupled derivative from each discipline’s local Jacobian and the cost collapses to a handful of single evaluations plus one small linear solve.

Three things have to be differentiated, and each has a clean route:

The spectral operator. Accelerations are Omega^2 times a second derivative with respect to crank angle, and that derivative is a linear operator. So the derivative of an acceleration is the same operator applied to the derivative of the trajectory – no new mathematics, just spectral_derivative applied to the arrays exlink.jacobian already produces.

The 18x18 equilibrium solve. For A x = b,

\[\frac{\partial x}{\partial p} = A^{-1}\left( \frac{\partial b}{\partial p} - \frac{\partial A}{\partial p} x\right)\]

and the factorisation of A is already available from the solve itself. The entries of A are moment arms – differences of joint and centre-of-mass positions – so dA/dp follows directly from the position derivatives.

The sizing bisection. The diameter is defined implicitly by driving the worst utilisation to one, F(d, N, M) = 0. The implicit function theorem gives dd/dN = -(dF/dN)/(dF/dd) without differentiating the bisection at all.

Parameter layout

Derivative arrays carry a trailing axis of length N_PARAMETERS: the eleven design variables first, then the seven section diameters. DESIGN_SLICE and DIAMETER_SLICE name the two halves.

exlink.dynamics_jacobian.member_length_jacobian(design)[source]

d L_k / dX for every sized member, shaped (n_members, 11).

Most members take their length straight from a design variable. The two sides of the trigonal link that are derived – b = hypot(x_b, y_b) and d = hypot(x_b - c, y_b) – carry the chain rule of that reparametrisation.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

class exlink.dynamics_jacobian.MassJacobian(member_mass, body_mass, body_com, body_inertia)[source]

Bases: object

Derivatives of the inertial description with respect to (X, d).

member_mass: ndarray[tuple[Any, ...], dtype[float64]]

d m_k / dp, shaped (n_members, N_PARAMETERS).

body_mass: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d M_i / dp, each shaped (N_PARAMETERS,).

body_com: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d G_i / dp, each shaped (n_angles, 2, N_PARAMETERS).

body_inertia: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d I_i / dp, each shaped (N_PARAMETERS,).

exlink.dynamics_jacobian.mass_property_jacobian(design, kinematics, kinematic, properties, diameters, density, section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Differentiate the body masses, centres of mass and inertias.

Parameters:
  • design (Design) – The mechanism dimensions.

  • kinematics (Kinematics) – The solved kinematics.

  • kinematic (KinematicJacobian) – Its derivatives from exlink.jacobian.kinematic_jacobian().

  • properties (MassProperties) – The mass properties being differentiated.

  • diameters (dict[str, float]) – Outer diameter of each member [mm].

  • density (float) – Material density [tonne/mm^3].

  • section (Section) – The cross-section shape. A bore enters as two constant factors – 1 - k^2 on the mass and 1 + k^2 on the radial term of the transverse inertia – and changes nothing else here.

Return type:

MassJacobian

Returns:

The derivatives of every inertial quantity.

class exlink.dynamics_jacobian.AccelerationJacobian(joint, body, angular)[source]

Bases: object

Derivatives of the acceleration field with respect to (X, d).

joint: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d a_J / dp, each (n_angles, 2, N_PARAMETERS).

body: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d a_Gi / dp, each (n_angles, 2, N_PARAMETERS).

angular: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

d alpha_i / dp, each (n_angles, N_PARAMETERS).

exlink.dynamics_jacobian.acceleration_jacobian(kinematic, mass_jacobian, speed, n_angles)[source]

Differentiate the accelerations, by reusing the spectral operator.

a = Omega^2 D^2 r with D^2 linear, so da/dp = Omega^2 D^2 (dr/dp).

Parameters:
  • kinematic (KinematicJacobian) – Derivatives of the kinematics.

  • mass_jacobian (MassJacobian) – Derivatives of the centres of mass.

  • speed (float) – Crankshaft speed Omega [rad/s].

  • n_angles (int) – Crank angles per revolution.

Return type:

AccelerationJacobian

Returns:

The derivatives of every acceleration.

exlink.dynamics_jacobian.equilibrium_jacobian(design, kinematics, kinematic, properties, mass_jacobian, acceleration, loads, gas_force_derivative, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Differentiate the 18x18 equilibrium solve.

For A x = b the derivative is dx/dp = A^-1 (db/dp - dA/dp x), so the same matrix the forward solve already assembled is reused – no second factorisation, and no differentiation of the solve itself.

The entries of A are moment arms, differences of joint and centre-of-mass positions, so dA/dp follows straight from the position derivatives. b holds m a and I alpha and the applied gas load, so db/dp follows from the mass, acceleration and gas-force derivatives.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

dx/dp, shaped (n_angles, 18, N_PARAMETERS), in the unknown order of exlink.dynamics.

exlink.dynamics_jacobian.member_load_jacobian(design, kinematics, kinematic, properties, mass_jacobian, acceleration, loads, reaction_jacobian, stations)[source]

Differentiate the internal axial force and bending moment of every member.

The closed forms of exlink.sizing.internal_loads() are differentiated directly. For the trigonal link’s three sides the pin-jointed idealisation is differentiated too: the joint-equilibrium least squares gives dz = (M^T M)^-1 M^T (dr - dM z), exact because the system is consistent.

Parameters:
Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

(d_axial, d_bending), each shaped (n_members, n_angles, stations, N_PARAMETERS).

class exlink.dynamics_jacobian.SizingJacobian(d_axial, d_bending, d_length)[source]

Bases: object

Derivatives of the required diameters, with respect to what sets them.

d_axial: ndarray[tuple[Any, ...], dtype[float64]]

d(diameter_k) / d(axial), shaped (n_members, n_angles, stations).

Sparse in practice: only the sections and crank angles that attain the binding utilisation carry a non-zero entry.

d_bending: ndarray[tuple[Any, ...], dtype[float64]]

d(diameter_k) / d(bending), same shape.

d_length: ndarray[tuple[Any, ...], dtype[float64]]

d(diameter_k) / d(L_k), shaped (n_members,).

Non-zero only where buckling binds; yield and fatigue do not see the span.

exlink.dynamics_jacobian.sizing_jacobian(axial, bending, diameters, lengths, material, safety, section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Differentiate the sizing solve, without differentiating the bisection.

The diameter is defined implicitly: it is the value that drives the worst utilisation to one, U(d, N, M, L) = 1. The implicit function theorem then gives

\[\frac{\partial d}{\partial q} = -\frac{\partial U/\partial q}{\partial U/\partial d}\]

so the bisection itself never has to be differentiated – only the closed form of whichever failure mode binds. Each mode is an extremum over fibre, crank angle and station, so the envelope theorem applies again: the derivative is taken at the entry that attains it.

Parameters:
  • axial (ndarray[tuple[Any, ...], dtype[float64]]) – (n_members, n_angles, n_stations) [N], tension positive.

  • bending (ndarray[tuple[Any, ...], dtype[float64]]) – Same shape [N.mm].

  • diameters (ndarray[tuple[Any, ...], dtype[float64]]) – The solved diameters (n_members,) [mm].

  • lengths (ndarray[tuple[Any, ...], dtype[float64]]) – Member lengths (n_members,) [mm].

  • material (Material) – The Material.

  • safety (SafetyFactors) – The SafetyFactors.

  • section (Section) – The cross-section shape. A bore rescales A, Z and I by constants, so it moves the values below but none of the slopes: the logarithmic derivatives d(1/A)/dd = -2/(A d), d(1/Z)/dd = -3/(Z d) and dI/dd = 4 I / d are the same for a tube as for a bar, and the shape factor cancels out of every one.

Return type:

SizingJacobian

Returns:

The derivative of each diameter with respect to its loads and length.

class exlink.dynamics_jacobian.CoupledJacobian(axial, bending, mean_torque, peak_bearing_load)[source]

Bases: object

Everything the two coupled disciplines need to report a local Jacobian.

axial: ndarray[tuple[Any, ...], dtype[float64]]

d(axial)/dp, (n_members, n_angles, stations, N_PARAMETERS).

bending: ndarray[tuple[Any, ...], dtype[float64]]

d(bending)/dp, same shape.

mean_torque: ndarray[tuple[Any, ...], dtype[float64]]

d(mean M_r)/dp, (N_PARAMETERS,).

peak_bearing_load: ndarray[tuple[Any, ...], dtype[float64]]

d(max |R_1|)/dp, (N_PARAMETERS,).

exlink.dynamics_jacobian.coupled_jacobian(design, analysis, diameters, properties, loads, stations, material, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Assemble the dynamics discipline’s local Jacobian, end to end.

Chains exlink.jacobian.kinematic_jacobian() through the mass properties, the accelerations, the 18x18 solve and the internal loads.

Parameters:
  • design (Design) – The mechanism dimensions.

  • analysis (object) – A valid Analysis.

  • diameters (dict[str, float]) – Section diameter of each member [mm].

  • properties (MassProperties) – The mass properties used for the load case.

  • loads (DynamicLoads) – The solved load case.

  • stations (int) – Sections evaluated along each member.

  • material (Material) – The material, for its density.

  • spec (EngineSpec) – Fixed engine data.

  • section (Section) – The cross-section shape, which enters through the member masses and inertias.

Return type:

CoupledJacobian

Returns:

The derivatives the discipline reports.

The vehicle-level problem

What turns three competing objectives into one: friction makes the constraint set cost something, the mass budget converts the envelope and the torque ripple into kilograms, and the vehicle converts kilograms and efficiency into range.

Mechanical losses, and the efficiency that actually reaches the crankshaft.

The geometric problem carries a quantity called eta, defined as a ratio of the crank torque’s work to the gas force’s work. With no friction in the model those two works are equal at every crank angle – that is the virtual-work identity exlink.loads is verified against – so eta is not an efficiency in the thermodynamic sense at all. It is a kinematic quality measure: how much mean torque a given piston motion converts into, normalised by the stroke. Useful, but it cannot be the objective of an engine study, because nothing is lost in it.

This module supplies the losses that are really there, from quantities the dynamic solve already produces:

Journal bearings. Every revolute joint carries a known reaction R and rotates through a known relative angle. A pin of radius r under boundary or mixed lubrication dissipates dW = mu |R| r |d(theta_rel)|. Seven joints contribute, and the two crank throws contribute the most because they turn through a full revolution while the others only oscillate.

Piston and rings. The liner reaction D is already solved for – it is the same quantity the side-load constraint gamma bounds. The rings add a roughly constant radial tension on top of it, which at the low loads of an economy engine is the dominant term. Both slide through |d(lambda)|.

Gear mesh. A well-cut spur pair loses one to two per cent of what it transmits; this is carried as a flat mesh efficiency rather than modelled.

Why this closes the argument

Friction is what makes the constraint set mean something. Without it the side-load bound gamma <= 0.02 is an assertion about wear that never appears in any objective, and the bearing-load bound is likewise inert. With it, both feed straight into the quantity being maximised: a design that leans on the liner burns its fuel on the liner.

It is also what makes size cost something in the right place. Large H and B buy a long lever arm and a flattering eta, but they do it with long members carrying large bearing reactions – and now that shows up as friction work as well as mass.

Model fidelity

This is a Coulomb model with constant coefficients, not a hydrodynamic one. It gets the scaling right (loss proportional to load and to sliding distance) and it responds correctly to design changes, which is what an optimizer needs. It will not predict absolute friction mean effective pressure to better than about 30 %, and no conclusion here rests on the absolute value – only on comparisons between designs evaluated with the same coefficients.

exlink.friction.JOURNAL_FRICTION = 0.008

Equivalent Coulomb coefficient at a hydrodynamic steel journal [-].

A plain bearing running on a full oil film has an effective coefficient of 0.005 to 0.015; 0.008 is a warm, mid-speed value. It is not the boundary figure of 0.04, which applies only at start-up – using the boundary value would predict an engine that cannot turn itself over, and this one does.

The absolute loss scales linearly with this number, so every conclusion drawn from it is stated as a comparison between designs at the same coefficient. exlink.friction.sensitivity() sweeps it.

exlink.friction.PISTON_FRICTION = 0.1

Coulomb coefficient between the piston and ring pack and the liner [-].

exlink.friction.RING_TENSION = 30.0

Radial force the ring pack presses on the liner with, independent of gas load [N].

For a 32 mm bore with a two-compression-plus-oil pack. At the low cylinder pressures of an economy engine this term is larger than the gas-driven side load, which is why it cannot be dropped.

exlink.friction.MESH_EFFICIENCY = 0.985

Fraction of transmitted power surviving one spur-gear mesh [-].

exlink.friction.JOINT_BODIES: dict[str, tuple[str, str]] = {'A': ('swing_rod', 'trigonal'), 'D': ('trigonal', 'crank_2'), 'E': ('trigonal', 'piston_rod'), 'P': ('piston_rod', 'piston'), 'Q': ('crank_1', 'swing_rod'), 'R1': ('crank_1', 'frame'), 'R2': ('crank_2', 'frame')}

the two bodies whose angles differ there.

Type:

Relative rotation at each joint

exlink.friction.JOINT_MEMBERS: dict[str, tuple[str, ...]] = {'A': ('swing_rod', 'trigonal_ad', 'trigonal_ae'), 'D': ('trigonal_ad', 'trigonal_de', 'crank_2'), 'E': ('trigonal_ae', 'trigonal_de', 'piston_rod'), 'P': ('piston_rod',), 'Q': ('crank_1', 'swing_rod'), 'R1': ('crank_1',), 'R2': ('crank_2',)}

Which sized members meet at each joint; the pin is as stout as the stoutest.

class exlink.friction.FrictionLosses(joint_work, piston_work, mesh_work, indicated_work)[source]

Bases: object

Mechanical loss breakdown over one crankshaft revolution.

joint_work: dict[str, float]

Friction work at each journal [N.mm per revolution].

piston_work: float

Friction work at the piston and rings [N.mm per revolution].

mesh_work: float

Work lost in the gear mesh [N.mm per revolution].

indicated_work: float

Work delivered to the crankshaft before friction [N.mm per revolution].

property bearing_work: float

Total journal friction work [N.mm per revolution].

property total_work: float

All mechanical losses [N.mm per revolution].

property brake_work: float

Work leaving the crankshaft [N.mm per revolution].

property mechanical_efficiency: float

W_brake / W_indicated, the real mechanical efficiency [-].

Negative or zero when friction exceeds the indicated work, which is a genuine outcome for a badly proportioned linkage at low load, not a numerical failure. Callers should treat a non-positive value as an engine that will not run.

property runs: bool

Whether the engine produces net positive work.

breakdown_kj()[source]

Loss shares as fractions of indicated work, for reporting.

Return type:

dict[str, float]

exlink.friction.pin_diameters(diameters)[source]

Pin diameter at each joint, from the members that meet there.

A pin is taken as stout as the stoutest member it joins. This is a modelling choice, not a sizing calculation: the pins are not separately sized, and making them follow the members keeps the friction radius responding to the structural design in the right direction.

Parameters:

diameters (dict[str, float]) – Sized member diameters [mm].

Return type:

dict[str, float]

Returns:

{joint: diameter} [mm].

exlink.friction.losses(loads, diameters, journal_friction=0.008, piston_friction=0.1, ring_tension=30.0, mesh_efficiency=0.985)[source]

Integrate the mechanical losses over one crankshaft revolution.

Parameters:
  • loads (DynamicLoads) – A solved dynamic load case, supplying joint reactions, the liner force and the crank torque.

  • diameters (dict[str, float]) – Sized member diameters [mm], for the pin radii.

  • journal_friction (float) – Coulomb coefficient at the journals.

  • piston_friction (float) – Coulomb coefficient at the liner.

  • ring_tension (float) – Ring-pack radial load [N].

  • mesh_efficiency (float) – Fraction of transmitted power surviving the mesh.

Return type:

FrictionLosses

Returns:

The loss breakdown. All works are per crankshaft revolution [N.mm].

exlink.friction.sensitivity(loads, diameters, coefficients=(0.004, 0.006, 0.008, 0.012, 0.02))[source]

Mechanical efficiency against the assumed journal coefficient.

The single most uncertain number in the loss model is the journal coefficient, so its influence is reported rather than assumed away. A conclusion that survives this sweep does not depend on the friction model being right in absolute terms.

Parameters:
  • loads (DynamicLoads) – A solved dynamic load case.

  • diameters (dict[str, float]) – Sized member diameters [mm].

  • coefficients (tuple[float, ...]) – Journal coefficients to evaluate.

Return type:

dict[float, float]

Returns:

{coefficient: mechanical_efficiency}.

The gear pair, and the discrete variable hiding inside the inter-axle distance.

The linkage carries one 2:1 external gear pair between the crankshaft and the eccentric shaft. In the geometric problem it appears only as a pair of primitive radii, r_1 = 2I/3 and r_2 = I/3, which are continuous functions of the design variable I. That is a fiction, and it is the interesting kind.

Why I is not really continuous

A gear has an integer number of teeth cut with a standard-module hob. For a pair on a fixed centre distance,

\[r = \frac{m z}{2}, \qquad I = r_1 + r_2 = \frac{m (z_1 + z_2)}{2}\]

and the 2:1 ratio forces z_1 = 2 z_2. Together:

\[I = \tfrac{3}{2} m z_2, \qquad m \in \text{ISO 54}, \; z_2 \in \mathbb{Z}\]

So the inter-axle distance lives on a lattice, not on an interval. At m = 1.5 the spacing is 2.25 mm; at m = 3 it is 4.5 mm. A continuous optimizer that returns I = 61.37 mm has returned a mechanism that cannot be geared, and the nearest buildable neighbours are 60.75 and 63.00.

This matters more than it sounds, because I is one of the variables the equality constraints STE = 74 and epsilon = 16 are satisfied with. Snapping I to the lattice moves the design off both equalities, and the remaining continuous variables have to be re-solved to restore them. That is the classic mixed-integer structure: choose the discrete variable, then repair the continuous ones. lattice_neighbours() enumerates the candidates and exlink.scenarios does the repair.

Undercutting sets the floor. A 20 deg involute pinion needs z >= 2 / sin^2(alpha) = 17 teeth to avoid an undercut root, so the smaller gear pins z_2 >= 17 and hence I >= 25.5 m.

Sizing the teeth

Two failure modes, both standard:

  • Root bending (Lewis): sigma = F_t / (b m Y), with Y the form factor of the tooth count.

  • Flank contact (Hertz line contact, the AGMA pitting check): sigma_H = Z_E sqrt(F_t (u + 1) / (b d_1 u)).

Both are inversely proportional to face width, so b is what carries the load once m is chosen. The face width is bounded above at 12 m: wider than that and the tooth load stops being uniform across the face, because no real shaft is stiff enough to keep the mesh aligned.

exlink.gears.ELASTIC_COEFFICIENT = 191.0

Z_E for a steel-on-steel pair [sqrt(MPa)].

Z_E = sqrt(1 / (pi ((1 - nu_1^2)/E_1 + (1 - nu_2^2)/E_2))) at E = 210 GPa, nu = 0.3.

exlink.gears.CONTACT_ALLOWABLE = 1100.0

Allowable flank contact stress for a case-hardened steel gear [MPa].

exlink.gears.MAX_WIDTH_FACTOR = 12.0

Largest face width as a multiple of the module, for mesh alignment.

exlink.gears.UTILISATION_TOLERANCE = 1e-09

Slack on the strength checks, in units of utilisation.

Not a safety allowance – the safety factors are already in the allowables. size_pair() solves for the face width that makes the governing stress equal its allowable, so the governing utilisation of every automatically sized pair is 1 up to round-off. Comparing that against exactly 1.0 makes feasibility depend on the last bit: the same pair evaluates to 1.0000000000000002 on one platform and 0.9999999999999999 on another, and is declared unsafe on the first. The tolerance decides that comparison on the physics rather than on the floating-point unit; it is nine orders of magnitude below any stress the model resolves.

exlink.gears.WEB_FRACTION = 0.35

Web thickness as a fraction of face width, for the blank mass model.

exlink.gears.lewis_form_factor(teeth)[source]

Lewis form factor Y for a 20 deg full-depth involute tooth.

A smooth fit to the standard table over z in [17, 200], accurate to about 2 % across that range, which is finer than the uncertainty in the allowable stress it multiplies.

Parameters:

teeth (float) – Number of teeth.

Return type:

float

Returns:

Y, dimensionless.

exlink.gears.tooth_count(inter_axle, module)[source]

Teeth on the smaller gear for a given centre distance and module.

Parameters:
  • inter_axle (float) – I, the centre distance [mm].

  • module (float) – Gear module [mm].

Return type:

int

Returns:

z_2 = 2 I / (3 m), rounded to the nearest integer and floored at MIN_TEETH.

exlink.gears.lattice_inter_axle(module, teeth)[source]

The centre distance a module and tooth count actually produce [mm].

Parameters:
  • module (float) – Gear module [mm].

  • teeth (int) – Teeth on the smaller gear.

Return type:

float

Returns:

I = 1.5 m z_2.

exlink.gears.lattice_neighbours(inter_axle, modules=array([0.5, 0.6, 0.8, 1., 1.25, 1.5, 2., 2.5, 3., 4., 5., 6., 8., 10.]), count=2)[source]

Buildable centre distances bracketing a continuous one.

For every standard module, the tooth counts whose centre distance sits closest to the requested one are returned. This is the candidate list a mixed-integer search enumerates.

Parameters:
  • inter_axle (float) – The continuous I an optimizer asked for [mm].

  • modules (ndarray) – Standard modules to consider [mm].

  • count (int) – Tooth counts to keep on each side, per module.

Return type:

list[tuple[float, int, float]]

Returns:

[(module, teeth, inter_axle), ...] sorted by distance from the requested value. Entries below the undercut limit are dropped.

class exlink.gears.GearPair(module, teeth_small, teeth_large, face_width, inter_axle, bending_utilisation, contact_utilisation, mass, inertia_small, inertia_large)[source]

Bases: object

A sized 2:1 external gear pair.

module: float

Standard module m [mm].

teeth_small: int

z_2, on the eccentric shaft.

teeth_large: int

z_1 = 2 z_2, on the crankshaft.

face_width: float

b, common to both gears [mm].

inter_axle: float

I = 1.5 m z_2 [mm].

bending_utilisation: float

Lewis root stress over its allowable; <= 1 is safe.

contact_utilisation: float

Hertz flank stress over its allowable; <= 1 is safe.

mass: float

Mass of both blanks [tonne].

inertia_small: float

Polar inertia of the small gear about its own axis [tonne mm^2].

inertia_large: float

Polar inertia of the large gear [tonne mm^2].

property mass_kg: float

Mass of both blanks [kg].

property radius_small: float

Primitive radius of the small gear [mm].

property radius_large: float

Primitive radius of the large gear [mm].

property width_factor: float

Face width as a multiple of the module.

property feasible: bool

Whether the pair is both safe and within the face-width limit.

exlink.gears.size_pair(inter_axle, tangential_force, module=None, teeth=None, shaft_bore=12.0, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Size a 2:1 pair for a peak tangential tooth load.

The module and tooth count may be given (a mixed-integer search fixes them and asks what it costs) or left out, in which case the smallest standard module that can carry the load within the face-width limit is chosen.

Parameters:
  • inter_axle (float) – Requested centre distance I [mm]. Used to pick the tooth count when teeth is not given; the realised centre distance is on the lattice and is returned in the result.

  • tangential_force (float) – Peak tangential tooth load F_t [N].

  • module (float | None) – Standard module to use [mm]; chosen automatically if omitted.

  • teeth (int | None) – Teeth on the small gear; derived from inter_axle if omitted.

  • shaft_bore (float) – Shaft diameter the gears are bored for [mm].

  • material (Material) – Gear material.

  • safety (SafetyFactors) – Design factors; the static factor is applied to root bending.

Return type:

GearPair

Returns:

The sized pair. Check GearPair.feasible.

exlink.gears.buildable_neighbours(inter_axle, tangential_force, shaft_bore=12.0, count=3, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Lattice points that can actually carry the tooth load, nearest first.

lattice_neighbours() ranks purely by distance from the requested centre distance, and that is the wrong order to optimise in. The nearest lattice points are reached with the smallest modules, and a small module needs a wide face to carry a given tooth load – so the geometrically closest candidates are routinely the structurally worst, and can be unbuildable outright.

Ranking instead by whether the pair is within its face-width limit, then by distance, is what an enumeration over the discrete variable should do.

Parameters:
  • inter_axle (float) – The continuous I an optimizer asked for [mm].

  • tangential_force (float) – Peak tangential tooth load F_t [N].

  • shaft_bore (float) – Shaft diameter the gears are bored for [mm].

  • count (int) – Tooth counts to keep on each side, per module.

  • material (Material) – Gear material.

  • safety (SafetyFactors) – Design factors.

Return type:

list[tuple[float, int, float, GearPair]]

Returns:

[(module, teeth, inter_axle, pair), ...], feasible pairs first and each group ordered by distance from the request. The list is never empty: when nothing is feasible the infeasible candidates are still returned, least-overloaded first, so a caller can start from the best available and report that none was buildable.

Manufacturability: what a shop can actually cut, and what that costs in mass.

A sizing loop that reports d = 7.3184 mm has answered a question nobody asked. Round bar arrives in preferred sizes, gear cutters exist for standard modules, and a wall thinner than a couple of millimetres cannot be cast or reliably machined. This module holds those rules in one place so that the rest of the package can state a buildable design rather than a mathematical one.

Three kinds of rule appear here:

Preferred sizes. STOCK_DIAMETERS is the R20 renard series of metric round bar, STANDARD_MODULES the ISO 54 series 1 gear modules. Both are ordered ascending, and round_up_to_stock() moves a continuous requirement up to the next available size. Rounding up is the only safe direction: it can never turn a safe section unsafe.

Minimum thicknesses. A dimension can be safe against every stress and still be unmakeable. MIN_WALL_THICKNESS and friends are floors applied after strength sizing, never before.

The manufacturing premium. Rounding up adds mass that no stress analysis asked for. stock_premium() measures it, because it is the honest answer to “how much did buildability cost?” and because it is not negligible: on a member whose strength requirement lands just above a stock size the premium can exceed 30 %.

Why this is applied outside the sizing fixed point

Rounding is a step function, and a step function inside a fixed-point iteration turns a smooth contraction into something that can limit-cycle between two stock sizes forever. The coupled solve therefore converges on continuous diameters, and discretisation is applied once at the end – then the loads are re-evaluated on the discretised sections to confirm the design is still safe. That “size continuous, discretise, verify” order is standard practice and it is what exlink.coupled.solve_coupled() does when asked for a buildable result.

exlink.manufacturing.STOCK_DIAMETERS: ndarray[tuple[Any, ...], dtype[float64]] = array([  3. ,   3.5,   4. ,   4.5,   5. ,   5.5,   6. ,   7. ,   8. ,          9. ,  10. ,  11. ,  12. ,  14. ,  16. ,  18. ,  20. ,  22. ,         25. ,  28. ,  30. ,  32. ,  35. ,  40. ,  45. ,  50. ,  56. ,         63. ,  70. ,  80. ,  90. , 100. ])

Preferred metric round-bar diameters, R20 renard series [mm].

exlink.manufacturing.STANDARD_MODULES: ndarray[tuple[Any, ...], dtype[float64]] = array([ 0.5 ,  0.6 ,  0.8 ,  1.  ,  1.25,  1.5 ,  2.  ,  2.5 ,  3.  ,         4.  ,  5.  ,  6.  ,  8.  , 10.  ])

ISO 54 series-1 gear modules [mm]. Cutters exist for these; a module between two of them means a bespoke hob.

exlink.manufacturing.MIN_WALL_THICKNESS = 2.5

Thinnest reliably castable aluminium wall [mm].

Sand and gravity die casting of a small crankcase; a thinner wall risks misruns and cold shuts regardless of what the stress says.

exlink.manufacturing.MIN_MACHINED_THICKNESS = 1.5

Thinnest wall to leave on a machined-from-solid part [mm].

Below this the part distorts as the clamping releases and the tolerance is not holdable.

exlink.manufacturing.MIN_CROWN_THICKNESS = 3.0

Thinnest piston crown [mm].

Set by thermal, not mechanical, considerations: the crown has to conduct combustion heat to the rings, and a thin crown runs hot enough to pre-ignite.

exlink.manufacturing.MIN_FACE_WIDTH = 4.0

Narrowest gear face a small hob will cut cleanly [mm].

exlink.manufacturing.MIN_TEETH = 17

Fewest teeth on a 20 deg involute pinion before undercutting [-].

The standard result: z_min = 2 / sin^2(alpha) rounded up, which is 17 at a 20 deg pressure angle. Fewer teeth need profile shift, which this model does not carry.

exlink.manufacturing.round_up_to_stock(diameter, catalogue=array([3., 3.5, 4., 4.5, 5., 5.5, 6., 7., 8., 9., 10., 11., 12., 14., 16., 18., 20., 22., 25., 28., 30., 32., 35., 40., 45., 50., 56., 63., 70., 80., 90., 100.]))[source]

Move each diameter up to the next available stock size.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

The next catalogue entry at or above each input, elementwise. A requirement above the largest entry is returned unchanged, so that a runaway design is still visible as a runaway rather than being silently clipped to the top of the catalogue.

exlink.manufacturing.round_to_module(module, catalogue=array([0.5, 0.6, 0.8, 1., 1.25, 1.5, 2., 2.5, 3., 4., 5., 6., 8., 10.]))[source]

Move a gear module to the nearest standard one.

Unlike bar stock this rounds to nearest, not up: a module is not a safety margin, and the tooth strength is recovered by the face width instead.

Parameters:
Return type:

float

Returns:

The closest catalogue entry.

exlink.manufacturing.stock_premium(continuous, discrete)[source]

Fractional mass added by rounding sections up to stock.

Mass goes as d^2 at fixed length, so the premium on one member is (d_stock / d_required)^2 - 1. Reported over a whole set of members as the ratio of summed areas, which weights each member by how much material it actually carries.

Parameters:
Return type:

float

Returns:

mass_discrete / mass_continuous - 1, a fraction. Zero means every member landed exactly on a stock size.

What the engine actually weighs, rather than what its seven links weigh.

The coupled sizing loop returns the mass of the sized members: about 0.25 kg. No engine weighs 0.25 kg. Reporting that number as “the mass” and then optimising it is a category error – it optimises a quarter-kilogram tail while a four-kilogram dog goes unmodelled, and worse, it hides the fact that the two envelope objectives H and B are themselves mass in disguise.

This module assembles the whole reciprocating assembly. Eight contributions, each sized from something the analysis already knows:

contribution

what sets it

linkage members

the coupled sizing fixed point

piston assembly

peak gas pressure

gear pair

peak tooth load, and the chosen module

shafts

combined bending and torsion at the journals

bearings

journal diameter, via a catalogue regression

crankcase

the envelope H x B, at minimum castable wall

cylinder and head

bore and peak pressure

flywheel

the cyclic torque fluctuation the linkage produces

Two of these deserve emphasis, because they are what change the shape of the optimization problem rather than just its scale.

The crankcase makes H and B physical. A box has to enclose the mechanism, and its walls scale with the envelope area. In the geometric problem H and B are two of three competing objectives with no stated exchange rate against efficiency – which is precisely why that problem could only produce a Pareto front and never a design. Here they convert to kilograms at a rate the physics fixes, and kilograms convert to range. The three-objective problem collapses to one objective, and the exchange rate is no longer the designer’s to choose.

The flywheel makes torque smoothness physical. A single-cylinder engine needs enough rotating inertia to carry it through the compression stroke. The requirement follows from the fluctuation of the torque the linkage produces, so a design whose torque curve is flat needs less flywheel and therefore less mass – a design driver that no constraint in the geometric problem expresses, and one that pushes in the opposite direction from a long lever arm. It is also frequently the single heaviest item in the budget.

Fidelity

Every item is a first-order sizing model of the kind used in conceptual design: right scaling laws, right sensitivities, absolute values good to perhaps 20 %. The budget is not a weight statement for a built engine. It is a consistent way to convert design changes into kilograms, which is what an objective needs.

exlink.mass_budget.ALUMINIUM_DENSITY = 2.7e-09

Density of a cast aluminium crankcase alloy [tonne/mm^3].

exlink.mass_budget.ALUMINIUM_YIELD = 200.0

Yield strength of the same alloy [MPa].

exlink.mass_budget.SPEED_FLUCTUATION = 0.1

Coefficient of cyclic speed fluctuation delta the flywheel must hold [-].

delta = (omega_max - omega_min) / omega_mean. Ten per cent suits a road vehicle, where the wheels and the driveline are themselves part of the smoothing and nothing is driving a generator; a machine tool would want half that. The flywheel is frequently the heaviest item in the budget and its mass goes as 1 / delta, so this number is swept rather than trusted.

exlink.mass_budget.CASE_SLENDERNESS = 60.0

Span-to-wall ratio a cast housing is stiffened to [-].

A cast case is not sized by stress – at these loads a 2.5 mm wall is overwhelmingly strong – but by stiffness, castability and the need to survive handling. Housings are conventionally proportioned at a span-to-thickness ratio of 50 to 80.

exlink.mass_budget.BEARING_FILL = 0.55

Fraction of a deep-groove bearing’s swept volume that is steel [-].

exlink.mass_budget.CASE_CLEARANCE = 8.0

Clearance between the mechanism envelope and the inside of the case [mm].

exlink.mass_budget.FLYWHEEL_WEB_FACTOR = 1.25

Multiplier turning a bare rim mass into a rim with web and hub [-].

class exlink.mass_budget.MassBudget(items=<factory>, gears=None, shaft_diameter=0.0, flywheel_inertia=0.0, flywheel_radius=0.0, required_inertia=0.0, inherent_inertia=0.0)[source]

Bases: object

Mass of the whole engine, itemised.

items: dict[str, float]

Each contribution [tonne].

gears: GearPair | None = None
shaft_diameter: float = 0.0

Journal diameter both shafts are sized to [mm].

flywheel_inertia: float = 0.0

Flywheel polar inertia actually fitted [tonne mm^2].

flywheel_radius: float = 0.0

Radius the flywheel rim sits at [mm].

required_inertia: float = 0.0

Rotating inertia the speed-fluctuation limit demands [tonne mm^2].

inherent_inertia: float = 0.0

Rotating inertia already present in the cranks and gears [tonne mm^2].

property total: float

Total engine mass [tonne].

property total_kg: float

Total engine mass [kg].

kilograms()[source]

The itemised budget in kilograms, heaviest first.

Return type:

dict[str, float]

shares()[source]

Each item as a fraction of the total.

Return type:

dict[str, float]

exlink.mass_budget.gas_torque(loads)[source]

The crank torque the gas force alone would produce [N.mm].

By virtual work, M = -F_gas d(lambda)/d(theta_1). This is the classic turning-moment diagram, and it is what the flywheel calculation needs: the inertia part of the full torque history represents energy traded with the mechanism’s own moving masses, which is accounted for separately as inherent rotating inertia. Feeding the full torque into a flywheel sizing double-counts that exchange and, at low speed, overstates the flywheel by an order of magnitude.

Parameters:

loads (DynamicLoads) – A solved dynamic load case.

Return type:

ndarray

Returns:

Gas torque at each crank angle [N.mm].

exlink.mass_budget.flywheel_requirement(torque, speed, fluctuation=0.1, span=6.283185307179586)[source]

Rotating inertia needed to hold the cyclic speed fluctuation.

The standard construction: integrate the excess of instantaneous torque over its mean around the cycle, take the peak-to-peak of that accumulated energy, and divide by delta omega^2.

\[J = \frac{\Delta E}{\delta \, \omega^2}, \qquad \Delta E = \max_\theta E(\theta) - \min_\theta E(\theta), \qquad E(\theta) = \int_0^\theta (M_r - \bar{M_r}) \, d\theta_1\]
Parameters:
  • torque (ndarray) – Output torque at uniformly spaced crank angles [N.mm].

  • speed (float) – Mean crankshaft speed omega [rad/s].

  • fluctuation (float) – Allowed delta.

  • span (float) – Crank angle the samples cover [rad]. 2 pi for a cycle that completes in one crankshaft revolution, 4 pi for a conventional four-stroke, whose flywheel has to carry it through two revolutions on one firing and is correspondingly larger.

Return type:

tuple[float, float]

Returns:

(required_inertia, energy_swing) in tonne mm^2 and N.mm. The inertia is zero at zero speed, where the concept does not apply.

exlink.mass_budget.shaft_diameter(peak_reaction, peak_torque, overhang, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Journal diameter from combined bending and torsion.

The ASME shaft equation with fatigue factors k_b = 1.5, k_t = 1.0:

\[d^3 = \frac{16}{\pi \tau_a} \sqrt{(k_b M)^2 + (k_t T)^2}\]
Parameters:
  • peak_reaction (float) – Largest journal reaction over the cycle [N].

  • peak_torque (float) – Largest shaft torque [N.mm].

  • overhang (float) – Distance from the journal to the load [mm].

  • material (Material) – Shaft material.

  • safety (SafetyFactors) – Design factors.

Return type:

float

Returns:

Required journal diameter [mm], floored at 6 mm.

exlink.mass_budget.bearing_mass(bore, count, density)[source]

Mass of a set of deep-groove ball bearings [tonne].

Outer diameter and width follow the usual catalogue proportions for a medium series, D ~ 2.2 d + 8 and B ~ 0.55 d + 3; the swept annulus is then filled to BEARING_FILL with steel to account for the raceways, balls and cage.

Parameters:
  • bore (float) – Bearing bore, i.e. journal diameter [mm].

  • count (int) – How many bearings.

  • density (float) – Steel density [tonne/mm^3].

Return type:

float

Returns:

Total mass [tonne].

exlink.mass_budget.crankcase_mass(height, width, depth, peak_reaction, thickness=None)[source]

Mass of a thin-walled cast crankcase enclosing the mechanism.

This is where the envelope objectives become kilograms. The case is a closed box one clearance larger than the mechanism on every side. Its wall is the greater of the minimum castable thickness and what a plate bending check needs to carry the main-bearing reaction into the structure, treating the bearing boss as a load on a plate of span min(H, B):

\[t = \sqrt{\frac{3 F \, s}{2 \, \sigma_a \, w}}\]

with s the span, w an effective load-spreading width, and sigma_a the allowable.

Parameters:
  • height (float) – Mechanism envelope H [mm].

  • width (float) – Mechanism envelope B [mm].

  • depth (float) – Case depth, across the crankshaft axis [mm].

  • peak_reaction (float) – Largest main-bearing reaction [N].

  • thickness (float | None) – Wall thickness [mm]; derived if omitted.

Return type:

tuple[float, float]

Returns:

(mass_tonne, wall_thickness_mm).

exlink.mass_budget.cylinder_mass(stroke, peak_pressure, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Mass of the liner and head [tonne].

The liner is a thick-walled tube under internal pressure (Lame, thin-wall limit); the head is a clamped circular plate over the bore.

Parameters:
  • stroke (float) – Total piston travel the liner must guide [mm].

  • peak_pressure (float) – Peak in-cylinder gauge pressure [MPa].

  • spec (EngineSpec) – Fixed engine data.

  • material (Material) – Liner and head material.

  • safety (SafetyFactors) – Design factors.

Return type:

float

Returns:

Mass of liner plus head [tonne].

exlink.mass_budget.assemble(loads, diameters, member_mass, piston_mass, height, width, stroke, peak_pressure, module=None, teeth=None, fluctuation=0.1, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Assemble the full engine mass budget.

Parameters:
  • loads (DynamicLoads) – A solved dynamic load case.

  • diameters (dict[str, float]) – Sized member diameters [mm].

  • member_mass (dict[str, float]) – Mass of each sized member [tonne].

  • piston_mass (float) – Piston assembly mass [tonne].

  • height (float) – Mechanism envelope H [mm].

  • width (float) – Mechanism envelope B [mm].

  • stroke (float) – Total piston travel [mm].

  • peak_pressure (float) – Peak in-cylinder gauge pressure [MPa].

  • module (float | None) – Gear module to use [mm]; chosen automatically if omitted.

  • teeth (int | None) – Teeth on the small gear; derived from I if omitted.

  • fluctuation (float) – Allowed cyclic speed fluctuation.

  • spec (EngineSpec) – Fixed engine data.

  • material (Material) – Steel parts’ material.

  • safety (SafetyFactors) – Design factors.

Return type:

MassBudget

Returns:

The itemised budget.

Range: the one objective the whole problem was always about.

The geometric problem has three objectives – maximise eta, minimise H, minimise B – and no exchange rate between them. That is why it can only ever produce a Pareto front, and why picking a point on that front is left to the designer’s taste. But the engine is for a Shell Eco-marathon car, and that competition has exactly one figure of merit: how far you get on a given quantity of fuel. Once that is written down, the exchange rates are no longer anyone’s taste. They are physics.

eta_mech  --.
            +--> brake efficiency --.
cycle work -'                        +--> fuel per metre --> RANGE
                                    /
H, B  --> crankcase --.            /
torque ripple --> flywheel --> engine mass --> rolling resistance

Efficiency and mass both land on range, in commensurable units, and they pull in opposite directions: a long lever arm flatters eta but builds a big crankcase and a heavy flywheel, and the heavier car then drags that back out through rolling resistance.

How the car is driven

Not at constant speed. Every serious Eco-marathon team drives burn and coast: run the engine hard from v_lo up to v_hi, declutch, coast back down to v_lo, repeat. This is not a detail – it is the reason the engine can be optimised at one operating point at all, and it is where the accelerations and decelerations enter.

The energy bookkeeping is worth stating, because the naive expectation is wrong. Over one full burn-and-coast cycle the car starts and ends at the same speed, so the kinetic energy nets to zero and

\[W_{\text{burn}} = \int_{\text{burn} + \text{coast}} F_{\text{res}}(v) \, dx\]

Accelerating hard costs nothing in resistance work. What burn-and-coast actually buys is that the engine spends its whole running time at high load, where brake efficiency is far better than at the part load a constant-speed drive would need. What it costs is aerodynamic: drag goes as v^2, so swinging between v_lo and v_hi burns more than cruising at their mean. Both effects are in the model below, and the optimum window is the balance between them.

What is not modelled

Transient combustion during the burn, gear-change losses, the driver’s line around the track, wind, and the engine’s own warm-up. Rolling resistance is a constant coefficient and aerodynamic drag a constant C_d A; neither responds to speed or load beyond the explicit v^2. The absolute range is therefore good to perhaps 20 %, and the model earns its place by ranking designs consistently, not by predicting a competition result.

exlink.vehicle.GRAVITY = 9.80665

Standard gravity [m/s^2].

exlink.vehicle.AIR_DENSITY = 1.2

Air density at competition conditions [kg/m^3].

exlink.vehicle.GASOLINE_LHV = 44000000.0

Lower heating value of gasoline [J/kg].

exlink.vehicle.GASOLINE_DENSITY = 745.0

Density of gasoline [kg/m^3].

exlink.vehicle.ENERGY_PER_LITRE = 32780000.0

Energy in one litre of gasoline [J/L].

class exlink.vehicle.Vehicle(glider_mass=35.0, driver_mass=50.0, rolling_resistance=0.0015, drag_area=0.075, transmission_efficiency=0.9, minimum_average_speed=25.0)[source]

Bases: object

The car the engine goes into, everything except the engine.

Defaults describe a Shell Eco-marathon Prototype class entry: a three-wheeled, fully faired, single-seat lay-down car.

glider_mass: float = 35.0

Chassis, body, wheels and driveline, without the engine [kg].

driver_mass: float = 50.0

Driver plus equipment [kg]; the competition sets a 50 kg minimum.

rolling_resistance: float = 0.0015

C_rr for competition tyres on smooth asphalt [-].

drag_area: float = 0.075

C_d A [m^2]; roughly C_d = 0.15 over a 0.5 m^2 frontal area.

transmission_efficiency: float = 0.9

Crankshaft to driven wheel, including the clutch [-].

minimum_average_speed: float = 25.0

Average speed the competition requires [km/h].

mass(engine_mass_kg)[source]

Total moving mass with a given engine [kg].

Return type:

float

resistance(speed, total_mass)[source]

Road-load force at a speed [N].

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

C_rr m g + 0.5 rho C_d A v^2 [N].

class exlink.vehicle.RangeResult(distance_per_joule, km_per_litre, average_speed, burn_fraction, burn_distance, coast_distance, total_mass, engine_mass, brake_power, brake_efficiency, feasible, reason='')[source]

Bases: object

Outcome of a burn-and-coast range calculation.

distance_per_joule: float

Distance covered per joule of fuel energy [m/J].

km_per_litre: float

The competition’s figure of merit [km/L].

average_speed: float

Mean road speed over the burn-and-coast cycle [km/h].

burn_fraction: float

Share of the cycle distance covered under power [-].

burn_distance: float

Distance covered under power, one cycle [m].

coast_distance: float

Distance covered coasting, one cycle [m].

total_mass: float

Vehicle mass including engine and driver [kg].

engine_mass: float

Engine mass [kg].

brake_power: float

Brake power at the chosen operating point [W].

brake_efficiency: float

Brake work over fuel energy [-].

feasible: bool

Whether the engine can drive the car and meet the speed rule.

reason: str = ''

Why not, when feasible is false.

exlink.vehicle.brake_efficiency(brake_work, heat_release)[source]

Brake thermal efficiency from one cycle’s works.

Parameters:
  • brake_work (float) – Work leaving the crankshaft per cycle [N.mm].

  • heat_release (float) – Heat added per cycle [N.mm].

Return type:

float

Returns:

W_brake / Q, zero when either is non-positive.

exlink.vehicle.heat_release(dead_volume, p_compression_end, p_combustion, heat_capacity_ratio)[source]

Heat added by the constant-volume combustion step [N.mm].

\[Q = \frac{V_0 (P_3 - P_2)}{\gamma - 1}\]

This, not an assumed air-fuel ratio, is what fixes the fuel consumed: it is the only heat input the idealised cycle has, so deriving the fuel from it keeps the range model consistent with the thermodynamics rather than bolting a second, independent combustion model on the side.

Parameters:
  • dead_volume (float) – Clearance volume V_0 [mm^3].

  • p_compression_end (float) – P_2 [MPa].

  • p_combustion (float) – P_3 [MPa].

  • heat_capacity_ratio (float) – gamma.

Return type:

float

Returns:

Q per cycle [N.mm].

exlink.vehicle.burn_and_coast(vehicle, engine_mass_kg, brake_power, efficiency, speed_low, speed_high, samples=201)[source]

Range under a burn-and-coast strategy.

Parameters:
  • vehicle (Vehicle) – The car.

  • engine_mass_kg (float) – Engine mass [kg].

  • brake_power (float) – Brake power at the operating point [W].

  • efficiency (float) – Brake thermal efficiency [-].

  • speed_low (float) – Speed the coast ends at [m/s].

  • speed_high (float) – Speed the burn ends at [m/s].

  • samples (int) – Quadrature points per phase.

Return type:

RangeResult

Returns:

The range result. Check RangeResult.feasible.

exlink.vehicle.top_speed(vehicle, engine_mass_kg, brake_power, ceiling=40.0)[source]

The speed at which traction and road load balance [m/s].

A burn phase cannot be asked to reach beyond this, and the burn-and-coast window is therefore bounded by it rather than by an arbitrary constant.

Parameters:
  • vehicle (Vehicle) – The car.

  • engine_mass_kg (float) – Engine mass [kg].

  • brake_power (float) – Brake power [W].

  • ceiling (float) – Upper bracket for the search [m/s].

Return type:

float

Returns:

The balance speed, or 0 when the engine cannot move the car at all.

exlink.vehicle.best_strategy(vehicle, engine_mass_kg, brake_power, efficiency, window=(2.0, 25.0), steps=24)[source]

Choose the burn-and-coast window that maximises range.

The structure of this sub-problem is worth stating, because it makes the search both fast and smooth.

Range always improves as the whole speed window comes down: rolling resistance is speed-independent and aerodynamic drag is not, so the cheapest way to cover a metre is to cover it slowly. What stops the optimizer driving the window to zero is the competition’s minimum average speed, and that constraint is therefore active at every optimum where the engine has power to spare.

So the two-dimensional search collapses to one dimension. For each v_low, the rule pins v_high exactly – average speed is monotone in v_high, so a bisection finds it – and what remains is a scalar minimisation over v_low, trading a narrow window at a higher mean speed against a wide one swinging around a lower mean.

Solving the active constraint rather than sampling a grid matters for more than speed. A grid makes the objective a step function of the design variables, and a step function has no useful finite difference; the gradient-based optimizer downstream would be differentiating quantisation noise. Here the objective is smooth.

Parameters:
  • vehicle (Vehicle) – The car.

  • engine_mass_kg (float) – Engine mass [kg].

  • brake_power (float) – Brake power [W].

  • efficiency (float) – Brake thermal efficiency [-].

  • window (tuple[float, float]) – Speeds to search between [m/s].

  • steps (int) – Scalar-search resolution over v_low.

Return type:

RangeResult

Returns:

The best feasible result, or the least-bad infeasible one – never an exception, so that an optimizer can walk through infeasible ground.

The whole chain, from eleven linkage dimensions to kilometres per litre.

This module does no physics of its own. It composes the seven that do, in the one order the couplings allow, and returns a single object holding every intermediate so that a result can be interrogated rather than just believed.

Design (11 vars) + speed + gear module
    |
    v
kinematics --> Atkinson cycle --> gas force
    |                                 |
    |          +----------------------+
    v          v
  [ dynamics <===> sizing ]        <-- MDA: neither runs first
    |
    +--> friction  -----> brake work --> brake efficiency --.
    |                                                        |
    +--> mass budget ---> engine mass ---------------------. |
              ^                                            v v
    H, B, torque ripple ------------------------------>  RANGE

The one-way parts are ordinary function calls. The two-way part – sections set masses, masses set inertia loads, loads set sections – is the fixed point in exlink.coupled, and everything downstream of it depends on it having converged.

Why range is the right objective

Every earlier formulation of this problem had to stop short of a single number. Efficiency alone is maximised by an unboundedly large mechanism. Adding H and B as competing objectives bounds it, but only produces a front, because nothing in the problem says what a millimetre of height is worth in points of efficiency. Mass alone is minimised by a mechanism that produces no torque.

Range prices all of them, in units the competition actually scores, and the prices are not adjustable:

  • a point of brake efficiency is worth a fixed number of kilometres, through the fuel burnt per unit of work;

  • a millimetre of H or B is worth a fixed number of grams of crankcase, and a gram is worth a fixed number of kilometres through rolling resistance;

  • a newton-millimetre of torque ripple is worth a fixed number of grams of flywheel, on the same terms.

That is what makes this a multidisciplinary design problem rather than a multi-objective one.

class exlink.performance.Performance(design, speed_rpm, analysis, coupled, friction, budget, range, heat_release, buildable_diameters, stock_premium, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Bases: object

Everything known about one design, at one operating point.

design: Design
speed_rpm: float
analysis: Analysis
coupled: CoupledResult | None

The sizing fixed point, or None if the kinematics rejected the design.

friction: FrictionLosses | None

The loss breakdown, or None for a design that was never sized.

budget: MassBudget
range: RangeResult
heat_release: float

Heat added per cycle [N.mm].

buildable_diameters: dict[str, float]

Member diameters rounded up to stock sizes [mm].

stock_premium: float

Fractional mass added by that rounding [-].

spec: EngineSpec = EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659)

Fixed engine data, kept so the output shaft can be reported.

property metrics: Metrics

The geometric metrics of the design.

property km_per_litre: float

The objective [km/L].

property engine_mass_kg: float

Total engine mass [kg].

property output_speed_rpm: float

Speed of the shaft power is taken from [rev/min].

Twice speed_rpm, which is the half-speed shaft the kinematics are parametrised on. The cycle spans 720 deg of the output shaft, as it does on a conventional four-stroke, so this is the speed to quote and the speed to compare at.

property cycles_per_minute: float

Power strokes per minute [1/min], equal to speed_rpm.

property output_torque: float

Mean torque at the output shaft [N.mm].

property brake_power: float

Brake power at this speed [W].

One cycle per turn of theta_1, hence one per two turns of the output shaft; reading the speed at either shaft gives the same power.

property indicated_efficiency: float

Indicated work over heat released [-].

property brake_efficiency: float

Brake work over heat released [-].

property geometrically_feasible: bool

Whether the design meets the geometric constraint set.

Kept separate from feasible because the two ask different questions and conflating them is how a violating design gets reported as a result: an engine can run perfectly well, and go a long way, while sitting outside the specification it was supposed to meet.

property feasible: bool

Whether this is a design that both works and meets its brief.

Every discipline has to return something usable – the kinematics closes, the sizing settles, the engine produces net work, the gears fit, the car meets the speed rule – and the geometric constraints have to hold. Leaving that last clause out reports designs that run but miss the stroke, the compression ratio, the top-dead-centre gap or the side-load limit, which is not a result.

reason()[source]

Why the design is not feasible, or an empty string.

Return type:

str

exlink.performance.evaluate(design, speed_rpm=1500.0, vehicle=None, module=None, teeth=None, samples=360, fluctuation=0.1, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), friction_scale=1.0, **kwargs)[source]

Run the full chain for one design.

Parameters:
  • design (Design) – The mechanism dimensions.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • vehicle (Vehicle | None) – The car; a default Prototype-class entry if omitted.

  • module (float | None) – Gear module [mm]; the lightest workable standard one if omitted.

  • teeth (int | None) – Teeth on the small gear; derived from I if omitted.

  • samples (int) – Crank angles per revolution.

  • fluctuation (float) – Allowed cyclic speed fluctuation, for the flywheel.

  • spec (EngineSpec) – Fixed engine data.

  • material (Material) – Structural material.

  • safety (SafetyFactors) – Design factors.

  • friction_scale (float) – Multiplier on both Coulomb coefficients. Exists so that exlink.uncertainty can propagate friction scatter, which is not a design variable and not a constant either.

  • **kwargs (Any) – Forwarded to exlink.coupled.solve_coupled().

Return type:

Performance

Returns:

The full result. Check Performance.feasible and Performance.reason() – an unanalysable design comes back as an infeasible Performance, never as an exception, so that an optimizer can keep walking.

exlink.performance.speed_sweep(design, speeds=(600.0, 800.0, 1000.0, 1250.0, 1500.0, 2000.0, 2500.0, 3000.0), **kwargs)[source]

Evaluate one design across crankshaft speeds.

Speed is the sharpest single variable in the coupled problem, and it is the one the geometric formulation could not see at all. It pulls two ways:

  • up, because the flywheel inertia needed for a given speed fluctuation goes as 1 / omega^2, so a faster engine needs a dramatically lighter flywheel – often the heaviest item in the budget;

  • down, because every inertia load goes as omega^2, and the sections that carry them, and hence the structural mass, follow.

The optimum sits where those two cross, and it is not near either end.

Parameters:
  • design (Design) – The mechanism dimensions.

  • speeds (tuple[float, ...]) – Crankshaft speeds to evaluate [rev/min].

  • **kwargs (Any) – Forwarded to evaluate().

Return type:

list[Performance]

Returns:

One Performance per speed, in the order given.

Study of the result

Whether the optimum survives manufacturing tolerance – of the dimensions, and then of everything else – how strongly coupled the problem actually is, whether it survives being asked to run at more than one speed, and whether the finding generalises past one linkage.

What manufacturing tolerance does to a design that sits on a knife edge.

The central finding of the coupled study is that the quasi-statically optimal mechanism sits at the transmission-angle singularity. That is a statement about conditioning, and a design chosen for its nominal performance in a badly conditioned region is exactly the design a tolerance study exists to catch. Presenting a deterministic optimum here without one would be negligent: the whole point of the finding is that small perturbations matter enormously.

Two propagation methods, and why both

First order, from the exact Jacobians. For a constraint g(X) and independent dimensional errors with covariance Sigma,

\[\sigma_g^2 = \nabla g^T \Sigma \nabla g\]

Because exlink.jacobian already supplies \nabla g analytically, a full robustness assessment costs one extra Jacobian evaluation. The exact derivatives built to make the optimizer work turn out to pay for the uncertainty analysis as well – the same object serves both.

Monte Carlo, to check the first order is not lying. Linearisation is exactly what should be distrusted near a singularity, where the constraint surfaces curve sharply. A sample confirms or refutes the linear estimate, and the comparison between them is itself a measure of how nonlinear the design point is.

Where the tolerances come from

ISO 286 IT grades, not invented numbers. The standard tolerance unit is

\[i = 0.45 \sqrt[3]{D} + 0.001 D \quad [\mu m]\]

for a nominal size D in millimetres, and grade IT_n is a fixed multiple of it (16i at IT7, 25i at IT8, 40i at IT9). A machined linkage member is an IT8 part; holding IT7 costs real money and IT9 is loose for a bearing centre.

Angular variables are assembly rather than machining quantities – theta_f and theta_r are set by how the gears are clocked – so they get a fixed angular band instead.

What the answer turns out to be

The top-dead-centre gap constraint is g <= 0.01 mm. The dimensions that produce g are held to about +/- 0.02 mm each at IT8. The constraint is therefore tighter than the tolerance of the parts that produce it, and no amount of optimization fixes that: it is a specification defect, not a design one. tolerance_report() quantifies it, and it is the strongest argument in this package for treating g as a quantity to be minimised – or adjusted at assembly with a shim – rather than bounded.

exlink.robustness.IT_FACTORS: dict[int, float] = {6: 10.0, 7: 16.0, 8: 25.0, 9: 40.0, 10: 64.0, 11: 100.0}

Multiples of the standard tolerance unit i for each ISO 286 IT grade.

exlink.robustness.DEFAULT_GRADE = 8

IT grade a machined linkage member is normally held to.

exlink.robustness.ANGULAR_TOLERANCE = 0.05

Half-width of the assembly tolerance on the two clocking angles [deg].

exlink.robustness.EQUALITY_BAND: dict[str, float] = {'compression_ratio': 0.05, 'expansion_stroke': 0.05}

Design band each equality is really held to.

STE = 74 and epsilon = 16 are written as equalities, but no part is made to an exact dimension, so a perturbation analysis that scores |residual| > 0 as a violation would report 100 % failure for any design whatsoever and say nothing. The equalities are scored against the same half-widths the optimizer relaxes them to, which is what a drawing would actually call out.

exlink.robustness.SIGMA_PER_HALF_WIDTH = 3.0

Standard deviations spanned by a tolerance half-width.

A +/- t drawing callout is read as 3 sigma, i.e. a process capability of Cp = 1. Reading it as 1 sigma would be pessimistic and as 6 sigma optimistic; this is the conventional middle.

exlink.robustness.CONSTRAINT_NAMES: tuple[str, ...] = ('expansion_stroke', 'compression_ratio', 'rod_angle', 'compatibility', 'tdc_gap', 'clearance', 'side_load')

Names of the constraints, in the order the vectors below use.

exlink.robustness.tolerance_unit(size)[source]

ISO 286 standard tolerance unit i [mm].

Parameters:

size (float) – Nominal size D [mm].

Return type:

float

Returns:

i = (0.45 D^(1/3) + 0.001 D) in millimetres.

exlink.robustness.tolerance_half_widths(design, grade=8, angular=0.05)[source]

Tolerance half-width on each design variable.

Parameters:
  • design (Design) – The nominal design.

  • grade (int) – ISO 286 IT grade for the dimensional variables.

  • angular (float) – Half-width on the two clocking angles [deg].

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

One half-width per entry of VARIABLE_NAMES, in that variable’s own unit (mm, or degrees for the angles).

exlink.robustness.covariance(design, grade=8, angular=0.05)[source]

Diagonal covariance of the manufacturing errors.

Errors on separate features are taken independent, which is the right default for parts made on separate operations and is conservative for the two angles, whose errors would partly cancel if they were clocked together.

Parameters:
  • design (Design) – The nominal design.

  • grade (int) – ISO 286 IT grade.

  • angular (float) – Angular half-width [deg].

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

An (11, 11) diagonal covariance matrix.

class exlink.robustness.ToleranceReport(design, grade, half_widths, nominal, linear_sigma, monte_carlo_sigma, violation_rate, any_violation_rate, unbuildable_rate, samples)[source]

Bases: object

How a design stands up to the tolerances of the parts that make it.

design: Design
grade: int
half_widths: ndarray[tuple[Any, ...], dtype[float64]]

Tolerance half-width on each design variable.

nominal: dict[str, float]

Constraint value at the nominal design; negative is satisfied.

linear_sigma: dict[str, float]

First-order standard deviation of each constraint.

monte_carlo_sigma: dict[str, float]

Sampled standard deviation of each constraint.

violation_rate: dict[str, float]

Fraction of samples violating each constraint.

any_violation_rate: float

Fraction of samples violating at least one constraint.

unbuildable_rate: float

Fraction of samples the kinematics could not close at all.

samples: int
capability()[source]

One-sided process capability Cpk of each constraint.

Cpk = -g_nominal / (3 sigma): how many three-sigma widths of margin the nominal design holds. Below 1 the constraint is violated by ordinary manufacturing variation; below 0 the nominal design is already outside. A value of 1.33 is the usual industrial target.

Return type:

dict[str, float]

linearity_error()[source]

Ratio of first-order to sampled sigma, per constraint.

Far from 1 means the design point is strongly nonlinear over its own tolerance band – which is precisely the symptom of sitting near a singularity, and a warning that a first-order robust formulation would be optimistic there.

Return type:

dict[str, float]

exlink.robustness.tolerance_report(design, grade=8, angular=0.05, samples=2000, crank_samples=720, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), seed=0)[source]

Propagate manufacturing tolerance through the constraints, two ways.

Parameters:
  • design (Design) – The nominal design.

  • grade (int) – ISO 286 IT grade for the dimensional variables.

  • angular (float) – Angular half-width [deg].

  • samples (int) – Monte Carlo sample count.

  • crank_samples (int) – Crank angles per revolution in each analysis.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • seed (int) – Random seed, so the report is reproducible.

Return type:

ToleranceReport

Returns:

The report.

Raises:

ValueError – If the nominal design cannot be analysed.

exlink.robustness.format_report(report)[source]

Render a ToleranceReport as an aligned table.

Return type:

str

exlink.robustness.required_grade(design, constraint='tdc_gap', target_capability=1.33, crank_samples=720, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

The loosest IT grade at which a constraint is actually holdable.

Constraint sigma is linear in the tolerance factor, so the whole grade ladder can be scanned from one Jacobian. Answering “which grade would fix this?” is what turns a robustness finding into an engineering decision: if the answer is a grade a machine shop can hold, tighten the drawing; if it is off the bottom of the ladder, the constraint itself is the problem.

Parameters:
  • design (Design) – The nominal design.

  • constraint (str) – Which of CONSTRAINT_NAMES to assess.

  • target_capability (float) – Cpk to reach; 1.33 is the usual target.

  • crank_samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

tuple[int | None, float]

Returns:

(grade, factor_needed). The grade is the loosest listed one that reaches the target, or None when even the tightest does not – in which case factor_needed says how far below the ladder the requirement sits, and the constraint has to be met by adjustment at assembly rather than by machining.

Raises:

ValueError – If the nominal design does not satisfy the constraint, or cannot be analysed.

exlink.robustness.CONSTRAINT_ROWS_FOR_RELIABILITY: dict[str, str] = {'compatibility': 'compatibility_margin', 'ratio_lower_1': 'compression_ratio_1', 'ratio_lower_2': 'compression_ratio_2', 'ratio_upper_1': 'compression_ratio_1', 'ratio_upper_2': 'compression_ratio_2', 'rod_angle': 'rod_angle_margin', 'side_load': 'side_load_margin', 'stroke_lower_1': 'expansion_stroke_1', 'stroke_lower_2': 'expansion_stroke_2', 'stroke_upper_1': 'expansion_stroke_1', 'stroke_upper_2': 'expansion_stroke_2', 'tdc_gap': 'tdc_gap_margin'}

Which Jacobian row each reliability constraint is built from.

The two strokes appear once per top dead centre, and that is not a refinement – it is the difference between an estimate that agrees with sampling and one that is optimistic by a factor of seven.

The expansion stroke is \(\max(\lambda_{tdc,1}, \lambda_{tdc,2}) - \min\lambda\), a maximum of two smooth functions, and a maximum is differentiable only away from the tie. Linearising it uses the branch that attains the maximum at the nominal design; the parts, whose dimensions scatter by some 7 um, straddle a tie of 0.107 um and breach whichever branch is nearer. At RELIABLE_DESIGN the attaining branch has \(\beta = 3.00\) and the other \(\beta = 2.36\), and it is the second that decides: sampling 150 000 exact builds gives \(9.3\times10^{-3}\), against \(1.3\times10^{-3}\) from the attaining branch alone and \(9.1\times10^{-3}\) from the other (§5.3).

Carrying both is also the right probability statement for the upper bounds, not merely a safe one: the realised stroke exceeds its bound when either branch does, which is a union, and a union over correlated normals is exactly what the orthant integral of failure_probability() computes. For the two lower bounds the honest statement is an intersection rather than a union, so carrying both there is conservative; both sit above \(\beta = 10\) at every design in this study, so the conservatism costs nothing and is left in rather than special-cased.

exlink.robustness.TARGET_FAILURE_PROBABILITY = 0.001

Default system probability of failure to design to.

A design target, not a fact about the parts: it says how often a nominally-conforming build may miss any of its requirements. 1e-3 corresponds to a reliability index of about 3.1 on a single constraint, which is the usual structural-design level.

class exlink.robustness.ConstraintMoments(names, value, sigma, correlation)[source]

Bases: object

First-order moments of every constraint under manufacturing scatter.

names: tuple[str, ...]
value: ndarray[tuple[Any, ...], dtype[float64]]

Nominal constraint value; negative is satisfied.

sigma: ndarray[tuple[Any, ...], dtype[float64]]

Standard deviation induced by the tolerances.

correlation: ndarray[tuple[Any, ...], dtype[float64]]

Correlation between constraints, (n, n).

Non-trivial and the whole reason a system probability differs from the product of the individual ones: every constraint is a function of the same eleven dimensions, so their errors are strongly dependent. The stroke and compression-ratio residuals in particular move together.

property beta: ndarray[tuple[Any, ...], dtype[float64]]

Reliability index -g / sigma of each constraint.

Large and positive is safe. This is the first-order (FORM) index: the distance, in standard deviations, from the nominal design to the constraint surface along the direction the gradient says is steepest.

exlink.robustness.constraint_moments(design, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None)[source]

Constraint values, standard deviations and correlations, from the exact Jacobians.

First-order propagation: with Sigma the covariance of the dimensional errors,

\[\sigma_i = \sqrt{\nabla g_i^\top \Sigma \nabla g_i}, \qquad \rho_{ij} = \frac{\nabla g_i^\top \Sigma \nabla g_j} {\sigma_i \sigma_j}\]

The correlation matters. Every constraint here is a function of the same eleven dimensions, so their scatter is strongly dependent, and treating them as independent – which is what applying a fixed margin to each one separately amounts to – overstates the chance that some constraint fails.

Parameters:
  • design (Design) – The design to assess.

  • grade (int) – ISO 286 IT grade of the machined dimensions.

  • angular (float) – Angular assembly half-width [deg].

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

ConstraintMoments | None

Returns:

The moments, or None if the design cannot be analysed.

exlink.robustness.constraint_jacobian(design, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None)[source]

The eight geometric constraints and their exact derivatives.

Split out of constraint_moments() so that exlink.uncertainty, which reaches the other five constraints by finite differences, can still take these columns analytically. Where an exact derivative exists there is no reason to difference it, and the two models then agree on their overlap by construction rather than to within a step size.

Parameters:
  • design (Design) – The design.

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]] | None

Returns:

(value, jacobian) of shapes (8,) and (8, 11), or None if the design cannot be analysed.

class exlink.robustness.Reliability(moments, per_constraint, system, independent_bound)[source]

Bases: object

Probability that a nominally-conforming build misses its requirements.

moments: ConstraintMoments
per_constraint: dict[str, float]

First-order failure probability of each constraint, Phi(-beta).

system: float

Probability that any constraint fails, with the correlation kept.

independent_bound: float

The same quantity assuming the constraints are independent.

Reported beside system because the gap between them is the price of the approximation a per-constraint margin makes.

property system_beta: float

The system reliability index, -Phi^-1(P_f).

binding()[source]

The constraint contributing most of the failure probability.

Return type:

str

exlink.robustness.failure_probability(design, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None)[source]

Probability of failure by FORM, with the constraint correlation kept.

This replaces the fixed-margin formulation g + k sigma <= 0 that an earlier version of this module used, and the reason is worth stating.

A margin of k standard deviations on each constraint separately is a reliability statement only if the constraints are independent. They are emphatically not: every one of them is a function of the same eleven dimensions, and ConstraintMoments.correlation measures pairs at 0.94 and at exactly -1. Requiring all eight to hold at three sigma simultaneously is therefore much stronger than requiring the system to be reliable at three sigma, and it buys that strength by refusing designs that are in fact acceptable.

What is computed instead is the thing actually wanted:

\[P_f = 1 - \Phi_n(\beta; \rho), \qquad \beta_i = -g_i / \sigma_i\]

the probability that any constraint is missed, from the multivariate normal orthant with the correlation in place. Phi_n is evaluated by SciPy’s implementation of Genz’s algorithm, which is randomised quasi-Monte Carlo: repeated calls agree to about seven significant figures rather than exactly, so anything comparing two evaluations should allow for that.

First order means the constraint surfaces are linearised, which is the same approximation the exact Jacobians already make cheap, and the same one tolerance_report() checks against sampling.

Parameters:
  • design (Design) – The design to assess.

  • grade (int) – ISO 286 IT grade of the machined dimensions.

  • angular (float) – Angular assembly half-width [deg].

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

Reliability | None

Returns:

The reliability, or None if the design cannot be analysed.

exlink.robustness.reliability_from_moments(moments)[source]

The orthant integral itself, given the moments.

Split out from failure_probability() so that the widened uncertainty model of exlink.uncertainty – which reaches its moments a different way, and prices five more constraints – goes through exactly this integral rather than a second implementation of it.

Parameters:

moments (ConstraintMoments) – Constraint values, standard deviations and correlations.

Return type:

Reliability

Returns:

The system and independent probabilities, and the per-constraint ones.

exlink.robustness.format_reliability(reliability, target=0.001)[source]

Render a Reliability as an aligned table.

Return type:

str

exlink.robustness.required_bound(design, constraint='tdc_gap', target=0.001, grade=8, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

The bound a constraint needs so its failure probability meets the target.

Inverts the first-order reliability relation: for a target p, the nominal value must sit beta = -Phi^-1(p) standard deviations inside the bound, so the bound has to be at least current + beta * sigma above the nominal quantity.

This answers the practical question the tolerance study raises and cannot settle – how much would the specification have to give? – with a number rather than “more”.

Parameters:
  • design (Design) – The design to assess.

  • constraint (str) – Which of RELIABILITY_NAMES to invert.

  • target (float) – Failure probability to design to.

  • grade (int) – ISO 286 IT grade.

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

float

Returns:

The required right-hand side, in the constraint’s own units.

Raises:

ValueError – If the design cannot be analysed.

class exlink.robustness.FailureProbabilityDiscipline(grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), step=1e-05, name='')[source]

Bases: Discipline

System probability of failure, as a GEMSEO constraint output.

Publishes the FORM estimate so an optimizer can be given P_f <= target – a single, correlation-aware reliability requirement – instead of a fixed margin on each constraint separately.

First order is what makes this affordable inside an optimization: it costs one Jacobian evaluation of a geometric analysis, so it can be evaluated at every iteration. Its error against sampling is real and is documented on failure_probability(); the sampling estimate is the reference, not this.

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

Reliability against more than the dimensions.

exlink.robustness propagates ISO 286 dimensional tolerances through the geometric constraints, exactly, using the analytic Jacobians. That is the right thing to have inside an optimizer – it costs one Jacobian and no extra analyses – but it leaves five of the thirteen constraints without a probability, and §6.2 records why: they are not functions of the eleven dimensions alone. saturation, slenderness and bearing depend on the material the members are made of; runs depends on how much of the indicated work friction takes; and all of them depend on how hard the charge burns.

This module widens the uncertain vector to seventeen entries – the eleven dimensions plus six parameters that are not design variables and are not constants either – and prices every constraint against all of them.

What the six parameters are, and why these values

Coefficients of variation, taken as the JCSS Probabilistic Model Code and the usual structural-reliability literature give them for hot-rolled steel, with two that are this problem’s rather than the material’s:

parameter

CoV

why

yield strength

0.07

JCSS steel; the dominant material scatter

ultimate strength

0.05

correlated with yield in reality; see below

Young’s modulus

0.03

JCSS; tight, and it only enters buckling

density

0.01

tighter still; a bar is a bar

friction coeff.

0.20

§6.2 puts the FMEP uncertainty near 30 %; this is the part of it attributable to mu alone

explosion ratio

0.10

cycle-to-cycle variation of a single-cylinder engine, plus calibration of an idealised burn

Taking the two strengths independent is conservative: on a real heat they move together, and a build with a low yield usually has a low ultimate, so the joint probability of both being bad is smaller than the product used here.

Why finite differences here and analytic Jacobians there

Four of the five newly covered constraints are outputs of the sizing/inertia fixed point, and one is an output of the vehicle sub-problem beyond it. There is no closed form for d(bearing load)/d(yield strength) that does not amount to differentiating the whole MDA, so this module differences it: one full evaluation per uncertain entry, eighteen in all. That is far too expensive to put inside an optimizer, which is exactly why the fast analytic path in exlink.robustness still exists and is still what the RBDO loop calls. This is the wider, slower picture the fast one is checked against.

The step sizes matter and are not uniform: a dimensional step is absolute (microns, matched to the tolerance being propagated) and a parameter step is relative (a fraction of the nominal), because the two have no common scale.

exlink.uncertainty.MAX_SLENDERNESS = 0.34

Largest d / L a connecting link may reach before it is not a rod.

exlink.uncertainty.SATURATION_FRACTION = 0.98

Fraction of MAX_DIAMETER counted as run away.

exlink.uncertainty.MAX_WIDTH_FACTOR = 12.0

Largest gear face width as a multiple of the module.

exlink.uncertainty.COUPLED_NAMES: tuple[str, ...] = ('saturation', 'slenderness', 'bearing', 'runs', 'gear')

The five constraints the dimensional-only model cannot price.

exlink.uncertainty.WIDENED_NAMES: tuple[str, ...] = ('rod_angle', 'compatibility', 'tdc_gap', 'side_load', 'stroke_upper_1', 'stroke_upper_2', 'stroke_lower_1', 'stroke_lower_2', 'ratio_upper_1', 'ratio_upper_2', 'ratio_lower_1', 'ratio_lower_2', 'saturation', 'slenderness', 'bearing', 'runs', 'gear')

Every constraint, in the order this module’s vectors use.

The geometric ones first, in RELIABILITY_NAMES order – which since §5.3 carries the two strokes once per top dead centre – then the five that only exist once the loads are dynamic and the car is in the problem.

class exlink.uncertainty.ParameterScatter(yield_strength=0.07, ultimate_strength=0.05, youngs_modulus=0.03, density=0.01, friction=0.2, explosion_ratio=0.1)[source]

Bases: object

Coefficients of variation of the six non-dimensional uncertainties.

yield_strength: float = 0.07
ultimate_strength: float = 0.05
youngs_modulus: float = 0.03
density: float = 0.01
friction: float = 0.2
explosion_ratio: float = 0.1
as_array()[source]

The six, in the order PARAMETER_NAMES uses.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.uncertainty.DEFAULT_SCATTER = ParameterScatter(yield_strength=0.07, ultimate_strength=0.05, youngs_modulus=0.03, density=0.01, friction=0.2, explosion_ratio=0.1)

The scatter the study reports against.

exlink.uncertainty.UNCERTAIN_NAMES: tuple[str, ...] = ('a', 'c', 'I', 'x_b', 'y_b', 'x_1', 'e', 'q_1', 'q_2', 'theta_f', 'theta_r', 'yield_strength', 'ultimate_strength', 'youngs_modulus', 'density', 'friction', 'explosion_ratio')

The seventeen uncertain entries, dimensions first.

exlink.uncertainty.constraint_vector(performance, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), band=None)[source]

Every constraint of one evaluation, negative meaning satisfied.

The eight of RELIABILITY_NAMES in their usual form, then the five that only exist once the loads are dynamic and the car is in the problem. The two sign conventions of exlink.scenarios are reconciled here: everything below is a violation when positive.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]] | None

Returns:

The constraint vector, or None if the design was never sized.

exlink.uncertainty.widened_moments(design, grade=8, angular=0.05, scatter=ParameterScatter(yield_strength=0.07, ultimate_strength=0.05, youngs_modulus=0.03, density=0.01, friction=0.2, explosion_ratio=0.1), speed_rpm=1500.0, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), band=None, relative_step=0.001, **kwargs)[source]

First-order moments of every constraint, over all seventeen inputs.

Differences one full evaluation per uncertain entry, so it costs eighteen analyses – seconds, not milliseconds. See the module docstring for why that is the right trade here and the wrong one inside an optimizer.

Parameters:
  • design (Design) – The design to assess.

  • grade (int) – ISO 286 IT grade of the machined dimensions.

  • angular (float) – Angular assembly half-width [deg].

  • scatter (ParameterScatter) – Coefficients of variation of the six parameters.

  • speed_rpm (float) – Analysis speed [rev/min].

  • samples (int) – Crank angles per revolution. The study’s 360, not less: the expansion stroke and the top-dead-centre gap are differences of nearby extrema, and their analytic gradients move by tens of per cent between 180 and 360 stations. Eighteen analyses at 360 cost about a minute, which is the price of the two constraints that bind agreeing with section 6.2.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • material (Material) – The nominal material.

  • safety (SafetyFactors) – The design factors.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

  • relative_step (float) – Differencing step on the six parameters, as a fraction.

  • **kwargs (Any) – Forwarded to exlink.performance.evaluate().

Return type:

tuple[ConstraintMoments, VarianceShares] | None

Returns:

The moments over every constraint and the split of each one’s variance by source, or None if the nominal design cannot be analysed or sized.

class exlink.uncertainty.VarianceShares(names, sources, shares)[source]

Bases: object

Where each constraint’s variance comes from.

names: tuple[str, ...]

Constraint names, as WIDENED_NAMES.

sources: tuple[str, ...]

("dimensions", *PARAMETER_NAMES) – seven sources, not seventeen.

The eleven dimensions are pooled, because they are one modelling decision (an ISO grade) and are reported as one in section 3.10. The six parameters are kept apart, because the point of widening the model is to find out which of them matters.

shares: ndarray[tuple[Any, ...], dtype[float64]]

(n_constraints, 7), each row summing to one where sigma is positive.

dominant()[source]

The largest source of each constraint’s variance.

Return type:

dict[str, str]

exlink.uncertainty.format_shares(shares)[source]

Render a VarianceShares as an aligned percentage table.

Return type:

str

exlink.uncertainty.widened_reliability(design, **kwargs)[source]

The system failure probability over every constraint.

Parameters:
Return type:

Reliability | None

Returns:

The reliability, or None if the design cannot be assessed.

Checking the first-order reliability estimate against sampling.

Why the check is needed

Every probability in this study comes from FORM: the constraint surfaces are linearised at the nominal design and the failure probability is read off a multivariate-normal orthant (§4.5). That is what makes the estimate affordable enough to sit inside an optimizer – one exact Jacobian per design point – and it is an approximation whose error is not bounded by anything the estimate itself reports. §6.2 lists it as a limitation and §6.3 asks for a sampling check.

This module is that check. It evaluates the exact constraints on random builds drawn from the same covariance and counts how many miss, so the number it produces makes no linearity assumption at all.

Why crude Monte Carlo is not enough on its own

The study’s headline is \(P_f = 1.3\times10^{-3}\) and §5.2 reaches \(1.9\times10^{-5}\). Crude sampling resolves a probability \(p\) to a relative standard error of \(\sqrt{(1-p)/(np)}\), so ten per cent on \(10^{-5}\) needs \(10^{7}\) analyses – six hours, to check a number that took milliseconds. Crude sampling is therefore offered for the range where it works, and the tail is reached by importance sampling.

How the importance sampling is set up

Work in the standard normal space \(u\), where a build is \(X = X_0 + \Sigma^{1/2} u\) and \(\Sigma\) is diagonal. For a linearised constraint, the nearest failure point to the origin – the most probable point – is

\[u^\star_i = \beta_i \alpha_i, \qquad \alpha_i = -\frac{\Sigma^{1/2}\nabla g_i}{\sigma_i},\]

and the failure region lies beyond it. Sampling instead from \(\mathcal{N}(u^\star, I)\) puts most of the draws where failures actually are, and unbiasing the count with the likelihood ratio

\[w(u) = \frac{\varphi(u)}{\varphi(u - u^\star)} = \exp\bigl(-u^\star\!\cdot u + \tfrac12 |u^\star|^2\bigr)\]

recovers the true probability. The shift is chosen at the constraint with the smallest \(\beta\), which is where the failures are; the estimator stays unbiased whatever shift is used, so a poor choice costs variance rather than correctness – and the reported standard error says when that has happened.

Note what is and is not checked. The design point comes from the linearised constraint, but every draw is evaluated exactly, so a curved constraint surface shows up in the answer. What is assumed is the input distribution: normal errors of the stated width on each dimension, independent.

exlink.sampling.DEFAULT_DRAWS = 20000

Draws for the importance-sampled estimate.

Enough for a few per cent on a probability anywhere from 1 to 1e-8, because the shifted density puts a large fraction of the draws in the failure region whatever its size.

class exlink.sampling.SampledReliability(names, per_constraint, system, standard_error, draws, unbuildable, importance, effective_draws)[source]

Bases: object

A failure probability estimated by sampling the exact constraints.

names: tuple[str, ...]
per_constraint: dict[str, float]

Probability that each constraint alone is missed.

system: float

Probability that any constraint is missed.

standard_error: float

Standard error of system.

draws: int
unbuildable: int

Draws whose kinematics did not close; counted as failures.

importance: bool

Whether the estimate is importance sampled or crude.

effective_draws: float

Kish effective sample size, (sum w)^2 / sum w^2.

For a crude estimate this equals draws. For an importance-sampled one it is the number of independent draws the weighted sample is worth, and a value far below draws means the shift was badly chosen and the estimate is noisier than its count suggests.

property interval: tuple[float, float]

Approximate 95 % interval on system, clipped to [0, 1].

agrees_with(form, factor=3.0)[source]

Whether a FORM estimate sits within factor of this one.

A factor rather than an interval, because the two are compared over orders of magnitude and the question asked of FORM is whether it has the right size, not the right third digit.

Parameters:
  • form (float) – The first-order estimate.

  • factor (float) – Ratio counted as agreement.

Return type:

bool

Returns:

Whether they agree.

exlink.sampling.sampled_reliability(design, draws=20000, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None, seed=0, importance=True)[source]

Estimate the system failure probability by sampling exact builds.

Parameters:
  • design (Design) – The nominal design.

  • draws (int) – Number of builds to draw.

  • grade (int) – ISO 286 IT grade of the machined dimensions.

  • angular (float) – Angular assembly half-width [deg].

  • samples (int) – Crank angles per revolution in each analysis.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

  • seed (int) – Random seed, so the estimate is reproducible.

  • importance (bool) – Shift the sampling density to the most probable failure point. Leave it on unless the point of the run is to check the importance sampling itself against crude sampling.

Return type:

SampledReliability | None

Returns:

The estimate, or None if the nominal design cannot be analysed.

exlink.sampling.format_sampled(sampled, form=None)[source]

Render a SampledReliability as an aligned table.

Return type:

str

Scoring a design over a schedule of operating points, not one.

Everything in sections 6.1 to 6.4 is reported at a single crankshaft speed with a sweep around it, and section 7.2 lists that as a limitation: an optimum found at one point may be an artefact of the point. This module scores a design over a weighted schedule instead, and the aggregation is where the interest is.

Why a weighted harmonic mean

The figure of merit is distance per unit fuel, so fuel per unit distance is what adds. Over a schedule whose points carry distance shares w_i,

\[\frac{1}{R} = \sum_i \frac{w_i}{R_i}, \qquad \sum_i w_i = 1\]

which is the harmonic mean weighted by distance, not the arithmetic mean of the R_i. The difference is not cosmetic: the arithmetic mean flatters a design whose range collapses at one point, and a schedule exists precisely to catch that.

Why one engine, not one per point

Scoring each point with exlink.performance.evaluate() and averaging is wrong, and wrong in the optimistic direction, because it lets the engine change from point to point: the sections shrink at the point where the inertia is small and the flywheel shrinks at the point where the speed is high. A real engine is one engine.

The two ends of the schedule size different parts of it, and they pull opposite ways:

  • the fastest point sizes the structure. Every inertia load grows as the square of speed and the sections follow, so the members, bearings and case must be those of the highest speed in the schedule.

  • the slowest point sizes the flywheel. The rotating inertia needed to hold a given speed fluctuation goes as the inverse square of speed, so the wheel is largest at the bottom of the schedule – and on this engine it is frequently the heaviest single item.

score_cycle() therefore takes the structure from the fastest point and the flywheel requirement from whichever point demands most, assembles one engine from the two, and scores every point with that mass. Sizing the structure at the top of the schedule is exact in this model rather than conservative: the flywheel is concentric with its shaft, so fitting a larger one adds no inertia force and cannot feed back into the member loads (exlink.dynamics states the same for shafts and gears).

class exlink.drivecycle.CyclePoint(crankshaft_rpm, share)[source]

Bases: object

One operating point of a schedule.

crankshaft_rpm: float

Speed of the shaft power is taken from [rev/min].

share: float

Share of the run’s distance covered at this point [-].

property speed_rpm: float

The analysis speed, half the crankshaft’s [rev/min].

class exlink.drivecycle.DriveCycle(points, name='')[source]

Bases: object

A weighted schedule of operating points.

points: tuple[CyclePoint, ...]
name: str = ''
normalised()[source]

The same schedule with the shares summing to one.

Return type:

DriveCycle

property fastest: CyclePoint

The point that sizes the structure.

exlink.drivecycle.STANDARD_CYCLE = DriveCycle(points=(CyclePoint(crankshaft_rpm=1600.0, share=0.2), CyclePoint(crankshaft_rpm=2000.0, share=0.4), CyclePoint(crankshaft_rpm=2400.0, share=0.25), CyclePoint(crankshaft_rpm=2800.0, share=0.15)), name='four-point schedule about the design speed')

The schedule the study reports against.

A competition run is not held at one operating point: gradient, wind and traffic move the power the car needs, and the driver’s burn-and-coast rhythm moves with it. Without a surveyed track there is no defensible way to derive the weights, so they are stated as an assumption rather than computed – a spread from 0.8 to 1.4 times the design speed, weighted towards it. What the schedule is for is not a more accurate number but a test: a design whose range holds across it was not an artefact of the point it was found at.

class exlink.drivecycle.CycleRow(point, km_per_litre, brake_power, brake_efficiency, feasible, reason='')[source]

Bases: object

What one point of the schedule contributed.

point: CyclePoint
km_per_litre: float
brake_power: float
brake_efficiency: float
feasible: bool
reason: str = ''
class exlink.drivecycle.CycleResult(cycle, rows, engine_mass_kg, flywheel_kg, sizing_rpm)[source]

Bases: object

A design scored over a schedule.

cycle: DriveCycle
rows: tuple[CycleRow, ...]
engine_mass_kg: float

Mass of the one engine every point was scored with [kg].

flywheel_kg: float

The flywheel inside that mass [kg], sized by the slowest point.

sizing_rpm: float

Crankshaft speed the structure was sized at [rev/min].

property km_per_litre: float

Distance-weighted harmonic mean of the per-point ranges [km/L].

property feasible: bool

Whether the engine drives the car at every point of the schedule.

property spread: float

Worst point’s range over the best point’s [-].

One near unity says the design is not specific to the point it was found at; one well below says it is.

exlink.drivecycle.score_cycle(design, cycle=DriveCycle(points=(CyclePoint(crankshaft_rpm=1600.0, share=0.2), CyclePoint(crankshaft_rpm=2000.0, share=0.4), CyclePoint(crankshaft_rpm=2400.0, share=0.25), CyclePoint(crankshaft_rpm=2800.0, share=0.15)), name='four-point schedule about the design speed'), vehicle=None, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), **kwargs)[source]

Score one design over a schedule, with one engine.

Parameters:
  • design (Design) – The mechanism.

  • cycle (DriveCycle) – The schedule; shares are normalised on the way in.

  • vehicle (Vehicle | None) – The car; a default Prototype-class entry if omitted.

  • spec (EngineSpec) – Fixed engine data.

  • **kwargs (Any) – Forwarded to exlink.performance.evaluate(), so the gear pair can be pinned as every run in the study pins it.

Return type:

CycleResult

Returns:

The per-point rows and the aggregate.

exlink.drivecycle.score_slidercrank_cycle(mechanism, cycle=DriveCycle(points=(CyclePoint(crankshaft_rpm=1600.0, share=0.2), CyclePoint(crankshaft_rpm=2000.0, share=0.4), CyclePoint(crankshaft_rpm=2400.0, share=0.25), CyclePoint(crankshaft_rpm=2800.0, share=0.15)), name='four-point schedule about the design speed'), vehicle=None, samples=360, **kwargs)[source]

Score a conventional engine over the same schedule, the same way.

The baseline of section 6.3 is optimised at one speed exactly as the EX-link is, so it inherits the same objection and has to answer it the same way. The mechanics are identical – one engine, structure from the fastest point, flywheel from whichever point demands most – with the one difference that the slider-crank’s crankshaft is its analysis shaft, so the schedule’s speeds are used unhalved.

Parameters:
Return type:

CycleResult

Returns:

The per-point rows and the aggregate.

exlink.drivecycle.format_cycle(result)[source]

Render a CycleResult as an aligned table.

Return type:

str

How strong is the coupling, and does the formulation choice matter?

Two questions a reviewer of any MDO study asks, and neither is answered by asserting that a problem is “strongly coupled”.

How strong is the coupling?

The sizing/dynamics loop is solved by Gauss-Seidel, and a Gauss-Seidel iteration converges linearly with a factor that is the coupling strength:

\[\rho = \lim_{k \to \infty} \frac{\|r_{k+1}\|}{\|r_k\|}\]

rho near 0 means the disciplines barely see each other and a single sequential pass would have done; rho near 1 means each discipline substantially rewrites the other’s input and the fixed point is doing real work; rho >= 1 means no fixed point exists at all – the mechanism cannot be built to survive its own inertia.

coupling_strength() measures it from the residual history the solve already records, at no extra cost. Because the loop gain scales with the inertia forces, rho is a function of engine speed, and reporting rho(omega) is the honest way to say how coupled this problem is: not very, at rest; decisively, at speed.

Does the formulation matter?

MDF converges the MDA at every optimizer iteration, so every point it evaluates is physically consistent, and the optimizer sees a small design space (the 11 linkage variables). IDF hands the coupling variables to the optimizer as extra design variables with consistency constraints, so no inner iteration runs at all, but the design space grows by the 7 diameters and 7 equality constraints come with them.

The textbook trade is that IDF wins when the MDA is expensive and loses when the coupling variables are numerous. Here the MDA is expensive, so the trade looks open – until the coupling is counted.

It is not close. The strong couplings are diameters and piston_mass, which are small, and member_axial and member_bending, which are not scalars at all: they are the internal load history of every member, at every crank angle, at every station along it. That is 45 367 coupling scalars against 11 design variables. IDF would carry all of them in the design space with a matching consistency constraint each, to optimise eleven real degrees of freedom.

So the formulation question has a decisive answer on this problem, and it is structural rather than a matter of timing: IDF is not slower here, it is unavailable. coupling_dimension() reports the count and compare_formulations() runs both anyway, because an attempt that fails for a stated reason is a better answer than an assertion.

The general lesson is worth separating from this mechanism. IDF’s cost scales with the dimension of the coupling, and a discipline pair that exchanges distributed fields – load histories, pressure distributions, temperature fields – rather than a handful of scalars will always sit on the wrong side of that trade, however expensive its MDA.

One asymmetry is worth stating in advance, because it is a property of this problem rather than of the formulations in general: intermediate IDF iterates are not physically consistent, so any quantity read from one – the range, the mass budget, the friction – is meaningless until the consistency constraints are satisfied. With MDF, every iterate is a real engine.

class exlink.formulations.CouplingStrength(speed_rpm, rho, sweeps, converged, history=<factory>)[source]

Bases: object

Observed contraction of the sizing/dynamics fixed point.

speed_rpm: float
rho: float

Asymptotic residual ratio per sweep; the coupling strength.

sweeps: int

Sweeps the solve took.

converged: bool
history: list[float]
property sweeps_per_decade: float

How many sweeps the loop needs to gain one digit.

A compact way to read rho: 1 sweep per decade is a nearly decoupled problem, 20 is a strongly coupled one.

property descriptor: str

A word for how coupled this is, on the usual reading of rho.

exlink.formulations.coupling_strength(design, speed_rpm=1500.0, tail=5, **kwargs)[source]

Measure the coupling strength from the fixed point’s own residuals.

The ratio is taken over the last few sweeps, where the iteration has settled into its asymptotic linear rate; the early sweeps are contaminated by the starting guess and would understate it.

Parameters:
  • design (Design) – The mechanism dimensions.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • tail (int) – Sweeps at the end of the history to average the ratio over.

  • **kwargs (Any) – Forwarded to solve_for_design().

Return type:

CouplingStrength

Returns:

The measurement. A design whose loop runs away comes back with rho >= 1 rather than raising.

exlink.formulations.coupling_curve(design, speeds=(0.0, 250.0, 500.0, 750.0, 1000.0, 1250.0, 1500.0), **kwargs)[source]

Coupling strength against engine speed.

The point of the curve is that the answer to “is this problem coupled?” is it depends on the operating point, and the dependence is steep. At rest the loop gain is zero – there are no inertia forces, so the sections do not feed back into the loads at all, and the quasi-static problem of the geometric formulation is recovered exactly. The gain grows with the inertia, i.e. with omega^2.

Parameters:
Return type:

list[CouplingStrength]

Returns:

One measurement per speed.

class exlink.formulations.FormulationResult(name, objective, design, evaluations, disciplinary_calls, seconds, feasible, error='')[source]

Bases: object

What one formulation cost and what it found.

name: str
objective: float

Best objective value reached.

design: Design | None
evaluations: int

Objective evaluations the optimizer performed.

disciplinary_calls: int

Total discipline executions, MDA sweeps included.

The fair cost measure: MDF hides its work inside the MDA, and counting only optimizer iterations would flatter it.

seconds: float
feasible: bool
error: str = ''
exlink.formulations.compare_formulations(initial=None, objective='total_mass', speed_rpm=1500.0, max_iter=25, relative=0.2, algorithm='SLSQP', names=('MDF', 'IDF'), **kwargs)[source]

Run the same coupled problem under each formulation and compare.

Parameters:
  • initial (Design | None) – Starting design; defaults to the refined reference.

  • objective (str) – Output to minimise.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • max_iter (int) – Evaluation budget for each run.

  • relative (float) – Half-width of the design box, as a fraction of |X_0|.

  • algorithm (str) – A single-objective GEMSEO optimizer.

  • names (tuple[str, ...]) – Formulations to compare.

  • **kwargs (Any) – Forwarded to the scenario builder.

Return type:

list[FormulationResult]

Returns:

One result per formulation, in the order given. A formulation that fails outright is reported as a result carrying its error, not raised: a failure is a comparison outcome.

exlink.formulations.format_coupling(measurements)[source]

Render a coupling curve as an aligned table.

Return type:

str

exlink.formulations.coupling_dimension(samples=None, stations=None)[source]

How many scalars the two coupled disciplines exchange.

This is the number that decides the formulation question, and it is not close. The strong couplings here are diameters, piston_mass and – dominating everything – member_axial and member_bending, which are not scalars but the internal load history of every member, at every crank angle, at every station along it.

IDF puts every coupling variable into the design space with a matching consistency constraint. With 11 real degrees of freedom and tens of thousands of coupling scalars, that trade is not merely unfavourable, it is unavailable: the optimizer would carry four orders of magnitude more variables than the problem has.

Parameters:
  • samples (int | None) – Crank angles per revolution; the package default if omitted.

  • stations (int | None) – Sections along each member; the package default if omitted.

Return type:

dict[str, int]

Returns:

{variable: size} plus "total" and "design_variables".

exlink.formulations.motion_harmonics(design, tolerances=(0.1, 0.01, 0.001), samples=720)[source]

How many Fourier harmonics reproduce the piston motion, per RMS tolerance.

Why this is the interesting number

coupling_dimension() counts the coupling pointwise and concludes that IDF is unavailable. That conclusion is a property of the parameterisation, not of the physics, and this function is the measurement that shows it.

The linkage’s eleven dimensions reach the rest of the model through exactly one quantity: the piston motion lam(theta). The cycle turns it into a volume and a pressure, the dynamics differentiate it twice for the inertia loads, the vehicle sees only the work that results. So lam is the coupling variable of the whole problem – and it is smooth and periodic, so counting it at 45 368 grid points overstates it by orders of magnitude.

In a Fourier basis the count is what this returns: of order twenty coefficients to reproduce the motion more tightly than the part can be machined. A decomposition on that basis would carry tens of consistency variables where the pointwise one carries tens of thousands. See Appendix C.7 of the documentation.

type design:

Design

param design:

The mechanism.

type tolerances:

Sequence[float]

param tolerances:

RMS errors to report, in millimetres, largest first.

type samples:

int

param samples:

Crank angles per revolution to analyse.

returns:

the fewest leading harmonics whose truncation error is below that RMS, one entry per requested tolerance.

rtype:

{tolerance: harmonics}

raises ValueError:

If the design cannot be analysed.

exlink.formulations.format_formulations(rows)[source]

Render a formulation comparison, with the coupling dimension that explains it.

Return type:

str

The mechanism the EX-link replaces, modelled on identical terms.

Every conclusion in this package so far is about one linkage. A reader is entitled to ask two questions that one linkage cannot answer.

Is the singularity finding general, or a quirk of this topology? The claim is that a mechanism optimised quasi-statically drifts to its transmission-angle singularity, because that is where the lever arm is longest – and that the same proximity is what amplifies the inertia loads, so the quasi-static optimum is the worst possible starting point once the parts have mass. If that is a real mechanism-design principle it should appear in the simplest possible linkage too, and here it does: a slider-crank’s lever arm also peaks as its connecting rod approaches alignment with the crank.

Is the extra complexity worth it at all? The EX-link buys extended expansion – the gas expands through a larger volume ratio than it was compressed through – and pays for it with seven members instead of two, seven journals instead of three, and a gear pair. The geometric formulation could not even pose that question: it has no way to price a member. With range as the objective it becomes a straight comparison, and the answer is not obvious in advance. Extended expansion is worth a few points of indicated efficiency; four extra journals and a gear train are worth a few points of mechanical efficiency in the other direction, plus their mass.

Both sides have to be optimised

A comparison between an optimised EX-link and a slider-crank whose proportions were written down from a textbook measures the optimization, not the topology, and it flatters the side that was searched. optimise_slidercrank() removes that asymmetry by maximising the conventional engine’s range over the two degrees of freedom it has – the rod length and the speed – under the same models. It is worth 7.4 % to the baseline, and it is the baseline the study’s headline comparison is made against.

Modelling parity

The comparison is only worth making if both mechanisms are treated identically, so the slider-crank goes through the same code wherever the code is not topology-specific: the same material and safety factors, the same yield, fatigue and buckling sizing in exlink.sizing, the same Coulomb friction coefficients from exlink.friction, the same crankcase, bearing, shaft and flywheel models from exlink.mass_budget, and the same vehicle and burn-and-coast strategy from exlink.vehicle. What differs is only what must: the kinematics, the equilibrium system, and the cycle.

The cycles line up, and that has to be stated because it is easy to assume otherwise. The EX-link completes all four strokes in one revolution of the shaft the kinematics are parametrised on – that is exactly what exlink.cycle.find_phases() requires of it – but power is taken from the other shaft, which the gear pair turns twice as fast (output_revolutions_per_cycle). A cycle is therefore 720 deg of the crankshaft on both mechanisms, so equal crankshaft speed is equal fuel per minute and the comparison carries no firing-rate correction of any kind.

exlink.slidercrank.STATIONS = 9

Sections evaluated along each member, matching the EX-link sizing.

class exlink.slidercrank.SliderCrank(crank, rod, offset=0.0)[source]

Bases: object

A conventional slider-crank, sized by its two lengths.

crank: float

Crank radius r [mm].

rod: float

Connecting-rod length l [mm].

offset: float = 0.0

Wrist-pin offset d from the crank axis [mm].

Zero is the centred slider-crank the study compares against. A non-zero offset – desaxe, in the older literature – is the cheapest extra degree of freedom a conventional engine has, and §5.4 uses it as a third point on the dimensionality axis §6.1’s explanation rests on. It does not make an Atkinson engine: the piston still makes two identical strokes per revolution, so the expansion and compression strokes stay equal and the offset buys only a rearranged side load and a slightly asymmetric velocity.

property stroke: float

Piston travel [mm].

2r when centred. With an offset the two dead centres move off the crank axis and the travel is the difference of the two extreme heights, which is slightly more than 2r.

property obliquity: float

r / l, the rod obliquity ratio.

The slider-crank’s own conditioning number. As it approaches 1 the rod aligns with the crank at some point in the revolution, the mechanism loses its ability to turn the gas force into torque there, and the joint forces diverge – the same phenomenon the EX-link’s W constraint guards against. Practical engines sit at 0.25 to 0.33.

classmethod for_compression_ratio(ratio, obliquity=0.3, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), offset=0.0)[source]

Build the slider-crank that realises a compression ratio.

With a fixed clearance volume, the compression ratio fixes the swept volume and hence the stroke; the rod length then follows from the chosen obliquity.

Parameters:
  • ratio (float) – Required compression ratio epsilon.

  • obliquity (float) – r / l.

  • spec (EngineSpec) – Fixed engine data.

  • offset (float) – Wrist-pin offset, as a fraction of the crank radius.

Return type:

SliderCrank

Returns:

The mechanism.

exlink.slidercrank.kinematics(mechanism, samples=720)[source]

Piston motion and rod angle over one crankshaft revolution.

\[\lambda(\theta) = r \cos\theta + \sqrt{l^2 - (r \sin\theta - d)^2}\]
Parameters:
  • mechanism (SliderCrank) – The slider-crank.

  • samples (int) – Crank angles per revolution.

Return type:

dict[str, ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

theta, lam, rod_angle, and the two joint trajectories.

exlink.slidercrank.otto_cycle(lam, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

The equivalent-Otto cycle on a slider-crank motion.

Compression and expansion through the same volume ratio – which is the whole difference from the Atkinson cycle, and the reason extended expansion is worth building a linkage for. The four strokes occupy two crankshaft revolutions, so the pressure is assigned over a doubled angle base and the work per revolution is half the work per cycle.

Parameters:
Return type:

dict[str, ndarray[tuple[Any, ...], dtype[float64]] | float]

Returns:

pressure, gauge_pressure, piston_force over two revolutions, plus p_compression_end, p_combustion, compression_ratio and indicated_work per cycle [N.mm].

exlink.slidercrank.SC_MEMBERS: tuple[tuple[str, str], ...] = (('crank', 'cantilever'), ('rod', 'link'))

The two sized members, and their Euler end-fixity kind.

class exlink.slidercrank.SliderCrankResult(mechanism, speed, diameters, member_mass, piston_mass, torque, reaction, liner_force, lam, gas_force, indicated_work, heat_release, peak_pressure, conditioning, converged)[source]

Bases: object

A sized, loaded slider-crank at one operating point.

mechanism: SliderCrank
speed: float

Crankshaft speed [rad/s].

diameters: dict[str, float]
member_mass: dict[str, float]

[tonne]

piston_mass: float

[tonne]

torque: ndarray[tuple[Any, ...], dtype[float64]]

Output torque over two crankshaft revolutions [N.mm].

reaction: dict[str, ndarray[tuple[Any, ...], dtype[float64]]]
liner_force: ndarray[tuple[Any, ...], dtype[float64]]
lam: ndarray[tuple[Any, ...], dtype[float64]]

Piston height over the two revolutions [mm].

gas_force: ndarray[tuple[Any, ...], dtype[float64]]

Gas force on the crown over the two revolutions [N].

indicated_work: float

Work per cycle, from the p-V loop [N.mm].

heat_release: float

Heat added per cycle [N.mm].

peak_pressure: float

Peak gauge pressure [MPa].

conditioning: float

Worst condition number of the 9x9 equilibrium matrix.

converged: bool
property mean_torque: float

Mean output torque over the full cycle [N.mm].

property peak_bearing_load: float

Largest main-journal reaction [N].

property height: float

Envelope along the stroke [mm].

property width: float

Envelope across the stroke [mm].

exlink.slidercrank.solve(mechanism, speed_rpm, samples=360, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), tolerance=1e-06, max_iterations=200, section=Section(bore_ratio=0.0, min_wall=1.5))[source]

Solve the slider-crank’s sizing/dynamics fixed point.

The same coupling as the EX-link’s and solved the same way – sections set masses, masses set inertia loads, loads set sections – so that the two mechanisms are compared under identical treatment rather than one being given a converged structure and the other an assumed one.

Parameters:
  • mechanism (SliderCrank) – The slider-crank.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • samples (int) – Crank angles per revolution.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • spec (EngineSpec) – Fixed engine data.

  • tolerance (float) – Convergence tolerance on the diameter change [mm].

  • max_iterations (int) – Sweep limit.

  • section (Section) – The member cross-section. The rod may be bored and the crank throw may not, which is the same rule exlink.sections applies to the linkage: a rod is tube, a crank throw is a forging.

Return type:

SliderCrankResult

Returns:

The converged result.

exlink.slidercrank.friction_work(result, journal_friction=0.008, piston_friction=0.1, ring_tension=30.0)[source]

Mechanical loss over one full cycle [N.mm].

Three journals instead of the EX-link’s seven, and no gear mesh. That is the slider-crank’s structural advantage, and it is exactly what has to be weighed against extended expansion.

Parameters:
  • result (SliderCrankResult) – A solved slider-crank.

  • journal_friction (float) – Coulomb coefficient at the journals.

  • piston_friction (float) – Coulomb coefficient at the liner.

  • ring_tension (float) – Ring-pack radial load [N].

Return type:

float

Returns:

Friction work per cycle [N.mm].

exlink.slidercrank.mass_budget(result, fluctuation=0.1, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

The slider-crank’s engine mass, on the EX-link’s terms.

Every item is computed by the same function exlink.mass_budget uses for the EX-link – shafts, bearings, crankcase, cylinder head, flywheel – so that the comparison is a comparison of mechanisms and not of modelling conventions. Two entries differ, and only because the mechanism does: there is no gear pair, and the flywheel has to carry the engine through two revolutions on one firing rather than one.

Parameters:
Return type:

MassBudget

Returns:

The itemised budget.

class exlink.slidercrank.Comparison(name, speed_rpm, indicated_efficiency, mechanical_efficiency, brake_efficiency, engine_mass, brake_power, km_per_litre, joints, members, feasible)[source]

Bases: object

One mechanism’s numbers, for the head-to-head table.

name: str
speed_rpm: float
indicated_efficiency: float
mechanical_efficiency: float
brake_efficiency: float
engine_mass: float

[kg]

brake_power: float

[W]

km_per_litre: float
joints: int
members: int
feasible: bool
exlink.slidercrank.evaluate_slidercrank(mechanism, speed_rpm, vehicle=None, samples=360, **kwargs)[source]

Run the slider-crank all the way to kilometres per litre.

Parameters:
  • mechanism (SliderCrank) – The slider-crank.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • vehicle (Vehicle | None) – The car; a default Prototype-class entry if omitted.

  • samples (int) – Crank angles per revolution.

  • **kwargs (object) – Forwarded to solve().

Return type:

Comparison

Returns:

The comparison row.

exlink.slidercrank.OBLIQUITY_BOUNDS = (0.12, 0.45)

Search bounds on r / l for the optimised baseline.

The upper end is where the rod is short enough that it approaches alignment with the crank – the slider-crank’s own transmission-angle singularity, and the same pathology exlink.metrics guards against with W. The lower end is where the rod is nearly four times the stroke: solve() still sizes it, but the engine is by then taller than the car, and the model prices rod length only through mass, not through the packaging that would really stop it. Practical engines occupy 0.25 to 0.33, comfortably inside.

exlink.slidercrank.SPEED_BOUNDS = (800.0, 3200.0)

Search bounds on crankshaft speed [rev/min] for the optimised baseline.

class exlink.slidercrank.OptimisedSliderCrank(mechanism, speed_rpm, comparison, evaluations, starts)[source]

Bases: object

The best conventional engine this model can build, and how it was found.

mechanism: SliderCrank
speed_rpm: float
comparison: Comparison
evaluations: int
starts: int
exlink.slidercrank.optimise_slidercrank(ratio=16.0, vehicle=None, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), obliquity_bounds=(0.12, 0.45), speed_bounds=(800.0, 3200.0), starts=4)[source]

Maximise a conventional engine’s range over the variables it has.

Why this exists

Comparing an optimised EX-link against a slider-crank whose proportions were written down by hand does not measure the topology; it measures the optimization. The honest comparison is optimum against optimum, and this is the other optimum.

What is free and what is not

The compression ratio is held at the value the EX-link is required to reach, so both engines trap the same charge in the same clearance volume and burn the same fuel per cycle. It is not made a design variable, because this model has no knock limit: left free it would rise without bound and the comparison would measure a missing sub-model rather than a mechanism. With the ratio fixed and the clearance volume fixed, the stroke follows, and the slider-crank has exactly two degrees of freedom left – the rod length, as the obliquity r / l, and the speed it is run at.

Why a derivative-free method is admissible here, having been rejected there

The EX-link’s feasible set is a manifold of measure zero, which is what excludes sampling methods from that problem. This one is a box in two variables: every point in it is feasible to evaluate, so Nelder-Mead is a reasonable choice and no gradient has to be derived for a baseline. The restarts are there because the objective is not concave – range rises with speed through the vehicle’s operating point and falls with it through friction and the inertia loads that set the mass.

type ratio:

float

param ratio:

Compression ratio to hold, matching the EX-link’s requirement.

type vehicle:

Vehicle | None

param vehicle:

The car; a default Prototype-class entry if omitted.

type samples:

int

param samples:

Crank angles per revolution.

type spec:

EngineSpec

param spec:

Fixed engine data.

type obliquity_bounds:

tuple[float, float]

param obliquity_bounds:

Search interval for r / l.

type speed_bounds:

tuple[float, float]

param speed_bounds:

Search interval for the speed [rev/min].

type starts:

int

param starts:

Nelder-Mead restarts, spread over the box.

rtype:

OptimisedSliderCrank

returns:

The best mechanism, its speed, and its scored row.

raises RuntimeError:

If no point in the box produced a feasible engine.

exlink.slidercrank.optimise_slidercrank_constrained(ratio=16.0, vehicle=None, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), obliquity_bounds=(0.12, 0.45), speed_bounds=(800.0, 3200.0), max_iterations=80)[source]

The same baseline, optimised the way the EX-link now is.

Why this exists alongside optimise_slidercrank()

A comparison is only worth making if both sides get the same treatment, and that has to include the method, not only the models. The EX-link’s best design comes from an SQP that holds every constraint at every step (§4.7); scoring the baseline with a derivative-free search that simply rejects infeasible points would leave the comparison measuring the optimizer again, which is the error optimise_slidercrank() was written to remove one level up.

So this poses the baseline the same way: maximise range subject to the constraints as constraints, by SLSQP. The slider-crank has two of them – the engine must produce net work, and the vehicle must meet the average speed rule – against the EX-link’s fourteen, because a slider-crank has one dead centre, no transmission angle to lose and no gear train.

What is not matched, and cannot be here

The EX-link’s final formulation also constrains a system reliability index over its eleven dimensions (§4.7). There is no equivalent for the slider-crank in this package: exlink.robustness builds its covariance and its constraint Jacobians for the EX-link’s design vector specifically. Constructing the analogue for a two-variable mechanism is tractable but is not the same model, and asserting a reliability comparison across two different uncertainty models would be worse than declining one. The comparison in §5.4 is therefore between two nominally optimised engines, and says so.

type ratio:

float

param ratio:

Compression ratio to hold, matching the EX-link’s requirement.

type vehicle:

Vehicle | None

param vehicle:

The car; a default Prototype-class entry if omitted.

type samples:

int

param samples:

Crank angles per revolution.

type spec:

EngineSpec

param spec:

Fixed engine data.

type obliquity_bounds:

tuple[float, float]

param obliquity_bounds:

Search interval for r / l.

type speed_bounds:

tuple[float, float]

param speed_bounds:

Search interval for the speed [rev/min].

type max_iterations:

int

param max_iterations:

SLSQP iteration budget.

rtype:

OptimisedSliderCrank

returns:

The best mechanism, its speed, and its scored row.

raises RuntimeError:

If no point in the box produced a feasible engine.

exlink.slidercrank.SPECIFICATION_OBLIQUITY_BOUNDS = (0.05, 0.45)

Search bounds on r / l when the EX-link’s own limits are imposed.

Wider at the lower end than OBLIQUITY_BOUNDS, because the side-load cap pushes the rod longer than the unconstrained optimum ever wants it, and a bound that excluded those proportions would decide the comparison by fiat.

exlink.slidercrank.CAP_SPEED_RPM = 1.0

Speed at which the side-load cap is evaluated [rev/min].

The EX-link’s own gamma <= 0.02 is a quasi-static quantity: it comes from exlink.loads, which propagates the gas load alone. Holding the baseline to the same specification therefore means the same definition, so the cap is evaluated with the inertia contribution negligible. Scoring it at the operating speed instead is a different, and materially looser, requirement – the reciprocating inertia opposes the gas side load over part of the cycle – and would flatter the baseline by about a tenth of its range.

exlink.slidercrank.side_load_ratio(mechanism, speed_rpm, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

gamma = max|D| / max|P|, the piston side-load ratio, at one speed.

The same definition exlink.metrics applies to the EX-link: a ratio of two maxima over the cycle, not the maximum of a ratio, and not the tan of the maximum rod angle – the gas force peaks at top dead centre where the rod is nearly upright and the side load is near zero. The speed is an argument because the answer depends on it: at CAP_SPEED_RPM the result is the quasi-static ratio the specification bounds, and at an operating speed it also carries the reciprocating inertia.

Parameters:
  • mechanism (SliderCrank) – The slider-crank.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

Return type:

float

Returns:

The ratio, or 1e3 if the model cannot be solved there.

exlink.slidercrank.optimise_slidercrank_to_specification(ratio=16.0, vehicle=None, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), obliquity_bounds=(0.05, 0.45), speed_bounds=(800.0, 3200.0), grid=(25, 13), refinements=24, cap_speed_rpm=1.0)[source]

Maximise the baseline’s range under the EX-link’s own geometric limits.

Why this exists

optimise_slidercrank() tests convergence, net work and the speed rule and nothing else, so its optimum sits at 11.2 deg of rod angle and a side-load ratio of 0.039 – roughly twice each of the caps the EX-link is held to. Quoting that engine against a linkage that had to meet those caps exempts one competitor from the other’s specification. This function imposes them on the baseline as well.

Which gamma

The quasi-static one, at CAP_SPEED_RPM, because that is the quantity the EX-link’s own cap is applied to. The distinction is not cosmetic: at r/l = 0.12 the ratio is 0.0248 quasi-statically and 0.0181 at 1500 rev/min, so scoring the cap at the operating speed would admit proportions the EX-link would not be allowed and hand the baseline about a tenth of a range it has not earned.

Why a grid rather than a descent

Range is not concave in the speed, and the cap binds on the obliquity axis, so the optimum sits against a boundary in one variable and at an interior maximum in the other. A grid over the box locates it without assuming either shape; two refinement passes then sharpen each axis by bisection, two because moving one axis moves the optimum of the other.

type ratio:

float

param ratio:

Compression ratio to hold, matching the EX-link’s requirement.

type vehicle:

Vehicle | None

param vehicle:

The car; a default Prototype-class entry if omitted.

type samples:

int

param samples:

Crank angles per revolution.

type spec:

EngineSpec

param spec:

Fixed engine data.

type targets:

DesignTargets

param targets:

Supplies max_rod_angle and max_side_load.

type obliquity_bounds:

tuple[float, float]

param obliquity_bounds:

Search interval for r / l.

type speed_bounds:

tuple[float, float]

param speed_bounds:

Search interval for the speed [rev/min].

type grid:

tuple[int, int]

param grid:

Obliquity and speed divisions of the initial scan.

type refinements:

int

param refinements:

Bisection steps per axis in each of the two refinement passes that follow the scan.

type cap_speed_rpm:

float

param cap_speed_rpm:

Speed at which the side-load cap is evaluated.

rtype:

OptimisedSliderCrank

returns:

The best mechanism meeting both caps, its speed, and its scored row.

raises RuntimeError:

If no point in the box meets both caps.

exlink.slidercrank.SLIDERCRANK_CONSTRAINTS: tuple[str, ...] = ('ratio_upper', 'ratio_lower', 'rod_angle', 'side_load')

The requirements a slider-crank actually has, in the order reported.

Four, against the EX-link’s seven, and the difference is the mechanism rather than the treatment. A slider-crank has one dead centre, so there is no top-dead-centre gap; its coupler cannot approach a transmission-angle singularity, so there is no compatibility condition; and its expansion and compression strokes are the same length by construction, so STE = 74 is not a requirement it can be asked to meet – that asymmetry is the whole reason the EX-link exists. What both must meet is the compression ratio, the rod-angle limit and the side-load limit, and those are what is compared.

class exlink.slidercrank.SliderCrankReliability(mechanism, grade, value, sigma, correlation, per_constraint, system, independent_bound)[source]

Bases: object

FORM reliability of a slider-crank, on the same terms as the EX-link’s.

mechanism: SliderCrank
grade: int
value: ndarray[tuple[Any, ...], dtype[float64]]

Constraint values, negative meaning satisfied.

sigma: ndarray[tuple[Any, ...], dtype[float64]]

First-order standard deviation of each constraint.

correlation: ndarray[tuple[Any, ...], dtype[float64]]
per_constraint: dict[str, float]
system: float

Probability that any constraint fails, correlation kept.

independent_bound: float
property beta: ndarray[tuple[Any, ...], dtype[float64]]

Reliability index of each constraint.

property system_beta: float

System reliability index.

binding()[source]

The constraint contributing most of the failure probability.

Return type:

str

exlink.slidercrank.slidercrank_reliability(mechanism, grade=8, band=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), step=0.0001)[source]

Probability that a built slider-crank misses one of its requirements.

The same method as exlink.robustness.failure_probability() – ISO 286 tolerances on the machined lengths, first-order propagation to each constraint, and the correlated multivariate-normal orthant for the system probability – applied to the baseline so that §5.4 compares reliability with reliability rather than reliability with silence.

What is the same, and what cannot be

Same: the IT grade, the sigma = half-width / 3 convention, the definitions of the compression ratio, the rod angle and the side-load ratio, and the orthant integral.

Not the same, and unavoidably so: the dimensions carrying the tolerance. The EX-link’s covariance is over eleven lengths and two clocking angles; a slider-crank has two lengths. A mechanism with fewer dimensions has fewer ways to be wrong, and a like-for-like reliability comparison between two mechanisms is therefore never wholly like-for-like. What the comparison does establish is each engine’s probability of missing its own requirements when machined to the same grade, which is the question a builder would ask.

Gradients are central differences on two variables, not the analytic Jacobians §4.2 needs for the EX-link. The reason §4.2 rejects differences is extremum switching, and it applies here too – gamma is a ratio of two maxima – so the step is checked against a tenth and ten times its value in the tests rather than assumed safe.

type mechanism:

SliderCrank

param mechanism:

The slider-crank.

type grade:

int

param grade:

ISO 286 IT grade of the machined lengths.

type band:

float

param band:

Half-width on the compression-ratio requirement.

type samples:

int

param samples:

Crank angles per revolution.

type targets:

DesignTargets

param targets:

Constraint right-hand sides.

type spec:

EngineSpec

param spec:

Fixed engine data.

type step:

float

param step:

Relative step for the central differences.

rtype:

SliderCrankReliability | None

returns:

The reliability, or None if the mechanism cannot be solved.

Prescribed-motion synthesis, and the range problem it grew into.

The formulation of exlink.scenarios asks for the best mechanism and lets the piston motion fall out of it. Kinematic synthesis conventionally does the opposite – it prescribes a target motion and fits the linkage to it:

\[\min_X\; J(X) = \sum_k \bigl(\lambda_k(X) - \lambda^\star_k\bigr)^2\]

This module started there and did not stay there, because each round of the exercise found the same thing: whatever constraint had been left out of the fit was the one the solve then violated. The entry points below are that argument in order.

Why a target helps at all

Both equality requirements are functionals of \(\lambda\) alone – \(STE\) is the span from top dead centre to the deeper bottom dead centre, and \(\varepsilon\) follows from the shallower one through the swept volume. So a target built to have the right two strokes satisfies both requirements before any linkage exists. target_motion() solves for one and describe_target() confirms it with the code the constraints use.

That makes the fit a sampling device. Uniform sampling of the design box finds a design on the equality manifold in 0 of 12 000 attempts, because the manifold has measure zero; sampling and then fitting reaches it in most attempts.

The entry points

fit_to_target()

Unconstrained least squares. Enough only when the target is reachable: against one that is not, it walks ten to fifteen units outside the geometric constraint set chasing a motion no buildable linkage produces.

fit_within_constraints()

The same fit subject to g(X) <= 0, and with hold_bands also subject to the tolerance bands. Absorbing the equalities into the target removed the measure-zero part of the feasible set, which is the only part the reformulation earns the right to drop; the inequalities define a full-dimensional set and cost one SQP instead of one least-squares solve.

maximise_range_from_target()

The endpoint, and no longer a synthesis problem: maximise the quantity the application scores, subject to all fourteen constraints, with the motion residual standing in wherever the range does not exist. Its docstring has the objective ladder and why a constant penalty will not do.

What the target does not buy

Reachability is a property of the mechanism, not of the target. A target can sit exactly on the equality manifold and still be unfittable, which is why target_from_design() exists: the obvious two-harmonic construction is one of those. And fitting to a single target returns a single linkage whatever start it is given, so diversity has to come from varying the target rather than the start.

exlink.synthesis.DEFAULT_TARGET_SAMPLES = 360

Crank angles used to represent a target motion.

class exlink.synthesis.TargetMotion(lam, expansion_stroke, compression_ratio, amplitude, skew)[source]

Bases: object

A piston motion prescribed by the two strokes it must realise.

lam: ndarray[tuple[Any, ...], dtype[float64]]

The target \(\lambda^\star(\theta)\) [mm].

expansion_stroke: float

STE the target was solved for [mm].

compression_ratio: float

epsilon the target was solved for.

amplitude: float
skew: float
property samples: int

Crank angles in the target.

exlink.synthesis.target_motion(expansion_stroke=74.0, compression_ratio=16.0, samples=360, offset=150.0, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Build a motion that realises the two equality requirements exactly.

The compression ratio fixes the compression stroke through the swept volume, STC = (epsilon - 1) V_0 / A_p; the two strokes then fix the two harmonic coefficients, and the solve is two equations in two unknowns.

Parameters:
  • expansion_stroke (float) – Required STE [mm].

  • compression_ratio (float) – Required epsilon.

  • samples (int) – Crank angles to represent the target on.

  • offset (float) – Mean height; irrelevant to both requirements, and removed before any comparison with a real mechanism.

  • spec (EngineSpec) – Fixed engine data.

Return type:

TargetMotion

Returns:

The target.

Raises:

ValueError – If no two-harmonic motion realises the pair.

exlink.synthesis.target_from_design(design, expansion_stroke=74.0, compression_ratio=16.0, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Build a target by correcting a real mechanism’s motion onto the manifold.

Why this exists, and why target_motion() is not enough

A two-harmonic target satisfies both equalities exactly but is not reachable: measured, the closest a seven-bar linkage gets to one is 1.16 mm RMS, and that residual carries the fitted design outside both tolerance bands. That is the attainability problem in its sharpest form – a target can be perfectly on the manifold and still be useless, because being on the manifold is a property of the target and being fittable is a property of the mechanism.

So this starts from a motion that is reachable by definition – one an actual design produces – and adds only the two harmonics needed to move its strokes onto the requirement. Every other harmonic is left as the mechanism made it, so the correction is small and the target stays near the reachable set.

type design:

Design

param design:

A design whose motion seeds the target.

type expansion_stroke:

float

param expansion_stroke:

Required STE [mm].

type compression_ratio:

float

param compression_ratio:

Required epsilon.

type samples:

int

param samples:

Crank angles to represent the target on.

type spec:

EngineSpec

param spec:

Fixed engine data.

rtype:

TargetMotion

returns:

The target.

raises ValueError:

If the seed design is unanalysable, or no correction in these two harmonics realises the pair.

exlink.synthesis.describe_target(target, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Measure a target with the same code the constraints use.

The point of the exercise is that the target satisfies the equalities as the model measures them, so it is measured by find_phases() and not by the formula it was constructed from.

Parameters:
Return type:

dict[str, float]

Returns:

expansion_stroke, compression_ratio and tdc_gap.

class exlink.synthesis.FitResult(design, rms, expansion_stroke, compression_ratio, stroke_error, ratio_error, converged, evaluations)[source]

Bases: object

One prescribed-motion fit, and how close it landed.

design: Design
rms: float

RMS of the motion residual [mm].

expansion_stroke: float
compression_ratio: float
stroke_error: float

|STE - target| [mm].

ratio_error: float

|epsilon - target|.

converged: bool
evaluations: int
exlink.synthesis.fit_to_target(target, start, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), max_evaluations=300, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Fit the linkage to a prescribed motion by least squares.

Uses Levenberg-Marquardt through a trust region (SciPy’s least_squares), which is the natural method once the objective is a sum of squares: the Gauss-Newton Hessian approximation comes free from the residual Jacobian.

Parameters:
  • target (TargetMotion) – The motion to fit.

  • start (Design) – Initial design.

  • bounds (Bounds) – Box to search in.

  • max_evaluations (int) – Residual evaluation budget.

  • spec (EngineSpec) – Fixed engine data.

Return type:

FitResult | None

Returns:

The fit, or None if the start is unanalysable.

exlink.synthesis.fit_within_constraints(target, start, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), max_iterations=120, hold_bands=False, band=0.05, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Fit to a prescribed motion subject to the inequality constraints.

Why this is the right form of the reformulated problem

fit_to_target() drops the constraints entirely, which is defensible only because the equalities were absorbed into the target. The inequalities were never the difficulty – they define a full-dimensional set – so discarding them buys nothing and can hand back a design that tracks the motion beautifully and is not buildable.

Keeping them costs almost nothing. The reformulation has already removed the measure-zero part of the feasible set, so what remains is an ordinary box-and-inequality NLP that SQP handles directly:

\[\min_X \; \lVert \lambda(X) - \lambda^\star \rVert^2 \quad \text{s.t.} \quad g(X) \le 0, \; X \in [X_{lb}, X_{ub}]\]

That is the difference between a generator of points near the manifold and a generator of points that are actually usable as starts.

hold_bands carries the same argument one step further. Against a target the mechanism cannot reach, holding g <= 0 and chasing the motion trade against each other, and the strokes drift out of the tolerance bands of §4.1. Those bands are constraints too, so they belong in the problem rather than being checked afterwards: with them added the solve either returns a design that is feasible and in band, or reports that it found none, which is the useful answer in both cases.

The same reasoning does not stop there. A fit that satisfies the geometric inequalities and the bands can still fail the coupled and vehicle constraints, which are not included here because each evaluation would then carry an MDA. Where that matters, they are the next constraints to add.

type target:

TargetMotion

param target:

The motion to fit.

type start:

Design

param start:

Initial design.

type bounds:

Bounds

param bounds:

Box to search in.

type max_iterations:

int

param max_iterations:

SLSQP iteration budget.

type hold_bands:

bool

param hold_bands:

Also require |STE - target| <= band and |epsilon - target| <= band rather than checking them after.

type band:

float

param band:

Half-width for those two, when hold_bands is set.

type targets:

DesignTargets

param targets:

Constraint right-hand sides.

type spec:

EngineSpec

param spec:

Fixed engine data.

rtype:

FitResult | None

returns:

The fit, or None if the start is unanalysable or SLSQP fails to return a design that is analysable at the end.

exlink.synthesis.CYCLE_PENALTY = 1000000.0

Objective value for a design whose motion is not an Atkinson cycle at all.

Worse than any range-unavailable value, so the search always climbs back to a mechanism that at least completes four strokes before it starts caring about how far the car goes.

exlink.synthesis.RANGE_UNAVAILABLE = 1000.0

Objective floor for a design that is analysable but produces no range.

Any computable range scores negative, so this is worse than every real design and better than a broken cycle – the middle rung of the ladder.

exlink.synthesis.MOTION_WEIGHT = 100.0

Weight on the motion residual when it stands in for the range [1/mm^2].

class exlink.synthesis.RangeFit(design, km_per_litre, rms, stroke_error, ratio_error, worst_constraint, feasible, fell_back, broken_cycle, evaluations, converged)[source]

Bases: object

One range-maximising solve guided by a target motion.

design: Design
km_per_litre: float
rms: float

Motion residual against the target [mm].

stroke_error: float
ratio_error: float
worst_constraint: float

Largest constraint value; <= 0 means every constraint holds.

feasible: bool

Feasible against the whole model, by exlink.performance.evaluate().

fell_back: int

Evaluations that scored on the target because the range was unavailable.

broken_cycle: int

Evaluations whose motion was not a four-stroke cycle.

evaluations: int
converged: bool
exlink.synthesis.NUMBER_OF_CONSTRAINTS = 14

Five geometric, four band sides, three coupled, two vehicle.

exlink.synthesis.maximise_range_from_target(target, start, speed_rpm=1000.0, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), max_iterations=120, band=0.05, beta_target=None, grade=8, module=None, teeth=None, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Maximise range under every constraint, with the target as a fallback.

The formulation

This is the whole problem of §4.7 with the prescribed motion carried along, rather than a synthesis problem that hands its answer to a separate optimization:

\[\max_X \; R(X) \quad \text{s.t.} \quad g(X) \le 0, \; |STE - 74| \le \delta, \; |\varepsilon - 16| \le \delta, \; X \in [X_{lb}, X_{ub}]\]

where \(g\) now carries all of it – the five geometric constraints, the three coupled ones and the two vehicle ones – because each previous round of this exercise found that whatever was left out was what the solve then violated.

Why the target does not simply disappear

The range is not computable everywhere. A design whose kinematics closes can still fail to size, fail to run, or fail to produce a motion that is a four-stroke cycle at all, and at such a point \(R\) has no value for a line search to descend. Deleting the target and penalising those points with a constant leaves the optimizer a flat, uninformative region to cross.

So the objective is a ladder, and the target holds the middle rung:

the design

scores

range computable

\(-R(X)\), the real objective

analysable, no range

RANGE_UNAVAILABLE plus the motion residual – the target takes over

motion is not a four-stroke cycle

CYCLE_PENALTY

Every rung is worse than the one above it, so the search is always pushed back towards designs that run; and on the middle rung it still has a gradient to follow, because tracking \(\lambda^\star\) is a proxy for getting back to a cycle that works.

type target:

TargetMotion

param target:

The motion to fall back on.

type start:

Design

param start:

Initial design.

type speed_rpm:

float

param speed_rpm:

Operating point for the range.

type bounds:

Bounds

param bounds:

Box to search in.

type max_iterations:

int

param max_iterations:

SLSQP iteration budget.

type band:

float

param band:

Half-width on the two stroke requirements.

type beta_target:

float | None

param beta_target:

When given, the system reliability index is constrained to >= beta_target, making this the reliability-based form of the problem. None leaves it deterministic.

type grade:

int

param grade:

ISO 286 IT grade assumed for the dimensions when beta_target is set; ignored otherwise.

type module:

float | None

param module:

Gear module to pin, or None to let the sizer choose.

type teeth:

int | None

param teeth:

Teeth on the small gear, or None.

type targets:

DesignTargets

param targets:

Constraint right-hand sides.

type spec:

EngineSpec

param spec:

Fixed engine data.

rtype:

RangeFit | None

returns:

The solve, or None if the final design is unanalysable.

exlink.synthesis.feasible_starts(target, attempts=20, seed=0, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), reference=None, scatter=0.25, max_evaluations=300, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Generate starting points by fitting random starts to the target.

This is the answer to §4.1’s measurement that uniform sampling finds 0 feasible designs in 12 000 draws. Sampling and then fitting is a different operation: the random draw only has to be analysable, and the fit carries it towards a motion that already satisfies both equalities.

Parameters:
  • target (TargetMotion) – The motion to fit to.

  • attempts (int) – Random starts to try.

  • seed (int) – Seed for the draws.

  • bounds (Bounds) – Box to search in.

  • reference (Design | None) – Centre of the draws; the draws are uniform over bounds when omitted, and log-scattered around this design when given.

  • scatter (float) – Relative scatter when reference is given.

  • max_evaluations (int) – Per-fit residual budget.

  • spec (EngineSpec) – Fixed engine data.

Return type:

list[FitResult]

Returns:

The fits that converged, best (smallest stroke error) first.

exlink.synthesis.fit_report(results, stroke_band=0.05, ratio_band=0.05)[source]

Render what a batch of fits achieved against the tolerance bands.

Parameters:
Return type:

str

Returns:

A table, one row per fit, with the in-band count.

Reaching the reliable region by walking to it.

The problem this solves

Section 3.10 records a failure that section 6.2 shows is expensive: from the deterministic optimum, SLSQP with a reliability constraint attached returns its starting point unchanged – not only for a demanding target, but for beta >= -0.2, a step of 0.17 from where it stands. Meanwhile sampling in a neighbourhood of that same point finds fully feasible designs that halve the failure probability at no cost in range. Better designs are there; descent cannot get to them.

The cause is not the reliability gradient, which is smooth. It is the thinness of the feasible set: a step of 0.05 mm along the normalised grad beta leaves the geometric constraint set entirely, so every direction that improves reliability also violates something, and the QP subproblem has nothing admissible to offer.

Why a continuation is the right instrument

The obstruction is local, and it is local to the target rather than to the problem: at beta_target = 3 nothing near the start is admissible, so the line search fails at the first step; at beta_target = -0.3 the current point is admissible and there is somewhere to go. Solving a sequence of problems whose targets rise from where the design already stands, warm-starting each from the last, replaces one impossible step with many possible ones.

That is a homotopy in the constraint level rather than in the objective, and the property it relies on is that the reliable set is connected to the starting point through the feasible set even though it is not reachable from it in one step. Whether that holds is exactly what this module measures; it is not assumed.

What it does not do

A continuation cannot cross a gap. If the admissible set at some rung is disconnected from the current point, the rung fails and every rung above it fails with it, and the run says so rather than reporting the last success as the answer.

What it did not do here

§5.5 measures this against a single solve at the same iteration budget and the ladder loses on both counts: 2060 evaluations against 1435, ending at min_i beta_i = 2.651 where the single solve reaches 3.000 exactly. The rungs behave as designed – each moves, and the climb is monotone in reliability while the range rises – but thirty iterations is not enough for any rung to converge, so the budget goes on restarting rather than arriving.

The instrument is not at fault; the obstruction is absent. The non-movement this module was written for was measured under the first of §4.7’s three forms, with the coupled and vehicle constraints bound only at the end. Under the second form, with the branch-aware constraint set §5.3 introduced, the search moves freely from the same start. Given a search that can move, subdividing its target only fragments its budget. The code is kept because the measurement is the finding, and because a problem whose feasible set is genuinely disconnected from its start would need exactly this.

exlink.continuation.DEFAULT_SCHEDULE: tuple[float, ...] = (0.0, 1.0, 2.0, 2.5, 3.0)

Reliability targets to climb, in order.

Coarse at the bottom, where the constraint is slack and each solve is a plain range maximisation, and fine at the top, where it binds and the admissible set is thin. The first rung is deliberately below the target most starts already meet, so that the first solve is a warm-up rather than a test.

exlink.continuation.MOVED_TOLERANCE = 1e-09

Relative design change below which a rung counts as not having moved.

exlink.continuation.CONSTRAINT_TOLERANCE = 1e-06

Largest constraint violation a rung may carry and still count as feasible.

SLSQP converges onto its active constraints, so a solution routinely sits a few parts in ten million outside one of them. Calling that a failure would report every successful rung as a failure.

class exlink.continuation.ContinuationStep(beta_target, design, km_per_litre, steered_beta, system_beta, system_pf, worst_constraint, feasible, strictly_feasible, moved, evaluations, converged)[source]

Bases: object

One rung of the ladder.

beta_target: float
design: Design
km_per_litre: float
steered_beta: float

min_i beta_i, the quantity the solve actually holds (§4.7).

system_beta: float

The system index, reported rather than steered.

system_pf: float
worst_constraint: float

Largest constraint value; <= 0 means every constraint holds.

feasible: bool

Whether every constraint holds at the bands this solve is using.

Not the same as strictly_feasible, and the distinction matters: §5.2 settles the study on a widened specification, and a rung solved against a band of 0.15 must be judged against 0.15. Scoring it against the specification as written would mark every rung a failure and say nothing about whether the continuation works.

strictly_feasible: bool

Whether it would also pass the specification as written.

moved: bool

Whether this rung changed the design at all.

A rung that does not move is the failure mode this module exists to diagnose: it means the line search found no admissible direction, which is what happened to the single-shot solve at every target.

evaluations: int
converged: bool
class exlink.continuation.Continuation(start, steps)[source]

Bases: object

A ladder of reliability targets, climbed or not.

start: Design
steps: tuple[ContinuationStep, ...]
property reached: bool

Whether the last rung was met, feasibly.

property best: ContinuationStep | None

The highest rung that was met, feasibly.

property evaluations: int

Total analyses across every rung.

exlink.continuation.reliability_continuation(target, start, schedule=(0.0, 1.0, 2.0, 2.5, 3.0), speed_rpm=1000.0, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), max_iterations=60, band=0.15, grade=8, module=None, teeth=None, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Climb a ladder of reliability targets, warm-starting each rung.

Parameters:
  • target (TargetMotion) – The fallback motion, as §4.7’s ladder needs.

  • start (Design) – Where to begin – typically a deterministic optimum.

  • schedule (tuple[float, ...]) – Reliability targets, ascending.

  • speed_rpm (float) – Analysis speed [rev/min].

  • bounds (Bounds) – The design box.

  • max_iterations (int) – SLSQP budget per rung, not in total.

  • band (float) – Half-width of the two relaxed equalities.

  • grade (int) – ISO 286 grade the probabilities assume.

  • module (float | None) – Gear module to pin [mm].

  • teeth (int | None) – Tooth count to pin.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

Continuation

Returns:

Every rung attempted, in order, whether or not it succeeded. A rung that fails does not stop the climb: the next is attempted from the same point, because a target that is unreachable in one step is sometimes reachable after the one above it has been tried and the design has drifted. What it does mean is that Continuation.reached is false unless the last rung was met.

exlink.continuation.format_continuation(continuation)[source]

Render a Continuation as an aligned table.

Return type:

str

Getting inside the feasible set before trying to optimise within it.

The measurement this answers

Appendix C reports that manifold-projected restarts reached feasibility in 0 of 6 attempts on the range problem at an affordable budget, which is why §4.6’s multistart is inconclusive there: it is not that the restarts found worse optima, it is that they never found the feasible set at all, so there was nothing to compare. §6.3 asks for a restoration phase before each restart.

The reason a restart lands outside is §4.1’s: the relaxed equalities make the feasible set a thin shell, and a design scattered from a good one by any useful amount is outside it. A range-maximising solve started there spends its whole budget on the constraint violation and has none left for the objective – and SLSQP in particular, which needs a feasible point to build a useful QP around, frequently never recovers.

The restoration problem

Separate the two jobs. Before optimising, solve

\[\max_{X,\,t} \; t \quad \text{s.t.} \quad c_i(X) \ge t, \; X \in [X_{lb}, X_{ub}]\]

with \(c\) the constraints on the “positive means satisfied” convention of exlink.synthesis._range_constraints(). The solution maximises the smallest margin, so it does not merely reach the feasible set but heads for its interior, which is where the next solve wants to start.

Why the epigraph form rather than a penalty

The natural objective is \(\min_i c_i(X)\), which is non-smooth wherever two constraints tie – and on this problem they tie constantly, because the two sides of each relaxed band are the same residual negated. Introducing the scalar \(t\) and moving the minimum into the constraints makes an eleven-variable non-smooth problem into a twelve-variable smooth one, at no cost in the answer. A weighted penalty would also be smooth but would need weights, and the weights are exactly what is not known before the run.

What restoration cannot do

It maximises a margin, so it stops at the interior point nearest its start. It does not search for the global feasible region, and if a restart is in a basin whose closest feasible point is poor, restoration will hand the optimizer a poor start – promptly, rather than after spending the whole budget failing. That is still the improvement worth having: it turns a run that reports nothing into one that reports something.

exlink.restoration.DEFAULT_MARGIN = 0.0001

Smallest margin counted as being inside rather than merely on the boundary.

Zero would accept a point sitting exactly on a constraint, which is the state §5.2 shows fails half the builds and §5.3 shows breaks the reliability estimate. A restoration that stops at the boundary has not done its job.

class exlink.restoration.Restoration(design, margin, start_margin, evaluations, converged)[source]

Bases: object

One restoration solve.

design: Design
margin: float

The smallest constraint margin reached; positive means feasible.

start_margin: float

What it was before, for comparison.

evaluations: int
converged: bool
property feasible: bool

Whether the point ended inside the feasible set.

property improved: bool

Whether the solve moved the margin in the right direction at all.

exlink.restoration.restore(target, start, bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), speed_rpm=1000.0, band=0.15, max_iterations=60, module=None, teeth=None, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Push a design into the interior of the feasible set.

Parameters:
  • target (TargetMotion) – Supplies the two equality targets the bands are taken about.

  • start (Design) – The design to restore, feasible or not.

  • bounds (Bounds) – The design box.

  • speed_rpm (float) – Analysis speed [rev/min].

  • band (float) – Half-width of the two relaxed equalities.

  • max_iterations (int) – SLSQP budget.

  • module (float | None) – Gear module to pin [mm].

  • teeth (int | None) – Tooth count to pin.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

Return type:

Restoration

Returns:

The restored design and the margin it reached.

exlink.restoration.format_restoration(results)[source]

Render a set of restorations as an aligned table.

Return type:

str

Second derivatives, and the measurement that they are the wrong instrument.

What this module was built for

§6.3 asked for \(\nabla^2 g\), for two reasons that look like one problem. The reliability estimate is first order, and where a constraint surface curves towards the design that under-predicts; and the derivative of \(\beta_i = -g_i/\sigma_i\) is a finite difference, because \(\partial\sigma_i/\partial x\) needs the Hessian. §5.3 had just measured the first-order estimate as optimistic by a factor of seven at the study’s result, so curvature was the obvious suspect.

What it found instead

The Hessian obtained by differencing the analytic gradient – which is the well-conditioned route, since the first derivatives are exact – does not exist. Its norm scales as \(1/h\) over two decades of step size and it is 100 % asymmetric at every step, which is the signature of a constant jump in the gradient rather than of a second derivative. A finer crank-angle grid does not change it, so it is not discretisation.

The jump is a branch switch. The expansion stroke is a maximum over the two top dead centres, and the design §5.5 arrives at has them 0.107 um apart, so perturbing any dimension by a fraction of a micron swaps which one attains the maximum and moves \(\partial STE/\partial a\) by a factor of 56. The surface is not curved, it is kinked, and no order of Taylor expansion repairs a kink. What was needed was not a second derivative but a first one, of the other branch – which is what exlink.robustness now carries.

So the contents here are a diagnostic rather than a correction: differencing_scaling() produces the table that establishes the noise floor, constraint_hessian() and hessian_asymmetry() are what it is built from, and second_order_reliability() applies Breitung’s curvature correction and reports – honestly – that its expansion does not hold at this design. beta_gradient() is the closed-form reliability derivative the entry also asked for; it is correct wherever the Hessian is, which is to say away from the tie, and is offered on that condition.

exlink.secondorder.DEFAULT_STEP_FRACTION = 0.05

Differencing step, as a fraction of each variable’s tolerance half-width.

Scaled to the tolerance rather than to the variable, because the derivative wanted is the one that governs behaviour over the scatter: a step much smaller than a micron differences numerical noise in the extremum refinement, and one much larger than the tolerance measures the wrong neighbourhood.

exlink.secondorder.MIN_CURVATURE_PRODUCT = 1e-06

Floor on 1 + beta kappa, below which Breitung’s formula has broken down.

The expansion is valid while every factor is positive. A curvature that drives one negative means the limit surface curves back on itself within a \(\beta\) of the design point, and the answer there is a sampled one, not a corrected first-order one.

class exlink.secondorder.SecondOrder(names, beta, first_order, second_order, curvature, valid, system, first_order_system, asymmetry)[source]

Bases: object

A reliability estimate corrected for constraint curvature.

names: tuple[str, ...]
beta: ndarray[tuple[Any, ...], dtype[float64]]

First-order index of each constraint.

first_order: dict[str, float]

Phi(-beta) for each constraint.

second_order: dict[str, float]

The same, with Breitung’s curvature correction.

curvature: dict[str, float]

the factor the correction applies.

Type:

prod (1 + beta kappa)^(-1/2)

valid: dict[str, bool]

Whether Breitung’s expansion held for that constraint.

system: float

System probability with every constraint corrected.

first_order_system: float

The uncorrected system probability, for comparison.

asymmetry: float

Largest relative asymmetry of any computed Hessian; the differencing error.

property ratio: float

How much the correction moved the system probability.

exlink.secondorder.constraint_hessian(design, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None, step_fraction=0.05)[source]

Second derivatives of the eight geometric constraints.

Parameters:
  • design (Design) – The design to differentiate at.

  • grade (int) – ISO 286 grade, which sets the differencing step.

  • angular (float) – Angular half-width [deg], likewise.

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

  • step_fraction (float) – Step as a fraction of each tolerance half-width.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]], float] | None

Returns:

(value, jacobian, hessian, asymmetry) of shapes (8,), (8, 11), (8, 11, 11) and a scalar, or None if the design cannot be analysed. The asymmetry is measured before symmetrising and is the honest error bar on the differencing.

exlink.secondorder.hessian_asymmetry(raw)[source]

Largest relative asymmetry in a computed Hessian.

The exact Hessian is symmetric, so |H - H^T| / |H| measures nothing but the differencing error and is the honest error bar on it.

Parameters:

raw (ndarray[tuple[Any, ...], dtype[float64]]) – (n, k, k) Hessians, before symmetrisation.

Return type:

float

Returns:

The largest relative asymmetry over the stack; zero for an empty one.

exlink.secondorder.second_order_reliability(design, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None, step_fraction=0.05)[source]

Correct the first-order probabilities for constraint curvature.

Parameters:
  • design (Design) – The design to assess.

  • grade (int) – ISO 286 grade of the machined dimensions.

  • angular (float) – Angular assembly half-width [deg].

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

  • step_fraction (float) – Differencing step, as a fraction of the tolerance.

Return type:

SecondOrder | None

Returns:

The corrected estimate, or None if the design cannot be analysed.

exlink.secondorder.beta_gradient(design, grade=8, angular=0.05, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), band=None, step_fraction=0.05)[source]

Exact derivative of each reliability index, (8, 11).

The quantity §4.7’s search is steered on is min_i beta_i, and until now its derivative was a finite difference of a difference. With the Hessian in hand it is closed form:

\[\frac{\partial \beta_i}{\partial x} = -\frac{\nabla g_i}{\sigma_i} + \frac{g_i}{\sigma_i^{3}} \nabla^2 g_i \Sigma \nabla g_i\]
Parameters:
  • design (Design) – The design to differentiate at.

  • grade (int) – ISO 286 grade.

  • angular (float) – Angular half-width [deg].

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • band (dict[str, float] | None) – Half-widths of the two relaxed equalities.

  • step_fraction (float) – Step for the Hessian.

Return type:

ndarray[tuple[Any, ...], dtype[float64]] | None

Returns:

d beta_i / d x_j, or None if the design cannot be analysed.

exlink.secondorder.differencing_scaling(design, rows=('stroke_error', 'expansion_stroke_1', 'expansion_stroke_2'), fractions=(0.5, 0.2, 0.1, 0.05, 0.02, 0.01), grade=8, angular=0.05, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Norm and asymmetry of the differenced Hessian, against step size.

The diagnostic of §5.3, and the reason it is worth having as a function rather than as a one-off script: a genuine second derivative is insensitive to the step over a range of steps and its matrix is symmetric, while a discontinuous gradient gives a norm proportional to 1/h and an asymmetry of one. Run on the three rows it defaults to, it separates the two cases side by side – stroke_error is the maximum over the two top dead centres and jumps, the two branch rows are smooth and do not.

Parameters:
  • design (Design) – The design to differentiate at.

  • rows (tuple[str, ...]) – Jacobian row names to test.

  • fractions (tuple[float, ...]) – Steps to try, as fractions of the tolerance half-width.

  • grade (int) – ISO 286 grade, which sets the tolerance.

  • angular (float) – Angular half-width [deg].

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

Return type:

dict[str, list[tuple[float, float, float]]]

Returns:

{row: [(fraction, norm, asymmetry), ...]}, scaled to the standard normal space so the norms are comparable across rows.

exlink.secondorder.format_scaling(measured)[source]

Render differencing_scaling() as a table.

The last column is the tell: norm x step constant means the numerator is a fixed jump rather than a derivative, so there is no Hessian to find.

Return type:

str

exlink.secondorder.format_second_order(outcome)[source]

Render a SecondOrder as an aligned table.

Return type:

str

Synthesising the mechanism itself, rather than the dimensions of a given one.

What this is for

§5.4 measures the EXlink topology against a slider-crank and finds it worth 17.6 %. Two topologies establish a contrast and not a trend, and §6.3 recorded the missing third – with the warning that choosing one by hand moves the arbitrariness rather than removing it. This module removes it instead, by asking the optimizer for the topology.

The method is the second family of §2.2: the spring-connected model of Kim et al. [2007], in the reduced link form of Tran and Jang [2024], with the gear-linkage extension of Yim et al. [2019]. A design domain is populated with candidate elements, every candidate is a spring whose stiffness is a design variable, and the stiffnesses are penalised towards their extremes. A spring driven stiff is a rigid connection; a spring driven soft is no connection at all. One continuous parameterisation therefore covers every mechanism the domain can hold, and the topology falls out of a gradient solve rather than being assumed.

The synthesis problem

The input is the half-speed shaft, turning once per cycle, and the output is the piston height. Over one input revolution the target motion has two equal maxima and two unequal minima – exlink.synthesis.target_motion() builds the one that realises STE = 74 mm and epsilon = 16 exactly – so the synthesis is asked for a mechanism that turns one circular input into two unequal up-and-downs of a point on the cylinder axis.

That period is the whole difficulty. A mechanism driven by a shaft is periodic in that shaft’s angle, so one driven from the crankshaft alone cannot produce a motion that differs between its two revolutions: extended expansion needs something that turns once per cycle. §5.6 sharpens this into an identity – reach the piston from one shaft only and every odd harmonic vanishes exactly, so the asymmetry, which lives entirely in the first harmonic, is zero rather than small. The piston must be reached from both shafts.

What the domain can hold

Three kinds of candidate, each with one presence variable \(\rho\):

Bars. A spring between two nodes.

Bodies. A rigid triangle on three nodes, carried as three springs sharing a single presence. §5.6 measured why this matters: assembled from three separate bars a triangle is unreachable by descent, because each bar alone does nothing and the objective is flat in its geometry until all three switch on together. One variable turns the whole three-cornered link on, which is the shape the identity above says extended expansion needs.

Gears. A pair between two shafts, at a ratio from a small catalogue. Every shaft carries its own angle; the input’s is prescribed and the rest are free coordinates of the equilibrium, so a shaft with no gear on it is simply loose and one with two is over-constrained – both visible in the strain the answer runs at. A pair between shafts at centres \(c_i, c_j\) meshes externally, so its pitch radii are fixed by the centre distance and the ratio,

\[r_i = \frac{d\,r}{1 + r}, \qquad r_j = \frac{d}{1 + r}, \qquad d = \lVert c_i - c_j \rVert,\]

and rolling without slip at the pitch point costs

\[E = \tfrac12 k(\rho)\, \bigl(r_i \vartheta_i + r_j \vartheta_j - \varphi\bigr)^2 .\]

Writing it as slip rather than as an angular error is what keeps the mesh geometrically consistent for free – no separate centre-distance constraint is needed, because the radii are read off the centres the search chose – and keeps the term in the same units as every other spring. Choosing the ratio from a catalogue of one presence per (pair, ratio) needs no new machinery either: two ratios switched on at once is over-constraint, which the strain already detects.

The model

Nodes carry planar coordinates. Some are grounded, some are pins on shafts, one is the piston – abscissa on the cylinder axis, ordinate the output – and the rest are free. Every spring \(m\) joining nodes \(i\) and \(j\) contributes

\[E_m = \tfrac12 k_m \bigl(\lVert x_i - x_j \rVert - L_m\bigr)^2, \qquad k_m = k_{\min} + (1 - k_{\min})\,\rho_m^{\,p}\]

with \(L_m\) measured on the initial pose, so no design is prestressed. The configuration at an input angle minimises the total over the free coordinates – free node positions and free shaft angles – by a ridged Newton with a backtracking line search, warm-started from the previous angle. That is the incremental analysis the published method uses, and the reason the branch a mechanism runs on is followed rather than re-chosen at every angle.

The penalty exponent \(p\) is raised on a schedule (PENALTY_SCHEDULE). At \(p = 1\) the problem is nearly convex in the stiffnesses and the search moves freely; raising it makes intermediate stiffnesses uneconomic, so the candidates separate into present and absent.

Why the objective can afford to be only the motion

Nothing here prices a part, and that is a real restriction rather than an oversight: §2.6 records that the topology-synthesis literature targets a kinematic quantity and therefore sizes nothing. What this module produces is a candidate, and a candidate is exactly what the rest of the package is for – the range chain of §3.3 scores it afterwards. Synthesising against range directly would need the sizing fixed point inside every equilibrium solve at every angle, which is a study of its own (§6.3).

exlink.topology.STIFFNESS_FLOOR = 1e-06

Stiffness of a fully absent candidate, relative to a fully present one.

Not zero, for the reason SIMP keeps a floor: a node reachable only through absent springs would otherwise leave the Hessian singular, and the damped Newton would have nothing to say about where it goes.

exlink.topology.PENALTY_SCHEDULE: tuple[float, ...] = (1.0, 2.0, 3.0, 4.0)

Exponents \(p\) the continuation walks through.

exlink.topology.GROUND = -1

coordinates fixed by the design vector.

Type:

Node kind

exlink.topology.PIN = -2

a point on a shaft’s crank, carried round by the shaft’s angle.

Type:

Node kind

exlink.topology.SLIDER = -3

abscissa fixed, ordinate free. The piston.

Type:

Node kind

exlink.topology.FREE = -4

both coordinates free.

Type:

Node kind

exlink.topology.DRIVEN = -2

Old name for PIN, kept so §5.6’s vocabulary still reads.

exlink.topology.PRESENCE_THRESHOLD = 0.5

Presence above which a candidate is kept when the answer is made discrete.

class exlink.topology.Shaft(name, centre_slot, ratio, angle_slot=None)[source]

Bases: object

A grounded axis with an angle, which may be prescribed or solved for.

name: str
centre_slot: int | None

Design-vector offset of its centre, or None when pinned at the origin.

ratio: float | None

Its speed as a multiple of the input’s, or None when the angle is free.

1 is the input shaft itself. -2 is a shaft geared to it two to one and turning the other way, which is the relation exlink.kinematics gives the EXlink – prescribed here rather than discovered, which is what §5.6’s domain does and §5.7’s does not.

angle_slot: int | None = None

Design-vector offset of its starting angle, for a free shaft.

property is_free: bool

Whether the equilibrium solves for this shaft’s angle.

class exlink.topology.Pin(node, shaft, slot)[source]

Bases: object

A node carried round by a shaft at a radius and a phase.

node: int
shaft: int
slot: int

Design-vector offset of the radius, with the phase next to it.

class exlink.topology.Element(kind, nodes)[source]

Bases: object

A candidate the search may switch on: one presence variable, one shape.

kind: str

"bar" for two nodes, "body" for a rigid triangle on three.

nodes: tuple[int, ...]
property springs: tuple[tuple[int, int], ...]

one for a bar, three for a body.

Type:

The springs it is carried as

class exlink.topology.Gear(first, second, ratio, phase_slot)[source]

Bases: object

A candidate gear pair between two shafts, at one catalogue ratio.

first: int
second: int
ratio: float

How many times faster the second shaft turns, and in the other sense.

phase_slot: int

Design-vector offset of its assembly phase.

class exlink.topology.GroundStructure(names, kinds, shafts, pins, elements, gears=())[source]

Bases: object

The candidate nodes, shafts and elements a mechanism may be built from.

Instances are pure topology – what exists and what may connect to what. Everything dimensional lives in the design vector, so one ground structure serves every design drawn on it.

names: tuple[str, ...]

Node names, in coordinate order.

kinds: tuple[int, ...]

One of GROUND, PIN, SLIDER, FREE.

shafts: tuple[Shaft, ...]
pins: tuple[Pin, ...]
elements: tuple[Element, ...]
gears: tuple[Gear, ...] = ()
property n_nodes: int

Nodes in the structure.

property n_members: int

Candidate elements – bars and bodies, not gears.

property n_presences: int

one per element and one per gear.

Type:

Presence variables in all

property members: tuple[tuple[int, int], ...]

The bar elements, as node pairs. §5.6’s vocabulary.

property free_shafts: tuple[int, ...]

Indices of the shafts whose angle the equilibrium solves for.

springs()[source]

Every spring in the domain, expanded from the elements.

Return type:

tuple[tuple[int, int], ...]

spring_owner()[source]

Which presence variable each spring takes its stiffness from.

Return type:

ndarray[tuple[Any, ...], dtype[int64]]

free_mask()[source]

Which of the 2 n_nodes coordinates the equilibrium solves for.

Return type:

ndarray[tuple[Any, ...], dtype[bool]]

property n_reduced: int

free coordinates, then free shaft angles.

Type:

Unknowns per equilibrium

ends()[source]

The springs’ first and second node indices, as arrays.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[int64]], ndarray[tuple[Any, ...], dtype[int64]]]

index(name)[source]

Index of a node by name.

Return type:

int

class exlink.topology.Layout(structure, ground_slots, free_slots, slider_slot, presence_offset, driven_slots=(), centre_of=<factory>)[source]

Bases: object

Where each quantity sits in the design vector.

The vector holds the initial configuration, the gear phases and the presences, and nothing else. Element lengths are deliberately absent: each is the distance between its endpoints in the initial pose, so every design is assembled without prestress and an entire class of structures that could never be built is never visited.

structure: GroundStructure
ground_slots: tuple[tuple[int, int], ...]

(node, offset) for every grounded node whose position is free.

free_slots: tuple[tuple[int, int], ...]

(node, offset) for every node whose initial position is chosen.

slider_slot: tuple[int, int]

cylinder abscissa then initial height.

Type:

(node, offset) of the piston

presence_offset: int

First index of the presences.

driven_slots: tuple[tuple[int, int], ...] = ()

(node, offset) of each pin’s radius, with its phase next to it.

centre_of: dict[int, int]

Grounded node each pin turns about. §5.6’s vocabulary.

property size: int

Length of the design vector.

presences(x)[source]

The presence variables of a design vector.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

element_presences(x)[source]

The presences of the bars and bodies alone.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

gear_presences(x)[source]

The presences of the candidate gear pairs.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.shaft_centres(layout, x)[source]

Where each shaft’s axis sits.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.initial_configuration(layout, x)[source]

The full node layout at zero input angle, free nodes included.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.member_lengths(layout, x)[source]

Rest length of every spring in the domain, from the initial pose.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.stiffnesses(presence, penalty)[source]

SIMP stiffness of each candidate: a floor plus its penalised presence.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.gear_radii(layout, x)[source]

Pitch radii of every candidate pair, from its centres and its ratio.

An external mesh puts the pitch point between the two axes, so the radii sum to the centre distance and their ratio is the speed ratio. Reading them off the centres the search chose is what makes the mesh consistent for free: no separate centre-distance constraint is needed, because a pair drawn this way always fits the shafts it is drawn between.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.NEWTON_STEPS = 30

Newton iterations allowed per input angle.

exlink.topology.NEWTON_TOLERANCE = 1e-09

Convergence test on the reduced gradient, relative to its scale.

The gradient of a spring energy carries the stiffness and a length, and both vary by orders of magnitude across a penalisation schedule, so an absolute threshold would be met instantly at one end of it and never at the other.

exlink.topology.RIDGE = 1e-09

Fraction of the mean diagonal added to the Hessian before solving.

The Hessian is singular exactly when some node or shaft is held only by candidates the search has switched off – which, in a topology optimization, is most of the time. The ridge leaves such a coordinate where the warm start put it instead of sending it to infinity, and is small enough not to disturb one that is properly held.

exlink.topology.BACKTRACK_STEPS = 20

Halvings allowed before a Newton step is abandoned.

exlink.topology.SLACK_TOLERANCE = 0.001

Component in a null direction above which the piston counts as undetermined.

exlink.topology.SINGULAR_TOLERANCE = 1e-08

Singular values below this fraction of the largest count as a null direction.

class exlink.topology.Motion(lam, converged, strain, gear_ratios=(), output_slack=0.0)[source]

Bases: object

What a candidate mechanism does over one input revolution.

lam: ndarray[tuple[Any, ...], dtype[float64]]

Piston height at each input angle [mm].

converged: bool

Whether every angle reached equilibrium.

strain: float

Largest strain over the revolution, as a fraction of a rest length.

The honest measure of whether the springs are behaving as rigid links, and it covers the gears too: a pair’s strain is its slip at the pitch point as a fraction of the centre distance. A mechanism the penalisation has resolved runs at a strain of order the stiffness floor; anything larger means the answer is a deforming structure rather than a mechanism.

gear_ratios: tuple[float, ...] = ()

Ratio of every pair the design kept, in catalogue order.

output_slack: float = 0.0

How undetermined the piston is, from 0 (fixed by the input) to 1 (free).

Strain catches an over-constrained answer. This catches the opposite, and the opposite is the one that lies. If the equilibrium leaves a direction the energy does not resist – a shaft with no gear on it, a chain with a link missing – then where the mechanism goes is chosen by the ridge and the warm start rather than by the mechanism, and the motion that comes back is an artefact that can look like anything, including extended expansion. The reading is the largest component the slider’s coordinate has in the null space of the reduced Hessian, over the revolution. A mechanism whose motion the input actually determines runs at zero; anything above SLACK_TOLERANCE is not a mechanism.

exlink.topology.sweep(layout, x, samples=180, penalty=1.0)[source]

Run a candidate through one revolution of the input shaft.

The angles are walked in order from zero and each equilibrium is warm-started from the last, which is what makes the result a mechanism’s motion: the configuration follows one branch of the loop-closure solution rather than being re-chosen at every angle.

Parameters:
  • layout (Layout) – The design vector’s layout.

  • x (ndarray[tuple[Any, ...], dtype[float64]]) – Design vector.

  • samples (int) – Input angles over [0, 2 pi).

  • penalty (float) – SIMP exponent applied to the presences.

Return type:

Motion

Returns:

The piston motion and its diagnostics.

exlink.topology.engine_ground_structure()[source]

§5.6’s candidate set: bars only, and the gear ratio given.

Eight nodes and twelve candidate bars. The input shaft is grounded at the origin – a gauge choice, since translating the whole mechanism changes nothing – and carries the pin P1. A second grounded shaft carries P2 at twice the input speed and the opposite sense, which is the relation a 2:1 gear pair imposes and the one EXlink uses; the search is free to leave it out by switching off every bar that touches it, but not to choose it. C is a plain grounded pivot, F1 and F2 are free nodes, and S is the piston, held on the cylinder axis.

Bars between two prescribed nodes are excluded: their length is a function of the input angle alone, so such a bar is a constraint on the design rather than a degree of freedom for the mechanism.

Return type:

tuple[GroundStructure, Layout]

Returns:

The structure and the layout of the design vector drawn on it.

exlink.topology.GEAR_CATALOGUE: tuple[float, ...] = (1.0, 2.0, 3.0, 4.0)

Speed ratios a candidate pair may be cut for.

Whole numbers, and that is a requirement rather than a convenience. The cycle has to close: after one revolution of the input shaft the mechanism must be back where it started, or the engine’s motion differs between successive cycles and there is no four-stroke to speak of. A geared shaft turning \(r\) times per input revolution returns to its start only when \(r\) is an integer, so a three-to-two pair would give a mechanism whose period is two input revolutions. That is a real machine and not this one.

Small on purpose otherwise. A ratio is one integer tooth pair and one module away from being buildable, and §4.4 already owns that step; what this catalogue has to settle is only whether the synthesis wants a two-to-one relation, which is the question §5.6 could not ask because it was handed one.

exlink.topology.geared_ground_structure()[source]

§5.7’s candidate set: bodies as well as bars, and the gear chosen.

Three changes from engine_ground_structure(), each aimed at something §5.6 measured.

The second shaft’s angle is free, and four candidate pairs – one per catalogue ratio – may drive it from the input. So the ratio is an outcome: a design that keeps the two-to-one pair has rediscovered EXlink’s gearing, one that keeps another has found something else, and one that keeps none has a loose shaft its linkage must hold.

Ten candidate bodies join the twelve bars: rigid triangles carried as three springs sharing a single presence. §5.6’s stall was a triangle that could only be assembled from three separately-switched bars, each useless alone; one variable now turns a whole three-cornered link on.

Return type:

tuple[GroundStructure, Layout]

Returns:

The structure and the layout of the design vector drawn on it.

exlink.topology.default_bounds(layout)[source]

Box the search runs in, in millimetres and radians.

Deliberately loose. The point of a topology search is that the answer is not known in advance, and a box tight enough to be informative would be assuming it.

Return type:

tuple[ndarray[tuple[Any, ...], dtype[float64]], ndarray[tuple[Any, ...], dtype[float64]]]

exlink.topology.COUNT_WEIGHT = 0.05

Millimetres of motion error a whole extra candidate is worth.

A tie-break and nothing more. It began at 1.0 mm, which was a mistake worth recording: a design with no elements at all leaves the piston free, the piston then stays where the warm start put it, and a constant motion scores the target’s own standard deviation – 23.7 mm here. At 1.0 mm the empty structure was therefore cheaper than any partly-connected one, and one start duly converged to it, fully discrete and completely useless. The travel floor below is the real repair; this weight is now small enough not to matter either way.

exlink.topology.TRAVEL_WEIGHT = 2.0

Millimetres charged per millimetre of stroke the piston fails to deliver.

Without it “build nothing” is a local minimum of the motion error, for the reason above. With it a static piston is charged twice the required stroke, which no moving mechanism can be worse than, so the search is obliged to connect the piston to something before it can start improving the shape.

exlink.topology.STRAIN_WEIGHT = 800.0

Millimetres of motion error a unit of strain is worth.

This is the term that decides whether the answer is a mechanism, and it began an order of magnitude too small. At 50 a start reached a motion error of 2.0 mm while carrying 3 % strain, and paid 1.5 mm for it – so the search was being paid to leave candidates half present, because a half-stiff member lets an overconstrained network move by giving rather than by articulating. The gray design fitted the target well and rounded to nothing. At 800 the same 3 % costs 25 mm and softness stops being a way to buy motion.

exlink.topology.HARMONIC_WEIGHT = 1.0

Weight on the relative error of each harmonic, against the plain distance.

The repair for the stall §5.6 measured. The target carries 32.33 mm at its second harmonic and 9.02 mm at its first, and the whole Atkinson asymmetry is the first; but a plain root-mean-square is dominated by the second, so a mechanism that reproduces the second and none of the first already scores 7.19 mm against the 23.74 mm of doing nothing. That is a deep local minimum holding an Otto engine. Charging each harmonic by its error relative to the target’s own amplitude there makes missing the small one cost as much, proportionally, as missing the large one.

exlink.topology.BRIDGE_WEIGHT = 30.0

Millimetres charged for a driven pin the linkage does not carry to the piston.

The condition §5.6 derives rather than assumes: a piston reached from one shaft alone is periodic in that shaft’s angle, so every odd harmonic vanishes exactly and the answer is an Otto engine whatever else it does. Extended expansion therefore needs both pins joined to the piston, and both searches spent their budget on designs where one of them was not – start 2 of §5.7 built two three-cornered bodies and hung all of them off the geared shaft.

Imposing a proven necessary condition is not assuming the answer: nothing here says how the two chains meet, how many elements it takes, or what shape the body that joins them has.

The charge is weighted by how far each pin’s shaft is driven (driven_fraction()), which is what keeps it honest in both directions. A pin on a shaft no gear turns is not a second source, so reaching it earns nothing – and not reaching it costs nothing either, because a topology with no gear at all is a legitimate single-input linkage rather than a broken two-input one. The enumeration of enumerate_mechanisms() contains both families.

exlink.topology.SLACK_WEIGHT = 200.0

Millimetres charged for a piston the equilibrium does not determine.

The other half of §5.7’s finding. Strain charges an over-constrained answer; without this, nothing charges an under-constrained one, and an under-constrained one can report any motion at all – start 3 of §5.7 reported 70.9 mm of asymmetry from a mechanism whose geared shaft nothing was driving.

exlink.topology.HARMONIC_ORDERS: tuple[int, ...] = (1, 2)

the asymmetry, and the strokes.

Type:

Harmonics the relative term is taken over

exlink.topology.HARMONIC_FLOOR = 1e-06

Amplitude, relative to the target’s own scale, below which an order is skipped.

A relative error needs something to be relative to. A target with no content at an order still has a coefficient of order 1e-15 there rather than exactly zero, and dividing by that turns a bounded objective into 1e16 – which is what happened the first time this was run against a pure second-harmonic target.

exlink.topology.DISCRETENESS_SCHEDULE: tuple[float, ...] = (0.0, 2.0, 6.0, 18.0)

Millimetres charged for a fully undecided candidate, one per penalty rung.

The SIMP exponent alone did not separate them here. Ramping an explicit \(4\rho(1-\rho)\) charge alongside it is the standard repair, and the ramp matters: charged from the start it decides the topology before the geometry is any good, and never charged at all it leaves an answer that cannot be rounded.

class exlink.topology.Candidate(x, rms, strain, discreteness, present, gear_ratios, motion, converged)[source]

Bases: object

A mechanism the synthesis produced, and how well it does.

x: ndarray[tuple[Any, ...], dtype[float64]]

Design vector.

rms: float

Motion error against the target, mean removed [mm].

strain: float

Largest strain in a present candidate (Motion.strain).

discreteness: float

zero when every candidate has resolved.

Type:

Mean of \(4\rho(1-\rho)\)

present: tuple[tuple[str, ...], ...]

Elements that survived, by node name.

gear_ratios: tuple[float, ...]

Ratios of the gear pairs that survived.

motion: ndarray[tuple[Any, ...], dtype[float64]]

The piston motion it realises [mm].

converged: bool

Whether every equilibrium solve met its gradient test.

property uses_second_shaft: bool

Whether any surviving element touches the geared shaft’s pin.

exlink.topology.best_datum(target, lam)[source]

The rotation of the input datum that lines a motion up with the target.

Where \(\theta_1 = 0\) sits is a choice, not a property of a mechanism: turn the whole design about the input axis and every crank phase moves with it, giving the same engine with its cycle starting somewhere else. A comparison that fixes the datum therefore charges a design for something it was never asked to control, and it charges a lot – the real EXlink sits 170 degrees from the target’s datum and was scored at 13.97 mm as posed against 4.05 mm once aligned, which put it behind a degenerate answer that had tuned its phase.

Minimising the squared difference over the shift is the same as maximising the circular cross-correlation, so one transform pair settles it.

Return type:

int

exlink.topology.align(target, lam)[source]

A motion resampled onto the target’s angles and rolled onto its datum.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.motion_error(target, lam)[source]

Root-mean-square distance between two motions, offset and datum removed.

The target’s mean height is arbitrary – it is set by where the cylinder is bolted, not by the mechanism – and so is the crank angle its cycle is drawn from, for the reason best_datum() gives. Both are removed before anything is charged.

Return type:

float

exlink.topology.harmonic(lam, order)[source]

The cosine and sine coefficients of one harmonic of a motion.

The diagnostic that matters for extended expansion. A motion’s even harmonics give the two up-and-downs per input revolution; its odd ones are what make the two halves differ, so the whole Atkinson asymmetry lives in the first harmonic and nowhere else. A synthesised mechanism can therefore be read off directly: a first harmonic of zero is an Otto engine whatever else it does.

Return type:

tuple[float, float]

exlink.topology.harmonic_error(target, lam)[source]

Millimetres charged for missing each harmonic, in proportion to its size.

Each order’s coefficient error is divided by the target’s own amplitude there and the mean is scaled back into millimetres by the target’s standard deviation, so a design that misses the first harmonic entirely is charged the same as one that misses the second entirely – which a plain distance does not do, and which is why §5.6 stalled on an Otto engine.

Taken on the aligned motion, for the reason best_datum() gives: a harmonic coefficient carries a phase, and rotating the input datum by \(s\) turns the \(n\)-th of them by \(ns\), so comparing coefficients at a fixed datum charges a design an order-one error per harmonic for a quantity that is not its business.

Return type:

float

exlink.topology.DEGENERATE_ERROR = 1000.0

What a motion scores when it is not a piston motion at all.

Above anything a real candidate reaches – the worst of §5.7’s four starts scored 236 – and finite, so that a failed sweep does not poison a whole run. Not larger than it needs to be, because requirement_error() adds a real error on top of it and the sum has to stay well conditioned.

exlink.topology.requirement_error(lam)[source]

Root-mean-square requirement residual, in millimetres.

The four conditions of requirement_residuals(), put back on the scale of the expansion stroke so the number can be read as a motion error and compared against motion_error().

A motion with no four-stroke phasing to speak of scores DEGENERATE_ERROR plus its precision-point error, rather than raising or returning a constant. The constant was tried and it does not work: descent from a random start sat at 1000022 for five iterations and stopped, because a flat penalty is a plateau with no way off it. The precision-point error is defined for any motion at all – it reads four heights and four slopes off the grid and needs no phasing – so adding it makes the plateau a slope that runs towards a motion with dead centres in the right places, which is where the requirement becomes readable again.

Return type:

float

exlink.topology.motion_measure(kind, target, lam)[source]

How close a motion is to what is wanted, in millimetres, three ways.

Parameters:
  • kind (str) – "target" for the distance to a sampled target motion – the measure §5.7’s first screen used, and a proxy: a design can beat the studied mechanism on it while missing the requirement. "requirements" for requirement_error(), which prescribes the four dead centres and their heights but lets the two bottom ones fall where they fall. "precision" for precision_error(), which additionally fixes them a quarter revolution apart – stricter than EXlink itself satisfies, and so reported alongside rather than searched on.

  • target (ndarray[tuple[Any, ...], dtype[float64]]) – Target piston motion, used by "target" only.

  • lam (ndarray[tuple[Any, ...], dtype[float64]]) – The motion to measure.

Return type:

float

Returns:

Millimetres.

Raises:

ValueError – If kind is not one of the three.

exlink.topology.travel_shortfall(target, lam)[source]

Stroke the piston fails to deliver, in millimetres, never negative.

Overshoot is not charged here: a design that moves too far is wrong in a way the motion error already sees, and charging it twice would bias the search towards under-travel – which is the failure this term exists to prevent.

Return type:

float

exlink.topology.objective(layout, x, target, samples, penalty, discreteness=0.0, measure='target')[source]

What the search minimises: the motion, and four deterrents.

Parameters:
  • layout (Layout) – The design vector’s layout.

  • x (ndarray[tuple[Any, ...], dtype[float64]]) – Design vector.

  • target (ndarray[tuple[Any, ...], dtype[float64]]) – Target piston motion.

  • samples (int) – Input angles per sweep.

  • penalty (float) – SIMP exponent on the presences.

  • discreteness (float) – Millimetres charged for a fully undecided candidate.

  • measure (str) – How the motion is judged; see motion_measure(). The default keeps the sampled-target reading the first screen used.

Return type:

float

Returns:

The objective, in millimetres of equivalent motion error.

Note

The harmonic term is charged only under the sampled-target measure, and deliberately. It compares a candidate’s first two harmonics against a particular motion’s, so under a precision-point measure it would put the proxy straight back: on EXlink at 48 samples it is 4.09 mm against a requirement error of 0.21, twenty to one, so whatever the four points asked for, the search would still be fitting one nominated curve. What happens between the precision points is either free or expressed as more precision points – not as a shape charge.

exlink.topology.START_SPAN = 110.0

Half-width, in millimetres, of the box a start is drawn from.

Not the same as the box the search runs in, and the difference matters. The bounds are deliberately loose because the answer is not known in advance; a start drawn that loosely is a different thing – nodes scattered over half a metre, elements three hundred millimetres long, and a piston that has to travel 74. The first runs drew from the full box and spent their budget dragging that back to scale. This is a stroke and a half, which is the size of the mechanism the specification implies without saying anything about its shape.

exlink.topology.random_start(layout, rng)[source]

A design drawn on the specification’s own scale, every candidate half present.

Starting the presences at one half rather than at random is deliberate: at \(p = 1\) the stiffnesses enter almost linearly, so a symmetric start lets the first rung of the continuation decide which candidates matter instead of inheriting a decision from the draw.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.synthesis_residuals(layout, x, samples=48, penalty=4.0, measure='requirements')[source]

The precision-point conditions as a vector, for a least-squares fit.

The objective of objective() aggregates these into one number, which throws away the structure a Gauss-Newton method lives on. Precision-point synthesis is classically a root-finding problem – five conditions in fourteen free coordinates, underdetermined, with a solution manifold rather than an isolated minimum – and that is what this exposes.

Two structural residuals ride along, so that a fit cannot buy the motion by straining the linkage or by leaving the piston under-determined. They enter as square roots because a least-squares fit squares whatever it is handed, and the weights are the ones objective() charges.

Parameters:
  • layout (Layout) – The design vector’s layout.

  • x (ndarray[tuple[Any, ...], dtype[float64]]) – Design vector.

  • samples (int) – Input angles per sweep.

  • penalty (float) – SIMP exponent on the presences.

  • measure (str) – "requirements" for the five conditions the engine imposes, "precision" for the eight that additionally fix the angles.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

Seven residuals, or ten under "precision".

Raises:

ValueError – If measure is neither.

exlink.topology.fit_to_the_points(layout, x0, samples=48, evaluations=60, measure='requirements', frozen=None)[source]

Drive the precision-point residuals to zero, by Gauss-Newton.

A trust-region least-squares step rather than a quasi-Newton one on the aggregated objective. The two cost the same per iteration – both build their derivative by finite differences over the free coordinates – but this one knows that the thing it is driving to zero is a vector, and that the problem has more coordinates than conditions. §5.8’s control is what motivates the change: descent on the scalar could not recover the studied mechanism’s dimensions even when handed its topology.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

The design reached, on all of the layout’s coordinates.

exlink.topology.synthesise_one(layout, x0, target, samples=90, iterations=120, schedule=(1.0, 2.0, 3.0, 4.0), measure='target')[source]

Walk one start up the penalisation schedule and make the answer discrete.

The last step is the one that turns a topology optimization into a mechanism. Rounding the presences to zero and one gives a linkage; the geometry is then re-optimised with that topology frozen, so what is reported is a real mechanism’s best fit rather than a blurred one’s.

Parameters:
Return type:

Candidate

Returns:

The discrete mechanism and its score.

class exlink.topology.Assessment(rms, strain, four_phases, expansion_stroke, compression_stroke, compression_ratio, first_harmonic, second_harmonic, present, gear_ratios=(), output_slack=0.0, travel=0.0)[source]

Bases: object

What a synthesised mechanism actually delivers, against the requirements.

rms: float

Motion error against the target [mm].

strain: float

Largest strain in a present candidate; a mechanism runs at the floor.

four_phases: bool

Whether the motion is a four-stroke one at all.

expansion_stroke: float

STE realised [mm]; nan when the motion is not a four-stroke.

compression_stroke: float

STC realised [mm]; nan likewise.

compression_ratio: float

epsilon implied by STC and the clearance volume.

first_harmonic: float

Amplitude of the first harmonic [mm] – the asymmetry, and nothing else.

second_harmonic: float

Amplitude of the second harmonic [mm] – the two up-and-downs.

present: tuple[tuple[str, ...], ...]

Elements that survived.

gear_ratios: tuple[float, ...] = ()

Ratios of the gear pairs that survived.

output_slack: float = 0.0

How undetermined the piston is (Motion.output_slack).

travel: float = 0.0

Peak-to-peak piston movement [mm].

property delivers_the_stroke: bool

Whether the piston moves far enough for the strokes to mean anything.

A guard the results asked for. A design whose piston wobbles by two millimetres can still show four monotone phases and an “asymmetry” above a millimetre, and one did – start 3 of §5.7’s re-run, whose harmonics are 1.9 and 2.5 mm against a target’s 9.0 and 32.3. Calling that extended expansion is an artefact of testing the shape of a motion without testing its size.

property is_mechanism: bool

Whether the input actually determines where the piston goes.

property asymmetry: float

what makes the cycle extended-expansion rather than Otto.

Type:

STE - STC

property is_extended_expansion: bool

Whether this is a mechanism, and its two strokes actually differ.

The mobility and travel tests come first on purpose. An under-constrained answer can report any motion at all, extended expansion included, and so can a piston that barely moves. Both happened.

exlink.topology.assess(layout, x, target, samples=720)[source]

Score a synthesised design against what the engine needs.

Deliberately separate from the objective. The objective is a distance to a prescribed motion, which is what a gradient method can descend; this is the requirement, which is what a design has to meet. Keeping them apart is what lets a run report that it minimised the one without satisfying the other.

Return type:

Assessment

exlink.topology.format_assessment(assessment)[source]

A readable summary of a synthesised mechanism.

Return type:

str

Write the studied mechanism into the synthesis domain, as a design vector.

The check that says whether a negative result is about the domain or about the search, and here it says the search. EXlink is three elements and a gear pair – the swing rod P1-F1, the trigonal link as a body P2-F1-F2, the piston rod F2-S, and a two-to-one pair – so it is expressible in geared_ground_structure() exactly, and the spring model reproduces its analytic motion to about 2e-3 mm.

Every corner is a design variable, which is what makes this possible: the two free nodes are the trigonal link’s corners \(A\) and \(E\), the pins carry a radius and a phase, the piston carries its axis, and each element’s lengths follow from where those corners are put. Nothing about the shape of the three-cornered body is fixed in advance.

Parameters:
  • design (Design | None) – The linkage to write in. Defaults to the study’s result.

  • samples (int) – Angles used to read its pose at zero input angle.

Return type:

tuple[GroundStructure, Layout, ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

The geared ground structure, its layout, and the design vector.

exlink.topology.reachability(layout, x)[source]

How well each driven pin is joined to the piston through the linkage.

A bottleneck path: the value of a route is the weakest presence along it, and the value of a pin is the best route it has. Fully present elements all the way give 1, no route at all gives 0, and a half-built chain gives its weakest link – which is what makes it something a gradient can climb rather than a yes-or-no test.

This is the path alone. Whether a pin is worth reaching is a separate question – driven_fraction() answers it – and keeping the two apart matters in both directions. Reaching a pin whose shaft nothing drives is vacuous, and a run duly reached 1.00 on both while keeping no gear at all; but charging for not reaching it would be wrong too, because a topology with no gear is a legitimate single-input linkage rather than a broken two-input one.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

One value per pin, in the order the structure lists its pins.

exlink.topology.driven_fraction(layout, x)[source]

How far each pin’s shaft is actually driven, from 0 to 1.

The input shaft is driven by definition. Any other is driven only as far as a gear presence says, because a shaft carrying no gear is a passive grounded pivot – a perfectly good linkage element, and not a second source of motion.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

One value per pin, in the order the structure lists its pins.

class exlink.topology.Mechanism(elements, gear)[source]

Bases: object

One discrete topology: which elements are present, and which gear.

elements: tuple[int, ...]

Indices into the structure’s elements.

gear: int | None

Index of the gear pair fitted, or None.

presences(structure)[source]

The presence vector this topology stands for.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

names(structure)[source]

The elements it keeps, by node name.

Return type:

tuple[tuple[str, ...], ...]

exlink.topology.is_determinate(layout, mechanism, rng, tries=3)[source]

Whether the input angle fixes every unknown, at a general pose.

The rank of the reduced Hessian is the test, taken at random geometries so that a topology is not rejected for being singular at one unlucky pose. A topology failing it is under-constrained however its dimensions are chosen, which is the property §5.7 found reports whatever motion the solver’s ridge happens to pick.

The absent elements are given a stiffness of exactly zero rather than the usual floor. The floor exists so that the continuation always has a Hessian to invert, and it leaves that Hessian nominally full rank whatever the topology, so a rank test taken through it answers a question about the tolerance rather than about the mechanism.

Return type:

bool

exlink.topology.enumerate_mechanisms(layout, seed=0, max_elements=4)[source]

Every discrete topology in the domain that is a mechanism at all.

The globalization this problem actually wants. §5.7 leaves a search problem: the objective ranks the answer an order of magnitude ahead of anything six local runs found, and the runs commit to a short path in their first rung and never leave it. The discrete part of the problem is small enough not to need a heuristic – it can be enumerated, which is the same treatment §4.4 gives the gear lattice and the exhaustive baseline of Appendix C.3.

Three conditions, applied in increasing cost. The elements must supply exactly as many constraints as there are unknowns, counting a bar as one, a body as three and a fitted gear as one. Both driven pins must reach the piston, which is §5.6’s identity as a condition on the graph. And the reduced Hessian must have full rank at a general pose, which rejects the topologies that are under-constrained however they are dimensioned.

Parameters:
  • layout (Layout) – The design vector’s layout.

  • seed (int) – Draws the random poses the rank test uses.

  • max_elements (int) – Largest number of elements to consider.

Return type:

tuple[Mechanism, ...]

Returns:

The admissible topologies, smallest first.

exlink.topology.screen_mechanism(layout, mechanism, target, rng, draws=4, iterations=20, samples=24, measure='target', evaluations=None)[source]

Give one topology a cheap chance, and score it.

With the presences fixed the problem is no longer a topology optimization at all – it is the well-conditioned continuous fit exlink.synthesis already solves reliably on a known linkage. That is the point of enumerating: the hard, deceptive part of the search is the discrete part, and enumeration removes it, leaving each topology a problem that local descent handles.

Parameters:
  • layout (Layout) – The design vector’s layout.

  • mechanism (Mechanism) – The topology to try.

  • target (ndarray[tuple[Any, ...], dtype[float64]]) – Target piston motion.

  • rng (Generator) – Draws the starting geometries.

  • draws (int) – Random geometries to try before descending from the best.

  • iterations (int) – L-BFGS-B iterations allowed.

  • samples (int) – Input angles per sweep – coarse, because this is a screen.

  • measure (str) – How the motion is judged; see motion_measure().

  • evaluations (int | None) – Total objective evaluations the topology is allowed, split a third to the warm-up and the rest to the polish. An iteration count does not bound the work on this objective – L-BFGS-B spends about five evaluations an iteration on the line search – and a screen over hundreds of topologies has to be bounded.

Return type:

tuple[float, ndarray[tuple[Any, ...], dtype[float64]]]

Returns:

The objective reached and the design vector reaching it.

Note

There was a warm-up here, a third of the iterations spent on the sampled-target measure before handing over. It existed because a precision-point error used to be undefined wherever the motion had no four-stroke phasing to read, which is most random starts, so that measure alone offered a plateau with no gradient off it. Reading the requirement off the turning points removed the plateau and with it the warm-up’s reason to exist, and measuring says it had become actively harmful: on the studied mechanism’s own topology, dropping it took the screened error from 18.84 mm to 11.52 and the time from 49 seconds to 31. It was pulling the geometry towards one nominated curve before the requirement was allowed to speak.

exlink.topology.PRECISION_ANGLES: tuple[float, ...] = (0.0, 90.0, 180.0, 270.0)

Input-shaft angles, in degrees, at which the motion is prescribed exactly.

One input revolution is one cycle, so these are 0, 180, 360 and 540 degrees of crankshaft: the first top dead centre, the deep bottom dead centre that ends expansion, the second top dead centre, and the shallow one that ends compression. Prescribing the motion at points rather than matching a sampled curve is classical precision-point synthesis, and it is the repair for what §5.7’s screen exposed – a distance to a target motion is a proxy, and a design can beat the studied mechanism on the proxy while missing the requirement. Here STE and the compression ratio are conditions rather than outcomes.

exlink.topology.precision_heights(expansion_stroke=None, compression_ratio=None)[source]

Piston height at each precision angle, measured down from top dead centre.

The absolute height is free – it is set by where the cylinder is bolted – so only the differences are prescribed: both top dead centres at the same height, one bottom dead centre a full expansion stroke below it, the other a compression stroke below it.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

exlink.topology.precision_residuals(lam, heights=None, shift=0)[source]

How far a motion is from passing the precision points, as extremes.

Eight conditions, made dimensionless by the expansion stroke: four on the height, taken relative to the first point so that the cylinder’s mounting height is not charged, and four on the slope, because each precision point has to be a dead centre rather than merely a passing height.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

Four height residuals then four slope residuals.

exlink.topology.precision_error(lam, heights=None)[source]

Root-mean-square precision residual in millimetres, datum profiled out.

The datum is searched rather than assumed, on the same argument as best_datum(): which crank angle is called zero is a choice, so a mechanism that passes all four points starting somewhere else passes them.

Return type:

float

exlink.topology.requirement_residuals(lam)[source]

How far a motion is from the requirements, without fixing their angles.

The looser reading of the precision points, and the one the engine actually imposes. What a four-stroke needs is four dead centres in the right order with the right heights, and the two top ones half an input revolution apart so that a cycle is 720 degrees of crankshaft and the valve train can be geared to it. Where the bottom dead centres fall between them is an outcome, not a requirement.

That distinction is worth measuring rather than assuming: EXlink’s own turning points sit at 10.5, 101.5, 189.5 and 286 degrees of input shaft, so a target demanding them at 0, 90, 180 and 270 is stricter than the studied mechanism satisfies – it scores 3.53 mm against that and 0.38 mm against this.

The turning points are read off directly rather than through exlink.cycle.find_phases(), and that is the difference between a screen that ranks and one that does not. That function demands exactly four monotone phases, which is right for judging a finished design and wrong for judging a search iterate: one spurious reversal and a motion that is plainly a four-stroke on a fine grid reads as no motion at all. It happened to the studied mechanism itself – screened from scratch on 24 angles it scored 1281, the degenerate floor, while the same design read 28.7 on 240. Here a fifth residual charges the spurious reversals instead of refusing to look: a clean four-phase motion has a total variation of exactly twice its two strokes, and anything above that is movement nobody asked for.

Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

Expansion stroke, compression stroke, top-dead-centre height difference, top-dead-centre spacing and spurious travel, each as a fraction of the expansion stroke.

Raises:

PhaseError – If the motion has no two peaks and two troughs to read.

Mixed-integer design

The gear choice is discrete, and it pins the inter-axle distance. This states that as a mixed-integer program and solves it by bi-level outer approximation. Needs the optional gemseo-bilevel-outer-approximation plugin (pip install exlink-opt[minlp]).

The gear choice as a real mixed-integer problem: bi-level outer approximation.

The discrete part of this design – which standard module, how many teeth – was previously handled by solving the two or three nearest buildable lattice points and keeping the best. That is enumeration with an arbitrary budget: no bound, no stopping criterion, and no way to know whether an unvisited point would have won.

This module states it as the mixed-integer nonlinear program it is and hands it to the gemseo-bilevel-outer-approximation plugin, which implements the Duran-Grossmann decomposition directly.

The formulation

main       gear_choice, a one-hot selection over the lattice   (categorical)
           solved by BILEVEL_MASTER_OUTER_APPROXIMATION
                |                                    ^
                | I, gear_module, gear_teeth         | linearisations and
                v      (catalogue interpolation)     | feasibility
sub        the ten remaining linkage variables       (continuous)
           MDF over the coupled disciplines, solved by SLSQP

The Benders formulation performs the split itself. Categorical variables go to the main problem; everything continuous goes to a sub-scenario which it wraps in an MDOScenarioAdapterBenders, so the main problem optimises the sub-problem’s optimum. The sub-problem keeps the MDF formulation and the exact Jacobians this package already supplies, and SLSQP solves it.

The lattice as a catalogue

The gear pair reaches the physics through three numbers: the centre distance I = 1.5 m z, which is dominant and geometric, and the module and tooth count themselves, which set the blank mass and the face width. A single CatalogueDesignSpace categorical variable drives all three through one interpolation chain, so the main problem picks a lattice point and the three follow. At unit SIMP penalty the interpolation is exactly I = sum_j y_j I_j: linear in the selection, analytically differentiable, and therefore something the outer-approximation master can linearise.

This is why RangeDiscipline takes the gear pair as inputs rather than construction data – under this formulation they are set by the main problem.

Infeasible sub-problems

At several lattice points the continuous problem has no feasible solution: pinning I throws the design off the equalities STE = 74 and epsilon = 16, which I was one of the variables used to satisfy.

Outer approximation handles that by construction, and it is the reason the constraints here are attached with main_level=True. That flag tells the formulation the constraint depends on the categorical choice and can render the sub-problem infeasible, so the main problem carries an is_feasible condition alongside the linearisations. A lattice point whose sub-problem cannot be solved is then excluded on evidence rather than silently returning a number, which is exactly the information the previous enumeration discarded.

The honest caveat

Finite convergence of outer approximation to the global optimum requires the sub-problem convex in the continuous variables for each fixed categorical choice. This problem is emphatically nonconvex, so the main problem’s bound is a bound under an assumption this problem violates. Because the lattice is small, exhaustive() solves every candidate, so what the decomposition returns can be checked against the true best over the lattice.

exlink.minlp.RANGE_VIOLATION_OUTPUTS: tuple[str, ...] = ('runs_violation', 'gear_violation')

The range constraints in violation form, which the adapter can address.

exlink.minlp.GEAR_CHOICE = 'gear_choice'

which lattice point, as a one-hot selection.

Type:

The categorical variable

exlink.minlp.MASTER_ALGORITHM = 'BILEVEL_MASTER_OUTER_APPROXIMATION'

The main-problem solver supplied by the plugin.

class exlink.minlp.LatticePoint(module, teeth)[source]

Bases: object

One buildable gear pair, and the centre distance it forces.

module: float
teeth: int
property inter_axle: float

I = 1.5 m z [mm].

class exlink.minlp.MinlpResult(design, point, km_per_litre, feasible, iterations, seconds, candidates=<factory>, scenario=None, reason='')[source]

Bases: object

Outcome of the mixed-integer solve.

design: Design | None
point: LatticePoint | None
km_per_litre: float
feasible: bool
iterations: int

Main-problem iterations, i.e. sub-problems solved.

seconds: float
candidates: list[LatticePoint]
scenario: Any = None
reason: str = ''
property solves: int

Sub-problems solved – the cost that matters.

exlink.minlp.candidates_from_design(design, speed_rpm=1500.0, count=3, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), limit=6)[source]

Buildable lattice points around a design, nearest workable first.

Ranked by whether the pair can carry the tooth load and only then by distance – see exlink.gears.buildable_neighbours() for why the obvious ranking is the wrong one.

Parameters:
  • design (Design) – The design whose centre distance to bracket.

  • speed_rpm (float) – Crankshaft speed [rev/min], for the tooth load.

  • count (int) – Tooth counts to consider on each side, per module.

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • limit (int) – Most candidates to return.

Return type:

list[LatticePoint]

Returns:

The candidate lattice points.

exlink.minlp.build_minlp_scenario(candidates, initial, bounds=None, speed_rpm=1500.0, samples=None, sub_max_iter=30, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0))[source]

Assemble the bi-level mixed-integer scenario.

Parameters:
  • candidates (list[LatticePoint]) – The lattice points the main problem may choose between.

  • initial (Design) – Starting design; its I is supplied by the catalogue instead.

  • bounds (Bounds | None) – The continuous design box.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • samples (int | None) – Crank angles per revolution.

  • sub_max_iter (int) – SLSQP budget for each sub-problem.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

Return type:

Any

Returns:

A scenario to execute with MASTER_ALGORITHM.

Raises:

ValueError – If no candidates are given.

exlink.minlp.solve(initial, candidates=None, bounds=None, speed_rpm=1500.0, max_iter=20, sub_max_iter=25, samples=None, posa=2.0, adapt=True, min_dfk=0.0, max_step=1000.0, **kwargs)[source]

Solve the gear choice and the linkage together, by bi-level outer approximation.

Parameters:
  • initial (Design) – Starting design.

  • candidates (list[LatticePoint] | None) – Lattice points to choose between; derived from initial by candidates_from_design() if omitted.

  • bounds (Bounds | None) – The continuous design box.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • max_iter (int) – Main-problem iteration budget.

  • sub_max_iter (int) – SLSQP budget per sub-problem.

  • samples (int | None) – Crank angles per revolution.

  • posa (float) – Post-optimal sensitivity amplification. Multiplies the cut slopes, making each linearisation steeper and so less likely to cut off a lattice point that is actually better. 1.0 is the raw linearisation, which on a nonconvex problem terminates early: with it this problem stops after two sub-solves, 0.6 % short of the best lattice point.

  • adapt (bool) – Adaptive convexification. Corrects the slopes by a secant method over the visited history so the cuts behave like valid supports even where the true value function is not convex. This is the plugin’s own answer to premature convergence.

  • min_dfk (float) – Convexity margin used by the adaptive correction.

  • max_step (float) – Main-problem trust radius.

  • **kwargs (Any) – Forwarded to build_minlp_scenario().

Return type:

MinlpResult

Returns:

The result, with the chosen lattice point and the design that goes with it.

exlink.minlp.exhaustive(initial, candidates=None, bounds=None, speed_rpm=1500.0, sub_max_iter=25, samples=None, **kwargs)[source]

Solve every candidate separately, to check what the decomposition returned.

One full continuous solve per lattice point, which is what the previous enumeration did and what the decomposition is measured against.

Parameters:
  • initial (Design) – Starting design.

  • candidates (list[LatticePoint] | None) – Lattice points; derived from initial if omitted.

  • bounds (Bounds | None) – The continuous design box.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • sub_max_iter (int) – SLSQP budget per point.

  • samples (int | None) – Crank angles per revolution.

  • **kwargs (Any) – Forwarded to the scenario builder.

Return type:

list[tuple[LatticePoint, float, bool]]

Returns:

(point, km/L, feasible) per candidate; km/L is zero when the continuous problem had no feasible solution there.

exlink.minlp.format_result(result, title='bi-level outer approximation')[source]

Render a MinlpResult as an aligned table.

Return type:

str

Optimization

GEMSEO wrappers around the mechanism analysis.

GEMSEO owns the problem formulation, rather than the penalty function, the design space and the algorithm loop being hand-rolled: ExlinkDiscipline exposes the analysis as a discipline with a named grammar, and the scenarios in exlink.scenarios attach objectives and constraints to it declaratively. Every algorithm in GEMSEO’s catalogue – gradient-based, derivative-free, evolutionary, multi-objective – then applies to the same problem without touching the physics.

Two disciplines are provided:

ExlinkDiscipline

Takes the eleven design variables as separate scalar inputs and returns every objective and constraint measure as a separate scalar output. This is the natural form for GEMSEO and the one the scenarios use.

PenalisedExlinkDiscipline

Adds an external penalty function F(X) as one extra output, so that a “penalise, then run an unconstrained solver” workflow can be run as-is.

exlink.disciplines.OUTPUT_NAMES: tuple[str, ...] = ('neg_efficiency', 'efficiency', 'height', 'width', 'rod_angle_margin', 'compatibility_margin', 'tdc_gap_margin', 'clearance_margin', 'side_load_margin', 'stroke_error', 'compression_ratio_error', 'expansion_stroke', 'compression_ratio', 'rod_angle', 'compatibility', 'tdc_gap', 'clearance', 'side_load_ratio', 'mean_torque', 'valid')

Outputs produced by ExlinkDiscipline, in a stable order.

class exlink.disciplines.ExlinkDiscipline(samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), penalty=PenaltyValues(efficiency=0.0, height=1000.0, width=1000.0), name='')[source]

Bases: Discipline

The EX-link mechanism as a GEMSEO discipline.

Inputs are the eleven design variables of exlink.design.VARIABLE_NAMES, each a scalar array. Outputs are the three objectives, the seven constraint residuals and a handful of diagnostics; see OUTPUT_NAMES.

Constraint outputs follow GEMSEO’s convention of “feasible when <= 0” for inequalities and “feasible when == 0” for equalities, so they can be handed straight to add_constraint().

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

analyse_design(design)[source]

Run the underlying analysis with this discipline’s settings.

Return type:

Analysis

exlink.disciplines.to_output_data(analysis, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0))[source]

Flatten an Analysis into GEMSEO output data.

Return type:

dict[str, ndarray]

class exlink.disciplines.PenalisedExlinkDiscipline(penalty_parameter=0.1, max_height=inf, max_width=inf, **kwargs)[source]

Bases: ExlinkDiscipline

Adds an external penalty function as an output.

This converts the constrained problem into an unconstrained one via

\[F(X) = -\eta(X) + \frac{1}{r^2}\left( c_{eq}^T c_{eq} + \langle c \rangle^T \langle c \rangle \right),\]

where <c> keeps only the violated inequalities and 0 < r < 1 is the penalty parameter. Smaller r means a more accurate but worse conditioned problem – the trade-off that motivates finishing with an augmented Lagrangian instead.

The size objectives are handled the way a hand-swept Pareto front handles them: as moving limits H <= h_max, B <= b_max folded into the penalty.

penalised_objective(analysis)[source]

Evaluate F(X) for an analysed design.

Return type:

float

exlink.disciplines.RANGE_OUTPUTS: tuple[str, ...] = ('km_per_litre', 'neg_range', 'engine_mass', 'flywheel_mass', 'crankcase_mass', 'brake_efficiency', 'mechanical_efficiency', 'brake_power', 'runs_margin', 'gear_margin', 'runs_violation', 'gear_violation')

Everything RangeDiscipline reports, in a stable order.

The two margins appear twice, once in each sign convention. *_margin is positive when satisfied, which reads naturally and is what the single-level scenarios attach with positive=True. *_violation is the negation, positive when violated, matching the coupled margins. The bi-level formulation needs the second form because its scenario adapter identifies a constraint by its output name, and positive=True renames it to -runs_margin, which is not an output of anything.

exlink.disciplines.RANGE_PENALTY: dict[str, float] = {'brake_efficiency': 0.0, 'brake_power': 0.0, 'crankcase_mass': 1000.0, 'engine_mass': 1000.0, 'flywheel_mass': 1000.0, 'gear_margin': -1.0, 'gear_violation': 1.0, 'km_per_litre': 0.0, 'mechanical_efficiency': 0.0, 'neg_range': 0.0, 'runs_margin': -1.0, 'runs_violation': 1.0}

What the range chain returns for a design the kinematics cannot close.

exlink.disciplines.COUPLING_AXIAL = 'member_axial'

Internal axial force of every member, flattened [N].

exlink.disciplines.COUPLING_BENDING = 'member_bending'

Internal bending moment of every member, flattened [N.mm].

exlink.disciplines.GEAR_MODULE = 'gear_module'

Chosen gear module [mm], an input so a mixed-integer master can set it.

exlink.disciplines.GEAR_TEETH = 'gear_teeth'

Teeth on the small gear, likewise.

exlink.disciplines.COUPLING_DIAMETERS = 'diameters'

Section diameter of every member, in the order of MEMBER_NAMES [mm].

exlink.disciplines.COUPLED_SAMPLES = 360

Crank angles per revolution used inside the coupled disciplines.

This sets the size of the coupling vector – one value per member, per angle, per station – so there is a real incentive to keep it small. It is set by g rather than by the loads, which are smooth and would be happy with far fewer.

g is the difference between two nearly equal maxima of lambda, so its absolute error is what matters against a 0.01 mm bound, and on a coarse grid that error is not small: for the refined reference, g reads 0.0086 mm at 180 angles against a converged 0.0060 mm – 44 % high. Optimizing against a constraint measured that badly lands the design outside the real one. At 360 angles the error is 3e-4 mm, or 3 % of the bound; 720 brings it to 1e-5 mm at twice the cost. See tests/test_coupled.py.

class exlink.disciplines.DynamicsDiscipline(speed_rpm=1500.0, samples=360, stations=9, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), section=Section(bore_ratio=0.0, min_wall=1.5), name='')[source]

Bases: Discipline

Loads on the mechanism at speed, given the member sections.

Takes the eleven design variables, the section diameters and the crankshaft speed; returns the internal load history of every member together with the output torque and the bearing loads.

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

class exlink.disciplines.StructureDiscipline(samples=360, stations=9, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), section=Section(bore_ratio=0.0, min_wall=1.5), name='')[source]

Bases: Discipline

Section sizes that survive a given load history.

Takes the design variables and the internal load histories; returns the diameter each member needs to satisfy yield, fatigue and buckling, together with the mass that follows.

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

class exlink.disciplines.RangeDiscipline(speed_rpm=1500.0, samples=360, vehicle=None, module=None, teeth=None, fluctuation=0.1, material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), section=Section(bore_ratio=0.0, min_wall=1.5), step=1e-06, name='')[source]

Bases: Discipline

Kilometres per litre, from the design and its converged sections.

Chains friction, the engine mass budget and the burn-and-coast vehicle model. The objective is exposed as neg_range so that a minimiser can take it directly, alongside the quantities a reader needs to interpret it.

Derivatives are finite differences, and deliberately so. The chain here contains a bisection on the active average-speed constraint, a maximum over the crank revolution, and a choice among discrete gear modules; the first two are differentiable almost everywhere but tedious to differentiate exactly, and the third is not differentiable at all. What makes finite differences safe here – and they are not safe on the geometric metrics, where exlink.jacobian exists precisely because they are not – is that this discipline is cheap: one load solve, no fixed point, about 0.3 s. A full gradient costs eighteen of those, against a single MDA at 2.1 s.

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

GEMSEO problem formulations for the EX-link sizing problem.

The quasi-static formulation is

\[\begin{split}\begin{cases} l_b \le X \le u_b \\ \min_X f(X) = (-\eta, H, B)^T \\ c(X) \le 0 \\ c_{eq}(X) = 0 \end{cases}\end{split}\]

with c = (mra - 10, W - 0.985, g - 0.01, 10 - d, \gamma - 0.02)^T and c_eq = (STE - 74, \epsilon - 16)^T.

Five entry points cover the strategies that apply to it, in the order a study would normally use them:

maximise_efficiency()

Single objective, all constraints, global search. DEFAULT_GLOBAL_ALGORITHM is differential evolution, which – unlike NSGA-II – handles the equality constraints directly.

refine()

Augmented Lagrangian polish from a starting design. Worth having because the external penalty function is accurate only for small r and badly conditioned when r is small.

pareto_front()

Multi-objective search over all three objectives.

local_pareto()

The MOEA in a box shrunk around a design already known to be good, rather than over the whole space – which is what makes it work at all here.

sweep_moving_limits()

Keep efficiency as the only objective and walk a moving upper limit on H and B down, re-solving each time.

exlink.scenarios.DEFAULT_GLOBAL_ALGORITHM = 'DIFFERENTIAL_EVOLUTION'

Global search over the full design box.

exlink.scenarios.DEFAULT_LOCAL_ALGORITHM = 'Augmented_Lagrangian_order_0'

Local refinement, accurate on the active constraints.

exlink.scenarios.DEFAULT_MOEA = 'PYMOO_NSGA2'

Multi-objective evolutionary algorithm.

exlink.scenarios.OBJECTIVE_NAMES: tuple[str, str, str] = ('neg_efficiency', 'height', 'width')

f(X) = (-eta, H, B)^T.

exlink.scenarios.INEQUALITY_OUTPUTS: tuple[str, ...] = ('rod_angle_margin', 'compatibility_margin', 'tdc_gap_margin', 'clearance_margin', 'side_load_margin')

Discipline outputs that must stay <= 0.

exlink.scenarios.EQUALITY_OUTPUTS: tuple[str, ...] = ('stroke_error', 'compression_ratio_error')

Discipline outputs that must equal 0.

exlink.scenarios.DEFAULT_EQUALITY_TOLERANCE: dict[str, float] = {'compression_ratio_error': 0.05, 'stroke_error': 0.05}

Half-widths used when relaxing the equalities into pairs of inequalities.

An algorithm such as NSGA-II cannot take equality constraints. Relaxing STE = 74 to |STE - 74| <= 0.05 mm and epsilon = 16 to |epsilon - 16| <= 0.05 keeps the design within manufacturing tolerance of the specification – the same pragmatism already applied to g, which is constrained to a tolerance rather than to zero.

exlink.scenarios.MOEA_EQUALITY_TOLERANCE: dict[str, float] = {'compression_ratio_error': 0.5, 'stroke_error': 1.0}

Looser half-widths for the equalities during a multi-objective run.

See moea_targets() for why the multi-objective stage needs a relaxed problem at all.

exlink.scenarios.MOEA_TDC_GAP: float = 1.0

Relaxed bound on g during a multi-objective run [mm].

exlink.scenarios.moea_targets(targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), tdc_gap=1.0)[source]

Relax g for a population-based run.

Why this is necessary is worth stating plainly, because it is the same wall a population method hits on this problem: it returns solutions no better, and often worse, than a local search from the same starting point.

Sampling 2000 designs uniformly from a box around a known-good solution and testing each constraint on its own gives:

constraint

satisfied by

d >= 10

96 % of analysable designs

W <= 0.985

76 %

mra <= 10

28 %

|eps - 16|

8 %

gamma <= 0.02

6 %

|STE - 74|

4 %

g <= 0.01

0.1 %

g, the gap between the two top dead centres, is nominally an inequality but at 0.01 mm it is a third equality in disguise. With three equalities in eleven variables the feasible set is a thin sheet, and the joint hit rate for a random population is of order 1e-7. NSGA-II duly returns a “front” of one point, because one point is all it ever found that was feasible.

So the multi-objective stage runs on a relaxed problem: it is a scouting device that maps the shape of the efficiency-versus-size trade-off. The design finally quoted comes from refine(), which drives g, STE and epsilon back onto their true targets – exactly the two-stage two-stage workflow:

front = pareto_front()
final = refine(front.design)

On this problem sweep_moving_limits() is the more reliable way to trace the trade-off, since every one of its steps is a warm-started local solve on the unrelaxed problem.

Parameters:
  • targets (DesignTargets) – The targets to relax.

  • tdc_gap (float) – The relaxed bound on g [mm].

Return type:

DesignTargets

Returns:

A copy of targets with max_tdc_gap widened.

class exlink.scenarios.Outcome(design, analysis, algorithm, scenario, front=<factory>)[source]

Bases: object

Result of one optimization run.

design: Design

The best design found (for multi-objective runs, the highest-efficiency point of the front).

analysis: Analysis

Its full analysis.

algorithm: str
scenario: BaseScenario
front: list[Design]

Pareto-optimal designs; empty for single-objective runs.

property feasible: bool

Whether the returned design satisfies every constraint.

property n_evaluations: int

Design points recorded in the top-level problem database.

For the augmented Lagrangian this counts outer iterations only: each one solves a sub-problem with its own database, so the true number of analyses is larger.

summary()[source]

A short human-readable report of the outcome.

Return type:

str

exlink.scenarios.INEQUALITY_TOLERANCE = 0.0001

Slack allowed on the inequality constraints when judging feasibility.

GEMSEO’s own default (ineq_tolerance), and it matters here: an augmented Lagrangian drives the active constraints onto their bounds, so a converged design routinely lands a few parts in 1e5 outside one of them – g, in practice, which the optimizer pushes hard against 0.01 mm. Demanding <= 0 exactly would label designs infeasible that every solver in the chain considers converged.

exlink.scenarios.EQUALITY_TOLERANCE = 0.05

Half-width allowed on STE - 74 [mm] and epsilon - 16.

Looser than GEMSEO’s 0.01 default because both are physical dimensions: 0.05 mm on a 74 mm stroke is well inside what the linkage could be manufactured to.

exlink.scenarios.is_feasible(analysis, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), tolerance=0.05, inequality_tolerance=0.0001)[source]

Whether an analysed design satisfies every constraint.

Parameters:
  • analysis (Analysis) – The analysis to check.

  • targets (DesignTargets) – Constraint right-hand sides.

  • tolerance (float) – Half-width allowed on the two equality constraints.

  • inequality_tolerance (float) – Slack allowed on the inequality constraints; see INEQUALITY_TOLERANCE.

Return type:

bool

Returns:

True if the design was analysable and meets every constraint within those tolerances.

exlink.scenarios.build_design_space(bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, fixed=())[source]

Build the GEMSEO design space for the eleven variables.

Parameters:
  • bounds (Bounds) – The box l_b <= X <= u_b.

  • initial (Design | None) – Starting point; defaults to the centre of the box.

  • fixed (Sequence[str]) – Variables to leave out of the design space, because an outer loop has already chosen them. The disciplines then read them from their default input data, which build_range_scenario() sets to the chosen value. This is how the discrete gear variables pin I: once a module and a tooth count are chosen, I = 1.5 m z is not free.

Return type:

DesignSpace

Returns:

A design space with one scalar variable per entry of exlink.design.VARIABLE_NAMES that is not in fixed.

exlink.scenarios.build_scenario(objective='neg_efficiency', bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), relax_equalities=False, equality_tolerance=None, max_height=inf, max_width=inf)[source]

Assemble a constrained GEMSEO scenario.

Parameters:
  • objective (str | Sequence[str]) – Output name, or several for a multi-objective run.

  • bounds (Bounds) – The design box.

  • initial (Design | None) – Starting design.

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides.

  • relax_equalities (bool) – Express STE = 74 and epsilon = 16 as pairs of inequalities. Required for algorithms that cannot take equality constraints, such as NSGA-II.

  • equality_tolerance (dict[str, float] | None) – Overrides for DEFAULT_EQUALITY_TOLERANCE.

  • max_height (float) – Moving upper limit on H [mm].

  • max_width (float) – Moving upper limit on B [mm].

Return type:

BaseScenario

Returns:

A scenario ready to execute().

exlink.scenarios.maximise_efficiency(algorithm='DIFFERENTIAL_EVOLUTION', bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, max_iter=4000, samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), max_height=inf, max_width=inf, relax_equalities=False, seed=1, **settings)[source]

Maximise eta subject to every constraint.

The single-objective reading of the geometric problem, with the size objectives either dropped or turned into the moving limits max_height / max_width.

Parameters:
  • algorithm (str) – Any GEMSEO optimizer; the default is differential evolution.

  • bounds (Bounds) – The design box.

  • initial (Design | None) – Starting design.

  • max_iter (int) – Evaluation budget.

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides.

  • max_height (float) – Moving upper limit on H [mm].

  • max_width (float) – Moving upper limit on B [mm].

  • relax_equalities (bool) – See build_scenario().

  • seed (int | None) – Random seed, for the stochastic algorithms that accept one.

  • **settings (Any) – Extra algorithm settings passed through to GEMSEO.

Return type:

Outcome

Returns:

The outcome, whose Outcome.feasible flag should be checked.

exlink.scenarios.refine(design=Design(a=112.7, c=91.1, I=57.0, x_b=103.0, y_b=-74.1, x_1=-18.8, e=156.2, q_1=8.0, q_2=23.1, theta_f=111.1, theta_r=-43.1), algorithm='Augmented_Lagrangian_order_0', bounds=None, relative=0.25, max_iter=400, samples=720, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), sub_algorithm_name='NELDER-MEAD', sub_algorithm_settings=None, **settings)[source]

Polish a design with the augmented Lagrangian.

The natural last step of a penalty-based workflow: take a point off the Pareto front and run an augmented Lagrangian from it, precisely because the external penalty function cannot be both accurate and well conditioned.

Parameters:
  • design (Design) – The starting design.

  • algorithm (str) – Local algorithm; the default is the augmented Lagrangian.

  • bounds (Bounds | None) – The box to search in; defaults to a window around design.

  • relative (float) – Half-width of that default window, as a fraction of |X_0|.

  • max_iter (int) – Outer-iteration budget.

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides.

  • sub_algorithm_name (str) – Solver for the augmented Lagrangian sub-problems.

  • sub_algorithm_settings (dict[str, Any] | None) – Settings for that solver.

  • **settings (Any) – Extra algorithm settings passed through to GEMSEO.

Return type:

Outcome

Returns:

The refined outcome.

exlink.scenarios.pareto_front(algorithm='PYMOO_NSGA2', bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, pop_size=200, max_gen=60, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=None, equality_tolerance=None, seed=1, **settings)[source]

Approximate the Pareto front of (-eta, H, B).

Parameters:
  • algorithm (str) – A multi-objective algorithm; NSGA-II by default.

  • bounds (Bounds) – The design box.

  • initial (Design | None) – Starting design, used only by algorithms that take one.

  • pop_size (int) – Population size. Over the full space this needs hundreds of individuals to find anything; start smaller and grow.

  • max_gen (int) – Generation budget. On this problem it matters more than pop_size: seeded in a box around a good design, 20 generations still return a single point whatever the population, because the run has not yet worked its way into the thin feasible region, while 35 generations give fronts of several dozen. Spend extra budget here before spending it on population.

  • samples (int) – Crank angles per revolution. Lowered by default, because a MOEA spends tens of thousands of evaluations here.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets | None) – Constraint right-hand sides; defaults to moea_targets(), which relaxes g. Read its docstring before overriding this.

  • equality_tolerance (dict[str, float] | None) – Half-widths for the relaxed equalities; defaults to MOEA_EQUALITY_TOLERANCE.

  • seed (int) – Random seed.

  • **settings (Any) – Extra algorithm settings passed through to GEMSEO.

Return type:

Outcome

Returns:

The outcome, with Outcome.front populated and Outcome.design set to the most efficient point of the front. Its designs satisfy the relaxed problem, so Outcome.feasible will usually be False until the chosen point has been through refine().

exlink.scenarios.local_pareto(design, relative=0.1, absolute_angle=20.0, **kwargs)[source]

Run the MOEA in a box shrunk around a known-good design.

This is the move that makes the multi-objective step work at all: over the full eleven-dimensional space the MOEA returned solutions worse than the gradient-based ones, but seeded near an existing optimum it produced a usable local front. Combining several such fronts into one starting population, then re-running over the full box, is what eventually gave the published front.

Parameters:
  • design (Design) – The centre of the shrunken box.

  • relative (float) – Half-width as a fraction of |X_0| for the lengths.

  • absolute_angle (float) – Half-width in degrees for theta_f and theta_r.

  • **kwargs (Any) – Forwarded to pareto_front().

Return type:

Outcome

Returns:

The outcome of the local multi-objective run.

exlink.scenarios.sweep_moving_limits(limits, algorithm='Augmented_Lagrangian_order_0', on_height=True, **kwargs)[source]

Trace a Pareto front by walking a moving size limit downwards.

The epsilon-constraint approach to the multi-objective problem: treat H (or B) as a constraint rather than an objective, solve for maximum efficiency, tighten the limit, and repeat. Each solve is an ordinary single-objective problem, and the sequence of solutions traces the efficiency-versus-size trade-off.

Parameters:
  • limits (Iterable[float]) – The successive upper limits [mm], typically decreasing.

  • algorithm (str) – The single-objective algorithm to use at each step. The default is the augmented Lagrangian: each step has to satisfy the two equalities and the relaxed-nothing inequalities exactly, and a derivative-free method such as COBYLA does not get there reliably within a sensible budget.

  • on_height (bool) – Apply the limit to H; otherwise to B.

  • **kwargs (Any) – Forwarded to maximise_efficiency().

Return type:

list[Outcome]

Returns:

One outcome per limit, in the order given.

exlink.scenarios.format_analysis(analysis, title='analysis')[source]

Render an analysis as an aligned text table.

Return type:

str

exlink.scenarios.COUPLED_OBJECTIVE_NAMES: tuple[str, str, str, str] = ('neg_efficiency', 'height', 'width', 'total_mass')

the three geometric objectives plus mass.

Structural mass cannot appear in the quasi-static formulation at all – nothing in it determines a cross-section. It only becomes an objective once the parts are sized, and it is the objective the dynamics most affects.

Type:

f(X) = (-eta, H, B, M)^T

exlink.scenarios.COUPLED_INEQUALITY_OUTPUTS: tuple[str, ...] = ('saturation_margin', 'slenderness_margin', 'bearing_margin')

Constraints that exist only once the loads are dynamic.

saturation_margin

Positive when some member has been driven to the diameter ceiling, i.e. the sizing loop ran away rather than settling: no section is thick enough to survive the inertia its own mass creates.

slenderness_margin

Positive when a connecting link has grown thicker than a third of its length, at which point calling it a rod – and sizing it as a beam – has stopped being credible.

bearing_margin

Peak crankshaft bearing reaction against its limit.

exlink.scenarios.DEFAULT_MDA = 'MDAGaussSeidel'

Fixed-point sweep, which is what this coupling wants.

The loop gain is sub-linear (see exlink.coupled), so plain Gauss-Seidel converges without needing the coupled Jacobians a Newton MDA would demand – and the sizing step, a bisection, has no useful derivative to give it anyway.

exlink.scenarios.DEFAULT_MDA_SETTINGS: dict[str, Any] = {'max_mda_iter': 300, 'tolerance': 1e-06, 'warm_start': True}

Settings for the inner MDA, chosen against measured cost.

Every objective evaluation runs this MDA to convergence, so its settings set the price of the whole optimization. From a cold start at 2000 rev/min and 360 crank angles:

tolerance

sweeps

time

section error vs 1e-8

1e-8

56

9.3 s

–

1e-6

44

6.9 s

2e-6 mm

1e-4

31

5.1 s

2e-4 mm

1e-6 buys sub-micron sections for three quarters of the cost, which is far inside anything a part would be made to.

warm_start matters more than the tolerance: successive design points during an optimization are close together, so starting each MDA from the previous converged sections turns a ~50-sweep solve into a handful, and takes the cost per evaluation from around 9 s to under 2.

class exlink.scenarios.BearingMarginDiscipline(limit=25000.0, name='')[source]

Bases: Discipline

Turns the peak bearing reaction into a signed constraint.

A one-line discipline rather than a constraint offset, so that the limit travels with the problem and shows up in the coupling graph.

auto_detect_grammar_files: ClassVar[bool] = False

Whether to find the grammar files automatically.

exlink.scenarios.build_coupled_scenario(objective='total_mass', bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, speed_rpm=1500.0, samples=360, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), relax_equalities=True, equality_tolerance=None, max_height=inf, max_width=inf, mda_name='MDAGaussSeidel', mda_settings=None, formulation_name='MDF')[source]

Assemble the coupled scenario, under a chosen formulation.

Three disciplines take part. ExlinkDiscipline supplies the geometric objectives and constraints and is feed-forward from the design variables. The other two – DynamicsDiscipline and StructureDiscipline – are strongly coupled to each other through the section diameters and the internal load histories, and MDF puts an MDA around them so that every point the optimizer evaluates is a converged one.

Parameters:
  • objective (str | Sequence[str]) – Output name, or several for a multi-objective run.

  • bounds (Bounds) – The design box.

  • initial (Design | None) – Starting design.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • samples (int) – Crank angles per revolution.

  • spec (EngineSpec) – Fixed engine data.

  • targets (DesignTargets) – Constraint right-hand sides.

  • material (Material) – The material.

  • safety (SafetyFactors) – The design factors.

  • relax_equalities (bool) – Express the two equalities as pairs of inequalities. On by default here: the coupled problem is harder, and few algorithms that cope with it also take equality constraints.

  • equality_tolerance (dict[str, float] | None) – Overrides for the relaxed half-widths.

  • max_height (float) – Moving upper limit on H [mm].

  • max_width (float) – Moving upper limit on B [mm].

  • mda_name (str) – Inner MDA.

  • mda_settings (dict[str, Any] | None) – Overrides for DEFAULT_MDA_SETTINGS.

  • formulation_name (str) – "MDF" (the default: converge the MDA at every optimizer iteration, so every point evaluated is a real engine) or "IDF" (hand the diameters to the optimizer as design variables with consistency constraints, so no inner iteration runs but the design space grows by seven variables and seven equalities). See exlink.formulations for a measured comparison.

Return type:

BaseScenario

Returns:

A scenario ready to execute.

exlink.scenarios.DEFAULT_COUPLED_ALGORITHM = 'SLSQP'

Single-objective solver for the coupled problem.

Gradient-based, and not by preference. The feasible set is a sliver – two equalities 0.1 wide inside an eleven-dimensional box with sides of tens of millimetres – and a derivative-free method has no direction to move in: COBYLA returns its starting point unchanged after 120 evaluations and 313 s. With the analytic Jacobians of exlink.jacobian and exlink.dynamics_jacobian, SLSQP takes the same problem from 1.04 kg to 0.23 kg in 40 evaluations.

exlink.scenarios.minimise_mass(algorithm='SLSQP', bounds=None, initial=None, speed_rpm=1500.0, max_iter=60, relative=0.25, min_efficiency=0.25, **kwargs)[source]

Minimise total moving mass at a floor on efficiency.

The natural single-objective reading of the coupled problem: efficiency becomes a constraint to hold, and the mass the dynamics creates becomes the thing to reduce.

Budget realistically, and note that the budget depends entirely on whether the solver has gradients. With them (the default) a few dozen evaluations suffice: 40 take the published geometry from 1.04 kg to 0.23 kg at 2000 rev/min. Without them a derivative-free search over eleven variables needs several hundred, and on this feasible set may never move at all.

Parameters:
  • algorithm (str) – A single-objective GEMSEO optimizer.

  • bounds (Bounds | None) – The design box; defaults to a window around initial.

  • initial (Design | None) – Starting design; defaults to the refined reference.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • max_iter (int) – Evaluation budget.

  • relative (float) – Half-width of the default box, as a fraction of |X_0|.

  • min_efficiency (float) – Floor on eta.

  • **kwargs (Any) – Forwarded to build_coupled_scenario().

Return type:

Outcome

Returns:

The outcome; its Outcome.design is the lightest design found.

exlink.scenarios.format_coupled(result, title='coupled sizing')[source]

Render a CoupledResult as an aligned table.

Return type:

str

exlink.scenarios.EFFICIENCY_MASS_OBJECTIVES: tuple[str, str] = ('neg_efficiency', 'total_mass')

the trade that only exists once the parts are sized.

The four-way front over COUPLED_OBJECTIVE_NAMES is available, but H, B and M are strongly correlated – a bigger mechanism is a heavier one – so most of it is redundant. Efficiency against mass is the trade with real tension in it, because the geometry that maximises the quasi-static lever arm is the geometry that makes the parts heavy.

Type:

(-eta, M)

exlink.scenarios.sweep_efficiency_floor(floors, speed_rpm=1500.0, algorithm='SLSQP', initial=None, relative=0.3, max_iter=60, **kwargs)[source]

Trace the efficiency-versus-mass trade by epsilon-constraint.

Minimise mass subject to eta >= floor, for a ladder of floors, warm starting each solve from the last. This is the practical way to get the coupled front: every step is an ordinary single-objective problem on the unrelaxed constraints, and a handful of local solves replaces the tens of thousands of MDA-backed evaluations a population method would need.

Parameters:
  • floors (Iterable[float]) – Efficiency floors to impose, typically decreasing so that each solve starts from a feasible point found by the last.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • algorithm (str) – The single-objective algorithm used at each step.

  • initial (Design | None) – Starting design; defaults to the refined reference.

  • relative (float) – Half-width of the design box, as a fraction of |X_0|.

  • max_iter (int) – Evaluation budget per step.

  • **kwargs (Any) – Forwarded to minimise_mass().

Return type:

list[Outcome]

Returns:

One outcome per floor, in the order given.

exlink.scenarios.coupled_pareto(objective=('neg_efficiency', 'total_mass'), algorithm='PYMOO_NSGA2', bounds=None, initial=None, speed_rpm=1500.0, pop_size=40, max_gen=20, relative=0.25, seed=1, **kwargs)[source]

Approximate a Pareto front of the coupled problem with a MOEA.

Budget with care. Every individual of every generation runs an MDA to convergence, so the cost is pop_size * max_gen MDAs – the defaults here are an order of magnitude smaller than the standalone pareto_front() for that reason, and are a starting point rather than a converged front. sweep_efficiency_floor() reaches the same trade for a small fraction of the work and is the better tool unless a genuinely non-convex front is suspected.

Parameters:
  • objective (Sequence[str]) – Output names to trade off.

  • algorithm (str) – A multi-objective algorithm.

  • bounds (Bounds | None) – The design box; defaults to a window around initial.

  • initial (Design | None) – Starting design; defaults to the refined reference.

  • speed_rpm (float) – Crankshaft speed [rev/min].

  • pop_size (int) – Population size.

  • max_gen (int) – Generation budget.

  • relative (float) – Half-width of the default box.

  • seed (int) – Random seed.

  • **kwargs (Any) – Forwarded to build_coupled_scenario().

Return type:

Outcome

Returns:

The outcome, with Outcome.front populated.

exlink.scenarios.RANGE_INEQUALITY_OUTPUTS: tuple[str, ...] = ('runs_margin', 'gear_margin')

Constraints that exist only once the vehicle is in the problem.

runs_margin

Positive when the engine produces net work: friction must not exceed the indicated work. A near-singular linkage at speed can fail this outright, and nothing in the geometric problem would have noticed.

gear_margin

Positive when the gear pair is inside its face-width limit, i.e. the mesh can be kept aligned across the tooth face.

exlink.scenarios.build_range_scenario(bounds=Bounds(lower=array([20., 20., 20., -200., -200., -100., 40., 2., 2., -180., -180.]), upper=array([250., 250., 150., 250., 200., 100., 300., 60., 80., 180., 180.])), initial=None, speed_rpm=1500.0, samples=360, vehicle=None, module=None, teeth=None, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), material=Material(name='42CrMo4 quenched and tempered', density=7.85e-09, youngs_modulus=210000.0, yield_strength=700.0, ultimate_strength=900.0, surface_factor_a=4.51, surface_factor_b=-0.265, reliability_factor=0.814, temperature_factor=1.0), safety=SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0), relax_equalities=True, beta_target=None, grade=8, equality_tolerance=None, mda_name='MDAGaussSeidel', mda_settings=None)[source]

Assemble the range problem: five disciplines, one objective, MDF.

The discipline graph is the argument of the whole study:

ExlinkDiscipline      feed-forward: geometry -> eta, H, B, constraints
     |
     |   +--------------------------------------+
     |   |  DynamicsDiscipline <==> Structure    |  <- MDA (strong)
     |   +--------------------------------------+
     |                     | diameters
     v                     v
         RangeDiscipline  -->  neg_range

RangeDiscipline sits downstream of the MDA and consumes its converged coupling variable, so every point the optimizer evaluates is a range computed on a self-consistent structure.

The gear pair is held fixed

The module and tooth count are always pinned here, never left to the automatic choice inside exlink.gears.size_pair(). This is not a convenience, and getting it wrong is instructive.

Letting the module float makes the objective a step function of I: the lightest workable module changes at a threshold, and the range jumps 40 km/L across it. A central difference straddling such a threshold returns a gradient of order 1e5 against a true gradient of order 1e3, and SLSQP – handed a quadratic subproblem built from two constraint gradients that are both quantisation noise – rejects it as “inequality constraints incompatible” and stops at the starting point without evaluating anything.

Pinning the pair is also simply the correct treatment. With a 2:1 ratio, choosing (m, z) fixes I = 1.5 m z, so I leaves the design space entirely: the continuous problem has ten variables, not eleven, and the discrete choice is enumerated outside it by maximise_range().

type bounds:

Bounds

param bounds:

The design box.

type initial:

Design | None

param initial:

Starting design.

type speed_rpm:

float

param speed_rpm:

Crankshaft speed [rev/min]. Held fixed; see maximise_range(), which sweeps it.

type samples:

int

param samples:

Crank angles per revolution.

type vehicle:

Any

param vehicle:

The car; a default Prototype-class entry if omitted.

type module:

float | None

param module:

Gear module [mm]. Derived from initial if omitted, then held fixed for the whole run.

type teeth:

int | None

param teeth:

Teeth on the small gear; derived likewise.

type spec:

EngineSpec

param spec:

Fixed engine data.

type targets:

DesignTargets

param targets:

Constraint right-hand sides.

type material:

Material

param material:

The material.

type safety:

SafetyFactors

param safety:

The design factors.

type relax_equalities:

bool

param relax_equalities:

Express the two equalities as pairs of inequalities.

type equality_tolerance:

dict[str, float] | None

param equality_tolerance:

Overrides for the relaxed half-widths.

type mda_name:

str

param mda_name:

Inner MDA.

type mda_settings:

dict[str, Any] | None

param mda_settings:

Overrides for DEFAULT_MDA_SETTINGS.

type beta_target:

float | None

param beta_target:

When given, the search is reliability-constrained: a FailureProbabilityDiscipline is added and system_beta >= beta_target attached, so the optimizer must hold a system reliability index rather than merely satisfy the nominal constraints. None leaves the problem deterministic.

type grade:

int

param grade:

ISO 286 IT grade assumed for the machined dimensions when beta_target is set. Ignored otherwise.

rtype:

BaseScenario

returns:

A scenario ready to execute, over ten continuous variables.

exlink.scenarios.maximise_range(algorithm='SLSQP', bounds=None, initial=None, speeds=(800.0, 1000.0, 1250.0), candidates=3, max_iter=40, relative=0.3, **kwargs)[source]

Maximise kilometres per litre over a mixed discrete/continuous problem.

Two variables are enumerated outside the gradient-based solve, for two different reasons.

The gear pair, because it is genuinely discrete. A module comes off the ISO 54 list and a tooth count is an integer, and together they pin I = 1.5 m z. There is no meaningful derivative with respect to either, and pretending otherwise is what breaks the optimizer – see build_range_scenario(). Each candidate pair defines its own ten-variable continuous problem, and the equality constraints STE = 74 and epsilon = 16 then have to be re-satisfied by the remaining variables, since I was one of the variables that used to satisfy them. That “choose the integers, repair the continuum” structure is the whole mixed-integer content of the problem.

The speed, because it enters as a construction parameter. The dynamics discipline is built at a speed. A sweep is also the honest treatment: the trade it governs is sharp and non-convex – flywheel mass falls as 1 / omega^2 while structural mass climbs with omega^2 – and seeing the curve is worth more than seeing one converged number.

Parameters:
  • algorithm (str) – A single-objective GEMSEO optimizer. Gradient-based by default; see DEFAULT_COUPLED_ALGORITHM for why.

  • bounds (Bounds | None) – The design box; defaults to a window around initial.

  • initial (Design | None) – Starting design; defaults to the coupled reference.

  • speeds (Sequence[float]) – Crankshaft speeds to optimize at [rev/min].

  • candidates (int) – Gear pairs to try, nearest first on the I lattice.

  • max_iter (int) – Evaluation budget per (speed, gear pair).

  • relative (float) – Half-width of the default box, as a fraction of |X_0|.

  • **kwargs (Any) – Forwarded to build_range_scenario().

Return type:

tuple[Design, float, dict[tuple[float, float, int], float]]

Returns:

(best_design, best_km_per_litre, history), where the history maps (speed, module, teeth) to the range reached. Combinations where nothing feasible was found map to zero.

exlink.scenarios.project_onto_equalities(design, samples=360, targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), iterations=8, tolerance=1e-09, fixed=('I',))[source]

Restore STE = 74 and epsilon = 16 by the smallest design change.

A gradient solver stops within its convergence tolerance of the constraints it was given, not exactly on them. On this problem SLSQP routinely finishes a few parts in ten thousand outside the relaxed equality band – physically irrelevant, since the tolerance study puts the machining standard deviation of the stroke two orders of magnitude higher, but enough for a strict feasibility check to reject the design.

Rather than loosen the check, the design is projected back onto the equality manifold. With the exact Jacobian rows for both equalities from exlink.jacobian, the minimum-norm Newton step is

\[\Delta X = -J^{+} r\]

which is the smallest change in the design that restores the equalities, so everything else about the design – and hence its range – moves as little as it can. Two or three iterations converge to machine precision.

Parameters:
  • design (Design) – A design near, but not on, the equality manifold.

  • samples (int) – Crank angles per revolution.

  • targets (DesignTargets) – Constraint right-hand sides.

  • spec (EngineSpec) – Fixed engine data.

  • iterations (int) – Newton step limit. The residual is measured at samples, so a caller that later checks feasibility at a different crank-angle resolution should project at that resolution: STE and g both shift slightly with it.

  • tolerance (float) – Residual norm to stop at.

  • fixed (Sequence[str]) – Variables the step may not touch. I by default, because it is pinned to the gear lattice: a projection free to move it would hand back a mechanism whose gears cannot be cut, which is exactly what the pinning exists to prevent.

Return type:

Design

Returns:

The projected design, or the input unchanged if it cannot be analysed or the Jacobian is singular there.

Warning

Restoring the equalities can break the top-dead-centre gap. The minimum-norm step on this problem is a few hundredths of a millimetre, and that is enough to move g from 0.001 mm to 0.020 mm – twice its bound. This is not a defect in the projection; it is the same hypersensitivity that exlink.robustness measures against machining tolerance, seen once more. Always re-check the inequalities afterwards rather than assuming a projected design is feasible.

class exlink.scenarios.MultiStartOutcome(design, value, starts, feasible_starts, values=<factory>, seconds=0.0)[source]

Bases: object

What a multi-start search found, and how varied its local optima were.

design: Design

The best feasible design found.

value: float

Its objective value.

starts: int
feasible_starts: int

How many restarts ended somewhere feasible.

values: list[float]

Objective at every feasible local solution found.

seconds: float = 0.0
property spread: float

Range of the feasible local optima, as a fraction of the best.

Near zero means every restart reached effectively the same point, which is evidence – not proof – that the single-start answer was not merely the nearest local optimum.

exlink.scenarios.multistart(objective='neg_efficiency', initial=None, bounds=None, n_start=10, spread=0.08, max_iter=60, algorithm='SLSQP', samples=720, seed=0, build=None, evaluate_value=None, **kwargs)[source]

Restart a gradient solve from perturbed points on the feasible manifold.

Why not GEMSEO’s MultiStart

Because on this problem it cannot work, and the reason is structural rather than a matter of budget. MultiStart draws its restarts from an LHS over the design box, and this feasible set contains two equality constraints – STE = 74 and epsilon = 16 – so it is a codimension-two manifold and has measure zero. Sampling it uniformly succeeds with probability zero, and that is what happens: no feasible point in 12 000 uniform samples, not even within 10 % of a design known to be feasible. Only 5.8 % of the global box is analysable at all. Run anyway, MultiStart returns its starting point unchanged.

What works instead

Restart from points that are on the manifold. Each restart perturbs the incumbent, projects the perturbation back onto the two equalities with project_onto_equalities() – a deterministic Newton step from the analytic Jacobians, so the start is feasible in the equalities by construction – and lets the optimizer restore the inequalities from there.

That is the standard treatment for an equality-constrained problem, and it is what makes the question answerable at all: whether the single-start answers this package reports are local optima or something better.

type objective:

str

param objective:

Output to minimise.

type initial:

Design | None

param initial:

Incumbent to perturb around; the refined reference if omitted.

type bounds:

Bounds | None

param bounds:

The design box.

type n_start:

int

param n_start:

Restarts, including the incumbent itself.

type spread:

float

param spread:

Perturbation size, as a fraction of each variable’s magnitude.

type max_iter:

int

param max_iter:

Budget per restart.

type algorithm:

str

param algorithm:

A single-objective GEMSEO optimizer.

type samples:

int

param samples:

Crank angles per revolution.

type seed:

int

param seed:

Random seed, so the search is reproducible.

type build:

Any

param build:

Scenario builder taking (bounds, initial); the geometric build_scenario() if omitted.

type evaluate_value:

Any

param evaluate_value:

Maps a design to its objective; reads the analysis if omitted.

type **kwargs:

Any

param **kwargs:

Forwarded to the builder.

rtype:

MultiStartOutcome

returns:

The best feasible design found, and the spread of the local optima.

Visualisation

Static matplotlib views of a design and of an optimization run.

Every figure here doubles as a diagnostic:

  • plot_motion() shows the four monotone phases of the piston motion, against crankshaft angle over the 720 deg of a cycle. A design that is not Atkinson shows up immediately – either as a single up-and-down (a plain Otto motion) or as two top dead centres at visibly different heights, which is the g constraint made visible.

  • plot_cycle() shows the approximate Atkinson cycle in the p-V plane, where the expansion loop being longer than the compression loop is the whole point of the linkage.

  • plot_pareto() shows the efficiency-versus-size trade-off produced by a multi-objective run.

exlink.plots.STYLE: dict[str, Any] = {'axes.grid': True, 'axes.labelsize': 9, 'axes.spines.right': False, 'axes.spines.top': False, 'axes.titlesize': 10, 'figure.facecolor': 'white', 'grid.alpha': 0.25, 'grid.linestyle': ':', 'legend.fontsize': 8, 'xtick.labelsize': 8, 'ytick.labelsize': 8}

A light, uncluttered style applied by every plotting helper.

exlink.plots.JOINT_NAMES: tuple[str, ...] = ('R1', 'R2', 'Q', 'A', 'D', 'E', 'P', 'H')

Joints of the linkage, in the order exlink.kinematics defines them.

exlink.plots.JOINT_LABEL_OFFSET: dict[str, tuple[float, float]] = {'A': (8.0, 4.0), 'D': (8.0, -8.0), 'E': (-16.0, -6.0), 'H': (8.0, 4.0), 'P': (8.0, 0.0), 'Q': (-16.0, 2.0), 'R1': (8.0, -12.0), 'R2': (8.0, -4.0)}

Where each joint label sits relative to its joint, in points.

exlink.plots.crankshaft_degrees(theta_1, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659))[source]

Crankshaft angle over the cycle [deg], from the analysis angle [rad].

The analysis is parametrised on theta_1, one turn of which is one full cycle, but power is taken from the shaft the gear pair turns twice as fast (output_revolutions_per_cycle). A cycle is therefore 720 deg of crankshaft, exactly as on a conventional four-stroke, and that is the abscissa every figure here uses: a p-V loop or a turning-moment diagram is read against the crankshaft, and one drawn against a half-speed shaft cannot be compared with anyone else’s.

Parameters:
Return type:

ndarray[tuple[Any, ...], dtype[float64]]

Returns:

The crankshaft angle [deg].

exlink.plots.plot_motion(design, analysis=None, **kwargs)[source]

Plot the piston motion over the 720 deg of crankshaft the cycle spans.

Return type:

Figure

exlink.plots.plot_cycle(design, analysis=None, **kwargs)[source]

Plot the approximate Atkinson cycle in the p-V plane.

Return type:

Figure

exlink.plots.plot_torque(design, analysis=None, **kwargs)[source]

Plot the crankshaft torque over one revolution.

Return type:

Figure

exlink.plots.plot_mechanism(design, crank_angles=(0.0, 180.0, 360.0, 540.0), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), analysis=None, **kwargs)[source]

Draw the mechanism at several crankshaft angles side by side.

Parameters:
Return type:

Figure

Returns:

The figure.

exlink.plots.plot_variables(design, theta_1=45.0, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), analysis=None, **kwargs)[source]

Draw the mechanism with all eleven design variables dimensioned.

The design vector is a list of symbols until someone sees where each one sits on the linkage, so this is the figure the use case opens with. Every entry of X appears exactly once: the four member lengths and two crank throws as dimensions along the members they measure, the inter-axle distance and its orientation at the shafts, the local coordinates of E in the frame the trigonal link carries, the cylinder offset as a horizontal dimension from the R1 axis, and the crank dephasing through the kinematic relation that defines it.

Parameters:
  • design (Design) – The mechanism to draw.

  • theta_1 (float) – Crank angle to freeze the linkage at [deg]. The default opens the triangle enough that no two dimensions overlap.

  • spec (EngineSpec) – Fixed engine data.

  • analysis (Analysis | None) – A precomputed analysis at full resolution.

  • **kwargs (Any) – Forwarded to exlink.model.analyse().

Return type:

Figure

Returns:

The figure.

exlink.plots.plot_overview(design, analysis=None, **kwargs)[source]

Motion, cycle and torque on one page.

Return type:

Figure

exlink.plots.plot_pareto(designs, analyses=None, highlight=None, **kwargs)[source]

Plot a Pareto front of (eta, H, B).

Two panels: efficiency against each envelope dimension, with the marker colour carrying the third objective, so the whole three-dimensional front is readable on a flat page.

Parameters:
Return type:

Figure

Returns:

The figure.

Raises:

ValueError – If no valid design is given.

exlink.plots.plot_convergence(outcome)[source]

Plot the objective history of a solved scenario.

Parameters:

outcome (Any) – An exlink.scenarios.Outcome.

Return type:

Figure

Returns:

The figure.

exlink.plots.plot_bearing_loads(results, labels)[source]

Compare a joint reaction across several engine speeds.

Shows what adding inertia actually does: the mean torque is untouched, but the peak reaction climbs as the square of speed, and that peak is what sizes the parts.

Parameters:
Return type:

Figure

Returns:

The figure.

exlink.plots.plot_sizing(result)[source]

Show the sized sections, what drove each, and where the mass went.

Parameters:

result (Any) – A CoupledResult.

Return type:

Figure

Returns:

The figure.

exlink.plots.plot_mass_vs_speed(speeds, results)[source]

Structural mass against engine speed, on log axes.

The slope is the story: a bending-critical member needs d ~ F^(1/3), so its mass goes as F^(2/3) while the inertia force it creates goes as its own mass – composing to m ~ (C Omega^2)^3. A cubic-in-acceleration, sixth-power-in-speed sensitivity is why the answer collapses so sharply.

Parameters:
  • speeds (Sequence[float]) – Crankshaft speeds [rev/min].

  • results (Sequence[Any]) – The coupled result at each, feasible or not.

Return type:

Figure

Returns:

The figure.

exlink.plots.plot_efficiency_mass(designs, results, labels=None, reference=None, **kwargs)[source]

Efficiency against structural mass – the trade the geometric problem cannot see.

Nothing in the quasi-static formulation determines a cross-section, so mass is not an objective it can express. Once the parts are sized it becomes the one the dynamics most affects, and it pulls against efficiency through the transmission angle: the geometry with the longest lever arm sits nearest the singularity, where the accelerations – and so the sections – are worst.

Parameters:
Return type:

Figure

Returns:

The figure.

Matplotlib animation of the mechanism turning through a cycle.

An animation shows in a couple of seconds what static sketches take a chapter to explain – that the piston reaches top dead centre twice per crankshaft revolution, and that the two bottom dead centres differ, which is the whole point of the Atkinson linkage.

Three entry points:

animate()

The mechanism alone.

animate_dashboard()

The mechanism beside the piston motion, the p-V diagram and the torque curve, with a marker tracking the current crank angle on each – the view that makes the link between geometry and thermodynamics obvious.

animate_formulations()

Every formulation’s final design, turning side by side on a common scale. Each panel is what a different objective converged to from the same starting point, so the panels differ only in what was asked of them – which makes the proportions readable as a consequence of the objective rather than as four unrelated mechanisms.

Per-link line styles, shared by the static and animated views.

exlink.animation.animate(design, frames=180, interval=40, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), figsize=(6.0, 7.0), analysis=None)[source]

Animate the mechanism through one crankshaft revolution.

Parameters:
  • design (Design) – The mechanism to animate.

  • frames (int) – Number of crank angles in the animation.

  • interval (int) – Delay between frames [ms].

  • spec (EngineSpec) – Fixed engine data.

  • figsize (tuple[float, float]) – Figure size [in].

  • analysis (Analysis | None) – A precomputed analysis; recomputed at frames resolution if omitted.

Return type:

FuncAnimation

Returns:

The animation. Keep a reference to it, or matplotlib will garbage collect it before it plays; save() handles that for you.

Raises:

ValueError – If the design cannot be analysed.

exlink.animation.animate_dashboard(design, frames=180, interval=40, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), figsize=(12.0, 7.5), analysis=None)[source]

Animate the mechanism alongside its motion, cycle and torque curves.

A marker on each curve tracks the crank angle shown by the linkage, so the two top dead centres in the piston motion, the two loops of the p-V diagram and the torque peaks can be read off against the geometry that produces them.

Parameters:
  • design (Design) – The mechanism to animate.

  • frames (int) – Number of crank angles in the animation.

  • interval (int) – Delay between frames [ms].

  • spec (EngineSpec) – Fixed engine data.

  • figsize (tuple[float, float]) – Figure size [in].

  • analysis (Analysis | None) – A precomputed analysis.

Return type:

FuncAnimation

Returns:

The animation.

Raises:

ValueError – If the design cannot be analysed.

exlink.animation.animate_formulations(designs=None, frames=120, interval=40, spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), panel_size=(3.2, 4.3), common_scale=True)[source]

Animate each formulation’s final design, side by side.

The three static views of a converged design – a table of metrics, a mechanism sketch, a mass budget – all answer “what did it reach?”. None of them answers “what does the objective do to the mechanism?”, which is the question this study is actually about. Four linkages turning together answer it directly: the geometric optimum is visibly long-limbed and close to alignment, the coupled optimum visibly shorter and squatter, and the difference is the inertia loading that only the second formulation could see.

All panels share one set of axis limits by default, because the point is that the designs are different sizes; scaling each to fit its own frame would hide exactly the effect being shown.

Parameters:
  • designs (dict[str, Design] | None) – Label to design, in the order the panels should appear. Defaults to the four results of exlink.reference, each labelled with its formulation and its headline number.

  • frames (int) – Crank angles in the animation.

  • interval (int) – Delay between frames [ms].

  • spec (EngineSpec) – Fixed engine data.

  • panel_size (tuple[float, float]) – Size of one panel [in].

  • common_scale (bool) – Share one set of axis limits across the panels. Set False only to inspect a single mechanism’s detail.

Return type:

FuncAnimation

Returns:

The animation. Keep a reference to it, or matplotlib will garbage collect it; save() handles that.

Raises:

ValueError – If a design cannot be analysed, or none is given.

exlink.animation.save(animation, path, fps=25, dpi=110, **kwargs)[source]

Write an animation to disk, picking a writer from the file extension.

.gif uses Pillow and always works; .mp4 needs ffmpeg on the PATH.

Parameters:
  • animation (FuncAnimation) – The animation to save.

  • path (str | Path) – Destination; .gif or .mp4.

  • fps (int) – Frames per second.

  • dpi (int) – Resolution.

  • **kwargs (Any) – Forwarded to save().

Return type:

Path

Returns:

The path written.

The two standard MDO pictures of the problem, drawn by GEMSEO.

Section 3 argues the formulation in prose and section 4 maps it onto modules. Neither shows the shape of the problem, and two conventional diagrams do:

  • an N2 chart – the disciplines on the diagonal and their couplings off it. Everything above the diagonal is feed-forward and everything below it is feedback, so a glance says whether an MDA is needed at all. Here exactly one entry sits below the diagonal – StructureDiscipline returning the section diameters to DynamicsDiscipline – and that pair is the fixed point of section 3.3.

  • an XDSM – the same graph with the process on it: who calls whom, in what order, and how many times. It is what distinguishes MDF from IDF at a glance, and it shows the MDA sitting inside the optimizer’s loop, which is the whole content of the choice section 3.6 makes.

Both come from GEMSEO rather than being drawn by hand, so they cannot drift from the scenario the study actually solves: they are generated from the very object exlink.scenarios.build_range_scenario() hands to the optimizer.

What each output needs

The N2 chart is matplotlib and writes a PNG anywhere. The XDSM has two back-ends: an interactive HTML one that works everywhere, and pyXDSM, which emits TikZ and needs a LaTeX toolchain to compile. The pyXDSM output is the one worth publishing – the HTML renderer overlaps its node captions with the variable lists when the names are as long as this problem’s – so write_xdsm() prefers it and falls back to HTML with a warning when pdflatex is absent. Converting the PDF to a PNG for the documentation needs pdftoppm from Poppler; without it the PDF is left in place.

exlink.diagrams.PINNED_MODULE = 0.8

Gear module the published scenario is pinned to [mm].

exlink.diagrams.PINNED_TEETH = 48

Teeth on the small gear, pinning I and leaving ten continuous variables.

exlink.diagrams.RENDER_DPI = 200

Resolution the XDSM PDF is rasterised at, for the documentation.

exlink.diagrams.build_published_scenario(design=Design(a=72.2352, c=71.4395, I=57.6, x_b=70.2676, y_b=-71.2069, x_1=-31.462, e=129.7043, q_1=9.0337, q_2=23.517, theta_f=76.6957, theta_r=-52.542), spec=EngineSpec(bore=32.0, piston_length=16.0, piston_skirt=16.0, dead_volume=3000.0, p_intake=0.12, heat_capacity_ratio=1.22, explosion_ratio=1.7, gear_ratio=2.0, pressure_angle=0.3490658503988659), targets=DesignTargets(expansion_stroke=74.0, compression_ratio=16.0, max_rod_angle=10.0, max_transmission=0.985, max_tdc_gap=0.01, min_clearance=10.0, max_side_load=0.02, max_bearing_load=25000.0), **kwargs)[source]

The scenario the diagrams are drawn from.

The range problem of section 3.10 at the design section 6.4 arrives at, with the gear pair pinned as every run in the study pins it. Kept in one place so the N2 chart and the XDSM cannot be drawn from different problems.

Parameters:
Return type:

BaseScenario

Returns:

The scenario.

exlink.diagrams.write_n2(path='n2.png', scenario=None, fig_size=(11.0, 8.0))[source]

Write the N2 chart of the coupled problem.

Parameters:
  • path (str | Path) – Destination; the suffix chooses the format.

  • scenario (BaseScenario | None) – The scenario to read the disciplines from; the published one if omitted.

  • fig_size (tuple[float, float]) – Figure size [in].

Return type:

Path

Returns:

The path written.

exlink.diagrams.write_xdsm(directory='.', scenario=None, file_name='xdsm', dpi=200)[source]

Write the XDSM of the coupled problem, as a PNG where the tools allow.

Prefers the pyXDSM back-end, which needs pdflatex, and rasterises its PDF with pdftoppm. Falls back a step at a time: to the PDF if Poppler is missing, and to the interactive HTML if LaTeX is missing. The return value says which was produced.

Parameters:
  • directory (str | Path) – Directory to write into.

  • scenario (BaseScenario | None) – The scenario; the published one if omitted.

  • file_name (str) – Stem of the written file.

  • dpi (int) – Resolution for the PNG.

Return type:

Path

Returns:

The path written – .png, .pdf or .html.

Command line

Command-line interface: exlink <command>.

Commands

analyse Print every objective and constraint of a design. animate Write a GIF or MP4 of the mechanism turning. plot Write the motion / cycle / torque / mechanism figures. optimize Maximise efficiency subject to all the constraints. refine Polish a design with the augmented Lagrangian. pareto Approximate the Pareto front of (-eta, H, B). cycle Score a design over a schedule of speeds, not one point. size Size the parts, solid or tubular (--bore).

A design is given either by name (published, refined) or as a JSON file written by --save, so the commands chain:

exlink optimize --save best.json
exlink refine --design best.json --save final.json
exlink animate --design final.json -o final.gif
exlink animate --formulations -o formulations.gif
exlink.cli.load_design(reference)[source]

Resolve a design from a name or a JSON file path.

Parameters:

reference (str | None) – One of NAMED_DESIGNS, a path to a JSON file, or None for RELIABLE_DESIGN, the best design the study found and the one the figures are drawn from.

Return type:

Design

Returns:

The design.

Raises:

SystemExit – If the reference cannot be resolved.

exlink.cli.save_design(design, path)[source]

Write a design to a JSON file.

Return type:

Path

exlink.cli.build_parser()[source]

Build the argument parser.

Return type:

ArgumentParser

exlink.cli.configure_logging(verbose)[source]

Quieten GEMSEO unless the user asked for its output.

GEMSEO installs its own stream handler and sets the gemseo logger to INFO when it is imported, which overwrites anything configured before that. Importing it here first means the level set below is the one that sticks; otherwise every solver iteration is printed.

Parameters:

verbose (bool) – Whether to let GEMSEO log at INFO.

Return type:

None

exlink.cli.main(argv=None)[source]

Entry point for the exlink console script.

Return type:

int

Reference designs

Reference designs: one historical baseline and three results.

PUBLISHED_DESIGN is a historical baseline – a design vector carried over from an earlier, unpublished MATLAB study of this mechanism (Universite de Technologie de Compiegne, 2015) by the same author. It is kept as a starting point, not as a result, and everything needed to use it is stated here.

Why it is a starting point and not a result

Re-analysed as printed it violates five constraints. Most tellingly it gives a top-dead-centre gap of g = 8.5 mm against a 0.01 mm bound, and STE = 79.6 mm against a required 74 mm. Since g is precisely the quantity such an optimization drives to zero, this vector cannot be the design that produced the properties recorded alongside it in that study.

Rounding does not explain it. The vector is given to four significant figures, and perturbing each variable by its rounding half-width moves STE by less than 0.2 mm.

The real reason is conditioning, and it generalises. The vector sits at W = 0.982, a hair from the transmission-angle singularity at W = 1, where the piston motion is violently sensitive to the link lengths. A design in that region is not reproducible to four digits across two independent implementations – a property of where the optimum sits, not of either implementation. That same proximity to the singularity is what exlink.dynamics later shows to be the expensive place to be once inertia enters the load path.

Sweeping the two crank angles about the baseline values does recover its recorded figures closely – theta_f = 86 deg, theta_r = -30 deg gives W = 0.9817 and mra = 6.7 deg – so the model agrees; it is the vector that is not reproducible.

The results

REFINED_DESIGN

augmented Lagrangian, eta = 27.87 %

GRADIENT_DESIGN

SLSQP with exact gradients, eta = 30.77 %

COUPLED_DESIGN

coupled sizing, 0.234 kg of moving mass

RANGE_DESIGN

maximum range, constraints checked at the end

RELIABLE_DESIGN

the study’s result, 3395 km/L at P_f = 1.3e-3

Speeds are quoted at the output shaft throughout, which turns twice per cycle (exlink.constants.EngineSpec.output_revolutions_per_cycle); the speed_rpm the analysis functions take is half of it.

exlink.reference.PUBLISHED_DESIGN = Design(a=112.7, c=91.1, I=57.0, x_b=103.0, y_b=-74.1, x_1=-18.8, e=156.2, q_1=8.0, q_2=23.1, theta_f=111.1, theta_r=-43.1)

the design vector from the earlier MATLAB study.

Infeasible when re-analysed – see the module docstring. Used as a starting point for the optimizers, and as the “before” in every comparison here.

Type:

Historical baseline

exlink.reference.PUBLISHED_METRICS: dict[str, float] = {'clearance': 68.9, 'compatibility': 0.9817, 'compression_ratio': 15.98, 'efficiency': 0.2776, 'expansion_stroke': 73.98, 'height': 256.7, 'rod_angle': 7.5499, 'side_load_ratio': 0.02, 'tdc_gap': 0.0069, 'torque_pressure_ratio': 0.9446, 'width': 156.2}

The properties tabulated for that design.

torque_pressure_ratio was recorded there as “94.46%”. With phi = M_r,ave / p_ave that quantity has the dimension of a volume and no normalisation turning it into a percentage was given, so it is kept here for completeness but never compared against.

exlink.reference.REFINED_DESIGN = Design(a=109.892413, c=88.629613, I=56.547758, x_b=99.798999, y_b=-72.80327, x_1=-18.691005, e=137.158796, q_1=8.081368, q_2=23.008134, theta_f=106.385886, theta_r=-23.199421)

A genuinely feasible design, obtained from PUBLISHED_DESIGN.

Produced by running exlink.scenarios.refine() (augmented Lagrangian, Nelder-Mead sub-problems) from the published table, in successively tighter boxes, with a small margin held on the active constraints so that the design stays feasible at any crank-angle resolution.

It lands close to the properties recorded in the earlier study, which is worth noting even though those numbers are not independently verifiable:

quantity

this

earlier study

eta

27.87 %

27.76 %

W

0.9811

0.9817

mra

8.68 deg

7.55 deg

g

0.0060 mm

0.0069 mm

STE

74.000 mm

73.98 mm

eps

16.000

15.98

gamma

0.0181

0.02

B

151.9 mm

156.2 mm

H

238.6 mm

256.7 mm

d is not compared: no geometric construction for the clearance was recorded, so exlink.metrics.cylinder_clearance() defines its own (see its docstring).

Reproduce it with:

exlink refine --design published --save refined.json
exlink.reference.REFINED_METRICS: dict[str, float] = {'clearance': 57.65, 'compatibility': 0.98115, 'compression_ratio': 16.0, 'efficiency': 0.27874, 'expansion_stroke': 74.0, 'height': 238.55, 'rod_angle': 8.679, 'side_load_ratio': 0.0181, 'tdc_gap': 0.006, 'width': 151.86}

Properties of REFINED_DESIGN, at samples=1440.

exlink.reference.GRADIENT_DESIGN = Design(a=104.115553, c=95.061896, I=50.326843, x_b=84.708674, y_b=-87.482327, x_1=-19.922767, e=186.341296, q_1=7.777963, q_2=24.567851, theta_f=91.1, theta_r=-38.844734)

What a gradient-based search finds, starting from the published table.

SLSQP with the exact Jacobians of exlink.jacobian, on the single-objective geometric problem (maximise eta subject to every constraint), started from PUBLISHED_DESIGN. It reaches eta = 30.77 % against the 27.87 % of REFINED_DESIGN, and is feasible at every crank-angle resolution from 720 samples up.

It is not a strictly better engine, and the comparison should not be read that way: with no limit on the envelope in this formulation, it buys the efficiency partly with size, growing to H = 320 mm against 239 mm. What it does show is that a derivative-free polish stops well short of the efficiency available at comparable feasibility – three points of it – and that the limit is the method rather than the mechanism.

Reproduce it with:

exlink optimize --design published --algorithm SLSQP --gradients
exlink.reference.GRADIENT_METRICS: dict[str, float] = {'clearance': 103.92, 'compatibility': 0.985, 'compression_ratio': 16.001, 'efficiency': 0.30768, 'expansion_stroke': 74.001, 'height': 319.79, 'rod_angle': 9.151, 'side_load_ratio': 0.0199, 'tdc_gap': 0.00952, 'width': 159.34}

Properties of GRADIENT_DESIGN, at samples=2880.

exlink.reference.COUPLED_DESIGN = Design(a=85.841929, c=66.47221, I=57.784469, x_b=80.562685, y_b=-73.48177, x_1=-23.363749, e=102.869097, q_1=7.057353, q_2=19.807655, theta_f=86.385886, theta_r=-33.363656)

The lightest design the coupled problem yields, at 2000 rev/min.

SLSQP on the MDF formulation, with exact Jacobians through the sizing/dynamics MDA (exlink.dynamics_jacobian), minimising total moving mass subject to every constraint and a floor of 25 % on efficiency.

Against REFINED_DESIGN at the same speed:

quantity

refined

coupled

total moving mass

1.039 kg

0.234 kg

peak bearing load

12 629 N

7 504 N

H

238.5 mm

205.7 mm

W

0.9811

0.9372

eta

28.20 %

25.00 %

Four times lighter, a third off the bearing load, and a smaller envelope, for three points of efficiency – and the mechanism has moved well away from the transmission-angle singularity, which is where the mass was going.

Feasible at every crank-angle resolution from 360 to 2880 samples. The search held a small margin on the constraints SLSQP drives hardest (g, mra, gamma), because a gradient method converges onto its active bounds and those metrics shift slightly with resolution.

Reproduce it with:

exlink optimize-mass --rpm 1000 --gradients
exlink.reference.COUPLED_METRICS: dict[str, float] = {'compatibility': 0.93717, 'compression_ratio': 15.97, 'efficiency': 0.24933, 'expansion_stroke': 73.97, 'peak_bearing_load': 7504.0, 'rod_angle': 9.701, 'tdc_gap': 0.00697, 'total_mass_kg': 0.2343}

Properties of COUPLED_DESIGN, at samples=1440, 2000 rev/min.

exlink.reference.RANGE_DESIGN = Design(a=76.0262, c=71.037, I=57.6, x_b=72.9272, y_b=-74.1048, x_1=-31.5411, e=127.6415, q_1=8.2337, q_2=22.6621, theta_f=87.4537, theta_r=-45.2847)

The best design the vehicle-level problem produced, and what it does not achieve.

SLSQP on neg_range with the gear pair pinned to m = 0.8, z = 48 (hence I = 57.6) at 2000 rev/min, started from COUPLED_DESIGN. It reaches 3388 km/L against 3338, on an engine of 12.47 kg, and it satisfies every inequality constraint including the top-dead-centre gap, at g = 0.0009 mm.

It is nonetheless not reported as feasible, and the reason is worth stating rather than smoothing over.

What it misses, and by how much

The two equalities are relaxed to |STE - 74| <= 0.05 and |epsilon - 16| <= 0.05 for the coupled problem. This design finishes at 0.0501 and 0.0501 – outside the band by 1.5e-4 mm and 6.1e-5, which is SLSQP stopping within its own convergence tolerance of the constraint it was given. For scale, exlink.robustness puts the machining standard deviation of STE at 0.020 mm, some 130 times larger. No real part would distinguish this design from one exactly on the band.

Why it is not simply projected back

Because doing so breaks something else. exlink.scenarios.project_onto_equalities() restores both equalities to machine precision with a minimum-norm step of a few hundredths of a millimetre – and that step moves g from 0.0009 mm to 0.0201 mm, twice its bound.

That is the same hypersensitivity three other measurements find:

perturbation

effect on g

IT8 machining tolerance on the members

sigma = 0.013 mm

snapping I 0.18 mm onto the gear lattice

0.003 -> 0.058 mm

minimum-norm equality projection

0.0009 -> 0.0201 mm

against a bound of 0.01 mm. All three are larger than the bound, so which number is written down decides whether a design on the equality manifold exists at all: at g <= 0.01 the manifold and the feasible region intersect in a sliver thinner than the Newton step that reaches the manifold, and at g <= 0.1 – 0.1 mm of dead-centre mismatch being 2.7 % of the clearance volume, worth 0.47 % of the range – the projected design is strictly feasible.

What to do about it

Take the wider bound, which exlink.robustness shows is also where g stops governing the reliability of the mechanism. The projected design is then the result at 3388 km/L. Under the 0.01 mm bound as written the best strictly feasible design remains COUPLED_DESIGN at 3338 km/L, and the 1.5 % difference is the price of a bound set finer than the model can resolve.

exlink.reference.RELIABLE_DESIGN = Design(a=72.2352, c=71.4395, I=57.6, x_b=70.2676, y_b=-71.2069, x_1=-31.462, e=129.7043, q_1=9.0337, q_2=23.517, theta_f=76.6957, theta_r=-52.542)

the best design that also meets a reliability target.

From exlink.synthesis.maximise_range_from_target() with beta_target = 3, started at COUPLED_DESIGN, with the gear pair pinned to m = 0.8, z = 48 and every constraint – geometric, coupled, vehicle and reliability – imposed at every step rather than checked at the end. 1352 evaluations, 61 minutes, stopped at the iteration cap, so it is a lower bound on what the formulation reaches.

range

3395 km/L

engine mass

12.94 kg

output speed

2000 rev/min (1000 cycles/min)

system P_f at IT8

1.3e-3

system beta

3.00, on its target

worst constraint

-2.2e-7

The bounds it was solved against are the relaxed ones exlink.robustness identifies – the top-dead-centre gap at 0.054 mm and both equality bands at +/- 0.15 – and the result is insensitive to the first of those: re-scored at a 0.1 mm gap the failure probability moves from 1.344e-3 to 1.339e-3, because the gap sits at beta = 8.1 either way. What binds is the band on the expansion stroke.

It is the design the figures in the documentation are drawn from.

Type:

The study’s result

exlink.reference.RELIABLE_METRICS: dict[str, float] = {'clearance': 55.5741, 'compatibility': 0.936426, 'compression_ratio': 16.1179, 'efficiency': 0.251373, 'expansion_stroke': 74.0957, 'height': 237.876, 'rod_angle': 9.67497, 'side_load_ratio': 0.0180587, 'tdc_gap': 0.000106805, 'width': 128.432}

Properties of RELIABLE_DESIGN as shipped, at samples=1440.

Of the design rounded to four decimals, which is how it is stored. That rounding moves g by 12 % – from 9.6e-05 mm to 1.07e-04 – and nothing else by more than a part in ten thousand, which is exlink.robustness’s point about that quantity restated on the result itself. Both figures are four orders of magnitude inside any bound the study considers, so it changes nothing here; it would matter if g were still bounded at 0.01 mm.