Skip to main content

Command Palette

Search for a command to run...

How a Storage BMS Actually Computes Its Charge Current Limit

Updated
26 min readView as Markdown

There is one number in every energy storage system that gets asked for constantly: "How much can we charge right now?"

That number is not pulled out of thin air, and it is not simply read from a parameter table. It is the product of a chain of constraints, each one clamping the value down a little further, each one guarding against a different way the battery could be hurt. It is the number that tells the PCS — the power conversion system — exactly how much power it is allowed to draw from the grid at this instant, and it is, in a very real sense, the number that decides whether the battery lives a long, safe life or dies early.

I want to explain how that number is computed, using a real production codebase as the reference. It is a storage BMU (battery management unit) firmwar, running on an NXP, and the current-limit logic lives in two files: CurrLimCalc.c ) and CurrLimFall.c . I will write this in the first person, because what I actually want to share is the thinking I did while reading this code — what each step is defending against, why it is designed the way it is, and the places where a senior engineer reading this would nod and smile.

Some context first, so the rest makes sense. This is a lithium iron phosphate (LFP) storage battery, nominal 280 Ah, arranged as 16 cells per pack × 15 packs, for a total stack voltage of roughly 700 V. The BMS recomputes the limit every 100 ms and reports it over CAN (protocol number 51) to the PCS. When the PCS receives that number, it knows how hard it is allowed to push.

Why does this single number deserve a long article? Because the charge current limit is not really "a number." It is the point where electrochemistry, thermal safety, power electronics, and control engineering all converge into one quantity. Get it too loose, and you plate lithium onto the anode in the cold, or drive a hot cell past its voltage ceiling in the heat — either way the battery ages prematurely, and in the worst case, it catches fire. Get it too tight, and you leave money on the table: the entire economic case for a storage plant rests on how many megawatt-hours it can shove through the battery each day, and an over-conservative limit eats directly into that revenue.

What makes the problem genuinely hard is that none of these constraints is static. The safe charging current depends on temperature, which changes with the weather and with the cell's own internal heating. It depends on the spread of temperatures between cells, which drifts as the pack ages unevenly. It depends on the state of charge, because a nearly-full cell cannot absorb current the way an empty one can. And it depends on the stack voltage, which itself swings by tens of volts over a single charge cycle. A correct current limit is therefore not a constant you look up once — it is a function of the live system state, recomputed continuously, and every one of those dependencies has to be modeled, parameterized, and defended against measurement noise.

That is what the next few thousand words are about. I will walk the full chain from the loosest constraint to the final reported value, stopping at each step to explain what it defends against and why the code looks the way it does.

One. The skeleton: a funnel of "take the minimum"

The first thing that struck me when I read CurrLimCalcTask() is that it is not a clever algorithm at all. It is a funnel. The current limit starts from the loosest constraint — the hardware ceiling — and every constraint that follows shaves it down a bit more. By the end, what comes out is guaranteed to be the strictest of all of them.

Here is the charge-side chain, distilled:

chgHCurr   = GetChgHReqMaxCurr();         /* 1. hardware ceiling */
tempMulLim = CalcTempChgMulCurr();        /* 2. temperature multiplier */
chUpLim    = min(chgHCurr, tempMulLim);

if (CalcGetGroupNeedChgLimStateHook())  chUpLim = 0;   /* 3. fault → zero */

chUpLim = ChgLimDownByTempH(...);         /* 4. high-temperature derating */
chUpLim = ChgLimDownBySubTemp(...);       /* 5. cell-delta derating */
chUpLim = ChgLimCurrByPowerLim(...);      /* 6. power-to-current conversion */

tempMulLim = GetFallChgLimCurr();         /* 7. end-of-charge taper */
chUpLim = min(chUpLim, tempMulLim);

gGLimCPInfo_51[ChgC] = chUpLim;           /* 8. write to global array */

The discharge side is essentially symmetric, with different parameters. This funnel structure is the soul of the whole algorithm, and I will come back to why it is good at the end. But first I have to explain the top of the funnel — how the hardware ceiling itself is chosen.

