MARKUS PLANDOWSKI

I build end-to-end ML systems for real-world sensor data.

Building my own end-to-end ML system for battery data

A personal account of building an end-to-end battery ML system from heterogeneous data: the infrastructure that worked, the experiments that failed, and the questions the released checkpoint leaves open. The implementation, notebooks, and checkpoint are available in batgrad for direct inspection.

Why: the deliberately naive vision

The vision is simple: train one model across cell specifications, operating conditions, and eventually chemistries, instead of fitting and parameterizing a new model every time the datasheet changes. Give it the controls and sensor history, then let it predict how the system evolves.

Empirical equivalent-circuit models and filters are fast, efficient, and very hard to beat when the problem is close to linear. Physics-based electrochemical models provide insight into more complex behavior and allow precise interpretation. They can also be quite fast when the operating profile is not too dynamic.

A neural network will not win by recreating the same next-step lookup with more parameters. It has to remember enough history to capture effects the compact models miss, remain useful over long rollouts, or make it practical to combine signals that do not fit neatly into one set of equations.

The ambitious version includes data from EIS, GITT, DCA, X-ray imaging, and other measurement domains. A large model could run in the cloud, with a distilled version on the edge. It could act as a useful digital twin: helping decide which expensive experiment is worth running next.

That is the direction, not the result. What the current implementation demonstrates is much narrower: the entire path from heterogeneous source files to reproducible autoregressive evaluation can live in one coherent system.

Environment: simplicity first, until reality wins

I like simple tools that do one thing, do it well, and do it fast.

I like working in the terminal, usually with Neovim and tmux. I am happier removing a line of configuration than adding the plugin of the week. The same preference carries into my projects: few concepts, explicit contracts, and dependencies that justify their weight.

That makes developer experience a real concern:

  • Plotting a suspicious signal takes 30 seconds: I will plot it later.
  • A smoke run needs a remote job: I will run it later.
  • A commit is blocked by a local hook: I will just stash it for now.

I have caught myself doing all three.

Small bits of friction quietly become reasons not to check. So the target was simple: data inspection, loader checks, tiny training runs, and inference should all work locally first, fast, and with as little friction as possible.

batgrad uses one Dockerfile and closely related images for local development and remote GPU runs. Every stage is set up around the same non-root ubuntu user, and remote startup passes SSH access to that account as well.

Getting there was not boring. I spent two days setting up Google Cloud around its free credits, fighting the CLI and container environment, before discovering that my account had no GPU quota. Vast.ai was less polished but much more practical. The first remote images still failed while the provider initialized its own user, SSH, and Python environment, and each fix meant another several-minute build and upload loop. That work eventually paid for itself: I can now spin up a GPU instance, run one script, and get the same shell and project layout I use locally.

I also avoid binding the core system too tightly to external services. Experiment tracking sits behind a small adapter, while standard logging continues to work without it. The data and ML packages meet through files and explicit contracts instead of importing each other’s internals. This is not architecture for architecture’s sake. It means I can replace one side without turning the change into a full rewrite, deploy the parts separately if I ever get there, or open-source only the pieces that make sense.

The code stays mostly procedural until an abstraction earns its place. I use objects for state, but try to keep the structure flat. I would rather read a slightly longer function and understand what is happening than jump through several layers until I lose the thread.

At least, that is the logic I try to follow.

Data: do the tedious part first

Starting with the model is more fun, they say. So I started with the data: finding sources, reading papers and licenses, understanding what was actually measured, and looking for useful variation across profiles, temperatures, and protocols.

Public experimental data is not one dataset. It is a collection of local dialects.

Downloads stop halfway through. One spreadsheet calls a column Test_Time(s), another adds spaces, and a third changes the sheet layout entirely. Units, signs, timestamps, and missing values all carry assumptions. The first useful abstraction was therefore not a neural layer, but a dataset adapter.

Each adapter translates one source into a canonical schema, while a manifest records where each segment came from, its protocol, row range, statistics, and producing code revision. Unknown source columns are rejected instead of silently disappearing; declared but absent columns remain visible as missing. This is less convenient on day one, but pays off on day one hundred, once I have forgotten the ins and outs of each source.

Processing then follows three explicit stages: raw, ingested, normalized. Polars and Parquet make the local path fast, while lazy scans, bounded batches, incremental writes, and sharding keep memory use predictable. batgrad still has only a local filesystem backend, but those choices leave a path to larger storage later.

Checks run around normalization: time must move sensibly, values must remain within physical bounds, required signals must exist, and protocol-specific axes must be valid. The interactive notebook uses the same transformation code and marks failed sections visually, so a failed check leads directly to the suspicious trace instead of another round of copying paths into plotting scripts.

