Skip to content

Instantly share code, notes, and snippets.

@bbartling
Last active June 21, 2025 15:55
Show Gist options
  • Save bbartling/14824ea508ab05b1bdd6b1fba54031d8 to your computer and use it in GitHub Desktop.
Save bbartling/14824ea508ab05b1bdd6b1fba54031d8 to your computer and use it in GitHub Desktop.
Modernized Optimal Start Efforts based on PNNL white paper
package com.tridium.kitControl.energy;
import java.text.DecimalFormat;
import javax.baja.log.Log;
import javax.baja.nre.annotations.NiagaraActions;
import javax.baja.nre.annotations.NiagaraProperties;
import javax.baja.nre.annotations.NiagaraType;
import javax.baja.status.BStatus;
import javax.baja.status.BStatusBoolean;
import javax.baja.status.BStatusNumeric;
import javax.baja.status.BStatusString;
import javax.baja.sys.Action;
import javax.baja.sys.BAbsTime;
import javax.baja.sys.BComplex;
import javax.baja.sys.BComponent;
import javax.baja.sys.BFacets;
import javax.baja.sys.BRelTime;
import javax.baja.sys.BTime;
import javax.baja.sys.Clock;
import javax.baja.sys.Clock.Ticket;
import javax.baja.sys.Context;
import javax.baja.sys.Property;
import javax.baja.sys.Slot;
import javax.baja.sys.Sys;
import javax.baja.sys.Type;
@NiagaraType
@NiagaraProperties({@javax.baja.nre.annotations.NiagaraProperty(name="heatCoolMode", type="BStatusBoolean", defaultValue="new BStatusBoolean(false)", flags=10, facets={@javax.baja.nre.annotations.Facet("BFacets.makeBoolean(\"coolMode\", \"heatMode\")")}), @javax.baja.nre.annotations.NiagaraProperty(name="parameterResetTime", type="BAbsTime", defaultValue="BAbsTime.NULL", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="startEnable", type="BStatusBoolean", defaultValue="new BStatusBoolean(false)", flags=8), @javax.baja.nre.annotations.NiagaraProperty(name="stopEnable", type="BStatusBoolean", defaultValue="new BStatusBoolean(false)", flags=8), @javax.baja.nre.annotations.NiagaraProperty(name="scheduleStatus", type="BStatusBoolean", defaultValue="new BStatusBoolean(false)", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="nextEventTime", type="BAbsTime", defaultValue="BAbsTime.NULL", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="nextEventValue", type="BStatusBoolean", defaultValue="new BStatusBoolean(false)", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="outsideTemp", type="BStatusNumeric", defaultValue="new BStatusNumeric()", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="spaceTemp", type="BStatusNumeric", defaultValue="new BStatusNumeric()", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="startTimeCommand", type="BStatusBoolean", defaultValue="new BStatusBoolean(false, BStatus.nullStatus)", flags=74), @javax.baja.nre.annotations.NiagaraProperty(name="stopTimeCommand", type="BStatusBoolean", defaultValue="new BStatusBoolean(false, BStatus.nullStatus)", flags=74), @javax.baja.nre.annotations.NiagaraProperty(name="message", type="BStatusString", defaultValue="new BStatusString(\"\")", flags=10), @javax.baja.nre.annotations.NiagaraProperty(name="upperComfortLimit", type="float", defaultValue="77.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="lowerComfortLimit", type="float", defaultValue="68.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="dynamicParameterAdjust", type="boolean", defaultValue="true"), @javax.baja.nre.annotations.NiagaraProperty(name="oldParameterMultiplier", type="int", defaultValue="2"), @javax.baja.nre.annotations.NiagaraProperty(name="earliestStartTime", type="BTime", defaultValue="BTime.make(0, 0, 10)"), @javax.baja.nre.annotations.NiagaraProperty(name="earliestStopTime", type="BTime", defaultValue="BTime.make(16, 0, 0)"), @javax.baja.nre.annotations.NiagaraProperty(name="drifttimePerDegreeCoolingUserDefined", type="float", defaultValue="0.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="drifttimePerDegreeHeatingUserDefined", type="float", defaultValue="0.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="runtimePerDegreeCoolingUserDefined", type="float", defaultValue="0.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="runtimePerDegreeHeatingUserDefined", type="float", defaultValue="0.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="drifttimePerDegreeCooling", type="float", defaultValue="10.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="drifttimePerDegreeHeating", type="float", defaultValue="10.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="runtimePerDegreeCooling", type="float", defaultValue="10.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="runtimePerDegreeHeating", type="float", defaultValue="10.0f"), @javax.baja.nre.annotations.NiagaraProperty(name="lastStartTime", type="BAbsTime", defaultValue="BAbsTime.NULL", flags=2), @javax.baja.nre.annotations.NiagaraProperty(name="lastStopTime", type="BAbsTime", defaultValue="BAbsTime.NULL", flags=2), @javax.baja.nre.annotations.NiagaraProperty(name="outsideTempAtBeginning", type="BStatusNumeric", defaultValue="new BStatusNumeric()", flags=2), @javax.baja.nre.annotations.NiagaraProperty(name="spaceTempAtBeginning", type="BStatusNumeric", defaultValue="new BStatusNumeric()", flags=2), @javax.baja.nre.annotations.NiagaraProperty(name="calculatedCommandTime", type="BTime", defaultValue="BTime.DEFAULT", flags=66), @javax.baja.nre.annotations.NiagaraProperty(name="programMode", type="int", defaultValue="0", flags=66)})
@NiagaraActions({@javax.baja.nre.annotations.NiagaraAction(name="startTimeTrigger", flags=4), @javax.baja.nre.annotations.NiagaraAction(name="stopTimeTrigger", flags=4), @javax.baja.nre.annotations.NiagaraAction(name="calculate", flags=4)})
public class BOptimizedStartStop extends BComponent
{
public static final Property heatCoolMode = newProperty(10, new BStatusBoolean(false), BFacets.makeBoolean("coolMode", "heatMode"));
public static final Property parameterResetTime = newProperty(10, BAbsTime.NULL, null);
public static final Property startEnable = newProperty(8, new BStatusBoolean(false), null);
public static final Property stopEnable = newProperty(8, new BStatusBoolean(false), null);
public static final Property scheduleStatus = newProperty(10, new BStatusBoolean(false), null);
public static final Property nextEventTime = newProperty(10, BAbsTime.NULL, null);
public static final Property nextEventValue = newProperty(10, new BStatusBoolean(false), null);
public static final Property outsideTemp = newProperty(10, new BStatusNumeric(), null);
public static final Property spaceTemp = newProperty(10, new BStatusNumeric(), null);
public static final Property startTimeCommand = newProperty(74, new BStatusBoolean(false, BStatus.nullStatus), null);
public static final Property stopTimeCommand = newProperty(74, new BStatusBoolean(false, BStatus.nullStatus), null);
public static final Property message = newProperty(10, new BStatusString(""), null);
public static final Property upperComfortLimit = newProperty(0, 77.0F, null);
public static final Property lowerComfortLimit = newProperty(0, 68.0F, null);
public static final Property dynamicParameterAdjust = newProperty(0, true, null);
public static final Property oldParameterMultiplier = newProperty(0, 2, null);
public static final Property earliestStartTime = newProperty(0, BTime.make(0, 0, 10), null);
public static final Property earliestStopTime = newProperty(0, BTime.make(16, 0, 0), null);
public static final Property drifttimePerDegreeCoolingUserDefined = newProperty(0, 0F, null);
public static final Property drifttimePerDegreeHeatingUserDefined = newProperty(0, 0F, null);
public static final Property runtimePerDegreeCoolingUserDefined = newProperty(0, 0F, null);
public static final Property runtimePerDegreeHeatingUserDefined = newProperty(0, 0F, null);
public static final Property drifttimePerDegreeCooling = newProperty(0, 10.0F, null);
public static final Property drifttimePerDegreeHeating = newProperty(0, 10.0F, null);
public static final Property runtimePerDegreeCooling = newProperty(0, 10.0F, null);
public static final Property runtimePerDegreeHeating = newProperty(0, 10.0F, null);
public static final Property lastStartTime = newProperty(2, BAbsTime.NULL, null);
public static final Property lastStopTime = newProperty(2, BAbsTime.NULL, null);
public static final Property outsideTempAtBeginning = newProperty(2, new BStatusNumeric(), null);
public static final Property spaceTempAtBeginning = newProperty(2, new BStatusNumeric(), null);
public static final Property calculatedCommandTime = newProperty(66, BTime.DEFAULT, null);
public static final Property programMode = newProperty(66, 0, null);
public static final Action startTimeTrigger = newAction(4, null);
public static final Action stopTimeTrigger = newAction(4, null);
public static final Action calculate = newAction(4, null);
public static final Type TYPE = Sys.loadType(BOptimizedStartStop.class);
boolean controlModeAtBeginning;
boolean startDone;
boolean analysisComplete;
float observedMinutesPerDegree;
float spaceTempChange;
int leadTime;
int optimizedRuntimeMinutes;
int lastProgramMode;
BAbsTime now;
BAbsTime lastResetTime;
int lastMinute;
private static boolean ACTIVE = true;
private static boolean INACTIVE = false;
private static boolean START = true;
private static boolean STOP = false;
private static boolean DISABLED = false;
private static boolean ENABLED = true;
private static boolean COOLING = true;
private static boolean HEATING = false;
private static long TIME_00_01 = 60000L;
private static int NO_CALCULATION = 0;
private static int START_CALCULATION = 1;
private static int START_IN_PROCESS = 2;
private static int STOP_CALCULATION = 3;
private static int STOP_IN_PROCESS = 4;
Clock.Ticket ticket;
public static final Log ossLog = Log.getLog("kitControl.oss");
public BOptimizedStartStop()
{
this.analysisComplete = false;
this.observedMinutesPerDegree = 0F;
this.spaceTempChange = 0F;
this.leadTime = 0;
this.optimizedRuntimeMinutes = 0;
this.lastProgramMode = 0;
this.now = BAbsTime.NULL;
this.lastResetTime = BAbsTime.NULL;
this.lastMinute = 0;
this.ticket = null;
}
public BStatusBoolean getHeatCoolMode()
{
return ((BStatusBoolean)get(heatCoolMode));
}
public void setHeatCoolMode(BStatusBoolean v)
{
set(heatCoolMode, v, null);
}
public BAbsTime getParameterResetTime()
{
return ((BAbsTime)get(parameterResetTime));
}
public void setParameterResetTime(BAbsTime v)
{
set(parameterResetTime, v, null);
}
public BStatusBoolean getStartEnable()
{
return ((BStatusBoolean)get(startEnable));
}
public void setStartEnable(BStatusBoolean v)
{
set(startEnable, v, null);
}
public BStatusBoolean getStopEnable()
{
return ((BStatusBoolean)get(stopEnable));
}
public void setStopEnable(BStatusBoolean v)
{
set(stopEnable, v, null);
}
public BStatusBoolean getScheduleStatus()
{
return ((BStatusBoolean)get(scheduleStatus));
}
public void setScheduleStatus(BStatusBoolean v)
{
set(scheduleStatus, v, null);
}
public BAbsTime getNextEventTime()
{
return ((BAbsTime)get(nextEventTime));
}
public void setNextEventTime(BAbsTime v)
{
set(nextEventTime, v, null);
}
public BStatusBoolean getNextEventValue()
{
return ((BStatusBoolean)get(nextEventValue));
}
public void setNextEventValue(BStatusBoolean v)
{
set(nextEventValue, v, null);
}
public BStatusNumeric getOutsideTemp()
{
return ((BStatusNumeric)get(outsideTemp));
}
public void setOutsideTemp(BStatusNumeric v)
{
set(outsideTemp, v, null);
}
public BStatusNumeric getSpaceTemp()
{
return ((BStatusNumeric)get(spaceTemp));
}
public void setSpaceTemp(BStatusNumeric v)
{
set(spaceTemp, v, null);
}
public BStatusBoolean getStartTimeCommand()
{
return ((BStatusBoolean)get(startTimeCommand));
}
public void setStartTimeCommand(BStatusBoolean v)
{
set(startTimeCommand, v, null);
}
public BStatusBoolean getStopTimeCommand()
{
return ((BStatusBoolean)get(stopTimeCommand));
}
public void setStopTimeCommand(BStatusBoolean v)
{
set(stopTimeCommand, v, null);
}
public BStatusString getMessage()
{
return ((BStatusString)get(message));
}
public void setMessage(BStatusString v)
{
set(message, v, null);
}
public float getUpperComfortLimit()
{
return getFloat(upperComfortLimit);
}
public void setUpperComfortLimit(float v)
{
setFloat(upperComfortLimit, v, null);
}
public float getLowerComfortLimit()
{
return getFloat(lowerComfortLimit);
}
public void setLowerComfortLimit(float v)
{
setFloat(lowerComfortLimit, v, null);
}
public boolean getDynamicParameterAdjust()
{
return getBoolean(dynamicParameterAdjust);
}
public void setDynamicParameterAdjust(boolean v)
{
setBoolean(dynamicParameterAdjust, v, null);
}
public int getOldParameterMultiplier()
{
return getInt(oldParameterMultiplier);
}
public void setOldParameterMultiplier(int v)
{
setInt(oldParameterMultiplier, v, null);
}
public BTime getEarliestStartTime()
{
return ((BTime)get(earliestStartTime));
}
public void setEarliestStartTime(BTime v)
{
set(earliestStartTime, v, null);
}
public BTime getEarliestStopTime()
{
return ((BTime)get(earliestStopTime));
}
public void setEarliestStopTime(BTime v)
{
set(earliestStopTime, v, null);
}
public float getDrifttimePerDegreeCoolingUserDefined()
{
return getFloat(drifttimePerDegreeCoolingUserDefined);
}
public void setDrifttimePerDegreeCoolingUserDefined(float v)
{
setFloat(drifttimePerDegreeCoolingUserDefined, v, null);
}
public float getDrifttimePerDegreeHeatingUserDefined()
{
return getFloat(drifttimePerDegreeHeatingUserDefined);
}
public void setDrifttimePerDegreeHeatingUserDefined(float v)
{
setFloat(drifttimePerDegreeHeatingUserDefined, v, null);
}
public float getRuntimePerDegreeCoolingUserDefined()
{
return getFloat(runtimePerDegreeCoolingUserDefined);
}
public void setRuntimePerDegreeCoolingUserDefined(float v)
{
setFloat(runtimePerDegreeCoolingUserDefined, v, null);
}
public float getRuntimePerDegreeHeatingUserDefined()
{
return getFloat(runtimePerDegreeHeatingUserDefined);
}
public void setRuntimePerDegreeHeatingUserDefined(float v)
{
setFloat(runtimePerDegreeHeatingUserDefined, v, null);
}
public float getDrifttimePerDegreeCooling()
{
return getFloat(drifttimePerDegreeCooling);
}
public void setDrifttimePerDegreeCooling(float v)
{
setFloat(drifttimePerDegreeCooling, v, null);
}
public float getDrifttimePerDegreeHeating()
{
return getFloat(drifttimePerDegreeHeating);
}
public void setDrifttimePerDegreeHeating(float v)
{
setFloat(drifttimePerDegreeHeating, v, null);
}
public float getRuntimePerDegreeCooling()
{
return getFloat(runtimePerDegreeCooling);
}
public void setRuntimePerDegreeCooling(float v)
{
setFloat(runtimePerDegreeCooling, v, null);
}
public float getRuntimePerDegreeHeating()
{
return getFloat(runtimePerDegreeHeating);
}
public void setRuntimePerDegreeHeating(float v)
{
setFloat(runtimePerDegreeHeating, v, null);
}
public BAbsTime getLastStartTime()
{
return ((BAbsTime)get(lastStartTime));
}
public void setLastStartTime(BAbsTime v)
{
set(lastStartTime, v, null);
}
public BAbsTime getLastStopTime()
{
return ((BAbsTime)get(lastStopTime));
}
public void setLastStopTime(BAbsTime v)
{
set(lastStopTime, v, null);
}
public BStatusNumeric getOutsideTempAtBeginning()
{
return ((BStatusNumeric)get(outsideTempAtBeginning));
}
public void setOutsideTempAtBeginning(BStatusNumeric v)
{
set(outsideTempAtBeginning, v, null);
}
public BStatusNumeric getSpaceTempAtBeginning()
{
return ((BStatusNumeric)get(spaceTempAtBeginning));
}
public void setSpaceTempAtBeginning(BStatusNumeric v)
{
set(spaceTempAtBeginning, v, null);
}
public BTime getCalculatedCommandTime()
{
return ((BTime)get(calculatedCommandTime));
}
public void setCalculatedCommandTime(BTime v)
{
set(calculatedCommandTime, v, null);
}
public int getProgramMode()
{
return getInt(programMode);
}
public void setProgramMode(int v)
{
setInt(programMode, v, null);
}
public void startTimeTrigger()
{
invoke(startTimeTrigger, null, null);
}
public void stopTimeTrigger()
{
invoke(stopTimeTrigger, null, null);
}
public void calculate()
{
invoke(calculate, null, null);
}
public Type getType()
{
return TYPE;
}
public void started()
throws Exception
{
initClockTicket();
super.started();
if (!(Sys.atSteadyState()))
return;
}
public void stopped()
throws Exception
{
if (this.ticket != null)
this.ticket.cancel();
super.stopped();
}
public void clockChanged(BRelTime value)
{
initClockTicket();
}
private void initClockTicket()
{
if (this.ticket != null)
this.ticket.cancel();
BAbsTime tom = Clock.nextTopOfMinute().add(BRelTime.makeSeconds(15));
this.ticket = Clock.schedulePeriodically(this, tom, BRelTime.makeMinutes(1), calculate, null);
}
public void changed(Property property, Context context)
{
super.changed(property, context);
if ((!(Sys.atSteadyState())) || (!(isRunning())))
return;
boolean parameterReset = false;
if (property.equals(drifttimePerDegreeCoolingUserDefined))
{
setDrifttimePerDegreeCooling(getDrifttimePerDegreeCoolingUserDefined());
parameterReset = true;
}
else if (property.equals(drifttimePerDegreeHeatingUserDefined))
{
setDrifttimePerDegreeHeating(getDrifttimePerDegreeHeatingUserDefined());
parameterReset = true;
}
else if (property.equals(runtimePerDegreeCoolingUserDefined))
{
setRuntimePerDegreeCooling(getRuntimePerDegreeCoolingUserDefined());
parameterReset = true;
}
else if (property.equals(runtimePerDegreeHeatingUserDefined))
{
setRuntimePerDegreeHeating(getRuntimePerDegreeHeatingUserDefined());
parameterReset = true;
}
if (parameterReset)
setParameterResetTime(Clock.time());
}
public BFacets getSlotFacets(Slot slot)
{
return super.getSlotFacets(slot); }
public void doStopTimeTrigger() {
}
public void doStartTimeTrigger() {
}
private String formatNumeric(double value, String pattern) {
DecimalFormat format = new DecimalFormat(pattern);
return format.format(value);
}
public void doCalculate()
{
this.now = Clock.time();
if ((getStartEnable().getValue() == DISABLED) || (this.now.getTimeOfDayMillis() < TIME_00_01))
this.startDone = false;
performStartStopAnalysis();
performStartStopCalculation();
performStartStopControl();
updateControlOutput();
this.lastProgramMode = getProgramMode();
}
private void performStartStopCalculation()
{
if (getNextEventTime().getDayOfYear() != this.now.getDayOfYear())
{
setProgramMode(NO_CALCULATION);
return;
}
if (getNextEventValue().getValue() == ACTIVE)
performStartCalculation();
else
performStopCalculation();
}
private void performStartCalculation()
{
if ((getStartEnable().getValue() != ENABLED) || (getScheduleStatus().getValue() == ACTIVE))
{
setProgramMode(NO_CALCULATION);
return;
}
if ((!(this.startDone)) && (getSpaceTemp().getStatus().isValid()))
{
if (getProgramMode() != START_IN_PROCESS)
{
setProgramMode(START_CALCULATION);
if (getSpaceTemp().getValue() > getUpperComfortLimit())
this.leadTime = (1 + (int)((getSpaceTemp().getValue() - getUpperComfortLimit()) * getRuntimePerDegreeCooling()));
else if (getSpaceTemp().getValue() < getLowerComfortLimit())
this.leadTime = (1 + (int)((getLowerComfortLimit() - getSpaceTemp().getValue()) * getRuntimePerDegreeHeating()));
else
this.leadTime = 0;
}
}
else
this.leadTime = 0;
ossLog.trace(getParent().getName() + "." + getName() + "::oss start lead time = " + this.leadTime);
}
private void performStopCalculation()
{
if ((getStopEnable().getValue() != ENABLED) || (getScheduleStatus().getValue() == INACTIVE))
{
setProgramMode(NO_CALCULATION);
return;
}
if ((!(getSpaceTemp().getStatus().isValid())) ||
(getProgramMode() == STOP_IN_PROCESS))
{
this.leadTime = 0;
return;
}
setProgramMode(STOP_CALCULATION);
if (getSpaceTemp().getValue() > getLowerComfortLimit())
{
this.controlModeAtBeginning = getHeatCoolMode().getValue();
if (getHeatCoolMode().getValue() == HEATING)
{
this.leadTime = (int)((getSpaceTemp().getValue() - getLowerComfortLimit()) * getDrifttimePerDegreeHeating());
}
else
this.leadTime = (int)((getUpperComfortLimit() - getSpaceTemp().getValue()) * getDrifttimePerDegreeCooling());
}
ossLog.trace(getName() + "::oss stop lead time = " + this.leadTime);
}
private void performStartStopControl()
{
if ((getProgramMode() != START_CALCULATION) && (getProgramMode() != STOP_CALCULATION))
{
setCalculatedCommandTime(BTime.make(getNextEventTime()));
return;
}
long calcCmdTime = getNextEventTime().getTimeOfDayMillis() - this.leadTime * TIME_00_01;
if (calcCmdTime < getEarliestStartTime().getTimeOfDayMillis())
calcCmdTime = getEarliestStartTime().getTimeOfDayMillis();
setCalculatedCommandTime(BTime.make(BRelTime.make(calcCmdTime)));
if ((getProgramMode() == STOP_CALCULATION) && (getCalculatedCommandTime().isBefore(getEarliestStopTime())))
setCalculatedCommandTime(getEarliestStopTime());
BTime currentTime = BTime.make(Clock.time());
if ((currentTime.isAfter(getCalculatedCommandTime())) || (currentTime.isAfter(BTime.make(getNextEventTime()))))
{
if (getProgramMode() == START_CALCULATION)
{
this.startDone = true;
setProgramMode(START_IN_PROCESS);
setLastStartTime(Clock.time());
getSpaceTempAtBeginning().setValue(getSpaceTemp().getValue());
getOutsideTempAtBeginning().setValue(getOutsideTemp().getValue());
startTimeTrigger();
getMessage().setValue("Optimized start for " + getNextEventTime() + " schedule time. Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
}
else
{
setProgramMode(STOP_IN_PROCESS);
setLastStopTime(Clock.time());
getSpaceTempAtBeginning().setValue(getSpaceTemp().getValue());
getOutsideTempAtBeginning().setValue(getOutsideTemp().getValue());
this.controlModeAtBeginning = getHeatCoolMode().getValue();
stopTimeTrigger();
getMessage().setValue("Optimized stop for " + getNextEventTime() + " schedule time. Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
}
}
}
private void updateControlOutput()
{
if (getProgramMode() == START_IN_PROCESS)
{
getStartTimeCommand().setValue(START);
getStartTimeCommand().setStatusNull(false);
getStopTimeCommand().setValue(STOP);
getStopTimeCommand().setStatusNull(true);
}
else if (getProgramMode() == STOP_IN_PROCESS)
{
getStopTimeCommand().setValue(STOP);
getStopTimeCommand().setStatusNull(false);
getStartTimeCommand().setValue(STOP);
getStartTimeCommand().setStatusNull(true);
}
else
{
getStopTimeCommand().setValue(STOP);
getStopTimeCommand().setStatusNull(true);
getStartTimeCommand().setValue(STOP);
getStartTimeCommand().setStatusNull(true);
this.analysisComplete = false;
}
}
private void performStartStopAnalysis()
{
if ((!(getDynamicParameterAdjust())) || (this.analysisComplete) || (!(getSpaceTemp().getStatus().isValid())))
return;
if (this.lastProgramMode == START_IN_PROCESS)
handleStartAnalysis();
else if (this.lastProgramMode == STOP_IN_PROCESS)
handleStopAnalysis();
}
private void handleStartAnalysis()
{
if (isCoolingAnalysis())
{
if ((getSpaceTemp().getValue() < getSpaceTempAtBeginning().getValue()) && ((
(getSpaceTemp().getValue() <= getUpperComfortLimit()) || (getScheduleStatus().getValue() == ACTIVE))))
{
this.spaceTempChange = (float)(getSpaceTempAtBeginning().getValue() - getSpaceTemp().getValue());
this.optimizedRuntimeMinutes = getLastStartTime().delta(this.now).getMinutes();
this.observedMinutesPerDegree = (this.optimizedRuntimeMinutes / this.spaceTempChange);
setRuntimePerDegreeCooling((getRuntimePerDegreeCooling() * getOldParameterMultiplier() + this.observedMinutesPerDegree) / (getOldParameterMultiplier() + 1));
this.analysisComplete = true;
getMessage().setValue("Optimized start analysis done at " + this.now + ". Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
}
}
else if ((getSpaceTemp().getValue() > getSpaceTempAtBeginning().getValue()) && ((
(getSpaceTemp().getValue() >= getLowerComfortLimit()) || (getScheduleStatus().getValue() == ACTIVE))))
{
this.spaceTempChange = (float)(getSpaceTemp().getValue() - getSpaceTempAtBeginning().getValue());
this.optimizedRuntimeMinutes = getLastStartTime().delta(this.now).getMinutes();
this.observedMinutesPerDegree = (this.optimizedRuntimeMinutes / this.spaceTempChange);
setRuntimePerDegreeHeating((getRuntimePerDegreeHeating() * getOldParameterMultiplier() + this.observedMinutesPerDegree) / (getOldParameterMultiplier() + 1));
this.analysisComplete = true;
getMessage().setValue("Optimized start analysis done at " + this.now + ". Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
}
}
private boolean isCoolingAnalysis()
{
return (getSpaceTempAtBeginning().getValue() > getUpperComfortLimit());
}
private void handleStopAnalysis()
{
if (this.controlModeAtBeginning == HEATING)
{
if ((getSpaceTemp().getValue() < getSpaceTempAtBeginning().getValue()) && ((
(getSpaceTemp().getValue() <= getLowerComfortLimit()) || (getScheduleStatus().getValue() == INACTIVE))))
{
this.spaceTempChange = ((float)getSpaceTempAtBeginning().getValue() - (float)getSpaceTemp().getValue());
this.optimizedRuntimeMinutes = getLastStopTime().delta(this.now).getMinutes();
this.observedMinutesPerDegree = (this.optimizedRuntimeMinutes / this.spaceTempChange);
setDrifttimePerDegreeHeating((getDrifttimePerDegreeHeating() * getOldParameterMultiplier() + this.observedMinutesPerDegree) / (getOldParameterMultiplier() + 1));
getMessage().setValue("Optimized stop analysis done at " + this.now + ". Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
this.analysisComplete = true;
}
}
else if ((getSpaceTemp().getValue() > getSpaceTempAtBeginning().getValue()) && ((
(getSpaceTemp().getValue() >= getUpperComfortLimit()) || (getScheduleStatus().getValue() == INACTIVE))))
{
this.spaceTempChange = ((float)getSpaceTemp().getValue() - (float)getSpaceTempAtBeginning().getValue());
this.optimizedRuntimeMinutes = getLastStopTime().delta(this.now).getMinutes();
this.observedMinutesPerDegree = (this.optimizedRuntimeMinutes / this.spaceTempChange);
setDrifttimePerDegreeCooling((getDrifttimePerDegreeCooling() * getOldParameterMultiplier() + this.observedMinutesPerDegree) / (getOldParameterMultiplier() + 1));
getMessage().setValue("Optimized stop analysis done at " + this.now + ". Space temp is " + formatNumeric(getSpaceTemp().getValue(), "#0.0") + ".");
this.analysisComplete = true;
}
}
}
// Developer Note: For the `historyLog` feature to work, please add a new
// BStatusString slot named "historyLog" to this Program Object in Workbench.
// A static inner class to hold detailed historical performance data.
static class PerformanceRecord {
long timestamp;
double rate; // degrees per minute
String mode; // "HEAT" or "COOL"
double zoneTempStart;
double outdoorTempStart;
PerformanceRecord(long timestamp, double rate, String mode, double zoneTempStart, double outdoorTempStart) {
this.timestamp = timestamp;
this.rate = rate;
this.mode = mode;
this.zoneTempStart = zoneTempStart;
this.outdoorTempStart = outdoorTempStart;
}
}
// Member variables
private Clock.Ticket ticket;
private long startTimestamp = 0;
private boolean isOptimalStartRunning = false;
private long lastStartTriggerTimestamp = 0;
private static final double DEFAULT_RATE_DEG_PER_MIN = 0.1; // Default rate: 6 degrees per hour
// NEW: State variables for the command off-delay timer
private boolean isCommandActive = false;
private boolean isCountdownActive = false;
private long countdownStartTime = 0;
// Two separate lists to store performance history for each mode.
private java.util.List<PerformanceRecord> heatHistory = new java.util.ArrayList<>();
private java.util.List<PerformanceRecord> coolHistory = new java.util.ArrayList<>();
/**
* Called once when the program starts. Initializes defaults.
*/
public void onStart() throws Exception {
// Set default values for configurable parameters if they are not already set
if (getMaxMinutesAllowed().isNull()) setMaxMinutesAllowed(new BStatusNumeric(180.0));
if (getTempTolerance().isNull()) setTempTolerance(new BStatusNumeric(0.5));
if (getHistoryDaysToRetain().isNull()) setHistoryDaysToRetain(new BStatusNumeric(10.0));
if (getEmaWeightingFactor().isNull()) setEmaWeightingFactor(new BStatusNumeric(2.0));
// CORRECTED LOGIC: Check if the value is the default 0.0, not if the slot is null.
// This ensures the default is set correctly on first startup.
if (getCommandOffDelaySeconds().getValue() == 0.0) {
double delayInSeconds = getMaxMinutesAllowed().getValue() * 60.0;
setCommandOffDelaySeconds(new BStatusNumeric(delayInSeconds));
}
// Initialize output/status slots
setIsRunning(new BStatusBoolean(false));
setMinutesToSetpoint(new BStatusNumeric(getMaxMinutesAllowed().getValue()));
setDegreesPerMinuteHeat(new BStatusNumeric(DEFAULT_RATE_DEG_PER_MIN));
setDegreesPerMinuteCool(new BStatusNumeric(DEFAULT_RATE_DEG_PER_MIN));
setEquipmentStartCommand(new BStatusBoolean(false));
getEquipmentStartCommand().setStatus(BStatus.NULL);
getCountdownToNullStatus().setValue("Inactive");
setFormattedStatusLog("[onStart] Optimal Start block initialized.");
// Initialize history log and update model to load any persistent values
updateHistoryLog();
updateModel();
setZoneAtTempTolerance(new BStatusBoolean(false));
updateTimer();
}
/**
* Main execution loop, called periodically by the timer.
*/
public void onExecute() throws Exception {
updateTimer();
updateZoneAtTempTolerance();
if (getStartTimerNow().getValue()) {
lastStartTriggerTimestamp = System.currentTimeMillis();
startOptimalStartSequence();
setStartTimerNow(new BStatusBoolean(false));
}
if (getClearHistoryNow().getValue()) {
clearHistory();
setClearHistoryNow(new BStatusBoolean(false));
}
if (isOptimalStartRunning) {
monitorActiveRun();
} else {
updateIdleEstimate();
}
// Always update the final equipment start command based on the latest values.
updateEquipmentStartCommand();
}
/**
* Called once when the program is stopped.
*/
public void onStop() throws Exception {
if (ticket != null) {
ticket.cancel();
}
}
//================================================================
// --- Core Logic Methods ---
//================================================================
/**
* Starts a new warmup/cooldown sequence.
*/
private void startOptimalStartSequence() {
if (getZoneAtTempTolerance().getValue()) {
setFormattedStatusLog("[Start] Skipping: Zone temp is already within tolerance.");
setMinutesToSetpoint(new BStatusNumeric(0.0));
return;
}
if (!getZoneTemp().getStatus().isOk() || !getTargetZoneTempSetpoint().getStatus().isOk()) {
setFormattedStatusLog("[ERROR] Cannot start: Zone Temp or Target Setpoint not available.");
return;
}
startTimestamp = System.currentTimeMillis();
isOptimalStartRunning = true;
setIsRunning(new BStatusBoolean(true));
setZoneTempAtStart(new BStatusNumeric(getZoneTemp().getValue()));
if (getOutdoorAirTemp().getStatus().isOk()) {
setOutdoorTempAtStart(new BStatusNumeric(getOutdoorAirTemp().getValue()));
} else {
setOutdoorTempAtStart(new BStatusNumeric(Double.NaN));
}
setFormattedStatusLog("[Start] Optimal Start sequence initiated.");
}
/**
* Monitors an active warmup/cooldown run.
*/
private void monitorActiveRun() {
long now = System.currentTimeMillis();
double elapsedMinutes = (now - startTimestamp) / 60000.0;
if (getZoneAtTempTolerance().getValue()) {
setFormattedStatusLog("[Monitor] Target met in " + round1(elapsedMinutes) + " minutes.");
stopAndRecordPerformance(elapsedMinutes);
return;
}
if (!getZoneTemp().getStatus().isOk() || !getTargetZoneTempSetpoint().getStatus().isOk()) {
setFormattedStatusLog("[Monitor] Run stopped: Lost Zone Temp or Target Setpoint.");
stopAndRecordPerformance(elapsedMinutes);
return;
}
setWarmupTimeMinutes(new BStatusNumeric(elapsedMinutes));
if (elapsedMinutes >= getMaxMinutesAllowed().getValue()) {
setFormattedStatusLog("[Monitor] Max runtime reached after " + round1(elapsedMinutes) + " minutes.");
stopAndRecordPerformance(elapsedMinutes);
}
}
/**
* Stops the run and records its performance into the correct history list.
*/
private void stopAndRecordPerformance(double actualMinutes) {
isOptimalStartRunning = false;
setIsRunning(new BStatusBoolean(false));
double zoneStart = getZoneTempAtStart().getValue();
double zoneNow = getZoneTemp().getValue();
double delta = Math.abs(zoneNow - zoneStart);
if (actualMinutes <= 0.1) {
setFormattedStatusLog("[Record] Run was too short. Performance not recorded.");
return;
}
double rate = delta / actualMinutes;
String mode = (zoneStart < getTargetZoneTempSetpoint().getValue()) ? "HEAT" : "COOL";
double outdoorStart = getOutdoorTempAtStart().getStatus().isOk() ? getOutdoorTempAtStart().getValue() : Double.NaN;
PerformanceRecord newRecord = new PerformanceRecord(System.currentTimeMillis(), rate, mode, zoneStart, outdoorStart);
if ("HEAT".equals(mode)) {
heatHistory.add(newRecord);
} else {
coolHistory.add(newRecord);
}
setFormattedStatusLog("[" + mode + "] run recorded. Rate: " + round1(rate) + " deg/min.");
updateModel();
}
/**
* Updates the learned performance model using an EMA of historical data for each mode.
*/
private void updateModel() {
pruneHistory();
double heatRateEma = computeEmaForMode("HEAT");
double coolRateEma = computeEmaForMode("COOL");
if (heatRateEma > 0.0) {
setDegreesPerMinuteHeat(new BStatusNumeric(heatRateEma));
} else {
setDegreesPerMinuteHeat(new BStatusNumeric(DEFAULT_RATE_DEG_PER_MIN));
}
if (coolRateEma > 0.0) {
setDegreesPerMinuteCool(new BStatusNumeric(coolRateEma));
} else {
setDegreesPerMinuteCool(new BStatusNumeric(DEFAULT_RATE_DEG_PER_MIN));
}
updateCurrentHistoryRecordCount();
updateHistoryLog();
setFormattedStatusLog("[Model Updated] Heat Rate: " + round1(getDegreesPerMinuteHeat().getValue()) + ", Cool Rate: " + round1(getDegreesPerMinuteCool().getValue()));
}
/**
* Calculates and outputs the estimated time to reach setpoint when idle.
*/
private void updateIdleEstimate() {
if (getZoneAtTempTolerance().getValue()) {
setMinutesToSetpoint(new BStatusNumeric(0.0));
return;
}
if (!getZoneTemp().getStatus().isOk() || !getTargetZoneTempSetpoint().getStatus().isOk()) {
return;
}
double zone = getZoneTemp().getValue();
double target = getTargetZoneTempSetpoint().getValue();
double delta = Math.abs(target - zone);
double maxMinutes = getMaxMinutesAllowed().getValue();
double estimatedMinutes = maxMinutes;
if (zone < target) {
double heatRate = getDegreesPerMinuteHeat().getValue();
if (heatRate > 0.01) {
estimatedMinutes = delta / heatRate;
}
} else {
double coolRate = getDegreesPerMinuteCool().getValue();
if (coolRate > 0.01) {
estimatedMinutes = delta / coolRate;
}
}
setMinutesToSetpoint(new BStatusNumeric(Math.min(estimatedMinutes, maxMinutes)));
}
//================================================================
// --- Helper Methods ---
//================================================================
/**
* This method now contains the final start/stop logic, including the off-delay countdown.
* It's called on every cycle from onExecute().
*/
private void updateEquipmentStartCommand() {
// If countdown is active, manage it and skip other logic.
if (isCountdownActive) {
int delay = getCommandOffDelayValue();
long elapsed = (System.currentTimeMillis() - countdownStartTime) / 1000;
int remaining = (int) (delay - elapsed);
if (remaining < 1) {
getEquipmentStartCommand().setStatus(BStatus.NULL);
getEquipmentStartCommand().setValue(false);
isCommandActive = false;
isCountdownActive = false;
getCountdownToNullStatus().setValue("Delay expired -> Output = NULL");
} else {
getCountdownToNullStatus().setValue("Countdown active -> " + remaining + "s remaining");
}
return; // Do not evaluate inputs while counting down
}
// Determine if the core start condition is met
boolean startConditionMet = false;
// Check if all necessary inputs are valid
if (getScheduleNextValue().getStatus().isOk() &&
getScheduleNextEventTime().getStatus().isOk() &&
getMinutesToSetpoint().getStatus().isOk()) {
long currentTime = System.currentTimeMillis();
long nextEventTime = (long) getScheduleNextEventTime().getValue();
double timeToNextMinutes = (nextEventTime - currentTime) / 60000.0;
if (timeToNextMinutes < 0) {
timeToNextMinutes = 0;
}
boolean nextScheduleIsOccupied = getScheduleNextValue().getValue();
double optimalStartMinutes = getMinutesToSetpoint().getValue();
if (nextScheduleIsOccupied && (optimalStartMinutes >= timeToNextMinutes)) {
startConditionMet = true;
}
}
// Logic to set output or start countdown
if (startConditionMet) {
getEquipmentStartCommand().setValue(true);
getEquipmentStartCommand().setStatus(BStatus.ok);
isCommandActive = true;
isCountdownActive = false; // Ensure countdown is off
getCountdownToNullStatus().setValue("Active: Conditions Met");
} else if (isCommandActive && getZoneAtTempTolerance().getValue()) {
// REVISED LOGIC: Only start the countdown if the command was active
// AND the zone has reached the temperature tolerance.
isCountdownActive = true;
countdownStartTime = System.currentTimeMillis();
getCountdownToNullStatus().setValue("Entering countdown: Zone at temp.");
} else {
// Conditions are not met and the command wasn't active, so stay NULL
getEquipmentStartCommand().setValue(false);
getEquipmentStartCommand().setStatus(BStatus.NULL);
isCommandActive = false;
getCountdownToNullStatus().setValue("Inactive");
}
}
/**
* Safely gets the off-delay value for the equipment command.
*/
int getCommandOffDelayValue() {
int delay = 5400; // Default value
if (getCommandOffDelaySeconds().getStatus().isOk()) {
// Clamp value for safety
delay = (int) Math.max(1, getCommandOffDelaySeconds().getValue());
}
return delay;
}
/**
* Sets the status log with a prepended timestamp of the last trigger.
*/
private void setFormattedStatusLog(String message) {
String lastTriggerTimeStr = "never";
if (lastStartTriggerTimestamp > 0) {
java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
lastTriggerTimeStr = sdf.format(new java.util.Date(lastStartTriggerTimestamp));
}
String finalMessage = "[Last Trigger: " + lastTriggerTimeStr + "] " + message;
setStatusLog(new BStatusString(finalMessage));
}
/**
* Continuously checks if the zone is within tolerance and updates the status slot.
*/
private void updateZoneAtTempTolerance() {
if (!getZoneTemp().getStatus().isOk() || !getTargetZoneTempSetpoint().getStatus().isOk() || getTempTolerance().isNull()) {
setZoneAtTempTolerance(new BStatusBoolean(false));
return;
}
double zone = getZoneTemp().getValue();
double target = getTargetZoneTempSetpoint().getValue();
double tolerance = getTempTolerance().getValue();
boolean isWithinTolerance = Math.abs(zone - target) <= tolerance;
setZoneAtTempTolerance(new BStatusBoolean(isWithinTolerance));
}
/**
* Removes old records from the performanceHistory list.
*/
private void pruneHistory() {
if (getHistoryDaysToRetain().isNull()) return;
int maxRecords = (int) getHistoryDaysToRetain().getValue();
while (heatHistory.size() > maxRecords) {
heatHistory.remove(0);
}
while (coolHistory.size() > maxRecords) {
coolHistory.remove(0);
}
}
/**
* Clears all learned history and resets the logs.
*/
private void clearHistory() {
heatHistory.clear();
coolHistory.clear();
updateModel();
setFormattedStatusLog("[History] All performance records have been cleared.");
}
/**
* Computes the Exponential Moving Average (EMA) for a specific mode.
*/
private double computeEmaForMode(String mode) {
java.util.List<PerformanceRecord> relevantHistory = "HEAT".equals(mode) ? heatHistory : coolHistory;
if (relevantHistory.isEmpty()) return 0.0;
double[] series = new double[relevantHistory.size()];
for (int i = 0; i < relevantHistory.size(); i++) {
series[i] = relevantHistory.get(i).rate;
}
if (series.length == 1) return series[0];
return computeEMA(series);
}
/**
* Generic EMA calculation.
*/
private double computeEMA(double[] series) {
if (series.length == 0) return 0.0;
double weightingFactor = 2.0;
if (getEmaWeightingFactor().getStatus().isOk()) {
weightingFactor = Math.max(1.0, Math.min(getEmaWeightingFactor().getValue(), 10.0));
}
double k = weightingFactor / (series.length + 1.0);
double ema = series[0];
for (int i = 1; i < series.length; i++) {
ema = series[i] * k + ema * (1 - k);
}
return ema;
}
/**
* Updates the detailed multi-line history log string and prints to console.
*/
private void updateHistoryLog() {
if (getPrintToConsoleLog().getValue()) {
System.out.println("--- [MODEL UPDATE] Dumping Full Performance History ---");
System.out.println("--- HEAT History (" + heatHistory.size() + " records) ---");
if (heatHistory.isEmpty()) {
System.out.println("No heat records available.");
} else {
StringBuilder heatRatesArray = new StringBuilder("Heat Rates (deg/min): [");
for (int i = 0; i < heatHistory.size(); i++) {
heatRatesArray.append(round1(heatHistory.get(i).rate));
if (i < heatHistory.size() - 1) {
heatRatesArray.append(", ");
}
}
heatRatesArray.append("]");
System.out.println(heatRatesArray.toString());
System.out.println("---");
for (int i = 0; i < heatHistory.size(); i++) {
PerformanceRecord r = heatHistory.get(i);
String logEntry = String.format("%d) Rate: %.1f | ZoneStart: %.1f | OAT: %s",
i + 1, r.rate, r.zoneTempStart,
Double.isNaN(r.outdoorTempStart) ? "N/A" : String.valueOf(round1(r.outdoorTempStart)));
System.out.println(logEntry);
}
}
System.out.println("\n--- COOL History (" + coolHistory.size() + " records) ---");
if (coolHistory.isEmpty()) {
System.out.println("No cool records available.");
} else {
StringBuilder coolRatesArray = new StringBuilder("Cool Rates (deg/min): [");
for (int i = 0; i < coolHistory.size(); i++) {
coolRatesArray.append(round1(coolHistory.get(i).rate));
if (i < coolHistory.size() - 1) {
coolRatesArray.append(", ");
}
}
coolRatesArray.append("]");
System.out.println(coolRatesArray.toString());
System.out.println("---");
for (int i = 0; i < coolHistory.size(); i++) {
PerformanceRecord r = coolHistory.get(i);
String logEntry = String.format("%d) Rate: %.1f | ZoneStart: %.1f | OAT: %s",
i + 1, r.rate, r.zoneTempStart,
Double.isNaN(r.outdoorTempStart) ? "N/A" : String.valueOf(round1(r.outdoorTempStart)));
System.out.println(logEntry);
}
}
System.out.println("\n--- End of History Dump ---");
}
try {
BStatusString historyLogSlot = (BStatusString)get("historyLog");
if (historyLogSlot == null) return;
StringBuilder sb = new StringBuilder();
sb.append("--- HEAT History ---\n");
if (heatHistory.isEmpty()) sb.append("No records.\n");
for (PerformanceRecord r : heatHistory) {
sb.append(String.format("Rate: %.1f, ZS: %.1f, OAT: %s\n", r.rate, r.zoneTempStart, Double.isNaN(r.outdoorTempStart) ? "N/A" : String.valueOf(round1(r.outdoorTempStart))));
}
sb.append("\n--- COOL History ---\n");
if (coolHistory.isEmpty()) sb.append("No records.\n");
for (PerformanceRecord r : coolHistory) {
sb.append(String.format("Rate: %.1f, ZS: %.1f, OAT: %s\n", r.rate, r.zoneTempStart, Double.isNaN(r.outdoorTempStart) ? "N/A" : String.valueOf(round1(r.outdoorTempStart))));
}
historyLogSlot.setValue(sb.toString());
} catch (Exception e) {
System.out.println("[ERROR] Failed to update historyLog slot: " + e.getMessage());
}
}
/**
* Updates the output slot that shows the current number of historical records.
*/
private void updateCurrentHistoryRecordCount() {
setCurrentHistoryRecordCount(new BStatusNumeric(heatHistory.size() + coolHistory.size()));
}
/**
* Reschedules the onExecute method to run again.
*/
private void updateTimer() {
if (ticket != null) {
ticket.cancel();
}
ticket = Clock.schedule(getComponent(), BRelTime.makeSeconds(15), BProgram.execute, null);
}
/**
* Utility function to round a double to one decimal place.
*/
private double round1(double v) {
if (Double.isNaN(v)) return 0.0;
return Math.round(v * 10.0) / 10.0;
}
import time
import datetime
class PerformanceRecord:
"""A classic class to hold the performance data for a single HVAC run."""
def __init__(self, timestamp, rate, mode, zone_temp_start, outdoor_temp_start):
self.timestamp = timestamp
self.rate = rate
self.mode = mode
self.zone_temp_start = zone_temp_start
self.outdoor_temp_start = outdoor_temp_start
def __repr__(self):
"""Provides a clean string representation for printing the object."""
return (f"PerformanceRecord(date={datetime.datetime.fromtimestamp(self.timestamp / 1000).strftime('%Y-%m-%d %H:%M')}, "
f"rate={self.rate:.2f}, mode='{self.mode}')")
class OptimalStartModel:
"""
A Python class that simulates the complete Niagara Optimal Start Program Object.
It uses classic getter/setter methods for its attributes to mirror the Java style.
"""
def __init__(self):
# --- Internal State Variables (like Java member variables) ---
self._is_optimal_start_running = False
self._start_timestamp = 0
self._last_start_trigger_timestamp = 0
self._is_command_active = False
self._is_countdown_active = False
self._countdown_start_time = 0
self.DEFAULT_RATE_DEG_PER_MIN = 0.1
# --- Data Histories ---
self._heat_history = []
self._cool_history = []
# --- Attributes mimicking Niagara Slots ---
# Inputs
self._zone_temp = 75.0
self._target_zone_temp_setpoint = 72.0
self._schedule_next_value = False
self._schedule_next_event_time = 0
self._start_timer_now = False
self._clear_history_now = False
# Configurable Parameters
self._max_minutes_allowed = 180.0
self._temp_tolerance = 1.0
self._history_days_to_retain = 10
self._ema_weighting_factor = 2.0
self._command_off_delay_seconds = 5400.0
# Outputs
self._is_running = False
self._minutes_to_setpoint = 0.0
self._degrees_per_minute_heat = self.DEFAULT_RATE_DEG_PER_MIN
self._degrees_per_minute_cool = self.DEFAULT_RATE_DEG_PER_MIN
self._equipment_start_command = None # Using None to represent NULL status
self._zone_at_temp_tolerance = False
self._status_log = ""
self._countdown_to_null_status = "Inactive"
# --- "onStart" Equivalent ---
def start(self):
"""Initializes the model with default values, mimicking onStart()."""
print("[System] Block starting...")
# Set default for commandOffDelaySeconds based on maxMinutesAllowed
if self.get_command_off_delay_seconds() == 0.0:
delay_in_seconds = self.get_max_minutes_allowed() * 60.0
self.set_command_off_delay_seconds(delay_in_seconds)
self.set_minutes_to_setpoint(self.get_max_minutes_allowed())
self._update_model()
self._set_formatted_status_log("[onStart] Optimal Start block initialized.")
print(f"[Status] {self.get_status_log()}")
print("-" * 50)
# --- "onExecute" Equivalent ---
def execute(self):
"""
Runs a single execution cycle of the program logic, mimicking onExecute().
"""
self._update_zone_at_temp_tolerance()
if self.get_start_timer_now():
self._last_start_trigger_timestamp = int(time.time() * 1000)
self._start_optimal_start_sequence()
self.set_start_timer_now(False)
if self.get_clear_history_now():
self._clear_history()
self.set_clear_history_now(False)
if self._is_optimal_start_running:
self._monitor_active_run()
else:
self._update_idle_estimate()
self._update_equipment_start_command()
# --- Helper Methods (Translated from Java) ---
def _update_zone_at_temp_tolerance(self):
zone = self.get_zone_temp()
target = self.get_target_zone_temp_setpoint()
tolerance = self.get_temp_tolerance()
is_within_tolerance = abs(zone - target) <= tolerance
self.set_zone_at_temp_tolerance(is_within_tolerance)
def _start_optimal_start_sequence(self):
if self.get_zone_at_temp_tolerance():
self._set_formatted_status_log("[Start] Skipping: Zone temp is already within tolerance.")
self.set_minutes_to_setpoint(0.0)
return
self._start_timestamp = int(time.time() * 1000)
self._is_optimal_start_running = True
self.set_is_running(True)
self._set_formatted_status_log("[Start] Optimal Start sequence initiated.")
def _monitor_active_run(self):
now = int(time.time() * 1000)
elapsed_minutes = (now - self._start_timestamp) / 60000.0
if self.get_zone_at_temp_tolerance():
self._set_formatted_status_log(f"[Monitor] Target met in {elapsed_minutes:.1f} minutes.")
self._stop_and_record_performance(elapsed_minutes)
return
if elapsed_minutes >= self.get_max_minutes_allowed():
self._set_formatted_status_log(f"[Monitor] Max runtime reached after {elapsed_minutes:.1f} minutes.")
self._stop_and_record_performance(elapsed_minutes)
def _stop_and_record_performance(self, actual_minutes):
self._is_optimal_start_running = False
self.set_is_running(False)
if actual_minutes <= 0.1:
self._set_formatted_status_log("[Record] Run was too short. Performance not recorded.")
return
# Simplified for Python simulation; in Java, this reads start/end values
delta = 5.0 # Assume a 5 degree change for simulation
rate = delta / actual_minutes
mode = "COOL" if self.get_zone_temp() > self.get_target_zone_temp_setpoint() else "HEAT"
new_record = PerformanceRecord(int(time.time() * 1000), rate, mode, 0, 0)
if mode == "HEAT":
self._heat_history.append(new_record)
else:
self._cool_history.append(new_record)
self._set_formatted_status_log(f"[{mode}] run recorded. Rate: {rate:.2f} deg/min.")
self._update_model()
def _update_model(self):
self._prune_history()
heat_rate_ema = self._compute_ema_for_mode("HEAT")
cool_rate_ema = self._compute_ema_for_mode("COOL")
self.set_degrees_per_minute_heat(heat_rate_ema if heat_rate_ema > 0 else self.DEFAULT_RATE_DEG_PER_MIN)
self.set_degrees_per_minute_cool(cool_rate_ema if cool_rate_ema > 0 else self.DEFAULT_RATE_DEG_PER_MIN)
self._set_formatted_status_log(f"[Model Updated] Heat Rate: {self.get_degrees_per_minute_heat():.2f}, Cool Rate: {self.get_degrees_per_minute_cool():.2f}")
def _update_idle_estimate(self):
if self.get_zone_at_temp_tolerance():
self.set_minutes_to_setpoint(0.0)
return
zone = self.get_zone_temp()
target = self.get_target_zone_temp_setpoint()
delta = abs(target - zone)
if zone < target: # Heating
rate = self.get_degrees_per_minute_heat()
else: # Cooling
rate = self.get_degrees_per_minute_cool()
estimated_minutes = delta / rate if rate > 0.01 else self.get_max_minutes_allowed()
self.set_minutes_to_setpoint(min(estimated_minutes, self.get_max_minutes_allowed()))
def _update_equipment_start_command(self):
if self._is_countdown_active:
delay = self.get_command_off_delay_seconds()
elapsed = (time.time() * 1000 - self._countdown_start_time) / 1000
remaining = delay - elapsed
if remaining < 1:
self.set_equipment_start_command(None) # NULL
self._is_command_active = False
self._is_countdown_active = False
self.set_countdown_to_null_status("Delay expired -> Output = NULL")
else:
self.set_countdown_to_null_status(f"Countdown active -> {int(remaining)}s remaining")
return
current_time = time.time() * 1000
next_event_time = self.get_schedule_next_event_time()
time_to_next_minutes = (next_event_time - current_time) / 60000.0
time_to_next_minutes = max(0, time_to_next_minutes)
optimal_start_minutes = self.get_minutes_to_setpoint()
next_schedule_is_occupied = self.get_schedule_next_value()
start_condition_met = next_schedule_is_occupied and (optimal_start_minutes >= time_to_next_minutes)
if start_condition_met:
self.set_equipment_start_command(True)
self._is_command_active = True
self._is_countdown_active = False
self.set_countdown_to_null_status("Active: Conditions Met")
elif self._is_command_active and self.get_zone_at_temp_tolerance():
self._is_countdown_active = True
self._countdown_start_time = time.time() * 1000
self.set_countdown_to_null_status("Entering countdown: Zone at temp.")
else:
self.set_equipment_start_command(None) # NULL
self._is_command_active = False
self.set_countdown_to_null_status("Inactive")
def _prune_history(self):
max_records = self.get_history_days_to_retain()
while len(self._heat_history) > max_records:
self._heat_history.pop(0)
while len(self._cool_history) > max_records:
self._cool_history.pop(0)
def _clear_history(self):
self._heat_history.clear()
self._cool_history.clear()
self._update_model()
self._set_formatted_status_log("[History] All performance records have been cleared.")
def _compute_ema_for_mode(self, mode):
history = self._heat_history if mode == "HEAT" else self._cool_history
if not history:
return 0.0
series = [rec.rate for rec in history]
return self._compute_ema(series)
def _compute_ema(self, series):
if not series: return 0.0
weighting_factor = self.get_ema_weighting_factor()
k = weighting_factor / (len(series) + 1.0)
ema = series[0]
for i in range(1, len(series)):
ema = series[i] * k + ema * (1 - k)
return ema
def _set_formatted_status_log(self, message):
last_trigger_str = "never"
if self._last_start_trigger_timestamp > 0:
last_trigger_str = datetime.datetime.fromtimestamp(self._last_start_trigger_timestamp / 1000).strftime('%Y-%m-%d %H:%M:%S')
final_message = f"[Last Trigger: {last_trigger_str}] {message}"
self.set_status_log(final_message)
# --- Classic Getters and Setters ---
def get_zone_temp(self): return self._zone_temp
def set_zone_temp(self, value): self._zone_temp = value
def get_target_zone_temp_setpoint(self): return self._target_zone_temp_setpoint
def set_target_zone_temp_setpoint(self, value): self._target_zone_temp_setpoint = value
def get_schedule_next_value(self): return self._schedule_next_value
def set_schedule_next_value(self, value): self._schedule_next_value = value
def get_schedule_next_event_time(self): return self._schedule_next_event_time
def set_schedule_next_event_time(self, value): self._schedule_next_event_time = value
def get_start_timer_now(self): return self._start_timer_now
def set_start_timer_now(self, value): self._start_timer_now = value
def get_clear_history_now(self): return self._clear_history_now
def set_clear_history_now(self, value): self._clear_history_now = value
def get_max_minutes_allowed(self): return self._max_minutes_allowed
def set_max_minutes_allowed(self, value): self._max_minutes_allowed = value
def get_temp_tolerance(self): return self._temp_tolerance
def set_temp_tolerance(self, value): self._temp_tolerance = value
def get_history_days_to_retain(self): return self._history_days_to_retain
def set_history_days_to_retain(self, value): self._history_days_to_retain = value
def get_ema_weighting_factor(self): return self._ema_weighting_factor
def set_ema_weighting_factor(self, value): self._ema_weighting_factor = value
def get_command_off_delay_seconds(self): return self._command_off_delay_seconds
def set_command_off_delay_seconds(self, value): self._command_off_delay_seconds = value
def get_is_running(self): return self._is_running
def set_is_running(self, value): self._is_running = value
def get_minutes_to_setpoint(self): return self._minutes_to_setpoint
def set_minutes_to_setpoint(self, value): self._minutes_to_setpoint = value
def get_degrees_per_minute_heat(self): return self._degrees_per_minute_heat
def set_degrees_per_minute_heat(self, value): self._degrees_per_minute_heat = value
def get_degrees_per_minute_cool(self): return self._degrees_per_minute_cool
def set_degrees_per_minute_cool(self, value): self._degrees_per_minute_cool = value
def get_equipment_start_command(self): return self._equipment_start_command
def set_equipment_start_command(self, value): self._equipment_start_command = value
def get_zone_at_temp_tolerance(self): return self._zone_at_temp_tolerance
def set_zone_at_temp_tolerance(self, value): self._zone_at_temp_tolerance = value
def get_status_log(self): return self._status_log
def set_status_log(self, value): self._status_log = value
def get_countdown_to_null_status(self): return self._countdown_to_null_status
def set_countdown_to_null_status(self, value): self._countdown_to_null_status = value
# --- Main Execution Block (Simulation) ---
if __name__ == "__main__":
# 1. Create and start the model
model = OptimalStartModel()
model.start()
# 2. Set up the simulation scenario
# The next schedule event is in 60 minutes and it will be occupied.
model.set_schedule_next_event_time((time.time() + 3600) * 1000)
model.set_schedule_next_value(True)
# 3. Run the simulation loop
print("--- Starting Simulation ---")
for i in range(120): # Simulate for 30 minutes (120 * 15s = 1800s)
print(f"\n--- Cycle {i+1} ---")
# Simulate the environment: zone temp slowly cools down
current_temp = model.get_zone_temp()
model.set_zone_temp(current_temp - 0.1)
# Run the block's logic
model.execute()
# Print key outputs
print(f"Zone Temp: {model.get_zone_temp():.2f} F")
print(f"Minutes to Setpoint: {model.get_minutes_to_setpoint():.2f}")
print(f"Equipment Start Command: {model.get_equipment_start_command()}")
print(f"Countdown Status: {model.get_countdown_to_null_status()}")
time.sleep(1) # Pause for 1 second to make output readable
print("\n--- Simulation Complete ---")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment