aemo-mcp 0.1.2__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.
Files changed (56) hide show
  1. aemo_mcp-0.1.2/.github/dependabot.yml +39 -0
  2. aemo_mcp-0.1.2/.github/workflows/codeql.yml +32 -0
  3. aemo_mcp-0.1.2/.github/workflows/release.yml +54 -0
  4. aemo_mcp-0.1.2/.github/workflows/test.yml +47 -0
  5. aemo_mcp-0.1.2/.gitignore +30 -0
  6. aemo_mcp-0.1.2/CHANGELOG.md +133 -0
  7. aemo_mcp-0.1.2/CLAUDE.md +182 -0
  8. aemo_mcp-0.1.2/CODE_OF_CONDUCT.md +39 -0
  9. aemo_mcp-0.1.2/CONTRIBUTING.md +68 -0
  10. aemo_mcp-0.1.2/LICENSE +33 -0
  11. aemo_mcp-0.1.2/PKG-INFO +283 -0
  12. aemo_mcp-0.1.2/README.md +253 -0
  13. aemo_mcp-0.1.2/RESEARCH.md +238 -0
  14. aemo_mcp-0.1.2/SECURITY.md +49 -0
  15. aemo_mcp-0.1.2/examples/claude_desktop_config.json +8 -0
  16. aemo_mcp-0.1.2/examples/demo_prompts.md +54 -0
  17. aemo_mcp-0.1.2/glama.json +4 -0
  18. aemo_mcp-0.1.2/llms.txt +54 -0
  19. aemo_mcp-0.1.2/pyproject.toml +66 -0
  20. aemo_mcp-0.1.2/src/aemo_mcp/__init__.py +6 -0
  21. aemo_mcp-0.1.2/src/aemo_mcp/cache.py +139 -0
  22. aemo_mcp-0.1.2/src/aemo_mcp/client.py +237 -0
  23. aemo_mcp-0.1.2/src/aemo_mcp/curated.py +226 -0
  24. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/daily_summary.yaml +68 -0
  25. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/dispatch_price.yaml +54 -0
  26. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/dispatch_region.yaml +64 -0
  27. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/generation_scada.yaml +75 -0
  28. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/interconnector_flows.yaml +61 -0
  29. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/predispatch_30min.yaml +65 -0
  30. aemo_mcp-0.1.2/src/aemo_mcp/data/curated/rooftop_pv.yaml +66 -0
  31. aemo_mcp-0.1.2/src/aemo_mcp/data/duid_snapshot.csv +357 -0
  32. aemo_mcp-0.1.2/src/aemo_mcp/duid_lookup.py +138 -0
  33. aemo_mcp-0.1.2/src/aemo_mcp/feeds.py +153 -0
  34. aemo_mcp-0.1.2/src/aemo_mcp/fetch.py +598 -0
  35. aemo_mcp-0.1.2/src/aemo_mcp/models.py +99 -0
  36. aemo_mcp-0.1.2/src/aemo_mcp/parsing.py +138 -0
  37. aemo_mcp-0.1.2/src/aemo_mcp/py.typed +0 -0
  38. aemo_mcp-0.1.2/src/aemo_mcp/server.py +591 -0
  39. aemo_mcp-0.1.2/src/aemo_mcp/shaping.py +249 -0
  40. aemo_mcp-0.1.2/tests/__init__.py +0 -0
  41. aemo_mcp-0.1.2/tests/conftest.py +155 -0
  42. aemo_mcp-0.1.2/tests/test_cache.py +118 -0
  43. aemo_mcp-0.1.2/tests/test_client.py +357 -0
  44. aemo_mcp-0.1.2/tests/test_curated.py +202 -0
  45. aemo_mcp-0.1.2/tests/test_duid_lookup.py +142 -0
  46. aemo_mcp-0.1.2/tests/test_edge_cases.py +283 -0
  47. aemo_mcp-0.1.2/tests/test_feeds.py +124 -0
  48. aemo_mcp-0.1.2/tests/test_fetch.py +381 -0
  49. aemo_mcp-0.1.2/tests/test_integration.py +201 -0
  50. aemo_mcp-0.1.2/tests/test_live.py +92 -0
  51. aemo_mcp-0.1.2/tests/test_mcp_protocol.py +78 -0
  52. aemo_mcp-0.1.2/tests/test_parsing.py +211 -0
  53. aemo_mcp-0.1.2/tests/test_regressions.py +403 -0
  54. aemo_mcp-0.1.2/tests/test_server_validation.py +271 -0
  55. aemo_mcp-0.1.2/tests/test_shaping.py +383 -0
  56. aemo_mcp-0.1.2/uv.lock +1547 -0
