Metadata-Version: 2.5
Name: sunsightenergy
Version: 0.3.2
Summary: Python client for the Sunsight Energy data API
Project-URL: Homepage, https://sunsightenergy.com
Project-URL: Documentation, https://docs.sunsightenergy.com
Project-URL: Repository, https://github.com/Sunsightenergy/sunsight-platform
Author-email: Sunsight Energy <support@sunsightenergy.com>
License: MIT
Keywords: electricity,energy,ercot,iso,lmp,market,pjm,sunsight
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Requires-Dist: pandas>=2.0.0
Requires-Dist: pyarrow>=14.0.0
Requires-Dist: requests>=2.31.0
Requires-Dist: tqdm>=4.66.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Provides-Extra: polars
Requires-Dist: polars>=0.20.0; extra == 'polars'
Description-Content-Type: text/markdown

# Sunsight Energy Python SDK

Python client for the [Sunsight Energy](https://sunsightenergy.com) data API:
real-time and historical data for US/Canadian ISOs (ERCOT, PJM, CAISO, SPP,
MISO, NYISO, ISONE, IESO), weather and satellite imagery.

You ask for **a dataset and a time range**. The SDK handles paging, your
plan's date-range limit, retries and file formats, and hands back a pandas or
polars DataFrame, parquet files on disk, or images in a folder.

## Install

```bash
pip install sunsightenergy            # pandas
pip install "sunsightenergy[polars]"  # + polars output
```

Python 3.10+. Works in Jupyter, Google Colab, scripts and pipelines.

## Quick start

```python
import sunsightenergy as ss

c = ss.Client("ss_live_...")          # or set SUNSIGHT_API_KEY and use ss.Client()

df = c.get("ercot_load", "2026-09-01", "2026-10-01")
df.plot(x="interval_end_utc", y="load")
```

Create a key in the portal under **API Platform → API Keys**.

## Coming from Grid Status

The client has gridstatusio's methods with the same arguments, so existing
code only needs a new import and key:

```python
from sunsightenergy import Client as GridStatusClient

client = GridStatusClient("ss_live_...")
df = client.get_dataset(
    dataset="ercot_load",
    start="2026-10-04",
    end="2026-10-05",
    timezone="market",
)
client.list_datasets(filter_term="lmp")
client.get_dataset_metadata("ercot_load")
client.get_api_usage()
```

`get_dataset` takes every Grid Status argument: `publish_time_start`,
`publish_time_end`, `publish_time` ("latest", "latest_report" or a
timestamp), `columns`, `filter_column` / `filter_value` / `filter_operator`
(including "in"), `resample` / `resample_by` / `resample_function`, `limit`,
`page_size`, `timezone`, `verbose` and `return_format` ("pandas", "polars",
"python"). Differences:

- `timezone` defaults to `"market"` (Grid Status defaults to UTC). Pass
  `timezone="UTC"` for Grid Status's default output.
- For forecasts, `start`/`end` are delivery times as on Grid Status; the
  client reads publications from up to 7 days before `start`. Pass
  `publish_time_start` for forecasts published further ahead.
- `get_daily_peak_report` isn't available.

## Loading data

```python
# pandas (default), polars, or a pyarrow Table
df  = c.get("pjm_load", "2026-09-01", "2026-10-01")
pdf = c.get("pjm_load", "2026-09-01", "2026-10-01", output="polars")

# One market day: ERCOT's operating day 2026-10-04, in Central time
df = c.get("ercot_load", day="2026-10-04")

# Fewer columns, a filter, a row limit, another timezone
df = c.get(
    "pjm_load", "2026-09-01", "2026-09-08",
    columns=["interval_start_utc", "pjm_rto"],
    filter=("pjm_rto", ">", 90000),        # ("col", value) means "="; a list value means "in"
    limit=1000,
    timezone="UTC",                        # "market" (default), "UTC", "CST"-style or any IANA name
)
```

- `start` is inclusive and `end` exclusive. They accept strings, `date`,
  `datetime` or `pd.Timestamp`. **A value without a timezone is read in
  `timezone`, which defaults to the market's own clock** (Central for ERCOT
  and SPP, Eastern for PJM, NYISO and ISO-NE, Pacific for CAISO, fixed EST
  for MISO and IESO). So `"2026-10-04"` on an ERCOT dataset is midnight
  Central. `end` defaults to now; `day=` is one whole day.
