diff --git a/css/styles.css b/css/styles.css index 23a376d..144330c 100644 --- a/css/styles.css +++ b/css/styles.css @@ -444,17 +444,89 @@ output { transition: width var(--transition-normal); } -.progress-stats { - display: flex; - flex-wrap: wrap; - gap: var(--space-lg); - font-size: 0.875rem; - color: var(--text-secondary); +.progress-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-lg); } -.progress-stats strong { - color: var(--text-primary); +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + text-align: center; +} + +.stat-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: var(--space-xs); +} + +.stat-value { + font-size: 1.1rem; + font-weight: 600; font-family: var(--font-mono); + color: var(--text-primary); +} + +.stat-highlight { + color: var(--accent); +} + +/* Live Preview */ +.live-preview { + margin-top: var(--space-lg); + padding: var(--space-md); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + border: 1px dashed var(--border-color); +} + +.live-preview h3 { + margin: 0 0 var(--space-sm); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.live-preview-content { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.live-preview-group { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.live-preview-group .group-label { + color: var(--text-muted); + font-weight: 600; +} + +.live-preview-group .group-cells { + color: var(--text-primary); +} + +.live-preview-group .group-cap { + color: var(--accent); + margin-left: var(--space-xs); } /* -------------------------------------------------------------------------- diff --git a/index.html b/index.html index 7be6a21..0d0f601 100644 --- a/index.html +++ b/index.html @@ -136,14 +136,41 @@
-
- Iteration: 0 - Best Score: - - Time: 0s +
+
+ Iteration + 0 +
+
+ Combinations + - +
+
+ Best Score + - +
+
+ Elapsed + 0s +
+
+ Speed + - +
+
+ ETA + - +
+
+ + +
@@ -163,7 +190,7 @@ rel="noopener">(Shi et al., 2013)
  • Always use a BMS with cell-level monitoring and balancing
  • -
  • Use only same model of cell in a pack.
  • +
  • Use only same model of cells in a pack.
  • Never charge unattended; use fireproof storage
  • Cells with significantly different ages may degrade unpredictably
  • @@ -250,8 +277,7 @@ Git · Based on research by - Shi et al., - 2013 + Shi et al., 2013

    This tool is for educational purposes. Always consult professional guidance for battery pack assembly. @@ -277,7 +303,6 @@ - diff --git a/js/app.js b/js/app.js index e94af86..d31a41e 100644 --- a/js/app.js +++ b/js/app.js @@ -1,8 +1,6 @@ /** * LiXX Cell Pack Matcher - Main Application - * - * A web application for optimal matching of lithium battery cells. - * Supports capacity and internal resistance matching with multiple algorithms. + * Uses Web Workers for non-blocking computation. */ // ============================================================================= @@ -12,7 +10,7 @@ const AppState = { cells: [], cellIdCounter: 0, - currentAlgorithm: null, + worker: null, isRunning: false, results: null }; @@ -22,13 +20,10 @@ const AppState = { // ============================================================================= const DOM = { - // Configuration cellsSerial: document.getElementById('cells-serial'), cellsParallel: document.getElementById('cells-parallel'), configDisplay: document.getElementById('config-display'), totalCellsNeeded: document.getElementById('total-cells-needed'), - - // Cell input cellTbody: document.getElementById('cell-tbody'), btnAddCell: document.getElementById('btn-add-cell'), btnLoadExample: document.getElementById('btn-load-example'), @@ -36,27 +31,24 @@ const DOM = { statCount: document.getElementById('stat-count'), statAvgCap: document.getElementById('stat-avg-cap'), statAvgIr: document.getElementById('stat-avg-ir'), - - // Settings weightCapacity: document.getElementById('weight-capacity'), weightIr: document.getElementById('weight-ir'), weightCapValue: document.getElementById('weight-cap-value'), weightIrValue: document.getElementById('weight-ir-value'), algorithmSelect: document.getElementById('algorithm-select'), maxIterations: document.getElementById('max-iterations'), - - // Matching btnStartMatching: document.getElementById('btn-start-matching'), btnStopMatching: document.getElementById('btn-stop-matching'), - - // Progress progressSection: document.getElementById('progress-section'), progressFill: document.getElementById('progress-fill'), progressIteration: document.getElementById('progress-iteration'), + progressCombinations: document.getElementById('progress-combinations'), progressScore: document.getElementById('progress-score'), progressTime: document.getElementById('progress-time'), - - // Results + progressSpeed: document.getElementById('progress-speed'), + progressEta: document.getElementById('progress-eta'), + livePreview: document.getElementById('live-preview'), + livePreviewContent: document.getElementById('live-preview-content'), resultsSection: document.getElementById('results-section'), resultScore: document.getElementById('result-score'), resultCapVariance: document.getElementById('result-cap-variance'), @@ -66,32 +58,48 @@ const DOM = { resultsTbody: document.getElementById('results-tbody'), excludedCellsSection: document.getElementById('excluded-cells-section'), excludedCellsList: document.getElementById('excluded-cells-list'), - - // Export btnExportJson: document.getElementById('btn-export-json'), btnExportCsv: document.getElementById('btn-export-csv'), btnCopyResults: document.getElementById('btn-copy-results'), - - // Dialog shortcutsDialog: document.getElementById('shortcuts-dialog'), btnCloseShortcuts: document.getElementById('btn-close-shortcuts') }; +// ============================================================================= +// Utility Functions +// ============================================================================= + +function formatDuration(ms) { + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600000) { + const mins = Math.floor(ms / 60000); + const secs = Math.round((ms % 60000) / 1000); + return `${mins}m ${secs}s`; + } + const hours = Math.floor(ms / 3600000); + const mins = Math.round((ms % 3600000) / 60000); + return `${hours}h ${mins}m`; +} + +function formatNumber(num) { + if (num === Infinity) return '∞'; + if (num >= 1e9) return `${(num / 1e9).toFixed(1)}B`; + if (num >= 1e6) return `${(num / 1e6).toFixed(1)}M`; + if (num >= 1e3) return `${(num / 1e3).toFixed(1)}K`; + return num.toLocaleString(); +} + // ============================================================================= // Configuration Management // ============================================================================= -/** - * Update the configuration display. - */ function updateConfigDisplay() { const serial = parseInt(DOM.cellsSerial.value) || 1; const parallel = parseInt(DOM.cellsParallel.value) || 1; const total = serial * parallel; - DOM.configDisplay.textContent = `${serial}S${parallel}P`; DOM.totalCellsNeeded.textContent = total; - updateMatchingButtonState(); } @@ -99,10 +107,6 @@ function updateConfigDisplay() { // Cell Management // ============================================================================= -/** - * Add a new cell row to the table. - * @param {Object} cellData - Optional initial data - */ function addCell(cellData = null) { const id = AppState.cellIdCounter++; const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`; @@ -116,76 +120,48 @@ function addCell(cellData = null) { row.dataset.cellId = id; row.innerHTML = ` ${AppState.cells.length} - - - - - - - - - - - - + + + + `; DOM.cellTbody.appendChild(row); - // Add event listeners row.querySelectorAll('input').forEach(input => { input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value)); input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value)); }); row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id)); - updateCellStats(); updateMatchingButtonState(); - // Focus the capacity input of the new row if (!cellData) { row.querySelector('input[data-field="capacity"]').focus(); } } -/** - * Update cell data when input changes. - */ function updateCellData(id, field, value) { const cell = AppState.cells.find(c => c.id === id); if (!cell) return; - if (field === 'label') { - cell.label = value || `C${id}`; - } else if (field === 'capacity') { - cell.capacity = value ? parseFloat(value) : null; - } else if (field === 'ir') { - cell.ir = value ? parseFloat(value) : null; - } + if (field === 'label') cell.label = value || `C${id}`; + else if (field === 'capacity') cell.capacity = value ? parseFloat(value) : null; + else if (field === 'ir') cell.ir = value ? parseFloat(value) : null; updateCellStats(); updateMatchingButtonState(); } -/** - * Remove a cell from the table. - */ function removeCell(id) { const index = AppState.cells.findIndex(c => c.id === id); if (index === -1) return; AppState.cells.splice(index, 1); - const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`); if (row) row.remove(); - // Update row numbers DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => { row.querySelector('td:first-child').textContent = idx + 1; }); @@ -194,23 +170,15 @@ function removeCell(id) { updateMatchingButtonState(); } -/** - * Clear all cells. - */ function clearAllCells() { if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return; - AppState.cells = []; AppState.cellIdCounter = 0; DOM.cellTbody.innerHTML = ''; - updateCellStats(); updateMatchingButtonState(); } -/** - * Update cell statistics display. - */ function updateCellStats() { const count = AppState.cells.length; DOM.statCount.textContent = count; @@ -224,50 +192,28 @@ function updateCellStats() { const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity); const irs = AppState.cells.filter(c => c.ir).map(c => c.ir); - if (capacities.length > 0) { - const avgCap = capacities.reduce((a, b) => a + b, 0) / capacities.length; - DOM.statAvgCap.textContent = `${Math.round(avgCap)} mAh`; - } else { - DOM.statAvgCap.textContent = '-'; - } - - if (irs.length > 0) { - const avgIr = irs.reduce((a, b) => a + b, 0) / irs.length; - DOM.statAvgIr.textContent = `${avgIr.toFixed(1)} mΩ`; - } else { - DOM.statAvgIr.textContent = '-'; - } + DOM.statAvgCap.textContent = capacities.length > 0 + ? `${Math.round(capacities.reduce((a, b) => a + b, 0) / capacities.length)} mAh` : '-'; + DOM.statAvgIr.textContent = irs.length > 0 + ? `${(irs.reduce((a, b) => a + b, 0) / irs.length).toFixed(1)} mΩ` : '-'; } -/** - * Load example cell data. - */ function loadExampleData() { if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return; - // Clear without confirmation since we just asked AppState.cells = []; AppState.cellIdCounter = 0; DOM.cellTbody.innerHTML = ''; - // Example: 14 cells for a 6S2P pack (2 spare) const exampleCells = [ - { label: 'B01', capacity: 3330, ir: 42 }, - { label: 'B02', capacity: 3360, ir: 38 }, - { label: 'B03', capacity: 3230, ir: 45 }, - { label: 'B04', capacity: 3390, ir: 41 }, - { label: 'B05', capacity: 3280, ir: 44 }, - { label: 'B06', capacity: 3350, ir: 39 }, - { label: 'B07', capacity: 3350, ir: 40 }, - { label: 'B08', capacity: 3490, ir: 36 }, - { label: 'B09', capacity: 3280, ir: 43 }, - { label: 'B10', capacity: 3420, ir: 37 }, - { label: 'B11', capacity: 3350, ir: 41 }, - { label: 'B12', capacity: 3420, ir: 38 }, - { label: 'B13', capacity: 3150, ir: 52 }, // Spare - lower quality - { label: 'B14', capacity: 3380, ir: 40 } // Spare + { label: 'B01', capacity: 3330, ir: 42 }, { label: 'B02', capacity: 3360, ir: 38 }, + { label: 'B03', capacity: 3230, ir: 45 }, { label: 'B04', capacity: 3390, ir: 41 }, + { label: 'B05', capacity: 3280, ir: 44 }, { label: 'B06', capacity: 3350, ir: 39 }, + { label: 'B07', capacity: 3350, ir: 40 }, { label: 'B08', capacity: 3490, ir: 36 }, + { label: 'B09', capacity: 3280, ir: 43 }, { label: 'B10', capacity: 3420, ir: 37 }, + { label: 'B11', capacity: 3350, ir: 41 }, { label: 'B12', capacity: 3420, ir: 38 }, + { label: 'B13', capacity: 3150, ir: 52 }, { label: 'B14', capacity: 3380, ir: 40 } ]; - exampleCells.forEach(cell => addCell(cell)); } @@ -275,51 +221,32 @@ function loadExampleData() { // Weight Sliders // ============================================================================= -/** - * Update weight slider displays and keep them summing to 100%. - */ function updateWeights(source) { const capWeight = parseInt(DOM.weightCapacity.value); - const irWeight = parseInt(DOM.weightIr.value); - - if (source === 'capacity') { - DOM.weightIr.value = 100 - capWeight; - } else { - DOM.weightCapacity.value = 100 - irWeight; - } + if (source === 'capacity') DOM.weightIr.value = 100 - capWeight; + else DOM.weightCapacity.value = 100 - parseInt(DOM.weightIr.value); DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`; DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`; } // ============================================================================= -// Matching Control +// Matching Control (Web Worker) // ============================================================================= -/** - * Update the state of the matching button. - */ function updateMatchingButtonState() { const serial = parseInt(DOM.cellsSerial.value) || 1; const parallel = parseInt(DOM.cellsParallel.value) || 1; const needed = serial * parallel; - const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0); const canStart = validCells.length >= needed && !AppState.isRunning; DOM.btnStartMatching.disabled = !canStart; - - if (validCells.length < needed) { - DOM.btnStartMatching.title = `Need at least ${needed} cells with capacity data`; - } else { - DOM.btnStartMatching.title = ''; - } + DOM.btnStartMatching.title = validCells.length < needed + ? `Need at least ${needed} cells with capacity data` : ''; } -/** - * Start the matching process. - */ -async function startMatching() { +function startMatching() { if (AppState.isRunning) return; const serial = parseInt(DOM.cellsSerial.value) || 1; @@ -335,96 +262,120 @@ async function startMatching() { DOM.progressSection.hidden = false; DOM.resultsSection.hidden = true; DOM.btnStartMatching.disabled = true; + DOM.progressFill.style.width = '0%'; + DOM.progressIteration.textContent = '0'; + DOM.progressCombinations.textContent = '-'; + DOM.progressScore.textContent = '-'; + DOM.progressTime.textContent = '0s'; + DOM.progressSpeed.textContent = '-'; + DOM.progressEta.textContent = '-'; + DOM.livePreview.hidden = true; const algorithmType = DOM.algorithmSelect.value; const maxIterations = parseInt(DOM.maxIterations.value) || 5000; const capacityWeight = parseInt(DOM.weightCapacity.value) / 100; const irWeight = parseInt(DOM.weightIr.value) / 100; - const options = { - maxIterations, - capacityWeight, - irWeight, - onProgress: updateProgress + AppState.worker = new Worker('js/matching-worker.js'); + + AppState.worker.onmessage = function (e) { + const { type, data } = e.data; + if (type === 'progress') updateProgress(data); + else if (type === 'complete') handleComplete(data); + else if (type === 'error') handleError(data); }; - // Create algorithm instance - const { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch } = window.CellMatchingAlgorithms; + AppState.worker.onerror = function (error) { + console.error('Worker error:', error); + handleError(error.message); + }; - switch (algorithmType) { - case 'genetic': - AppState.currentAlgorithm = new GeneticAlgorithm(validCells, serial, parallel, options); - break; - case 'simulated-annealing': - AppState.currentAlgorithm = new SimulatedAnnealing(validCells, serial, parallel, options); - break; - case 'exhaustive': - AppState.currentAlgorithm = new ExhaustiveSearch(validCells, serial, parallel, options); - break; - } - - try { - const results = await AppState.currentAlgorithm.run(); - AppState.results = results; - displayResults(results); - } catch (error) { - console.error('Matching error:', error); - alert('An error occurred during matching. See console for details.'); - } finally { - AppState.isRunning = false; - AppState.currentAlgorithm = null; - DOM.progressSection.hidden = true; - DOM.btnStartMatching.disabled = false; - updateMatchingButtonState(); - } + AppState.worker.postMessage({ + type: 'start', + data: { + cells: validCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })), + serial, parallel, + algorithm: algorithmType, + options: { maxIterations, capacityWeight, irWeight } + } + }); } -/** - * Stop the matching process. - */ function stopMatching() { - if (AppState.currentAlgorithm) { - AppState.currentAlgorithm.stop(); - } + if (AppState.worker) AppState.worker.postMessage({ type: 'stop' }); } -/** - * Update progress display. - */ function updateProgress(progress) { const percent = (progress.iteration / progress.maxIterations) * 100; DOM.progressFill.style.width = `${percent}%`; - DOM.progressIteration.textContent = progress.iteration.toLocaleString(); + DOM.progressIteration.textContent = formatNumber(progress.iteration); + + DOM.progressCombinations.textContent = progress.totalCombinations + ? `${formatNumber(progress.evaluatedCombinations)} / ${formatNumber(progress.totalCombinations)}` + : formatNumber(progress.evaluatedCombinations || progress.iteration); + DOM.progressScore.textContent = progress.bestScore.toFixed(4); - DOM.progressTime.textContent = `${(progress.elapsedTime / 1000).toFixed(1)}s`; + DOM.progressTime.textContent = formatDuration(progress.elapsedTime); + + if (progress.iterationsPerSecond > 0) { + DOM.progressSpeed.textContent = `${formatNumber(Math.round(progress.iterationsPerSecond))}/s`; + } + + if (progress.eta > 0 && progress.eta < Infinity) { + DOM.progressEta.textContent = formatDuration(progress.eta); + } else if (progress.iteration >= progress.maxIterations * 0.9) { + DOM.progressEta.textContent = 'Almost done...'; + } + + if (progress.currentBest && progress.currentBest.config) { + renderLivePreview(progress.currentBest.config); + } +} + +function renderLivePreview(config) { + DOM.livePreview.hidden = false; + DOM.livePreviewContent.innerHTML = config.map((group, idx) => { + const cells = group.map(c => c.label).join('+'); + const totalCap = group.reduce((sum, c) => sum + c.capacity, 0); + return `

    S${idx + 1}:${cells}${totalCap}
    `; + }).join(''); +} + +function handleComplete(results) { + AppState.isRunning = false; + AppState.results = results; + if (AppState.worker) { AppState.worker.terminate(); AppState.worker = null; } + DOM.progressSection.hidden = true; + DOM.btnStartMatching.disabled = false; + updateMatchingButtonState(); + displayResults(results); +} + +function handleError(errorMessage) { + AppState.isRunning = false; + if (AppState.worker) { AppState.worker.terminate(); AppState.worker = null; } + DOM.progressSection.hidden = true; + DOM.btnStartMatching.disabled = false; + updateMatchingButtonState(); + alert('An error occurred during matching: ' + errorMessage); } // ============================================================================= // Results Display // ============================================================================= -/** - * Display the matching results. - */ function displayResults(results) { DOM.resultsSection.hidden = false; - - // Summary metrics DOM.resultScore.textContent = results.score.toFixed(3); DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`; DOM.resultIrVariance.textContent = results.irCV ? `${results.irCV.toFixed(2)}%` : 'N/A'; - // Calculate pack capacity (limited by smallest parallel group) const packCapacity = Math.min(...results.groupCapacities); DOM.resultPackCapacity.textContent = `${packCapacity} mAh`; - // Visualize pack layout renderPackVisualization(results); - - // Results table renderResultsTable(results); - // Excluded cells if (results.excludedCells.length > 0) { DOM.excludedCellsSection.hidden = false; DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', '); @@ -432,13 +383,9 @@ function displayResults(results) { DOM.excludedCellsSection.hidden = true; } - // Scroll to results DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } -/** - * Render the pack visualization. - */ function renderPackVisualization(results) { const config = results.configuration; const allCapacities = config.flat().map(c => c.capacity); @@ -463,18 +410,10 @@ function renderPackVisualization(results) { group.forEach(cell => { const cellEl = document.createElement('div'); cellEl.className = 'pack-cell'; - - // Color based on relative capacity const normalized = (cell.capacity - minCap) / range; - const hue = normalized * 120; // 0 = red, 60 = yellow, 120 = green + const hue = normalized * 120; cellEl.style.backgroundColor = `hsl(${hue}, 70%, 45%)`; - - cellEl.innerHTML = ` - ${cell.label} - ${cell.capacity} mAh - ${cell.ir ? `${cell.ir} mΩ` : ''} - `; - + cellEl.innerHTML = `${cell.label}${cell.capacity} mAh${cell.ir ? `${cell.ir} mΩ` : ''}`; cellsContainer.appendChild(cellEl); }); @@ -483,36 +422,23 @@ function renderPackVisualization(results) { }); } -/** - * Render the results table. - */ function renderResultsTable(results) { const config = results.configuration; const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length; - DOM.resultsTbody.innerHTML = ''; config.forEach((group, idx) => { const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0); const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100); const irsWithValues = group.filter(c => c.ir); - const avgIr = irsWithValues.length > 0 - ? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length - : null; + const avgIr = irsWithValues.length > 0 ? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length : null; let deviationClass = 'deviation-good'; if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning'; if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad'; const row = document.createElement('tr'); - row.innerHTML = ` - S${idx + 1} - ${group.map(c => c.label).join(' + ')} - ${groupCapacity} mAh - ${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'} - ${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}% - `; - + row.innerHTML = `S${idx + 1}${group.map(c => c.label).join(' + ')}${groupCapacity} mAh${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%`; DOM.resultsTbody.appendChild(row); }); } @@ -521,12 +447,8 @@ function renderResultsTable(results) { // Export Functions // ============================================================================= -/** - * Export results as JSON. - */ function exportJson() { if (!AppState.results) return; - const data = { configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, timestamp: new Date().toISOString(), @@ -538,82 +460,46 @@ function exportJson() { cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })), totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0) })), - excludedCells: AppState.results.excludedCells.map(c => ({ - label: c.label, - capacity: c.capacity, - ir: c.ir - })) + excludedCells: AppState.results.excludedCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })) }; - downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json'); } -/** - * Export results as CSV. - */ function exportCsv() { if (!AppState.results) return; - const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total']; - AppState.results.configuration.forEach((group, idx) => { const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0); group.forEach((cell, cellIdx) => { lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`); }); }); - if (AppState.results.excludedCells.length > 0) { - lines.push(''); - lines.push('Excluded Cells'); - AppState.results.excludedCells.forEach(cell => { - lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`); - }); + lines.push('', 'Excluded Cells'); + AppState.results.excludedCells.forEach(cell => lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`)); } - downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv'); } -/** - * Copy results to clipboard. - */ async function copyResults() { if (!AppState.results) return; - const config = AppState.results.configuration; - const lines = [ - `Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, - `Score: ${AppState.results.score.toFixed(3)}`, - `Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`, - '', - 'Pack Configuration:' - ]; - + const lines = [`Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, `Score: ${AppState.results.score.toFixed(3)}`, `Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`, '', 'Pack Configuration:']; config.forEach((group, idx) => { const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + '); const total = group.reduce((sum, c) => sum + c.capacity, 0); lines.push(` S${idx + 1}: ${cells} = ${total}mAh`); }); - if (AppState.results.excludedCells.length > 0) { - lines.push(''); - lines.push(`Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`); + lines.push('', `Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`); } - try { await navigator.clipboard.writeText(lines.join('\n')); DOM.btnCopyResults.textContent = 'Copied!'; - setTimeout(() => { - DOM.btnCopyResults.textContent = 'Copy to Clipboard'; - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); - } + setTimeout(() => { DOM.btnCopyResults.textContent = 'Copy to Clipboard'; }, 2000); + } catch (err) { console.error('Failed to copy:', err); } } -/** - * Helper function to download a file. - */ function downloadFile(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -627,80 +513,32 @@ function downloadFile(content, filename, mimeType) { } // ============================================================================= -// Keyboard Navigation +// Keyboard Navigation & Event Listeners // ============================================================================= -/** - * Setup keyboard shortcuts. - */ function setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { - // Alt + A: Add cell - if (e.altKey && e.key === 'a') { - e.preventDefault(); - addCell(); - } - - // Alt + S: Start matching - if (e.altKey && e.key === 's') { - e.preventDefault(); - if (!DOM.btnStartMatching.disabled) { - startMatching(); - } - } - - // Alt + E: Load example - if (e.altKey && e.key === 'e') { - e.preventDefault(); - loadExampleData(); - } - - // Escape: Stop matching or close dialog - if (e.key === 'Escape') { - if (AppState.isRunning) { - stopMatching(); - } - if (DOM.shortcutsDialog.open) { - DOM.shortcutsDialog.close(); - } - } - - // ?: Show shortcuts - if (e.key === '?' && !e.target.matches('input, textarea')) { - e.preventDefault(); - DOM.shortcutsDialog.showModal(); - } + if (e.altKey && e.key === 'a') { e.preventDefault(); addCell(); } + if (e.altKey && e.key === 's') { e.preventDefault(); if (!DOM.btnStartMatching.disabled) startMatching(); } + if (e.altKey && e.key === 'e') { e.preventDefault(); loadExampleData(); } + if (e.key === 'Escape') { if (AppState.isRunning) stopMatching(); if (DOM.shortcutsDialog.open) DOM.shortcutsDialog.close(); } + if (e.key === '?' && !e.target.matches('input, textarea')) { e.preventDefault(); DOM.shortcutsDialog.showModal(); } }); } -// ============================================================================= -// Event Listeners -// ============================================================================= - function initEventListeners() { - // Configuration DOM.cellsSerial.addEventListener('input', updateConfigDisplay); DOM.cellsParallel.addEventListener('input', updateConfigDisplay); - - // Cell management DOM.btnAddCell.addEventListener('click', () => addCell()); DOM.btnLoadExample.addEventListener('click', loadExampleData); DOM.btnClearAll.addEventListener('click', clearAllCells); - - // Weight sliders DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity')); DOM.weightIr.addEventListener('input', () => updateWeights('ir')); - - // Matching DOM.btnStartMatching.addEventListener('click', startMatching); DOM.btnStopMatching.addEventListener('click', stopMatching); - - // Export DOM.btnExportJson.addEventListener('click', exportJson); DOM.btnExportCsv.addEventListener('click', exportCsv); DOM.btnCopyResults.addEventListener('click', copyResults); - - // Dialog DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close()); } @@ -714,16 +552,8 @@ function init() { updateConfigDisplay(); updateWeights('capacity'); updateMatchingButtonState(); - - // Add a few empty cell rows to start - for (let i = 0; i < 3; i++) { - addCell(); - } + for (let i = 0; i < 3; i++) addCell(); } -// Start the application when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); -} +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); +else init(); diff --git a/js/matching-algorithms.js b/js/matching-worker.js similarity index 71% rename from js/matching-algorithms.js rename to js/matching-worker.js index 7099c4a..bc38709 100644 --- a/js/matching-algorithms.js +++ b/js/matching-worker.js @@ -1,15 +1,8 @@ /** - * LiXX Cell Pack Matcher - Matching Algorithms + * LiXX Cell Pack Matcher - Web Worker * - * 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 + * Runs matching algorithms in a background thread to keep the UI responsive. + * Communicates with the main thread via postMessage. */ // ============================================================================= @@ -18,9 +11,6 @@ /** * 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; @@ -32,8 +22,6 @@ function coefficientOfVariation(values) { /** * 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--) { @@ -45,8 +33,6 @@ function shuffleArray(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]); @@ -59,30 +45,18 @@ function cloneConfiguration(arr) { /** * 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; @@ -90,15 +64,11 @@ function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) { 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 { @@ -111,53 +81,75 @@ function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) { }; } +// ============================================================================= +// 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 // ============================================================================= -/** - * 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; + this.stats = new StatsTracker(); } - /** - * 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 = []; @@ -173,21 +165,11 @@ class GeneticAlgorithm { 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++) { @@ -201,12 +183,6 @@ class GeneticAlgorithm { 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); @@ -215,13 +191,11 @@ class GeneticAlgorithm { 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; @@ -238,24 +212,16 @@ class GeneticAlgorithm { 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]]; @@ -265,13 +231,7 @@ class GeneticAlgorithm { return mutated; } - /** - * Run the genetic algorithm. - * @returns {Promise} Best solution found - */ - async run() { - const startTime = Date.now(); - + run() { // Initialize population let population = []; for (let i = 0; i < this.populationSize; i++) { @@ -285,7 +245,6 @@ class GeneticAlgorithm { ...calculateScore(config, this.capacityWeight, this.irWeight) })); - // Sort by score evaluated.sort((a, b) => a.score - b.score); if (evaluated[0].score < this.bestScore) { @@ -293,9 +252,14 @@ class GeneticAlgorithm { 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++) { - // Selection (tournament selection) const newPopulation = []; // Keep elite individuals @@ -305,22 +269,17 @@ class GeneticAlgorithm { // 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); } @@ -334,31 +293,34 @@ class GeneticAlgorithm { }; }); - // 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 - }); + this.stats.recordIteration(); - // Allow UI to update - await new Promise(resolve => setTimeout(resolve, 0)); + // 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 + } + }); } } - // 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)); @@ -370,26 +332,24 @@ class GeneticAlgorithm { groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: this.maxIterations, - elapsedTime: Date.now() - startTime + 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 // ============================================================================= -/** - * 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; @@ -401,20 +361,17 @@ class SimulatedAnnealing { 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; + this.stats = new StatsTracker(); } stop() { this.stopped = true; } - /** - * Create initial configuration. - */ createInitialConfig() { const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded); const configuration = []; @@ -430,9 +387,6 @@ class SimulatedAnnealing { 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)); @@ -441,13 +395,11 @@ class SimulatedAnnealing { 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) { @@ -460,7 +412,6 @@ class SimulatedAnnealing { 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); @@ -477,12 +428,7 @@ class SimulatedAnnealing { return newConfig; } - /** - * Run simulated annealing. - */ - async run() { - const startTime = Date.now(); - + run() { let current = this.createInitialConfig(); let currentScore = calculateScore(current, this.capacityWeight, this.irWeight); @@ -490,17 +436,20 @@ class SimulatedAnnealing { 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++; - // Accept if better, or with probability based on temperature if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) { current = neighbor; currentScore = neighborScore; + acceptedMoves++; if (currentScore.score < this.bestScore) { this.bestScore = currentScore.score; @@ -508,21 +457,25 @@ class SimulatedAnnealing { } } - // Cool down temperature *= this.coolingRate; + this.stats.recordIteration(); - // 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 + 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 + } }); - - await new Promise(resolve => setTimeout(resolve, 0)); } } @@ -537,19 +490,15 @@ class SimulatedAnnealing { groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: this.maxIterations, - elapsedTime: Date.now() - startTime + elapsedTime: Date.now() - this.stats.startTime }; } } // ============================================================================= -// Exhaustive Search (for small configurations) +// Exhaustive Search // ============================================================================= -/** - * 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; @@ -559,21 +508,18 @@ class ExhaustiveSearch { 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; + this.stats = new StatsTracker(); } stop() { this.stopped = true; } - /** - * Generate all combinations of k elements from array. - */ *combinations(array, k) { if (k === 0) { yield []; @@ -588,9 +534,6 @@ class ExhaustiveSearch { yield* this.combinations(rest, k); } - /** - * Generate all partitions of cells into groups. - */ *generatePartitions(cells, groupSize, numGroups) { if (numGroups === 0) { yield []; @@ -607,11 +550,41 @@ class ExhaustiveSearch { } } - async run() { - const startTime = Date.now(); - let iteration = 0; + 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(); - // 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]]; @@ -630,17 +603,23 @@ class ExhaustiveSearch { } iteration++; + this.stats.recordIteration(); - if (iteration % 1000 === 0) { - this.onProgress({ - iteration, - maxIterations: this.maxIterations, - bestScore: this.bestScore, - currentBest: this.bestSolution, - elapsedTime: Date.now() - startTime + 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 + } }); - - await new Promise(resolve => setTimeout(resolve, 0)); } if (iteration >= this.maxIterations) { @@ -650,6 +629,21 @@ class ExhaustiveSearch { } } + // 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)); @@ -661,20 +655,50 @@ class ExhaustiveSearch { groupCapacities: this.bestSolution.groupCapacities, excludedCells, iterations: iteration, - elapsedTime: Date.now() - startTime + elapsedTime: Date.now() - this.stats.startTime }; } } // ============================================================================= -// Export +// Worker Message Handler // ============================================================================= -// Make available globally for the main app -window.CellMatchingAlgorithms = { - GeneticAlgorithm, - SimulatedAnnealing, - ExhaustiveSearch, - calculateScore, - coefficientOfVariation +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; + } };