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
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:
objectA 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.- 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:
- classmethod from_mapping(mapping)[source]¶
Build a design from a
{name: value}mapping (GEMSEO input data).- Return type:
- to_array()[source]¶
Return the design as a flat vector ordered as
VARIABLE_NAMES.
- class exlink.design.Bounds(lower, upper)[source]¶
Bases:
objectBox bounds
l_b <= X <= u_bon the design vector.- 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.
- 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:
objectFixed 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
EngineSpecaround instead of editing the code.- piston_skirt: float = 16.0¶
Guided length
hof the piston inside the liner [mm].Only enters the (unused) reaction moment
M_D, so it simply defaults to the piston length.
- gear_ratio: float = 2.0¶
r1 / r2between the analysis shaft’s gear and the output shaft’s.The kinematics of
exlink.kinematicsare parametrised ontheta_1, the shaft carryingq_1and the large gear, and the four strokes complete in one turn of it. The second shaft therefore turnstheta_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, sotheta_1is the half-speed member of the pair – Honda’s own arrangement – and every engine speed the study quotes is the output shaft’s, atoutput_revolutions_per_cycletimes thespeed_rpmthe analysis functions take.
- pressure_angle: float = 0.3490658503988659¶
Gear pressure angle
alpha.20 deg is the standard involute profile and is used throughout;
alphaenters only the gear-mesh separating force.
- 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:
- output_torque(torque)[source]¶
Torque referred from
theta_1to the output shaft [N.mm].The virtual-work identity of
exlink.loadsmakesM_rthe whole engine torque referred totheta_1, so referring it to a shaft turninggear_ratiotimes 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:
- 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:
objectRight-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).
- class exlink.constants.PenaltyValues(efficiency=0.0, height=1000.0, width=1000.0)[source]¶
Bases:
objectValues substituted when a design cannot be analysed at all.
eta(X) = 0,H(X) = 1000,B(X) = 1000are substituted whenever the kinematic compatibility conditions (4a)/(6a) fail, or wheneverlambda(theta_1)does not have four monotone phases.
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:
theta_2 = -2 theta_1 + theta_f(1)(A, B), the closure of the four-barR1 Q A D R2(3a)T = arccos((A^2 + B^2 - a^2 - c^2) / (2 a c))(4)q = atan2(a sin T, a cos T + c)(2a)theta_T = atan2(B, A) - qandtheta_a = theta_T + T(5)theta_e = arccos((q_1 sin theta_1 - a cos theta_a - b cos(theta_b + theta_T) + x_1) / e)(6)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.metricslands within a micrometre.
- exlink.kinematics.crank_angles(samples=720)[source]¶
Return
samplescrank angles spanning one revolution, endpoint excluded.
- 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:
objectPose of every body over one crankshaft revolution.
All array attributes are shaped
(n_angles,)unless stated otherwise; point attributes are shaped(n_angles, 2).- spec: EngineSpec¶
- lam: ndarray[tuple[Any, ...], dtype[float64]]¶
Piston crown height
lambdaabove the crankshaft axis [mm].
- transmission_angle: ndarray[tuple[Any, ...], dtype[float64]]¶
T = theta_a - theta_T, the swing-rod / trigonal transmission angle.
- 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 whentheta_1is given.spec (
EngineSpec) – Fixed engine data (onlypiston_lengthis used here).theta_1 (
ndarray[tuple[Any,...],dtype[float64]] |None) – Explicit crank angles [rad]; defaults to a uniform revolution.
- Return type:
- Returns:
The pose of every body at every crank angle. Check
Kinematics.feasiblebefore trusting the angles: an incompatible design is still returned, with clipped arccosine arguments, so that the optimizer can readdelta_c1/delta_c2and 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:
IntEnumThe four strokes, in the order they follow one another.
- EXPANSION = 0¶
- EXHAUST = 1¶
- INTAKE = 2¶
- COMPRESSION = 3¶
- exception exlink.cycle.PhaseError[source]¶
Bases:
ValueErrorRaised 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:
objectDecomposition of one revolution into the four strokes.
- labels: ndarray[tuple[Any, ...], dtype[int64]]¶
Phase index of every crank angle, shaped
(n_angles,).
- expansion_strokes: tuple[float, float]¶
STEmeasured from each top dead centre separately [mm].expansion_strokeis 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.
- 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:
- 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:
objectIn-cylinder state over one crankshaft revolution.
- gauge_pressure: ndarray[tuple[Any, ...], dtype[float64]]¶
P - P0, the pressure that actually loads the piston [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:
- Return type:
- 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
- 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:
objectInternal forces and output torque over one crankshaft revolution.
- liner_moment: ndarray[tuple[Any, ...], dtype[float64]]¶
Reaction moment
M_Dabout the wrist pin [N.mm].
- trigonal_reaction: ndarray[tuple[Any, ...], dtype[float64]]¶
Reaction
Q = (Q_x, Q_y)atD, shaped(n_angles, 2)[N].
- eccentric_reaction: ndarray[tuple[Any, ...], dtype[float64]]¶
Eccentric-shaft bearing reaction
R_2, shaped(n_angles, 2)[N].
- 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:
- 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 |
|---|---|
|
average mechanical efficiency – maximised |
|
envelope along the stroke – minimised |
|
envelope across the stroke – minimised |
|
expansion stroke, must equal 74 mm |
|
compression ratio, must equal 16 |
|
peak piston-rod tilt, at most 10 deg |
|
|
|
gap between the two top dead centres, at most 0.01 mm |
|
trigonal-link to cylinder clearance, at least 10 mm |
|
|
- 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:
- 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].His measured along the stroke (y) andBacross 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:
- 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, wherey_bottomis 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 edgesAD,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:
- 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:
objectEvery objective and constraint measure of one design.
- 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
HandBhave to enter the problem as well.
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:
objectAn
Analysisthat succeeded, with none of its members optional.The plotting and animation code needs the kinematics, the cycle and the loads, all of which are
Noneon a penalised design. Narrowing once, at the entry point, keeps every downstream helper free ofNonechecks and lets the type checker verify that.- spec: EngineSpec¶
- kinematics: Kinematics¶
- thermodynamics: Thermodynamics¶
- class exlink.model.Analysis(design, spec, metrics, kinematics, thermodynamics, loads)[source]¶
Bases:
objectEverything computed about one design.
- spec: EngineSpec¶
- kinematics: Kinematics | None¶
- thermodynamics: Thermodynamics | None¶
- require_solved()[source]¶
Return a non-optional view of this analysis.
- Return type:
- 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:
- Returns:
The full analysis; check
Analysis.validbefore reading the physical quantities.
- exlink.model.objectives(analysis)[source]¶
f(X) = (-eta, H, B)^T, the three competing objectives.- Return type:
- 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:
- 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:
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_fdoes. 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:
- Return type:
- Returns:
The derivative, same shape as
values.- Raises:
ValueError – If
orderis negative.
- exlink.derivatives.winding_number(angles)[source]¶
Whole turns an angle history accumulates over one revolution.
- exlink.derivatives.ramp_derivative(angles, order=1)[source]¶
Differentiate an angle history that may accumulate whole turns.
- 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_1andd^2/dt^2 = Omega^2 d^2/dtheta_1^2exactly – notheta_1-dot-dot term.
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:
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:
objectAn isotropic metal, described well enough to size a part against it.
- property endurance_limit_uncorrected: float¶
S_e' = 0.5 S_u[MPa], capped at 700 MPa as usual for steels.
- class exlink.materials.SafetyFactors(static=1.5, fatigue=2.0, buckling=3.0)[source]¶
Bases:
objectDesign factors applied to each failure mode.
- exlink.materials.goodman_utilisation(alternating, mean, endurance, ultimate)[source]¶
Goodman utilisation;
<= 1 / n_fis safe.- Parameters:
- Return type:
- 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 |
|
|
section mod. |
|
|
second mom. |
|
|
transverse I |
|
|
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_oaccepted [-].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:
objectThe member cross-section shape, as a bore ratio and a wall floor.
- 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
ktakes metal out of the members, which is the point;raising
kalso raises the wall floor’s outer diameter,2 t / (1 - k), and past somekthe floor – not the load – is what sizes the light members, so the section grows back. Atk = 0.75and 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
kand speed together lands atk = 0.579and 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.modulus_factor(ratios)[source]¶
1 - k^4: section modulus, and second moment, against a solid bar.
- 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.
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 – seeoutput_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.
- class exlink.dynamics.Member(name, start, end, kind, length_attribute)[source]¶
Bases:
objectOne sized structural member, spanning two joints of the linkage.
- 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:
objectInertial description of every moving body, at every crank angle.
All in the consistent unit system (mm, N, MPa, tonne, s).
- 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].
- 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:
objectJoint reactions and output torque over one revolution, with inertia.
- kinematics: Kinematics¶
- 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
Talong the line of action [N].
- 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_jacobiancan differentiate the solve without reassembling or re-factorising it.
- 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_ratiowhere 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 ofMEMBERS[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:
- 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 speedOmega[rad/s]. Zero recovers the quasi-static result ofexlink.loadsexactly.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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¶
staticPeak absolute fibre stress against
S_y / n_y. The state is uniaxial (axial plus bending), so von Mises reduces to|sigma|.fatigueGoodman, 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.
bucklingEuler, 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
Kin 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. Witha(s) = a_1 + s (a_2 - a_1)andr(s)linear, the two integrals evaluate in closed form toF(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:
objectSizing outcome for one member.
- exlink.sizing.member_lengths(design)[source]¶
Length of each sized member, in the order of
MEMBERS[mm].
- 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 aDynamicLoads– and so thatexlink.slidercrankcan 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:
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].lengths (
ndarray[tuple[Any,...],dtype[float64]]) – Member lengths(n_members,)[mm].material (
Material) – The material.safety (
SafetyFactors) – The design factors.fixity (
ndarray[tuple[Any,...],dtype[float64]] |None) – Euler end-fixity factor per member; the EX-link’s if omitted.names (
Sequence[str] |None) – Member names; the EX-link’s if omitted.ratios (
ndarray[tuple[Any,...],dtype[float64]] |None) – Bore ratio per member; solid bars if omitted.floor (
ndarray[tuple[Any,...],dtype[float64]] |None) – Smallest outer diameter each member may take [mm], which is what holds a tube’s wall abovemin_wall;MIN_DIAMETERthroughout if omitted.
- Return type:
- 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:
- 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 / 2under uniform pressure, whose peak stress is3 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:
- 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:
- 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:
objectOutcome of the sizing / dynamics fixed point.
- sizing: dict[str, MemberSizing]¶
- loads: DynamicLoads¶
- mass_properties: MassProperties¶
- 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.disciplinesexposes the same coupling to GEMSEO as two disciplines joined by an MDA, andtests/test_coupled.pychecks 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:
- 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
relaxationis 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 tosolve_coupled().
- Return type:
- 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*,
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:
objectDerivatives 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 ofexlink.design.VARIABLE_NAMES.- cos_transmission: ndarray[tuple[Any, ...], dtype[float64]]¶
d(cos T)/dX– the argument of condition (4a).
- 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 correspondingKinematics.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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_strokeandstroke_errorare 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,widthandclearanceare 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_jacobiancan 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:
- 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:
design (
Design) – The design the analysis was computed at.jacobian (
KinematicJacobian) – The kinematic derivatives fromkinematic_jacobian().spec (
EngineSpec) – Fixed engine data.
- Return type:
- Returns:
{output name: d(output)/dX}, each shaped(11,), coveringANALYTIC_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,
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 / dXfor 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)andd = hypot(x_b - c, y_b)– carry the chain rule of that reparametrisation.
- class exlink.dynamics_jacobian.MassJacobian(member_mass, body_mass, body_com, body_inertia)[source]¶
Bases:
objectDerivatives 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,).
- 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 fromexlink.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^2on the mass and1 + k^2on the radial term of the transverse inertia – and changes nothing else here.
- Return type:
- Returns:
The derivatives of every inertial quantity.
- class exlink.dynamics_jacobian.AccelerationJacobian(joint, body, angular)[source]¶
Bases:
objectDerivatives 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).
- 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 rwithD^2linear, soda/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 speedOmega[rad/s].n_angles (
int) – Crank angles per revolution.
- Return type:
- 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 = bthe derivative isdx/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
Aare moment arms, differences of joint and centre-of-mass positions, sodA/dpfollows straight from the position derivatives.bholdsm aandI alphaand the applied gas load, sodb/dpfollows from the mass, acceleration and gas-force derivatives.- Parameters:
design (
Design) – The mechanism dimensions.kinematics (
Kinematics) – The solved kinematics.kinematic (
KinematicJacobian) – Its derivatives.properties (
MassProperties) – The mass properties.mass_jacobian (
MassJacobian) – Their derivatives.acceleration (
AccelerationJacobian) – The acceleration derivatives.loads (
DynamicLoads) – The solved load case, supplyingxand the matrix.gas_force_derivative (
ndarray[tuple[Any,...],dtype[float64]]) –d P_gas / dX, shaped(n_angles, 11).spec (
EngineSpec) – Fixed engine data.
- Return type:
- Returns:
dx/dp, shaped(n_angles, 18, N_PARAMETERS), in the unknown order ofexlink.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 givesdz = (M^T M)^-1 M^T (dr - dM z), exact because the system is consistent.- Parameters:
design (
Design) – The mechanism dimensions.kinematics (
Kinematics) – The solved kinematics.kinematic (
KinematicJacobian) – Its derivatives.properties (
MassProperties) – The mass properties.mass_jacobian (
MassJacobian) – Their derivatives.acceleration (
AccelerationJacobian) – The acceleration derivatives.loads (
DynamicLoads) – The solved load case.reaction_jacobian (
ndarray[tuple[Any,...],dtype[float64]]) –dx/dpfromequilibrium_jacobian().stations (
int) – Sections evaluated along each member.
- 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:
objectDerivatives of the required diameters, with respect to what sets them.
- 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].safety (
SafetyFactors) – TheSafetyFactors.section (
Section) – The cross-section shape. A bore rescalesA,ZandIby constants, so it moves the values below but none of the slopes: the logarithmic derivativesd(1/A)/dd = -2/(A d),d(1/Z)/dd = -3/(Z d)anddI/dd = 4 I / dare the same for a tube as for a bar, and the shape factor cancels out of every one.
- Return type:
- 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:
objectEverything the two coupled disciplines need to report a local Jacobian.
- 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.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:
- 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:
objectMechanical loss breakdown over one crankshaft 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.
- 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.
- 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:
- 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.
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,
and the 2:1 ratio forces z_1 = 2 z_2. Together:
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), withYthe 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_Efor a steel-on-steel pair [sqrt(MPa)].Z_E = sqrt(1 / (pi ((1 - nu_1^2)/E_1 + (1 - nu_2^2)/E_2)))atE = 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
Yfor 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.
- exlink.gears.tooth_count(inter_axle, module)[source]¶
Teeth on the smaller gear for a given centre distance and module.
- exlink.gears.lattice_inter_axle(module, teeth)[source]¶
The centre distance a module and tooth count actually produce [mm].
- 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:
- Return type:
- 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:
objectA sized 2:1 external gear pair.
- 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 distanceI[mm]. Used to pick the tooth count whenteethis not given; the realised centre distance is on the lattice and is returned in the result.tangential_force (
float) – Peak tangential tooth loadF_t[N].module (
float|None) – Standard module to use [mm]; chosen automatically if omitted.teeth (
int|None) – Teeth on the small gear; derived frominter_axleif 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:
- 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 continuousIan optimizer asked for [mm].tangential_force (
float) – Peak tangential tooth loadF_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:
- 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:
- 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.
Fractional mass added by rounding sections up to stock.
Mass goes as
d^2at 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:
- 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
deltathe 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 as1 / 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:
objectMass of the whole engine, itemised.
- inherent_inertia: float = 0.0¶
Rotating inertia already present in the cranks and gears [tonne mm^2].
- 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:
- 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 speedomega[rad/s].fluctuation (
float) – Alloweddelta.span (
float) – Crank angle the samples cover [rad].2 pifor a cycle that completes in one crankshaft revolution,4 pifor a conventional four-stroke, whose flywheel has to carry it through two revolutions on one firing and is correspondingly larger.
- Return type:
- 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:
- 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 + 8andB ~ 0.55 d + 3; the swept annulus is then filled toBEARING_FILLwith steel to account for the raceways, balls and cage.
- 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
sthe span,wan effective load-spreading width, andsigma_athe allowable.- Parameters:
- Return type:
- 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:
- 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.member_mass (
dict[str,float]) – Mass of each sized member [tonne].piston_mass (
float) – Piston assembly mass [tonne].height (
float) – Mechanism envelopeH[mm].width (
float) – Mechanism envelopeB[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 fromIif omitted.fluctuation (
float) – Allowed cyclic speed fluctuation.spec (
EngineSpec) – Fixed engine data.material (
Material) – Steel parts’ material.safety (
SafetyFactors) – Design factors.
- Return type:
- 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
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:
objectThe 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.
- 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:
objectOutcome of a burn-and-coast range calculation.
- exlink.vehicle.brake_efficiency(brake_work, heat_release)[source]¶
Brake thermal efficiency from one cycle’s works.
- 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.
- 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:
- 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.
- 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 pinsv_highexactly – average speed is monotone inv_high, so a bisection finds it – and what remains is a scalar minimisation overv_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:
- Return type:
- 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
HorBis 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:
objectEverything known about one design, at one operating point.
- coupled: CoupledResult | None¶
The sizing fixed point, or
Noneif the kinematics rejected the design.
- friction: FrictionLosses | None¶
The loss breakdown, or
Nonefor a design that was never sized.
- budget: MassBudget¶
- range: RangeResult¶
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 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 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 geometrically_feasible: bool¶
Whether the design meets the geometric constraint set.
Kept separate from
feasiblebecause 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.
- 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 fromIif 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 thatexlink.uncertaintycan propagate friction scatter, which is not a design variable and not a constant either.**kwargs (
Any) – Forwarded toexlink.coupled.solve_coupled().
- Return type:
- Returns:
The full result. Check
Performance.feasibleandPerformance.reason()– an unanalysable design comes back as an infeasiblePerformance, 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:
- Return type:
- Returns:
One
Performanceper 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,
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
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
ifor 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 = 74andepsilon = 16are written as equalities, but no part is made to an exact dimension, so a perturbation analysis that scores|residual| > 0as 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
+/- tdrawing callout is read as3 sigma, i.e. a process capability ofCp = 1. Reading it as1 sigmawould be pessimistic and as6 sigmaoptimistic; 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_half_widths(design, grade=8, angular=0.05)[source]¶
Tolerance half-width on each design variable.
- Parameters:
- Return type:
- 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.
- class exlink.robustness.ToleranceReport(design, grade, half_widths, nominal, linear_sigma, monte_carlo_sigma, violation_rate, any_violation_rate, unbuildable_rate, samples)[source]¶
Bases:
objectHow a design stands up to the tolerances of the parts that make it.
- half_widths: ndarray[tuple[Any, ...], dtype[float64]]¶
Tolerance half-width on each design variable.
- capability()[source]¶
One-sided process capability
Cpkof 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.
- 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.
- 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:
- Returns:
The report.
- Raises:
ValueError – If the nominal design cannot be analysed.
- exlink.robustness.format_report(report)[source]¶
Render a
ToleranceReportas an aligned table.- Return type:
- 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 ofCONSTRAINT_NAMESto assess.target_capability (
float) –Cpkto 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:
- Returns:
(grade, factor_needed). The grade is the loosest listed one that reaches the target, orNonewhen even the tightest does not – in which casefactor_neededsays 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_DESIGNthe 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:
objectFirst-order moments of every constraint under manufacturing scatter.
- 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 / sigmaof 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
Sigmathe 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:
- Returns:
The moments, or
Noneif 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 thatexlink.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), orNoneif the design cannot be analysed.
- class exlink.robustness.Reliability(moments, per_constraint, system, independent_bound)[source]¶
Bases:
objectProbability that a nominally-conforming build misses its requirements.
- moments: ConstraintMoments¶
- 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 <= 0that an earlier version of this module used, and the reason is worth stating.A margin of
kstandard 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, andConstraintMoments.correlationmeasures pairs at0.94and 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_nis 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:
- Returns:
The reliability, or
Noneif 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 ofexlink.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:
- Returns:
The system and independent probabilities, and the per-constraint ones.
- exlink.robustness.format_reliability(reliability, target=0.001)[source]¶
Render a
Reliabilityas an aligned table.- Return type:
- 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 sitbeta = -Phi^-1(p)standard deviations inside the bound, so the bound has to be at leastcurrent + beta * sigmaabove 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 ofRELIABILITY_NAMESto 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:
- 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:
DisciplineSystem 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.
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 |
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 / La connecting link may reach before it is not a rod.
- exlink.uncertainty.SATURATION_FRACTION = 0.98¶
Fraction of
MAX_DIAMETERcounted 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_NAMESorder – 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:
objectCoefficients of variation of the six non-dimensional uncertainties.
- 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_NAMESin 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 ofexlink.scenariosare reconciled here: everything below is a violation when positive.- Parameters:
performance (
Performance) – A solved evaluation.targets (
DesignTargets) – Constraint right-hand sides.band (
dict[str,float] |None) – Half-widths of the two relaxed equalities.
- Return type:
- Returns:
The constraint vector, or
Noneif 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 toexlink.performance.evaluate().
- Return type:
- Returns:
The moments over every constraint and the split of each one’s variance by source, or
Noneif the nominal design cannot be analysed or sized.
Bases:
objectWhere each constraint’s variance comes from.
Constraint names, as
WIDENED_NAMES.
("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.
(n_constraints, 7), each row summing to one where sigma is positive.
Render a
VarianceSharesas an aligned percentage table.- Return type:
- exlink.uncertainty.widened_reliability(design, **kwargs)[source]¶
The system failure probability over every constraint.
- Parameters:
design (
Design) – The design to assess.**kwargs (
Any) – Forwarded towidened_moments().
- Return type:
- Returns:
The reliability, or
Noneif 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
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
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:
objectA failure probability estimated by sampling the exact constraints.
- 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 belowdrawsmeans the shift was badly chosen and the estimate is noisier than its count suggests.
- 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:
- Returns:
The estimate, or
Noneif the nominal design cannot be analysed.
- exlink.sampling.format_sampled(sampled, form=None)[source]¶
Render a
SampledReliabilityas an aligned table.- Return type:
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,
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:
objectOne operating point of a schedule.
Share of the run’s distance covered at this point [-].
- class exlink.drivecycle.DriveCycle(points, name='')[source]¶
Bases:
objectA weighted schedule of operating points.
- points: tuple[CyclePoint, ...]¶
- 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:
objectWhat one point of the schedule contributed.
- point: CyclePoint¶
- class exlink.drivecycle.CycleResult(cycle, rows, engine_mass_kg, flywheel_kg, sizing_rpm)[source]¶
Bases:
objectA design scored over a schedule.
- cycle: DriveCycle¶
- 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 toexlink.performance.evaluate(), so the gear pair can be pinned as every run in the study pins it.
- Return type:
- 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:
mechanism (
SliderCrank) – The slider-crank.cycle (
DriveCycle) – The schedule.vehicle (
Vehicle|None) – The car; a default Prototype-class entry if omitted.samples (
int) – Crank angles per revolution.**kwargs (
Any) – Forwarded toexlink.slidercrank.solve().
- Return type:
- Returns:
The per-point rows and the aggregate.
- exlink.drivecycle.format_cycle(result)[source]¶
Render a
CycleResultas an aligned table.- Return type:
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 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:
objectObserved contraction of the sizing/dynamics fixed point.
- 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 tosolve_for_design().
- Return type:
- Returns:
The measurement. A design whose loop runs away comes back with
rho >= 1rather 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:
- Returns:
One measurement per speed.
- class exlink.formulations.FormulationResult(name, objective, design, evaluations, disciplinary_calls, seconds, feasible, error='')[source]¶
Bases:
objectWhat one formulation cost and what it found.
- 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.**kwargs (
Any) – Forwarded to the scenario builder.
- Return type:
- 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:
- 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_massand – dominating everything –member_axialandmember_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.
- 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. Solamis 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:
- param design:
The mechanism.
- type tolerances:
- param tolerances:
RMS errors to report, in millimetres, largest first.
- type samples:
- 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:
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:
objectA conventional slider-crank, sized by its two lengths.
- offset: float = 0.0¶
Wrist-pin offset
dfrom 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].
2rwhen 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 than2r.
- 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
Wconstraint 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 ratioepsilon.obliquity (
float) –r / l.spec (
EngineSpec) – Fixed engine data.offset (
float) – Wrist-pin offset, as a fraction of the crank radius.
- Return type:
- 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}\]
- 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:
- Returns:
pressure,gauge_pressure,piston_forceover two revolutions, plusp_compression_end,p_combustion,compression_ratioandindicated_workper 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:
objectA sized, loaded slider-crank at one operating point.
- mechanism: SliderCrank¶
- torque: ndarray[tuple[Any, ...], dtype[float64]]¶
Output torque over two crankshaft revolutions [N.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 ruleexlink.sectionsapplies to the linkage: a rod is tube, a crank throw is a forging.
- Return type:
- 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:
- 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_budgetuses 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:
result (
SliderCrankResult) – A solved slider-crank.fluctuation (
float) – Allowed cyclic speed fluctuation.material (
Material) – The material.safety (
SafetyFactors) – The design factors.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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:
objectOne mechanism’s numbers, for the head-to-head table.
- 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:
- Return type:
- Returns:
The comparison row.
- exlink.slidercrank.OBLIQUITY_BOUNDS = (0.12, 0.45)¶
Search bounds on
r / lfor 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.metricsguards against withW. 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:
objectThe best conventional engine this model can build, and how it was found.
- mechanism: SliderCrank¶
- comparison: Comparison¶
- 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:
- param ratio:
Compression ratio to hold, matching the EX-link’s requirement.
- type vehicle:
- param vehicle:
The car; a default Prototype-class entry if omitted.
- type samples:
- param samples:
Crank angles per revolution.
- type spec:
- param spec:
Fixed engine data.
- type obliquity_bounds:
- param obliquity_bounds:
Search interval for
r / l.- type speed_bounds:
- param speed_bounds:
Search interval for the speed [rev/min].
- type starts:
- param starts:
Nelder-Mead restarts, spread over the box.
- rtype:
- 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.robustnessbuilds 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:
- param ratio:
Compression ratio to hold, matching the EX-link’s requirement.
- type vehicle:
- param vehicle:
The car; a default Prototype-class entry if omitted.
- type samples:
- param samples:
Crank angles per revolution.
- type spec:
- param spec:
Fixed engine data.
- type obliquity_bounds:
- param obliquity_bounds:
Search interval for
r / l.- type speed_bounds:
- param speed_bounds:
Search interval for the speed [rev/min].
- type max_iterations:
- param max_iterations:
SLSQP iteration budget.
- rtype:
- 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 / lwhen 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.02is a quasi-static quantity: it comes fromexlink.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.metricsapplies to the EX-link: a ratio of two maxima over the cycle, not the maximum of a ratio, and not thetanof 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: atCAP_SPEED_RPMthe 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:
- Returns:
The ratio, or
1e3if 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: atr/l = 0.12the 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:
- param ratio:
Compression ratio to hold, matching the EX-link’s requirement.
- type vehicle:
- param vehicle:
The car; a default Prototype-class entry if omitted.
- type samples:
- param samples:
Crank angles per revolution.
- type spec:
- param spec:
Fixed engine data.
- type targets:
- param targets:
Supplies
max_rod_angleandmax_side_load.- type obliquity_bounds:
- param obliquity_bounds:
Search interval for
r / l.- type speed_bounds:
- param speed_bounds:
Search interval for the speed [rev/min].
- type grid:
- param grid:
Obliquity and speed divisions of the initial scan.
- type refinements:
- param refinements:
Bisection steps per axis in each of the two refinement passes that follow the scan.
- type cap_speed_rpm:
- param cap_speed_rpm:
Speed at which the side-load cap is evaluated.
- rtype:
- 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 = 74is 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:
objectFORM reliability of a slider-crank, on the same terms as the EX-link’s.
- mechanism: SliderCrank¶
- 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 / 3convention, 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 –
gammais 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:
- param mechanism:
The slider-crank.
- type grade:
- param grade:
ISO 286 IT grade of the machined lengths.
- type band:
- param band:
Half-width on the compression-ratio requirement.
- type samples:
- param samples:
Crank angles per revolution.
- type targets:
- param targets:
Constraint right-hand sides.
- type spec:
- param spec:
Fixed engine data.
- type step:
- param step:
Relative step for the central differences.
- rtype:
- returns:
The reliability, or
Noneif 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:
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 withhold_bandsalso 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:
objectA piston motion prescribed by the two strokes it must realise.
- 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) – RequiredSTE[mm].compression_ratio (
float) – Requiredepsilon.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:
- 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:
- param design:
A design whose motion seeds the target.
- type expansion_stroke:
- param expansion_stroke:
Required
STE[mm].- type compression_ratio:
- param compression_ratio:
Required
epsilon.- type samples:
- param samples:
Crank angles to represent the target on.
- type spec:
- param spec:
Fixed engine data.
- rtype:
- 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:
target (
TargetMotion) – The target motion.spec (
EngineSpec) – Fixed engine data.
- Return type:
- Returns:
expansion_stroke,compression_ratioandtdc_gap.
- class exlink.synthesis.FitResult(design, rms, expansion_stroke, compression_ratio, stroke_error, ratio_error, converged, evaluations)[source]¶
Bases:
objectOne prescribed-motion fit, and how close it landed.
- 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:
- Returns:
The fit, or
Noneif 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_bandscarries the same argument one step further. Against a target the mechanism cannot reach, holdingg <= 0and 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:
- param target:
The motion to fit.
- type start:
- param start:
Initial design.
- type bounds:
- param bounds:
Box to search in.
- type max_iterations:
- param max_iterations:
SLSQP iteration budget.
- type hold_bands:
- param hold_bands:
Also require
|STE - target| <= bandand|epsilon - target| <= bandrather than checking them after.- type band:
- param band:
Half-width for those two, when
hold_bandsis set.- type targets:
- param targets:
Constraint right-hand sides.
- type spec:
- param spec:
Fixed engine data.
- rtype:
- returns:
The fit, or
Noneif 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:
objectOne range-maximising solve guided by a target motion.
- feasible: bool¶
Feasible against the whole model, by
exlink.performance.evaluate().
- 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_UNAVAILABLEplus the motion residual – the target takes overmotion is not a four-stroke cycle
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:
- param target:
The motion to fall back on.
- type start:
- param start:
Initial design.
- type speed_rpm:
- param speed_rpm:
Operating point for the range.
- type bounds:
- param bounds:
Box to search in.
- type max_iterations:
- param max_iterations:
SLSQP iteration budget.
- type band:
- param band:
Half-width on the two stroke requirements.
- type beta_target:
- param beta_target:
When given, the system reliability index is constrained to
>= beta_target, making this the reliability-based form of the problem.Noneleaves it deterministic.- type grade:
- param grade:
ISO 286 IT grade assumed for the dimensions when
beta_targetis set; ignored otherwise.- type module:
- param module:
Gear module to pin, or
Noneto let the sizer choose.- type teeth:
- param teeth:
Teeth on the small gear, or
None.- type targets:
- param targets:
Constraint right-hand sides.
- type spec:
- param spec:
Fixed engine data.
- rtype:
- returns:
The solve, or
Noneif 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 overboundswhen omitted, and log-scattered around this design when given.scatter (
float) – Relative scatter whenreferenceis given.max_evaluations (
int) – Per-fit residual budget.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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.
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:
objectOne rung of the ladder.
- 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.
- class exlink.continuation.Continuation(start, steps)[source]¶
Bases:
objectA ladder of reliability targets, climbed or not.
- steps: tuple[ContinuationStep, ...]¶
- property best: ContinuationStep | None¶
The highest rung that was met, feasibly.
- 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.targets (
DesignTargets) – Constraint right-hand sides.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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.reachedis false unless the last rung was met.
- exlink.continuation.format_continuation(continuation)[source]¶
Render a
Continuationas an aligned table.- Return type:
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
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:
objectOne restoration solve.
- 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.targets (
DesignTargets) – Constraint right-hand sides.spec (
EngineSpec) – Fixed engine data.
- Return type:
- 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:
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:
objectA reliability estimate corrected for constraint curvature.
- 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, orNoneif 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.
- 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:
- Returns:
The corrected estimate, or
Noneif 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:
- Returns:
d beta_i / d x_j, orNoneif 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/hand an asymmetry of one. Run on the three rows it defaults to, it separates the two cases side by side –stroke_erroris 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.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:
- 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 stepconstant means the numerator is a fixed jump rather than a derivative, so there is no Hessian to find.- Return type:
- exlink.secondorder.format_second_order(outcome)[source]¶
Render a
SecondOrderas an aligned table.- Return type:
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,
and rolling without slip at the pitch point costs
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
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.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:
objectA grounded axis with an angle, which may be prescribed or solved for.
- ratio: float | None¶
Its speed as a multiple of the input’s, or
Nonewhen the angle is free.1is the input shaft itself.-2is a shaft geared to it two to one and turning the other way, which is the relationexlink.kinematicsgives the EXlink – prescribed here rather than discovered, which is what §5.6’s domain does and §5.7’s does not.
- class exlink.topology.Pin(node, shaft, slot)[source]¶
Bases:
objectA node carried round by a shaft at a radius and a phase.
- class exlink.topology.Element(kind, nodes)[source]¶
Bases:
objectA candidate the search may switch on: one presence variable, one shape.
- class exlink.topology.Gear(first, second, ratio, phase_slot)[source]¶
Bases:
objectA candidate gear pair between two shafts, at one catalogue ratio.
- class exlink.topology.GroundStructure(names, kinds, shafts, pins, elements, gears=())[source]¶
Bases:
objectThe 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.
- property free_shafts: tuple[int, ...]¶
Indices of the shafts whose angle the equilibrium solves for.
- class exlink.topology.Layout(structure, ground_slots, free_slots, slider_slot, presence_offset, driven_slots=(), centre_of=<factory>)[source]¶
Bases:
objectWhere 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
- driven_slots: tuple[tuple[int, int], ...] = ()¶
(node, offset)of each pin’s radius, with its phase next to it.
- exlink.topology.initial_configuration(layout, x)[source]¶
The full node layout at zero input angle, free nodes included.
- exlink.topology.member_lengths(layout, x)[source]¶
Rest length of every spring in the domain, from the initial pose.
- exlink.topology.stiffnesses(presence, penalty)[source]¶
SIMP stiffness of each candidate: a floor plus its penalised presence.
- 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.
- 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:
objectWhat a candidate mechanism does over one input revolution.
- 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.
- 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_TOLERANCEis 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.
- 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 carriesP2at 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.Cis a plain grounded pivot,F1andF2are free nodes, andSis 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:
- 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:
- 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.
- 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 ofenumerate_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:
objectA mechanism the synthesis produced, and how well it does.
- strain: float¶
Largest strain in a present candidate (
Motion.strain).
- 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:
- exlink.topology.align(target, lam)[source]¶
A motion resampled onto the target’s angles and rolled onto its datum.
- 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:
- 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.
- 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:
- 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 againstmotion_error().A motion with no four-stroke phasing to speak of scores
DEGENERATE_ERRORplus 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:
- 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"forrequirement_error(), which prescribes the four dead centres and their heights but lets the two bottom ones fall where they fall."precision"forprecision_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:
- Returns:
Millimetres.
- Raises:
ValueError – If
kindis 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:
- 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; seemotion_measure(). The default keeps the sampled-target reading the first screen used.
- Return type:
- 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.
- 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:
- Returns:
Seven residuals, or ten under
"precision".- Raises:
ValueError – If
measureis 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:
layout (
Layout) – The design vector’s layout.x0 (
ndarray[tuple[Any,...],dtype[float64]]) – Starting design.samples (
int) – Input angles per sweep.evaluations (
int) – Cap on residual evaluations.measure (
str) – Which conditions to drive; seesynthesis_residuals().frozen (
ndarray[tuple[Any,...],dtype[bool]] |None) – Coordinates to hold at their starting values.
- Return type:
- 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:
layout (
Layout) – The design vector’s layout.x0 (
ndarray[tuple[Any,...],dtype[float64]]) – Starting design.target (
ndarray[tuple[Any,...],dtype[float64]]) – Target motion, on any number of samples.samples (
int) – Input angles per sweep during the search.iterations (
int) – L-BFGS-B iterations per rung.measure (
str) – How the motion is judged; seemotion_measure().
- Return type:
- 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:
objectWhat a synthesised mechanism actually delivers, against the requirements.
- output_slack: float = 0.0¶
How undetermined the piston is (
Motion.output_slack).
- 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.
- 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:
- exlink.topology.format_assessment(assessment)[source]¶
A readable summary of a synthesised mechanism.
- Return type:
- exlink.topology.exlink_in_the_domain(design=None, samples=720)[source]¶
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 bodyP2-F1-F2, the piston rodF2-S, and a two-to-one pair – so it is expressible ingeared_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.
- 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.
- 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.
- class exlink.topology.Mechanism(elements, gear)[source]¶
Bases:
objectOne discrete topology: which elements are present, and which gear.
- 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:
- 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.
- 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.synthesisalready 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; seemotion_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:
- 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
STEand 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.
- 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:
lam (
ndarray[tuple[Any,...],dtype[float64]]) – Piston height over one input revolution, on a grid whose length is a multiple of four so the precision angles are grid points.heights (
ndarray[tuple[Any,...],dtype[float64]] |None) – Prescribed heights;precision_heights()by default.shift (
int) – Rotation of the input datum, in grid steps – free, for the reasonbest_datum()gives.
- Return type:
- 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:
- 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:
- 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:
objectOne buildable gear pair, and the centre distance it forces.
- class exlink.minlp.MinlpResult(design, point, km_per_litre, feasible, iterations, seconds, candidates=<factory>, scenario=None, reason='')[source]¶
Bases:
objectOutcome of the mixed-integer solve.
- point: LatticePoint | None¶
- candidates: list[LatticePoint]¶
- 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:
- 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; itsIis supplied by the catalogue instead.speed_rpm (
float) – Crankshaft speed [rev/min].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:
- 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 frominitialbycandidates_from_design()if omitted.speed_rpm (
float) – Crankshaft speed [rev/min].max_iter (
int) – Main-problem iteration budget.sub_max_iter (
int) – SLSQP budget per sub-problem.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.0is 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 tobuild_minlp_scenario().
- Return type:
- 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:
- Return type:
list[tuple[LatticePoint,float,bool]]- Returns:
(point, km/L, feasible)per candidate;km/Lis zero when the continuous problem had no feasible solution there.
- exlink.minlp.format_result(result, title='bi-level outer approximation')[source]¶
Render a
MinlpResultas an aligned table.- Return type:
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:
ExlinkDisciplineTakes 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.
PenalisedExlinkDisciplineAdds 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:
DisciplineThe 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; seeOUTPUT_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 toadd_constraint().
- 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
Analysisinto GEMSEO output data.
- class exlink.disciplines.PenalisedExlinkDiscipline(penalty_parameter=0.1, max_height=inf, max_width=inf, **kwargs)[source]¶
Bases:
ExlinkDisciplineAdds 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 and0 < r < 1is the penalty parameter. Smallerrmeans 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_maxfolded into the penalty.
- 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
RangeDisciplinereports, in a stable order.The two margins appear twice, once in each sign convention.
*_marginis positive when satisfied, which reads naturally and is what the single-level scenarios attach withpositive=True.*_violationis 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, andpositive=Truerenames 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
grather than by the loads, which are smooth and would be happy with far fewer.gis the difference between two nearly equal maxima oflambda, 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,greads 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. Seetests/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:
DisciplineLoads 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.
- 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:
DisciplineSection 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.
- 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:
DisciplineKilometres 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_rangeso 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.jacobianexists 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.
GEMSEO problem formulations for the EX-link sizing problem.
The quasi-static formulation is
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_ALGORITHMis 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
rand badly conditioned whenris 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
HandBdown, 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 = 74to|STE - 74| <= 0.05 mmandepsilon = 16to|epsilon - 16| <= 0.05keeps the design within manufacturing tolerance of the specification – the same pragmatism already applied tog, 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_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
gfor 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 >= 1096 % of analysable designs
W <= 0.98576 %
mra <= 1028 %
|eps - 16|8 %
gamma <= 0.026 %
|STE - 74|4 %
g <= 0.010.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 drivesg,STEandepsilonback 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 ong[mm].
- Return type:
- Returns:
A copy of
targetswithmax_tdc_gapwidened.
- class exlink.scenarios.Outcome(design, analysis, algorithm, scenario, front=<factory>)[source]¶
Bases:
objectResult of one optimization run.
- design: Design¶
The best design found (for multi-objective runs, the highest-efficiency point of the front).
- scenario: BaseScenario¶
- 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<= 0exactly 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] andepsilon - 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; seeINEQUALITY_TOLERANCE.
- Return type:
- Returns:
Trueif 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 boxl_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, whichbuild_range_scenario()sets to the chosen value. This is how the discrete gear variables pinI: once a module and a tooth count are chosen,I = 1.5 m zis not free.
- Return type:
DesignSpace- Returns:
A design space with one scalar variable per entry of
exlink.design.VARIABLE_NAMESthat is not infixed.
- 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.samples (
int) – Crank angles per revolution.spec (
EngineSpec) – Fixed engine data.targets (
DesignTargets) – Constraint right-hand sides.relax_equalities (
bool) – ExpressSTE = 74andepsilon = 16as pairs of inequalities. Required for algorithms that cannot take equality constraints, such as NSGA-II.equality_tolerance (
dict[str,float] |None) – Overrides forDEFAULT_EQUALITY_TOLERANCE.max_height (
float) – Moving upper limit onH[mm].max_width (
float) – Moving upper limit onB[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
etasubject 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.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 onH[mm].max_width (
float) – Moving upper limit onB[mm].relax_equalities (
bool) – Seebuild_scenario().seed (
int|None) – Random seed, for the stochastic algorithms that accept one.**settings (
Any) – Extra algorithm settings passed through to GEMSEO.
- Return type:
- Returns:
The outcome, whose
Outcome.feasibleflag 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 arounddesign.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:
- 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 thanpop_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 tomoea_targets(), which relaxesg. Read its docstring before overriding this.equality_tolerance (
dict[str,float] |None) – Half-widths for the relaxed equalities; defaults toMOEA_EQUALITY_TOLERANCE.seed (
int) – Random seed.**settings (
Any) – Extra algorithm settings passed through to GEMSEO.
- Return type:
- Returns:
The outcome, with
Outcome.frontpopulated andOutcome.designset to the most efficient point of the front. Its designs satisfy the relaxed problem, soOutcome.feasiblewill usually beFalseuntil the chosen point has been throughrefine().
- 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 fortheta_fandtheta_r.**kwargs (
Any) – Forwarded topareto_front().
- Return type:
- 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(orB) 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 toH; otherwise toB.**kwargs (
Any) – Forwarded tomaximise_efficiency().
- Return type:
- 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:
- 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_marginPositive 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_marginPositive 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_marginPeak 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-856
9.3 s
–
1e-644
6.9 s
2e-6 mm
1e-431
5.1 s
2e-4 mm
1e-6buys sub-micron sections for three quarters of the cost, which is far inside anything a part would be made to.warm_startmatters 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:
DisciplineTurns 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.
- 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.
ExlinkDisciplinesupplies the geometric objectives and constraints and is feed-forward from the design variables. The other two –DynamicsDisciplineandStructureDiscipline– 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.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 onH[mm].max_width (
float) – Moving upper limit onB[mm].mda_name (
str) – Inner MDA.mda_settings (
dict[str,Any] |None) – Overrides forDEFAULT_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). Seeexlink.formulationsfor 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.jacobianandexlink.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 aroundinitial.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 oneta.**kwargs (
Any) – Forwarded tobuild_coupled_scenario().
- Return type:
- Returns:
The outcome; its
Outcome.designis the lightest design found.
- exlink.scenarios.format_coupled(result, title='coupled sizing')[source]¶
Render a
CoupledResultas an aligned table.- Return type:
- 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_NAMESis available, butH,BandMare 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 tominimise_mass().
- Return type:
- 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_genMDAs – the defaults here are an order of magnitude smaller than the standalonepareto_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:
algorithm (
str) – A multi-objective algorithm.bounds (
Bounds|None) – The design box; defaults to a window aroundinitial.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 tobuild_coupled_scenario().
- Return type:
- Returns:
The outcome, with
Outcome.frontpopulated.
- exlink.scenarios.RANGE_INEQUALITY_OUTPUTS: tuple[str, ...] = ('runs_margin', 'gear_margin')¶
Constraints that exist only once the vehicle is in the problem.
runs_marginPositive 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_marginPositive 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_rangeRangeDisciplinesits 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)fixesI = 1.5 m z, soIleaves the design space entirely: the continuous problem has ten variables, not eleven, and the discrete choice is enumerated outside it bymaximise_range().- type bounds:
- param bounds:
The design box.
- type initial:
- param initial:
Starting design.
- type speed_rpm:
- param speed_rpm:
Crankshaft speed [rev/min]. Held fixed; see
maximise_range(), which sweeps it.- type samples:
- param samples:
Crank angles per revolution.
- type vehicle:
- param vehicle:
The car; a default Prototype-class entry if omitted.
- type module:
- param module:
Gear module [mm]. Derived from
initialif omitted, then held fixed for the whole run.- type teeth:
- param teeth:
Teeth on the small gear; derived likewise.
- type spec:
- param spec:
Fixed engine data.
- type targets:
- param targets:
Constraint right-hand sides.
- type material:
- param material:
The material.
- type safety:
- param safety:
The design factors.
- type relax_equalities:
- param relax_equalities:
Express the two equalities as pairs of inequalities.
- type equality_tolerance:
- param equality_tolerance:
Overrides for the relaxed half-widths.
- type mda_name:
- param mda_name:
Inner MDA.
- type mda_settings:
- param mda_settings:
Overrides for
DEFAULT_MDA_SETTINGS.- type beta_target:
- param beta_target:
When given, the search is reliability-constrained: a
FailureProbabilityDisciplineis added andsystem_beta >= beta_targetattached, so the optimizer must hold a system reliability index rather than merely satisfy the nominal constraints.Noneleaves the problem deterministic.- type grade:
- param grade:
ISO 286 IT grade assumed for the machined dimensions when
beta_targetis 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 – seebuild_range_scenario(). Each candidate pair defines its own ten-variable continuous problem, and the equality constraintsSTE = 74andepsilon = 16then have to be re-satisfied by the remaining variables, sinceIwas 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^2while structural mass climbs withomega^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; seeDEFAULT_COUPLED_ALGORITHMfor why.bounds (
Bounds|None) – The design box; defaults to a window aroundinitial.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 theIlattice.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 tobuild_range_scenario().
- Return type:
- 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 = 74andepsilon = 16by 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 atsamples, so a caller that later checks feasibility at a different crank-angle resolution should project at that resolution:STEandgboth shift slightly with it.tolerance (
float) – Residual norm to stop at.fixed (
Sequence[str]) – Variables the step may not touch.Iby 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:
- 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
gfrom 0.001 mm to 0.020 mm – twice its bound. This is not a defect in the projection; it is the same hypersensitivity thatexlink.robustnessmeasures 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:
objectWhat a multi-start search found, and how varied its local optima were.
- 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.
MultiStartdraws its restarts from an LHS over the design box, and this feasible set contains two equality constraints –STE = 74andepsilon = 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,MultiStartreturns 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:
- param objective:
Output to minimise.
- type initial:
- param initial:
Incumbent to perturb around; the refined reference if omitted.
- type bounds:
- param bounds:
The design box.
- type n_start:
- param n_start:
Restarts, including the incumbent itself.
- type spread:
- param spread:
Perturbation size, as a fraction of each variable’s magnitude.
- type max_iter:
- param max_iter:
Budget per restart.
- type algorithm:
- param algorithm:
A single-objective GEMSEO optimizer.
- type samples:
- param samples:
Crank angles per revolution.
- type seed:
- param seed:
Random seed, so the search is reproducible.
- type build:
- param build:
Scenario builder taking
(bounds, initial); the geometricbuild_scenario()if omitted.- type evaluate_value:
- param evaluate_value:
Maps a design to its objective; reads the analysis if omitted.
- type **kwargs:
- param **kwargs:
Forwarded to the builder.
- rtype:
- 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 thegconstraint made visible.plot_cycle()shows the approximate Atkinson cycle in thep-Vplane, 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.kinematicsdefines 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.
- exlink.plots.plot_motion(design, analysis=None, **kwargs)[source]¶
Plot the piston motion over the 720 deg of crankshaft the cycle spans.
- Return type:
- exlink.plots.plot_cycle(design, analysis=None, **kwargs)[source]¶
Plot the approximate Atkinson cycle in the
p-Vplane.- Return type:
- exlink.plots.plot_torque(design, analysis=None, **kwargs)[source]¶
Plot the crankshaft torque over one revolution.
- Return type:
- 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:
design (
Design) – The mechanism to draw.crank_angles (
Sequence[float]) – Crankshaft angles to show [deg], over the 720 deg the cycle spans; the analysis angle is half of each (crankshaft_degrees()).spec (
EngineSpec) – Fixed engine data.analysis (
Analysis|None) – A precomputed analysis at full resolution.**kwargs (
Any) – Forwarded toexlink.model.analyse().
- Return type:
- 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
Xappears 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 ofEin the frame the trigonal link carries, the cylinder offset as a horizontal dimension from theR1axis, 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 toexlink.model.analyse().
- Return type:
- Returns:
The figure.
- exlink.plots.plot_overview(design, analysis=None, **kwargs)[source]¶
Motion, cycle and torque on one page.
- Return type:
- 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:
- 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) – Anexlink.scenarios.Outcome.- Return type:
- 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.
- exlink.plots.plot_sizing(result)[source]¶
Show the sized sections, what drove each, and where the mass went.
- Parameters:
result (
Any) – ACoupledResult.- Return type:
- 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 asF^(2/3)while the inertia force it creates goes as its own mass – composing tom ~ (C Omega^2)^3. A cubic-in-acceleration, sixth-power-in-speed sensitivity is why the answer collapses so sharply.
- 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:
designs (
Sequence[Design]) – The designs on the trade-off curve.results (
Sequence[Any]) – Their coupled results, in the same order.labels (
Sequence[str] |None) – Optional annotation for each point.reference (
tuple[Design,Any] |None) –(design, coupled result)to mark as the starting point.**kwargs (
Any) – Forwarded toexlink.model.analyse().
- Return type:
- 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.
- exlink.animation.LINK_STYLE: dict[str, dict[str, Any]] = {'crank_1': {'color': '#c0392b', 'lw': 3.0, 'zorder': 4}, 'crank_2': {'color': '#16a085', 'lw': 3.0, 'zorder': 4}, 'piston_rod': {'color': '#d35400', 'lw': 2.6, 'zorder': 5}, 'swing_rod': {'color': '#8e44ad', 'lw': 2.6, 'zorder': 5}, 'trigonal': {'color': '#2c3e50', 'lw': 2.6, 'zorder': 5}}¶
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:
- Return type:
- 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:
- Return type:
- 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 ofexlink.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.common_scale (
bool) – Share one set of axis limits across the panels. SetFalseonly to inspect a single mechanism’s detail.
- Return type:
- 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.
.gifuses Pillow and always works;.mp4needs ffmpeg on the PATH.
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 –
StructureDisciplinereturning the section diameters toDynamicsDiscipline– 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
Iand 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:
design (
Design) – Starting design; the study’s result by default.spec (
EngineSpec) – Fixed engine data.targets (
DesignTargets) – Constraint right-hand sides.**kwargs (
Any) – Forwarded toexlink.scenarios.build_range_scenario().
- 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.
- 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 withpdftoppm. 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.
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 ofNAMED_DESIGNS, a path to a JSON file, orNoneforRELIABLE_DESIGN, the best design the study found and the one the figures are drawn from.- Return type:
- Returns:
The design.
- Raises:
SystemExit – If the reference cannot be resolved.
- exlink.cli.configure_logging(verbose)[source]¶
Quieten GEMSEO unless the user asked for its output.
GEMSEO installs its own stream handler and sets the
gemseologger 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.
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¶
augmented Lagrangian, |
|
SLSQP with exact gradients, |
|
coupled sizing, |
|
maximum range, constraints checked at the end |
|
the study’s result, |
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_ratiowas recorded there as “94.46%”. Withphi = M_r,ave / p_avethat 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
eta27.87 %
27.76 %
W0.9811
0.9817
mra8.68 deg
7.55 deg
g0.0060 mm
0.0069 mm
STE74.000 mm
73.98 mm
eps16.000
15.98
gamma0.0181
0.02
B151.9 mm
156.2 mm
H238.6 mm
256.7 mm
dis not compared: no geometric construction for the clearance was recorded, soexlink.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, atsamples=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 (maximiseetasubject to every constraint), started fromPUBLISHED_DESIGN. It reaches eta = 30.77 % against the 27.87 % ofREFINED_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 mmagainst 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, atsamples=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_DESIGNat the same speed:quantity
refined
coupled
total moving mass
1.039 kg
0.234 kg
peak bearing load
12 629 N
7 504 N
H238.5 mm
205.7 mm
W0.9811
0.9372
eta28.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, atsamples=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_rangewith the gear pair pinned tom = 0.8,z = 48(henceI = 57.6) at 2000 rev/min, started fromCOUPLED_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, atg = 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.05and|epsilon - 16| <= 0.05for the coupled problem. This design finishes at0.0501and0.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.robustnessputs the machining standard deviation ofSTEat 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 movesgfrom 0.0009 mm to 0.0201 mm, twice its bound.That is the same hypersensitivity three other measurements find:
perturbation
effect on
gIT8 machining tolerance on the members
sigma = 0.013 mmsnapping
I0.18 mm onto the gear lattice0.003 -> 0.058 mmminimum-norm equality projection
0.0009 -> 0.0201 mmagainst 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.01the manifold and the feasible region intersect in a sliver thinner than the Newton step that reaches the manifold, and atg <= 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.robustnessshows is also wheregstops 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 remainsCOUPLED_DESIGNat 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()withbeta_target = 3, started atCOUPLED_DESIGN, with the gear pair pinned tom = 0.8,z = 48and 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_fat IT81.3e-3
system
beta3.00, on its target
worst constraint
-2.2e-7
The bounds it was solved against are the relaxed ones
exlink.robustnessidentifies – 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 atbeta = 8.1either 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_DESIGNas shipped, atsamples=1440.Of the design rounded to four decimals, which is how it is stored. That rounding moves
gby 12 % – from 9.6e-05 mm to 1.07e-04 – and nothing else by more than a part in ten thousand, which isexlink.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 ifgwere still bounded at 0.01 mm.