> For the complete documentation index, see [llms.txt](https://docs.ecosuite.io/user-guide/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ecosuite.io/user-guide/modules/data/project/expected-energy-calculation-setup.md).

# Expected Energy Calculation Setup

{% hint style="warning" %}
**Preview feature.** Two setup paths: the quick setup using the Ecosuite package, or the manual setup for custom algorithms. The manual path assumes familiarity with [SolarNode expressions](https://solarnetwork.github.io/solarnode-handbook/users/expressions/).
{% endhint %}

### Overview

By default, Ecosuite computes expected energy from each SolarNetwork datum using the configured solar array. This setup lets you compute `expectedWattHours` *inside* SolarNetwork instead, giving you full control over the algorithm and the ability to layer in things like GHI→POA transposition later.

**At a glance:**

1. Get an `expectedWattHours` field onto your GEN source datums, either via the **Ecosuite package** (recommended) or by configuring datum filters manually.
2. Tick **Calculate Expected in SolarNetwork** on the corresponding Ecosuite system.

```mermaid
flowchart TD
  PYR["PYR Device<br>(Pyranometer)"] -->|Raw irradiance| Filter1
  Metadata["System Metadata<br>panel area, efficiency,<br>perf ratio, tilt,<br>degradation, commission date"]

  subgraph SolarNetwork
    Filter1["Filter: irradiance → irradiancePOA"] --> Filter2
    Metadata --> Filter2
    Filter2["Filter: calculates expectedWattHours"] --> GEN["GEN node datum<br>with expectedWattHours"]
  end

  GEN -->|API call| Ecosuite["Ecosuite"]
  Setting["Ecosuite setting:<br>Calculate Expected in SolarNetwork ✓"] -->|Enables| Ecosuite
  Ecosuite --> Display["Use pre-calculated value"]

  classDef primary fill:#d4f1f9,stroke:#05728f,stroke-width:2px
  classDef secondary fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
  classDef highlight fill:#d5e8d4,stroke:#82b366,stroke-width:2px
  class PYR,Metadata secondary
  class Filter1,Filter2,GEN primary
  class Setting,Ecosuite,Display highlight
```

### Quick setup (recommended)

If you just want expected energy working, install the Ecosuite `ecosuite-solarnode-config-expected-energy` package on the node - it provisions the datum filter chain, the `irradiancePOA` filter, and the `expectedWattHours` filter for you. After install, you configure everything through an **Expected Energy System Configuration** menu rather than writing SpEL by hand.

1. **Install the package** on the SolarNode.
2. **Open the Expected Energy System Configuration menu** and fill in your system characteristics (panel area, efficiency, performance ratio, tilt factor, degradation rate, commission date, nameplate AC max power, PYR source ID, GEN source ID, timezone).
3. **Save.** The package writes the underlying metadata and datum filters automatically.
4. **Enable in Ecosuite** - on the matching system, tick **Calculate Expected in SolarNetwork** on the GEN source.

Skip to Verifying to confirm it's working.

If you need a custom algorithm (e.g. a different formula, GHI→POA transposition via a microservice, or anything the menu doesn't expose), use the manual setup below instead, or use it on top of the package by editing the filters it created.

### Verifying

Fetch the latest datum for the GEN source and confirm `expectedWattHours` is present:

```json
{
  "sourceId": "/FOO/BAR/BAZ/GEN/1",
  "created": "2025-05-14 23:54:37.003Z",
  "expectedWattHours": 439,
  "wattHours": 1758767000,
  "...": "..."
}
```

If it's missing or wrong, check:

* The package's menu fields are all populated (quick setup), **or** service names match across the datum filter chain and its components (manual setup).
* Required metadata is set.
* SpEL syntax - see the [SolarNode logs](https://solarnetwork.github.io/solarnode-handbook/users/logging/) for evaluation errors, or refer to the [SolarNetwork SpEL tests](https://github.com/SolarNetwork/solarnetwork-node/blob/master/net.solarnetwork.node.internal.test/src/net/solarnetwork/node/domain/test/ExpressionRootTests.java) for inspiration.

***

### Manual setup (advanced)

The package above does all of this for you. Follow this section only if you want a custom algorithm, are working on a node where the package isn't available, or want to understand what the package is doing under the hood.

> Paths below use `/FOO/BAR/BAZ/**` as a placeholder - swap in your own.

#### 1. Create a datum filter chain

If one doesn't already exist on the node, create a new **datum filter chain**. It will hold two components in order:

1. Irradiance POA
2. Expected Generation

Service names are arbitrary, but keep them consistent across the chain and the individual filters.

#### 2. Populate `irradiancePOA`

Create an **expression datum filter component** attached to the PYR device. Set **Property** to `irradiancePOA` and **Property Type** to `Instantaneous`.

```javascript
latest('/FOO/BAR/BAZ/PYR/1')?.irradiance != null
  ? latest('/FOO/BAR/BAZ/PYR/1')?.irradiance
  : null
```

This copies `irradiance` to `irradiancePOA` unchanged. The separate field is useful later if you want to swap in GHI→POA transposition — for example, calling a microservice:

```javascript
has('irradiance') ? httpGet('http://example.solarnetwork:8000/ghi-to-poa', union(nodeMetadata('/pm/pv-characteristics-poa'), {
  date: local(timestamp.atZone(nodeMetadata('/pm/pv-characteristics-poa/zone'))),
  irradiance: irradiance
}), {'Authorization': httpBasic('foo', 'bar')})?.data?.poa_global : null
```

Downstream filters that need POA irradiance then read it directly from SolarNetwork. You can add this transposition later, start with the simple copy.

#### 3. Add system metadata

Store configurable values (panel area, efficiency, performance ratio, tilt factor, degradation rate, commission date, timezone, nameplate AC max power) as [SolarNetwork node metadata](https://github.com/SolarNetwork/solarnetwork/wiki/SolarNet-API-global-objects#metadata) rather than hardcoding them.

If you already use the [POAI Calculator metadata](https://solarnetwork.github.io/solarnode-handbook/users/datum-filters/pvlib/#metadata-parameters), reuse that `pv-characteristics` path. Otherwise pick your own, the `pm` namespace is arbitrary.

#### 4. Populate `expectedWattHours`

This step is **required:** Ecosuite reads this field by name.

Create an expression datum filter component on the GEN source. Set **Property** to `expectedWattHours` and **Property Type** to `Instantaneous`.

The algorithm:

$$
E = \min\left(\left\lfloor I\_{POA} \cdot A\_p \cdot \eta\_p \cdot R\_{perf} \cdot F\_{tilt} \cdot \min\left((1-d)^y,, 1\right) \right\rfloor,\ P\_{max}\right)
$$

| Symbol        | Meaning                           |
| ------------- | --------------------------------- |
| $$I\_{POA}$$  | Irradiance on plane of array      |
| $$A\_p$$      | Panel area                        |
| $$\eta\_p$$   | Panel efficiency                  |
| $$R\_{perf}$$ | Performance ratio (system losses) |
| $$F\_{tilt}$$ | Array tilt factor                 |
| $$d$$         | Annual degradation rate           |
| $$y$$         | Years since commission date       |
| $$P\_{max}$$  | Nameplate AC max power            |

If `irradiancePOA` or `panelArea` is missing, return `null`. Otherwise compute the product, floor it, and clip to nameplate.

> If you skipped step 2, substitute `irradiance` for `irradiancePOA` in the snippets below.

{% tabs %}
{% tab title="With metadata (recommended)" %}

```javascript
latest('/FOO/BAR/BAZ/PYR/1')?.irradiancePOA != null
  && getInfoNumber('pv-characteristics', 'panelArea') != null
? min(roundDown(latest('/FOO/BAR/BAZ/PYR/1')?.irradiancePOA
    * getInfoNumber('pv-characteristics', 'panelArea')
    * getInfoNumber('pv-characteristics', 'panelEfficiency')
    * getInfoNumber('pv-characteristics', 'performanceRatio')
    * getInfoNumber('pv-characteristics', 'panelArrayTiltFactor')
    * min(pow(
        1 - getInfoNumber('pv-characteristics', 'degradationRate'),
        yearsBetween(
          date(getInfoString('pv-characteristics', 'pvArrayCommissionDate')),
          today(getInfoString('pv-characteristics', 'zone'))
        )
      ), 1)
  , 0), getInfoNumber('pv-characteristics', 'nameplateAcMaxPower'))
: null
```

{% endtab %}

{% tab title="Without metadata (hardcoded)" %}
Sample values shown — yours will differ. Hardcoding produces magic numbers that are hard to maintain; prefer metadata.

```javascript
latest('/FOO/BAR/BAZ/PYR/1')?.irradiancePOA != null
? min(roundDown(latest('/FOO/BAR/BAZ/PYR/1')?.irradiancePOA
    * /* TODO: panel area */
    * 0.192   /* panel efficiency */
    * 0.192   /* performance ratio */
    * 1       /* tilt factor */
    * min(pow(
        1 - 0.0063,
        yearsBetween(date("2021-12-22"), today("America/New_York"))
      ), 1)
  , 0), 333300)
: null
```

{% endtab %}
{% endtabs %}

#### 5. Enable in Ecosuite

On the matching system in Ecosuite, tick **Calculate Expected in SolarNetwork** on the GEN source. Ecosuite will now use the pre-calculated `expectedWattHours` from each datum instead of computing it.

### Extending

Because the calculation lives in SolarNetwork, you can evolve it without Ecosuite knowing. For example, swap step 2 for a [GHI→POA transposition microservice](https://github.com/SolarNetwork/solarnetwork-node/blob/c044fcb101e9f3fd5a49f6bceaf02dcd72a03f15/net.solarnetwork.node.datum.filter.pvlib/def/ghi-to-poa.py#L27) backed by [pvlib](https://pvlib-python.readthedocs.io/en/stable/), and every downstream calculation picks it up automatically.

#### Cloud Integrations setup

The setup above runs on a SolarNode. If a system's irradiance and generation data instead arrives through a Cloud Integration, the same `expectedWattHours` field is produced with **expression Mapping Properties** on the Cloud Datum Stream Mapping rather than expression datum filter components. The algorithm is identical; only where you configure it changes. The `ecosuite-solarnode-config-expected-energy` package does not apply here - this is the manual path for cloud-sourced systems.

> Paths below use `PYR/1` and `GEN/1` as placeholders - swap in your own source IDs.

**1. Confirm both streams share a mapping**

Expression Mapping Properties are evaluated against **all resolved datum** for a request, so the `expectedWattHours` expression on the GEN stream can read the PYR stream's values. For this to work within a single request, both streams must be produced by the same Cloud Datum Stream Mapping.

**2. Populate `irradiancePOA`**

Add an expression Mapping Property with **Property Type** `Instantaneous` (`i`) and **Property Name** `irradiancePOA`, restricted to the PYR stream:

```javascript
sourceId == 'PYR/1' ? irradiance : null
```

As in the manual setup, this copies `irradiance` unchanged, and you can later swap in a GHI→POA transposition microservice:

```javascript
sourceId == 'PYR/1' && has('irradiance')
  ? httpGet('https://example.com/ghi-to-poa',
      union(nodeMetadata('/pm/pv-characteristics'), {
        date: local(timestamp.atZone(nodeMetadata('/pm/pv-characteristics/zone'))),
        irradiance: irradiance
      }), {'Authorization': httpBasic('foo', 'bar')})?.data?.poa_global
  : null
```

Define this expression **before** the `expectedWattHours` expression - Mapping Property expressions evaluate in the order configured.

**3. Add node metadata**

Store the system characteristics as SolarNetwork node metadata on the node ID configured on the Cloud Datum Stream, exactly as in step 3 of the manual setup.

**4. Populate `expectedWattHours`**

Add an expression Mapping Property with **Property Type** `Instantaneous` (`i`) and **Property Name** `expectedWattHours`, restricted to the GEN stream. Same algorithm, reading the PYR stream's `irradiancePOA` via `latest()` and the characteristics via `nodeMetadata()`:

```javascript
sourceId == 'GEN/1'
  && latest('PYR/1', timestamp)?.irradiancePOA != null
  && nodeMetadata('/pm/pv-characteristics/panelArea') != null
? min(roundDown(latest('PYR/1', timestamp).irradiancePOA
    * nodeMetadata('/pm/pv-characteristics/panelArea')
    * nodeMetadata('/pm/pv-characteristics/panelEfficiency')
    * nodeMetadata('/pm/pv-characteristics/performanceRatio')
    * nodeMetadata('/pm/pv-characteristics/panelArrayTiltFactor')
    * min(pow(
        1 - nodeMetadata('/pm/pv-characteristics/degradationRate'),
        yearsBetween(
          date(nodeMetadata('/pm/pv-characteristics/pvArrayCommissionDate')),
          today(nodeMetadata('/pm/pv-characteristics/zone'))
        )
      ), 1)
  , 0), nodeMetadata('/pm/pv-characteristics/nameplateAcMaxPower'))
: null
```

`nodeMetadata('/pm/pv-characteristics/panelArea')` is the Cloud expression equivalent of the SolarNode `getInfoNumber('pv-characteristics', 'panelArea')`.

**5. Enable in Ecosuite**

Tick **Calculate Expected in SolarNetwork** on the GEN source as before. Ecosuite uses the pre-calculated `expectedWattHours` regardless of whether it came from a node datum filter or a Cloud Datum Stream Mapping expression.

**Caveats**

* **Expression order matters.** `irradiancePOA` must be configured before `expectedWattHours`. If a derived property on one stream isn't visible to a later expression, persist `irradiancePOA` so `latest()` reads it back from stored data on subsequent requests, or inline the copy into the `expectedWattHours` expression.
* **Time alignment.** Cloud Datum Streams poll on a schedule. Passing `timestamp` as the `date` argument selects the PYR datum nearest in time to the GEN datum being evaluated.
