Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions Data/fs4voltagemodel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import numpy as np
import matplotlib.pyplot as plt
from dataclasses import dataclass

# ======================================================
# PARAMETERS
# ======================================================
Q_rated = 2.5 * 3600 # Coulombs (2.5 Ah cell)
dt = 1.0 # timestep [s]
N = 300 # number of steps

# ======================================================
# VEHICLE / BATTERY STATE
# ======================================================
@dataclass
class VehicleState:
soc: float # State of Charge [0..1]
voltage: float # Terminal voltage [V]

# Reserved for future revised model
temp_c: float = 25.0
hyst: float = 0.0
v_rc: float = 0.0


# ======================================================
# VOLTAGE UPDATE TEMPLATE ← THIS IS THE DELIVERABLE
# ======================================================
def update_voltage_template(prev_current: float, state: VehicleState) -> float:
"""
Template for revised voltage updating (ECM baseline).

Model:
V = OCV(SOC) - I*R_internal

Inputs:
prev_current: current from previous timestep [A] (+ discharge, - charge)
state.soc: SOC in [0,1]

Output:
new terminal voltage [V]

TODO later:
- Replace OCV curve with real discharge curve fit
- Make R_internal depend on temperature/SOC
- Add hysteresis / RC recovery (sag + slow rebound)
"""
soc = float(state.soc)

# 1) OCV curve (placeholder, but reasonable):
# At SOC=1.0 -> ~4.2V, at SOC=0.0 -> ~3.0V
ocv = 3.0 + 1.2 * soc

# 2) Internal resistance (placeholder constant)
r_internal = 0.015 # Ohms (cell-level example)

# 3) Terminal voltage
v_new = ocv - prev_current * r_internal

# 4) Clamp to realistic bounds to avoid weird plots
v_new = float(np.clip(v_new, 2.5, 4.25))

return v_new


# ======================================================
# INPUT CURRENT PROFILE (synthetic)
# ======================================================
time = np.arange(N) * dt
I = np.zeros(N)

I[20:80] = 5.0
I[100:150] = 2.5
I[170:220] = -3.0
I[250:280] = 6.0

# ======================================================
# LOG ARRAYS
# ======================================================
SOC_log = np.zeros(N)
V_log = np.zeros(N)

# ======================================================
# INITIAL STATE
# ======================================================
state = VehicleState(
soc=1.0,
voltage=4.2, # reasonable initial guess
)

SOC_log[0] = state.soc
V_log[0] = state.voltage

# ======================================================
# SIMULATION LOOP
# ======================================================
for t in range(1, N):
prev_I = I[t - 1]

# --- SOC UPDATE (Coulomb counting) ---
state.soc -= (prev_I * dt / Q_rated)
state.soc = float(np.clip(state.soc, 0.0, 1.0))

# --- VOLTAGE UPDATE (TEMPLATE CALL) ---
state.voltage = update_voltage_template(prev_I, state)

# --- LOG ---
SOC_log[t] = state.soc
V_log[t] = state.voltage

# ======================================================
# PLOTS
# ======================================================
plt.figure(figsize=(10, 6))

plt.subplot(3, 1, 1)
plt.plot(time, I, label="Current [A]")
plt.ylabel("Current (A)")
plt.grid(True)
plt.legend()

plt.subplot(3, 1, 2)
plt.plot(time, V_log, label="Voltage [V]", color="tab:red")
plt.ylabel("Voltage (V)")
plt.grid(True)
plt.legend()

plt.subplot(3, 1, 3)
plt.plot(time, SOC_log * 100, label="SOC [%]", color="tab:green")
plt.xlabel("Time (s)")
plt.ylabel("SOC (%)")
plt.grid(True)
plt.legend()

plt.tight_layout()
plt.show()
17 changes: 12 additions & 5 deletions FullVehicleSim/MBS/granola2.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import quesadilla as VoltageTools
import numpy as np
import lionCellModel as LionCellModel

def stepElectrical(worldPrev, worldNext, params, inputs):

worldNext.wheelRPM = worldPrev.speed / params["mechanical"]["wheelCircumferance"] * 60.0

worldNext.wheelRotationsHz = worldPrev.speed / params["mechanical"]["wheelCircumferance"] * 2.0 * np.pi

worldNext.rpm = worldNext.wheelRPM * params["mechanical"]["gearRatio"]

worldNext.motorRotationHz = worldNext.wheelRotationsHz * params["mechanical"]["gearRatio"]

worldNext.maxPower = params["electrical"]["tractiveIMax"] * worldPrev.voltage
Expand All @@ -22,7 +21,13 @@ def stepElectrical(worldPrev, worldNext, params, inputs):
worldNext.torque = min(perfectTractionTorque, worldPrev.maxTractionTorqueAtWheel)

