# 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.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.ecosuite.io/user-guide/modules/data/project/expected-energy-calculation-setup.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
