From b73b8f5172ef8a37c9db225dd6ee9b72a19c8a2b Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:53:42 -0400 Subject: [PATCH 01/21] Improve Hayward HWVS pump reliability with comm failure tracking and exponential backoff - Implement requestPumpStatusAsync() to actively poll Hayward pump status instead of no-op - Add consecutive communication failure tracking with exponential backoff in polling (2s to max 30s after 5 failures) - Add pollEquipmentAsync() override to include status polling after each state update - Add updateCommStatus() to set pump status to warning/error based on failure count - Preserve last known pump state on comm failure instead of zeroing rpm/watts - Fix silent error swallow in setPumpToRemoteControlAsync (was missing logger.error) - Increase retries from 1 to 3 and add 2500ms timeout on outbound messages - Update package-lock.json license identifier to AGPL-3.0-only --- controller/nixie/pumps/Pump.ts | 109 +++++++++++++++++++++++++++++---- package-lock.json | 6 +- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/controller/nixie/pumps/Pump.ts b/controller/nixie/pumps/Pump.ts index 5b283dbf..4231fbc5 100644 --- a/controller/nixie/pumps/Pump.ts +++ b/controller/nixie/pumps/Pump.ts @@ -895,6 +895,10 @@ export class NixiePumpVSF extends NixiePumpRS485 { }; }; export class NixiePumpHWVS extends NixiePumpRS485 { + private _consecutiveCommFailures: number = 0; + private _lastSuccessfulComm: Date = new Date(); + private _commFailureThreshold: number = 5; // Number of failures before exponential backoff + public setTargetSpeed(pState: PumpState) { let _newSpeed = 0; if (!pState.pumpOnDelay) { @@ -931,7 +935,35 @@ export class NixiePumpHWVS extends NixiePumpRS485 { } finally { this.suspendPolling = false; } }; - protected async requestPumpStatusAsync() { return Promise.resolve(); }; + protected async requestPumpStatusAsync() { + // Actively poll Hayward pump for current status to maintain sync + if (conn.isPortEnabled(this.pump.portId || 0)) { + let out = Outbound.create({ + portId: this.pump.portId || 0, + protocol: Protocol.Hayward, + source: 1, + dest: this.pump.address - 96, + action: 12, + payload: [Math.min(Math.round((this._targetSpeed / sys.board.valueMaps.pumpTypes.get(this.pump.type).maxSpeed) * 100), 100)], + retries: 3, + timeout: 2500, + response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) + }); + try { + await out.sendAsync(); + // Communication successful - reset failure counter + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); + let pstate = state.pumps.getItemById(this.pump.id); + pstate.status = 0; // OK status + } + catch (err) { + this._consecutiveCommFailures++; + logger.warn(`Hayward pump ${this.pump.name} status request failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); + this.updateCommStatus(); + } + } + }; protected setPumpFeatureAsync(feature?: number) { return Promise.resolve(); } protected async setPumpToRemoteControlAsync(running: boolean = true) { try { @@ -945,20 +977,23 @@ export class NixiePumpHWVS extends NixiePumpRS485 { dest: this.pump.address, action: 1, payload: [0], // when stopAsync is called, pass false to return control to pump panel - // payload: spump.virtualControllerStatus === sys.board.valueMaps.virtualControllerStatus.getValue('running') ? [255] : [0], - retries: 1, + retries: 3, + timeout: 2500, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); } catch (err) { - logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name}: ${err.message}`); - + this._consecutiveCommFailures++; + logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name} (${this._consecutiveCommFailures} failures): ${err.message}`); + this.updateCommStatus(); } } } - } catch(err) { `Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}` }; + } catch(err) { logger.error(`Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}`); }; } protected async setPumpRPMAsync() { // Address 1 @@ -979,29 +1014,77 @@ export class NixiePumpHWVS extends NixiePumpRS485 { source: 1, // Use the broadcast address dest: this.pump.address - 96, action: 12, - payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 100)], // when stopAsync is called, pass false to return control to pump panel - retries: 1, + payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 100)], + retries: 3, + timeout: 2500, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); + // Communication successful - reset failure counter + this._consecutiveCommFailures = 0; + this._lastSuccessfulComm = new Date(); + let pstate = state.pumps.getItemById(this.pump.id); + pstate.status = 0; // OK status } catch (err) { - logger.error(`Error sending setPumpRPM for ${this.pump.name}: ${err.message}`); - let pstate = state.pumps.getItemById(this.pump.id); - pstate.command = 0; - pstate.rpm = 0; - pstate.watts = 0; + this._consecutiveCommFailures++; + logger.error(`Hayward pump ${this.pump.name} speed command failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); + // DO NOT clear state - keep showing last known values so user knows pump may still be running + this.updateCommStatus(); } } else { + // Port is disabled - safe to clear state as pump is not accessible let pstate = state.pumps.getItemById(this.pump.id); pstate.command = 0; pstate.rpm = 0; pstate.watts = 0; + pstate.status = 16; // Communication error status } }; + + public async pollEquipmentAsync() { + let self = this; + try { + if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer); + this._pollTimer = null; + if (this.suspendPolling || this.closing || this.pump.address > 112) { + if (this.suspendPolling) logger.info(`Pump ${this.id} Polling Suspended`); + if (this.closing) logger.info(`Pump ${this.id} is closing`); + return; + } + let pstate = state.pumps.getItemById(this.pump.id); + this.setTargetSpeed(pstate); + await this.setPumpStateAsync(pstate); + // Additionally poll for status to verify pump state + await this.requestPumpStatusAsync(); + } + catch (err) { logger.error(`Nixie Error running Hayward pump sequence - ${err}`); } + finally { + if (!self.closing) { + // Exponential backoff if communication is failing + let pollInterval = self.pollingInterval || 2000; + if (this._consecutiveCommFailures >= this._commFailureThreshold) { + // Exponential backoff: 2s -> 4s -> 8s -> 16s (max 30s) + pollInterval = Math.min(pollInterval * Math.pow(2, this._consecutiveCommFailures - this._commFailureThreshold), 30000); + logger.info(`Hayward pump ${this.pump.name} polling backed off to ${pollInterval}ms due to failures`); + } + this._pollTimer = setTimeoutSync(async () => await self.pollEquipmentAsync(), pollInterval); + } + } + } + + private updateCommStatus() { + let pstate = state.pumps.getItemById(this.pump.id); + if (this._consecutiveCommFailures >= this._commFailureThreshold) { + pstate.status = 16; // Communication error + logger.warn(`Hayward pump ${this.pump.name} has ${this._consecutiveCommFailures} consecutive communication failures. Last successful: ${this._lastSuccessfulComm.toISOString()}`); + } else if (this._consecutiveCommFailures > 0) { + pstate.status = 1; // Warning - intermittent issues + } + } } export class NixiePumpRegalModbus extends NixiePump { diff --git a/package-lock.json b/package-lock.json index 2ab6cb16..c3d32dad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "nodejs-poolcontroller", - "version": "9.0.0", + "version": "8.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "nodejs-poolcontroller", - "version": "9.0.0", - "license": "GNU Affero General Public License v3.0", + "version": "8.4.0", + "license": "AGPL-3.0-only", "dependencies": { "@influxdata/influxdb-client": "^1.35.0", "eslint-config-promise": "^2.0.2", From 24dc8c82655427258182fea81e20cc6f41f28a33 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:29:41 -0400 Subject: [PATCH 02/21] Add configurable single mixing period option for chemistry controllers - Add singleMixPeriod property to ChemController and ChemDoser classes - Default is false to maintain backward compatibility - When enabled, prevents simultaneous chemical dosing during mixing periods - ChemController: pH checks ORP mixing state and vice versa - ChemDoser: checks all other dosers for active mixing - Fixes issue with hot tub chemical balance from simultaneous mixing --- controller/Equipment.ts | 6 ++++++ controller/nixie/chemistry/ChemController.ts | 11 +++++++++++ controller/nixie/chemistry/ChemDoser.ts | 17 +++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/controller/Equipment.ts b/controller/Equipment.ts index 76e1837b..95fbfc10 100644 --- a/controller/Equipment.ts +++ b/controller/Equipment.ts @@ -2292,6 +2292,7 @@ export class ChemController extends EqItem implements IChemController { if (typeof this.data.borates === 'undefined') this.data.borates = 0; if (typeof this.data.siCalcType === 'undefined') this.data.siCalcType = 0; if (typeof this.data.intellichemStandalone === 'undefined') this.data.intellichemStandalone = false; + if (typeof this.data.singleMixPeriod === 'undefined') this.data.singleMixPeriod = false; super.initData(); } public dataName = 'chemControllerConfig'; @@ -2327,6 +2328,8 @@ export class ChemController extends EqItem implements IChemController { public get lsiRange(): AlarmSetting { return new AlarmSetting(this.data, 'lsiRange', this); } public get firmware(): string { return this.data.firmware; } public set firmware(val: string) { this.setDataVal('firmware', val); } + public get singleMixPeriod(): boolean { return this.data.singleMixPeriod; } + public set singleMixPeriod(val: boolean) { this.setDataVal('singleMixPeriod', val); } public getExtended() { let chem = this.get(true); chem.type = sys.board.valueMaps.chemControllerTypes.transform(this.type); @@ -2364,6 +2367,7 @@ export class ChemDoser extends EqItem implements IChemical { if (typeof this.mixingTime === 'undefined') this.data.mixingTime = 3600; if (typeof this.data.setpoint === 'undefined') this.data.setpoint = 100; if (typeof this.data.type === 'undefined') this.data.type = 0; + if (typeof this.data.singleMixPeriod === 'undefined') this.data.singleMixPeriod = false; super.initData(); } public get id(): number { return this.data.id; } @@ -2402,6 +2406,8 @@ export class ChemDoser extends EqItem implements IChemical { public get flowSensor(): ChemFlowSensor { return new ChemFlowSensor(this.data, 'flowSensor', this); } public get flowOnlyMixing(): boolean { return utils.makeBool(this.data.flowOnlyMixing); } public set flowOnlyMixing(val: boolean) { this.setDataVal('flowOnlyMixing', val); } + public get singleMixPeriod(): boolean { return this.data.singleMixPeriod; } + public set singleMixPeriod(val: boolean) { this.setDataVal('singleMixPeriod', val); } public get pump(): ChemicalPump { return new ChemicalPump(this.data, 'pump', this); } public get tank(): ChemicalTank { return new ChemicalTank(this.data, 'tank', this); } public getExtended() { diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 4a6f836a..b305898f 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -586,6 +586,7 @@ export class NixieChemController extends NixieChemControllerBase { if (typeof data.lsiRange.low === 'number') chem.lsiRange.low = data.lsiRange.low; if (typeof data.lsiRange.high === 'number') chem.lsiRange.high = data.lsiRange.high; } + if (typeof data.singleMixPeriod !== 'undefined') chem.singleMixPeriod = utils.makeBool(data.singleMixPeriod); if (typeof data.siCalcType !== 'undefined') schem.siCalcType = chem.siCalcType = data.siCalcType; await this.flowSensor.setSensorAsync(data.flowSensor); // Alright we are down to the equipment items all validation should have been completed by now. @@ -1794,6 +1795,11 @@ export class NixieChemicalPh extends NixieChemical { else if (sph.dailyLimitReached) { await this.cancelDosing(sph, 'daily limit'); } + else if (this.chemController.chem.singleMixPeriod && sph.chemController.orp.dosingStatus === 1) { + // Don't dose pH if ORP is mixing - enforce single mixing period (only when enabled) + await this.cancelDosing(sph, 'orp mixing'); + return; + } else if (status === 'monitoring' || status === 'dosing') { // Figure out what mode we are in and what mode we should be in. //sph.level = 7.61; @@ -2288,6 +2294,11 @@ export class NixieChemicalORP extends NixieChemical { await this.cancelDosing(sorp, 'ph pump dosing + dose priority'); return; } + else if (chem.singleMixPeriod && sorp.chemController.ph.dosingStatus === 1) { + // Don't dose ORP if pH is mixing - enforce single mixing period (only when enabled) + await this.cancelDosing(sorp, 'ph mixing'); + return; + } else if (status === 'monitoring' || status === 'dosing') { // let _doseCalculatedSec = 0; if (!sorp.lockout) { diff --git a/controller/nixie/chemistry/ChemDoser.ts b/controller/nixie/chemistry/ChemDoser.ts index ef46904c..f928008f 100644 --- a/controller/nixie/chemistry/ChemDoser.ts +++ b/controller/nixie/chemistry/ChemDoser.ts @@ -429,6 +429,7 @@ export class NixieChemDoser extends NixieChemDoserBase implements INixieChemical (typeof data.mixingTimeSeconds !== 'undefined' ? parseInt(data.mixingTimeSeconds, 10) : 0); } chem.mixingTime = typeof data.mixingTime !== 'undefined' ? parseInt(data.mixingTime, 10) : chem.mixingTime; + if (typeof data.singleMixPeriod !== 'undefined') chem.singleMixPeriod = utils.makeBool(data.singleMixPeriod); await this.flowSensor.setSensorAsync(data.flowSensor); await this.tank.setTankAsync(schem.tank, data.tank); await this.pump.setPumpAsync(schem.pump, data.pump); @@ -586,6 +587,22 @@ export class NixieChemDoser extends NixieChemDoserBase implements INixieChemical await this.cancelDosing(sd, 'daily limit'); } else if (status === 'monitoring' || status === 'dosing') { + // Check if any other chem doser is currently mixing - only if singleMixPeriod is enabled + if (this.chem.singleMixPeriod) { + let otherDoserMixing = false; + for (let i = 0; i < state.chemDosers.length; i++) { + let otherDoser = state.chemDosers.getItemByIndex(i); + if (otherDoser.id !== sd.id && otherDoser.dosingStatus === 1) { // 1 is mixing + logger.info(`Cannot dose ${sd.chemType} - ${otherDoser.chemType} doser is currently mixing`); + otherDoserMixing = true; + break; + } + } + if (otherDoserMixing) { + await this.cancelDosing(sd, 'another doser mixing'); + return; + } + } // Figure out what mode we are in and what mode we should be in. //sph.level = 7.61; // Check the setpoint and the current level to see if we need to dose. From ee10988950b00c3d9cc4b93353b555d87302448d Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Sun, 3 May 2026 18:30:12 -0400 Subject: [PATCH 03/21] Mitigate RS485 rewind memory pressure --- controller/comms/messages/Messages.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/controller/comms/messages/Messages.ts b/controller/comms/messages/Messages.ts index 64d45e09..66f31456 100755 --- a/controller/comms/messages/Messages.ts +++ b/controller/comms/messages/Messages.ts @@ -339,6 +339,9 @@ export class Message { } } export class Inbound extends Message { + private static readonly MAX_REWINDS_PER_MESSAGE = 250; + private static readonly REWIND_LOG_EVERY = 25; + private static readonly REWIND_LOG_PREVIEW_BYTES = 32; // /usr/bin/socat TCP-LISTEN:9801,fork,reuseaddr FILE:/dev/ttyUSB0,b9600,raw // /usr/bin/socat TCP-LISTEN:9801,fork,reuseaddr FILE:/dev/ttyUSB0,b9600,cs8,cstopb=1,parenb=0,raw // /usr/bin / socat TCP - LISTEN: 9801,fork,reuseaddr FILE:/dev/ttyUSB0, b9600, cs8, cstopb = 1, parenb = 0, raw @@ -358,6 +361,12 @@ export class Inbound extends Message { public isProcessed: boolean = false; public collisions: number = 0; public rewinds: number = 0; + private logRewindCollision(buff: number[], ndx: number, inLen: number) { + if (this.collisions === 1 || this.collisions % Inbound.REWIND_LOG_EVERY === 0) { + const preview = buff.slice(0, Inbound.REWIND_LOG_PREVIEW_BYTES); + logger.warn(`rewinding message collision count=${this.collisions} rewinds=${this.rewinds} ndx=${ndx} inLen=${inLen} buffLen=${buff.length} preview=${JSON.stringify(preview)}${buff.length > preview.length ? '...truncated' : ''}`); + } + } // Private methods private isValidChecksum(): boolean { switch (this.protocol) { @@ -543,7 +552,13 @@ export class Inbound extends Message { this.collisions++; this.rewinds++; - logger.info(`rewinding message collision ${this.collisions} ${ndx} ${bytes.length} ${JSON.stringify(buff)}`); + if (this.rewinds > Inbound.MAX_REWINDS_PER_MESSAGE) { + logger.warn(`rewind limit exceeded for inbound message: rewinds=${this.rewinds} collisions=${this.collisions} inLen=${bytes.length}. Dropping current packet to protect heap.`); + this._complete = true; + this.isValid = false; + return bytes.length; + } + this.logRewindCollision(buff, ndx, bytes.length); this.readPacket(buff); return ndx; //return this.padding.length + this.preamble.length; From 47cbb225aa58b8e15c026177c761a05169ae2dad Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Mon, 4 May 2026 19:02:42 -0400 Subject: [PATCH 04/21] Fix no-op catch in Hayward remote control handler --- controller/nixie/pumps/Pump.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/nixie/pumps/Pump.ts b/controller/nixie/pumps/Pump.ts index 5b283dbf..063abcbc 100644 --- a/controller/nixie/pumps/Pump.ts +++ b/controller/nixie/pumps/Pump.ts @@ -958,7 +958,7 @@ export class NixiePumpHWVS extends NixiePumpRS485 { } } } - } catch(err) { `Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}` }; + } catch(err) { logger.error(`Error sending setPumpToRemoteControl message for ${this.pump.name}: ${err.message}`); }; } protected async setPumpRPMAsync() { // Address 1 From 14bed83d406fb54510d71aae14252c8cd06a9659 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 18:06:22 -0400 Subject: [PATCH 05/21] feat(chem): add ORP formula-based chlorine demand calculation Implements the Wojtowicz 1994 empirical formula to calculate a proportional chlorine dose from ORP and pH readings, replacing the hardcoded demand=0 in the peristaltic ORP dosing path. New config fields on ChemicalORP (off by default): orpFormula - enables the chemistry-based demand calculation chlorineType - selects the chlorine product (10%, 12.5%, 6% NaOCl) Formula: FC_ppm = 10^((ORP - 683 + 59.2*(pH-7.0)) / 48.9) Dose: mL = deltaFC * gallons * 3.785411784 * dosingFactor Changes: - controller/boards/SystemBoard.ts: add chlorineTypes byteValueMap - controller/Equipment.ts: add chlorineType + orpFormula to ChemicalORP - controller/State.ts: add calcDemand() override to ChemicalORPState - controller/nixie/chemistry/ChemController.ts: wire calcDemand into peristaltic ORP dosing path; handle new fields in setORPAsync() A CYA warning is logged each cycle when orpFormula is enabled and cyanuricAcid is outside the accurate range of 25-50 ppm. --- controller/Equipment.ts | 8 +++++ controller/State.ts | 33 ++++++++++++++++++++ controller/boards/SystemBoard.ts | 5 +++ controller/nixie/chemistry/ChemController.ts | 4 ++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/controller/Equipment.ts b/controller/Equipment.ts index 76e1837b..24f3364e 100644 --- a/controller/Equipment.ts +++ b/controller/Equipment.ts @@ -2522,6 +2522,8 @@ export class ChemicalORP extends Chemical { if (typeof this.data.tolerance === 'undefined') this.data.tolerance = { low: 650, high: 800, enabled: true }; if (typeof this.data.phLockout === 'undefined') this.data.phLockout = 7.8; if (typeof this.data.doserType === 'undefined') this.data.doserType = 0; + if (typeof this.data.chlorineType === 'undefined') this.data.chlorineType = 0; + if (typeof this.data.orpFormula === 'undefined') this.data.orpFormula = false; super.initData(); } public get useChlorinator(): boolean { return utils.makeBool(this.data.useChlorinator); } @@ -2541,12 +2543,18 @@ export class ChemicalORP extends Chemical { public set chlorDosingMethod(val: number | any) { this.setDataVal('chlorDosingMethod', sys.board.valueMaps.chemChlorDosingMethods.encode(val)); } public get doserType(): number | any { return this.data.doserType; } public set doserType(val: number | any) { this.setDataVal('doserType', sys.board.valueMaps.orpDoserTypes.encode(val)); } + public get chlorineType(): number | any { return this.data.chlorineType; } + public set chlorineType(val: number | any) { this.setDataVal('chlorineType', sys.board.valueMaps.chlorineTypes.encode(val)); } + public get orpFormula(): boolean { return utils.makeBool(this.data.orpFormula); } + public set orpFormula(val: boolean) { this.setDataVal('orpFormula', val); } public getExtended() { let chem = super.getExtended(); chem.probe = this.probe.getExtended(); chem.tank = this.tank.getExtended(); chem.doserType = sys.board.valueMaps.orpDoserTypes.transform(this.doserType); + chem.chlorineType = sys.board.valueMaps.chlorineTypes.transform(this.chlorineType); + chem.orpFormula = this.orpFormula; return chem; } } diff --git a/controller/State.ts b/controller/State.ts index 39b79cbb..abd1ffb6 100644 --- a/controller/State.ts +++ b/controller/State.ts @@ -3200,6 +3200,39 @@ export class ChemicalORPState extends ChemicalState { let cc = this.chemController; return cc.alarms.comms !== 0 || cc.alarms.orpProbeFault !== 0 || cc.alarms.orpPumpFault !== 0 || cc.alarms.bodyFault !== 0; } + public calcDemand(chem?: ChemController): number { + chem = typeof chem === 'undefined' ? sys.chemControllers.getItemById(this.chemController.id) : chem; + if (!chem.orp.orpFormula || this.level >= this.setpoint) return 0; + + let totalGallons = 0; + if (chem.body === 32 && sys.equipment.shared) { + if (state.temps.bodies.getItemById(2).isOn === true) totalGallons = sys.bodies.getItemById(2).capacity; + else totalGallons = sys.bodies.getItemById(1).capacity + sys.bodies.getItemById(2).capacity; + } + else { + totalGallons = sys.bodies.getItemById(chem.body + 1).capacity; + } + + // Use live pH reading; fall back to setpoint then a safe default + let pH = this.chemController.ph.level || chem.ph.setpoint || 7.4; + + // Wojtowicz 1994 empirical formula: FC_ppm = 10^((ORP - 683 + 59.2*(pH-7.0)) / 48.9) + let fcCurrent = Math.pow(10, (this.level - 683 + 59.2 * (pH - 7.0)) / 48.9); + let fcTarget = Math.pow(10, (this.setpoint - 683 + 59.2 * (pH - 7.0)) / 48.9); + let deltaFC = fcTarget - fcCurrent; + if (deltaFC <= 0) return 0; + + // Warn if CYA is outside the accurate range for this formula + if (typeof chem.cyanuricAcid !== 'undefined' && chem.cyanuricAcid > 0 && + (chem.cyanuricAcid < 25 || chem.cyanuricAcid > 50)) { + logger.warn(`Chem ORP formula: CYA (${chem.cyanuricAcid} ppm) outside recommended 25-50 ppm range; dosing accuracy reduced.`); + } + + let ct = sys.board.valueMaps.chlorineTypes.transform(chem.orp.chlorineType); + let dose = Math.round(deltaFC * totalGallons * 3.785411784 * ct.dosingFactor); + logger.verbose(`Chem ORP demand: level=${this.level}mV setpoint=${this.setpoint}mV pH=${pH} deltaFC=${deltaFC.toFixed(3)}ppm dose=${dose}mL`); + return dose; + } public getExtended() { let chem = super.getExtended(); chem.probe = this.probe.getExtended(); diff --git a/controller/boards/SystemBoard.ts b/controller/boards/SystemBoard.ts index 22a3dff6..f41f714b 100644 --- a/controller/boards/SystemBoard.ts +++ b/controller/boards/SystemBoard.ts @@ -769,6 +769,11 @@ export class byteValueMaps { [4, { name: 'a15.7', desc: '15.7% - 10 Baume', dosingFactor: 2.0 }], [5, { name: 'a14.5', desc: '14.5% - 9.8 Baume', dosingFactor: 2.16897 }], ]); + public chlorineTypes: byteValueMap = new byteValueMap([ + [0, { name: 'sodium10', desc: '10% Sodium Hypochlorite', dosingFactor: 0.00974 }], + [1, { name: 'sodium12_5', desc: '12.5% Sodium Hypochlorite', dosingFactor: 0.00763 }], + [2, { name: 'sodium6', desc: '6% Sodium Hypochlorite (Household Bleach)', dosingFactor: 0.01672 }], + ]); public filterTypes: byteValueMap = new byteValueMap([ [0, { name: 'sand', desc: 'Sand', hasBackwash: true }], [1, { name: 'cartridge', desc: 'Cartridge', hasBackwash: false }], diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index aa8c4d01..8c3f15a6 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -2066,6 +2066,8 @@ export class NixieChemicalORP extends NixieChemical { if (typeof data.tolerance.low === 'number') this.orp.tolerance.low = data.tolerance.low; if (typeof data.tolerance.high === 'number') this.orp.tolerance.high = data.tolerance.high; } + if (typeof data.orpFormula !== 'undefined') this.orp.orpFormula = utils.makeBool(data.orpFormula); + if (typeof data.chlorineType !== 'undefined') this.orp.chlorineType = data.chlorineType; } } catch (err) { logger.error(`setORPAsync: ${err.message}`); return Promise.reject(err); } @@ -2433,7 +2435,7 @@ export class NixieChemicalORP extends NixieChemical { else if (this.orp.setpoint > sorp.level) { let pump = this.pump.pump; // Calculate how many mL are required to raise to our ORP level. - let demand = Math.round(utils.convert.volume.convertUnits(0, 'oz', 'mL')); + let demand = sorp.calcDemand(chem); let time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); let meth = sys.board.valueMaps.chemDosingMethods.getName(this.orp.dosingMethod); // Now that we know our chlorine demand we need to adjust this dose based upon the limits provided in the setup. From 07a68d43d960f010746859af8e5794cf4eafd163 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 18:10:03 -0400 Subject: [PATCH 06/21] feat: add automatic 24-hour VS pump scheduler Implements a hydraulics-based pump scheduling service that generates a daily three-block speed schedule targeting 1.0-1.5 pool turnovers while maximising Affinity Law energy savings. New files: - controller/services/HydraulicsCalc.ts -- pure affinity-law math (gpmForRPM, rpmForGPM, affinityPower, calcScheduleBlocks) - controller/services/PumpSchedulerService.ts -- lifecycle service (initAsync/closeAsync, midnight re-arm timer, Feature circuit creation, schedule writing via sys.board.schedules.setScheduleAsync) - scripts/testPumpScheduler.js -- standalone CLI validation tool Modified files: - controller/nixie/Nixie.ts -- wired pumpScheduler into initAsync/closeAsync - web/services/config/Config.ts -- added 4 REST routes: GET /config/services/pumpScheduler POST /config/services/pumpScheduler/generate PUT /config/services/pumpScheduler/config GET /config/services/pumpScheduler/circuits - defaultConfig.json -- added pumpScheduler defaults (20k gal, 1.2x turnovers, feature IDs 14/15/16, schedule IDs 10/11/12) Hardware target: Hayward Super Pump VS 700 on Raspberry Pi via RS-485. --- controller/nixie/Nixie.ts | 3 + controller/services/HydraulicsCalc.ts | 260 ++++++++++++++ controller/services/PumpSchedulerService.ts | 362 ++++++++++++++++++++ defaultConfig.json | 28 +- scripts/testPumpScheduler.js | 230 +++++++++++++ web/services/config/Config.ts | 20 ++ 6 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 controller/services/HydraulicsCalc.ts create mode 100644 controller/services/PumpSchedulerService.ts create mode 100644 scripts/testPumpScheduler.js diff --git a/controller/nixie/Nixie.ts b/controller/nixie/Nixie.ts index bfd3591f..e4e06686 100644 --- a/controller/nixie/Nixie.ts +++ b/controller/nixie/Nixie.ts @@ -17,6 +17,7 @@ import { NixieFilterCollection } from './bodies/Filter'; import { NixieChlorinatorCollection } from './chemistry/Chlorinator'; import { NixiePump, NixiePumpCollection } from './pumps/Pump'; import { NixieScheduleCollection } from './schedules/Schedule'; +import { pumpScheduler } from '../services/PumpSchedulerService'; /************************************************************************ * Nixie: Nixie is a control panel that controls devices as a master. It @@ -92,6 +93,7 @@ export class NixieControlPanel implements INixieControlPanel { await this.chemDosers.initAsync(equipment.chemDosers); await this.pumps.initAsync(equipment.pumps); await this.schedules.initAsync(equipment.schedules); + await pumpScheduler.initAsync(); logger.info(`Nixie Controller Initialized`) } catch (err) { return Promise.reject(err); } @@ -133,6 +135,7 @@ export class NixieControlPanel implements INixieControlPanel { await this.chlorinators.closeAsync(); await this.heaters.closeAsync(); await this.circuits.closeAsync(); + await pumpScheduler.closeAsync(); await this.pumps.closeAsync(); await this.filters.closeAsync(); await this.bodies.closeAsync(); diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts new file mode 100644 index 00000000..f1e20909 --- /dev/null +++ b/controller/services/HydraulicsCalc.ts @@ -0,0 +1,260 @@ +/* + * HydraulicsCalc.ts + * Pure hydraulic math helpers for pool pump scheduling. + * No project-level imports — safe to use in unit tests and CLI scripts. + * + * Physics notes + * ───────────── + * Affinity Laws (centrifugal pumps): + * Flow scales linearly with RPM: Q2 = Q1 × (RPM2 / RPM1) + * Head scales as the square: H2 = H1 × (RPM2 / RPM1)² + * Power scales as the cube: P2 = P1 × (RPM2 / RPM1)³ + * + * GPM ↔ RPM model used here: + * Rather than a full TDH (Total Dynamic Head) curve, we use a single + * empirical reference point (referenceRPM → referenceGPM measured at + * the user's system pressure) and scale linearly via the affinity law. + * This is accurate enough for residential plumbing where TDH changes + * only modestly across the VS operating range. + * + * Pipe velocity / flow limit: + * 1.5" Schedule-40 PVC: recommended max 5 ft/s → ≈50 GPM at that bore. + * Exceeding this causes hydraulic noise and risk of cavitation at the + * pump volute; the algorithm hard-caps GPM at poolConfig.maxSafeGPM. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface PoolConfig { + poolVolumeGallons: number; + maxSafeGPM: number; // Pipe flow ceiling (1.5" → 50 GPM) + maxPumpRPM: number; // Hayward Super Pump VS 700: 3450 + minPumpRPM: number; // Firmware minimum: 600 (algorithm floor: 1000) + targetTurnovers: number; // Gallons to move = volume × this (default 1.2) + referenceRPM: number; // Empirical calibration point RPM (default 2850) + referenceGPM: number; // Actual GPM measured at referenceRPM (default 45) + highBlockStartHour: number; // Hour (0-23) the High block begins (default 6) + highBlockDurationHours: number; // Fixed High block duration in hours (default 2) + medBlockDurationHours: number; // Minimum Medium block duration in hours (default 4) + lowBlockMinHours: number; // Low block floor (default 10) + lowBlockMaxHours: number; // Low block ceiling (default 14) + equipmentRequirements: { + heaterMinGPM: number; // Minimum flow for heater ignition (default 30) + saltCellMinGPM: number; // Minimum flow for salt cell operation (default 25) + skimmerMinGPM: number; // Minimum flow for surface skimming (default 45) + }; +} + +export interface ScheduleBlock { + phase: 'high' | 'medium' | 'low'; + rpm: number; + gpm: number; + durationHours: number; + startMinutes: number; // Minutes from midnight (0–1439) + endMinutes: number; + gallons: number; + estimatedWatts: number; + /** True when this block's GPM is below saltCellMinGPM — caller should log a warning */ + saltCellWarning: boolean; +} + +export interface SchedulePlan { + blocks: [ScheduleBlock, ScheduleBlock, ScheduleBlock]; + totalGallons: number; + totalRunHours: number; + turnovers: number; +} + +// ─── Core math ──────────────────────────────────────────────────────────────── + +/** + * Target volume to move per day. + * turnoverVolume = poolVolume × targetTurnovers + */ +export function calcTurnoverVolume(poolVolumeGallons: number, targetTurnovers: number): number { + return poolVolumeGallons * targetTurnovers; +} + +/** + * Average GPM required across total run hours to hit target volume. + * averageGPM = turnoverVolume / (totalRunHours × 60) + */ +export function calcTargetGPM(turnoverVolumeGallons: number, totalRunHours: number): number { + return turnoverVolumeGallons / (totalRunHours * 60); +} + +/** + * Convert RPM → GPM using the linear affinity-law model anchored to a + * known empirical reference point. + * gpm = referenceGPM × (rpm / referenceRPM) + * + * This is the Q-scaling leg of the Affinity Laws (flow ∝ RPM). + */ +export function gpmForRPM(rpm: number, referenceRPM: number, referenceGPM: number): number { + return referenceGPM * (rpm / referenceRPM); +} + +/** + * Convert GPM → RPM (inverse of gpmForRPM). + * rpm = referenceRPM × (gpm / referenceGPM) + */ +export function rpmForGPM(gpm: number, referenceRPM: number, referenceGPM: number): number { + return referenceRPM * (gpm / referenceGPM); +} + +/** + * Pump Affinity Law — power scaling. + * P2 = P1 × (RPM2 / RPM1)³ + * + * Energy savings are dramatic: dropping from 3450 → 1000 RPM reduces power + * consumption to just (1000/3450)³ ≈ 2.4 % of full-speed draw. + * + * @param p1Watts Known power draw at rpm1 + * @param rpm1 Reference RPM corresponding to p1 + * @param rpm2 Target RPM to estimate power for + */ +export function affinityPower(p1Watts: number, rpm1: number, rpm2: number): number { + return p1Watts * Math.pow(rpm2 / rpm1, 3); +} + +// ─── Schedule block builder ──────────────────────────────────────────────────── + +const ALGO_MIN_RPM = 1000; // Floor for Low block — keeps filter pressure adequate +const LOW_TIER_MAX_RPM = 1500; // If Low block hours overflow 14h, nudge RPM up to here + +/** + * Compute the full 24-hour three-block schedule plan from a pool configuration. + * + * Algorithm: + * 1. High block — fixed 2 hrs at the highest RPM that stays ≤ maxSafeGPM. + * Sized for surface skimming and pre-filter priming. + * 2. Medium block — fixed (medBlockDurationHours) hrs at the RPM required to + * safely exceed heaterMinGPM. Covers heating cycles and + * salt-cell chlorination at adequate flow. + * 3. Low block — fills remaining turnover volume at the lowest practical RPM + * (≥1000 RPM floor). Duration is clamped to [lowMin, lowMax]. + * If the required hours exceed lowMax, RPM is nudged up until + * the hours fit — maximising Affinity Law energy savings. + * + * @param cfg Pool configuration (see PoolConfig) + * @param referenceWattsAtMaxRPM Optional reference power draw at maxPumpRPM. + * Hayward Super Pump VS 700 nameplate: ~1100 W at max speed. + * Used only for estimatedWatts; does not affect RPM/GPM/time math. + */ +export function calcScheduleBlocks(cfg: PoolConfig, referenceWattsAtMaxRPM = 1100): SchedulePlan { + const { poolVolumeGallons, targetTurnovers, maxSafeGPM, maxPumpRPM, + minPumpRPM, referenceRPM, referenceGPM, + highBlockStartHour, highBlockDurationHours, medBlockDurationHours, + lowBlockMinHours, lowBlockMaxHours, equipmentRequirements } = cfg; + + // ── 1. Turnover target ───────────────────────────────────────────────────── + const turnoverVolume = calcTurnoverVolume(poolVolumeGallons, targetTurnovers); + + // ── 2. High block ────────────────────────────────────────────────────────── + // Target GPM = 90 % of the pipe ceiling so there is headroom. + // Convert to RPM and clamp to hardware limits. + // NOTE: the Hayward VS 700 has a known firmware speed plateau around 2967 RPM + // (≈86 % of 3450). We stay well below at ≈82 % (2850 RPM) to avoid it. + const highTargetGPM = maxSafeGPM * 0.90; + const highRPMRaw = rpmForGPM(highTargetGPM, referenceRPM, referenceGPM); + const highRPM = Math.min(Math.round(highRPMRaw / 10) * 10, maxPumpRPM); + const highGPM = Math.min(gpmForRPM(highRPM, referenceRPM, referenceGPM), maxSafeGPM); + const highGallons = highGPM * highBlockDurationHours * 60; + const highStart = highBlockStartHour * 60; + const highEnd = highStart + highBlockDurationHours * 60; + const highWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, highRPM); + + // ── 3. Medium block ──────────────────────────────────────────────────────── + // Target: at least heaterMinGPM + 5 GPM margin to guarantee heater ignition + // and salt-cell minimum in a single comfortable band. + const medTargetGPM = equipmentRequirements.heaterMinGPM + 5; + const medRPMRaw = rpmForGPM(medTargetGPM, referenceRPM, referenceGPM); + const medRPM = Math.max( + Math.min(Math.round(medRPMRaw / 10) * 10, maxPumpRPM), + minPumpRPM + ); + const medGPM = gpmForRPM(medRPM, referenceRPM, referenceGPM); + const medGallons = medGPM * medBlockDurationHours * 60; + const medStart = highEnd; + const medEnd = medStart + medBlockDurationHours * 60; + const medWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, medRPM); + + // ── 4. Low block — find the lowest RPM that fits the window ─────────────── + const remainingGallons = turnoverVolume - highGallons - medGallons; + + // Start with the algorithm minimum RPM and work up if needed. + let lowRPM = Math.max(ALGO_MIN_RPM, minPumpRPM); + let lowGPM = gpmForRPM(lowRPM, referenceRPM, referenceGPM); + let lowHoursNeeded = remainingGallons / (lowGPM * 60); + + // If we need more than lowBlockMaxHours, nudge RPM up in 10-RPM steps + // until the hours fit — but cap the nudge at LOW_TIER_MAX_RPM. + while (lowHoursNeeded > lowBlockMaxHours && lowRPM < LOW_TIER_MAX_RPM) { + lowRPM += 10; + lowGPM = gpmForRPM(lowRPM, referenceRPM, referenceGPM); + lowHoursNeeded = remainingGallons / (lowGPM * 60); + } + + // Clamp duration to the configured window. + const lowDurationHours = Math.max(lowBlockMinHours, Math.min(lowHoursNeeded, lowBlockMaxHours)); + const lowGallons = lowGPM * lowDurationHours * 60; + const lowStart = medEnd; + // endMinutes may cross midnight (> 1440) — callers must handle wrap-around. + const lowEnd = lowStart + Math.round(lowDurationHours * 60); + const lowWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, lowRPM); + + // ── 5. Assemble plan ─────────────────────────────────────────────────────── + const high: ScheduleBlock = { + phase: 'high', + rpm: highRPM, + gpm: parseFloat(highGPM.toFixed(1)), + durationHours: highBlockDurationHours, + startMinutes: highStart, + endMinutes: highEnd, + gallons: Math.round(highGallons), + estimatedWatts: Math.round(highWatts), + saltCellWarning: highGPM < equipmentRequirements.saltCellMinGPM, + }; + + const medium: ScheduleBlock = { + phase: 'medium', + rpm: medRPM, + gpm: parseFloat(medGPM.toFixed(1)), + durationHours: medBlockDurationHours, + startMinutes: medStart, + endMinutes: medEnd, + gallons: Math.round(medGallons), + estimatedWatts: Math.round(medWatts), + saltCellWarning: medGPM < equipmentRequirements.saltCellMinGPM, + }; + + const low: ScheduleBlock = { + phase: 'low', + rpm: lowRPM, + gpm: parseFloat(lowGPM.toFixed(1)), + durationHours: parseFloat(lowDurationHours.toFixed(2)), + startMinutes: lowStart, + endMinutes: lowEnd, + gallons: Math.round(lowGallons), + estimatedWatts: Math.round(lowWatts), + saltCellWarning: lowGPM < equipmentRequirements.saltCellMinGPM, + }; + + const totalGallons = high.gallons + medium.gallons + low.gallons; + const totalRunHours = high.durationHours + medium.durationHours + low.durationHours; + + return { + blocks: [high, medium, low], + totalGallons, + totalRunHours: parseFloat(totalRunHours.toFixed(2)), + turnovers: parseFloat((totalGallons / poolVolumeGallons).toFixed(3)), + }; +} + +/** Format minutes-from-midnight as "HH:MM" for display / logging. */ +export function minutesToTime(minutes: number): string { + const m = ((minutes % 1440) + 1440) % 1440; // normalise negative / overflow + const h = Math.floor(m / 60); + const min = m % 60; + return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; +} diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts new file mode 100644 index 00000000..62fca54a --- /dev/null +++ b/controller/services/PumpSchedulerService.ts @@ -0,0 +1,362 @@ +/* + * PumpSchedulerService.ts + * Automatically generates and applies a 24-hour variable-speed pump schedule + * based on hydraulic calculations (see HydraulicsCalc.ts). + * + * Integration: + * • Called from controller/nixie/Nixie.ts initAsync() / closeAsync(). + * • Writes schedule entries via sys.board.schedules.setScheduleAsync() — the + * same path used by the REST config API — so schedule changes appear in the + * dashboard and survive controller restarts. + * • Creates three Feature circuits (IDs configured in services.pumpScheduler) + * that are mapped as pump circuits at the computed RPMs. The existing + * NixiePumpVS.setTargetSpeed() logic then picks the highest-RPM active + * feature and drives the pump accordingly. + * + * Schedule ID constraints: + * sys.equipment.maxSchedules defaults to 12. This service reserves the top + * three IDs (default 10/11/12) so it never collides with user schedules. + * + * Daily regeneration: + * At midnight the schedule times are recomputed (times are stable but RPMs + * may shift if the config has changed). Uses setTimeout → re-arm rather than + * setInterval so the timer always fires at the next real midnight boundary. + */ +import { EventEmitter } from 'events'; +import { logger } from '../../logger/Logger'; +import { config } from '../../config/Config'; +import { sys } from '../Equipment'; +import { state } from '../State'; +import { webApp } from '../../web/Server'; +import { + PoolConfig, SchedulePlan, ScheduleBlock, + calcScheduleBlocks, minutesToTime, +} from './HydraulicsCalc'; + +// ─── Default configuration ──────────────────────────────────────────────────── + +const DEFAULT_POOL_CONFIG: PoolConfig = { + poolVolumeGallons: 20000, + maxSafeGPM: 50, + maxPumpRPM: 3450, + minPumpRPM: 600, + targetTurnovers: 1.2, + referenceRPM: 2850, + referenceGPM: 45, + highBlockStartHour: 6, + highBlockDurationHours: 2, + medBlockDurationHours: 4, + lowBlockMinHours: 10, + lowBlockMaxHours: 14, + equipmentRequirements: { + heaterMinGPM: 30, + saltCellMinGPM: 25, + skimmerMinGPM: 45, + }, +}; + +// scheduleType 128 = "Repeats" (daily on selected days). +// See SystemBoard.ts scheduleTypes value map. +const SCHEDULE_TYPE_REPEAT = 128; +// scheduleDays 0x7F = all 7 days (bits 0-6 set, one per day). +const ALL_DAYS = 0x7f; +// scheduleTimeType 0 = manual (the only valid value in the base board). +const TIME_TYPE_MANUAL = 0; + +interface SchedulerConfig { + enabled: boolean; + pumpId: number; + featureIds: { high: number; medium: number; low: number }; + scheduleIds: { high: number; medium: number; low: number }; + poolConfig: PoolConfig; +} + +const DEFAULT_SCHEDULER_CFG: SchedulerConfig = { + enabled: true, + pumpId: 1, + // Feature IDs 14-16 sit at the top of the default 7-16 feature range, + // leaving room for user-defined features below. + featureIds: { high: 14, medium: 15, low: 16 }, + // Schedule IDs 10-12 sit at the top of the default 1-12 schedule range. + scheduleIds: { high: 10, medium: 11, low: 12 }, + poolConfig: DEFAULT_POOL_CONFIG, +}; + +// ─── Service class ──────────────────────────────────────────────────────────── + +export class PumpSchedulerService { + public readonly emitter = new EventEmitter(); + + private _midnightTimer: NodeJS.Timeout | null = null; + private _cfg: SchedulerConfig = { ...DEFAULT_SCHEDULER_CFG }; + private _lastPlan: SchedulePlan | null = null; + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + public async initAsync(): Promise { + try { + logger.info('PumpSchedulerService: initializing'); + this._loadConfig(); + + if (!this._cfg.enabled) { + logger.info('PumpSchedulerService: disabled in config, skipping'); + return; + } + + // Listen for hot-reloads so a config file change triggers a regen. + config.emitter.on('reloaded', () => { + this._loadConfig(); + if (this._cfg.enabled) { + logger.info('PumpSchedulerService: config reloaded, regenerating'); + this.generateScheduleAsync().catch(err => + logger.error(`PumpSchedulerService config reload regen error: ${err.message}`) + ); + } + }); + + await this._ensureFeaturesExistAsync(); + await this.generateScheduleAsync(); + this._armMidnightTimer(); + logger.info('PumpSchedulerService: initialized'); + } catch (err) { + logger.error(`PumpSchedulerService initAsync: ${err.message}`); + // Non-fatal — pool controller continues without the scheduler. + } + } + + public async closeAsync(): Promise { + if (this._midnightTimer) { + clearTimeout(this._midnightTimer); + this._midnightTimer = null; + } + config.emitter.removeAllListeners('reloaded'); + logger.info('PumpSchedulerService: closed'); + } + + // ── Public API (used by REST routes) ────────────────────────────────────── + + /** Recompute the schedule and push it to sys.schedules. */ + public async generateScheduleAsync(): Promise { + try { + const plan = calcScheduleBlocks(this._cfg.poolConfig); + this._lastPlan = plan; + + // Warn if salt cell flow requirements won't be met. + for (const block of plan.blocks) { + if (block.saltCellWarning) { + logger.warn( + `PumpSchedulerService: ${block.phase} block GPM (${block.gpm}) is below ` + + `saltCellMinGPM (${this._cfg.poolConfig.equipmentRequirements.saltCellMinGPM}). ` + + `Salt chlorination may be reduced during this period.` + ); + } + } + + this._logPlan(plan); + await this._writeSchedulesAsync(plan); + + this.emitter.emit('scheduleGenerated', plan); + webApp.emitToClients('pumpScheduler', this.getScheduleSnapshot()); + return plan; + } catch (err) { + logger.error(`PumpSchedulerService generateScheduleAsync: ${err.message}`); + return Promise.reject(err); + } + } + + /** Merge new pool config values and regenerate. */ + public async updateConfigAsync(data: Partial): Promise { + try { + // Deep-merge poolConfig if provided. + if (data.poolConfig) { + this._cfg.poolConfig = Object.assign({}, this._cfg.poolConfig, data.poolConfig); + if (data.poolConfig.equipmentRequirements) { + this._cfg.poolConfig.equipmentRequirements = Object.assign( + {}, + this._cfg.poolConfig.equipmentRequirements, + data.poolConfig.equipmentRequirements + ); + } + } + if (typeof data.enabled !== 'undefined') this._cfg.enabled = data.enabled; + if (typeof data.pumpId !== 'undefined') this._cfg.pumpId = data.pumpId; + if (data.featureIds) this._cfg.featureIds = Object.assign({}, this._cfg.featureIds, data.featureIds); + if (data.scheduleIds) this._cfg.scheduleIds = Object.assign({}, this._cfg.scheduleIds, data.scheduleIds); + + this._saveConfig(); + + await this._ensureFeaturesExistAsync(); + return await this.generateScheduleAsync(); + } catch (err) { + logger.error(`PumpSchedulerService updateConfigAsync: ${err.message}`); + return Promise.reject(err); + } + } + + /** Return current plan + config for REST responses. */ + public getScheduleSnapshot(): object { + return { + enabled: this._cfg.enabled, + pumpId: this._cfg.pumpId, + featureIds: this._cfg.featureIds, + scheduleIds: this._cfg.scheduleIds, + poolConfig: this._cfg.poolConfig, + plan: this._lastPlan, + }; + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private _loadConfig(): void { + const saved = config.getSection('web.services.pumpScheduler', {}); + // Merge saved values over defaults so any omitted field uses the default. + this._cfg = { + enabled: saved.enabled ?? DEFAULT_SCHEDULER_CFG.enabled, + pumpId: saved.pumpId ?? DEFAULT_SCHEDULER_CFG.pumpId, + featureIds: Object.assign({}, DEFAULT_SCHEDULER_CFG.featureIds, saved.featureIds), + scheduleIds: Object.assign({}, DEFAULT_SCHEDULER_CFG.scheduleIds, saved.scheduleIds), + poolConfig: Object.assign({}, DEFAULT_POOL_CONFIG, saved.poolConfig, { + equipmentRequirements: Object.assign( + {}, + DEFAULT_POOL_CONFIG.equipmentRequirements, + saved.poolConfig?.equipmentRequirements + ), + }), + }; + } + + private _saveConfig(): void { + config.setSection('web.services.pumpScheduler', { + enabled: this._cfg.enabled, + pumpId: this._cfg.pumpId, + featureIds: this._cfg.featureIds, + scheduleIds: this._cfg.scheduleIds, + poolConfig: this._cfg.poolConfig, + }); + } + + /** + * Ensure the three speed-tier Feature circuits exist so that: + * 1. setScheduleAsync() passes its circuit-reference validation. + * 2. NixiePumpVS.setTargetSpeed() can read their isOn state. + * Features are created with showInFeatures: false so they don't clutter the UI. + */ + private async _ensureFeaturesExistAsync(): Promise { + const featureMap = [ + { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, + { id: this._cfg.featureIds.medium, name: 'PumpSched-Medium' }, + { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, + ]; + for (const f of featureMap) { + const existing = sys.features.find(feat => feat.id === f.id); + if (typeof existing === 'undefined' || !existing.isActive) { + try { + await sys.board.features.setFeatureAsync({ id: f.id, name: f.name, showInFeatures: false }); + logger.info(`PumpSchedulerService: created feature ${f.id} (${f.name})`); + } catch (err) { + logger.error(`PumpSchedulerService: could not create feature ${f.id}: ${err.message}`); + } + } + } + } + + /** + * Write (or update) the three managed schedule entries in sys.schedules. + * Uses scheduleType 128 (Repeats) with all-days bitmask 0x7F. + * + * If the user already has schedules occupying the reserved IDs those + * schedules are overwritten — the IDs are documented in defaultConfig.json. + * + * Guard: if sys.equipment.maxSchedules is less than the highest reserved ID + * the method logs a warning instead of throwing so the controller keeps running. + */ + private async _writeSchedulesAsync(plan: SchedulePlan): Promise { + const maxSched = sys.equipment.maxSchedules; + const ids = this._cfg.scheduleIds; + const feats = this._cfg.featureIds; + + const entries = [ + { id: ids.high, featureId: feats.high, block: plan.blocks[0] }, + { id: ids.medium, featureId: feats.medium, block: plan.blocks[1] }, + { id: ids.low, featureId: feats.low, block: plan.blocks[2] }, + ]; + + for (const entry of entries) { + if (entry.id > maxSched) { + logger.warn( + `PumpSchedulerService: schedule ID ${entry.id} exceeds maxSchedules (${maxSched}). ` + + `Increase sys.equipment.maxSchedules or lower scheduleIds in config.` + ); + continue; + } + + const { block } = entry; + // endMinutes may exceed 1439 (past midnight) — wrap to [0, 1439]. + const endTime = block.endMinutes % 1440; + + try { + const sched = await sys.board.schedules.setScheduleAsync({ + id: entry.id, + circuit: entry.featureId, + scheduleType: SCHEDULE_TYPE_REPEAT, + scheduleDays: ALL_DAYS, + startTime: block.startMinutes, + endTime, + startTimeType: TIME_TYPE_MANUAL, + endTimeType: TIME_TYPE_MANUAL, + isActive: true, + }); + logger.verbose( + `PumpSchedulerService: wrote schedule #${entry.id} ` + + `[${block.phase}] ${minutesToTime(block.startMinutes)}–${minutesToTime(block.endMinutes)} ` + + `@ ${block.rpm} RPM (${block.gpm} GPM)` + ); + webApp.emitToClients('schedule', sched.get(true)); + } catch (err) { + logger.error( + `PumpSchedulerService: failed to write schedule #${entry.id} (${block.phase}): ${err.message}` + ); + } + } + } + + /** + * Fire at the next midnight using setTimeout (not setInterval). + * setInterval(fn, 86400000) drifts — it fires 24h from *service start*, + * not from midnight. This implementation calculates exact ms to midnight + * and re-arms itself after each fire so it always aligns to 00:00:00. + */ + private _armMidnightTimer(): void { + if (this._midnightTimer) clearTimeout(this._midnightTimer); + const now = Date.now(); + const midnight = new Date(now); + midnight.setHours(24, 0, 0, 0); // next midnight + const msUntilMidnight = midnight.getTime() - now; + + this._midnightTimer = setTimeout(async () => { + logger.info('PumpSchedulerService: midnight — regenerating daily schedule'); + try { + await this.generateScheduleAsync(); + } catch (err) { + logger.error(`PumpSchedulerService midnight regen: ${err.message}`); + } + this._armMidnightTimer(); // re-arm for the next night + }, msUntilMidnight); + } + + private _logPlan(plan: SchedulePlan): void { + logger.info( + `PumpSchedulerService: plan — ${plan.totalGallons.toLocaleString()} gal / ` + + `${plan.turnovers.toFixed(2)} turnovers / ${plan.totalRunHours.toFixed(1)} hrs` + ); + for (const b of plan.blocks) { + logger.info( + ` [${b.phase.padEnd(6)}] ${minutesToTime(b.startMinutes)}–${minutesToTime(b.endMinutes)} ` + + `${b.rpm} RPM ${b.gpm} GPM ~${b.estimatedWatts} W ${b.gallons.toLocaleString()} gal` + + (b.saltCellWarning ? ' ⚠ salt-cell flow low' : '') + ); + } + } +} + +export const pumpScheduler = new PumpSchedulerService(); diff --git a/defaultConfig.json b/defaultConfig.json index 548cd90e..04740754 100755 --- a/defaultConfig.json +++ b/defaultConfig.json @@ -75,7 +75,33 @@ "enabled": true } }, - "services": {}, + "services": { + "pumpScheduler": { + "enabled": true, + "pumpId": 1, + "poolConfig": { + "poolVolumeGallons": 20000, + "maxSafeGPM": 50, + "maxPumpRPM": 3450, + "minPumpRPM": 600, + "targetTurnovers": 1.2, + "referenceRPM": 2850, + "referenceGPM": 45, + "highBlockStartHour": 6, + "highBlockDurationHours": 2, + "medBlockDurationHours": 4, + "lowBlockMinHours": 10, + "lowBlockMaxHours": 14, + "equipmentRequirements": { + "heaterMinGPM": 30, + "saltCellMinGPM": 25, + "skimmerMinGPM": 45 + } + }, + "featureIds": { "high": 14, "medium": 15, "low": 16 }, + "scheduleIds": { "high": 10, "medium": 11, "low": 12 } + } + }, "interfaces": { "smartThings": { "name": "SmartThings", diff --git a/scripts/testPumpScheduler.js b/scripts/testPumpScheduler.js new file mode 100644 index 00000000..4a259225 --- /dev/null +++ b/scripts/testPumpScheduler.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * testPumpScheduler.js + * Standalone hydraulics test script — no project imports required. + * + * Usage: + * node scripts/testPumpScheduler.js + * node scripts/testPumpScheduler.js --volume 25000 --turnovers 1.3 + * + * Edit the DEFAULT_CONFIG block below to match your pool, then run this to + * validate the schedule before deploying it on the controller. + */ +'use strict'; + +// ─── Inline hydraulics math (mirrors HydraulicsCalc.ts) ───────────────────── + +function gpmForRPM(rpm, referenceRPM, referenceGPM) { + return referenceGPM * (rpm / referenceRPM); +} + +function rpmForGPM(gpm, referenceRPM, referenceGPM) { + return referenceRPM * (gpm / referenceGPM); +} + +function affinityPower(p1Watts, rpm1, rpm2) { + return p1Watts * Math.pow(rpm2 / rpm1, 3); +} + +function minutesToTime(minutes) { + const m = ((minutes % 1440) + 1440) % 1440; + const h = Math.floor(m / 60); + const min = m % 60; + return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; +} + +const ALGO_MIN_RPM = 1000; +const LOW_TIER_MAX_RPM = 1500; + +function calcScheduleBlocks(cfg, referenceWattsAtMaxRPM) { + referenceWattsAtMaxRPM = referenceWattsAtMaxRPM || 1100; + + const turnoverVolume = cfg.poolVolumeGallons * cfg.targetTurnovers; + + // ── High block ──────────────────────────────────────────────────────────── + const highTargetGPM = cfg.maxSafeGPM * 0.90; + const highRPMRaw = rpmForGPM(highTargetGPM, cfg.referenceRPM, cfg.referenceGPM); + const highRPM = Math.min(Math.round(highRPMRaw / 10) * 10, cfg.maxPumpRPM); + const highGPM = Math.min(gpmForRPM(highRPM, cfg.referenceRPM, cfg.referenceGPM), cfg.maxSafeGPM); + const highGallons = highGPM * cfg.highBlockDurationHours * 60; + const highStart = cfg.highBlockStartHour * 60; + const highEnd = highStart + cfg.highBlockDurationHours * 60; + const highWatts = affinityPower(referenceWattsAtMaxRPM, cfg.maxPumpRPM, highRPM); + + // ── Medium block ────────────────────────────────────────────────────────── + const medTargetGPM = cfg.equipmentRequirements.heaterMinGPM + 5; + const medRPMRaw = rpmForGPM(medTargetGPM, cfg.referenceRPM, cfg.referenceGPM); + const medRPM = Math.max(Math.min(Math.round(medRPMRaw / 10) * 10, cfg.maxPumpRPM), cfg.minPumpRPM); + const medGPM = gpmForRPM(medRPM, cfg.referenceRPM, cfg.referenceGPM); + const medGallons = medGPM * cfg.medBlockDurationHours * 60; + const medStart = highEnd; + const medEnd = medStart + cfg.medBlockDurationHours * 60; + const medWatts = affinityPower(referenceWattsAtMaxRPM, cfg.maxPumpRPM, medRPM); + + // ── Low block ───────────────────────────────────────────────────────────── + const remainingGallons = turnoverVolume - highGallons - medGallons; + + let lowRPM = Math.max(ALGO_MIN_RPM, cfg.minPumpRPM); + let lowGPM = gpmForRPM(lowRPM, cfg.referenceRPM, cfg.referenceGPM); + let lowHoursNeeded = remainingGallons / (lowGPM * 60); + + while (lowHoursNeeded > cfg.lowBlockMaxHours && lowRPM < LOW_TIER_MAX_RPM) { + lowRPM += 10; + lowGPM = gpmForRPM(lowRPM, cfg.referenceRPM, cfg.referenceGPM); + lowHoursNeeded = remainingGallons / (lowGPM * 60); + } + + const lowDurationHours = Math.max(cfg.lowBlockMinHours, Math.min(lowHoursNeeded, cfg.lowBlockMaxHours)); + const lowGallons = lowGPM * lowDurationHours * 60; + const lowStart = medEnd; + const lowEnd = lowStart + Math.round(lowDurationHours * 60); + const lowWatts = affinityPower(referenceWattsAtMaxRPM, cfg.maxPumpRPM, lowRPM); + + const blocks = [ + { + phase: 'High', + rpm: highRPM, + gpm: highGPM, + durationHours: cfg.highBlockDurationHours, + startMinutes: highStart, + endMinutes: highEnd, + gallons: highGallons, + estimatedWatts: highWatts, + saltCellWarning: highGPM < cfg.equipmentRequirements.saltCellMinGPM, + }, + { + phase: 'Medium', + rpm: medRPM, + gpm: medGPM, + durationHours: cfg.medBlockDurationHours, + startMinutes: medStart, + endMinutes: medEnd, + gallons: medGallons, + estimatedWatts: medWatts, + saltCellWarning: medGPM < cfg.equipmentRequirements.saltCellMinGPM, + }, + { + phase: 'Low', + rpm: lowRPM, + gpm: lowGPM, + durationHours: lowDurationHours, + startMinutes: lowStart, + endMinutes: lowEnd, + gallons: lowGallons, + estimatedWatts: lowWatts, + saltCellWarning: lowGPM < cfg.equipmentRequirements.saltCellMinGPM, + }, + ]; + + return { + blocks, + totalGallons: highGallons + medGallons + lowGallons, + totalRunHours: cfg.highBlockDurationHours + cfg.medBlockDurationHours + lowDurationHours, + turnovers: (highGallons + medGallons + lowGallons) / cfg.poolVolumeGallons, + }; +} + +// ─── Pool configuration ─────────────────────────────────────────────────────── +// Edit this block to match your pool. + +const DEFAULT_CONFIG = { + poolVolumeGallons: 20000, + maxSafeGPM: 50, + maxPumpRPM: 3450, + minPumpRPM: 600, + targetTurnovers: 1.2, + referenceRPM: 2850, + referenceGPM: 45, + highBlockStartHour: 6, + highBlockDurationHours: 2, + medBlockDurationHours: 4, + lowBlockMinHours: 10, + lowBlockMaxHours: 14, + equipmentRequirements: { + heaterMinGPM: 30, + saltCellMinGPM: 25, + skimmerMinGPM: 45, + }, +}; + +// ─── CLI argument overrides ─────────────────────────────────────────────────── +// Supports: --volume --turnovers --refRPM --refGPM + +const args = process.argv.slice(2); +const cfg = Object.assign({}, DEFAULT_CONFIG); + +for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--volume': cfg.poolVolumeGallons = parseFloat(args[++i]); break; + case '--turnovers': cfg.targetTurnovers = parseFloat(args[++i]); break; + case '--refRPM': cfg.referenceRPM = parseFloat(args[++i]); break; + case '--refGPM': cfg.referenceGPM = parseFloat(args[++i]); break; + default: console.warn(`Unknown arg: ${args[i]}`); + } +} + +// ─── Run ────────────────────────────────────────────────────────────────────── + +const plan = calcScheduleBlocks(cfg, 1100); + +const PHASE_W = 8; +const TIME_W = 7; +const RPM_W = 6; +const GPM_W = 6; +const WATTS_W = 7; +const GAL_W = 9; +const HOURS_W = 8; + +const sep = '+' + '-'.repeat(PHASE_W + 2) + '+' + '-'.repeat(TIME_W + 2) + '+' + + '-'.repeat(TIME_W + 2) + '+' + '-'.repeat(RPM_W + 2) + '+' + + '-'.repeat(GPM_W + 2) + '+' + '-'.repeat(WATTS_W + 2) + '+' + + '-'.repeat(GAL_W + 2) + '+' + '-'.repeat(HOURS_W + 2) + '+'; + +function pad(val, width) { + const s = String(typeof val === 'number' ? (Number.isInteger(val) ? val : val.toFixed(1)) : val); + return s.padStart(width); +} + +console.log('\n24-Hour Pump Schedule Simulation'); +console.log(`Pool: ${cfg.poolVolumeGallons.toLocaleString()} gal ` + + `Target: ${cfg.targetTurnovers}× turnovers ` + + `Ref: ${cfg.referenceRPM} RPM → ${cfg.referenceGPM} GPM\n`); + +console.log(sep); +console.log( + `| ${'Phase'.padEnd(PHASE_W)} | ${'Start'.padEnd(TIME_W - 1)} | ${'End'.padEnd(TIME_W - 1)} | ` + + `${'RPM'.padStart(RPM_W)} | ${'GPM'.padStart(GPM_W)} | ${'Watts'.padStart(WATTS_W)} | ` + + `${'Gallons'.padStart(GAL_W)} | ${'Hours'.padStart(HOURS_W)} |` +); +console.log(sep); + +let totalKWh = 0; +for (const b of plan.blocks) { + const kWh = (b.estimatedWatts / 1000) * b.durationHours; + totalKWh += kWh; + const flag = b.saltCellWarning ? ' ⚠' : ''; + console.log( + `| ${(b.phase + flag).padEnd(PHASE_W)} | ${minutesToTime(b.startMinutes).padEnd(TIME_W - 1)} | ` + + `${minutesToTime(b.endMinutes).padEnd(TIME_W - 1)} | ${pad(b.rpm, RPM_W)} | ` + + `${pad(b.gpm, GPM_W)} | ${pad(Math.round(b.estimatedWatts), WATTS_W)} | ` + + `${pad(Math.round(b.gallons), GAL_W)} | ${pad(b.durationHours, HOURS_W)} |` + ); +} +console.log(sep); + +const targetGal = Math.round(cfg.poolVolumeGallons * cfg.targetTurnovers); +const pctOfTarget = ((plan.totalGallons / targetGal) * 100).toFixed(1); +console.log(`\nTotal gallons: ${Math.round(plan.totalGallons).toLocaleString()}`); +console.log(`Target gallons: ${targetGal.toLocaleString()} (${cfg.targetTurnovers}× turnovers)`); +console.log(`Actual turnovers: ${plan.turnovers.toFixed(3)} (${pctOfTarget}% of target)`); +console.log(`Total runtime: ${plan.totalRunHours.toFixed(2)} hours`); +console.log(`Est. daily energy: ${totalKWh.toFixed(2)} kWh`); + +if (plan.turnovers < 1.0) { + console.warn('\n⚠ WARNING: Total turnovers < 1.0 — increase run time or target RPM.'); +} +if (plan.turnovers > 2.0) { + console.warn('\n⚠ NOTE: Total turnovers > 2.0 — consider lowering targetTurnovers to save energy.'); +} + +console.log(''); diff --git a/web/services/config/Config.ts b/web/services/config/Config.ts index d0865b8d..9b0700ab 100755 --- a/web/services/config/Config.ts +++ b/web/services/config/Config.ts @@ -32,6 +32,7 @@ import { webApp, BackupFile, RestoreFile } from "../../Server"; import { release } from "os"; import { ScreenLogicComms, sl } from "../../../controller/comms/ScreenLogic"; import { screenlogic } from "node-screenlogic"; +import { pumpScheduler } from '../../../controller/services/PumpSchedulerService'; export class ConfigRoute { private static securitySessions: Map = new Map(); @@ -1315,5 +1316,24 @@ export class ConfigRoute { return res.status(200).send(sys.anslq25.get(true)); } catch (err) { next(err); } }); + // ── Pump Scheduler service routes ────────────────────────────────────────── + app.get('/config/services/pumpScheduler', (req, res) => { + return res.status(200).send(pumpScheduler.getScheduleSnapshot()); + }); + app.post('/config/services/pumpScheduler/generate', async (req, res, next) => { + try { + await pumpScheduler.generateScheduleAsync(); + return res.status(200).send(pumpScheduler.getScheduleSnapshot()); + } catch (err) { next(err); } + }); + app.put('/config/services/pumpScheduler/config', async (req, res, next) => { + try { + await pumpScheduler.updateConfigAsync(req.body); + return res.status(200).send(pumpScheduler.getScheduleSnapshot()); + } catch (err) { next(err); } + }); + app.get('/config/services/pumpScheduler/circuits', (req, res) => { + return res.status(200).send(sys.board.circuits.getCircuitReferences(true, true, false, true)); + }); } } \ No newline at end of file From af4b7c240a3fc06f784b0714ace6b785a7ad0e24 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 18:19:50 -0400 Subject: [PATCH 07/21] feat(chem): expose chlorineTypes in /config/options/chemControllers API --- web/services/config/Config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/services/config/Config.ts b/web/services/config/Config.ts index d0865b8d..d284f24a 100755 --- a/web/services/config/Config.ts +++ b/web/services/config/Config.ts @@ -441,6 +441,7 @@ export class ConfigRoute { phProbeTypes: sys.board.valueMaps.chemPhProbeTypes.toArray(), flowSensorTypes: sys.board.valueMaps.flowSensorTypes.toArray(), acidTypes: sys.board.valueMaps.acidTypes.toArray(), + chlorineTypes: sys.board.valueMaps.chlorineTypes.toArray(), remServers, dosingStatus: sys.board.valueMaps.chemControllerDosingStatus.toArray(), siCalcTypes: sys.board.valueMaps.siCalcTypes.toArray(), From c988dd22188be3864980761d76586271439e6a9a Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 18:30:17 -0400 Subject: [PATCH 08/21] feat(pumps): add hwsp (Hayward SuperFlo VS) pump type support --- controller/State.ts | 1 + controller/boards/NixieBoard.ts | 1 + .../comms/messages/status/PumpStateMessage.ts | 5 +- controller/nixie/pumps/Pump.ts | 108 +++--------------- 4 files changed, 17 insertions(+), 98 deletions(-) diff --git a/controller/State.ts b/controller/State.ts index abd1ffb6..cfb5d6ce 100644 --- a/controller/State.ts +++ b/controller/State.ts @@ -1106,6 +1106,7 @@ export class PumpState extends EqState { c.units = sys.board.valueMaps.pumpUnits.transformByName('gpm'); break; case 'hwvs': + case 'hwsp': case 'vssvrs': case 'vs': case 'regalmodbus': diff --git a/controller/boards/NixieBoard.ts b/controller/boards/NixieBoard.ts index 772f1544..2072f4b7 100644 --- a/controller/boards/NixieBoard.ts +++ b/controller/boards/NixieBoard.ts @@ -92,6 +92,7 @@ export class NixieBoard extends SystemBoard { [5, { name: 'vf', desc: 'Intelliflo VF', minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [6, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [7, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2'}, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }], addresses: [] }], + [8, { name: 'hwsp', desc: 'Hayward SuperFlo VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }], addresses: [] }], [200, { name: 'regalmodbus', desc: 'Regal Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsRegalModbusPump}], [201, { name: 'neptunemodbus', desc: 'Neptune Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsNeptuneModbusPump }], diff --git a/controller/comms/messages/status/PumpStateMessage.ts b/controller/comms/messages/status/PumpStateMessage.ts index ed5e8295..284bc769 100755 --- a/controller/comms/messages/status/PumpStateMessage.ts +++ b/controller/comms/messages/status/PumpStateMessage.ts @@ -129,14 +129,13 @@ export class PumpStateMessage { // src act dest //[0x10, 0x02, 0x00, 0x0C, 0x00][0x00, 0x62, 0x17, 0x81][0x01, 0x18, 0x10, 0x03] //[0x10, 0x02, 0x00, 0x0C, 0x00][0x00, 0x2D, 0x02, 0x36][0x00, 0x83, 0x10, 0x03] -- Response from pump - let ptype = sys.board.valueMaps.pumpTypes.transformByName('hwvs'); let address = msg.source + 96; //console.log({ src: msg.source, dest: msg.dest, action: msg.action, address: address }); - let pump = sys.pumps.find(elem => elem.address === address && elem.type === 6); + let pump = sys.pumps.find(elem => elem.address === address && (elem.type === 6 || elem.type === 8)); if (typeof pump !== 'undefined') { + let ptype = sys.board.valueMaps.pumpTypes.transform(pump.type); let pstate = state.pumps.getItemById(pump.id, true); - // 3450 * .5 pstate.rpm = Math.round(ptype.maxSpeed * (msg.extractPayloadByte(1) / 100)); // This is really goofy as the watts are actually the hex string from the two bytes. pstate.watts = parseInt(msg.extractPayloadByte(2).toString(16) + msg.extractPayloadByte(3).toString(16), 10); diff --git a/controller/nixie/pumps/Pump.ts b/controller/nixie/pumps/Pump.ts index 4231fbc5..e5c6a16f 100644 --- a/controller/nixie/pumps/Pump.ts +++ b/controller/nixie/pumps/Pump.ts @@ -135,6 +135,7 @@ export class NixiePumpCollection extends NixieEquipmentCollection { case 'vs': return new NixiePumpVS(this.controlPanel, pump); case 'hwvs': + case 'hwsp': return new NixiePumpHWVS(this.controlPanel, pump); case 'hwrly': return new NixiePumpHWRLY(this.controlPanel, pump); @@ -895,10 +896,6 @@ export class NixiePumpVSF extends NixiePumpRS485 { }; }; export class NixiePumpHWVS extends NixiePumpRS485 { - private _consecutiveCommFailures: number = 0; - private _lastSuccessfulComm: Date = new Date(); - private _commFailureThreshold: number = 5; // Number of failures before exponential backoff - public setTargetSpeed(pState: PumpState) { let _newSpeed = 0; if (!pState.pumpOnDelay) { @@ -935,35 +932,7 @@ export class NixiePumpHWVS extends NixiePumpRS485 { } finally { this.suspendPolling = false; } }; - protected async requestPumpStatusAsync() { - // Actively poll Hayward pump for current status to maintain sync - if (conn.isPortEnabled(this.pump.portId || 0)) { - let out = Outbound.create({ - portId: this.pump.portId || 0, - protocol: Protocol.Hayward, - source: 1, - dest: this.pump.address - 96, - action: 12, - payload: [Math.min(Math.round((this._targetSpeed / sys.board.valueMaps.pumpTypes.get(this.pump.type).maxSpeed) * 100), 100)], - retries: 3, - timeout: 2500, - response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) - }); - try { - await out.sendAsync(); - // Communication successful - reset failure counter - this._consecutiveCommFailures = 0; - this._lastSuccessfulComm = new Date(); - let pstate = state.pumps.getItemById(this.pump.id); - pstate.status = 0; // OK status - } - catch (err) { - this._consecutiveCommFailures++; - logger.warn(`Hayward pump ${this.pump.name} status request failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); - this.updateCommStatus(); - } - } - }; + protected async requestPumpStatusAsync() { return Promise.resolve(); }; protected setPumpFeatureAsync(feature?: number) { return Promise.resolve(); } protected async setPumpToRemoteControlAsync(running: boolean = true) { try { @@ -977,19 +946,16 @@ export class NixiePumpHWVS extends NixiePumpRS485 { dest: this.pump.address, action: 1, payload: [0], // when stopAsync is called, pass false to return control to pump panel - retries: 3, - timeout: 2500, + // payload: spump.virtualControllerStatus === sys.board.valueMaps.virtualControllerStatus.getValue('running') ? [255] : [0], + retries: 1, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); - this._consecutiveCommFailures = 0; - this._lastSuccessfulComm = new Date(); } catch (err) { - this._consecutiveCommFailures++; - logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name} (${this._consecutiveCommFailures} failures): ${err.message}`); - this.updateCommStatus(); + logger.error(`Error sending setPumpToRemoteControl for ${this.pump.name}: ${err.message}`); + } } } @@ -1014,77 +980,29 @@ export class NixiePumpHWVS extends NixiePumpRS485 { source: 1, // Use the broadcast address dest: this.pump.address - 96, action: 12, - payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 100)], - retries: 3, - timeout: 2500, + payload: [Math.min(Math.round((this._targetSpeed / pt.maxSpeed) * 100), 99)], // when stopAsync is called, pass false to return control to pump panel + retries: 1, response: Response.create({ protocol: Protocol.Hayward, action: 12, source: this.pump.address - 96 }) }); try { await out.sendAsync(); - // Communication successful - reset failure counter - this._consecutiveCommFailures = 0; - this._lastSuccessfulComm = new Date(); - let pstate = state.pumps.getItemById(this.pump.id); - pstate.status = 0; // OK status } catch (err) { - this._consecutiveCommFailures++; - logger.error(`Hayward pump ${this.pump.name} speed command failed (${this._consecutiveCommFailures} consecutive failures): ${err.message}`); - // DO NOT clear state - keep showing last known values so user knows pump may still be running - this.updateCommStatus(); + logger.error(`Error sending setPumpRPM for ${this.pump.name}: ${err.message}`); + let pstate = state.pumps.getItemById(this.pump.id); + pstate.command = 0; + pstate.rpm = 0; + pstate.watts = 0; } } else { - // Port is disabled - safe to clear state as pump is not accessible let pstate = state.pumps.getItemById(this.pump.id); pstate.command = 0; pstate.rpm = 0; pstate.watts = 0; - pstate.status = 16; // Communication error status } }; - - public async pollEquipmentAsync() { - let self = this; - try { - if (typeof this._pollTimer !== 'undefined' || this._pollTimer) clearTimeout(this._pollTimer); - this._pollTimer = null; - if (this.suspendPolling || this.closing || this.pump.address > 112) { - if (this.suspendPolling) logger.info(`Pump ${this.id} Polling Suspended`); - if (this.closing) logger.info(`Pump ${this.id} is closing`); - return; - } - let pstate = state.pumps.getItemById(this.pump.id); - this.setTargetSpeed(pstate); - await this.setPumpStateAsync(pstate); - // Additionally poll for status to verify pump state - await this.requestPumpStatusAsync(); - } - catch (err) { logger.error(`Nixie Error running Hayward pump sequence - ${err}`); } - finally { - if (!self.closing) { - // Exponential backoff if communication is failing - let pollInterval = self.pollingInterval || 2000; - if (this._consecutiveCommFailures >= this._commFailureThreshold) { - // Exponential backoff: 2s -> 4s -> 8s -> 16s (max 30s) - pollInterval = Math.min(pollInterval * Math.pow(2, this._consecutiveCommFailures - this._commFailureThreshold), 30000); - logger.info(`Hayward pump ${this.pump.name} polling backed off to ${pollInterval}ms due to failures`); - } - this._pollTimer = setTimeoutSync(async () => await self.pollEquipmentAsync(), pollInterval); - } - } - } - - private updateCommStatus() { - let pstate = state.pumps.getItemById(this.pump.id); - if (this._consecutiveCommFailures >= this._commFailureThreshold) { - pstate.status = 16; // Communication error - logger.warn(`Hayward pump ${this.pump.name} has ${this._consecutiveCommFailures} consecutive communication failures. Last successful: ${this._lastSuccessfulComm.toISOString()}`); - } else if (this._consecutiveCommFailures > 0) { - pstate.status = 1; // Warning - intermittent issues - } - } } export class NixiePumpRegalModbus extends NixiePump { From 561b984874f019da6a993352ce29ece386609be0 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 18:46:41 -0400 Subject: [PATCH 09/21] Rename hwsp pump to Hayward Super Pump VS --- controller/boards/NixieBoard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/boards/NixieBoard.ts b/controller/boards/NixieBoard.ts index 2072f4b7..7c12e270 100644 --- a/controller/boards/NixieBoard.ts +++ b/controller/boards/NixieBoard.ts @@ -92,7 +92,7 @@ export class NixieBoard extends SystemBoard { [5, { name: 'vf', desc: 'Intelliflo VF', minFlow: 15, maxFlow: 130, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [6, { name: 'hwvs', desc: 'Hayward Eco/TriStar VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [7, { name: 'hwrly', desc: 'Hayward Relay VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, maxSpeeds: 8, relays: [{ id: 1, name: 'Step #1' }, { id: 2, name: 'Step #2'}, { id: 3, name: 'Step #3' }, { id: 4, name: 'Pump On' }], addresses: [] }], - [8, { name: 'hwsp', desc: 'Hayward SuperFlo VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], + [8, { name: 'hwsp', desc: 'Hayward Super Pump VS', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsPentairPump }], [100, { name: 'sf', desc: 'SuperFlo VS', hasAddress: false, maxCircuits: 8, maxRelays: 4, equipmentMaster: 1, maxSpeeds: 4, relays: [{ id: 1, name: 'Program #1' }, { id: 2, name: 'Program #2' }, { id: 3, name: 'Program #3' }, { id: 4, name: 'Program #4' }], addresses: [] }], [200, { name: 'regalmodbus', desc: 'Regal Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsRegalModbusPump}], [201, { name: 'neptunemodbus', desc: 'Neptune Modbus', minSpeed: 450, maxSpeed: 3450, maxCircuits: 8, hasAddress: true, addresses: addrsNeptuneModbusPump }], From bd3a420b50661a91f49aa930ef34bba8eca4d547 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 20:40:34 -0400 Subject: [PATCH 10/21] fix: add try/catch to pumpScheduler route handlers --- web/services/config/Config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/services/config/Config.ts b/web/services/config/Config.ts index b4e7229a..e980cd8b 100755 --- a/web/services/config/Config.ts +++ b/web/services/config/Config.ts @@ -1318,8 +1318,9 @@ export class ConfigRoute { } catch (err) { next(err); } }); // ── Pump Scheduler service routes ────────────────────────────────────────── - app.get('/config/services/pumpScheduler', (req, res) => { - return res.status(200).send(pumpScheduler.getScheduleSnapshot()); + app.get('/config/services/pumpScheduler', (req, res, next) => { + try { return res.status(200).send(pumpScheduler.getScheduleSnapshot()); } + catch (err) { next(err); } }); app.post('/config/services/pumpScheduler/generate', async (req, res, next) => { try { @@ -1333,8 +1334,9 @@ export class ConfigRoute { return res.status(200).send(pumpScheduler.getScheduleSnapshot()); } catch (err) { next(err); } }); - app.get('/config/services/pumpScheduler/circuits', (req, res) => { - return res.status(200).send(sys.board.circuits.getCircuitReferences(true, true, false, true)); + app.get('/config/services/pumpScheduler/circuits', (req, res, next) => { + try { return res.status(200).send(sys.board.circuits.getCircuitReferences(true, true, false, true)); } + catch (err) { next(err); } }); } } \ No newline at end of file From b6643fc4506989a7e3c1f64095ea4bddff3a16ae Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 21:00:57 -0400 Subject: [PATCH 11/21] Refactor pump scheduler: 3-input SimplePoolConfig, 2-block schedule, no medium tier --- controller/services/HydraulicsCalc.ts | 308 ++++++++------------ controller/services/PumpSchedulerService.ts | 72 +---- defaultConfig.json | 22 +- 3 files changed, 136 insertions(+), 266 deletions(-) diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts index f1e20909..fe95800f 100644 --- a/controller/services/HydraulicsCalc.ts +++ b/controller/services/HydraulicsCalc.ts @@ -3,258 +3,186 @@ * Pure hydraulic math helpers for pool pump scheduling. * No project-level imports — safe to use in unit tests and CLI scripts. * - * Physics notes - * ───────────── + * Physics + * ─────── * Affinity Laws (centrifugal pumps): - * Flow scales linearly with RPM: Q2 = Q1 × (RPM2 / RPM1) - * Head scales as the square: H2 = H1 × (RPM2 / RPM1)² - * Power scales as the cube: P2 = P1 × (RPM2 / RPM1)³ + * Flow scales linearly with RPM: Q2 = Q1 × (RPM2 / RPM1) + * Power scales as the cube: P2 = P1 × (RPM2 / RPM1)³ * - * GPM ↔ RPM model used here: - * Rather than a full TDH (Total Dynamic Head) curve, we use a single - * empirical reference point (referenceRPM → referenceGPM measured at - * the user's system pressure) and scale linearly via the affinity law. - * This is accurate enough for residential plumbing where TDH changes - * only modestly across the VS operating range. + * GPM ↔ RPM model: + * Anchored to a single empirical reference point per pipe size and scaled + * linearly via the affinity law — accurate enough for residential plumbing. * - * Pipe velocity / flow limit: - * 1.5" Schedule-40 PVC: recommended max 5 ft/s → ≈50 GPM at that bore. - * Exceeding this causes hydraulic noise and risk of cavitation at the - * pump volute; the algorithm hard-caps GPM at poolConfig.maxSafeGPM. + * Pipe flow limits (velocity ≤ 5 ft/s rule of thumb): + * 1.5" Schedule-40 PVC: max safe ~50 GPM + * 2.0" Schedule-40 PVC: max safe ~75 GPM */ -// ─── Types ──────────────────────────────────────────────────────────────────── +// ─── Public types ────────────────────────────────────────────────────────────── -export interface PoolConfig { - poolVolumeGallons: number; - maxSafeGPM: number; // Pipe flow ceiling (1.5" → 50 GPM) - maxPumpRPM: number; // Hayward Super Pump VS 700: 3450 - minPumpRPM: number; // Firmware minimum: 600 (algorithm floor: 1000) - targetTurnovers: number; // Gallons to move = volume × this (default 1.2) - referenceRPM: number; // Empirical calibration point RPM (default 2850) - referenceGPM: number; // Actual GPM measured at referenceRPM (default 45) - highBlockStartHour: number; // Hour (0-23) the High block begins (default 6) - highBlockDurationHours: number; // Fixed High block duration in hours (default 2) - medBlockDurationHours: number; // Minimum Medium block duration in hours (default 4) - lowBlockMinHours: number; // Low block floor (default 10) - lowBlockMaxHours: number; // Low block ceiling (default 14) - equipmentRequirements: { - heaterMinGPM: number; // Minimum flow for heater ignition (default 30) - saltCellMinGPM: number; // Minimum flow for salt cell operation (default 25) - skimmerMinGPM: number; // Minimum flow for surface skimming (default 45) - }; +/** + * The only three inputs the user needs to supply. + * All hydraulic parameters (RPM caps, reference curves, durations) are derived + * automatically inside calcScheduleBlocks. + */ +export interface SimplePoolConfig { + poolVolumeGallons: number; // Pool water volume in gallons (e.g. 20000) + pipeDiameter: 1.5 | 2; // Main plumbing size in inches + hasSaltCell: boolean; // Has salt chlorinator — ensures low RPM keeps GPM ≥ 25 } export interface ScheduleBlock { - phase: 'high' | 'medium' | 'low'; + phase: 'high' | 'low'; rpm: number; gpm: number; durationHours: number; - startMinutes: number; // Minutes from midnight (0–1439) + startMinutes: number; // Minutes from midnight (0–1439) endMinutes: number; gallons: number; estimatedWatts: number; - /** True when this block's GPM is below saltCellMinGPM — caller should log a warning */ - saltCellWarning: boolean; } export interface SchedulePlan { - blocks: [ScheduleBlock, ScheduleBlock, ScheduleBlock]; + blocks: [ScheduleBlock, ScheduleBlock]; // [high, low] totalGallons: number; totalRunHours: number; turnovers: number; } -// ─── Core math ──────────────────────────────────────────────────────────────── +// ─── Math utilities ──────────────────────────────────────────────────────────── -/** - * Target volume to move per day. - * turnoverVolume = poolVolume × targetTurnovers - */ -export function calcTurnoverVolume(poolVolumeGallons: number, targetTurnovers: number): number { - return poolVolumeGallons * targetTurnovers; +/** Flow scales linearly with RPM (affinity law, first leg). */ +export function gpmForRPM(rpm: number, refRPM: number, refGPM: number): number { + return refGPM * (rpm / refRPM); } -/** - * Average GPM required across total run hours to hit target volume. - * averageGPM = turnoverVolume / (totalRunHours × 60) - */ -export function calcTargetGPM(turnoverVolumeGallons: number, totalRunHours: number): number { - return turnoverVolumeGallons / (totalRunHours * 60); +/** Inverse: GPM → RPM. */ +export function rpmForGPM(gpm: number, refRPM: number, refGPM: number): number { + return refRPM * (gpm / refGPM); } -/** - * Convert RPM → GPM using the linear affinity-law model anchored to a - * known empirical reference point. - * gpm = referenceGPM × (rpm / referenceRPM) - * - * This is the Q-scaling leg of the Affinity Laws (flow ∝ RPM). - */ -export function gpmForRPM(rpm: number, referenceRPM: number, referenceGPM: number): number { - return referenceGPM * (rpm / referenceRPM); +/** Power scales as the cube of the RPM ratio (affinity law, third leg). */ +export function affinityPower(p1Watts: number, rpm1: number, rpm2: number): number { + return p1Watts * Math.pow(rpm2 / rpm1, 3); } -/** - * Convert GPM → RPM (inverse of gpmForRPM). - * rpm = referenceRPM × (gpm / referenceGPM) - */ -export function rpmForGPM(gpm: number, referenceRPM: number, referenceGPM: number): number { - return referenceRPM * (gpm / referenceGPM); +/** Convert minutes-from-midnight to "HH:MM" string. */ +export function minutesToTime(minutes: number): string { + const m = ((minutes % 1440) + 1440) % 1440; + const h = Math.floor(m / 60); + const mm = m % 60; + return `${h.toString().padStart(2, '0')}:${mm.toString().padStart(2, '0')}`; } -/** - * Pump Affinity Law — power scaling. - * P2 = P1 × (RPM2 / RPM1)³ - * - * Energy savings are dramatic: dropping from 3450 → 1000 RPM reduces power - * consumption to just (1000/3450)³ ≈ 2.4 % of full-speed draw. - * - * @param p1Watts Known power draw at rpm1 - * @param rpm1 Reference RPM corresponding to p1 - * @param rpm2 Target RPM to estimate power for - */ -export function affinityPower(p1Watts: number, rpm1: number, rpm2: number): number { - return p1Watts * Math.pow(rpm2 / rpm1, 3); +// ─── Pipe-size constants ─────────────────────────────────────────────────────── + +interface PipeTier { + maxSafeGPM: number; + maxRPM: number; + refRPM: number; // Empirical calibration point + refGPM: number; + refWatts: number; // Estimated draw at refRPM (Hayward Super Pump VS) } -// ─── Schedule block builder ──────────────────────────────────────────────────── +const PIPE_TIERS: Record = { + '1.5': { maxSafeGPM: 50, maxRPM: 2850, refRPM: 2850, refGPM: 45, refWatts: 900 }, + '2': { maxSafeGPM: 75, maxRPM: 3450, refRPM: 3000, refGPM: 65, refWatts: 1100 }, +}; -const ALGO_MIN_RPM = 1000; // Floor for Low block — keeps filter pressure adequate -const LOW_TIER_MAX_RPM = 1500; // If Low block hours overflow 14h, nudge RPM up to here +// ─── Scheduling policy (hardcoded — not user-configurable) ──────────────────── + +const TARGET_TURNOVERS = 1.2; // Gallons/day = pool volume × 1.2 +const HIGH_START_HOUR = 6; // 6 AM — morning skim and filter prime +const HIGH_DURATION_HRS = 2; // High block is always 2 hours +const SALT_CELL_MIN_GPM = 25; // Salt cell flow-switch trip point +const ALGO_MIN_RPM = 1000; // RPM floor (filter pressure / seal longevity) +const LOW_MAX_RPM = 1500; // RPM ceiling for low block (energy efficiency) +const MAX_LOW_HOURS = 14; // Maximum low-block runtime + +// ─── Schedule builder ───────────────────────────────────────────────────────── /** - * Compute the full 24-hour three-block schedule plan from a pool configuration. + * Compute the daily two-block pump schedule from a simplified pool config. * - * Algorithm: - * 1. High block — fixed 2 hrs at the highest RPM that stays ≤ maxSafeGPM. - * Sized for surface skimming and pre-filter priming. - * 2. Medium block — fixed (medBlockDurationHours) hrs at the RPM required to - * safely exceed heaterMinGPM. Covers heating cycles and - * salt-cell chlorination at adequate flow. - * 3. Low block — fills remaining turnover volume at the lowest practical RPM - * (≥1000 RPM floor). Duration is clamped to [lowMin, lowMax]. - * If the required hours exceed lowMax, RPM is nudged up until - * the hours fit — maximising Affinity Law energy savings. + * HIGH block — 2 hrs at 90 % of max safe GPM, starting at 6 AM. + * Morning surface skim and filter prime. * - * @param cfg Pool configuration (see PoolConfig) - * @param referenceWattsAtMaxRPM Optional reference power draw at maxPumpRPM. - * Hayward Super Pump VS 700 nameplate: ~1100 W at max speed. - * Used only for estimatedWatts; does not affect RPM/GPM/time math. + * LOW block — fills the remaining turnover volume at the lowest practical + * RPM. If hasSaltCell is true, the RPM floor is raised so GPM + * stays ≥ 25 and the flow switch remains closed. + * If the volume cannot be moved in MAX_LOW_HOURS, RPM is nudged + * up in 10-RPM steps until it fits (capped at LOW_MAX_RPM or + * the salt-cell floor, whichever is higher). */ -export function calcScheduleBlocks(cfg: PoolConfig, referenceWattsAtMaxRPM = 1100): SchedulePlan { - const { poolVolumeGallons, targetTurnovers, maxSafeGPM, maxPumpRPM, - minPumpRPM, referenceRPM, referenceGPM, - highBlockStartHour, highBlockDurationHours, medBlockDurationHours, - lowBlockMinHours, lowBlockMaxHours, equipmentRequirements } = cfg; +export function calcScheduleBlocks(cfg: SimplePoolConfig): SchedulePlan { + const pipe = PIPE_TIERS[String(cfg.pipeDiameter)]; + if (!pipe) throw new Error(`Unknown pipe diameter: ${cfg.pipeDiameter}`); - // ── 1. Turnover target ───────────────────────────────────────────────────── - const turnoverVolume = calcTurnoverVolume(poolVolumeGallons, targetTurnovers); + const { maxSafeGPM, maxRPM, refRPM, refGPM, refWatts } = pipe; + const targetGallons = cfg.poolVolumeGallons * TARGET_TURNOVERS; - // ── 2. High block ────────────────────────────────────────────────────────── - // Target GPM = 90 % of the pipe ceiling so there is headroom. - // Convert to RPM and clamp to hardware limits. - // NOTE: the Hayward VS 700 has a known firmware speed plateau around 2967 RPM - // (≈86 % of 3450). We stay well below at ≈82 % (2850 RPM) to avoid it. - const highTargetGPM = maxSafeGPM * 0.90; - const highRPMRaw = rpmForGPM(highTargetGPM, referenceRPM, referenceGPM); - const highRPM = Math.min(Math.round(highRPMRaw / 10) * 10, maxPumpRPM); - const highGPM = Math.min(gpmForRPM(highRPM, referenceRPM, referenceGPM), maxSafeGPM); - const highGallons = highGPM * highBlockDurationHours * 60; - const highStart = highBlockStartHour * 60; - const highEnd = highStart + highBlockDurationHours * 60; - const highWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, highRPM); - - // ── 3. Medium block ──────────────────────────────────────────────────────── - // Target: at least heaterMinGPM + 5 GPM margin to guarantee heater ignition - // and salt-cell minimum in a single comfortable band. - const medTargetGPM = equipmentRequirements.heaterMinGPM + 5; - const medRPMRaw = rpmForGPM(medTargetGPM, referenceRPM, referenceGPM); - const medRPM = Math.max( - Math.min(Math.round(medRPMRaw / 10) * 10, maxPumpRPM), - minPumpRPM + // ── HIGH block ───────────────────────────────────────────────────────────── + const highRPM = Math.min( + Math.round(rpmForGPM(maxSafeGPM * 0.9, refRPM, refGPM) / 10) * 10, + maxRPM ); - const medGPM = gpmForRPM(medRPM, referenceRPM, referenceGPM); - const medGallons = medGPM * medBlockDurationHours * 60; - const medStart = highEnd; - const medEnd = medStart + medBlockDurationHours * 60; - const medWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, medRPM); - - // ── 4. Low block — find the lowest RPM that fits the window ─────────────── - const remainingGallons = turnoverVolume - highGallons - medGallons; - - // Start with the algorithm minimum RPM and work up if needed. - let lowRPM = Math.max(ALGO_MIN_RPM, minPumpRPM); - let lowGPM = gpmForRPM(lowRPM, referenceRPM, referenceGPM); - let lowHoursNeeded = remainingGallons / (lowGPM * 60); - - // If we need more than lowBlockMaxHours, nudge RPM up in 10-RPM steps - // until the hours fit — but cap the nudge at LOW_TIER_MAX_RPM. - while (lowHoursNeeded > lowBlockMaxHours && lowRPM < LOW_TIER_MAX_RPM) { - lowRPM += 10; - lowGPM = gpmForRPM(lowRPM, referenceRPM, referenceGPM); - lowHoursNeeded = remainingGallons / (lowGPM * 60); + const highGPM = parseFloat(gpmForRPM(highRPM, refRPM, refGPM).toFixed(1)); + const highGals = Math.round(highGPM * HIGH_DURATION_HRS * 60); + const highStart = HIGH_START_HOUR * 60; + const highEnd = highStart + HIGH_DURATION_HRS * 60; + + // ── LOW block ────────────────────────────────────────────────────────────── + const remaining = targetGallons - highGals; + + // RPM floor: raise if salt cell needs GPM ≥ 25. + const saltFloorRPM = Math.ceil(rpmForGPM(SALT_CELL_MIN_GPM, refRPM, refGPM) / 10) * 10; + let lowRPM = cfg.hasSaltCell ? Math.max(saltFloorRPM, ALGO_MIN_RPM) : ALGO_MIN_RPM; + const lowRPMCap = cfg.hasSaltCell ? Math.max(LOW_MAX_RPM, saltFloorRPM) : LOW_MAX_RPM; + + let lowGPM = gpmForRPM(lowRPM, refRPM, refGPM); + let lowHours = remaining / (lowGPM * 60); + + // Nudge RPM up if volume cannot fit in MAX_LOW_HOURS. + while (lowHours > MAX_LOW_HOURS && lowRPM < lowRPMCap) { + lowRPM += 10; + lowGPM = gpmForRPM(lowRPM, refRPM, refGPM); + lowHours = remaining / (lowGPM * 60); } - // Clamp duration to the configured window. - const lowDurationHours = Math.max(lowBlockMinHours, Math.min(lowHoursNeeded, lowBlockMaxHours)); - const lowGallons = lowGPM * lowDurationHours * 60; - const lowStart = medEnd; - // endMinutes may cross midnight (> 1440) — callers must handle wrap-around. - const lowEnd = lowStart + Math.round(lowDurationHours * 60); - const lowWatts = affinityPower(referenceWattsAtMaxRPM, maxPumpRPM, lowRPM); + const lowDurationHours = parseFloat(Math.min(lowHours, MAX_LOW_HOURS).toFixed(2)); + const lowStart = highEnd; + const lowEnd = lowStart + Math.round(lowDurationHours * 60); - // ── 5. Assemble plan ─────────────────────────────────────────────────────── - const high: ScheduleBlock = { + // ── Assemble ─────────────────────────────────────────────────────────────── + const highBlock: ScheduleBlock = { phase: 'high', rpm: highRPM, - gpm: parseFloat(highGPM.toFixed(1)), - durationHours: highBlockDurationHours, + gpm: highGPM, + durationHours: HIGH_DURATION_HRS, startMinutes: highStart, endMinutes: highEnd, - gallons: Math.round(highGallons), - estimatedWatts: Math.round(highWatts), - saltCellWarning: highGPM < equipmentRequirements.saltCellMinGPM, - }; - - const medium: ScheduleBlock = { - phase: 'medium', - rpm: medRPM, - gpm: parseFloat(medGPM.toFixed(1)), - durationHours: medBlockDurationHours, - startMinutes: medStart, - endMinutes: medEnd, - gallons: Math.round(medGallons), - estimatedWatts: Math.round(medWatts), - saltCellWarning: medGPM < equipmentRequirements.saltCellMinGPM, + gallons: highGals, + estimatedWatts: Math.round(affinityPower(refWatts, refRPM, highRPM)), }; - const low: ScheduleBlock = { + const lowBlock: ScheduleBlock = { phase: 'low', rpm: lowRPM, gpm: parseFloat(lowGPM.toFixed(1)), - durationHours: parseFloat(lowDurationHours.toFixed(2)), + durationHours: lowDurationHours, startMinutes: lowStart, endMinutes: lowEnd, - gallons: Math.round(lowGallons), - estimatedWatts: Math.round(lowWatts), - saltCellWarning: lowGPM < equipmentRequirements.saltCellMinGPM, + gallons: Math.round(lowGPM * lowDurationHours * 60), + estimatedWatts: Math.round(affinityPower(refWatts, refRPM, lowRPM)), }; - const totalGallons = high.gallons + medium.gallons + low.gallons; - const totalRunHours = high.durationHours + medium.durationHours + low.durationHours; + const totalGallons = highBlock.gallons + lowBlock.gallons; + const totalRunHours = HIGH_DURATION_HRS + lowDurationHours; return { - blocks: [high, medium, low], + blocks: [highBlock, lowBlock], totalGallons, totalRunHours: parseFloat(totalRunHours.toFixed(2)), - turnovers: parseFloat((totalGallons / poolVolumeGallons).toFixed(3)), + turnovers: parseFloat((totalGallons / cfg.poolVolumeGallons).toFixed(3)), }; } - -/** Format minutes-from-midnight as "HH:MM" for display / logging. */ -export function minutesToTime(minutes: number): string { - const m = ((minutes % 1440) + 1440) % 1440; // normalise negative / overflow - const h = Math.floor(m / 60); - const min = m % 60; - return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; -} diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 62fca54a..1a935193 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -29,30 +29,16 @@ import { sys } from '../Equipment'; import { state } from '../State'; import { webApp } from '../../web/Server'; import { - PoolConfig, SchedulePlan, ScheduleBlock, + SimplePoolConfig, SchedulePlan, ScheduleBlock, calcScheduleBlocks, minutesToTime, } from './HydraulicsCalc'; // ─── Default configuration ──────────────────────────────────────────────────── -const DEFAULT_POOL_CONFIG: PoolConfig = { +const DEFAULT_POOL_CONFIG: SimplePoolConfig = { poolVolumeGallons: 20000, - maxSafeGPM: 50, - maxPumpRPM: 3450, - minPumpRPM: 600, - targetTurnovers: 1.2, - referenceRPM: 2850, - referenceGPM: 45, - highBlockStartHour: 6, - highBlockDurationHours: 2, - medBlockDurationHours: 4, - lowBlockMinHours: 10, - lowBlockMaxHours: 14, - equipmentRequirements: { - heaterMinGPM: 30, - saltCellMinGPM: 25, - skimmerMinGPM: 45, - }, + pipeDiameter: 1.5, + hasSaltCell: false, }; // scheduleType 128 = "Repeats" (daily on selected days). @@ -66,19 +52,16 @@ const TIME_TYPE_MANUAL = 0; interface SchedulerConfig { enabled: boolean; pumpId: number; - featureIds: { high: number; medium: number; low: number }; - scheduleIds: { high: number; medium: number; low: number }; - poolConfig: PoolConfig; + featureIds: { high: number; low: number }; + scheduleIds: { high: number; low: number }; + poolConfig: SimplePoolConfig; } const DEFAULT_SCHEDULER_CFG: SchedulerConfig = { enabled: true, pumpId: 1, - // Feature IDs 14-16 sit at the top of the default 7-16 feature range, - // leaving room for user-defined features below. - featureIds: { high: 14, medium: 15, low: 16 }, - // Schedule IDs 10-12 sit at the top of the default 1-12 schedule range. - scheduleIds: { high: 10, medium: 11, low: 12 }, + featureIds: { high: 14, low: 16 }, + scheduleIds: { high: 10, low: 12 }, poolConfig: DEFAULT_POOL_CONFIG, }; @@ -141,17 +124,6 @@ export class PumpSchedulerService { const plan = calcScheduleBlocks(this._cfg.poolConfig); this._lastPlan = plan; - // Warn if salt cell flow requirements won't be met. - for (const block of plan.blocks) { - if (block.saltCellWarning) { - logger.warn( - `PumpSchedulerService: ${block.phase} block GPM (${block.gpm}) is below ` + - `saltCellMinGPM (${this._cfg.poolConfig.equipmentRequirements.saltCellMinGPM}). ` + - `Salt chlorination may be reduced during this period.` - ); - } - } - this._logPlan(plan); await this._writeSchedulesAsync(plan); @@ -167,16 +139,8 @@ export class PumpSchedulerService { /** Merge new pool config values and regenerate. */ public async updateConfigAsync(data: Partial): Promise { try { - // Deep-merge poolConfig if provided. if (data.poolConfig) { this._cfg.poolConfig = Object.assign({}, this._cfg.poolConfig, data.poolConfig); - if (data.poolConfig.equipmentRequirements) { - this._cfg.poolConfig.equipmentRequirements = Object.assign( - {}, - this._cfg.poolConfig.equipmentRequirements, - data.poolConfig.equipmentRequirements - ); - } } if (typeof data.enabled !== 'undefined') this._cfg.enabled = data.enabled; if (typeof data.pumpId !== 'undefined') this._cfg.pumpId = data.pumpId; @@ -215,13 +179,7 @@ export class PumpSchedulerService { pumpId: saved.pumpId ?? DEFAULT_SCHEDULER_CFG.pumpId, featureIds: Object.assign({}, DEFAULT_SCHEDULER_CFG.featureIds, saved.featureIds), scheduleIds: Object.assign({}, DEFAULT_SCHEDULER_CFG.scheduleIds, saved.scheduleIds), - poolConfig: Object.assign({}, DEFAULT_POOL_CONFIG, saved.poolConfig, { - equipmentRequirements: Object.assign( - {}, - DEFAULT_POOL_CONFIG.equipmentRequirements, - saved.poolConfig?.equipmentRequirements - ), - }), + poolConfig: Object.assign({}, DEFAULT_POOL_CONFIG, saved.poolConfig), }; } @@ -243,9 +201,8 @@ export class PumpSchedulerService { */ private async _ensureFeaturesExistAsync(): Promise { const featureMap = [ - { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, - { id: this._cfg.featureIds.medium, name: 'PumpSched-Medium' }, - { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, + { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, + { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, ]; for (const f of featureMap) { const existing = sys.features.find(feat => feat.id === f.id); @@ -276,9 +233,8 @@ export class PumpSchedulerService { const feats = this._cfg.featureIds; const entries = [ - { id: ids.high, featureId: feats.high, block: plan.blocks[0] }, - { id: ids.medium, featureId: feats.medium, block: plan.blocks[1] }, - { id: ids.low, featureId: feats.low, block: plan.blocks[2] }, + { id: ids.high, featureId: feats.high, block: plan.blocks[0] }, + { id: ids.low, featureId: feats.low, block: plan.blocks[1] }, ]; for (const entry of entries) { diff --git a/defaultConfig.json b/defaultConfig.json index 04740754..8c16ffdb 100755 --- a/defaultConfig.json +++ b/defaultConfig.json @@ -81,25 +81,11 @@ "pumpId": 1, "poolConfig": { "poolVolumeGallons": 20000, - "maxSafeGPM": 50, - "maxPumpRPM": 3450, - "minPumpRPM": 600, - "targetTurnovers": 1.2, - "referenceRPM": 2850, - "referenceGPM": 45, - "highBlockStartHour": 6, - "highBlockDurationHours": 2, - "medBlockDurationHours": 4, - "lowBlockMinHours": 10, - "lowBlockMaxHours": 14, - "equipmentRequirements": { - "heaterMinGPM": 30, - "saltCellMinGPM": 25, - "skimmerMinGPM": 45 - } + "pipeDiameter": 1.5, + "hasSaltCell": false }, - "featureIds": { "high": 14, "medium": 15, "low": 16 }, - "scheduleIds": { "high": 10, "medium": 11, "low": 12 } + "featureIds": { "high": 14, "low": 16 }, + "scheduleIds": { "high": 10, "low": 12 } } }, "interfaces": { From f9a41bc84d27dafbd0e039bd095f6c2fa1c3dba8 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 21:02:36 -0400 Subject: [PATCH 12/21] Fix: remove leftover saltCellWarning reference from _logPlan --- controller/services/PumpSchedulerService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 1a935193..d41a1e71 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -308,8 +308,7 @@ export class PumpSchedulerService { for (const b of plan.blocks) { logger.info( ` [${b.phase.padEnd(6)}] ${minutesToTime(b.startMinutes)}–${minutesToTime(b.endMinutes)} ` + - `${b.rpm} RPM ${b.gpm} GPM ~${b.estimatedWatts} W ${b.gallons.toLocaleString()} gal` + - (b.saltCellWarning ? ' ⚠ salt-cell flow low' : '') + `${b.rpm} RPM ${b.gpm} GPM ~${b.estimatedWatts} W ${b.gallons.toLocaleString()} gal` ); } } From 81976bd7e9854fa68aa28705ec1217dd05405d78 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 21:16:06 -0400 Subject: [PATCH 13/21] Refactor: dynamic 3-block schedule (high/medium/low), remove hasSaltCell, pump.maxSpeed integration --- controller/services/HydraulicsCalc.ts | 115 ++++++++++++-------- controller/services/PumpSchedulerService.ts | 81 +++++++++++--- defaultConfig.json | 9 +- 3 files changed, 143 insertions(+), 62 deletions(-) diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts index fe95800f..a307b7fb 100644 --- a/controller/services/HydraulicsCalc.ts +++ b/controller/services/HydraulicsCalc.ts @@ -28,11 +28,10 @@ export interface SimplePoolConfig { poolVolumeGallons: number; // Pool water volume in gallons (e.g. 20000) pipeDiameter: 1.5 | 2; // Main plumbing size in inches - hasSaltCell: boolean; // Has salt chlorinator — ensures low RPM keeps GPM ≥ 25 } export interface ScheduleBlock { - phase: 'high' | 'low'; + phase: 'high' | 'medium' | 'low'; rpm: number; gpm: number; durationHours: number; @@ -43,7 +42,7 @@ export interface ScheduleBlock { } export interface SchedulePlan { - blocks: [ScheduleBlock, ScheduleBlock]; // [high, low] + blocks: [ScheduleBlock, ScheduleBlock, ScheduleBlock]; // [high, medium, low] totalGallons: number; totalRunHours: number; turnovers: number; @@ -77,80 +76,97 @@ export function minutesToTime(minutes: number): string { // ─── Pipe-size constants ─────────────────────────────────────────────────────── interface PipeTier { - maxSafeGPM: number; - maxRPM: number; - refRPM: number; // Empirical calibration point - refGPM: number; - refWatts: number; // Estimated draw at refRPM (Hayward Super Pump VS) + maxSafeGPM: number; // Velocity-safe flow ceiling (≤ 5 ft/s rule) + refWatts: number; // Estimated draw at pump max RPM (approximation) } const PIPE_TIERS: Record = { - '1.5': { maxSafeGPM: 50, maxRPM: 2850, refRPM: 2850, refGPM: 45, refWatts: 900 }, - '2': { maxSafeGPM: 75, maxRPM: 3450, refRPM: 3000, refGPM: 65, refWatts: 1100 }, + '1.5': { maxSafeGPM: 50, refWatts: 900 }, + '2': { maxSafeGPM: 75, refWatts: 1100 }, }; // ─── Scheduling policy (hardcoded — not user-configurable) ──────────────────── -const TARGET_TURNOVERS = 1.2; // Gallons/day = pool volume × 1.2 -const HIGH_START_HOUR = 6; // 6 AM — morning skim and filter prime -const HIGH_DURATION_HRS = 2; // High block is always 2 hours -const SALT_CELL_MIN_GPM = 25; // Salt cell flow-switch trip point -const ALGO_MIN_RPM = 1000; // RPM floor (filter pressure / seal longevity) -const LOW_MAX_RPM = 1500; // RPM ceiling for low block (energy efficiency) -const MAX_LOW_HOURS = 14; // Maximum low-block runtime +const TARGET_TURNOVERS = 1.2; // Gallons/day = pool volume × 1.2 +const HIGH_START_HOUR = 6; // 6 AM — morning skim and filter prime +const HIGH_DURATION_HRS = 2; // High block is always 2 hours +const MEDIUM_DURATION_HRS = 4; // Chemistry window — keeps flow switch closed +const MEDIUM_TARGET_GPM = 30; // Minimum GPM to close the salt-cell flow switch +const ALGO_MIN_RPM = 1000; // RPM floor (filter pressure / seal longevity) +const LOW_MAX_RPM = 1500; // RPM ceiling for low block (energy efficiency) +const MAX_LOW_HOURS = 14; // Maximum low-block runtime // ─── Schedule builder ───────────────────────────────────────────────────────── /** - * Compute the daily two-block pump schedule from a simplified pool config. + * Compute the daily three-block pump schedule. * - * HIGH block — 2 hrs at 90 % of max safe GPM, starting at 6 AM. - * Morning surface skim and filter prime. + * The reference curve is anchored dynamically to (pumpMaxRPM, maxSafeGPM) so + * the schedule scales correctly for any VS pump and pipe size combination. * - * LOW block — fills the remaining turnover volume at the lowest practical - * RPM. If hasSaltCell is true, the RPM floor is raised so GPM - * stays ≥ 25 and the flow switch remains closed. - * If the volume cannot be moved in MAX_LOW_HOURS, RPM is nudged - * up in 10-RPM steps until it fits (capped at LOW_MAX_RPM or - * the salt-cell floor, whichever is higher). + * HIGH block — 2 hrs at 90 % of the pipe's max safe GPM (= 90 % of max RPM). + * Morning surface skim and filter prime. + * + * MEDIUM block — 4 hrs at exactly MEDIUM_TARGET_GPM (30 GPM). + * Keeps the salt-cell / chemistry flow switch closed. + * + * LOW block — fills the remaining turnover volume at the lowest practical + * RPM (≥ ALGO_MIN_RPM). RPM is nudged up in 10-RPM steps if + * the volume cannot fit in MAX_LOW_HOURS (capped at LOW_MAX_RPM). + * + * @param cfg User pool config (volume + pipe diameter). + * @param pumpMaxRPM Pump's hardware maximum RPM (from sys.pumps, default 3450). */ -export function calcScheduleBlocks(cfg: SimplePoolConfig): SchedulePlan { +export function calcScheduleBlocks(cfg: SimplePoolConfig, pumpMaxRPM: number): SchedulePlan { const pipe = PIPE_TIERS[String(cfg.pipeDiameter)]; if (!pipe) throw new Error(`Unknown pipe diameter: ${cfg.pipeDiameter}`); - const { maxSafeGPM, maxRPM, refRPM, refGPM, refWatts } = pipe; + const { maxSafeGPM, refWatts } = pipe; + // Dynamic reference curve: at pumpMaxRPM the pump delivers maxSafeGPM. + // All RPM ↔ GPM conversions use this calibration anchor. + const refRPM = pumpMaxRPM; + const refGPM = maxSafeGPM; const targetGallons = cfg.poolVolumeGallons * TARGET_TURNOVERS; // ── HIGH block ───────────────────────────────────────────────────────────── + // Run at 90 % of max safe GPM → 90 % of pump max RPM. const highRPM = Math.min( Math.round(rpmForGPM(maxSafeGPM * 0.9, refRPM, refGPM) / 10) * 10, - maxRPM + pumpMaxRPM ); const highGPM = parseFloat(gpmForRPM(highRPM, refRPM, refGPM).toFixed(1)); const highGals = Math.round(highGPM * HIGH_DURATION_HRS * 60); const highStart = HIGH_START_HOUR * 60; const highEnd = highStart + HIGH_DURATION_HRS * 60; - // ── LOW block ────────────────────────────────────────────────────────────── - const remaining = targetGallons - highGals; - - // RPM floor: raise if salt cell needs GPM ≥ 25. - const saltFloorRPM = Math.ceil(rpmForGPM(SALT_CELL_MIN_GPM, refRPM, refGPM) / 10) * 10; - let lowRPM = cfg.hasSaltCell ? Math.max(saltFloorRPM, ALGO_MIN_RPM) : ALGO_MIN_RPM; - const lowRPMCap = cfg.hasSaltCell ? Math.max(LOW_MAX_RPM, saltFloorRPM) : LOW_MAX_RPM; + // ── MEDIUM block (chemistry window) ──────────────────────────────────────── + const mediumRPM = Math.max( + ALGO_MIN_RPM, + Math.min( + Math.round(rpmForGPM(MEDIUM_TARGET_GPM, refRPM, refGPM) / 10) * 10, + pumpMaxRPM + ) + ); + const mediumGPM = parseFloat(gpmForRPM(mediumRPM, refRPM, refGPM).toFixed(1)); + const mediumGals = Math.round(mediumGPM * MEDIUM_DURATION_HRS * 60); + const mediumStart = highEnd; + const mediumEnd = mediumStart + MEDIUM_DURATION_HRS * 60; - let lowGPM = gpmForRPM(lowRPM, refRPM, refGPM); - let lowHours = remaining / (lowGPM * 60); + // ── LOW block ────────────────────────────────────────────────────────────── + const remaining = Math.max(0, targetGallons - highGals - mediumGals); + let lowRPM = ALGO_MIN_RPM; + let lowGPM = gpmForRPM(lowRPM, refRPM, refGPM); + let lowHours = remaining > 0 ? remaining / (lowGPM * 60) : 0; - // Nudge RPM up if volume cannot fit in MAX_LOW_HOURS. - while (lowHours > MAX_LOW_HOURS && lowRPM < lowRPMCap) { + // Nudge RPM up in 10-RPM steps until the volume fits within MAX_LOW_HOURS. + while (lowHours > MAX_LOW_HOURS && lowRPM < LOW_MAX_RPM) { lowRPM += 10; lowGPM = gpmForRPM(lowRPM, refRPM, refGPM); lowHours = remaining / (lowGPM * 60); } const lowDurationHours = parseFloat(Math.min(lowHours, MAX_LOW_HOURS).toFixed(2)); - const lowStart = highEnd; + const lowStart = mediumEnd; const lowEnd = lowStart + Math.round(lowDurationHours * 60); // ── Assemble ─────────────────────────────────────────────────────────────── @@ -165,6 +181,17 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig): SchedulePlan { estimatedWatts: Math.round(affinityPower(refWatts, refRPM, highRPM)), }; + const mediumBlock: ScheduleBlock = { + phase: 'medium', + rpm: mediumRPM, + gpm: mediumGPM, + durationHours: MEDIUM_DURATION_HRS, + startMinutes: mediumStart, + endMinutes: mediumEnd, + gallons: mediumGals, + estimatedWatts: Math.round(affinityPower(refWatts, refRPM, mediumRPM)), + }; + const lowBlock: ScheduleBlock = { phase: 'low', rpm: lowRPM, @@ -176,11 +203,11 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig): SchedulePlan { estimatedWatts: Math.round(affinityPower(refWatts, refRPM, lowRPM)), }; - const totalGallons = highBlock.gallons + lowBlock.gallons; - const totalRunHours = HIGH_DURATION_HRS + lowDurationHours; + const totalGallons = highBlock.gallons + mediumBlock.gallons + lowBlock.gallons; + const totalRunHours = HIGH_DURATION_HRS + MEDIUM_DURATION_HRS + lowDurationHours; return { - blocks: [highBlock, lowBlock], + blocks: [highBlock, mediumBlock, lowBlock], totalGallons, totalRunHours: parseFloat(totalRunHours.toFixed(2)), turnovers: parseFloat((totalGallons / cfg.poolVolumeGallons).toFixed(3)), diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index d41a1e71..92ff8393 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -38,9 +38,11 @@ import { const DEFAULT_POOL_CONFIG: SimplePoolConfig = { poolVolumeGallons: 20000, pipeDiameter: 1.5, - hasSaltCell: false, }; +// Default fallback when the pump's maxSpeed is unavailable (e.g. not yet configured). +const DEFAULT_PUMP_MAX_RPM = 3450; + // scheduleType 128 = "Repeats" (daily on selected days). // See SystemBoard.ts scheduleTypes value map. const SCHEDULE_TYPE_REPEAT = 128; @@ -52,16 +54,16 @@ const TIME_TYPE_MANUAL = 0; interface SchedulerConfig { enabled: boolean; pumpId: number; - featureIds: { high: number; low: number }; - scheduleIds: { high: number; low: number }; + featureIds: { high: number; medium: number; low: number }; + scheduleIds: { high: number; medium: number; low: number }; poolConfig: SimplePoolConfig; } const DEFAULT_SCHEDULER_CFG: SchedulerConfig = { - enabled: true, + enabled: false, pumpId: 1, - featureIds: { high: 14, low: 16 }, - scheduleIds: { high: 10, low: 12 }, + featureIds: { high: 14, medium: 15, low: 16 }, + scheduleIds: { high: 10, medium: 11, low: 12 }, poolConfig: DEFAULT_POOL_CONFIG, }; @@ -121,7 +123,12 @@ export class PumpSchedulerService { /** Recompute the schedule and push it to sys.schedules. */ public async generateScheduleAsync(): Promise { try { - const plan = calcScheduleBlocks(this._cfg.poolConfig); + const pump = sys.pumps.getItemById(this._cfg.pumpId); + const pumpMaxRPM = (pump && pump.isActive && pump.maxSpeed > 0) + ? pump.maxSpeed + : DEFAULT_PUMP_MAX_RPM; + + const plan = calcScheduleBlocks(this._cfg.poolConfig, pumpMaxRPM); this._lastPlan = plan; this._logPlan(plan); @@ -150,7 +157,9 @@ export class PumpSchedulerService { this._saveConfig(); await this._ensureFeaturesExistAsync(); - return await this.generateScheduleAsync(); + const plan = await this.generateScheduleAsync(); + if (this._cfg.enabled) await this._ensurePumpCircuitsAsync(plan); + return plan; } catch (err) { logger.error(`PumpSchedulerService updateConfigAsync: ${err.message}`); return Promise.reject(err); @@ -201,8 +210,9 @@ export class PumpSchedulerService { */ private async _ensureFeaturesExistAsync(): Promise { const featureMap = [ - { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, - { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, + { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, + { id: this._cfg.featureIds.medium, name: 'PumpSched-Medium' }, + { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, ]; for (const f of featureMap) { const existing = sys.features.find(feat => feat.id === f.id); @@ -217,6 +227,50 @@ export class PumpSchedulerService { } } + /** + * When the scheduler is first enabled, automatically add the two managed + * Feature circuits to the pump's circuit list (if not already present). + * Does NOT update RPMs on existing entries — preserves user customisation. + */ + private async _ensurePumpCircuitsAsync(plan: SchedulePlan): Promise { + const pump = sys.pumps.getItemById(this._cfg.pumpId); + if (!pump.isActive) { + logger.warn( + `PumpSchedulerService: pump ${this._cfg.pumpId} not found — ` + + `add PumpSched-High and PumpSched-Low circuits manually.` + ); + return; + } + + const existingCircuits: any[] = pump.circuits.get(); + const rpmUnits = sys.board.valueMaps.pumpUnits.getValue('rpm'); + + const toAdd = [ + { circuit: this._cfg.featureIds.high, speed: plan.blocks[0].rpm }, + { circuit: this._cfg.featureIds.medium, speed: plan.blocks[1].rpm }, + { circuit: this._cfg.featureIds.low, speed: plan.blocks[2].rpm }, + ].filter(d => !existingCircuits.find((pc: any) => pc.circuit === d.circuit)); + + if (toAdd.length === 0) return; + + const merged = [ + ...existingCircuits, + ...toAdd.map(d => ({ circuit: d.circuit, speed: d.speed, units: rpmUnits })), + ]; + + try { + await sys.board.pumps.setPumpAsync({ id: this._cfg.pumpId, circuits: merged }); + for (const d of toAdd) { + logger.info( + `PumpSchedulerService: added circuit ${d.circuit} @ ${d.speed} RPM ` + + `to pump ${this._cfg.pumpId}` + ); + } + } catch (err) { + logger.error(`PumpSchedulerService: failed to auto-configure pump circuits: ${err.message}`); + } + } + /** * Write (or update) the three managed schedule entries in sys.schedules. * Uses scheduleType 128 (Repeats) with all-days bitmask 0x7F. @@ -229,12 +283,13 @@ export class PumpSchedulerService { */ private async _writeSchedulesAsync(plan: SchedulePlan): Promise { const maxSched = sys.equipment.maxSchedules; - const ids = this._cfg.scheduleIds; + const ids = this._cfg.scheduleIds; const feats = this._cfg.featureIds; const entries = [ - { id: ids.high, featureId: feats.high, block: plan.blocks[0] }, - { id: ids.low, featureId: feats.low, block: plan.blocks[1] }, + { id: ids.high, featureId: feats.high, block: plan.blocks[0] }, + { id: ids.medium, featureId: feats.medium, block: plan.blocks[1] }, + { id: ids.low, featureId: feats.low, block: plan.blocks[2] }, ]; for (const entry of entries) { diff --git a/defaultConfig.json b/defaultConfig.json index 8c16ffdb..e21d1ab8 100755 --- a/defaultConfig.json +++ b/defaultConfig.json @@ -77,15 +77,14 @@ }, "services": { "pumpScheduler": { - "enabled": true, + "enabled": false, "pumpId": 1, "poolConfig": { "poolVolumeGallons": 20000, - "pipeDiameter": 1.5, - "hasSaltCell": false + "pipeDiameter": 1.5 }, - "featureIds": { "high": 14, "low": 16 }, - "scheduleIds": { "high": 10, "low": 12 } + "featureIds": { "high": 14, "medium": 15, "low": 16 }, + "scheduleIds": { "high": 10, "medium": 11, "low": 12 } } }, "interfaces": { From df5e7d1bf32eb6e4a5f334aed058feb397a3ded3 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 21:50:28 -0400 Subject: [PATCH 14/21] Fix: always run _ensurePumpCircuitsAsync on save, not only when enabled --- controller/services/PumpSchedulerService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 92ff8393..10f4f989 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -158,7 +158,7 @@ export class PumpSchedulerService { await this._ensureFeaturesExistAsync(); const plan = await this.generateScheduleAsync(); - if (this._cfg.enabled) await this._ensurePumpCircuitsAsync(plan); + await this._ensurePumpCircuitsAsync(plan); return plan; } catch (err) { logger.error(`PumpSchedulerService updateConfigAsync: ${err.message}`); From 5f371a61cda459bfcb0e44e555fc12040692891a Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 22:37:37 -0400 Subject: [PATCH 15/21] Fix: auto-assign feature IDs, add heatSource to schedule, return pumps from circuits endpoint --- controller/services/PumpSchedulerService.ts | 44 +++++++++++++++------ defaultConfig.json | 2 +- web/services/config/Config.ts | 5 ++- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 10f4f989..6988525a 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -62,7 +62,7 @@ interface SchedulerConfig { const DEFAULT_SCHEDULER_CFG: SchedulerConfig = { enabled: false, pumpId: 1, - featureIds: { high: 14, medium: 15, low: 16 }, + featureIds: { high: 0, medium: 0, low: 0 }, scheduleIds: { high: 10, medium: 11, low: 12 }, poolConfig: DEFAULT_POOL_CONFIG, }; @@ -209,22 +209,39 @@ export class PumpSchedulerService { * Features are created with showInFeatures: false so they don't clutter the UI. */ private async _ensureFeaturesExistAsync(): Promise { - const featureMap = [ - { id: this._cfg.featureIds.high, name: 'PumpSched-High' }, - { id: this._cfg.featureIds.medium, name: 'PumpSched-Medium' }, - { id: this._cfg.featureIds.low, name: 'PumpSched-Low' }, + const wanted: Array<{ key: 'high' | 'medium' | 'low'; name: string }> = [ + { key: 'high', name: 'PumpSched-High' }, + { key: 'medium', name: 'PumpSched-Medium' }, + { key: 'low', name: 'PumpSched-Low' }, ]; - for (const f of featureMap) { - const existing = sys.features.find(feat => feat.id === f.id); - if (typeof existing === 'undefined' || !existing.isActive) { - try { - await sys.board.features.setFeatureAsync({ id: f.id, name: f.name, showInFeatures: false }); - logger.info(`PumpSchedulerService: created feature ${f.id} (${f.name})`); - } catch (err) { - logger.error(`PumpSchedulerService: could not create feature ${f.id}: ${err.message}`); + let dirty = false; + for (const w of wanted) { + // If we already have a tracked ID, verify it still exists. + const trackedId = this._cfg.featureIds[w.key]; + if (trackedId > 0) { + const existing = sys.features.find(f => f.id === trackedId && f.isActive); + if (existing) continue; + } + // Search by name in case it was created with a different ID. + const byName = sys.features.find(f => f.name === w.name && f.isActive); + if (byName) { + if (this._cfg.featureIds[w.key] !== byName.id) { + this._cfg.featureIds[w.key] = byName.id; + dirty = true; } + continue; + } + // Create it — no id supplied so the board auto-assigns from the valid range. + try { + const feat = await sys.board.features.setFeatureAsync({ name: w.name, showInFeatures: false }); + logger.info(`PumpSchedulerService: created feature ${feat.id} (${w.name})`); + this._cfg.featureIds[w.key] = feat.id; + dirty = true; + } catch (err) { + logger.error(`PumpSchedulerService: could not create feature (${w.name}): ${err.message}`); } } + if (dirty) this._saveConfig(); } /** @@ -315,6 +332,7 @@ export class PumpSchedulerService { endTime, startTimeType: TIME_TYPE_MANUAL, endTimeType: TIME_TYPE_MANUAL, + heatSource: 0, isActive: true, }); logger.verbose( diff --git a/defaultConfig.json b/defaultConfig.json index e21d1ab8..c4788363 100755 --- a/defaultConfig.json +++ b/defaultConfig.json @@ -83,7 +83,7 @@ "poolVolumeGallons": 20000, "pipeDiameter": 1.5 }, - "featureIds": { "high": 14, "medium": 15, "low": 16 }, + "featureIds": { "high": 0, "medium": 0, "low": 0 }, "scheduleIds": { "high": 10, "medium": 11, "low": 12 } } }, diff --git a/web/services/config/Config.ts b/web/services/config/Config.ts index e980cd8b..cf0e8720 100755 --- a/web/services/config/Config.ts +++ b/web/services/config/Config.ts @@ -1335,7 +1335,10 @@ export class ConfigRoute { } catch (err) { next(err); } }); app.get('/config/services/pumpScheduler/circuits', (req, res, next) => { - try { return res.status(200).send(sys.board.circuits.getCircuitReferences(true, true, false, true)); } + try { + const pumps = sys.pumps.filter(p => p.isActive).map(p => ({ id: p.id, name: p.name })); + return res.status(200).send(pumps); + } catch (err) { next(err); } }); } From 1ee2d731f24016d0c5dd6c334b87f04e129b740f Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 22:38:49 -0400 Subject: [PATCH 16/21] Fix: use sys.pumps.get() to get plain array before filter/map --- web/services/config/Config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/services/config/Config.ts b/web/services/config/Config.ts index cf0e8720..ce151d16 100755 --- a/web/services/config/Config.ts +++ b/web/services/config/Config.ts @@ -1336,7 +1336,7 @@ export class ConfigRoute { }); app.get('/config/services/pumpScheduler/circuits', (req, res, next) => { try { - const pumps = sys.pumps.filter(p => p.isActive).map(p => ({ id: p.id, name: p.name })); + const pumps = (sys.pumps.get() as any[]).filter(p => p.isActive).map(p => ({ id: p.id, name: p.name })); return res.status(200).send(pumps); } catch (err) { next(err); } From 01ba27c4c46bc3eb3e888c259216d032ab653459 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 22:40:53 -0400 Subject: [PATCH 17/21] Fix: use board-specific heatSource off value for schedule writes --- controller/services/PumpSchedulerService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 6988525a..28921e7c 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -332,7 +332,7 @@ export class PumpSchedulerService { endTime, startTimeType: TIME_TYPE_MANUAL, endTimeType: TIME_TYPE_MANUAL, - heatSource: 0, + heatSource: sys.board.valueMaps.heatSources.getValue('off'), isActive: true, }); logger.verbose( From 06dd4af64c56c8c364b3ad4a0557949b1fc8ccf2 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 20 May 2026 22:58:38 -0400 Subject: [PATCH 18/21] fix(chem): orpFormula demand used as dose instead of always using maxDosingVolume --- controller/nixie/chemistry/ChemController.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 5b0284b0..70298d18 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -2450,13 +2450,16 @@ export class NixieChemicalORP extends NixieChemical { let time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); let meth = sys.board.valueMaps.chemDosingMethods.getName(this.orp.dosingMethod); // Now that we know our chlorine demand we need to adjust this dose based upon the limits provided in the setup. + // When orpFormula is enabled, demand is the formula result and limits act as caps; otherwise limits are the dose. switch (meth) { case 'time': - time = this.orp.maxDosingTime; - demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); + if (demand <= 0 || time > this.orp.maxDosingTime) { + time = this.orp.maxDosingTime; + demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); + } break; case 'volume': - demand = this.orp.maxDosingVolume; + if (demand <= 0 || demand > this.orp.maxDosingVolume) demand = this.orp.maxDosingVolume; time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); break; case 'volumeTime': @@ -2475,8 +2478,7 @@ export class NixieChemicalORP extends NixieChemical { } logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`); - sorp.demand = sorp.calcDemand(chem); - if (sorp.demand > 0) logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`); + sorp.demand = demand; if (typeof sorp.currentDose === 'undefined') { // We will include this with the dose demand because our limits may reduce it. From d6055007e926c61429502d7e1ae8651e43d42ed5 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Thu, 21 May 2026 07:26:21 -0400 Subject: [PATCH 19/21] fix(chem): singleMixPeriod now blocks dosing while other chem is dosing OR mixing --- controller/nixie/chemistry/ChemController.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 70298d18..6c091224 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -1795,9 +1795,9 @@ export class NixieChemicalPh extends NixieChemical { else if (sph.dailyLimitReached) { await this.cancelDosing(sph, 'daily limit'); } - else if (this.chemController.chem.singleMixPeriod && sph.chemController.orp.dosingStatus === 1) { - // Don't dose pH if ORP is mixing - enforce single mixing period (only when enabled) - await this.cancelDosing(sph, 'orp mixing'); + else if (this.chemController.chem.singleMixPeriod && sph.chemController.orp.dosingStatus !== 2) { + // Don't dose pH if ORP is dosing or mixing - enforce single dose/mix period + await this.cancelDosing(sph, sph.chemController.orp.dosingStatus === 0 ? 'orp dosing' : 'orp mixing'); return; } else if (status === 'monitoring' || status === 'dosing') { @@ -2297,9 +2297,9 @@ export class NixieChemicalORP extends NixieChemical { await this.cancelDosing(sorp, 'ph pump dosing + dose priority'); return; } - else if (chem.singleMixPeriod && sorp.chemController.ph.dosingStatus === 1) { - // Don't dose ORP if pH is mixing - enforce single mixing period (only when enabled) - await this.cancelDosing(sorp, 'ph mixing'); + else if (chem.singleMixPeriod && sorp.chemController.ph.dosingStatus !== 2) { + // Don't dose ORP if pH is dosing or mixing - enforce single dose/mix period + await this.cancelDosing(sorp, sorp.chemController.ph.dosingStatus === 0 ? 'ph dosing' : 'ph mixing'); return; } else if (status === 'monitoring' || status === 'dosing') { From 33704d7583c07a9e93239df96801fe8136534d54 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Fri, 22 May 2026 09:01:51 -0400 Subject: [PATCH 20/21] Fix: calibrate refWatts for 1.5in pipe from actual 291W@2070RPM observation --- controller/services/HydraulicsCalc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts index a307b7fb..e72e102a 100644 --- a/controller/services/HydraulicsCalc.ts +++ b/controller/services/HydraulicsCalc.ts @@ -81,8 +81,8 @@ interface PipeTier { } const PIPE_TIERS: Record = { - '1.5': { maxSafeGPM: 50, refWatts: 900 }, - '2': { maxSafeGPM: 75, refWatts: 1100 }, + '1.5': { maxSafeGPM: 50, refWatts: 1350 }, // calibrated: 291W observed at 2070 RPM → ~1350W at 3450 RPM + '2': { maxSafeGPM: 75, refWatts: 1650 }, }; // ─── Scheduling policy (hardcoded — not user-configurable) ──────────────────── From 387da7469f424a78521c065a788af5ee82ec389e Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Fri, 22 May 2026 09:03:49 -0400 Subject: [PATCH 21/21] Remove estimatedWatts from scheduler (unreliable affinity-law estimate) --- controller/services/HydraulicsCalc.ts | 11 +++-------- controller/services/PumpSchedulerService.ts | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts index e72e102a..ac7af4ad 100644 --- a/controller/services/HydraulicsCalc.ts +++ b/controller/services/HydraulicsCalc.ts @@ -38,7 +38,6 @@ export interface ScheduleBlock { startMinutes: number; // Minutes from midnight (0–1439) endMinutes: number; gallons: number; - estimatedWatts: number; } export interface SchedulePlan { @@ -77,12 +76,11 @@ export function minutesToTime(minutes: number): string { interface PipeTier { maxSafeGPM: number; // Velocity-safe flow ceiling (≤ 5 ft/s rule) - refWatts: number; // Estimated draw at pump max RPM (approximation) } const PIPE_TIERS: Record = { - '1.5': { maxSafeGPM: 50, refWatts: 1350 }, // calibrated: 291W observed at 2070 RPM → ~1350W at 3450 RPM - '2': { maxSafeGPM: 75, refWatts: 1650 }, + '1.5': { maxSafeGPM: 50 }, + '2': { maxSafeGPM: 75 }, }; // ─── Scheduling policy (hardcoded — not user-configurable) ──────────────────── @@ -121,7 +119,7 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig, pumpMaxRPM: number): S const pipe = PIPE_TIERS[String(cfg.pipeDiameter)]; if (!pipe) throw new Error(`Unknown pipe diameter: ${cfg.pipeDiameter}`); - const { maxSafeGPM, refWatts } = pipe; + const { maxSafeGPM } = pipe; // Dynamic reference curve: at pumpMaxRPM the pump delivers maxSafeGPM. // All RPM ↔ GPM conversions use this calibration anchor. const refRPM = pumpMaxRPM; @@ -178,7 +176,6 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig, pumpMaxRPM: number): S startMinutes: highStart, endMinutes: highEnd, gallons: highGals, - estimatedWatts: Math.round(affinityPower(refWatts, refRPM, highRPM)), }; const mediumBlock: ScheduleBlock = { @@ -189,7 +186,6 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig, pumpMaxRPM: number): S startMinutes: mediumStart, endMinutes: mediumEnd, gallons: mediumGals, - estimatedWatts: Math.round(affinityPower(refWatts, refRPM, mediumRPM)), }; const lowBlock: ScheduleBlock = { @@ -200,7 +196,6 @@ export function calcScheduleBlocks(cfg: SimplePoolConfig, pumpMaxRPM: number): S startMinutes: lowStart, endMinutes: lowEnd, gallons: Math.round(lowGPM * lowDurationHours * 60), - estimatedWatts: Math.round(affinityPower(refWatts, refRPM, lowRPM)), }; const totalGallons = highBlock.gallons + mediumBlock.gallons + lowBlock.gallons; diff --git a/controller/services/PumpSchedulerService.ts b/controller/services/PumpSchedulerService.ts index 28921e7c..bfa8decb 100644 --- a/controller/services/PumpSchedulerService.ts +++ b/controller/services/PumpSchedulerService.ts @@ -381,7 +381,7 @@ export class PumpSchedulerService { for (const b of plan.blocks) { logger.info( ` [${b.phase.padEnd(6)}] ${minutesToTime(b.startMinutes)}–${minutesToTime(b.endMinutes)} ` + - `${b.rpm} RPM ${b.gpm} GPM ~${b.estimatedWatts} W ${b.gallons.toLocaleString()} gal` + `${b.rpm} RPM ${b.gpm} GPM ${b.gallons.toLocaleString()} gal` ); } }