# Customer Metrics

Read the signed-in customer's savings, peaks avoided, flexibility, comfort, plan adherence, and a privacy-preserving ranking against comparable homes via /api/v1/me.

The `/api/v1/me` endpoints return the signed-in customer's own headline numbers — savings, peaks avoided, flexibility, comfort, plan adherence, connected machines, and a ranking against comparable homes. Use them to build a "what did my system do for me" dashboard.

Every query is filtered to the signed-in user: these routes return **only the caller's own figures** and never expose another customer's data. Metrics cover completed days, so a newly connected home fills in over its first days of operation.

> **Caution: Render null as an em dash, never a zero**
>
> A metric with no data yet serializes as JSON `null`, never a fabricated `0`. Render `null` as an em dash ("—") rather than a zero — a zero would read as "did nothing" when the truth is "nothing measured yet". A customer with no history yet gets a `200` with null fields, **not a `404`**.

## GET /api/v1/me/metrics/summary

Trailing-window aggregate of the caller's savings, peaks avoided, flexibility, comfort, plan adherence and connected machines — one call for a headline row.

**Auth:** Required (Bearer JWT)

### Query parameters

| Param | Required | Type | Notes |
|---|---|---|---|
| `window` | No | integer | Trailing days to aggregate. Default `30`, minimum `1`, maximum `90`. A value above 90 returns `400`; a value below 1, or a non-integer, returns `422`. |

Cumulative fields (dollars saved, hours idle, override events) are **summed** over the window; intensity fields (percentages, degree-hours) are **meaned**, so those figures stay comparable regardless of how many days of data a home has.

### Example

```bash
curl "https://api.hungrymachines.io/api/v1/me/metrics/summary?window=30" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

### Response (200)

```json
{
  "window": 30,
  "latest_date": "2026-08-02",
  "days_counted": 2,
  "savings": {
    "modeled_usd": 4.0,
    "realized_usd": 2.0,
    "realized_days": 1,
    "modeled_days": 2,
    "savings_pct": 20.0,
    "degenerate": false
  },
  "peaks": {
    "peak_hours_idle": 6.0,
    "top_peak_hours_idle": 3.0,
    "off_peak_kwh_share_pct": 55.0
  },
  "flexibility": {
    "degree_hours": 336.0,
    "percent_hours": null
  },
  "comfort": { "in_band_home_pct": 96.0 },
  "adherence": { "conformance_pct": 95.0, "override_events": 3, "override_by_hour": [0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0] },
  "machines": { "active": 2, "by_type": { "hvac": 1, "ev_charger": 1 } }
}
```

### Response fields

| Field | Type | Description |
|---|---|---|
| `latest_date` | string \| null | Most recent day the window is anchored on (`YYYY-MM-DD`); `null` when the customer has no history yet. |
| `days_counted` | integer | Number of days inside the window that contributed data. |
| `savings.modeled_usd` | float \| null | Projected savings the plan aimed for, summed over the window (USD). |
| `savings.realized_usd` | float \| null | Savings measured from actual consumption, summed over the window (USD). `null` when no day in the window could be measured. |
| `savings.realized_days` / `savings.modeled_days` | int | How many days in the window contributed each total. The two differ whenever a day could not be measured, so present the day counts alongside the sums rather than comparing them directly. `0` when nothing contributed. |
| `savings.savings_pct` | float \| null | Mean daily savings percent. |
| `savings.degenerate` | boolean | `true` when `savings_pct` is ≤ 0 or ≥ 95 — a near-zero or implausibly-high figure a client should flag as a caution, not a win. `false` when `savings_pct` is `null`. |
| `peaks.peak_hours_idle` | float \| null | Total hours the home's machines sat idle during the day's priciest quarter, summed over the window. |
| `peaks.top_peak_hours_idle` | float \| null | Same measure over the **three priciest hours** only (max 3.0 per day), summed over the window. Always ≤ `peak_hours_idle`. |
| `peaks.off_peak_kwh_share_pct` | float \| null | Mean share of daily kWh that landed in the cheapest slots. |
| `flexibility.degree_hours` | float \| null | Mean daily comfort-band width, in degree-hours (thermal machines). Room the customer gave the plan to shift load. |
| `flexibility.percent_hours` | float \| null | Mean charge-window slack percent (shiftable machines — EV, battery). |
| `comfort.in_band_home_pct` | float \| null | Mean percent of the day the home stayed inside its comfort band. Show this alongside flexibility so the page never reads as "give up comfort to win". |
| `adherence.conformance_pct` | float \| null | Mean plan-delivery conformance across the customer's machines — how reliably we delivered the plan. A shortfall is our delivery problem, kept separate from the customer's own overrides. |
| `adherence.override_events` | float \| null | Total manual-override events over the window. |
| `adherence.override_by_hour` | `array[int]` (length 24) \| null | Hour-of-day histogram (index `0` = local midnight) of when the customer overrode the plan, summed over the window — render it as a 24-bar chart. `null` (never a fabricated 24 zeros) until there is histogram data. Reflects the customer's decisions only; never combined with delivery conformance. |
| `machines.active` | integer | Count of the customer's active machines. A real count — a `0` here is legitimate, not a missing metric. |
| `machines.by_type` | object | Per-type count of active machines, keyed by machine type (`hvac`, `ev_charger`, `home_battery`, `water_heater`, `solar`, `dehumidifier`, `robot`). Only types the customer has appear. |

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"window exceeds 90 days"` | `window` greater than 90 |
| 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token |
| 422 | array `detail` | `window` below 1, or not an integer |

