switch to web app
This commit is contained in:
2
LICENSE
2
LICENSE
@ -1,4 +1,4 @@
|
||||
MIT License Copyright (c) <year> <copyright holders>
|
||||
MIT License Copyright (c) <2025> <Hendrik Schutter>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
127
README.md
127
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.
|
||||
854
css/styles.css
Normal file
854
css/styles.css
Normal file
@ -0,0 +1,854 @@
|
||||
/* ==========================================================================
|
||||
LiXX Cell Pack Matcher - Styles
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
CSS Custom Properties (Variables)
|
||||
-------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* Colors */
|
||||
--bg-primary: #0f1419;
|
||||
--bg-secondary: #1a1f2e;
|
||||
--bg-tertiary: #242b3d;
|
||||
--text-primary: #e7e9ea;
|
||||
--text-secondary: #8b98a5;
|
||||
--text-muted: #6e7681;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-light: rgba(59, 130, 246, 0.15);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--border-color: #2f3336;
|
||||
|
||||
/* Cell visualization colors */
|
||||
--cell-low: #ef4444;
|
||||
--cell-mid: #22c55e;
|
||||
--cell-high: #3b82f6;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
|
||||
/* Borders & Shadows */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
}
|
||||
|
||||
/* Light mode support */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Base Styles
|
||||
-------------------------------------------------------------------------- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Focus styles for keyboard navigation */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Layout
|
||||
-------------------------------------------------------------------------- */
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Header
|
||||
-------------------------------------------------------------------------- */
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--space-sm) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Cards
|
||||
-------------------------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 var(--space-lg);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: var(--space-lg) 0 var(--space-md);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Form Elements
|
||||
-------------------------------------------------------------------------- */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
select {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
input[type="text"]:hover,
|
||||
input[type="number"]:hover,
|
||||
select:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="number"]:focus,
|
||||
select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
output {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Buttons
|
||||
-------------------------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Configuration Grid
|
||||
-------------------------------------------------------------------------- */
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.config-badge {
|
||||
display: inline-block;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Cell Input Section
|
||||
-------------------------------------------------------------------------- */
|
||||
.cell-input-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.cell-input-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cell-table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 0 calc(-1 * var(--space-xl));
|
||||
padding: 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.cell-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cell-table th,
|
||||
.cell-table td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cell-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cell-table tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.cell-table input {
|
||||
width: 100%;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.cell-table .cell-label-input {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.cell-table .btn-remove {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--danger);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.cell-table .btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cell-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cell-stats strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Settings Grid
|
||||
-------------------------------------------------------------------------- */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Progress Section
|
||||
-------------------------------------------------------------------------- */
|
||||
.progress-container {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||
border-radius: 4px;
|
||||
transition: width var(--transition-normal);
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-stats strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Warning Banner
|
||||
-------------------------------------------------------------------------- */
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warning-content strong {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.warning-content ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.warning-content li {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.warning-content a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Results Summary
|
||||
-------------------------------------------------------------------------- */
|
||||
.results-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.result-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Pack Visualization
|
||||
-------------------------------------------------------------------------- */
|
||||
.pack-visualization {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.pack-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pack-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pack-row-label {
|
||||
min-width: 30px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.pack-cells {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pack-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 70px;
|
||||
padding: var(--space-sm);
|
||||
background: var(--cell-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.pack-cell:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pack-cell .cell-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pack-cell .cell-capacity {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pack-cell .cell-ir {
|
||||
opacity: 0.7;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.pack-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-md);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: var(--space-xs);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Results Table
|
||||
-------------------------------------------------------------------------- */
|
||||
.results-table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.results-table th,
|
||||
.results-table td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.results-table td {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.results-table .deviation-good {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.results-table .deviation-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.results-table .deviation-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Excluded Cells
|
||||
-------------------------------------------------------------------------- */
|
||||
.excluded-cells {
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.excluded-cells h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.excluded-cells p {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Export Buttons
|
||||
-------------------------------------------------------------------------- */
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Footer
|
||||
-------------------------------------------------------------------------- */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: var(--space-xl) 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Dialog / Modal
|
||||
-------------------------------------------------------------------------- */
|
||||
dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
padding: var(--space-xl);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
dialog h2 {
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.shortcuts-list dt {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shortcuts-list dd {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive Adjustments
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.config-grid,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cell-input-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.pack-cell {
|
||||
min-width: 55px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Reduced Motion
|
||||
-------------------------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Print Styles
|
||||
-------------------------------------------------------------------------- */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: none;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.btn,
|
||||
#progress-section,
|
||||
.export-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
border: 2px solid #f59e0b;
|
||||
}
|
||||
}
|
||||
16
data/favicon.svg
Normal file
16
data/favicon.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||
<defs>
|
||||
<linearGradient id="batteryGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||
<stop offset="100%" style="stop-color:#22c55e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Battery body -->
|
||||
<rect x="4" y="8" width="40" height="32" rx="4" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
||||
<!-- Battery terminal -->
|
||||
<rect x="44" y="18" width="4" height="12" rx="1" fill="#3b82f6"/>
|
||||
<!-- Cells -->
|
||||
<rect x="10" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
|
||||
<rect x="20" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
|
||||
<rect x="30" y="14" width="8" height="20" rx="2" fill="url(#batteryGrad)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
284
index.html
Normal file
284
index.html
Normal file
@ -0,0 +1,284 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LiXX Cell Pack Matcher</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<link rel="icon" href="data/favicon.svg" type="image/svg+xml">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 48 48" width="40" height="40" aria-hidden="true">
|
||||
<rect x="4" y="8" width="40" height="32" rx="4" fill="none" stroke="currentColor"
|
||||
stroke-width="2" />
|
||||
<rect x="44" y="18" width="4" height="12" rx="1" fill="currentColor" />
|
||||
<rect x="10" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
|
||||
<rect x="20" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
|
||||
<rect x="30" y="14" width="8" height="20" rx="2" fill="var(--accent)" />
|
||||
</svg>
|
||||
<h1>LiXX Cell Pack Matcher</h1>
|
||||
</div>
|
||||
<p class="subtitle">Optimal cell matching for lithium battery packs</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Configuration Section -->
|
||||
<section class="card" aria-labelledby="config-heading">
|
||||
<h2 id="config-heading">Pack Configuration</h2>
|
||||
<div class="config-grid">
|
||||
<div class="form-group">
|
||||
<label for="cells-serial">Series (S)</label>
|
||||
<input type="number" id="cells-serial" min="1" max="42" value="6"
|
||||
aria-describedby="serial-help">
|
||||
<small id="serial-help">Number of cells in series</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cells-parallel">Parallel (P)</label>
|
||||
<input type="number" id="cells-parallel" min="1" max="42" value="2"
|
||||
aria-describedby="parallel-help">
|
||||
<small id="parallel-help">Number of cells in parallel</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config-display">Configuration</label>
|
||||
<output id="config-display" class="config-badge">6S2P</output>
|
||||
<small>Total: <span id="total-cells-needed">12</span> cells</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cell Input Section -->
|
||||
<section class="card" aria-labelledby="cells-heading">
|
||||
<h2 id="cells-heading">Cell Data</h2>
|
||||
<div class="cell-input-header">
|
||||
<p>Enter cell data: Label, Capacity (mAh), Internal Resistance (mΩ, optional)</p>
|
||||
<div class="button-group">
|
||||
<button type="button" id="btn-add-cell" class="btn btn-secondary">
|
||||
<span aria-hidden="true">+</span> Add Cell
|
||||
</button>
|
||||
<button type="button" id="btn-load-example" class="btn btn-ghost">
|
||||
Load Example
|
||||
</button>
|
||||
<button type="button" id="btn-clear-all" class="btn btn-ghost btn-danger">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell-table-wrapper">
|
||||
<table class="cell-table" id="cell-table" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Label</th>
|
||||
<th scope="col">Capacity (mAh)</th>
|
||||
<th scope="col">IR (mΩ)</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cell-tbody">
|
||||
<!-- Cells will be added dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="cell-stats" id="cell-stats" aria-live="polite">
|
||||
<span>Cells: <strong id="stat-count">0</strong></span>
|
||||
<span>Avg Capacity: <strong id="stat-avg-cap">-</strong></span>
|
||||
<span>Avg IR: <strong id="stat-avg-ir">-</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Algorithm Settings -->
|
||||
<section class="card" aria-labelledby="algo-heading">
|
||||
<h2 id="algo-heading">Matching Settings</h2>
|
||||
<div class="settings-grid">
|
||||
<div class="form-group">
|
||||
<label for="weight-capacity">Capacity Weight</label>
|
||||
<input type="range" id="weight-capacity" min="0" max="100" value="70"
|
||||
aria-describedby="weight-cap-value">
|
||||
<output id="weight-cap-value">70%</output>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="weight-ir">IR Weight</label>
|
||||
<input type="range" id="weight-ir" min="0" max="100" value="30"
|
||||
aria-describedby="weight-ir-value">
|
||||
<output id="weight-ir-value">30%</output>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="algorithm-select">Algorithm</label>
|
||||
<select id="algorithm-select">
|
||||
<option value="genetic">Genetic Algorithm (Fast)</option>
|
||||
<option value="simulated-annealing">Simulated Annealing</option>
|
||||
<option value="exhaustive">Exhaustive (Small packs only)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="max-iterations">Max Iterations</label>
|
||||
<input type="number" id="max-iterations" min="100" max="100000" value="5000">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="btn-start-matching" class="btn btn-primary btn-large">
|
||||
<span class="btn-icon" aria-hidden="true">⚡</span>
|
||||
Start Matching
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<section class="card" id="progress-section" hidden aria-labelledby="progress-heading">
|
||||
<h2 id="progress-heading">Matching Progress</h2>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="progress-stats">
|
||||
<span>Iteration: <strong id="progress-iteration">0</strong></span>
|
||||
<span>Best Score: <strong id="progress-score">-</strong></span>
|
||||
<span>Time: <strong id="progress-time">0s</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="btn-stop-matching" class="btn btn-secondary">
|
||||
Stop
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Results Section -->
|
||||
<section class="card" id="results-section" hidden aria-labelledby="results-heading">
|
||||
<h2 id="results-heading">Matching Results</h2>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="warning-banner" role="alert">
|
||||
<div class="warning-icon" aria-hidden="true">⚠️</div>
|
||||
<div class="warning-content">
|
||||
<strong>Safety Warning - Used Lithium Cells</strong>
|
||||
<ul>
|
||||
<li>Used cells may have hidden defects not detectable by capacity/IR testing</li>
|
||||
<li>Internal resistance mismatch >20% can reduce cycle life by up to 40%
|
||||
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank"
|
||||
rel="noopener">(Shi et al., 2013)</a>
|
||||
</li>
|
||||
<li>Always use a BMS with cell-level monitoring and balancing</li>
|
||||
<li>Use only same model of cell in a pack.</li>
|
||||
<li>Never charge unattended; use fireproof storage</li>
|
||||
<li>Cells with significantly different ages may degrade unpredictably</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary -->
|
||||
<div class="results-summary" id="results-summary">
|
||||
<div class="result-metric">
|
||||
<span class="metric-value" id="result-score">-</span>
|
||||
<span class="metric-label">Match Score</span>
|
||||
</div>
|
||||
<div class="result-metric">
|
||||
<span class="metric-value" id="result-cap-variance">-</span>
|
||||
<span class="metric-label">Capacity CV%</span>
|
||||
</div>
|
||||
<div class="result-metric">
|
||||
<span class="metric-value" id="result-ir-variance">-</span>
|
||||
<span class="metric-label">IR CV%</span>
|
||||
</div>
|
||||
<div class="result-metric">
|
||||
<span class="metric-value" id="result-pack-capacity">-</span>
|
||||
<span class="metric-label">Pack Capacity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visual Pack Layout -->
|
||||
<div class="pack-visualization" id="pack-visualization">
|
||||
<h3>Pack Layout</h3>
|
||||
<div class="pack-grid" id="pack-grid">
|
||||
<!-- Generated dynamically -->
|
||||
</div>
|
||||
<div class="pack-legend">
|
||||
<span><span class="legend-color" style="background: var(--cell-low)"></span> Lower
|
||||
Capacity</span>
|
||||
<span><span class="legend-color" style="background: var(--cell-mid)"></span> Average</span>
|
||||
<span><span class="legend-color" style="background: var(--cell-high)"></span> Higher
|
||||
Capacity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Results Table -->
|
||||
<div class="results-table-wrapper">
|
||||
<h3>Parallel Group Details</h3>
|
||||
<table class="results-table" id="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Cells</th>
|
||||
<th>Total Capacity</th>
|
||||
<th>Avg IR</th>
|
||||
<th>Deviation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="results-tbody">
|
||||
<!-- Generated dynamically -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Excluded Cells -->
|
||||
<div class="excluded-cells" id="excluded-cells-section" hidden>
|
||||
<h3>Excluded Cells</h3>
|
||||
<p id="excluded-cells-list"></p>
|
||||
</div>
|
||||
|
||||
<!-- Export Buttons -->
|
||||
<div class="export-buttons">
|
||||
<button type="button" id="btn-export-json" class="btn btn-secondary">
|
||||
Export JSON
|
||||
</button>
|
||||
<button type="button" id="btn-export-csv" class="btn btn-secondary">
|
||||
Export CSV
|
||||
</button>
|
||||
<button type="button" id="btn-copy-results" class="btn btn-secondary">
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
<a href="https://git.mosad.xyz/localhorst/LiXX_Cell_Pack_Matcher" target="_blank" rel="noopener">Git</a>
|
||||
·
|
||||
Based on research by
|
||||
<a href="https://doi.org/10.1016/j.jpowsour.2013.11.064" target="_blank" rel="noopener">Shi et al.,
|
||||
2013</a>
|
||||
</p>
|
||||
<p class="disclaimer">
|
||||
This tool is for educational purposes. Always consult professional guidance for battery pack assembly.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Shortcuts Modal -->
|
||||
<dialog id="shortcuts-dialog">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<dl class="shortcuts-list">
|
||||
<dt><kbd>Alt</kbd> + <kbd>A</kbd></dt>
|
||||
<dd>Add new cell</dd>
|
||||
<dt><kbd>Alt</kbd> + <kbd>S</kbd></dt>
|
||||
<dd>Start matching</dd>
|
||||
<dt><kbd>Alt</kbd> + <kbd>E</kbd></dt>
|
||||
<dd>Load example data</dd>
|
||||
<dt><kbd>Esc</kbd></dt>
|
||||
<dd>Stop matching / Close dialog</dd>
|
||||
<dt><kbd>?</kbd></dt>
|
||||
<dd>Show this help</dd>
|
||||
</dl>
|
||||
<button type="button" class="btn btn-primary" id="btn-close-shortcuts">Close</button>
|
||||
</dialog>
|
||||
|
||||
<script src="js/matching-algorithms.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
729
js/app.js
Normal file
729
js/app.js
Normal file
@ -0,0 +1,729 @@
|
||||
/**
|
||||
* LiXX Cell Pack Matcher - Main Application
|
||||
*
|
||||
* A web application for optimal matching of lithium battery cells.
|
||||
* Supports capacity and internal resistance matching with multiple algorithms.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Application State
|
||||
// =============================================================================
|
||||
|
||||
const AppState = {
|
||||
cells: [],
|
||||
cellIdCounter: 0,
|
||||
currentAlgorithm: null,
|
||||
isRunning: false,
|
||||
results: null
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// DOM Elements
|
||||
// =============================================================================
|
||||
|
||||
const DOM = {
|
||||
// Configuration
|
||||
cellsSerial: document.getElementById('cells-serial'),
|
||||
cellsParallel: document.getElementById('cells-parallel'),
|
||||
configDisplay: document.getElementById('config-display'),
|
||||
totalCellsNeeded: document.getElementById('total-cells-needed'),
|
||||
|
||||
// Cell input
|
||||
cellTbody: document.getElementById('cell-tbody'),
|
||||
btnAddCell: document.getElementById('btn-add-cell'),
|
||||
btnLoadExample: document.getElementById('btn-load-example'),
|
||||
btnClearAll: document.getElementById('btn-clear-all'),
|
||||
statCount: document.getElementById('stat-count'),
|
||||
statAvgCap: document.getElementById('stat-avg-cap'),
|
||||
statAvgIr: document.getElementById('stat-avg-ir'),
|
||||
|
||||
// Settings
|
||||
weightCapacity: document.getElementById('weight-capacity'),
|
||||
weightIr: document.getElementById('weight-ir'),
|
||||
weightCapValue: document.getElementById('weight-cap-value'),
|
||||
weightIrValue: document.getElementById('weight-ir-value'),
|
||||
algorithmSelect: document.getElementById('algorithm-select'),
|
||||
maxIterations: document.getElementById('max-iterations'),
|
||||
|
||||
// Matching
|
||||
btnStartMatching: document.getElementById('btn-start-matching'),
|
||||
btnStopMatching: document.getElementById('btn-stop-matching'),
|
||||
|
||||
// Progress
|
||||
progressSection: document.getElementById('progress-section'),
|
||||
progressFill: document.getElementById('progress-fill'),
|
||||
progressIteration: document.getElementById('progress-iteration'),
|
||||
progressScore: document.getElementById('progress-score'),
|
||||
progressTime: document.getElementById('progress-time'),
|
||||
|
||||
// Results
|
||||
resultsSection: document.getElementById('results-section'),
|
||||
resultScore: document.getElementById('result-score'),
|
||||
resultCapVariance: document.getElementById('result-cap-variance'),
|
||||
resultIrVariance: document.getElementById('result-ir-variance'),
|
||||
resultPackCapacity: document.getElementById('result-pack-capacity'),
|
||||
packGrid: document.getElementById('pack-grid'),
|
||||
resultsTbody: document.getElementById('results-tbody'),
|
||||
excludedCellsSection: document.getElementById('excluded-cells-section'),
|
||||
excludedCellsList: document.getElementById('excluded-cells-list'),
|
||||
|
||||
// Export
|
||||
btnExportJson: document.getElementById('btn-export-json'),
|
||||
btnExportCsv: document.getElementById('btn-export-csv'),
|
||||
btnCopyResults: document.getElementById('btn-copy-results'),
|
||||
|
||||
// Dialog
|
||||
shortcutsDialog: document.getElementById('shortcuts-dialog'),
|
||||
btnCloseShortcuts: document.getElementById('btn-close-shortcuts')
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Configuration Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Update the configuration display.
|
||||
*/
|
||||
function updateConfigDisplay() {
|
||||
const serial = parseInt(DOM.cellsSerial.value) || 1;
|
||||
const parallel = parseInt(DOM.cellsParallel.value) || 1;
|
||||
const total = serial * parallel;
|
||||
|
||||
DOM.configDisplay.textContent = `${serial}S${parallel}P`;
|
||||
DOM.totalCellsNeeded.textContent = total;
|
||||
|
||||
updateMatchingButtonState();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cell Management
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add a new cell row to the table.
|
||||
* @param {Object} cellData - Optional initial data
|
||||
*/
|
||||
function addCell(cellData = null) {
|
||||
const id = AppState.cellIdCounter++;
|
||||
const label = cellData?.label || `C${String(AppState.cells.length + 1).padStart(2, '0')}`;
|
||||
const capacity = cellData?.capacity || '';
|
||||
const ir = cellData?.ir || '';
|
||||
|
||||
const cell = { id, label, capacity: capacity || null, ir: ir || null };
|
||||
AppState.cells.push(cell);
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.cellId = id;
|
||||
row.innerHTML = `
|
||||
<td>${AppState.cells.length}</td>
|
||||
<td>
|
||||
<input type="text" class="cell-label-input" value="${label}"
|
||||
aria-label="Cell label" data-field="label">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" max="99999" value="${capacity}"
|
||||
aria-label="Capacity in mAh" data-field="capacity" placeholder="mAh">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" max="9999" step="0.1" value="${ir}"
|
||||
aria-label="Internal resistance in milliohms" data-field="ir" placeholder="optional">
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-remove" aria-label="Remove cell" data-remove="${id}">
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
DOM.cellTbody.appendChild(row);
|
||||
|
||||
// Add event listeners
|
||||
row.querySelectorAll('input').forEach(input => {
|
||||
input.addEventListener('change', () => updateCellData(id, input.dataset.field, input.value));
|
||||
input.addEventListener('input', () => updateCellData(id, input.dataset.field, input.value));
|
||||
});
|
||||
|
||||
row.querySelector('.btn-remove').addEventListener('click', () => removeCell(id));
|
||||
|
||||
updateCellStats();
|
||||
updateMatchingButtonState();
|
||||
|
||||
// Focus the capacity input of the new row
|
||||
if (!cellData) {
|
||||
row.querySelector('input[data-field="capacity"]').focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cell data when input changes.
|
||||
*/
|
||||
function updateCellData(id, field, value) {
|
||||
const cell = AppState.cells.find(c => c.id === id);
|
||||
if (!cell) return;
|
||||
|
||||
if (field === 'label') {
|
||||
cell.label = value || `C${id}`;
|
||||
} else if (field === 'capacity') {
|
||||
cell.capacity = value ? parseFloat(value) : null;
|
||||
} else if (field === 'ir') {
|
||||
cell.ir = value ? parseFloat(value) : null;
|
||||
}
|
||||
|
||||
updateCellStats();
|
||||
updateMatchingButtonState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a cell from the table.
|
||||
*/
|
||||
function removeCell(id) {
|
||||
const index = AppState.cells.findIndex(c => c.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
AppState.cells.splice(index, 1);
|
||||
|
||||
const row = DOM.cellTbody.querySelector(`tr[data-cell-id="${id}"]`);
|
||||
if (row) row.remove();
|
||||
|
||||
// Update row numbers
|
||||
DOM.cellTbody.querySelectorAll('tr').forEach((row, idx) => {
|
||||
row.querySelector('td:first-child').textContent = idx + 1;
|
||||
});
|
||||
|
||||
updateCellStats();
|
||||
updateMatchingButtonState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cells.
|
||||
*/
|
||||
function clearAllCells() {
|
||||
if (AppState.cells.length > 0 && !confirm('Clear all cells?')) return;
|
||||
|
||||
AppState.cells = [];
|
||||
AppState.cellIdCounter = 0;
|
||||
DOM.cellTbody.innerHTML = '';
|
||||
|
||||
updateCellStats();
|
||||
updateMatchingButtonState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cell statistics display.
|
||||
*/
|
||||
function updateCellStats() {
|
||||
const count = AppState.cells.length;
|
||||
DOM.statCount.textContent = count;
|
||||
|
||||
if (count === 0) {
|
||||
DOM.statAvgCap.textContent = '-';
|
||||
DOM.statAvgIr.textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
const capacities = AppState.cells.filter(c => c.capacity).map(c => c.capacity);
|
||||
const irs = AppState.cells.filter(c => c.ir).map(c => c.ir);
|
||||
|
||||
if (capacities.length > 0) {
|
||||
const avgCap = capacities.reduce((a, b) => a + b, 0) / capacities.length;
|
||||
DOM.statAvgCap.textContent = `${Math.round(avgCap)} mAh`;
|
||||
} else {
|
||||
DOM.statAvgCap.textContent = '-';
|
||||
}
|
||||
|
||||
if (irs.length > 0) {
|
||||
const avgIr = irs.reduce((a, b) => a + b, 0) / irs.length;
|
||||
DOM.statAvgIr.textContent = `${avgIr.toFixed(1)} 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 = `
|
||||
<span class="cell-label">${cell.label}</span>
|
||||
<span class="cell-capacity">${cell.capacity} mAh</span>
|
||||
${cell.ir ? `<span class="cell-ir">${cell.ir} mΩ</span>` : ''}
|
||||
`;
|
||||
|
||||
cellsContainer.appendChild(cellEl);
|
||||
});
|
||||
|
||||
row.appendChild(cellsContainer);
|
||||
DOM.packGrid.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the results table.
|
||||
*/
|
||||
function renderResultsTable(results) {
|
||||
const config = results.configuration;
|
||||
const avgCapacity = results.groupCapacities.reduce((a, b) => a + b, 0) / results.groupCapacities.length;
|
||||
|
||||
DOM.resultsTbody.innerHTML = '';
|
||||
|
||||
config.forEach((group, idx) => {
|
||||
const groupCapacity = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||
const deviation = ((groupCapacity - avgCapacity) / avgCapacity * 100);
|
||||
const irsWithValues = group.filter(c => c.ir);
|
||||
const avgIr = irsWithValues.length > 0
|
||||
? irsWithValues.reduce((sum, c) => sum + c.ir, 0) / irsWithValues.length
|
||||
: null;
|
||||
|
||||
let deviationClass = 'deviation-good';
|
||||
if (Math.abs(deviation) > 2) deviationClass = 'deviation-warning';
|
||||
if (Math.abs(deviation) > 5) deviationClass = 'deviation-bad';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>S${idx + 1}</td>
|
||||
<td>${group.map(c => c.label).join(' + ')}</td>
|
||||
<td>${groupCapacity} mAh</td>
|
||||
<td>${avgIr ? avgIr.toFixed(1) + ' mΩ' : '-'}</td>
|
||||
<td class="${deviationClass}">${deviation >= 0 ? '+' : ''}${deviation.toFixed(2)}%</td>
|
||||
`;
|
||||
|
||||
DOM.resultsTbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Export results as JSON.
|
||||
*/
|
||||
function exportJson() {
|
||||
if (!AppState.results) return;
|
||||
|
||||
const data = {
|
||||
configuration: `${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
|
||||
timestamp: new Date().toISOString(),
|
||||
score: AppState.results.score,
|
||||
capacityCV: AppState.results.capacityCV,
|
||||
irCV: AppState.results.irCV,
|
||||
groups: AppState.results.configuration.map((group, idx) => ({
|
||||
group: `S${idx + 1}`,
|
||||
cells: group.map(c => ({ label: c.label, capacity: c.capacity, ir: c.ir })),
|
||||
totalCapacity: group.reduce((sum, c) => sum + c.capacity, 0)
|
||||
})),
|
||||
excludedCells: AppState.results.excludedCells.map(c => ({
|
||||
label: c.label,
|
||||
capacity: c.capacity,
|
||||
ir: c.ir
|
||||
}))
|
||||
};
|
||||
|
||||
downloadFile(JSON.stringify(data, null, 2), 'cell-matching-results.json', 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export results as CSV.
|
||||
*/
|
||||
function exportCsv() {
|
||||
if (!AppState.results) return;
|
||||
|
||||
const lines = ['Group,Cell Label,Capacity (mAh),IR (mΩ),Group Total'];
|
||||
|
||||
AppState.results.configuration.forEach((group, idx) => {
|
||||
const groupTotal = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||
group.forEach((cell, cellIdx) => {
|
||||
lines.push(`S${idx + 1},${cell.label},${cell.capacity},${cell.ir || ''},${cellIdx === 0 ? groupTotal : ''}`);
|
||||
});
|
||||
});
|
||||
|
||||
if (AppState.results.excludedCells.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Excluded Cells');
|
||||
AppState.results.excludedCells.forEach(cell => {
|
||||
lines.push(`-,${cell.label},${cell.capacity},${cell.ir || ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile(lines.join('\n'), 'cell-matching-results.csv', 'text/csv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy results to clipboard.
|
||||
*/
|
||||
async function copyResults() {
|
||||
if (!AppState.results) return;
|
||||
|
||||
const config = AppState.results.configuration;
|
||||
const lines = [
|
||||
`Cell Matching Results - ${DOM.cellsSerial.value}S${DOM.cellsParallel.value}P`,
|
||||
`Score: ${AppState.results.score.toFixed(3)}`,
|
||||
`Capacity CV: ${AppState.results.capacityCV.toFixed(2)}%`,
|
||||
'',
|
||||
'Pack Configuration:'
|
||||
];
|
||||
|
||||
config.forEach((group, idx) => {
|
||||
const cells = group.map(c => `${c.label} (${c.capacity}mAh)`).join(' + ');
|
||||
const total = group.reduce((sum, c) => sum + c.capacity, 0);
|
||||
lines.push(` S${idx + 1}: ${cells} = ${total}mAh`);
|
||||
});
|
||||
|
||||
if (AppState.results.excludedCells.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`Excluded: ${AppState.results.excludedCells.map(c => c.label).join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'));
|
||||
DOM.btnCopyResults.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
DOM.btnCopyResults.textContent = 'Copy to Clipboard';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to download a file.
|
||||
*/
|
||||
function downloadFile(content, filename, mimeType) {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Keyboard Navigation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Setup keyboard shortcuts.
|
||||
*/
|
||||
function setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Alt + A: Add cell
|
||||
if (e.altKey && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
addCell();
|
||||
}
|
||||
|
||||
// Alt + S: Start matching
|
||||
if (e.altKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!DOM.btnStartMatching.disabled) {
|
||||
startMatching();
|
||||
}
|
||||
}
|
||||
|
||||
// Alt + E: Load example
|
||||
if (e.altKey && e.key === 'e') {
|
||||
e.preventDefault();
|
||||
loadExampleData();
|
||||
}
|
||||
|
||||
// Escape: Stop matching or close dialog
|
||||
if (e.key === 'Escape') {
|
||||
if (AppState.isRunning) {
|
||||
stopMatching();
|
||||
}
|
||||
if (DOM.shortcutsDialog.open) {
|
||||
DOM.shortcutsDialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ?: Show shortcuts
|
||||
if (e.key === '?' && !e.target.matches('input, textarea')) {
|
||||
e.preventDefault();
|
||||
DOM.shortcutsDialog.showModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Event Listeners
|
||||
// =============================================================================
|
||||
|
||||
function initEventListeners() {
|
||||
// Configuration
|
||||
DOM.cellsSerial.addEventListener('input', updateConfigDisplay);
|
||||
DOM.cellsParallel.addEventListener('input', updateConfigDisplay);
|
||||
|
||||
// Cell management
|
||||
DOM.btnAddCell.addEventListener('click', () => addCell());
|
||||
DOM.btnLoadExample.addEventListener('click', loadExampleData);
|
||||
DOM.btnClearAll.addEventListener('click', clearAllCells);
|
||||
|
||||
// Weight sliders
|
||||
DOM.weightCapacity.addEventListener('input', () => updateWeights('capacity'));
|
||||
DOM.weightIr.addEventListener('input', () => updateWeights('ir'));
|
||||
|
||||
// Matching
|
||||
DOM.btnStartMatching.addEventListener('click', startMatching);
|
||||
DOM.btnStopMatching.addEventListener('click', stopMatching);
|
||||
|
||||
// Export
|
||||
DOM.btnExportJson.addEventListener('click', exportJson);
|
||||
DOM.btnExportCsv.addEventListener('click', exportCsv);
|
||||
DOM.btnCopyResults.addEventListener('click', copyResults);
|
||||
|
||||
// Dialog
|
||||
DOM.btnCloseShortcuts.addEventListener('click', () => DOM.shortcutsDialog.close());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Initialization
|
||||
// =============================================================================
|
||||
|
||||
function init() {
|
||||
initEventListeners();
|
||||
setupKeyboardShortcuts();
|
||||
updateConfigDisplay();
|
||||
updateWeights('capacity');
|
||||
updateMatchingButtonState();
|
||||
|
||||
// Add a few empty cell rows to start
|
||||
for (let i = 0; i < 3; i++) {
|
||||
addCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
680
js/matching-algorithms.js
Normal file
680
js/matching-algorithms.js
Normal file
@ -0,0 +1,680 @@
|
||||
/**
|
||||
* LiXX Cell Pack Matcher - Matching Algorithms
|
||||
*
|
||||
* Implements optimized algorithms for lithium cell matching:
|
||||
* - Genetic Algorithm (default, fast)
|
||||
* - Simulated Annealing
|
||||
* - Exhaustive search (for small configurations)
|
||||
*
|
||||
* Based on research:
|
||||
* - Shi et al., 2013: "Internal resistance matching for parallel-connected
|
||||
* lithium-ion cells and impacts on battery pack cycle life"
|
||||
* DOI: 10.1016/j.jpowsour.2013.11.064
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the coefficient of variation (CV) as a percentage.
|
||||
* CV = (standard deviation / mean) * 100
|
||||
* @param {number[]} values - Array of numeric values
|
||||
* @returns {number} CV as percentage, or 0 if invalid
|
||||
*/
|
||||
function coefficientOfVariation(values) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
if (mean === 0) return 0;
|
||||
const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length;
|
||||
return (Math.sqrt(variance) / mean) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array in place using Fisher-Yates algorithm.
|
||||
* @param {Array} array - Array to shuffle
|
||||
* @returns {Array} The same array, shuffled
|
||||
*/
|
||||
function shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone an array of arrays.
|
||||
* @param {Array[]} arr - Array to clone
|
||||
* @returns {Array[]} Cloned array
|
||||
*/
|
||||
function cloneConfiguration(arr) {
|
||||
return arr.map(group => [...group]);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Scoring Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the match score for a pack configuration.
|
||||
* Lower score = better match.
|
||||
*
|
||||
* The score combines:
|
||||
* - Capacity variance between parallel groups (weighted by capacityWeight)
|
||||
* - Internal resistance variance within parallel groups (weighted by irWeight)
|
||||
*
|
||||
* @param {Object[][]} configuration - Array of parallel groups, each containing cell objects
|
||||
* @param {number} capacityWeight - Weight for capacity matching (0-1)
|
||||
* @param {number} irWeight - Weight for IR matching (0-1)
|
||||
* @returns {Object} Score breakdown
|
||||
*/
|
||||
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
|
||||
// Calculate total capacity for each parallel group
|
||||
const groupCapacities = configuration.map(group =>
|
||||
group.reduce((sum, cell) => sum + cell.capacity, 0)
|
||||
);
|
||||
|
||||
// Calculate average IR for each parallel group
|
||||
const groupIRs = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length === 0) return null;
|
||||
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
|
||||
}).filter(ir => ir !== null);
|
||||
|
||||
// Calculate IR variance within each parallel group (important for parallel cells)
|
||||
const withinGroupIRVariances = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length < 2) return 0;
|
||||
const irs = irsWithValues.map(cell => cell.ir);
|
||||
return coefficientOfVariation(irs);
|
||||
});
|
||||
|
||||
// Capacity CV between groups (should be low for balanced pack)
|
||||
const capacityCV = coefficientOfVariation(groupCapacities);
|
||||
|
||||
// Average IR CV within groups (should be low for parallel cells)
|
||||
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
|
||||
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
|
||||
: 0;
|
||||
|
||||
// Combined score (lower is better)
|
||||
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
|
||||
|
||||
return {
|
||||
score,
|
||||
capacityCV,
|
||||
irCV: avgWithinGroupIRCV,
|
||||
groupCapacities,
|
||||
groupIRs,
|
||||
withinGroupIRVariances
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Genetic Algorithm
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Genetic Algorithm for cell matching.
|
||||
* Fast and effective for most configurations.
|
||||
*/
|
||||
class GeneticAlgorithm {
|
||||
/**
|
||||
* @param {Object[]} cells - Array of cell objects {label, capacity, ir}
|
||||
* @param {number} serial - Number of series groups
|
||||
* @param {number} parallel - Number of cells in parallel per group
|
||||
* @param {Object} options - Algorithm options
|
||||
*/
|
||||
constructor(cells, serial, parallel, options = {}) {
|
||||
this.cells = cells;
|
||||
this.serial = serial;
|
||||
this.parallel = parallel;
|
||||
this.totalCellsNeeded = serial * parallel;
|
||||
|
||||
// Options with defaults
|
||||
this.populationSize = options.populationSize || 50;
|
||||
this.maxIterations = options.maxIterations || 5000;
|
||||
this.mutationRate = options.mutationRate || 0.15;
|
||||
this.eliteCount = options.eliteCount || 5;
|
||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||
this.irWeight = options.irWeight ?? 0.3;
|
||||
this.onProgress = options.onProgress || (() => { });
|
||||
|
||||
this.stopped = false;
|
||||
this.bestSolution = null;
|
||||
this.bestScore = Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the algorithm.
|
||||
*/
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random individual (configuration).
|
||||
* @param {Object[]} cellPool - Cells to choose from
|
||||
* @returns {Object[][]} Configuration
|
||||
*/
|
||||
createIndividual(cellPool) {
|
||||
const shuffled = shuffleArray([...cellPool]).slice(0, this.totalCellsNeeded);
|
||||
const configuration = [];
|
||||
|
||||
for (let i = 0; i < this.serial; i++) {
|
||||
const group = [];
|
||||
for (let j = 0; j < this.parallel; j++) {
|
||||
group.push(shuffled[i * this.parallel + j]);
|
||||
}
|
||||
configuration.push(group);
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert configuration to flat array of cell indices for crossover.
|
||||
* @param {Object[][]} config - Configuration
|
||||
* @returns {number[]} Flat array of cell indices
|
||||
*/
|
||||
configToIndices(config) {
|
||||
const flat = config.flat();
|
||||
return flat.map(cell => this.cells.findIndex(c => c.label === cell.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert indices back to configuration.
|
||||
* @param {number[]} indices - Array of cell indices
|
||||
* @returns {Object[][]} Configuration
|
||||
*/
|
||||
indicesToConfig(indices) {
|
||||
const configuration = [];
|
||||
for (let i = 0; i < this.serial; i++) {
|
||||
const group = [];
|
||||
for (let j = 0; j < this.parallel; j++) {
|
||||
const idx = indices[i * this.parallel + j];
|
||||
group.push(this.cells[idx]);
|
||||
}
|
||||
configuration.push(group);
|
||||
}
|
||||
return configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform crossover between two parents using Order Crossover (OX).
|
||||
* @param {number[]} parent1 - First parent indices
|
||||
* @param {number[]} parent2 - Second parent indices
|
||||
* @returns {number[]} Child indices
|
||||
*/
|
||||
crossover(parent1, parent2) {
|
||||
const length = parent1.length;
|
||||
const start = Math.floor(Math.random() * length);
|
||||
const end = start + Math.floor(Math.random() * (length - start));
|
||||
|
||||
const child = new Array(length).fill(-1);
|
||||
const usedIndices = new Set();
|
||||
|
||||
// Copy segment from parent1
|
||||
for (let i = start; i <= end; i++) {
|
||||
child[i] = parent1[i];
|
||||
usedIndices.add(parent1[i]);
|
||||
}
|
||||
|
||||
// Fill remaining from parent2
|
||||
let childIdx = (end + 1) % length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
const parent2Idx = (end + 1 + i) % length;
|
||||
if (!usedIndices.has(parent2[parent2Idx])) {
|
||||
while (child[childIdx] !== -1) {
|
||||
childIdx = (childIdx + 1) % length;
|
||||
}
|
||||
child[childIdx] = parent2[parent2Idx];
|
||||
usedIndices.add(parent2[parent2Idx]);
|
||||
childIdx = (childIdx + 1) % length;
|
||||
}
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate an individual by swapping cells.
|
||||
* @param {number[]} indices - Individual indices
|
||||
* @param {Object[]} unusedCells - Cells not in this configuration
|
||||
* @returns {number[]} Mutated indices
|
||||
*/
|
||||
mutate(indices, unusedCells) {
|
||||
const mutated = [...indices];
|
||||
|
||||
if (Math.random() < this.mutationRate) {
|
||||
if (unusedCells.length > 0 && Math.random() < 0.3) {
|
||||
// Replace a cell with an unused one
|
||||
const replaceIdx = Math.floor(Math.random() * mutated.length);
|
||||
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
||||
const unusedIdx = this.cells.findIndex(c => c.label === unusedCell.label);
|
||||
mutated[replaceIdx] = unusedIdx;
|
||||
} else {
|
||||
// Swap two cells within the configuration
|
||||
const i = Math.floor(Math.random() * mutated.length);
|
||||
const j = Math.floor(Math.random() * mutated.length);
|
||||
[mutated[i], mutated[j]] = [mutated[j], mutated[i]];
|
||||
}
|
||||
}
|
||||
|
||||
return mutated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the genetic algorithm.
|
||||
* @returns {Promise<Object>} Best solution found
|
||||
*/
|
||||
async run() {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Initialize population
|
||||
let population = [];
|
||||
for (let i = 0; i < this.populationSize; i++) {
|
||||
population.push(this.createIndividual(this.cells));
|
||||
}
|
||||
|
||||
// Evaluate initial population
|
||||
let evaluated = population.map(config => ({
|
||||
config,
|
||||
indices: this.configToIndices(config),
|
||||
...calculateScore(config, this.capacityWeight, this.irWeight)
|
||||
}));
|
||||
|
||||
// Sort by score
|
||||
evaluated.sort((a, b) => a.score - b.score);
|
||||
|
||||
if (evaluated[0].score < this.bestScore) {
|
||||
this.bestScore = evaluated[0].score;
|
||||
this.bestSolution = evaluated[0];
|
||||
}
|
||||
|
||||
// Main evolution loop
|
||||
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
||||
// Selection (tournament selection)
|
||||
const newPopulation = [];
|
||||
|
||||
// Keep elite individuals
|
||||
for (let i = 0; i < this.eliteCount && i < evaluated.length; i++) {
|
||||
newPopulation.push(evaluated[i].indices);
|
||||
}
|
||||
|
||||
// Generate rest through crossover and mutation
|
||||
while (newPopulation.length < this.populationSize) {
|
||||
// Tournament selection
|
||||
const tournament1 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
|
||||
const tournament2 = evaluated.slice(0, Math.ceil(evaluated.length / 2));
|
||||
const parent1 = tournament1[Math.floor(Math.random() * tournament1.length)];
|
||||
const parent2 = tournament2[Math.floor(Math.random() * tournament2.length)];
|
||||
|
||||
// Crossover
|
||||
let child = this.crossover(parent1.indices, parent2.indices);
|
||||
|
||||
// Determine unused cells
|
||||
const usedLabels = new Set(child.map(idx => this.cells[idx].label));
|
||||
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
// Mutation
|
||||
child = this.mutate(child, unusedCells);
|
||||
|
||||
newPopulation.push(child);
|
||||
}
|
||||
|
||||
// Evaluate new population
|
||||
evaluated = newPopulation.map(indices => {
|
||||
const config = this.indicesToConfig(indices);
|
||||
return {
|
||||
config,
|
||||
indices,
|
||||
...calculateScore(config, this.capacityWeight, this.irWeight)
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by score
|
||||
evaluated.sort((a, b) => a.score - b.score);
|
||||
|
||||
// Update best solution
|
||||
if (evaluated[0].score < this.bestScore) {
|
||||
this.bestScore = evaluated[0].score;
|
||||
this.bestSolution = evaluated[0];
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
if (iteration % 50 === 0 || iteration === this.maxIterations - 1) {
|
||||
this.onProgress({
|
||||
iteration,
|
||||
maxIterations: this.maxIterations,
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
elapsedTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
// Allow UI to update
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine excluded cells
|
||||
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
return {
|
||||
configuration: this.bestSolution.config,
|
||||
score: this.bestScore,
|
||||
capacityCV: this.bestSolution.capacityCV,
|
||||
irCV: this.bestSolution.irCV,
|
||||
groupCapacities: this.bestSolution.groupCapacities,
|
||||
excludedCells,
|
||||
iterations: this.maxIterations,
|
||||
elapsedTime: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Simulated Annealing
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Simulated Annealing algorithm for cell matching.
|
||||
* Good for escaping local minima.
|
||||
*/
|
||||
class SimulatedAnnealing {
|
||||
/**
|
||||
* @param {Object[]} cells - Array of cell objects
|
||||
* @param {number} serial - Number of series groups
|
||||
* @param {number} parallel - Number of cells in parallel per group
|
||||
* @param {Object} options - Algorithm options
|
||||
*/
|
||||
constructor(cells, serial, parallel, options = {}) {
|
||||
this.cells = cells;
|
||||
this.serial = serial;
|
||||
this.parallel = parallel;
|
||||
this.totalCellsNeeded = serial * parallel;
|
||||
|
||||
this.maxIterations = options.maxIterations || 5000;
|
||||
this.initialTemp = options.initialTemp || 100;
|
||||
this.coolingRate = options.coolingRate || 0.995;
|
||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||
this.irWeight = options.irWeight ?? 0.3;
|
||||
this.onProgress = options.onProgress || (() => { });
|
||||
|
||||
this.stopped = false;
|
||||
this.bestSolution = null;
|
||||
this.bestScore = Infinity;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create initial configuration.
|
||||
*/
|
||||
createInitialConfig() {
|
||||
const shuffled = shuffleArray([...this.cells]).slice(0, this.totalCellsNeeded);
|
||||
const configuration = [];
|
||||
|
||||
for (let i = 0; i < this.serial; i++) {
|
||||
const group = [];
|
||||
for (let j = 0; j < this.parallel; j++) {
|
||||
group.push(shuffled[i * this.parallel + j]);
|
||||
}
|
||||
configuration.push(group);
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a neighbor solution by making a small change.
|
||||
*/
|
||||
getNeighbor(config) {
|
||||
const newConfig = cloneConfiguration(config);
|
||||
const usedLabels = new Set(config.flat().map(c => c.label));
|
||||
const unusedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
const moveType = Math.random();
|
||||
|
||||
if (unusedCells.length > 0 && moveType < 0.3) {
|
||||
// Replace a cell with an unused one
|
||||
const groupIdx = Math.floor(Math.random() * this.serial);
|
||||
const cellIdx = Math.floor(Math.random() * this.parallel);
|
||||
const unusedCell = unusedCells[Math.floor(Math.random() * unusedCells.length)];
|
||||
newConfig[groupIdx][cellIdx] = unusedCell;
|
||||
} else if (moveType < 0.65) {
|
||||
// Swap cells between different groups
|
||||
const group1 = Math.floor(Math.random() * this.serial);
|
||||
let group2 = Math.floor(Math.random() * this.serial);
|
||||
while (group2 === group1 && this.serial > 1) {
|
||||
group2 = Math.floor(Math.random() * this.serial);
|
||||
}
|
||||
const cell1 = Math.floor(Math.random() * this.parallel);
|
||||
const cell2 = Math.floor(Math.random() * this.parallel);
|
||||
|
||||
const temp = newConfig[group1][cell1];
|
||||
newConfig[group1][cell1] = newConfig[group2][cell2];
|
||||
newConfig[group2][cell2] = temp;
|
||||
} else {
|
||||
// Swap cells within the same group
|
||||
const groupIdx = Math.floor(Math.random() * this.serial);
|
||||
if (this.parallel >= 2) {
|
||||
const cell1 = Math.floor(Math.random() * this.parallel);
|
||||
let cell2 = Math.floor(Math.random() * this.parallel);
|
||||
while (cell2 === cell1) {
|
||||
cell2 = Math.floor(Math.random() * this.parallel);
|
||||
}
|
||||
const temp = newConfig[groupIdx][cell1];
|
||||
newConfig[groupIdx][cell1] = newConfig[groupIdx][cell2];
|
||||
newConfig[groupIdx][cell2] = temp;
|
||||
}
|
||||
}
|
||||
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run simulated annealing.
|
||||
*/
|
||||
async run() {
|
||||
const startTime = Date.now();
|
||||
|
||||
let current = this.createInitialConfig();
|
||||
let currentScore = calculateScore(current, this.capacityWeight, this.irWeight);
|
||||
|
||||
this.bestSolution = { config: cloneConfiguration(current), ...currentScore };
|
||||
this.bestScore = currentScore.score;
|
||||
|
||||
let temperature = this.initialTemp;
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations && !this.stopped; iteration++) {
|
||||
const neighbor = this.getNeighbor(current);
|
||||
const neighborScore = calculateScore(neighbor, this.capacityWeight, this.irWeight);
|
||||
|
||||
const delta = neighborScore.score - currentScore.score;
|
||||
|
||||
// Accept if better, or with probability based on temperature
|
||||
if (delta < 0 || Math.random() < Math.exp(-delta / temperature)) {
|
||||
current = neighbor;
|
||||
currentScore = neighborScore;
|
||||
|
||||
if (currentScore.score < this.bestScore) {
|
||||
this.bestScore = currentScore.score;
|
||||
this.bestSolution = { config: cloneConfiguration(current), ...currentScore };
|
||||
}
|
||||
}
|
||||
|
||||
// Cool down
|
||||
temperature *= this.coolingRate;
|
||||
|
||||
// Progress callback
|
||||
if (iteration % 100 === 0 || iteration === this.maxIterations - 1) {
|
||||
this.onProgress({
|
||||
iteration,
|
||||
maxIterations: this.maxIterations,
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
temperature,
|
||||
elapsedTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
return {
|
||||
configuration: this.bestSolution.config,
|
||||
score: this.bestScore,
|
||||
capacityCV: this.bestSolution.capacityCV,
|
||||
irCV: this.bestSolution.irCV,
|
||||
groupCapacities: this.bestSolution.groupCapacities,
|
||||
excludedCells,
|
||||
iterations: this.maxIterations,
|
||||
elapsedTime: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exhaustive Search (for small configurations)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Exhaustive search - finds the globally optimal solution.
|
||||
* Only practical for small configurations due to factorial complexity.
|
||||
*/
|
||||
class ExhaustiveSearch {
|
||||
constructor(cells, serial, parallel, options = {}) {
|
||||
this.cells = cells;
|
||||
this.serial = serial;
|
||||
this.parallel = parallel;
|
||||
this.totalCellsNeeded = serial * parallel;
|
||||
|
||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||
this.irWeight = options.irWeight ?? 0.3;
|
||||
this.onProgress = options.onProgress || (() => { });
|
||||
this.maxIterations = options.maxIterations || 100000;
|
||||
|
||||
this.stopped = false;
|
||||
this.bestSolution = null;
|
||||
this.bestScore = Infinity;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all combinations of k elements from array.
|
||||
*/
|
||||
*combinations(array, k) {
|
||||
if (k === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
if (array.length < k) return;
|
||||
|
||||
const [first, ...rest] = array;
|
||||
for (const combo of this.combinations(rest, k - 1)) {
|
||||
yield [first, ...combo];
|
||||
}
|
||||
yield* this.combinations(rest, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all partitions of cells into groups.
|
||||
*/
|
||||
*generatePartitions(cells, groupSize, numGroups) {
|
||||
if (numGroups === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (cells.length < groupSize * numGroups) return;
|
||||
|
||||
for (const group of this.combinations(cells, groupSize)) {
|
||||
const remaining = cells.filter(c => !group.includes(c));
|
||||
for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) {
|
||||
yield [group, ...rest];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
const startTime = Date.now();
|
||||
let iteration = 0;
|
||||
|
||||
// Select best subset if we have more cells than needed
|
||||
const cellCombos = this.cells.length > this.totalCellsNeeded
|
||||
? this.combinations(this.cells, this.totalCellsNeeded)
|
||||
: [[...this.cells]];
|
||||
|
||||
for (const cellSubset of cellCombos) {
|
||||
if (this.stopped) break;
|
||||
|
||||
for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) {
|
||||
if (this.stopped) break;
|
||||
|
||||
const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight);
|
||||
|
||||
if (scoreResult.score < this.bestScore) {
|
||||
this.bestScore = scoreResult.score;
|
||||
this.bestSolution = { config: partition, ...scoreResult };
|
||||
}
|
||||
|
||||
iteration++;
|
||||
|
||||
if (iteration % 1000 === 0) {
|
||||
this.onProgress({
|
||||
iteration,
|
||||
maxIterations: this.maxIterations,
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
elapsedTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
if (iteration >= this.maxIterations) {
|
||||
this.stopped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
return {
|
||||
configuration: this.bestSolution.config,
|
||||
score: this.bestScore,
|
||||
capacityCV: this.bestSolution.capacityCV,
|
||||
irCV: this.bestSolution.irCV,
|
||||
groupCapacities: this.bestSolution.groupCapacities,
|
||||
excludedCells,
|
||||
iterations: iteration,
|
||||
elapsedTime: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export
|
||||
// =============================================================================
|
||||
|
||||
// Make available globally for the main app
|
||||
window.CellMatchingAlgorithms = {
|
||||
GeneticAlgorithm,
|
||||
SimulatedAnnealing,
|
||||
ExhaustiveSearch,
|
||||
calculateScore,
|
||||
coefficientOfVariation
|
||||
};
|
||||
Reference in New Issue
Block a user