/** * LiXX Cell Pack Matcher - Web Worker * * Runs matching algorithms in a background thread to keep the UI responsive. * Communicates with the main thread via postMessage. */ // ============================================================================= // Utility Functions // ============================================================================= /** * Calculate the coefficient of variation (CV) as a percentage. */ 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. */ 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. */ function cloneConfiguration(arr) { return arr.map(group => [...group]); } // ============================================================================= // Scoring Functions // ============================================================================= /** * Calculate the match score for a pack configuration. * Lower score = better match. */ function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) { const groupCapacities = configuration.map(group => group.reduce((sum, cell) => sum + cell.capacity, 0) ); 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); 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); }); const capacityCV = coefficientOfVariation(groupCapacities); const avgWithinGroupIRCV = withinGroupIRVariances.length > 0 ? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length : 0; const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV); return { score, capacityCV, irCV: avgWithinGroupIRCV, groupCapacities, groupIRs, withinGroupIRVariances }; } // ============================================================================= // Statistics Tracker // ============================================================================= class StatsTracker { constructor() { this.startTime = Date.now(); this.iterationTimes = []; this.lastIterationTime = this.startTime; this.windowSize = 100; // Rolling window for time estimates } recordIteration() { const now = Date.now(); const iterTime = now - this.lastIterationTime; this.lastIterationTime = now; this.iterationTimes.push(iterTime); if (this.iterationTimes.length > this.windowSize) { this.iterationTimes.shift(); } } getStats(currentIteration, maxIterations) { const elapsedTime = Date.now() - this.startTime; const avgIterTime = this.iterationTimes.length > 0 ? this.iterationTimes.reduce((a, b) => a + b, 0) / this.iterationTimes.length : 0; const remainingIterations = maxIterations - currentIteration; const eta = avgIterTime * remainingIterations; return { elapsedTime, avgIterTime, eta, iterationsPerSecond: avgIterTime > 0 ? 1000 / avgIterTime : 0 }; } } // ============================================================================= // Genetic Algorithm // ============================================================================= class GeneticAlgorithm { constructor(cells, serial, parallel, options = {}) { this.cells = cells; this.serial = serial; this.parallel = parallel; this.totalCellsNeeded = serial * parallel; 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.stopped = false; this.bestSolution = null; this.bestScore = Infinity; this.stats = new StatsTracker(); } stop() { this.stopped = true; } 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; } configToIndices(config) { const flat = config.flat(); return flat.map(cell => this.cells.findIndex(c => c.label === cell.label)); } 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]; // Safety check: ensure index is valid if (idx >= 0 && idx < this.cells.length) { group.push(this.cells[idx]); } else { // Fallback: use a random valid cell group.push(this.cells[Math.floor(Math.random() * this.cells.length)]); } } configuration.push(group); } return configuration; } crossover(parent1, parent2) { // Simple two-point crossover with repair const length = parent1.length; // 50% chance to just return a copy of one parent (with shuffle) if (Math.random() < 0.5) { const child = [...parent1]; // Swap a few random positions for (let i = 0; i < 2; i++) { const a = Math.floor(Math.random() * length); const b = Math.floor(Math.random() * length); [child[a], child[b]] = [child[b], child[a]]; } return child; } // Otherwise, take half from each parent and repair duplicates const midpoint = Math.floor(length / 2); const child = [...parent1.slice(0, midpoint), ...parent2.slice(midpoint)]; // Find and fix duplicates const seen = new Set(); const duplicatePositions = []; const allIndices = new Set(parent1.concat(parent2)); for (let i = 0; i < child.length; i++) { if (seen.has(child[i])) { duplicatePositions.push(i); } else { seen.add(child[i]); } } // Find missing indices const missing = []; for (const idx of allIndices) { if (!seen.has(idx)) { missing.push(idx); } } // Replace duplicates with missing values for (let i = 0; i < duplicatePositions.length && i < missing.length; i++) { child[duplicatePositions[i]] = missing[i]; } return child; } mutate(indices, unusedCells) { const mutated = [...indices]; if (Math.random() < this.mutationRate) { if (unusedCells.length > 0 && Math.random() < 0.3) { 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 { 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() { // 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) })); evaluated.sort((a, b) => a.score - b.score); if (evaluated[0].score < this.bestScore) { this.bestScore = evaluated[0].score; this.bestSolution = evaluated[0]; } // Calculate total combinations for display const totalCombinations = this.factorial(this.cells.length) / (this.factorial(this.cells.length - this.totalCellsNeeded) * Math.pow(this.factorial(this.parallel), this.serial) * this.factorial(this.serial)); // Main evolution loop for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) { 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) { 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)]; let child = this.crossover(parent1.indices, parent2.indices); // Safety: ensure all indices are valid child = child.map(idx => { if (idx >= 0 && idx < this.cells.length) return idx; return Math.floor(Math.random() * this.cells.length); }); const usedLabels = new Set(child.map(idx => this.cells[idx].label)); const unusedCells = this.cells.filter(c => !usedLabels.has(c.label)); 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) }; }); evaluated.sort((a, b) => a.score - b.score); if (evaluated[0].score < this.bestScore) { this.bestScore = evaluated[0].score; this.bestSolution = evaluated[0]; } this.stats.recordIteration(); // Send progress update every 10 iterations if (iteration % 10 === 0 || iteration === this.maxIterations - 1) { const stats = this.stats.getStats(iteration, this.maxIterations); self.postMessage({ type: 'progress', data: { iteration, maxIterations: this.maxIterations, bestScore: this.bestScore, currentBest: this.bestSolution, totalCombinations, evaluatedCombinations: (iteration + 1) * this.populationSize, ...stats } }); } } 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() - this.stats.startTime }; } factorial(n) { if (n <= 1) return 1; if (n > 20) return Infinity; // Prevent overflow let result = 1; for (let i = 2; i <= n; i++) result *= i; return result; } } // ============================================================================= // Simulated Annealing // ============================================================================= class SimulatedAnnealing { 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.stopped = false; this.bestSolution = null; this.bestScore = Infinity; this.stats = new StatsTracker(); } stop() { this.stopped = true; } 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; } 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) { 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) { 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 { 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() { 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; let acceptedMoves = 0; let totalMoves = 0; 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; totalMoves++; if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) { current = neighbor; currentScore = neighborScore; acceptedMoves++; if (currentScore.score < this.bestScore) { this.bestScore = currentScore.score; this.bestSolution = { config: cloneConfiguration(current), ...currentScore }; } } temperature *= this.coolingRate; this.stats.recordIteration(); if (iteration % 50 === 0 || iteration === this.maxIterations - 1) { const stats = this.stats.getStats(iteration, this.maxIterations); self.postMessage({ type: 'progress', data: { iteration, maxIterations: this.maxIterations, bestScore: this.bestScore, currentBest: this.bestSolution, temperature, acceptanceRate: totalMoves > 0 ? (acceptedMoves / totalMoves * 100) : 0, evaluatedCombinations: iteration + 1, ...stats } }); } } 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() - this.stats.startTime }; } } // ============================================================================= // Exhaustive Search // ============================================================================= 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.maxIterations = options.maxIterations || 100000; this.stopped = false; this.bestSolution = null; this.bestScore = Infinity; this.stats = new StatsTracker(); } stop() { this.stopped = true; } *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); } *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]; } } } calculateTotalCombinations() { // Formula: C(n, k) * C(n-k, k) * ... / numGroups! for identical groups const n = this.cells.length; const k = this.parallel; const numGroups = this.serial; let total = 1; let remaining = n; for (let i = 0; i < numGroups; i++) { total *= this.binomial(remaining, k); remaining -= k; } // Divide by numGroups! if groups are interchangeable // (but for battery packs, position matters, so we don't divide) return total; } binomial(n, k) { if (k > n) return 0; if (k === 0 || k === n) return 1; let result = 1; for (let i = 0; i < k; i++) { result = result * (n - i) / (i + 1); } return Math.round(result); } run() { let iteration = 0; const totalCombinations = this.calculateTotalCombinations(); 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++; this.stats.recordIteration(); if (iteration % 500 === 0) { const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations)); self.postMessage({ type: 'progress', data: { iteration, maxIterations: Math.min(totalCombinations, this.maxIterations), bestScore: this.bestScore, currentBest: this.bestSolution, totalCombinations, evaluatedCombinations: iteration, ...stats } }); } if (iteration >= this.maxIterations) { this.stopped = true; break; } } } // Final progress update const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations)); self.postMessage({ type: 'progress', data: { iteration, maxIterations: Math.min(totalCombinations, this.maxIterations), bestScore: this.bestScore, currentBest: this.bestSolution, totalCombinations, evaluatedCombinations: iteration, ...stats } }); 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() - this.stats.startTime }; } } // ============================================================================= // Worker Message Handler // ============================================================================= let currentAlgorithm = null; self.onmessage = function (e) { const { type, data } = e.data; switch (type) { case 'start': const { cells, serial, parallel, algorithm, options } = data; switch (algorithm) { case 'genetic': currentAlgorithm = new GeneticAlgorithm(cells, serial, parallel, options); break; case 'simulated-annealing': currentAlgorithm = new SimulatedAnnealing(cells, serial, parallel, options); break; case 'exhaustive': currentAlgorithm = new ExhaustiveSearch(cells, serial, parallel, options); break; } try { const result = currentAlgorithm.run(); self.postMessage({ type: 'complete', data: result }); } catch (error) { self.postMessage({ type: 'error', data: error.message }); } currentAlgorithm = null; break; case 'stop': if (currentAlgorithm) { currentAlgorithm.stop(); } break; } };