← Back to Autonomy

Diagnostics · Prognostics · A Worked Case

The Anatomy of a Root Cause

A fleet of batteries is aging too fast. The statistics point one way, the physics another. This is how you tell a true cause from a convincing impostor.

Abstract

We follow a single anomaly — a cohort of electric vehicles losing battery health faster than the model allows — from first alarm to confirmed root cause and forward into a prognosis. Along the way we meet the central hazard of diagnostics: a feature that correlates beautifully with failure but causes nothing. The tool that rescues us is abduction — inference to the best explanation — disciplined by partial correlation and, finally, by physics. All figures are computed from a simulated 240-vehicle fleet with a known ground truth, so we can grade our own detective work.

§ 01 — The Signal

An alarm with no obvious cause

It starts as a line on a dashboard that bends the wrong way.

You monitor a fleet of 240 vehicles. The battery state-of-health (SOH) — estimated onboard from capacity fade and internal-resistance rise — is supposed to decline gently, a few percent a year. Instead, a sizeable slice of the fleet is shedding health at roughly double the baseline rate. No crashes, no thermal events, no diagnostic trouble codes. Just a population quietly dividing into two: the ordinary, and the alarming.

The first job is not to explain it. The first job is to see its shape. When we plot SOH against age and color by the two emerging groups, the fleet is unmistakably bimodal — two trajectories, not one cloud.

Figure 1 — Fleet SOH trajectories
Fast-fading cohort Baseline cohort
The anomaly, made visible. Two populations separate cleanly over 36 months. The fast-fading group reaches the 80% end-of-life threshold years early. The question is not whether something is wrong — it is what, and that is where most investigations go astray.

Two trajectories means two regimes — something is sorting these vehicles into camps. The investigation is the search for the sorting variable, and for the mechanism behind it.

§ 02 — The Frame

Diagnosis answers why; prognosis answers when

Before chasing causes, fix the vocabulary, because the two halves of the job have different standards of proof.

Diagnosis is a snapshot: given the evidence now, what is the fault and what mechanism produced it? Prognosis is the projection: given that mechanism and the operating profile ahead, how much useful life remains? The hinge between them is the mechanism. Diagnose only a correlation and your prognosis is a guess with error bars wide enough to drive through. Diagnose the mechanism and the same data becomes a degradation model you can extrapolate, intervene on, and price.

Why the rigor matters A root cause is not "the variable most associated with failure." It is the variable that, if you changed it, would change the failure. Association is cheap; intervention is the test. Everything below is machinery for telling those two apart without running 240 destructive experiments.
§ 03 — Hypothesis Generation

The candidate field

Good diagnostics begins by being generous, then ruthless. We enumerate every plausible cause first, deliberately spanning the failure-mode families so we don't quietly assume the answer:

FamilyHypothesis for this fleet
ThermalHot-climate operation accelerates calendar aging (Arrhenius).
Usage / behaviorFrequent DC fast-charging stresses cells.
Usage / storageLong dwell at high state-of-charge drives calendar fade.
Mechanical / wearCumulative cycling (depth × count) wears the electrodes.
Electrical / controlA balancing fault lets cells drift apart, over-stressing the weak ones.
Software / firmwareA specific build mismanages the battery — the upstream suspect.

Each is a story about a cause. None is yet measurable. The next step is the one beginners skip: translating each story into a number we can actually pull off the bus.

§ 04 — From Hypothesis to Feature

Make every suspicion measurable

A hypothesis you cannot measure is a hypothesis you cannot reject. So we map each candidate to a feature already present in the telemetry — CAN logs, BMS snapshots, charge records:

HypothesisMeasurable featureSource
Thermal agingMean ambient temperature (°C)BMS / climate log
Fast-charging stressDC fast-charge events / monthCharge records
High-SOC storage% time parked above 80% SOCBMS state log
Cycling wearEquivalent full cyclesCoulomb counter
Balancing faultPost-charge cell-voltage spread, ΔV (mV)Per-cell BMS telemetry
FirmwareBuild version (categorical)ECU manifest

Now the outcome — annualized SOH fade rate (%/yr) — and six candidate features sit in one table, 240 rows deep. The detective work can begin.

§ 05 — First Pass

What the raw correlations say

The reflex move — and the right place to start — is to correlate each feature against the fade rate and rank them.

# Step 1 — rank candidates by raw association with fade rate
from scipy import stats
for name, x in features.items():
    r, p = stats.pearsonr(x, degradation_rate)
    print(f"{name:24s}  r={r:+.3f}  p={p:.1e}")
Figure 2 — Raw correlation with fade rate
The leaderboard. Cell-imbalance ΔV leads, but fast-charging is right behind it — a strong, plausible, intuitively satisfying r = . This is exactly the kind of result that ends a lazy investigation. It shouldn't end this one.

Two features stand out: cell-imbalance ΔV and fast-charge frequency. The fast-charging result is seductive: it has a clean engineering narrative (fast charging is "hard on batteries," everyone knows that), a strong coefficient, and a vanishingly small p-value. Plot it and it looks like a smoking gun.

Figure 3 — Fast-charging vs. fade rate
On firmware v3.2.1 Other builds
Convincing — and wrong. The upward trend is real and tight. A team under deadline pressure writes "fast-charging causes accelerated fade," recommends throttling DC charge rates, and closes the ticket. Notice the coloring, though: the high-fast-charge cars are almost all the same firmware build. Hold that thought.
§ 06 — The Trap

