# nemweb

Rust client for [AEMO NEMWeb](https://nemweb.com.au) — Australian National Electricity Market data.

Provides typed, async access to real-time and historical market data with zero configuration.

## Data types

| Query | Record | Interval | Description |
|-------|--------|----------|-------------|
| `dispatch()` | `DispatchPrice` | 5-min | Dispatch interval spot prices (raw 5-min) |
| `trading()` | `TradingPrice` | 5-min [^5ms] | Settlement prices (used for billing) |
| `demand()` | `RegionDemand` | 5-min | Region demand, generation & interchange |
| `interconnector()` | `InterconnectorFlow` | 5-min | Cross-region interconnector flows |
| `predispatch()` | `PredispatchPrice` | 30-min | Price forecasts (~40 hours ahead) |
| `scada()` | `UnitScada` | 5-min | Generator SCADA telemetry |

[^5ms]: Since AEMO's 5-Minute Settlement reform on 1 October 2021, trading intervals are 5-min (same as dispatch). Pre-5MS historical queries return data at the original 30-min cadence.

All record types derive `Serialize`/`Deserialize` for easy JSON/CSV export (enabled by default, disable with `default-features = false`).

## Quick start

```toml
[dependencies]
nemweb = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```

```rust
use nemweb::Region;

#[tokio::main]
async fn main() -> nemweb::Result<()> {
    // Latest dispatch prices — one record per region
    let prices = nemweb::dispatch().fetch().await?;
    for p in &prices {
        println!("{} ${:.2}/MWh", p.region, p.price);
    }

    // Filter to a single region
    let nsw = nemweb::dispatch()
        .region(Region::NSW1)
        .fetch()
        .await?;
    println!("NSW1: ${:.2}/MWh", nsw[0].price);

    // Historical data — automatically fetches from MMSDM archive
    let jan = nemweb::dispatch()
        .from("2024-01-01")?
        .to("2024-01-31")?
        .fetch()
        .await?;
    println!("{} records", jan.len()); // ~44,600

    Ok(())
}
```

## API overview

Every query follows the same builder pattern:

```rust
nemweb::<data_type>()       // create query
    .region(Region::NSW1)   // optional filter (or .id() / .duid())
    .from("2024-01-01")?    // optional: triggers MMSDM historical fetch
    .to("2024-03-31")?      // optional: defaults to today
    .fetch()                // async, returns Result<Vec<Record>>
    .await?;
```

### Record types

Every query returns a `Vec<T>` of a small, fully-typed record. Full field
documentation is on [docs.rs](https://docs.rs/nemweb); the summary:

```rust
pub struct DispatchPrice {
    pub region: Region,
    pub settlement_date: NaiveDateTime,
    pub price: f64,               // RRP ($/MWh)
    pub apc_flag: u8,             // Administered Price Cap bitmask (0 = none)
    pub market_suspended_flag: u8, // 0 = normal; non-zero = suspended state
}

pub struct TradingPrice {
    pub region: Region,
    pub settlement_date: NaiveDateTime,
    pub price: f64,
    pub invalid: bool,            // AEMO INVALIDFLAG = 1
}

pub struct RegionDemand {
    pub region: Region,
    pub settlement_date: NaiveDateTime,
    pub total_demand: f64,         // MW
    pub available_generation: f64, // MW
    pub net_interchange: f64,      // MW, positive = net export
}

pub struct InterconnectorFlow {
    pub interconnector_id: String,
    pub settlement_date: NaiveDateTime,
    pub mw_flow: f64,              // NEMDE target (what it should flow)
    pub metered_mw_flow: f64,      // SCADA actual (what it did flow)
    pub mw_losses: f64,
}

pub struct PredispatchPrice {
    pub region: Region,
    pub forecast_time: NaiveDateTime,
    pub price: f64,
}

pub struct UnitScada {
    pub duid: String,
    pub settlement_date: NaiveDateTime,
    pub scada_value: f64,          // MW (can be negative — loads, batteries)
}
```

### Real-time queries

```rust
let prices    = nemweb::dispatch().fetch().await?;       // Vec<DispatchPrice>
let trading   = nemweb::trading().fetch().await?;        // Vec<TradingPrice>
let demand    = nemweb::demand().fetch().await?;         // Vec<RegionDemand>
let flows     = nemweb::interconnector().fetch().await?; // Vec<InterconnectorFlow>
let forecasts = nemweb::predispatch().fetch().await?;    // Vec<PredispatchPrice>
let scada     = nemweb::scada().fetch().await?;          // Vec<UnitScada>
```

### Filters

```rust
// By NEM region
nemweb::dispatch().region(Region::SA1).fetch().await?;

// By interconnector ID
nemweb::interconnector().id("VIC1-NSW1").fetch().await?;

// By generating unit DUID
nemweb::scada().duid("BASTYAN").fetch().await?;
```

### Historical queries (MMSDM)

Add `.from()` to any query (except `predispatch`) to fetch from the MMSDM monthly archive:

```rust
let prices = nemweb::dispatch()
    .region(Region::VIC1)
    .from("2024-01-01")?
    .to("2024-01-07")?
    .fetch()
    .await?;
// ~2,000 records (288 intervals/day × 7 days × 1 region)
```

> **Note:** Historical queries download ZIP archives from AEMO servers — one per month in the date range. A single month typically takes 1-2 seconds; multi-month queries are proportionally slower.

The MMSDM archive starts in **July 2009**; queries for earlier dates return zero records and emit a `log::warn!`.

### Historical data caveats

Historical analysis has a few sharp edges you should know about before you
aggregate anything:

- **Market suspension events** — During the June 2022 NEM suspension and any
  other administered-price period, `DispatchPrice::market_suspended_flag` is
  non-zero and the reported `price` is **not** a market-clearing price — it is
  an Administered Price capped at a few hundred $/MWh. Mixing suspended
  intervals into averages will distort results. Filter or segregate them:
  ```rust
  let clean = prices.iter().filter(|p| p.market_suspended_flag == 0);
  ```
- **Administered Price Cap (APC)** — `DispatchPrice::apc_flag` is a bitmask of
  which prices were capped by the AEMC mechanism (bit 0 is energy, the rest
  are FCAS). `apc_flag & 1 != 0` means the energy price was capped.
- **Intervention dispatch** — AEMO publishes two copies of every interval
  during intervention events (counterfactual and actual). `nemweb` always
  keeps the actual (`INTERVENTION=1`) row.
- **Interval-ending timestamps** — a `settlement_date` of `2024-02-01 00:00:00`
  is the end of the 23:55–00:00 interval and belongs to 31 Jan's dispatch
  activity. Range queries handle this automatically; if you bucket data
  yourself, shift midnight timestamps back one day.
- **Pre-5MS trading prices** — before 1 October 2021, `TradingPrice` records
  are at 30-min cadence instead of 5-min. The values are still correct, just
  sparser.
- **Modelled vs metered interconnector flow** — `InterconnectorFlow::mw_flow`
  is the NEMDE target; `metered_mw_flow` is the actual SCADA reading. They
  can differ by tens of MW routinely and by over 1 GW during constraint
  events. For physical/engineering analysis prefer `metered_mw_flow`; for
  economic / market-clearing analysis use `mw_flow`.

### JSON export

All types implement `serde::Serialize`:

```rust
let prices = nemweb::dispatch().fetch().await?;
let json = serde_json::to_string_pretty(&prices)?;
println!("{json}");
```

### Timestamps

All timestamps are **AEST** (Australian Eastern Standard Time, UTC+10) — this is how AEMO publishes all NEM data. They are represented as `chrono::NaiveDateTime` (re-exported as `nemweb::NaiveDateTime`), so you don't need to add `chrono` as a direct dependency:

```rust
let prices = nemweb::dispatch().fetch().await?;
let ts: nemweb::NaiveDateTime = prices[0].settlement_date;
```

### Logging

`nemweb` uses the [`log`](https://docs.rs/log) facade. When a historical query
spans months that haven't been published to the MMSDM archive yet (common for
recent dates), those months are skipped and a `warn!` is emitted so you can
tell data was silently omitted.

To see these warnings, plug in any `log` implementation — e.g. `env_logger`:

```toml
[dependencies]
env_logger = "0.11"
```

```rust
fn main() {
    env_logger::init();
    // ...
}
```

```sh
RUST_LOG=nemweb=warn cargo run
# [WARN  nemweb] skipping 2026-03: MMSDM data not available for 2026-03
```

### Error handling

All errors are returned as `nemweb::Error`, which you can match on:

```rust
match nemweb::dispatch().from("bad-date") {
    Err(nemweb::Error::InvalidDate { value }) => {
        eprintln!("Bad date: {value}");
    }
    _ => {}
}
```

## Examples

```sh
cargo run --example quickstart   # 10 lines, zero to real data
cargo run --example realtime     # all six data types with filters
cargo run --example historical   # MMSDM archive queries
cargo run --example json_export  # serialize to JSON
```

## Regions

The five NEM regions:

| Variant | State |
|---------|-------|
| `Region::NSW1` | New South Wales |
| `Region::QLD1` | Queensland |
| `Region::SA1` | South Australia |
| `Region::TAS1` | Tasmania |
| `Region::VIC1` | Victoria |

`Region` implements `FromStr`, so you can parse from strings: `"NSW1".parse::<Region>()`.

## Testing

Tests hit the live NEMWeb API. Run sequentially to avoid rate limiting:

```sh
cargo test -- --test-threads=1
```

## License

MIT