@@ -0,0 +1,39 @@
1
+ version: 2
2
+
3
+ updates:
4
+ # Python dependencies (pyproject.toml + uv.lock)
5
+ - package-ecosystem: pip
6
+ directory: "/"
7
+ schedule:
8
+ interval: weekly
9
+ day: monday
10
+ time: "10:00"
11
+ timezone: Australia/Sydney
12
+ open-pull-requests-limit: 5
13
+ groups:
14
+ # Group all minor + patch updates into one PR per week.
15
+ python-runtime-minor-patch:
16
+ applies-to: version-updates
17
+ update-types: [minor, patch]
18
+ labels: [dependencies]
19
+ commit-message:
20
+ prefix: deps
21
+ include: scope
22
+
23
+ # GitHub Actions used in workflows
24
+ - package-ecosystem: github-actions
25
+ directory: "/"
26
+ schedule:
27
+ interval: weekly
28
+ day: monday
29
+ time: "10:00"
30
+ timezone: Australia/Sydney
31
+ open-pull-requests-limit: 3
32
+ groups:
33
+ gha-minor-patch:
34
+ applies-to: version-updates
35
+ update-types: [minor, patch]
36
+ labels: [dependencies, github-actions]
37
+ commit-message:
38
+ prefix: ci
39
+ include: scope
@@ -0,0 +1,32 @@
1
+ name: CodeQL
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ schedule:
9
+ - cron: "21 6 * * 1"
10
+
11
+ jobs:
12
+ analyze:
13
+ name: Analyze
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ actions: read
17
+ contents: read
18
+ security-events: write
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ language: ["python"]
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - name: Initialize CodeQL
26
+ uses: github/codeql-action/init@v3
27
+ with:
28
+ languages: ${{ matrix.language }}
29
+ - name: Perform CodeQL Analysis
30
+ uses: github/codeql-action/analyze@v3
31
+ with:
32
+ category: "/language:${{ matrix.language }}"
@@ -0,0 +1,54 @@
1
+ name: release
2
+
3
+ # Push a tag like v0.1.0 → wheel is built and published to PyPI via
4
+ # Trusted Publishing (no API token in repo secrets). One-time setup:
5
+ # 1. https://pypi.org/manage/project/aemo-mcp/settings/publishing/
6
+ # 2. Add a "pending publisher":
7
+ # owner = Bigred97
8
+ # repo = aemo-mcp
9
+ # workflow = release.yml
10
+ # environment = pypi
11
+ # 3. Create a `pypi` environment in repo Settings → Environments
12
+ # (no secrets needed; the environment scopes the OIDC token).
13
+ # Then any `git push origin vX.Y.Z` triggers this job.
14
+
15
+ on:
16
+ push:
17
+ tags:
18
+ - "v*"
19
+
20
+ jobs:
21
+ release:
22
+ runs-on: ubuntu-latest
23
+ environment:
24
+ name: pypi
25
+ url: https://pypi.org/project/aemo-mcp/
26
+ permissions:
27
+ id-token: write # required for Trusted Publishing OIDC
28
+ contents: read
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+
32
+ - name: Install uv
33
+ uses: astral-sh/setup-uv@v3
34
+
35
+ - name: Sanity-check the tag matches pyproject.toml
36
+ run: |
37
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
38
+ PYPROJECT_VERSION=$(uv run python -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
39
+ if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
40
+ echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $PYPROJECT_VERSION"
41
+ exit 1
42
+ fi
43
+ echo "Releasing version $PYPROJECT_VERSION"
44
+
45
+ - name: Build wheel + sdist
46
+ run: uv build
47
+
48
+ - name: Publish to PyPI (Trusted Publishing)
49
+ uses: pypa/gh-action-pypi-publish@release/v1
50
+
51
+ - uses: actions/upload-artifact@v4
52
+ with:
53
+ name: dist
54
+ path: dist/
@@ -0,0 +1,47 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v3
20
+ with:
21
+ enable-cache: true
22
+ - name: Set up Python ${{ matrix.python-version }}
23
+ run: uv python install ${{ matrix.python-version }}
24
+ - name: Sync dependencies
25
+ run: uv sync --extra dev
26
+ - name: Install package
27
+ run: uv pip install -e .
28
+ - name: Run unit tests
29
+ run: uv run pytest -q
30
+
31
+ build:
32
+ runs-on: ubuntu-latest
33
+ needs: test
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - name: Install uv
37
+ uses: astral-sh/setup-uv@v3
38
+ - name: Build wheel + sdist
39
+ run: uv build
40
+ - name: Verify wheel installs cleanly
41
+ run: |
42
+ uv run --isolated --with ./dist/*.whl python -c \
43
+ "import aemo_mcp.server as s; n = len(s.list_curated()); assert n >= 7, f'expected >=7 curated, got {n}'; print(f'OK ({n} curated feeds)')"
44
+ - uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
@@ -0,0 +1,30 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .Python
5
+ .venv/
6
+ venv/
7
+ env/
8
+
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+ *.egg
13
+
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+ .tox/
18
+
19
+ .DS_Store
20
+ .idea/
21
+ .vscode/
22
+
23
+ # Local cache database
24
+ *.db
25
+ *.db-journal
26
+ *.db-wal
27
+ *.db-shm
28
+
29
+ # uv lockfile pinned in repo
30
+ # uv.lock is committed deliberately
@@ -0,0 +1,133 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 (2026-05-15)
4
+
5
+ Portfolio parity — stale-cache fallback + error-message sweep + dependabot +
6
+ CLAUDE.md. 297 unit tests (was 288) + 10 live tests, 3× zero-flake green.
7
+
8
+ - **Add: stale-cache fallback (graceful degradation).** When NEMWEB returns
9
+ 5xx or is unreachable (`httpx.RequestError`), `AEMOClient._fetch_cached`
10
+ now falls back to the most-recent cached payload (regardless of TTL) via
11
+ the new `Cache.get_stale()` and records the staleness on a `_stale_signal`
12
+ ContextVar. Server-side tool wrappers (`get_data`, `latest`) read the
13
+ signal after the fetch chain and surface it on the response via
14
+ `DataResponse.stale=True, stale_reason="AEMO/OpenNEM fetch returned X
15
+ for Y; serving cached payload from ~N minute(s) ago"`. Mirrors abs-mcp
16
+ 0.2.13 / rba-mcp 0.1.10 patterns. Empty-cache fallback preserves the
17
+ original `AEMOAPIError` behaviour.
18
+ - **Add: `DataResponse.stale_reason` and `truncated_at` fields.** Aligns the
19
+ envelope with the rest of the portfolio (abs / rba / ato / apra / aihw /
20
+ asic). `stale` retains its dual meaning: True when the latest NEM
21
+ observation is older than 2x cadence OR a cached fallback was served.
22
+ - **Error-message sweep.** Every weak `ValueError` rewritten to suggest the
23
+ correction via stdlib `difflib.get_close_matches`. Unknown dataset IDs
24
+ now emit `Did you mean 'dispatch_price'?` for close typos; unknown filter
25
+ keys emit `Did you mean 'region'?`; unknown formats emit `Did you mean
26
+ 'records'?`. Period errors now show a worked example
27
+ (`'2026-05-14' or '2026-05-14 09:00'`). `Unsupported aggregation
28
+ dimension` in duid_lookup now lists valid options. No new top-level
29
+ dependencies.
30
+ - **Add: `CLAUDE.md`.** Repo-specific conventions auto-loaded by Claude
31
+ Code, mirroring the rest of the portfolio. Calls out the AEMO-specific
32
+ module set (`fetch.py`, `feeds.py`, `parsing.py`, `duid_lookup.py`),
33
+ the dual-meaning `stale` flag, the 5-min cadence cache-TTL ladder, and
34
+ the `/Reports/Current/` vs `/Reports/Archive/` pivot.
35
+ - **Add: `.github/dependabot.yml`.** Weekly minor + patch update PRs for
36
+ pip + GitHub Actions, grouped, Mon 10:00 Sydney. Verbatim from the
37
+ sister repos.
38
+ - **Tests: +4 stale-fallback regressions + 5 error-message-suggestion
39
+ regressions.** New tests cover: 5xx + stale cache → fallback + signal;
40
+ ConnectError + stale cache → same; 5xx + empty cache → still raises
41
+ `AEMOAPIError`; `Cache.get_stale()` round-trip + TTL bypass; "Did you
42
+ mean" hint for dataset / filter-key / format typos; period worked
43
+ example.
44
+
45
+ ## 0.1.1 (2026-05-15)
46
+
47
+ Customer-simulation hardening pass. Hammered every dataset against the live
48
+ NEMWEB feed; fixed every bug surfaced. 288 unit tests (was 225) + 10 live
49
+ tests, 10× zero-flake green.
50
+
51
+ - **Fix: `daily_summary` section name** — Daily_Reports publishes regional
52
+ data under `DREGION.` (with trailing dot — second cell of I-row is empty),
53
+ not `DISPATCH.PRICE`. YAML updated + 4 metrics (rrp, total_demand,
54
+ dispatchable_generation, net_interchange) now resolve correctly. The
55
+ parser already builds the empty-subname name; added a regression test.
56
+ - **Fix: `predispatch_30min` section names** — the actual NEMWEB sections
57
+ are `PREDISPATCH.REGION_SOLUTION` (demand/generation) and
58
+ `PREDISPATCH.REGION_PRICES` (RRP); the YAML pointed at the non-existent
59
+ `PREDISPATCH.REGIONSUM`. Forward curves now return 135+ records per
60
+ region per run (instead of 1).
61
+ - **Fix: `rooftop_pv` filename regex** — AEMO renamed the ACTUAL infix
62
+ from `MEASUREMENT` to `SATELLITE` and now also publishes some files
63
+ with no infix at all. Regex accepts all three forms.
64
+ - **Fix: archive fallback** — added `/Reports/Archive/<feed>/` (one ZIP-of-
65
+ ZIPs per day) fetch for windows older than 4 hours. Daily archive zips
66
+ are unpacked recursively. Demo 3 ("did SA hit negative pricing in the
67
+ last 24h?") and Demo 4 ("weekly avg dispatch price for VIC, last 4
68
+ weeks") now succeed where they previously hit 403 on rolled-out
69
+ /Current/ files. Capped at 31 days per response.
70
+ - **Fix: archive path** — the path constructor was including the literal
71
+ `Current` segment, producing `/Reports/Archive/Current/...` instead of
72
+ `/Reports/Archive/...`. Now strips correctly.
73
+ - **Fix: 5-min feeds: skip rolled-out files instead of failing the whole
74
+ response** — NEMWEB rolls files in/out of /Current/ continuously; a
75
+ file present in the directory listing may have moved to /Archive/ by
76
+ the time we GET it. Individual 403/404s now skip silently; only a
77
+ fully-empty result surfaces an error.
78
+ - **Fix: `latest()` on forecast feeds returns the full forward curve**,
79
+ not a single row collapsed to the furthest-out horizon. `rooftop_pv`
80
+ forecast and `predispatch_30min` now behave correctly.
81
+ - **Fix: section filter at folder level** — `filters={"section": "actual"}`
82
+ now skips fetching the FORECAST folder entirely, cutting one HTTP round
83
+ trip + sidestepping flaky listings.
84
+ - **Fix: section filter row-level skip** — the synthesised `section`
85
+ filter is no longer treated as a row column (which would reject every
86
+ row since rows have no SECTION cell).
87
+ - **Fix: section dedup** — when AEMO emits the same section twice in one
88
+ file with different versions (e.g. `DREGION.` v2 + v3 in Daily_Reports),
89
+ we now combine and dedupe by (settlement_column, filter columns)
90
+ instead of taking only the first match.
91
+ - **Fix: `Cache(db_path=DEFAULT_DB_PATH)` honors monkeypatches** — the
92
+ default value was captured at class-definition time, so test
93
+ monkeypatches of `DEFAULT_DB_PATH` had no effect. Now resolved at
94
+ construction time. Fixes flaky integration test where live NEMWEB data
95
+ bled through respx mocks.
96
+ - **Fix: in-flight dedup future exception leaks** — failed fetches no
97
+ longer log "Future exception was never retrieved" warnings.
98
+ - **Expand: DUID snapshot from 128 → 350 entries**, covering the majority
99
+ of active NEM units across all 5 regions and 7 fuel buckets. Generation-
100
+ by-fuel queries (QLD gas, NSW solar, SA battery, etc.) now return non-
101
+ empty results.
102
+ - **Tests: +63 regressions + edge cases.** Tests now cover: every bug above
103
+ + unicode queries, very long queries, special-char queries, negative
104
+ TTL, concurrent 10x dedup, DOS line endings, truncated CSV, quoted
105
+ commas, ZIP-bomb defence, every-dataset describe sweep, fuzzy ranker
106
+ invariants, and DUID coverage thresholds per fuel/region.
107
+
108
+ ## 0.1.0 (2026-05-14)
109
+
110
+ Initial release. MCP server wrapping AEMO NEMWEB feeds with 5 plain-English
111
+ tools and 7 curated datasets.
112
+
113
+ - **5 tools** mirroring abs-mcp / rba-mcp / ato-mcp: `search_datasets`,
114
+ `describe_dataset`, `get_data`, `latest`, `list_curated`.
115
+ - **7 curated feeds** covering ~95% of typical NEM analytic queries:
116
+ - `dispatch_price` — 5-min regional spot price (RRP) per NEM region
117
+ - `dispatch_region` — 5-min total demand, scheduled + semi-scheduled generation, net interchange
118
+ - `interconnector_flows` — 5-min MW flow across the 6 NEM interconnectors
119
+ - `generation_scada` — 5-min DUID-level SCADA MW (every generating unit)
120
+ - `rooftop_pv` — 30-min regional rooftop solar (actual + forecast)
121
+ - `predispatch_30min` — 30-min half-hourly forecast, ~40h horizon
122
+ - `daily_summary` — daily rolled-up compendium of yesterday's price + demand + dispatch
123
+ - **Trust contract** on every `DataResponse`: `source`, `attribution`,
124
+ `source_url`, `retrieved_at`, `interval_start`, `interval_end`, `stale`.
125
+ - **Live-fetch only**, no pre-bundled NEMWEB archives in the wheel.
126
+ - **Cache TTLs tuned per cadence**: 60s for 5-min feeds, 5min for 30-min
127
+ feeds, 1h forecasts, 24h daily, immutable for archived timestamped files.
128
+ - **In-flight request deduplication** — concurrent callers share one HTTP
129
+ request per URL (critical at 5-min cadence with many users).
130
+ - **AEMO Copyright Permissions** attribution string in every response.
131
+
132
+ Licence: AEMO grants general permission to use AEMO Material for any purpose
133
+ with attribution. See https://aemo.com.au/privacy-and-legal-notices/copyright-permissions.
@@ -0,0 +1,182 @@
1
+ # aemo-mcp
2
+
3
+ Sister MCP in the Australian Public Data stack. See `../CLAUDE.md` for
4
+ portfolio-wide conventions; this file captures repo-specific details
5
+ plus the cross-sister discipline.
6
+
7
+ ## Source
8
+
9
+ | | |
10
+ |--|--|
11
+ | Source agency | Australian Energy Market Operator (AEMO) |
12
+ | Source URL | http://nemweb.com.au/Reports/Current/ |
13
+ | Data format | Multi-section CSV (AEMO `C,/I,/D,` rows) packed in ZIP files, served from NEMWEB (IIS static file server). Directory listings are HTML. |
14
+ | Licence | AEMO Copyright Permissions (general permission for any purpose with attribution; commercial use allowed) |
15
+ | Licence URL | https://aemo.com.au/privacy-and-legal-notices/copyright-permissions |
16
+ | Python module | `aemo_mcp` |
17
+ | PyPI package | `aemo-mcp` |
18
+ | GitHub | https://github.com/Bigred97/aemo-mcp |
19
+
20
+ Note: AEMO's data is NOT CC-BY. Their Copyright Permissions policy is similar
21
+ in effect (general permission with attribution) but the canonical attribution
22
+ string differs — see `models._AEMO_ATTRIBUTION`.
23
+
24
+ ## Curated datasets (7)
25
+
26
+ dispatch_price · dispatch_region · interconnector_flows · generation_scada ·
27
+ rooftop_pv · predispatch_30min · daily_summary
28
+
29
+ All cover the NEM (NSW1, QLD1, SA1, TAS1, VIC1). Western Australia (WEM) and
30
+ the Northern Territory are not on the NEM and are out of scope.
31
+
32
+ ## Repo-specific module set
33
+
34
+ Required (every sister): `server.py`, `models.py`, `curated.py`, `client.py`, `cache.py`, `shaping.py`, `data/curated/*.yaml`
35
+
36
+ Repo-specific extras:
37
+ - `fetch.py` — orchestration layer between server.py and the HTTP/parsing
38
+ stack. Resolves curated dataset → folder(s) → file selection (Current vs
39
+ Archive) → ZIP fetch → CSV parse → filter → shape. Sits where most
40
+ sisters' `server.py` would have inline orchestration logic — pulled out
41
+ because NEM file selection (5-min vs 30-min, Current vs Archive, latest
42
+ vs window, forecast vs actual) is non-trivial.
43
+ - `feeds.py` — dataset search ranking + DatasetSummary projection. Replaces
44
+ rba-mcp's `tables.py` / abs-mcp's `catalog.py`.
45
+ - `parsing.py` — AEMO multi-section CSV parser + ZIP unpacker. Each NEMWEB
46
+ ZIP holds one CSV with one or more `I,/D,` sections (DISPATCH.PRICE,
47
+ DISPATCH.REGIONSUM, DISPATCH.INTERCONNECTORRES, ...). Stdlib `csv` +
48
+ `zipfile` only; no pandas.
49
+ - `duid_lookup.py` — DUID → region/fuel join table for `generation_scada`.
50
+ Static snapshot in `data/duid_snapshot.csv` (DUIDs change infrequently);
51
+ refreshed periodically. Used to translate `region`/`fuel` filters into a
52
+ DUID allow-set before filtering DISPATCH.UNIT_SCADA rows.
53
+
54
+ ## Repo-specific gotchas
55
+
56
+ - **5-min cadence drives cache TTLs.** `live` = 60s, `half_hour` = 5min,
57
+ `forecast` = 1h, `daily` = 24h, `archive` = 7d, `listing` = 30s. Timestamped
58
+ NEMWEB files are immutable once written (filename embeds the interval), so
59
+ the file-body cache is effectively infinite. Only the directory listing
60
+ has freshness sensitivity.
61
+ - **AEMO market time is UTC+10, no DST.** NEM is Brisbane-aligned year-round.
62
+ All NEMWEB timestamps in this code are tz-aware in NEM time (`NEM_TZ`).
63
+ - **`/Reports/Current/` vs `/Reports/Archive/`.** Current holds ~24-48h of
64
+ 5-min files; older intervals roll into daily ZIP-of-ZIPs compendia at
65
+ `/Reports/Archive/<feed>/PUBLIC_<feed>_YYYYMMDD.zip`. `fetch.py` auto-pivots
66
+ to Archive for windows older than `_CURRENT_WINDOW_HOURS` (4h). Archive
67
+ fallback unpacks two ZIP levels.
68
+ - **In-flight request deduplication is mandatory.** At 5-min cadence with
69
+ many concurrent users, naive caching would hammer NEMWEB. `AEMOClient._in_flight`
70
+ shares one HTTP call across concurrent identical URLs.
71
+ - **Latest-file detection is purely lexicographic.** AEMO embeds the interval
72
+ timestamp (`YYYYMMDDHHmm`) as the first 12-digit group in every filename,
73
+ so `max(filenames)` is the most recent. No HEAD requests needed.
74
+ - **Forecast feeds use `latest()` differently.** For `rooftop_pv` forecast and
75
+ `predispatch_30min`, `latest()` returns the FULL forward curve from the
76
+ most-recent run, not a single collapsed row per dim. `_is_forecast_folder`
77
+ controls this.
78
+ - **AEMO `C,/I,/D,` CSV format.** `C` = comment, `I` = schema row (opens a
79
+ new section), `D` = data row. Section name is `col1.col2` of the I-row;
80
+ data rows positionally map cells 4+ to the I-row's column names. One ZIP
81
+ can hold many sections, and one file can hold the same section twice in
82
+ two schema versions (`DREGION.` v2 + v3 in Daily_Reports) — `find_sections`
83
+ returns all; the caller dedupes.
84
+ - **NEMWEB rolls files in/out continuously.** A filename present in the
85
+ directory listing may have moved to `/Archive/` between the listing GET
86
+ and the per-file GET. Individual 403/404 must NOT fail the whole response
87
+ — `_fetch_current_zips` skips and continues.
88
+ - **`stale` field has dual meaning.** Set True if EITHER the latest observation
89
+ is older than 2× the feed cadence (NEM-side delay) OR a cached-fallback was
90
+ served because NEMWEB returned a non-2xx (graceful degradation). `stale_reason`
91
+ disambiguates.
92
+
93
+ ## Cache kinds (aemo-specific, not portable to other sisters)
94
+
95
+ ```
96
+ live 60s — 5-min dispatch feeds
97
+ half_hour 5min — 30-min feeds: rooftop PV actual, predispatch
98
+ forecast 1h — longer-horizon forecast bundles
99
+ daily 24h — daily rolled-up archives
100
+ archive 7d — immutable historical files (could be infinite)
101
+ listing 30s — NEMWEB directory HTML
102
+ ```
103
+
104
+ ---
105
+
106
+ ## The 5-tool surface (uniform across sisters — non-negotiable)
107
+
108
+ 1. `search_datasets(query, limit)` — fuzzy-search the 7 curated NEM feeds
109
+ 2. `describe_dataset(dataset_id)` — schema + filters + cadence + source URL
110
+ 3. `get_data(dataset_id, filters, start_period, end_period, format)` — query
111
+ 4. `latest(dataset_id, filters)` — most recent 5-min / 30-min / daily interval
112
+ 5. `list_curated()` — enumerate supported IDs
113
+
114
+ Every parameter uses `Annotated[Type, Field(description=..., examples=[...])]`.
115
+ This is the Glama Tool Definition Quality requirement — non-negotiable.
116
+
117
+ ## Trust contract (every DataResponse carries)
118
+
119
+ ```
120
+ source "Australian Energy Market Operator"
121
+ source_url the NEMWEB folder the data came from
122
+ attribution full AEMO Copyright Permissions attribution string
123
+ retrieved_at UTC timestamp
124
+ server_version importlib.metadata.version("aemo-mcp")
125
+ interval_start ISO-8601 in AEMO market time (UTC+10)
126
+ interval_end ISO-8601 in AEMO market time (UTC+10)
127
+ stale True when the feed is delayed OR cached fallback was served
128
+ stale_reason human-readable when stale=True (e.g. "AEMO/OpenNEM fetch returned 503 ...")
129
+ truncated_at int | None — set when latest() caps a large response
130
+ ```
131
+
132
+ ## The 5 quality dimensions (audit every release against these)
133
+
134
+ 1. **Semantic Clarity** — verb-noun tool names, Annotated[Field] with examples, rich docstrings (Examples + Returns blocks), `pattern=` constraints on dataset IDs and region codes
135
+ 2. **Data Pruning** — <10k tokens for typical responses, `latest()` returns the most-recent interval(s) for the filter dims rather than the whole file, no leaked AEMO row metadata in observations
136
+ 3. **Cross-Agency Joining** — AEMO market time uniformly UTC+10; region codes (NSW1/QLD1/SA1/TAS1/VIC1) match the canonical AEMO IDs that other sisters can join against; periods accept the shared YYYY / YYYY-MM / YYYY-MM-DD / YYYY-MM-DD HH:MM grammar
137
+ 4. **Reliability + Caching** — TTLs tuned per AEMO cadence (60s / 5min / 1h / 24h / 7d), self-heal on `sqlite3.DatabaseError`, **graceful degradation**: when NEMWEB returns 5xx or is unreachable, fall back to last cached payload via `Cache.get_stale()` and set `stale=True, stale_reason="..."` rather than raising
138
+ 5. **Deterministic Error Handling** — every `ValueError` carries a "Try X" / "Did you mean X?" / "Valid options: ..." hint that suggests the correction, not just describes the rejection
139
+
140
+ ## Test taxonomy
141
+
142
+ Required: `test_cache.py`, `test_curated.py`, `test_server_validation.py`, `test_shaping.py`, `test_integration.py` (live, `@pytest.mark.live`)
143
+ Recommended: `test_client.py`, `test_fetch.py`, `test_feeds.py`, `test_parsing.py`, `test_duid_lookup.py`, `test_mcp_protocol.py`, `test_regressions.py`, `test_edge_cases.py`, `test_live.py`
144
+
145
+ Zero-flake bar: full unit suite must run 10× consecutively green before tagging a release.
146
+
147
+ ## Release workflow (Trusted Publishing via OIDC, no API tokens in CI)
148
+
149
+ ```
150
+ 1. Bump version in pyproject.toml (semver)
151
+ 2. Update CHANGELOG.md (latest entry at top, semver headings)
152
+ 3. uv run pytest × 10 — zero flakes
153
+ 4. git commit -am "X.Y.Z: <one-line reason>"
154
+ 5. git tag -a vX.Y.Z -m "X.Y.Z: <reason>"
155
+ 6. git push origin main vX.Y.Z
156
+ 7. release.yml fires → builds → OIDC publish → PyPI
157
+ ```
158
+
159
+ PyPI new-project rate limit: 5/day per account; not an issue for existing
160
+ projects (only counts NEW package names). `aemo-mcp` is already published.
161
+
162
+ ## Anti-patterns — DO NOT do these
163
+
164
+ - Don't add a 6th tool; uniform 5-tool surface is the brand
165
+ - Don't add new top-level dependencies beyond what other sisters use (httpx, pydantic, fastmcp, aiosqlite, rapidfuzz, pyyaml)
166
+ - Don't introduce pandas at the parsing layer — stdlib `csv` + `zipfile` is enough
167
+ - Don't bundle large NEMWEB archives in the wheel; cache at runtime
168
+ - Don't ship without 10 consecutive zero-flake pytest runs
169
+ - Don't echo PyPI tokens / PATs in tool output, commit messages, or CHANGELOG
170
+ - Don't classify a slow source as a bug — NEMWEB cold fetches take 1-3s, only flag >10s or actual errors
171
+ - Don't hammer NEMWEB — in-flight dedup + cache are mandatory at 5-min cadence
172
+ - Don't widen scope mid-audit-loop; loops are fix-only
173
+
174
+ ## Common operations
175
+
176
+ ```bash
177
+ cd . # in the repo
178
+ uv sync --extra dev # install deps
179
+ uv run pytest # unit tests
180
+ uv run pytest -m live # live tests too
181
+ uvx --refresh --from aemo-mcp==<ver> python -c "..." # smoke a published wheel
182
+ ```
@@ -0,0 +1,39 @@
1
+ # Code of Conduct
2
+
3
+ This project adopts the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
4
+
5
+ ## Our Pledge
6
+
7
+ We pledge to make participation in our project a harassment-free experience
8
+ for everyone, regardless of age, body size, visible or invisible disability,
9
+ ethnicity, sex characteristics, gender identity and expression, level of
10
+ experience, education, socio-economic status, nationality, personal appearance,
11
+ race, caste, color, religion, or sexual identity and orientation.
12
+
13
+ ## Standards
14
+
15
+ Examples of behavior that contributes to a positive environment:
16
+
17
+ - Demonstrating empathy and kindness toward others
18
+ - Being respectful of differing opinions, viewpoints, and experiences
19
+ - Giving and gracefully accepting constructive feedback
20
+ - Accepting responsibility, apologizing, and learning from mistakes
21
+ - Focusing on what is best for the overall community
22
+
23
+ Unacceptable behavior includes:
24
+
25
+ - Trolling, insulting or derogatory comments, and personal or political attacks
26
+ - Public or private harassment
27
+ - Publishing others' private information without explicit permission
28
+ - Other conduct which could reasonably be considered inappropriate
29
+
30
+ ## Enforcement
31
+
32
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
33
+ reported to the project maintainers via GitHub issues or a private channel.
34
+ All complaints will be reviewed and investigated promptly and fairly.
35
+
36
+ ## Attribution
37
+
38
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
39
+ version 2.1. [homepage]: https://www.contributor-covenant.org
@@ -0,0 +1,68 @@
1
+ # Contributing to aemo-mcp
2
+
3
+ Thanks for the interest. This MCP server is part of a family of Australian
4
+ open-data wrappers ([abs-mcp](https://github.com/Bigred97/abs-mcp),
5
+ [rba-mcp](https://github.com/Bigred97/rba-mcp),
6
+ [ato-mcp](https://github.com/Bigred97/ato-mcp),
7
+ [apra-mcp](https://github.com/Bigred97/apra-mcp),
8
+ [aihw-mcp](https://github.com/Bigred97/aihw-mcp),
9
+ [asic-mcp](https://github.com/Bigred97/asic-mcp),
10
+ [au-weather-mcp](https://github.com/Bigred97/au-weather-mcp)) and follows the
11
+ same architectural template.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ git clone https://github.com/Bigred97/aemo-mcp
17
+ cd aemo-mcp
18
+ uv sync --extra dev
19
+ uv pip install -e .
20
+ uv run pytest -q
21
+ ```
22
+
23
+ ## What we accept
24
+
25
+ - **Bug fixes** with a regression test.
26
+ - **New curated feeds** for high-value NEMWEB reports. Add a YAML in
27
+ `src/aemo_mcp/data/curated/`, extend `src/aemo_mcp/data/feeds.yaml`, and
28
+ add at least one unit test against a fixture in `tests/fixtures/`.
29
+ - **Search-keyword expansions** to help LLMs route common queries — must be
30
+ paired with a routing regression test in `test_feeds.py`.
31
+ - **Performance improvements** in parsing, caching, or in-flight dedup.
32
+
33
+ ## What we don't accept (by design)
34
+
35
+ - **Tool count > 5.** We hold to the standard `search_datasets / describe_dataset / get_data / latest / list_curated` surface across all sibling MCPs.
36
+ - **Pre-bundled NEMWEB archives in the wheel.** Live fetch only.
37
+ - **Pandas at the tool surface.** Records must be plain `list[dict]` / `list[Observation]`. Pandas is fine internally for CSV parsing.
38
+ - **Dependencies on `nemosis`, `nempy`, `openelectricity`.** Stay light.
39
+
40
+ ## Tests
41
+
42
+ ```bash
43
+ # Unit tests (offline)
44
+ uv run pytest -q
45
+
46
+ # Live tests (hit NEMWEB)
47
+ uv run pytest -q -m live
48
+
49
+ # 10x zero-flake validation
50
+ for i in $(seq 1 10); do uv run pytest -q || break; done
51
+ ```
52
+
53
+ ## Release
54
+
55
+ Tag the commit:
56
+
57
+ ```bash
58
+ git tag v0.1.x
59
+ git push origin v0.1.x
60
+ ```
61
+
62
+ GitHub Actions builds the wheel and publishes to PyPI via Trusted Publishing
63
+ (OIDC, no token needed).
64
+
65
+ ## Attribution
66
+
67
+ Every response carries the AEMO Copyright Permissions attribution string.
68
+ Keep that contract intact across changes.
aemo_mcp-0.1.2/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Harry Vass
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Note on AEMO data: this software fetches data published by the Australian
26
+ Energy Market Operator (AEMO) via NEMWEB. AEMO grants general permission to
27
+ use AEMO Material for any purpose (commercial included) on the sole condition
28
+ of accurate attribution of the relevant material and AEMO as its author. See
29
+ https://aemo.com.au/privacy-and-legal-notices/copyright-permissions.
30
+
31
+ End-users redistributing data fetched via this server must credit AEMO. The
32
+ `DataResponse.attribution` field on every response carries the canonical
33
+ attribution string.