Pylontech charging curve doesn't match

Hello everyone,

I noticed that since updating from the official release version to the

OS beta version 3.80~39, the charging behaviour has changed. The cell voltage has been rising faster since then, causing one of the batteries to switch off. In addition, the charging voltage rises to as much as 52.80 V, which was not the case before.

I can limit the charging voltage in DVCC, but that does not help me with the cell issue during charging.

For now, I have written a charging curve in Node-RED that slowly increases the charging voltage from 51.30 V at around 95% SoC, in 1% increments of 0.24 V, up to 52.50 V at 100%. This gives me a very gentle charge, and all the cells reach the same level evenly.

Without adjustment, the first two charging curves are still with the official OS version, and the last charging curve

Of the three, this one is with OS beta 3.80~39

With adjustment via Node-RED

There have been no changes in 3.80 that could specifically affect pylontech or indeed any other battery.

This was changed in version 3.80~25. I hadn’t received that one yet, though, as I switched from version 3.75 to the beta because of the new Shelly control. I think the change to 3.52 V per cell is good, although charging should already be throttled earlier, from 3.48 V per cell, and then charge slowly until all cells reach 3.52 V. I’ve prepared something; perhaps it can be implemented like this.

V 3.80~25

Other:

Motor drive: Use the ejection floor setting to estimate the range correctly.

The calculation of the Pylontech charging voltage and resetting the state of charge (SOC) to 100% have been improved.

This only affects 15-cell batteries without their own voltage regulation.

The cell with the highest voltage is now raised to 3.52 V in order to reset the estimated state of charge to 100% more reliably.

For heavily discharged cells, the voltage is reduced to lower values more reliably.

My suggestion:

Python

# — ANGEPASSTE LOGIK: SANFTE SCHLEICHLADUNG BEREITS AB 3.42V —

try:

spread = max(0.0, bms.maxcellvoltage - bms.mincellvoltage)

dd = bms.voltage / 15 - spread / 2

try:

    skew = min(0.5, max(-0.5, dd / spread))

except ZeroDivisionError:

    skew = 0.0

\# Sobald die höchste Zelle 3.42V erreicht, greift die Schleichladung

if bms.maxcellvoltage >= 3.42:

    \# Zielspannung wandert nur max. 0.25V ĂĽber der aktuellen Akkuspannung mit

    \# Obergrenze bleibt strikt bei 52.8V (3.52V pro Zelle)

    target_cv = min(52.8, bms.voltage + 0.25)

else:

    \# Unterhalb von 3.42V normale Hauptladephase (Bulk)

    target_cv = min(52.8, bms.voltage + 16 \* (3.52 - bms.maxcellvoltage) - skew)

cv = max(47.0, target_cv)

\# Filter-Glättung (0.98), damit der Übergang sanft verläuft

cv = self.\_chargevoltage = 0.98 \* self.\_chargevoltage + 0.02 \* cv

return round(cv, 2)

except TypeError: # Falls Zelldaten fehlen

return min(cv, 52.4)

Apologies, you are completely correct. Missed that in the details.

@iburger thoughts?

Will this be revised again, or will it stay as it is?

As I was telling in another thread, the algorithm for Pylontech charging curve, in order to avoid high voltage alarms, has been changed… Again.
And, it’s a little bit flawed…

For example:
“dd” variable will hold about the voltage of a cell. Something around 3.5.
“spread” variable will hold the difference between min and max cell voltages. Something between 0 … 0.2, never negative.
Then the report between “dd” and “spread” will be 3.5 divided by something up to 0.2, so the result will always be way above 15.
Then the skew = min(0.5, max(-0.5, dd / spread)), will always be 0.5… Never will be -0.5 or something in between.

So the part with that skewing is interesting but it’s acting just for dragging the voltage down. Which in a way is good.
But because the target voltage was increased from 3.485 to 3.52, this somehow is counterproductive and it’s now a little bit worse…

It’s worse as OP said, because the batteries is not just throwing high voltage alarms, but also over voltage alarms, that are just shutting them down.

Developers…
Please also look at limiting the current at the end of charging, for those batteries that are starting to age and have high internal resistance cells.

How would the system determine the age of a battery?
Pylontech’s tend to significantly drop charge current at the latter stages of charge regardless.
Victron have always stated that charge should be controlled by voltage not current, certainly when wanting to block charge ccl=0 is undesirable.

Pylontech’s tend to significantly drop charge current at the latter stages of charge regardless.

Indeed, but not enough. They are doing it steps of 5A, which is way to big for a step. For a US2000C, is C/10 which is too much.

Victron have always stated that charge should be controlled by voltage not current, certainly when wanting to block charge ccl=0 is undesirable.

We are talking about the batteries that are still in the charging phase, when the CCL is bigger than 0.

How would the system determine the age of a battery?

There is no need to determine the age of the battery.
For a battery with similar cells, their voltages will go in sync.
When a battery has an aged cell with higher internal resistance, that cell will have the biggest voltage when charging and the smallest voltage when discharging.
From outside this will translate in a waaay bigger deltaV than normal. Something like min=3.38 and high=3.52, just like OP has, with a deltaV=140mV
Detect this and reduce the current up until the max cell will stabilize at or below 3.5V.
Then, by all means, tune the voltage in order to avoid those nasty oscillations that eventually will generate high or over voltages.
And use different coefficients for that smoothing filter.

This is how it should look with a proper implemented algorithm…

I’ve currently solved it using Node-RED, and it works very well. It is controlled mainly via the charging voltage; the charging current has been adjusted for my seven Pylontech batteries and serves as a reserve. If something like this could be implemented, I think it would help everyone, regardless of whether they use US2000C, US3000C or US5000C. Here, the system simply reacts when a cell rises too quickly. You can take a look at it and decide for yourselves; perhaps it can even be improved further. [

{

    "id": "tab_pylon_dyn_cell_protect",

    "type": "tab",

    "label": "Pylontech Dyn. Zellschutz",

    "disabled": false,

    "info": "",

    "env": \[\]

},

{

    "id": "set_topic_soc",

    "type": "change",

    "z": "tab_pylon_dyn_cell_protect",

    "name": "topic = soc",

    "rules": \[

        {

            "t": "set",

            "p": "topic",

            "pt": "msg",

            "to": "soc",

            "tot": "str"

        }

    \],

    "x": 590,

    "y": 140,

    "wires": \[

        \[

            "logic_dyn_cell_protect"

        \]

    \]

},

{

    "id": "set_topic_cell",

    "type": "change",

    "z": "tab_pylon_dyn_cell_protect",

    "name": "topic = cell_volt",

    "rules": \[

        {

            "t": "set",

            "p": "topic",

            "pt": "msg",

            "to": "cell_volt",

            "tot": "str"

        }

    \],

    "x": 600,

    "y": 200,

    "wires": \[

        \[

            "logic_dyn_cell_protect"

        \]

    \]

},

{

    "id": "set_topic_bat",

    "type": "change",

    "z": "tab_pylon_dyn_cell_protect",

    "name": "topic = bat_volt",

    "rules": \[

        {

            "t": "set",

            "p": "topic",

            "pt": "msg",

            "to": "bat_volt",

            "tot": "str"

        }

    \],

    "x": 600,

    "y": 260,

    "wires": \[

        \[

            "logic_dyn_cell_protect"

        \]

    \]

},

{

    "id": "logic_dyn_cell_protect",

    "type": "function",

    "z": "tab_pylon_dyn_cell_protect",

    "name": "SOC-Rampe & Dyn. Zellschutz (Ist-Spannung)",

    "func": "if (msg.topic === 'soc') {\\n    let raw = (typeof msg.payload === 'object' && msg.payload !== null) ? msg.payload.value : msg.payload;\\n    if (!isNaN(Number(raw))) {\\n        context.set('currentSOC', Number(raw));\\n    }\\n} else if (msg.topic === 'cell_volt') {\\n    let raw = (typeof msg.payload === 'object' && msg.payload !== null) ? msg.payload.value : msg.payload;\\n    if (!isNaN(Number(raw))) {\\n        context.set('maxCellVolt', Number(raw));\\n    }\\n} else if (msg.topic === 'bat_volt') {\\n    let raw = (typeof msg.payload === 'object' && msg.payload !== null) ? msg.payload.value : msg.payload;\\n    if (!isNaN(Number(raw))) {\\n        context.set('batVolt', Number(raw));\\n    }\\n}\\n\\nlet soc = context.get('currentSOC');\\nlet maxCellVolt = context.get('maxCellVolt') || 0;\\nlet batVolt = context.get('batVolt');\\n\\nif (soc === undefined || isNaN(soc)) {\\n    return null;\\n}\\n\\nlet state = context.get('state') || 'UNKNOWN';\\n\\nif (soc >= 95) {\\n    context.set('state', 'LIMITED');\\n\\n    let boundedSoc = Math.min(100, soc);\\n    let targetVoltage = 51.30 + ((boundedSoc - 95) \* 0.26);\\n\\n    let targetCurrent = 50;\\n    if (soc >= 98) {\\n        targetCurrent = 10;\\n    } else if (soc >= 97) {\\n        targetCurrent = 25;\\n    }\\n\\n    if (maxCellVolt >= 3.55 && batVolt !== undefined && !isNaN(batVolt)) {\\n        targetVoltage = Math.min(targetVoltage, batVolt - 0.10);\\n    }\\n\\n    targetVoltage = Math.max(51.00, Math.round(targetVoltage \* 100) / 100);\\n\\n    return \[\\n        { payload: targetCurrent },\\n        { payload: targetVoltage }\\n    \];\\n} else if (soc < 92) {\\n    if (state !== 'NORMAL') {\\n        context.set('state', 'NORMAL');\\n        \\n        return \[\\n            { payload: 256 },\\n            { payload: 52.50 }\\n        \];\\n    }\\n}\\n\\nreturn null;",

    "outputs": 2,

    "timeout": "",

    "noerr": 0,

    "initialize": "",

    "finalize": "",

    "libs": \[\],

    "x": 900,

    "y": 200,

    "wires": \[

        \[

            "c0446d57e9897113"

        \],

        \[

            "26265e3945cea9bc"

        \]

    \]

},

{

    "id": "4d0ad8730f7884d4",

    "type": "victron-input-battery",

    "z": "tab_pylon_dyn_cell_protect",

    "service": "com.victronenergy.battery/512",

    "path": "/System/MaxCellVoltage",

    "serviceObj": {

        "service": "com.victronenergy.battery/512",

        "name": "Pylontech battery",

        "communityTag": "battery"

    },

    "pathObj": {

        "path": "/System/MaxCellVoltage",

        "type": "float",

        "name": "System; maximum cell voltage (V DC)"

    },

    "name": "",

    "onlyChanges": false,

    "roundValues": "no",

    "rateLimit": 0,

    "conditionalMode": false,

    "condition1HysteresisEnabled": false,

    "condition1HysteresisThreshold": "",

    "outputTrue": "true",

    "outputFalse": "false",

    "debounce": "2000",

    "x": 210,

    "y": 200,

    "wires": \[

        \[

            "set_topic_cell"

        \]

    \]

},

{

    "id": "ee9b01cb0bc4025a",

    "type": "victron-input-battery",

    "z": "tab_pylon_dyn_cell_protect",

    "service": "com.victronenergy.battery/512",

    "path": "/Dc/0/Voltage",

    "serviceObj": {

        "service": "com.victronenergy.battery/512",

        "name": "Pylontech battery",

        "communityTag": "battery"

    },

    "pathObj": {

        "path": "/Dc/0/Voltage",

        "type": "float",

        "name": "Battery voltage (V)"

    },

    "name": "",

    "onlyChanges": false,

    "roundValues": "no",

    "rateLimit": 0,

    "conditionalMode": false,

    "condition1HysteresisEnabled": false,

    "condition1HysteresisThreshold": "",

    "outputTrue": "true",

    "outputFalse": "false",

    "debounce": "2000",

    "x": 150,

    "y": 260,

    "wires": \[

        \[

            "set_topic_bat"

        \]

    \]

},

{

    "id": "0470b3957b684ae8",

    "type": "victron-input-battery",

    "z": "tab_pylon_dyn_cell_protect",

    "service": "com.victronenergy.battery/512",

    "path": "/Soc",

    "serviceObj": {

        "service": "com.victronenergy.battery/512",

        "name": "Pylontech battery",

        "communityTag": "battery"

    },

    "pathObj": {

        "path": "/Soc",

        "type": "float",

        "name": "State of charge (%)"

    },

    "name": "",

    "onlyChanges": false,

    "roundValues": "no",

    "rateLimit": 0,

    "conditionalMode": false,

    "condition1HysteresisEnabled": false,

    "condition1HysteresisThreshold": "",

    "outputTrue": "true",

    "outputFalse": "false",

    "debounce": "2000",

    "x": 150,

    "y": 140,

    "wires": \[

        \[

            "set_topic_soc"

        \]

    \]

},

{

    "id": "c0446d57e9897113",

    "type": "victron-output-ess",

    "z": "tab_pylon_dyn_cell_protect",

    "service": "com.victronenergy.settings",

    "path": "/Settings/SystemSetup/MaxChargeCurrent",

    "serviceObj": {

        "service": "com.victronenergy.settings",

        "name": "Venus settings",

        "communityTag": "ess"

    },

    "pathObj": {

        "path": "/Settings/SystemSetup/MaxChargeCurrent",

        "type": "float",

        "name": "DVCC Charge current limit (A)",

        "mode": "both"

    },

    "name": "DVCC Charge Current (A)",

    "onlyChanges": true,

    "conditionalMode": false,

    "condition1HysteresisEnabled": false,

    "condition1HysteresisThreshold": "",

    "outputTrue": "",

    "outputFalse": "",

    "debounce": "",

    "x": 1170,

    "y": 140,

    "wires": \[\]

},

{

    "id": "26265e3945cea9bc",

    "type": "victron-output-ess",

    "z": "tab_pylon_dyn_cell_protect",

    "service": "com.victronenergy.settings",

    "path": "/Settings/SystemSetup/MaxChargeVoltage",

    "serviceObj": {

        "service": "com.victronenergy.settings",

        "name": "Venus settings",

        "communityTag": "ess"

    },

    "pathObj": {

        "path": "/Settings/SystemSetup/MaxChargeVoltage",

        "type": "float",

        "name": "DVCC Maximum charge voltage (V)",

        "mode": "both"

    },

    "name": "DVCC Max Charge Voltage ESS (V)",

    "onlyChanges": true,

    "conditionalMode": false,

    "condition1HysteresisEnabled": false,

    "condition1HysteresisThreshold": "",

    "outputTrue": "",

    "outputFalse": "",

    "debounce": "",

    "x": 1210,

    "y": 260,

    "wires": \[\]

},

{

    "id": "c7bb500a958ef4d5",

    "type": "global-config",

    "env": \[\],

    "modules": {

        "@victronenergy/node-red-contrib-victron": "1.7.23"

    }

}

]

