switch to web app

This commit is contained in:
2025-12-20 15:06:53 +01:00
parent 52cd4ea0ec
commit f600897ee8
7 changed files with 2682 additions and 10 deletions

729
js/app.js Normal file
View File

@ -0,0 +1,729 @@
/**
* 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.
*/
// =============================================================================
// Application State
// =============================================================================
const AppState = {
cells: [],
cellIdCounter: 0,
currentAlgorithm: null,
isRunning: false,
results: null
};
// =============================================================================
// DOM Elements
// =============================================================================
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'),
btnClearAll: document.getElementById('btn-clear-all'),
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'),
progressScore: document.getElementById('progress-score'),
progressTime: document.getElementById('progress-time'),
// Results
resultsSection: document.getElementById('results-section'),
resultScore: document.getElementById('result-score'),
resultCapVariance: document.getElementById('result-cap-variance'),
resultIrVariance: document.getElementById('result-ir-variance'),
resultPackCapacity: document.getElementById('result-pack-capacity'),
packGrid: document.getElementById('pack-grid'),
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')
};
// =============================================================================
// 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();
}
// =============================================================================
// 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')}`;
const capacity = cellData?.capacity || '';
const ir = cellData?.ir || '';
const cell = { id, label, capacity: capacity || null, ir: ir || null };
AppState.cells.push(cell);
const row = document.createElement('tr');
row.dataset.cellId = id;
row.innerHTML = `
<td>${AppState.cells.length}</td>
<td>
<input type="text" class="cell-label-input" value="${label}"
aria-label="Cell label" data-field="label">
</td>
<td>
<input type="number" min="0" max="99999" value="${capacity}"
aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh">
</td>
<td>
<input type="number" min="0" max="9999" step="0.1" value="${ir}"
aria-label="Internal resistance in milliohms" data-field="ir" placeholder="optional">
</td>
<td>
<button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">
</button>
</td>
`;
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;
}
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;
});
updateCellStats();
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;
if (count === 0) {
DOM.statAvgCap.textContent = '-';
DOM.statAvgIr.textContent = '-';
return;
}
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)}`;
} else {
DOM.statAvgIr.textContent = '-';
}
}
/**
* 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
];
exampleCells.forEach(cell => addCell(cell));
}
// =============================================================================
// 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;
}
DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`;
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
}
// =============================================================================
// Matching Control
// =============================================================================
/**
* 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 = '';
}
}
/**
* Start the matching process.
*/
async function startMatching() {
if (AppState.isRunning) return;
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
if (validCells.length < serial * parallel) {
alert(`Need at least ${serial * parallel} cells with capacity data.`);
return;
}
AppState.isRunning = true;
DOM.progressSection.hidden = false;
DOM.resultsSection.hidden = true;
DOM.btnStartMatching.disabled = 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
};
// Create algorithm instance
const { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch } = window.CellMatchingAlgorithms;
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();
}
}
/**
* Stop the matching process.
*/
function stopMatching() {
if (AppState.currentAlgorithm) {
AppState.currentAlgorithm.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.progressScore.textContent = progress.bestScore.toFixed(4);
DOM.progressTime.textContent = `${(progress.elapsedTime / 1000).toFixed(1)}s`;
}
// =============================================================================
// 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(', ');
} else {
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);
const minCap = Math.min(...allCapacities);
const maxCap = Math.max(...allCapacities);
const range = maxCap - minCap || 1;
DOM.packGrid.innerHTML = '';
config.forEach((group, groupIdx) => {
const row = document.createElement('div');
row.className = 'pack-row';
const label = document.createElement('span');
label.className = 'pack-row-label';
label.textContent = `S${groupIdx + 1}`;
row.appendChild(label);
const cellsContainer = document.createElement('div');
cellsContainer.className = 'pack-cells';
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
cellEl.style.backgroundColor = `hsl(${hue}, 70%, 45%)`;
cellEl.innerHTML = `
<span class="cell-label">${cell.label}</span>
<span class="cell-capacity">${cell.capacity} mAh</span>
${cell.ir ? `<span class="cell-ir">${cell.ir} mΩ</span>` : ''}
`;
cellsContainer.appendChild(cellEl);
});
row.appendChild(cellsContainer);
DOM.packGrid.appendChild(row);
});
}
/**
* 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;
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 = `
<td>S${idx + 1}</td>
<td>${group.map(c => c.label).join(' + ')}</td>
<td>${groupCapacity} mAh</td>
<td>${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}</td>
<td class="${deviationClass}">${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%</td>
`;
DOM.resultsTbody.appendChild(row);
});
}
// =============================================================================
// 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(),
score: AppState.results.score,
capacityCV: AppState.results.capacityCV,
irCV: AppState.results.irCV,
groups: AppState.results.configuration.map((group, idx) => ({
group: `S${idx + 1}`,
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
}))
};
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 || ''}`);
});
}
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:'
];
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(', ')}`);
}
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);
}
}
/**
* Helper function to download a file.
*/
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// =============================================================================
// Keyboard Navigation
// =============================================================================
/**
* 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();
}
});
}
// =============================================================================
// 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());
}
// =============================================================================
// Initialization
// =============================================================================
function init() {
initEventListeners();
setupKeyboardShortcuts();
updateConfigDisplay();
updateWeights('capacity');
updateMatchingButtonState();
// Add a few empty cell rows to start
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();
}

680
js/matching-algorithms.js Normal file
View File

@ -0,0 +1,680 @@
/**
* 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<Object>} 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
};