switch to web app

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

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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

127
README.md
View File

@ -1,13 +1,122 @@
# LiXX Cell Pack Matcher # LiXX Cell Pack Matcher
Tool for finding the best configuration in a LiXX Battery 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.
Matches capacity in parallel cell groups from a serial pack.
## Working ![License](https://img.shields.io/badge/license-MIT-blue.svg)
- Matches cells bases on capacity for varius Pack configuration. Set parallel and serial cell count respectively.
- Supports labels as identifier for cells.
## Not Working ## Features
- Clould be faster, 6S2P needs more than 10min to compute
- Support internal cell resistance matching - **Pack Configuration**: Support for any SxP configuration (e.g., 6S2P, 4S3P, 12S4P)
- Support bigger cell pool for a pack that is needed - **Cell Matching**: Optimize by capacity (mAh) and internal resistance (mΩ)
- **Multiple Algorithms**:
- Genetic Algorithm (fast, recommended)
- Simulated Annealing (good for escaping local minima)
- 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.

854
css/styles.css Normal file
View File

@ -0,0 +1,854 @@
/* ==========================================================================
LiXX Cell Pack Matcher - Styles
========================================================================== */
/* --------------------------------------------------------------------------
CSS Custom Properties (Variables)
-------------------------------------------------------------------------- */
:root {
/* Colors */
--bg-primary: #0f1419;
--bg-secondary: #1a1f2e;
--bg-tertiary: #242b3d;
--text-primary: #e7e9ea;
--text-secondary: #8b98a5;
--text-muted: #6e7681;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-light: rgba(59, 130, 246, 0.15);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--border-color: #2f3336;
/* Cell visualization colors */
--cell-low: #ef4444;
--cell-mid: #22c55e;
--cell-high: #3b82f6;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
/* Typography */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Consolas, monospace;
/* Borders & Shadows */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
}
/* Light mode support */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}
}
/* --------------------------------------------------------------------------
Base Styles
-------------------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Focus styles for keyboard navigation */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
Layout
-------------------------------------------------------------------------- */
.container {
max-width: 1000px;
margin: 0 auto;
padding: var(--space-lg);
}
/* --------------------------------------------------------------------------
Header
-------------------------------------------------------------------------- */
header {
text-align: center;
margin-bottom: var(--space-2xl);
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
color: var(--accent);
}
.logo h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
}
.subtitle {
margin: var(--space-sm) 0 0;
color: var(--text-secondary);
font-size: 1.1rem;
}
/* --------------------------------------------------------------------------
Cards
-------------------------------------------------------------------------- */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-xl);
margin-bottom: var(--space-lg);
box-shadow: var(--shadow-sm);
}
.card h2 {
margin: 0 0 var(--space-lg);
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.card h3 {
margin: var(--space-lg) 0 var(--space-md);
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
}
/* --------------------------------------------------------------------------
Form Elements
-------------------------------------------------------------------------- */
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
input[type="text"],
input[type="number"],
select {
padding: var(--space-sm) var(--space-md);
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
input[type="text"]:hover,
input[type="number"]:hover,
select:hover {
border-color: var(--text-muted);
}
input[type="text"]:focus,
input[type="number"]:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
outline: none;
}
input[type="range"] {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
cursor: pointer;
accent-color: var(--accent);
}
small {
font-size: 0.75rem;
color: var(--text-muted);
}
output {
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--text-secondary);
}
/* --------------------------------------------------------------------------
Buttons
-------------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
text-decoration: none;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
color: white;
background: var(--accent);
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
color: var(--text-primary);
background: var(--bg-tertiary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-primary);
border-color: var(--text-muted);
}
.btn-ghost {
color: var(--text-secondary);
background: transparent;
}
.btn-ghost:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.btn-danger {
color: var(--danger);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
.btn-large {
padding: var(--space-md) var(--space-xl);
font-size: 1rem;
width: 100%;
}
.btn-icon {
font-size: 1.1em;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
/* --------------------------------------------------------------------------
Configuration Grid
-------------------------------------------------------------------------- */
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-lg);
}
.config-badge {
display: inline-block;
padding: var(--space-sm) var(--space-md);
font-size: 1.25rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent);
background: var(--accent-light);
border-radius: var(--radius-md);
}
/* --------------------------------------------------------------------------
Cell Input Section
-------------------------------------------------------------------------- */
.cell-input-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.cell-input-header p {
margin: 0;
color: var(--text-secondary);
}
.cell-table-wrapper {
overflow-x: auto;
margin: 0 calc(-1 * var(--space-xl));
padding: 0 var(--space-xl);
}
.cell-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.cell-table th,
.cell-table td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.cell-table th {
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-tertiary);
position: sticky;
top: 0;
}
.cell-table tbody tr:hover {
background: var(--bg-tertiary);
}
.cell-table input {
width: 100%;
min-width: 60px;
}
.cell-table .cell-label-input {
min-width: 80px;
}
.cell-table .btn-remove {
padding: var(--space-xs) var(--space-sm);
color: var(--danger);
background: transparent;
border: none;
cursor: pointer;
opacity: 0.7;
transition: opacity var(--transition-fast);
}
.cell-table .btn-remove:hover {
opacity: 1;
}
.cell-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
margin-top: var(--space-lg);
padding-top: var(--space-md);
border-top: 1px solid var(--border-color);
font-size: 0.875rem;
color: var(--text-secondary);
}
.cell-stats strong {
color: var(--text-primary);
font-family: var(--font-mono);
}
/* --------------------------------------------------------------------------
Settings Grid
-------------------------------------------------------------------------- */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-xl);
}
/* --------------------------------------------------------------------------
Progress Section
-------------------------------------------------------------------------- */
.progress-container {
margin-bottom: var(--space-lg);
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-md);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 4px;
transition: width var(--transition-normal);
}
.progress-stats {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
font-size: 0.875rem;
color: var(--text-secondary);
}
.progress-stats strong {
color: var(--text-primary);
font-family: var(--font-mono);
}
/* --------------------------------------------------------------------------
Warning Banner
-------------------------------------------------------------------------- */
.warning-banner {
display: flex;
gap: var(--space-md);
padding: var(--space-lg);
background: rgba(245, 158, 11, 0.1);
border: 1px solid var(--warning);
border-radius: var(--radius-md);
margin-bottom: var(--space-xl);
}
.warning-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.warning-content {
font-size: 0.875rem;
color: var(--text-primary);
}
.warning-content strong {
display: block;
margin-bottom: var(--space-sm);
color: var(--warning);
}
.warning-content ul {
margin: 0;
padding-left: var(--space-lg);
}
.warning-content li {
margin-bottom: var(--space-xs);
}
.warning-content a {
color: var(--accent);
}
/* --------------------------------------------------------------------------
Results Summary
-------------------------------------------------------------------------- */
.results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.result-metric {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-lg);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
text-align: center;
}
.metric-value {
font-size: 1.5rem;
font-weight: 700;
font-family: var(--font-mono);
color: var(--accent);
}
.metric-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--space-xs);
}
/* --------------------------------------------------------------------------
Pack Visualization
-------------------------------------------------------------------------- */
.pack-visualization {
margin-bottom: var(--space-xl);
}
.pack-grid {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow-x: auto;
}
.pack-row {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.pack-row-label {
min-width: 30px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-align: right;
}
.pack-cells {
display: flex;
gap: var(--space-xs);
}
.pack-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 70px;
padding: var(--space-sm);
background: var(--cell-mid);
border-radius: var(--radius-sm);
font-size: 0.7rem;
color: white;
text-align: center;
transition: transform var(--transition-fast);
}
.pack-cell:hover {
transform: scale(1.05);
}
.pack-cell .cell-label {
font-weight: 600;
margin-bottom: 2px;
}
.pack-cell .cell-capacity {
opacity: 0.9;
}
.pack-cell .cell-ir {
opacity: 0.7;
font-size: 0.65rem;
}
.pack-legend {
display: flex;
justify-content: center;
gap: var(--space-lg);
margin-top: var(--space-md);
font-size: 0.75rem;
color: var(--text-secondary);
}
.legend-color {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: var(--space-xs);
vertical-align: middle;
}
/* --------------------------------------------------------------------------
Results Table
-------------------------------------------------------------------------- */
.results-table-wrapper {
overflow-x: auto;
margin-bottom: var(--space-xl);
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.results-table th,
.results-table td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.results-table th {
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.results-table td {
font-family: var(--font-mono);
}
.results-table .deviation-good {
color: var(--success);
}
.results-table .deviation-warning {
color: var(--warning);
}
.results-table .deviation-bad {
color: var(--danger);
}
/* --------------------------------------------------------------------------
Excluded Cells
-------------------------------------------------------------------------- */
.excluded-cells {
padding: var(--space-lg);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: var(--space-xl);
}
.excluded-cells h3 {
margin-top: 0;
}
.excluded-cells p {
margin: 0;
font-family: var(--font-mono);
font-size: 0.875rem;
color: var(--text-secondary);
}
/* --------------------------------------------------------------------------
Export Buttons
-------------------------------------------------------------------------- */
.export-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
justify-content: center;
}
/* --------------------------------------------------------------------------
Footer
-------------------------------------------------------------------------- */
footer {
text-align: center;
padding: var(--space-xl) 0;
font-size: 0.875rem;
color: var(--text-muted);
}
footer a {
color: var(--text-secondary);
text-decoration: none;
}
footer a:hover {
color: var(--accent);
}
.disclaimer {
margin-top: var(--space-sm);
font-size: 0.75rem;
}
/* --------------------------------------------------------------------------
Dialog / Modal
-------------------------------------------------------------------------- */
dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 400px;
width: 90%;
padding: var(--space-xl);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
color: var(--text-primary);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.7);
}
dialog h2 {
margin: 0 0 var(--space-lg);
}
.shortcuts-list {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-sm) var(--space-lg);
margin-bottom: var(--space-xl);
}
.shortcuts-list dt {
font-weight: 500;
}
.shortcuts-list dd {
margin: 0;
color: var(--text-secondary);
}
kbd {
display: inline-block;
padding: 2px 6px;
font-size: 0.75rem;
font-family: var(--font-mono);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
}
/* --------------------------------------------------------------------------
Responsive Adjustments
-------------------------------------------------------------------------- */
@media (max-width: 640px) {
.container {
padding: var(--space-md);
}
.card {
padding: var(--space-lg);
}
.logo h1 {
font-size: 1.25rem;
}
.config-grid,
.settings-grid {
grid-template-columns: 1fr;
}
.cell-input-header {
flex-direction: column;
align-items: flex-start;
}
.button-group {
width: 100%;
}
.button-group .btn {
flex: 1;
}
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
.pack-cell {
min-width: 55px;
padding: var(--space-xs);
}
}
/* --------------------------------------------------------------------------
Reduced Motion
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* --------------------------------------------------------------------------
Print Styles
-------------------------------------------------------------------------- */
@media print {
body {
background: white;
color: black;
}
.card {
border: 1px solid #ccc;
box-shadow: none;
break-inside: avoid;
}
.btn,
#progress-section,
.export-buttons {
display: none;
}
.warning-banner {
border: 2px solid #f59e0b;
}
}

16
data/favicon.svg Normal file
View File

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<defs>
<linearGradient id="batteryGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#22c55e"/>
</linearGradient>
</defs>
<!-- Battery body -->
<rect x="4" y="8" width="40" height="32" rx="4" fill="none" stroke="#3b82f6" stroke-width="2"/>
<!-- Battery terminal -->
<rect x="44" y="18" width="4" height="12" rx="1" fill="#3b82f6"/>
<!-- Cells -->
<rect x="10" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
<rect x="20" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
<rect x="30" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

284
index.html Normal file
View File

@ -0,0 +1,284 @@
<!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">
<svg viewBox="0 0 48 48" width="40" height="40" aria-hidden="true">
<rect x="4" y="8" width="40" height="32" rx="4" fill="none" stroke="currentColor"
stroke-width="2" />
<rect x="44" y="18" width="4" height="12" rx="1" fill="currentColor" />
<rect x="10" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
<rect x="20" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
<rect x="30" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
</svg>
<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="genetic">Genetic Algorithm (Fast)</option>
<option value="simulated-annealing">Simulated Annealing</option>
<option value="exhaustive">Exhaustive (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">
<span>Iteration: <strong id="progress-iteration">0</strong></span>
<span>Best Score: <strong id="progress-score">-</strong></span>
<span>Time: <strong id="progress-time">0s</strong></span>
</div>
</div>
<button type="button" id="btn-stop-matching" class="btn btn-secondary">
Stop
</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%
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank"
rel="noopener">(Shi et al., 2013)</a>
</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>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">
<span class="metric-value" id="result-score">-</span>
<span class="metric-label">Match Score</span>
</div>
<div class="result-metric">
<span class="metric-value" id="result-cap-variance">-</span>
<span class="metric-label">Capacity CV%</span>
</div>
<div class="result-metric">
<span class="metric-value" id="result-ir-variance">-</span>
<span class="metric-label">IR CV%</span>
</div>
<div class="result-metric">
<span class="metric-value" id="result-pack-capacity">-</span>
<span class="metric-label">Pack Capacity</span>
</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">Shi 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/matching-algorithms.js"></script>
<script src="js/app.js"></script>
</body>
</html>

729
js/app.js Normal file
View File

@ -0,0 +1,729 @@
/**
* LiXX Cell Pack Matcher - Main Application
*
* A web application for optimal matching of lithium battery cells.
* Supports capacity and internal resistance matching with multiple algorithms.
*/
// =============================================================================
// Application State
// =============================================================================
const AppState = {
cells: [],
cellIdCounter: 0,
currentAlgorithm: null,
isRunning: false,
results: null
};
// =============================================================================
// DOM Elements
// =============================================================================
const DOM = {
// Configuration
cellsSerial: document.getElementById('cells-serial'),
cellsParallel: document.getElementById('cells-parallel'),
configDisplay: document.getElementById('config-display'),
totalCellsNeeded: document.getElementById('total-cells-needed'),
// Cell input
cellTbody: document.getElementById('cell-tbody'),
btnAddCell: document.getElementById('btn-add-cell'),
btnLoadExample: document.getElementById('btn-load-example'),
btnClearAll: document.getElementById('btn-clear-all'),
statCount: document.getElementById('stat-count'),
statAvgCap: document.getElementById('stat-avg-cap'),
statAvgIr: document.getElementById('stat-avg-ir'),
// Settings
weightCapacity: document.getElementById('weight-capacity'),
weightIr: document.getElementById('weight-ir'),
weightCapValue: document.getElementById('weight-cap-value'),
weightIrValue: document.getElementById('weight-ir-value'),
algorithmSelect: document.getElementById('algorithm-select'),
maxIterations: document.getElementById('max-iterations'),
// Matching
btnStartMatching: document.getElementById('btn-start-matching'),
btnStopMatching: document.getElementById('btn-stop-matching'),
// Progress
progressSection: document.getElementById('progress-section'),
progressFill: document.getElementById('progress-fill'),
progressIteration: document.getElementById('progress-iteration'),
progressScore: document.getElementById('progress-score'),
progressTime: document.getElementById('progress-time'),
// Results
resultsSection: document.getElementById('results-section'),
resultScore: document.getElementById('result-score'),
resultCapVariance: document.getElementById('result-cap-variance'),
resultIrVariance: document.getElementById('result-ir-variance'),
resultPackCapacity: document.getElementById('result-pack-capacity'),
packGrid: document.getElementById('pack-grid'),
resultsTbody: document.getElementById('results-tbody'),
excludedCellsSection: document.getElementById('excluded-cells-section'),
excludedCellsList: document.getElementById('excluded-cells-list'),
// Export
btnExportJson: document.getElementById('btn-export-json'),
btnExportCsv: document.getElementById('btn-export-csv'),
btnCopyResults: document.getElementById('btn-copy-results'),
// Dialog
shortcutsDialog: document.getElementById('shortcuts-dialog'),
btnCloseShortcuts: document.getElementById('btn-close-shortcuts')
};
// =============================================================================
// Configuration Management
// =============================================================================
/**
* Update the configuration display.
*/
function updateConfigDisplay() {
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const total = serial * parallel;
DOM.configDisplay.textContent = `${serial}S${parallel}P`;
DOM.totalCellsNeeded.textContent = total;
updateMatchingButtonState();
}
// =============================================================================
// Cell Management
// =============================================================================
/**
* Add a new cell row to the table.
* @param {Object} cellData - Optional initial data
*/
function addCell(cellData = null) {
const id = AppState.cellIdCounter++;
const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`;
const capacity = cellData?.capacity || '';
const ir = cellData?.ir || '';
const cell = { id, label, capacity: capacity || null, ir: ir || null };
AppState.cells.push(cell);
const row = document.createElement('tr');
row.dataset.cellId = id;
row.innerHTML = `
<td>${AppState.cells.length}</td>
<td>
<input type="text" class="cell-label-input" value="${label}"
aria-label="Cell label" data-field="label">
</td>
<td>
<input type="number" min="0" max="99999" value="${capacity}"
aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh">
</td>
<td>
<input type="number" min="0" max="9999" step="0.1" value="${ir}"
aria-label="Internal resistance in milliohms" data-field="ir" placeholder="optional">
</td>
<td>
<button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">
</button>
</td>
`;
DOM.cellTbody.appendChild(row);
// Add event listeners
row.querySelectorAll('input').forEach(input => {
input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value));
});
row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id));
updateCellStats();
updateMatchingButtonState();
// Focus the capacity input of the new row
if (!cellData) {
row.querySelector('input[data-field="capacity"]').focus();
}
}
/**
* Update cell data when input changes.
*/
function updateCellData(id, field, value) {
const cell = AppState.cells.find(c => c.id === id);
if (!cell) return;
if (field === 'label') {
cell.label = value || `C${id}`;
} else if (field === 'capacity') {
cell.capacity = value ? parseFloat(value) : null;
} else if (field === 'ir') {
cell.ir = value ? parseFloat(value) : null;
}
updateCellStats();
updateMatchingButtonState();
}
/**
* Remove a cell from the table.
*/
function removeCell(id) {
const index = AppState.cells.findIndex(c => c.id === id);
if (index === -1) return;
AppState.cells.splice(index, 1);
const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`);
if (row) row.remove();
// Update row numbers
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
row.querySelector('td:first-child').textContent = idx + 1;
});
updateCellStats();
updateMatchingButtonState();
}
/**
* Clear all cells.
*/
function clearAllCells() {
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
AppState.cells = [];
AppState.cellIdCounter = 0;
DOM.cellTbody.innerHTML = '';
updateCellStats();
updateMatchingButtonState();
}
/**
* Update cell statistics display.
*/
function updateCellStats() {
const count = AppState.cells.length;
DOM.statCount.textContent = count;
if (count === 0) {
DOM.statAvgCap.textContent = '-';
DOM.statAvgIr.textContent = '-';
return;
}
const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity);
const irs = AppState.cells.filter(c => c.ir).map(c => c.ir);
if (capacities.length > 0) {
const avgCap = capacities.reduce((a, b) => a + b, 0) / capacities.length;
DOM.statAvgCap.textContent = `${Math.round(avgCap)} mAh`;
} else {
DOM.statAvgCap.textContent = '-';
}
if (irs.length > 0) {
const avgIr = irs.reduce((a, b) => a + b, 0) / irs.length;
DOM.statAvgIr.textContent = `${avgIr.toFixed(1)}`;
} else {
DOM.statAvgIr.textContent = '-';
}
}
/**
* Load example cell data.
*/
function loadExampleData() {
if (AppState.cells.length > 0 && !confirm('Replace current cells with example data?')) return;
// Clear without confirmation since we just asked
AppState.cells = [];
AppState.cellIdCounter = 0;
DOM.cellTbody.innerHTML = '';
// Example: 14 cells for a 6S2P pack (2 spare)
const exampleCells = [
{ label: 'B01', capacity: 3330, ir: 42 },
{ label: 'B02', capacity: 3360, ir: 38 },
{ label: 'B03', capacity: 3230, ir: 45 },
{ label: 'B04', capacity: 3390, ir: 41 },
{ label: 'B05', capacity: 3280, ir: 44 },
{ label: 'B06', capacity: 3350, ir: 39 },
{ label: 'B07', capacity: 3350, ir: 40 },
{ label: 'B08', capacity: 3490, ir: 36 },
{ label: 'B09', capacity: 3280, ir: 43 },
{ label: 'B10', capacity: 3420, ir: 37 },
{ label: 'B11', capacity: 3350, ir: 41 },
{ label: 'B12', capacity: 3420, ir: 38 },
{ label: 'B13', capacity: 3150, ir: 52 }, // Spare - lower quality
{ label: 'B14', capacity: 3380, ir: 40 } // Spare
];
exampleCells.forEach(cell => addCell(cell));
}
// =============================================================================
// Weight Sliders
// =============================================================================
/**
* Update weight slider displays and keep them summing to 100%.
*/
function updateWeights(source) {
const capWeight = parseInt(DOM.weightCapacity.value);
const irWeight = parseInt(DOM.weightIr.value);
if (source === 'capacity') {
DOM.weightIr.value = 100 - capWeight;
} else {
DOM.weightCapacity.value = 100 - irWeight;
}
DOM.weightCapValue.textContent = `${DOM.weightCapacity.value}%`;
DOM.weightIrValue.textContent = `${DOM.weightIr.value}%`;
}
// =============================================================================
// Matching Control
// =============================================================================
/**
* Update the state of the matching button.
*/
function updateMatchingButtonState() {
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const needed = serial * parallel;
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
const canStart = validCells.length >= needed && !AppState.isRunning;
DOM.btnStartMatching.disabled = !canStart;
if (validCells.length < needed) {
DOM.btnStartMatching.title = `Need at least ${needed} cells with capacity data`;
} else {
DOM.btnStartMatching.title = '';
}
}
/**
* Start the matching process.
*/
async function startMatching() {
if (AppState.isRunning) return;
const serial = parseInt(DOM.cellsSerial.value) || 1;
const parallel = parseInt(DOM.cellsParallel.value) || 1;
const validCells = AppState.cells.filter(c => c.capacity && c.capacity > 0);
if (validCells.length < serial * parallel) {
alert(`Need at least ${serial * parallel} cells with capacity data.`);
return;
}
AppState.isRunning = true;
DOM.progressSection.hidden = false;
DOM.resultsSection.hidden = true;
DOM.btnStartMatching.disabled = true;
const algorithmType = DOM.algorithmSelect.value;
const maxIterations = parseInt(DOM.maxIterations.value) || 5000;
const capacityWeight = parseInt(DOM.weightCapacity.value) / 100;
const irWeight = parseInt(DOM.weightIr.value) / 100;
const options = {
maxIterations,
capacityWeight,
irWeight,
onProgress: updateProgress
};
// Create algorithm instance
const { GeneticAlgorithm, SimulatedAnnealing, ExhaustiveSearch } = window.CellMatchingAlgorithms;
switch (algorithmType) {
case 'genetic':
AppState.currentAlgorithm = new GeneticAlgorithm(validCells, serial, parallel, options);
break;
case 'simulated-annealing':
AppState.currentAlgorithm = new SimulatedAnnealing(validCells, serial, parallel, options);
break;
case 'exhaustive':
AppState.currentAlgorithm = new ExhaustiveSearch(validCells, serial, parallel, options);
break;
}
try {
const results = await AppState.currentAlgorithm.run();
AppState.results = results;
displayResults(results);
} catch (error) {
console.error('Matching error:', error);
alert('An error occurred during matching. See console for details.');
} finally {
AppState.isRunning = false;
AppState.currentAlgorithm = null;
DOM.progressSection.hidden = true;
DOM.btnStartMatching.disabled = false;
updateMatchingButtonState();
}
}
/**
* Stop the matching process.
*/
function stopMatching() {
if (AppState.currentAlgorithm) {
AppState.currentAlgorithm.stop();
}
}
/**
* Update progress display.
*/
function updateProgress(progress) {
const percent = (progress.iteration / progress.maxIterations) * 100;
DOM.progressFill.style.width = `${percent}%`;
DOM.progressIteration.textContent = progress.iteration.toLocaleString();
DOM.progressScore.textContent = progress.bestScore.toFixed(4);
DOM.progressTime.textContent = `${(progress.elapsedTime / 1000).toFixed(1)}s`;
}
// =============================================================================
// Results Display
// =============================================================================
/**
* Display the matching results.
*/
function displayResults(results) {
DOM.resultsSection.hidden = false;
// Summary metrics
DOM.resultScore.textContent = results.score.toFixed(3);
DOM.resultCapVariance.textContent = `${results.capacityCV.toFixed(2)}%`;
DOM.resultIrVariance.textContent = results.irCV ? `${results.irCV.toFixed(2)}%` : 'N/A';
// Calculate pack capacity (limited by smallest parallel group)
const packCapacity = Math.min(...results.groupCapacities);
DOM.resultPackCapacity.textContent = `${packCapacity} mAh`;
// Visualize pack layout
renderPackVisualization(results);
// Results table
renderResultsTable(results);
// Excluded cells
if (results.excludedCells.length > 0) {
DOM.excludedCellsSection.hidden = false;
DOM.excludedCellsList.textContent = results.excludedCells.map(c => c.label).join(', ');
} else {
DOM.excludedCellsSection.hidden = true;
}
// Scroll to results
DOM.resultsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* Render the pack visualization.
*/
function renderPackVisualization(results) {
const config = results.configuration;
const allCapacities = config.flat().map(c => c.capacity);
const minCap = Math.min(...allCapacities);
const maxCap = Math.max(...allCapacities);
const range = maxCap - minCap || 1;
DOM.packGrid.innerHTML = '';
config.forEach((group, groupIdx) => {
const row = document.createElement('div');
row.className = 'pack-row';
const label = document.createElement('span');
label.className = 'pack-row-label';
label.textContent = `S${groupIdx + 1}`;
row.appendChild(label);
const cellsContainer = document.createElement('div');
cellsContainer.className = 'pack-cells';
group.forEach(cell => {
const cellEl = document.createElement('div');
cellEl.className = 'pack-cell';
// Color based on relative capacity
const normalized = (cell.capacity - minCap) / range;
const hue = normalized * 120; // 0 = red, 60 = yellow, 120 = green
cellEl.style.backgroundColor = `hsl(${hue}, 70%, 45%)`;
cellEl.innerHTML = `
<span class="cell-label">${cell.label}</span>
<span class="cell-capacity">${cell.capacity} mAh</span>
${cell.ir ? `<span class="cell-ir">${cell.ir} mΩ</span>` : ''}
`;
cellsContainer.appendChild(cellEl);
});
row.appendChild(cellsContainer);
DOM.packGrid.appendChild(row);
});
}
/**
* Render the results table.
*/
function renderResultsTable(results) {
const config = results.configuration;
const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length;
DOM.resultsTbody.innerHTML = '';
config.forEach((group, idx) => {
const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0);
const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100);
const irsWithValues = group.filter(c => c.ir);
const avgIr = irsWithValues.length > 0
? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length
: null;
let deviationClass = 'deviation-good';
if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning';
if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad';
const row = document.createElement('tr');
row.innerHTML = `
<td>S${idx + 1}</td>
<td>${group.map(c => c.label).join(' + ')}</td>
<td>${groupCapacity} mAh</td>
<td>${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}</td>
<td class="${deviationClass}">${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%</td>
`;
DOM.resultsTbody.appendChild(row);
});
}
// =============================================================================
// Export Functions
// =============================================================================
/**
* Export results as JSON.
*/
function exportJson() {
if (!AppState.results) return;
const data = {
configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
timestamp: new Date().toISOString(),
score: AppState.results.score,
capacityCV: AppState.results.capacityCV,
irCV: AppState.results.irCV,
groups: AppState.results.configuration.map((group, idx) => ({
group: `S${idx + 1}`,
cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0)
})),
excludedCells: AppState.results.excludedCells.map(c => ({
label: c.label,
capacity: c.capacity,
ir: c.ir
}))
};
downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json');
}
/**
* Export results as CSV.
*/
function exportCsv() {
if (!AppState.results) return;
const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total'];
AppState.results.configuration.forEach((group, idx) => {
const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0);
group.forEach((cell, cellIdx) => {
lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`);
});
});
if (AppState.results.excludedCells.length > 0) {
lines.push('');
lines.push('Excluded Cells');
AppState.results.excludedCells.forEach(cell => {
lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`);
});
}
downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv');
}
/**
* Copy results to clipboard.
*/
async function copyResults() {
if (!AppState.results) return;
const config = AppState.results.configuration;
const lines = [
`Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
`Score: ${AppState.results.score.toFixed(3)}`,
`Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`,
'',
'Pack Configuration:'
];
config.forEach((group, idx) => {
const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + ');
const total = group.reduce((sum, c) => sum + c.capacity, 0);
lines.push(` S${idx + 1}: ${cells} = ${total}mAh`);
});
if (AppState.results.excludedCells.length > 0) {
lines.push('');
lines.push(`Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`);
}
try {
await navigator.clipboard.writeText(lines.join('\n'));
DOM.btnCopyResults.textContent = 'Copied!';
setTimeout(() => {
DOM.btnCopyResults.textContent = 'Copy to Clipboard';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
/**
* Helper function to download a file.
*/
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// =============================================================================
// Keyboard Navigation
// =============================================================================
/**
* Setup keyboard shortcuts.
*/
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Alt + A: Add cell
if (e.altKey && e.key === 'a') {
e.preventDefault();
addCell();
}
// Alt + S: Start matching
if (e.altKey && e.key === 's') {
e.preventDefault();
if (!DOM.btnStartMatching.disabled) {
startMatching();
}
}
// Alt + E: Load example
if (e.altKey && e.key === 'e') {
e.preventDefault();
loadExampleData();
}
// Escape: Stop matching or close dialog
if (e.key === 'Escape') {
if (AppState.isRunning) {
stopMatching();
}
if (DOM.shortcutsDialog.open) {
DOM.shortcutsDialog.close();
}
}
// ?: Show shortcuts
if (e.key === '?' && !e.target.matches('input, textarea')) {
e.preventDefault();
DOM.shortcutsDialog.showModal();
}
});
}
// =============================================================================
// Event Listeners
// =============================================================================
function initEventListeners() {
// Configuration
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
// Cell management
DOM.btnAddCell.addEventListener('click', () => addCell());
DOM.btnLoadExample.addEventListener('click', loadExampleData);
DOM.btnClearAll.addEventListener('click', clearAllCells);
// Weight sliders
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
// Matching
DOM.btnStartMatching.addEventListener('click', startMatching);
DOM.btnStopMatching.addEventListener('click', stopMatching);
// Export
DOM.btnExportJson.addEventListener('click', exportJson);
DOM.btnExportCsv.addEventListener('click', exportCsv);
DOM.btnCopyResults.addEventListener('click', copyResults);
// Dialog
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
}
// =============================================================================
// Initialization
// =============================================================================
function init() {
initEventListeners();
setupKeyboardShortcuts();
updateConfigDisplay();
updateWeights('capacity');
updateMatchingButtonState();
// Add a few empty cell rows to start
for (let i = 0; i < 3; i++) {
addCell();
}
}
// Start the application when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

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

@ -0,0 +1,680 @@
/**
* LiXX Cell Pack Matcher - Matching Algorithms
*
* Implements optimized algorithms for lithium cell matching:
* - Genetic Algorithm (default, fast)
* - Simulated Annealing
* - Exhaustive search (for small configurations)
*
* Based on research:
* - Shi et al., 2013: "Internal resistance matching for parallel-connected
* lithium-ion cells and impacts on battery pack cycle life"
* DOI: 10.1016/j.jpowsour.2013.11.064
*/
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Calculate the coefficient of variation (CV) as a percentage.
* CV = (standard deviation / mean) * 100
* @param {number[]} values - Array of numeric values
* @returns {number} CV as percentage, or 0 if invalid
*/
function coefficientOfVariation(values) {
if (!values || values.length === 0) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
if (mean === 0) return 0;
const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length;
return (Math.sqrt(variance) / mean) * 100;
}
/**
* Shuffle array in place using Fisher-Yates algorithm.
* @param {Array} array - Array to shuffle
* @returns {Array} The same array, shuffled
*/
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Deep clone an array of arrays.
* @param {Array[]} arr - Array to clone
* @returns {Array[]} Cloned array
*/
function cloneConfiguration(arr) {
return arr.map(group => [...group]);
}
// =============================================================================
// Scoring Functions
// =============================================================================
/**
* Calculate the match score for a pack configuration.
* Lower score = better match.
*
* The score combines:
* - Capacity variance between parallel groups (weighted by capacityWeight)
* - Internal resistance variance within parallel groups (weighted by irWeight)
*
* @param {Object[][]} configuration - Array of parallel groups, each containing cell objects
* @param {number} capacityWeight - Weight for capacity matching (0-1)
* @param {number} irWeight - Weight for IR matching (0-1)
* @returns {Object} Score breakdown
*/
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
// Calculate total capacity for each parallel group
const groupCapacities = configuration.map(group =>
group.reduce((sum, cell) => sum + cell.capacity, 0)
);
// Calculate average IR for each parallel group
const groupIRs = configuration.map(group => {
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
if (irsWithValues.length === 0) return null;
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
}).filter(ir => ir !== null);
// Calculate IR variance within each parallel group (important for parallel cells)
const withinGroupIRVariances = configuration.map(group => {
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
if (irsWithValues.length < 2) return 0;
const irs = irsWithValues.map(cell => cell.ir);
return coefficientOfVariation(irs);
});
// Capacity CV between groups (should be low for balanced pack)
const capacityCV = coefficientOfVariation(groupCapacities);
// Average IR CV within groups (should be low for parallel cells)
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
: 0;
// Combined score (lower is better)
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
return {
score,
capacityCV,
irCV: avgWithinGroupIRCV,
groupCapacities,
groupIRs,
withinGroupIRVariances
};
}
// =============================================================================
// Genetic Algorithm
// =============================================================================
/**
* Genetic Algorithm for cell matching.
* Fast and effective for most configurations.
*/
class GeneticAlgorithm {
/**
* @param {Object[]} cells - Array of cell objects {label, capacity, ir}
* @param {number} serial - Number of series groups
* @param {number} parallel - Number of cells in parallel per group
* @param {Object} options - Algorithm options
*/
constructor(cells, serial, parallel, options = {}) {
this.cells = cells;
this.serial = serial;
this.parallel = parallel;
this.totalCellsNeeded = serial * parallel;
// Options with defaults
this.populationSize = options.populationSize || 50;
this.maxIterations = options.maxIterations || 5000;
this.mutationRate = options.mutationRate || 0.15;
this.eliteCount = options.eliteCount || 5;
this.capacityWeight = options.capacityWeight ?? 0.7;
this.irWeight = options.irWeight ?? 0.3;
this.onProgress = options.onProgress || (() => { });
this.stopped = false;
this.bestSolution = null;
this.bestScore = Infinity;
}
/**
* Stop the algorithm.
*/
stop() {
this.stopped = true;
}
/**
* Create a random individual (configuration).
* @param {Object[]} cellPool - Cells to choose from
* @returns {Object[][]} Configuration
*/
createIndividual(cellPool) {
const shuffled = shuffleArray([...cellPool]).slice(0, this.totalCellsNeeded);
const configuration = [];
for (let i = 0; i < this.serial; i++) {
const group = [];
for (let j = 0; j < this.parallel; j++) {
group.push(shuffled[i * this.parallel + j]);
}
configuration.push(group);
}
return configuration;
}
/**
* Convert configuration to flat array of cell indices for crossover.
* @param {Object[][]} config - Configuration
* @returns {number[]} Flat array of cell indices
*/
configToIndices(config) {
const flat = config.flat();
return flat.map(cell => this.cells.findIndex(c => c.label === cell.label));
}
/**
* Convert indices back to configuration.
* @param {number[]} indices - Array of cell indices
* @returns {Object[][]} Configuration
*/
indicesToConfig(indices) {
const configuration = [];
for (let i = 0; i < this.serial; i++) {
const group = [];
for (let j = 0; j < this.parallel; j++) {
const idx = indices[i * this.parallel + j];
group.push(this.cells[idx]);
}
configuration.push(group);
}
return configuration;
}
/**
* Perform crossover between two parents using Order Crossover (OX).
* @param {number[]} parent1 - First parent indices
* @param {number[]} parent2 - Second parent indices
* @returns {number[]} Child indices
*/
crossover(parent1, parent2) {
const length = parent1.length;
const start = Math.floor(Math.random() * length);
const end = start + Math.floor(Math.random() * (length - start));
const child = new Array(length).fill(-1);
const usedIndices = new Set();
// Copy segment from parent1
for (let i = start; i <= end; i++) {
child[i] = parent1[i];
usedIndices.add(parent1[i]);
}
// Fill remaining from parent2
let childIdx = (end + 1) % length;
for (let i = 0; i < length; i++) {
const parent2Idx = (end + 1 + i) % length;
if (!usedIndices.has(parent2[parent2Idx])) {
while (child[childIdx] !== -1) {
childIdx = (childIdx + 1) % length;
}
child[childIdx] = parent2[parent2Idx];
usedIndices.add(parent2[parent2Idx]);
childIdx = (childIdx + 1) % length;
}
}
return child;
}
/**
* Mutate an individual by swapping cells.
* @param {number[]} indices - Individual indices
* @param {Object[]} unusedCells - Cells not in this configuration
* @returns {number[]} Mutated indices
*/
mutate(indices, unusedCells) {
const mutated = [...indices];
if (Math.random() < this.mutationRate) {
if (unusedCells.length > 0 && Math.random() < 0.3) {
// Replace a cell with an unused one
const replaceIdx = Math.floor(Math.random() * mutated.length);
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
const unusedIdx = this.cells.findIndex(c => c.label === unusedCell.label);
mutated[replaceIdx] = unusedIdx;
} else {
// Swap two cells within the configuration
const i = Math.floor(Math.random() * mutated.length);
const j = Math.floor(Math.random() * mutated.length);
[mutated[i], mutated[j]] = [mutated[j], mutated[i]];
}
}
return mutated;
}
/**
* Run the genetic algorithm.
* @returns {Promise<Object>} Best solution found
*/
async run() {
const startTime = Date.now();
// Initialize population
let population = [];
for (let i = 0; i < this.populationSize; i++) {
population.push(this.createIndividual(this.cells));
}
// Evaluate initial population
let evaluated = population.map(config => ({
config,
indices: this.configToIndices(config),
...calculateScore(config, this.capacityWeight, this.irWeight)
}));
// Sort by score
evaluated.sort((a, b) => a.score - b.score);
if (evaluated[0].score < this.bestScore) {
this.bestScore = evaluated[0].score;
this.bestSolution = evaluated[0];
}
// Main evolution loop
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
// Selection (tournament selection)
const newPopulation = [];
// Keep elite individuals
for (let i = 0; i < this.eliteCount && i < evaluated.length; i++) {
newPopulation.push(evaluated[i].indices);
}
// Generate rest through crossover and mutation
while (newPopulation.length < this.populationSize) {
// Tournament selection
const tournament1 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
const tournament2 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
const parent1 = tournament1[Math.floor(Math.random() * tournament1.length)];
const parent2 = tournament2[Math.floor(Math.random() * tournament2.length)];
// Crossover
let child = this.crossover(parent1.indices, parent2.indices);
// Determine unused cells
const usedLabels = new Set(child.map(idx => this.cells[idx].label));
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
// Mutation
child = this.mutate(child, unusedCells);
newPopulation.push(child);
}
// Evaluate new population
evaluated = newPopulation.map(indices => {
const config = this.indicesToConfig(indices);
return {
config,
indices,
...calculateScore(config, this.capacityWeight, this.irWeight)
};
});
// Sort by score
evaluated.sort((a, b) => a.score - b.score);
// Update best solution
if (evaluated[0].score < this.bestScore) {
this.bestScore = evaluated[0].score;
this.bestSolution = evaluated[0];
}
// Progress callback
if (iteration % 50 === 0 || iteration === this.maxIterations - 1) {
this.onProgress({
iteration,
maxIterations: this.maxIterations,
bestScore: this.bestScore,
currentBest: this.bestSolution,
elapsedTime: Date.now() - startTime
});
// Allow UI to update
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Determine excluded cells
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
return {
configuration: this.bestSolution.config,
score: this.bestScore,
capacityCV: this.bestSolution.capacityCV,
irCV: this.bestSolution.irCV,
groupCapacities: this.bestSolution.groupCapacities,
excludedCells,
iterations: this.maxIterations,
elapsedTime: Date.now() - startTime
};
}
}
// =============================================================================
// Simulated Annealing
// =============================================================================
/**
* Simulated Annealing algorithm for cell matching.
* Good for escaping local minima.
*/
class SimulatedAnnealing {
/**
* @param {Object[]} cells - Array of cell objects
* @param {number} serial - Number of series groups
* @param {number} parallel - Number of cells in parallel per group
* @param {Object} options - Algorithm options
*/
constructor(cells, serial, parallel, options = {}) {
this.cells = cells;
this.serial = serial;
this.parallel = parallel;
this.totalCellsNeeded = serial * parallel;
this.maxIterations = options.maxIterations || 5000;
this.initialTemp = options.initialTemp || 100;
this.coolingRate = options.coolingRate || 0.995;
this.capacityWeight = options.capacityWeight ?? 0.7;
this.irWeight = options.irWeight ?? 0.3;
this.onProgress = options.onProgress || (() => { });
this.stopped = false;
this.bestSolution = null;
this.bestScore = Infinity;
}
stop() {
this.stopped = true;
}
/**
* Create initial configuration.
*/
createInitialConfig() {
const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded);
const configuration = [];
for (let i = 0; i < this.serial; i++) {
const group = [];
for (let j = 0; j < this.parallel; j++) {
group.push(shuffled[i * this.parallel + j]);
}
configuration.push(group);
}
return configuration;
}
/**
* Generate a neighbor solution by making a small change.
*/
getNeighbor(config) {
const newConfig = cloneConfiguration(config);
const usedLabels = new Set(config.flat().map(c => c.label));
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
const moveType = Math.random();
if (unusedCells.length > 0 && moveType < 0.3) {
// Replace a cell with an unused one
const groupIdx = Math.floor(Math.random() * this.serial);
const cellIdx = Math.floor(Math.random() * this.parallel);
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
newConfig[groupIdx][cellIdx] = unusedCell;
} else if (moveType < 0.65) {
// Swap cells between different groups
const group1 = Math.floor(Math.random() * this.serial);
let group2 = Math.floor(Math.random() * this.serial);
while (group2 === group1 && this.serial > 1) {
group2 = Math.floor(Math.random() * this.serial);
}
const cell1 = Math.floor(Math.random() * this.parallel);
const cell2 = Math.floor(Math.random() * this.parallel);
const temp = newConfig[group1][cell1];
newConfig[group1][cell1] = newConfig[group2][cell2];
newConfig[group2][cell2] = temp;
} else {
// Swap cells within the same group
const groupIdx = Math.floor(Math.random() * this.serial);
if (this.parallel >= 2) {
const cell1 = Math.floor(Math.random() * this.parallel);
let cell2 = Math.floor(Math.random() * this.parallel);
while (cell2 === cell1) {
cell2 = Math.floor(Math.random() * this.parallel);
}
const temp = newConfig[groupIdx][cell1];
newConfig[groupIdx][cell1] = newConfig[groupIdx][cell2];
newConfig[groupIdx][cell2] = temp;
}
}
return newConfig;
}
/**
* Run simulated annealing.
*/
async run() {
const startTime = Date.now();
let current = this.createInitialConfig();
let currentScore = calculateScore(current, this.capacityWeight, this.irWeight);
this.bestSolution = { config: cloneConfiguration(current), ...currentScore };
this.bestScore = currentScore.score;
let temperature = this.initialTemp;
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
const neighbor = this.getNeighbor(current);
const neighborScore = calculateScore(neighbor, this.capacityWeight, this.irWeight);
const delta = neighborScore.score - currentScore.score;
// Accept if better, or with probability based on temperature
if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) {
current = neighbor;
currentScore = neighborScore;
if (currentScore.score < this.bestScore) {
this.bestScore = currentScore.score;
this.bestSolution = { config: cloneConfiguration(current), ...currentScore };
}
}
// Cool down
temperature *= this.coolingRate;
// Progress callback
if (iteration % 100 === 0 || iteration === this.maxIterations - 1) {
this.onProgress({
iteration,
maxIterations: this.maxIterations,
bestScore: this.bestScore,
currentBest: this.bestSolution,
temperature,
elapsedTime: Date.now() - startTime
});
await new Promise(resolve => setTimeout(resolve, 0));
}
}
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
return {
configuration: this.bestSolution.config,
score: this.bestScore,
capacityCV: this.bestSolution.capacityCV,
irCV: this.bestSolution.irCV,
groupCapacities: this.bestSolution.groupCapacities,
excludedCells,
iterations: this.maxIterations,
elapsedTime: Date.now() - startTime
};
}
}
// =============================================================================
// Exhaustive Search (for small configurations)
// =============================================================================
/**
* Exhaustive search - finds the globally optimal solution.
* Only practical for small configurations due to factorial complexity.
*/
class ExhaustiveSearch {
constructor(cells, serial, parallel, options = {}) {
this.cells = cells;
this.serial = serial;
this.parallel = parallel;
this.totalCellsNeeded = serial * parallel;
this.capacityWeight = options.capacityWeight ?? 0.7;
this.irWeight = options.irWeight ?? 0.3;
this.onProgress = options.onProgress || (() => { });
this.maxIterations = options.maxIterations || 100000;
this.stopped = false;
this.bestSolution = null;
this.bestScore = Infinity;
}
stop() {
this.stopped = true;
}
/**
* Generate all combinations of k elements from array.
*/
*combinations(array, k) {
if (k === 0) {
yield [];
return;
}
if (array.length < k) return;
const [first, ...rest] = array;
for (const combo of this.combinations(rest, k - 1)) {
yield [first, ...combo];
}
yield* this.combinations(rest, k);
}
/**
* Generate all partitions of cells into groups.
*/
*generatePartitions(cells, groupSize, numGroups) {
if (numGroups === 0) {
yield [];
return;
}
if (cells.length < groupSize * numGroups) return;
for (const group of this.combinations(cells, groupSize)) {
const remaining = cells.filter(c => !group.includes(c));
for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) {
yield [group, ...rest];
}
}
}
async run() {
const startTime = Date.now();
let iteration = 0;
// Select best subset if we have more cells than needed
const cellCombos = this.cells.length > this.totalCellsNeeded
? this.combinations(this.cells, this.totalCellsNeeded)
: [[...this.cells]];
for (const cellSubset of cellCombos) {
if (this.stopped) break;
for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) {
if (this.stopped) break;
const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight);
if (scoreResult.score < this.bestScore) {
this.bestScore = scoreResult.score;
this.bestSolution = { config: partition, ...scoreResult };
}
iteration++;
if (iteration % 1000 === 0) {
this.onProgress({
iteration,
maxIterations: this.maxIterations,
bestScore: this.bestScore,
currentBest: this.bestSolution,
elapsedTime: Date.now() - startTime
});
await new Promise(resolve => setTimeout(resolve, 0));
}
if (iteration >= this.maxIterations) {
this.stopped = true;
break;
}
}
}
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
return {
configuration: this.bestSolution.config,
score: this.bestScore,
capacityCV: this.bestSolution.capacityCV,
irCV: this.bestSolution.irCV,
groupCapacities: this.bestSolution.groupCapacities,
excludedCells,
iterations: iteration,
elapsedTime: Date.now() - startTime
};
}
}
// =============================================================================
// Export
// =============================================================================
// Make available globally for the main app
window.CellMatchingAlgorithms = {
GeneticAlgorithm,
SimulatedAnnealing,
ExhaustiveSearch,
calculateScore,
coefficientOfVariation
};