Before that, though, there are two cases where the funnel is short-circuited entirely, and the limit is slammed straight to zero. They are worth naming explicitly, because they reveal the hierarchy of the whole design.

The first is the non-running state. At the very top of CurrLimCalcTask(), before anything else, the code checks the work state:

if ((GetGWorkStateAPI() != eWORK_RUN)
    && (GetGWorkStateAPI() != eWORK_IDLE)
    && (GetGWorkStateAPI() != eWORK_START)
    && (GetGWorkStateAPI() != eWORK_STOP))
{
    gGLimCPInfo_51[ChgC] = 0;   /* all four limits forced to zero */
    gGLimCPInfo_51[DhgC] = 0;
    gGLimCPInfo_51[ChgP] = 0;
    gGLimCPInfo_51[DhgP] = 0;
    return;
}

If the system is in INIT, OFF, ERROR, or any state where it is not actually allowed to move power, the limit is zero, full stop. The current limit is not just "how much the battery can take physically" — it is "how much the system is allowed to push, given its current state." A battery in ERROR state might be physically capable of accepting 200 A, but the BMS must report zero, because the system has decided it is not safe to push anything.

The second is the system-level limit flag. Even in a running state, an external stop command (an emergency stop button, a system alarm) can force the limit to zero:

if (CalcGetGropuNeedSysLimStateHook())   /* system alarm / button stop */
{
    /* all four limits forced to zero */
    return;
}

This is the safety override sitting above the entire funnel. No matter how carefully you compute the temperature derating and the power conversion, a single "system says stop" flips everything to zero before the funnel even runs. In a layered safety design, this is the top layer: the highest-priority signal always wins, and it always wins by being checked first.

I dwell on these two zero-cases because they tell you something important about the philosophy of this code: the current limit is not primarily a physics calculation — it is a permission calculation. Physics gives you the ceiling; permission decides whether you may even touch it.

Two. Constraint one: the hardware ceiling, and why it sits 1 A below the alarm

GetChgHReqMaxCurr() is nominally "get the maximum allowed charge current." But there is a subtle and important detail inside it:

u16 GetChgHReqMaxCurr(void)
{
    u16 curr = gGBmuGenPara_102[eBmuGenPara102_ChgCMaxLim];   /* parameter: charge current ceiling */

    /* if ceiling >= alarm threshold, push the ceiling below it */
    if (curr >= gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim])
    {
        if (gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] > 20)
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] - 10;   /* 1 A below alarm */
        }
        else
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim];
        }
    }
    return curr;
}

(Throughout this code, current is stored in units of 0.1 A, so -10 means "minus 1 A.")

It took me a few passes to understand why this is written this way. It is defending against a very sneaky, very real failure mode: the limit touching the alarm threshold.

Imagine the "maximum allowed charge current" and the "over-current alarm threshold" were the same value. What happens when the PCS actually charges at that limit? The current sits exactly on the alarm threshold, and any sampling glitch, any tiny overshoot, immediately trips the over-current alarm. The system then falls into an absurd loop: the BMS tells the PCS to charge at 100 A, the PCS charges at 100 A, the BMS sees "100 A — alarm!" — but that 100 A is exactly what the BMS itself asked for.

So the sane thing is to keep the limit permanently below the alarm, by a fixed margin. Alarm is 101 A? Then the limit is 100 A. That 1 A of headroom is a buffer between the limit and the alarm, so the system never fights itself.

This "leave a margin" instinct shows up everywhere in this codebase. By the end of the article you will see it has almost become an engineering reflex.

Three. Constraint two: the temperature multiplier — the temperament of LFP

After the hardware ceiling comes temperature. A battery cannot charge or discharge at full power at any temperature, and LFP in particular is fussy about it.

The code does a table lookup. CalcTempChgMulCurr() first locates the current average cell temperature in a temperature table:

const s16 gTempCurLimitTable[TEMP_CURRLIMIT_NUM] =
{
    -10, -5, 0, 5, 10, 15, 20, 25, 35, 40, 45, 50, 55, 60, 70
};

