/** * LiXX Cell Pack Matcher - Matching Algorithms * * Implements optimized algorithms for lithium cell matching: * - Genetic Algorithm (default, fast) * - Simulated Annealing * - Exhaustive search (for small configurations) * * Based on research: * - Shi et al., 2013: "Internal resistance matching for parallel-connected * lithium-ion cells and impacts on battery pack cycle life" * DOI: 10.1016/j.jpowsour.2013.11.064 */ // ============================================================================= // Utility Functions // ============================================================================= /** * Calculate the coefficient of variation (CV) as a percentage. * CV = (standard deviation / mean) * 100 * @param {number[]} values - Array of numeric values * @returns {number} CV as percentage, or 0 if invalid */ function coefficientOfVariation(values) { if (!values || values.length === 0) return 0; const mean = values.reduce((a, b) => a + b, 0) / values.length; if (mean === 0) return 0; const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length; return (Math.sqrt(variance) / mean) * 100; } /** * Shuffle array in place using Fisher-Yates algorithm. * @param {Array} array - Array to shuffle * @returns {Array} The same array, shuffled */ function shuffleArray(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } /** * Deep clone an array of arrays. * @param {Array[]} arr - Array to clone * @returns {Array[]} Cloned array */ function cloneConfiguration(arr) { return arr.map(group => [...group]); } // ============================================================================= // Scoring Functions // ============================================================================= /** * Calculate the match score for a pack configuration. * Lower score = better match. * * The score combines: * - Capacity variance between parallel groups (weighted by capacityWeight) * - Internal resistance variance within parallel groups (weighted by irWeight) * * @param {Object[][]} configuration - Array of parallel groups, each containing cell objects * @param {number} capacityWeight - Weight for capacity matching (0-1) * @param {number} irWeight - Weight for IR matching (0-1) * @returns {Object} Score breakdown */ function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) { // Calculate total capacity for each parallel group const groupCapacities = configuration.map(group => group.reduce((sum, cell) => sum + cell.capacity, 0) ); // Calculate average IR for each parallel group const groupIRs = configuration.map(group => { const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined); if (irsWithValues.length === 0) return null; return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length; }).filter(ir => ir !== null); // Calculate IR variance within each parallel group (important for parallel cells) const withinGroupIRVariances = configuration.map(group => { const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined); if (irsWithValues.length < 2) return 0; const irs = irsWithValues.map(cell => cell.ir); return coefficientOfVariation(irs); }); // Capacity CV between groups (should be low for balanced pack) const capacityCV = coefficientOfVariation(groupCapacities); // Average IR CV within groups (should be low for parallel cells) const avgWithinGroupIRCV = withinGroupIRVariances.length > 0 ? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length : 0; // Combined score (lower is better) const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV); return { score, capacityCV, irCV: avgWithinGroupIRCV, groupCapacities, groupIRs, withinGroupIRVariances }; } // ============================================================================= // Genetic Algorithm // ============================================================================= /** * Genetic Algorithm for cell matching. * Fast and effective for most configurations. */ class GeneticAlgorithm { /** * @param {Object[]} cells - Array of cell objects {label, capacity, ir} * @param {number} serial - Number of series groups * @param {number} parallel - Number of cells in parallel per group * @param {Object} options - Algorithm options */ constructor(cells, serial, parallel, options = {}) { this.cells = cells; this.serial = serial; this.parallel = parallel; this.totalCellsNeeded = serial * parallel; // Options with defaults this.populationSize = options.populationSize || 50; this.maxIterations = options.maxIterations || 5000; this.mutationRate = options.mutationRate || 0.15; this.eliteCount = options.eliteCount || 5; this.capacityWeight = options.capacityWeight ?? 0.7; this.irWeight = options.irWeight ?? 0.3; this.onProgress = options.onProgress || (() => { }); this.stopped = false; this.bestSolution = null; this.bestScore = Infinity; } /** * Stop the algorithm. */ stop() { this.stopped = true; } /** * Create a random individual (configuration). * @param {Object[]} cellPool - Cells to choose from * @returns {Object[][]} Configuration */ createIndividual(cellPool) { const shuffled = shuffleArray([...cellPool]).slice(0, this.totalCellsNeeded); const configuration = []; for (let i = 0; i < this.serial; i++) { const group = []; for (let j = 0; j < this.parallel; j++) { group.push(shuffled[i * this.parallel + j]); } configuration.push(group); } return configuration; } /** * Convert configuration to flat array of cell indices for crossover. * @param {Object[][]} config - Configuration * @returns {number[]} Flat array of cell indices */ configToIndices(config) { const flat = config.flat(); return flat.map(cell => this.cells.findIndex(c => c.label === cell.label)); } /** * Convert indices back to configuration. * @param {number[]} indices - Array of cell indices * @returns {Object[][]} Configuration */ indicesToConfig(indices) { const configuration = []; for (let i = 0; i < this.serial; i++) { const group = []; for (let j = 0; j < this.parallel; j++) { const idx = indices[i * this.parallel + j]; group.push(this.cells[idx]); } configuration.push(group); } return configuration; } /** * Perform crossover between two parents using Order Crossover (OX). * @param {number[]} parent1 - First parent indices * @param {number[]} parent2 - Second parent indices * @returns {number[]} Child indices */ crossover(parent1, parent2) { const length = parent1.length; const start = Math.floor(Math.random() * length); const end = start + Math.floor(Math.random() * (length - start)); const child = new Array(length).fill(-1); const usedIndices = new Set(); // Copy segment from parent1 for (let i = start; i <= end; i++) { child[i] = parent1[i]; usedIndices.add(parent1[i]); } // Fill remaining from parent2 let childIdx = (end + 1) % length; for (let i = 0; i < length; i++) { const parent2Idx = (end + 1 + i) % length; if (!usedIndices.has(parent2[parent2Idx])) { while (child[childIdx] !== -1) { childIdx = (childIdx + 1) % length; } child[childIdx] = parent2[parent2Idx]; usedIndices.add(parent2[parent2Idx]); childIdx = (childIdx + 1) % length; } } return child; } /** * Mutate an individual by swapping cells. * @param {number[]} indices - Individual indices * @param {Object[]} unusedCells - Cells not in this configuration * @returns {number[]} Mutated indices */ mutate(indices, unusedCells) { const mutated = [...indices]; if (Math.random() < this.mutationRate) { if (unusedCells.length > 0 && Math.random() < 0.3) { // Replace a cell with an unused one const replaceIdx = Math.floor(Math.random() * mutated.length); const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)]; const unusedIdx = this.cells.findIndex(c => c.label === unusedCell.label); mutated[replaceIdx] = unusedIdx; } else { // Swap two cells within the configuration const i = Math.floor(Math.random() * mutated.length); const j = Math.floor(Math.random() * mutated.length); [mutated[i], mutated[j]] = [mutated[j], mutated[i]]; } } return mutated; } /** * Run the genetic algorithm. * @returns {Promise} Best solution found */ async run() { const startTime = Date.now(); // Initialize population let population = []; for (let i = 0; i < this.populationSize; i++) { population.push(this.createIndividual(this.cells)); } // Evaluate initial population let evaluated = population.map(config => ({ config, indices: this.configToIndices(config), ...calculateScore(config, this.capacityWeight, this.irWeight) })); // Sort by score evaluated.sort((a, b) => a.score - b.score); if (evaluated[0].score < this.bestScore) { this.bestScore = evaluated[0].score; this.bestSolution = evaluated[0]; } // Main evolution loop for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) { // Selection (tournament selection) const newPopulation = []; // Keep elite individuals for (let i = 0; i < this.eliteCount && i < evaluated.length; i++) { newPopulation.push(evaluated[i].indices); } // Generate rest through crossover and mutation while (newPopulation.length < this.populationSize) { // Tournament selection const tournament1 = evaluated.slice(0, Math.ceil(evaluated.length / 2)); const tournament2 = evaluated.slice(0, Math.ceil(evaluated.length / 2)); const parent1 = tournament1[Math.floor(Math.random() * tournament1.length)]; const parent2 = tournament2[Math.floor(Math.random() * tournament2.length)]; // Crossover let child = this.crossover(parent1.indices, parent2.indices); // Determine unused cells const usedLabels = new Set(child.map(idx => this.cells[idx].label)); const unusedCells = this.cells.filter(c => !usedLabels.has(c.label)); // Mutation child = this.mutate(child, unusedCells); newPopulation.push(child); } // Evaluate new population evaluated = newPopulation.map(indices => { const config = this.indicesToConfig(indices); return { config, indices, ...calculateScore(config, this.capacityWeight, this.irWeight) }; }); // Sort by score evaluated.sort((a, b) => a.score - b.score); // Update best solution if (evaluated[0].score < this.bestScore) { this.bestScore = evaluated[0].score; this.bestSolution = evaluated[0]; } // Progress callback if (iteration % 50 === 0 || iteration === this.maxIterations - 1) { this.onProgress({ iteration, maxIterations: this.maxIterations, bestScore: this.bestScore, currentBest: this.bestSolution, elapsedTime: Date.now() - startTime }); // Allow UI to update await new Promise(resolve => setTimeout(resolve, 0)); } } // Determine excluded cells const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label)); const excludedCells = this.cells.filter(c => !usedLabels.has(c.label)); return { configuration: this.bestSolution.config, score: this.bestScore, capacityCV: this.bestSolution.capacityCV, irCV: this.bestSolution.irCV, groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: this.maxIterations, elapsedTime: Date.now() - startTime }; } } // ============================================================================= // Simulated Annealing // ============================================================================= /** * Simulated Annealing algorithm for cell matching. * Good for escaping local minima. */ class SimulatedAnnealing { /** * @param {Object[]} cells - Array of cell objects * @param {number} serial - Number of series groups * @param {number} parallel - Number of cells in parallel per group * @param {Object} options - Algorithm options */ constructor(cells, serial, parallel, options = {}) { this.cells = cells; this.serial = serial; this.parallel = parallel; this.totalCellsNeeded = serial * parallel; this.maxIterations = options.maxIterations || 5000; this.initialTemp = options.initialTemp || 100; this.coolingRate = options.coolingRate || 0.995; this.capacityWeight = options.capacityWeight ?? 0.7; this.irWeight = options.irWeight ?? 0.3; this.onProgress = options.onProgress || (() => { }); this.stopped = false; this.bestSolution = null; this.bestScore = Infinity; } stop() { this.stopped = true; } /** * Create initial configuration. */ createInitialConfig() { const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded); const configuration = []; for (let i = 0; i < this.serial; i++) { const group = []; for (let j = 0; j < this.parallel; j++) { group.push(shuffled[i * this.parallel + j]); } configuration.push(group); } return configuration; } /** * Generate a neighbor solution by making a small change. */ getNeighbor(config) { const newConfig = cloneConfiguration(config); const usedLabels = new Set(config.flat().map(c => c.label)); const unusedCells = this.cells.filter(c => !usedLabels.has(c.label)); const moveType = Math.random(); if (unusedCells.length > 0 && moveType < 0.3) { // Replace a cell with an unused one const groupIdx = Math.floor(Math.random() * this.serial); const cellIdx = Math.floor(Math.random() * this.parallel); const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)]; newConfig[groupIdx][cellIdx] = unusedCell; } else if (moveType < 0.65) { // Swap cells between different groups const group1 = Math.floor(Math.random() * this.serial); let group2 = Math.floor(Math.random() * this.serial); while (group2 === group1 && this.serial > 1) { group2 = Math.floor(Math.random() * this.serial); } const cell1 = Math.floor(Math.random() * this.parallel); const cell2 = Math.floor(Math.random() * this.parallel); const temp = newConfig[group1][cell1]; newConfig[group1][cell1] = newConfig[group2][cell2]; newConfig[group2][cell2] = temp; } else { // Swap cells within the same group const groupIdx = Math.floor(Math.random() * this.serial); if (this.parallel >= 2) { const cell1 = Math.floor(Math.random() * this.parallel); let cell2 = Math.floor(Math.random() * this.parallel); while (cell2 === cell1) { cell2 = Math.floor(Math.random() * this.parallel); } const temp = newConfig[groupIdx][cell1]; newConfig[groupIdx][cell1] = newConfig[groupIdx][cell2]; newConfig[groupIdx][cell2] = temp; } } return newConfig; } /** * Run simulated annealing. */ async run() { const startTime = Date.now(); let current = this.createInitialConfig(); let currentScore = calculateScore(current, this.capacityWeight, this.irWeight); this.bestSolution = { config: cloneConfiguration(current), ...currentScore }; this.bestScore = currentScore.score; let temperature = this.initialTemp; for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) { const neighbor = this.getNeighbor(current); const neighborScore = calculateScore(neighbor, this.capacityWeight, this.irWeight); const delta = neighborScore.score - currentScore.score; // Accept if better, or with probability based on temperature if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) { current = neighbor; currentScore = neighborScore; if (currentScore.score < this.bestScore) { this.bestScore = currentScore.score; this.bestSolution = { config: cloneConfiguration(current), ...currentScore }; } } // Cool down temperature *= this.coolingRate; // Progress callback if (iteration % 100 === 0 || iteration === this.maxIterations - 1) { this.onProgress({ iteration, maxIterations: this.maxIterations, bestScore: this.bestScore, currentBest: this.bestSolution, temperature, elapsedTime: Date.now() - startTime }); await new Promise(resolve => setTimeout(resolve, 0)); } } const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label)); const excludedCells = this.cells.filter(c => !usedLabels.has(c.label)); return { configuration: this.bestSolution.config, score: this.bestScore, capacityCV: this.bestSolution.capacityCV, irCV: this.bestSolution.irCV, groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: this.maxIterations, elapsedTime: Date.now() - startTime }; } } // ============================================================================= // Exhaustive Search (for small configurations) // ============================================================================= /** * Exhaustive search - finds the globally optimal solution. * Only practical for small configurations due to factorial complexity. */ class ExhaustiveSearch { constructor(cells, serial, parallel, options = {}) { this.cells = cells; this.serial = serial; this.parallel = parallel; this.totalCellsNeeded = serial * parallel; this.capacityWeight = options.capacityWeight ?? 0.7; this.irWeight = options.irWeight ?? 0.3; this.onProgress = options.onProgress || (() => { }); this.maxIterations = options.maxIterations || 100000; this.stopped = false; this.bestSolution = null; this.bestScore = Infinity; } stop() { this.stopped = true; } /** * Generate all combinations of k elements from array. */ *combinations(array, k) { if (k === 0) { yield []; return; } if (array.length < k) return; const [first, ...rest] = array; for (const combo of this.combinations(rest, k - 1)) { yield [first, ...combo]; } yield* this.combinations(rest, k); } /** * Generate all partitions of cells into groups. */ *generatePartitions(cells, groupSize, numGroups) { if (numGroups === 0) { yield []; return; } if (cells.length < groupSize * numGroups) return; for (const group of this.combinations(cells, groupSize)) { const remaining = cells.filter(c => !group.includes(c)); for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) { yield [group, ...rest]; } } } async run() { const startTime = Date.now(); let iteration = 0; // Select best subset if we have more cells than needed const cellCombos = this.cells.length > this.totalCellsNeeded ? this.combinations(this.cells, this.totalCellsNeeded) : [[...this.cells]]; for (const cellSubset of cellCombos) { if (this.stopped) break; for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) { if (this.stopped) break; const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight); if (scoreResult.score < this.bestScore) { this.bestScore = scoreResult.score; this.bestSolution = { config: partition, ...scoreResult }; } iteration++; if (iteration % 1000 === 0) { this.onProgress({ iteration, maxIterations: this.maxIterations, bestScore: this.bestScore, currentBest: this.bestSolution, elapsedTime: Date.now() - startTime }); await new Promise(resolve => setTimeout(resolve, 0)); } if (iteration >= this.maxIterations) { this.stopped = true; break; } } } const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label)); const excludedCells = this.cells.filter(c => !usedLabels.has(c.label)); return { configuration: this.bestSolution.config, score: this.bestScore, capacityCV: this.bestSolution.capacityCV, irCV: this.bestSolution.irCV, groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: iteration, elapsedTime: Date.now() - startTime }; } } // ============================================================================= // Export // ============================================================================= // Make available globally for the main app window.CellMatchingAlgorithms = { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch, calculateScore, coefficientOfVariation };