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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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` ); } } From b4e0872c02e54bfee51cf867e9b89501af97cf81 Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Tue, 26 May 2026 20:16:42 -0400 Subject: [PATCH 22/24] fix: acid dose cancels mid-dose when probe bounces at setpoint boundary When actively dosing (dosingStatus=0, currentDose defined), skip demand re-evaluation. The pH probe can read at or slightly below setpoint right after acid injection starts, causing calcDemand to return 0 and the dose to be cancelled after only ~10s (~10.64mL). Safety cancels (body off, freeze, no flow) still fire. Let pump.dose() run to natural completion via volumeRemaining/timeRemaining tracking. --- controller/nixie/chemistry/ChemController.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 6c091224..9cd915cd 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -1810,6 +1810,12 @@ export class NixieChemicalPh extends NixieChemical { await this.cancelDosing(sph, 'freeze'); else if (!sph.chemController.flowDetected) await this.cancelDosing(sph, 'no flow'); + else if (sph.dosingStatus === 0 && typeof sph.currentDose !== 'undefined') { + // Mid-dose: do not re-evaluate demand (probe can bounce at setpoint boundary). + // Let pump.dose() run to completion via volumeRemaining/timeRemaining. + if (sph.tank.level > 0) await this.pump.dose(sph); + else await this.cancelDosing(sph, 'empty tank'); + } else if (demand <= 0) await this.cancelDosing(sph, 'setpoint reached'); else if (demand > 0) { From ebf7633800f8a5fcf74fca67b53c2fc64c6c47ce Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Tue, 26 May 2026 20:36:00 -0400 Subject: [PATCH 23/24] refactor: extract shared dosing state machine to NixieChemical base class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add applyDosingLimits(): caps demand by configured method (time/volume/volumeTime) - Add calcDoseForDemand(): abstract-style override per chemical; pH and ORP each implement - Add doAutoDosing(): shared kernel — safety guards, mid-dose continuation, new dose start - NixieChemicalPh.checkDosing: 180 lines -> 65 lines; delegates to doAutoDosing() - NixieChemicalORP.checkDosing: 180 lines -> 60 lines; delegates to doAutoDosing() or checkChlorDosing() - Extract ORP chlorinator path to checkChlorDosing() (logic unchanged, pure code move) - Mid-dose guard is now structural in doAutoDosing() rather than a patch in checkDosing() - No behavior changes; all ORP-specific limit fallbacks preserved exactly --- controller/nixie/chemistry/ChemController.ts | 574 +++++++++---------- 1 file changed, 270 insertions(+), 304 deletions(-) diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 9cd915cd..2426ebd7 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -1142,6 +1142,78 @@ class NixieChemical extends NixieChildEquipment implements INixieChemical { } } catch (err) { logger.error(`cancelDosing: ${err.message}`); return Promise.reject(err); } } + // Caps a raw demand value by the configured dosing method limits (time, volume, or volumeTime). + protected applyDosingLimits(method: string, demand: number, ratedFlow: number, maxVolume: number, maxTime: number): { dose: number, time: number } { + let dose = demand; + let time = typeof ratedFlow === 'undefined' || ratedFlow <= 0 ? 0 : Math.round(dose / (ratedFlow / 60)); + switch (method) { + case 'time': + if (time > maxTime) { + time = maxTime; + dose = typeof ratedFlow === 'undefined' ? 0 : Math.round(time * (ratedFlow / 60)); + } + break; + case 'volume': + if (dose > maxVolume) { + dose = maxVolume; + time = typeof ratedFlow === 'undefined' || ratedFlow <= 0 ? 0 : Math.round(dose / (ratedFlow / 60)); + } + break; + case 'volumeTime': + default: + if (dose > maxVolume) { + dose = maxVolume; + time = typeof ratedFlow === 'undefined' || ratedFlow <= 0 ? 0 : Math.round(dose / (ratedFlow / 60)); + } + if (time > maxTime) { + time = maxTime; + dose = typeof ratedFlow === 'undefined' ? 0 : Math.round(time * (ratedFlow / 60)); + } + break; + } + return { dose, time }; + } + // Subclasses implement this to calculate the capped dose for a new auto dose cycle. + // Returns null when demand is at or below setpoint (nothing to dose). + protected calcDoseForDemand(schem: ChemicalState): { dose: number, time: number } | null { return null; } + // Shared auto-dosing kernel called from checkDosing after all non-safety guards have passed. + // Handles: safety guards (body/freeze/flow), mid-dose continuation, new dose start. + protected async doAutoDosing(schem: ChemicalState): Promise { + // Safety guards — cancel even if a dose is currently running. + if (!schem.chemController.isBodyOn) { await this.cancelDosing(schem, 'body off'); return; } + if (schem.freezeProtect) { await this.cancelDosing(schem, 'freeze'); return; } + if (!schem.chemController.flowDetected) { await this.cancelDosing(schem, 'no flow'); return; } + // Mid-dose: do not re-evaluate demand (probe can bounce at setpoint boundary). + // Let pump.dose() run to completion via volumeRemaining/timeRemaining. + if (schem.dosingStatus === 0 && typeof schem.currentDose !== 'undefined') { + if (schem.tank.level > 0) await this.pump.dose(schem); + else await this.cancelDosing(schem, 'empty tank'); + return; + } + // No active dose: calculate demand and either start a new dose or cancel. + let result = this.calcDoseForDemand(schem); + if (result === null) { await this.cancelDosing(schem, 'setpoint reached'); return; } + let { dose, time } = result; + if (typeof schem.currentDose === 'undefined' && schem.tank.level > 0) { + if (schem.dosingStatus === 0) { + // Resume a dose interrupted by njspc restart. + logger.info(`Continuing a previous ${schem.chemType} dose ${schem.doseVolume}mL`); + schem.startDose(new Timestamp().addSeconds(-schem.doseTime).toDate(), 'auto', schem.doseVolume + schem.dosingVolumeRemaining, schem.doseVolume, (schem.doseTime + schem.dosingTimeRemaining) * 1000, schem.doseTime * 1000); + } + else { + logger.info(`Starting a new ${schem.chemType} dose ${dose}mL`); + schem.startDose(new Date(), 'auto', dose, 0, time, 0); + } + } + if (schem.tank.level > 0) { + logger.verbose(`Chem ${schem.chemType} dose activate pump ${this.pump.pump.ratedFlow}mL/min`); + await this.pump.dose(schem); + } + else { + logger.warn(`Chem ${schem.chemType} NOT dosed because tank level is ${schem.tank.level}.`); + await this.cancelDosing(schem, 'empty tank'); + } + } } export class NixieChemTank extends NixieChildEquipment { public tank: ChemicalTank; @@ -1721,24 +1793,28 @@ export class NixieChemicalPh extends NixieChemical { } catch (err) { logger.error(`chemController setPhAysnc.: ${err.message}`); return Promise.reject(err); } } + protected calcDoseForDemand(schem: ChemicalState): { dose: number, time: number } | null { + let sph = schem as ChemicalPhState; + let demand = sph.calcDemand(this.chemController.chem); + if (demand <= 0) return null; + let pump = this.pump.pump; + // Cap by the remaining daily budget first, then apply dosing method limits. + let dose = Math.max(0, Math.min(this.chemical.maxDailyVolume - sph.dailyVolumeDosed, demand)); + let meth = sys.board.valueMaps.chemDosingMethods.getName(this.ph.dosingMethod); + logger.info(`Chem acid demand calculated ${demand}mL for ${utils.formatDuration(typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(dose / (pump.ratedFlow / 60)))} Tank Level: ${sph.tank.level}`); + let result = this.applyDosingLimits(meth, dose, pump.ratedFlow, this.ph.maxDosingVolume, this.ph.maxDosingTime); + logger.verbose(`Chem acid dosing maximums applied ${result.dose}mL for ${utils.formatDuration(result.time)}`); + return result; + } public async checkDosing(chem: ChemController, sph: ChemicalPhState) { try { let status = sys.board.valueMaps.chemControllerDosingStatus.getName(sph.dosingStatus); logger.debug(`Begin check ${sph.chemType} dosing status = ${status}`); - let demand = sph.calcDemand(chem); - sph.demand = Math.max(demand, 0); - if (!chem.ph.enabled) { - await this.cancelDosing(sph, 'disabled'); - return; - } - if (sph.suspendDosing) { - // Kill off the dosing and make sure the pump isn't running. Let's force the issue here. - await this.cancelDosing(sph, 'suspended'); - return; - } + sph.demand = Math.max(0, sph.calcDemand(chem)); + if (!chem.ph.enabled) { await this.cancelDosing(sph, 'disabled'); return; } + if (sph.suspendDosing) { await this.cancelDosing(sph, 'suspended'); return; } if (status === 'monitoring') { - // Alright our mixing and dosing have either been cancelled or we fininsed a mixing cycle. Either way - // let the system clean these up. + // Alright our mixing and dosing have either been cancelled or we finished a mixing cycle. if (typeof sph.currentDose !== 'undefined') logger.error('Somehow we made it to monitoring and still have a current dose'); sph.currentDose = undefined; sph.manualDosing = false; @@ -1747,9 +1823,7 @@ export class NixieChemicalPh extends NixieChemical { if (typeof this.currentMix !== 'undefined') { if (ncp.chemControllers.length > 1) { let arrIds = []; - for (let i = 0; i < ncp.chemControllers.length; i++) { - arrIds.push(ncp[i].id); - } + for (let i = 0; i < ncp.chemControllers.length; i++) { arrIds.push(ncp[i].id); } logger.info(`More than one NixieChemController object was found ${JSON.stringify(arrIds)}`); } logger.debug(`We are now monitoring and have a mixing object`); @@ -1760,15 +1834,6 @@ export class NixieChemicalPh extends NixieChemical { if (status === 'mixing') { await this.cancelDosing(sph, 'mixing'); if (typeof this.currentMix === 'undefined') { - // First lets check to see how many chem controllers we have. - // RKS: Keep this case around in case there is another Moby Dick and Nixie has an orphan out there. - //if (ncp.chemControllers.length > 1) { - // let arrIds = []; - // for (let i = 0; i < ncp.chemControllers.length; i++) { - // arrIds.push(ncp[i].id); - // } - // logger.info(`More than one NixieChemController object was found ${JSON.stringify(arrIds)}`); - //} logger.info(`Current ${sph.chemType} mix object not defined initializing mix`); await this.mixChemicals(sph); } @@ -1776,8 +1841,7 @@ export class NixieChemicalPh extends NixieChemical { else if (sph.manualDosing) { // We are manually dosing. We are not going to dynamically change the dose. if (typeof sph.currentDose === 'undefined') { - // This will only happen when njspc is killed in the middle of a dose. Unlike IntelliChem we will pick that back up. - // Unfortunately we will lose the original start date but who cares as the volumes should remain the same. + // This will only happen when njspc is killed in the middle of a dose. let volume = sph.volumeDosed + sph.dosingVolumeRemaining; let time = sph.timeDosed + sph.dosingTimeRemaining; sph.startDose(new Timestamp().addSeconds(-sph.doseTime).toDate(), 'manual', volume, sph.dosingVolumeRemaining, time * 1000, sph.doseTime * 1000); @@ -1792,93 +1856,15 @@ export class NixieChemicalPh extends NixieChemical { else await this.cancelDosing(sph, 'empty tank'); } } - else if (sph.dailyLimitReached) { + else if (sph.dailyLimitReached) await this.cancelDosing(sph, 'daily limit'); - } 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 + // 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') { - // 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. - if (!sph.chemController.isBodyOn) - await this.cancelDosing(sph, 'body off'); - else if (sph.freezeProtect) - await this.cancelDosing(sph, 'freeze'); - else if (!sph.chemController.flowDetected) - await this.cancelDosing(sph, 'no flow'); - else if (sph.dosingStatus === 0 && typeof sph.currentDose !== 'undefined') { - // Mid-dose: do not re-evaluate demand (probe can bounce at setpoint boundary). - // Let pump.dose() run to completion via volumeRemaining/timeRemaining. - if (sph.tank.level > 0) await this.pump.dose(sph); - else await this.cancelDosing(sph, 'empty tank'); - } - else if (demand <= 0) - await this.cancelDosing(sph, 'setpoint reached'); - else if (demand > 0) { - let pump = this.pump.pump; - let dose = Math.max(0, Math.min(this.chemical.maxDailyVolume - sph.dailyVolumeDosed, demand)); - let time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(dose / (pump.ratedFlow / 60)); - let meth = sys.board.valueMaps.chemDosingMethods.getName(this.ph.dosingMethod); - logger.info(`Chem acid demand calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sph.tank.level}`); - // Now that we know our acid demand we need to adjust this dose based upon the limits provided in the setup. - switch (meth) { - case 'time': - if (time > this.ph.maxDosingTime) { - time = this.ph.maxDosingTime; - dose = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); - } - break; - case 'volume': - if (dose > this.ph.maxDosingVolume) { - dose = this.ph.maxDosingVolume; - time = time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(dose / (pump.ratedFlow / 60)); - } - break; - case 'volumeTime': - default: - // This is maybe a bit dumb as the volume and time should equal out for the rated flow. In other words - // you will never get to the volume limit if the rated flow can't keep up to the time. - if (dose > this.ph.maxDosingVolume) { - dose = this.ph.maxDosingVolume; - time = time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(dose / (pump.ratedFlow / 60)); - } - if (time > this.ph.maxDosingTime) { - time = this.ph.maxDosingTime; - dose = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); - } - break; - } - logger.verbose(`Chem acid dosing maximums applied ${dose}mL for ${utils.formatDuration(time)}`); - if (typeof sph.currentDose === 'undefined' && sph.tank.level > 0) { - // We will include this with the dose demand because our limits may reduce it. - //dosage.demand = demand; - if (sph.dosingStatus === 0) { // 0 is dosing. - // We need to finish off a dose that was interrupted by regular programming. This occurs - // when for instance njspc is interrupted and restarted in the middle of a dose. If we were - // mixing before we will never get here. - logger.info(`Continuing a previous new acid dose ${sph.doseVolume}mL`); - sph.startDose(new Timestamp().addSeconds(-sph.doseTime).toDate(), 'auto', sph.doseVolume + sph.dosingVolumeRemaining, sph.doseVolume, (sph.doseTime + sph.dosingTimeRemaining) * 1000, sph.doseTime * 1000); - } - else { - logger.info(`Starting a new acid dose ${dose}mL`); - sph.startDose(new Date(), 'auto', dose, 0, time, 0); - } - } - // Now let's determine what we need to do with our pump to satisfy our acid demand. - if (sph.tank.level > 0) { - logger.verbose(`Chem acid dose activate pump ${this.pump.pump.ratedFlow}mL/min`); - await this.pump.dose(sph); - } - else { - logger.warn(`Chem acid NOT dosed because tank level is level ${sph.tank.level}.`); - await this.cancelDosing(sph, 'empty tank'); - } - } - } + else if (status === 'monitoring' || status === 'dosing') + await this.doAutoDosing(sph); } catch (err) { logger.error(`Error checking for dosing: ${err.message}`); return Promise.reject(err); } finally { @@ -2309,202 +2295,11 @@ export class NixieChemicalORP extends NixieChemical { return; } else if (status === 'monitoring' || status === 'dosing') { - // let _doseCalculatedSec = 0; if (!sorp.lockout) { - - // 1. Get the total gallons of water that the chem controller is in control of. - let totalGallons = 0; - let body1 = sys.bodies.getItemById(1); - let body2 = sys.bodies.getItemById(2); - if (chem.body === 0 || chem.body === 32) totalGallons += body1.capacity; - if (chem.body === 1 || chem.body === 32) totalGallons += body2.capacity; - if (chem.body === 2) totalGallons += sys.bodies.getItemById(3).capacity; - if (chem.body === 3) totalGallons += sys.bodies.getItemById(4).capacity; - - if (chem.orp.useChlorinator) { - /* - Alright, here's the current thinking. - 1. If the orp setpoint is > 50mV below the current orp, the chlor will - be run at 100%. - 2. At the other end, if the demand is < -20mV above the setpoint, chlor - will be run at 0%. - 3. This assumes a sliding scale where we will have an equilibrium point when - setpoint = current orp and hopefully this will be somewhere near (50-20 / 100) = ~30% of time the chlor is on - - Thoughts from @rstrouste - Volume -- Check - Delivery Rate -- IC40, IC60, IC20 and IC30 all have different production rates in pounds/day. The pounds are Sodium Hypochlorite which translates into Hypochlorous acid (HOCl) + Hypochlorite (OCI-). The former is stronger and the amount of this that is produced is based upon, temperature, pH, and CYA with pH within range being irrelevant (hence the reason for pH lockout). - - - Additional future factors to consider- - * If temp is below 65(?), the chlor won't be producing any chlorine. Throw a warning/error? - * If salt level is too low/high it will cause issues. Warning/error? - * Adjust chlor output if it is under/oversized for the total gallons - */ - // if we are still mixing, return - if (typeof sorp.mixTimeRemaining !== 'undefined' && sorp.mixTimeRemaining > 0) { - await this.cancelDosing(sorp, 'still mixing'); - return; - } - // Old fashion method; let the setpoints on chlor be the master - // if (chem.orp.chlorDosingMethod === 0) return; - // if there is a current pending dose, finish it out - if (typeof sorp.currentDose === 'undefined') { - if (sorp.dosingStatus === 0) { // 0 is dosing - // We need to finish off a dose that was interrupted by regular programming. This occurs - // when for instance njspc is interrupted and restarted in the middle of a dose. If we were - // mixing before we will never get here. - if (typeof sorp.currentDose === 'undefined') - sorp.startDose(new Timestamp().addSeconds(-sorp.doseTime).toDate(), 'auto', sorp.doseVolume + sorp.dosingVolumeRemaining, sorp.doseVolume, (sorp.doseTime + sorp.dosingTimeRemaining) * 1000, sorp.doseTime * 1000); - await this.chlor.dose(sorp); - return; - } - } - let chlor = this.chlor.chlor; // Still haven't seen any systems with 2+ chlors. - // 2024.12.25 RSG - Oh really? See https://github.com/tagyoureit/nodejs-poolController/discussions/896 - let schlor = state.chlorinators.getItemById(chlor.id); - // If someone or something is superchloring the pool, let it be - if (schlor.superChlor) return; - // Let's have some fun trying to figure out a dynamic approach to chlor management - let body = sys.board.bodies.getBodyState(this.chemController.chem.body); - let adj = 1; - if (typeof body !== 'undefined' && totalGallons > 0 && sys.bodies.length > 1) { - // Intellichem scales down dosing based on the spa being on - // vs the pool. May be interesting to experiment with. - let type = sys.board.valueMaps.bodyTypes.getName(body.type); - switch (type) { - case "pool": - case "pool/spa": - // normal dosing - break; - default: - // case "spa": - // adjust dosing down to the amount of the smaller body - adj -= Math.abs((body1.capacity - body2.capacity) / Math.max(body1.capacity, body2.capacity)); - break; - } - } - let model = sys.board.valueMaps.chlorinatorModel.findItem(chlor.model); - if (typeof model === 'undefined' || model === 0) return Promise.reject(new EquipmentNotFoundError(`Please specify a chlorinator model to allow Nixie to calculate chlorine demand`, `chlorinator`)); - // if we want to adjust for over/under sized chlorinator we can do so here - // if (typeof model !== 'undefined' && model.capacity > 0) { - // adj *= totalGallons / model.capacity; - // } - - // unlike ph/orp tank dosing, we are using 15 min intervals so if there is an existing dose then continue - if (typeof sorp.currentDose !== 'undefined' && sorp.currentDose.volumeRemaining > 0) { - await this.chlor.dose(sorp); - return; - } - - // We could store these data points in a separate file like the dosing logs. - let percentOfTime = 0; - if (sorp.demand > 50) { - logger.info(`Chlor demand ${sorp.demand} > 50; % of time set to 100%.`); - percentOfTime = 1; - } - else if (sorp.demand < -20) { - await this.cancelDosing(sorp, 'demand < -20'); - } - else { - // y=mx+b; m = 100/70; b = 100-(50*100/70) = 28.57 - // let's start with a straight line - let b = 100 - (50 * 100 / 70); - percentOfTime = ((100 / 70) * sorp.demand * adj + b) / 100; - logger.info(`Chlor trend line is ${sorp.demandHistory.slope}.`); - if (sorp.demandHistory.slope > 5 && sorp.demand < 0) { - // need less chlorine, but we're getting there too fast - // slope is high; turn down dose - percentOfTime *= .5; - } - else if (sorp.demandHistory.slope < 5 && sorp.demand > 0) { - // need more chlorine, but we aren't getting there fast enough - // slope is too low, turn up dose - percentOfTime *= 1.1; - } - else if (sorp.demandHistory.slope > 0 && sorp.demand > 0) { - // chlorine is increasing, but we need less of it - percentOfTime *= .5; - } - else if (sorp.demandHistory.slope < 0 && sorp.demand > 0) { - // chlorine is decreasing, but we need more - percentOfTime *= 1.1; - } - percentOfTime = Math.min(1, Math.max(0, percentOfTime)); - logger.info(`Chlor dosing % of time is ${Math.round(percentOfTime * 10000) / 100}%`) - } - - // convert the % of time back to an amount of chlorine over 15 minutes; - let time = this.chlor.chlorInterval * 60 * percentOfTime; - let dose = model.chlorinePerSec * time; - - if (dose > 0) { - logger.info(`Chem chlor calculated dosing at ${Math.round(percentOfTime * 10000) / 100}% and will dose ${Math.round(dose * 1000000) / 1000000}Lbs of chlorine over the next ${utils.formatDuration(time)}.`) - sorp.startDose(new Date(), 'auto', dose, 0, time, 0); - await this.chlor.dose(sorp); - return; - } - - // if none of the other conditions are true, mix - // await this.mixChemicals(sorp, this.chlor.chlorInterval * 60); - - } - 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 = 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. - // When orpFormula is enabled, demand is the formula result and limits act as caps; otherwise limits are the dose. - switch (meth) { - case 'time': - 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': - 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': - default: - // This is maybe a bit dumb as the volume and time should equal out for the rated flow. In other words - // you will never get to the volume limit if the rated flow can't keep up to the time. - if (demand > this.orp.maxDosingVolume) { - demand = this.orp.maxDosingVolume; - time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); - } - if (time > this.orp.maxDosingTime) { - time = this.orp.maxDosingTime; - demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); - } - break; - } - 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. - //dosage.demand = demand; - if (sorp.dosingStatus === 0) { // 0 is dosing. - // We need to finish off a dose that was interrupted by regular programming. This occurs - // when for instance njspc is interrupted and restarted in the middle of a dose. If we were - // mixing before we will never get here. - if (typeof sorp.currentDose === 'undefined') - sorp.startDose(new Timestamp().addSeconds(-sorp.doseTime).toDate(), 'auto', sorp.doseVolume + sorp.dosingVolumeRemaining, sorp.doseVolume, (sorp.doseTime + sorp.dosingTimeRemaining) * 1000, sorp.doseTime * 1000); - } - else - sorp.startDose(new Date(), 'auto', demand, 0, time, 0); - } - // Now let's determine what we need to do with our pump to satisfy our acid demand. - if (sorp.tank.level > 0) { - await this.pump.dose(sorp); - } - else await this.cancelDosing(sorp, 'empty tank'); - } + if (chem.orp.useChlorinator) + await this.checkChlorDosing(chem, sorp); + else if (this.orp.setpoint > sorp.level) + await this.doAutoDosing(sorp); } else await this.cancelDosing(sorp, 'unknown cancel'); @@ -2512,6 +2307,177 @@ export class NixieChemicalORP extends NixieChemical { } catch (err) { logger.error(`checkDosing ORP: ${err.message}`); return Promise.reject(err); } } + // Extracted chlorinator dosing path — trend-line math, 15-min intervals, chlor.dose(). + private async checkChlorDosing(chem: ChemController, sorp: ChemicalORPState): Promise { + let totalGallons = 0; + let body1 = sys.bodies.getItemById(1); + let body2 = sys.bodies.getItemById(2); + if (chem.body === 0 || chem.body === 32) totalGallons += body1.capacity; + if (chem.body === 1 || chem.body === 32) totalGallons += body2.capacity; + if (chem.body === 2) totalGallons += sys.bodies.getItemById(3).capacity; + if (chem.body === 3) totalGallons += sys.bodies.getItemById(4).capacity; + /* +Alright, here's the current thinking. +1. If the orp setpoint is > 50mV below the current orp, the chlor will +be run at 100%. +2. At the other end, if the demand is < -20mV above the setpoint, chlor +will be run at 0%. +3. This assumes a sliding scale where we will have an equilibrium point when +setpoint = current orp and hopefully this will be somewhere near (50-20 / 100) = ~30% of time the chlor is on + +Thoughts from @rstrouste +Volume -- Check +Delivery Rate -- IC40, IC60, IC20 and IC30 all have different production rates in pounds/day. The pounds are Sodium Hypochlorite which translates into Hypochlorous acid (HOCl) + Hypochlorite (OCI-). The former is stronger and the amount of this that is produced is based upon, temperature, pH, and CYA with pH within range being irrelevant (hence the reason for pH lockout). + + +Additional future factors to consider- +* If temp is below 65(?), the chlor won't be producing any chlorine. Throw a warning/error? +* If salt level is too low/high it will cause issues. Warning/error? +* Adjust chlor output if it is under/oversized for the total gallons +*/ + // if we are still mixing, return + if (typeof sorp.mixTimeRemaining !== 'undefined' && sorp.mixTimeRemaining > 0) { + await this.cancelDosing(sorp, 'still mixing'); + return; + } + // Old fashion method; let the setpoints on chlor be the master + // if (chem.orp.chlorDosingMethod === 0) return; + // if there is a current pending dose, finish it out + if (typeof sorp.currentDose === 'undefined') { + if (sorp.dosingStatus === 0) { // 0 is dosing + // We need to finish off a dose that was interrupted by regular programming. This occurs + // when for instance njspc is interrupted and restarted in the middle of a dose. If we were + // mixing before we will never get here. + if (typeof sorp.currentDose === 'undefined') + sorp.startDose(new Timestamp().addSeconds(-sorp.doseTime).toDate(), 'auto', sorp.doseVolume + sorp.dosingVolumeRemaining, sorp.doseVolume, (sorp.doseTime + sorp.dosingTimeRemaining) * 1000, sorp.doseTime * 1000); + await this.chlor.dose(sorp); + return; + } + } + let chlor = this.chlor.chlor; // Still haven't seen any systems with 2+ chlors. + // 2024.12.25 RSG - Oh really? See https://github.com/tagyoureit/nodejs-poolController/discussions/896 + let schlor = state.chlorinators.getItemById(chlor.id); + // If someone or something is superchloring the pool, let it be + if (schlor.superChlor) return; + // Let's have some fun trying to figure out a dynamic approach to chlor management + let body = sys.board.bodies.getBodyState(this.chemController.chem.body); + let adj = 1; + if (typeof body !== 'undefined' && totalGallons > 0 && sys.bodies.length > 1) { + // Intellichem scales down dosing based on the spa being on + // vs the pool. May be interesting to experiment with. + let type = sys.board.valueMaps.bodyTypes.getName(body.type); + switch (type) { + case "pool": + case "pool/spa": + // normal dosing + break; + default: + // case "spa": + // adjust dosing down to the amount of the smaller body + adj -= Math.abs((body1.capacity - body2.capacity) / Math.max(body1.capacity, body2.capacity)); + break; + } + } + let model = sys.board.valueMaps.chlorinatorModel.findItem(chlor.model); + if (typeof model === 'undefined' || model === 0) return Promise.reject(new EquipmentNotFoundError(`Please specify a chlorinator model to allow Nixie to calculate chlorine demand`, `chlorinator`)); + // if we want to adjust for over/under sized chlorinator we can do so here + // if (typeof model !== 'undefined' && model.capacity > 0) { + // adj *= totalGallons / model.capacity; + // } + + // unlike ph/orp tank dosing, we are using 15 min intervals so if there is an existing dose then continue + if (typeof sorp.currentDose !== 'undefined' && sorp.currentDose.volumeRemaining > 0) { + await this.chlor.dose(sorp); + return; + } + + // We could store these data points in a separate file like the dosing logs. + let percentOfTime = 0; + if (sorp.demand > 50) { + logger.info(`Chlor demand ${sorp.demand} > 50; % of time set to 100%.`); + percentOfTime = 1; + } + else if (sorp.demand < -20) { + await this.cancelDosing(sorp, 'demand < -20'); + } + else { + // y=mx+b; m = 100/70; b = 100-(50*100/70) = 28.57 + // let's start with a straight line + let b = 100 - (50 * 100 / 70); + percentOfTime = ((100 / 70) * sorp.demand * adj + b) / 100; + logger.info(`Chlor trend line is ${sorp.demandHistory.slope}.`); + if (sorp.demandHistory.slope > 5 && sorp.demand < 0) { + // need less chlorine, but we're getting there too fast + // slope is high; turn down dose + percentOfTime *= .5; + } + else if (sorp.demandHistory.slope < 5 && sorp.demand > 0) { + // need more chlorine, but we aren't getting there fast enough + // slope is too low, turn up dose + percentOfTime *= 1.1; + } + else if (sorp.demandHistory.slope > 0 && sorp.demand > 0) { + // chlorine is increasing, but we need less of it + percentOfTime *= .5; + } + else if (sorp.demandHistory.slope < 0 && sorp.demand > 0) { + // chlorine is decreasing, but we need more + percentOfTime *= 1.1; + } + percentOfTime = Math.min(1, Math.max(0, percentOfTime)); + logger.info(`Chlor dosing % of time is ${Math.round(percentOfTime * 10000) / 100}%`) + } + + // convert the % of time back to an amount of chlorine over 15 minutes; + let time = this.chlor.chlorInterval * 60 * percentOfTime; + let dose = model.chlorinePerSec * time; + + if (dose > 0) { + logger.info(`Chem chlor calculated dosing at ${Math.round(percentOfTime * 10000) / 100}% and will dose ${Math.round(dose * 1000000) / 1000000}Lbs of chlorine over the next ${utils.formatDuration(time)}.`) + sorp.startDose(new Date(), 'auto', dose, 0, time, 0); + await this.chlor.dose(sorp); + return; + } + + // if none of the other conditions are true, mix + // await this.mixChemicals(sorp, this.chlor.chlorInterval * 60); + } + protected calcDoseForDemand(schem: ChemicalState): { dose: number, time: number } | null { + let sorp = schem as ChemicalORPState; + if (this.orp.setpoint <= sorp.level) return null; + let pump = this.pump.pump; + let demand = sorp.calcDemand(this.chemController.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); + // Preserve original ORP limit logic including the demand<=0 fallback for time/volume methods. + switch (meth) { + case 'time': + 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': + 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': + default: + if (demand > this.orp.maxDosingVolume) { + demand = this.orp.maxDosingVolume; + time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); + } + if (time > this.orp.maxDosingTime) { + time = this.orp.maxDosingTime; + demand = typeof pump.ratedFlow === 'undefined' ? 0 : Math.round(time * (this.pump.pump.ratedFlow / 60)); + } + break; + } + logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`); + sorp.demand = demand; + if (demand <= 0 && time <= 0) return null; + return { dose: demand, time }; + } public async deleteChlorAsync(chlor: NixieChlorinator) { logger.info(`Removing chlor ${chlor.id} from Chem Controller ${this.getParent().id}`); let schem = state.chemControllers.getItemById(this.getParent().id); From 0a18d5f6bcb4f55be22f9b53b13b59fa77cfdcac Mon Sep 17 00:00:00 2001 From: Joseph <22131916+camaro4life18@users.noreply.github.com> Date: Wed, 27 May 2026 09:07:56 -0400 Subject: [PATCH 24/24] feat: add minDosingVolume floor for ORP; clamp ORP formula pH to setpoint when acidic --- controller/Equipment.ts | 3 +++ controller/State.ts | 8 ++++++-- controller/nixie/chemistry/ChemController.ts | 9 +++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/controller/Equipment.ts b/controller/Equipment.ts index f1d81b38..8c69435e 100644 --- a/controller/Equipment.ts +++ b/controller/Equipment.ts @@ -2437,6 +2437,7 @@ export interface IChemical { startDelay: number; maxDosingTime?: number; maxDosingVolume?: number; + minDosingVolume?: number; } export class Chemical extends ChildEqItem implements IChemical { public dataName = 'chemicalConfig'; @@ -2461,6 +2462,8 @@ export class Chemical extends ChildEqItem implements IChemical { public set maxDosingTime(val: number) { this.setDataVal('maxDosingTime', val); } public get maxDosingVolume(): number { return this.data.maxDosingVolume; } public set maxDosingVolume(val: number) { this.setDataVal('maxDosingVolume', val); } + public get minDosingVolume(): number { return this.data.minDosingVolume || 0; } + public set minDosingVolume(val: number) { this.setDataVal('minDosingVolume', val); } public get maxDailyVolume(): number { return this.data.maxDailyVolume; } public set maxDailyVolume(val: number) { this.setDataVal('maxDailyVolume', val); } diff --git a/controller/State.ts b/controller/State.ts index cfb5d6ce..ee31a40b 100644 --- a/controller/State.ts +++ b/controller/State.ts @@ -3214,8 +3214,12 @@ export class ChemicalORPState extends ChemicalState { 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; + // Use live pH reading; fall back to setpoint then a safe default. + // Clamp to the pH setpoint as a floor: if pH is below setpoint the pool is already + // acidic and we still want to dose chlorine at the normal rate so the alkaline NaOCl + // helps bring pH back up. Using actual low pH would artificially suppress demand. + let rawPh = this.chemController.ph.level || chem.ph.setpoint || 7.4; + let pH = Math.max(rawPh, 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); diff --git a/controller/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index 2426ebd7..27de6050 100644 --- a/controller/nixie/chemistry/ChemController.ts +++ b/controller/nixie/chemistry/ChemController.ts @@ -970,6 +970,7 @@ class NixieChemical extends NixieChildEquipment implements INixieChemical { } chemical.maxDosingTime = typeof data.maxDosingTime !== 'undefined' ? parseInt(data.maxDosingTime, 10) : chemical.maxDosingTime; chemical.maxDosingVolume = typeof data.maxDosingVolume !== 'undefined' ? parseInt(data.maxDosingVolume, 10) : chemical.maxDosingVolume; + chemical.minDosingVolume = typeof data.minDosingVolume !== 'undefined' ? parseInt(data.minDosingVolume, 10) : chemical.minDosingVolume; chemical.startDelay = typeof data.startDelay !== 'undefined' ? parseFloat(data.startDelay) : chemical.startDelay; chemical.maxDailyVolume = typeof data.maxDailyVolume !== 'undefined' ? typeof data.maxDailyVolume === 'number' ? data.maxDailyVolume : parseInt(data.maxDailyVolume, 10) : chemical.maxDailyVolume; } @@ -2476,6 +2477,14 @@ Additional future factors to consider- logger.info(`Chem orp dose calculated ${demand}mL for ${utils.formatDuration(time)} Tank Level: ${sorp.tank.level} using ${meth}`); sorp.demand = demand; if (demand <= 0 && time <= 0) return null; + // If the formula demand is positive but smaller than the configured minimum, overdose to + // the minimum so that tiny probe-noise-driven doses still have a meaningful effect. + let minVol = this.orp.minDosingVolume || 0; + if (minVol > 0 && demand > 0 && demand < minVol) { + demand = minVol; + time = typeof pump.ratedFlow === 'undefined' || pump.ratedFlow <= 0 ? 0 : Math.round(demand / (pump.ratedFlow / 60)); + logger.info(`Chem orp demand below minDosingVolume (${minVol}mL); bumping dose to ${demand}mL for ${utils.formatDuration(time)}`); + } return { dose: demand, time }; } public async deleteChlorAsync(chlor: NixieChlorinator) {