Calculating Circular Mean for Wind Direction - JS Script

TLDR Version: I want to calculate a ‘Circular Mean’ Wind Direction value from a series of individual data points persisted over the last 10 minutes. What is the most easiest way of calculating that in OpenHAB, and if scripting is required, I would prefer UI based JS script.

I have a draft version, which gives the illusion of working, but I still have some doubts… Details below.


After replacing my old weather station, I noticed that one of my rules to close the Vents on the greenhouse, was frequently leaving the North facing vents open, when a strong wind from that general direction was occurring.

I eventually put the clues together, and realised that the old weather station wind-vane probably had a stiff bearing, and didn’t react to wind shifts as quickly (So less directional variation), and that also my greenhouse vent rule was using just an average of the last 10 minutes of the wind direction, which can produce incorrect outcomes when the wind directions are above & below zero (North).

So after a bit of googling, I found that I needed to be using a Circular Mean for this value, and also realised looking at the formula’s, that I probably slept through that class at high-school !!

I see that this question was kind-of asked before, but the solution was more targeted at concurrency of speed & direction measurements, and the actual calculation of the Circular Mean was not addressed…

Anyway, fast forward a bit, with some help from Chat-GPT (Who apparently slept through JavaScript classes, and kept on insisting it ‘now had the right answer’, many times over!!), I ended up taking the core of the calculation if provided, and made it work without throwing errors in JS (Via Main UI).

// UI rule script – ECMAScript 2021

var SOURCE_ITEM = "WeatherStation_WindDirection";
var TARGET_ITEM = "WeatherStation_WindDirection_Mean";
var MINUTES = 10;

// Get the source item
var item = items.getItem(SOURCE_ITEM);

// Java time API
var ZonedDateTime = Java.type("java.time.ZonedDateTime");

// Time window for averaging
var now = ZonedDateTime.now();
var startTime = now.minusMinutes(MINUTES);

// Read historic states from InfluxDB
var entries = item.persistence.getAllStatesBetween(startTime, now)
   
// Check that we actually received data
if (!entries || entries.length === 0) {
  console.log("Circular mean: no persisted data available in InfluxDB");
} else {

  // Convert degrees → radians
  var radians = entries
    .map(e => parseFloat(e.state))
    .filter(v => !isNaN(v))
    .map(v => v * Math.PI / 180);

  // Summed sine and cosine components
  var sinSum = radians.reduce((a, r) => a + Math.sin(r), 0);
  var cosSum = radians.reduce((a, r) => a + Math.cos(r), 0);

  // Circular mean calculation
  var meanRad = Math.atan2(sinSum, cosSum);

  // Convert back to degrees
  let meanDeg = meanRad * 180 / Math.PI;
  if (meanDeg < 0) meanDeg += 360;

  // Update the target item
  items.getItem(TARGET_ITEM).postUpdate(meanDeg);

  // Log
  //console.log(
  //  "Circular mean wind direction = " +
  //  meanDeg.toFixed(1) +
  //  "° (from " + entries.length + " InfluxDB samples)"
  //);
var avgWindDirection = Math.round((items.WeatherStation_WindDirection.persistence.averageSince(time.ZonedDateTime.now().minusMinutes(MINUTES))).numericState);
items.getItem("WeatherStation_WindDirection_Avg").postUpdate(avgWindDirection); 
}

This is just a test script, which takes the wind-direction, and then calculates the result using just an average (As per my current vent rule), and also a Circular Mean, and then I have all 3 items persisted so I can analyse the results….

Or over a slightly longer interval (and less granularity):

Although for this second graph, I suspect I am comparing values averaged by Grafana, with my calculated values (5m interval, with the first graph at 5s), so it may be a bit misleading.

So, at face value, it appears to be working OK, and both calculated values track closely when the wind is predominantly from a Southern direction (180deg), and perhaps diverge as expected, when the wind direction spans either side of North.

