DotNet.Document.Build metrics - Elite-Dangerous-Almanac/Almanac-Core GitHub Wiki
.NET / Build metrics
How this library arrives at the numbers an outfitting screen shows β power, shields, armour, resistances, weapon output, ammunition, heat and jump range. Each metric has its own page in the API reference; this is the argument that runs through all of them, which no single symbol owns.
Every metric exists twice, and the difference is where the numbers come from rather than what the maths does.
The calculation classes β Power, Shields, Armour, Resistances, Weapons, Ammunition, Heat, JumpRange and Mobility β are data-free. You hand them the constants and they hand back a figure, reading no catalogue at all, so they are the right layer for a what-if tool that is not modelling a real build.
using EliteDangerousAlmanac.Ships;
double deployed = Power.Budget(20.4, new[]
{
new PowerConsumer(0.45) { Priority = 1 }, // life support
new PowerConsumer(5.72) { Priority = 1 }, // thrusters
new PowerConsumer(2.48) { Priority = 3, DeployedOnly = true }, // a beam laser
}).Deployed; // 8.65The BuildMetrics methods of the same name gather
those constants out of a real build β the fitted modules, the hull, and whatever
engineering each module carries β and call the function for you. This is what an
outfitting screen wants. Attach one to a
ShipLoadout with BuildMetrics.Of(build).
using EliteDangerousAlmanac.Ships;
// `metrics` is BuildMetrics.Of(build) for a Federal Corvette.
double deployed = metrics.PowerBudget().Deployed; // 46.8597
double armour = metrics.ArmourMetrics().HitPoints; // 5062.6
double guns = metrics.WeaponMetrics().Total.DamagePerSecond; // 137.04
if (metrics.ShieldMetricsResult().TryGetValue(out ShieldMetrics shields))
{
double strength = shields.Strength; // 3940.4
}The rest of this page is about the second layer, because the first is documented where it lives: each function's own page states its formula, its units and its reference implementation.
Every BuildMetrics figure reads post-engineering stats. A build's modifiers are
folded onto the module's catalogue values before any metric sees them, so there is no step
where a caller applies engineering themselves β and no way to ask for the stock figure
through these methods.
Two consequences follow.
A journal's own modifiers are never recomputed. When a build comes from FromLoadout
or FromSlef, the modifier block the game wrote is taken as stated. That is deliberate:
the game is the authority on a build it exported, and a recomputation that disagreed would
silently replace a fact with a model. The library recomputes only what it rolled itself,
through ShipLoadout.ApplyBlueprint β which includes a block that states a recipe and
no modifiers, since there is then no fact to replace. See
Working with SLEF
for what such a block resolves to.
Four stats are percentages of a multiplier, not of the stat. Hull boost, shield boost
and the four damage resistances compound on 1 + v and 1 β v respectively, whichever
apply method the recipe names. A +80% bulkhead engineered by a +32% blueprint reads
137.6%, not 105.6%, because 1.8 Γ 1.32 β 1 = 1.376; a β20% kinetic resistance with
+5% becomes β14%, because the multipliers 1.2 Γ 0.95 multiply. This is Frontier's own
convention and it is why those four stats look wrong if you read them as ordinary
percentages. Engineering states the rule; every metric
below inherits it.
The plant's capacity against what the build draws, split two ways because weapons and most utility fittings only draw while the hardpoints are deployed.
using System.Collections.Generic;
using EliteDangerousAlmanac.Ships;
PowerBudget power = metrics.PowerBudget();
double available = power.Available; // MW the plant makes
double retracted = power.Retracted; // MW drawn with hardpoints in
double deployed = power.Deployed; // MW drawn with hardpoints out
bool fits = power.WithinBudget; // does it fit?
IReadOnlyList<PowerBand> bands = power.Bands; // the five priority groups
IReadOnlyList<PowerConsumerResult> draws = power.Consumers; // positive draws, in source orderBands is what drives a priority-group table. A group is powered when its running
total β its own draw plus every higher-priority group's β fits in Available; the game
shuts off the first group that would go over and everything below it, rather than the
individual module that broke the budget.
Consumers keeps every positive power draw in source order and normalises the fields the
budget uses; passive and zero-draw fittings are absent.
Strength and hit points are separate calculations, but the four resistances that decide what they are worth stack by one shared rule, in Resistances.
Sources stack multiplicatively on the damage multiplier, not additively on the
resistance: two 20% resisters leave 0.8 Γ 0.8 = 0.64, which is 36% resisted, not 40%.
The game then bends the result so stacking cannot run away β past a threshold, the
remaining gain is halved. The threshold differs between shields and hull, and both are
stated on StackShieldResistance and StackArmourResistance.
using EliteDangerousAlmanac.Ships;
// A stock generator at 40% kinetic, under four 20% resistance-augmented boosters.
double kinetic = Resistances.StackShieldResistance(0.4, new[] { 0.2, 0.2, 0.2, 0.2 });
// 0.667β¦, not 0.4 + 4 Γ 0.2What a resistance is worth is the effective hit points it buys β the pool divided by
what still gets through. Both metrics report that per damage type, and
Resistances.EffectiveHitPoints is the same function they use, so a pool of your own
converts the same way:
using EliteDangerousAlmanac.Ships;
// 945 hull points behind lightweight alloy, which is weak to kinetic (-20%) and
// explosive (-40%) damage alike.
DamageTypeValues resistances = new(Kinetic: -0.2, Thermal: 0, Explosive: -0.4, Caustic: 0);
double pool = Resistances.EffectiveHitPoints(945, resistances).Kinetic;
// 787.5, fewer than the hull holdsTwo things about shields catch people out. A generator's strength multiplier is read off a
curve against the bare hull mass, not the loaded ship β so fitting more modules never
weakens your shields. And past the generator's maximum mass it will not raise a shield at
all, which the curve reports as 0 rather than as a small number.
ShieldMetricsResult evaluates the generator, boosters and reinforcement against the
hardpoints-stowed (retracted) budget. It is incomplete when no generator is powered in
that state, and its issues distinguish a missing, switched-off or shed generator from an
unavailable power supply. Every imported build has a known hull and armour.
MobilityMetricsResult and ShieldRecoveryResult use the same retracted power state for
their thrusters, generator and distributor, and explain an unavailable answer in the same
way.
The pips are a separate call. ShieldMetricsResult is the bare shield an outfitting
screen shows, and the SYS capacitor is ShieldCapacitorMetricsResult β the same split
WeaponMetrics and WeaponsCapacitorMetrics use for WEP. It reports the SYS capacity and
recharge the allocation buys, the resistance the pips add on their own, and the effective
resistances and hit points with them folded in, so a panel showing both readings computes
the shield once:
using EliteDangerousAlmanac.Ships;
BuildMetrics metrics = BuildMetrics.Of(ShipLoadout.Default("Anaconda"));
// The generator and boosters alone.
if (metrics.ShieldMetricsResult().TryGetValue(out ShieldMetrics bare))
{
double bareKinetic = bare.Resistances.Kinetic;
}
if (metrics.ShieldCapacitorMetricsResult(systemsPips: 2).TryGetValue(
out ShieldCapacitorMetrics sys))
{
double pips = sys.SystemsResistance; // what two pips add, on their own
double both = sys.EffectiveResistances.Kinetic; // the two, multiplied together
double soaked = sys.EffectiveHitPoints.Kinetic; // MJ of kinetic damage at two pips
double recharge = sys.RechargeRate; // MJ/s into the SYS capacitor
}The pips multiply with the shield's own stack rather than adding to it, which is why the
capacitor owns the arithmetic: 1 β (1 β shieldResistance) Γ (1 β systemsResistance). At
zero pips the effective figures are the bare ones, exactly.
ShieldRecoveryResult keeps its own pip allocation. Every figure it contains is a
function of the allocation β there is no unpiped recovery time to report β so nothing
there is being folded into a base figure and then discarded. Its recharge is the
RechargeRate in ShieldCapacitorMetricsResult at the same allocation.
WeaponMetrics reports per-weapon figures and a total. The distinction that matters is
damage per second against sustained damage per second: the first is the rate while
firing, the second folds in the clip and the reload, because a weapon that stops to reload
is not firing.
using EliteDangerousAlmanac.Ships;
BuildWeaponMetrics weapons = metrics.WeaponMetrics();
double firing = weapons.Total.DamagePerSecond; // while firing
double sustained = weapons.Total.SustainedDamagePerSecond; // with reloads
double energy = weapons.Total.EnergyPerSecond; // capacitor draw, MW
double heat = weapons.Total.HeatPerSecond;
int fitted = weapons.Weapons.Count; // per-hardpoint breakdown
if (metrics.DistributorMetricsResult(new DistributorPips(2, 2, 2))
.TryGetValue(out DistributorMetrics distributor))
{
double rated = distributor.Engines.RatedRecharge; // four-pip catalogue rate, MJ/s
double actual = distributor.Engines.RechargeRate; // ENG recharge at two pips, MJ/s
}
WeaponsCapacitorMetrics capacitor = metrics.WeaponsCapacitorMetrics(weaponsPips: 2);
double recharge = capacitor.RechargeRate; // actual WEP recharge at two pips, MJ/s
double drain = capacitor.NetDrainRate; // sustained draw minus recharge, floored at zero
double endurance = capacitor.TimeToDrain; // seconds from full, or infinity if recharge keeps paceBeam and mining lasers are continuous: they carry no rate of fire, and their damage, distributor draw and thermal load are already per second, so the per-shot arithmetic collapses to the raw stats.
A distributor's three catalogue recharge figures are their four-pip maxima.
DistributorMetricsResult scales SYS, ENG and WEP independently by (pips / 4) ^ 1.1 and
answers each capacity, rated recharge and actual rate. Fractional allocations from zero
through four are accepted, an absent allocation is four pips to each, and the three need
not total six, so the result can compare three independent scenarios. It is incomplete when the
distributor is missing, switched off or shed, or when the fitted article's capacitor stats
cannot be resolved; the issue's Reason says which.
WeaponsCapacitorMetrics adds firing endurance to the WEP calculation. It compares
pip-scaled recharge with sustained energy per second: a magazine's reload is time for
the capacitor to recover, so burst draw would understate endurance.
The general distributor facade applies the retracted power budget: all three capacitors recharge while hardpoints are stowed. Weapons endurance instead applies the deployed budget, because its result models firing. A distributor or weapon shed in the relevant state contributes nothing.
One asymmetry is deliberate. Frontier's Rapid Fire and High Capacity recipes shorten the
fire interval rather than raising the rate of fire, so that is the label those
blueprints carry; a weapon's combined rate of fire follows from the interval and the burst
pattern. ShipLoadout recomputes it for you, so you only meet this if you call
Engineering.ComputeModifiers directly.
Ammunition.Capacity reports what a module can hold when fully rearmed β the magazine,
the reserve behind it, and the two together. A journal's AmmoInClip and AmmoInHopper
report what is loaded at the moment of capture, which is a different question: a reading is
a lower bound on a capacity and never a reading of one, so the library never infers a
catalogue figure from a rearm state.
Three answers are distinct, and a consumer should not collapse them:
- a module with a magazine and a reserve reports both, and their sum;
- a module with a magazine but no reserve figure is reported as unlimited;
- a reserve of zero is a real answer, not an unlimited one β the Mk II Plasma Shock Accelerator has nothing behind its magazine.
Engineered ammunition is reported in whole rounds, because a ship cannot load a tenth of a round and both stats are multiplicative under engineering. A clip rounds up to a whole burst; a reserve rounds to the nearest round. The rounding runs after every blueprint and experimental contribution has compounded, and it applies only to a value the library computed β a clip a journal states passes through untouched, a recipe leg that overwrites the clip is a published figure rather than a product of one, and a roll that leaves the clip where it was leaves it there.
One more rounding rule exists because registries state a recipe's multiplier to three or
four decimals. A leg meant to add two thirds is written 0.667, which computes 10.002
rounds on a 6-round rack β and left alone that thousandth becomes a whole extra round once
the clip rounds up, or a whole extra burst on a burst weapon. A clip within half a unit in
the multiplier's third decimal is therefore taken as the whole number it means. A quality
roll between two published legs gets no such treatment: it is a real number with no whole
magazine behind it.
What the build runs at, and whether firing everything cooks it. Heat is the one metric
here that no stated Frontier figure underwrites: the game publishes no formula and shows
no dissipation figure, so the model β and the per-hull heat dissipation it reads β is
community measurement of the game, ported from EDSY and credited in ATTRIBUTIONS.md.
using EliteDangerousAlmanac.Ships;
if (metrics.HeatMetricsResult().TryGetValue(out HeatMetrics heat))
{
double idle = heat.Idle.Gauge; // stowed, as the cockpit gauge reads it: 1 is 100%
double spooling = heat.FsdCharging.Gauge; // the hottest thing most ships do
bool cooks = heat.FiringSustained.Overheats; // trigger held, WEP capacitor keeping up
double? alpha = heat.FiringDrained.SecondsToOverheat; // on an empty capacitor
}Two numbers decide everything, and they are not interchangeable. Dissipation is a ceiling: a build whose thermal load stays under the hull's heat dissipation settles below heat level 1 and never overheats, however long it fires; one that goes over never settles at all. Capacity is only inertia β it sets how long the climb takes, which is why a build that cooks itself in eight seconds and one that cooks itself in two are the same kind of broken.
Heat follows what the plant actually feeds. A module switched off makes no heat, and
neither does one in a priority group the plant cannot keep lit β including the thrusters
and the guns. That check is state-dependent, so a build whose thrusters survive with the
hardpoints stowed but get shed once they are out reports thruster heat in Thrusters and
none in the firing scenarios.
Each scenario is cumulative, and each reports both a settled level and a countdown:
Gauge is the level as a fraction of the in-game readout, Overheats says whether it
settles at all, and SecondsToOverheat fills in when it does not. A load beyond
dissipation reports an infinite level rather than a settling point it never reaches.
FiringSustained and FiringDrained differ only in the state of the weapons capacitor,
and the gap is large: a shot the capacitor cannot pay for makes five times its thermal
load. A build that never overheats in a duel can cook itself in a wing fight with the same
guns.
Mass is the input half of the flight model, so the library publishes it rather than
leaving a screen to reassemble it. BuildMass is the mass counterpart of BuildCost, and
splits the same three ways:
using EliteDangerousAlmanac.Ships;
BuildMetrics metrics = BuildMetrics.Of(ShipLoadout.Default("Anaconda"));
BuildMass mass = metrics.BuildMass();
double hull = mass.Hull; // 400 the bare hull, in tonnes
double modules = mass.Modules; // 664 every fitted module, post-engineering
double unladen = mass.Unladen; // 1064 what ShipLoadout.UnladenMass reports
double total = mass.Total; // 1096 with the load below aboard
double fuel = mass.Fuel; // 32 a full main tank by default
double cargo = mass.Cargo; // 0 an empty hold by default
double loaded = metrics.BuildMass(new BuildLoad(Fuel: 8, Cargo: 32)).Total; // 1104The fuel and cargo defaults are exactly those of JumpRangeAt and
MobilityMetricsResult, so the three agree by construction. Each of the standard loads
carries its own resulting mass too:
using EliteDangerousAlmanac.Ships;
if (metrics.StandardLoadResult(StandardLoad.Laden).TryGetValue(out StandardLoadFigures laden))
{
double ladenMass = laden.Mass; // 1210
}The reserve tank is in none of these. The game's statistics panel counts it in the current mass it displays, and neither the jump equation nor the flight model does β ten observed builds reproduce their angular rates only with the reserve excluded. Add the reserve capacity where you are reproducing the panel, and nowhere else.
Hull and Modules are always computed from the hull record and the current fit, while
Unladen is the build's own unladen mass β which for an unedited import is the figure the
capture stated, and is the one every calculation here uses.
What the thrusters do with that mass is a three-point curve, and Thrusters publishes it
the way FrameShiftDrive publishes the jump constants:
using EliteDangerousAlmanac.Ships;
ThrusterParams curve = metrics.Thrusters()!;
double rated = curve.OptMass; // 1440 rated performance at or below this
double ceiling = curve.MaxMass; // 2160 past this the ship does not move at all
if (metrics.MobilityMetricsResult().TryGetValue(out MobilityMetrics mobility))
{
double loaded = mobility.LoadedMass; // 1096 what the curve was evaluated at
bool agrees = Mobility.ThrusterMassCurveMultiplier(
mobility.LoadedMass, curve.SpeedCurve ?? curve) == mobility.MassCurveMultiplier;
}MobilityMetricsResult quotes speed, pitch, roll and yaw at four ENG pips, which is
the hull's own upper endpoint for each. A lower allocation is
MobilityCapacitorMetricsResult, the ENG half of the same split the shields and weapons
use:
using EliteDangerousAlmanac.Ships;
if (metrics.MobilityMetricsResult().TryGetValue(out MobilityMetrics full))
{
double atFour = full.Speed; // m/s at four ENG pips
}
if (metrics.MobilityCapacitorMetricsResult(enginesPips: 2)
.TryGetValue(out MobilityCapacitorMetrics half))
{
double atTwo = half.Speed; // m/s at two
double pips = half.EnginesPips; // 2
}Boost is not on the capacitor result, because the allocation cannot move it: it stays on
MobilityMetricsResult beside LoadedMass and the two curve multipliers the two share.
Both take the same load, so a screen can quote either at the same fuel and cargo.
LoadedMass against OptMass and MaxMass is the whole of "where does this build sit on
its thrusters" β the reading an outfitting screen shows beside the speed. A mass past the
maximum reports zero performance rather than a fabricated curve value, which is the same
convention the shield generator's own mass curve follows.
Thrusters is the fitted article's curve, so a switched-off or shed thruster still has
one; it is MobilityMetricsResult that decides whether the build can use it. It answers
null β rather than raising, as FrameShiftDrive does β when no complete curve is
fitted, because a build without usable thrusters is still a build.
JumpRangeSummary answers the loads a screen actually shows, so you do not have to
assemble them:
using EliteDangerousAlmanac.Ships;
JumpRangeSummary jumps = metrics.JumpRangeSummary();
double best = jumps.Max; // one jump's fuel, empty hold
double unladen = jumps.Unladen; // full tank, empty hold
double laden = jumps.Laden; // full tank, full hold
double bestTotal = jumps.TotalMax.Range; // the same best jump as a one-jump total
int bestJumps = jumps.TotalMax.Jumps; // one jump when the build carries fuel
double onATank = jumps.TotalUnladen.Range; // every jump on one tank, empty
int tankJumps = jumps.TotalUnladen.Jumps; // including the final partial one
double loadedTank = jumps.TotalLaden.Range; // every jump on one tank, full
TotalRangeDetails tank = metrics.TotalRange();
double distance = tank.Range; // summed distance as the tank drains
int count = tank.Jumps; // full and final-partial jumps before the tank is empty
// A chosen partial load, and the drive's dimensionless mass factor.
TotalRangeDetails partial = metrics.TotalRange(new BuildLoad(Fuel: 8, Cargo: 32));
double factor = metrics.FrameShiftDriveMassFactor();The model is the community-standard hyperspace one, and
JumpRange holds it as data-free functions if you want a
single jump rather than a summary. Guardian FSD boosters and the drive's own engineering
are already folded in by the time ShipLoadout calls them. A frame shift drive has no
thruster-style three-point mass curve: its mass term is the direct
optMass / (mass + fuel) ratio, while a Guardian boost is added after that base equation.
The shield generator may be absent, and any fitted record may omit a stat a metric needs. Do not treat an unavailable metric as zero:
- The
β¦Resultmethods answer a CalculationResult<T>. One that is incomplete carries no value and itsIssuesname what was missing, switched off or shed. Each issue'sReasonisMissing,Unresolved,Disabled,ShedorInvalid; use it instead of parsing the message. -
UnladenMass,FuelCapacity,CargoCapacity,PassengerCapacityandBuildMassare not nullable and have no diagnostic result: no article a build can hold is unweighable, so they always answer.Thrustersis nullable β it reports the fitted article's curve, andMobilityMetricsResultis what explains an unusable one. The failure model covers that split, and how it differs from the exceptions a malformed input raises. -
ArmourMetricsalways has the known hull's base figures. - A caller-supplied power plant without a usable capacity makes every power-dependent
metric unavailable rather than projecting its dependants as powered.
PowerBudgetreports zero available; the mobility, shield and recovery results identifyCalculationField.PowerCapacitydirectly. Those results report a non-positive or non-finite capacity asInvalidrather than askingPowerBudgetto accept it; they likewise identify a malformed known module draw asCalculationField.PowerDraw. The direct budget remains strict and raises for either invalid numeric input. -
JumpRangeSummaryand the other jump methods raiseInvalidOperationExceptionrather than answer, when the fitted drive's record carries no usable jump constants. -
HeatMetricsResultis incomplete when the build has no powered plant whose heat efficiency it can read, and its issue names which condition caused it.
On an imported build, every figure here describes the fit that remains. Import
discards a module in a removable mount and stocks armour, a core internal, the cargo hatch
and the planetary approach suite from the hull defaults, and neither the figures nor
Validation say so β The failure model says why.
What normalization can still do is leave a result incomplete for an ordinary reason:
discard the only shield generator and ShieldMetricsResult reports ShieldGenerator /
Missing, exactly as an empty mount would; stock a plant over an engineered one and the
mobility, shield and recovery results report Shed.
ImportOutcomes is the account, and it is the entries that matter, not the length. A
ModuleDefaulted whose SourceSymbol is null
marks a mount the capture named nothing for, which import stocks from the hull defaults. A
stocked bulkhead or approach suite moves no metric at all, and a stocked cargo hatch only
its own 0.6 MW draw β most third-party exports name neither the hatch nor an
approach-suite mount, so most produce exactly those two entries. Every other entry means
the figures are the normalized fit's β except two that mean the opposite.
EngineeringUnresolved says nothing was
changed and that module's figures are the unengineered ones its source only claimed to
engineer. EngineeringAmbiguous says the fit
is one of two legitimate readings of an identity-only block: these figures are the roll,
and its PreEngineeredVariant is the catalogued article that would give a different set.
- Engineering β what a recipe may go on, and what it does to the stats above.
- Building an outfitting screen β the screen these metrics feed.
- The failure model
- API reference