Finally, more rows do not automatically mean more information.

The concrete dataset behind this project went from roughly 300 GB of spreadsheets to 60 GB of ingested Parquet and about 2 GB after protocol-aware resampling. Those numbers are not a universal compression recipe. They are a useful reminder that preserving events, shapes, and transitions matters more than preserving every sample on a fixed time grid.

Protocol-aware resampling produces variable time steps, so elapsed time is carried as an explicit input rather than leaving the model to assume a fixed interval.

The handoff: add the right friction

The boundary between data engineering and ML is where quiet mistakes become expensive. I had learned this in an earlier project after spending far too long changing a model that was reading the wrong data revision. In batgrad, the run configuration pairs every selected dataset with its expected producing Git revision. If the manifest does not match, loading fails. That is deliberate friction. The loader also starts from normalized manifests, builds an index across datasets without physically merging them, and checks configured scaling limits against recorded statistics.

What counts as a meaningful group depends on the problem. It might be a dataset, cell, experiment, cycle, protocol, or some combination of them. batgrad therefore makes the grouping strategy explicit instead of hard-coding one definition. The current baseline keeps rows belonging to the same dataset, cell, cycle, and protocol together, then assigns complete groups to training or validation rather than randomly splitting rows from the same trace. It also makes the temporal contract inspectable: input row k predicts target row k+1.

Model: the fun part, until it isn’t

Once the clean data, manifests, splits, windows, and scaling exist, I can finally ask the model question.

The first version was deliberately ordinary: project the scalar features, pass them through a causal Transformer, and regress the next voltage and temperature. The one-step predictions could look good while the model received the true history. The moment it had to feed back its own outputs, the trajectories drifted, oscillated, or settled onto a plausible-looking average.

I scaled the experiments down before making them more complicated. A small run could return an answer in roughly 30 to 60 minutes, which was short enough to test an idea without committing a day to it. It was still long enough for weeks of attention variants, recurrent blocks, objectives, data-loader changes, and gradient inspections to accumulate.

Plain RNNs brought the stateful behavior I wanted but slowed training substantially. xLSTM blocks improved parts of that design while adding another specialized path to build, tune, and keep working. In my experiments, Mamba became the more useful compromise: a linear recurrent model with an efficient training path and a state that could continue across coherent windows. The connection to state-space estimation also made more sense to me than asking attention alone to preserve state beyond a finite context window.

A few architectural choices mattered more than the rest. Ordering mattered in those experiments. Putting Mamba before attention was not working well. The configuration I ultimately kept puts causal attention first, to work with the visible context, and Mamba after it, to carry a compact state beyond that context. The current stack repeats that order with feed-forward blocks between them.

Pre-normalization stabilized gradients; post-normalization did not have the same effect, and applying both nearly collapsed them. None of the positional encodings I tried improved the result. The model already receives time differences directly, while Mamba carries recurrent state across coherent windows, so I left any additional position embedding out.

I also tried preserving a separate feature axis and applying attention across the scalar features. In those runs, the model behaved better when each feature was embedded independently and the embeddings were summed into one representation before temporal mixing. That simpler reduction is what remains in the baseline.

I did not arrive there through one clean ablation study. Optuna searched layer order, depth, width, heads, and learning rates, while I manually tested several newer attention and residual designs. Most did not help at this model size and data scale. They added parameters, kernels, and more places to look when training failed.

The objective search followed the same pattern. I went through direct regression with MSE, MAE, Huber loss, and shape terms. I also treated the outputs as separate tasks, with one head and loss per feature. That made it easier to see when temperature was learning while voltage was not, but introduced another balancing problem: one task could still dominate the shared model. I also added auxiliary heads for quantities such as resistance and capacity, hoping they would force the model to learn a more useful physical state and stronger time dependence. They did not.

The next attempt added physics-based regularization to the objective. I used simplified differential relations to penalize predictions that violated them. It sounded like the obvious way to stop implausible rollouts. In practice, the additional constraints made optimization stiffer without fixing the underlying problem.

The current baseline instead represents each scalar target as a distribution over bins and trains with categorical cross-entropy. Compared with direct regression, this made training easier to control: gradients behaved better, the loss scale was easier to reason about, and the imbalance between voltage and temperature became less extreme. More importantly, recursive rollouts improved. The distributions can be decoded into scalar predictions or fed back directly during rollout, and provide a rough uncertainty signal that I do not treat as calibrated.

Concretely, it uses the time difference, C-rate, voltage, surface temperature, ambient temperature, and cooling to predict the next voltage and surface temperature. The controls remain known during rollout. Predicted voltage and temperature do not: they become the next inputs.

