commit 7a136c9d2b7ea4f2ef9cfbb02ef4e8f2728e1e00 Author: Michael Balzer Date: Fri Oct 20 22:12:12 2017 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5db9870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +assets +*.html diff --git a/KlausBMS.ino b/KlausBMS.ino new file mode 100644 index 0000000..1913a5b --- /dev/null +++ b/KlausBMS.ino @@ -0,0 +1,1393 @@ +/** + * ========================================================================== + * Klaus' Twizy Lithium BMS + * ========================================================================== + * + * Hardware setup: + * - basically like Twizy-Battery-Part-List.md#example-arduino-wiring-scheme + * - supports packs with up to 16 cells (see CELL_COUNT in _config.h) + * + * Author: + * - Michael Balzer + * + * Ideas, support & testing: + * - Błażej Błaszczyk + * - Klaus Zinser + * + * Libraries used: + * - TwizyVirtualBMS https://github.com/dexterbg/Twizy-Virtual-BMS + * - MCP_CAN https://github.com/coryjfowler/MCP_CAN_lib + * - FlexiTimer2 https://github.com/PaulStoffregen/FlexiTimer2 + * - AltSoftSerial https://github.com/PaulStoffregen/AltSoftSerial + * + * License: + * This is free software under GNU Lesser General Public License (LGPL) + * https://www.gnu.org/licenses/lgpl.html + * + */ + +#define KLAUS_BMS_VERSION "V0.8.4 (2017-10-20)" + +#include +#include + +#include "TwizyVirtualBMS_config.h" +#include "TwizyVirtualBMS.h" +#include "AltSoftSerial.h" +#include "KlausBMS_config.h" + +// VirtualBMS: +TwizyVirtualBMS twizy; + +// Bluetooth software serial port: +// Note: AltSoftSerial uses fixed pins! +// i.e. Arduino Nano: RX = pin 8, TX = pin 9 +// Arduino Mega: RX = pin 48, TX = pin 46 +AltSoftSerial bt; + +Stream *com_channels[] = { &Serial, &bt }; + +char inputbuf[30] = ""; // shared command input buffer +byte inputpos = 0; + +byte quiet = 0; // status output inhibitor (seconds) +char temp_chg; // charger temperature [°C] + + +// Current, capacity & charge (coulomb): +// QA = quarter amps (1/4 A) (Twizy current resolution) +// CS = centi seconds (1/100 s) (Twizy time resolution) +#define SCALE_CURR_QA ((SCALE_CURR) * 4L) +#define BASE_CURR_QA ((BASE_CURR) * 4L) +#define CAP_NOMINAL_QACS ((CAP_NOMINAL_AH) * 4L * 3600L * 100L) +#define AMPS(qa) ((float) (qa) / 4L) +#define AMPHOURS(qacs) ((float) (qacs) / 4L / 3600L / 100L) + + +// -------------------------------------------------------------------------- +// KlausBMS main object +// + +class KlausBMS { + +public: + + // -------------------------------------------------------------------------- + // State variables + // + + float + temp_f, // pack front temperature [°C] + temp_r, // pack rear temperature [°C] + tdif; // difference front/rear temperature [°C] + + float + vpack, // pack voltage [V] + vstack[CELL_COUNT], // stacked voltages [V] + vcell[CELL_COUNT]; // cell voltages [V] + + float + cmin, // minimum cell voltage [V] + cmax, // maximum cell voltage [V] + cdif; // difference min/max cell voltage [V] + byte + cmin_i, // cell number with min voltage + cmax_i; // cell number with max voltage + float + cmin_soc, // minimum cell voltage based SOC [%] + cmax_soc; // maximum cell voltage based SOC [%] + + float + soc, // effective SOC [%] combining … + soc_volt, // … voltage based SOC estimation [%] + soc_coulomb; // … charge based SOC estimation [%] + + float + soh; // state of health [%] + + unsigned int + drvpwr, // drive power level [W] + recpwr; // recuperation power level [W] + byte + chgcur; // charge current level [A] + + unsigned long + error; // display error status (0 = OK, see setError()) + byte + bms_error; // informational BMS error code (see TwizyBmsError / setInfoError()) + + unsigned long + cap_qacs, // battery charge capacity (self-adjusting) [1/400 As] + avail_qacs; // available charge [1/400 As] + + signed long + curr_qa; // momentary current (10 ms measurement) [1/4 A] + + float + soc_chgstart; // charge start SOC [%] + unsigned long + avail_qacs_chgstart; // charge start coulomb count [1/400 As] + + + // -------------------------------------------------------------------------- + // Configuration variables + // + + // Maximum charge current to use [A]: + byte max_charge_current; + + // Maximum driving & recuperation power limits to use [W]: + unsigned int max_drive_power; + unsigned int max_recup_power; + + // Drive power cutback [%]: + // (100% at FULL → 100% at % → % at % → 0% at EMPTY) + byte drv_cutback_soc1; + byte drv_cutback_soc2; + byte drv_cutback_lvl2; + + // Voltage range for discharging [V]: + float vmin_drv; + float vmax_drv; + + // Voltage range for charging [V]: + float vmin_chg; + float vmax_chg; + + // Prioritize voltage based SOC [%]: + byte soc_volt_prio_above; + byte soc_volt_prio_below; + + // Degrade coulomb based SOC [%]: + byte soc_coul_degr_above; + byte soc_coul_degr_below; + + // Charge stop at SOC [%]: + byte chg_stop_soc; + +public: + + // -------------------------------------------------------------------------- + // Init + // + + KlausBMS() { + init(); + } + + void initSOC(float newsoc) { + + soc = newsoc; + soc_volt = newsoc; + + #ifdef PORT_CURR + soc_coulomb = newsoc; + avail_qacs = cap_qacs / 100 * soc_coulomb; + #endif + + float cv; + + if (twizy.inState(Charging)) { + cv = VMIN_CHG + (VMAX_CHG - VMIN_CHG) * newsoc / 100.0; + } else { + cv = VMIN_DRV + (VMAX_DRV - VMIN_DRV) * newsoc / 100.0; + } + + for (int i=0; i < CELL_COUNT; i++) { + vcell[i] = cv; + vstack[i] = cv * (i+1); + } + + vpack = vstack[CELL_COUNT-1]; + + cmin_soc = cmax_soc = newsoc; + cmin = cmax = cv; + } + + void initSOH(float newsoh) { + + soh = newsoh; + + #ifdef PORT_CURR + cap_qacs = CAP_NOMINAL_QACS / 100 * soh; + avail_qacs = cap_qacs / 100 * soc_coulomb; + #endif + } + + void init() { + + // State variables: + + temp_f = 20.0; + temp_r = 20.0; + tdif = 0; + + initSOC(99.0); + initSOH(100.0); + + drvpwr = MAX_DRIVE_POWER; + recpwr = MAX_RECUP_POWER; + chgcur = MAX_CHARGE_CURRENT; + + error = TWIZY_OK; + bms_error = bmsError_None; + + cap_qacs = CAP_NOMINAL_QACS; + avail_qacs = 0.99 * CAP_NOMINAL_QACS; + + curr_qa = 0; + + soc_chgstart = 100; + avail_qacs_chgstart = 0; + + + // Configuration variables: + + max_charge_current = MAX_CHARGE_CURRENT; + + max_drive_power = MAX_DRIVE_POWER; + max_recup_power = MAX_RECUP_POWER; + + drv_cutback_soc1 = DRV_CUTBACK_SOC1; + drv_cutback_soc2 = DRV_CUTBACK_SOC2; + drv_cutback_lvl2 = DRV_CUTBACK_LVL2; + + vmin_drv = VMIN_DRV; + vmax_drv = VMAX_DRV; + + vmin_chg = VMIN_CHG; + vmax_chg = VMAX_CHG; + + soc_volt_prio_above = SOC_VOLT_PRIO_ABOVE; + soc_volt_prio_below = SOC_VOLT_PRIO_BELOW; + + soc_coul_degr_above = SOC_COUL_DEGR_ABOVE; + soc_coul_degr_below = SOC_COUL_DEGR_BELOW; + + chg_stop_soc = 100; + } + + void begin() { + + // Init I/O ports: + + pinMode(PORT_VOLT, INPUT); + pinMode(PORT_TEMP_F, INPUT); + pinMode(PORT_TEMP_R, INPUT); + #ifdef PORT_CURR + pinMode(PORT_CURR, INPUT); + #endif + + pinMode(PORT_MUX_S0, OUTPUT); + pinMode(PORT_MUX_S1, OUTPUT); + pinMode(PORT_MUX_S2, OUTPUT); + pinMode(PORT_MUX_S3, OUTPUT); + + // load state from EEPROM: + + loadState(); + + // init VirtualBMS state: + + twizy.setInfoBmsType(bmsType_VirtualBMS); + twizy.setInfoState1(twizy.state()); + twizy.setInfoState2(0); + twizy.setInfoError(bms_error); + twizy.setInfoBalancing(0); + + twizy.setPowerLimits(drvpwr, recpwr); + twizy.setChargeCurrent(chgcur); + twizy.setSOC(soc); + twizy.setTemperature(temp_r, temp_f, true); + twizy.setVoltage(vpack, true); + twizy.setError(error); + twizy.setSOH(soh); + twizy.setCurrent(curr_qa); + + Serial.println(F("bms.begin: done")); + } + + + // -------------------------------------------------------------------------- + // EEPROM utility: save state + // + void saveState() { + uint16_t crc, i; + uint8_t *data; + + // calculate CRC: + crc = 0xffff; + data = (uint8_t *) this; + for (i = 0; i < sizeof(*this); i++) { + crc = _crc16_update(crc, data[i]); + } + + // write: + EEPROM.put(0, crc); + EEPROM.put(sizeof(crc), *this); + + Serial.println(F("bms.saveState: state saved")); + } + + + // -------------------------------------------------------------------------- + // EEPROM utility: load state + // + void loadState() { + uint16_t crc, savedcrc, i; + + // check CRC: + EEPROM.get(0, savedcrc); + crc = 0xffff; + for (i = 0; i < sizeof(*this); i++) { + crc = _crc16_update(crc, EEPROM[sizeof(crc) + i]); + } + + if (crc != savedcrc) { + Serial.println(F("bms.loadState: CRC mismatch, state not loaded!")); + } + else { + // read: + EEPROM.get(sizeof(crc), *this); + Serial.println(F("bms.loadState: state loaded")); + } + } + + + // -------------------------------------------------------------------------- + // Utility: output voltage alert/state details + // + void printVoltAlert(FLASHSTRING *intro) { + if (quiet) { + return; + } + for (Stream *s : com_channels) { + s->print(F("!!! ")); s->print(intro); + s->print(F(": dif=")); s->print(cdif, 2); + s->print(F(", min=")); s->print(cmin, 2); + s->print(F(" #")); s->print(cmin_i); + s->print(F(", max=")); s->print(cmax, 2); + s->print(F(" #")); s->print(cmax_i); + s->print(F(", avg=")); s->print(vpack/CELL_COUNT, 2); + s->println(); + } + } + + + // -------------------------------------------------------------------------- + // Utility: output temperature alert/state details + // + void printTempAlert(FLASHSTRING *intro) { + if (quiet) { + return; + } + for (Stream *s : com_channels) { + s->print(F("!!! ")); s->print(intro); + s->print(F(": dif=")); s->print(tdif, 1); + s->print(F(", temp_f=")); s->print(temp_f, 1); + s->print(F(", temp_r=")); s->print(temp_r, 1); + s->println(); + } + } + + + // -------------------------------------------------------------------------- + // Utility: output space padded numbers + // + + // …integer: + void print(Stream *s, char len, long ival) { + unsigned long p = 10; + + // get length of ival: + if (ival < 0) { + len--; + while (-ival >= p) { + p *= 10; + len--; + } + } else { + while (ival >= p) { + p *= 10; + len--; + } + } + + // pad & print: + while (--len > 0) { + s->print(' '); + } + s->print(ival); + } + + // …float: + void print(Stream *s, char len, float fval, char prec) { + long p; + + // get rounded integer part: + p = 1; + for (char i=0; i < prec; i++) { + p *= 10; + } + long ival = ((long) round(fval * p)) / p; + + // get length of rounded integer part: + p = 10; + if (signbit(fval)) { + len--; + ival = -ival; + } + while (ival >= p) { + p *= 10; + len--; + } + + // pad & print: + while (--len > 0) { + s->print(' '); + } + s->print(fval, prec); + } + + + // -------------------------------------------------------------------------- + // Utility: output BMS status + // + + void printStatus(Stream *s) { + + /* + | 100.0 %SOC | 55.6 V | 18000 Wd | 35 Ac | StartTrickle + | 100.0 %Sv | -500.0 A | 8500 Wr | -10 Cc | [error] + | 100.0 %Sc | 120.0 Ah | 100 Cf |< 88 %V | 0.12 V + | 100.0 %SOH | 120.0 Ah | 100 Cr |>100 %V | + | 3.12 | 3.12 | 3.12 |<3.12 | 3.12 | 3.12 | 3.12 | 3.12 | + | 3.12 | 3.12 | 3.12 | 3.12 |>3.12 | 3.12 | 3.12 | 3.12 | + */ + + s->println(); + + s->print('|'); print(s, 4, soc, 1); s->print(F(" %SOC")); s->print(' '); + s->print('|'); print(s, 5, vpack, 1); s->print(F(" V ")); s->print(' '); + s->print('|'); print(s, 6, drvpwr); s->print(F(" Wd")); s->print(' '); + s->print('|'); print(s, 4, chgcur); s->print(F(" Ac")); s->print(' '); + s->print('|'); s->print(' '); s->print(twizy.stateName()); + s->println(); + + s->print('|'); print(s, 4, soc_volt, 1); s->print(F(" %Sv ")); s->print(' '); + s->print('|'); print(s, 5, AMPS(curr_qa), 1); s->print(F(" A ")); s->print(' '); + s->print('|'); print(s, 6, recpwr); s->print(F(" Wr")); s->print(' '); + s->print('|'); print(s, 4, temp_chg); s->print(F(" Cc")); s->print(' '); + s->print('|'); + if (error) { + s->print(' '); + s->print(error, HEX); + } + s->println(); + + s->print('|'); print(s, 4, soc_coulomb, 1); s->print(F(" %Sc ")); s->print(' '); + s->print('|'); print(s, 5, AMPHOURS(avail_qacs), 1); s->print(F(" Ah")); s->print(' '); + s->print('|'); print(s, 6, temp_f); s->print(F(" Cf")); s->print(' '); + s->print('|'); s->print('<'); print(s, 3, (int)cmin_soc); s->print(F(" %V")); s->print(' '); + s->print('|'); print(s, 2, cdif, 2); s->print(F(" V")); + s->println(); + + s->print('|'); print(s, 4, soh, 1); s->print(F(" %SOH")); s->print(' '); + s->print('|'); print(s, 5, AMPHOURS(cap_qacs), 1); s->print(F(" Ah")); s->print(' '); + s->print('|'); print(s, 6, temp_r); s->print(F(" Cr")); s->print(' '); + s->print('|'); s->print('>'); print(s, 3, (int)cmax_soc); s->print(F(" %V")); s->print(' '); + s->print('|'); + s->println(); + + // cell voltages: + + for (byte i=0; i < CELL_COUNT; i++) { + if (i == CELL_COUNT/2) { + s->println('|'); + } + s->print('|'); + s->print((i == cmin_i) ? '<' : (i == cmax_i) ? '>' : ' '); + s->print(vcell[i], 2); + s->print(' '); + } + s->println('|'); + + } + + + // -------------------------------------------------------------------------- + // Callback: handle state transition for BMS + // - called by twizy.enterState() after Twizy handling + // Note: avoid complex operations, this needs to be fast. + // + void enterState(TwizyState currentState, TwizyState newState) { + + static uint8_t tricklecnt = 30; + + #if TWIZY_DEBUG_LEVEL == 0 + Serial.print(F("bms.enterState: newState=")); + Serial.println(FS(twizyStateName[newState])); + #endif + + + #if CALIBRATION_MODE == 0 + + // ---------------------------------------------------------------------- + // bms.enterState: lower SOC at switch-on to prevent immediate charge stop: + // + + if (currentState == Init && newState == Ready) { + if (soc > 99) { + soc -= 0.01; + chgcur = 5; + twizy.setSOC(soc); + twizy.setChargeCurrent(chgcur); + Serial.print(F("bms.enterState: SOC lowered to ")); + Serial.println(soc, 1); + } + } + + + // ---------------------------------------------------------------------- + // bms.enterState: battery capacity adjustment by charging: + // + // Principle of operation: + // SOC estimation combines pack voltage _and_ coulomb counting. + // Voltage limits are hard limits overriding coulomb count, so + // 100% SOC means 100% voltage. So we can check if the charge + // sum matches the SOC difference and adjust the capacity by the + // difference. + // + + if (newState == StartCharge) { + + #ifdef PORT_CURR + // remember start SOC & charge: + soc_chgstart = soc; + avail_qacs_chgstart = avail_qacs; + #endif // #ifdef PORT_CURR + + } + + else if (newState == StopCharge) { + + #ifdef PORT_CURR + + // adjust capacity & SOH when charged at least 50% SOC difference: + float soc_charged = soc - soc_chgstart; + if (soc_charged >= 50) { + + // the charged SOC difference should account for... + unsigned long expected_chgd_qacs = cap_qacs / 100 * soc_charged; + // we actually got... + unsigned long real_chgd_qacs = avail_qacs - avail_qacs_chgstart; + + // adjust capacity: + unsigned long oldcap = cap_qacs; + unsigned long newcap = real_chgd_qacs / soc_charged * 100; + // ...smoothed, weighted by soc_charged: + cap_qacs = (oldcap / SMOOTH_CAP) * (SMOOTH_CAP - soc_charged) + + (newcap / SMOOTH_CAP) * soc_charged; + + // calculate SOH: + soh = constrain((float) cap_qacs / CAP_NOMINAL_QACS * 100, 0, 100); + twizy.setSOH(soh); + + // output adjustment info: + for (Stream *s : com_channels) { + s->println(); + s->println(F("bms.enterState: capacity/SOH adjustment:")); + s->print(F("- SOC charged = ")); s->println(soc_charged, 1); + s->print(F("- expected Ah = ")); s->println(AMPHOURS(expected_chgd_qacs), 1); + s->print(F("- charged Ah = ")); s->println(AMPHOURS(real_chgd_qacs), 1); + s->print(F("- old cap Ah = ")); s->println(AMPHOURS(oldcap), 1); + s->print(F("- new cap Ah = ")); s->println(AMPHOURS(cap_qacs), 1); + s->print(F("- new SOH % = ")); s->println(soh, 1); + s->println(); + } + } + + // adjust available charge based on SOC: + avail_qacs = cap_qacs / 100 * soc; + + // …or just limit to cap? + //if (soc > 99.99) + // avail_qacs = cap_qacs; + //else if (avail_qacs > cap_qacs) + // avail_qacs = cap_qacs; + + #endif // PORT_CURR + + // save state to EEPROM: + saveState(); + tricklecnt = 30; + + } // if (newState == StopCharge) + + else if (newState == StopDrive) { + + // save state to EEPROM: + saveState(); + tricklecnt = 30; + + } + + else if (newState == StopTrickle) { + + // to avoid high wear on the EEPROM, only save state to EEPROM + // after 30 successive trickle charges: + if (--tricklecnt == 0) { + saveState(); + tricklecnt = 30; + } + + } + + #endif // CALIBRATION_MODE == 0 + + twizy.setInfoState1(newState); + + } // bms.enterState() + + + // -------------------------------------------------------------------------- + // Callback: timer ticker + // - called every 10 ms by twizy.ticker() after twizy handling + // - clockCnt cyclic range: 0 .. 2999 = 30 seconds (reset to 0 on Off/Init) + // Note: avoid complex operations, this needs to be fast. + // + void ticker(unsigned int clockCnt) { + + int i; + + + // ---------------------------------------------------------------------- + // bms.ticker: read current & count coulomb every 10 ms while on: + // + + #ifdef PORT_CURR + + if (!twizy.inState(Off)) { + + // read current level: + curr_qa = BASE_CURR_QA + analogRead(PORT_CURR) * VPORT * SCALE_CURR_QA; + curr_qa = constrain(curr_qa, -2000, 2000); + + if (twizy.inState(Charging, StopCharge)) { + curr_qa *= CURR_POLARITY_CHG; + } else { + curr_qa *= CURR_POLARITY_DRV; + } + + #if CALIBRATION_MODE == 0 + // update VirtualBMS model: + twizy.setCurrentQA(curr_qa); + #endif + + // account for discharge/charge: + avail_qacs += curr_qa; + + } + + #endif // PORT_CURR + + + // ---------------------------------------------------------------------- + // bms.ticker: Read stacked cell voltages + // + + if (!twizy.inState(Off) && clockCnt % 10 == 0) { + + for (i=0; i < CELL_COUNT; i++) { + + // select MUX input for PORT_VOLT: + digitalWrite(PORT_MUX_S0, (i & 1) ? HIGH : LOW); + digitalWrite(PORT_MUX_S1, (i & 2) ? HIGH : LOW); + digitalWrite(PORT_MUX_S2, (i & 4) ? HIGH : LOW); + digitalWrite(PORT_MUX_S3, (i & 8) ? HIGH : LOW); + + // read stacked voltage: + float vc = analogRead(PORT_VOLT) * SCALE_VOLT[i]; + + // …smooth: + vstack[i] = ((vstack[i] * (SMOOTH_VOLT-1)) + vc) / SMOOTH_VOLT; + } + + // derive single cell voltages from stacked voltages: + vpack = vstack[CELL_COUNT-1]; + for (i=CELL_COUNT-1; i>0; i--) { + vcell[i] = max(vstack[i] - vstack[i-1], 0); + } + vcell[0] = vstack[0]; + + #if CALIBRATION_MODE == 0 + + // update VirtualBMS model: + #if CELL_COUNT >= 14 + twizy.setVoltage(vpack, false); + for (i=1; i<=CELL_COUNT; i++) { + twizy.setCellVoltage(i, vcell[i-1]); + } + #else + twizy.setVoltage(vpack, true); + #endif + + #endif // CALIBRATION_MODE == 0 + } + + + // ---------------------------------------------------------------------- + // bms.ticker: more processing / status output? + // + + #if CALIBRATION_MODE == 1 + + // 10 seconds interval: + if (clockCnt % 1000 != 0) { + return; + } + + Serial.println(F("\r\n*** CALIBRATION INFO [10s interval] ***")); + + for (i=0; i < CELL_COUNT; i++) { + Serial.print(F("< c")); + if (i<10) Serial.print('0'); + Serial.print(i); + Serial.print(F(" = ")); + print(&Serial, 3, vstack[i], 3); + Serial.print(F(" [ ")); + print(&Serial, 3, vcell[i], 3); + Serial.println(F(" ]")); + } + + #else // CALIBRATION_MODE == 0 + + // 1 second interval: + if (twizy.inState(Off) || clockCnt % 100 != 0) { + return; + } + + #if TWIZY_DEBUG_LEVEL >= 1 + if (!quiet) { + Serial.println(F("\r\nbms.ticker:")); + } + #endif + + #endif // CALIBRATION_MODE + + + error = TWIZY_OK; + bms_error = bmsError_None; + + + // ------------------------------------------------------------ + // bms.ticker: find min/max cell voltages + // + + cmin = 5.0; + cmax = 0.0; + + for (i=0; i < CELL_COUNT; i++) { + if (vcell[i] < cmin) { + cmin = vcell[i]; + cmin_i = i; + } + if (vcell[i] > cmax) { + cmax = vcell[i]; + cmax_i = i; + } + } + + cdif = cmax - cmin; + + + // ---------------------------------------------------------------------- + // bms.ticker: calculate voltage based SOC + // + // - newsoc_volt = direct SOC in operation mode voltage range + // - soc_volt = smoothed operation mode SOC + // (used to derive drive & recup power & charge current) + // … accordingly for cmin_soc & cmax_soc + // + + float newsoc_volt, newsoc_cmin, newsoc_cmax; + + // voltage range depends on operation mode: + if (twizy.inState(Charging)) { + newsoc_volt = (vpack - (vmin_chg * CELL_COUNT)) / ((vmax_chg - vmin_chg) * CELL_COUNT) * 100.0; + newsoc_cmin = (cmin - vmin_chg) / (vmax_chg - vmin_chg) * 100.0; + newsoc_cmax = (cmax - vmin_chg) / (vmax_chg - vmin_chg) * 100.0; + } + else { + newsoc_volt = (vpack - (vmin_drv * CELL_COUNT)) / ((vmax_drv - vmin_drv) * CELL_COUNT) * 100.0; + newsoc_cmin = (cmin - vmin_drv) / (vmax_drv - vmin_drv) * 100.0; + newsoc_cmax = (cmax - vmin_drv) / (vmax_drv - vmin_drv) * 100.0; + } + + // smooth... + if (newsoc_volt < soc_volt) { + // slow adaption to lower voltages: + soc_volt = ((soc_volt * (SMOOTH_SOC_DOWN-1)) + newsoc_volt) / SMOOTH_SOC_DOWN; + cmin_soc = ((cmin_soc * (SMOOTH_SOC_DOWN-1)) + newsoc_cmin) / SMOOTH_SOC_DOWN; + cmax_soc = ((cmax_soc * (SMOOTH_SOC_DOWN-1)) + newsoc_cmax) / SMOOTH_SOC_DOWN; + } + else { + if (twizy.inState(Charging)) { + // fast adaption while charging: + soc_volt = ((soc_volt * (SMOOTH_SOC_UP_CHG-1)) + newsoc_volt) / SMOOTH_SOC_UP_CHG; + cmin_soc = ((cmin_soc * (SMOOTH_SOC_UP_CHG-1)) + newsoc_cmin) / SMOOTH_SOC_UP_CHG; + cmax_soc = ((cmax_soc * (SMOOTH_SOC_UP_CHG-1)) + newsoc_cmax) / SMOOTH_SOC_UP_CHG; + } + else { + // slow adaption while driving: + soc_volt = ((soc_volt * (SMOOTH_SOC_UP_DRV-1)) + newsoc_volt) / SMOOTH_SOC_UP_DRV; + cmin_soc = ((cmin_soc * (SMOOTH_SOC_UP_DRV-1)) + newsoc_cmin) / SMOOTH_SOC_UP_DRV; + cmax_soc = ((cmax_soc * (SMOOTH_SOC_UP_DRV-1)) + newsoc_cmax) / SMOOTH_SOC_UP_DRV; + } + } + + // sanitize... + soc_volt = constrain(soc_volt, 0.0, 100.0); + cmin_soc = constrain(cmin_soc, 0.0, 100.0); + cmax_soc = constrain(cmax_soc, 0.0, 100.0); + + + #ifdef PORT_CURR + + // ---------------------------------------------------------------------- + // bms.ticker: calculate coulomb based SOC + // + + #if CALIBRATION_MODE == 1 + Serial.print(F("< curr = ")); Serial.println((float) curr_qa/4, 1); + #endif + + soc_coulomb = (float) avail_qacs / cap_qacs * 100; + soc_coulomb = constrain(soc_coulomb, 0, 100); + + + // ---------------------------------------------------------------------- + // bms.ticker: combine coulomb & voltage based SOC (hybrid SOC): + // - prioritize soc_volt over soc_coulomb … + // a) …when soc_volt approaches 0/100% + // b) …when soc_coulomb approaches 0/100% + // + + float soc_volt_prio = 0, soc_coul_degr = 0; + + if (twizy.inState(Charging) || curr_qa > 0) { + // Charging: prioritize voltage when approaching 100% + if (soc_volt > soc_volt_prio_above) + soc_volt_prio = (soc_volt - soc_volt_prio_above) / (100 - soc_volt_prio_above); + if (soc_coulomb > soc_coul_degr_above) + soc_coul_degr = (soc_coulomb - soc_coul_degr_above) / (100 - soc_coul_degr_above); + } else { + // Discharging: prioritize voltage when approaching 0% + if (soc_volt < soc_volt_prio_below) + soc_volt_prio = (soc_volt_prio_below - soc_volt) / soc_volt_prio_below; + if (soc_coulomb < soc_coul_degr_below) + soc_coul_degr = (soc_coul_degr_below - soc_coulomb) / soc_coul_degr_below; + } + + soc_volt_prio = max(soc_volt_prio, soc_coul_degr); + + soc = (soc_volt * soc_volt_prio) + (soc_coulomb * (1 - soc_volt_prio)); + soc = constrain(soc, 0, 100); + + #else // PORT_CURR undefined + + soc = soc_volt; + + #endif // PORT_CURR + + + // ---------------------------------------------------------------------- + // bms.ticker: Derive power limits & charge current from SOC + // + + // scale down drive power for low SOC: + // 100% at FULL → 100% at at → 0% at EMPTY + + // Note: minimum cell voltage SOC has priority if below , + // so if there is a bad cell in the pack, it will be protected + // from over discharge. + + #define soc2_drive_power ((drv_cutback_lvl2 / 100.0f) * max_drive_power) + + if (cmin_soc <= drv_cutback_soc2) { + float factor = cmin_soc / drv_cutback_soc2; + drvpwr = factor * soc2_drive_power; + } + else if (soc <= drv_cutback_soc2) { + float factor = soc / drv_cutback_soc2; + drvpwr = factor * soc2_drive_power; + } + else if (soc <= drv_cutback_soc1) { + float factor = ((cmin_soc - drv_cutback_soc2) / (drv_cutback_soc1 - drv_cutback_soc2)); + drvpwr = soc2_drive_power + (factor * (max_drive_power - soc2_drive_power)); + } + else { + drvpwr = max_drive_power; + } + + // scale down recuperation power & charge current for high SOC: + // 0% at FULL → 100% at → 100% at EMPTY + + // Note: stop is controlled by overall pack SOC, + // current reduction is controlled first by maximum cell voltage. + // So the charger will enter the balancing phase when the first cell + // is getting full, but won't stop until the pack is full. + + if (soc > chg_stop_soc - 0.01) { + // stop charge & reduce recuperation at 100% pack SOC / charge stop: + recpwr = 500; // TODO: should(?) be 0 when driving, but affects D/R change + chgcur = 0; + } + else if (cmax_soc >= CHG_CUTBACK_SOC) { + // keep min 500W / 5A below 100% SOC: + float factor = ((100 - cmax_soc) / (100 - CHG_CUTBACK_SOC)); + recpwr = 500 + (factor * (max_recup_power - 500)); + chgcur = 5 + (factor * (max_charge_current - 5)); + } + else if (soc >= CHG_CUTBACK_SOC) { + // keep min 500W / 5A below 100% SOC: + float factor = ((100 - soc) / (100 - CHG_CUTBACK_SOC)); + recpwr = 500 + (factor * (max_recup_power - 500)); + chgcur = 5 + (factor * (max_charge_current - 5)); + } + else { + recpwr = max_recup_power; + chgcur = max_charge_current; + } + + + // ------------------------------------------------------------ + // bms.ticker: Check cell voltage difference (min - max) + // + + #if CALIBRATION_MODE == 0 + + // check voltages: + if (cdif >= VOLT_DIFF_SHUTDOWN) { + // cell difference is critical: emergency shutdown + printVoltAlert(F("VOLT_SHUTDOWN")); + error |= TWIZY_SERV_BATT | TWIZY_SERV_STOP; + bms_error = bmsError_VoltageDiff; + twizy.enterState(Error); + } + else if (cdif >= VOLT_DIFF_ERROR) { + // cell difference is high: set STOP signal, reduce drive power, stop recuperation & charge: + printVoltAlert(F("VOLT_ERROR")); + error |= TWIZY_SERV_BATT | TWIZY_SERV_STOP; + bms_error = bmsError_VoltageDiff; + drvpwr /= 4; + recpwr /= 4; + chgcur = 0; + } + else if (cdif >= VOLT_DIFF_WARN) { + // cell difference detected: reduce power & charge levels: + printVoltAlert(F("VOLT_WARN")); + error |= TWIZY_SERV_BATT; + bms_error = bmsError_VoltageDiff; + drvpwr /= 2; + recpwr /= 2; + chgcur = min(chgcur, 5); + } + + #endif // CALIBRATION_MODE + + + // ---------------------------------------------------------------------- + // bms.ticker: Read & check battery temperature + // + + float newtemp_f, newtemp_r; + + // dual read _seems_ to yield better results (LM35D issue?) + newtemp_f = analogRead(PORT_TEMP_F); + newtemp_f = BASE_TEMP + analogRead(PORT_TEMP_F) * VPORT * SCALE_TEMP; + newtemp_r = analogRead(PORT_TEMP_R); + newtemp_r = BASE_TEMP + analogRead(PORT_TEMP_R) * VPORT * SCALE_TEMP; + + // smooth... + temp_f = (temp_f * (SMOOTH_TEMP-1) + newtemp_f) / SMOOTH_TEMP; + temp_r = (temp_r * (SMOOTH_TEMP-1) + newtemp_r) / SMOOTH_TEMP; + + tdif = abs(temp_f - temp_r); + + #if CALIBRATION_MODE == 1 + + Serial.print(F("< temp_f = ")); Serial.println(newtemp_f, 1); + Serial.print(F("< temp_r = ")); Serial.println(newtemp_r, 1); + + #else // CALIBRATION_MODE == 0 + + // check temperatures: + if (max(temp_f, temp_r) > TEMP_SHUTDOWN || tdif > TEMP_DIFF_SHUTDOWN) { + // battery is burning: emergency shutdown + printTempAlert(F("TEMP_SHUTDOWN")); + error |= TWIZY_SERV_TEMP | TWIZY_SERV_STOP; + bms_error = bmsError_TemperatureHigh; + twizy.enterState(Error); + } + else if (max(temp_f, temp_r) > TEMP_ERROR || tdif > TEMP_DIFF_ERROR) { + // battery very hot: set STOP signal, stop recuperation, stop charge: + printTempAlert(F("TEMP_ERROR")); + error |= TWIZY_SERV_TEMP | TWIZY_SERV_STOP; + bms_error = bmsError_TemperatureHigh; + drvpwr /= 4; + recpwr = 0; + chgcur = 0; + } + else if (max(temp_f, temp_r) > TEMP_WARN || tdif > TEMP_DIFF_WARN) { + // battery hot, show warning, reduce recuperation, reduce charge current: + printTempAlert(F("TEMP_WARN")); + error |= TWIZY_SERV_TEMP; + bms_error = bmsError_TemperatureHigh; + drvpwr /= 2; + recpwr /= 2; + chgcur = min(chgcur, 5); + } + + #endif // CALIBRATION_MODE + + + // ---------------------------------------------------------------------- + // bms.ticker: Read & check charger temperature + // + + temp_chg = twizy.getChargerTemperature(); + + if (temp_chg > CHG_CUTBACK_TEMP) { + float cutback = (float) (CHG_CUTBACK_TEMPMAX - temp_chg) / (CHG_CUTBACK_TEMPMAX - CHG_CUTBACK_TEMP); + chgcur = max(chgcur * cutback, 5.0); + bms_error = bmsError_ChargerTemperatureHigh; + } + + + // ---------------------------------------------------------------------- + // bms.ticker: Update VirtualBMS model + // + + #if CALIBRATION_MODE == 0 + + twizy.setTemperature(min(temp_f, temp_r), max(temp_f, temp_r), false); + for (i=1; i<=7; i++) { + // virtual module temperature = linear interpolation front→rear: + twizy.setModuleTemperature(i, temp_f + ((float) (i-1) / 6 * (temp_r - temp_f))); + } + + twizy.setSOC(soc); + + twizy.setPowerLimits(drvpwr, recpwr); + twizy.setChargeCurrent(chgcur); + + twizy.setError(error); + twizy.setInfoError(bms_error); + + #endif // CALIBRATION_MODE == 0 + + + // ---------------------------------------------------------------------- + // bms.ticker: Output state to serial ports + // + + #if CALIBRATION_MODE == 0 + + if (!quiet && clockCnt % 300 == 0) { + printStatus(&Serial); + } + else if (!quiet && clockCnt % 300 == 100) { + printStatus(&bt); + } + + // Countdown quiet mode: + if (quiet) + quiet--; + + #endif // CALIBRATION_MODE == 0 + + + } // bms.ticker() + + + // -------------------------------------------------------------------------- + // Command interpreter: + // + void executeCommand(char *cmd) { + + if (!*cmd) { + // no command: return to log mode + quiet = 0; + return; + } + + for (Stream *s : com_channels) { + s->print(F("\r\nbms.executeCommand: ")); + s->println(cmd); + s->println(); + } + + char *arg = strtok(cmd, " "); + + if (strcasecmp(cmd, "soh") == 0) { + // soh + if (arg = strtok(NULL, " ")) { + initSOH(constrain(atoi(arg), 0, 100)); + } + } + + else if (strcasecmp(cmd, "soc") == 0) { + // soc + if (arg = strtok(NULL, " ")) { + initSOC(constrain(atoi(arg), 0, 100)); + } + } + + else if (strcasecmp(cmd, "mcc") == 0) { + // mcc + if (arg = strtok(NULL, " ")) { + max_charge_current = constrain(atoi(arg), 5, 35); + } + } + + else if (strcasecmp(cmd, "sc") == 0) { + // sc [] + if (arg = strtok(NULL, " ")) { + chg_stop_soc = constrain(atoi(arg), 1, 100); + } + else { + twizy.setChargeCurrent(0); // stop charge + } + } + + else if (strcasecmp(cmd, "mpw") == 0) { + // mpw + if (arg = strtok(NULL, " ")) { + max_drive_power = constrain(atoi(arg), 500, 30000); + } + if (arg = strtok(NULL, " ")) { + max_recup_power = constrain(atoi(arg), 500, 30000); + } + } + + else if (strcasecmp(cmd, "dcb") == 0) { + // dcb + if (arg = strtok(NULL, " ")) { + drv_cutback_soc1 = constrain(atoi(arg), 0, 100); + } + if (arg = strtok(NULL, " ")) { + drv_cutback_soc2 = constrain(atoi(arg), 0, 100); + } + if (arg = strtok(NULL, " ")) { + drv_cutback_lvl2 = constrain(atoi(arg), 0, 100); + } + } + + else if (strcasecmp(cmd, "vrd") == 0) { + // vrd + if (arg = strtok(NULL, " ")) { + vmin_drv = constrain(atof(arg), 0.0, 5.0); + } + if (arg = strtok(NULL, " ")) { + vmax_drv = constrain(atof(arg), 0.0, 5.0); + } + } + + else if (strcasecmp(cmd, "vrc") == 0) { + // vrc + if (arg = strtok(NULL, " ")) { + vmin_chg = constrain(atof(arg), 0.0, 5.0); + } + if (arg = strtok(NULL, " ")) { + vmax_chg = constrain(atof(arg), 0.0, 5.0); + } + } + + else if (strcasecmp(cmd, "svp") == 0) { + // svp + if (arg = strtok(NULL, " ")) { + soc_volt_prio_above = constrain(atoi(arg), 0, 100); + } + if (arg = strtok(NULL, " ")) { + soc_volt_prio_below = constrain(atoi(arg), 0, 100); + } + } + + else if (strcasecmp(cmd, "scd") == 0) { + // scd + if (arg = strtok(NULL, " ")) { + soc_coul_degr_above = constrain(atoi(arg), 0, 100); + } + if (arg = strtok(NULL, " ")) { + soc_coul_degr_below = constrain(atoi(arg), 0, 100); + } + } + + else if (strcasecmp(cmd, "init") == 0) { + init(); + } + else if (strcasecmp(cmd, "load") == 0) { + loadState(); + } + else if (strcasecmp(cmd, "save") == 0) { + saveState(); + } + + #if FEATURE_CMD_ES == 1 + else if (strcasecmp(cmd, "es") == 0) { + // es + if (arg = strtok(NULL, " ")) { + twizy.enterState((TwizyState) constrain(atoi(arg), Off, StopTrickle)); + } + } + #endif // FEATURE_CMD_ES + + else { + // HELP: + for (Stream *s : com_channels) { + s->println(F("HELP:\r\n" + " soh -- set SOH%\r\n" + " soc -- set SOC%\r\n" + " mcc -- set max charge current\r\n" + " sc [] -- stop charge / set stop SOC\r\n" + " mpw -- set max power\r\n" + " dcb -- set drive cutback\r\n" + " vrd -- set voltage range discharging\r\n" + " vrc -- set voltage range charging\r\n" + " svp -- set SOC voltage prioritize\r\n" + " scd -- set SOC coulomb degrade\r\n" + " init -- reset to default config\r\n" + " load -- load state from EEPROM\r\n" + " save -- save state to EEPROM\r\n" + #if FEATURE_CMD_ES == 1 + " es -- enter a VBMS state (0..12)\r\n" + #endif + )); + } + } + + // output status info: + for (Stream *s : com_channels) { + + s->print(F("- SOH% = ")); s->println(soh, 1); + s->print(F("- SOC% = ")); s->println(soc, 1); + #ifdef PORT_CURR + s->print(F("- cap = ")); s->println(AMPHOURS(cap_qacs), 1); + s->print(F("- avail = ")); s->println(AMPHOURS(avail_qacs), 1); + #endif + s->println(); + + s->print(F("- MCC = ")); s->println(max_charge_current); + s->print(F("- SC = ")); s->println(chg_stop_soc); + s->print(F("- MPW = ")); s->print(max_drive_power); + s->print(' '); s->println(max_recup_power); + s->print(F("- DCB = ")); s->print(drv_cutback_soc1); + s->print(' '); s->print(drv_cutback_soc2); + s->print(' '); s->println(drv_cutback_lvl2); + s->print(F("- VRD = ")); s->print(vmin_drv, 3); + s->print(' '); s->println(vmax_drv, 3); + s->print(F("- VRC = ")); s->print(vmin_chg, 3); + s->print(' '); s->println(vmax_chg, 3); + s->print(F("- SVP = ")); s->print(soc_volt_prio_above); + s->print(' '); s->println(soc_volt_prio_below); + s->print(F("- SCD = ")); s->print(soc_coul_degr_above); + s->print(' '); s->println(soc_coul_degr_below); + s->println(); + } + + // Be quiet for 10 seconds: + quiet = 10; + + } // bms.executeCommand() + + +} bms; // class KlausBMS + + +// -------------------------------------------------------------------------- +// Callback object wrappers +// + +void bmsEnterState(TwizyState currentState, TwizyState newState) { + bms.enterState(currentState, newState); +} + +void bmsTicker(unsigned int clockCnt) { + bms.ticker(clockCnt); +} + + +// ----------------------------------------------------- +// SETUP +// + +void setup() { + + // Init communication: + + Serial.begin(SERIAL_BAUD); + bt.begin(BT_BAUD); + + for (Stream *s : com_channels) { + s->println(F(KLAUS_BMS_NAME " " KLAUS_BMS_VERSION)); + } + + // Init VirtualBMS & KlausBMS: + + twizy.begin(); + bms.begin(); + + twizy.attachTicker(bmsTicker); + twizy.attachEnterState(bmsEnterState); + + #if TWIZY_CAN_SEND == 0 + Serial.println(F("*** DRY RUN (NO CAN TX) ***")); + twizy.enterState(Init); + #endif + + #if CALIBRATION_MODE == 1 + Serial.println(F("*** CALIBRATION MODE ***")); + twizy.enterState(Init); + #endif + +} // setup() + + +// ----------------------------------------------------- +// MAIN LOOP +// + +void loop() { + + // VirtualBMS main loop: + + twizy.looper(); + + // check for Serial/Bluetooth command: + // (note: using shared input buffer for both to save space) + + static char lastc = 0; + char c = 0; + if (Serial.available()) { + c = (char) Serial.read(); + } + else if (bt.available()) { + c = (char) bt.read(); + } + + if (c == '\r' || c == '\n') { + if (c == '\r' || lastc != '\r') { + inputbuf[inputpos] = 0; + bms.executeCommand(inputbuf); + inputpos = 0; + } + lastc = c; + } + else if (c >= 32 && inputpos < sizeof(inputbuf)-1) { + inputbuf[inputpos++] = c; + lastc = c; + } + +} + diff --git a/KlausBMS_config.h b/KlausBMS_config.h new file mode 100644 index 0000000..282a936 --- /dev/null +++ b/KlausBMS_config.h @@ -0,0 +1,201 @@ +/** + * ========================================================================== + * Klaus' Twizy LiFEPO4 (LFP) BMS: Configuration + * ========================================================================== + */ +#ifndef _KlausBMS_config_h +#define _KlausBMS_config_h + +// Personalization: +#define KLAUS_BMS_NAME "KlausBMS" + +// Serial baud rate: +// (Arduino supports up to 2 Mbit, but cannot send faster than 1 Mbit) +#define SERIAL_BAUD 1000000 + +// Bluetooth baud rate: +// (i.e. 57600 / 38400 / 19200 / 9600, Default of HC-05/06 is 9600) +#define BT_BAUD 57600 + +// Input calibration mode (inhibits normal operation): +#define CALIBRATION_MODE 1 +// Note: calibration mode will still allow VirtualBMS state transitions. +// You can drive & charge in calibration mode, but no sensor data +// will be used to update the Twizy SOC & power status. So if you charge +// or drive in calibration mode, you need to monitor your voltages! + +// Optional / development features: +#define FEATURE_CMD_ES 0 + + +// -------------------------------------------------------------------------- +// PORTS +// -------------------------------------------------------------------------- + +// Analog input port assignment: +#define PORT_VOLT A0 // cell voltage MUX +#define PORT_TEMP_F A1 // temperature sensor front +#define PORT_TEMP_R A2 // temperature sensor rear +#define PORT_CURR A3 // pack current (comment out to disable) + +// MUX address pins: +#define PORT_MUX_S0 4 +#define PORT_MUX_S1 5 +#define PORT_MUX_S2 6 +#define PORT_MUX_S3 7 + + +// -------------------------------------------------------------------------- +// OPERATION +// -------------------------------------------------------------------------- + +// Maximum charge current to use [A] (5…35): +#define MAX_CHARGE_CURRENT 35 +// Charge current → power drawn from socket: +// 35 A = 2,2 kW +// 30 A = 2,1 kW +// 25 A = 1,7 kW +// 20 A = 1,4 kW +// 15 A = 1,0 kW +// 10 A = 0,7 kW +// 5 A = 0,4 kW + +// Maximum driving & recuperation power limits to use [W] (500…30000): +#define MAX_DRIVE_POWER 25000 +#define MAX_RECUP_POWER 12500 + + +// Drive power cutback [%]: +// (100% at FULL → 100% at % → % at % → 0% at EMPTY) +#define DRV_CUTBACK_SOC1 50 +#define DRV_CUTBACK_SOC2 25 +#define DRV_CUTBACK_LVL2 70 + +// Charge power cutback by SOC [%]: +// (100% at EMPTY → 100% at % → 0% at FULL) +#define CHG_CUTBACK_SOC 90 + +// Charge power cutback by charger temperature [°C]: +#define CHG_CUTBACK_TEMP 50 +#define CHG_CUTBACK_TEMPMAX 65 + + +// -------------------------------------------------------------------------- +// VOLTAGE +// -------------------------------------------------------------------------- + +// Number of cells (max 16): +#define CELL_COUNT 16 + +// Voltage range for discharging [V]: +#define VMIN_DRV 2.90 +#define VMAX_DRV 3.35 + +// Voltage range for charging [V]: +#define VMIN_CHG 2.90 +#define VMAX_CHG 3.65 + +// Voltage smoothing [100ms samples] (min 1 = no smoothing): +#define SMOOTH_VOLT 20 + +// Port scaling utils: +#define VPORT 5.0 / 1024.0 +#define VDIV(R1,R2) (R1+R2) / R2 + +// Voltage divider analog input scaling: +// - scale = R_sum / R_probe * calibration +// - first cell is connected directly +const float SCALE_VOLT[CELL_COUNT] = { + VPORT * 1.00000 // 3.2 V + , VPORT * VDIV( 27, 47) * 1.00000 // 6.4 V + , VPORT * VDIV( 68, 47) * 1.00000 // 9.6 V + , VPORT * VDIV(100, 47) * 1.00000 // 12.8 V + , VPORT * VDIV(150, 47) * 1.00000 // 16.0 V + , VPORT * VDIV(180, 47) * 1.00000 // 19.2 V + , VPORT * VDIV(220, 47) * 1.00000 // 22.4 V + , VPORT * VDIV(270, 47) * 1.00000 // 25.6 V + , VPORT * VDIV(330, 47) * 1.00000 // 28.8 V + , VPORT * VDIV(330, 47) * 1.00000 // 32.0 V + , VPORT * VDIV(390, 47) * 1.00000 // 35.2 V + , VPORT * VDIV(390, 47) * 1.00000 // 38.4 V + , VPORT * VDIV(470, 47) * 1.00000 // 41.6 V + , VPORT * VDIV(470, 47) * 1.00000 // 44.8 V + , VPORT * VDIV(560, 47) * 1.00000 // 48.0 V + , VPORT * VDIV(560, 47) * 1.00000 // 51.2 V +}; + +// Voltage warning/error thresholds [V]: +// (Note: resolution of cell #16 is ~ 80 mV) +#define VOLT_DIFF_WARN 0.3 +#define VOLT_DIFF_ERROR 0.6 +#define VOLT_DIFF_SHUTDOWN 1.0 + +// SOC smoothing [1s samples] (min 1 = no smoothing): +#define SMOOTH_SOC_DOWN 60 // adaption to lower voltage +#define SMOOTH_SOC_UP_DRV 30 // adaption to higher voltage while driving +#define SMOOTH_SOC_UP_CHG 10 // adaption to higher voltage while charging + + +// -------------------------------------------------------------------------- +// CURRENT & CAPACITY +// -------------------------------------------------------------------------- + +// Current analog input scaling: + +// LEM HAC-600-S: -600 … +600 A → 0.072 … 4.002 V +//#define SCALE_CURR (1200.0 / (4.002 - 0.072)) +//#define BASE_CURR (-600.0 - 0.072 * SCALE_CURR) + +// Tamura L06P400S05: -400 … +400 A → 1.0 … 4.0 V +#define SCALE_CURR (800.0 / (4.0 - 1.0)) +#define BASE_CURR (-400.0 - 1.0 * SCALE_CURR) + +// If you need to reverse polarity, change to -1: +#define CURR_POLARITY_DRV 1 +#define CURR_POLARITY_CHG 1 + +// Battery capacity: +#define CAP_NOMINAL_AH 120 + +// Capacity adjustment smoothing (min 100 = fastest adaption): +#define SMOOTH_CAP 200 + + +// -------------------------------------------------------------------------- +// HYBRID SOC +// -------------------------------------------------------------------------- + +// Prioritize voltage based SOC [%]: +#define SOC_VOLT_PRIO_ABOVE 90 +#define SOC_VOLT_PRIO_BELOW 20 + +// Degrade coulomb based SOC [%]: +#define SOC_COUL_DEGR_ABOVE 90 +#define SOC_COUL_DEGR_BELOW 20 + + +// -------------------------------------------------------------------------- +// TEMPERATURE +// -------------------------------------------------------------------------- + +// Temperature analog input scaling: +// LM35D: +2 .. +100°, 10 mV / °C => 100 °C = 1.0 V +#define SCALE_TEMP (100.0 / 1.0) +#define BASE_TEMP (+2.0) + +// Temperature smoothing [samples]: +#define SMOOTH_TEMP 30 + +// Temperature warning/error thresholds [°C]: +#define TEMP_WARN 40 +#define TEMP_ERROR 45 +#define TEMP_SHUTDOWN 50 + +// Temperature front/rear difference warning/error thresholds [°C]: +#define TEMP_DIFF_WARN 3 +#define TEMP_DIFF_ERROR 5 +#define TEMP_DIFF_SHUTDOWN 10 + + +#endif // _KlausBMS_config_h + diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..408c98d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,157 @@ +### GNU LESSER GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +#### 0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the +GNU General Public License. + +"The Library" refers to a covered work governed by this License, other +than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + +The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +#### 1. Exception to Section 3 of the GNU GPL. + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +#### 2. Conveying Modified Versions. + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +- a) under this License, provided that you make a good faith effort + to ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or +- b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + +#### 3. Object Code Incorporating Material from Library Header Files. + +The object code form of an Application may incorporate material from a +header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +- a) Give prominent notice with each copy of the object code that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the object code with a copy of the GNU GPL and this + license document. + +#### 4. Combined Works. + +You may convey a Combined Work under terms of your choice that, taken +together, effectively do not restrict modification of the portions of +the Library contained in the Combined Work and reverse engineering for +debugging such modifications, if you also do each of the following: + +- a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the Combined Work with a copy of the GNU GPL and this + license document. +- c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. +- d) Do one of the following: + - 0) Convey the Minimal Corresponding Source under the terms of + this License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + - 1) Use a suitable shared library mechanism for linking with + the Library. A suitable mechanism is one that (a) uses at run + time a copy of the Library already present on the user's + computer system, and (b) will operate properly with a modified + version of the Library that is interface-compatible with the + Linked Version. +- e) Provide Installation Information, but only if you would + otherwise be required to provide such information under section 6 + of the GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the Application + with a modified version of the Linked Version. (If you use option + 4d0, the Installation Information must accompany the Minimal + Corresponding Source and Corresponding Application Code. If you + use option 4d1, you must provide the Installation Information in + the manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.) + +#### 5. Combined Libraries. + +You may place library facilities that are a work based on the Library +side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +- a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities, conveyed under the terms of this License. +- b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + +#### 6. Revised Versions of the GNU Lesser General Public License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +as you received it specifies that a certain numbered version of the +GNU Lesser General Public License "or any later version" applies to +it, you have the option of following the terms and conditions either +of that published version or of any later version published by the +Free Software Foundation. If the Library as you received it does not +specify a version number of the GNU Lesser General Public License, you +may choose any version of the GNU Lesser General Public License ever +published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddc504a --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# KlausBMS + +This is a battery monitoring system for Renault Twizy based on [TwizyVirtualBMS](https://github.com/dexterbg/Twizy-Virtual-BMS/) supporting Lithium packs with 14 (LiPo) or 16 (LiFe) cells. + +This is not a fully featured BMS yet (in terms of M=*management*), it currently relies on an additional standard BMS for cell protection and balancing. The BMS can be a very simple one without any communication though, as KlausBMS does the monitoring itself. + +KlausBMS is capable of monitoring up to 16 cell voltages, two temperature sensors and a current sensor. + +The hardware setup is following the [VirtualBMS example](https://github.com/dexterbg/Twizy-Virtual-BMS/blob/master/extras/Twizy-Battery-Part-List.md#example-arduino-wiring-scheme). See [Voltage-Dividers.ods](extras/Voltage-Dividers.ods) for the default voltage probe resistor values. Both LEM HAC-600-S and Tamura L06P400S05 current sensors have been used successfully, others may work as well (see configuration). + +The BMS will raise warnings and errors based on voltage and temperature anomalies detected. Drive and recuperation power levels as well as charging current can be controlled following the battery state and condition. + +Using the (optional but recommended) current sensor it provides SOC and SOH/capacity measurements based on a (simple) hybrid calculation combining coulomb counting and voltage readings. + +Log output can be monitored in parallel at the USB serial port and via Bluetooth (if BT module is used). Both log channels also support controlling and configuring the BMS by sending text commands. + +KlausBMS is currently used in Twizy battery projects by: + - Błażej Błaszczyk (LiPo) + - Klaus Zinser (LiFePO4) + + +## Installation + +To download, click the DOWNLOADS button in the top right corner, download the ZIP file. Extract to your Arduino sketchbook folder, start the Arduino IDE and open the sketch. + +You will also need these libraries: + - [Twizy-Virtual-BMS](https://github.com/dexterbg/Twizy-Virtual-BMS) + - [MCP_CAN_lib by Cory Fowler](https://github.com/coryjfowler/MCP_CAN_lib) + - …and one of… + - [TimerOne by Paul Stoffregen](https://github.com/PaulStoffregen/TimerOne) + - [FlexiTimer2 by Paul Stoffregen](https://github.com/PaulStoffregen/FlexiTimer2) + - [TimerThree by Paul Stoffregen](https://github.com/PaulStoffregen/TimerThree) + + +## Configuration & calibration + +All configuration directives are located in the files "TwizyVirtualBMS_config.h" and "KlausBMS_config.h", read these files first to get an overview of the configuration features. When installing a KlausBMS update later on, you should be able to mostly keep your configuration files and just replace the "KlausBMS.ino" file, but take care to check for configuration changes. + +Most operational configuration values are just initial defaults used when the Arduino EEPROM is empty/invalid. You can also restore the system to these values any time by issuing the "init" command (see "Runtime commands" below). + +Configure your hardware characteristics and sensor setup first and enable the calibration mode (is enabled on a fresh copy). Update the voltage sensor scaling if you use other divider resistors. + +In calibration mode, the KlausBMS will just output all sensor readings every 10 seconds. Upload the sketch and check for necessary input scaling adjustments. Adjust scaling factors and check again, until the Arduino readings match the real values, then disable calibration mode. + +Hint: to do a dry run now, set `TWIZY_CAN_SEND` to 0. The KlausBMS will then enter the VirtualBMS "Init" and "Ready" state on startup even if the Twizy is not switched on (or not even connected). + + +## Operation + +During operation, KlausBMS logs the battery state every three seconds on both communication channels like this: + + | 33.8 %SOC | 52.6 V | 9598 Wd | 35 Ac | Driving + | 55.1 %Sv | -1.2 A | 3240 Wr | 29 Cc | + | 33.8 %Sc | 40.6 Ah | 19 Cf |< 48 %V | 0.06 V + | 100.0 %SOH | 120.0 Ah | 18 Cr |> 61 %V | + | 3.29 | 3.28 | 3.30 | 3.28 | 3.29 | 3.31 | 3.27 | 3.29 | + |>3.31 |<3.26 | 3.29 | 3.29 | 3.29 | 3.29 | 3.29 | 3.28 | + +The layout needs a fixed spacing font, so make sure your (bluetooth) terminal is configured appropriately. + +The upper four lines show the battery pack state followed by two lines of up to 16 cell voltages. + +The `<` and `>` mark the cells with lowest and highest voltages. Their respective voltage based SOC is shown as `%V` above, along with the voltage difference between them (`0.06 V` in this example). + +Pack state: + + - `%SOC`: combined (effective) SOC of battery pack + - `%Sv`, `%Sc`: voltage and coulomb based SOC of battery pack + - `%SOH`: state of health (SOH) of battery pack (equivalent to relative capacity) + - `V`, `A`: momentary pack voltage and current + - `Ah`: upper/first value = available charge, lower value = absolute pack capacity in amp hours + - `Wd`, `Wr`: available drive and recuperation power levels (W) + - `Cf`, `Cr`: temperatures of front and rear sensors (°C) + - `Ac`: available/configured charge current (A) + - `Cc`: charger temperature (read from CAN) (°C) + - `Driving`: the current VirtualBMS state, error codes will be displayed below this field + + +## Log analysis + +Capture the output to a file. Be aware the standard Arduino will do a reset on a new USB connection, so make sure your laptop will stay awake, or apply one of the hardware patches to disable this behaviour. Of course the bluetooth channel can be used as well, take care the terminal app is capable of receiving large logs (some will eat up your memory and become unusable). + +The bash script [`logconv.sh`](extras/logconv.sh) can be used to convert the captured output into CSV and ODS format. You'll need some standard linux setup and the OpenOffice or LibreOffice "headless" package. + +Alternatively, you can use the [logconv web service](https://dexters-web.de/logconv) on my site. + +The resulting table will contain all of the log values in separate numerical columns, so can be used directly for analysis and to generate charts. + + +## Runtime commands + +Set your serial monitor or terminal program to send CR, LF or both. + +After sending any command, the KlausBMS will respond with the current configuration state and inhibit standard log output for 10 seconds. To return to standard log output immediately, send an empty command. Any unknown / misspelled command will output a command overview. + +| Command | Function | +| --- | --- | +| `soh ` | set SOH% | +| `soc ` | set SOC% | +| `mcc ` | set max charge current | +| `sc []` | stop charge / set charge stop SOC | +| `mpw ` | set max power | +| `dcb ` | set drive cutback | +| `vrd ` | set voltage range discharging | +| `vrc ` | set voltage range charging | +| `svp ` | set SOC voltage prioritize | +| `scd ` | set SOC coulomb degrade | +| `init` | reset to default config | +| `load` | load state from EEPROM | +| `save` | save state to EEPROM | + +**Notes:** + + - All parameters are validated and constrained to their respective ranges. + - The max current and power levels define the upper limits, the system will use lower values as necessary / configured. + - To stop a running charge, simply issue `sc` without a parameter. + - Voltage ranges are defined at the cell voltage level. + - `save` is done automatically at every drive & charge end. + - `load` is done automatically after every Arduino start/reset. + - `init` is done automatically if the EEPROM is empty or the checksum is invalid. + +The EEPROM checksum will become invalid when a KlausBMS update introduces new state variables, so it's a good idea to note the last state before installing an update. + + +## SOC hybrid algorithm parameters + +The effectice pack SOC is calculated from both pack voltage and coulomb (Ah) counting. The pack voltage marks the absolute limits of the battery for "empty" and "full", but is a bad base for the SOC in between. Especially LiFe chemistry has a nearly constant voltage between 20 and 80%. Coulomb counting alone on the other hand would introduce cumulating measurement errors. + +So the algorithm uses coulomb counting most of the time, but prioritizes the voltage measurement over the coulomb counting when approaching 0% or 100% relative voltage and/or degrades the coulomb count when approaching 0% or 100% respectively. + +This process is defined by the voltage ranges and the threshold values: + + - Voltage range discharging / charging (commands `vrd` and `vrc`) + - SOC voltage prioritize top / bottom (command `svp`) + - SOC coulomb degrade top / bottom (command `scd`) + +The ranges and thresholds apply depending on the state: the "top" thresholds apply on charging/recuperating, the "bottom" ones on discharging. + +The voltage thresholds always take precedence over the coulomb degradation, as voltage is a hard measurement. + +The effects of wrong/bad voltage ranges and/or thresholds parameters are nonlinear SOC slopes. If you encounter sudden jumps or fast changes when approaching 0/100%, you need to adjust the SOC parameters. + +Keep in mind you can do manual corrections of the SOC by issuing `soc`. + + +## SOH measurement + +The SOH is the percentage of the measured usable capacity in relation to the nominal capacity as configured (`CAP_NOMINAL_AH`). + +The usable capacity measurement is again based on the coulomb counting, so a properly calibrated current sensor is crucial. + +The system adjusts the usable capacity at every charge stop after charging at least 50% SOC difference. The adjustment is calculated based on the difference between the charge amount reflecting the SOC change and the actual measured charge amount. The difference is applied smoothed by `SMOOTH_CAP` and weighted by the SOC difference charged: the more you charge, the more weight is put on the new measurement. + +The results are written to the serial ports like this: + + bms.enterState: capacity/SOH adjustment: + - SOC charged = 72% + - expected Ah = 86.4 + - charged Ah = 84.7 + - old cap Ah = 120.0 + - new cap Ah = 119.2 + - new SOH % = 99.3 + +Keep in mind you can do manual corrections of the SOH by issuing `soh`. + + +## Author + +KlausBMS has been written and is maintained by Michael Balzer ( / https://dexters-web.de/). + +A lot of ideas and refinements have been contributed by Błażej Błaszczyk, who has been using this software for multiple Twizy battery replacements beginning with the [Build #2 using Nissan Leaf cells](https://github.com/dexterbg/Twizy-Virtual-BMS/blob/master/extras/Blazej2-Leaf-Cells.md), and by Klaus Zinser, who is using the system for his 120 Ah LiFePO4 battery pack and heads for an upgrade to 240 Ah. + + +## Donations + +**Donations** to support my efforts and further development are very welcome. +Please send donations via **Paypal** to: `dexter@dexters-web.de` +**Thanks! :-)** + + +## License + +This is free software; you can redistribute it and/or modify it under the terms of the [GNU Lesser General Public License](https://www.gnu.org/licenses/lgpl.html) as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. + +This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License along with this software; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110- 1301 USA + + +**Have fun!** diff --git a/TwizyVirtualBMS_config.h b/TwizyVirtualBMS_config.h new file mode 100644 index 0000000..ecbb9e2 --- /dev/null +++ b/TwizyVirtualBMS_config.h @@ -0,0 +1,45 @@ +/** + * ========================================================================== + * Twizy Virtual BMS: Configuration + * ========================================================================== + */ + +#ifndef _TwizyVirtualBMS_config_h +#define _TwizyVirtualBMS_config_h + +// Serial debug output: +// Level 0 = none, only output init message +// Level 1 = log state transitions, errors & CAN statistics +// Level 2 = log CAN frame dumps (10 second interval) +#define TWIZY_DEBUG_LEVEL 0 + +// Set to 0 to disable CAN transmissions for testing: +#define TWIZY_CAN_SEND 1 + +// CAN send timing is normally 10 ms (10.000 us). +// You may need to lower this if your Arduino is too slow. +#define TWIZY_CAN_CLOCK_US 10000 + +// VirtualBMS can use Timer1 (16 bit), Timer2 (8 bit) or Timer3 (16bit) +// Select Timer2/3 if you need Timer1 for e.g. AltSoftSerial +#define TWIZY_USE_TIMER 2 + +// Timer2: precise resolutions depend on CPU type & clock frequency +// i.e. Arduino Nano 16 MHz: 1000 = 1 ms / 2000 = 0.5 ms / 5000 = 0.2 ms / 10000 = 0.1 ms +// Use lowest resolution possible to minimize side effects on AltSoftSerial +#define TWIZY_TIMER2_RESOLUTION 1000 + +// Set your MCP clock frequency here: +#define TWIZY_CAN_MCP_FREQ MCP_16MHZ + +// Set your SPI CS pin number here: +#define TWIZY_CAN_CS_PIN 10 + +// If you've connected the CAN module's IRQ pin (0 = polling): +#define TWIZY_CAN_IRQ_PIN 2 + +// Set your 3MW control pin here: +#define TWIZY_3MW_CONTROL_PIN 3 + +#endif // _TwizyVirtualBMS_config_h + diff --git a/extras/Voltage-Divider.ods b/extras/Voltage-Divider.ods new file mode 100644 index 0000000..647de77 Binary files /dev/null and b/extras/Voltage-Divider.ods differ diff --git a/extras/logconv.sh b/extras/logconv.sh new file mode 100755 index 0000000..2a373c4 --- /dev/null +++ b/extras/logconv.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +log=$1 +base=$(basename $log .txt) +csv=${2:-$base.csv} +ods=$base.ods + +echo "Konvertiere $log ..." + +if [ ! -f $log ] ; then + echo "Abbruch: '$log' nicht gefunden!" + exit 1 +fi + +conv() { + n=1 + while true ; do + line="" + for i in {1..6} ; do + read col || return + line+=$col + done + echo $n $line + n=$((n + 1)) + done +} + +echo \ + '#;soc;-;vpack;-;drvpwr;-;chgcur;-;state;soc_volt;-;curr;-;recpwr;-;temp_chg;-;error;' \ + 'soc_coul;-;avail_ah;-;temp_f;-;cmin_soc;-;cdif;-;' \ + 'soh;-;cap_ah;-;temp_r;-;cmax_soc;-;' \ + 'c0;c1;c2;c3;c4;c5;c6;c7;c8;c9;c10;c11;c12;c13;c14;c15' \ + > $csv + +cat $log | recode -f /CRLF | grep '^|' | conv \ + | sed -e 's:Cc ||:Cc |-|:g' -e 's:|[<>]:|:g' -e 's:[| ][| ]*:;:g' -e 's:;$::' \ + >> $csv + +echo "CSV: $csv" + +#unoconv -d spreadsheet -f ods -i 59,34,UTF8,,,1033 $csv +soffice --headless --convert-to ods --infilter=CSV:59,34,UTF8,,,1033 $csv + +echo "ODS: $ods" + +exit 0