diff --git a/controller/Equipment.ts b/controller/Equipment.ts index 76e1837b..f1d81b38 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() { @@ -2522,6 +2528,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 +2549,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..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': @@ -3200,6 +3201,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/NixieBoard.ts b/controller/boards/NixieBoard.ts index 772f1544..7c12e270 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 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 }], 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/comms/messages/Messages.ts b/controller/comms/messages/Messages.ts index a9a7ed21..14659ff6 100755 --- a/controller/comms/messages/Messages.ts +++ b/controller/comms/messages/Messages.ts @@ -342,6 +342,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 @@ -361,6 +364,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) { @@ -561,7 +570,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; 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/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/nixie/chemistry/ChemController.ts b/controller/nixie/chemistry/ChemController.ts index aa8c4d01..6c091224 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 !== 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') { // Figure out what mode we are in and what mode we should be in. //sph.level = 7.61; @@ -2066,6 +2072,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); } @@ -2289,6 +2297,11 @@ export class NixieChemicalORP extends NixieChemical { await this.cancelDosing(sorp, 'ph pump dosing + dose priority'); return; } + 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') { // let _doseCalculatedSec = 0; if (!sorp.lockout) { @@ -2433,17 +2446,20 @@ 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. + // 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': @@ -2462,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. 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. diff --git a/controller/nixie/pumps/Pump.ts b/controller/nixie/pumps/Pump.ts index 5b283dbf..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); @@ -958,7 +959,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 @@ -979,7 +980,7 @@ 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 + 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 }) }); diff --git a/controller/services/HydraulicsCalc.ts b/controller/services/HydraulicsCalc.ts new file mode 100644 index 00000000..ac7af4ad --- /dev/null +++ b/controller/services/HydraulicsCalc.ts @@ -0,0 +1,210 @@ +/* + * HydraulicsCalc.ts + * Pure hydraulic math helpers for pool pump scheduling. + * No project-level imports — safe to use in unit tests and CLI scripts. + * + * Physics + * ─────── + * Affinity Laws (centrifugal pumps): + * Flow scales linearly with RPM: Q2 = Q1 × (RPM2 / RPM1) + * Power scales as the cube: P2 = P1 × (RPM2 / RPM1)³ + * + * 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 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 + */ + +// ─── Public types ────────────────────────────────────────────────────────────── + +/** + * 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 +} + +export interface ScheduleBlock { + phase: 'high' | 'medium' | 'low'; + rpm: number; + gpm: number; + durationHours: number; + startMinutes: number; // Minutes from midnight (0–1439) + endMinutes: number; + gallons: number; +} + +export interface SchedulePlan { + blocks: [ScheduleBlock, ScheduleBlock, ScheduleBlock]; // [high, medium, low] + totalGallons: number; + totalRunHours: number; + turnovers: number; +} + +// ─── Math utilities ──────────────────────────────────────────────────────────── + +/** Flow scales linearly with RPM (affinity law, first leg). */ +export function gpmForRPM(rpm: number, refRPM: number, refGPM: number): number { + return refGPM * (rpm / refRPM); +} + +/** Inverse: GPM → RPM. */ +export function rpmForGPM(gpm: number, refRPM: number, refGPM: number): number { + return refRPM * (gpm / refGPM); +} + +/** 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 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')}`; +} + +// ─── Pipe-size constants ─────────────────────────────────────────────────────── + +interface PipeTier { + maxSafeGPM: number; // Velocity-safe flow ceiling (≤ 5 ft/s rule) +} + +const PIPE_TIERS: Record = { + '1.5': { maxSafeGPM: 50 }, + '2': { maxSafeGPM: 75 }, +}; + +// ─── 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 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 three-block pump schedule. + * + * The reference curve is anchored dynamically to (pumpMaxRPM, maxSafeGPM) so + * the schedule scales correctly for any VS pump and pipe size combination. + * + * 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, pumpMaxRPM: number): SchedulePlan { + const pipe = PIPE_TIERS[String(cfg.pipeDiameter)]; + if (!pipe) throw new Error(`Unknown pipe diameter: ${cfg.pipeDiameter}`); + + const { maxSafeGPM } = 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, + 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; + + // ── 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; + + // ── 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 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 = mediumEnd; + const lowEnd = lowStart + Math.round(lowDurationHours * 60); + + // ── Assemble ─────────────────────────────────────────────────────────────── + const highBlock: ScheduleBlock = { + phase: 'high', + rpm: highRPM, + gpm: highGPM, + durationHours: HIGH_DURATION_HRS, + startMinutes: highStart, + endMinutes: highEnd, + gallons: highGals, + }; + + const mediumBlock: ScheduleBlock = { + phase: 'medium', + rpm: mediumRPM, + gpm: mediumGPM, + durationHours: MEDIUM_DURATION_HRS, + startMinutes: mediumStart, + endMinutes: mediumEnd, + gallons: mediumGals, + }; + + const lowBlock: ScheduleBlock = { + phase: 'low', + rpm: lowRPM, + gpm: parseFloat(lowGPM.toFixed(1)), + durationHours: lowDurationHours, + startMinutes: lowStart, + endMinutes: lowEnd, + gallons: Math.round(lowGPM * lowDurationHours * 60), + }; + + const totalGallons = highBlock.gallons + mediumBlock.gallons + lowBlock.gallons; + const totalRunHours = HIGH_DURATION_HRS + MEDIUM_DURATION_HRS + lowDurationHours; + + return { + 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 new file mode 100644 index 00000000..bfa8decb --- /dev/null +++ b/controller/services/PumpSchedulerService.ts @@ -0,0 +1,390 @@ +/* + * 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 { + SimplePoolConfig, SchedulePlan, ScheduleBlock, + calcScheduleBlocks, minutesToTime, +} from './HydraulicsCalc'; + +// ─── Default configuration ──────────────────────────────────────────────────── + +const DEFAULT_POOL_CONFIG: SimplePoolConfig = { + poolVolumeGallons: 20000, + pipeDiameter: 1.5, +}; + +// 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; +// 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: SimplePoolConfig; +} + +const DEFAULT_SCHEDULER_CFG: SchedulerConfig = { + enabled: false, + pumpId: 1, + featureIds: { high: 0, medium: 0, low: 0 }, + 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 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); + 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 { + if (data.poolConfig) { + this._cfg.poolConfig = Object.assign({}, this._cfg.poolConfig, data.poolConfig); + } + 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(); + const plan = await this.generateScheduleAsync(); + await this._ensurePumpCircuitsAsync(plan); + return plan; + } 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), + }; + } + + 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 wanted: Array<{ key: 'high' | 'medium' | 'low'; name: string }> = [ + { key: 'high', name: 'PumpSched-High' }, + { key: 'medium', name: 'PumpSched-Medium' }, + { key: 'low', name: 'PumpSched-Low' }, + ]; + 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(); + } + + /** + * 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. + * + * 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, + heatSource: sys.board.valueMaps.heatSources.getValue('off'), + 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.gallons.toLocaleString()} gal` + ); + } + } +} + +export const pumpScheduler = new PumpSchedulerService(); diff --git a/defaultConfig.json b/defaultConfig.json index 548cd90e..c4788363 100755 --- a/defaultConfig.json +++ b/defaultConfig.json @@ -75,7 +75,18 @@ "enabled": true } }, - "services": {}, + "services": { + "pumpScheduler": { + "enabled": false, + "pumpId": 1, + "poolConfig": { + "poolVolumeGallons": 20000, + "pipeDiameter": 1.5 + }, + "featureIds": { "high": 0, "medium": 0, "low": 0 }, + "scheduleIds": { "high": 10, "medium": 11, "low": 12 } + } + }, "interfaces": { "smartThings": { "name": "SmartThings", 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", 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..ce151d16 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(); @@ -441,6 +442,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(), @@ -1315,5 +1317,29 @@ 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, next) => { + try { return res.status(200).send(pumpScheduler.getScheduleSnapshot()); } + catch (err) { next(err); } + }); + 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, next) => { + try { + 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); } + }); } } \ No newline at end of file