None of the architectural work removes the central problem: teacher forcing can make a weak model look good. Small errors still move an autoregressive state away from the histories seen during training.

That changed what I treat as validation.

batgrad still measures teacher-forced held-out windows because they are cheap and useful for debugging. But a good one-step loss can hide the behavior that matters once the model consumes its own predictions. A single average can also hide a short local voltage error, which can matter even when the average looks good. I therefore run and plot recursive rollouts whenever validation runs, retaining only the controls that would actually be known and feeding predicted measurements back into the next step. Checkpoint selection follows this rollout loss, not the nicer one-step number.

The split keeps complete dataset-cell-cycle-protocol streams together, but it is not a strict unseen-cell test. Related cycles from the same physical cell may still appear in training, and repetitive cycling patterns can make an unseen trajectory look more novel than it really is.

I also appended a short, unscored constant-current continuation to each validation rollout as a rough out-of-distribution probe. The model failed to capture its time dependence. I took that as a sign that the current model and data had not captured even this simple unseen control profile robustly. That sent me back to the data: the published measurements contain real cycling, HPPC, and RPT behavior, but much of it repeats similar current and thermal conditions. I added synthetic trajectories with broader current profiles, temperatures, and cooling settings. The later model handled the probe better, although too many other parts had changed to credit the synthetic data alone. It enriches the training distribution; it does not solve the validation limitation.

The same pattern shaped the instrumentation. Per-target errors exposed the gap between voltage and temperature learning, component-level gradient norms located spikes, and rollout plots revealed average-prediction shortcuts that a scalar loss could not explain.

That is how I arrived at the current baseline. It has not been benchmarked against an ECM or persistence model in this implementation; the useful result at this stage is that the complete loop is fast enough to inspect, challenge, and improve.

AI: the zero-shot refactor

AI became increasingly useful throughout this project, but not as an autonomous engineer. I tried pointing an agent at the full research repository, roughly 100,000 lines at the time, and asking it to refactor everything into batgrad.

The old repository included hyperparameter optimization, experiments with new architectural ideas, and PyBaMM logic for generating synthetic data. The agent reduced the code substantially and produced something that looked convincing, but it quietly dropped important behavior. Better test coverage would have made this safer, but finding the omissions was often harder than rewriting the code myself.

I also tried using an agent for architecture research: change the model, run an experiment, evaluate it, and iterate. That did not work either. The repository was too entangled, and the experiments were not comparable enough for me to trust an automated search loop.

What worked was a narrower, iterative workflow: define the task and its invariants, let the agent handle one coherent part, then review the result. It became an excellent implementation partner and reviewer, helping me trace bugs, write tests, and spot inconsistencies. The back-and-forth also sharpened my judgment about code design by surfacing APIs and implementation patterns I might not have considered on my own.

Ironically, batgrad might now be reaching the point where agent-driven autoresearch becomes useful. Data revisions are explicit, experiments are configured, and validation follows long rollouts rather than one flattering metric. That gives an automated loop a better chance of making controlled comparisons. Zero-shot refactors are impressive demos, but directed iteration produced a system I can understand, explain, and perhaps now use for the kind of automation that failed before.

Wrap-up: where this leaves batgrad

I did not build the unified battery model from the opening vision. The current model does not replace an ECM or a physics-based model, and the released checkpoint does not establish generalization to entirely unseen cells or chemistries.

I did build the machinery I was missing when I started. New datasets can enter through explicit adapters. Processing is fast and inspectable. I can move from a failed check to the exact trace, zoom from a complete experiment into the raw signal, spin up a GPU machine without rebuilding my environment, and compare a rollout with the data that produced it. The part I expected to be tedious has become the part that is a joy to use.

The modeling search remains expensive. Copying an architecture from another domain was not enough, and most added sophistication produced a larger search space rather than a better model. The useful progress came from making experiments cheaper, validating the behavior I actually care about, and carrying the relevant intuition from state estimation into a neural architecture.

In the rollouts I inspected, the checkpoint remains bounded, captures highly dynamic sections, sometimes recovers after drifting, and tracks trajectories across the current, ambient-temperature, and cooling variation represented by the data. The model predicts chunks rather than one point per forward pass, which also makes long, dynamic rollouts practical. That makes it worth further tuning and larger experiments.

Whether that behavior survives broader data and stricter validation, whether the model is learning or mostly remembering, and whether more physics belongs in the data, objective, or architecture remain open questions.

You can run the released checkpoint yourself in the GPU-hosted inference notebook on Molab. One click downloads the checkpoint and processed dataset from Hugging Face, letting you inspect the rollouts yourself. The documentation covers the implementation details omitted here, including the data flow and ML architecture.