It then looks up the "multiplier" for that temperature bin and multiplies by the nominal capacity:

result = (u32)gGroupParaRO_115[index] * GetGroupStandCapAPI() / 10000;

gGroupParaRO_115 is a "charge multiplier versus temperature" table stored in EEPROM, and it is configurable. At 25 °C the multiplier might be 100 % (1C, i.e. 280 A). At 10 °C it might drop to 60 % (0.6C). Below 0 °C, LFP charge rates get squeezed hard — because charging at low temperature causes lithium plating, which damages the cell irreversibly and is a genuine safety risk.

This is where one of the key differences between LFP and NMC shows up. NMC can tolerate somewhat more relaxed low-temperature charging, but LFP must be extremely conservative below 0 °C. So this multiplier table is, in essence, the electrochemical safety boundary translated into a single number in software.

I also noticed a block of #if 0-disabled code in this area, specifically for the "low temperature + low SOC" corner case — below 0 °C and below 5 % SOC, charging is outright forbidden. It is disabled in the current build, but its presence tells you something: the person who wrote this had a clear understanding of the low-temperature lithium-plating risk in LFP.

Let me make that low-temperature physics concrete, because it is the reason this table exists at all. When you charge a lithium cell below 0 °C, the lithium ions arriving at the anode cannot intercalate into the graphite fast enough. Instead of lodging into the graphite layers, they accumulate on the anode surface as metallic lithium — that is "lithium plating." Plated lithium is not reversible: it permanently removes capacity, it can grow into dendrites that eventually puncture the separator and short the cell internally, and it is a primary contributor to thermal runaway. Worse, the plating reaction is self-accelerating — once a rough spot of plated lithium forms, current concentrates there and plates even faster. So the low-temperature charge limit is not a "recommendation"; it is the electrochemical line beyond which you are actively manufacturing a future fire inside the cell.

To see how the table plays out, take a concrete 280 Ah LFP pack. At 25 °C, the charge multiplier might be 1.00, giving 1.00 × 280 = 280 A, i.e. a full 1C. At 0 °C the table might drop the multiplier to 0.20, giving only 56 A (0.2C). At −10 °C it might be 0.05 or even zero — effectively "do not charge." The spacing of the temperature bins (denser near the freezing point) is not an accident: it is where the derating is steepest, so the table needs more resolution there. This is the kind of domain knowledge that you only get from actually living with LFP cells, and it is exactly the kind of thing a generic "AI-assisted" implementation would get subtly wrong if it had no one to tell it where the cliff is.

Four. Constraint three: high-temperature derating, and the hysteresis everywhere

The multiplier table handles the cold and moderate ends. The hot end is handled by a finer mechanism: linear derating.

The core of ChgLimDownByTempH() is a single line of linear interpolation:

dowmCur = ((u16)(endTemp - nowTemp) * currLim) / (u16)(endTemp - startTemp + 1);

The meaning: once temperature exceeds startTemp (the derating start point), every additional degree shaves the current limit down linearly; by the time temperature reaches endTemp (the derating-to-zero point), the limit has been cut to zero. It is a straight line from startTemp to endTemp, with the slope determined by those two temperatures.

The linear derating itself is nothing special. What is special is the pile of hysteresis logic wrapped around it:

if ((nowTemp < sHisTemp) && ((nowTemp + 2) >= sHisTemp))    /* cooled < 2 °C */
{
    nowTemp = sHisTemp;                                     /* treat as unchanged */
}
else if ((nowTemp < sHisTemp) && ((nowTemp + 3) >= sHisTemp)) /* cooled ~3 °C */
{
    nowTemp += 1;                                           /* back off only 1 °C */
    sHisTemp = nowTemp;
}
else
{
    sHisTemp = nowTemp;                                     /* normal update */
}

I consider this block the single most skilled piece of the entire limit algorithm. It solves this problem:

Suppose the temperature hovers right at the derating start point — say startTemp is 45 °C, and the actual temperature jitters between 44.9 °C and 45.1 °C. Without hysteresis, the current limit would go "enter derating → exit derating → enter derating → exit derating," flapping wildly, and the PCS would receive a command that jumps high, then low, then high, and the whole system would convulse in sympathy.