Pylontech Dyn. Zellschutz.zip (1.7 KB)

This is what my charging curve looks like with these settings

Nice flow!
How old are your batteries?
Wait to be older than 3 years…

At 95%, even with 51.3V-0.1V and 50A limiting values, you can have overvoltage alarms if you have a cell with high internal resistance.
For example, you have 14 cells with 3.38V and one runaway with 3.6V… Total 50.92V.
And 50A charging current guarantees this to be possible. Even if you have 10 batteries and just 5A reaches the one with problematic cell.

You will see that you can have this situation even at smaller than 90% SOC…

I know, you have a hard limit at 3.55… but the time between running the flows is quite big and to this is also added the time between the values reaches the d-bus and then the inverter… an eternity for a skyrocketing runaway cell between regulations…

Hint… instead of relying on a lot of ifs and voltage and/or SOC limits, you can use an exponential function. Smoooth transition. :grin:

This is from a set of 5-year old US3000C.

You are lucky to have a good set of batteries.
And probably you usually charged/discharged them with smaller currents.

On one of my installations, from a pack of 8 batteries (combination of US5000 and US2000C), so 120 cells, only 3 cells are problematic.
But they are grouped like this: cells 14 and 15 in one US2000C and cell 8 in another US2000C.

Only these 3 cells are dragging the charging process down and instead of spending only 30 minutes in the final stage above 90% SOC up until 100%, the whole pack is slowing down to up to 2 hours, depending on the current cycle DOD.
This just for avoiding the runaway cells to reach over 3.6V and generate alarms.

At some point, if the process will get worse, I will take out that battery with 2 problematic cells and I will have 10 good cells from which one I will be using to replace the one from the battery with one problematic cell and I will remain with 9 good cells to replace during time the progressively aging batteries’ cells.

Anyway, a small calculus will give us this:

2 hours spent at 70mA balancing current means 140mA difference between cells.
140mA/50A capacity of US2000C means 0.3% difference between cells by wear and tear.

Pylontech says that they are considering a battery to be problematic at over 2% difference between cells.

So when it will spend over 14 hours for balancing, then it will be trouble. :grin:
In fact this is consistent with their tech department statement that: “if it takes more than 12 hours for balancing, it’s a problem”.

Up until then… we just need to pamper them to not go on high or over voltage alarms.

I’ve got a pack where two cells cause problems while charging: they can drift as much as 180–240 mV apart and are no longer able to balance themselves. That’s despite what I’ve already done with Node-RED.

The algo can’t fix a damaged cell that needs to be replaced. Best get a warranty claim made.

Everything worked before until it was changed; since then, the two cells have been acting up, and if I slowly increase the voltage while charging, it works.

The battery packs are now four years old, and one of them is causing problems—but only since the charging voltage was raised that high again. With my adjusted charging voltage, it’s working again.