Rewrite as static webapp (#1)
Reviewed-on: #1 Co-authored-by: localhorst <localhorst@mosad.xyz> Co-committed-by: localhorst <localhorst@mosad.xyz>
This commit is contained in:
379
js/matching-worker.js
Normal file
379
js/matching-worker.js
Normal file
@ -0,0 +1,379 @@
|
||||
/**
|
||||
* LiXX Cell Pack Matcher - Web Worker
|
||||
*
|
||||
* Runs matching algorithms in a background thread to keep the UI responsive.
|
||||
* Communicates with the main thread via postMessage.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the coefficient of variation (CV) as a percentage.
|
||||
*/
|
||||
function coefficientOfVariation(values) {
|
||||
if (!values || values.length === 0) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
if (mean === 0) return 0;
|
||||
const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / values.length;
|
||||
return (Math.sqrt(variance) / mean) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array in place using Fisher-Yates algorithm.
|
||||
*/
|
||||
function shuffleArray(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone an array of arrays.
|
||||
*/
|
||||
function cloneConfiguration(arr) {
|
||||
return arr.map(group => [...group]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle cells based on random
|
||||
*/
|
||||
function shuffleCells(cells) {
|
||||
const shuffled = [...cells];
|
||||
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Scoring Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Calculate the match score for a pack configuration.
|
||||
* Lower score = better match.
|
||||
*/
|
||||
function calculateScore(configuration, capacityWeight = 0.7, irWeight = 0.3) {
|
||||
const groupCapacities = configuration.map(group =>
|
||||
group.reduce((sum, cell) => sum + cell.capacity, 0)
|
||||
);
|
||||
|
||||
const groupIRs = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length === 0) return null;
|
||||
return irsWithValues.reduce((sum, cell) => sum + cell.ir, 0) / irsWithValues.length;
|
||||
}).filter(ir => ir !== null);
|
||||
|
||||
const withinGroupIRVariances = configuration.map(group => {
|
||||
const irsWithValues = group.filter(cell => cell.ir !== null && cell.ir !== undefined);
|
||||
if (irsWithValues.length < 2) return 0;
|
||||
const irs = irsWithValues.map(cell => cell.ir);
|
||||
return coefficientOfVariation(irs);
|
||||
});
|
||||
|
||||
const capacityCV = coefficientOfVariation(groupCapacities);
|
||||
const avgWithinGroupIRCV = withinGroupIRVariances.length > 0
|
||||
? withinGroupIRVariances.reduce((a, b) => a + b, 0) / withinGroupIRVariances.length
|
||||
: 0;
|
||||
|
||||
const score = (capacityWeight * capacityCV) + (irWeight * avgWithinGroupIRCV);
|
||||
|
||||
return {
|
||||
score,
|
||||
capacityCV,
|
||||
irCV: avgWithinGroupIRCV,
|
||||
groupCapacities,
|
||||
groupIRs,
|
||||
withinGroupIRVariances
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Statistics Tracker
|
||||
// =============================================================================
|
||||
|
||||
class StatsTracker {
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.lastProgressTime = this.startTime;
|
||||
this.lastProgressIteration = 0;
|
||||
this.speedHistory = [];
|
||||
this.windowSize = 5; // Rolling window for speed averaging
|
||||
this.lastEta = null;
|
||||
this.lastEtaUpdate = 0;
|
||||
}
|
||||
|
||||
getStats(currentIteration, maxIterations) {
|
||||
const now = Date.now();
|
||||
const elapsedTime = now - this.startTime;
|
||||
|
||||
// Calculate speed based on iterations since last progress update
|
||||
const iterationsDelta = currentIteration - this.lastProgressIteration;
|
||||
const timeDelta = now - this.lastProgressTime;
|
||||
|
||||
if (timeDelta > 0 && iterationsDelta > 0) {
|
||||
const currentSpeed = (iterationsDelta / timeDelta) * 1000; // iterations per second
|
||||
this.speedHistory.push(currentSpeed);
|
||||
if (this.speedHistory.length > this.windowSize) {
|
||||
this.speedHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
this.lastProgressTime = now;
|
||||
this.lastProgressIteration = currentIteration;
|
||||
|
||||
// Average speed from history
|
||||
const avgSpeed = this.speedHistory.length > 0
|
||||
? this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length
|
||||
: 0;
|
||||
|
||||
const remainingIterations = maxIterations - currentIteration;
|
||||
const eta = avgSpeed > 0 ? (remainingIterations / avgSpeed) * 1000 : 0;
|
||||
|
||||
// Only update ETA every 500ms to avoid flickering
|
||||
if (now - this.lastEtaUpdate > 500 || this.lastEta === null) {
|
||||
this.lastEta = eta;
|
||||
this.lastEtaUpdate = now;
|
||||
}
|
||||
|
||||
return {
|
||||
elapsedTime,
|
||||
eta: this.lastEta,
|
||||
iterationsPerSecond: avgSpeed
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Exhaustive Search
|
||||
// =============================================================================
|
||||
|
||||
class ExhaustiveSearch {
|
||||
constructor(cells, serial, parallel, options = {}) {
|
||||
this.cells = shuffleCells(cells);
|
||||
this.serial = serial;
|
||||
this.parallel = parallel;
|
||||
this.totalCellsNeeded = serial * parallel;
|
||||
|
||||
this.capacityWeight = options.capacityWeight ?? 0.7;
|
||||
this.irWeight = options.irWeight ?? 0.3;
|
||||
this.maxIterations = options.maxIterations || 100000;
|
||||
|
||||
this.stopped = false;
|
||||
this.bestSolution = null;
|
||||
this.bestScore = Infinity;
|
||||
this.stats = new StatsTracker();
|
||||
}
|
||||
|
||||
stop() {
|
||||
console.log("ExhaustiveSearch: stop requested")
|
||||
this.stopped = true;
|
||||
}
|
||||
|
||||
*combinations(array, k) {
|
||||
if (k === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
if (array.length < k) return;
|
||||
|
||||
const [first, ...rest] = array;
|
||||
for (const combo of this.combinations(rest, k - 1)) {
|
||||
yield [first, ...combo];
|
||||
}
|
||||
yield* this.combinations(rest, k);
|
||||
}
|
||||
|
||||
*generatePartitions(cells, groupSize, numGroups) {
|
||||
if (numGroups === 0) {
|
||||
yield [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (cells.length < groupSize * numGroups) return;
|
||||
|
||||
for (const group of this.combinations(cells, groupSize)) {
|
||||
const remaining = cells.filter(c => !group.includes(c));
|
||||
for (const rest of this.generatePartitions(remaining, groupSize, numGroups - 1)) {
|
||||
yield [group, ...rest];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calculateTotalCombinations() {
|
||||
// Formula: C(n, k) * C(n-k, k) * ... / numGroups! for identical groups
|
||||
const n = this.cells.length;
|
||||
const k = this.parallel;
|
||||
const numGroups = this.serial;
|
||||
|
||||
let total = 1;
|
||||
let remaining = n;
|
||||
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
total *= this.binomial(remaining, k);
|
||||
remaining -= k;
|
||||
}
|
||||
|
||||
// Divide by numGroups! if groups are interchangeable
|
||||
// (but for battery packs, position matters, so we don't divide)
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
binomial(n, k) {
|
||||
if (k > n) return 0;
|
||||
if (k === 0 || k === n) return 1;
|
||||
|
||||
let result = 1;
|
||||
for (let i = 0; i < k; i++) {
|
||||
result = result * (n - i) / (i + 1);
|
||||
}
|
||||
return Math.round(result);
|
||||
}
|
||||
|
||||
run() {
|
||||
let iteration = 0;
|
||||
const totalCombinations = this.calculateTotalCombinations();
|
||||
|
||||
const cellCombos = this.cells.length > this.totalCellsNeeded
|
||||
? this.combinations(this.cells, this.totalCellsNeeded)
|
||||
: [[...this.cells]];
|
||||
|
||||
for (const cellSubset of cellCombos) {
|
||||
if (this.stopped) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
for (const partition of this.generatePartitions(cellSubset, this.parallel, this.serial)) {
|
||||
if (this.stopped) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
const scoreResult = calculateScore(partition, this.capacityWeight, this.irWeight);
|
||||
|
||||
if (scoreResult.score < this.bestScore) {
|
||||
this.bestScore = scoreResult.score;
|
||||
this.bestSolution = { config: partition, ...scoreResult };
|
||||
}
|
||||
|
||||
iteration++;
|
||||
|
||||
// Check for stop every 100 iterations, but only send progress updates every 200ms
|
||||
if (iteration % 100 === 0) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastProgress = now - this.stats.lastProgressTime;
|
||||
|
||||
if (timeSinceLastProgress >= 200) {
|
||||
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
|
||||
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
data: {
|
||||
iteration,
|
||||
maxIterations: Math.min(totalCombinations, this.maxIterations),
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
totalCombinations,
|
||||
evaluatedCombinations: iteration,
|
||||
...stats
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (iteration >= this.maxIterations) {
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.returnBestResult(iteration, totalCombinations);
|
||||
}
|
||||
|
||||
returnBestResult(iteration, totalCombinations) {
|
||||
if (!this.bestSolution) {
|
||||
// No solution found yet, create one from first cells
|
||||
const config = [];
|
||||
for (let i = 0; i < this.serial; i++) {
|
||||
config.push(this.cells.slice(i * this.parallel, (i + 1) * this.parallel));
|
||||
}
|
||||
const scoreResult = calculateScore(config, this.capacityWeight, this.irWeight);
|
||||
this.bestSolution = { config, ...scoreResult };
|
||||
this.bestScore = scoreResult.score;
|
||||
}
|
||||
|
||||
const usedLabels = new Set(this.bestSolution.config.flat().map(c => c.label));
|
||||
const excludedCells = this.cells.filter(c => !usedLabels.has(c.label));
|
||||
|
||||
// Final progress update
|
||||
const stats = this.stats.getStats(iteration, Math.min(totalCombinations, this.maxIterations));
|
||||
self.postMessage({
|
||||
type: 'progress',
|
||||
data: {
|
||||
iteration,
|
||||
maxIterations: Math.min(totalCombinations, this.maxIterations),
|
||||
bestScore: this.bestScore,
|
||||
currentBest: this.bestSolution,
|
||||
totalCombinations,
|
||||
evaluatedCombinations: iteration,
|
||||
...stats
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
configuration: this.bestSolution.config,
|
||||
score: this.bestScore,
|
||||
capacityCV: this.bestSolution.capacityCV,
|
||||
irCV: this.bestSolution.irCV,
|
||||
groupCapacities: this.bestSolution.groupCapacities,
|
||||
excludedCells,
|
||||
iterations: iteration,
|
||||
elapsedTime: Date.now() - this.stats.startTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Worker Message Handler
|
||||
// =============================================================================
|
||||
|
||||
let currentAlgorithm = null;
|
||||
|
||||
self.onmessage = function (e) {
|
||||
const { type, data } = e.data;
|
||||
|
||||
switch (type) {
|
||||
case 'start':
|
||||
const { cells, serial, parallel, algorithm, options } = data;
|
||||
|
||||
switch (algorithm) {
|
||||
case 'exhaustive':
|
||||
currentAlgorithm = new ExhaustiveSearch(cells, serial, parallel, options);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = currentAlgorithm.run();
|
||||
self.postMessage({ type: 'complete', data: result });
|
||||
} catch (error) {
|
||||
self.postMessage({ type: 'error', data: error.message });
|
||||
}
|
||||
|
||||
currentAlgorithm = null;
|
||||
break;
|
||||
|
||||
case 'stop':
|
||||
console.log("Algo: Stop requested")
|
||||
if (currentAlgorithm) {
|
||||
currentAlgorithm.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user