worldNext.motorTorque = worldNext.torque / params["mechanical"]["gearRatio"]
worldNext.voltage = 28.0 * VoltageTools.lookup(worldPrev.charge, worldPrev.current)

# voltage now updated via template function (previous current + vehicle state)
worldNext.voltage = LionCellModel.update_pack_voltage_template(
prev_current=worldPrev.current,
vehicle_state=worldPrev,
params=params
)

worldNext.power = worldNext.motorTorque * worldNext.motorRotationHz

Expand All @@ -31,6 +36,8 @@ def stepElectrical(worldPrev, worldNext, params, inputs):
else:
worldNext.current = worldNext.power / worldNext.voltage

worldNext.maxTractionTorqueAtWheel = (worldPrev.lbTireTraction.getLongForcePureSlip() + worldPrev.rbTireTraction.getLongForcePureSlip()) * params["mechanical"]["wheelRadius"]
worldNext.maxTractionTorqueAtWheel = (
worldPrev.lbTireTraction.getLongForcePureSlip() + worldPrev.rbTireTraction.getLongForcePureSlip()
) * params["mechanical"]["wheelRadius"]

worldNext.motorForce = worldNext.torque / params["mechanical"]["wheelRadius"]
16 changes: 16 additions & 0 deletions FullVehicleSim/MBS/lionCellModel.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,19 @@ def voltage_match(current):
lower_percent = (current_per_cell - lower) / 5.0
voltage = lower_percent * upper_voltage + (1.0 - lower_percent) * lower_voltage
return voltage

def update_pack_voltage_template(prev_current: float, vehicle_state, params=None) -> float:

# keep the existing scale behavior: 28 series cells (as used in granola2.py)
series_cells = 28.0
if params is not None:
# If your params ever defines this, it'll override the hardcoded default.
series_cells = params.get("electrical", {}).get("seriesCells", series_cells)

# use the previous current
try:
cell_v = lookup(vehicle_state.charge, prev_current)
return float(series_cells * cell_v)
except Exception:
# safe fallback if something goes out of bounds
return float(getattr(vehicle_state, "voltage", series_cells * 3.6))
8 changes: 8 additions & 0 deletions FullVehicleSim/MBS/salvoltagemodel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Nathaniel wanted me to focus on granola2.py and improve what already exists. Since granola2.py is part of the main simulation, I’ve been thinking of it like a game loop: every timestep it takes the previous world state (worldPrev) and computes the next world state (worldNext). worldPrev holds what the car was like one step ago, and worldNext is where we write updated values for the next timestep inside stepElectrical. That includes things like speed-related values, torque, power, voltage, current, and traction limits.
Inside stepElectrical, the sim uses the previous speed plus mechanical parameters (wheel circumference, gear ratio, wheel radius) to compute wheel RPM and motor RPM / motor rotation rate. Then it determines how much torque the car can apply (based on traction and max torque), and computes power from that using the relationship power = torque × rotation rate. That power requirement is what drives how much electrical current the car needs from the battery.
The specific line Nathaniel highlighted is where voltage gets computed, and that voltage ends up affecting basically everything that comes after it. Before my changes, granola2.py computed voltage directly with:
worldNext.voltage = 28.0 * VoltageTools.lookup(worldPrev.charge, worldPrev.current).
That hard-codes the sim to a specific voltage model: it uses a lookup-table cell model for voltage and then multiplies by 28 to convert cell voltage into pack voltage (28 cells in series). After voltage is computed, the sim uses it to compute the next current draw using current = power / voltage, and then clamps it to the max allowed current (tractiveIMax). So voltage directly affects current, which affects charge usage, which affects power limits and the overall behavior of the sim.
lionCellModel.py contains the battery lookup model itself. It’s basically datasheet-derived voltage tables for different discharge currents per cell, and the lookup(charge, current_draw) function converts pack-level values into per-cell values (it divides by 20 as a parallel-cell assumption), converts charge into an “Ah discharged” representation, and then interpolates between tables to estimate the cell voltage. That’s why the main sim multiplies by 28 afterward to get full pack voltage.
The main improvement I made based on Nathaniel’s request was to stop calculating voltage directly inside the main sim loop and instead route it through a dedicated function that takes the previous current and the vehicle state and returns voltage. This decouples the battery voltage model from granola2.py, keeps the sim loop cleaner, and makes it easier to swap in a revised voltage model later (ECM/RC/hysteresis/temp) without having to rewrite the main simulation logic again.
For now the template can wrap the existing lookup behavior so results don’t change, but it creates the interface needed for a revised model later once Nathaniel believes its needed.