When the statistics are loud and the physics is silent

Here is the discipline that separates a diagnosis from a coincidence. Before accepting fast-charging, we ask the four questions every candidate root cause must survive:

TestFast-charging
1. Correlated?Yes — strongly (r ≈ ).
2. Statistically significant?Yes — p ≪ 0.001.
3. Explains real variance?Yes — alone it accounts for a large share.
4. Mechanistically plausible?This is where it stumbles.

Three of four pass. The fourth is the one that matters, and it is uncomfortable. When we look at the actual charge sessions, the DC fast-charges in this fleet are within rated C-rate, the pack temperatures during them stay inside the thermal window, and the cell chemistry is qualified for exactly this duty. There is no mechanism by which charging that the cell is specified to tolerate should, by itself, double the fade rate. The number is loud; the physics says nothing.

The rule A correlation with no mechanism is not a finding — it is a clue. It is telling you something true about the data, just not what it appears to say. The honest response is not "ignore it" and not "believe it." It is to ask: what could produce this correlation without being its cause? That question is the doorway to abduction.
§ 07 — Abduction

Reasoning to the best explanation

We are not deducing (we have no law that forces a conclusion) and we are not blindly guessing. We are doing what a diagnostician actually does: surveying the candidate explanations and asking which one best accounts for the whole pattern — the strong fast-charge correlation, the absent mechanism, and the equally strong ΔV signal. That is abduction: inference to the best explanation.

The best explanation for "strong correlation, no mechanism" is almost always a confounder: a hidden third variable that drives both the suspect feature and the outcome. So we form a new, sharper hypothesis:

Fast-charging does not cause the fade. Both fast-charging and the fade are downstream of something else — and the firmware coloring in Figure 3 is the hint. Suppose a firmware build mismanages cell balancing and ships an aggressive charge profile. Then its cars both fast-charge more and degrade faster, with no causal arrow between those two at all.

The test: hold the suspected mechanism constant

If ΔV (the balancing signal) is the real driver, then among cars with the same ΔV, fast-charging should carry no extra information about fade. We compute the partial correlation — the correlation of each feature with fade after regressing out ΔV:

# Partial correlation: corr(feature, fade) with ΔV held constant
def partial_corr(x, y, z):
    rx = x - np.polyval(np.polyfit(z, x, 1), z)   # strip z from x
    ry = y - np.polyval(np.polyfit(z, y, 1), z)   # strip z from y
    return stats.pearsonr(rx, ry)            # what's left

partial_corr(fast_charge, fade, z=cell_imbalance_dV)
Figure 4 — Raw vs. partial correlation (control for ΔV)
Raw correlation Partial (ΔV held constant)
The reveal — in both directions. Fast-charging collapses from a commanding r ≈  to statistical noise once ΔV is held constant: it was a proxy, not a cause. Meanwhile ambient temperature, which looked unimportant in the raw ranking, emerges — its genuine but modest effect was being masked. Raw correlation misled us twice, in opposite directions.

The mirror-image test confirms it: hold fast-charging constant and ΔV barely moves (r = ). The dependence runs one way. A multiple regression tells the same story in coefficients — the moment ΔV enters the model, the fast-charge coefficient falls to near zero while the model's explanatory power jumps:

ModelFast-charge coef.ΔV coef.
fade ~ fast-charge + ambient
fade ~ fast-charge + ambient + ΔV

Abduction has done its work: it took an anomaly the raw statistics couldn't resolve and proposed a structure — a confounder — that the partial-correlation test then confirmed. But we are not finished. A surviving statistical signal is still only a strong clue. It must now pass the test fast-charging failed: physics.

§ 08 — The Smoking Gun

Where statistics and physics finally agree

ΔV survived every statistical test. Now we demand a mechanism — and unlike fast-charging, ΔV has one ready. The question is concrete: why would a few millivolts of post-charge cell spread accelerate fade?

A battery pack charges as a series string under one current. The BMS terminates charge on pack voltage. If balancing is healthy, all cells sit at nearly the same voltage and rise together, each topping out safely below the over-voltage stress line. But if balancing fails, the cells drift apart — and the weakest, highest-voltage cell is driven past its limit while the pack average still looks fine. We pull the per-cell traces from a balanced pack and an imbalanced one during a single charge:

Figure 5 — Per-cell voltage during charge
Balanced pack cells Imbalanced — weak cell Over-voltage stress line
The mechanism, caught in the act. In the balanced pack (left, teal) every cell crests safely. In the imbalanced pack (right) the weak cell is pushed across the 4.25 V over-voltage line on every single charge while the pack-level reading stays nominal. Repeated micro-over-voltage drives lithium plating and electrolyte breakdown on that cell — the physical pathway from "millivolts of spread" to "accelerated capacity loss."

Now the four tests all pass for ΔV at once: strong correlation, significant, dominant share of variance, and a clean, observable physical mechanism. This is what a root cause looks like when you've actually found it — the numbers and the physics stop arguing and start agreeing.

§ 09 — Upstream

Following the chain to its source

ΔV is the mechanism, but a mechanism has an origin. Cells don't drift apart on their own — something stopped balancing them. We stratify the fleet by firmware build, the last suspect on our original list:

Figure 6 — Stratification by firmware build
The origin. Cars on build v3.2.1 show a mean ΔV of  mV and fade at  %/yr; every other build sits near  mV and  %/yr. The split is categorical, not gradual — the signature of a software defect, not a wear process.

Inspecting the build confirms it: a timing bug in the cell-balancing controller — it only equalizes during a narrow state window and misses the windows where the weak cell is drifting. The same build also carried an aggressive DC-charge default, which is why its cars fast-charged more — and why fast-charging looked guilty in Figure 3. The phantom correlation finally has an honest explanation.

The full causal chain, recovered end to end:

firmware v3.2.1 bug  →  balancing fails  →  cell ΔV grows
 →  weak cell over-volts each charge  →  accelerated SOH fade
(same build's aggressive charge default  →  more fast-charging  =  the red herring)

§ 10 — The Verdicts

Three features, three different fates

The case rests on recognizing that "correlated with failure" resolves into three genuinely different roles — and that raw correlation cannot tell them apart:

Cell-imbalance ΔV  Root cause

raw r ≈ · survives all controls · mechanism confirmed

Strong in the raw ranking, undiminished under control, and backed by an observable physical pathway. Accept — and act on it.

Fast-charging frequency  Spurious

raw r ≈ · collapses under control · no mechanism

Loud in the raw ranking, gone the moment ΔV is held constant, and never had a mechanism. A confounded proxy for the firmware build. Reject — throttling charge rates would have fixed nothing.

Ambient temperature  Contributing

raw r ≈ · emerges under control · modest mechanism

Nearly invisible in the raw ranking, yet a real secondary cause revealed once ΔV is removed — a genuine Arrhenius effect, masked by the dominant driver. Keep it in the prognostic model; it is not the headline.

Note the symmetry of the two failures of naïve correlation: one feature looked important and wasn't; another looked unimportant and was. The same instrument — partial correlation, then physics — corrected both.

§ 11 — The Reasoning, Distilled

The abductive ladder

Stripped of the battery specifics, here is the move that did the real work — repeatable on any diagnostic problem:

1
Observe

Surprise, not just a value

The fleet split into two fade regimes. An anomaly worth explaining is a pattern your current model didn't predict.

2
Enumerate

Generate the candidate field generously

Span the failure-mode families before testing any one, so the answer isn't smuggled in as an assumption.

3
Operationalize

Turn each cause into a measurable feature

A hypothesis you can't measure is one you can't reject. Map suspicion to telemetry.

4
Screen

Rank by association — but distrust the ranking

Raw correlation is a flashlight, not a verdict. It shows you where to look, including at impostors.

5
Confront

Demand a mechanism

Strong correlation + no physics = a clue about a hidden variable, never a conclusion. This is the trigger to reason abductively.

6
Abduce

Propose the structure that best explains the whole pattern

"Confounder" is the usual best explanation for loud-stats-no-mechanism. Name the hidden driver and predict what holding it constant should do.

7
Control

Test the structure: partial correlation, stratification, regression

True causes survive controls; proxies collapse; masked contributors emerge. The data sorts itself once you ask the conditional question.

8
Confirm

Close the loop with physics

The surviving candidate must show its mechanism in the raw signal — here, the weak cell crossing the over-voltage line on every charge.

On choosing the next hypothesis When a hypothesis is rejected, you don't pick the next one at random. Order the field by expected information per unit effort: prefer the candidate that (a) is most prior-probable in your fleet, (b) would explain the most observed failures if true, and (c) is cheapest to test with data you already have. Rejection is not a dead end — each "no" reshapes the remaining probabilities, which is exactly what abduction formalizes.
§ 12 — Diagnosis Becomes Prognosis

From the mechanism, a forecast — and a decision

Because we found the mechanism and not merely a correlate, the diagnosis converts directly into a prognosis. We know ΔV drives fade and we know the firmware fix collapses ΔV back to the healthy range. So for an affected vehicle — say, one currently at 91% SOH and 14 months old — we can project two futures and put a number on the intervention:

Figure 7 — Remaining-life projection
No intervention After OTA balancing fix 80% end-of-life
The payoff of a real root cause. Left untreated, the vehicle crosses the 80% end-of-life line years early. Patch the balancing controller over the air, ΔV recovers, and the fade rate drops to baseline — extending useful life substantially. This forecast is only trustworthy because the diagnosis was mechanistic. Had we "fixed" fast-charging, the curve would not have moved at all.

And it generalizes: any vehicle on build v3.2.1 carries the same latent risk, even those not yet visibly degrading. The diagnosis becomes a fleet-wide screen — flag every affected VIN, prioritize by current ΔV, push the OTA, and verify recovery in the post-patch telemetry. Diagnosis told us why; prognosis told us who's next and how much time we have.

§ 13 — The Audit Trail

Make the reasoning legible to others — and to agents

A root cause nobody can follow is a root cause nobody will trust. The investigation above should be logged not as a conclusion but as a decision path — the artifact that lets a reviewer, a regulator, or a downstream automated agent reconstruct exactly how you got there:

# Decision record — one node per inference, machine- and human-readable
{
  "observation": "bimodal SOH fade; fast cohort ~2x baseline",
  "hypotheses": ["thermal","fast_charge","high_soc","cycling","balancing","firmware"],
  "screen": {"method":"pearson", "top":["dV","fast_charge"]},
  "challenge": {"feature":"fast_charge", "mechanism":null,
                "flag":"strong corr, no physics -> suspect confounder"},
  "abduction": "both fast_charge and fade are downstream of firmware build",
  "control": {"partial_r(fast_charge|dV)":-0.09,   # collapsed
              "partial_r(dV|fast_charge)":0.82}, # survived
  "physics": "weak cell crosses 4.25V on every charge (per-cell trace)",
  "root_cause": "firmware v3.2.1 balancing timing bug",
  "action": {"fix":"OTA balancing patch", "screen":"all v3.2.1 VINs"},
  "confidence": "high — stats + mechanism + categorical stratification agree"
}

Logged this way, every link in the chain carries its own evidence and its own rejected alternatives. That is what makes the diagnosis defensible, the prognosis auditable, and the whole pipeline safe to hand to an autonomous diagnostic agent — which, after all, must reason exactly like this, just faster.

Part II

The Foundations

The case is closed. What follows is the theory underneath every move we made — the concepts that turn a lucky guess into a repeatable method.

§ F1 — Two Questions

Diagnosis and prognosis are different jobs

They sit on the same data but answer different questions and carry different burdens of proof.

Diagnosis is inverse inference: from observed effects (symptoms, signals, codes) back to the hidden state that produced them. Its standard of proof is mechanistic identification — you must name the fault and the pathway. Prognosis is forward inference: from the identified state and the expected operating profile, project the future trajectory and the time to a threshold. Its standard of proof is predictive calibration — your forecasts must match what actually happens.

The mechanism is the bridge. Formally, if degradation follows some rate law

d(SOH)/dt = f(state, stressors; θ)

then diagnosis is the act of identifying f and its parameters θ, and prognosis is integrating that law forward to find when SOH crosses end-of-life. Diagnose only a correlation and you have no f to integrate — only a line you are extrapolating on faith. Diagnose the mechanism and θ and the same telemetry becomes a calibrated forecast you can act on. This is exactly why, in the case study, finding the balancing mechanism (not just the fast-charge correlation) is what made the remaining-life projection trustworthy.

§ F2 — The Candidate Space

Why hypotheses come in families

Hypothesis generation fails most often by omission — the true cause was never on the list. The defense is to enumerate by failure-mode family, because families are how physical systems actually break, and spanning them guarantees coverage:

FamilyGenerating mechanismSignature in data
Mechanical / wearFatigue, friction, cyclic loadingMonotonic drift with usage/cycles
ThermalArrhenius-accelerated chemistryCorrelates with temperature exposure
Electrical / controlSensor drift, actuator fault, control-loop errorStep changes, regime shifts
Software / firmwareLogic defect in a specific buildCategorical split by version
ManufacturingMaterial or assembly varianceBatch / lot clustering
Usage / environmentAbuse, duty cycle, contaminationCorrelates with operating profile

The "signature" column is the practical payoff: each family leaves a different fingerprint, which tells you what to plot. A categorical split (as in our firmware case) looks nothing like a wear process's smooth drift — and recognizing that difference is half the diagnosis.

§ F3 — The Four Gates

What it takes to call something a cause

A candidate is promoted from "associated" to "causal" only by clearing four gates in order. The first three are statistical; the fourth is physical, and it is the one that catches impostors.

GateQuestionToolWhat failure looks like
1 · AssociationDo they move together?Correlation, mutual informationFlat scatter
2 · SignificanceCould it be chance?p-value, confidence intervalWide CI, large p
3 · MaterialityDoes it explain enough?Effect size, R², variance shareSignificant but tiny effect
4 · MechanismIs there a physical pathway?First-principles model, bench testNo story, or story contradicts data

Fast-charging cleared gates 1–3 and failed gate 4 — that is the exact profile of a confounded variable, and why gate 4 is non-negotiable. A fifth, meta-gate sits above all of these: robustness under control. A true cause keeps its association when you hold other variables constant; a proxy does not. That meta-gate is the subject of §F5.

A warning about gate 3 Statistical significance and materiality are not the same thing. With enough vehicles, a cause that explains 0.5% of the variance will still post p ≪ 0.001. Significance answers "is the effect real?"; effect size answers "is it worth acting on?" Conflating them is how teams chase real-but-trivial factors while the dominant driver hides in plain sight.
§ F4 — Three Ways to Reason

Deduction, induction, abduction

Diagnostics uses all three, but it is powered by abduction. The distinction is worth making precise, because each has a different job and a different fallibility.

Deduction — from rule to certain consequence

Given a rule and a case, the conclusion is guaranteed. Rule: a balancing failure produces cell drift. Case: this car's balancer is dead. Therefore: this car will show cell drift. Truth-preserving, but it only works once you already have the rule — deduction tests hypotheses, it does not generate them.

Induction — from cases to general rule

Observe many buggy-build cars all showing drift, and generalize: "this build causes drift." Induction builds the rules that deduction later uses. It is probabilistic — the next case could break the pattern — and it is how the statistical screens in the case study earn their keep.

Abduction — from observation to best explanation

This is the diagnostic engine. We observe an effect (cell drift and accelerated fade) and ask: of all the explanations that could produce this, which one accounts for it best? Abduction is generative — it proposes hypotheses — and it is openly fallible: the best current explanation may be overturned by new evidence. Its selection criteria are explanatory: prefer the hypothesis that covers the most observations, invokes the fewest unsupported assumptions, and coheres with known mechanism.

In practice the three chain into a loop, and naming the loop is what makes the method teachable:

abduce a hypothesis  →  deduce its testable consequences
 →  test inductively against data  →  update  ↻

The whole case study is one turn of this loop: we abduced "confounder," deduced "then fast-charge should lose its signal once ΔV is held constant," and confirmed it inductively in the partial-correlation test.

§ F5 — Confounding & Control

The math behind "hold it constant"

A confounder is a variable Z that causes both the suspect feature X and the outcome Y. It manufactures an association between X and Y even when no arrow runs from X to Y. Our case is the textbook shape:

firmware (Z)  →  fast-charge (X)
firmware (Z)  →  ΔV  →  fade (Y)
∴ X and Y correlate, with no X → Y

Partial correlation dissolves the illusion. To get the association between X and Y that is not explained by Z, regress Z out of each and correlate what remains:

rXY·Z = corr( X − X̂(Z),  Y − Ŷ(Z) )

If every bit of the X–Y association ran through Z, both residuals are now independent and the partial correlation falls to zero — which is precisely what happened to fast-charging (r: 0.74 → −0.09). If X genuinely drives Y, the association survives, as ΔV did.

The masking case — confounding's mirror image

Ambient temperature did the opposite: weak raw correlation, strong partial. This is suppression (or masking). A dominant driver — ΔV — carried so much of the variance that ambient's genuine but modest effect was buried in the marginal correlation. Removing the dominant variable unmasks the smaller true one. The lesson cuts both ways: raw correlation under-states real causes as readily as it over-states fake ones.

Control the right variable, or be misled Conditioning is not always virtuous. Control for a mediator (a variable on the causal path, like ΔV when you are asking about the firmware's total effect) and you block the very effect you want to measure. Control for a collider (a common effect of X and Y) and you can manufacture a correlation from nothing. Partial correlation is a scalpel: powerful, and dangerous in the wrong place. The causal diagram, not the correlation matrix, tells you what to condition on.
§ F6 — When a Hypothesis Is Rejected

Choosing what to test next

Rejection is not a dead end — it is information. The question is which surviving candidate to test next, and the answer is an economics problem: maximize expected information per unit of effort. Three factors set the order:

Prior probability. What is the base rate of this failure family in your fleet? A known-common mode is worth testing before an exotic one, all else equal.

Coverage. If true, how much of the observed pattern would this hypothesis explain? Prefer candidates that, if confirmed, close the most of the case at once.

Testability. Can you test it with data already in hand, or does it require a bench experiment that takes weeks? Cheap, fast tests come first even at slightly lower prior.

Each rejection then reshapes the field. Viewed through Bayes, observing "test failed" reallocates posterior probability mass across the remaining hypotheses — the eliminated candidate's prior flows to those still standing, sharpening the next choice. Diagnosis is therefore not a checklist run top to bottom but an adaptive search whose ordering updates after every result. This is what we did implicitly when fast-charging's mechanism failure pushed us straight to the confounder hypothesis rather than to the next item on the original list.

§ F7 — Mechanism Into Forecast

How diagnosis becomes prognosis

Once the rate law f is identified, prognosis is integration. For our fleet the diagnosed mechanism makes ΔV the dominant stressor, so a usable model is

fade_rate ≈ β₀ + β₁·ΔV + β₂·(T − 20)⁺ + …

and remaining useful life is the time until the integrated SOH reaches the end-of-life threshold:

RUL = ( SOH_now − SOH_eol ) / fade_rate

The mechanism does three things no curve-fit can. It tells you which covariates belong in f (ΔV and temperature, not fast-charge). It tells you how an intervention changes the forecast — patch the balancer, ΔV collapses, β₁·ΔV shrinks, the curve bends up. And it lets you screen the unaffected — any car on the buggy build carries the latent risk before it ever shows symptoms. A purely statistical RUL fit can extrapolate the past; only a mechanistic one can answer the counterfactual "what happens if we act," which is the only question a fleet operator actually cares about.

Part III

Systems, Flow & Algorithms

The same investigation, drawn as a machine: the pipeline it lives in, the decision flow it follows, and the algorithms that execute it.

§ S1 — The Pipeline

Where diagnosis and prognosis sit

Every health-monitoring system is the same five-stage pipe: raw signals in, a decision out, and a loop that never stops. Diagnosis and prognosis are two distinct engines inside it, with the diagnostic output feeding the prognostic input.

Diagram 1 — End-to-end VHM pipeline
Two engines, one pipe. Acquisition turns sensors into features. The diagnostic engine answers what is wrong and why — it isolates the fault and identifies the mechanism. The prognostic engine answers when and how much — it turns that mechanism into a remaining-life forecast. Action closes the loop, and the cycle repeats on the next telemetry batch.

The arrow between the two engines is the load-bearing one. A prognosis built on a misdiagnosis inherits the error and amplifies it over the projection horizon. That is the systems-level reason the case study spent so long separating a true cause from a confounder before projecting anything.

§ S2 — The Causal Block Diagram

The example, as a graph of cause

Strip the battery case to its wiring and it becomes a causal diagram — the "answer key" the analysis was trying to recover. Reading it explains, at a glance, why fast-charging fooled the raw statistics.

Diagram 2 — Causal structure of the fleet
Causal pathway Spurious (no arrow) Secondary cause
The fork that fooled us. The firmware build is a common cause: it drives fast-charging (via an aggressive default) and, separately, drives ΔV (via the balancing bug). Fast-charge and fade therefore correlate while no arrow connects them — the definition of confounding. Only ΔV and ambient temperature have arrows into fade.

This is what §F5 means in pictures: condition on the firmware build (or on ΔV, which carries its effect), and the fast-charge → fade association evaporates because the path that created it is blocked.

§ S3 — The Decision Flowchart

The investigation as a flow

Here is the whole method as an operational flowchart — the path any case follows, with the decision diamonds that route it. Note the two ways out of "no": a feature with no mechanism triggers the confounder branch rather than an immediate reject.

Diagram 3 — Root-cause decision flow
The route from alarm to root cause. The spine runs top to bottom; the confounder branch (right) handles the dangerous case of strong statistics with no physics; every rejection returns to the screen to test the next-best hypothesis. Only a candidate that survives both control and physics exits to prognosis.
§ S4 — The Algorithms

Three routines that run the method

The flowchart compiles into three cooperating algorithms. The first drives the search, the second is the confounder test it calls at the critical branch, and the third converts the surviving diagnosis into a forecast.

Algorithm 1Abductive Root-Cause Search
Input: outcome y, candidate features F, telemetry
Output: root cause c*, mechanism m, confidence
H ← enumerate hypotheses across failure-mode families # §F2
rank F by |corr(f, y)| # screen, §05
for f in F (descending score):
if not (significant(f,y) and material(f,y)): continue
if mechanism_exists(f): # gate 4
if confirm_physics(f): return (f, mechanism, high)
else: # strong stats, no mechanism
Z ← abduce_confounder(f, H) # best explanation
if partial_corr(f, y | Z) ≈ 0: # Algorithm 2
mark f spurious; continue
else: promote Z (or its mediator) into F
update priors over remaining H # §F6, Bayesian
return best-supported candidate
Algorithm 2Confounder Test · Partial Correlation
Input: suspect x, outcome y, control variable z
Output: verdict {spurious | retained}
r_x ← x − OLS_fit(z → x) # residual of x given z
r_y ← y − OLS_fit(z → y) # residual of y given z
(r, p) ← pearson(r_x, r_y)
if |r| small and p large:
return spurious # association ran entirely through z
else:
return retained # x carries signal z cannot explain
Algorithm 3Mechanistic Prognosis · RUL
Input: mechanism m, state SOH_now, stressors s, threshold SOH_eol
Output: RUL, RUL_after_fix, life_gain
f ← fade_rate = β0 + Σ βi·si # covariates chosen BY m, §F7
rate ← f(s)
RUL ← (SOH_now − SOH_eol) / rate
# intervention counterfactual
s′ ← apply_fix(s) # e.g. ΔV → healthy range
RUL_fix ← (SOH_now − SOH_eol) / f(s′)
return RUL, RUL_fix, (RUL_fix − RUL)
§ S5 — The Two Engines, Up Close

Diagnostic vs. prognostic, applied

Now we open the two middle blocks of Diagram 1 and run the example through each in turn. They consume different inputs, run different math, and emit different outputs — but they are joined at the mechanism.

Diagram 4 — Inside the two engines
What each engine computes. The diagnostic engine performs detection → isolation → root-cause attribution, emitting a fault label and a mechanism. The prognostic engine takes that mechanism, parameterizes a degradation law, integrates it to the end-of-life threshold, and emits a remaining-life estimate with its counterfactual under intervention.

The diagnostic pass — for one vehicle

Take VIN on build v3.2.1, currently 91% SOH at 14 months, measured post-charge ΔV ≈ 55 mV:

Detection: fade rate sits well above the fleet baseline band → flag.   Isolation: the anomaly localizes to the pack, and within it to cell-voltage spread, not temperature or cycling.   Attribution: high ΔV + firmware = v3.2.1 + the confirmed over-voltage trace ⇒ root cause is the balancing-controller timing bug. Output: {fault: balancing_bug, mechanism: weak-cell over-voltage, confidence: high}.

The prognostic pass — same vehicle

The diagnosis hands the prognostic engine the mechanism, which fixes the rate law's covariates (ΔV dominant, ambient secondary). Using the fleet-fitted rates:

ScenarioFade rateRUL to 80% SOH
No intervention (ΔV ≈ 55 mV) %/yr(91 − 80) / rate ≈ mo
After OTA fix (ΔV → ≈ 9 mV) %/yr mo

The intervention roughly the remaining life — a number the operator can weigh against the cost of the OTA campaign. This is the payoff of the whole method: because the diagnostic engine identified a mechanism and not a correlate, the prognostic engine can answer the counterfactual "what happens if we act." Had the diagnosis blamed fast-charging, Algorithm 3 would have applied a fix to s that changes nothing in f — and the RUL line would not have moved at all.

§ S6 — The Diagnostic Decision Tree

The same logic as a tree you can walk

The flowchart in §S3 is the full investigative process. Once the method has run and the causes are known, the recurring per-vehicle decision compresses into a short decision tree of feature thresholds — walk it from the top and any vehicle lands in a fault class in three questions. The split points come straight from the data: the buggy cohort sits near 53 mV ΔV and the healthy near 9 mV, so a 25 mV cut separates them cleanly.

post-charge ΔV > 25 mV ?
NO ↓
Healthy aging — routine monitoring, no action
YES ↓
firmware build = v3.2.1 ?
YES ↓
ROOT CAUSE — balancing-controller bug. Push OTA fix; screen all v3.2.1 VINs; hand to prognosis.
NO ↓
sustained ambient > 30 °C ?
YES ↓
Thermal-driven fade — flag climate exposure, model Arrhenius term
NO ↓
Imbalance, origin unknown — bench-test cell & balancer

This is the diagnostic engine of Diagram 1 distilled into production form: cheap to evaluate on every vehicle, and every leaf names both a fault and an action. Note the ordering encodes the analysis — ΔV first because it is the dominant, mechanism-backed signal; firmware second to confirm the origin; ambient last as the secondary cause.

§ S7 — Simulating the Future

Predicting when failure arrives

Figure 7 drew the average future. Real prognosis is probabilistic: the fade rate varies across vehicles (parameter uncertainty) and wobbles month to month (process noise), so the honest output is a distribution over failure times, not a single line. We make Algorithm 3 stochastic and run a Monte Carlo — 4,000 future SOH trajectories for the affected vehicle, each with its own sampled fade rate, advanced month by month until it crosses the 80% end-of-life threshold.

The forecast as a fan

Plotting the 10th–90th percentile band shows where the vehicle is likely to be over time, not just the median guess:

Figure 8 — Forecast fan chart
No fix: median & 10–90% band After OTA fix: median 80% EOL
Uncertainty made visible. The shaded band is the 10th–90th percentile of 4,000 simulated futures with no intervention; the spread is the forecast's honesty about what it doesn't know. The fix (teal) bends the whole distribution away from the end-of-life line.

The probability of failure over time

Integrating the failure times into a cumulative curve answers the operator's actual question — what is the chance this vehicle has failed by month T?

Figure 9 — Probability of failure vs. time
No fix After OTA fix
The decision-grade output. Untreated, the vehicle reaches a chance of having crossed end-of-life by 24 months; with the fix that falls to . This is the curve a warranty-reserve or recall-timing decision is actually priced against.

The remaining-life distribution

Figure 10 — RUL distribution (no fix)
A number with a spread. Median remaining life is months, with an 80% interval of months. Quoting the median alone hides the left tail — the vehicles that fail soonest — which is exactly where the warranty risk concentrates.

Together these three views are the prognostic engine's full answer: not "it will last 20 months," but a calibrated forecast with its uncertainty, a failure-probability curve to act on, and a counterfactual showing the fix moves the median from to months. Because the underlying diagnosis was mechanistic, every one of these curves responds correctly when we simulate the intervention — the payoff promised back in §F1.

Appendix

Code & Data

Everything in this monograph is reproducible. The complete script and the generated dataset are provided alongside; the essential listings are reproduced here.

§ A1 — The Mockup Data Source

A fleet with a known ground truth

The dataset is synthetic by design: to grade a diagnostic method you must know the true answer in advance. We simulate 240 vehicles, planting one real causal chain and one deliberate confounder, then let the analysis try to recover the truth from the data alone. The generated table (fleet.csv) has one row per vehicle with these columns:

ColumnMeaningRole
firmware_v3211 if on the buggy buildRoot upstream cause
cell_imbalance_mVPost-charge cell ΔVMechanism
fast_charge_per_moDC fast-charge events / monthConfounded proxy
ambient_CMean ambient temperatureModest cause
cycle_countEquivalent full cyclesMinor cause
soc_dwell_pct% time parked above 80% SOCNull (decoy)
age_monthsVehicle ageMinor cause
fade_rate_pct_yrAnnualized SOH fade rateOutcome (target)

Crucially, fast_charge_per_mo enters the fade equation with zero coefficient — any correlation it shows is an artifact of its shared parent, the firmware build. That single design choice is what makes the dataset a fair test of whether the method can resist a convincing impostor.

§ A2 — Data Generation

Planting the ground truth

Each block below is one edge of the causal graph. Read top to bottom, it is the graph itself, written in code:

# === SECTION: data-generation (the mockup data source) ===========
# Each block below is one edge of the ground-truth causal graph.

# Root upstream cause: ~42% of the fleet runs the buggy build v3.2.1.
firmware_buggy = (rng.random(N) < 0.42).astype(int)

# Confounding link: the buggy build also shipped an aggressive DC
# fast-charge default, so those cars fast-charge more often.
# (This is why fast-charging will LOOK guilty but cause nothing.)
fast_charge_pm = np.where(firmware_buggy == 1,
                          rng.normal(15, 3.0, N),
                          rng.normal(6, 2.5, N)).clip(0)

# Ambient temperature: set by the owner's climate, independent of
# firmware. ~40% of owners live in a warm region.
region_warm = (rng.random(N) < 0.40).astype(int)
ambient_C = np.where(region_warm == 1,
                     rng.normal(31, 3.0, N),
                     rng.normal(18, 4.0, N))

# Nuisance / secondary features.
age_months  = rng.uniform(6, 36, N)
cycle_count = age_months * rng.normal(38, 6, N)        # cycles grow with age
soc_dwell   = rng.uniform(5, 60, N)                    # % time parked > 80% SOC

# THE bridging variable: post-charge cell-voltage spread dV (mV).
# Driven almost entirely by the firmware bug; tiny aging term.
cell_imbalance_mV = (
    np.where(firmware_buggy == 1, rng.normal(52, 8, N),
                                  rng.normal(6, 2.5, N))
    + 0.004 * cycle_count
    + rng.normal(0, 2.5, N)
).clip(2)

# OUTCOME: annualized SOH fade rate (%/yr).
# TRUE drivers = dV (dominant) + ambient (modest) + cycling (small).
# NOTE: fast_charge_pm has NO coefficient here -> any correlation it
# shows with fade is pure confounding through the firmware build.
degr_rate = (
    1.8
    + 0.075 * cell_imbalance_mV
    + 0.075 * np.maximum(ambient_C - 20, 0)
    + 0.0010 * cycle_count
    + rng.normal(0, 0.45, N)
).clip(0.5)

# Convenience: SOH after 24 months under the current fade rate.
soh_24mo = (100 - degr_rate * 2.0).clip(60)

# Assemble the table and write the mockup data source to CSV.
fields = ["vin", "firmware_v321", "fast_charge_per_mo", "ambient_C",
          "age_months", "cycle_count", "soc_dwell_pct",
          "cell_imbalance_mV", "fade_rate_pct_yr", "soh_24mo_pct"]

def write_csv(path="fleet.csv"):
    with open(path, "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(fields)
        for i in range(N):
            w.writerow([
                f"VIN{i:04d}", int(firmware_buggy[i]),
                round(float(fast_charge_pm[i]), 2), round(float(ambient_C[i]), 2),
                round(float(age_months[i]), 1), round(float(cycle_count[i]), 0),
                round(float(soc_dwell[i]), 1), round(float(cell_imbalance_mV[i]), 1),
                round(float(degr_rate[i]), 3), round(float(soh_24mo[i]), 2),
            ])
    print(f"wrote {path}  ({N} rows)")

Run as a script, this writes fleet.csv and prints the full analysis below it.

§ A3 — The Diagnostic Pipeline

Recovering the truth from the data

The same four moves from the case study, as functions: the correlation screen (§05), the partial-correlation control that exposes the confounder (§07), the regression where the fast-charge coefficient dies, and the firmware stratification that finds the origin (§09).

# === SECTION: analysis (the diagnostic pipeline) =================
features = {
    "Cell imbalance dV (mV)":  cell_imbalance_mV,
    "Fast-charge events / mo": fast_charge_pm,
    "Ambient temp (C)":        ambient_C,
    "Cycle count":             cycle_count,
    "High-SOC dwell (%)":      soc_dwell,
    "Age (months)":            age_months,
}

def correlation_screen(y):
    """Step 1 - rank candidates by raw Pearson correlation."""
    print("\n[1] RAW correlation vs fade rate")
    for name, x in features.items():
        r, p = stats.pearsonr(x, y)
        print(f"    {name:24s}  r={r:+.3f}  p={p:.1e}")

def partial_corr(x, y, z):
    """corr(x, y) with the linear effect of z removed from both.
    True causes survive this; confounded proxies collapse."""
    rx = x - np.polyval(np.polyfit(z, x, 1), z)   # residual of x | z
    ry = y - np.polyval(np.polyfit(z, y, 1), z)   # residual of y | z
    return stats.pearsonr(rx, ry)

def partial_screen(y, control, control_name):
    print(f"\n[2] PARTIAL correlation (controlling for {control_name})")
    for name, x in features.items():
        if x is control:
            continue
        r, p = partial_corr(x, y, control)
        verdict = "survives" if abs(r) > 0.3 and p < 0.05 else "COLLAPSES"
        print(f"    {name:24s}  r={r:+.3f}  p={p:.2f}   {verdict}")

def regression(y):
    """Step 3 - watch the fast-charge coefficient die when dV enters."""
    def fit(cols):
        X = np.column_stack([np.ones(N)] + cols)
        b, *_ = np.linalg.lstsq(X, y, rcond=None)
        pred = X @ b
        r2 = 1 - np.sum((y - pred)**2) / np.sum((y - y.mean())**2)
        return b, r2
    b1, r2_1 = fit([fast_charge_pm, ambient_C])
    b2, r2_2 = fit([fast_charge_pm, ambient_C, cell_imbalance_mV])
    print("\n[3] REGRESSION")
    print(f"    naive  fade~fc+amb        : fc_coef={b1[1]:+.3f}  R2={r2_1:.3f}")
    print(f"    full   fade~fc+amb+dV     : fc_coef={b2[1]:+.3f}  "
          f"dV_coef={b2[3]:+.3f}  R2={r2_2:.3f}")

def stratify(y):
    """Step 4 - follow the chain upstream to the firmware build."""
    bug = firmware_buggy == 1
    print("\n[4] FIRMWARE STRATIFICATION")
    print(f"    v3.2.1 (buggy) : dV={cell_imbalance_mV[bug].mean():5.1f} mV   "
          f"fade={degr_rate[bug].mean():.2f} %/yr   (n={bug.sum()})")
    print(f"    other builds   : dV={cell_imbalance_mV[~bug].mean():5.1f} mV   "
          f"fade={degr_rate[~bug].mean():.2f} %/yr   (n={(~bug).sum()})")

The printed output reproduces every number quoted in Parts I and II — fast-charge collapsing to r ≈ −0.09 under control, ΔV holding at ≈ 0.82, the regression coefficient falling from +0.27 to −0.02, and the categorical firmware split. The method recovers the planted truth without ever being told it.