---

## GET /api/v1/me/metrics/daily

The same savings / peaks / flexibility / comfort metrics as `/summary`, but **per day and un-aggregated**, so a dashboard can chart a trend rather than a single number.

**Auth:** Required (Bearer JWT)

### Query parameters

| Param | Required | Type | Notes |
|---|---|---|---|
| `start` | No | string (ISO date `YYYY-MM-DD`) | Window start, inclusive. Default `end − 30 days`. |
| `end` | No | string (ISO date `YYYY-MM-DD`) | Window end, inclusive. Default the caller's local today. |

Send both dates as `YYYY-MM-DD`. The window covers at most **90 calendar days inclusive** of both `start` and `end` (matching `/summary` and `/rankings`' `window` cap of 90); a wider span, or `start` after `end`, returns `400`.

> **Note: Omit, don't zero-fill**
>
> Each entry is one calendar day's own metrics. Days with no data are **omitted** from `days` (not zero-filled), so a gap reads as "no data that day" rather than a fabricated `0`. Entries are ordered oldest → newest. Within a day, any metric with no value is JSON `null` (render as an em dash), never `0`. A window with no data returns a `200` with an empty `days` list, not a `404`.

### Example

```bash
curl "https://api.hungrymachines.io/api/v1/me/metrics/daily?start=2026-07-04&end=2026-08-03" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

### Response (200)

```json
{
  "start": "2026-07-04",
  "end": "2026-08-03",
  "days": [
    {
      "date": "2026-08-01",
      "savings": { "modeled_usd": 1.0, "realized_usd": 0.5, "savings_pct": 10.0, "degenerate": false },
      "peaks": { "peak_hours_idle": 2.0, "top_peak_hours_idle": 1.5, "off_peak_kwh_share_pct": 50.0 },
      "flexibility": { "degree_hours": 336.0, "percent_hours": null },
      "comfort": { "in_band_home_pct": 96.0 }
    }
  ]
}
```

Per-day field meanings match the corresponding `/summary` fields above; `savings.degenerate` is computed for that single day.

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"window exceeds 90 days"` / `"start must be <= end"` | `start` more than 90 days before `end`, or reversed range |
| 400 | `"start must be an ISO date (YYYY-MM-DD)"` / `"end must be an ISO date (YYYY-MM-DD)"` | `start`/`end` is not a valid ISO date |
| 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token |

---

## GET /api/v1/me/metrics/day/{'{date}'}

One local calendar day's **plan versus actual** for the caller's HVAC: the plan the system intended (the 48-slot setpoints, the comfort band, the price curve), the measured indoor temperature, and where plan and reality diverged — split into two **separate** lists so an override (the customer's own decision) is never conflated with a conformance defect (a plan the service failed to deliver).

**Auth:** Required (Bearer JWT)

### Path parameters

| Param | Required | Type | Notes |
|---|---|---|---|
| `date` | Yes | string (ISO date `YYYY-MM-DD`) | The local calendar day to render. A non-ISO value returns `400`. |

### Query parameters

| Param | Required | Type | Notes |
|---|---|---|---|
| `appliance_id` | No | string (uuid) | Which HVAC to render. Defaults to the caller's first active HVAC. An id that is not one of the caller's active HVAC appliances is ignored and the default is used; the response's `appliance_id` names the unit shown. |

> **Note: Empty, never 404**
>
> A day with no plan, no active HVAC, or no readings returns a `200`: `overrides` and `conformance_defects` are `[]`, plan arrays are `null`, and `indoor_temps` is 48 `null`s. Missing indoor slots stay `null` (never `0`) so a chart breaks the line across a gap rather than drawing through it.

### Example

```bash
curl "https://api.hungrymachines.io/api/v1/me/metrics/day/2026-06-08" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

### Response (200)

```json
{
  "date": "2026-06-08",
  "appliance_id": "9191a36c-9b61-444a-ac3a-666b7912514e",
  "setpoints": [72.0, 72.0, "… 48 …"],
  "comfort_band": { "high": [74.0, "… 48 …"], "low": [70.0, "… 48 …"] },
  "price_curve": [12.4, 12.1, "… 48 …"],
  "indoor_temps": [71.0, null, "… 48 …"],
  "overrides": [
    { "start": "2026-06-08T10:10:00+00:00", "end": "2026-06-08T10:20:00+00:00", "start_hour": 10, "readings": 3 }
  ],
  "conformance_defects": [
    { "start": "2026-06-08T12:10:00+00:00", "end": "2026-06-08T12:15:00+00:00", "start_hour": 12, "readings": 2 }
  ]
}
```

### Response fields

| Field | Type | Description |
|---|---|---|
| `appliance_id` | string \| null | The HVAC this view describes; `null` when the caller has no active HVAC. |
| `setpoints` | `array[float]` \| null | The 48-slot planned setpoints (slot 0 = local midnight). `null` when there is no plan for the day. |
| `comfort_band.high` / `low` | `array[float]` \| null | The 48-slot comfort-band bounds, for display. `null` when absent. |
| `price_curve` | `array[float]` \| null | The 48-slot price curve in the feed's own minor units (¢/kWh, pence, etc.). `null` when unavailable. |
| `indoor_temps` | `array[float \| null]` | Measured indoor temperature averaged into the 48 local half-hour slots; a slot with no reading is `null`. |
| `overrides` | `array[object]` | Episodes where the customer changed the setpoint away from the plan. Each: `{start, end, start_hour, readings}` — `start` / `end` are UTC ISO timestamps, `start_hour` is the local hour, `readings` is the run length. |
| `conformance_defects` | `array[object]` | Runs where the plan was not delivered to the thermostat — **the service's** delivery failure, kept strictly apart from `overrides`. Same shape. Never mix these into an override count. |

> **Caution: Overrides and defects are different things**
>
> An **override** is the customer's own decision; a **conformance defect** is a plan the service failed to deliver. They are returned in separate lists on purpose — never sum them together or present a defect as if the customer caused it.

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"date must be ISO YYYY-MM-DD"` | Malformed `date` path value |
| 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token |

---

## GET /api/v1/me/rankings

How the caller stacks up against comparable homes — **without revealing anything about those homes**. For each of four higher-is-better metrics, the route compares the caller's own value over the window with comparable homes, and returns **only the caller's rank, percentile and own value** — never the cohort's size, and never another home's value.

**Auth:** Required (Bearer JWT)

### Query parameters

| Param | Required | Type | Notes |
|---|---|---|---|
| `window` | No | integer | Trailing days the ranking window spans. Default `30`, minimum `1`, maximum `90`. A value above 90 returns `400`; a value below 1, or a non-integer, returns `422`. Use the default: rankings are available for the 30-day window, and another window may return null ranks. |

**Ranked metrics** — all higher-is-better, so `rank` is 1-based with `#1` the best:

| Metric | What it measures |
|---|---|
| `savings_pct` | Savings as a percent, not dollars. |
| `off_peak_kwh_share_pct` | Share of energy drawn in the cheapest hours. |
| `flexibility_degree_hours` | Comfort-band width offered to the plan. |
| `active_appliances` | Count of connected machine types. |

**Cohorts.** The caller is ranked against the closest group of comparable homes available. `cohort_kind` names that group (`zip5`, `zip3`, `rate_peers` or `fleet`); `cohort_label` is a generic, share-safe string — `"your ZIP code"`, `"your area"`, `"your rate plan"`, `"all Hungry Machines homes"` — that **never contains raw ZIP digits or any identifier**. It is safe to render on a share card.

> **Caution: Privacy contract**
>
> The response carries **no cohort population size** under any key (no `n`, `count`, `total`, `size`) and **no other home's value** — only the caller's `rank`, `percentile` and `your_value`. A metric the caller can't be ranked on yet (no eligible value, or no comparable cohort) carries a **`null` rank — never `0` and never a rank of `0`**. A home with no ranking data at all still gets a `200` with its own values and null ranks, not a `404`.

### Example

```bash
curl "https://api.hungrymachines.io/api/v1/me/rankings?window=30" \
  -H "Authorization: Bearer YOUR_TOKEN"
```

### Response (200)

```json
{
  "window": 30,
  "latest_date": "2026-08-03",
  "rankings": [
    {
      "metric": "savings_pct",
      "cohort_kind": "zip5",
      "cohort_label": "your ZIP code",
      "rank": 1,
      "percentile": 100.0,
      "your_value": 22.5
    },
    {
      "metric": "off_peak_kwh_share_pct",
      "cohort_kind": "rate_peers",
      "cohort_label": "your rate plan",
      "rank": 3,
      "percentile": 62.5,
      "your_value": 55.0
    },
    {
      "metric": "flexibility_degree_hours",
      "cohort_kind": "fleet",
      "cohort_label": "all Hungry Machines homes",
      "rank": null,
      "percentile": null,
      "your_value": null
    },
    {
      "metric": "active_appliances",
      "cohort_kind": "zip5",
      "cohort_label": "your ZIP code",
      "rank": 1,
      "percentile": 100.0,
      "your_value": 2.0
    }
  ]
}
```

### Response fields

| Field | Type | Description |
|---|---|---|
| `latest_date` | string \| null | The date the ranking is anchored on (`YYYY-MM-DD`); `null` when the caller has no data. |
| `rankings[].metric` | string | One of the four ranked metrics above. All four are always present. |
| `rankings[].cohort_kind` | string \| null | The cohort the rank is against (`zip5` / `zip3` / `rate_peers` / `fleet`); `null` when no cohort qualifies. |
| `rankings[].cohort_label` | string \| null | Generic, share-safe label for that cohort. Never contains ZIP digits. `null` when no cohort qualifies. |
| `rankings[].rank` | integer \| null | 1-based, higher-is-better rank within the cohort. `null` (never `0`) when the caller has no eligible value or no cohort. |
| `rankings[].percentile` | float \| null | Percent of the cohort at or below the caller's value (higher is better). `null` when unranked. |
| `rankings[].your_value` | float \| null | The caller's **own** windowed value for the metric. `null` when they have no eligible day. |

### Errors

| Status | Detail | Cause |
|---|---|---|
| 400 | `"window exceeds 90 days"` | `window` greater than 90 |
| 401 | `"Not authenticated"` / `"Invalid token"` / `"Token expired"` | No `Authorization` header / malformed token / expired token |
| 422 | array `detail` | `window` below 1, or not an integer |

Canonical page: https://docs.hungrymachines.io/api-reference/metrics/
