diff --git a/controller/Equipment.ts b/controller/Equipment.ts index 76e1837b..8c69435e 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() { @@ -2431,6 +2437,7 @@ export interface IChemical { startDelay: number; maxDosingTime?: number; maxDosingVolume?: number; + minDosingVolume?: number; } export class Chemical extends ChildEqItem implements IChemical { public dataName = 'chemicalConfig'; @@ -2455,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); } @@ -2522,6 +2531,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 +2552,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..ee31a40b 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,43 @@ 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. + // 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); + 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..27de6050 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. @@ -969,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; } @@ -1141,6 +1143,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; @@ -1720,24 +1794,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; @@ -1746,9 +1824,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`); @@ -1759,15 +1835,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); } @@ -1775,8 +1842,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); @@ -1791,82 +1857,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. + 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 (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 { @@ -2066,6 +2065,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,201 +2290,17 @@ 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) { - - // 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 = Math.round(utils.convert.volume.convertUnits(0, 'oz', 'mL')); - 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. - switch (meth) { - case 'time': - 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; - 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 = 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}`); - - 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'); @@ -2491,6 +2308,185 @@ 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; + // 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) { logger.info(`Removing chlor ${chlor.id} from Chem Controller ${this.getParent().id}`); let schem = state.chemControllers.getItemById(this.getParent().id); 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