- Interval datasets filter on `interval_start_utc`, so a day is the intervals
  that start in it (as on Grid Status).
- Every `*_utc` column stays UTC and gets a `*_local` twin in the query
  timezone. `timezone="UTC"` returns only the UTC columns.
- Long ranges are split into the windows your plan allows and fetched in
  parallel where your plan allows, with one progress bar.
- Timestamps come back as real timezone-aware datetimes; numbers as numbers.

### Large ranges: save to disk

```python
paths = c.download("pjm_load", "2025-01-01", "2026-01-01", dir="data/")
# data/pjm_load/2025-01-01.parquet, data/pjm_load/2025-01-02.parquet, ...
```

- One parquet file per day in the query timezone (the market's by default).
  The folder is created if it doesn't exist.
- **Every run fetches every day again and overwrites its file**, so the
  folder always matches the server. A day that no longer has data has its
  old file removed.
- Read the folder back with any tool:

```python
import pandas as pd, polars as pl
pd.read_parquet("data/pjm_load")
pl.scan_parquet("data/pjm_load/*.parquet").collect()
# DuckDB: SELECT * FROM read_parquet('data/pjm_load/*.parquet')
```

### Bulk history: export (Enterprise)

```python
paths = c.export("pjm_load", "2016-01-01", "2026-01-01", dir="data/")
# data/pjm_load/2016-01-01.parquet, ... straight from storage
```

- Saves the stored files as they are: whole partitions (usually one UTC
  day), every column, and every publication of a forecast. Filter after
  loading.
- Much faster than `download()` for years of data, and it doesn't use your
  requests-at-a-time slots. Files already on disk with the same size are
  skipped.
- Available on plans without a monthly row cap.

### Process piece by piece

```python
for chunk in c.stream("pjm_load", "2025-01-01", "2026-01-01"):
    print(len(chunk))                     # one DataFrame per window; memory stays flat
```

## Forecasts

A forecast is published many times (ERCOT's 7-day load forecast: every hour),
so each delivery hour has many predictions. `start`/`end` select
**publications**: you get every forecast published in `[start, end)`, each
with its full horizon and its `publish_time_utc`.

```python
ds = "ercot_load_forecast_by_forecast_zone"
c.info(ds)                     # kind: forecast; time column (start/end): publish_time_utc

all_ = c.get(ds, "2026-10-01", "2026-10-02")                    # 24 publications x 7-day horizon
best = c.get(ds, "2026-10-01", "2026-10-02", vintage="latest")  # newest forecast per delivery hour
then = c.get(ds, as_of="2026-10-01T09:00Z",                     # the forecast as it stood at 09:00,
             start="2026-10-02", end="2026-10-03")              # cut to delivery on Oct 2
```

- `vintage="latest"` keeps, for each delivery interval, the newest publication
  in the window. Rows still carry `publish_time_utc`, so you see which won.
- `as_of=` returns one whole publication: the newest at or before that time
  (looking back up to 7 days). `start`/`end` are optional and cut its
  delivery times. Use it for backtests: "what did we know at 9am?"
- `download()` saves forecasts per **publish** day.
- Snapshot datasets (reference lists, outage snapshots) work the same way.

## IoT sensors

```python
c.devices()                    # device_id + when it last checked in
df = c.get("sensor_readings", "2026-10-02", "2026-10-03",
           devices=["sunsight-001"],     # default: all devices
           images=True)                  # adds image_url: the sky photo for that reading
```

Device data is stored as hundreds of small files per device-day, so each
request covers at most 7 days (`get()` splits longer ranges for you).
`images=True` counts each distinct photo against your monthly files & images
quota.

## Raw files

Original source files, byte for byte (PJM eDART outage reports, PJM
workbooks):

```python
f = c.files("pjm_transmission_outages_raw", "2026-10-02", "2026-10-03",
            source="linesout", dir="raw/")   # source= is optional: default all
f[["captured_at", "source", "filename", "local_path"]]
```

Same columns and options as `images()`, plus `source`. Files share the
monthly files & images quota.

## Images

```python
# A table of images with download links (links last ~15 minutes)
imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01")

# Also save them: imgs/goes19_regional_southcentral_truecolor/2026-09-30/<file>.jpg
imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01", dir="imgs/")
imgs[["captured_at", "local_path"]]
```

Columns: `captured_at` (UTC), `filename`, `size_bytes`, `url` and, with
`dir=`, `local_path`. Saving runs in parallel, skips files you already have,
and recovers when a link expires mid-run. On networks that block direct
storage links, pass `via="api"`.

## Finding datasets

```python
c.datasets()                          # everything your key can see
c.datasets(search="ercot load")       # every word must match id, name, description or tags
c.datasets(queryable=True)            # only what get() can serve today

c.info("pjm_load")                    # columns + types, time column, data range
c.columns("pjm_load")                 # just the columns: name, type, description, primary_key
```

A dataset with `queryable == False` has a `reason`, e.g. it is a file or
image dataset (use `files()` / `images()`).

## Errors

| Exception | When |
|---|---|
| `AuthError` | The key is missing or invalid. |
| `ForbiddenError` | Your plan or key doesn't allow it: a range over the plan limit, image history on the free plan, a dataset outside the key's scope, a revoked key. |
| `NotFoundError` | No such dataset. |
| `NotQueryableError` | The dataset exists, but `get()` can't serve it yet (see `reason`). |
| `QuotaExceededError` | This month's rows or images are used up. Raised immediately, never retried. |
| `RateLimitError` | Still over your plan's concurrent-request or requests-per-minute limit after waiting ~5 minutes. |
| `SunsightError` (code `too_large`, HTTP 413) | Even an hour of the range is too much data for one request (the SDK splits larger windows in half on its own). Use fewer columns. |

All of them subclass `ss.SunsightError` and include the server's message, the
HTTP `status_code`, and a machine-readable `code` such as `range_exceeded`,
`not_scoped`, `quota_rows`, `quota_images` or `rate_limited`:

```python
try:
    df = c.get("pjm_load", "2026-01-01", "2026-03-01")
except ss.ForbiddenError as e:
    if e.code == "range_exceeded":
        ...
```
Network errors and server errors are retried (3 times by default).

## Plan limits

Each plan limits:

| Plan | Requests at a time | Requests per minute | Days per request | Rows per month | Image history |
|---|---|---|---|---|---|
| Free | 1 | 60 | 7 | 500,000 | latest 1 day |
| Starter | 2 | 300 | 90 | 5,000,000 | full |
| Pro | 4 | 1,200 | 365 | 50,000,000 | full |
| Enterprise | 8 | 6,000 | 366 | unlimited | full |

The SDK reads your limits automatically (`c.usage()` shows them, plus rows
used this month), so a "days per request" limit never shows up in your code.
If you hit the per-minute limit, the SDK waits until the next minute and
carries on (one log line says so). Every response carries
`X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.

## Notebooks, scripts and pipelines

```python
c = ss.Client(
    progress=None,        # None: bars in notebooks/terminals, off when output is redirected
    log_level="INFO",     # one line per call; "DEBUG" for every request; "WARNING" for quiet
)
```

- `SUNSIGHT_API_KEY`: the key, so it stays out of your notebook.
- `SUNSIGHT_BASE_URL`: point at another environment (e.g. `http://localhost:8001`).
- `SUNSIGHT_PROGRESS=0`: turn progress bars off everywhere (CI, cron).
- Logs go to the standard `logging` logger named `sunsightenergy`. If your app
  already configures logging, the SDK adds no handler of its own.

## Without the SDK

```bash
curl -H "X-API-Key: ss_live_..." \
  "https://api.sunsightenergy.com/v1/datasets/pjm_load/query?start_time=2026-09-01&end_time=2026-09-02"
```

Add `return_format=csv` or `return_format=parquet` for those formats. The
next page's cursor is in the `X-Next-Cursor` response header.

## License

MIT
