live results
This commit is contained in:
@ -444,17 +444,89 @@ output {
|
|||||||
transition: width var(--transition-normal);
|
transition: width var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-stats {
|
.progress-stats-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
gap: var(--space-lg);
|
gap: var(--space-md);
|
||||||
font-size: 0.875rem;
|
margin-bottom: var(--space-lg);
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-stats strong {
|
.stat-item {
|
||||||
color: var(--text-primary);
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
|
|||||||
43
index.html
43
index.html
@ -136,14 +136,41 @@
|
|||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-stats">
|
<div class="progress-stats-grid">
|
||||||
<span>Iteration: <strong id="progress-iteration">0</strong></span>
|
<div class="stat-item">
|
||||||
<span>Best Score: <strong id="progress-score">-</strong></span>
|
<span class="stat-label">Iteration</span>
|
||||||
<span>Time: <strong id="progress-time">0s</strong></span>
|
<span class="stat-value" id="progress-iteration">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Combinations</span>
|
||||||
|
<span class="stat-value" id="progress-combinations">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Best Score</span>
|
||||||
|
<span class="stat-value stat-highlight" id="progress-score">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Elapsed</span>
|
||||||
|
<span class="stat-value" id="progress-time">0s</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Speed</span>
|
||||||
|
<span class="stat-value" id="progress-speed">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">ETA</span>
|
||||||
|
<span class="stat-value" id="progress-eta">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Best Configuration Preview -->
|
||||||
|
<div class="live-preview" id="live-preview" hidden>
|
||||||
|
<h3>Current Best Configuration</h3>
|
||||||
|
<div class="live-preview-content" id="live-preview-content"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" id="btn-stop-matching" class="btn btn-secondary">
|
<button type="button" id="btn-stop-matching" class="btn btn-secondary">
|
||||||
Stop
|
Stop Matching
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -163,7 +190,7 @@
|
|||||||
rel="noopener">(Shi et al., 2013)</a>
|
rel="noopener">(Shi et al., 2013)</a>
|
||||||
</li>
|
</li>
|
||||||
<li>Always use a BMS with cell-level monitoring and balancing</li>
|
<li>Always use a BMS with cell-level monitoring and balancing</li>
|
||||||
<li>Use only same model of cell in a pack.</li>
|
<li>Use only same model of cells in a pack.</li>
|
||||||
<li>Never charge unattended; use fireproof storage</li>
|
<li>Never charge unattended; use fireproof storage</li>
|
||||||
<li>Cells with significantly different ages may degrade unpredictably</li>
|
<li>Cells with significantly different ages may degrade unpredictably</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -250,8 +277,7 @@
|
|||||||
<a href="https://git.mosad.xyz/localhorst/LiXX_Cell_Pack_Matcher" target="_blank" rel="noopener">Git</a>
|
<a href="https://git.mosad.xyz/localhorst/LiXX_Cell_Pack_Matcher" target="_blank" rel="noopener">Git</a>
|
||||||
·
|
·
|
||||||
Based on research by
|
Based on research by
|
||||||
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank" rel="noopener">Shi et al.,
|
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank" rel="noopener">Shi et al., 2013</a>
|
||||||
2013</a>
|
|
||||||
</p>
|
</p>
|
||||||
<p class="disclaimer">
|
<p class="disclaimer">
|
||||||
This tool is for educational purposes. Always consult professional guidance for battery pack assembly.
|
This tool is for educational purposes. Always consult professional guidance for battery pack assembly.
|
||||||
@ -277,7 +303,6 @@
|
|||||||
<button type="button" class="btn btn-primary" id="btn-close-shortcuts">Close</button>
|
<button type="button" class="btn btn-primary" id="btn-close-shortcuts">Close</button>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="js/matching-algorithms.js"></script>
|
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
476
js/app.js
476
js/app.js
@ -1,8 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* LiXX Cell Pack Matcher - Main Application
|
* LiXX Cell Pack Matcher - Main Application
|
||||||
*
|
* Uses Web Workers for non-blocking computation.
|
||||||
* A web application for optimal matching of lithium battery cells.
|
|
||||||
* Supports capacity and internal resistance matching with multiple algorithms.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -12,7 +10,7 @@
|
|||||||
const AppState = {
|
const AppState = {
|
||||||
cells: [],
|
cells: [],
|
||||||
cellIdCounter: 0,
|
cellIdCounter: 0,
|
||||||
currentAlgorithm: null,
|
worker: null,
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
results: null
|
results: null
|
||||||
};
|
};
|
||||||
@ -22,13 +20,10 @@ const AppState = {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const DOM = {
|
const DOM = {
|
||||||
// Configuration
|
|
||||||
cellsSerial: document.getElementById('cells-serial'),
|
cellsSerial: document.getElementById('cells-serial'),
|
||||||
cellsParallel: document.getElementById('cells-parallel'),
|
cellsParallel: document.getElementById('cells-parallel'),
|
||||||
configDisplay: document.getElementById('config-display'),
|
configDisplay: document.getElementById('config-display'),
|
||||||
totalCellsNeeded: document.getElementById('total-cells-needed'),
|
totalCellsNeeded: document.getElementById('total-cells-needed'),
|
||||||
|
|
||||||
// Cell input
|
|
||||||
cellTbody: document.getElementById('cell-tbody'),
|
cellTbody: document.getElementById('cell-tbody'),
|
||||||
btnAddCell: document.getElementById('btn-add-cell'),
|
btnAddCell: document.getElementById('btn-add-cell'),
|
||||||
btnLoadExample: document.getElementById('btn-load-example'),
|
btnLoadExample: document.getElementById('btn-load-example'),
|
||||||
@ -36,27 +31,24 @@ const DOM = {
|
|||||||
statCount: document.getElementById('stat-count'),
|
statCount: document.getElementById('stat-count'),
|
||||||
statAvgCap: document.getElementById('stat-avg-cap'),
|
statAvgCap: document.getElementById('stat-avg-cap'),
|
||||||
statAvgIr: document.getElementById('stat-avg-ir'),
|
statAvgIr: document.getElementById('stat-avg-ir'),
|
||||||
|
|
||||||
// Settings
|
|
||||||
weightCapacity: document.getElementById('weight-capacity'),
|
weightCapacity: document.getElementById('weight-capacity'),
|
||||||
weightIr: document.getElementById('weight-ir'),
|
weightIr: document.getElementById('weight-ir'),
|
||||||
weightCapValue: document.getElementById('weight-cap-value'),
|
weightCapValue: document.getElementById('weight-cap-value'),
|
||||||
weightIrValue: document.getElementById('weight-ir-value'),
|
weightIrValue: document.getElementById('weight-ir-value'),
|
||||||
algorithmSelect: document.getElementById('algorithm-select'),
|
algorithmSelect: document.getElementById('algorithm-select'),
|
||||||
maxIterations: document.getElementById('max-iterations'),
|
maxIterations: document.getElementById('max-iterations'),
|
||||||
|
|
||||||
// Matching
|
|
||||||
btnStartMatching: document.getElementById('btn-start-matching'),
|
btnStartMatching: document.getElementById('btn-start-matching'),
|
||||||
btnStopMatching: document.getElementById('btn-stop-matching'),
|
btnStopMatching: document.getElementById('btn-stop-matching'),
|
||||||
|
|
||||||
// Progress
|
|
||||||
progressSection: document.getElementById('progress-section'),
|
progressSection: document.getElementById('progress-section'),
|
||||||
progressFill: document.getElementById('progress-fill'),
|
progressFill: document.getElementById('progress-fill'),
|
||||||
progressIteration: document.getElementById('progress-iteration'),
|
progressIteration: document.getElementById('progress-iteration'),
|
||||||
|
progressCombinations: document.getElementById('progress-combinations'),
|
||||||
progressScore: document.getElementById('progress-score'),
|
progressScore: document.getElementById('progress-score'),
|
||||||
progressTime: document.getElementById('progress-time'),
|
progressTime: document.getElementById('progress-time'),
|
||||||
|
progressSpeed: document.getElementById('progress-speed'),
|
||||||
// Results
|
progressEta: document.getElementById('progress-eta'),
|
||||||
|
livePreview: document.getElementById('live-preview'),
|
||||||
|
livePreviewContent: document.getElementById('live-preview-content'),
|
||||||
resultsSection: document.getElementById('results-section'),
|
resultsSection: document.getElementById('results-section'),
|
||||||
resultScore: document.getElementById('result-score'),
|
resultScore: document.getElementById('result-score'),
|
||||||
resultCapVariance: document.getElementById('result-cap-variance'),
|
resultCapVariance: document.getElementById('result-cap-variance'),
|
||||||
@ -66,32 +58,48 @@ const DOM = {
|
|||||||
resultsTbody: document.getElementById('results-tbody'),
|
resultsTbody: document.getElementById('results-tbody'),
|
||||||
excludedCellsSection: document.getElementById('excluded-cells-section'),
|
excludedCellsSection: document.getElementById('excluded-cells-section'),
|
||||||
excludedCellsList: document.getElementById('excluded-cells-list'),
|
excludedCellsList: document.getElementById('excluded-cells-list'),
|
||||||
|
|
||||||
// Export
|
|
||||||
btnExportJson: document.getElementById('btn-export-json'),
|
btnExportJson: document.getElementById('btn-export-json'),
|
||||||
btnExportCsv: document.getElementById('btn-export-csv'),
|
btnExportCsv: document.getElementById('btn-export-csv'),
|
||||||
btnCopyResults: document.getElementById('btn-copy-results'),
|
btnCopyResults: document.getElementById('btn-copy-results'),
|
||||||
|
|
||||||
// Dialog
|
|
||||||
shortcutsDialog: document.getElementById('shortcuts-dialog'),
|
shortcutsDialog: document.getElementById('shortcuts-dialog'),
|
||||||
btnCloseShortcuts: document.getElementById('btn-close-shortcuts')
|
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
|
// Configuration Management
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the configuration display.
|
|
||||||
*/
|
|
||||||
function updateConfigDisplay() {
|
function updateConfigDisplay() {
|
||||||
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
||||||
const parallel = parseInt(DOM.cellsParallel.value) || 1;
|
const parallel = parseInt(DOM.cellsParallel.value) || 1;
|
||||||
const total = serial * parallel;
|
const total = serial * parallel;
|
||||||
|
|
||||||
DOM.configDisplay.textContent = `${serial}S${parallel}P`;
|
DOM.configDisplay.textContent = `${serial}S${parallel}P`;
|
||||||
DOM.totalCellsNeeded.textContent = total;
|
DOM.totalCellsNeeded.textContent = total;
|
||||||
|
|
||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,10 +107,6 @@ function updateConfigDisplay() {
|
|||||||
// Cell Management
|
// Cell Management
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new cell row to the table.
|
|
||||||
* @param {Object} cellData - Optional initial data
|
|
||||||
*/
|
|
||||||
function addCell(cellData = null) {
|
function addCell(cellData = null) {
|
||||||
const id = AppState.cellIdCounter++;
|
const id = AppState.cellIdCounter++;
|
||||||
const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`;
|
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.dataset.cellId = id;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${AppState.cells.length}</td>
|
<td>${AppState.cells.length}</td>
|
||||||
<td>
|
<td><input type="text" class="cell-label-input" value="${label}" aria-label="Cell label" data-field="label"></td>
|
||||||
<input type="text" class="cell-label-input" value="${label}"
|
<td><input type="number" min="0" max="99999" value="${capacity}" aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh"></td>
|
||||||
aria-label="Cell label" data-field="label">
|
<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>
|
<td><button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">✕</button></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);
|
DOM.cellTbody.appendChild(row);
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
row.querySelectorAll('input').forEach(input => {
|
row.querySelectorAll('input').forEach(input => {
|
||||||
input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
|
input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
|
||||||
input.addEventListener('input', () => 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));
|
row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id));
|
||||||
|
|
||||||
updateCellStats();
|
updateCellStats();
|
||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
|
|
||||||
// Focus the capacity input of the new row
|
|
||||||
if (!cellData) {
|
if (!cellData) {
|
||||||
row.querySelector('input[data-field="capacity"]').focus();
|
row.querySelector('input[data-field="capacity"]').focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update cell data when input changes.
|
|
||||||
*/
|
|
||||||
function updateCellData(id, field, value) {
|
function updateCellData(id, field, value) {
|
||||||
const cell = AppState.cells.find(c => c.id === id);
|
const cell = AppState.cells.find(c => c.id === id);
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
|
|
||||||
if (field === 'label') {
|
if (field === 'label') cell.label = value || `C${id}`;
|
||||||
cell.label = value || `C${id}`;
|
else if (field === 'capacity') cell.capacity = value ? parseFloat(value) : null;
|
||||||
} else if (field === 'capacity') {
|
else if (field === 'ir') cell.ir = value ? parseFloat(value) : null;
|
||||||
cell.capacity = value ? parseFloat(value) : null;
|
|
||||||
} else if (field === 'ir') {
|
|
||||||
cell.ir = value ? parseFloat(value) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCellStats();
|
updateCellStats();
|
||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a cell from the table.
|
|
||||||
*/
|
|
||||||
function removeCell(id) {
|
function removeCell(id) {
|
||||||
const index = AppState.cells.findIndex(c => c.id === id);
|
const index = AppState.cells.findIndex(c => c.id === id);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
AppState.cells.splice(index, 1);
|
AppState.cells.splice(index, 1);
|
||||||
|
|
||||||
const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`);
|
const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`);
|
||||||
if (row) row.remove();
|
if (row) row.remove();
|
||||||
|
|
||||||
// Update row numbers
|
|
||||||
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
|
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
|
||||||
row.querySelector('td:first-child').textContent = idx + 1;
|
row.querySelector('td:first-child').textContent = idx + 1;
|
||||||
});
|
});
|
||||||
@ -194,23 +170,15 @@ function removeCell(id) {
|
|||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all cells.
|
|
||||||
*/
|
|
||||||
function clearAllCells() {
|
function clearAllCells() {
|
||||||
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
|
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
|
||||||
|
|
||||||
AppState.cells = [];
|
AppState.cells = [];
|
||||||
AppState.cellIdCounter = 0;
|
AppState.cellIdCounter = 0;
|
||||||
DOM.cellTbody.innerHTML = '';
|
DOM.cellTbody.innerHTML = '';
|
||||||
|
|
||||||
updateCellStats();
|
updateCellStats();
|
||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update cell statistics display.
|
|
||||||
*/
|
|
||||||
function updateCellStats() {
|
function updateCellStats() {
|
||||||
const count = AppState.cells.length;
|
const count = AppState.cells.length;
|
||||||
DOM.statCount.textContent = count;
|
DOM.statCount.textContent = count;
|
||||||
@ -224,50 +192,28 @@ function updateCellStats() {
|
|||||||
const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity);
|
const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity);
|
||||||
const irs = AppState.cells.filter(c => c.ir).map(c => c.ir);
|
const irs = AppState.cells.filter(c => c.ir).map(c => c.ir);
|
||||||
|
|
||||||
if (capacities.length > 0) {
|
DOM.statAvgCap.textContent = capacities.length > 0
|
||||||
const avgCap = capacities.reduce((a, b) => a + b, 0) / capacities.length;
|
? `${Math.round(capacities.reduce((a, b) => a + b, 0) / capacities.length)} mAh` : '-';
|
||||||
DOM.statAvgCap.textContent = `${Math.round(avgCap)} mAh`;
|
DOM.statAvgIr.textContent = irs.length > 0
|
||||||
} else {
|
? `${(irs.reduce((a, b) => a + b, 0) / irs.length).toFixed(1)} mΩ` : '-';
|
||||||
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 = '-';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load example cell data.
|
|
||||||
*/
|
|
||||||
function loadExampleData() {
|
function loadExampleData() {
|
||||||
if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return;
|
if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return;
|
||||||
|
|
||||||
// Clear without confirmation since we just asked
|
|
||||||
AppState.cells = [];
|
AppState.cells = [];
|
||||||
AppState.cellIdCounter = 0;
|
AppState.cellIdCounter = 0;
|
||||||
DOM.cellTbody.innerHTML = '';
|
DOM.cellTbody.innerHTML = '';
|
||||||
|
|
||||||
// Example: 14 cells for a 6S2P pack (2 spare)
|
|
||||||
const exampleCells = [
|
const exampleCells = [
|
||||||
{ label: 'B01', capacity: 3330, ir: 42 },
|
{ label: 'B01', capacity: 3330, ir: 42 }, { label: 'B02', capacity: 3360, ir: 38 },
|
||||||
{ label: 'B02', capacity: 3360, ir: 38 },
|
{ label: 'B03', capacity: 3230, ir: 45 }, { label: 'B04', capacity: 3390, ir: 41 },
|
||||||
{ label: 'B03', capacity: 3230, ir: 45 },
|
{ label: 'B05', capacity: 3280, ir: 44 }, { label: 'B06', capacity: 3350, ir: 39 },
|
||||||
{ label: 'B04', capacity: 3390, ir: 41 },
|
{ label: 'B07', capacity: 3350, ir: 40 }, { label: 'B08', capacity: 3490, ir: 36 },
|
||||||
{ label: 'B05', capacity: 3280, ir: 44 },
|
{ label: 'B09', capacity: 3280, ir: 43 }, { label: 'B10', capacity: 3420, ir: 37 },
|
||||||
{ label: 'B06', capacity: 3350, ir: 39 },
|
{ label: 'B11', capacity: 3350, ir: 41 }, { label: 'B12', capacity: 3420, ir: 38 },
|
||||||
{ label: 'B07', capacity: 3350, ir: 40 },
|
{ label: 'B13', capacity: 3150, ir: 52 }, { label: 'B14', capacity: 3380, 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));
|
exampleCells.forEach(cell => addCell(cell));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,51 +221,32 @@ function loadExampleData() {
|
|||||||
// Weight Sliders
|
// Weight Sliders
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Update weight slider displays and keep them summing to 100%.
|
|
||||||
*/
|
|
||||||
function updateWeights(source) {
|
function updateWeights(source) {
|
||||||
const capWeight = parseInt(DOM.weightCapacity.value);
|
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 - 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.weightCapValue.textContent = `${DOM.weightCapacity.value}%`;
|
||||||
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
|
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Matching Control
|
// Matching Control (Web Worker)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the state of the matching button.
|
|
||||||
*/
|
|
||||||
function updateMatchingButtonState() {
|
function updateMatchingButtonState() {
|
||||||
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
||||||
const parallel = parseInt(DOM.cellsParallel.value) || 1;
|
const parallel = parseInt(DOM.cellsParallel.value) || 1;
|
||||||
const needed = serial * parallel;
|
const needed = serial * parallel;
|
||||||
|
|
||||||
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
|
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
|
||||||
const canStart = validCells.length >= needed && !AppState.isRunning;
|
const canStart = validCells.length >= needed && !AppState.isRunning;
|
||||||
|
|
||||||
DOM.btnStartMatching.disabled = !canStart;
|
DOM.btnStartMatching.disabled = !canStart;
|
||||||
|
DOM.btnStartMatching.title = validCells.length < needed
|
||||||
if (validCells.length < needed) {
|
? `Need at least ${needed} cells with capacity data` : '';
|
||||||
DOM.btnStartMatching.title = `Need at least ${needed} cells with capacity data`;
|
|
||||||
} else {
|
|
||||||
DOM.btnStartMatching.title = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function startMatching() {
|
||||||
* Start the matching process.
|
|
||||||
*/
|
|
||||||
async function startMatching() {
|
|
||||||
if (AppState.isRunning) return;
|
if (AppState.isRunning) return;
|
||||||
|
|
||||||
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
||||||
@ -335,96 +262,120 @@ async function startMatching() {
|
|||||||
DOM.progressSection.hidden = false;
|
DOM.progressSection.hidden = false;
|
||||||
DOM.resultsSection.hidden = true;
|
DOM.resultsSection.hidden = true;
|
||||||
DOM.btnStartMatching.disabled = 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 algorithmType = DOM.algorithmSelect.value;
|
||||||
const maxIterations = parseInt(DOM.maxIterations.value) || 5000;
|
const maxIterations = parseInt(DOM.maxIterations.value) || 5000;
|
||||||
const capacityWeight = parseInt(DOM.weightCapacity.value) / 100;
|
const capacityWeight = parseInt(DOM.weightCapacity.value) / 100;
|
||||||
const irWeight = parseInt(DOM.weightIr.value) / 100;
|
const irWeight = parseInt(DOM.weightIr.value) / 100;
|
||||||
|
|
||||||
const options = {
|
AppState.worker = new Worker('js/matching-worker.js');
|
||||||
maxIterations,
|
|
||||||
capacityWeight,
|
AppState.worker.onmessage = function (e) {
|
||||||
irWeight,
|
const { type, data } = e.data;
|
||||||
onProgress: updateProgress
|
if (type === 'progress') updateProgress(data);
|
||||||
|
else if (type === 'complete') handleComplete(data);
|
||||||
|
else if (type === 'error') handleError(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create algorithm instance
|
AppState.worker.onerror = function (error) {
|
||||||
const { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch } = window.CellMatchingAlgorithms;
|
console.error('Worker error:', error);
|
||||||
|
handleError(error.message);
|
||||||
|
};
|
||||||
|
|
||||||
switch (algorithmType) {
|
AppState.worker.postMessage({
|
||||||
case 'genetic':
|
type: 'start',
|
||||||
AppState.currentAlgorithm = new GeneticAlgorithm(validCells, serial, parallel, options);
|
data: {
|
||||||
break;
|
cells: validCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
|
||||||
case 'simulated-annealing':
|
serial, parallel,
|
||||||
AppState.currentAlgorithm = new SimulatedAnnealing(validCells, serial, parallel, options);
|
algorithm: algorithmType,
|
||||||
break;
|
options: { maxIterations, capacityWeight, irWeight }
|
||||||
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() {
|
function stopMatching() {
|
||||||
if (AppState.currentAlgorithm) {
|
if (AppState.worker) AppState.worker.postMessage({ type: 'stop' });
|
||||||
AppState.currentAlgorithm.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update progress display.
|
|
||||||
*/
|
|
||||||
function updateProgress(progress) {
|
function updateProgress(progress) {
|
||||||
const percent = (progress.iteration / progress.maxIterations) * 100;
|
const percent = (progress.iteration / progress.maxIterations) * 100;
|
||||||
DOM.progressFill.style.width = `${percent}%`;
|
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.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 `<div class="live-preview-group"><span class="group-label">S${idx + 1}:</span><span class="group-cells">${cells}</span><span class="group-cap">${totalCap}</span></div>`;
|
||||||
|
}).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
|
// Results Display
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the matching results.
|
|
||||||
*/
|
|
||||||
function displayResults(results) {
|
function displayResults(results) {
|
||||||
DOM.resultsSection.hidden = false;
|
DOM.resultsSection.hidden = false;
|
||||||
|
|
||||||
// Summary metrics
|
|
||||||
DOM.resultScore.textContent = results.score.toFixed(3);
|
DOM.resultScore.textContent = results.score.toFixed(3);
|
||||||
DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`;
|
DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`;
|
||||||
DOM.resultIrVariance.textContent = results.irCV ? `${results.irCV.toFixed(2)}%` : 'N/A';
|
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);
|
const packCapacity = Math.min(...results.groupCapacities);
|
||||||
DOM.resultPackCapacity.textContent = `${packCapacity} mAh`;
|
DOM.resultPackCapacity.textContent = `${packCapacity} mAh`;
|
||||||
|
|
||||||
// Visualize pack layout
|
|
||||||
renderPackVisualization(results);
|
renderPackVisualization(results);
|
||||||
|
|
||||||
// Results table
|
|
||||||
renderResultsTable(results);
|
renderResultsTable(results);
|
||||||
|
|
||||||
// Excluded cells
|
|
||||||
if (results.excludedCells.length > 0) {
|
if (results.excludedCells.length > 0) {
|
||||||
DOM.excludedCellsSection.hidden = false;
|
DOM.excludedCellsSection.hidden = false;
|
||||||
DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', ');
|
DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', ');
|
||||||
@ -432,13 +383,9 @@ function displayResults(results) {
|
|||||||
DOM.excludedCellsSection.hidden = true;
|
DOM.excludedCellsSection.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to results
|
|
||||||
DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the pack visualization.
|
|
||||||
*/
|
|
||||||
function renderPackVisualization(results) {
|
function renderPackVisualization(results) {
|
||||||
const config = results.configuration;
|
const config = results.configuration;
|
||||||
const allCapacities = config.flat().map(c => c.capacity);
|
const allCapacities = config.flat().map(c => c.capacity);
|
||||||
@ -463,18 +410,10 @@ function renderPackVisualization(results) {
|
|||||||
group.forEach(cell => {
|
group.forEach(cell => {
|
||||||
const cellEl = document.createElement('div');
|
const cellEl = document.createElement('div');
|
||||||
cellEl.className = 'pack-cell';
|
cellEl.className = 'pack-cell';
|
||||||
|
|
||||||
// Color based on relative capacity
|
|
||||||
const normalized = (cell.capacity - minCap) / range;
|
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.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>` : ''}`;
|
||||||
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);
|
cellsContainer.appendChild(cellEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -483,36 +422,23 @@ function renderPackVisualization(results) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the results table.
|
|
||||||
*/
|
|
||||||
function renderResultsTable(results) {
|
function renderResultsTable(results) {
|
||||||
const config = results.configuration;
|
const config = results.configuration;
|
||||||
const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length;
|
const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length;
|
||||||
|
|
||||||
DOM.resultsTbody.innerHTML = '';
|
DOM.resultsTbody.innerHTML = '';
|
||||||
|
|
||||||
config.forEach((group, idx) => {
|
config.forEach((group, idx) => {
|
||||||
const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0);
|
const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||||
const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100);
|
const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100);
|
||||||
const irsWithValues = group.filter(c => c.ir);
|
const irsWithValues = group.filter(c => c.ir);
|
||||||
const avgIr = irsWithValues.length > 0
|
const avgIr = irsWithValues.length > 0 ? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length : null;
|
||||||
? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let deviationClass = 'deviation-good';
|
let deviationClass = 'deviation-good';
|
||||||
if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning';
|
if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning';
|
||||||
if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad';
|
if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad';
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
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>`;
|
||||||
<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);
|
DOM.resultsTbody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -521,12 +447,8 @@ function renderResultsTable(results) {
|
|||||||
// Export Functions
|
// Export Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Export results as JSON.
|
|
||||||
*/
|
|
||||||
function exportJson() {
|
function exportJson() {
|
||||||
if (!AppState.results) return;
|
if (!AppState.results) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
|
configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -538,82 +460,46 @@ function exportJson() {
|
|||||||
cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
|
cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
|
||||||
totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0)
|
totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0)
|
||||||
})),
|
})),
|
||||||
excludedCells: AppState.results.excludedCells.map(c => ({
|
excludedCells: AppState.results.excludedCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir }))
|
||||||
label: c.label,
|
|
||||||
capacity: c.capacity,
|
|
||||||
ir: c.ir
|
|
||||||
}))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json');
|
downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Export results as CSV.
|
|
||||||
*/
|
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
if (!AppState.results) return;
|
if (!AppState.results) return;
|
||||||
|
|
||||||
const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total'];
|
const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total'];
|
||||||
|
|
||||||
AppState.results.configuration.forEach((group, idx) => {
|
AppState.results.configuration.forEach((group, idx) => {
|
||||||
const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0);
|
const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||||
group.forEach((cell, cellIdx) => {
|
group.forEach((cell, cellIdx) => {
|
||||||
lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`);
|
lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (AppState.results.excludedCells.length > 0) {
|
if (AppState.results.excludedCells.length > 0) {
|
||||||
lines.push('');
|
lines.push('', 'Excluded Cells');
|
||||||
lines.push('Excluded Cells');
|
AppState.results.excludedCells.forEach(cell => lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`));
|
||||||
AppState.results.excludedCells.forEach(cell => {
|
|
||||||
lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv');
|
downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy results to clipboard.
|
|
||||||
*/
|
|
||||||
async function copyResults() {
|
async function copyResults() {
|
||||||
if (!AppState.results) return;
|
if (!AppState.results) return;
|
||||||
|
|
||||||
const config = AppState.results.configuration;
|
const config = AppState.results.configuration;
|
||||||
const lines = [
|
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:'];
|
||||||
`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) => {
|
config.forEach((group, idx) => {
|
||||||
const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + ');
|
const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + ');
|
||||||
const total = group.reduce((sum, c) => sum + c.capacity, 0);
|
const total = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||||
lines.push(` S${idx + 1}: ${cells} = ${total}mAh`);
|
lines.push(` S${idx + 1}: ${cells} = ${total}mAh`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (AppState.results.excludedCells.length > 0) {
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(lines.join('\n'));
|
await navigator.clipboard.writeText(lines.join('\n'));
|
||||||
DOM.btnCopyResults.textContent = 'Copied!';
|
DOM.btnCopyResults.textContent = 'Copied!';
|
||||||
setTimeout(() => {
|
setTimeout(() => { DOM.btnCopyResults.textContent = 'Copy to Clipboard'; }, 2000);
|
||||||
DOM.btnCopyResults.textContent = 'Copy to Clipboard';
|
} catch (err) { console.error('Failed to copy:', err); }
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to download a file.
|
|
||||||
*/
|
|
||||||
function downloadFile(content, filename, mimeType) {
|
function downloadFile(content, filename, mimeType) {
|
||||||
const blob = new Blob([content], { type: mimeType });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
const url = URL.createObjectURL(blob);
|
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() {
|
function setupKeyboardShortcuts() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// Alt + A: Add cell
|
if (e.altKey && e.key === 'a') { e.preventDefault(); addCell(); }
|
||||||
if (e.altKey && e.key === 'a') {
|
if (e.altKey && e.key === 's') { e.preventDefault(); if (!DOM.btnStartMatching.disabled) startMatching(); }
|
||||||
e.preventDefault();
|
if (e.altKey && e.key === 'e') { e.preventDefault(); loadExampleData(); }
|
||||||
addCell();
|
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(); }
|
||||||
|
|
||||||
// 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() {
|
function initEventListeners() {
|
||||||
// Configuration
|
|
||||||
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
|
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
|
||||||
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
|
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
|
||||||
|
|
||||||
// Cell management
|
|
||||||
DOM.btnAddCell.addEventListener('click', () => addCell());
|
DOM.btnAddCell.addEventListener('click', () => addCell());
|
||||||
DOM.btnLoadExample.addEventListener('click', loadExampleData);
|
DOM.btnLoadExample.addEventListener('click', loadExampleData);
|
||||||
DOM.btnClearAll.addEventListener('click', clearAllCells);
|
DOM.btnClearAll.addEventListener('click', clearAllCells);
|
||||||
|
|
||||||
// Weight sliders
|
|
||||||
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
|
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
|
||||||
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
|
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
|
||||||
|
|
||||||
// Matching
|
|
||||||
DOM.btnStartMatching.addEventListener('click', startMatching);
|
DOM.btnStartMatching.addEventListener('click', startMatching);
|
||||||
DOM.btnStopMatching.addEventListener('click', stopMatching);
|
DOM.btnStopMatching.addEventListener('click', stopMatching);
|
||||||
|
|
||||||
// Export
|
|
||||||
DOM.btnExportJson.addEventListener('click', exportJson);
|
DOM.btnExportJson.addEventListener('click', exportJson);
|
||||||
DOM.btnExportCsv.addEventListener('click', exportCsv);
|
DOM.btnExportCsv.addEventListener('click', exportCsv);
|
||||||
DOM.btnCopyResults.addEventListener('click', copyResults);
|
DOM.btnCopyResults.addEventListener('click', copyResults);
|
||||||
|
|
||||||
// Dialog
|
|
||||||
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
|
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -714,16 +552,8 @@ function init() {
|
|||||||
updateConfigDisplay();
|
updateConfigDisplay();
|
||||||
updateWeights('capacity');
|
updateWeights('capacity');
|
||||||
updateMatchingButtonState();
|
updateMatchingButtonState();
|
||||||
|
for (let i = 0; i < 3; i++) addCell();
|
||||||
// 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);
|
||||||
if (document.readyState === 'loading') {
|
else init();
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
|
||||||
} else {
|
|
||||||
init();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,15 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* LiXX Cell Pack Matcher - Matching Algorithms
|
* LiXX Cell Pack Matcher - Web Worker
|
||||||
*
|
*
|
||||||
* Implements optimized algorithms for lithium cell matching:
|
* Runs matching algorithms in a background thread to keep the UI responsive.
|
||||||
* - Genetic Algorithm (default, fast)
|
* Communicates with the main thread via postMessage.
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -18,9 +11,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the coefficient of variation (CV) as a percentage.
|
* 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) {
|
function coefficientOfVariation(values) {
|
||||||
if (!values || values.length === 0) return 0;
|
if (!values || values.length === 0) return 0;
|
||||||
@ -32,8 +22,6 @@ function coefficientOfVariation(values) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shuffle array in place using Fisher-Yates algorithm.
|
* Shuffle array in place using Fisher-Yates algorithm.
|
||||||
* @param {Array} array - Array to shuffle
|
|
||||||
* @returns {Array} The same array, shuffled
|
|
||||||
*/
|
*/
|
||||||
function shuffleArray(array) {
|
function shuffleArray(array) {
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
@ -45,8 +33,6 @@ function shuffleArray(array) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deep clone an array of arrays.
|
* Deep clone an array of arrays.
|
||||||
* @param {Array[]} arr - Array to clone
|
|
||||||
* @returns {Array[]} Cloned array
|
|
||||||
*/
|
*/
|
||||||
function cloneConfiguration(arr) {
|
function cloneConfiguration(arr) {
|
||||||
return arr.map(group => [...group]);
|
return arr.map(group => [...group]);
|
||||||
@ -59,30 +45,18 @@ function cloneConfiguration(arr) {
|
|||||||
/**
|
/**
|
||||||
* Calculate the match score for a pack configuration.
|
* Calculate the match score for a pack configuration.
|
||||||
* Lower score = better match.
|
* 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) {
|
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
|
||||||
// Calculate total capacity for each parallel group
|
|
||||||
const groupCapacities = configuration.map(group =>
|
const groupCapacities = configuration.map(group =>
|
||||||
group.reduce((sum, cell) => sum + cell.capacity, 0)
|
group.reduce((sum, cell) => sum + cell.capacity, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate average IR for each parallel group
|
|
||||||
const groupIRs = configuration.map(group => {
|
const groupIRs = configuration.map(group => {
|
||||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||||
if (irsWithValues.length === 0) return null;
|
if (irsWithValues.length === 0) return null;
|
||||||
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
|
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
|
||||||
}).filter(ir => ir !== null);
|
}).filter(ir => ir !== null);
|
||||||
|
|
||||||
// Calculate IR variance within each parallel group (important for parallel cells)
|
|
||||||
const withinGroupIRVariances = configuration.map(group => {
|
const withinGroupIRVariances = configuration.map(group => {
|
||||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||||
if (irsWithValues.length < 2) return 0;
|
if (irsWithValues.length < 2) return 0;
|
||||||
@ -90,15 +64,11 @@ function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
|
|||||||
return coefficientOfVariation(irs);
|
return coefficientOfVariation(irs);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Capacity CV between groups (should be low for balanced pack)
|
|
||||||
const capacityCV = coefficientOfVariation(groupCapacities);
|
const capacityCV = coefficientOfVariation(groupCapacities);
|
||||||
|
|
||||||
// Average IR CV within groups (should be low for parallel cells)
|
|
||||||
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
|
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
|
||||||
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
|
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Combined score (lower is better)
|
|
||||||
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
|
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
|
||||||
|
|
||||||
return {
|
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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Genetic Algorithm for cell matching.
|
|
||||||
* Fast and effective for most configurations.
|
|
||||||
*/
|
|
||||||
class GeneticAlgorithm {
|
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 = {}) {
|
constructor(cells, serial, parallel, options = {}) {
|
||||||
this.cells = cells;
|
this.cells = cells;
|
||||||
this.serial = serial;
|
this.serial = serial;
|
||||||
this.parallel = parallel;
|
this.parallel = parallel;
|
||||||
this.totalCellsNeeded = serial * parallel;
|
this.totalCellsNeeded = serial * parallel;
|
||||||
|
|
||||||
// Options with defaults
|
|
||||||
this.populationSize = options.populationSize || 50;
|
this.populationSize = options.populationSize || 50;
|
||||||
this.maxIterations = options.maxIterations || 5000;
|
this.maxIterations = options.maxIterations || 5000;
|
||||||
this.mutationRate = options.mutationRate || 0.15;
|
this.mutationRate = options.mutationRate || 0.15;
|
||||||
this.eliteCount = options.eliteCount || 5;
|
this.eliteCount = options.eliteCount || 5;
|
||||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||||
this.irWeight = options.irWeight ?? 0.3;
|
this.irWeight = options.irWeight ?? 0.3;
|
||||||
this.onProgress = options.onProgress || (() => { });
|
|
||||||
|
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
this.bestSolution = null;
|
this.bestSolution = null;
|
||||||
this.bestScore = Infinity;
|
this.bestScore = Infinity;
|
||||||
|
this.stats = new StatsTracker();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the algorithm.
|
|
||||||
*/
|
|
||||||
stop() {
|
stop() {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a random individual (configuration).
|
|
||||||
* @param {Object[]} cellPool - Cells to choose from
|
|
||||||
* @returns {Object[][]} Configuration
|
|
||||||
*/
|
|
||||||
createIndividual(cellPool) {
|
createIndividual(cellPool) {
|
||||||
const shuffled = shuffleArray([...cellPool]).slice(0, this.totalCellsNeeded);
|
const shuffled = shuffleArray([...cellPool]).slice(0, this.totalCellsNeeded);
|
||||||
const configuration = [];
|
const configuration = [];
|
||||||
@ -173,21 +165,11 @@ class GeneticAlgorithm {
|
|||||||
return configuration;
|
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) {
|
configToIndices(config) {
|
||||||
const flat = config.flat();
|
const flat = config.flat();
|
||||||
return flat.map(cell => this.cells.findIndex(c => c.label === cell.label));
|
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) {
|
indicesToConfig(indices) {
|
||||||
const configuration = [];
|
const configuration = [];
|
||||||
for (let i = 0; i < this.serial; i++) {
|
for (let i = 0; i < this.serial; i++) {
|
||||||
@ -201,12 +183,6 @@ class GeneticAlgorithm {
|
|||||||
return configuration;
|
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) {
|
crossover(parent1, parent2) {
|
||||||
const length = parent1.length;
|
const length = parent1.length;
|
||||||
const start = Math.floor(Math.random() * length);
|
const start = Math.floor(Math.random() * length);
|
||||||
@ -215,13 +191,11 @@ class GeneticAlgorithm {
|
|||||||
const child = new Array(length).fill(-1);
|
const child = new Array(length).fill(-1);
|
||||||
const usedIndices = new Set();
|
const usedIndices = new Set();
|
||||||
|
|
||||||
// Copy segment from parent1
|
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
child[i] = parent1[i];
|
child[i] = parent1[i];
|
||||||
usedIndices.add(parent1[i]);
|
usedIndices.add(parent1[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining from parent2
|
|
||||||
let childIdx = (end + 1) % length;
|
let childIdx = (end + 1) % length;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const parent2Idx = (end + 1 + i) % length;
|
const parent2Idx = (end + 1 + i) % length;
|
||||||
@ -238,24 +212,16 @@ class GeneticAlgorithm {
|
|||||||
return child;
|
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) {
|
mutate(indices, unusedCells) {
|
||||||
const mutated = [...indices];
|
const mutated = [...indices];
|
||||||
|
|
||||||
if (Math.random() < this.mutationRate) {
|
if (Math.random() < this.mutationRate) {
|
||||||
if (unusedCells.length > 0 && Math.random() < 0.3) {
|
if (unusedCells.length > 0 && Math.random() < 0.3) {
|
||||||
// Replace a cell with an unused one
|
|
||||||
const replaceIdx = Math.floor(Math.random() * mutated.length);
|
const replaceIdx = Math.floor(Math.random() * mutated.length);
|
||||||
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
||||||
const unusedIdx = this.cells.findIndex(c => c.label === unusedCell.label);
|
const unusedIdx = this.cells.findIndex(c => c.label === unusedCell.label);
|
||||||
mutated[replaceIdx] = unusedIdx;
|
mutated[replaceIdx] = unusedIdx;
|
||||||
} else {
|
} else {
|
||||||
// Swap two cells within the configuration
|
|
||||||
const i = Math.floor(Math.random() * mutated.length);
|
const i = Math.floor(Math.random() * mutated.length);
|
||||||
const j = Math.floor(Math.random() * mutated.length);
|
const j = Math.floor(Math.random() * mutated.length);
|
||||||
[mutated[i], mutated[j]] = [mutated[j], mutated[i]];
|
[mutated[i], mutated[j]] = [mutated[j], mutated[i]];
|
||||||
@ -265,13 +231,7 @@ class GeneticAlgorithm {
|
|||||||
return mutated;
|
return mutated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
run() {
|
||||||
* Run the genetic algorithm.
|
|
||||||
* @returns {Promise<Object>} Best solution found
|
|
||||||
*/
|
|
||||||
async run() {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Initialize population
|
// Initialize population
|
||||||
let population = [];
|
let population = [];
|
||||||
for (let i = 0; i < this.populationSize; i++) {
|
for (let i = 0; i < this.populationSize; i++) {
|
||||||
@ -285,7 +245,6 @@ class GeneticAlgorithm {
|
|||||||
...calculateScore(config, this.capacityWeight, this.irWeight)
|
...calculateScore(config, this.capacityWeight, this.irWeight)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort by score
|
|
||||||
evaluated.sort((a, b) => a.score - b.score);
|
evaluated.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
if (evaluated[0].score < this.bestScore) {
|
if (evaluated[0].score < this.bestScore) {
|
||||||
@ -293,9 +252,14 @@ class GeneticAlgorithm {
|
|||||||
this.bestSolution = evaluated[0];
|
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
|
// Main evolution loop
|
||||||
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
||||||
// Selection (tournament selection)
|
|
||||||
const newPopulation = [];
|
const newPopulation = [];
|
||||||
|
|
||||||
// Keep elite individuals
|
// Keep elite individuals
|
||||||
@ -305,22 +269,17 @@ class GeneticAlgorithm {
|
|||||||
|
|
||||||
// Generate rest through crossover and mutation
|
// Generate rest through crossover and mutation
|
||||||
while (newPopulation.length < this.populationSize) {
|
while (newPopulation.length < this.populationSize) {
|
||||||
// Tournament selection
|
|
||||||
const tournament1 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
|
const tournament1 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
|
||||||
const tournament2 = 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 parent1 = tournament1[Math.floor(Math.random() * tournament1.length)];
|
||||||
const parent2 = tournament2[Math.floor(Math.random() * tournament2.length)];
|
const parent2 = tournament2[Math.floor(Math.random() * tournament2.length)];
|
||||||
|
|
||||||
// Crossover
|
|
||||||
let child = this.crossover(parent1.indices, parent2.indices);
|
let child = this.crossover(parent1.indices, parent2.indices);
|
||||||
|
|
||||||
// Determine unused cells
|
|
||||||
const usedLabels = new Set(child.map(idx => this.cells[idx].label));
|
const usedLabels = new Set(child.map(idx => this.cells[idx].label));
|
||||||
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||||
|
|
||||||
// Mutation
|
|
||||||
child = this.mutate(child, unusedCells);
|
child = this.mutate(child, unusedCells);
|
||||||
|
|
||||||
newPopulation.push(child);
|
newPopulation.push(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,31 +293,34 @@ class GeneticAlgorithm {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by score
|
|
||||||
evaluated.sort((a, b) => a.score - b.score);
|
evaluated.sort((a, b) => a.score - b.score);
|
||||||
|
|
||||||
// Update best solution
|
|
||||||
if (evaluated[0].score < this.bestScore) {
|
if (evaluated[0].score < this.bestScore) {
|
||||||
this.bestScore = evaluated[0].score;
|
this.bestScore = evaluated[0].score;
|
||||||
this.bestSolution = evaluated[0];
|
this.bestSolution = evaluated[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress callback
|
this.stats.recordIteration();
|
||||||
if (iteration % 50 === 0 || iteration === this.maxIterations - 1) {
|
|
||||||
this.onProgress({
|
// 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,
|
iteration,
|
||||||
maxIterations: this.maxIterations,
|
maxIterations: this.maxIterations,
|
||||||
bestScore: this.bestScore,
|
bestScore: this.bestScore,
|
||||||
currentBest: this.bestSolution,
|
currentBest: this.bestSolution,
|
||||||
elapsedTime: Date.now() - startTime
|
totalCombinations,
|
||||||
|
evaluatedCombinations: (iteration + 1) * this.populationSize,
|
||||||
|
...stats
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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 usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||||
|
|
||||||
@ -370,26 +332,24 @@ class GeneticAlgorithm {
|
|||||||
groupCapacities: this.bestSolution.groupCapacities,
|
groupCapacities: this.bestSolution.groupCapacities,
|
||||||
excludedCells,
|
excludedCells,
|
||||||
iterations: this.maxIterations,
|
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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulated Annealing algorithm for cell matching.
|
|
||||||
* Good for escaping local minima.
|
|
||||||
*/
|
|
||||||
class SimulatedAnnealing {
|
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 = {}) {
|
constructor(cells, serial, parallel, options = {}) {
|
||||||
this.cells = cells;
|
this.cells = cells;
|
||||||
this.serial = serial;
|
this.serial = serial;
|
||||||
@ -401,20 +361,17 @@ class SimulatedAnnealing {
|
|||||||
this.coolingRate = options.coolingRate || 0.995;
|
this.coolingRate = options.coolingRate || 0.995;
|
||||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||||
this.irWeight = options.irWeight ?? 0.3;
|
this.irWeight = options.irWeight ?? 0.3;
|
||||||
this.onProgress = options.onProgress || (() => { });
|
|
||||||
|
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
this.bestSolution = null;
|
this.bestSolution = null;
|
||||||
this.bestScore = Infinity;
|
this.bestScore = Infinity;
|
||||||
|
this.stats = new StatsTracker();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create initial configuration.
|
|
||||||
*/
|
|
||||||
createInitialConfig() {
|
createInitialConfig() {
|
||||||
const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded);
|
const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded);
|
||||||
const configuration = [];
|
const configuration = [];
|
||||||
@ -430,9 +387,6 @@ class SimulatedAnnealing {
|
|||||||
return configuration;
|
return configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a neighbor solution by making a small change.
|
|
||||||
*/
|
|
||||||
getNeighbor(config) {
|
getNeighbor(config) {
|
||||||
const newConfig = cloneConfiguration(config);
|
const newConfig = cloneConfiguration(config);
|
||||||
const usedLabels = new Set(config.flat().map(c => c.label));
|
const usedLabels = new Set(config.flat().map(c => c.label));
|
||||||
@ -441,13 +395,11 @@ class SimulatedAnnealing {
|
|||||||
const moveType = Math.random();
|
const moveType = Math.random();
|
||||||
|
|
||||||
if (unusedCells.length > 0 && moveType < 0.3) {
|
if (unusedCells.length > 0 && moveType < 0.3) {
|
||||||
// Replace a cell with an unused one
|
|
||||||
const groupIdx = Math.floor(Math.random() * this.serial);
|
const groupIdx = Math.floor(Math.random() * this.serial);
|
||||||
const cellIdx = Math.floor(Math.random() * this.parallel);
|
const cellIdx = Math.floor(Math.random() * this.parallel);
|
||||||
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
||||||
newConfig[groupIdx][cellIdx] = unusedCell;
|
newConfig[groupIdx][cellIdx] = unusedCell;
|
||||||
} else if (moveType < 0.65) {
|
} else if (moveType < 0.65) {
|
||||||
// Swap cells between different groups
|
|
||||||
const group1 = Math.floor(Math.random() * this.serial);
|
const group1 = Math.floor(Math.random() * this.serial);
|
||||||
let group2 = Math.floor(Math.random() * this.serial);
|
let group2 = Math.floor(Math.random() * this.serial);
|
||||||
while (group2 === group1 && this.serial > 1) {
|
while (group2 === group1 && this.serial > 1) {
|
||||||
@ -460,7 +412,6 @@ class SimulatedAnnealing {
|
|||||||
newConfig[group1][cell1] = newConfig[group2][cell2];
|
newConfig[group1][cell1] = newConfig[group2][cell2];
|
||||||
newConfig[group2][cell2] = temp;
|
newConfig[group2][cell2] = temp;
|
||||||
} else {
|
} else {
|
||||||
// Swap cells within the same group
|
|
||||||
const groupIdx = Math.floor(Math.random() * this.serial);
|
const groupIdx = Math.floor(Math.random() * this.serial);
|
||||||
if (this.parallel >= 2) {
|
if (this.parallel >= 2) {
|
||||||
const cell1 = Math.floor(Math.random() * this.parallel);
|
const cell1 = Math.floor(Math.random() * this.parallel);
|
||||||
@ -477,12 +428,7 @@ class SimulatedAnnealing {
|
|||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
run() {
|
||||||
* Run simulated annealing.
|
|
||||||
*/
|
|
||||||
async run() {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
let current = this.createInitialConfig();
|
let current = this.createInitialConfig();
|
||||||
let currentScore = calculateScore(current, this.capacityWeight, this.irWeight);
|
let currentScore = calculateScore(current, this.capacityWeight, this.irWeight);
|
||||||
|
|
||||||
@ -490,17 +436,20 @@ class SimulatedAnnealing {
|
|||||||
this.bestScore = currentScore.score;
|
this.bestScore = currentScore.score;
|
||||||
|
|
||||||
let temperature = this.initialTemp;
|
let temperature = this.initialTemp;
|
||||||
|
let acceptedMoves = 0;
|
||||||
|
let totalMoves = 0;
|
||||||
|
|
||||||
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
||||||
const neighbor = this.getNeighbor(current);
|
const neighbor = this.getNeighbor(current);
|
||||||
const neighborScore = calculateScore(neighbor, this.capacityWeight, this.irWeight);
|
const neighborScore = calculateScore(neighbor, this.capacityWeight, this.irWeight);
|
||||||
|
|
||||||
const delta = neighborScore.score - currentScore.score;
|
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)) {
|
if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) {
|
||||||
current = neighbor;
|
current = neighbor;
|
||||||
currentScore = neighborScore;
|
currentScore = neighborScore;
|
||||||
|
acceptedMoves++;
|
||||||
|
|
||||||
if (currentScore.score < this.bestScore) {
|
if (currentScore.score < this.bestScore) {
|
||||||
this.bestScore = currentScore.score;
|
this.bestScore = currentScore.score;
|
||||||
@ -508,21 +457,25 @@ class SimulatedAnnealing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cool down
|
|
||||||
temperature *= this.coolingRate;
|
temperature *= this.coolingRate;
|
||||||
|
this.stats.recordIteration();
|
||||||
|
|
||||||
// Progress callback
|
if (iteration % 50 === 0 || iteration === this.maxIterations - 1) {
|
||||||
if (iteration % 100 === 0 || iteration === this.maxIterations - 1) {
|
const stats = this.stats.getStats(iteration, this.maxIterations);
|
||||||
this.onProgress({
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
data: {
|
||||||
iteration,
|
iteration,
|
||||||
maxIterations: this.maxIterations,
|
maxIterations: this.maxIterations,
|
||||||
bestScore: this.bestScore,
|
bestScore: this.bestScore,
|
||||||
currentBest: this.bestSolution,
|
currentBest: this.bestSolution,
|
||||||
temperature,
|
temperature,
|
||||||
elapsedTime: Date.now() - startTime
|
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,
|
groupCapacities: this.bestSolution.groupCapacities,
|
||||||
excludedCells,
|
excludedCells,
|
||||||
iterations: this.maxIterations,
|
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 {
|
class ExhaustiveSearch {
|
||||||
constructor(cells, serial, parallel, options = {}) {
|
constructor(cells, serial, parallel, options = {}) {
|
||||||
this.cells = cells;
|
this.cells = cells;
|
||||||
@ -559,21 +508,18 @@ class ExhaustiveSearch {
|
|||||||
|
|
||||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||||
this.irWeight = options.irWeight ?? 0.3;
|
this.irWeight = options.irWeight ?? 0.3;
|
||||||
this.onProgress = options.onProgress || (() => { });
|
|
||||||
this.maxIterations = options.maxIterations || 100000;
|
this.maxIterations = options.maxIterations || 100000;
|
||||||
|
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
this.bestSolution = null;
|
this.bestSolution = null;
|
||||||
this.bestScore = Infinity;
|
this.bestScore = Infinity;
|
||||||
|
this.stats = new StatsTracker();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all combinations of k elements from array.
|
|
||||||
*/
|
|
||||||
*combinations(array, k) {
|
*combinations(array, k) {
|
||||||
if (k === 0) {
|
if (k === 0) {
|
||||||
yield [];
|
yield [];
|
||||||
@ -588,9 +534,6 @@ class ExhaustiveSearch {
|
|||||||
yield* this.combinations(rest, k);
|
yield* this.combinations(rest, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate all partitions of cells into groups.
|
|
||||||
*/
|
|
||||||
*generatePartitions(cells, groupSize, numGroups) {
|
*generatePartitions(cells, groupSize, numGroups) {
|
||||||
if (numGroups === 0) {
|
if (numGroups === 0) {
|
||||||
yield [];
|
yield [];
|
||||||
@ -607,11 +550,41 @@ class ExhaustiveSearch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async run() {
|
calculateTotalCombinations() {
|
||||||
const startTime = Date.now();
|
// Formula: C(n, k) * C(n-k, k) * ... / numGroups! for identical groups
|
||||||
let iteration = 0;
|
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
|
const cellCombos = this.cells.length > this.totalCellsNeeded
|
||||||
? this.combinations(this.cells, this.totalCellsNeeded)
|
? this.combinations(this.cells, this.totalCellsNeeded)
|
||||||
: [[...this.cells]];
|
: [[...this.cells]];
|
||||||
@ -630,17 +603,23 @@ class ExhaustiveSearch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
iteration++;
|
iteration++;
|
||||||
|
this.stats.recordIteration();
|
||||||
|
|
||||||
if (iteration % 1000 === 0) {
|
if (iteration % 500 === 0) {
|
||||||
this.onProgress({
|
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
data: {
|
||||||
iteration,
|
iteration,
|
||||||
maxIterations: this.maxIterations,
|
maxIterations: Math.min(totalCombinations, this.maxIterations),
|
||||||
bestScore: this.bestScore,
|
bestScore: this.bestScore,
|
||||||
currentBest: this.bestSolution,
|
currentBest: this.bestSolution,
|
||||||
elapsedTime: Date.now() - startTime
|
totalCombinations,
|
||||||
|
evaluatedCombinations: iteration,
|
||||||
|
...stats
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (iteration >= this.maxIterations) {
|
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 usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||||
|
|
||||||
@ -661,20 +655,50 @@ class ExhaustiveSearch {
|
|||||||
groupCapacities: this.bestSolution.groupCapacities,
|
groupCapacities: this.bestSolution.groupCapacities,
|
||||||
excludedCells,
|
excludedCells,
|
||||||
iterations: iteration,
|
iterations: iteration,
|
||||||
elapsedTime: Date.now() - startTime
|
elapsedTime: Date.now() - this.stats.startTime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export
|
// Worker Message Handler
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Make available globally for the main app
|
let currentAlgorithm = null;
|
||||||
window.CellMatchingAlgorithms = {
|
|
||||||
GeneticAlgorithm,
|
self.onmessage = function (e) {
|
||||||
SimulatedAnnealing,
|
const { type, data } = e.data;
|
||||||
ExhaustiveSearch,
|
|
||||||
calculateScore,
|
switch (type) {
|
||||||
coefficientOfVariation
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user