Hysteresis is the rule: enter eagerly, exit reluctantly. When temperature rises past 45 °C, derating starts immediately, no hesitation. But when temperature falls back from 45 °C to 44 °C, the limit does not snap back up — instead it watches, waits to confirm the temperature has really settled downward, and only then recovers gradually.

This "fast in, slow out" hysteresis is implemented with two or three static variables, at essentially zero cost, yet it prevents the system from oscillating at the boundary. It is the kind of thing that is baked into the bones of anyone who does real-time control: any threshold-based control must answer the boundary-jitter question.

Let me put numbers on the derating so the shape of it is concrete. Suppose the charge current limit coming into this step is 280 A, and the derating parameters are startTemp = 45 °C and endTemp = 60 °C. The derating span is 15 °C (well, 16 with the +1 in the denominator). At 45 °C the limit is untouched: 280 A. At 52.5 °C, exactly halfway, the linear interpolation gives (60 − 52.5) / (60 − 45 + 1) × 280 ≈ 131 A — roughly half. At 60 °C it reaches zero. So between 45 °C and 60 °C, the limit walks down a straight line from full to nothing, which is the BMS's way of saying "the hotter it gets, the less current I will allow, until I allow none."

Now add the hysteresis on top of that slope. As the pack heats from 44 °C toward 46 °C, the limit begins falling the moment it crosses 45 °C — fast in. But if the pack then cools back to 44.5 °C, the hysteresis holds the effective temperature at 45 °C for a while, so the limit does not immediately climb back to full. Only when the temperature has dropped a full degree or two below the boundary does the limit start recovering. On a hot afternoon where the pack temperature drifts up and down around the threshold by a fraction of a degree, this means the PCS sees a limit that holds steady instead of fluttering, and a steady limit is what keeps a megawatt-scale power converter from thrashing.

Five. Constraint four: cell-delta derating — defending "consistency"

High-temperature derating looks at absolute temperature. Cell-delta derating looks at the spread between cells.

The input to ChgLimDownBySubTemp() is GetGCellMaxTempAPI() - GetGCellMinTempAPI() — the difference between the hottest and coldest cell in the entire stack. A large difference means the temperature distribution is uneven; some cells are hot, some are cold.

Why derate for unevenness? Because uneven temperature means worsening cell-to-cell consistency. In the same stack, the hot cells age faster, have higher internal resistance, and hit their voltage ceiling sooner; the cold cells are not yet full. If you keep pushing a large current, the hot cells get squeezed harder, accelerate their degradation, and may even trip cell over-voltage.

So the logic is: once the temperature delta exceeds a threshold (ChgHTDnDifT), derate by a percentage:

if (subTemp >= gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnDifT])
{
    if (nowTemp >= (gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnFstT] + 3))
    {
        /* big delta + high temp → derate 2 steps */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate * 2 / 1000);
    }
    else if (nowTemp >= (CalcGetChgCDnFstHTempHook() - 1))
    {
        /* big delta + moderate temp → derate 1 step */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate / 1000);
    }
    ...
}

Notice the stepped derating: big delta but not-too-high temperature → one step down; big delta and high temperature → two steps down. And the same hysteresis logic is repeated here — temperature hysteresis, delta hysteresis, both handled.

This two-condition, stepped design reflects a mature judgment: a single condition (delta alone, or temperature alone) does not tell the whole story, but the two together are a danger signal. Big delta means consistency is degrading; high temperature means you are already near the thermal edge; both at once means you must derate harder.

Six. Constraint five: power conversion — turning a power limit into a current limit

Past the temperature gates, there is a step that looks roundabout but is necessary: power conversion.

ChgLimCurrByPowerLim() does something simple:

sumVolt = GetGCellSumVoltAPI();              /* total stack voltage */
if (sumVolt > 0)
{
    chgPowerCur = ChgPMaxLim * 10000 / sumVolt;   /* I = P / U */
}

