sunsightenergy 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # Dependencies
2
+ node_modules/
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ venv/
7
+ temp/
8
+ # Build
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+
13
+ # Environment — only .env.local files are gitignored (secrets)
14
+ # .env.dev and .env.prod contain non-secret config and ARE committed
15
+ .env
16
+ .env.local
17
+ *.env.local
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+
24
+ # Claude Code — personal overrides (stay out of git); .claude/settings.json IS committed
25
+ .claude/settings.local.json
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Logs
32
+ *.log
33
+
34
+ # Testing
35
+ coverage/
36
+ htmlcov/
37
+ .pytest_cache/
38
+
39
+ # uv
40
+ .python-version
41
+
42
+ # TypeScript incremental build cache (generated by `tsc -b` during builds)
43
+ *.tsbuildinfo
44
+
45
+ # Ruff cache
46
+ .ruff_cache/
47
+
48
+ # Throwaway local backend env files used for manual smoke testing. Some
49
+ # variants (.env.smoketest.aws/.real) hold real AWS creds copied in by hand —
50
+ # never let any dotted suffix of these slip into git.
51
+ .env.smoketest
52
+ .env.smoketest.*
53
+ *.env.smoketest
54
+
55
+ # Local S3 fixture data used for smoke testing client-api against fake buckets.
56
+ data-pipeline/.local_s3/
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.5
2
+ Name: sunsightenergy
3
+ Version: 0.3.0
4
+ Summary: Python client for the Sunsight Energy data API
5
+ Project-URL: Homepage, https://sunsightenergy.com
6
+ Project-URL: Documentation, https://docs.sunsightenergy.com
7
+ Project-URL: Repository, https://github.com/Sunsightenergy/sunsight-platform
8
+ Author-email: Sunsight Energy <support@sunsightenergy.com>
9
+ License: MIT
10
+ Keywords: electricity,energy,ercot,iso,lmp,market,pjm,sunsight
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: pandas>=2.0.0
22
+ Requires-Dist: pyarrow>=14.0.0
23
+ Requires-Dist: requests>=2.31.0
24
+ Requires-Dist: tqdm>=4.66.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
27
+ Provides-Extra: polars
28
+ Requires-Dist: polars>=0.20.0; extra == 'polars'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Sunsight Energy Python SDK
32
+
33
+ Python client for the [Sunsight Energy](https://sunsightenergy.com) data API:
34
+ real-time and historical data for US/Canadian ISOs (ERCOT, PJM, CAISO, SPP,
35
+ MISO, NYISO, ISONE, IESO), weather and satellite imagery.
36
+
37
+ You ask for **a dataset and a time range**. The SDK handles paging, your
38
+ plan's date-range limit, retries and file formats, and hands back a pandas or
39
+ polars DataFrame, parquet files on disk, or images in a folder.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install sunsightenergy # pandas
45
+ pip install "sunsightenergy[polars]" # + polars output
46
+ ```
47
+
48
+ Python 3.10+. Works in Jupyter, Google Colab, scripts and pipelines.
49
+
50
+ ## Quick start
51
+
52
+ ```python
53
+ import sunsightenergy as ss
54
+
55
+ c = ss.Client("ss_live_...") # or set SUNSIGHT_API_KEY and use ss.Client()
56
+
57
+ df = c.get("ercot_load", "2026-09-01", "2026-10-01")
58
+ df.plot(x="interval_end_utc", y="load")
59
+ ```
60
+
61
+ Create a key in the portal under **API Platform → API Keys**.
62
+
63
+ ## Coming from Grid Status
64
+
65
+ The client has gridstatusio's methods with the same arguments, so existing
66
+ code only needs a new import and key:
67
+
68
+ ```python
69
+ from sunsightenergy import Client as GridStatusClient
70
+
71
+ client = GridStatusClient("ss_live_...")
72
+ df = client.get_dataset(
73
+ dataset="ercot_load",
74
+ start="2026-10-04",
75
+ end="2026-10-05",
76
+ timezone="market",
77
+ )
78
+ client.list_datasets(filter_term="lmp")
79
+ client.get_dataset_metadata("ercot_load")
80
+ client.get_api_usage()
81
+ ```
82
+
83
+ `get_dataset` takes every Grid Status argument: `publish_time_start`,
84
+ `publish_time_end`, `publish_time` ("latest", "latest_report" or a
85
+ timestamp), `columns`, `filter_column` / `filter_value` / `filter_operator`
86
+ (including "in"), `resample` / `resample_by` / `resample_function`, `limit`,
87
+ `page_size`, `timezone`, `verbose` and `return_format` ("pandas", "polars",
88
+ "python"). Differences:
89
+
90
+ - `timezone` defaults to `"market"` (Grid Status defaults to UTC). Pass
91
+ `timezone="UTC"` for Grid Status's default output.
92
+ - For forecasts, `start`/`end` are delivery times as on Grid Status; the
93
+ client reads publications from up to 7 days before `start`. Pass
94
+ `publish_time_start` for forecasts published further ahead.
95
+ - `get_daily_peak_report` isn't available.
96
+
97
+ ## Loading data
98
+
99
+ ```python
100
+ # pandas (default), polars, or a pyarrow Table
101
+ df = c.get("pjm_load", "2026-09-01", "2026-10-01")
102
+ pdf = c.get("pjm_load", "2026-09-01", "2026-10-01", output="polars")
103
+
104
+ # One market day: ERCOT's operating day 2026-10-04, in Central time
105
+ df = c.get("ercot_load", day="2026-10-04")
106
+
107
+ # Fewer columns, a filter, a row limit, another timezone
108
+ df = c.get(
109
+ "pjm_load", "2026-09-01", "2026-09-08",
110
+ columns=["interval_start_utc", "pjm_rto"],
111
+ filter=("pjm_rto", ">", 90000), # ("col", value) means "="; a list value means "in"
112
+ limit=1000,
113
+ timezone="UTC", # "market" (default), "UTC", "CST"-style or any IANA name
114
+ )
115
+ ```
116
+
117
+ - `start` is inclusive and `end` exclusive. They accept strings, `date`,
118
+ `datetime` or `pd.Timestamp`. **A value without a timezone is read in
119
+ `timezone`, which defaults to the market's own clock** (Central for ERCOT
120
+ and SPP, Eastern for PJM, NYISO and ISO-NE, Pacific for CAISO, fixed EST
121
+ for MISO and IESO). So `"2026-10-04"` on an ERCOT dataset is midnight
122
+ Central. `end` defaults to now; `day=` is one whole day.
123
+ - Interval datasets filter on `interval_start_utc`, so a day is the intervals
124
+ that start in it (as on Grid Status).
125
+ - Every `*_utc` column stays UTC and gets a `*_local` twin in the query
126
+ timezone. `timezone="UTC"` returns only the UTC columns.
127
+ - Long ranges are split into the windows your plan allows and fetched in
128
+ parallel where your plan allows, with one progress bar.
129
+ - Timestamps come back as real timezone-aware datetimes; numbers as numbers.
130
+
131
+ ### Large ranges: save to disk
132
+
133
+ ```python
134
+ paths = c.download("pjm_load", "2025-01-01", "2026-01-01", dir="data/")
135
+ # data/pjm_load/2025-01-01.parquet, data/pjm_load/2025-01-02.parquet, ...
136
+ ```
137
+
138
+ - One parquet file per day in the query timezone (the market's by default).
139
+ The folder is created if it doesn't exist.
140
+ - **Every run fetches every day again and overwrites its file**, so the
141
+ folder always matches the server. A day that no longer has data has its
142
+ old file removed.
143
+ - Read the folder back with any tool:
144
+
145
+ ```python
146
+ import pandas as pd, polars as pl
147
+ pd.read_parquet("data/pjm_load")
148
+ pl.scan_parquet("data/pjm_load/*.parquet").collect()
149
+ # DuckDB: SELECT * FROM read_parquet('data/pjm_load/*.parquet')
150
+ ```
151
+
152
+ ### Process piece by piece
153
+
154
+ ```python
155
+ for chunk in c.stream("pjm_load", "2025-01-01", "2026-01-01"):
156
+ print(len(chunk)) # one DataFrame per window; memory stays flat
157
+ ```
158
+
159
+ ## Forecasts
160
+
161
+ A forecast is published many times (ERCOT's 7-day load forecast: every hour),
162
+ so each delivery hour has many predictions. `start`/`end` select
163
+ **publications**: you get every forecast published in `[start, end)`, each
164
+ with its full horizon and its `publish_time_utc`.
165
+
166
+ ```python
167
+ ds = "ercot_load_forecast_by_forecast_zone"
168
+ c.info(ds) # kind: forecast; time column (start/end): publish_time_utc
169
+
170
+ all_ = c.get(ds, "2026-10-01", "2026-10-02") # 24 publications x 7-day horizon
171
+ best = c.get(ds, "2026-10-01", "2026-10-02", vintage="latest") # newest forecast per delivery hour
172
+ then = c.get(ds, as_of="2026-10-01T09:00Z", # the forecast as it stood at 09:00,
173
+ start="2026-10-02", end="2026-10-03") # cut to delivery on Oct 2
174
+ ```
175
+
176
+ - `vintage="latest"` keeps, for each delivery interval, the newest publication
177
+ in the window. Rows still carry `publish_time_utc`, so you see which won.
178
+ - `as_of=` returns one whole publication: the newest at or before that time
179
+ (looking back up to 7 days). `start`/`end` are optional and cut its
180
+ delivery times. Use it for backtests: "what did we know at 9am?"
181
+ - `download()` saves forecasts per **publish** day.
182
+ - Snapshot datasets (reference lists, outage snapshots) work the same way.
183
+
184
+ ## IoT sensors
185
+
186
+ ```python
187
+ c.devices() # device_id + when it last checked in
188
+ df = c.get("sensor_readings", "2026-10-02", "2026-10-03",
189
+ devices=["sunsight-001"], # default: all devices
190
+ images=True) # adds image_url: the sky photo for that reading
191
+ ```
192
+
193
+ Device data is stored as hundreds of small files per device-day, so each
194
+ request covers at most 7 days (`get()` splits longer ranges for you).
195
+ `images=True` counts each distinct photo against your monthly files & images
196
+ quota.
197
+
198
+ ## Raw files
199
+
200
+ Original source files, byte for byte (PJM eDART outage reports, PJM
201
+ workbooks):
202
+
203
+ ```python
204
+ f = c.files("pjm_transmission_outages_raw", "2026-10-02", "2026-10-03",
205
+ source="linesout", dir="raw/") # source= is optional: default all
206
+ f[["captured_at", "source", "filename", "local_path"]]
207
+ ```
208
+
209
+ Same columns and options as `images()`, plus `source`. Files share the
210
+ monthly files & images quota.
211
+
212
+ ## Images
213
+
214
+ ```python
215
+ # A table of images with download links (links last ~15 minutes)
216
+ imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01")
217
+
218
+ # Also save them: imgs/goes19_regional_southcentral_truecolor/2026-09-30/<file>.jpg
219
+ imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01", dir="imgs/")
220
+ imgs[["captured_at", "local_path"]]
221
+ ```
222
+
223
+ Columns: `captured_at` (UTC), `filename`, `size_bytes`, `url` and, with
224
+ `dir=`, `local_path`. Saving runs in parallel, skips files you already have,
225
+ and recovers when a link expires mid-run. On networks that block direct
226
+ storage links, pass `via="api"`.
227
+
228
+ ## Finding datasets
229
+
230
+ ```python
231
+ c.datasets() # everything your key can see
232
+ c.datasets(search="ercot load") # every word must match id, name, description or tags
233
+ c.datasets(queryable=True) # only what get() can serve today
234
+
235
+ c.info("pjm_load") # columns + types, time column, data range
236
+ ```
237
+
238
+ A dataset with `queryable == False` has a `reason`, e.g. it is a file or
239
+ image dataset (use `files()` / `images()`).
240
+
241
+ ## Errors
242
+
243
+ | Exception | When |
244
+ |---|---|
245
+ | `AuthError` | The key is missing or invalid. |
246
+ | `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. |
247
+ | `NotFoundError` | No such dataset. |
248
+ | `NotQueryableError` | The dataset exists, but `get()` can't serve it yet (see `reason`). |
249
+ | `QuotaExceededError` | This month's rows or images are used up. Raised immediately, never retried. |
250
+ | `RateLimitError` | Still over your plan's concurrent-request or requests-per-minute limit after waiting ~5 minutes. |
251
+ | `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. |
252
+
253
+ All of them subclass `ss.SunsightError` and include the server's message, the
254
+ HTTP `status_code`, and a machine-readable `code` such as `range_exceeded`,
255
+ `not_scoped`, `quota_rows`, `quota_images` or `rate_limited`:
256
+
257
+ ```python
258
+ try:
259
+ df = c.get("pjm_load", "2026-01-01", "2026-03-01")
260
+ except ss.ForbiddenError as e:
261
+ if e.code == "range_exceeded":
262
+ ...
263
+ ```
264
+ Network errors and server errors are retried (3 times by default).
265
+
266
+ ## Plan limits
267
+
268
+ Each plan limits:
269
+
270
+ | Plan | Requests at a time | Requests per minute | Days per request | Rows per month | Image history |
271
+ |---|---|---|---|---|---|
272
+ | Free | 1 | 60 | 7 | 500,000 | latest 1 day |
273
+ | Starter | 3 | 300 | 90 | 5,000,000 | full |
274
+ | Pro | 10 | 1,200 | 365 | 50,000,000 | full |
275
+ | Enterprise | 50 | 6,000 | 366 | unlimited | full |
276
+
277
+ The SDK reads your limits automatically (`c.usage()` shows them, plus rows
278
+ used this month), so a "days per request" limit never shows up in your code.
279
+ If you hit the per-minute limit, the SDK waits until the next minute and
280
+ carries on (one log line says so). Every response carries
281
+ `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
282
+
283
+ ## Notebooks, scripts and pipelines
284
+
285
+ ```python
286
+ c = ss.Client(
287
+ progress=None, # None: bars in notebooks/terminals, off when output is redirected
288
+ log_level="INFO", # one line per call; "DEBUG" for every request; "WARNING" for quiet
289
+ )
290
+ ```
291
+
292
+ - `SUNSIGHT_API_KEY`: the key, so it stays out of your notebook.
293
+ - `SUNSIGHT_BASE_URL`: point at another environment (e.g. `http://localhost:8001`).
294
+ - `SUNSIGHT_PROGRESS=0`: turn progress bars off everywhere (CI, cron).
295
+ - Logs go to the standard `logging` logger named `sunsightenergy`. If your app
296
+ already configures logging, the SDK adds no handler of its own.
297
+
298
+ ## Without the SDK
299
+
300
+ ```bash
301
+ curl -H "X-API-Key: ss_live_..." \
302
+ "https://api.sunsightenergy.com/v1/datasets/pjm_load/query?start_time=2026-09-01&end_time=2026-09-02"
303
+ ```
304
+
305
+ Add `return_format=csv` or `return_format=parquet` for those formats. The
306
+ next page's cursor is in the `X-Next-Cursor` response header.
307
+
308
+ ## License
309
+
310
+ MIT
@@ -0,0 +1,280 @@
1
+ # Sunsight Energy Python SDK
2
+
3
+ Python client for the [Sunsight Energy](https://sunsightenergy.com) data API:
4
+ real-time and historical data for US/Canadian ISOs (ERCOT, PJM, CAISO, SPP,
5
+ MISO, NYISO, ISONE, IESO), weather and satellite imagery.
6
+
7
+ You ask for **a dataset and a time range**. The SDK handles paging, your
8
+ plan's date-range limit, retries and file formats, and hands back a pandas or
9
+ polars DataFrame, parquet files on disk, or images in a folder.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install sunsightenergy # pandas
15
+ pip install "sunsightenergy[polars]" # + polars output
16
+ ```
17
+
18
+ Python 3.10+. Works in Jupyter, Google Colab, scripts and pipelines.
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ import sunsightenergy as ss
24
+
25
+ c = ss.Client("ss_live_...") # or set SUNSIGHT_API_KEY and use ss.Client()
26
+
27
+ df = c.get("ercot_load", "2026-09-01", "2026-10-01")
28
+ df.plot(x="interval_end_utc", y="load")
29
+ ```
30
+
31
+ Create a key in the portal under **API Platform → API Keys**.
32
+
33
+ ## Coming from Grid Status
34
+
35
+ The client has gridstatusio's methods with the same arguments, so existing
36
+ code only needs a new import and key:
37
+
38
+ ```python
39
+ from sunsightenergy import Client as GridStatusClient
40
+
41
+ client = GridStatusClient("ss_live_...")
42
+ df = client.get_dataset(
43
+ dataset="ercot_load",
44
+ start="2026-10-04",
45
+ end="2026-10-05",
46
+ timezone="market",
47
+ )
48
+ client.list_datasets(filter_term="lmp")
49
+ client.get_dataset_metadata("ercot_load")
50
+ client.get_api_usage()
51
+ ```
52
+
53
+ `get_dataset` takes every Grid Status argument: `publish_time_start`,
54
+ `publish_time_end`, `publish_time` ("latest", "latest_report" or a
55
+ timestamp), `columns`, `filter_column` / `filter_value` / `filter_operator`
56
+ (including "in"), `resample` / `resample_by` / `resample_function`, `limit`,
57
+ `page_size`, `timezone`, `verbose` and `return_format` ("pandas", "polars",
58
+ "python"). Differences:
59
+
60
+ - `timezone` defaults to `"market"` (Grid Status defaults to UTC). Pass
61
+ `timezone="UTC"` for Grid Status's default output.
62
+ - For forecasts, `start`/`end` are delivery times as on Grid Status; the
63
+ client reads publications from up to 7 days before `start`. Pass
64
+ `publish_time_start` for forecasts published further ahead.
65
+ - `get_daily_peak_report` isn't available.
66
+
67
+ ## Loading data
68
+
69
+ ```python
70
+ # pandas (default), polars, or a pyarrow Table
71
+ df = c.get("pjm_load", "2026-09-01", "2026-10-01")
72
+ pdf = c.get("pjm_load", "2026-09-01", "2026-10-01", output="polars")
73
+
74
+ # One market day: ERCOT's operating day 2026-10-04, in Central time
75
+ df = c.get("ercot_load", day="2026-10-04")
76
+
77
+ # Fewer columns, a filter, a row limit, another timezone
78
+ df = c.get(
79
+ "pjm_load", "2026-09-01", "2026-09-08",
80
+ columns=["interval_start_utc", "pjm_rto"],
81
+ filter=("pjm_rto", ">", 90000), # ("col", value) means "="; a list value means "in"
82
+ limit=1000,
83
+ timezone="UTC", # "market" (default), "UTC", "CST"-style or any IANA name
84
+ )
85
+ ```
86
+
87
+ - `start` is inclusive and `end` exclusive. They accept strings, `date`,
88
+ `datetime` or `pd.Timestamp`. **A value without a timezone is read in
89
+ `timezone`, which defaults to the market's own clock** (Central for ERCOT
90
+ and SPP, Eastern for PJM, NYISO and ISO-NE, Pacific for CAISO, fixed EST
91
+ for MISO and IESO). So `"2026-10-04"` on an ERCOT dataset is midnight
92
+ Central. `end` defaults to now; `day=` is one whole day.
93
+ - Interval datasets filter on `interval_start_utc`, so a day is the intervals
94
+ that start in it (as on Grid Status).
95
+ - Every `*_utc` column stays UTC and gets a `*_local` twin in the query
96
+ timezone. `timezone="UTC"` returns only the UTC columns.
97
+ - Long ranges are split into the windows your plan allows and fetched in
98
+ parallel where your plan allows, with one progress bar.
99
+ - Timestamps come back as real timezone-aware datetimes; numbers as numbers.
100
+
101
+ ### Large ranges: save to disk
102
+
103
+ ```python
104
+ paths = c.download("pjm_load", "2025-01-01", "2026-01-01", dir="data/")
105
+ # data/pjm_load/2025-01-01.parquet, data/pjm_load/2025-01-02.parquet, ...
106
+ ```
107
+
108
+ - One parquet file per day in the query timezone (the market's by default).
109
+ The folder is created if it doesn't exist.
110
+ - **Every run fetches every day again and overwrites its file**, so the
111
+ folder always matches the server. A day that no longer has data has its
112
+ old file removed.
113
+ - Read the folder back with any tool:
114
+
115
+ ```python
116
+ import pandas as pd, polars as pl
117
+ pd.read_parquet("data/pjm_load")
118
+ pl.scan_parquet("data/pjm_load/*.parquet").collect()
119
+ # DuckDB: SELECT * FROM read_parquet('data/pjm_load/*.parquet')
120
+ ```
121
+
122
+ ### Process piece by piece
123
+
124
+ ```python
125
+ for chunk in c.stream("pjm_load", "2025-01-01", "2026-01-01"):
126
+ print(len(chunk)) # one DataFrame per window; memory stays flat
127
+ ```
128
+
129
+ ## Forecasts
130
+
131
+ A forecast is published many times (ERCOT's 7-day load forecast: every hour),
132
+ so each delivery hour has many predictions. `start`/`end` select
133
+ **publications**: you get every forecast published in `[start, end)`, each
134
+ with its full horizon and its `publish_time_utc`.
135
+
136
+ ```python
137
+ ds = "ercot_load_forecast_by_forecast_zone"
138
+ c.info(ds) # kind: forecast; time column (start/end): publish_time_utc
139
+
140
+ all_ = c.get(ds, "2026-10-01", "2026-10-02") # 24 publications x 7-day horizon
141
+ best = c.get(ds, "2026-10-01", "2026-10-02", vintage="latest") # newest forecast per delivery hour
142
+ then = c.get(ds, as_of="2026-10-01T09:00Z", # the forecast as it stood at 09:00,
143
+ start="2026-10-02", end="2026-10-03") # cut to delivery on Oct 2
144
+ ```
145
+
146
+ - `vintage="latest"` keeps, for each delivery interval, the newest publication
147
+ in the window. Rows still carry `publish_time_utc`, so you see which won.
148
+ - `as_of=` returns one whole publication: the newest at or before that time
149
+ (looking back up to 7 days). `start`/`end` are optional and cut its
150
+ delivery times. Use it for backtests: "what did we know at 9am?"
151
+ - `download()` saves forecasts per **publish** day.
152
+ - Snapshot datasets (reference lists, outage snapshots) work the same way.
153
+
154
+ ## IoT sensors
155
+
156
+ ```python
157
+ c.devices() # device_id + when it last checked in
158
+ df = c.get("sensor_readings", "2026-10-02", "2026-10-03",
159
+ devices=["sunsight-001"], # default: all devices
160
+ images=True) # adds image_url: the sky photo for that reading
161
+ ```
162
+
163
+ Device data is stored as hundreds of small files per device-day, so each
164
+ request covers at most 7 days (`get()` splits longer ranges for you).
165
+ `images=True` counts each distinct photo against your monthly files & images
166
+ quota.
167
+
168
+ ## Raw files
169
+
170
+ Original source files, byte for byte (PJM eDART outage reports, PJM
171
+ workbooks):
172
+
173
+ ```python
174
+ f = c.files("pjm_transmission_outages_raw", "2026-10-02", "2026-10-03",
175
+ source="linesout", dir="raw/") # source= is optional: default all
176
+ f[["captured_at", "source", "filename", "local_path"]]
177
+ ```
178
+
179
+ Same columns and options as `images()`, plus `source`. Files share the
180
+ monthly files & images quota.
181
+
182
+ ## Images
183
+
184
+ ```python
185
+ # A table of images with download links (links last ~15 minutes)
186
+ imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01")
187
+
188
+ # Also save them: imgs/goes19_regional_southcentral_truecolor/2026-09-30/<file>.jpg
189
+ imgs = c.images("goes19_regional_southcentral_truecolor", "2026-09-30", "2026-10-01", dir="imgs/")
190
+ imgs[["captured_at", "local_path"]]
191
+ ```
192
+
193
+ Columns: `captured_at` (UTC), `filename`, `size_bytes`, `url` and, with
194
+ `dir=`, `local_path`. Saving runs in parallel, skips files you already have,
195
+ and recovers when a link expires mid-run. On networks that block direct
196
+ storage links, pass `via="api"`.
197
+
198
+ ## Finding datasets
199
+
200
+ ```python
201
+ c.datasets() # everything your key can see
202
+ c.datasets(search="ercot load") # every word must match id, name, description or tags
203
+ c.datasets(queryable=True) # only what get() can serve today
204
+
205
+ c.info("pjm_load") # columns + types, time column, data range
206
+ ```
207
+
208
+ A dataset with `queryable == False` has a `reason`, e.g. it is a file or
209
+ image dataset (use `files()` / `images()`).
210
+
211
+ ## Errors
212
+
213
+ | Exception | When |
214
+ |---|---|
215
+ | `AuthError` | The key is missing or invalid. |
216
+ | `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. |
217
+ | `NotFoundError` | No such dataset. |
218
+ | `NotQueryableError` | The dataset exists, but `get()` can't serve it yet (see `reason`). |
219
+ | `QuotaExceededError` | This month's rows or images are used up. Raised immediately, never retried. |
220
+ | `RateLimitError` | Still over your plan's concurrent-request or requests-per-minute limit after waiting ~5 minutes. |
221
+ | `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. |
222
+
223
+ All of them subclass `ss.SunsightError` and include the server's message, the
224
+ HTTP `status_code`, and a machine-readable `code` such as `range_exceeded`,
225
+ `not_scoped`, `quota_rows`, `quota_images` or `rate_limited`:
226
+
227
+ ```python
228
+ try:
229
+ df = c.get("pjm_load", "2026-01-01", "2026-03-01")
230
+ except ss.ForbiddenError as e:
231
+ if e.code == "range_exceeded":
232
+ ...
233
+ ```
234
+ Network errors and server errors are retried (3 times by default).
235
+
236
+ ## Plan limits
237
+
238
+ Each plan limits:
239
+
240
+ | Plan | Requests at a time | Requests per minute | Days per request | Rows per month | Image history |
241
+ |---|---|---|---|---|---|
242
+ | Free | 1 | 60 | 7 | 500,000 | latest 1 day |
243
+ | Starter | 3 | 300 | 90 | 5,000,000 | full |
244
+ | Pro | 10 | 1,200 | 365 | 50,000,000 | full |
245
+ | Enterprise | 50 | 6,000 | 366 | unlimited | full |
246
+
247
+ The SDK reads your limits automatically (`c.usage()` shows them, plus rows
248
+ used this month), so a "days per request" limit never shows up in your code.
249
+ If you hit the per-minute limit, the SDK waits until the next minute and
250
+ carries on (one log line says so). Every response carries
251
+ `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
252
+
253
+ ## Notebooks, scripts and pipelines
254
+
255
+ ```python
256
+ c = ss.Client(
257
+ progress=None, # None: bars in notebooks/terminals, off when output is redirected
258
+ log_level="INFO", # one line per call; "DEBUG" for every request; "WARNING" for quiet
259
+ )
260
+ ```
261
+
262
+ - `SUNSIGHT_API_KEY`: the key, so it stays out of your notebook.
263
+ - `SUNSIGHT_BASE_URL`: point at another environment (e.g. `http://localhost:8001`).
264
+ - `SUNSIGHT_PROGRESS=0`: turn progress bars off everywhere (CI, cron).
265
+ - Logs go to the standard `logging` logger named `sunsightenergy`. If your app
266
+ already configures logging, the SDK adds no handler of its own.
267
+
268
+ ## Without the SDK
269
+
270
+ ```bash
271
+ curl -H "X-API-Key: ss_live_..." \
272
+ "https://api.sunsightenergy.com/v1/datasets/pjm_load/query?start_time=2026-09-01&end_time=2026-09-02"
273
+ ```
274
+
275
+ Add `return_format=csv` or `return_format=parquet` for those formats. The
276
+ next page's cursor is in the `X-Next-Cursor` response header.
277
+
278
+ ## License
279
+
280
+ MIT