Rewrite as static webapp #1

Open
localhorst wants to merge 10 commits from feature/full-rewrite into master
8 changed files with 2538 additions and 146608 deletions

View File

@ -1,4 +1,4 @@
MIT License Copyright (c) <year> <copyright holders>
MIT License Copyright (c) <2025> <Hendrik Schutter>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

File diff suppressed because it is too large Load Diff

125
README.md
View File

@ -1,13 +1,120 @@
# LiXX Cell Pack Matcher
Tool for finding the best configuration in a LiXX Battery Pack.
Matches capacity in parallel cell groups from a serial pack.
A web-based tool for finding the optimal cell configuration in lithium battery packs. It matches cells based on capacity and internal resistance to maximize pack performance and longevity.
## Working
- Matches cells bases on capacity for varius Pack configuration. Set parallel and serial cell count respectively.
- Supports labels as identifier for cells.
![License](https://img.shields.io/badge/license-MIT-blue.svg)
## Not Working
- Clould be faster, 6S2P needs more than 10min to compute
- Support internal cell resistance matching
- Support bigger cell pool for a pack that is needed
## Features
- **Pack Configuration**: Support for any SxP configuration (e.g., 6S2P, 4S3P, 12S4P)
- **Cell Matching**: Optimize by capacity (mAh) and internal resistance (mΩ)
- **Multiple Algorithms**:
- Exhaustive Search (optimal for small configurations)
- **Surplus Cell Support**: Use more cells than needed; the algorithm selects the best subset
- **Live Progress**: Watch the optimization in real-time
- **Visual Pack Layout**: Color-coded visualization of the matched pack
- **Export Options**: JSON, CSV, and clipboard support
- **Keyboard Accessible**: Full keyboard navigation support
- **No Dependencies**: Pure HTML/CSS/JavaScript, no build step required
## Scientific Background
This tool implements cell matching algorithms based on research findings about lithium-ion battery pack assembly:
> **Internal resistance matching for parallel-connected lithium-ion cells and impacts on battery pack cycle life**
>
> Shi et al., Journal of Power Sources (2013)
> DOI: [10.1016/j.jpowsour.2013.11.064](https://doi.org/10.1016/j.jpowsour.2013.11.064)
Key findings:
- A 20% difference in internal resistance between parallel-connected cells can reduce cycle life by approximately 40%
- Resistance mismatch causes uneven current distribution
- Uneven current leads to higher operating temperatures and accelerated capacity fade
## Usage
### Quick Start
1. Open `index.html` in a web browser
2. Set your pack configuration (e.g., 6S2P)
3. Enter cell data (label, capacity, and optionally internal resistance)
4. Click "Load Example" to see sample data
5. Adjust weights for capacity vs. IR matching
6. Click "Start Matching"
7. Review results and export if needed
### Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Alt + A` | Add new cell |
| `Alt + S` | Start matching |
| `Alt + E` | Load example data |
| `Esc` | Stop matching / Close dialog |
| `?` | Show keyboard shortcuts |
### Cell Data Format
Each cell requires:
- **Label**: Unique identifier (e.g., "B01", "Cell-A")
- **Capacity**: Measured capacity in mAh
- **Internal Resistance** (optional): Measured IR in mΩ
### Algorithm Selection
| Algorithm | Best For | Speed |
|-----------|----------|-------|
| Genetic Algorithm | Most cases, large pools | Fast |
| Simulated Annealing | Avoiding local optima | Medium |
| Exhaustive | Small configs (<8 cells) | Slow |
### Matching Weights
- **Capacity Weight**: Importance of matching parallel group capacities
- **IR Weight**: Importance of matching internal resistance within parallel groups
For current high-rate applications (e.g., power tools, EVs), increase IR weight.
For capacity-focused applications, increase capacity weight.
## Project Structure
```
lixx_cell_pack_matcher/
├── index.html # Main application
├── css/
│ └── styles.css # Application styles
├── js/
│ ├── app.js # Main application logic
│ └── matching-algorithms.js # Matching algorithms
├── data/
│ └── favicon.svg # Application icon
├── README.md # This file
└── LICENSE # MIT License
```
## Technical Details
### Scoring Algorithm
The match quality score is calculated as:
```
score = (capacityWeight × capacityCV) + (irWeight × avgWithinGroupIRCV)
```
Where:
- `capacityCV`: Coefficient of variation of parallel group capacities
- `avgWithinGroupIRCV`: Average coefficient of variation of IR within each parallel group
- Lower score = better match
### Coefficient of Variation
```
CV = (σ / μ) × 100%
```
Where σ is the standard deviation and μ is the mean.
## License
MIT License - see [LICENSE](LICENSE) for details.

1029
css/styles.css Normal file

File diff suppressed because it is too large Load Diff

152
data/favicon.svg Normal file
View File

@ -0,0 +1,152 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 48 48"
version="1.1"
id="svg6"
sodipodi:docname="favicon.svg"
inkscape:version="1.4.2 (unknown)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.958333"
inkscape:cx="23.970516"
inkscape:cy="24"
inkscape:window-width="1920"
inkscape:window-height="1022"
inkscape:window-x="0"
inkscape:window-y="30"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<defs
id="defs2">
<linearGradient
id="linearGradient14"
x1="0%"
y1="0%"
x2="100%"
y2="100%">
<stop
offset="0"
style="stop-color:#daf63b;stop-opacity:1;"
id="stop13" />
<stop
offset="100%"
style="stop-color:#22c55e"
id="stop14" />
</linearGradient>
<linearGradient
id="batteryGrad"
x1="0%"
y1="0%"
x2="100%"
y2="100%">
<stop
offset="0%"
style="stop-color:#3b82f6"
id="stop1" />
<stop
offset="100%"
style="stop-color:#22c55e"
id="stop2" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#batteryGrad"
id="linearGradient6"
x1="53.494549"
y1="15.119884"
x2="60.083276"
y2="21.503488"
gradientTransform="scale(0.63245553,1.5811388)"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#batteryGrad"
id="linearGradient7"
x1="31.622777"
y1="8.8543774"
x2="37.745319"
y2="15.088804"
gradientTransform="scale(0.63245553,1.5811388)"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#batteryGrad"
id="linearGradient8"
x1="15.811388"
y1="8.8543774"
x2="25.663399"
y2="12.030641"
gradientTransform="scale(0.63245553,1.5811388)"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient14"
id="linearGradient13"
x1="3"
y1="24"
x2="45"
y2="24"
gradientUnits="userSpaceOnUse" />
</defs>
<!-- Battery body -->
<rect
x="4"
y="8"
width="40"
height="32"
rx="4"
fill="none"
stroke="#3b82f6"
stroke-width="2"
id="rect2"
style="fill-opacity:1;fill:url(#linearGradient13)" />
<!-- Battery terminal -->
<rect
x="44"
y="18"
width="4"
height="12"
rx="1"
fill="#3b82f6"
id="rect3" />
<!-- Cells -->
<rect
x="10"
y="14"
width="8"
height="20"
rx="2"
fill="url(#batteryGrad)"
id="rect4"
style="fill:url(#linearGradient8)" />
<rect
x="20"
y="14"
width="8"
height="20"
rx="2"
fill="url(#batteryGrad)"
id="rect5"
style="fill:url(#linearGradient7)" />
<rect
x="30"
y="14"
width="8"
height="20"
rx="2"
fill="url(#batteryGrad)"
id="rect6"
style="fill:url(#linearGradient6)" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

337
index.html Normal file
View File

@ -0,0 +1,337 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LiXX Cell Pack Matcher</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="icon" href="data/favicon.svg" type="image/svg+xml">
</head>
<body>
<div class="container">
<header>
<div class="logo">
<img src="data/favicon.svg" width="40" />
<h1>LiXX Cell Pack Matcher</h1>
</div>
<p class="subtitle">Optimal cell matching for lithium battery packs</p>
</header>
<main>
<!-- Configuration Section -->
<section class="card" aria-labelledby="config-heading">
<h2 id="config-heading">Pack Configuration</h2>
<div class="config-grid">
<div class="form-group">
<label for="cells-serial">Series (S)</label>
<input type="number" id="cells-serial" min="1" max="42" value="6"
aria-describedby="serial-help">
<small id="serial-help">Number of cells in series</small>
</div>
<div class="form-group">
<label for="cells-parallel">Parallel (P)</label>
<input type="number" id="cells-parallel" min="1" max="42" value="2"
aria-describedby="parallel-help">
<small id="parallel-help">Number of cells in parallel</small>
</div>
<div class="form-group">
<label for="config-display">Configuration</label>
<output id="config-display" class="config-badge">6S2P</output>
<small>Total: <span id="total-cells-needed">12</span> cells</small>
</div>
</div>
</section>
<!-- Cell Input Section -->
<section class="card" aria-labelledby="cells-heading">
<h2 id="cells-heading">Cell Data</h2>
<div class="cell-input-header">
<p>Enter cell data: Label, Capacity (mAh), Internal Resistance (mΩ, optional)</p>
<div class="button-group">
<button type="button" id="btn-add-cell" class="btn btn-secondary">
<span aria-hidden="true">+</span> Add Cell
</button>
<button type="button" id="btn-load-example" class="btn btn-ghost">
Load Example
</button>
<button type="button" id="btn-clear-all" class="btn btn-ghost btn-danger">
Clear All
</button>
</div>
</div>
<div class="cell-table-wrapper">
<table class="cell-table" id="cell-table" role="grid">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Label</th>
<th scope="col">Capacity (mAh)</th>
<th scope="col">IR (mΩ)</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="cell-tbody">
<!-- Cells will be added dynamically -->
</tbody>
</table>
</div>
<div class="cell-stats" id="cell-stats" aria-live="polite">
<span>Cells: <strong id="stat-count">0</strong></span>
<span>Avg Capacity: <strong id="stat-avg-cap">-</strong></span>
<span>Avg IR: <strong id="stat-avg-ir">-</strong></span>
</div>
</section>
<!-- Algorithm Settings -->
<section class="card" aria-labelledby="algo-heading">
<h2 id="algo-heading">Matching Settings</h2>
<div class="settings-grid">
<div class="form-group">
<label for="weight-capacity">Capacity Weight</label>
<input type="range" id="weight-capacity" min="0" max="100" value="70"
aria-describedby="weight-cap-value">
<output id="weight-cap-value">70%</output>
</div>
<div class="form-group">
<label for="weight-ir">IR Weight</label>
<input type="range" id="weight-ir" min="0" max="100" value="30"
aria-describedby="weight-ir-value">
<output id="weight-ir-value">30%</output>
</div>
<div class="form-group">
<label for="algorithm-select">Algorithm</label>
<select id="algorithm-select">
<option value="exhaustive">Exhaustive Search (Small packs only)</option>
</select>
</div>
<div class="form-group">
<label for="max-iterations">Max Iterations</label>
<input type="number" id="max-iterations" min="100" max="100000" value="5000">
</div>
</div>
<button type="button" id="btn-start-matching" class="btn btn-primary btn-large">
<span class="btn-icon" aria-hidden="true"></span>
Start Matching
</button>
</section>
<!-- Progress Section -->
<section class="card" id="progress-section" hidden aria-labelledby="progress-heading">
<h2 id="progress-heading">Matching Progress</h2>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
<div class="progress-stats-grid">
<div class="stat-item">
<span class="stat-label">Iteration</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>
<button type="button" id="btn-stop-matching" class="btn btn-ghost btn-danger">
Stop Matching
</button>
</section>
<!-- Results Section -->
<section class="card" id="results-section" hidden aria-labelledby="results-heading">
<h2 id="results-heading">Matching Results</h2>
<!-- Warning Banner -->
<div class="warning-banner" role="alert">
<div class="warning-icon" aria-hidden="true">⚠️</div>
<div class="warning-content">
<strong>Safety Warning - Used Lithium Cells</strong>
<ul>
<li>Used cells may have hidden defects not detectable by capacity/IR testing</li>
<li>Internal resistance mismatch >20% can reduce cycle life by up to 40%</li>
<li>Always use a BMS with cell-level monitoring and balancing</li>
<li>Use only same model of cells in a pack.</li>
<li>Never charge unattended; use fireproof storage</li>
<li>Cells with significantly different ages may degrade unpredictably</li>
</ul>
</div>
</div>
<!-- Results Summary -->
<div class="results-summary" id="results-summary">
<div class="result-metric" tabindex="0" data-tooltip="match-score">
<span class="metric-value" id="result-score">-</span>
<span class="metric-label">Match Score</span>
<div class="metric-tooltip">
<strong>Match Score</strong>
<p>Combined score from capacity and IR variance, weighted by your settings.</p>
<div class="tooltip-scale">
<span class="scale-good">&lt; 0.5 = Excellent</span>
<span class="scale-ok">0.5 - 2.0 = Good</span>
<span class="scale-bad">&gt; 2.0 = Poor</span>
</div>
<p class="tooltip-hint">Lower is better</p>
</div>
</div>
<div class="result-metric" tabindex="0" data-tooltip="cap-cv">
<span class="metric-value" id="result-cap-variance">-</span>
<span class="metric-label">Capacity CV%</span>
<div class="metric-tooltip">
<strong>Capacity Coefficient of Variation</strong>
<p>Measures how evenly matched the total capacity of each parallel group is. CV = (σ / μ) ×
100%</p>
<div class="tooltip-scale">
<span class="scale-good">&lt; 1% = Excellent</span>
<span class="scale-ok">1 - 3% = Acceptable</span>
<span class="scale-bad">&gt; 3% = Poor balance</span>
</div>
<p class="tooltip-hint">Lower is better ensures even discharge across series groups</p>
</div>
</div>
<div class="result-metric" tabindex="0" data-tooltip="ir-cv">
<span class="metric-value" id="result-ir-variance">-</span>
<span class="metric-label">IR CV%</span>
<div class="metric-tooltip">
<strong>Internal Resistance Variation</strong>
<p>Average variation of internal resistance within parallel groups. High mismatch causes
uneven current distribution.</p>
<div class="tooltip-scale">
<span class="scale-good">&lt; 5% = Excellent</span>
<span class="scale-ok">5 - 15% = Acceptable</span>
<span class="scale-bad">&gt; 20% = Risk of 40% lifetime reduction</span>
</div>
<p class="tooltip-hint">Lower is better critical for high-drain applications</p>
</div>
</div>
<div class="result-metric" tabindex="0" data-tooltip="pack-cap">
<span class="metric-value" id="result-pack-capacity">-</span>
<span class="metric-label">Pack Capacity</span>
<div class="metric-tooltip">
<strong>Effective Pack Capacity</strong>
<p>The usable capacity of your pack, limited by the smallest parallel group (weakest link).
</p>
<p>Formula: min(group capacities)</p>
<p class="tooltip-hint">Higher is better well-matched cells maximize this value</p>
</div>
</div>
</div>
<!-- Visual Pack Layout -->
<div class="pack-visualization" id="pack-visualization">
<h3>Pack Layout</h3>
<div class="pack-grid" id="pack-grid">
<!-- Generated dynamically -->
</div>
<div class="pack-legend">
<span><span class="legend-color" style="background: var(--cell-low)"></span> Lower
Capacity</span>
<span><span class="legend-color" style="background: var(--cell-mid)"></span> Average</span>
<span><span class="legend-color" style="background: var(--cell-high)"></span> Higher
Capacity</span>
</div>
</div>
<!-- Detailed Results Table -->
<div class="results-table-wrapper">
<h3>Parallel Group Details</h3>
<table class="results-table" id="results-table">
<thead>
<tr>
<th>Group</th>
<th>Cells</th>
<th>Total Capacity</th>
<th>Avg IR</th>
<th>Deviation</th>
</tr>
</thead>
<tbody id="results-tbody">
<!-- Generated dynamically -->
</tbody>
</table>
</div>
<!-- Excluded Cells -->
<div class="excluded-cells" id="excluded-cells-section" hidden>
<h3>Excluded Cells</h3>
<p id="excluded-cells-list"></p>
</div>
<!-- Export Buttons -->
<div class="export-buttons">
<button type="button" id="btn-export-json" class="btn btn-secondary">
Export JSON
</button>
<button type="button" id="btn-export-csv" class="btn btn-secondary">
Export CSV
</button>
<button type="button" id="btn-copy-results" class="btn btn-secondary">
Copy to Clipboard
</button>
</div>
</section>
</main>
<footer>
<p>
<a href="https://git.mosad.xyz/localhorst/LiXX_Cell_Pack_Matcher" target="_blank" rel="noopener">Git</a>
·
Based on research by
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank" rel="noopener">Wang et al.,
2013</a>
</p>
<p class="disclaimer">
This tool is for educational purposes. Always consult professional guidance for battery pack assembly.
</p>
</footer>
</div>
<!-- Keyboard Shortcuts Modal -->
<dialog id="shortcuts-dialog">
<h2>Keyboard Shortcuts</h2>
<dl class="shortcuts-list">
<dt><kbd>Alt</kbd> + <kbd>A</kbd></dt>
<dd>Add new cell</dd>
<dt><kbd>Alt</kbd> + <kbd>S</kbd></dt>
<dd>Start matching</dd>
<dt><kbd>Alt</kbd> + <kbd>E</kbd></dt>
<dd>Load example data</dd>
<dt><kbd>Esc</kbd></dt>
<dd>Stop matching / Close dialog</dd>
<dt><kbd>?</kbd></dt>
<dd>Show this help</dd>
</dl>
<button type="button" class="btn btn-primary" id="btn-close-shortcuts">Close</button>
</dialog>
<script src="js/app.js"></script>
</body>
</html>

562
js/app.js Normal file
View File

@ -0,0 +1,562 @@
/**
* LiXX Cell Pack Matcher - Main Application
* Uses Web Workers for non-blocking computation.
*/
// =============================================================================
// Application State
// =============================================================================
const AppState = {
cells: [],
cellIdCounter: 0,
worker: null,
isRunning: false,
results: null
};
// =============================================================================
// DOM Elements
// =============================================================================
const DOM = {
cellsSerial: document.getElementById('cells-serial'),
cellsParallel: document.getElementById('cells-parallel'),
configDisplay: document.getElementById('config-display'),
totalCellsNeeded: document.getElementById('total-cells-needed'),
cellTbody: document.getElementById('cell-tbody'),
btnAddCell: document.getElementById('btn-add-cell'),
btnLoadExample: document.getElementById('btn-load-example'),
btnClearAll: document.getElementById('btn-clear-all'),
statCount: document.getElementById('stat-count'),
statAvgCap: document.getElementById('stat-avg-cap'),
statAvgIr: document.getElementById('stat-avg-ir'),
weightCapacity: document.getElementById('weight-capacity'),
weightIr: document.getElementById('weight-ir'),
weightCapValue: document.getElementById('weight-cap-value'),
weightIrValue: document.getElementById('weight-ir-value'),
algorithmSelect: document.getElementById('algorithm-select'),
maxIterations: document.getElementById('max-iterations'),
btnStartMatching: document.getElementById('btn-start-matching'),
btnStopMatching: document.getElementById('btn-stop-matching'),
progressSection: document.getElementById('progress-section'),
progressFill: document.getElementById('progress-fill'),
progressIteration: document.getElementById('progress-iteration'),
progressCombinations: document.getElementById('progress-combinations'),
progressScore: document.getElementById('progress-score'),
progressTime: document.getElementById('progress-time'),
progressSpeed: document.getElementById('progress-speed'),
progressEta: document.getElementById('progress-eta'),
livePreview: document.getElementById('live-preview'),
livePreviewContent: document.getElementById('live-preview-content'),
resultsSection: document.getElementById('results-section'),
resultScore: document.getElementById('result-score'),
resultCapVariance: document.getElementById('result-cap-variance'),
resultIrVariance: document.getElementById('result-ir-variance'),
resultPackCapacity: document.getElementById('result-pack-capacity'),
packGrid: document.getElementById('pack-grid'),
resultsTbody: document.getElementById('results-tbody'),
excludedCellsSection: document.getElementById('excluded-cells-section'),
excludedCellsList: document.getElementById('excluded-cells-list'),
btnExportJson: document.getElementById('btn-export-json'),
btnExportCsv: document.getElementById('btn-export-csv'),
btnCopyResults: document.getElementById('btn-copy-results'),
shortcutsDialog: document.getElementById('shortcuts-dialog'),
btnCloseShortcuts: document.getElementById('btn-close-shortcuts')
};
// =============================================================================
// Utility Functions
// =============================================================================
function formatDuration(ms) {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) {
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
}
const hours = Math.floor(ms / 3600000);
const mins = Math.round((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
}
function formatNumber(num) {
if (num === Infinity) return '∞';
if (num >= 1e9) return `${(num / 1e9).toFixed(1)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(1)}M`;
if (num >= 1e3) return `${(num / 1e3).toFixed(1)}K`;
return num.toLocaleString();
}
// =============================================================================
// Configuration Management
// =============================================================================
function updateConfigDisplay() {
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const total = serial * parallel;
DOM.configDisplay.textContent = `${serial}S${parallel}P`;
DOM.totalCellsNeeded.textContent = total;
updateMatchingButtonState();
}
// =============================================================================
// Cell Management
// =============================================================================
function addCell(cellData = null) {
const id = AppState.cellIdCounter++;
const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`;
const capacity = cellData?.capacity || '';
const ir = cellData?.ir || '';
const cell = { id, label, capacity: capacity || null, ir: ir || null };
AppState.cells.push(cell);
const row = document.createElement('tr');
row.dataset.cellId = id;
row.innerHTML = `
<td>${AppState.cells.length}</td>
<td><input type="text" class="cell-label-input" value="${label}" aria-label="Cell label" data-field="label"></td>
<td><input type="number" min="0" max="99999" value="${capacity}" aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh"></td>
<td><input type="number" min="0" max="9999" step="0.1" value="${ir}" aria-label="Internal resistance in milliohms" data-field="ir" placeholder="optional"></td>
<td><button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">✕</button></td>
`;
DOM.cellTbody.appendChild(row);
row.querySelectorAll('input').forEach(input => {
input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value));
});
row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id));
updateCellStats();
updateMatchingButtonState();
if (!cellData) {
row.querySelector('input[data-field="capacity"]').focus();
}
}
function updateCellData(id, field, value) {
const cell = AppState.cells.find(c => c.id === id);
if (!cell) return;
if (field === 'label') cell.label = value || `C${id}`;
else if (field === 'capacity') cell.capacity = value ? parseFloat(value) : null;
else if (field === 'ir') cell.ir = value ? parseFloat(value) : null;
updateCellStats();
updateMatchingButtonState();
}
function removeCell(id) {
const index = AppState.cells.findIndex(c => c.id === id);
if (index === -1) return;
AppState.cells.splice(index, 1);
const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`);
if (row) row.remove();
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
row.querySelector('td:first-child').textContent = idx + 1;
});
updateCellStats();
updateMatchingButtonState();
}
function clearAllCells() {
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
AppState.cells = [];
AppState.cellIdCounter = 0;
DOM.cellTbody.innerHTML = '';
updateCellStats();
updateMatchingButtonState();
}
function updateCellStats() {
const count = AppState.cells.length;
DOM.statCount.textContent = count;
if (count === 0) {
DOM.statAvgCap.textContent = '-';
DOM.statAvgIr.textContent = '-';
return;
}
const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity);
const irs = AppState.cells.filter(c => c.ir).map(c => c.ir);
DOM.statAvgCap.textContent = capacities.length > 0
? `${Math.round(capacities.reduce((a, b) => a + b, 0) / capacities.length)} mAh` : '-';
DOM.statAvgIr.textContent = irs.length > 0
? `${(irs.reduce((a, b) => a + b, 0) / irs.length).toFixed(1)}` : '-';
}
function loadExampleData() {
if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return;
AppState.cells = [];
AppState.cellIdCounter = 0;
DOM.cellTbody.innerHTML = '';
const exampleCells = [
{ label: 'B01', capacity: 3330, ir: 42 }, { label: 'B02', capacity: 3360, ir: 38 },
{ label: 'B03', capacity: 3230, ir: 45 }, { label: 'B04', capacity: 3390, ir: 41 },
{ label: 'B05', capacity: 3280, ir: 44 }, { label: 'B06', capacity: 3350, ir: 39 },
{ label: 'B07', capacity: 3350, ir: 40 }, { label: 'B08', capacity: 3490, ir: 36 },
{ label: 'B09', capacity: 3280, ir: 43 }, { label: 'B10', capacity: 3420, ir: 37 },
{ label: 'B11', capacity: 3350, ir: 41 }, { label: 'B12', capacity: 3420, ir: 38 },
{ label: 'B13', capacity: 3150, ir: 52 }, { label: 'B14', capacity: 3380, ir: 40 }
];
exampleCells.forEach(cell => addCell(cell));
}
// =============================================================================
// Weight Sliders
// =============================================================================
function updateWeights(source) {
const capWeight = parseInt(DOM.weightCapacity.value);
if (source === 'capacity') DOM.weightIr.value = 100 - capWeight;
else DOM.weightCapacity.value = 100 - parseInt(DOM.weightIr.value);
DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`;
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
}
// =============================================================================
// Matching Control (Web Worker)
// =============================================================================
function updateMatchingButtonState() {
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const needed = serial * parallel;
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
const canStart = validCells.length >= needed && !AppState.isRunning;
DOM.btnStartMatching.disabled = !canStart;
DOM.btnStartMatching.title = validCells.length < needed
? `Need at least ${needed} cells with capacity data` : '';
}
function startMatching() {
if (AppState.isRunning) return;
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
if (validCells.length < serial * parallel) {
alert(`Need at least ${serial * parallel} cells with capacity data.`);
return;
}
AppState.isRunning = true;
DOM.progressSection.hidden = false;
DOM.resultsSection.hidden = true;
DOM.btnStartMatching.disabled = true;
DOM.progressFill.style.width = '0%';
DOM.progressIteration.textContent = '0';
DOM.progressCombinations.textContent = '-';
DOM.progressScore.textContent = '-';
DOM.progressTime.textContent = '0s';
DOM.progressSpeed.textContent = '-';
DOM.progressEta.textContent = '-';
DOM.livePreview.hidden = true;
const algorithmType = DOM.algorithmSelect.value;
const maxIterations = parseInt(DOM.maxIterations.value) || 5000;
const capacityWeight = parseInt(DOM.weightCapacity.value) / 100;
const irWeight = parseInt(DOM.weightIr.value) / 100;
AppState.worker = new Worker('js/matching-worker.js');
AppState.worker.onmessage = function (e) {
const { type, data } = e.data;
if (type === 'progress') updateProgress(data);
else if (type === 'complete') handleComplete(data);
else if (type === 'error') handleError(data);
};
AppState.worker.onerror = function (error) {
console.error('Worker error:', error);
handleError(error.message);
};
AppState.worker.postMessage({
type: 'start',
data: {
cells: validCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
serial, parallel,
algorithm: algorithmType,
options: { maxIterations, capacityWeight, irWeight }
}
});
}
function stopMatching() {
console.log("stopMatching")
if (AppState.worker) {
AppState.worker.postMessage({ type: 'stop' });
}
}
function updateProgress(progress) {
const percent = (progress.iteration / progress.maxIterations) * 100;
DOM.progressFill.style.width = `${percent}%`;
DOM.progressIteration.textContent = formatNumber(progress.iteration);
DOM.progressCombinations.textContent = progress.totalCombinations
? `${formatNumber(progress.evaluatedCombinations)} / ${formatNumber(progress.totalCombinations)}`
: formatNumber(progress.evaluatedCombinations || progress.iteration);
DOM.progressScore.textContent = progress.bestScore.toFixed(4);
DOM.progressTime.textContent = 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
// =============================================================================
function displayResults(results) {
DOM.resultsSection.hidden = false;
DOM.resultScore.textContent = results.score.toFixed(3);
DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`;
DOM.resultIrVariance.textContent = results.irCV ? `${results.irCV.toFixed(2)}%` : 'N/A';
const packCapacity = Math.min(...results.groupCapacities);
DOM.resultPackCapacity.textContent = `${packCapacity} mAh`;
renderPackVisualization(results);
renderResultsTable(results);
if (results.excludedCells.length > 0) {
DOM.excludedCellsSection.hidden = false;
DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', ');
} else {
DOM.excludedCellsSection.hidden = true;
}
DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function renderPackVisualization(results) {
const config = results.configuration;
const allCapacities = config.flat().map(c => c.capacity);
const minCap = Math.min(...allCapacities);
const maxCap = Math.max(...allCapacities);
const range = maxCap - minCap || 1;
DOM.packGrid.innerHTML = '';
config.forEach((group, groupIdx) => {
const row = document.createElement('div');
row.className = 'pack-row';
const label = document.createElement('span');
label.className = 'pack-row-label';
label.textContent = `S${groupIdx + 1}`;
row.appendChild(label);
const cellsContainer = document.createElement('div');
cellsContainer.className = 'pack-cells';
group.forEach(cell => {
const cellEl = document.createElement('div');
cellEl.className = 'pack-cell';
const normalized = (cell.capacity - minCap) / range;
const hue = normalized * 120;
cellEl.style.backgroundColor = `hsl(${hue}, 70%, 45%)`;
cellEl.innerHTML = `<span class="cell-label">${cell.label}</span><span class="cell-capacity">${cell.capacity} mAh</span>${cell.ir ? `<span class="cell-ir">${cell.ir} mΩ</span>` : ''}`;
cellsContainer.appendChild(cellEl);
});
row.appendChild(cellsContainer);
DOM.packGrid.appendChild(row);
});
}
function renderResultsTable(results) {
const config = results.configuration;
const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length;
DOM.resultsTbody.innerHTML = '';
config.forEach((group, idx) => {
const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0);
const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100);
const irsWithValues = group.filter(c => c.ir);
const avgIr = irsWithValues.length > 0 ? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length : null;
let deviationClass = 'deviation-good';
if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning';
if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad';
const row = document.createElement('tr');
row.innerHTML = `<td>S${idx + 1}</td><td>${group.map(c => c.label).join(' + ')}</td><td>${groupCapacity} mAh</td><td>${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}</td><td class="${deviationClass}">${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%</td>`;
DOM.resultsTbody.appendChild(row);
});
}
// =============================================================================
// Export Functions
// =============================================================================
function exportJson() {
if (!AppState.results) return;
const data = {
configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
timestamp: new Date().toISOString(),
score: AppState.results.score,
capacityCV: AppState.results.capacityCV,
irCV: AppState.results.irCV,
groups: AppState.results.configuration.map((group, idx) => ({
group: `S${idx + 1}`,
cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0)
})),
excludedCells: AppState.results.excludedCells.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir }))
};
downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json');
}
function exportCsv() {
if (!AppState.results) return;
const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total'];
AppState.results.configuration.forEach((group, idx) => {
const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0);
group.forEach((cell, cellIdx) => {
lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`);
});
});
if (AppState.results.excludedCells.length > 0) {
lines.push('', 'Excluded Cells');
AppState.results.excludedCells.forEach(cell => lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`));
}
downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv');
}
async function copyResults() {
if (!AppState.results) return;
const config = AppState.results.configuration;
const lines = [`Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`, `Score: ${AppState.results.score.toFixed(3)}`, `Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`, '', 'Pack Configuration:'];
config.forEach((group, idx) => {
const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + ');
const total = group.reduce((sum, c) => sum + c.capacity, 0);
lines.push(` S${idx + 1}: ${cells} = ${total}mAh`);
});
if (AppState.results.excludedCells.length > 0) {
lines.push('', `Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`);
}
try {
await navigator.clipboard.writeText(lines.join('\n'));
DOM.btnCopyResults.textContent = 'Copied!';
setTimeout(() => { DOM.btnCopyResults.textContent = 'Copy to Clipboard'; }, 2000);
} catch (err) { console.error('Failed to copy:', err); }
}
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// =============================================================================
// Keyboard Navigation & Event Listeners
// =============================================================================
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'a') { e.preventDefault(); addCell(); }
if (e.altKey && e.key === 's') { e.preventDefault(); if (!DOM.btnStartMatching.disabled) startMatching(); }
if (e.altKey && e.key === 'e') { e.preventDefault(); loadExampleData(); }
if (e.key === 'Escape') { if (AppState.isRunning) stopMatching(); if (DOM.shortcutsDialog.open) DOM.shortcutsDialog.close(); }
if (e.key === '?' && !e.target.matches('input, textarea')) { e.preventDefault(); DOM.shortcutsDialog.showModal(); }
});
}
function initEventListeners() {
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
DOM.btnAddCell.addEventListener('click', () => addCell());
DOM.btnLoadExample.addEventListener('click', loadExampleData);
DOM.btnClearAll.addEventListener('click', clearAllCells);
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
DOM.btnStartMatching.addEventListener('click', startMatching);
DOM.btnStopMatching.addEventListener('click', stopMatching);
DOM.btnExportJson.addEventListener('click', exportJson);
DOM.btnExportCsv.addEventListener('click', exportCsv);
DOM.btnCopyResults.addEventListener('click', copyResults);
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
}
// =============================================================================
// Initialization
// =============================================================================
function init() {
initEventListeners();
setupKeyboardShortcuts();
updateConfigDisplay();
updateWeights('capacity');
updateMatchingButtonState();
for (let i = 0; i < 3; i++) addCell();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();

341
js/matching-worker.js Normal file
View File

@ -0,0 +1,341 @@
/**
* LiXX Cell Pack Matcher - Web Worker
*
* Runs matching algorithms in a background thread to keep the UI responsive.
* Communicates with the main thread via postMessage.
*/
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Calculate the coefficient of variation (CV) as a percentage.
*/
function coefficientOfVariation(values) {
if (!values || values.length === 0) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
if (mean === 0) return 0;
const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length;
return (Math.sqrt(variance) / mean) * 100;
}
/**
* Shuffle array in place using Fisher-Yates algorithm.
*/
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Deep clone an array of arrays.
*/
function cloneConfiguration(arr) {
return arr.map(group => [...group]);
}
/**
* Shuffle cells based on random
*/
function shuffleCells(cells) {
const shuffled = [...cells];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// =============================================================================
// Scoring Functions
// =============================================================================
/**
* Calculate the match score for a pack configuration.
* Lower score = better match.
*/
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
const groupCapacities = configuration.map(group =>
group.reduce((sum, cell) => sum + cell.capacity, 0)
);
const groupIRs = configuration.map(group => {
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
if (irsWithValues.length === 0) return null;
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
}).filter(ir => ir !== null);
const withinGroupIRVariances = configuration.map(group => {
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
if (irsWithValues.length < 2) return 0;
const irs = irsWithValues.map(cell => cell.ir);
return coefficientOfVariation(irs);
});
const capacityCV = coefficientOfVariation(groupCapacities);
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
: 0;
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
return {
score,
capacityCV,
irCV: avgWithinGroupIRCV,
groupCapacities,
groupIRs,
withinGroupIRVariances
};
}
// =============================================================================
// Statistics Tracker
// =============================================================================
class StatsTracker {
constructor() {
this.startTime = Date.now();
this.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
};
}
}
// =============================================================================
// Exhaustive Search
// =============================================================================
class ExhaustiveSearch {
constructor(cells, serial, parallel, options = {}) {
this.cells = shuffleCells(cells);
this.serial = serial;
this.parallel = parallel;
this.totalCellsNeeded = serial * parallel;
this.capacityWeight = options.capacityWeight ?? 0.7;
this.irWeight = options.irWeight ?? 0.3;
this.maxIterations = options.maxIterations || 100000;
this.stopped = false;
this.bestSolution = null;
this.bestScore = Infinity;
this.stats = new StatsTracker();
}
stop() {
console.log("ExhaustiveSearch: stop requested")
this.stopped = true;
}
*combinations(array, k) {
if (k === 0) {
yield [];
return;
}
if (array.length < k) return;
const [first, ...rest] = array;
for (const combo of this.combinations(rest, k - 1)) {
yield [first, ...combo];
}
yield* this.combinations(rest, k);
}
*generatePartitions(cells, groupSize, numGroups) {
if (numGroups === 0) {
yield [];
return;
}
if (cells.length < groupSize * numGroups) return;
for (const group of this.combinations(cells, groupSize)) {
const remaining = cells.filter(c => !group.includes(c));
for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) {
yield [group, ...rest];
}
}
}
calculateTotalCombinations() {
// Formula: C(n, k) * C(n-k, k) * ... / numGroups! for identical groups
const n = this.cells.length;
const k = this.parallel;
const numGroups = this.serial;
let total = 1;
let remaining = n;
for (let i = 0; i < numGroups; i++) {
total *= this.binomial(remaining, k);
remaining -= k;
}
// Divide by numGroups! if groups are interchangeable
// (but for battery packs, position matters, so we don't divide)
return total;
}
binomial(n, k) {
if (k > n) return 0;
if (k === 0 || k === n) return 1;
let result = 1;
for (let i = 0; i < k; i++) {
result = result * (n - i) / (i + 1);
}
return Math.round(result);
}
run() {
let iteration = 0;
const totalCombinations = this.calculateTotalCombinations();
const cellCombos = this.cells.length > this.totalCellsNeeded
? this.combinations(this.cells, this.totalCellsNeeded)
: [[...this.cells]];
for (const cellSubset of cellCombos) {
if (this.stopped) break;
for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) {
if (this.stopped) break;
const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight);
if (scoreResult.score < this.bestScore) {
this.bestScore = scoreResult.score;
this.bestSolution = { config: partition, ...scoreResult };
}
iteration++;
this.stats.recordIteration();
if (iteration % (this.maxIterations * 0.01) === 0) {
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
self.postMessage({
type: 'progress',
data: {
iteration,
maxIterations: Math.min(totalCombinations, this.maxIterations),
bestScore: this.bestScore,
currentBest: this.bestSolution,
totalCombinations,
evaluatedCombinations: iteration,
...stats
}
});
}
if (iteration >= this.maxIterations) {
this.stopped = true;
break;
}
}
}
// Final progress update
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
self.postMessage({
type: 'progress',
data: {
iteration,
maxIterations: Math.min(totalCombinations, this.maxIterations),
bestScore: this.bestScore,
currentBest: this.bestSolution,
totalCombinations,
evaluatedCombinations: iteration,
...stats
}
});
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
return {
configuration: this.bestSolution.config,
score: this.bestScore,
capacityCV: this.bestSolution.capacityCV,
irCV: this.bestSolution.irCV,
groupCapacities: this.bestSolution.groupCapacities,
excludedCells,
iterations: iteration,
elapsedTime: Date.now() - this.stats.startTime
};
}
}
// =============================================================================
// Worker Message Handler
// =============================================================================
let currentAlgorithm = null;
self.onmessage = function (e) {
const { type, data } = e.data;
switch (type) {
case 'start':
const { cells, serial, parallel, algorithm, options } = data;
switch (algorithm) {
case 'exhaustive':
currentAlgorithm = new ExhaustiveSearch(cells, serial, parallel, options);
break;
}
try {
const result = currentAlgorithm.run();
self.postMessage({ type: 'complete', data: result });
} catch (error) {
self.postMessage({ type: 'error', data: error.message });
}
currentAlgorithm = null;
break;
case 'stop':
console.log("Algo: Stop requested")
if (currentAlgorithm) {
currentAlgorithm.stop();
}
break;
}
};