It is a rearrangement of Ohm's law: I = P / U. Given a "maximum charge power" ChgPMaxLim, divide by the current stack voltage, and you get "the maximum current allowed by the power constraint."

Why bother? Because current and power are two different limits, and both must be satisfied. Sometimes current has not reached its ceiling, but the voltage is already high, and P = U × I hits the power ceiling first. What actually limits you then is power, not current. So you convert the power ceiling into an equivalent current and take the minimum with the current limit you already have.

There is also a physical intuition hiding here: battery voltage varies with SOC. When full, voltage is high, so the same power corresponds to a smaller current; when empty, voltage is low, so the same power corresponds to a larger current. Therefore the power-to-current conversion must use the live voltage, not a fixed one. GetGCellSumVoltAPI() reads the real-time total voltage, and getting this detail right is what makes the limit accurate.

Seven. Constraint six: end-of-charge taper — CC-CV implemented in software

This is the part I most wanted to write about, because it touches the essence of lithium battery charging: the CC-CV charging curve.

The standard charge curve works like this: first constant current (CC) — current held constant while voltage climbs. When the cell voltage nears the charge cutoff (for LFP, roughly 3.65 V), it switches to constant voltage (CV) — voltage held constant while current tapers down, until the current falls to some small threshold (say 0.05C), at which point charging truly ends.

Why not keep charging at constant current all the way to full? Because as the cell approaches full, internal polarization intensifies. If you keep shoving a large current in, the voltage overshoots instantly — accelerating degradation at best, lithium plating, swelling, or thermal runaway at worst. So near full, you must let the current come down and let the voltage settle onto the cutoff.

This end-of-charge taper is implemented in CurrLimFall.c. Its core is a stepped table-based fallback, not a true PID loop (the PID part is commented out, kept as a future option).

The charge-side entry is CVPIDCtrlChgCLim(). Its logic: watch the maximum cell voltage continuously; the moment that voltage enters the end zone (maxVolt >= CVCalcChgPIDAimVolt(0)), mark "entering CV stage" and record the actual current at that instant as the reference:

/* entering CV: record the reference current */
if (GetGSampOutCurrAPI() < 0)              /* charging; current is negative */
{
    if ((0 - GetGSampOutCurrAPI()) <= (GetGBattAllCapAPI() * 7 / 10))
    {
        sFallChgMaxCurr = GetGBattAllCapAPI() * 7 / 10;   /* cap reference at 0.7C */
    }
    else
    {
        sFallChgMaxCurr = 0 - GetGSampOutCurrAPI();       /* use actual current */
    }
}

Notice the reference current is capped at 0.7C. That is deliberate — if you are still at 1C when you enter CV, the earlier CC phase was pushing too hard and the taper must be more decisive; if you are already low, use the actual current and wind down gently.

Then, as voltage steps closer to the cutoff, the target current drops step by step according to a segment table:

/* target current = reference × segment-table percentage */
curr = (u16)((u32)sFallChgMaxCurr * gGroupParaRO_119[tabNum] / 100);

gGroupParaRO_119 is a "voltage/SOC taper segment table" stored in EEPROM. It divides the end-of-charge voltage zone into several segments (SLOW_CURRLIMIT_NUM of them), each with its own current percentage. Each time the voltage climbs one step, the current target drops to the corresponding percentage, until it finally reaches the cutoff current (ChgCFinLim, e.g. 0.05C).

The discharge-side taper is symmetric — watching the minimum cell voltage, and as it approaches the discharge cutoff (about 2.5 V for LFP), the discharge current limit drops accordingly.

The beauty of this stepped approach is that it is simple, stable, and configurable. No PID tuning, no worry about closed-loop oscillation; configure the segment table correctly and the end-of-charge curve is fixed. For a large storage system whose first priority is "safe and reliable," choosing "simple over clever" is a very pragmatic call.

Eight. The last step: current times voltage gives the power limit

Once the current limit is computed, the code also derives the "power limit" as a by-product, at the end of CurrLimCalcTask:

gGLimCPInfo_51[eLimCPInfo51_ChgC] = chUpLim;   /* charge current limit */
gGLimCPInfo_51[eLimCPInfo51_DhgC] = dhUpLim;   /* discharge current limit */

