Real-time Battery Model Loop
In this section, we'll walk through the main loop of our battery simulation code, explaining each step in detail. This simulation models the behavior of a battery over time, considering factors like power demand, temperature, state of charge (SoC), and more.
Main Simulation Loop
The main simulation loop runs while the current time t is less than the endTime. Here's the code:
while t < endTime:
debug_print(DEBUG, f"Time: {datetime.fromtimestamp(t, timezone.utc)}")
# Adjust the profile time
t_profile = t - 0 * (power_profile_df.index[-1] - power_profile_df.index[0]).total_seconds()
# Interpolate required battery power and ambient temperature
required_battery_power, dt_to_next_profile_input, lastLoadTsIdx = interpolateValue(
t_profile, power_timestamps, power_values, lastLoadTsIdx
)
ambient_temperature, _, lastTempTsIdx = interpolateValue(
t_profile, temperature_timestamps, temperature_values, lastTempTsIdx
)
# Update effective energy
effectiveEnergy += (battery.voltage * abs(battery.current)) * dt_s / 3600
# Calculate temperature statistics
maxTemperature = max(analysedElements, key=lambda x: x.temperature).temperature
minTemperature = min(analysedElements, key=lambda x: x.temperature).temperature
averageTemperature = sum(el.temperature for el in analysedElements) / len(analysedElements)
# Calculate voltage statistics
maxVoltage = max(analysedElements, key=lambda x: x.voltage).voltage
minVoltage = min(analysedElements, key=lambda x: x.voltage).voltage
averageVoltage = sum(el.voltage for el in analysedElements) / len(analysedElements)
debug_print(DEBUG, f"SoC: {battery.soc}, Voltage: {battery.voltage}")
# Manage battery current
requiredCurrent = (required_battery_power) / battery.voltage
# Implement charge tapering logic
if chargeTaperStartTh <= battery.soc < simulation_cutoffs["maxSoC"] and requiredCurrent > 0:
scaling_factor = (simulation_cutoffs["maxSoC"] - battery.soc) / (simulation_cutoffs["maxSoC"] - chargeTaperStartTh)
requiredCurrent *= scaling_factor
current = requiredCurrent
# Safety checks
minCelltemp = min(el.temperature for el in analysedElements)
maxCelltemp = max(el.temperature for el in analysedElements)
debug_print(DEBUG, f"Max Temperature: {maxCelltemp}°C")
# State Machine
state, current = batteryStateMachine(
state, minVoltage, maxCelltemp, current, requiredCurrent,
temperatureSafety, temperatureRecovery, simulation_cutoffs['maxCurrent'],
lowVoltageSafetyCutoff, t_profile
)
# Update time step
dt_s, t, prev_dt_s = update_time_step(
batteryCapacity, current, prev_dt_s, t,
min_dt_s=minimum_dt_s, maximum_dt_s=maximum_dt_s,
dt_to_next_profile_input=dt_to_next_profile_input,
sampling_multiplier=1.0, sampling_relaxation_factor=1.2,
debug=DEBUG
)
battery.setCurrent(current, SIM_TARGETED_DEPTH)
# Cooling logic
if maxCelltemp <= startCoolingTemperature:
forcedConvectionMultiplier = restingForcedConvectionMultiplier
elif maxCelltemp >= maxCoolingTemperature:
forcedConvectionMultiplier = coolingForcedConvectionMultiplier
else:
scaling_factor = ((maxCelltemp - startCoolingTemperature) /
(maxCoolingTemperature - startCoolingTemperature))
forcedConvectionMultiplier = 1 + ((coolingForcedConvectionMultiplier - 1) * scaling_factor)
fanRPM = (forcedConvectionMultiplier - 1) * (maxFanRPM / (coolingForcedConvectionMultiplier - 1))
# Update battery elements
for el in analysedElements:
el.calculateNextStep(dt_s, ambient_temperature)
battery.getVoltage(SIM_TARGETED_DEPTH)
power = battery.voltage * battery.current
battery.soc = min(el.soc for el in analysedElements)
tDateUTCTimestamp = datetime.fromtimestamp(t, timezone.utc).timestamp()
# Update sensors
digitalTwinBatterySensors["SoC"].significantAppend(float(battery.soc) * 100, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|0"].significantAppend(minTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|1"].significantAppend(maxTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|2"].significantAppend(averageTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Voltage"].significantAppend(battery.voltage, tDateUTCTimestamp)
digitalTwinBatterySensors["Current"].significantAppend(battery.current, tDateUTCTimestamp)
digitalTwinBatterySensors["Power Demand"].significantAppend(required_battery_power, tDateUTCTimestamp)
digitalTwinBatterySensors["Power"].significantAppend(power, tDateUTCTimestamp)
digitalTwinBatterySensors["Ambient Temperature"].significantAppend(ambient_temperature, tDateUTCTimestamp)
Let's break down each part of the loop.
1. Time Management
while t < endTime:
debug_print(DEBUG, f"Time: {datetime.fromtimestamp(t, timezone.utc)}")
- Purpose: The loop runs until the simulation time
treachesendTime. - Debugging: If
DEBUGis enabled, it prints the current simulation time in UTC.
2. Adjusting Profile Time
t_profile = t - 0 * (power_profile_df.index[-1] - power_profile_df.index[0]).total_seconds()
- Purpose: Adjusts the profile time
t_profileused for interpolation. The multiplication by0effectively does nothing here, but it might be a placeholder for future adjustments.
3. Interpolating Power Demand and Ambient Temperature
required_battery_power, dt_to_next_profile_input, lastLoadTsIdx = interpolateValue(
t_profile, power_timestamps, power_values, lastLoadTsIdx
)
ambient_temperature, _, lastTempTsIdx = interpolateValue(
t_profile, temperature_timestamps, temperature_values, lastTempTsIdx
)
- Purpose: Interpolates the required battery power and ambient temperature at the current time
t_profile. - Functions Used:
interpolateValue: A function that interpolates values based on time series data.
4. Updating Effective Energy
effectiveEnergy += (battery.voltage * abs(battery.current)) * dt_s / 3600
- Purpose: Calculates the energy consumed or supplied during the time step
dt_sand adds it toeffectiveEnergy. - Explanation: Energy (
Wh) = Voltage (V) * Current (A) * Time (h).
5. Calculating Temperature Statistics
maxTemperature = max(analysedElements, key=lambda x: x.temperature).temperature
minTemperature = min(analysedElements, key=lambda x: x.temperature).temperature
averageTemperature = sum(el.temperature for el in analysedElements) / len(analysedElements)
- Purpose: Computes the maximum, minimum, and average temperatures of the battery elements.
- Variables:
analysedElements: A list of battery elements (e.g., cells) being analyzed.
6. Calculating Voltage Statistics
maxVoltage = max(analysedElements, key=lambda x: x.voltage).voltage
minVoltage = min(analysedElements, key=lambda x: x.voltage).voltage
averageVoltage = sum(el.voltage for el in analysedElements) / len(analysedElements)
- Purpose: Computes the maximum, minimum, and average voltages of the battery elements.
7. Debugging SoC and Voltage
debug_print(DEBUG, f"SoC: {battery.soc}, Voltage: {battery.voltage}")
- Purpose: Prints the current State of Charge (SoC) and voltage of the battery if debugging is enabled.
8. Managing Battery Current
requiredCurrent = (required_battery_power) / battery.voltage
- Purpose: Calculates the required current based on the power demand and current battery voltage.
- Explanation: Current (
A) = Power (W) / Voltage (V).
Charge Tapering Logic
if chargeTaperStartTh <= battery.soc < simulation_cutoffs["maxSoC"] and requiredCurrent > 0:
scaling_factor = (simulation_cutoffs["maxSoC"] - battery.soc) / (simulation_cutoffs["maxSoC"] - chargeTaperStartTh)
requiredCurrent *= scaling_factor
- Purpose: Implements charge tapering as the battery approaches maximum SoC to prevent overcharging.
- Variables:
chargeTaperStartTh: SoC threshold to start tapering the charge.simulation_cutoffs: Dictionary containing simulation limits likemaxSoC.
- Explanation:
- The
scaling_factorreduces the charging current linearly as SoC approachesmaxSoC.
- The
9. Safety Checks
minCelltemp = min(el.temperature for el in analysedElements)
maxCelltemp = max(el.temperature for el in analysedElements)
debug_print(DEBUG, f"Max Temperature: {maxCelltemp}°C")
- Purpose: Checks the minimum and maximum cell temperatures for safety.
- Action: Prints the maximum temperature for debugging.
10. State Machine for Battery Management
state, current = batteryStateMachine(
state, minVoltage, maxCelltemp, current, requiredCurrent,
temperatureSafety, temperatureRecovery, simulation_cutoffs['maxCurrent'],
lowVoltageSafetyCutoff, t_profile
)
- Purpose: Manages the battery state (e.g., Normal, Overheating, Overcurrent) and adjusts the current accordingly.
- Functions Used:
batteryStateMachine: A function that updates the state of the battery and possibly modifies the current based on safety thresholds.
- Variables:
state: Current state of the battery.temperatureSafety: Temperature threshold for safety cut-off.temperatureRecovery: Temperature at which battery can resume normal operation.lowVoltageSafetyCutoff: Minimum voltage threshold.
11. Updating Time Step
dt_s, t, prev_dt_s = update_time_step(
batteryCapacity, current, prev_dt_s, t,
min_dt_s=minimum_dt_s, maximum_dt_s=maximum_dt_s,
dt_to_next_profile_input=dt_to_next_profile_input,
sampling_multiplier=1.0, sampling_relaxation_factor=1.2,
debug=DEBUG
)
- Purpose: Dynamically adjusts the simulation time step
dt_sfor accuracy and performance. - Functions Used:
update_time_step: Adjustsdt_sbased on current conditions to ensure numerical stability.
- Variables:
batteryCapacity: Battery capacity in Ampere-hours.prev_dt_s: Previous time step.t: Current simulation time.dt_to_next_profile_input: Time until the next profile input.
12. Setting Battery Current
battery.setCurrent(current, SIM_TARGETED_DEPTH)
- Purpose: Updates the battery's current at the specified simulation depth.
- Variables:
SIM_TARGETED_DEPTH: The depth in the battery hierarchy (e.g., cell level, module level) where the current is applied.
13. Cooling Logic
if maxCelltemp <= startCoolingTemperature:
forcedConvectionMultiplier = restingForcedConvectionMultiplier
elif maxCelltemp >= maxCoolingTemperature:
forcedConvectionMultiplier = coolingForcedConvectionMultiplier
else:
scaling_factor = ((maxCelltemp - startCoolingTemperature) /
(maxCoolingTemperature - startCoolingTemperature))
forcedConvectionMultiplier = 1 + ((coolingForcedConvectionMultiplier - 1) * scaling_factor)
fanRPM = (forcedConvectionMultiplier - 1) * (maxFanRPM / (coolingForcedConvectionMultiplier - 1))
- Purpose: Adjusts the cooling system based on the maximum cell temperature.
- Variables:
startCoolingTemperature: Temperature to start increasing cooling.maxCoolingTemperature: Temperature at which maximum cooling is applied.restingForcedConvectionMultiplier: Cooling multiplier when the system is at rest.coolingForcedConvectionMultiplier: Maximum cooling multiplier.maxFanRPM: Maximum fan RPM.
14. Updating Battery Elements
for el in analysedElements:
el.calculateNextStep(dt_s, ambient_temperature)
- Purpose: Updates each battery element (e.g., cells) for the next time step.
- Functions Used:
calculateNextStep: Updates the state of an element based on the time step and ambient conditions.
- Variables:
dt_s: Time step in seconds.ambient_temperature: Ambient temperature in degrees Celsius.
15. Updating Battery Voltage and SoC
battery.getVoltage(SIM_TARGETED_DEPTH)
power = battery.voltage * battery.current
battery.soc = min(el.soc for el in analysedElements)
- Purpose:
getVoltage: Retrieves the battery voltage at the specified depth.power: Calculates the power output/input.battery.soc: Updates the battery's SoC to the minimum SoC among all elements to ensure safety.
16. Timestamp for Sensor Data
tDateUTCTimestamp = datetime.fromtimestamp(t, timezone.utc).timestamp()
- Purpose: Converts the simulation time
tto a UTC timestamp for recording sensor data.
17. Updating Sensors
digitalTwinBatterySensors["SoC"].significantAppend(float(battery.soc) * 100, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|0"].significantAppend(minTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|1"].significantAppend(maxTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Temperature Stack Aggregate|2"].significantAppend(averageTemperature, tDateUTCTimestamp)
digitalTwinBatterySensors["Voltage"].significantAppend(battery.voltage, tDateUTCTimestamp)
digitalTwinBatterySensors["Current"].significantAppend(battery.current, tDateUTCTimestamp)
digitalTwinBatterySensors["Power Demand"].significantAppend(required_battery_power, tDateUTCTimestamp)
digitalTwinBatterySensors["Power"].significantAppend(power, tDateUTCTimestamp)
digitalTwinBatterySensors["Ambient Temperature"].significantAppend(ambient_temperature, tDateUTCTimestamp)
- Purpose: Records the simulation data into the
digitalTwinBatterySensorsfor analysis and plotting. - Functions Used:
significantAppend: Appends data to the sensor if it's significantly different from the last recorded value to reduce data size.
Finalizing the DataFrame
After the simulation loop, we process and save the collected data.
# Finalize DataFrame
dtDf = dataframeFromSensors(digitalTwinBatterySensors).ffill()
dtDf.index = pd.to_datetime(dtDf.index, unit='s', utc=True)
dtDf.to_csv('battery_simulation.csv')
# Check for duplicate indices
duplicate_count = dtDf.index.duplicated().sum()
print(f"Duplicate index count: {duplicate_count}")
plot_data(dtDf, title="Battery Simulation")
- Purpose:
- Converts the sensor data into a pandas DataFrame.
- Forward-fills any missing data to ensure continuity.
- Saves the DataFrame to a CSV file.
- Checks for any duplicate timestamps (indices).
- Plots the data for visualization.
Understanding the Battery Class
In our battery simulation, the Battery class is a central component that represents the entire battery system. It inherits from the Assembly class and encapsulates multiple layers of hierarchy, including stacks, modules, and cells. This section delves into the Battery class, explaining its structure, methods, and how it integrates various components to simulate battery behavior accurately.
The Role of the Battery Class
The Battery class models the complete battery assembly, consisting of:
- Stacks: Groups of modules connected in series or parallel.
- Modules: Groups of cells connected in series or parallel.
- Cells: The fundamental electrochemical units of the battery.
By organizing these components hierarchically, the Battery class allows us to simulate complex battery architectures and analyze their electrical and thermal behaviors under different conditions.
Structure of the Battery Class
Here's the definition of the Battery class:
class Battery(Assembly):
def __init__(self):
Assembly.__init__(self, elements=[], positions=[])
self.smu = Smu(self)
## Cooling parameters
self.forcedConvectionMultiplier = 1.0
self.restingCoolingFactorMultiplier = 1.0
## Noise parameters for simulation variability
self.cellCapNoise = 0.0
self.cellImpNoise = 0.0
self.cellSocNoise = 0.0
## Battery architecture parameters
self.cellP = None
self.cellS = None
self.modP = None
self.modS = None
self.stackP = None
self.stackS = None
## Generate module and stack templates
self.genModule = Module()
self.genStack = Stack()
self.totalCellsInParallel = 0
self.totalCellsInSeries = 0
self.pdu = Pdu(self)
Key Attributes
elements: A list that holds the stacks in the battery.positions: Specifies the positions of the stacks.forcedConvectionMultiplier: Adjusts the cooling based on external factors.cellCapNoise,cellImpNoise,cellSocNoise: Introduce variability in capacity, impedance, and State of Charge (SoC) among cells to simulate real-world inconsistencies.- Architecture Parameters:
cellP,cellS: Number of cells in parallel and series within a module.modP,modS: Number of modules in parallel and series within a stack.stackP,stackS: Number of stacks in parallel and series within the battery.
Supporting Components
Smu: Stack Management Unit responsible for managing the stacks.Pdu: Power Distribution Unit that manages the power flow within the battery.
Building the Battery Architecture
The build method constructs the battery's architecture based on specified parameters:
def build(self, cell: Cell, params, batteryParams=None):
## Extract architecture parameters
self.cellP = params["architecture"]["cellsParallel"]
self.cellS = params["architecture"]["cellsSeries"]
self.modP = params["architecture"]["modulesParallel"]
self.modS = params["architecture"]["modulesSeries"]
self.stackP = params["architecture"]["stacksParallel"]
self.stackS = params["architecture"]["stacksSeries"]
## Set cooling parameters if provided
if batteryParams is not None:
if 'forcedConvectionMultiplier' in batteryParams:
self.forcedConvectionMultiplier = batteryParams['forcedConvectionMultiplier']
if 'restingConvectionMultiplier' in batteryParams:
self.restingCoolingFactorMultiplier = batteryParams['restingConvectionMultiplier']
## Calculate total cells in parallel and series
self.totalCellsInParallel = self.cellP * self.modP * self.stackP
self.totalCellsInSeries = self.modS * self.stackS * self.cellS
## Build the modules, stacks, and assemble the battery
## ...
Steps in Building the Battery
- Set Architecture Parameters: Define how many cells, modules, and stacks are connected in series and parallel.
- Configure Cooling Parameters: Adjust the cooling behavior if additional parameters are provided.
- Calculate Total Cells:
- Total Cells in Parallel:
- Total Cells in Series:
- Create Module Templates: Build prototype modules containing cells.
- Create Stack Templates: Assemble modules into prototype stacks.
- Assemble the Battery: Combine stacks to form the complete battery.
Example Architecture Calculation
Suppose we have the following parameters:
- Cells in Series (cellS): 10
- Cells in Parallel (cellP): 5
- Modules in Series (modS): 4
- Modules in Parallel (modP): 2
- Stacks in Series (stackS): 1
- Stacks in Parallel (stackP): 1
Total Cells in Series:
Total Cells in Parallel:
Total Number of Cells:
Initializing the Battery
After building the battery architecture, we need to initialize the state of each cell:
def initialize(self, initSoC=1, initSoH=1, initTamb=25.0, targetedDepth=LEVEL_STACK):
## Normalize input parameters
self.soc = normalize_value(initSoC, threshold=1.0, scale=100.0, normalize=True, clip_min=0, clip_max=100)
self.soh.value = normalize_value(initSoH, threshold=1.0, scale=100.0, normalize=True, clip_min=0, clip_max=100)
## Get all elements at the targeted depth (e.g., cells)
analysedElements = []
self.getElementsByDepth(analysedElements, targetedDepth)
## Initialize each element
for element in analysedElements:
element.soc = self.soc
element.temperature = initTamb
element.calculateNextStep(1, initTamb)
Initialization Steps
- Normalize Initial Conditions:
- State of Charge (SoC): Ensure it is between 0% and 100%.
- State of Health (SoH): Ensure it is between 0% and 100%.
- Select Elements for Initialization:
- Use
getElementsByDepthto retrieve all elements at the desired level (e.g., cells).
- Use
- Initialize Each Element:
- Set the initial SoC and temperature.
- Call
calculateNextStepto update the internal state based on the initial conditions.
Normalization Function
The normalize_value function ensures that input values are within acceptable ranges:
def normalize_value(value, threshold=1.0, scale=100.0, normalize=True, clip_min=0, clip_max=100):
if normalize:
value = value / scale
value = max(min(value, clip_max / scale), clip_min / scale)
return value
Calculating Battery Voltage and Capacity
The battery voltage and capacity depend on how cells are connected:
Total Voltage
Cells connected in series increase the total voltage:
Where:
- is the nominal voltage of a single cell.
- is the total number of cells connected in series.
Total Capacity
Cells connected in parallel increase the total capacity:
Where:
- is the capacity of a single cell (in Ampere-hours).
- is the total number of cells connected in parallel.
Example Calculation
Using the previous architecture with:
Total Voltage:
Total Capacity:
Thermal Management in the Battery Class
The battery's thermal behavior is critical for safety and performance. The Battery class manages thermal properties and cooling mechanisms.
Heat Generation
Heat generated in a cell due to internal resistance is calculated as:
Where:
- is the power loss due to heat (in Watts).
- is the current through the cell (in Amperes).
- is the internal resistance of the cell (in Ohms).
Temperature Update
The temperature change in a cell is computed using the heat capacity and thermal resistance:
Where:
- is the change in temperature (in Kelvin).
- is the time step (in seconds).
- is the convective heat transfer coefficient.
- is the surface area of the cell (in square meters).
- is the current temperature of the cell.
- is the ambient temperature.
- is the mass of the cell (in kilograms).
- is the specific heat capacity of the cell material (in J/kg·K).
Cooling Multipliers
The forcedConvectionMultiplier adjusts the cooling based on operating conditions:
- Resting State: Minimal cooling is applied.
- Active Cooling: When temperatures rise, the cooling multiplier increases to dissipate more heat.
Example Temperature Update
Assuming:
Calculate :
First, compute the cooling term:
Net heat:
Temperature change:
So, the cell temperature increases by approximately during this time step.
Managing State of Charge (SoC) and State of Health (SoH)
State of Charge Update
The SoC decreases or increases based on the current flow:
- Discharging:
- Charging:
State of Health Update
The SoH degrades over time due to:
- Calendar Aging: Degradation over time regardless of use.
- Cycle Aging: Degradation due to charge-discharge cycles.
Calendar Aging Model
The capacity loss due to calendar aging can be modeled as:
Where:
- is a calendar aging rate constant.
- is a factor depending on SoC and temperature.
- is time in days.
Cycle Aging Model
Capacity loss per equivalent full cycle:
Where:
- is the end-of-life SoH threshold (e.g., 80%).
- is the standard cycle life (e.g., 2000 cycles).
Total Capacity Loss
Total capacity loss combines calendar and cycle aging:
Updating SoH
The SoH is updated by subtracting the total capacity loss:
Voltage Calculation with Internal Resistance
The voltage of the battery is affected by internal resistance and can be calculated as:
Where:
- is the open-circuit voltage, a function of SoC and temperature.
- is the current (positive for discharging, negative for charging).
- is the total internal resistance of the battery.
Internal Resistance
The total internal resistance depends on:
- Cell Resistance: Varies with SoC, temperature, and SoH.
- Connections: Additional resistance from interconnections between cells, modules, and stacks.
For cells connected in series:
For cells connected in parallel (assuming identical cells):
Total internal resistance:
The calculateNextStep Method
In this subsection, we delve into the calculateNextStep method of the ElectroChemEntity class. This method is pivotal in updating the state of each battery element (cells, modules, stacks) during each simulation time step. It models both the electrical and thermal behaviors, accounting for factors like current flow, temperature changes, internal resistance, and state of charge (SoC).
Overview of calculateNextStep
The calculateNextStep method performs the following key functions:
- Recalculates Open Circuit Voltage (OCV) based on the current SoC and temperature.
- Updates Resistance Factors that depend on SoC, temperature, and State of Health (SoH).
- Calculates Power Loss due to internal resistance.
- Updates Temperature of the cell using thermal equations.
- Updates Voltage Components in the RC circuit model.
- Updates State of Charge (SoC) based on the current and capacity.
- Updates OCV Hysteresis to model voltage hysteresis effects.
- Accumulates Equivalent Cycles to model battery aging.
Let's explore each of these steps in detail.
1. Recalculating Open Circuit Voltage (OCV)
The OCV of a battery cell is a function of its SoC and temperature. Recalculating OCV is crucial because it serves as the reference voltage from which we compute other voltage drops due to internal resistances and hysteresis.
OCV Calculation
The OCV is calculated using bilinear interpolation from the OCV lookup tables:
Where:
- is the discharge OCV from the lookup table.
- is the charge OCV from the lookup table.
- is the hysteresis factor (ranges from 0 to 1).
This calculation accounts for the hysteresis effect, where the OCV differs during charging and discharging.
2. Updating Resistance Factors
The internal resistance of a battery changes with SoC, temperature, and SoH. These factors are represented as multipliers that adjust the base resistance to reflect current operating conditions.
SoC-Dependent Resistance Factor
The SoC resistance factor is calculated using linear interpolation:
Temperature-Dependent Resistance Factor
The temperature resistance factor is:
SoH-Dependent Resistance Factor
The SoH resistance factor accounts for degradation:
Total Resistance Factor
The total resistance factor is the product of the individual factors: