Scenario configuration
A scenario config is the NEMSweep.CLI input format. It is a single JSON file describing every
region a run will dispatch: its demand and weather inputs, its generating and storage fleets, the
interconnectors linking it to other regions, the cost basis the run is priced against, and the
bounds the storage-sizing search may use to grow a region's Battery capacity. --run-scenario reads
one of these, dispatches it over the period its demand series cover, and writes the results.
The CLI accepts free-form region IDs in scenarios when they refer to ingested artifacts, including IDs created by a format-2 portable input bundle. Legacy NEM-only import paths remain limited to the five National Electricity Market regions. The framework itself also treats region identifiers as free-form strings.
The authoritative machine-readable form of everything on this page is the published JSON Schema:
dotnet run --project NEMSweep.CLI -- --describe-schema scenario
Run that to see the exact schemaVersion the installed CLI accepts, and to validate a config
before you run it. This page does not repeat that number, because it changes independently of the
documentation.
The CLI upgrades supported legacy scenario versions 5 and 6 to version 7 sizeBattery configs.
Legacy reliability fields are moved out of storageSizing, with the historical default applied
when an older config has no target. The pre-emissions shape also gains a zero operational-emissions
intensity on generation profiles to preserve its prior behavior. The normalized config must still
meet current validation, including a Battery assumptions fleet in every region. Other older
schemas are rejected rather than guessed at.
How the file is read
Deserialisation is strict: additionalProperties is false throughout the schema, and the CLI
enforces the same thing at the JSON level (UnmappedMemberHandling.Disallow). Any unknown property
anywhere in the file is a hard error rather than a value that gets silently ignored, whether it is
a typo in a field name or a field copied from the wrong nesting level. That is deliberate: a
scenario that loaded despite a typo would run with different assumptions than the author intended,
and the model would never say so.
demandFile and weatherFile are per-region, not top-level fields. A file that sets them (or
dataCentreNameplateMw) at the root is rejected outright, specifically to stop a single-region
habit from carrying over into a multi-region config.
Root object
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
schemaVersion |
integer | yes | n/a | Must equal the version reported by --describe-schema scenario. |
id |
string | yes | n/a | Scenario identifier. Reported by --validate-scenario and carried on sweep-generated point configs, but not written into a dispatch result: neither results.json nor results-{region}.json records it. It does not appear in any published path either, because an ordinary run writes fixed results*.json names and sweep paths are keyed by sweep and point ID. Keep your own note of which config produced a result. |
name |
string | yes | n/a | Human-readable name. |
costBasis |
object | yes | n/a | See costBasis. |
executionMode |
string | no, defaults to fixedFleet |
n/a | fixedFleet dispatches declared fleets unchanged; sizeBattery runs the bounded Battery sizing search. |
reliability |
object | yes | n/a | Required target and optional standard label, used by both execution modes. |
regions |
array of region | yes, at least one | n/a | One entry per region, with IDs matching the ingested artifacts. |
storageSizing |
object or null | mode-dependent | n/a | Required as an object for sizeBattery; omitted or null for fixedFleet. |
interconnectors |
array of interconnector | no | n/a | Directed transmission links between regions. |
provenance |
object | no | n/a | Free-form; the CLI overwrites this on sweep-generated configs, so treat it as informational rather than as configuration. |
reliability
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
targetUsePercentage |
number | yes | percentage of demand energy, (0, 100] |
Maximum unserved energy as a percentage of demand energy. Both the system and each individual region must meet the target. |
standardName |
string or null | no | n/a | Optional descriptive label recorded with the reliability result; it does not affect dispatch. |
This target is required in both execution modes. fixedFleet reports the declared fleets' result even
when the target is missed; sizeBattery uses it to guide the bounded Battery search.
costBasis
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
year |
integer | yes | calendar year, 2000–2100 | The cost-basis year for all AUD figures in the scenario. |
realDiscountRate |
decimal | yes | fraction (e.g. 0.07 = 7%) |
Real discount rate used to annuitise capital costs. |
regions[]
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
regionId |
string | yes | n/a | Exact ID matching the demand and weather artifacts. IDs must be distinct and have no leading or trailing whitespace. Custom IDs may be at most 100 UTF-8 bytes after invariant uppercase normalization; known NEM IDs keep their existing filenames. |
demandFile |
string | yes | n/a | Path to this region's demand series (as written by --ingest). |
weatherFile |
string | yes | n/a | Path to this region's weather series (as written by --ingest). |
generatingFleets |
array of generating fleet | yes, at least one | n/a | This region's generation. Technologies must be distinct within the region. |
storageFleets |
array of storage fleet | yes, may be empty | n/a | This region's declared storage. Technologies must be distinct. sizeBattery additionally requires a Battery fleet in every region to provide its cost and technology assumptions; zero initial capacity is valid. |
dataCentreNameplateMw |
number | no, default 0 | MW | A flat, full-load-factor additive demand component, representing new load such as a data centre. |
Scenario validation also calls the portable filename encoder before dispatch. Custom IDs exceeding the 100-byte normalized limit or containing invalid UTF-16 are rejected. Free-form IDs can contain spaces, punctuation, commas, quotes and Unicode; if an ID appears in a demand CSV field, quote it using standard CSV escaping.
An interconnector endpoint must name a region that appears in regions (see
below).
regions[].generatingFleets[]
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
technology |
string | yes | n/a | Generation technology name (e.g. Coal, Gas, Solar, Wind, Hydro). Must be distinct within the region. |
nameplateCapacityMw |
number | yes | MW | Installed nameplate capacity. |
costParameters |
object | yes | n/a | See cost parameters. |
technologyProfile |
object | yes | n/a | See generation technology profile. |
monthlyCapacityFactors |
array of monthly capacity factor | no | n/a | An energy budget per calendar month, used by Hydro (see below). |
Generation cost parameters
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
capitalCostAudPerMw |
decimal | yes | AUD per MW | Overnight capital cost per MW of nameplate capacity. |
fixedOperatingCostAudPerMwYear |
decimal | yes | AUD per MW per year | Fixed O&M cost per MW of nameplate capacity per year. |
variableOperatingCostAudPerMwh |
decimal | yes | AUD per MWh generated | Variable O&M cost per MWh generated. |
fuelPriceAudPerGj |
decimal | yes | AUD per GJ | Fuel price. Zero for fuel-free technologies (Solar, Wind, Hydro). |
Generation technology profile
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
heatRateGjPerMwh |
number | yes | GJ per MWh generated | Fuel consumed per MWh generated. Zero for fuel-free technologies. Combined with fuelPriceAudPerGj and variableOperatingCostAudPerMwh to derive short-run marginal cost, which sets merit order. |
technicalLifeYears |
integer | yes | years | Technical life used to annuitise capital cost. |
emissionsIntensityTonnesPerMwh |
number | yes | t CO2-e per MWh generated | Operational emissions per MWh generated, on the same gross basis as fuel. Combustion only, so not a life-cycle figure. Zero for non-emitting technologies, stated rather than defaulted. This field is an input assumption; the emissions intensity a run publishes is a different quantity, t CO2-e per MWh served. |
Leave emissionsIntensityTonnesPerMwh at zero for Solar and Wind. Emissions are charged on
available generation, before curtailment, which is exact for the fleets that emit because dispatch
constrains off only Solar and Wind. A non-zero intensity on either of those two therefore charges
the scenario for output that was constrained off and never released anything. Nothing in validation
stops you: the field is yours to set, and this is the one value where a plausible-looking number is
wrong. Emissions covers the basis.
monthlyCapacityFactors[]
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
month |
date | yes | calendar month (first-of-month date) | The month this budget applies to. |
capacityFactor |
number | yes | fraction, (0, 1] |
Energy budget for the month, expressed as a capacity factor against nameplateCapacityMw. |
monthlyCapacityFactors is an energy budget, not a shape constraint: it caps how much energy
Hydro may generate across the month in total, rather than dictating an hourly output profile. It is
the mechanism that lets a scenario represent Hydro as a rationed but dispatchable resource rather
than as unlimited firm capacity.
regions[].storageFleets[]
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
technology |
string | yes | n/a | Storage technology name (e.g. Battery, PumpedHydro). Must be distinct within the region. |
initialEnergyCapacityMwh |
number | yes | MWh | Installed energy capacity at the start of the run. |
initialPowerCapacityMw |
number | yes | MW | Installed power capacity at the start of the run. |
costParameters |
object | yes | n/a | See storage cost parameters. |
technologyProfile |
object | yes | n/a | See storage technology profile. |
initialEnergyCapacityMwh and initialPowerCapacityMw must either both be zero or both be
positive, because a half-built fleet is rejected. A zero/zero fleet is not the same as omitting
the technology: it means no capacity of that technology is installed at the start of the run, but the
cost and technology-profile assumptions attached to it still apply to any capacity the
storage-sizing search later adds. In practice this is how you let the sizing loop introduce a
Battery: declare a Battery fleet at 0 MWh / 0 MW with the cost and efficiency assumptions you want
it to be built at, and the search grows it from there if the region fails its reliability target.
Only Battery fleets are grown this way; other storage technologies (for example PumpedHydro)
are dispatched as declared and are not resized by the search.
Storage cost parameters
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
powerCapitalCostAudPerMw |
decimal | yes | AUD per MW | Overnight capital cost per MW of power capacity. |
energyCapitalCostAudPerMwh |
decimal | yes | AUD per MWh | Overnight capital cost per MWh of energy capacity. |
fixedOperatingCostAudPerMwYear |
decimal | yes | AUD per MW per year | Fixed O&M cost per MW of power capacity per year. |
Storage technology profile
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
technicalLifeYears |
integer | yes | years | Technical life used to annuitise capital cost. |
roundTripEfficiency |
number | yes | fraction, [0, 1] |
Round-trip efficiency of one charge/discharge cycle. |
storageSizing
storageSizing is present only when executionMode is sizeBattery. It contains only search
bounds; reliability is configured separately in the required root reliability object.
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
maximumPowerMw |
number | yes | MW | Largest Battery power the sizing search may consider, applied per region, not to the system total. |
maximumEnergyMwh |
number | yes | MWh | Largest Battery energy the sizing search may consider, applied per region. Must support at least four hours at maximumPowerMw. |
maximumPasses |
integer | no, default 256 |
count | Cap on whole-system dispatch passes the sizing search may spend. Each pass re-dispatches every region for the full period, so this bounds wall-clock cost. Reaching it is a reportable outcome, not a crash. |
interconnectors[]
| Field | Type | Required | Unit | Meaning |
|---|---|---|---|---|
fromRegionId |
string | yes | n/a | Sending-end region. Must be one of regions. |
toRegionId |
string | yes | n/a | Receiving-end region. Must be one of regions and different from fromRegionId. |
capacityMw |
number | yes | MW | Directed transfer capacity, metered at the sending end. |
routeLengthKm |
number | yes, positive | km | The line's route length. Declared directly; not derived from anything else. |
capitalCostAudPerKmPerMw |
decimal | yes | AUD per km per MW | Capital cost rate, multiplied by route length and capacity. |
fixedOperatingCostAudPerKmPerMwYear |
decimal | yes | AUD per km per MW per year | Fixed O&M cost rate, multiplied by route length and capacity. |
technicalLifeYears |
integer | yes, nonzero | years | Technical life used to annuitise capital cost. |
Interconnectors are directed. A corridor that carries flow both ways is two entries, one
fromRegionId and toRegionId pair per direction, and each entry is costed independently at the
route's full length. Declaring both directions therefore roughly doubles the reported transmission
capital and fixed cost for that corridor rather than splitting it between the two flows. See
Limitations for what that does to reported system cost. At most one
interconnector is permitted per exact direction, and fromRegionId/toRegionId must not be equal.
Costing an interconnector does not require its endpoint regions to carry weather data: route length
is routeLengthKm, not anything derived from a region's weather file.
Time
A scenario period runs in market time: one fixed UTC offset with no daylight saving adjustment, carried on both period bounds and shared by every series in the run. The offset is whatever the demand artifact declares, which for AEMO operational demand is the NEM's UTC+10. Point the model at a single-timezone market by ingesting that market's demand and weather under its own offset instead.
Which inputs change dispatch, and which change only cost
Worth knowing before you read a result: some of the fields above feed back into the hour-by-hour dispatch, and the rest are priced on top of a dispatch that would have been identical without them. Change one of the second kind and the physical result — unserved energy, curtailment, the battery the search lands on — is unchanged; only the money moves.
The split follows from short-run marginal cost. GenerationCostParameters.ShortRunMarginalCostFor
(NEMSweep.Model/Scenarios/GenerationCostParameters.cs) is
variableOperatingCostAudPerMwh + fuelPriceAudPerGj * heatRateGjPerMwh, and
NEMSweep.Model/Simulation/GenerationMeritOrder.cs orders fleets by it. Nothing else in a cost
block reaches the dispatch.
Changes dispatch, and therefore cost as well:
| Field | Why |
|---|---|
generatingFleets[].costParameters.fuelPriceAudPerGj |
A term of short-run marginal cost, so it can reorder merit order. |
generatingFleets[].costParameters.variableOperatingCostAudPerMwh |
The other term of short-run marginal cost. |
generatingFleets[].technologyProfile.heatRateGjPerMwh |
Multiplies fuel price into short-run marginal cost. |
generatingFleets[].nameplateCapacityMw |
How much a fleet can deliver in an hour. |
generatingFleets[].monthlyCapacityFactors[] |
Hydro's monthly energy budget. |
storageFleets[].initialEnergyCapacityMwh, initialPowerCapacityMw |
What storage exists before the search. |
storageFleets[].technologyProfile.roundTripEfficiency |
Energy lost on charge. |
regions[].dataCentreNameplateMw |
Adds flat demand. |
regions[].demandFile, weatherFile |
The series being dispatched against. |
interconnectors[].capacityMw |
Caps directed transfer. |
executionMode, reliability.targetUsePercentage, storageSizing.* |
Whether the battery search runs, what it aims at, and the bounds it may use. |
Changes only the reported cost:
| Field | Why |
|---|---|
generatingFleets[].costParameters.capitalCostAudPerMw |
Annuitised capital; never consulted by dispatch. |
generatingFleets[].costParameters.fixedOperatingCostAudPerMwYear |
A per-MW-year charge on declared capacity. |
generatingFleets[].technologyProfile.technicalLifeYears |
Annuitisation period only. |
storageFleets[].costParameters.* (powerCapitalCostAudPerMw, energyCapitalCostAudPerMwh, fixedOperatingCostAudPerMwYear) |
Priced after the search; the search targets reliability, not cost. |
storageFleets[].technologyProfile.technicalLifeYears |
Annuitisation period only. |
interconnectors[].routeLengthKm, capitalCostAudPerKmPerMw, fixedOperatingCostAudPerKmPerMwYear, technicalLifeYears |
Transmission cost rates; the only interconnector field dispatch reads is capacityMw. |
costBasis.year, costBasis.realDiscountRate |
The cost basis every AUD figure is expressed and annuitised in. |
reliability.standardName |
A label recorded with the result. |
generatingFleets[].technologyProfile.emissionsIntensityTonnesPerMwh is a third case: it changes
neither dispatch nor cost, only the emissions accounting.
One consequence is worth stating plainly. Sweeping a capital cost does not change the system that gets built, because the sizing search is driven by the reliability target rather than by cost. A battery capital-cost sweep therefore reprices one fixed system across the axis; it does not tell you what system a cheaper battery would have led someone to build. Sweeping a fuel price does move the dispatch, and so can move the battery the search lands on.
Recipe: change one input
Two routes. Use the first when you want one answer; use the second when you want the before and after published together on an axis.
Edit a copy of the scenario. A scenario config is one JSON file, so changing one input is changing one value. To take the gas fuel price up by 50%, from 10 to 15 AUD/GJ:
cp scenarios/sized-scenario.json scenarios/sized-gas15.json
Then in the copy, set the Gas fleet's fuelPriceAudPerGj to 15 and give the file its own id.
The field is per fleet, per region, so a multi-region scenario has one to change in each region
that declares Gas:
{
"regionId": "Island-X",
"generatingFleets": [
{
"technology": "Gas",
"costParameters": { "fuelPriceAudPerGj": 15 }
}
]
}
Validate before running, which catches a typo in seconds rather than after a dispatch:
nemsweep --validate-scenario scenarios/sized-gas15.json --data-root ./data --format json
nemsweep --run-scenario scenarios/sized-gas15.json --data-root ./data --output ./out/gas15
Compare cost.slcoeAudPerMwh and reliability.achievedUsePercentageOfDemand in each run's
results-overview.json. Because fuel price is a dispatch input, expect the physical result to move
too, not only the cost.
Or write a two-point sweep, which keeps the baseline and the variant in one artifact and names what was varied. This is the same change as a sweep override patch:
{
"schemaVersion": 2,
"sweepId": "gas-price-x15",
"name": "Gas fuel price +50%",
"axis": { "label": "Gas fuel price", "unit": "AUD/GJ" },
"baselineConfigPath": "scenarios/sized-scenario.json",
"points": [
{
"pointId": "base",
"axisValue": 10,
"label": "10 AUD/GJ (baseline)",
"overrides": {}
},
{
"pointId": "x15",
"axisValue": 15,
"label": "15 AUD/GJ (+50%)",
"overrides": {
"regions": [
{
"regionId": "Island-X",
"generatingFleets": [
{ "technology": "Gas", "costParameters": { "fuelPriceAudPerGj": 15 } }
]
}
]
}
}
]
}
nemsweep --run-sweep sweeps/gas-price.json --data-root ./data --output ./out/gas --csv
The keyed-array merge means naming the Gas fleet's one changed field is enough: every other cost
parameter on it, and every other fleet and region, carries over from the baseline. See
JSON merge-patch semantics.
Recipe: sweep a cost parameter
Battery capital cost from 50% to 150% of the baseline, in five points. The baseline here declares
powerCapitalCostAudPerMw: 200000 and energyCapitalCostAudPerMwh: 150000, so each point states
the multiple of those two:
{
"schemaVersion": 2,
"sweepId": "battery-capex",
"name": "Battery capital cost 50-150%",
"axis": { "label": "Battery capital cost", "unit": "% of baseline" },
"baselineConfigPath": "scenarios/sized-scenario.json",
"points": [
{
"pointId": "p50",
"axisValue": 50,
"label": "50% of baseline battery capital cost",
"overrides": {
"regions": [
{
"regionId": "Island-X",
"storageFleets": [
{
"technology": "Battery",
"costParameters": {
"powerCapitalCostAudPerMw": 100000,
"energyCapitalCostAudPerMwh": 75000
}
}
]
}
]
}
}
]
}
Repeat that point for p75, p100, p125 and p150 at 75%, 100%, 125% and 150% of each figure,
with axisValue matching. p100 can use "overrides": {} instead, since it is the baseline.
Nothing cross-checks axisValue against overrides, so keeping the two in agreement is yours to
get right.
nemsweep --fan-out-sweep sweeps/battery-capex.json --data-root ./data --output ./out/capex
nemsweep --run-sweep sweeps/battery-capex.json --data-root ./data --output ./out/capex --csv
Fan out first and read sweeps/battery-capex/configs/p50.json to confirm each point patched what
you meant, then run. Read the result from sweeps/battery-capex/index.json, whose points[] carries
each point's axisValue and scalars, or from csv/points/<pointId>/fact_scalars.csv with --csv.
Two things to expect, both consequences of the section above. The storage the search installs is the
same at every point, because capital cost does not steer the search. And slcoeAudPerMwh moves
close to linearly with the axis, because the only thing changing is the annuitised capital on a
fixed quantity of storage. A capital-cost sweep is a repricing of one system, which is a narrower
finding than it can look like on a chart.
To vary a parameter that does change dispatch, use the same shape with fuelPriceAudPerGj on a
generating fleet.
See also
- Scenario parameters: what these values mean in the model and how to think about choosing them. They are assumptions you supply, not constants the model derives.
- Sweeps: applying override patches to this file across a series of runs on one labelled axis, instead of hand-editing copies of it.