if (eWORK_RUN == GetGWorkStateAPI())
{
    /* power = current × total voltage */
    gGLimCPInfo_51[ChgP] = (chUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
    gGLimCPInfo_51[DhgP] = (dhUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
}
else
{
    gGLimCPInfo_51[ChgP] = 0;
    gGLimCPInfo_51[DhgP] = 0;
}

The + 5000 is rounding (integer division truncates, so adding half a unit before dividing gives round-to-nearest). A small detail, but it tells you the author cared about numerical accuracy.

These four values — charge current, discharge current, charge power, discharge power — are the entire content of the protocol-51 message sent over CAN to the PCS. The PCS takes them and clamps its charge/discharge power inside that envelope.

There are two more things about this algorithm that are easy to overlook but matter a lot in production, and I want to give each its own moment.

The 100 ms cadence

Why recompute every 100 ms, and not, say, every second, or every 10 ms? The answer is a trade-off between responsiveness and stability.

Too slow, and the limit is stale: if a cell suddenly heats up or a fault appears, the PCS keeps pushing a current that was safe a second ago but is not safe now. In the worst case, a thermal or over-voltage excursion runs for a full second before the limit catches up — an eternity when a cell is climbing toward its ceiling. So the cycle must be fast enough that the limit tracks the fastest meaningful change in the system.

Too fast, and you amplify noise. Temperature and voltage samples jitter, and if you recompute the limit from raw jitter at 10 ms you would report a limit that vibrates, which as we have seen is exactly what you do not want downstream. The 100 ms period is the point where the cell's thermal time constant (seconds to minutes) and the need for a smooth, non-thrashing output meet. It is also, not coincidentally, the natural slot in this system's RTOS schedule: the current-limit task runs inside the 10 ms software-timer handler, advanced on a 10-step sub-counter, so it fires once every ten 10 ms ticks. The scheduling and the physics are in agreement.

Parameters everywhere

The second thing worth noticing is how little is hard-coded. The hardware ceiling, the alarm threshold, the temperature multiplier table, the derating start/end temperatures, the delta thresholds, the derating step rates, the power limit, the taper segment table, the cutoff current — every single one of them is a parameter stored in EEPROM and editable over CAN or Modbus.

This is a bigger deal than it looks. A BMS that is going to be shipped into many different projects — different cell chemistries, different pack sizes, different thermal designs — cannot afford to recompile firmware for every one of them. By pushing all the thresholds and tables into configurable parameters, the same firmware image can serve a 280 Ah LFP pack in a desert plant and a different chemistry in a cold climate, with only the parameter set changing. It also means field tuning is possible without a firmware release: if the first hot summer shows that the derating is kicking in too early and eating revenue, you can raise startTemp by two degrees over the network, and the fix is live in minutes.

That configurability has a cost, of course — a mis-set parameter is a latent hazard, and the code leans on the separate parameter layer's range-checking and CRC to catch it. But the alternative, baking thresholds into #defines, is how you end up with a dozen near-identical firmware builds and a field-upgrade nightmare. This is the kind of architectural choice that separates "a demo" from "a product line."

Nine. Back to the funnel: why this design is good

Now, looking back at the funnel from section one, let me say why it is good.

First, it hides complexity inside a simple skeleton. Every constraint is an independent function, each minding its own business, none interfering with the others. To add a new constraint — say, an "abnormal internal-resistance derating" some day — you insert one more min() step into the funnel and touch nothing else. That is extensibility by construction.

Second, "take the minimum" is itself a safety philosophy. You always output the strictest of all constraints, which structurally guarantees that if any single constraint thinks "danger," the system goes conservative. In a safety-critical system, this bias toward caution is not just correct — it is the only correct choice.

Third, every constraint has its own debounce. The hardware ceiling has the "1 A below alarm" margin; high-temperature derating has temperature hysteresis; delta derating has delta hysteresis; the end-of-charge taper has stepped smoothing. These debounces keep the limit curve smooth and stable, rather than flapping at boundary points. For a system whose downstream is a high-power device like a PCS, the smoothness of the limit directly determines the stability of the whole system.

Ten. Closing

The strongest impression this code left on me is: there is not a single line of show-off in it, yet every line lands exactly where it should.

It uses no Kalman filter, no neural network, not even the PID loop (which is commented out). It gets a safety-critical job done — steadily, accurately, configurably — with the most plain-spoken tools: table lookups, linear interpolation, stepped levels, and hysteresis.

There is a deep lesson in that. In safety-critical industrial systems, "simple and reliable" is worth more than "clever." Being able to solve a problem with an algorithm that anyone can read at a glance, that can still be maintained three years later, and that can be located quickly when something fails in the field — that is a skill, and a rarer one than "knowing a fancy algorithm."

If you are reading this code yourself, I suggest you read it with three questions in mind: why must the limit always sit a little below the alarm? why does high-temperature derating need hysteresis? why a stepped table instead of PID at the end of charge? Once you can answer those three, you have not just read the code — you have read the person who wrote it. You have understood an engineer who, faced with high voltage, high current, and real safety responsibility, chose plain tools to hold the line.

I will leave you with one more thought, because I think it is the actual lesson underneath everything else. The reason this code reads the way it does — conservative, plain-spoken, parameter-laden, full of small margins and small hystereses — is not that its author lacked ambition. It is that they understood the stakes. A storage battery is a megawatt-scale box of stored energy sitting next to a grid; the current limit is the single most direct lever anyone has on whether that energy moves safely or destructively. When the thing you are controlling can catch fire, the correct engineering aesthetic is not elegance — it is legibility, predictability, and restraint. That is a hard thing to teach from a textbook, and the reason I think reading production code like this is worth far more than reading another paper about battery models: the models tell you what the battery does, but only the code tells you what a careful engineer does when the battery's life — and the operator's safety — is in their hands.


This article is based on the CurrLimCalc.c and CurrLimFall.c files of the S32K146OFBMU storage BMS project. All key logic has been verified against the source.

More from this blog

一套储能 BMS 的充电电流限值,是怎么一步步算出来的

在储能系统里,有一个数字每天都在被追问:"现在最多能充多少电?" 这个数字不是拍脑袋定的,也不是简单地从参数表里读出来的。它是一个经过层层约束、反复取最小、还带了一堆防抖逻辑之后才诞生的结果。它决定了 PCS(储能变流器)此刻允许从电网吸取多少功率,也决定了电池能不能安全地、长久地活下去。 这篇文章我想讲清楚这件事。我会以一套真实的量产代码为底本一套基于S32K146 的储能 BMU 主控固件,电

Sep 12, 20264 min read

储能 EMS 的功率策略:从一块电表到一次逆流的 200 毫秒

写在前面 我是薛定谔的悦,储能领域的工程师。最近系统里最让我有表达欲的,不是那些花哨的协议栈,而是一套看起来特别"土"的东西——几块电表、一个信号量、两个预保护值,拼出来的防逆流和防过载。 这套逻辑代码不多,核心就分布在 ccu_sampler 和 kwhmeter_sampler 两个模块里,加起来可能不到两千行。但它是整个储能系统"并网安全"的底线:充电的时候不能把变压器顶爆,放电的时候不能把

Aug 26, 20263 min read

Complete Analysis and Resolution of the Out-of-Control Power Problem in Multi-Unit Parallel-Connected Energy Storage EMS Systems

一、事情的起因 最近做储能 EMS 软件遇到过不少奇怪的问题,但这次的情况让我记忆比较深刻。 是一个工程现场报告了一个间歇性的跳闸问题。具体现象是:系统运行正常,PCS和电池都在按计划工作,但突然某台PCS报了一个通信故障,紧接着几秒内整个储能系统的并网开关跳闸了。不是一次,而是多次,有规律性。 这种问题最难查,因为故障是偶发的,复现条件依赖现场拓扑,而且日志里显示"系统故障"之前,功率数据看起来

Aug 7, 20266 min read
E

EMS

8 posts