From f600897ee8212a7c959eaade298db9ea4054a7de Mon Sep 17 00:00:00 2001
From: localhorst
Date: Sat, 20 Dec 2025 15:06:53 +0100
Subject: [PATCH 01/12] switch to web app
---
LICENSE | 2 +-
README.md | 127 +++++-
css/styles.css | 854 ++++++++++++++++++++++++++++++++++++++
data/favicon.svg | 16 +
index.html | 284 +++++++++++++
js/app.js | 729 ++++++++++++++++++++++++++++++++
js/matching-algorithms.js | 680 ++++++++++++++++++++++++++++++
7 files changed, 2682 insertions(+), 10 deletions(-)
create mode 100644 css/styles.css
create mode 100644 data/favicon.svg
create mode 100644 index.html
create mode 100644 js/app.js
create mode 100644 js/matching-algorithms.js
diff --git a/LICENSE b/LICENSE
index 204b93d..282d634 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-MIT License Copyright (c)
+MIT License Copyright (c) <2025>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 3cee97d..fe57c68 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,122 @@
# 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.
+
-## 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**:
+ - 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.
\ No newline at end of file
diff --git a/css/styles.css b/css/styles.css
new file mode 100644
index 0000000..23a376d
--- /dev/null
+++ b/css/styles.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/data/favicon.svg b/data/favicon.svg
new file mode 100644
index 0000000..8171cc0
--- /dev/null
+++ b/data/favicon.svg
@@ -0,0 +1,16 @@
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7be6a21
--- /dev/null
+++ b/index.html
@@ -0,0 +1,284 @@
+
+
+
+
+
+
+ LiXX Cell Pack Matcher
+
+
+
+
+
+
+
+
+
+
+
+ Pack Configuration
+
+
+
+
+ Number of cells in series
+
+
+
+
+ Number of cells in parallel
+
+
+
+
+ Total: 12 cells
+
+
+
+
+
+
+ Cell Data
+
+
+
+
+
+
+ | # |
+ Label |
+ Capacity (mAh) |
+ IR (mΩ) |
+ Actions |
+
+
+
+
+
+
+
+
+
+ Cells: 0
+ Avg Capacity: -
+ Avg IR: -
+
+
+
+
+
+ Matching Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Matching Progress
+
+
+
+ Iteration: 0
+ Best Score: -
+ Time: 0s
+
+
+
+
+
+
+
+ Matching Results
+
+
+
+
⚠️
+
+
Safety Warning - Used Lithium Cells
+
+ - Used cells may have hidden defects not detectable by capacity/IR testing
+ - Internal resistance mismatch >20% can reduce cycle life by up to 40%
+ (Shi et al., 2013)
+
+ - Always use a BMS with cell-level monitoring and balancing
+ - Use only same model of cell in a pack.
+ - Never charge unattended; use fireproof storage
+ - Cells with significantly different ages may degrade unpredictably
+
+
+
+
+
+
+
+ -
+ Match Score
+
+
+ -
+ Capacity CV%
+
+
+ -
+ IR CV%
+
+
+ -
+ Pack Capacity
+
+
+
+
+
+
Pack Layout
+
+
+
+
+ Lower
+ Capacity
+ Average
+ Higher
+ Capacity
+
+
+
+
+
+
Parallel Group Details
+
+
+
+ | Group |
+ Cells |
+ Total Capacity |
+ Avg IR |
+ Deviation |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..e94af86
--- /dev/null
+++ b/js/app.js
@@ -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 = `
+ ${AppState.cells.length} |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+ `;
+
+ DOM.cellTbody.appendChild(row);
+
+ // Add event listeners
+ row.querySelectorAll('input').forEach(input => {
+ input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
+ input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value));
+ });
+
+ row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id));
+
+ updateCellStats();
+ updateMatchingButtonState();
+
+ // Focus the capacity input of the new row
+ if (!cellData) {
+ row.querySelector('input[data-field="capacity"]').focus();
+ }
+}
+
+/**
+ * Update cell data when input changes.
+ */
+function updateCellData(id, field, value) {
+ const cell = AppState.cells.find(c => c.id === id);
+ if (!cell) return;
+
+ if (field === 'label') {
+ cell.label = value || `C${id}`;
+ } else if (field === 'capacity') {
+ cell.capacity = value ? parseFloat(value) : null;
+ } else if (field === 'ir') {
+ cell.ir = value ? parseFloat(value) : null;
+ }
+
+ 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)} mΩ`;
+ } 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 = `
+ ${cell.label}
+ ${cell.capacity} mAh
+ ${cell.ir ? `${cell.ir} mΩ` : ''}
+ `;
+
+ 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 = `
+ S${idx + 1} |
+ ${group.map(c => c.label).join(' + ')} |
+ ${groupCapacity} mAh |
+ ${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'} |
+ ${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}% |
+ `;
+
+ DOM.resultsTbody.appendChild(row);
+ });
+}
+
+// =============================================================================
+// 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();
+}
diff --git a/js/matching-algorithms.js b/js/matching-algorithms.js
new file mode 100644
index 0000000..7099c4a
--- /dev/null
+++ b/js/matching-algorithms.js
@@ -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
This tool is for educational purposes. Always consult professional guidance for battery pack assembly.
@@ -277,7 +303,6 @@
-