From f600897ee8212a7c959eaade298db9ea4054a7de Mon Sep 17 00:00:00 2001 From: localhorst Date: Sat, 20 Dec 2025 15:06:53 +0100 Subject: [PATCH] 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. +![License](https://img.shields.io/badge/license-MIT-blue.svg) -## Not Working -- Clould be faster, 6S2P needs more than 10min to compute -- Support internal cell resistance matching -- Support bigger cell pool for a pack that is needed +## Features + +- **Pack Configuration**: Support for any SxP configuration (e.g., 6S2P, 4S3P, 12S4P) +- **Cell Matching**: Optimize by capacity (mAh) and internal resistance (mΩ) +- **Multiple Algorithms**: + - 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 + + + + + +
+
+ +

Optimal cell matching for lithium battery packs

+
+ +
+ +
+

Pack Configuration

+
+
+ + + Number of cells in series +
+
+ + + Number of cells in parallel +
+
+ + 6S2P + Total: 12 cells +
+
+
+ + +
+

Cell Data

+
+

Enter cell data: Label, Capacity (mAh), Internal Resistance (mΩ, optional)

+
+ + + +
+
+ +
+ + + + + + + + + + + + + +
#LabelCapacity (mAh)IR (mΩ)Actions
+
+ +
+ Cells: 0 + Avg Capacity: - + Avg IR: - +
+
+ + +
+

Matching Settings

+
+
+ + + 70% +
+
+ + + 30% +
+
+ + +
+
+ + +
+
+ + +
+ + + + + + +
+ +
+

+ Git + · + Based on research by + Shi et al., + 2013 +

+

+ This tool is for educational purposes. Always consult professional guidance for battery pack assembly. +

+
+
+ + + +

Keyboard Shortcuts

+
+
Alt + A
+
Add new cell
+
Alt + S
+
Start matching
+
Alt + E
+
Load example data
+
Esc
+
Stop matching / Close dialog
+
?
+
Show this help
+
+ +
+ + + + + + \ 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} 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 +};