/** * 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]); } /** * Shuffle cells based on random */ function shuffleCells(cells) { const shuffled = [...cells]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } // ============================================================================= // 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.lastProgressTime = this.startTime; this.lastProgressIteration = 0; this.speedHistory = []; this.windowSize = 5; // Rolling window for speed averaging this.lastEta = null; this.lastEtaUpdate = 0; } getStats(currentIteration, maxIterations) { const now = Date.now(); const elapsedTime = now - this.startTime; // Calculate speed based on iterations since last progress update const iterationsDelta = currentIteration - this.lastProgressIteration; const timeDelta = now - this.lastProgressTime; if (timeDelta > 0 && iterationsDelta > 0) { const currentSpeed = (iterationsDelta / timeDelta) * 1000; // iterations per second this.speedHistory.push(currentSpeed); if (this.speedHistory.length > this.windowSize) { this.speedHistory.shift(); } } this.lastProgressTime = now; this.lastProgressIteration = currentIteration; // Average speed from history const avgSpeed = this.speedHistory.length > 0 ? this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length : 0; const remainingIterations = maxIterations - currentIteration; const eta = avgSpeed > 0 ? (remainingIterations / avgSpeed) * 1000 : 0; // Only update ETA every 500ms to avoid flickering if (now - this.lastEtaUpdate > 500 || this.lastEta === null) { this.lastEta = eta; this.lastEtaUpdate = now; } return { elapsedTime, eta: this.lastEta, iterationsPerSecond: avgSpeed }; } } // ============================================================================= // Exhaustive Search // ============================================================================= class ExhaustiveSearch { constructor(cells, serial, parallel, options = {}) { this.cells = shuffleCells(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() { console.log("ExhaustiveSearch: stop requested") 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) { return this.returnBestResult(iteration, totalCombinations); } for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) { if (this.stopped) { return this.returnBestResult(iteration, totalCombinations); } const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight); if (scoreResult.score < this.bestScore) { this.bestScore = scoreResult.score; this.bestSolution = { config: partition, ...scoreResult }; } iteration++; // Check for stop every 100 iterations, but only send progress updates every 200ms if (iteration % 100 === 0) { const now = Date.now(); const timeSinceLastProgress = now - this.stats.lastProgressTime; if (timeSinceLastProgress >= 200) { 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) { return this.returnBestResult(iteration, totalCombinations); } } } return this.returnBestResult(iteration, totalCombinations); } returnBestResult(iteration, totalCombinations) { if (!this.bestSolution) { // No solution found yet, create one from first cells const config = []; for (let i = 0; i < this.serial; i++) { config.push(this.cells.slice(i * this.parallel, (i + 1) * this.parallel)); } const scoreResult = calculateScore(config, this.capacityWeight, this.irWeight); this.bestSolution = { config, ...scoreResult }; this.bestScore = scoreResult.score; } const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label)); const excludedCells = this.cells.filter(c => !usedLabels.has(c.label)); // 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 } }); 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 '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': console.log("Algo: Stop requested") if (currentAlgorithm) { currentAlgorithm.stop(); } break; } };