But I only make this post, as I do still have a few doubts about the validity of the formula used - Perhaps if we get a few more days of a predominant Northerly wind, it will answer my question as well (Where the Mean stays around either side of 0/360 deg, rather than averaging to 180.

But the other part, is I am wondering if the generated formula is unnecessarily complex (given its lineage) with a much easier/cleaner way of doing it eluding me.

Any feedback welcome/appreciated, before I move this test code into my main Greenhouse vent automation script…

The code seems to track with the formula.

Circular Mean=atan2(∑sin(αi),∑cos(αi))

The calculation to convert between degrees and radians also tracks:

const radians = 90 * (Math.PI / 180);

There are some unnecessary complications based on the fact that the chatbots don’t know openHAB rules.

If I were to write this I’d write it as follows (assumes OH 5.1 M1, if you are on a version before that, replace all the let and const with var below and replace the return with an if/else like in the original) :

// Get the historic data for the past ten minutes
const entries = items.WatherStation_WindDirection.getAllStatesSince(time.toZDT('PT-10M'), 'influxdb'); // I recommend always specifying the database to query

// Verify we got data from the database
if(!entries !! entries.length === 0) {
  console.info("Circular mean: no persisted data available in InfluxDB");
  return;
}

// Convert degrees to radians
const radians = entires.map( degree => degree.numericState * Math.PI / 180 ); 

// Sum the sines and cosines
const sinSum = radians.reduce( (sum, rad) => sum + Math.sin(rad), 0 );
const cosSum = radians.reduce( (sum, rad) => sum + Math.cos(rad), 0 );

// Complete the circular mean
const meanRad = Math.atan2(sinSum, cosSum);

// Convert back to degrees
let meanDeg = meanRad * 180 / Math.PI;
if (meanDeg < 0) meanDeg += 360;

// Update the target Item
items.WeatherStation_WindDirection_Mean.postUpdate(meanDeg);

I assume the last part of the code above is your original so I left it out.

The biggest changes are:

  • This code is so short, there’s really no need to define constants at the top of the code. It just needlessly adds lines. For a longer script it makes sense or if these values were used more than once.
  • Use time.toZDT() to get time values. Passing the ISO8601 duration PT-10M means minus ten minutes from now.
  • Use getAllStatesSince() and the now is assumed.
  • Persistence doesn’t save non-numeric values for number Items so every entry in entries will be a number. We don’t need to test to isNaN.
  • getAllStatesX() returns an array of PersistedItems which have the value already as a number. This plus the previous two lets you simplify the conversion to radians to a single map.

@rlkoshak - Thanks so much for that. Yeah, I haven’t made the move to 5.1 as yet (on 5.0.3), so modified the code to use var as you mention. That is so much tidier thanks.

You are correct - The extra bit at the bottom (to create the Mathematical average) is just there to help produce the persisted data I am using in the graphs to compare the circular mean to the old way of doing things - Will be gone when I merge it into the actual greenhouse rule.

A few small mods ( || instead of !! on the if statement, and the Item Name), and I think I am closer…

// Get the historic data for the past ten minutes
var entries = items.WeatherStation_WindDirection.getAllStatesSince(time.toZDT('PT-10M'), 'influxdb'); // I recommend always specifying the database to query

// Verify we got data from the database
if(!entries || entries.length === 0) {
  console.info("Circular mean: no persisted data available in InfluxDB");
}else{

// Convert degrees to radians
var radians = entires.map( degree => degree.numericState * Math.PI / 180 ); 

// Sum the sines and cosines
var sinSum = radians.reduce( (sum, rad) => sum + Math.sin(rad), 0 );
var cosSum = radians.reduce( (sum, rad) => sum + Math.cos(rad), 0 );

// Complete the circular mean
var meanRad = Math.atan2(sinSum, cosSum);

// Convert back to degrees
var meanDeg = meanRad * 180 / Math.PI;
if (meanDeg < 0) meanDeg += 360;

// Update the target Item
items.WeatherStation_WindDirection_Mean.postUpdate(meanDeg);

var avgWindDirection = Math.round((items.WeatherStation_WindDirection.persistence.averageSince(time.ZonedDateTime.now().minusMinutes(MINUTES))).numericState);
items.getItem("WeatherStation_WindDirection_Avg").postUpdate(avgWindDirection); 
}

The only thing I am seeing (and I did run into this before) is the following error:

2025-12-03 21:54:00.800 [ERROR] [.handler.AbstractScriptModuleHandler] - Script execution of rule with UID 'Test_WindDirection_Rule_2' failed: TypeError: (intermediate value).WeatherStation_WindDirection.getAllStatesSince is not a function in <eval> at line number 2 at column number 15

I got that on the ‘item.persistence.getAllStatesBetween’ I was using in the original version. I tried a suggested check:

const item = items.getItem("WeatherStation_WindDirection");

console.log("Available persistence services for this item:");
console.log(Object.keys(item.persistence));

And get the following (Should I have expected ‘InfluxDB"‘)??

2025-12-03 22:10:18.162 [INFO ] [.script.ui.Test_WindDirection_Rule_2] - Available persistence services for this item:
2025-12-03 22:10:18.163 [INFO ] [.script.ui.Test_WindDirection_Rule_2] - rawItem

So I found dropping the ‘influxdb ‘ from the following for the original version managed to get it going:

entries = item.persistence.influxdb.historicStatesBetween(startTime, now);

I tried removing the influxDB from the arguments in the modified version, but that did not remove the error.

The item is definitely persisted in Influx (As shown in the graphs in my previous post), so not sure if there is something wrong with my persistence config but seems to be the same as all other items:

  - items:
      - WeatherStation_WindDirection
    strategies:
      - everyChange
      - everyMinute
    filters: []

Any ideas on the typeError? My searching for other topics with this issue was fruitless…

Probably not a good night to try anyway - It’s windstill outside, so probably wont see any real results :slight_smile:

Cheers

No, that’s not how persistence works. There are a bunch of methods on .persistence. All of those Items have a final argument where you can specify the name of the persistence you want to use. Core takes it from there. There isn’t a separate anything added to each and every Item based on which persistence they are saved to.

See JavaScript Scripting - Automation | openHAB

  • .getAllStatesSince(timestamp, serviceId) ⇒ Array[PersistedItem]

If ChatGPT suggested that, it’s yet another case where it’s just making stuff up. It has no real knowledge of openHAB hand frankly you’re time would be better spent looking at the reference docs than trying to figure out what’s real and what’s pure fantacy.

But I do see the error. It’s missing the .persistence.

var entries = items.WeatherStation_WindDirection.persistence.getAllStatesSince(time.toZDT('PT-10M'), 'influxdb'); // I recommend always specifying the database to query

Cheers for that . You are right, chatGPT went down a rabbit hole, and I blindly followed it….!!

A few more minor tweaks and the cleaned up version is doing its thing.

Finally had some decent Northerly (NE and NW) winds to test this. Mean value is working as expected, where samples either side of North don’t result in a near southerly average (as demonstrated in the comparison below - The old average method in the purple line swings up to near South (180) ). Graph is a bit ugly, but if I select a lower granularity, Grafana starts averaging the ‘raw’ wind direction, meaning it just tracks similar to the old calculation I have.

I’ll give it a few more days testing (hopefully some other wind patterns), and then I will bake it into my main Vent control script.

I am only being so cautious with testing, as nature does my testing for me otherwise… (The below are the remains of a linear actuator, where I set the wind limits too high a year ago!!)

Thanks again.

Have tested further, and very comfortable that it’s doing the right thing when calculating the direction either side of North.

Have added the Mean Wind calculation to my Behemoth of a Greenhouse vent control script (Can’t blame chatGPT for any code bloat on this one - Was written over a year ago, so its all my fault!!), and its now running the actual vents.

Thanks again - Have marked this thread solved, as the Wind Mean calculation should be usable by others for similar applications.

Cheers for your help.

// Rule to control rollershutter opening position target for greenhouse vents, based on Temperature.
// Vents will also start closing when Wind thresholds (from certain directions) have been met
// Remember rollershutter position is based on 'Closure', so 100% is Closed, 0% is Open

console.loggerName = 'org.openhab.automation.script.greenhouse_vent_control';
osgi.getService('org.apache.karaf.log.core.LogService').setLevel(console.loggerName, 'INFO');


function windDirection(avgPeriod) {
  // Function to calculate if wind direction is within Range where vent closure should be considered
  // This function returns true if the wind is between the Minimum and maximum Compass degrees stored in the respective items. These should be entered in a clockwise direction
  // For example, a greenhouse with (due) North Facing Vents, may wish to consider vent closure when the average wind is within a 90 degree arc either side of due north, where north is '0' (0 +/- 90deg)
  // The start Degrees would therefore be 270 (West) and end Degrees 90 (East). For a south facing vent, these values would be reversed (Start 90-> End 270) 
  var meanWindDirection = windDirectionMean(avgPeriod)
  
  if ((items.greenhouseVentWindDirMin.numericState <= items.greenhouseVentWindDirMax.numericState) && ((meanWindDirection >= items.greenhouseVentWindDirMin.numericState)&&(meanWindDirection <= items.greenhouseVentWindDirMax.numericState)))
    {
      console.debug("Mean Wind Direction ", meanWindDirection," is above Start Degrees ", items.greenhouseVentWindDirMin.numericState, " and below End Degrees", items.greenhouseVentWindDirMax.numericState);
      return true;
    } else
      {
      if ((items.greenhouseVentWindDirMin.numericState > items.greenhouseVentWindDirMax.numericState) && ((meanWindDirection >= items.greenhouseVentWindDirMin.numericState)||(meanWindDirection <= items.greenhouseVentWindDirMax.numericState))) 
       {
         console.debug("Mean Wind Direction (Inverse) ", meanWindDirection," is between Start Degrees ", items.greenhouseVentWindDirMin.numericState, " and End Degrees", items.greenhouseVentWindDirMax.numericState);
         return true;
         } else
           {
           console.debug("Mean Wind Direction ", meanWindDirection," is not between Start Degrees ", items.greenhouseVentWindDirMin.numericState, " and End Degrees", items.greenhouseVentWindDirMax.numericState);
           return false;
           }
      }
}

function windDirectionMean(avgPeriod) {
 // Get the historic data for the past 'avgPeriod', in minutes
 var entries = items.WeatherStation_WindDirection.persistence.getAllStatesSince(time.toZDT().minusMinutes(avgPeriod), 'influxdb');

 // Verify we got data from the database
 if(!entries || entries.length === 0) {
   console.info("Circular mean: no persisted data available in InfluxDB");
 }else{

  // Convert degrees to radians
  var radians = entries.map( degree => degree.numericState * Math.PI / 180 ); 

  // Sum the sines and cosines
  var sinSum = radians.reduce( (sum, rad) => sum + Math.sin(rad), 0 );
  var cosSum = radians.reduce( (sum, rad) => sum + Math.cos(rad), 0 );

  // Complete the circular mean
  var meanRad = Math.atan2(sinSum, cosSum);

  // Convert back to degrees
  var meanDeg = meanRad * 180 / Math.PI;
  if (meanDeg < 0) meanDeg += 360;

  // Update the diagnostic Item
  items.WeatherStation_WindDirection_Mean.postUpdate(meanDeg);
  }
  return meanDeg
}
  
function ventMaxCalc(avgPeriod) {
  //Function to calculate if average wind is exceeding the minimum threshold, and progressively return a diminishing max vent opening value until the maximum threshold is reached (at which point this will prevent the vent from opening)
  // If the maximum gust value is also exceeded, this will also return a max value which will close the vent
  var avgWind = Math.round((items.WeatherStation_WindAverageSpeed.persistence.averageSince(time.ZonedDateTime.now().minusMinutes(avgPeriod))).numericState);
  var gustWind = Math.round((items.WeatherStation_WindGust.persistence.maximumSince(time.ZonedDateTime.now().minusMinutes(avgPeriod))).numericState);  
  var ventWindStep = (items.greenhouseVentWindAvgMax.numericState - items.greenhouseVentWindAvgMin.numericState) /10;
 
  if ((items.greenhouseVentWindAutoCloseEnable.state === "ON") && (windDirection(avgPeriod) === true))
    // Check if this function is enabled (item:switch) and also if the wind direction is within the expected range
    {
      if (gustWind > items.greenhouseVentWindGustMax.numericState)
       // If maximum wind gust from prior interval exceeds the threshold set in the assocaited item, set the Vent Maximum to 100% (which for a roller shutter is fully closed)
       {
         var ventMaxTarget=100;
         console.debug("Wind Gust ", gustWind," above target ", items.greenhouseVentWindGustMax.numericState," Vent Max Target (Wind): ",(100-ventMaxTarget));//Inverted percentage for display purposes
       } else if(avgWind < items.greenhouseVentWindAvgMin.numericState)
         {
          var ventMaxTarget=0;
          console.debug("Average wind ", avgWind," below target ", items.greenhouseVentWindAvgMin.numericState," Vent Max Target (Wind): ",(100-ventMaxTarget)); //Inverted percentage for display purposes
         } else if(avgWind > items.greenhouseVentWindAvgMax.numericState)
           {
            var ventMaxTarget = 100;
            console.debug("Average wind ", avgWind," above target ", items.greenhouseVentWindAvgMax.numericState," Vent Max Target (Wind): ",(100-ventMaxTarget)); //Inverted percentage for display purposes
           } else
             {       
              var ventMaxTarget = ((Math.round((((avgWind-items.greenhouseVentWindAvgMin.numericState)/ventWindStep) *2)/2) *10));
              console.debug("Average wind ", avgWind," is between ", items.greenhouseVentWindAvgMin.numericState," and ",items.greenhouseVentWindAvgMax.numericState," Vent Max Target (Wind): ",(100-ventMaxTarget)); //Inverted percentage for display purposes   
             }
    } else
      {
        var ventMaxTarget = 0;      
      }
  
  return ventMaxTarget;
}

  
function ventTempCalc(avgPeriod) {
  // Function to calculate Greenhouse vent position based on average temperature for the interval (in minutes) passed to this function
  // Temperature is calculated within a range, within which 10 equal steps are used for the rollershutter target position

  var avgTemp = (Math.round((items.greenhouseTemp.persistence.averageSince(time.ZonedDateTime.now().minusMinutes(avgPeriod))).numericState * 2))/2;   
  var ventTempStep = (items.greenhouseTempSetpointMax.numericState - items.greenhouseTempSetpointMin.numericState) /10;
  
  if(avgTemp < items.greenhouseTempSetpointMin.numericState)
    {
      var ventTempTarget=100;
      console.debug("Average temperature ", avgTemp," below target ", items.greenhouseTempSetpointMin.numericState," Vent Temp Target: " , (100-ventTempTarget)); //Inverted percentage for display purposes
      }
      else if(avgTemp > items.greenhouseTempSetpointMax.numericState)
        
        {
        var ventTempTarget = 0;
        console.debug("Average temperature ", avgTemp," above target ", items.greenhouseTempSetpointMax.numericState," Vent Temp Target: " , (100-ventTempTarget)); //Inverted percentage for display purposes
        }
        else
          {
            var ventTempTarget = (100-(Math.round((((avgTemp-items.greenhouseTempSetpointMin.numericState)/ventTempStep) *2)/2) *10));
            console.debug("Average temperature ", avgTemp," is between ", items.greenhouseTempSetpointMin.numericState," and ",items.greenhouseTempSetpointMax.numericState," Vent Temp Target: " , (100-ventTempTarget)); //Inverted percentage for display purposes
          }
  return ventTempTarget;
}


// Main Program

var ventPos = ventTempCalc(items.greenhouseVentTempAvgInterval.numericState);
var ventMax = ventMaxCalc(items.greenhouseVentWindAvgInterval.numericState);
items.greenhouseVentMaxLimit.sendCommandIfDifferent(100-ventMax); //Inverted percentage for display purposes

if ((items.greenhouseVentWindAutoCloseEnable.state === "ON") && (items.greenhouseVentTempAutoEnable.state === "ON")) 
  {
    if(ventPos >= ventMax)
    //Calculated vent position based on temp is within the Maximum Calculated Range based on Wind, so set the item to the temp calculated position
    {
          items.greenhouseVentEast.sendCommandIfDifferent(ventPos)
          items.greenhouseVentWest.sendCommandIfDifferent(ventPos)
          items.greenhouseVentTarget.sendCommandIfDifferent(100-ventPos); //Inverted percentage for display purposes

      console.debug("Temp Control ON/Wind Control ON - Final Target set based on TEMP: ",(100-ventPos)); //Inverted percentage for display purposes
      }
      else
        {
          //Calculated vent position based on temp is within the Maximum Calculated Range based on Wind, so set the item to the temp calculated position
          items.greenhouseVentEast.sendCommandIfDifferent(ventMax)
          items.greenhouseVentWest.sendCommandIfDifferent(ventMax)
          items.greenhouseVentTarget.sendCommandIfDifferent(100-ventMax); //Inverted percentage for display purposes
          console.debug("Temp Control ON/Wind Control ON - Final Target set based on WIND limit: ",(100-ventMax)); //Inverted percentage for display purposes
        }

  }
  else if ((items.greenhouseVentWindAutoCloseEnable.state === "ON") && (items.greenhouseVentTempAutoEnable.state === "OFF"))
     {
       if((100-items.greenhouseVentTarget.numericState) < ventMax)
         {
         items.greenhouseVentTarget.sendCommandIfDifferent(100-ventMax); //Inverted percentage for display purposes
         console.debug("Temp Control OFF/Wind Control ON - WIND Final Target: ",items.greenhouseVentTarget.numericState);
         }
       if((items.greenhouseVentEast.numericState) < ventMax)
         {
         items.greenhouseVentEast.sendCommandIfDifferent(ventMax);
         }
       if((items.greenhouseVentWest.numericState) < ventMax)
         {
         items.greenhouseVentWest.sendCommandIfDifferent(ventMax);
         }
     }
     else if ((items.greenhouseVentWindAutoCloseEnable.state === "OFF") && (items.greenhouseVentTempAutoEnable.state === "ON"))
       {
          items.greenhouseVentEast.sendCommandIfDifferent(ventPos)
          items.greenhouseVentWest.sendCommandIfDifferent(ventPos)
          items.greenhouseVentTarget.sendCommandIfDifferent(100-ventPos); //Inverted percentage for display purposes
          console.debug("Temp Control ON/Wind Control OFF - TEMP Final Target: ",(100-ventPos)); //Inverted percentage for display purposes
       }
       else if ((items.greenhouseVentWindAutoCloseEnable.state === "OFF") && (items.greenhouseVentTempAutoEnable.state === "OFF"))
         {
          console.debug("Temp Control OFF/Wind Control OFF - No updates made. Target position remains as ",items.greenhouseVentTarget.numericState);
         }

console.debug("Final Target: ",(items.greenhouseVentTarget.numericState))

If you would like I can help tighten up the code a bit. I find it’s a great way to learn. It’s not too bad as it’s, but there are some techniques which can shorten some of the complicated if conditionals and the like.

The one big things I’ll point out is seeing the logging level in the role every time it runs can be dangerous. OH needs to write to log4j2.xml every time and if two times try to do that at the same time you could lose the whole file.

I usually just leave that line commented out and uncomment it only when needing to change the level and immediately comment it back out.

Also, if this longer is in you log4j2.xml file already, you can change the level easily from the log viewer under developer tools.

HI Rich - That would be seriously appreciated if you had the time. I honestly have to say, when I went back to edit that rule a few days ago, it was the first time in probably over a year I had looked at it, and it took me a long while just to get my head around what I had done back then.

I also had planned to write-up a DIY greenhouse vent design (Including making, wiring, config and script etc), perhaps this coming winter (June/July for us down-under) for the forum, so having a tidier/easy to understand script would be fantastic.

Good tip re the logging thanks - I had no idea of that risk. TBH, I could probably loose a lot of the debug logging anyway - I just had that in there while I was trying to make the thing work, and I never removed it from the final version.

BTW, feel free to use whatever variant of JS is in your headspace at present (From your earlier posts , this looks to be 5.1 now?). Whilst I currently use Main UI based JS scripts in OH 5.0.1, I’m looking to move my ‘prod’ system to either 5.1 M4 or RC1 when they come out in the next few weeks, and I could always ‘adapt’ the script back as needed, anyway.

Cheers.

Explanations of the changes follow the code.

console.loggerName = 'org.openhab.automation.script.greenhouse_vent_control';
// osgi.getService('org.apache.karaf.log.core.LogService').setLevel(console.loggerName, 'INFO');

function shouldClose(startTime) {
  // Function to calculate if wind direction is within Range where vent closure should be considered
  // This function returns true if the wind is between the Minimum and maximum Compass degrees stored in the respective items. These should be entered in a clockwise direction
  // For example, a greenhouse with (due) North Facing Vents, may wish to consider vent closure when the average wind is within a 90 degree arc either side of due north, where north is '0' (0 +/- 90deg)
  // The start Degrees would therefore be 270 (West) and end Degrees 90 (East). For a south facing vent, these values would be reversed (Start 90-> End 270)
  const mean = windDirectionMean(startTime);
  const min = items.greenhouseVentWindDirMin.numericState;
  const max = items.greenhouseVentWindDirMax.numericState;

  const inverse = min > max // when true check to see if the mean is between min and max, when false check to see if mean is above min or below max
  const inside = mean >= min && mean <= max;
  const exceeds = mean >= min || mean <= max;

  const rval = ( inverse && inside ) || ( !inverse && exceeds );

  console.debug('Should Close: Mean Wind Direction ', mean, ' min ' , min, ' max ', max, ' should close ', true);
  return rval;

}

function windDirectionMean(startTime) {
  // Get the historic data for the past 'avgPeriod', in minutes
  var entries = items.WeatherStation_WindDirection.persistence.getAllStatesSince(startTime, 'influxdb');

  // Verify we got data from the database
  if(!entries || entries.length === 0) {
    console.info("Circular mean: no persisted data available in InfluxDB");
  }
  else{

    // Convert degrees to radians
    var radians = entries.map( degree => degree.numericState * Math.PI / 180 ); 

    // Sum the sines and cosines
    var sinSum = radians.reduce( (sum, rad) => sum + Math.sin(rad), 0 );
    var cosSum = radians.reduce( (sum, rad) => sum + Math.cos(rad), 0 );

    // Complete the circular mean
    var meanRad = Math.atan2(sinSum, cosSum);

    // Convert back to degrees
    var meanDeg = meanRad * 180 / Math.PI;
    if (meanDeg < 0) meanDeg += 360;

    // Update the diagnostic Item
    items.items.WeatherStation_WindDirection_Mean.postUpdate(meanDeg);
  }
  return meanDeg;
}

function ventMaxCalc(startTime) {
  const avgWind = Math.round(speedItem.persistence.averageSince(startTime).numericState);
  const gustWind = Math.round(gustItem.persistence.maximumSince(startTime).numericState);
  const ventWindMax = items.greenhouseVentWindAvgMax.numericState;
  const ventWindMin = items.greenhouseVentWindAvgMin.numericState;
  const ventWindStep = ( ventWindMax - ventWindMin ) /10;
  const gustMax = items.greenhouseVentWindGustMax.numericState;

  if( !items.greenhouseVentWindAutoCloseEnable.boolState && !shouldClose(startTime) ) {
    return 0;
  }

  if( gustWind > gustMax ) {
    console.debug( "Wind Gust ", gustWind," above target ", gustMax," Vent Max Target (Wind): 0" ); //Inverted percentage for display purposes
    return 100;
  }

  if( avgWind < ventWindMin ) {
    console.debug( "Average wind ", avgWind," below target ", ventWindMin," Vent Max Target (Wind): 100" ); //Inverted percentage for display purposes
    return 0;
  }

  if( avgWind > ventWindMax ) {
    console.debug( "Average wind ", avgWind," above target ", ventWindMax," Vent Max Target (Wind): 0" ); //Inverted percentage for display purposes
    return 100
  }

  // break this calculation up into parts. I broke it down based on how it was originally written
  // but the * 2 / 2 step doesn't make any sense to me. 
  let ventMaxTarget = (avgWind - ventWindMin) / ventWindStep
  ventMaxTarget = ( ventMaxTarget * 2 ) / 2;
  ventMaxTarget = Math.round( ventMaxTarget ) * 10;

  console.debug("Average wind ", avgWind," is between ", ventWindMin," and ",ventWindMax," Vent Max Target (Wind): ",(100-ventMaxTarget)); //Inverted percentage for display purposes 
  return ventMaxTarget;
}

function ventTempCalc(startTime) {

  const spMax = items.greenhouseTempSetpointMin.numericState;
  const spMin = items.greenhouseTempSetpointMin.numericState;
  const avgTemp = ( Math.round( items.greenhouseTemp.persistence.averageSince(startTime).numericState * 2 ) ) / 2;   
  const ventTempStep = ( spMax - spMin )  / 10;
  
  if( avgTemp < spMin ) {
    console.debug("Average temperature ", avgTemp," below target ", spMin," Vent Temp Target: 0"); //Inverted percentage for display purposes
    return 100;
  }

  if( avgTemp > spMax ) {
    console.debug("Average temperature ", avgTemp," above target ", spMax," Vent Temp Target: 100"); //Inverted percentage for display purposes
    return 0;
  }

  // Again we'll break up the calculation, so many parens makes it impossible to read and understand.
  let ventTempTarget = ( avgTemp = spMin ) / ventTempStep;
  ventTempTarget = ( ventTempTarget * 2 ) / 2;
  ventTempTarget = 100 - ( Math.round( ventTempTarget ) * 10 );
  console.debug("Average temperature ", avgTemp," is between ", spMin," and ",spMax," Vent Temp Target: " , (100-ventTempTarget)); //Inverted percentage for display purposes
  return ventTempTarget;
}


// Main Program: put into a self executing function so it will work as written in OH 5.1M1 and earlier as well as OH 5.1 M2+
(function(event) {
  const startTime = time.toZDT().minusMinutes(items.greenhouseVentTempAvgInterval.numericState);
  const ventPos = ventTempCalc(startTime);
  const ventMax = ventMaxCalc(startTime);
  items.greenhouseVentMaxLimit.sendCommandIfDifferent(100-ventMax); // Inverted percentage for display purposes

  const east = items.greenhouseVentEast;
  const west = items.greenhouseVentWest;
  const target = items.greenhouseVentTarget;
  const windCloseEnabled = items.greenhouseVentWindAutoCloseEnable.boolState;
  const tempAutoEnabled = items.greenhouseVentTempAutoEnable.boolState;
  let finalTarget = 0;

  if( windCloseEnabled && tempAutoEnabled ) {
    const newVent = ( ventPos >= ventMax )  ? ventPos : ventMax;
    const reason = ( ventPos >= ventMax ) ? 'TEMP' : 'WIND';  
    east.sendCommandIfDifferent( newVent );
    west.sendCommandIfDifferent( newVent );
    target.sendCommandIfDifferent( 100-newVent );
    finalTarget = 100 - newVent;

    console.debug("Temp Control ON/Wind Control ON - Final Target set based on ",reason,": ",( 100 - newVent ) );  // Inverted percentage for display purposes
  }

  else if( windCloseEnabled && !tempCloseEnabled ) {
    if( ( 100 - target.numericState ) < ventMax  ) {
      target.sendCommandIfDifferent( 100 - ventMax );
      finalTarget = 100 - ventMax;
      console.debug("Temp Control OFF/Wind Control ON - WIND Final Target: ",target.numericState);
    }
    // If it's not possible for east.numericState > ventMax, remove these if statements and just
    // sendCommandIfDifferent
    if( east.numericState < ventMax ) {
      east.sendCommandIfDifferent( ventMax );
    }
    if( west.numericState < ventMax ) {
      west.sendCommandIfDifferent( ventMax );
    }
  }

  else if(  !windCloseEnabled && tempCloseEnabled ) {
    east.sendCommandIfDifferent( ventPos );
    west.sendCommandIfDifferent( ventPos );
    targetsendCommandIfDifferent( 100 - ventPos );
    finalTarget = 100 - ventPos;
    console.debug("Temp Control ON/Wind Control OFF - TEMP Final Target: ",(100-ventPos)); //Inverted percentage for display purposes
  }
  
  else if(!windCloseEnabled && ! tempCloseEnabled ) {
    console.debug("Temp Control OFF/Wind Control OFF - No updates made. Target position remains as ",target.numericState);
  }

  console.debug("Final Target: ",finalTarget);
})(event)

Things changed:

  • Use more meaningful names. The first function returns a boolean if the vent should be closed based on the wind, so let’s call it that.
  • Use variable to convert Items and states of Items to a name that is easier to read and use. Especially if you use it more than once.
  • Break up complicated calculations and boolean expressions.
  • Do Not Repeat Yourself (DRY). Rather than calculating a new ZonedDateTime in every function that queries persistence, calculate it once and pass that into the functions.
  • Make sure all your if/else if/else statements line up with each other. The original code kept increasing the indentation for each else if at the same level.
  • Use return to exit early. This avoids nesting if statements and simplifies the code over all.
  • You cannot expect the state of an Item to reach its new state immediately after having commanded it. That takes some time and happens in the background. You already know what you commanded the target Item to, so just save that and log it at the end.

That’s pretty much all I did. I took steps to avoid repeating operations, created variables to make the rest of the code easier to read, and made sure everything lined up as it’s supposed to.

Note that I just typed this in. I did not test it. If you just copy and paste it, it will almost certainly fail. Use this as a guide to improve the running code little by little.

Also, double check your calculations. There were so many parens, may of which were unnecessary, it was really hard to figure out what the calculations were. I still don’t understand why there’s a * 2 / 2. That seems redundant.

Wow - Thanks so much. That is so much easier to follow.

I think I will look to get my system to 5.1M4 when that comes out shortly, and then set aside some time to fully test it on there

Yeah - I should have known that!! I picked up that issue when I was trying to write some completely bogus (but kinda working) code to turn the target position, into timed relay actuation’s for the linear motor, and kept on running into that issue (And that was between runs of the code), so I started using the cache instead (Not applicable here, as we just using in the same execution run of the script). That was before I found out it was a complete waste of time, as there was pre-existing functionality in OpenHAB, which put my code in the bin

Good tip - I’m not a developer in my day job (I probably didn’t need to tell you that, I’m sure you already figured that one out with once glance at my code!!) , so slightly a fish out of water here. But any feedback like this on coding standards are appreciated

I was struggling to come up with a meaningful answer, which didn’t make me look like a complete idiot. Couldn’t really find a good one!! I think I was trying to round it to 0.5 increments and found an article which suggested this. Probably not the same article, but similar context:

https://stackoverflow.com/a/6138087

Thanks again

That’s where breaking the formula up instead of using tons of parens becomes important. In order for that to work the * 2 must be inside the call to Math.round() and the / 2 must be outside the call to Math.round().

That isn’t what was actually happening though once you parse out the parens.

For example:

((Math.round((((avgWind-items.greenhouseVentWindAvgMin.numericState)/ventWindStep) *2)/2) *10));

If we break that down:

(
  (
    Math.round(
      (
        (
          (avgWind-items.greenhouseVentWindAvgMin.numericState)/ventWindStep
        ) *2
      )/2
    ) *10
  )
);

Both the *2 and the /2 are inside the call to Math.round(). If your actual goal is to round to the nearest half (i.e. 0.5) put that on a separate line. That’s a separate operation.

    let ventMaxTarget = ( avgWind - ventWindMin ) / ventWindStep; // calculate the target
    ventMaxTarget = Math.round(ventMaxTarget * 2) / 2; // round to the nearsest 0.5

I don’t know what the * 10 does here, but if you find you need it, add a third line and add a comment for future @glen_m to read so that poor guy knows what the code means.

1 Like