From 0bdb7047d3cac10534b20cd3b093b62ab90bfc66 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 06:45:36 +1200
Subject: [PATCH 1/7] Add single-axis tracker to ghi-to-poa script
---
.../def/ghi-to-poa.py | 107 +++++++++++++++---
1 file changed, 90 insertions(+), 17 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py b/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
index 53d4e8336..7c0ad14b5 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
+++ b/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
@@ -2,18 +2,28 @@
import json
import pandas as pd
import sys
+import warnings
from datetime import datetime
from datetime import timezone
from pvlib import irradiance
+from pvlib import tracking as pvtracking
from pvlib.location import Location
+# suppress numpy/pvlib runtime warnings.
+warnings.simplefilter('ignore')
+
def usage():
print("""Usage:
-a --altitude elevation above sea level, in meters
+-A --max-angle optional maximum tracker rotation angle from horizontal, in degrees (default 90)
+-b --backtrack optional 'true'/'false' to enable tracker backtracking (default false)
-d --date date, like YYYY-MM-DDTHH:mm:ss
+-g --gcr optional ground coverage ratio, used for backtracking (default 0.2857)
-i --irradiance GHI irradiance, in W/m^2
+-k --tracking 'true'/'false' to enable single-axis tracker mode; when true
+ --array-tilt and --array-azimuth are ignored
-l --latitude decimal latitude
-L --longitude decimal longitude
-m --min-cos-zenith optional minimum cos(zenith) value when calculating global clearness index
@@ -21,9 +31,14 @@ def usage():
-t --array-tilt solar array tilt angle from horizontal, in degrees
-T --transpose the transposition model to use, e.g. 'haydavies', 'perez-driesse'
-u --array-azimuth solar array angle clockwise from north
+-x --axis-tilt tracker axis tilt angle from horizontal, in degrees (default 0)
+-X --axis-azimuth tracker axis angle clockwise from north, in degrees (default 0)
-z --zone time zone, like Pacific/Auckland
""")
+def parse_bool(s: str) -> bool:
+ return s.strip().lower() in ('true', '1', 'yes', 'y')
+
def ghi_get_irradiance(location: Location,
array_tilt: float,
array_azimuth: float,
@@ -31,17 +46,23 @@ def ghi_get_irradiance(location: Location,
date: str,
min_cos_zenith=None,
max_zenith=None,
- transposition_model='haydavies') -> dict:
-
+ transposition_model='haydavies',
+ tracking=False,
+ axis_tilt=0,
+ axis_azimuth=0,
+ max_angle=90,
+ backtrack=False,
+ gcr=2.0/7.0) -> dict:
+
times = pd.DatetimeIndex(data = [date], tz = location.tz)
-
+
solar_position = location.get_solarposition(times=times)
-
+
ghi_data = pd.Series([ghi], index=times)
-
+
min_cos_zenith = 0.065 if min_cos_zenith is None else min_cos_zenith
max_zenith = 87 if max_zenith is None else max_zenith
-
+
erbs = irradiance.erbs(
ghi = ghi_data,
zenith = solar_position['apparent_zenith'],
@@ -49,13 +70,31 @@ def ghi_get_irradiance(location: Location,
max_zenith = max_zenith,
datetime_or_doy = times
)
-
+
dni_extra = irradiance.get_extra_radiation(times)
-
+
+ tracker = None
+ if tracking:
+ # single-axis tracker: derive the panel orientation from the sun
+ # position; sun below the horizon produces NaN, fall back to flat
+ tracker = pvtracking.singleaxis(
+ apparent_zenith = solar_position['apparent_zenith'],
+ apparent_azimuth = solar_position['azimuth'],
+ axis_tilt = axis_tilt,
+ axis_azimuth = axis_azimuth,
+ max_angle = max_angle,
+ backtrack = backtrack,
+ gcr = gcr).fillna(0)
+ surface_tilt = tracker['surface_tilt']
+ surface_azimuth = tracker['surface_azimuth']
+ else:
+ surface_tilt = array_tilt
+ surface_azimuth = array_azimuth
+
poa = irradiance.get_total_irradiance(
model = transposition_model,
- surface_tilt = array_tilt,
- surface_azimuth = array_azimuth,
+ surface_tilt = surface_tilt,
+ surface_azimuth = surface_azimuth,
dni = erbs['dni'],
dhi = erbs['dhi'],
dni_extra = dni_extra,
@@ -63,11 +102,11 @@ def ghi_get_irradiance(location: Location,
solar_azimuth = solar_position['azimuth'],
solar_zenith = solar_position['apparent_zenith']
)
-
+
# transpose single row (timestamp) into into simple dictionary
result = {'date': date,
'zone': location.tz,
- 'ghi': ghi,
+ 'ghi': ghi,
'dni': erbs['dni'].iloc[0],
'dhi': erbs['dhi'].iloc[0],
'zenith': solar_position['apparent_zenith'].iloc[0],
@@ -79,17 +118,26 @@ def ghi_get_irradiance(location: Location,
for r in poa[d]:
result.update({d: r})
+ if tracker is not None:
+ result.update({'tracker_theta': tracker['tracker_theta'].iloc[0],
+ 'aoi': tracker['aoi'].iloc[0],
+ 'surface_tilt': tracker['surface_tilt'].iloc[0],
+ 'surface_azimuth': tracker['surface_azimuth'].iloc[0],
+ })
+
return result
try:
opts, args = getopt.getopt(
sys.argv[1:],
- 'a:d:i:l:L:m:M:t:T:u:z:',
+ 'a:A:b:d:g:i:k:l:L:m:M:t:T:u:x:X:z:',
['altitude=', 'date=', 'irradiance=',
- 'latitude=', 'longitude=',
- 'min-cos-zenith=', 'max-zenith=',
+ 'latitude=', 'longitude=',
+ 'min-cos-zenith=', 'max-zenith=',
'array-tilt=', 'transpose=',
- 'array-azimuth=', 'zone='],
+ 'array-azimuth=', 'zone=',
+ 'tracking=', 'axis-tilt=', 'axis-azimuth=',
+ 'max-angle=', 'backtrack=', 'gcr='],
)
except getopt.GetoptError as e:
print(e)
@@ -107,16 +155,31 @@ def ghi_get_irradiance(location: Location,
max_zenith = None
model = 'haydavies'
+tracking = False
+axis_tilt = 0
+axis_azimuth = 0
+max_angle = 90
+backtrack = False
+gcr = 2.0/7.0
+
ghi = 0
date = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S')
for opt, arg in opts:
if opt in ('-a', '--altitude'): # m
alt = float(arg)
+ elif opt in ('-A', '--max-angle'): # angle in degrees
+ max_angle = float(arg)
+ elif opt in ('-b', '--backtrack'):
+ backtrack = parse_bool(arg)
elif opt in ('-d', '--date'):
date = arg
+ elif opt in ('-g', '--gcr'):
+ gcr = float(arg)
elif opt in ('-i', '--irradiance'): # W/m2
ghi = float(arg)
+ elif opt in ('-k', '--tracking'):
+ tracking = parse_bool(arg)
elif opt in ('-l', '--latitude'):
lat = float(arg)
elif opt in ('-L', '--longitude'):
@@ -131,6 +194,10 @@ def ghi_get_irradiance(location: Location,
model = arg
elif opt in ('-u', '--array-azimuth'): # angle in degrees
array_azimuth = float(arg)
+ elif opt in ('-x', '--axis-tilt'): # angle in degrees
+ axis_tilt = float(arg)
+ elif opt in ('-X', '--axis-azimuth'): # angle in degrees
+ axis_azimuth = float(arg)
elif opt in ('-z', '--zone'):
zone = arg
@@ -144,7 +211,13 @@ def ghi_get_irradiance(location: Location,
max_zenith = max_zenith,
ghi = ghi,
date = date,
- transposition_model = model
+ transposition_model = model,
+ tracking = tracking,
+ axis_tilt = axis_tilt,
+ axis_azimuth = axis_azimuth,
+ max_angle = max_angle,
+ backtrack = backtrack,
+ gcr = gcr
)
print(json.dumps(poa))
From ff70c14b486068d46537824240a2f77a7d612c9c Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 06:46:20 +1200
Subject: [PATCH 2/7] Add single-axis tracker options
---
.../META-INF/MANIFEST.MF | 2 +-
.../node/datum/pvlib/CommandOptions.java | 28 ++-
.../pvlib/PvlibPoaDatumFilterService.java | 171 +++++++++++++++++-
.../PvlibPoaDatumFilterService.properties | 22 +++
4 files changed, 219 insertions(+), 4 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/META-INF/MANIFEST.MF b/net.solarnetwork.node.datum.filter.pvlib/META-INF/MANIFEST.MF
index c1adf95c3..db81b191c 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/META-INF/MANIFEST.MF
+++ b/net.solarnetwork.node.datum.filter.pvlib/META-INF/MANIFEST.MF
@@ -3,7 +3,7 @@ Bundle-ManifestVersion: 2
Bundle-Name: pvlib Datum Filter
Bundle-Description: Calculate solar characteristics like POA irradiance from GHI data.
Bundle-SymbolicName: net.solarnetwork.node.datum.filter.pvlib
-Bundle-Version: 2.0.0
+Bundle-Version: 2.1.0
Bundle-Vendor: SolarNetwork
Automatic-Module-Name: net.solarnetwork.node.datum.filter.pvlib
Bundle-RequiredExecutionEnvironment: JavaSE-17
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
index 4cea6b388..99ba8ad1b 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
@@ -26,7 +26,7 @@
* Enumeration of command options with associated metadata keys.
*
* @author matt
- * @version 1.1
+ * @version 1.2
*/
public enum CommandOptions {
@@ -69,6 +69,32 @@ public enum CommandOptions {
*/
TranspositionModel("--transpose", "transpositionModel"),
+ /**
+ * A single-axis tracker mode flag, as {@literal true} or {@literal false}.
+ *
+ * @since 1.2
+ */
+ Tracking("--tracking", "tracking"),
+
+ /** A tracker axis tilt angle value, in degrees from horizontal. */
+ AxisTilt("--axis-tilt", "pvAxisTilt"),
+
+ /** A tracker axis angle value, in degrees clockwise from north. */
+ AxisAzimuth("--axis-azimuth", "pvAxisAzimuth"),
+
+ /**
+ * A maximum tracker rotation angle value, in degrees from horizontal.
+ */
+ MaxAngle("--max-angle", "maxAngle"),
+
+ /**
+ * A tracker backtracking flag, as {@literal true} or {@literal false}.
+ */
+ Backtrack("--backtrack", "backtrack"),
+
+ /** A ground coverage ratio value, used for backtracking. */
+ Gcr("--gcr", "gcr"),
+
;
private final String option;
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
index 906dad772..79cc9f533 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
@@ -87,7 +87,7 @@
*
*
* @author matt
- * @version 1.2
+ * @version 1.3
*/
public class PvlibPoaDatumFilterService extends BaseDatumFilterSupport
implements DatumFilterService, SettingSpecifierProvider {
@@ -130,6 +130,12 @@ public class PvlibPoaDatumFilterService extends BaseDatumFilterSupport
private BigDecimal minCosZenith;
private BigDecimal maxZenith;
private TranspositionModel transpositionModel = DEFAULT_TRANSPOSITION_MODEL;
+ private boolean tracking;
+ private BigDecimal axisTilt;
+ private BigDecimal axisAzimuth;
+ private BigDecimal maxAngle;
+ private boolean backtrack;
+ private BigDecimal gcr;
private String command = DEFAULT_COMMAND;
private String poaResultKey = DEFAULT_POA_RESULT_KEY;
@@ -170,7 +176,7 @@ public DatumSamplesOperations filter(Datum datum, DatumSamplesOperations samples
final String sourceId = datum.getSourceId();
- final Map cmdArguments = new HashMap<>(10);
+ final Map cmdArguments = new HashMap<>(16);
if ( lat != null ) {
cmdArguments.put(CommandOptions.Latitude.getOption(), lat.toPlainString());
}
@@ -198,6 +204,22 @@ public DatumSamplesOperations filter(Datum datum, DatumSamplesOperations samples
if ( transpositionModel != null ) {
cmdArguments.put(CommandOptions.TranspositionModel.getOption(), transpositionModel.getKey());
}
+ if ( tracking ) {
+ cmdArguments.put(CommandOptions.Tracking.getOption(), Boolean.TRUE.toString());
+ cmdArguments.put(CommandOptions.Backtrack.getOption(), String.valueOf(backtrack));
+ if ( axisTilt != null ) {
+ cmdArguments.put(CommandOptions.AxisTilt.getOption(), axisTilt.toPlainString());
+ }
+ if ( axisAzimuth != null ) {
+ cmdArguments.put(CommandOptions.AxisAzimuth.getOption(), axisAzimuth.toPlainString());
+ }
+ if ( maxAngle != null ) {
+ cmdArguments.put(CommandOptions.MaxAngle.getOption(), maxAngle.toPlainString());
+ }
+ if ( gcr != null ) {
+ cmdArguments.put(CommandOptions.Gcr.getOption(), gcr.toPlainString());
+ }
+ }
final String metaPath = nonEmptyString(metadataPath);
final String altMetaPath = nonEmptyString(alternateMetadataPath);
@@ -397,6 +419,13 @@ private List settingSpecifiers(final boolean template) {
results.add(new BasicTextFieldSettingSpecifier("minCosZenith", null));
results.add(new BasicTextFieldSettingSpecifier("maxZenith", null));
+ results.add(new BasicToggleSettingSpecifier("tracking", Boolean.FALSE));
+ results.add(new BasicTextFieldSettingSpecifier("axisTilt", null));
+ results.add(new BasicTextFieldSettingSpecifier("axisAzimuth", null));
+ results.add(new BasicTextFieldSettingSpecifier("maxAngle", null));
+ results.add(new BasicToggleSettingSpecifier("backtrack", Boolean.FALSE));
+ results.add(new BasicTextFieldSettingSpecifier("gcr", null));
+
final MessageSource messageSource = getMessageSource();
// drop-down menu for transpositionModelName
@@ -875,4 +904,142 @@ public final void setTranspositionModelName(String transpositionModel) {
setTranspositionModel(model);
}
+ /**
+ * Get the single-axis tracker mode.
+ *
+ * @return {@literal true} to model a single-axis tracker, in which case
+ * the array tilt and azimuth are ignored
+ * @since 1.3
+ */
+ public final boolean isTracking() {
+ return tracking;
+ }
+
+ /**
+ * Set the single-axis tracker mode.
+ *
+ * @param tracking
+ * {@literal true} to model a single-axis tracker, in which case the
+ * array tilt and azimuth are ignored
+ * @since 1.3
+ */
+ public final void setTracking(boolean tracking) {
+ this.tracking = tracking;
+ }
+
+ /**
+ * Get the tracker axis tilt.
+ *
+ * @return the tilt of the tracker axis in degrees from horizontal, or
+ * {@literal null} for the command default
+ * @since 1.3
+ */
+ public final BigDecimal getAxisTilt() {
+ return axisTilt;
+ }
+
+ /**
+ * Set the tracker axis tilt.
+ *
+ * @param axisTilt
+ * the tilt of the tracker axis in degrees from horizontal to set,
+ * or {@literal null} for the command default
+ * @since 1.3
+ */
+ public final void setAxisTilt(BigDecimal axisTilt) {
+ this.axisTilt = axisTilt;
+ }
+
+ /**
+ * Get the tracker axis azimuth.
+ *
+ * @return the angle of the tracker axis in degrees clockwise from north,
+ * or {@literal null} for the command default
+ * @since 1.3
+ */
+ public final BigDecimal getAxisAzimuth() {
+ return axisAzimuth;
+ }
+
+ /**
+ * Set the tracker axis azimuth.
+ *
+ * @param axisAzimuth
+ * the angle of the tracker axis in degrees clockwise from north to
+ * set, or {@literal null} for the command default
+ * @since 1.3
+ */
+ public final void setAxisAzimuth(BigDecimal axisAzimuth) {
+ this.axisAzimuth = axisAzimuth;
+ }
+
+ /**
+ * Get the maximum tracker rotation angle.
+ *
+ * @return the maximum rotation angle in degrees from horizontal, or
+ * {@literal null} for the command default
+ * @since 1.3
+ */
+ public final BigDecimal getMaxAngle() {
+ return maxAngle;
+ }
+
+ /**
+ * Set the maximum tracker rotation angle.
+ *
+ * @param maxAngle
+ * the maximum rotation angle in degrees from horizontal to set, or
+ * {@literal null} for the command default
+ * @since 1.3
+ */
+ public final void setMaxAngle(BigDecimal maxAngle) {
+ this.maxAngle = maxAngle;
+ }
+
+ /**
+ * Get the tracker backtracking mode.
+ *
+ * @return {@literal true} to apply backtracking to avoid row-to-row
+ * shading, using the ground coverage ratio
+ * @since 1.3
+ */
+ public final boolean isBacktrack() {
+ return backtrack;
+ }
+
+ /**
+ * Set the tracker backtracking mode.
+ *
+ * @param backtrack
+ * {@literal true} to apply backtracking to avoid row-to-row
+ * shading, using the ground coverage ratio
+ * @since 1.3
+ */
+ public final void setBacktrack(boolean backtrack) {
+ this.backtrack = backtrack;
+ }
+
+ /**
+ * Get the ground coverage ratio.
+ *
+ * @return the ratio of PV row width to row spacing, used for backtracking,
+ * or {@literal null} for the command default
+ * @since 1.3
+ */
+ public final BigDecimal getGcr() {
+ return gcr;
+ }
+
+ /**
+ * Set the ground coverage ratio.
+ *
+ * @param gcr
+ * the ratio of PV row width to row spacing, used for backtracking,
+ * to set, or {@literal null} for the command default
+ * @since 1.3
+ */
+ public final void setGcr(BigDecimal gcr) {
+ this.gcr = gcr;
+ }
+
}
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.properties b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.properties
index 04f7d3c72..92836451b 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.properties
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.properties
@@ -71,6 +71,28 @@ minCosZenith.desc = The minimum value of cos(zenith) to allow when
maxZenith.key = Maximum Zenith
maxZenith.desc = The maximum zenith value to allow in DNI calculation.
+tracking.key = Tracking
+tracking.desc = If enabled, model a single-axis tracker: the Tilt and Azimuth settings are \
+ ignored and the panel orientation is calculated from the sun position and the tracker axis settings.
+
+axisTilt.key = Axis Tilt
+axisTilt.desc = The tilt of the tracker axis in degrees from horizontal. Defaults to 0.
+
+axisAzimuth.key = Axis Azimuth
+axisAzimuth.desc = The angle of the tracker axis in degrees clockwise from true north, for example \
+ 0 for a north-south axis. Defaults to 0.
+
+maxAngle.key = Maximum Angle
+maxAngle.desc = The maximum tracker rotation angle in degrees from horizontal. Defaults to 90.
+
+backtrack.key = Backtrack
+backtrack.desc = If enabled, apply backtracking to avoid row-to-row shading, using the \
+ Ground Coverage Ratio.
+
+gcr.key = Ground Coverage Ratio
+gcr.desc = The ratio of PV row width to row spacing, used when Backtrack is enabled. \
+ Defaults to 0.2857.
+
command.key = Command
command.desc = The external command to run, where the parameters and GHI irradiance will be passed as arguments \
and the calculated POA irradiance is returned.
From 175342481b3c2ef92fbb6fb40202b967ccbad096 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 06:46:34 +1200
Subject: [PATCH 3/7] Update README
---
.../README.md | 203 ++++++++++--------
1 file changed, 118 insertions(+), 85 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/README.md b/net.solarnetwork.node.datum.filter.pvlib/README.md
index e8cf90084..cf30c6549 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/README.md
+++ b/net.solarnetwork.node.datum.filter.pvlib/README.md
@@ -12,11 +12,11 @@ filters.
The general idea on how to use this filter can be used can be outlined like this:
- 1. You have a GHI irradiance measurement property collected by some source ID
- 2. You configure this filter to process that same source ID, telling it which property is the GHI
+1. You have a GHI irradiance measurement property collected by some source ID
+2. You configure this filter to process that same source ID, telling it which property is the GHI
measurement and configuring all the PV characteristics necessary for deriving a POA irradiance
values from the GHI values.
- 3. Instead of configuring the PV characteristics on this filter itself, you can alternatively
+3. Instead of configuring the PV characteristics on this filter itself, you can alternatively
configure them in datum, node, or user metadata. See the
[Metadata Parameters](#metadata-parameters) section for more details.
@@ -26,31 +26,37 @@ The general idea on how to use this filter can be used can be outlined like this
Each filter configuration contains the following overall settings:
-| Setting | Description |
-|:-------------------|:------------------------------------------------------------------|
-| Service Name | A unique ID for the filter, to be referenced by other components. |
-| Service Group | An optional service group name to assign. |
-| Source ID | The source ID(s) to filter. |
-| Required Mode | If configured, an [operational mode][opmodes] that must be active for this filter to be applied. |
-| Required Tag | Only apply the filter on datum with the given tag. A tag may be prefixed with `!` to invert the logic so that the filter only applies to datum **without** the given tag. Multiple tags can be defined using a `,` delimiter, in which case **at least one** of the configured tags must match to apply the filter. |
-| Metadata Service | The **Service Name** of the Metadata Service to obtain the PV parameters from. See [Metadata Parameters](#metadata-parameters) for more information. |
-| Metadata Path | The [metadata path][meta-path] that will resolve the PV parameters from the configured **Metadata Service**. See [Metadata Parameters](#metadata-parameters) for more information. |
-| Alternate Metadata Path | An alternate [metadata path][meta-path] to resolve the PV parameters from the configured **Metadata Service**. See [Metadata Parameters](#metadata-parameters) for more information. |
-| Use Node Location | If enabled, then the location configured for this node in SolarNetwork will be used in preference to the **Latitude**, **Longitude**, and **Altitude** settings configured on this filter. |
-| GHI Property | The name of the datum property to obtain the GHI irradiance value from, to use in the **POA Property** calculation. |
-| POA Property | The name of the datum property to populate with the calculated POA irradiance values. |
-| Latitude | The decimal latitude of the PV system. |
-| Longitude | The decimal longitude of the PV system. |
-| Altitude | The altitude of the PV system, in meters above sea level. |
-| Time Zone | The identifier of the time zone of the PV system, for example `Pacific/Auckland`. If not specified then the system default zone will be used. |
-| Azimuth | The angle of the PV array in degrees clockwise from true north that the PV system is facing. |
-| Tilt | The angle of the PV array in degrees from horizontal, from `0` (facing directly upwards) to `90` (facing the horizon). |
-| Minimum cos(zenith) | The minimum value of `cos(zenith)` to allow when calculating the global clearness index. |
-| Maximum Zenith | The maximum zenith value to allow in DNI calculation. |
-| Transposition Model | The transposition model name to use. See [pvlib][pvlib-transpose] for more info. |
-| Command | The external command to run, where the parameters and GHI irradiance will be passed as arguments and the calculated POA irradiance is returned. See [Command](#command) below. |
-| POA Result Key | The command result key to extract for the calculated POA irradiance value. |
-| Expressions | A list of expression configurations that are evaluated to derive datum property values from the **Command** output. See [Expressions](#expressions) below. |
+| Setting | Description |
+| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Service Name | A unique ID for the filter, to be referenced by other components. |
+| Service Group | An optional service group name to assign. |
+| Source ID | The source ID(s) to filter. |
+| Required Mode | If configured, an [operational mode][opmodes] that must be active for this filter to be applied. |
+| Required Tag | Only apply the filter on datum with the given tag. A tag may be prefixed with `!` to invert the logic so that the filter only applies to datum **without** the given tag. Multiple tags can be defined using a `,` delimiter, in which case **at least one** of the configured tags must match to apply the filter. |
+| Metadata Service | The **Service Name** of the Metadata Service to obtain the PV parameters from. See [Metadata Parameters](#metadata-parameters) for more information. |
+| Metadata Path | The [metadata path][meta-path] that will resolve the PV parameters from the configured **Metadata Service**. See [Metadata Parameters](#metadata-parameters) for more information. |
+| Alternate Metadata Path | An alternate [metadata path][meta-path] to resolve the PV parameters from the configured **Metadata Service**. See [Metadata Parameters](#metadata-parameters) for more information. |
+| Use Node Location | If enabled, then the location configured for this node in SolarNetwork will be used in preference to the **Latitude**, **Longitude**, and **Altitude** settings configured on this filter. |
+| GHI Property | The name of the datum property to obtain the GHI irradiance value from, to use in the **POA Property** calculation. |
+| POA Property | The name of the datum property to populate with the calculated POA irradiance values. |
+| Latitude | The decimal latitude of the PV system. |
+| Longitude | The decimal longitude of the PV system. |
+| Altitude | The altitude of the PV system, in meters above sea level. |
+| Time Zone | The identifier of the time zone of the PV system, for example `Pacific/Auckland`. If not specified then the system default zone will be used. |
+| Azimuth | The angle of the PV array in degrees clockwise from true north that the PV system is facing. |
+| Tilt | The angle of the PV array in degrees from horizontal, from `0` (facing directly upwards) to `90` (facing the horizon). |
+| Minimum cos(zenith) | The minimum value of `cos(zenith)` to allow when calculating the global clearness index. |
+| Maximum Zenith | The maximum zenith value to allow in DNI calculation. |
+| Tracking | If enabled, model a single-axis tracker: the **Tilt** and **Azimuth** settings are ignored and the panel orientation is calculated from the sun position and the tracker axis settings. |
+| Axis Tilt | The tilt of the tracker axis in degrees from horizontal. Defaults to `0`. |
+| Axis Azimuth | The angle of the tracker axis in degrees clockwise from true north, for example `0` for a north-south axis. Defaults to `0`. |
+| Maximum Angle | The maximum tracker rotation angle in degrees from horizontal. Defaults to `90`. |
+| Backtrack | If enabled, apply backtracking to avoid row-to-row shading, using the **Ground Coverage Ratio**. |
+| Ground Coverage Ratio | The ratio of PV row width to row spacing, used when **Backtrack** is enabled. Defaults to `0.2857`. |
+| Transposition Model | The transposition model name to use. See [pvlib][pvlib-transpose] for more info. |
+| Command | The external command to run, where the parameters and GHI irradiance will be passed as arguments and the calculated POA irradiance is returned. See [Command](#command) below. |
+| POA Result Key | The command result key to extract for the calculated POA irradiance value. |
+| Expressions | A list of expression configurations that are evaluated to derive datum property values from the **Command** output. See [Expressions](#expressions) below. |
# Metadata Parameters
@@ -64,44 +70,55 @@ this:
```json
{
- "pm": {
- "pv-characteristics": {
- "lat": 41.18015,
- "lon": -73.8328,
- "alt": 10,
- "zone": "America/New_York",
- "pvArrayTilt": 7,
- "pvArrayAzimuth": 205,
- "minCosZenith": 3,
- "maxZenith": 83
- }
- }
+ "pm": {
+ "pv-characteristics": {
+ "lat": 41.18015,
+ "lon": -73.8328,
+ "alt": 10,
+ "zone": "America/New_York",
+ "pvArrayTilt": 7,
+ "pvArrayAzimuth": 205,
+ "minCosZenith": 3,
+ "maxZenith": 83,
+ "tracking": true,
+ "pvAxisTilt": 0,
+ "pvAxisAzimuth": 0,
+ "maxAngle": 60,
+ "backtrack": true,
+ "gcr": 0.35
+ }
+ }
}
```
The filter merges all possible PV characteristics from the settings on the filter itself and
metadata, in the following order, with **later sources overriding** earlier sources:
- 1. Filter settings
- 2. Metdata from the configured **Metadata Service**
- 3. Datum metadata associated with the source ID of the datum being filtered
- 4. **Latitude, longitude, and altitude** from the location configured for the node in SolarNetwork,
+1. Filter settings
+2. Metdata from the configured **Metadata Service**
+3. Datum metadata associated with the source ID of the datum being filtered
+4. **Latitude, longitude, and altitude** from the location configured for the node in SolarNetwork,
if **Use Node Location** is enabled
The supported metadata parameters are:
-| Metadata Key | Description |
-|:-------------|:------------|
-| `lat` | Decimal latitude of the PV system |
-| `lon` | Decimal longitude of the PV system |
-| `alt` | Altitude of the PV system, in meters above sea level |
-| `zone` | Time zone identifier of the PV system, for example `Pacific/Auckland` |
-| `pvArrayTilt` | PV array tilt angle value, in degrees from horizontal |
-| `pvArrayAzimuth` | PV array angle value, in degrees clockwise from north |
-| `minCosZenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
-| `maxZenith` | Maximum zenith value to allow in DNI calculation |
-| `transpositionModel` | The transposition model name to use, one of `haydavies` or `perez-driesse`; defaults to `haydavies` |
-
+| Metadata Key | Description |
+| :------------------- | :----------------------------------------------------------------------------------------------------------------------- |
+| `lat` | Decimal latitude of the PV system |
+| `lon` | Decimal longitude of the PV system |
+| `alt` | Altitude of the PV system, in meters above sea level |
+| `zone` | Time zone identifier of the PV system, for example `Pacific/Auckland` |
+| `pvArrayTilt` | PV array tilt angle value, in degrees from horizontal |
+| `pvArrayAzimuth` | PV array angle value, in degrees clockwise from north |
+| `minCosZenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
+| `maxZenith` | Maximum zenith value to allow in DNI calculation |
+| `transpositionModel` | The transposition model name to use, one of `haydavies` or `perez-driesse`; defaults to `haydavies` |
+| `tracking` | `true` to model a single-axis tracker, in which case `pvArrayTilt` and `pvArrayAzimuth` are ignored; defaults to `false` |
+| `pvAxisTilt` | Tracker axis tilt angle value, in degrees from horizontal; defaults to `0` |
+| `pvAxisAzimuth` | Tracker axis angle value, in degrees clockwise from north; defaults to `0` |
+| `maxAngle` | Maximum tracker rotation angle value, in degrees from horizontal; defaults to `90` |
+| `backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `gcr`; defaults to `false` |
+| `gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking; defaults to `0.2857` |
# Expressions
@@ -112,23 +129,26 @@ many properties, like these:
```json
{
- "date": "2024-11-18T10:24:47",
- "zone": "Pacific/Auckland",
- "ghi": 805.0,
- "dni": 844.5952562517782,
- "dhi": 151.71952235515488,
- "zenith": 39.3322587975266,
- "azimuth": 74.67509718383894,
- "min_cos_zenith": 0.065,
- "max_zenith": 87,
- "poa_global": 799.951169881113,
- "poa_direct": 648.6170024103126,
- "poa_diffuse": 151.3341674708004,
- "poa_sky_diffuse": 150.9512589662823,
- "poa_ground_diffuse": 0.38290850451810454
+ "date": "2024-11-18T10:24:47",
+ "zone": "Pacific/Auckland",
+ "ghi": 805.0,
+ "dni": 844.5952562517782,
+ "dhi": 151.71952235515488,
+ "zenith": 39.3322587975266,
+ "azimuth": 74.67509718383894,
+ "min_cos_zenith": 0.065,
+ "max_zenith": 87,
+ "poa_global": 799.951169881113,
+ "poa_direct": 648.6170024103126,
+ "poa_diffuse": 151.3341674708004,
+ "poa_sky_diffuse": 150.9512589662823,
+ "poa_ground_diffuse": 0.38290850451810454
}
```
+When **Tracking** is enabled the result also includes the calculated tracker orientation
+properties `tracker_theta`, `aoi`, `surface_tilt`, and `surface_azimuth`.
+
Properties like `poa_global`, `poa_direct`, and so on can be used in expressions, for example you
could round the `poa_global` value to at most 3 digits with:
@@ -136,27 +156,31 @@ could round the `poa_global` value to at most 3 digits with:
roundDown(poa_global, 3)
```
-
# Command
The **Command** setting is the system-specific path to the command to run, where the parameters and GHI irradiance will be passed as arguments and the calculated POA irradiance is returned. The
command must accept the options shown below, and is expected to output a JSON object. The
[def/ghi-to-poa.py](./def/ghi-to-poa.py) script is an example of such a command.
-| Command Option | Description |
-|:---------------|:------------|
-| `--latitude` | The decimal latitude |
-| `--longitude` | The decimal longitude |
-| `--altitude` | The altitude in meters above sea level |
-| `--zone` | Time zone identifier, for example `Pacific/Auckland` |
-| `--array-tilt` | PV tilt angle value, in degrees from horizontal |
-| `--array-azimuth` | PV array angle value, in degrees clockwise from north |
-| `--min-cos-zenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
-| `--max-zenith` | Maximum zenith value to allow in DNI calculation |
-| `--date` | Local timestamp, in `YYYY-MM-DDTHH:mm:ss` format |
-| `--irradiance` | The GHI irradiance to calculate the POA irradiance value for |
-| `--transpose` | The optional transposition model name to use, for example `haydavies` |
-
+| Command Option | Description |
+| :----------------- | :------------------------------------------------------------------------------------------------------------------------- |
+| `--latitude` | The decimal latitude |
+| `--longitude` | The decimal longitude |
+| `--altitude` | The altitude in meters above sea level |
+| `--zone` | Time zone identifier, for example `Pacific/Auckland` |
+| `--array-tilt` | PV tilt angle value, in degrees from horizontal |
+| `--array-azimuth` | PV array angle value, in degrees clockwise from north |
+| `--min-cos-zenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
+| `--max-zenith` | Maximum zenith value to allow in DNI calculation |
+| `--date` | Local timestamp, in `YYYY-MM-DDTHH:mm:ss` format |
+| `--irradiance` | The GHI irradiance to calculate the POA irradiance value for |
+| `--transpose` | The optional transposition model name to use, for example `haydavies` |
+| `--tracking` | `true` to model a single-axis tracker, in which case `--array-tilt` and `--array-azimuth` are ignored; defaults to `false` |
+| `--axis-tilt` | Tracker axis tilt angle value, in degrees from horizontal; defaults to `0` |
+| `--axis-azimuth` | Tracker axis angle value, in degrees clockwise from north; defaults to `0` |
+| `--max-angle` | Maximum tracker rotation angle value, in degrees from horizontal; defaults to `90` |
+| `--backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `--gcr`; defaults to `false` |
+| `--gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking; defaults to `0.2857` |
# Developer Setup
@@ -188,6 +212,15 @@ python def/ghi-to-poa.py --latitude -36.8509 --longitude 174.7645 \
--date 2024-11-16T10:00 --irradiance 1000
```
+Or with single-axis tracking enabled:
+
+```sh
+python def/ghi-to-poa.py --latitude -36.8509 --longitude 174.7645 \
+ --zone Pacific/Auckland --tracking true --axis-azimuth 0 \
+ --backtrack true --gcr 0.35 \
+ --date 2024-11-16T10:00 --irradiance 1000
+```
+
[expr]: https://solarnetwork.github.io/solarnode-handbook/users/expressions/
[metadata]: https://github.com/SolarNetwork/solarnetwork/wiki/SolarNet-API-global-objects#metadata
[meta-path]: https://github.com/SolarNetwork/solarnetwork/wiki/SolarNet-API-global-objects#metadata-filter-key-paths
From d70d76d7426da756e495510d137541f51ba2e039 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 06:53:05 +1200
Subject: [PATCH 4/7] Fix pvlib tracking/backtrack option wiring
---
.../pvlib/PvlibPoaDatumFilterService.java | 28 ++++++++++---------
1 file changed, 15 insertions(+), 13 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
index 79cc9f533..89ba6cf09 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
@@ -206,19 +206,21 @@ public DatumSamplesOperations filter(Datum datum, DatumSamplesOperations samples
}
if ( tracking ) {
cmdArguments.put(CommandOptions.Tracking.getOption(), Boolean.TRUE.toString());
- cmdArguments.put(CommandOptions.Backtrack.getOption(), String.valueOf(backtrack));
- if ( axisTilt != null ) {
- cmdArguments.put(CommandOptions.AxisTilt.getOption(), axisTilt.toPlainString());
- }
- if ( axisAzimuth != null ) {
- cmdArguments.put(CommandOptions.AxisAzimuth.getOption(), axisAzimuth.toPlainString());
- }
- if ( maxAngle != null ) {
- cmdArguments.put(CommandOptions.MaxAngle.getOption(), maxAngle.toPlainString());
- }
- if ( gcr != null ) {
- cmdArguments.put(CommandOptions.Gcr.getOption(), gcr.toPlainString());
- }
+ }
+ if ( backtrack ) {
+ cmdArguments.put(CommandOptions.Backtrack.getOption(), Boolean.TRUE.toString());
+ }
+ if ( axisTilt != null ) {
+ cmdArguments.put(CommandOptions.AxisTilt.getOption(), axisTilt.toPlainString());
+ }
+ if ( axisAzimuth != null ) {
+ cmdArguments.put(CommandOptions.AxisAzimuth.getOption(), axisAzimuth.toPlainString());
+ }
+ if ( maxAngle != null ) {
+ cmdArguments.put(CommandOptions.MaxAngle.getOption(), maxAngle.toPlainString());
+ }
+ if ( gcr != null ) {
+ cmdArguments.put(CommandOptions.Gcr.getOption(), gcr.toPlainString());
}
final String metaPath = nonEmptyString(metadataPath);
From 2fe0e53ba9429603aca3c631377f40845d1c9f77 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 07:01:03 +1200
Subject: [PATCH 5/7] Validate tracker and backtracking option ranges
---
.../README.md | 72 +++++------
.../def/ghi-to-poa.py | 46 +++++--
.../pvlib/PvlibPoaDatumFilterService.java | 116 +++++++++++++++---
3 files changed, 169 insertions(+), 65 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/README.md b/net.solarnetwork.node.datum.filter.pvlib/README.md
index cf30c6549..5889d77c8 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/README.md
+++ b/net.solarnetwork.node.datum.filter.pvlib/README.md
@@ -102,23 +102,23 @@ metadata, in the following order, with **later sources overriding** earlier sour
The supported metadata parameters are:
-| Metadata Key | Description |
-| :------------------- | :----------------------------------------------------------------------------------------------------------------------- |
-| `lat` | Decimal latitude of the PV system |
-| `lon` | Decimal longitude of the PV system |
-| `alt` | Altitude of the PV system, in meters above sea level |
-| `zone` | Time zone identifier of the PV system, for example `Pacific/Auckland` |
-| `pvArrayTilt` | PV array tilt angle value, in degrees from horizontal |
-| `pvArrayAzimuth` | PV array angle value, in degrees clockwise from north |
-| `minCosZenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
-| `maxZenith` | Maximum zenith value to allow in DNI calculation |
-| `transpositionModel` | The transposition model name to use, one of `haydavies` or `perez-driesse`; defaults to `haydavies` |
-| `tracking` | `true` to model a single-axis tracker, in which case `pvArrayTilt` and `pvArrayAzimuth` are ignored; defaults to `false` |
-| `pvAxisTilt` | Tracker axis tilt angle value, in degrees from horizontal; defaults to `0` |
-| `pvAxisAzimuth` | Tracker axis angle value, in degrees clockwise from north; defaults to `0` |
-| `maxAngle` | Maximum tracker rotation angle value, in degrees from horizontal; defaults to `90` |
-| `backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `gcr`; defaults to `false` |
-| `gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking; defaults to `0.2857` |
+| Metadata Key | Description |
+| :------------------- | :--------------------------------------------------------------------------------------------------------------------------- |
+| `lat` | Decimal latitude of the PV system |
+| `lon` | Decimal longitude of the PV system |
+| `alt` | Altitude of the PV system, in meters above sea level |
+| `zone` | Time zone identifier of the PV system, for example `Pacific/Auckland` |
+| `pvArrayTilt` | PV array tilt angle value, in degrees from horizontal |
+| `pvArrayAzimuth` | PV array angle value, in degrees clockwise from north |
+| `minCosZenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
+| `maxZenith` | Maximum zenith value to allow in DNI calculation |
+| `transpositionModel` | The transposition model name to use, one of `haydavies` or `perez-driesse`; defaults to `haydavies` |
+| `tracking` | `true` to model a single-axis tracker, in which case `pvArrayTilt` and `pvArrayAzimuth` are ignored; defaults to `false` |
+| `pvAxisTilt` | Tracker axis tilt angle value, in degrees from horizontal, between `0` and `90`; defaults to `0` |
+| `pvAxisAzimuth` | Tracker axis angle value, in degrees clockwise from north, between `0` and `360`; defaults to `0` |
+| `maxAngle` | Maximum tracker rotation angle value, in degrees from horizontal, greater than `0` up to `180`; defaults to `90` |
+| `backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `gcr`; defaults to `false` |
+| `gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking, greater than `0` up to `1`; defaults to `0.2857` |
# Expressions
@@ -162,25 +162,25 @@ The **Command** setting is the system-specific path to the command to run, where
command must accept the options shown below, and is expected to output a JSON object. The
[def/ghi-to-poa.py](./def/ghi-to-poa.py) script is an example of such a command.
-| Command Option | Description |
-| :----------------- | :------------------------------------------------------------------------------------------------------------------------- |
-| `--latitude` | The decimal latitude |
-| `--longitude` | The decimal longitude |
-| `--altitude` | The altitude in meters above sea level |
-| `--zone` | Time zone identifier, for example `Pacific/Auckland` |
-| `--array-tilt` | PV tilt angle value, in degrees from horizontal |
-| `--array-azimuth` | PV array angle value, in degrees clockwise from north |
-| `--min-cos-zenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
-| `--max-zenith` | Maximum zenith value to allow in DNI calculation |
-| `--date` | Local timestamp, in `YYYY-MM-DDTHH:mm:ss` format |
-| `--irradiance` | The GHI irradiance to calculate the POA irradiance value for |
-| `--transpose` | The optional transposition model name to use, for example `haydavies` |
-| `--tracking` | `true` to model a single-axis tracker, in which case `--array-tilt` and `--array-azimuth` are ignored; defaults to `false` |
-| `--axis-tilt` | Tracker axis tilt angle value, in degrees from horizontal; defaults to `0` |
-| `--axis-azimuth` | Tracker axis angle value, in degrees clockwise from north; defaults to `0` |
-| `--max-angle` | Maximum tracker rotation angle value, in degrees from horizontal; defaults to `90` |
-| `--backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `--gcr`; defaults to `false` |
-| `--gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking; defaults to `0.2857` |
+| Command Option | Description |
+| :----------------- | :--------------------------------------------------------------------------------------------------------------------------- |
+| `--latitude` | The decimal latitude |
+| `--longitude` | The decimal longitude |
+| `--altitude` | The altitude in meters above sea level |
+| `--zone` | Time zone identifier, for example `Pacific/Auckland` |
+| `--array-tilt` | PV tilt angle value, in degrees from horizontal |
+| `--array-azimuth` | PV array angle value, in degrees clockwise from north |
+| `--min-cos-zenith` | Minimum value of `cos(zenith)` to allow when calculating global clearness index |
+| `--max-zenith` | Maximum zenith value to allow in DNI calculation |
+| `--date` | Local timestamp, in `YYYY-MM-DDTHH:mm:ss` format |
+| `--irradiance` | The GHI irradiance to calculate the POA irradiance value for |
+| `--transpose` | The optional transposition model name to use, for example `haydavies` |
+| `--tracking` | `true` to model a single-axis tracker, in which case `--array-tilt` and `--array-azimuth` are ignored; defaults to `false` |
+| `--axis-tilt` | Tracker axis tilt angle value, in degrees from horizontal, between `0` and `90`; defaults to `0` |
+| `--axis-azimuth` | Tracker axis angle value, in degrees clockwise from north, between `0` and `360`; defaults to `0` |
+| `--max-angle` | Maximum tracker rotation angle value, in degrees from horizontal, greater than `0` up to `180`; defaults to `90` |
+| `--backtrack` | `true` to apply backtracking to avoid row-to-row shading, using `--gcr`; defaults to `false` |
+| `--gcr` | Ground coverage ratio (PV row width to row spacing), used for backtracking, greater than `0` up to `1`; defaults to `0.2857` |
# Developer Setup
diff --git a/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py b/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
index 7c0ad14b5..019045e42 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
+++ b/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py
@@ -1,5 +1,6 @@
import getopt
import json
+import math
import pandas as pd
import sys
import warnings
@@ -17,10 +18,12 @@ def usage():
print("""Usage:
-a --altitude elevation above sea level, in meters
--A --max-angle optional maximum tracker rotation angle from horizontal, in degrees (default 90)
+-A --max-angle optional maximum tracker rotation angle from horizontal, in degrees,
+ 0 exclusive to 180 (default 90)
-b --backtrack optional 'true'/'false' to enable tracker backtracking (default false)
-d --date date, like YYYY-MM-DDTHH:mm:ss
--g --gcr optional ground coverage ratio, used for backtracking (default 0.2857)
+-g --gcr optional ground coverage ratio, used for backtracking,
+ 0 exclusive to 1 (default 0.2857)
-i --irradiance GHI irradiance, in W/m^2
-k --tracking 'true'/'false' to enable single-axis tracker mode; when true
--array-tilt and --array-azimuth are ignored
@@ -31,13 +34,32 @@ def usage():
-t --array-tilt solar array tilt angle from horizontal, in degrees
-T --transpose the transposition model to use, e.g. 'haydavies', 'perez-driesse'
-u --array-azimuth solar array angle clockwise from north
--x --axis-tilt tracker axis tilt angle from horizontal, in degrees (default 0)
--X --axis-azimuth tracker axis angle clockwise from north, in degrees (default 0)
+-x --axis-tilt tracker axis tilt angle from horizontal, in degrees, 0 to 90 (default 0)
+-X --axis-azimuth tracker axis angle clockwise from north, in degrees, 0 to 360 (default 0)
-z --zone time zone, like Pacific/Auckland
""")
-def parse_bool(s: str) -> bool:
- return s.strip().lower() in ('true', '1', 'yes', 'y')
+def invalid_value(message: str):
+ print(message, file=sys.stderr)
+ sys.exit(2)
+
+def parse_bool(opt: str, s: str) -> bool:
+ v = s.strip().lower()
+ if v in ('true', '1', 'yes', 'y'):
+ return True
+ if v in ('false', '0', 'no', 'n'):
+ return False
+ invalid_value("%s: invalid boolean value '%s'" % (opt, s))
+
+def parse_ranged_float(opt: str, s: str, lo: float, hi: float, lo_exclusive=False) -> float:
+ try:
+ v = float(s)
+ except ValueError:
+ v = math.nan
+ if not math.isfinite(v) or v > hi or (v <= lo if lo_exclusive else v < lo):
+ invalid_value("%s: value '%s' not a number between %s%s and %s"
+ % (opt, s, lo, ' (exclusive)' if lo_exclusive else '', hi))
+ return v
def ghi_get_irradiance(location: Location,
array_tilt: float,
@@ -169,17 +191,17 @@ def ghi_get_irradiance(location: Location,
if opt in ('-a', '--altitude'): # m
alt = float(arg)
elif opt in ('-A', '--max-angle'): # angle in degrees
- max_angle = float(arg)
+ max_angle = parse_ranged_float(opt, arg, 0, 180, lo_exclusive=True)
elif opt in ('-b', '--backtrack'):
- backtrack = parse_bool(arg)
+ backtrack = parse_bool(opt, arg)
elif opt in ('-d', '--date'):
date = arg
elif opt in ('-g', '--gcr'):
- gcr = float(arg)
+ gcr = parse_ranged_float(opt, arg, 0, 1, lo_exclusive=True)
elif opt in ('-i', '--irradiance'): # W/m2
ghi = float(arg)
elif opt in ('-k', '--tracking'):
- tracking = parse_bool(arg)
+ tracking = parse_bool(opt, arg)
elif opt in ('-l', '--latitude'):
lat = float(arg)
elif opt in ('-L', '--longitude'):
@@ -195,9 +217,9 @@ def ghi_get_irradiance(location: Location,
elif opt in ('-u', '--array-azimuth'): # angle in degrees
array_azimuth = float(arg)
elif opt in ('-x', '--axis-tilt'): # angle in degrees
- axis_tilt = float(arg)
+ axis_tilt = parse_ranged_float(opt, arg, 0, 90)
elif opt in ('-X', '--axis-azimuth'): # angle in degrees
- axis_azimuth = float(arg)
+ axis_azimuth = parse_ranged_float(opt, arg, 0, 360)
elif opt in ('-z', '--zone'):
zone = arg
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
index 89ba6cf09..0aaefad35 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
@@ -111,6 +111,10 @@ public class PvlibPoaDatumFilterService extends BaseDatumFilterSupport
*/
public static final TranspositionModel DEFAULT_TRANSPOSITION_MODEL = TranspositionModel.HayDavies;
+ private static final BigDecimal DEGREES_90 = new BigDecimal(90);
+ private static final BigDecimal DEGREES_180 = new BigDecimal(180);
+ private static final BigDecimal DEGREES_360 = new BigDecimal(360);
+
private final OptionalService datumMetadataService;
private final OptionalFilterableService characteristicsMetadataService;
private final ObjectMapper objectMapper;
@@ -210,18 +214,10 @@ public DatumSamplesOperations filter(Datum datum, DatumSamplesOperations samples
if ( backtrack ) {
cmdArguments.put(CommandOptions.Backtrack.getOption(), Boolean.TRUE.toString());
}
- if ( axisTilt != null ) {
- cmdArguments.put(CommandOptions.AxisTilt.getOption(), axisTilt.toPlainString());
- }
- if ( axisAzimuth != null ) {
- cmdArguments.put(CommandOptions.AxisAzimuth.getOption(), axisAzimuth.toPlainString());
- }
- if ( maxAngle != null ) {
- cmdArguments.put(CommandOptions.MaxAngle.getOption(), maxAngle.toPlainString());
- }
- if ( gcr != null ) {
- cmdArguments.put(CommandOptions.Gcr.getOption(), gcr.toPlainString());
- }
+ putCommandArgument(cmdArguments, CommandOptions.AxisTilt, axisTilt);
+ putCommandArgument(cmdArguments, CommandOptions.AxisAzimuth, axisAzimuth);
+ putCommandArgument(cmdArguments, CommandOptions.MaxAngle, maxAngle);
+ putCommandArgument(cmdArguments, CommandOptions.Gcr, gcr);
final String metaPath = nonEmptyString(metadataPath);
final String altMetaPath = nonEmptyString(alternateMetadataPath);
@@ -332,13 +328,99 @@ private void populateArguments(Map cmdArguments, GeneralDatumMet
if ( metaKey == null ) {
continue;
}
- Object metaVal = params.get(metaKey);
- if ( metaVal != null ) {
- cmdArguments.put(opt.getOption(),
- metaVal instanceof BigDecimal ? ((BigDecimal) metaVal).toPlainString()
- : metaVal.toString());
+ putCommandArgument(cmdArguments, opt, params.get(metaKey));
+ }
+ }
+
+ /**
+ * Add a command argument value, if the value is valid.
+ *
+ *
+ * An invalid value is not added, preserving any previously resolved value
+ * for the same option, and a warning is logged.
+ *
+ *
+ * @param cmdArguments
+ * the arguments to add the value to
+ * @param opt
+ * the command option
+ * @param val
+ * the proposed option value; {@literal null} is ignored
+ */
+ private void putCommandArgument(Map cmdArguments, CommandOptions opt, Object val) {
+ if ( val == null ) {
+ return;
+ }
+ String argVal = commandArgumentValue(opt, val);
+ if ( argVal != null ) {
+ cmdArguments.put(opt.getOption(), argVal);
+ } else {
+ log.warn("Ignoring invalid GHI -> POA irradiance command option [{}] value [{}]",
+ opt.getOption(), val);
+ }
+ }
+
+ /**
+ * Validate and normalize a command option value.
+ *
+ *
+ * Tracker options are validated against the value ranges supported by
+ * pvlib {@code tracking.singleaxis()}; other options are normalized
+ * without validation.
+ *
+ *
+ * @param opt
+ * the command option
+ * @param val
+ * the proposed option value
+ * @return the normalized argument value, or {@literal null} if the value
+ * is not valid for the given option
+ */
+ private static String commandArgumentValue(CommandOptions opt, Object val) {
+ switch (opt) {
+ case Tracking:
+ case Backtrack: {
+ if ( val instanceof Boolean ) {
+ return val.toString();
+ }
+ String s = val.toString().trim();
+ return ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s)
+ ? s.toLowerCase(Locale.ROOT)
+ : null);
}
+
+ case AxisTilt:
+ return rangedDecimalArgumentValue(val, BigDecimal.ZERO, false, DEGREES_90);
+
+ case AxisAzimuth:
+ return rangedDecimalArgumentValue(val, BigDecimal.ZERO, false, DEGREES_360);
+
+ case MaxAngle:
+ return rangedDecimalArgumentValue(val, BigDecimal.ZERO, true, DEGREES_180);
+
+ case Gcr:
+ return rangedDecimalArgumentValue(val, BigDecimal.ZERO, true, BigDecimal.ONE);
+
+ default:
+ return (val instanceof BigDecimal ? ((BigDecimal) val).toPlainString()
+ : val.toString());
+ }
+ }
+
+ private static String rangedDecimalArgumentValue(Object val, BigDecimal min, boolean minExclusive,
+ BigDecimal max) {
+ BigDecimal n;
+ try {
+ n = (val instanceof BigDecimal ? (BigDecimal) val
+ : new BigDecimal(val.toString().trim()));
+ } catch ( NumberFormatException e ) {
+ return null;
+ }
+ final int minCompare = n.compareTo(min);
+ if ( (minExclusive ? minCompare <= 0 : minCompare < 0) || n.compareTo(max) > 0 ) {
+ return null;
}
+ return n.toPlainString();
}
private Map executeCommand(final Map args) {
From 3168476f560c7377640c3143fe98da14ec8abe91 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 07:31:00 +1200
Subject: [PATCH 6/7] Format files
---
.../node/datum/pvlib/PvlibPoaDatumFilterService.java | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
index 0aaefad35..8734d1262 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
@@ -402,8 +402,7 @@ private static String commandArgumentValue(CommandOptions opt, Object val) {
return rangedDecimalArgumentValue(val, BigDecimal.ZERO, true, BigDecimal.ONE);
default:
- return (val instanceof BigDecimal ? ((BigDecimal) val).toPlainString()
- : val.toString());
+ return (val instanceof BigDecimal ? ((BigDecimal) val).toPlainString() : val.toString());
}
}
@@ -411,8 +410,7 @@ private static String rangedDecimalArgumentValue(Object val, BigDecimal min, boo
BigDecimal max) {
BigDecimal n;
try {
- n = (val instanceof BigDecimal ? (BigDecimal) val
- : new BigDecimal(val.toString().trim()));
+ n = (val instanceof BigDecimal ? (BigDecimal) val : new BigDecimal(val.toString().trim()));
} catch ( NumberFormatException e ) {
return null;
}
From bcced840a3138b5a89670dd3f12a6b29b658a558 Mon Sep 17 00:00:00 2001
From: Elijah Passmore
Date: Mon, 31 Aug 2026 10:54:55 +1200
Subject: [PATCH 7/7] Add missing since tags
---
.../node/datum/pvlib/CommandOptions.java | 22 ++++++++++++++++---
.../pvlib/PvlibPoaDatumFilterService.java | 6 +++--
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
index 99ba8ad1b..8ce7cece2 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/CommandOptions.java
@@ -76,23 +76,39 @@ public enum CommandOptions {
*/
Tracking("--tracking", "tracking"),
- /** A tracker axis tilt angle value, in degrees from horizontal. */
+ /**
+ * A tracker axis tilt angle value, in degrees from horizontal.
+ *
+ * @since 1.2
+ */
AxisTilt("--axis-tilt", "pvAxisTilt"),
- /** A tracker axis angle value, in degrees clockwise from north. */
+ /**
+ * A tracker axis angle value, in degrees clockwise from north.
+ *
+ * @since 1.2
+ */
AxisAzimuth("--axis-azimuth", "pvAxisAzimuth"),
/**
* A maximum tracker rotation angle value, in degrees from horizontal.
+ *
+ * @since 1.2
*/
MaxAngle("--max-angle", "maxAngle"),
/**
* A tracker backtracking flag, as {@literal true} or {@literal false}.
+ *
+ * @since 1.2
*/
Backtrack("--backtrack", "backtrack"),
- /** A ground coverage ratio value, used for backtracking. */
+ /**
+ * A ground coverage ratio value, used for backtracking.
+ *
+ * @since 1.2
+ */
Gcr("--gcr", "gcr"),
;
diff --git a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
index 8734d1262..997b3fad4 100644
--- a/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
+++ b/net.solarnetwork.node.datum.filter.pvlib/src/net/solarnetwork/node/datum/pvlib/PvlibPoaDatumFilterService.java
@@ -346,6 +346,7 @@ private void populateArguments(Map cmdArguments, GeneralDatumMet
* the command option
* @param val
* the proposed option value; {@literal null} is ignored
+ * @since 1.3
*/
private void putCommandArgument(Map cmdArguments, CommandOptions opt, Object val) {
if ( val == null ) {
@@ -375,6 +376,7 @@ private void putCommandArgument(Map cmdArguments, CommandOptions
* the proposed option value
* @return the normalized argument value, or {@literal null} if the value
* is not valid for the given option
+ * @since 1.3
*/
private static String commandArgumentValue(CommandOptions opt, Object val) {
switch (opt) {
@@ -402,7 +404,7 @@ private static String commandArgumentValue(CommandOptions opt, Object val) {
return rangedDecimalArgumentValue(val, BigDecimal.ZERO, true, BigDecimal.ONE);
default:
- return (val instanceof BigDecimal ? ((BigDecimal) val).toPlainString() : val.toString());
+ return (val instanceof BigDecimal n ? n.toPlainString() : val.toString());
}
}
@@ -410,7 +412,7 @@ private static String rangedDecimalArgumentValue(Object val, BigDecimal min, boo
BigDecimal max) {
BigDecimal n;
try {
- n = (val instanceof BigDecimal ? (BigDecimal) val : new BigDecimal(val.toString().trim()));
+ n = (val instanceof BigDecimal d ? d : new BigDecimal(val.toString().trim()));
} catch ( NumberFormatException e ) {
return null;
}