diff --git a/CHANGELOG.md b/CHANGELOG.md index 433573b..81d0e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,40 +3,118 @@ Notable changes per release. Versions follow [semantic versioning](https://semver.org); while the major version is 0, minor bumps may include breaking changes and say so here. -## Unreleased +## 0.6.0 (2026-10-05) -### Upgrading +Bridge two totals with a waterfall and show a spread with a box plot, colour a KPI by its value, put cell bars on chosen table columns, and head the sections of a sketch. `advise` now checks axis titles, readable type, markdown that fits its block and sub-tabs too thin to earn a click, and `plan` is clean straight after `apply`. -- The design brain is version 8. `advise` now flags a table whose height can't show every row its `row_limit` allows, as the smoke check after `apply` does when the data fills it; before, it passed a table that showed half. A `--design strict` or `advise --strict` gate can newly fail on such a table. To keep the old threshold for tables that rarely reach their `row_limit`, set `table_visible_ratio: 0.5` in `design.yaml` or a standards file. -- The design brain is version 11. `advise` now warns when a markdown block's text runs past the block's bottom edge (`size.markdown-fit`), so a `--design strict` or `advise --strict` gate can newly fail on such a block; `advise --fix` raises it. `layout.markdown-height` now fixes a one-line block to the height its line takes, 1.6 units for a line of text up to 2.4 for a `#` heading, instead of 2. -- A spec with `conditional_formatting` on a big number is refused by chartwright 0.5.0 and earlier (`extra_forbidden`); upgrade CI before committing one. -- The design brain is version 14. Its new warn rule, `chart.color-contrast`, can fail `advise --strict` and `--design strict` on a spec that passed before: a colour rule painting text or a trendline too pale to read. +### Upgrading from 0.5 + +- A spec that uses a field new in 0.6 is refused by chartwright 0.5 (`extra_forbidden` or `literal_error`); upgrade CI before committing one. The new fields: + - the `waterfall` and `box_plot` chart types; + - a mixed chart's `kind: "area"`; + - a trendline's `rolling_type` and `y_axis_truncate`; + - a big number's `date_format` and `conditional_formatting`; + - a heatmap's `x_order`, `y_order`, `x_label_every`, `y_label_every` and `left_margin`; + - a bar's `sort_by`; + - a table's `cell_bars` as a list of labels, and its `color_by_sign` and `absolute_bars`; + - the colour-rule operators `>=`, `<=`, `!=` and `between_inclusive`; + - header and markdown blocks in a sketch. +- The design brain goes from version 7 to 17. New warn-level findings can make `--design strict` or `advise --strict` fail on a spec that passed before: + - a table whose height can't show every row its `row_limit` allows (before, a table that showed half passed). To keep the old threshold for tables that rarely fill their `row_limit`, set `table_visible_ratio: 0.5` in `design.yaml` or a standards file; + - markdown text cut off by its block (`size.markdown-fit`); + - a colour rule or trendline too pale to read (`chart.color-contrast`); + - a rolling trendline whose time range can't hold its window (`data.rolling-window-span`); + - a waterfall whose steps don't add up or run past about 12 (`chart.waterfall-additive`, `chart.waterfall-steps`); + - a box plot with too few observations per box or more than about 20 boxes (`chart.box-plot-observations`, `chart.box-plot-groups`); + - table or chart text that the dashboard's own CSS or theme sets below the readable floor (`readability.table-text`, `readability.chart-text`). +- `advise --fix` writes more design defaults, so review the diff after its first run: + - `compare_suffix` between trailing windows; + - `date_format` on a big number of a date column; + - `left_margin` on every heatmap; + - values on grouped bars and on a waterfall's steps; + - the height a big number needs for readable text; + - a one-line markdown block's own height. + + It also removes a search box it filled on a table whose rows all show. ### Added -- `advise` asks every axis chart to name its axes (design brain 17). `chart.axis-titles` notes a chart with no `y_axis_title` (the measure and its unit) or no `x_axis_title` (the dimension); Superset draws neither unless the chart sets it, and a screenshot or export can't show a tooltip. The heatmap, which has no title setting, and the waterfall, whose steps name themselves, are left out. The brief now tells authors to title every axis. +- `examples/sundown/`, the dashboard in the README: a six-tab grid-siting dashboard on EIA-930 and EIA-860M data. It ships with its spec, the script that writes it, seven colour palettes and the Superset theme it names. A test builds the spec in every palette and compiles it. -- `layout.thin-subtabs` notes a sub-tab holding two charts or fewer besides its KPIs: readers click for little, and the sub-tabs read better as sections of one page under header rows. +- `advise` asks every axis chart to name its axes. `chart.axis-titles` notes a chart with no `y_axis_title` (the measure and its unit) or no `x_axis_title` (the dimension); Superset draws neither unless the chart sets it, and a screenshot or export can't show a tooltip. The heatmap, which has no title setting, and the waterfall, whose steps name themselves, are left out. The brief now tells authors to title every axis. -- The value-label design default now covers grouped bars: a bar chart with several metrics side by side gets `show_value` when its bars (rows times metrics) fit the same 12-bar and spacing limits as a single series. Stacked metrics and grouped series stay unlabelled. +- A waterfall chart (`"type": "waterfall"`, Superset's Waterfall): steps that add up to a running total, such as a bridge from last year's revenue through each driver to this year's. Compiled, decompiled, compared by `plan` and checked by `apply` on 4.1.4, 5.0.0 and 6.1.0. + - `x_column` holds the steps (or a time column, one bar per `time_grain` period), `metric` the one measure they add, and `groupby` an optional breakdown per x value. + - `increase_color`, `decrease_color` and `total_color` take green, amber, red or any `#RRGGBB`, and replace Superset's stock green, red and grey on every release. `show_value`, `show_legend`, `number_format`, axis titles, `x_label_rotation` (0, 45 or 90) and `x_label_format` set the rest. + - On Superset 6.1.0 or later, `steps` lists a bridge's values in its own order and `closing` names the row that closes it, drawn last as the running total in the total colour: `"steps": ["FY2025", "Price", "Volume"], "closing": "FY2026"`. The first step rises from zero; `"opening": "FY2025"` draws the opening row as a total too, on a dashboard whose theme keeps zero on the value axis (`{"echartsOptionsOverridesByChartType": {"waterfall": {"yAxis": {"scale": false}}}}` in its JSON), which `check` and `apply` verify (`waterfall_opening_axis`). Older releases draw a running total after every step, so `check`, `apply` and `plan` refuse `steps` there. `total_label`, `increase_label` and `decrease_label` name the bars on 6.1.0 and warn before it. + - `apply`'s data check says when a bridge's opening, step or closing has no row, a value isn't in `steps`, or the closing row doesn't equal the steps added up (Superset draws the sum either way). + +- A waterfall gets `chart.waterfall-additive` (warn: `AVG`, `MIN`, `MAX` or `COUNT_DISTINCT` steps don't add up), `chart.waterfall-steps` (warn: past ~12 bars), `chart.waterfall-order` (info: a categorical waterfall without `steps` reads A to Z), `chart.waterfall-colors` (info for Superset's stock colours, whose green is 2.2:1 against the panel; warn for a colour under 3:1) and `chart.waterfall-axis-titles` (info: Superset puts the titles over wide or rotated tick labels). `advise --fix` fills a waterfall's `show_value` when it has at most 12 bars, and `number_format` `,.0f` on a count. A box plot gets `chart.box-plot-observations` (warn: grouped by a period at that period's grain, each box holds a few observations) and `chart.box-plot-groups` (warn, with `--profile`: past ~20 boxes). `chart.ordinal-order` reads a box plot's groups, and with `--profile` stands down for a column the dataset reports numeric or temporal. The brief's chart-choice guide says when a waterfall is the right form, and when a box plot beats a line of P50 and P90. + +- A box plot (`"type": "box_plot"`, Superset's Box Plot): a measure's distribution in each group, its median, quartiles, whiskers and outliers, such as the daily sales of each month. Compiled, decompiled, compared by `plan` and checked by `apply` on 4.1.4, 5.0.0 and 6.1.0. + - `distribute_across` holds the columns whose rows are the observations (a time column at `time_grain`, e.g. a day each), `groupby` the columns that get a box each, `metrics` the measure. + - `whiskers` is `"tukey"` (Superset's default), `"min_max"` (no outliers) or two percentiles such as `[5, 95]`, which every release takes. + - `number_format`, `x_label_format`, axis titles, `x_label_rotation` and `color_scheme` set the rest; `row_limit` is a Superset 6.0.0 control, and warns before it, where a dashboard's query still stops at it but Explore leaves it out. + - `apply`'s data check says when the observations reach the row limit (10,000 unset on Superset 6.x), so the boxes miss some. + +- A sketch can hold section headers and notes. A legend letter stands for a chart's name as before, or for a header (`{"header": "Monthly sales", "size": "large"}`) or a markdown block (`{"markdown": "Source: the ledger", "height": 1.6}`), drawn like a chart. A header is one line: drawn across the whole sketch it titles the band below it, as a header row does in `rows`; drawn above or below charts it sits in their column, and alone in a narrower slot it gets a column of its own. A markdown block takes its drawn width and height, or its own `height` in fifths of a unit. `decompile` (and so `adopt` and `plan`) reads headers and text blocks in a section of stacked charts back into its sketch, where it used to flatten the columns. Specs without blocks compile to the same bytes. See [the layout guide](docs/LAYOUT-GUIDE.md#section-headers-and-notes). + +- Filled areas on a mixed chart: `"kind": "area"` on query `a` or `b`, with `opacity` for the fill (0 to 1; Superset's default is 0.2). A line draws over an area whichever query holds it, and at opacity 1 the area's edge disappears into its fill, so solar output under a net-load line reads as one solid shape; give it a light colour with `label_colors`. `decompile` reads Superset's "Area chart" box back as `kind: "area"`. + +- A rolling window on a trendline KPI: `rolling_type` (`sum`, `mean`, `std`, or `cumsum` for a running total) with `rolling_periods`, the window in time-grain steps. At P1M, `"rolling_type": "sum", "rolling_periods": 12` shows a trailing-12-month total, and `compare_lag: 12` compares it with the 12 months before. Every point is a whole window; set `rolling_min_periods` to also draw the partial windows of the first steps. + - `advise` warns when the chart's time range, or a defaulted dashboard time filter it loads with, is too short to hold the window and the comparison (`data.rolling-window-span`), and apply's data check warns from the time buckets the chart really has. -- `advise` checks that each markdown block is tall enough for its text (`size.markdown-fit`). Superset cuts the rest off inside the block: macOS shows no scrollbar there, so a screenshot shows the text stopping mid-line. The check estimates the text's height from its headings, paragraphs, lists, tables, quotes, code and line breaks, the block's width and the dashboard's own CSS, in a window 1440 px wide, measured against what 4.1.4, 5.0.0 and 6.1.0 draw. A wider window wraps fewer lines, so a height that fits there fits wider too. - - It warns when letters are cut off on every release and says by how much ("it needs ~16 units at 12/12 in a 1440 px wide window, has 15"); `--fix` raises the block to a height that shows every line on every release, Windows scrollbars included. - - A block whose last line or padding only reaches the edge gets an info: it scrolls a few px, and Windows draws a scrollbar in it. That covers short strips: a line of text needs 1.6 units, a `##` heading 2.2. - - It reads the text sizes and spacing a dashboard's CSS gives its markdown: font sizes, line heights, weights, letter and word spacing, margins, padding and borders set on `.dashboard-markdown` elements, `h4 + p` and `p:last-child` rules included, in cascade order over Superset's own. CSS that could change a block's height in a way it doesn't read (another font family, attribute selectors and pseudo-elements, `@media` rules) is named in the finding, as is a theme. - - It checks blocks in layout rows, tabs and sub-tabs, and header and footer rows, and allows for the filter bar, which narrows the page on a dashboard with native filters. A header or footer row a standard owns is reported, never resized, and a block holding an image gets no fix, since its height is unknown. - `conditional_formatting` on `big_number_total`: colour the number by its value, such as a balance-closure KPI in red outside ±2% and green inside (`[{"operator": "<", "target": -0.02, "color": "red"}, {"operator": ">", "target": 0.02, "color": "red"}]`). The rules take the operators, thresholds and colours of table and pivot rules; a named colour takes its darker text shade, since the number is text. Superset 4.1.4, 5.0.0 and 6.1.0 all colour it, so no release is refused or warned. Decompile reads the rules back, and `plan` reports a rule changed in Superset. - When several rules match, the last one's colour wins, as in Superset. - `apply_to`, `paint` and a `metric` other than the chart's own are refused on a big number's rule, and so is `conditional_formatting` on `big_number_trend`, which Superset can't colour (its `trend_color` colours the line). - Superset never colours a value of exactly 0; apply's data check warns when a coloured big number is 0 and a rule would have coloured it. -- Design brain 14: + +- A big number shown as a date: `"date_format": "%a %-d %b %Y"` on a `big_number_total` shows `MAX(updated_at)` as "Sat 3 Oct 2026", for a tile that says how fresh the data is. It works on a date or timestamp metric and on a number of epoch milliseconds, and replaces `number_format`. With column types (`advise --profile`), `advise --fix` fills it on a big number of a date column's `MIN` or `MAX`, which Superset shows by default as its day alone ("Tue 31"). + +- A trendline KPI fitted to its values: `"y_axis_truncate": true` draws the trendline from its lowest value to its highest instead of up from zero, so a trailing-12-month total that moves a few percent shows the movement rather than a flat line over a solid block. `decompile` reads Superset's unticked "Start y-axis at 0" back as it. + +- Table cell bars per column. `cell_bars` takes a list of labels as well as true or false: `"cell_bars": ["Revenue", "Change"]` draws bars on those columns only. `color_by_sign` colours bars by sign, green above zero and red below (Superset 6.0+; 4.1.4 and 5.0.0 colour only the negative bars, red), for the whole table or the labels listed, so a change shows its sign while revenue bars stay neutral; `"color_by_sign": false` turns off the sign colours Superset draws by default. `absolute_bars` sizes bars by absolute value from the cell's left edge (Superset's "Align +/-"), so a rise and a fall of the same size draw the same bar. Each compiles to Superset's own table-wide and per-column settings; `decompile` reads any mix of them back as Superset draws it, and `plan` reports a change made in the UI. chartwright 0.5.0 and earlier refuse a spec that uses them. + +- Colour rules on tables, pivots and big numbers take `>=`, `<=`, `!=` and `between_inclusive` (a range with its bounds taken in), besides `<`, `>`, `=` and `between`. Every supported release has these comparators, so they draw the same everywhere. `decompile` reads them back from rules made in Superset, where before they were dropped as losses. + +- `sort_by` on a categorical bar ranks it by something other than its first metric, largest first, on every supported release. `"total"` ranks stacked or grouped bars by their sum. Before, Superset drew several series in name order: two stacked metrics on 4.1.4 and 5.0.0, and a grouped bar on every release. A metric (`"sort_by": "SUM(revenue)"`) ranks by its value and orders the query too, so a `row_limit` keeps the top bars by it; it need not be drawn, so two charts can share one order. `advise` warns when a `row_limit` cuts a bar ranked by `"total"`, since the query is still ordered by the first metric. + +- A threshold on a horizontal bar: `annotations` there now say what Superset draws, a line standing upright at its value (a 1.0x threshold, a 4-hour limit), seen on every supported release, and the live CI fixture builds one. + +- Heatmap axes: `x_label_every` and `y_label_every` label every Nth column or row, counted from the first, so an hour axis with `"x_label_every": 6` reads 0, 6, 12, 18 instead of Superset's uneven automatic spacing. `left_margin` leaves room, in px, left of the row labels. Every supported release takes all three; `decompile` reads them back and `plan` reports a change made in the UI. + +- Heatmap axis order: `x_order` (left to right) and `y_order` (top to bottom), each `a_to_z`, `z_to_a`, `value_asc` or `value_desc`. `"y_order": "a_to_z"` puts the first label on top, such as a cohort triangle's oldest cohort; omitted, the labels keep running A to Z from the bottom up. On Superset 6.1.0 a value order ranks each label by its total; older releases order by the cells, so `check` warns there. `decompile` reads the order back. + +- Apply's data check names the values a chart's `IN` filter lists that return no rows, on a column the chart groups or draws by: a line chart that lists four zones where two have data now says which two it draws nothing for. A series limit that drops values on purpose, or a time column, is left alone. + +- New design brain rules for big numbers: - `chart.color-contrast` (warn): a colour too pale to read on the white card, below 3:1 for a big number's rule or a trendline and below 4.5:1 for a table or pivot text rule. - `narrative.kpi-thresholds` (info): a coloured big number whose subtitle and description don't state its thresholds. - `chart.format-bands` checks a big number's rules for overlapping bands. A single band on a big number is fine: colouring only the exceptions is enough. -- Table cell bars per column. `cell_bars` takes a list of labels as well as true or false: `"cell_bars": ["Revenue", "Change"]` draws bars on those columns only. `color_by_sign` colours bars by sign, green above zero and red below (Superset 6.0+; 4.1.4 and 5.0.0 colour only the negative bars, red), for the whole table or the labels listed, so a change shows its sign while revenue bars stay neutral; `"color_by_sign": false` turns off the sign colours Superset draws by default. `absolute_bars` sizes bars by absolute value from the cell's left edge (Superset's "Align +/-"), so a rise and a fall of the same size draw the same bar. Each compiles to Superset's own table-wide and per-column settings; `decompile` reads any mix of them back as Superset draws it, and `plan` reports a change made in the UI. chartwright 0.5.0 and earlier refuse a spec that uses them. + +- `chartwright advise` checks type sizes against floors for reading a dashboard on a laptop: `readability.table-text` (table and pivot cells 14 px, their headers 12 px, rows 24 px tall), `readability.chart-text` (axis labels and legends 12 px, chart titles 14 px) and `readability.kpi-text` (a big number's value 24 px, its subtitle or comparison 12 px). Text Superset draws smaller by default, such as its 12 px table cells, is an info naming the CSS selector or theme token to set; text the dashboard's own CSS or theme sets smaller is a warn, so `--design strict` can newly block. Superset sizes a big number's text by the card's height, so `advise --fix` raises such a card, and the cards beside it, to the height where its text reads: a card with a subtitle to 5 units, a trendline card with a comparison to 6. The floors are design parameters (`min_cell_text_px` and four more) that `design.yaml` and a standard can tune or lock. `check` and `advise --profile` read a named theme's tokens and ECharts overrides. + +- `advise` checks that each markdown block is tall enough for its text (`size.markdown-fit`). Superset cuts the rest off inside the block: macOS shows no scrollbar there, so a screenshot shows the text stopping mid-line. The check estimates the text's height from its headings, paragraphs, lists, tables, quotes, code and line breaks, the block's width and the dashboard's own CSS, in a window 1440 px wide, measured against what 4.1.4, 5.0.0 and 6.1.0 draw. A wider window wraps fewer lines, so a height that fits there fits wider too. + - It warns when letters are cut off on every release and says by how much ("it needs ~16 units at 12/12 in a 1440 px wide window, has 15"); `--fix` raises the block to a height that shows every line on every release, Windows scrollbars included. + - A block whose last line or padding only reaches the edge gets an info: it scrolls a few px, and Windows draws a scrollbar in it. That covers short strips: a line of text needs 1.6 units, a `##` heading 2.2. + - It reads the text sizes and spacing a dashboard's CSS gives its markdown: font sizes, line heights, weights, letter and word spacing, margins, padding and borders set on `.dashboard-markdown` elements, `h4 + p` and `p:last-child` rules included, in cascade order over Superset's own. CSS that could change a block's height in a way it doesn't read (another font family, attribute selectors and pseudo-elements, `@media` rules) is named in the finding, as is a theme. + - It checks blocks in layout rows, tabs and sub-tabs, and header and footer rows, and allows for the filter bar, which narrows the page on a dashboard with native filters. A header or footer row a standard owns is reported, never resized, and a block holding an image gets no fix, since its height is unknown. + +- `layout.thin-subtabs` notes a sub-tab holding two charts or fewer besides its KPIs: readers click for little, and the sub-tabs read better as sections of one page under header rows. + +- The value-label design default now covers grouped bars: a bar chart with several metrics side by side gets `show_value` when its bars (rows times metrics) fit the same 12-bar and spacing limits as a single series. Stacked metrics and grouped series stay unlabelled. + - `check`, `apply` and `plan` warn when a table asks for cell bars beside colour rules on Superset before 6.1.0, which draws no cell bar on a table with any colour rule. -- Colour rules on tables, pivots and big numbers take `>=`, `<=`, `!=` and `between_inclusive` (a range with its bounds taken in), besides `<`, `>`, `=` and `between`. Every supported release has these comparators, so they draw the same everywhere. `decompile` reads them back from rules made in Superset, where before they were dropped as losses. -- `chartwright advise` checks type sizes against floors for reading a dashboard on a laptop (design brain 16): `readability.table-text` (table and pivot cells 14 px, their headers 12 px, rows 24 px tall), `readability.chart-text` (axis labels and legends 12 px, chart titles 14 px) and `readability.kpi-text` (a big number's value 24 px, its subtitle or comparison 12 px). Text Superset draws smaller by default, such as its 12 px table cells, is an info naming the CSS selector or theme token to set; text the dashboard's own CSS or theme sets smaller is a warn, so `--design strict` can newly block. Superset sizes a big number's text by the card's height, so `advise --fix` raises such a card, and the cards beside it, to the height where its text reads: a card with a subtitle to 5 units, a trendline card with a comparison to 6. The floors are design parameters (`min_cell_text_px` and four more) that `design.yaml` and a standard can tune or lock. `check` and `advise --profile` read a named theme's tokens and ECharts overrides. + +### Changed + +- The brief recommends titles that name the measure, unit and window, with a number in a title only when the spec computes it from data, instead of a typed takeaway such as "Orders fell 12%", which goes stale at the next refresh. It also recommends colour by role in place of "Superset's default palette": an accent for the chrome, this period against last as a hue and its tint, and status in green, amber and red beside an arrow or a word. The skill offers both to the user as recommendations before writing a spec, and a style the user asks for wins. + +- Documented: no supported release gives one line of a mixed chart its own width or dash. The plugin has no such control and its transform passes none, and 6.1's ECharts Options replace the series list whole. Set a ghost line apart by colour with `dashboard.label_colors`, or draw a fixed level as an annotation. + +- `advise --fix` fills `left_margin: 16` on every heatmap (`default.heatmap-label-room`). On Superset 6.1.0 a heatmap's longest row label lost its first letters at the card's edge, and a theme can't reach the heatmap there. Write `left_margin` yourself, or ignore the rule for a chart, to keep your own value. + +- The design brain adds no page-size picker, pager or search box to a table whose rows all show in its panel. `advise --fix` now fills a search box only on a raw table that pages, where the box sits in the bar the page-size picker already draws, and removes one it filled on a table whose rows all show. `advise` names a `page_length` or `search_box` you wrote on such a table (`size.table-chrome`, info), since Superset draws a page-size picker for any page size, even over one page; set `page_length` to 0 or leave it out to show every row with no picker. ### Fixed @@ -46,88 +124,43 @@ while the major version is 0, minor bumps may include breaking changes and say s - a table: advise names the height smoke names once the data fills `row_limit` (a 25-row table at 13 units now gets "raise height to ~21" from both); - a pivot: `size.pivot-window` no longer reads `row_limit` as the pivot's rows. `row_limit` caps the query's records, which are rows times columns, so a 5-row cause by month pivot was told it showed "~0 of 1000 rows" and to grow to 331 units. Offline, the rule now checks only what the spec fixes (its header rows, totals row and first row) and says so; `advise --profile` counts each row dimension's values and gives the height smoke gives; - messages name what takes the room: the card frame, each header row, the totals row and room for a horizontal scrollbar. + - Height estimates count what a pivot draws in every layout: a transposed pivot is sized by its column dimension, metrics laid out as rows multiply its rows, `row_subtotals` adds its subtotal rows, and a pivot with no row dimension is its totals row. A table's `show_totals` row now takes a row of height too, in the checks and in the page size `advise --fix` fills. + - The smoke check queries a table or pivot without a `row_limit` for as many rows as the chart does (10,000 for a pivot, 1,000 for a table), so a large pivot's height check counts all its rows. + - `apply`'s data check queries each chart the way the dashboard does when it loads: with the default of every native filter in the chart's scope. Before, it queried every chart unfiltered, so a table holding one set of rows per value of a required filter counted every set; the check then asked for more height on charts that fit, and could name a chart empty, or not, wrongly. As in Superset, a filter's default reaches a chart only when the chart is in the filter's scope and the chart's dataset has a column of that name. - Applied: a select's default values, or with `default_to_first` its first value, which the check reads with the query Superset's filter sends (its pre-filter and the defaults of the filters it depends on included); a range's bounds; a time range, on the chart's time axis or main time column; a time grain, on the chart's time axis. - Each chart's result names the defaults it ran with (`with the dashboard's filter defaults: Carrier = All carriers`), and lists them under `filter_defaults` in the apply report, present only when there are some. A default the check can't apply is named as not applied: a time column filter's, or a first value it could not read. - A chart the dashboard shows empty on load, such as one under a default time range the data doesn't reach, is now named empty. + - `decompile` (and so `adopt`) no longer reports the values an untouched table stores as settings it can't carry: HTML rendered, columns fixed in place, paging in the browser, no percentage metrics, and bars coloured by sign. A table saved in Superset reads back without a loss; each of these is still named when changed. + - `check`, `apply` and `plan` (and the MCP tools) warn when a table or pivot uses something its Superset release draws differently. Before, the dashboard came out different from the spec with no word. On 4.1.4, 5.0.0 and 6.0.0: - a table rule's `apply_to` paints the rule's own column instead; - `paint: "text"` fills the cell with the dark text shade instead, under the cell's own dark text, so the value is hard to read; - each band fades by the value's distance from its threshold instead of one solid colour (an `=` rule stays solid); - a dark `color` fill keeps the cell's dark text, where 6.1 turns it white. - On 4.1.4 and 5.0.0, a table's `hidden` columns show. Each warning says what that release draws. - `decompile` names a colour rule that Superset 6.1 fades by distance (its rule editor turns the gradient on by default). The spec paints a rule one solid colour, so the next apply changes how it looks. -- The design brain is version 15: `chart.format-bands` reads the bounds of the new operators, so `>= 80` and `<= 80` share 80 and `> 80` and `<= 80` share nothing. -### Added -- A sketch can hold section headers and notes. A legend letter stands for a chart's name as before, or for a header (`{"header": "Monthly sales", "size": "large"}`) or a markdown block (`{"markdown": "Source: the ledger", "height": 1.6}`), drawn like a chart. A header is one line: drawn across the whole sketch it titles the band below it, as a header row does in `rows`; drawn above or below charts it sits in their column, and alone in a narrower slot it gets a column of its own. A markdown block takes its drawn width and height, or its own `height` in fifths of a unit. `decompile` (and so `adopt` and `plan`) reads headers and text blocks in a section of stacked charts back into its sketch, where it used to flatten the columns. Specs without blocks compile to the same bytes. See [the layout guide](docs/LAYOUT-GUIDE.md#section-headers-and-notes). - -### Fixed +- `chart.format-bands` reads the bounds of the new operators, so `>= 80` and `<= 80` share 80 and `> 80` and `<= 80` share nothing. - `decompile` (and so `adopt`) reads columns of stacked charts side by side, such as two columns of two charts each, back as one row of columns. Before, the sketch it drew for them split into one row per level of the stacks, so a copy laid them out as rows. -### Added - -- A waterfall chart (`"type": "waterfall"`, Superset's Waterfall): steps that add up to a running total, such as a bridge from last year's revenue through each driver to this year's. Compiled, decompiled, compared by `plan` and checked by `apply` on 4.1.4, 5.0.0 and 6.1.0. - - `x_column` holds the steps (or a time column, one bar per `time_grain` period), `metric` the one measure they add, and `groupby` an optional breakdown per x value. - - `increase_color`, `decrease_color` and `total_color` take green, amber, red or any `#RRGGBB`, and replace Superset's stock green, red and grey on every release. `show_value`, `show_legend`, `number_format`, axis titles, `x_label_rotation` (0, 45 or 90) and `x_label_format` set the rest. - - On Superset 6.1.0 or later, `steps` lists a bridge's values in its own order and `closing` names the row that closes it, drawn last as the running total in the total colour: `"steps": ["FY2025", "Price", "Volume"], "closing": "FY2026"`. The first step rises from zero; `"opening": "FY2025"` draws the opening row as a total too, on a dashboard whose theme keeps zero on the value axis (`{"echartsOptionsOverridesByChartType": {"waterfall": {"yAxis": {"scale": false}}}}` in its JSON), which `check` and `apply` verify (`waterfall_opening_axis`). Older releases draw a running total after every step, so `check`, `apply` and `plan` refuse `steps` there. `total_label`, `increase_label` and `decrease_label` name the bars on 6.1.0 and warn before it. - - `apply`'s data check says when a bridge's opening, step or closing has no row, a value isn't in `steps`, or the closing row doesn't equal the steps added up (Superset draws the sum either way). -- A box plot (`"type": "box_plot"`, Superset's Box Plot): a measure's distribution in each group, its median, quartiles, whiskers and outliers, such as the daily sales of each month. Compiled, decompiled, compared by `plan` and checked by `apply` on 4.1.4, 5.0.0 and 6.1.0. - - `distribute_across` holds the columns whose rows are the observations (a time column at `time_grain`, e.g. a day each), `groupby` the columns that get a box each, `metrics` the measure. - - `whiskers` is `"tukey"` (Superset's default), `"min_max"` (no outliers) or two percentiles such as `[5, 95]`, which every release takes. - - `number_format`, `x_label_format`, axis titles, `x_label_rotation` and `color_scheme` set the rest; `row_limit` is a Superset 6.0.0 control, and warns before it, where a dashboard's query still stops at it but Explore leaves it out. - - `apply`'s data check says when the observations reach the row limit (10,000 unset on Superset 6.x), so the boxes miss some. -- The design brain is version 9. A waterfall gets `chart.waterfall-additive` (warn: `AVG`, `MIN`, `MAX` or `COUNT_DISTINCT` steps don't add up), `chart.waterfall-steps` (warn: past ~12 bars), `chart.waterfall-order` (info: a categorical waterfall without `steps` reads A to Z), `chart.waterfall-colors` (info for Superset's stock colours, whose green is 2.2:1 against the panel; warn for a colour under 3:1) and `chart.waterfall-axis-titles` (info: Superset puts the titles over wide or rotated tick labels). `advise --fix` fills a waterfall's `show_value` when it has at most 12 bars, and `number_format` `,.0f` on a count. A box plot gets `chart.box-plot-observations` (warn: grouped by a period at that period's grain, each box holds a few observations) and `chart.box-plot-groups` (warn, with `--profile`: past ~20 boxes). `chart.ordinal-order` reads a box plot's groups, and with `--profile` stands down for a column the dataset reports numeric or temporal. The brief's chart-choice guide says when a waterfall is the right form, and when a box plot beats a line of P50 and P90. -- `tools/extract_panel_contract.py` extracts the waterfall's and the box plot's options for the params contract from their control panels at each release; `tools/ci_live_waterfall.py` builds a bridge on a virtual dataset in CI. - -### Fixed - `decompile` reads Superset's stored defaults for data zoom and the series limit (`zoomable` false on time-axis and mixed charts, no `series_limit` on a pivot) as unset; before, `params not preserved` named them on charts saved in Explore. A zoomable chart or a series limit is still named. + - `plan` reads a `trend_color` written as the hex of a named shade (`#1B7F3B` for green) as equal to the dashboard, which decompile reads back as the name; before, the chart showed as changed after every apply. The waterfall's colours compare the same way. + - `plan` is clean straight after `apply` for a chart height in fifths of a unit and for an aggregate written as custom SQL. A height of 4.6 (as `absorb` writes after a resize in the UI) read back rounded to 5, and `SQL(MAX(col)) AS Label` read back as `MAX(col) AS Label`, so every such chart showed as changed after each apply. `decompile` now reads both back as written; custom SQL typed in Superset's metric popover still reads back as `MAX(col) AS Label`. An aggregate written with spaces (`SUM( qty )`, which is also the label Superset shows) reads back as written too, so a table's settings keyed by that label (number format, header, alignment, a rule painting it) are no longer dropped. - `plan` reads alike the spellings `apply` stores alike, so none of them shows as a change after apply: Superset's own defaults written out (`"time_range": "No filter"`, `"number_format": "SMART_NUMBER"`, `"x_label_format": "smart_date"`, `"x_label_rotation": 0`, a table's `"date_format": "smart_date"`), `COUNT(*) AS N` and `SQL(COUNT(*)) AS N`, a named colour's hex (`"#1B7F3B"` for a green trendline, `"#ACE1C4"` for a green colour rule on a table or pivot), a height between fifths, an empty `metrics` or `groupby` list on a table, a filter's pre-filter time range of `"No filter"`, and blank text. `decompile` leaves a big number's `SMART_NUMBER` format out, as it does on every other chart. -- `plan` compares a table's `hidden` labels as a set. Decompile reads them back in the order Superset stores them, not the spec's, so a table hiding two or more columns showed as changed after every apply. -### Added - -- `sort_by` on a categorical bar ranks it by something other than its first metric, largest first, on every supported release. `"total"` ranks stacked or grouped bars by their sum. Before, Superset drew several series in name order: two stacked metrics on 4.1.4 and 5.0.0, and a grouped bar on every release. A metric (`"sort_by": "SUM(revenue)"`) ranks by its value and orders the query too, so a `row_limit` keeps the top bars by it; it need not be drawn, so two charts can share one order. `advise` warns when a `row_limit` cuts a bar ranked by `"total"`, since the query is still ordered by the first metric. -- A threshold on a horizontal bar: `annotations` there now say what Superset draws, a line standing upright at its value (a 1.0x threshold, a 4-hour limit), seen on every supported release, and the live CI fixture builds one. -- Heatmap axes: `x_label_every` and `y_label_every` label every Nth column or row, counted from the first, so an hour axis with `"x_label_every": 6` reads 0, 6, 12, 18 instead of Superset's uneven automatic spacing. `left_margin` leaves room, in px, left of the row labels. Every supported release takes all three; `decompile` reads them back and `plan` reports a change made in the UI. - -### Changed - -- Documented: no supported release gives one line of a mixed chart its own width or dash. The plugin has no such control and its transform passes none, and 6.1's ECharts Options replace the series list whole. Set a ghost line apart by colour with `dashboard.label_colors`, or draw a fixed level as an annotation. -- Design brain 12: `advise --fix` fills `left_margin: 16` on every heatmap (`default.heatmap-label-room`). On Superset 6.1.0 a heatmap's longest row label lost its first letters at the card's edge, and a theme can't reach the heatmap there. Write `left_margin` yourself, or ignore the rule for a chart, to keep your own value. - -### Fixed +- `plan` compares a table's `hidden` labels as a set. Decompile reads them back in the order Superset stores them, not the spec's, so a table hiding two or more columns showed as changed after every apply. - `decompile` names a bar ranked by its series' minimum, maximum or average, or ranked smallest first; before, apply quietly put the largest-first ranking back. -- `decompile` (and so `adopt`) names more chart settings it can't carry when they differ from Superset's defaults: big-number font sizes and a hidden trendline, a smooth or step line, horizontal bars on a time axis, a pie's radius and hidden labels, a funnel's labels, tooltip and percentage calculation, treemap labels, a heatmap's axis sort, legend, margins, label intervals and value bounds, a histogram's normalize setting, and any chart's currency format. Before, they were dropped without a note. -### Upgrading from 0.5 -- A spec with a mixed chart's `kind: "area"`, a trendline's `rolling_type` or `y_axis_truncate`, a big number's `date_format` or a heatmap's `x_order` or `y_order` is refused by chartwright 0.5.0 and earlier (`literal_error` or `extra_forbidden`); upgrade CI before committing one. -- The design brain is version 10. `data.rolling-window-span` is a new warn-level rule, so `--design strict` can newly block a rolling trendline KPI whose time range can't hold its window. `advise --fix` fills `compare_suffix` as "vs prior 12 months" where a 12-step rolling window compares with the 12 steps before, and, with column types (`--profile`), `date_format` on a big number of a date column. It also removes a search box it filled on a table whose rows all show (below), so a spec it fixed before shows that change in its diff. - -### Added - -- Filled areas on a mixed chart: `"kind": "area"` on query `a` or `b`, with `opacity` for the fill (0 to 1; Superset's default is 0.2). A line draws over an area whichever query holds it, and at opacity 1 the area's edge disappears into its fill, so solar output under a net-load line reads as one solid shape; give it a light colour with `label_colors`. `decompile` reads Superset's "Area chart" box back as `kind: "area"`. -- A rolling window on a trendline KPI: `rolling_type` (`sum`, `mean`, `std`, or `cumsum` for a running total) with `rolling_periods`, the window in time-grain steps. At P1M, `"rolling_type": "sum", "rolling_periods": 12` shows a trailing-12-month total, and `compare_lag: 12` compares it with the 12 months before. Every point is a whole window; set `rolling_min_periods` to also draw the partial windows of the first steps. - - `advise` warns when the chart's time range, or a defaulted dashboard time filter it loads with, is too short to hold the window and the comparison (`data.rolling-window-span`), and apply's data check warns from the time buckets the chart really has. -- A big number shown as a date: `"date_format": "%a %-d %b %Y"` on a `big_number_total` shows `MAX(updated_at)` as "Sat 3 Oct 2026", for a tile that says how fresh the data is. It works on a date or timestamp metric and on a number of epoch milliseconds, and replaces `number_format`. With column types (`advise --profile`), `advise --fix` fills it on a big number of a date column's `MIN` or `MAX`, which Superset shows by default as its day alone ("Tue 31"). -- Heatmap axis order: `x_order` (left to right) and `y_order` (top to bottom), each `a_to_z`, `z_to_a`, `value_asc` or `value_desc`. `"y_order": "a_to_z"` puts the first label on top, such as a cohort triangle's oldest cohort; omitted, the labels keep running A to Z from the bottom up. On Superset 6.1.0 a value order ranks each label by its total; older releases order by the cells, so `check` warns there. `decompile` reads the order back. -- A trendline KPI fitted to its values: `"y_axis_truncate": true` draws the trendline from its lowest value to its highest instead of up from zero, so a trailing-12-month total that moves a few percent shows the movement rather than a flat line over a solid block. `decompile` reads Superset's unticked "Start y-axis at 0" back as it. -- Apply's data check names the values a chart's `IN` filter lists that return no rows, on a column the chart groups or draws by: a line chart that lists four zones where two have data now says which two it draws nothing for. A series limit that drops values on purpose, or a time column, is left alone. - -### Changed - -- The design brain adds no page-size picker, pager or search box to a table whose rows all show in its panel. `advise --fix` now fills a search box only on a raw table that pages, where the box sits in the bar the page-size picker already draws, and removes one it filled on a table whose rows all show. `advise` names a `page_length` or `search_box` you wrote on such a table (`size.table-chrome`, info), since Superset draws a page-size picker for any page size, even over one page; set `page_length` to 0 or leave it out to show every row with no picker. - -### Fixed +- `decompile` (and so `adopt`) names more chart settings it can't carry when they differ from Superset's defaults: big-number font sizes and a hidden trendline, a smooth or step line, horizontal bars on a time axis, a pie's radius and hidden labels, a funnel's labels, tooltip and percentage calculation, treemap labels, a heatmap's axis sort, legend, margins, label intervals and value bounds, a histogram's normalize setting, and any chart's currency format. Before, they were dropped without a note. - `decompile` (and so `adopt`) names more chart settings it can't carry when they differ from Superset's defaults: big-number font sizes and a hidden trendline, a smooth or step line, horizontal bars on a time axis, a pie's radius and hidden labels, a funnel's labels, tooltip and percentage calculation, treemap labels, a heatmap's legend, margins, label intervals and value bounds, a histogram's normalize setting, and any chart's currency format. Before, they were dropped without a note. diff --git a/chartwright/__init__.py b/chartwright/__init__.py index 1de4286..d786cf4 100644 --- a/chartwright/__init__.py +++ b/chartwright/__init__.py @@ -4,4 +4,4 @@ The LLM is an untrusted parser at the edge; the guarantee lives in typed code. """ -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/chartwright/design/guidelines/composition.md b/chartwright/design/guidelines/composition.md index ae66d6f..2c56e06 100644 --- a/chartwright/design/guidelines/composition.md +++ b/chartwright/design/guidelines/composition.md @@ -6,9 +6,9 @@ by side; two unrelated charts sharing a row invite false comparison. - A row's charts share their time window and grain, or the title says why not. - One message per chart: a chart needing a paragraph is two charts, or a table. -- Titles state the answer where possible ("Orders fell 12% WoW"), the - question otherwise ("Orders by week"); never just a column name. Filtered - charts name their scope in the title. +- Titles (recommended): the measure, unit and window ("Orders, last 12 weeks"), + numbers only when the spec computes them from data; a typed takeaway ("Orders + fell 12%") goes stale at the next refresh. Never just a column name. - Tabs split different questions; scrolling deepens one. Never a one-chart tab; a one- or two-chart sub-tab belongs on its parent page, under a header row. - Whitespace is markdown's job, sparingly: a one-line header (1.6-2.4 units) beats @@ -17,13 +17,13 @@ `{"markdown": "...", "height": 1.6}` is a slim caption. - One number format per measure across KPIs and axes (`number_format`; `number_formats` per table column; pivots keep Superset's smart default). -- Color restraint: Superset's default palette, RAG only where a threshold has - a real business meaning; never encode the same dimension with two palettes. +- Colour by role (recommended): one accent for chrome, this period saturated and + the prior period its tint, status green/amber/red beside an arrow or a word, row + tints on status tables. One meaning per colour; never two palettes for one dimension. - RAG polarity is a convention: red = adverse, green = good (IBCS). Bands on one metric are disjoint and tell one story; a status-coloured KPI colours only its exceptions and states its thresholds in its subtitle or description. -- A heatmap's default sequential scale, normalized over the whole map, suits - magnitudes, not signed deltas: don't heatmap a metric that crosses zero. +- Don't heatmap a metric that crosses zero: its sequential scale suits magnitudes. - Table bars only where size is the point (`cell_bars: ["Revenue"]`), never on ids or years. Superset tints every bar by sign, green on 6.x even for revenue: keep that for a change, `color_by_sign: ["Change"]`, listed in cell_bars too. diff --git a/docs/DESIGN-BRAIN.md b/docs/DESIGN-BRAIN.md index 59fe138..264178d 100644 --- a/docs/DESIGN-BRAIN.md +++ b/docs/DESIGN-BRAIN.md @@ -1275,7 +1275,7 @@ thresholds; those marked judgement are usability choices, not pixel facts. | `default.page-length` | `page_length` | the whole rows that fit beside the page-size bar and the pager | a table with an explicit `row_limit` larger than the rows that fit on one page, and a page of at least `page_min_rows` (3, judgement). The one grid model `size.table-window` reads, so the fill can never make that rule ask for more height | | `default.search-box` | `search_box` | `true` | a raw-mode table with an explicit `row_limit` above `search_min_rows` (20, judgement) that outgrows its panel and pages (`page_length` above 0 and below `row_limit`), so the box sits in the bar the page-size picker already draws and hides no row. Never on a table whose rows all show (brain 10; brain 5 to 9 also filled one there when every row still fit beside it) | | `default.single-series-legend` | `show_legend` | `false` | a timeseries chart or categorical bar with one metric, no groupby, no series limit, no goal lines and no legend placement written, whose shown title or `y_axis_title` contains the metric's label, and that label is at least 3 characters long. Never a heatmap, whose legend is the colour scale | -| `default.value-labels` | `show_value` | `true` | a categorical bar with one metric, no groupby, no `contribution`, an explicit `row_limit` of at most `value_label_max_bars` (12). A vertical bar also needs a width of at least `value_label_min_width` (6/12); a horizontal bar needs the height to space its labels, 4.5 units plus 0.4125 a bar (§17) | +| `default.value-labels` | `show_value` | `true` | a categorical bar with no groupby, no `contribution` and no stacked metrics, an explicit `row_limit`, and at most `value_label_max_bars` (12) bars, counting `row_limit` × metrics for grouped bars (brain 17). A vertical bar also needs a width of at least `value_label_min_width` (6/12); a horizontal bar needs the height to space its labels, 4.5 units plus 0.4125 a bar (§17) | | `default.heatmap-label-room` | `left_margin` | `16` | every heatmap (brain 12). With no margin, a heatmap's grid starts at the card's edge, and Superset 6.1.0 draws the longest y label wider than the room the grid gave it, cutting off its first letters: seen live on `Trucks and Buses` (95 px) and `Australian Gift Network, Co` (150 px). 8 px cleared the first and 16 px both; 4.1.4 and 5.0.0 drew them whole and only gain 16 px of space. A theme can't fix it, since 6.1.0 renders the heatmap without its chart type and no per-type override reaches it | `narrative.big-number-format` stands down where `default.count-format` diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 20e421b..dc122e5 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -35,7 +35,7 @@ injection. | Layer | Proves | Where it runs | |---|---|---| -| Offline suite (2421 tests, 78 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | +| Offline suite (2430 tests, 79 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | | Chart-option contract | Every emitted chart option is declared by each version's plugin source | every PR and push to main | | Live guarantee check | Three specs applied (every chart type, every display control, every dashboard and filter control), per-chart data check, ids stable across re-apply | every PR and push to main, all 3 versions | | Standards content round trip | `standards apply` writes an org footer, a team header and two CSS blocks into a spec; it applies, `plan` stays clean, decompile reads the CSS markers and every managed row back, `--claim` rebuilds the record, re-apply stays clean | every PR and push to main, all 3 versions | diff --git a/examples/README.md b/examples/README.md index 75f0708..2530983 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,8 +1,9 @@ # The README dashboard [nyc_taxi_operations.json](nyc_taxi_operations.json) is the spec behind the -README's screenshot. An AI session with this repo's skill wrote it, unedited, -from this request: +README's screenshot. An AI session with this repo's skill wrote it from this +request; the only later edit is its axis titles, added in 0.6.0 when `advise` began +asking for them: > Build an operations dashboard for NYC yellow taxi trips from the > nyc_yellow_taxi table in the examples database. I want headline numbers for @@ -51,3 +52,9 @@ chartwright apply examples/nyc_taxi_operations.json --profile local `sandbox/up.sh` prints the `[local]` profile block to save as `~/.config/chartwright/profiles.toml`. + +# Sundown + +[sundown/](sundown/) is a six-tab dashboard for siting data centres on the US power +grid, built from EIA-930 and EIA-860M data: its spec, the script that writes it, seven +colour palettes and the Superset theme it names. See [its README](sundown/README.md). diff --git a/examples/sundown/README.md b/examples/sundown/README.md new file mode 100644 index 0000000..fbac299 --- /dev/null +++ b/examples/sundown/README.md @@ -0,0 +1,20 @@ +# Sundown + +A six-tab dashboard for siting data centres on the US power grid, built from one spec with Chartwright. The data is EIA-930 hourly grid data and EIA-860M. It's the dashboard in Chartwright's README. + +## What's here +- `sundown.json`: the spec. `chartwright validate`, `compile` and `advise` run on it as it is. +- `build_spec_sundown.py`: writes the spec from `facts.json` and a palette. `facts.json` holds the numbers the titles and time windows are built from. +- `palettes/`: seven palettes, `coral-teal-v2` by default. A palette sets every colour in the spec (its CSS, label colours and colour rules) and in the theme. +- `theme/`: the Superset theme the spec names (`"theme": "Sundown"`), and a script that creates or updates it through Superset's API. + +## Rebuild it in another palette +```sh +python3 build_spec_sundown.py facts.json robins-egg > sundown.json +SUPERSET_URL=https://superset.example.com SUPERSET_PASSWORD=... python3 theme/create_theme.py robins-egg +chartwright apply sundown.json --profile +``` + +## What it needs to run +- **`apply` needs the warehouse.** The spec reads a Postgres warehouse with a `sundown` schema built from EIA-930 and EIA-860M, which are public domain. The data pipeline isn't included, so `apply` works only against such a warehouse. +- **The theme needs Superset 6.0 or later.** On 4.1 or 5.0, remove `"theme"` from the dashboard block; the dashboard keeps the colours its CSS sets. diff --git a/examples/sundown/build_spec_sundown.py b/examples/sundown/build_spec_sundown.py new file mode 100644 index 0000000..e4a0030 --- /dev/null +++ b/examples/sundown/build_spec_sundown.py @@ -0,0 +1,829 @@ +#!/usr/bin/env python3 +"""Write sundown.json, the Chartwright spec for the Sundown dashboard. + + python3 build_spec_sundown.py facts.json [palette] > sundown.json + +facts.json holds the numbers the titles and time windows are built from, queried from a +Postgres warehouse loaded with EIA-930 hourly grid data and EIA-860M (schema `sundown`). +Titles carry units and windows; section names are plain; definitions live in chart +descriptions and the glossary table; colour carries status. A new facts.json changes the +data-driven titles, and `chartwright plan` shows what moved. Chart names stay fixed: they +are the charts' identity. The palette is palettes/.json (default coral-teal-v2). +""" + +import json +import math +import sys +from datetime import date, timedelta +from pathlib import Path + +F = json.load(open(sys.argv[1], encoding="utf-8")) +PALETTE = sys.argv[2] if len(sys.argv) > 2 else "coral-teal-v2" + +DB, SCHEMA = "grid_warehouse", "sundown" + + +# ---------------------------------------------------------------- windows, all from the facts +def first_of_next_month(d: date) -> date: + return (d.replace(day=28) + timedelta(days=4)).replace(day=1) + + +def axis_floor(lowest: float | None, step: float) -> float: + """A value axis's floor from the data: zero, or the lowest value the chart can draw rounded + down a step. Recomputed on each refresh, so a new low is never clipped.""" + return min(0, math.floor((lowest or 0) / step) * step) + + +def months_back(d: date, n: int) -> date: + y, m = divmod(d.month - 1 - n, 12) + return date(d.year + y, m + 1, 1) + + +MONTH = F["month"]["label"] # "Sep 2026" +MONTH_END = F["month"]["end"] +END = first_of_next_month(date.fromisoformat(F["month"]["start"])) # exclusive: 2026-10-01 +START12, START24 = months_back(END, 12), months_back(END, 24) +BATTERY_END = first_of_next_month(date.fromisoformat(F["eia860m_vintage"])) +T12M = F["t12m"]["label"] # "12 months to Sep 2026" +QSTART, QEND = date.fromisoformat(F["quarter"]["start"]), date.fromisoformat(F["quarter"]["end"]) +Q = f"{QSTART:%b}–{QEND:%b %Y}" # "Jul–Sep 2026" +Q_PRIOR = f"{QSTART:%b}–{QEND:%b} {QSTART.year - 1}" +N_RANKED = F["roster"]["n"] +N_REPORTING = F["n_bas_complete"] + +# ---------------------------------------------------------------- palette (../palettes/.json) +# Every colour comes from one palette file, so a palette swaps with one argument. A palette +# may re-tint the fuels; otherwise they keep the EIA / Electricity Maps conventions. +P = json.load(open(Path(__file__).parent / "palettes" / f"{PALETTE}.json", encoding="utf-8")) +NAVY, ACCENT, ON_NAVY = P["masthead"], P["accent"], P["on_masthead"] +GROUND, TILE, BORDER, RULE, GRID, REFC = P["ground"], P["tile"], P["border"], P["rule"], P["grid"], P["ref"] +INK, SEC, HEAD_BG = P["ink"], P["sec"], P["head_bg"] +THIS, PRIOR, SECOND = P["this"], P["prior"], P["second"] # this period; prior period (a tint); a second series +UP, DOWN, TOTAL = P["up"], P["down"], P["total"] # a bridge's steps and totals +CELLBAR = P["cellbar"] +STATUS = {k: tuple(v) for k, v in P["status"].items()} # text, pill, row tint, fill +STATUS["neutral"] = (SEC, HEAD_BG, None, None) +FUEL = { + "Solar": "#FFC702", "Wind": "#1A9E8C", "Gas": "#D4A56E", "Coal": "#84511D", "Nuclear": "#B8475A", + "Hydro": "#3B9DD6", "Battery": "#8E5BD8", "Battery charging": "#C6ADEC", "Net imports": "#A9B8D0", + "Exports": "#DDE3EC", "Other": "#C0BAB4", "Coal, nuclear, hydro": "#84511D", +} +FUEL.update(P.get("fuel", {})) # a palette may re-tint the fuels +REGION = P["region"] +REF = {"style": "dashed", "color": REFC, "width": 1} # threshold and reference lines + + +def kpi_delta(chart: str, up: str, down: str) -> str: + """A trend KPI's delta as coloured text with ▲▼, coloured by what the change means: Superset marks + the card .positive or .negative by the comparison's sign (BigNumberWithTrendline/transformProps.ts:227-232).""" + rules = [] + for sign, status, arrow in (("positive", up, "▲"), ("negative", down, "▼")): + rules.append( + f'[data-test-chart-name="{chart}"] .superset-legacy-chart-big-number.{sign} .subheader-line:not(.subtitle-line) ' + f'{{{{ color: {STATUS[status][0]} !important; }}}}\n' + f'[data-test-chart-name="{chart}"] .superset-legacy-chart-big-number.{sign} .subheader-line:not(.subtitle-line)::before ' + f'{{{{ content: "{arrow} "; }}}}') + return "\n".join(rules) + + +KPI_DELTAS = [ # chart name, colour when it rises, colour when it falls + ("Lower-48 demand", "neutral", "neutral"), + ("Lower-48 evening ramp", "watch", "good"), # a steeper ramp is less headroom + ("Batteries installed", "good", "act"), + ("Batteries operating", "good", "act"), + ("Evening ramp, median day", "watch", "good"), + ("Evening ramp, bad day", "watch", "good"), + ("Baseload", "watch", "neutral"), # rising baseload: less headroom +] + +CSS = (""" +/* Sundown. Structural CSS only: Superset's markdown sanitizer drops class and style. Rules on + elements Superset paints itself say !important, or Superset's own equal-specificity rule wins. */ +/* ground and tiles */ +.dashboard-component-tabs, +.dashboard-component-tabs .dashboard-component-tabs-content {{ background-color: {GROUND} !important; }} +.dashboard-component-tabs {{ box-shadow: none !important; }} +.dashboard .dashboard-component-tabs {{ padding-left: 0 !important; }} +.dashboard-component-chart-holder {{ border: 1px solid {BORDER}; border-radius: 2px; }} +.background--white {{ border: 1px solid {BORDER}; border-radius: 2px; }} +.background--white .dashboard-component-chart-holder {{ border: 0; }} +.dashboard-component-chart-holder .header-controls {{ opacity: 0; transition: opacity .15s; }} +.dashboard-component-chart-holder:hover .header-controls {{ opacity: 1; }} +/* masthead: a navy band, white wordmark */ +.background--white:has(.dashboard-markdown h2) {{ background-color: {NAVY} !important; border-color: {NAVY} !important; }} +.background--white:has(.dashboard-markdown h2) .dashboard-component-chart-holder {{ background-color: {NAVY} !important; }} +.dashboard-markdown h2 {{ font-size: 26px; font-weight: 700; letter-spacing: -.01em; margin: 0; color: #FFFFFF; }} +.dashboard-markdown h2 + p {{ font-size: 13.5px; color: {ON_NAVY}; margin: 4px 0 0; }} +.dashboard-markdown h2 + p code {{ background: rgba(255,255,255,.12); color: #FFFFFF; }} +[data-test-chart-name="Data through"] {{ text-align: right; position: relative; }} +[data-test-chart-name="Data through"] .header-title {{ font-size: 11.5px !important; font-weight: 500 !important; + color: {ON_NAVY} !important; justify-content: flex-end; transform: translateX(16px); }} +[data-test-chart-name="Data through"] .header-title a {{ color: {ON_NAVY} !important; }} +[data-test-chart-name="Data through"] .superset-legacy-chart-big-number {{ align-items: flex-end !important; }} +[data-test-chart-name="Data through"] .text-container {{ align-items: flex-end !important; }} +[data-test-chart-name="Data through"] .header-controls {{ position: absolute; right: 0; }} +/* filter bar: 'More filters' as an outline button, not the theme's filled primary tint */ +[data-test="dropdown-container-btn"] {{ background: transparent !important; border: 1px solid {RULE} !important; + color: {SEC} !important; box-shadow: none !important; }} +[data-test="dropdown-container-btn"]:hover {{ border-color: {SEC} !important; color: {INK} !important; }} +[data-test="dropdown-container-btn"] .ant-badge-count {{ background: {HEAD_BG} !important; color: {SEC} !important; box-shadow: none !important; }} +/* tabs: inactive secondary, active ink on an accent underline */ +.dashboard-component-tabs .ant-tabs-nav::before {{ border-bottom: 1px solid {RULE}; }} +.dashboard-component-tabs .anchor-link-container {{ display: none; }} +.dashboard-component-tabs .ant-tabs-tab {{ font-size: 14.5px; padding: 10px 0; color: {SEC}; }} +.dashboard-component-tabs .ant-tabs-tab-btn, .dashboard-component-tabs .dragdroppable-tab {{ padding: 0 !important; margin: 0 !important; }} +.dashboard-component-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {{ font-weight: 600; color: {INK} !important; }} +.dashboard-component-tabs .ant-tabs-ink-bar {{ background: {ACCENT} !important; height: 3px !important; }} +.dashboard-component-tabs .ant-tabs-nav {{ margin-bottom: 14px !important; }} /* clear of the KPI cards' accent edge */ +/* section bands: a full-width band in the masthead colour, an accent left edge, white caps */ +.dashboard-component-header {{ background: {NAVY} !important; border-left: 5px solid {ACCENT} !important; + border-radius: 2px; padding: 0 14px !important; min-height: 34px; margin-top: 10px; display: flex !important; + align-items: center !important; }} +.dashboard-component-header .editable-title, .dashboard-component-header .editable-title input {{ font-size: 13.5px !important; + font-weight: 600 !important; color: #FFFFFF !important; letter-spacing: .07em !important; text-transform: uppercase; }} +.dashboard-component-header .anchor-link-container {{ display: none !important; }} +/* the one-line colour keys */ +.dashboard-markdown:has(h5) .dashboard-component-chart-holder, +.dashboard-markdown:has(h6) .dashboard-component-chart-holder {{ background-color: transparent !important; border: 0 !important; }} +.dashboard-markdown h5 {{ font-size: 12px; font-weight: 400; color: {SEC}; margin: 0; }} +.dashboard-markdown h6 {{ font-size: 13px; font-weight: 500; color: {SEC}; margin: 0; }} +.dashboard-markdown h6 strong {{ color: {INK}; font-weight: 600; }} +.dashboard-markdown [itemprop]::before {{ content: ""; display: inline-block; width: 10px; height: 10px; + border-radius: 2px; margin: 0 6px 0 2px; vertical-align: -1px; }} +.dashboard-markdown [itemprop="this"]::before {{ background: {THIS}; }} +.dashboard-markdown [itemprop="prior"]::before {{ background: {PRIOR}; }} +.dashboard-markdown [itemprop="solar"]::before {{ background: {SOLAR}; }} +.dashboard-markdown [itemprop="up"]::before {{ background: {UP}; }} +.dashboard-markdown [itemprop="down"]::before {{ background: {DOWN}; }} +.dashboard-markdown [itemprop="total"]::before {{ background: {TOTAL}; }} +.superset-chart-table .cell-bar {{ background-color: {CELLBAR} !important; }} +/* chart titles */ +.header-title {{ font-size: 15px !important; font-weight: 600 !important; color: {INK}; }} +/* KPI cards: an accent top edge; the number in ink; the delta as coloured text with ▲▼, by meaning */ +.dashboard-component-chart-holder:has([data-test-viz-type^="big_number"]):not(:has([data-test-chart-name="Data through"])) {{ + border-top: 3px solid {ACCENT} !important; }} +.header-line, .subheader-line, .superset-chart-table td {{ font-variant-numeric: tabular-nums; }} +[data-test-viz-type="big_number"] .header-line, +[data-test-viz-type="big_number_total"] .header-line {{ font-size: 36px !important; line-height: 1.1 !important; + font-weight: 600; letter-spacing: -.015em; }} +[data-test-viz-type="big_number"] .header-line {{ color: {INK}; }} +[data-test-viz-type="big_number"] .subheader-line:not(.subtitle-line) {{ font-size: 13px !important; font-weight: 600; + line-height: 1.4 !important; color: {SEC}; margin: 4px 0 2px; }} +[data-test-viz-type="big_number"] .subtitle-line, +[data-test-viz-type="big_number_total"] .subheader-line, +[data-test-viz-type="big_number_total"] .subtitle-line {{ font-size: 12.5px !important; color: {SEC}; line-height: 1.4 !important; }} +[data-test-viz-type="big_number_total"] .superset-legacy-chart-big-number {{ justify-content: flex-start !important; }} +[data-test-chart-name="Data through"] .header-line {{ font-size: 22px !important; color: #FFFFFF !important; }} +{KPI_DELTA_CSS} +/* tables: readable by standard (body 14 px on 36 px rows, header 13 px semibold), no vertical rules */ +.superset-chart-table table thead th {{ background: {HEAD_BG} !important; color: {INK}; font-weight: 600; font-size: 13px !important; }} +.superset-chart-table table th, .superset-chart-table table td {{ border-left: 0 !important; border-right: 0 !important; }} +.superset-chart-table table tbody td {{ border-top: 1px solid {GRID}; font-size: 14px !important; height: 36px; color: {INK}; }} +""".format(GROUND=GROUND, BORDER=BORDER, NAVY=NAVY, ON_NAVY=ON_NAVY, RULE=RULE, SEC=SEC, INK=INK, ACCENT=ACCENT, + THIS=THIS, PRIOR=PRIOR, GRID=GRID, HEAD_BG=HEAD_BG, UP=UP, DOWN=DOWN, TOTAL=TOTAL, CELLBAR=CELLBAR, + SOLAR=FUEL["Solar"], KPI_DELTA_CSS="{KPI_DELTA_CSS}") + .replace("{{", "{").replace("}}", "}") + .replace("{KPI_DELTA_CSS}", "\n".join(kpi_delta(c, u, d) for c, u, d in KPI_DELTAS).replace("{{", "{").replace("}}", "}")) + ).strip() + +charts: list[dict] = [] +BA_SCOPED: list[str] = [] + + +def ds(table: str) -> dict: + return {"database": DB, "schema": SCHEMA, "table": table} + + +def add(chart: dict, width: int, height: float, ba: bool = False) -> str: + chart["width"], chart["height"] = width, height + charts.append(chart) + if ba: + BA_SCOPED.append(chart["name"]) + return chart["name"] + + +def md(text: str, width: int = 12, height: float = 1.4) -> dict: + return {"markdown": text, "width": width, "height": height} + + +def header(text: str) -> dict: + return {"header": text, "size": "medium"} + + +def key(*items: tuple[str, str]) -> list: + """A one-line colour key, the only text a section carries besides its header.""" + return [md("###### " + " · ".join(f'**{label}**' for k, label in items), 12, 1.4)] + + +def white(*items) -> dict: + return {"row": list(items), "background": "white"} + + +def center(*labels: str) -> dict: + """Every column centred: the author's choice for the larger tables.""" + return {label: "center" for label in labels} + + +def eq(column: str, value) -> dict: + return {"column": column, "op": "==", "value": value} + + +def pct(x: float) -> str: + return f"{x * 100:+.0f}%" + + +def gw(mw: float) -> str: + return f"{mw / 1000:.1f} GW" + + +def status_rules(code_col: str, target: str, mapping: dict[int, str], paint: str = "cell", tint: str = "pill") -> list: + """Colour rules keyed on a numeric status code: each code paints the target column (or the row).""" + idx = {"text": 0, "pill": 1, "row": 2}[tint if paint == "cell" else "text"] + out = [] + for code, status in mapping.items(): + colour = STATUS[status][idx] + if colour is None: + continue + rule = {"metric": code_col, "operator": "=", "target": code, "color": colour, "apply_to": target} + if paint == "text": + rule["paint"] = "text" + out.append(rule) + return out + + +# ---------------------------------------------------------------- header and footer (every tab) +freshness = add({ + "name": "Data through", "type": "big_number_total", "dataset": ds("freshness"), + "metric": "MAX(data_through_date)", "date_format": "%a, %-d %b %Y", + "description": "The latest day with all 24 hours of demand reported by every grid operator in EIA-930.", +}, 3, 2.4) +masthead = md("## SUNDOWN\nData-centre siting across the US power grid", 9, 2.4) +footer = md( + "##### Source: U.S. Energy Information Administration, EIA-930 Hourly Electric Grid Monitor and " + f"EIA-860M ({F['eia860m_label']}), public domain · ◇ marks a proxy (Glossary) · " + "Built with Chartwright", 12, 1.4) + + +# ---------------------------------------------------------------- Overview +def has_duck(h: dict) -> bool: + """One rule for "duck" everywhere: solar a tenth of midday demand, a sunset window, a year-earlier ramp.""" + return ((h["sunset_share"] or 0) >= 0.5 and (h["solar_mid"] or 0) >= 0.10 + and h["sunset_p50_ly_mw"] is not None) + + +def duck_title(h: dict) -> str: + if not has_duck(h): + cur, ly = h["rise3h_p50_mw"], h["rise3h_p50_ly_mw"] + return f"{h['label']} · {gw(cur)} · {pct(cur / ly - 1)} · 3-hour rise" + cur, ly = h["sunset_p50_mw"], h["sunset_p50_ly_mw"] + return f"{h['label']} · {gw(cur)} · {pct(cur / ly - 1)}" + + +SITING_FILTERS = [eq("in_roster", True), {"column": "baseload_change_mw", "op": ">=", "value": 150}] + + +def overview() -> dict: + l48_month = [eq("period_type", "month")] + demand = add({ + "name": "Lower-48 demand", "type": "big_number_trend", "dataset": ds("l48_period"), + "display_name": "Demand, TWh", + "filters": l48_month, "metric": "SQL(SUM(demand_twh_all_bas)) AS Demand, TWh", + "time_column": "period_start", "time_grain": "P1M", "rolling_type": "sum", "rolling_periods": 12, + "compare_lag": 12, "compare_suffix": "vs prior 12 months", + "subtitle": f"Lower 48 · 12 months to {MONTH}", "number_format": ",.0f", + "time_range": f"{months_back(END, 48)} : {END}", "trend_color": THIS, "y_axis_truncate": True, + "description": "Electricity demand summed over every Lower-48 grid operator, trailing 12 months.", + }, 3, 4.8) + ramp = add({ + "name": "Lower-48 evening ramp", "type": "big_number_trend", "dataset": ds("l48_period"), + "display_name": "Evening ramp, GW", + "filters": l48_month, "metric": "SQL(MAX(coincident_sunset_ramp_p50_mw) / 1000) AS Evening ramp, GW", + "time_column": "period_start", "time_grain": "P1M", "compare_lag": 12, "compare_suffix": "vs a year earlier", + "subtitle": f"Lower 48 together · median day · {MONTH}", + "number_format": ",.1f", "time_range": f"{months_back(END, 36)} : {END}", "trend_color": THIS, + "y_axis_truncate": True, + "description": "The largest 3-hour rise in Lower-48 net load (demand minus solar and wind) after the " + "solar peak, on the median day of the month. Rising means less headroom.", + }, 3, 4.8) + added = add({ + "name": "Baseload added", "type": "big_number_total", "dataset": ds("siting_ba"), + "display_name": "◇ Baseload added, GW", + "filters": [eq("in_roster", True), eq("baseload_night_agrees", True)], + "metric": "SQL(SUM(baseload_change_mw) / 1000) AS Baseload added, GW", + "subtitle": f"Sum of {F['baseload_agree']['n']} operators · {T12M}", + "number_format": "+,.1f", + "conditional_formatting": [{"operator": ">", "target": 0, "color": STATUS["watch"][0]}], + "description": "Change in the 5th percentile of hourly demand (flat load) over 12 months, summed over " + "operators whose night hours agree. Amber: added load means less headroom.", + }, 3, 4.8) + batteries = add({ + "name": "Batteries installed", "type": "big_number_trend", "dataset": ds("battery_capacity_month"), + "display_name": "Batteries, GW", + "filters": [{"column": "region_name", "op": "IS NOT NULL"}], + "metric": "SQL(SUM(operating_battery_mw) / 1000) AS Batteries, GW", + "time_column": "month_start", "time_grain": "P1M", "compare_lag": 12, "compare_suffix": "vs a year earlier", + "subtitle": f"Operating · EIA-860M, {F['eia860m_label']}", "number_format": ",.1f", + "time_range": f"{months_back(BATTERY_END, 36)} : {BATTERY_END}", "trend_color": THIS, "y_axis_truncate": True, + "description": "Operating battery nameplate capacity, Lower 48, from EIA-860M.", + }, 3, 4.8) + + ducks = [] + for h in F["hero"]: + ducks.append(add({ + "name": f"Shape of the day: {h['label']}", "type": "mixed", "dataset": ds("profile_ba"), + "display_name": duck_title(h), + "filters": [eq("ba", h["ba"]), eq("period_type", "quarter"), eq("is_latest", True)], + "x_column": "hour_label", + "a": {"metrics": ["SQL(MAX(solar_pct_peak_demand)) AS Solar"], "kind": "area", "opacity": 0.85, + "axis": "primary"}, + "b": {"metrics": [f"SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, {Q_PRIOR}", + f"SQL(MAX(net_load_pct_peak_demand)) AS Net load, {Q}"], + "kind": "line", "axis": "primary"}, + "y_axis_min": 0, "y_axis_max": 100, "number_format": ",.0f", "show_legend": False, + "x_axis_title": "Hour of day", "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, " + "averaged over the quarter. The title gives the median evening ramp and its change; " + "where solar is under a tenth of midday demand, the 3-hour rise.", + }, 3, 6)) + + siting = add({ + "name": "Siting screen", "type": "table", "dataset": ds("siting_ba"), + "display_name": f"Operators that added 150 MW+ of baseload · {T12M}", + "filters": SITING_FILTERS, + "columns": ["ba_label", "region_name", "row_status", "baseload_status", "baseload_change_mw", + "baseload_growth_arrow", "baseload_growth_2y_arrow", "baseload_idx2019", + "ramp_p90_share_of_p95_demand", "cover_ratio", "npe10_duration_p90_h", "closure_display", + "row_status_code", "baseload_status_code", "cover_band", "hours_band", "closure_band"], + "hidden": ["row_status_code", "baseload_status_code", "cover_band", "hours_band", "closure_band"], + "sort_by": "baseload_change_mw", "row_limit": 14, "cell_bars": False, # row tints hide bars on 6.1 + "column_headers": { + "ba_label": "Operator", "region_name": "Region", "row_status": "Status", + "baseload_status": "◇ Baseload", "baseload_change_mw": "◇ Baseload +MW", + "baseload_growth_arrow": "1 yr", "baseload_growth_2y_arrow": "2 yr", "baseload_idx2019": "vs 2019", + "ramp_p90_share_of_p95_demand": "Rise ÷ peak", "cover_ratio": "◇ Battery ÷ ramp", + "npe10_duration_p90_h": "◇ Hours to cut 10%", "closure_display": "Closure", + }, + "number_formats": {"baseload_change_mw": "+,.0f", "baseload_idx2019": ",.0f", + "ramp_p90_share_of_p95_demand": ".0%", "cover_ratio": ".2f", + "npe10_duration_p90_h": ".1f", "closure_display": "+.1%"}, + "column_widths": {"ba_label": 90, "region_name": 110, "row_status": 80, "baseload_status": 120}, + "column_align": center("ba_label", "region_name", "row_status", "baseload_status", "baseload_change_mw", + "baseload_growth_arrow", "baseload_growth_2y_arrow", "baseload_idx2019", + "ramp_p90_share_of_p95_demand", "cover_ratio", "npe10_duration_p90_h", "closure_display"), + "conditional_formatting": ( + status_rules("row_status_code", "row", {1: "good", 2: "watch", 3: "act"}, paint="cell", tint="row") + + status_rules("row_status_code", "row_status", {1: "good", 2: "watch", 3: "act"}, paint="text") + + status_rules("baseload_status_code", "baseload_status", {3: "proven", 2: "watch", 1: "good"}, paint="text") + + status_rules("baseload_status_code", "baseload_growth_arrow", {3: "proven", 2: "watch"}, paint="text") + + status_rules("cover_band", "cover_ratio", {1: "good", 2: "watch", 3: "act"}, paint="text") + + status_rules("hours_band", "npe10_duration_p90_h", {1: "good", 2: "watch", 3: "act"}, paint="text") + + status_rules("closure_band", "closure_display", {3: "act"}, paint="text") + ), + "description": "Status: Act when cutting the peak 10% needs over 10 hours of storage or the data " + "doesn't reconcile (closure outside ±2%); Watch when it needs 4–10 hours, batteries cover " + "under half the bad-day ramp, or baseload rose beyond weather (proven); Good otherwise.", + }, 12, 13.6) + + return {"title": "Overview", "rows": [ + [demand, ramp, added, batteries], + header("Siting screen"), + [siting], + header("Shape of the day"), + key(("this", Q), ("prior", Q_PRIOR), ("solar", "Solar")), + list(ducks[:4]), + list(ducks[4:]), + ]} + + +# ---------------------------------------------------------------- Evening ramp (one grid operator) +def evening_ramp() -> dict: + quarter = [eq("period_type", "quarter")] + t12 = {"filters": [eq("period_type", "t12m")], "time_column": "period_end", "time_grain": "P1M", + "time_range": f"{months_back(END, 36)} : {END}", "trend_color": THIS, "y_axis_truncate": True} + r1 = add({ + "name": "Evening ramp, median day", "type": "big_number_trend", "dataset": ds("ba_period"), + "display_name": "Evening ramp, GW", "filters": quarter, + "metric": "SQL(MAX(sunset_ramp_p50_cmp_mw) / 1000) AS GW", + "time_column": "period_start", "time_grain": "P3M", "compare_lag": 4, "compare_suffix": f"vs {Q_PRIOR}", + "subtitle": f"Median day · {Q}", "number_format": ",.1f", + "time_range": f"{months_back(END, 36)} : {END}", "trend_color": THIS, "y_axis_truncate": True, + "description": "The largest 3-hour rise in net load after the solar peak, median day of the quarter. " + "Compared only where 9 days in 10 had a duck in both years.", + }, 3, 4.8, ba=True) + r2 = add({ + "name": "Evening ramp, bad day", "type": "big_number_trend", "dataset": ds("ba_period"), + "display_name": "Bad-day ramp, GW", "metric": "SQL(MAX(sunset_ramp_p90_cmp_mw) / 1000) AS GW", + "compare_lag": 12, "compare_suffix": "vs prior 12 months", + "subtitle": f"1 day in 10 · {T12M}", "number_format": ",.1f", **t12, + "description": "The evening ramp on 1 day in 10 (P90) over 12 months: what storage is sized to.", + }, 3, 4.8, ba=True) + r3 = add({ + "name": "Hours to cut the peak", "type": "big_number_trend", "dataset": ds("ba_period"), + "display_name": "◇ Hours to cut 10%", "metric": "SQL(MAX(npe10_duration_p90_h)) AS Hours", + "subtitle": f"Of storage · 1 day in 10 · {T12M}", "number_format": ",.1f", **t12, + "description": "Hours of storage to cut the daily net-load peak by 10% on 1 day in 10. Within 4 h a " + "4-hour battery covers it; over 10 h needs long-duration storage.", + }, 3, 4.8, ba=True) + r4 = add({ + "name": "Baseload", "type": "big_number_trend", "dataset": ds("ba_period"), "display_name": "◇ Baseload, GW", + "filters": [eq("period_type", "t12m")], "metric": "SQL(MAX(demand_p5_mw) / 1000) AS GW", + "time_column": "period_end", "time_grain": "P1M", "compare_lag": 12, "compare_suffix": "vs prior 12 months", + "subtitle": f"5th percentile of demand · {T12M}", "number_format": ",.1f", + "time_range": f"{months_back(END, 36)} : {END}", "trend_color": THIS, "y_axis_truncate": True, + "description": "The 5th percentile of hourly demand, a proxy for flat load.", + }, 3, 4.8, ba=True) + heat = add({ + "name": "Net load by hour and month", "type": "heatmap", "dataset": ds("profile_ba"), + "display_name": "Net load by hour and month, MW · last 24 months", + "filters": [eq("period_type", "month"), {"column": "period_start", "op": ">=", "value": str(START24)}], + "x_column": "period_key", "y_column": "hour_axis", "metric": "SQL(MAX(net_load_mw)) AS Net load, MW", + "x_order": "a_to_z", "color_scheme": P["heatmap"], + "x_label_every": 3, "y_label_every": 6, "left_margin": 40, + "description": "Mean net load by hour of day and month. Darker is more to serve; the evening ramp is " + "the climb from the midday trough to the evening peak.", + }, 7, 8, ba=True) + box = add({ + "name": "Daily evening ramps by month", "type": "box_plot", "dataset": ds("daily_ba"), + "display_name": f"Daily evening ramp, GW · {T12M}", + "filters": [{"column": "data_date", "op": ">=", "value": str(START12)}, + {"column": "data_date", "op": "<=", "value": MONTH_END}, + {"column": "sunset_ramp_mw", "op": "IS NOT NULL"}, eq("sunset_month_ok", True)], + "metrics": ["SQL(MAX(sunset_ramp_mw) / 1000) AS Evening ramp, GW"], + "distribute_across": ["data_date"], "groupby": ["month_key"], "whiskers": [10, 90], + "row_limit": 1000, "number_format": ",.0f", "x_axis_title": "Month", "y_axis_title": "GW", + "description": "Every day's evening ramp, one box per month: the box is the middle half, the whiskers " + "the 10th and 90th percentile days. Storage is sized to the upper whisker.", + }, 5, 8, ba=True) + bridge = add({ + "name": "Evening ramp bridge", "type": "waterfall", "dataset": ds("v_sunset_bridge"), + "display_name": f"What moved the mean day's evening ramp, GW · {Q_PRIOR} to {Q}", + "filters": [eq("is_latest", True)], "x_column": "step", "metric": "SUM(gw)", + "opening": F["quarter"]["bridge_open"], "steps": ["Demand", "Solar", "Wind"], + "closing": F["quarter"]["bridge_close"], + "increase_color": UP, "decrease_color": DOWN, "total_color": TOTAL, # 3:1 on white, as a graphic needs + "x_axis_title": "Change by source", "y_axis_title": "GW", + "increase_label": "Steepens the ramp", "decrease_label": "Eases the ramp", + "show_value": True, "number_format": ",.1f", "show_legend": False, + "description": "The change in the mean day's evening ramp split exactly into demand, solar and wind: each " + "step is how much that source's swing across the day's 3-hour window changed.", + }, 7, 11.6, ba=True) + movers = add({ + "name": "Evening ramp by operator", "type": "table", "dataset": ds("operator_ramp"), + "display_name": f"Ten largest evening ramps, GW · {Q}", + "filters": [eq("has_duck", 1)], + "columns": ["ba_label", "sunset_ramp_p50_gw", "sunset_ramp_p50_arrow", "sunset_ramp_p90_gw", + "sunset_ramp_p50_sign"], + "hidden": ["sunset_ramp_p50_sign"], + "sort_by": "sunset_ramp_p50_gw", "row_limit": 10, "page_length": 0, "search_box": False, + "cell_bars": ["sunset_ramp_p90_gw"], "color_by_sign": False, + "column_headers": {"ba_label": "Operator", "sunset_ramp_p50_gw": "Median day", + "sunset_ramp_p50_arrow": f"vs {Q_PRIOR}", "sunset_ramp_p90_gw": "1 day in 10"}, + "number_formats": {"sunset_ramp_p50_gw": ",.1f", "sunset_ramp_p90_gw": ",.1f"}, + "column_align": center("ba_label", "sunset_ramp_p50_gw", "sunset_ramp_p50_arrow", "sunset_ramp_p90_gw"), + "conditional_formatting": status_rules("sunset_ramp_p50_sign", "sunset_ramp_p50_arrow", + {1: "watch", -1: "good"}, paint="text"), + "description": "Operators with a duck (solar a tenth of midday demand): the evening ramp on the median " + "day and on 1 day in 10. A rising ramp is amber, a falling one green.", + }, 5, 11.6) + crit = F["z12_crit_evening"] + signal = add({ + "name": "Is the change real", "type": "mixed", "dataset": ds("signal_month"), + "display_name": "Evening ramp: each month's change ÷ weather noise", + "filters": [eq("metric", "sunset_ramp_p50"), {"column": "month_start", "op": ">=", "value": str(START24)}, + {"column": "z_noise", "op": "IS NOT NULL"}], + "x_column": "month_start", "time_grain": "P1M", "x_label_format": "%b %y", + "a": {"metrics": ["SQL(MAX(CASE WHEN outside_noise_band THEN z_noise END)) AS Outside the band", + "SQL(MAX(CASE WHEN NOT outside_noise_band THEN z_noise END)) AS Inside the band"], + "kind": "bar", "axis": "primary", "show_value": True}, # one bar a month: the theme overlaps them + "b": {"metrics": ["SQL(MAX(CASE WHEN n_z_12m = 12 THEN z_mean_12m END)) AS 12-month average"], + "kind": "line", "axis": "primary"}, + "annotations": [{"name": "12-month test", "value": round(crit, 2), **REF}], + "number_format": ",.1~f", "show_legend": True, "x_axis_title": "Month", "y_axis_title": "Change ÷ weather noise", + "description": "Each month's year-on-year change divided by this operator's own weather noise for that " + "month. Outside the band three months running, or a 12-month average above the dashed " + "line, counts as real. Tested only with four reference years of a duck.", + }, 7, 8, ba=True) + verdicts = add({ + "name": "Is it real, by operator", "type": "table", "dataset": ds("signal_status"), + "display_name": f"Verdict by operator · {T12M}", + "filters": [{"sql": "baseload_code >= 2 OR rise3h_code >= 2 OR evening_code >= 2"}], + "columns": ["ba_label", "baseload_verdict", "rise3h_verdict", "evening_verdict", + "baseload_code", "rise3h_code", "evening_code"], + "hidden": ["baseload_code", "rise3h_code", "evening_code"], + "sort_by": "baseload_code", "row_limit": F["signal_flagged_n"], "page_length": 0, "search_box": False, + "column_headers": {"ba_label": "Operator", "baseload_verdict": "◇ Baseload", + "rise3h_verdict": "3-hour rise", "evening_verdict": "Evening ramp"}, + "column_align": center("ba_label", "baseload_verdict", "rise3h_verdict", "evening_verdict"), + "conditional_formatting": ( + status_rules("baseload_code", "baseload_verdict", {3: "proven", 2: "watch"}, tint="pill") + + status_rules("rise3h_code", "rise3h_verdict", {3: "proven", 2: "watch"}, tint="pill") + + status_rules("evening_code", "evening_verdict", {3: "proven", 2: "watch"}, tint="pill")), + "description": "Proven: the 12-month test passed (beyond what weather produces 1 time in 100). Watch: " + "outside the weather band three months running. Every operator with a proven or " + "watched rise on any test.", + }, 5, 16.3) + hist = add({ + "name": "Evening ramp since 2018", "type": "timeseries_line", "dataset": ds("ba_period"), + "display_name": "Evening ramp, GW · median and bad day, by month since Jul 2018", + "filters": [eq("period_type", "month")], + "metrics": ["SQL(MAX(sunset_ramp_p50_mw) / 1000) AS Median day", + "SQL(MAX(sunset_ramp_p90_mw) / 1000) AS Bad day (P90)"], + "time_column": "period_start", "time_grain": "P1M", "x_label_format": "%Y", "number_format": ",.0f", + "y_axis_min": 0, "legend_position": "bottom", "time_range": f"2018-07-01 : {END}", + "x_axis_title": "Month", "y_axis_title": "GW", + "description": "Months when solar was under 5% of peak demand have no evening ramp and are left blank.", + }, 7, 8, ba=True) + return {"title": "Evening ramp", "tabs": [ + {"title": "Shape and drivers", "rows": [[r1, r2, r3, r4], header("By hour and by day"), [heat, box], + header(f"What moved, {Q_PRIOR} to {Q}"), + key(("up", "Steepens the ramp"), ("down", "Eases it"), ("total", "Mean day")), + [bridge, movers]]}, + {"title": "Is it real", "sketch": ["HHHHHHHHHHHH", + "SSSSSSSVVVVV", "SSSSSSSVVVVV", "SSSSSSSVVVVV", "SSSSSSSVVVVV", + "LLLLLLLVVVVV", "LLLLLLLVVVVV", "LLLLLLLVVVVV", "LLLLLLLVVVVV", "LLLLLLLVVVVV"], + "legend": {"H": {"header": "Change against weather"}, "S": signal, "V": verdicts, "L": hist}}, + ]} + + +# ---------------------------------------------------------------- Load growth +def growth() -> dict: + added = add({ + "name": "Baseload and peak added by grid operator", "type": "bar", "dataset": ds("siting_ba"), + "display_name": f"◇ Baseload and peak added, MW · {T12M}", + "filters": SITING_FILTERS, "x_column": "ba_label", + "metrics": ["SQL(MAX(baseload_change_mw)) AS Baseload added", "SQL(MAX(peak_change_mw)) AS Peak added"], + "orientation": "horizontal", "row_limit": 13, "number_format": ",.0f", "legend_position": "bottom", + "y_axis_min": axis_floor(F["peak_change_min_mw"], 500), "show_value": True, + "x_axis_title": "Grid operator", "y_axis_title": "MW", + "description": "Baseload is the 5th percentile of hourly demand, peak the 95th. Where they rise together " + "the new load is flat; peak also moves with summer weather.", + }, 7, 12) + index = add({ + "name": "Baseload against 2019", "type": "bar", "dataset": ds("siting_ba"), + "display_name": f"◇ Baseload, 2019 = 100 · {T12M}", + "filters": SITING_FILTERS, "x_column": "ba_label", "groupby": "baseload_status", + "metrics": ["SQL(MAX(baseload_idx2019)) AS Baseload index"], "stack": True, "sort_by": "total", + "annotations": [{"name": "2019 = 100", "value": 100, **REF}], + "orientation": "horizontal", "number_format": ",.0f", "legend_position": "bottom", + "show_value": True, "x_axis_title": "Grid operator", "y_axis_title": "Index, 2019 = 100", + "description": "Each operator's baseload against its own 2019, coloured by whether its rise passes the " + "12-month test.", + }, 5, 12) + zones = add({ + "name": "Load-zone baseload since 2019", "type": "timeseries_line", "dataset": ds("subregion_period"), + "display_name": "PJM load zones: ◇ baseload, 2019 = 100 · trailing 12 months", + "filters": [eq("period_type", "t12m"), {"column": "subregion", "op": "IN", "value": ["DOM", "AEP"]}], + "metrics": ["SQL(MAX(demand_p5_idx2019)) AS Baseload index"], "groupby": "subregion", + "time_column": "period_end", "time_grain": "P1M", "x_label_format": "%Y", "number_format": ",.0f", + "y_axis_min": 80, "y_axis_max": math.ceil((F["zones_idx_max"] + 10) / 20) * 20, + "time_range": f"2020-12-01 : {END}", "legend_position": "bottom", + "x_axis_title": "Month", "y_axis_title": "Index, 2019 = 100", + "annotations": [{"name": "2019 = 100", "value": 100, **REF}], + "description": "Dominion's Virginia zone (DOM) and American Electric Power's (AEP): lowest hours at " + "night, so their 5th percentile is flat load.", + }, 12, 8) + return {"title": "Load growth", "rows": [header("Baseload and peak added"), [added, index], + header("Data-centre zones in PJM"), [zones]]} + + +# ---------------------------------------------------------------- Storage +def storage() -> dict: + operating = add({ + "name": "Batteries operating", "type": "big_number_trend", "dataset": ds("battery_capacity_month"), + "display_name": "Batteries operating, GW", + "filters": [{"column": "region_name", "op": "IS NOT NULL"}], + "metric": "SQL(SUM(operating_battery_mw) / 1000) AS GW", + "time_column": "month_start", "time_grain": "P1M", "compare_lag": 12, "compare_suffix": "vs a year earlier", + "subtitle": f"Lower 48 · EIA-860M, {F['eia860m_label']}", "number_format": ",.1f", + "time_range": f"{months_back(BATTERY_END, 36)} : {BATTERY_END}", "trend_color": THIS, "y_axis_truncate": True, + }, 4, 4.8) + covered = add({ + "name": "Operators covered", "type": "big_number_total", "dataset": ds("siting_ba"), + "display_name": "Operators whose batteries cover the ramp", + "filters": [eq("in_roster", True), eq("cover_band", 1)], "metric": "COUNT(*)", + "subtitle": f"Battery MW ≥ the bad-day ramp · of {N_RANKED}", "number_format": ",.0f", + "conditional_formatting": [{"operator": ">", "target": 0, "color": STATUS["good"][0]}], + }, 4, 4.8) + within4 = add({ + "name": "Operators within 4 hours", "type": "big_number_total", "dataset": ds("siting_ba"), + "display_name": "Operators a 4-hour battery covers", + "filters": [eq("in_roster", True), eq("hours_band", 1)], "metric": "COUNT(*)", + "subtitle": f"Cut the peak 10% in 4 h or less · of {N_RANKED}", "number_format": ",.0f", + "conditional_formatting": [{"operator": ">", "target": 0, "color": STATUS["good"][0]}], + }, 4, 4.8) + installed = add({ + "name": "Batteries installed by region", "type": "timeseries_area", "dataset": ds("battery_capacity_month"), + "display_name": f"Batteries operating by region, GW · EIA-860M, {F['eia860m_label']}", + "filters": [{"column": "region_group", "op": "IS NOT NULL"}], + "metrics": ["SQL(SUM(operating_battery_mw) / 1000) AS GW"], "groupby": "region_group", + "time_column": "month_start", "time_grain": "P1M", "x_label_format": "%Y", "number_format": ",.0f", + "stack": True, "opacity": 0.9, "time_range": f"2020-01-01 : {BATTERY_END}", "legend_position": "bottom", + "x_axis_title": "Month", "y_axis_title": "GW", + }, 12, 8) + cover = add({ + "name": "Battery cover of the bad-day ramp", "type": "bar", "dataset": ds("siting_ba"), + "display_name": f"◇ Battery MW ÷ bad-day evening ramp · {T12M}", + "filters": [eq("in_roster", True), {"column": "sunset_ramp_p90_mw", "op": ">=", "value": 1000}], + "x_column": "ba_label", "groupby": "cover_band_label", "stack": True, "sort_by": "total", + "metrics": ["SQL(MAX(cover_ratio)) AS Battery ÷ ramp"], + "orientation": "horizontal", "number_format": ",.2f", "legend_position": "bottom", "x_label_every": True, + "show_value": True, "x_axis_title": "Grid operator", "y_axis_title": "Battery MW ÷ bad-day ramp", + "annotations": [{"name": "Covers the ramp (1.0)", "value": 1, **REF}, + {"name": "Half (0.5)", "value": 0.5, **REF}], + "description": "Operating battery nameplate MW over the bad-day (P90) evening ramp, for operators whose " + "bad-day ramp is 1 GW or more. Nameplate isn't accredited capacity.", + }, 6, 13) + hours = add({ + "name": "Hours of storage to cut the peak", "type": "bar", "dataset": ds("siting_ba"), + "display_name": f"◇ Hours of storage to cut the peak 10% · 1 day in 10 · {T12M}", + "filters": [eq("in_roster", True), {"column": "peak_p95_mw", "op": ">=", "value": 5000}], + "x_column": "ba_label", "groupby": "hours_band_label", "stack": True, "sort_by": "total", + "metrics": ["SQL(MAX(npe10_duration_p90_h)) AS Hours"], + "orientation": "horizontal", "number_format": ",.1f", "legend_position": "bottom", "x_label_every": True, + "show_value": True, "x_axis_title": "Grid operator", "y_axis_title": "Hours of storage", + "annotations": [{"name": "4-hour battery", "value": 4, **REF}, + {"name": "Long duration (10 h)", "value": 10, **REF}], + "description": "The energy above 90% of the day's peak net load divided by that 10%, on 1 day in 10: " + "perfect foresight, no losses. Operators with a peak over 5 GW.", + }, 6, 13) + return {"title": "Storage", "rows": [[operating, covered, within4], header("Batteries by region"), [installed], + header("Where storage covers the ramp"), [cover, hours]]} + + +# ---------------------------------------------------------------- Supply (one grid operator) +def supply() -> dict: + serve = add({ + "name": "What serves 13:00 and 20:00", "type": "bar", "dataset": ds("fuel_profile_ba"), + "display_name": f"What supplies 13:00 and 20:00, MW · {Q}", + "filters": [eq("is_latest", True), eq("closure_ok", True), + {"column": "hour_label", "op": "IN", "value": ["13:00", "20:00"]}, + {"column": "fuel_group", "op": "IS NOT NULL"}], + "x_column": "hour_label", "metrics": ["SQL(SUM(mean_mw)) AS MW"], "groupby": "fuel_group", "stack": True, + "category_sort": "asc", "x_label_every": True, "number_format": ",.0f", "legend_position": "bottom", + "legend_type": "plain", "show_value": True, "only_total": True, + "x_axis_title": "Hour of day", "y_axis_title": "MW", + "description": "Mean supply by fuel at 13:00, when solar peaks, and 20:00, after it sets. Below zero is " + "where part of it goes, exports and battery charging, so each bar nets to demand. Months " + "whose generation is more than 5% off demand are left out.", + }, 6, 9, ba=True) + battery = add({ + "name": "Battery output by hour", "type": "mixed", "dataset": ds("profile_ba"), + "display_name": f"Battery discharge by hour, MW · {Q} vs {Q_PRIOR}", + "filters": [eq("period_type", "quarter"), eq("is_latest", True)], "x_column": "hour_label", + "a": {"metrics": [f"SQL(MAX(battery_discharge_ly_mw)) AS Battery, {Q_PRIOR}"], "kind": "line", + "axis": "primary"}, + "b": {"metrics": [f"SQL(MAX(battery_discharge_mw)) AS Battery, {Q}"], "kind": "line", "axis": "primary"}, + "number_format": ",.0f", "show_legend": True, "x_axis_title": "Hour of day", "y_axis_title": "MW", + "description": "Discharge only: EIA-930 reports some operators' batteries net of charging and others as " + "discharge only (ERCOT switched in Dec 2025).", + }, 6, 9, ba=True) + latest = [eq("is_latest", True), eq("closure_ok", True)] + + def share(fuel, hour): + return (f"SQL(SUM(CASE WHEN fuel_group = '{fuel}' AND hour_label = '{hour}' THEN mean_mw END) / " + f"NULLIF(SUM(CASE WHEN hour_label = '{hour}' AND mean_mw > 0 THEN mean_mw END), 0)) " + f"AS {fuel} at {hour}") + k_solar = add({"name": "Solar at 13:00", "type": "big_number_total", "dataset": ds("fuel_profile_ba"), + "display_name": "Solar at 13:00, share", "filters": latest, "metric": share("Solar", "13:00"), + "number_format": ".0%", "subtitle": f"Of supply at that hour · {Q}"}, 3, 4.6, ba=True) + k_gas = add({"name": "Gas at 20:00", "type": "big_number_total", "dataset": ds("fuel_profile_ba"), + "display_name": "Gas at 20:00, share", "filters": latest, "metric": share("Gas", "20:00"), + "number_format": ".0%", "subtitle": f"Of supply at that hour · {Q}"}, 3, 4.6, ba=True) + k_battery = add({"name": "Battery at 20:00", "type": "big_number_total", "dataset": ds("profile_ba"), + "display_name": "Battery at 20:00, MW", + "filters": [eq("period_type", "quarter"), eq("is_latest", True), eq("hour_label", "20")], + "metric": "SQL(MAX(battery_discharge_mw)) AS Battery at 20:00", + "number_format": ",.0f", "subtitle": f"Mean discharge, where reported · {Q}"}, + 3, 4.6, ba=True) + closure = add({ + "name": "Balance closure", "type": "big_number_total", "dataset": ds("ba_period"), + "display_name": "Balance closure", + "filters": [eq("period_type", "quarter"), eq("is_latest", True)], + # unrounded: Superset never colours a value of exactly 0 (BigNumberViz.tsx:216 at 6.1.0) + "metric": "SQL(MAX(closure)) AS Closure", + "number_format": ".1%", "subtitle": f"{Q} · ±2% guardrail", + "conditional_formatting": [ + {"operator": "<", "target": -0.02, "color": STATUS["act"][0]}, + {"operator": ">", "target": 0.02, "color": STATUS["act"][0]}, + {"operator": "between", "target_left": -0.02, "target_right": 0.02, "color": STATUS["good"][0]}], + "description": "(Net generation − interchange − demand) ÷ demand. Green within ±2%; red outside, where " + "fuel shares are approximate. Beyond ±5% the fuel mix is left blank.", + }, 3, 4.6, ba=True) + mix = add({ + "name": "Generation mix since 2018", "type": "timeseries_area", "dataset": ds("fuel_ba_period"), + "display_name": "Generation mix, share · since Jul 2018", + "filters": [eq("period_type", "month"), eq("closure_ok", True), + {"column": "fuel_group", "op": "IN", + "value": ["Solar", "Wind", "Gas", "Coal, nuclear, hydro", "Other"]}], + "metrics": ["SQL(SUM(twh)) AS TWh"], "groupby": "fuel_group", "contribution": "row", "stack": True, + "opacity": 0.9, "time_column": "period_start", "time_grain": "P1M", "x_label_format": "%Y", + "number_format": ".0%", "y_axis_min": 0, "y_axis_max": 1, "time_range": f"2018-07-01 : {END}", + "legend_position": "bottom", "x_axis_title": "Month", "y_axis_title": "Share of generation", + }, 12, 8, ba=True) + return {"title": "Supply", "rows": [[k_solar, k_gas, k_battery, closure], header("Solar peak and after sunset"), + [serve, battery], header("Generation mix since 2018"), [mix]]} + + +# ---------------------------------------------------------------- About +def about() -> dict: + glossary = add({ + "name": "Glossary", "type": "table", "dataset": ds("glossary"), + "display_name": "Glossary · every measure on the dashboard", + "columns": ["measure", "definition", "formula", "threshold", "source", "sort_order"], + "hidden": ["sort_order"], "sort_by": "sort_order", "sort_ascending": True, + "row_limit": F.get("glossary_rows", 40), "page_length": 0, "search_box": False, + "column_headers": {"measure": "Measure", "definition": "Definition", "formula": "Formula", + "threshold": "Status bands", "source": "Source"}, + "column_widths": {"measure": 200, "definition": 420, "formula": 300, "threshold": 260, "source": 90}, + "column_align": {"measure": "left", "definition": "left", "formula": "left", "threshold": "left", + "source": "left"}, + }, 12, 52) + dq_rows = F["dq"]["n_with_closure"] - F["dq"]["n_within_2pct_all_months"] + dq = add({ + "name": "Data quality by grid operator", "type": "table", "dataset": ds("dq_ba"), + "display_name": "Operators with a month past ±2% closure · latest 12 months", + "filters": [{"column": "closure_abs", "op": "IS NOT NULL"}], + "columns": ["ba_label", "months_beyond_2pct", "closure_worst_monthly", "closure_t12m", "n_months", + "demand_imputed_share", "reports_batteries"], + "sort_by": "months_beyond_2pct", "row_limit": dq_rows, "page_length": 0, "search_box": False, + "column_headers": {"ba_label": "Grid operator", "months_beyond_2pct": "Months past ±2%", + "closure_t12m": "12-month closure", "closure_worst_monthly": "Worst month", + "n_months": "Months", "demand_imputed_share": "Demand imputed", + "reports_batteries": "Reports batteries"}, + "number_formats": {"closure_t12m": "+.1%", "closure_worst_monthly": "+.1%", "demand_imputed_share": ".1%"}, + "column_align": center("ba_label", "months_beyond_2pct", "closure_worst_monthly", "closure_t12m", "n_months", + "demand_imputed_share", "reports_batteries"), + "conditional_formatting": [ + {"metric": "months_beyond_2pct", "operator": ">", "target": 5, "color": STATUS["act"][2], "apply_to": "row"}, + {"metric": "months_beyond_2pct", "operator": "between", "target_left": 0, "target_right": 6, + "color": STATUS["watch"][2], "apply_to": "row"}, + {"metric": "months_beyond_2pct", "operator": ">", "target": 5, "color": STATUS["act"][0], + "apply_to": "months_beyond_2pct", "paint": "text"}, + ], + "description": "Balance closure is (net generation − interchange − demand) ÷ demand and should sit near " + "zero. Red rows: six or more months past ±2%; amber: one to five.", + }, 7, 15) + caiso = add({ + "name": "CAISO balance closure", "type": "timeseries_line", "dataset": ds("dq_ba_month"), + "display_name": f"Balance closure by month, % · CAISO and PJM · since {months_back(END, 33):%b %Y}", + "filters": [{"column": "ba", "op": "IN", "value": ["CISO", "PJM"]}], "metrics": ["SQL(MAX(closure)) AS Closure"], + "groupby": "ba_label", + "time_column": "month_start", "time_grain": "P1M", "x_label_format": "%b %y", "number_format": ".0%", + "time_range": f"{months_back(END, 33)} : {END}", "markers": False, "legend_position": "bottom", + "x_axis_title": "Month", "y_axis_title": "Closure, % of demand", + "annotations": [{"name": "+2%", "value": 0.02, **REF}, {"name": "−2%", "value": -0.02, **REF}], + "description": "CAISO's fuel mix stops reconciling from Dec 2025 (its ramp uses only demand, solar and " + "wind and is unaffected); PJM runs above +2% each summer.", + }, 5, 15) + return {"title": "About", "rows": [header("Data quality"), [dq, caiso], header("Glossary"), [glossary]]} + + +tabs = [overview(), evening_ramp(), growth(), storage(), supply(), about()] + +IGNORE = ([f"size.min-width@Shape of the day: {h['label']}" for h in F["hero"]] + + ["chart.ordinal-order@Net load by hour and month", "layout.tab-balance", "filters.time-picker", + "layout.fold-budget@tab-About-row-1"]) # one scrolling page, chosen over sub-tabs + +STATUS_LABELS = { + # a proven rise in a deeper amber than a rise, both 3:1 on white + "Proven rise": P["proven_rise"], "Rising": P["rising"], "Flat or falling": STATUS["good"][3], + "Covers the ramp": STATUS["good"][3], "Half or more": STATUS["watch"][3], "Under half": STATUS["act"][3], + "Within 4 h": STATUS["good"][3], "4–10 h": STATUS["watch"][3], "Over 10 h": STATUS["act"][3], +} + +spec = { + "spec_version": "1", + "design": {"ignore": IGNORE}, + "dashboard": { + "title": "Sundown", + "slug": "sundown", + "theme": "Sundown", + "css": CSS, + "cross_filters": True, + "filter_bar_orientation": "horizontal", + "label_colors": { + "Solar": FUEL["Solar"], f"Net load, {Q}": THIS, f"Net load, {Q_PRIOR}": PRIOR, + "Median day": THIS, "Bad day (P90)": PRIOR, + "Outside the band": STATUS["watch"][3], "Inside the band": PRIOR, "12-month average": INK, + "Baseload added": THIS, "Peak added": PRIOR, + f"Battery, {Q}": FUEL["Battery"], f"Battery, {Q_PRIOR}": P.get("battery_prior", "#CCB5ED"), + **FUEL, **REGION, **STATUS_LABELS, + "DOM": THIS, "AEP": SECOND, "CAISO": THIS, "PJM": SECOND, "Closure": THIS, + "2019 = 100": REFC, "12-month test": REFC, + # the box plot colours each month's box by its label: pin them all to one + **{f"{y}-{m:02d}": PRIOR for y in range(END.year - 2, END.year + 1) for m in range(1, 13)}, + }, + }, + "charts": charts, + "filters": [ + {"type": "select", "name": "Grid operator", "dataset": ds("ba_period"), "column": "ba_label", + "multi": False, "default": ["ERCOT"], "required": True, "charts": BA_SCOPED}, + ], + "layout": { + "header": [{"row": [masthead, freshness], "background": "white"}], + "tabs": tabs, + "footer": [[footer]], + }, +} +json.dump(spec, sys.stdout, indent=2, ensure_ascii=False) +sys.stdout.write("\n") diff --git a/examples/sundown/facts.json b/examples/sundown/facts.json new file mode 100644 index 0000000..e018a68 --- /dev/null +++ b/examples/sundown/facts.json @@ -0,0 +1 @@ +{"data_through_label" : "Sat 3 Oct 2026", "data_through_date" : "2026-10-03", "eia860m_label" : "August 2026", "eia860m_vintage" : "2026-08-01", "n_bas_complete" : 53, "quarter" : {"key" : "2026-Q3", "label" : "Q3 2026", "start" : "2026-07-01", "end" : "2026-09-30", "prior_start" : "2025-07-01", "bridge_open" : "Mean day, Jul–Sep 2025", "bridge_close" : "Mean day, Jul–Sep 2026"}, "month" : {"key" : "2026-09", "label" : "Sep 2026", "start" : "2026-09-01", "end" : "2026-09-30"}, "t12m" : {"key" : "T12M-2026-09", "label" : "12 months to Sep 2026", "start" : "2025-10-01", "end" : "2026-09-30"}, "hero" : [{"ba" : "ERCO", "label" : "ERCOT", "sunset_p50_mw" : 16902.5, "sunset_p50_ly_mw" : 13166, "sunset_yoy" : 0.28379917970530144, "sunset_share" : 0.92391304347826086957, "solar_mid" : 0.39233913811991894, "rise3h_p50_mw" : 16902.5, "rise3h_p50_ly_mw" : 13231}, {"ba" : "CISO", "label" : "CAISO", "sunset_p50_mw" : 15441.5, "sunset_p50_ly_mw" : 15979.5, "sunset_yoy" : -0.03366813730091678, "sunset_share" : 1.00000000000000000000, "solar_mid" : 0.5396321253881763, "rise3h_p50_mw" : 15441.5, "rise3h_p50_ly_mw" : 15979.5}, {"ba" : "PJM", "label" : "PJM", "sunset_p50_mw" : 10706, "sunset_p50_ly_mw" : 9658, "sunset_yoy" : 0.10851107889832257, "sunset_share" : 0.51648351648351648352, "solar_mid" : 0.09267008461645754, "rise3h_p50_mw" : 11520.5, "rise3h_p50_ly_mw" : 10975.5}, {"ba" : "MISO", "label" : "MISO", "sunset_p50_mw" : 10167.5, "sunset_p50_ly_mw" : 8780.5, "sunset_yoy" : 0.15796366949490337, "sunset_share" : 0.80434782608695652174, "solar_mid" : 0.1635210996197009, "rise3h_p50_mw" : 10493, "rise3h_p50_ly_mw" : 8879}, {"ba" : "AZPS", "label" : "AZPS", "sunset_p50_mw" : 2102, "sunset_p50_ly_mw" : 1661.5, "sunset_yoy" : 0.26512187782124585, "sunset_share" : 1.00000000000000000000, "solar_mid" : 0.36098321657818844, "rise3h_p50_mw" : 2102, "rise3h_p50_ly_mw" : 1661.5}, {"ba" : "NEVP", "label" : "NEVP", "sunset_p50_mw" : 2137.5, "sunset_p50_ly_mw" : 2364, "sunset_yoy" : -0.0958121827411168, "sunset_share" : 0.98913043478260869565, "solar_mid" : 0.4174961492878256, "rise3h_p50_mw" : 2137.5, "rise3h_p50_ly_mw" : 2364}, {"ba" : "SOCO", "label" : "SOCO", "sunset_p50_mw" : 3782, "sunset_p50_ly_mw" : 3980.5, "sunset_yoy" : -0.049868107021730945, "sunset_share" : 0.65217391304347826087, "solar_mid" : 0.11522447205274874, "rise3h_p50_mw" : 4362, "rise3h_p50_ly_mw" : 4213}, {"ba" : "TVA", "label" : "TVA", "sunset_p50_mw" : 2429.5, "sunset_p50_ly_mw" : null, "sunset_yoy" : null, "sunset_share" : 0.62318840579710144928, "solar_mid" : 0.054586169818307474, "rise3h_p50_mw" : 2848, "rise3h_p50_ly_mw" : 2506}], "roster" : {"n" : 51, "baseload_added_mw" : 10726.300000000003, "rise3h_proven_12m" : 7, "baseload_proven_12m" : 5, "baseload_proven" : "ERCOT, SPP, BPA, AZPS, NEVP"}, "fpr" : {"per_ba_month" : 0.006573333333333333, "expected_latest_month" : 0.33524, "n_tested" : 51, "expected_bas_rise_12m" : 1.63302}, "z12_crit_evening" : 1.4531801134092965, "z12_crit_3h" : 1.0391219286317057, "evening_proven" : {"n" : 0, "n_tested" : 5, "who" : null}, "evening_monthly" : {"n" : 3, "who" : "EPE, AZPS, PACE", "n_testable" : 19, "expected" : 0.78014}, "n12" : { "demand_p5" : 47, "demand_p95" : 48, "ramp_p50" : 47, "ramp_p90" : 47, "sunset_ramp_p50" : 5, "sunset_ramp_p90" : 6 }, "baseload_agree" : {"n" : 49, "added_mw" : 10390.400000000001}, "siting" : { "TIDC" : {"label" : "TIDC", "baseload_growth" : 0.008297872340425672, "baseload_change_mw" : 1.9500000000000455, "baseload_idx2019" : 107.72902932484655, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 7.791668776905542, "within_4h" : 0.43406593406593406593, "closure" : -0.00036244622458275744, "cov_3h" : 0, "dur_p90_ly_h" : 7.390653148908905}, "IID" : {"label" : "IID", "baseload_growth" : 0.03797468354430378, "baseload_change_mw" : 9, "baseload_idx2019" : 102.5, "cov_op" : 0.419921875, "cov_planned" : 0.615234375, "dur_p90_h" : 2.699235894614584, "within_4h" : 0.99724517906336088154, "closure" : -0.02165585869970649, "cov_3h" : 0.419921875, "dur_p90_ly_h" : 3.259531772575248}, "TPWR" : {"label" : "TPWR", "baseload_growth" : 0.005681818181818121, "baseload_change_mw" : 2, "baseload_idx2019" : 92.67015706806282, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 9.863639146851717, "within_4h" : 0.09589041095890410959, "closure" : 0, "cov_3h" : 0, "dur_p90_ly_h" : 9.46743359521137}, "HST" : {"label" : "HST", "baseload_growth" : 0, "baseload_change_mw" : 0, "baseload_idx2019" : 107.6923076923077, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 5.8951401869158895, "within_4h" : 0.35342465753424657534, "closure" : 0.00017966968419597817, "cov_3h" : 0, "dur_p90_ly_h" : 5.770833333333327}, "DUK" : {"label" : "DUK", "baseload_growth" : 0.005893060295790509, "baseload_change_mw" : 51.79999999999927, "baseload_idx2019" : 102.12348188658976, "cov_op" : 0.021610922604621104, "cov_planned" : 0.1427128776862175, "dur_p90_h" : 6.723603239265688, "within_4h" : 0.32602739726027397260, "closure" : 0.00021143773313985249, "cov_3h" : 0.018227037339874626, "dur_p90_ly_h" : 6.878865990247034}, "PSEI" : {"label" : "PSEI", "baseload_growth" : 0.005620692286172124, "baseload_change_mw" : 11.049999999999955, "baseload_idx2019" : 83.2947124499684, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 8.494817970051049, "within_4h" : 0.19178082191780821918, "closure" : 0, "cov_3h" : 0.004655493482309124, "dur_p90_ly_h" : 8.591140970863853}, "SC" : {"label" : "SC", "baseload_growth" : 0.027956989247311936, "baseload_change_mw" : 65, "baseload_idx2019" : 138.71154962275102, "cov_op" : 0, "cov_planned" : 0.38684719535783363, "dur_p90_h" : 6.838927901621443, "within_4h" : 0.38356164383561643836, "closure" : 0.00360234468032904, "cov_3h" : 0, "dur_p90_ly_h" : 7.2681255346323645}, "SRP" : {"label" : "SRP", "baseload_growth" : -0.05037783375314864, "baseload_change_mw" : -140, "baseload_idx2019" : 115.59351730179588, "cov_op" : 1.4843330349149508, "cov_planned" : 1.9767233661593555, "dur_p90_h" : 5.6788545233900765, "within_4h" : 0.64109589041095890411, "closure" : -0.023589851796572782, "cov_3h" : 1.360801050558109, "dur_p90_ly_h" : 5.030255877078523}, "ERCO" : {"label" : "ERCOT", "baseload_growth" : 0.04844807208175017, "baseload_change_mw" : 2002.4000000000015, "baseload_idx2019" : 138.89307349594537, "cov_op" : 0.7704698272193373, "cov_planned" : 1.8160524299940417, "dur_p90_h" : 3.480546734462128, "within_4h" : 0.94230769230769230769, "closure" : -5.262390940210802e-06, "cov_3h" : 0.7704698272193373, "dur_p90_ly_h" : 3.7462288427566186}, "FPC" : {"label" : "FPC", "baseload_growth" : 0.007036766496225866, "baseload_change_mw" : 28.899999999999636, "baseload_idx2019" : 110.9444995842163, "cov_op" : 0.023129879942909916, "cov_planned" : 0.06510788346906221, "dur_p90_h" : 4.871345080531376, "within_4h" : 0.71780821917808219178, "closure" : 0, "cov_3h" : 0.023129879942909916, "dur_p90_ly_h" : 5.210256748589619}, "JEA" : {"label" : "JEA", "baseload_growth" : -0.014464802314368419, "baseload_change_mw" : -15, "baseload_idx2019" : 104.29635677109908, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 6.565205692304772, "within_4h" : 0.40273972602739726027, "closure" : -0.0008214121044042364, "cov_3h" : 0.01278772378516624, "dur_p90_ly_h" : 6.20751894420122}, "SCEG" : {"label" : "SCEG", "baseload_growth" : 0.0005042864346949116, "baseload_change_mw" : 1, "baseload_idx2019" : 98.21782178217822, "cov_op" : 0.0881057268722467, "cov_planned" : 0.18237885462555067, "dur_p90_h" : 5.922225680578048, "within_4h" : 0.62359550561797752809, "closure" : 0.00031105138277801264, "cov_3h" : 0.08866371120962635, "dur_p90_ly_h" : 5.708767530382241}, "SPA" : {"label" : "SPA", "baseload_growth" : 0.19999999999999996, "baseload_change_mw" : 2, "baseload_idx2019" : 22.22222222222222, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 2.7035714285714287, "within_4h" : 0.98356164383561643836, "closure" : -0.004966234474431196, "cov_3h" : 0, "dur_p90_ly_h" : 2.8684684684684663}, "SCL" : {"label" : "SCL", "baseload_growth" : 0.01040312093628093, "baseload_change_mw" : 8, "baseload_idx2019" : 103.18725099601593, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 10.380644771526065, "within_4h" : 0.07397260273972602740, "closure" : -3.086398401884193e-06, "cov_3h" : 0, "dur_p90_ly_h" : 10.042991176588016}, "FMPP" : {"label" : "FMPP", "baseload_growth" : 0.00652510905223691, "baseload_change_mw" : 9.049999999999955, "baseload_idx2019" : 109.31871574001566, "cov_op" : 0, "cov_planned" : 0, "dur_p90_h" : 6.249847309771017, "within_4h" : 0.46301369863013698630, "closure" : 0.0011018887991612225, "cov_3h" : 0, "dur_p90_ly_h" : 6.139021027730044}, "ISNE" : {"label" : "ISO-NE", "baseload_growth" : 0.016134219836654307, "baseload_change_mw" : 153, "baseload_idx2019" : 96.36961880997505, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 5.539627546836385, "within_4h" : 0.67945205479452054795, "closure" : 0, "cov_3h" : 0.22152118294956855, "dur_p90_ly_h" : 5.1282373722103305}, "LDWP" : {"label" : "LDWP", "baseload_growth" : 0.03600000000000003, "baseload_change_mw" : 72, "baseload_idx2019" : 92.42160667291137, "cov_op" : 0.3300480371045221, "cov_planned" : 0.45428192810998835, "dur_p90_h" : 5.007699180201438, "within_4h" : 0.73698630136986301370, "closure" : 6.928650290198209e-05, "cov_3h" : 0.33136537502078817, "dur_p90_ly_h" : 4.740278331624243}, "LGEE" : {"label" : "LGEE", "baseload_growth" : 0.011235955056179803, "baseload_change_mw" : 35, "baseload_idx2019" : 110.91744572967835, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 10.17377782714247, "within_4h" : 0.14246575342465753425, "closure" : 0.0021638761176613975, "cov_3h" : 0, "dur_p90_ly_h" : 10.378438949082527}, "MISO" : {"label" : "MISO", "baseload_growth" : 0.02553524324952372, "baseload_change_mw" : 1516.449999999997, "baseload_idx2019" : 103.4677838598208, "cov_op" : 0.06613462014352882, "cov_planned" : 0.2749713167307822, "dur_p90_h" : 5.60950321405179, "within_4h" : 0.68493150684931506849, "closure" : 0.024016418510867752, "cov_3h" : 0.06615843366715426, "dur_p90_ly_h" : 6.875678902040707}, "NEVP" : {"label" : "NEVP", "baseload_growth" : 0.05001471020888504, "baseload_change_mw" : 170, "baseload_idx2019" : 111.04542626011201, "cov_op" : 0.5030807551127425, "cov_planned" : 1.1021893025694809, "dur_p90_h" : 5.2263566578709435, "within_4h" : 0.71506849315068493151, "closure" : -0.0017076640388694574, "cov_3h" : 0.5030807551127425, "dur_p90_ly_h" : 5.312923659573003}, "CPLW" : {"label" : "CPLW", "baseload_growth" : 0.06358381502890165, "baseload_change_mw" : 22, "baseload_idx2019" : 91.08910891089108, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 6.614919731433063, "within_4h" : 0.44931506849315068493, "closure" : 0.0004755741219326756, "cov_3h" : 0.10336726703210651, "dur_p90_ly_h" : 7.438309516570377}, "AVA" : {"label" : "AVA", "baseload_growth" : -0.01994560290117864, "baseload_change_mw" : -22, "baseload_idx2019" : 99.2653810835629, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 9.573397629093826, "within_4h" : 0.16986301369863013699, "closure" : 1.7085295967158266e-05, "cov_3h" : 0, "dur_p90_ly_h" : 9.333159805906355}, "NWMT" : {"label" : "NWMT", "baseload_growth" : 0.014044943820224809, "baseload_change_mw" : 15, "baseload_idx2019" : 93.04123711340206, "cov_op" : 0, "cov_planned" : 0.4434983147064041, "dur_p90_h" : 4.56035741253461, "within_4h" : 0.84109589041095890411, "closure" : -0.00012247004989747668, "cov_3h" : 0, "dur_p90_ly_h" : 4.690621210283778}, "NYIS" : {"label" : "NYISO", "baseload_growth" : -0.01132765993285334, "baseload_change_mw" : -147.95000000000073, "baseload_idx2019" : 98.31771858427966, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 7.356965799903494, "within_4h" : 0.31232876712328767123, "closure" : 0.0008344493463012395, "cov_3h" : 0.08165070456902336, "dur_p90_ly_h" : 7.14477031050642}, "PACE" : {"label" : "PACE", "baseload_growth" : -0.008689947517157903, "baseload_change_mw" : -43.05000000000018, "baseload_idx2019" : 108.00536623450886, "cov_op" : 0.2754655967423199, "cov_planned" : 0.48685550032936104, "dur_p90_h" : 4.5383233607703675, "within_4h" : 0.84383561643835616438, "closure" : 3.3560285384734365e-06, "cov_3h" : 0.2754655967423199, "dur_p90_ly_h" : 4.573914346996141}, "PGE" : {"label" : "PGE", "baseload_growth" : 0.03190898330877556, "baseload_change_mw" : 66.05000000000018, "baseload_idx2019" : 127.5223880597015, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 10.193170091002905, "within_4h" : 0.13150684931506849315, "closure" : -0.0003781581991900395, "cov_3h" : 1.0965302491103202, "dur_p90_ly_h" : 9.598436947928707}, "PJM" : {"label" : "PJM", "baseload_growth" : 0.04810387123137283, "baseload_change_mw" : 3496.300000000003, "baseload_idx2019" : 110.4635625226119, "cov_op" : 0.03909486424799447, "cov_planned" : 0.08370914742722872, "dur_p90_h" : 7.88100691874084, "within_4h" : 0.23835616438356164384, "closure" : 0.017843700358739716, "cov_3h" : 0.03868440332532077, "dur_p90_ly_h" : 8.117445882600647}, "PNM" : {"label" : "PNM", "baseload_growth" : 0.004384672070744333, "baseload_change_mw" : 5.9500000000000455, "baseload_idx2019" : 107.06598586017282, "cov_op" : 0.5518702211200662, "cov_planned" : 0.9641454846042572, "dur_p90_h" : 3.2321638335299534, "within_4h" : 0.96438356164383561644, "closure" : -0.005405045513764477, "cov_3h" : 0.5518702211200662, "dur_p90_ly_h" : 3.162673591104761}, "DOPD" : {"label" : "DOPD", "baseload_growth" : 0.04739336492891, "baseload_change_mw" : 10, "baseload_idx2019" : 152.41379310344828, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 10.441256880604783, "within_4h" : 0.27397260273972602740, "closure" : 0, "cov_3h" : 6.25, "dur_p90_ly_h" : 9.45756332295117}, "PSCO" : {"label" : "PSCO", "baseload_growth" : 0.01664143325763301, "baseload_change_mw" : 65.94999999999982, "baseload_idx2019" : 95.51801801801801, "cov_op" : 0.27373325567850904, "cov_planned" : 0.8070762958648806, "dur_p90_h" : 3.8319455480971425, "within_4h" : 0.91780821917808219178, "closure" : 5.3753648560816284e-05, "cov_3h" : 0.26772999145542575, "dur_p90_ly_h" : 4.08759443475883}, "SOCO" : {"label" : "SOCO", "baseload_growth" : -0.004123998537447937, "baseload_change_mw" : -82.89999999999782, "baseload_idx2019" : 100.9121383203952, "cov_op" : 0.13416976456009913, "cov_planned" : 0.24778500619578686, "dur_p90_h" : 6.655822389197834, "within_4h" : 0.35342465753424657534, "closure" : 0, "cov_3h" : 0.13487169905331342, "dur_p90_ly_h" : 6.618151382042465}, "FPL" : {"label" : "FPL", "baseload_growth" : 0.027548793355142376, "baseload_change_mw" : 285.89999999999964, "baseload_idx2019" : 119.96748772352188, "cov_op" : 0.18932890415049267, "cov_planned" : 0.18932890415049267, "dur_p90_h" : 5.211830469314603, "within_4h" : 0.67945205479452054795, "closure" : 1.2706921146801846e-05, "cov_3h" : 0.18932890415049267, "dur_p90_ly_h" : 4.705921405405875}, "IPCO" : {"label" : "IPCO", "baseload_growth" : -0.016811684275947814, "baseload_change_mw" : -27.049999999999955, "baseload_idx2019" : 114.30274566473989, "cov_op" : 0.7387892376681615, "cov_planned" : 0.8508968609865472, "dur_p90_h" : 5.100234228906915, "within_4h" : 0.77260273972602739726, "closure" : -0.00030616152419311047, "cov_3h" : 0.7404494382022472, "dur_p90_ly_h" : 5.321348322199809}, "TVA" : {"label" : "TVA", "baseload_growth" : 0.012702969797084407, "baseload_change_mw" : 176.10000000000036, "baseload_idx2019" : 106.46171579370436, "cov_op" : 0.04556427278213639, "cov_planned" : 0.12703681351840676, "dur_p90_h" : 8.199297571355869, "within_4h" : 0.15068493150684931507, "closure" : -0.003727379784779876, "cov_3h" : 0.042810161034248125, "dur_p90_ly_h" : 8.506550101870722}, "WALC" : {"label" : "WALC", "baseload_growth" : -0.03220338983050852, "baseload_change_mw" : -19, "baseload_idx2019" : 83.23615160349854, "cov_op" : 0.7211946377057527, "cov_planned" : 1.7186492448667912, "dur_p90_h" : 4.040015046735804, "within_4h" : 0.89807162534435261708, "closure" : -4.16109303041909e-05, "cov_3h" : 0.7200948830904778, "dur_p90_ly_h" : 4.132623994930685}, "GVL" : {"label" : "GVL", "baseload_growth" : 0.019230769230769162, "baseload_change_mw" : 3, "baseload_idx2019" : null, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 5.51374420242344, "within_4h" : 0.56868131868131868132, "closure" : -0.031526406923950596, "cov_3h" : 0, "dur_p90_ly_h" : 5.58146351324035}, "SWPP" : {"label" : "SPP", "baseload_growth" : 0.05376644611757553, "baseload_change_mw" : 1413.9500000000007, "baseload_idx2019" : 116.70695602002955, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 15.038865206288838, "within_4h" : 0.39178082191780821918, "closure" : 0.00022128413979782228, "cov_3h" : 0.07377543330821401, "dur_p90_ly_h" : 7.350173804824343}, "TAL" : {"label" : "TAL", "baseload_growth" : -0.0046511627906976605, "baseload_change_mw" : -1, "baseload_idx2019" : 103.88349514563107, "cov_op" : 0, "cov_planned" : 0, "dur_p90_h" : 5.365966010733452, "within_4h" : 0.61369863013698630137, "closure" : 0.0017051254610115911, "cov_3h" : 0, "dur_p90_ly_h" : 5.255473516620896}, "TEC" : {"label" : "TEC", "baseload_growth" : 0.0011977886977887486, "baseload_change_mw" : 1.9500000000000455, "baseload_idx2019" : 106.04749512036435, "cov_op" : 0.1238594447680062, "cov_planned" : 0.1432731508444962, "dur_p90_h" : 3.941192561062734, "within_4h" : 0.91506849315068493151, "closure" : 6.1369688736941095e-06, "cov_3h" : 0.12347590478033674, "dur_p90_ly_h" : 4.53975151972312}, "TEPC" : {"label" : "TEPC", "baseload_growth" : 0.06800829692395949, "baseload_change_mw" : 77.04999999999995, "baseload_idx2019" : 165.30054644808743, "cov_op" : 0.7209714141158613, "cov_planned" : 0.8320263091323045, "dur_p90_h" : 4.681490517266416, "within_4h" : 0.81917808219178082192, "closure" : 0, "cov_3h" : 0.719515273920727, "dur_p90_ly_h" : 4.414050309504618}, "CHPD" : {"label" : "CHPD", "baseload_growth" : 0.027397260273972712, "baseload_change_mw" : 4, "baseload_idx2019" : 121.95121951219512, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 8.554454694806013, "within_4h" : 0.42465753424657534247, "closure" : 0, "cov_3h" : 0, "dur_p90_ly_h" : 8.21354148697601}, "CISO" : {"label" : "CAISO", "baseload_growth" : 0.0132549346950801, "baseload_change_mw" : 269.9500000000007, "baseload_idx2019" : 111.93320658821487, "cov_op" : 0.9899798438477397, "cov_planned" : 1.6225586729374692, "dur_p90_h" : 4.092507476414128, "within_4h" : 0.87671232876712328767, "closure" : -0.1029634093831991, "cov_3h" : 0.9899798438477397, "dur_p90_ly_h" : 4.355155401296056}, "CPLE" : {"label" : "CPLE", "baseload_growth" : 0.002601456815816805, "baseload_change_mw" : 12.5, "baseload_idx2019" : 100.49229228811616, "cov_op" : 0.07257142857142856, "cov_planned" : 0.11923809523809524, "dur_p90_h" : 5.515471714350533, "within_4h" : 0.72328767123287671233, "closure" : 3.101888555379001e-08, "cov_3h" : 0.07346938775510208, "dur_p90_ly_h" : 5.425262870600306}, "GCPD" : {"label" : "GCPD", "baseload_growth" : -0.003012048192771122, "baseload_change_mw" : -2, "baseload_idx2019" : 131.87250996015936, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 16.45554870631104, "within_4h" : 0.02191780821917808219, "closure" : -0.001832908105336243, "cov_3h" : 0, "dur_p90_ly_h" : 15.780577117920654}, "PACW" : {"label" : "PACW", "baseload_growth" : 0.23091378905332394, "baseload_change_mw" : 344.04999999999995, "baseload_idx2019" : 110.61519903498191, "cov_op" : 0, "cov_planned" : 0.0032221685194135643, "dur_p90_h" : 5.511099331540352, "within_4h" : 0.70684931506849315068, "closure" : 0, "cov_3h" : 0, "dur_p90_ly_h" : 5.977419209977941}, "AZPS" : {"label" : "AZPS", "baseload_growth" : 0.12265678842177796, "baseload_change_mw" : 355.9499999999998, "baseload_idx2019" : 138.8163360957839, "cov_op" : 1.267049075844487, "cov_planned" : 3.1946303377947736, "dur_p90_h" : 5.345555163632949, "within_4h" : 0.74520547945205479452, "closure" : -0.00022552358935200168, "cov_3h" : 1.2671500278862244, "dur_p90_ly_h" : 4.853224636755991}, "BANC" : {"label" : "BANC", "baseload_growth" : -0.0026490066225165476, "baseload_change_mw" : -4, "baseload_idx2019" : 107.41797432239657, "cov_op" : 0, "cov_planned" : 0.6122967479674797, "dur_p90_h" : 5.526028087139194, "within_4h" : 0.72328767123287671233, "closure" : -0.0008412319808519199, "cov_3h" : 0, "dur_p90_ly_h" : 5.624522406913929}, "EPE" : {"label" : "EPE", "baseload_growth" : -0.0014224751066855834, "baseload_change_mw" : -1, "baseload_idx2019" : 105.24737631184408, "cov_op" : 0.423841059602649, "cov_planned" : 1.2516556291390728, "dur_p90_h" : 4.8677776094531096, "within_4h" : 0.75616438356164383562, "closure" : 0.0005374674395519914, "cov_3h" : 0.423841059602649, "dur_p90_ly_h" : 4.717978402108769}, "BPAT" : {"label" : "BPA", "baseload_growth" : 0.07778051652835982, "baseload_change_mw" : 426, "baseload_idx2019" : 120.41921664626683, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 8.454034406695905, "within_4h" : 0.26575342465753424658, "closure" : -0.0003499125836089659, "cov_3h" : 0.03191701575902653, "dur_p90_ly_h" : 8.719850780653646}, "AECI" : {"label" : "AECI", "baseload_growth" : 0.029488124619378775, "baseload_change_mw" : 46, "baseload_idx2019" : 88.38470005503578, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 5.563087828083212, "within_4h" : 0.59726027397260273973, "closure" : 1.5197442038073526e-06, "cov_3h" : 0, "dur_p90_ly_h" : 6.147861648668879}, "SEC" : {"label" : "SEC", "baseload_growth" : -0.038461538461538436, "baseload_change_mw" : -5, "baseload_idx2019" : null, "cov_op" : null, "cov_planned" : null, "dur_p90_h" : 4.660864817645771, "within_4h" : 0.83561643835616438356, "closure" : 0.005507004139549195, "cov_3h" : 0, "dur_p90_ly_h" : 4.578547034315057} }, "fastest" : [{"label" : "AZPS", "growth" : 0.12265678842177796}, {"label" : "BPA", "growth" : 0.07778051652835982}], "covered" : [{"label" : "SRP", "cov" : 1.4843330349149508, "energy" : 2.348078192726124}, {"label" : "AZPS", "cov" : 1.267049075844487, "energy" : 4.530676578526394}], "within4_big" : ["ERCOT", "PSCO"], "n_big" : 20, "peak_added_min_mw" : -15, "peak_change_min_mw" : -15, "serves_neg_min_mw" : -3934.5652173913045, "dq" : {"n_close_within_1pct" : 48, "n_with_closure" : 55, "n_within_2pct_all_months" : 42}, "zones_idx_max" : 152.8711442486922, "signal_flagged_n" : 15, "zones" : { "AEP" : {"name" : "American Electric Power zone", "idx2019" : 122.88452358435335, "yoy" : 0.12824732378759007, "change_mw" : 1550.8500000000004}, "DOM" : {"name" : "Dominion Virginia Power zone", "idx2019" : 152.8711442486922, "yoy" : 0.09228389757690314, "change_mw" : 1074} }, "z12_crit_baseload" : 1.0230367646704002, "status" : {"n" : 51, "hours" : {"Within 4 h" : 7, "4–10 h" : 38, "Over 10 h" : 6}, "cover" : {"Covers the ramp" : 4, "Half or more" : 7, "Under half" : 40}, "cover_basis" : {"Evening ramp" : 30, "3-hour rise" : 21}, "baseload" : {"Proven rise" : 5, "Rising" : 22, "Flat or falling" : 24}, "closure" : {"Within ±2%" : 46, "Outside ±2%" : 5}, "row" : {"Good" : 2, "Watch" : 38, "Act" : 11}}, "status_screen" : {"n" : 12, "hours" : {"Within 4 h" : 1, "4–10 h" : 10, "Over 10 h" : 1}, "cover" : {"Covers the ramp" : 1, "Half or more" : 3, "Under half" : 8}, "cover_basis" : {"Evening ramp" : 9, "3-hour rise" : 3}, "baseload" : {"Proven rise" : 5, "Rising" : 7, "Flat or falling" : 0}, "closure" : {"Within ±2%" : 10, "Outside ±2%" : 2}, "row" : {"Good" : 0, "Watch" : 9, "Act" : 3}}, "signal_status" : {"month" : "Sep 2026", "window" : "Oct 2025–Sep 2026", "n" : 51, "baseload" : {"Proven" : 5, "Watch" : 3, "No proven rise" : 39, "Not yet testable" : 4}, "rise3h" : {"Proven" : 7, "Watch" : 2, "No proven rise" : 39, "Not yet testable" : 3}, "evening" : {"Proven" : 0, "Watch" : 3, "No proven rise" : 4, "Not yet testable" : 44}}, "operator_ramp" : {"quarter" : "Jul–Sep 2026", "prior" : "Jul–Sep 2025", "n" : 51, "n_duck" : 22, "n_lfl" : 26, "duck_p50_up" : 15, "duck_p50_down" : 7, "duck_p90_up" : 18, "duck_p90_down" : 4}, "glossary_rows" : 32} diff --git a/examples/sundown/palettes/blurple.json b/examples/sundown/palettes/blurple.json new file mode 100644 index 0000000..5b6d7fe --- /dev/null +++ b/examples/sundown/palettes/blurple.json @@ -0,0 +1,75 @@ +{ + "about": "Deep navy chrome, a blurple accent, cyan and coral highlights.", + "masthead": "#0A2540", + "accent": "#635BFF", + "on_masthead": "#ADBDCC", + "ground": "#F6F9FC", + "tile": "#FFFFFF", + "border": "#E3E8EE", + "rule": "#D5DBE1", + "grid": "#EDF1F5", + "head_bg": "#F0F3F7", + "ref": "#8792A2", + "ink": "#0A2540", + "sec": "#425466", + "this": "#635BFF", + "prior": "#C1BDFF", + "second": "#0096C7", + "up": "#E86B57", + "down": "#0096C7", + "total": "#8792A2", + "cellbar": "rgba(99,91,255,0.18)", + "heatmap": "schemePuBu", + "status": { + "good": [ + "#0B6B3A", + "#CBF4D9", + "#EBFBF0", + "#1EA672" + ], + "watch": [ + "#8A4B00", + "#FCE3C3", + "#FFF4E6", + "#E5850B" + ], + "proven": [ + "#683800", + "#F9C98A", + "#FFF4E6", + "#C4751A" + ], + "act": [ + "#A41C3A", + "#FFD3DB", + "#FFEEF1", + "#DF1B41" + ] + }, + "proven_rise": "#9A4A00", + "rising": "#D97E0A", + "region": { + "California": "#635BFF", + "Texas": "#F5A623", + "Southwest": "#1EA672", + "Other regions": "#C1C9D2", + "Northwest": "#00B2E3", + "Florida": "#FF5996" + }, + "fuel": { + "Solar": "#FFBB00", + "Wind": "#00B2E3", + "Gas": "#FF7A45", + "Coal": "#425466", + "Nuclear": "#A960EE", + "Hydro": "#0073E6", + "Battery": "#635BFF", + "Battery charging": "#C1BDFF", + "Net imports": "#ADBDCC", + "Exports": "#E3E8EE", + "Other": "#C8B8A6", + "Coal, nuclear, hydro": "#425466" + }, + "battery_prior": "#C1BDFF", + "name": "Blurple" +} diff --git a/examples/sundown/palettes/coral-teal-v2.json b/examples/sundown/palettes/coral-teal-v2.json new file mode 100644 index 0000000..3b410d0 --- /dev/null +++ b/examples/sundown/palettes/coral-teal-v2.json @@ -0,0 +1,75 @@ +{ + "name": "Coral teal v2", + "about": "A deep teal shell and a coral accent; fuels in gold solar, teal wind, soft coral gas, slate coal, berry nuclear, ocean hydro and violet battery.", + "masthead": "#06474D", + "accent": "#FF385C", + "on_masthead": "#B9DCDD", + "ground": "#F7F7F7", + "tile": "#FFFFFF", + "border": "#EBEBEB", + "rule": "#DDDDDD", + "grid": "#EFEFEF", + "head_bg": "#F0F0F0", + "ref": "#8C8C8C", + "ink": "#222222", + "sec": "#6A6A6A", + "this": "#008489", + "prior": "#9FD8D6", + "second": "#1A73E8", + "up": "#FF385C", + "down": "#008489", + "total": "#717171", + "cellbar": "rgba(0, 132, 137, 0.20)", + "heatmap": "schemeYlGnBu", + "status": { + "good": [ + "#137333", + "#CEEAD6", + "#E6F4EA", + "#1E8E3E" + ], + "watch": [ + "#995000", + "#FEEFC3", + "#FEF7E0", + "#E8710A" + ], + "proven": [ + "#8C3F00", + "#FDD663", + "#FEF7E0", + "#B05A00" + ], + "act": [ + "#A50E0E", + "#FAD2CF", + "#FCE8E6", + "#D93025" + ] + }, + "proven_rise": "#8C3F00", + "rising": "#E8710A", + "region": { + "California": "#1A73E8", + "Texas": "#F9AB00", + "Southwest": "#00A699", + "Other regions": "#BDC1C6", + "Northwest": "#A142F4", + "Florida": "#E52592" + }, + "fuel": { + "Solar": "#FFB400", + "Wind": "#00A699", + "Gas": "#FF8E7A", + "Coal": "#2F4B4F", + "Nuclear": "#C2417A", + "Hydro": "#2E86C1", + "Battery": "#7B5CE0", + "Battery charging": "#D3C6F5", + "Net imports": "#B8C7C9", + "Exports": "#E3EBEC", + "Other": "#C9BFB8", + "Coal, nuclear, hydro": "#2F4B4F" + }, + "battery_prior": "#D3C6F5" +} diff --git a/examples/sundown/palettes/desert-dusk.json b/examples/sundown/palettes/desert-dusk.json new file mode 100644 index 0000000..e421e8f --- /dev/null +++ b/examples/sundown/palettes/desert-dusk.json @@ -0,0 +1,75 @@ +{ + "about": "Desert dusk: plum-brown chrome, terracotta, sage, sand and dusk blue.", + "masthead": "#3B1F2B", + "accent": "#D4735A", + "on_masthead": "#E2CFC9", + "ground": "#F7F3EE", + "tile": "#FFFDFB", + "border": "#ECE4DA", + "rule": "#E0D5C8", + "grid": "#F0E9E0", + "head_bg": "#F1EAE1", + "ref": "#A08F80", + "ink": "#2E2420", + "sec": "#6B5A50", + "this": "#3D5A80", + "prior": "#A9BCD3", + "second": "#D4735A", + "up": "#C8553D", + "down": "#3D5A80", + "total": "#8D7B6E", + "cellbar": "rgba(224,122,95,0.22)", + "heatmap": "schemeOrRd", + "status": { + "good": [ + "#3F5F2A", + "#D9E5C8", + "#EEF3E6", + "#6A8D4E" + ], + "watch": [ + "#7E4E0A", + "#F3DDB5", + "#FAF0DF", + "#C99837" + ], + "proven": [ + "#5E3500", + "#E9C07C", + "#FAF0DF", + "#B07A2A" + ], + "act": [ + "#8E2A1E", + "#F2CEC6", + "#F9EAE6", + "#C8553D" + ] + }, + "proven_rise": "#8C4A1E", + "rising": "#C58337", + "region": { + "California": "#3D5A80", + "Texas": "#E07A5F", + "Southwest": "#81B29A", + "Other regions": "#CBBFB3", + "Northwest": "#98C1D9", + "Florida": "#B56576" + }, + "fuel": { + "Solar": "#F2CC8F", + "Wind": "#81B29A", + "Gas": "#E07A5F", + "Coal": "#5C4B43", + "Nuclear": "#B56576", + "Hydro": "#3D5A80", + "Battery": "#6D597A", + "Battery charging": "#C5B8CE", + "Net imports": "#CBBFB3", + "Exports": "#E8DFD5", + "Other": "#B3A394", + "Coal, nuclear, hydro": "#5C4B43" + }, + "battery_prior": "#C5B8CE", + "name": "Desert Dusk" +} \ No newline at end of file diff --git a/examples/sundown/palettes/nord.json b/examples/sundown/palettes/nord.json new file mode 100644 index 0000000..8f14473 --- /dev/null +++ b/examples/sundown/palettes/nord.json @@ -0,0 +1,75 @@ +{ + "about": "After the Nord theme: arctic slate chrome, frost blue, aurora accents.", + "masthead": "#2E3440", + "accent": "#5E81AC", + "on_masthead": "#D8DEE9", + "ground": "#ECEFF4", + "tile": "#FFFFFF", + "border": "#E5E9F0", + "rule": "#D8DEE9", + "grid": "#ECEFF4", + "head_bg": "#E5E9F0", + "ref": "#7B88A1", + "ink": "#2E3440", + "sec": "#4C566A", + "this": "#5E81AC", + "prior": "#B8C7DD", + "second": "#BF616A", + "up": "#BF616A", + "down": "#5E81AC", + "total": "#4C566A", + "cellbar": "rgba(94,129,172,0.22)", + "heatmap": "schemePuBu", + "status": { + "good": [ + "#3B6B2E", + "#DCEBD0", + "#EEF5E8", + "#7FA067" + ], + "watch": [ + "#8A5A00", + "#F5E3B8", + "#FBF3E0", + "#D08770" + ], + "proven": [ + "#6B3A00", + "#EBCB8B", + "#FBF3E0", + "#B5683F" + ], + "act": [ + "#8C2B33", + "#F2D0D3", + "#FAECED", + "#BF616A" + ] + }, + "proven_rise": "#8F4B2E", + "rising": "#C5806A", + "region": { + "California": "#5E81AC", + "Texas": "#D08770", + "Southwest": "#A3BE8C", + "Other regions": "#C2C8D3", + "Northwest": "#88C0D0", + "Florida": "#B48EAD" + }, + "fuel": { + "Solar": "#EBCB8B", + "Wind": "#8FBCBB", + "Gas": "#D08770", + "Coal": "#4C566A", + "Nuclear": "#B48EAD", + "Hydro": "#81A1C1", + "Battery": "#5E81AC", + "Battery charging": "#B8C7DD", + "Net imports": "#D8DEE9", + "Exports": "#E5E9F0", + "Other": "#A7AEBB", + "Coal, nuclear, hydro": "#4C566A" + }, + "battery_prior": "#B8C7DD", + "name": "Nord" +} diff --git a/examples/sundown/palettes/robins-egg.json b/examples/sundown/palettes/robins-egg.json new file mode 100644 index 0000000..2185a0b --- /dev/null +++ b/examples/sundown/palettes/robins-egg.json @@ -0,0 +1,75 @@ +{ + "about": "A black shell, robin's-egg blue, rose gold and champagne, on a mint-white ground.", + "masthead": "#0A0A0A", + "accent": "#81D8D0", + "on_masthead": "#CFE9E6", + "ground": "#F3F8F7", + "tile": "#FFFFFF", + "border": "#E1ECEA", + "rule": "#D0E0DD", + "grid": "#EAF2F0", + "head_bg": "#E8F3F1", + "ref": "#8AA3A0", + "ink": "#111111", + "sec": "#4F5B5A", + "this": "#0A8A80", + "prior": "#81D8D0", + "second": "#B76E79", + "up": "#B76E79", + "down": "#0A8A80", + "total": "#6E6E6E", + "cellbar": "rgba(129,216,208,0.45)", + "heatmap": "schemeBuGn", + "status": { + "good": [ + "#1D6B4C", + "#C8EBDD", + "#E8F6F0", + "#2E9E72" + ], + "watch": [ + "#7A5410", + "#F6E2B9", + "#FCF5E6", + "#D19A3A" + ], + "proven": [ + "#5B3A0C", + "#EBC687", + "#FCF5E6", + "#A8742A" + ], + "act": [ + "#8E2533", + "#F2CFD4", + "#FAEDEF", + "#C2414F" + ] + }, + "proven_rise": "#7E4E1E", + "rising": "#BE833A", + "region": { + "California": "#0A8A80", + "Texas": "#B76E79", + "Southwest": "#81D8D0", + "Other regions": "#C9D3D1", + "Northwest": "#3E6E8E", + "Florida": "#E3A6AE" + }, + "fuel": { + "Solar": "#E9C46A", + "Wind": "#81D8D0", + "Gas": "#B76E79", + "Coal": "#2B2B2B", + "Nuclear": "#7E5A9B", + "Hydro": "#3E6E8E", + "Battery": "#0A8A80", + "Battery charging": "#A9E3DD", + "Net imports": "#C9D3D1", + "Exports": "#E1ECEA", + "Other": "#B5B5B5", + "Coal, nuclear, hydro": "#2B2B2B" + }, + "battery_prior": "#A9E3DD", + "name": "Robins Egg" +} diff --git a/examples/sundown/palettes/sunset.json b/examples/sundown/palettes/sunset.json new file mode 100644 index 0000000..ca69a71 --- /dev/null +++ b/examples/sundown/palettes/sunset.json @@ -0,0 +1,60 @@ +{ + "name": "Sunset", + "about": "Navy chrome, an Okabe-Ito vermillion accent and a blue period pair; status colours after the Carbon and Atlassian design systems; EIA fuel colours.", + "masthead": "#14213D", + "accent": "#D55E00", + "on_masthead": "#C5CCD9", + "ground": "#F3F4F7", + "tile": "#FFFFFF", + "border": "#E5E8EE", + "rule": "#D9DEE6", + "grid": "#EEF1F5", + "head_bg": "#EEF1F5", + "ref": "#9AA3AE", + "ink": "#1B1F27", + "sec": "#5B6472", + "this": "#0072B2", + "prior": "#8CC0DC", + "second": "#E69F00", + "up": "#D55E00", + "down": "#0072B2", + "total": "#6B7280", + "cellbar": "rgba(213, 94, 0, 0.26)", + "heatmap": "schemeOranges", + "status": { + "good": [ + "#0E6027", + "#A7F0BA", + "#DEFBE6", + "#24A148" + ], + "watch": [ + "#9E4C00", + "#FCE4A6", + "#FFF5DB", + "#BD5B00" + ], + "proven": [ + "#693200", + "#FBD779", + "#FFF5DB", + "#BD5B00" + ], + "act": [ + "#A2191F", + "#FFD7D9", + "#FFF1F1", + "#DA1E28" + ] + }, + "proven_rise": "#7A3A00", + "rising": "#C7701F", + "region": { + "California": "#0072B2", + "Texas": "#E69F00", + "Southwest": "#009E73", + "Other regions": "#BAB0AC", + "Northwest": "#56B4E9", + "Florida": "#CC79A7" + } +} diff --git a/examples/sundown/palettes/synth-sunset.json b/examples/sundown/palettes/synth-sunset.json new file mode 100644 index 0000000..f5af888 --- /dev/null +++ b/examples/sundown/palettes/synth-sunset.json @@ -0,0 +1,75 @@ +{ + "about": "Synthwave sunset, on theme for Sundown: deep violet shell, hot-pink accent, electric violet and orange.", + "masthead": "#2B1055", + "accent": "#FF2E88", + "on_masthead": "#D9C8F5", + "ground": "#F7F4FC", + "tile": "#FFFFFF", + "border": "#ECE6F7", + "rule": "#DDD3F0", + "grid": "#F0EBF8", + "head_bg": "#F1ECFA", + "ref": "#9A8FB5", + "ink": "#1E1433", + "sec": "#5B5172", + "this": "#7B2FF7", + "prior": "#CDB4FF", + "second": "#D97500", + "up": "#FF2E88", + "down": "#7B2FF7", + "total": "#8A82A0", + "cellbar": "rgba(255,46,136,0.18)", + "heatmap": "schemeRdPu", + "status": { + "good": [ + "#16674A", + "#C4EEDD", + "#E6F8F0", + "#14B37D" + ], + "watch": [ + "#874500", + "#FFDDB3", + "#FFF3E3", + "#F28300" + ], + "proven": [ + "#5F2E00", + "#FFC27A", + "#FFF3E3", + "#D46A00" + ], + "act": [ + "#A1142F", + "#FFCDD6", + "#FFEDF0", + "#E5294B" + ] + }, + "proven_rise": "#A04E00", + "rising": "#D97500", + "region": { + "California": "#7B2FF7", + "Texas": "#FF8A00", + "Southwest": "#14B37D", + "Other regions": "#CFC8DD", + "Northwest": "#00B4D8", + "Florida": "#FF2E88" + }, + "fuel": { + "Solar": "#FFB400", + "Wind": "#00B4D8", + "Gas": "#FF6B6B", + "Coal": "#2B1055", + "Nuclear": "#FF2E88", + "Hydro": "#3A86FF", + "Battery": "#7B2FF7", + "Battery charging": "#CDB4FF", + "Net imports": "#CFC8DD", + "Exports": "#ECE6F7", + "Other": "#B3A9C9", + "Coal, nuclear, hydro": "#2B1055" + }, + "battery_prior": "#CDB4FF", + "name": "Synth Sunset" +} \ No newline at end of file diff --git a/examples/sundown/sundown.json b/examples/sundown/sundown.json new file mode 100644 index 0000000..f9d307b --- /dev/null +++ b/examples/sundown/sundown.json @@ -0,0 +1,2504 @@ +{ + "spec_version": "1", + "design": { + "ignore": [ + "size.min-width@Shape of the day: ERCOT", + "size.min-width@Shape of the day: CAISO", + "size.min-width@Shape of the day: PJM", + "size.min-width@Shape of the day: MISO", + "size.min-width@Shape of the day: AZPS", + "size.min-width@Shape of the day: NEVP", + "size.min-width@Shape of the day: SOCO", + "size.min-width@Shape of the day: TVA", + "chart.ordinal-order@Net load by hour and month", + "layout.tab-balance", + "filters.time-picker", + "layout.fold-budget@tab-About-row-1" + ] + }, + "dashboard": { + "title": "Sundown", + "slug": "sundown", + "theme": "Sundown", + "css": "/* Sundown. Structural CSS only: Superset's markdown sanitizer drops class and style. Rules on\n elements Superset paints itself say !important, or Superset's own equal-specificity rule wins. */\n/* ground and tiles */\n.dashboard-component-tabs,\n.dashboard-component-tabs .dashboard-component-tabs-content { background-color: #F7F7F7 !important; }\n.dashboard-component-tabs { box-shadow: none !important; }\n.dashboard .dashboard-component-tabs { padding-left: 0 !important; }\n.dashboard-component-chart-holder { border: 1px solid #EBEBEB; border-radius: 2px; }\n.background--white { border: 1px solid #EBEBEB; border-radius: 2px; }\n.background--white .dashboard-component-chart-holder { border: 0; }\n.dashboard-component-chart-holder .header-controls { opacity: 0; transition: opacity .15s; }\n.dashboard-component-chart-holder:hover .header-controls { opacity: 1; }\n/* masthead: a navy band, white wordmark */\n.background--white:has(.dashboard-markdown h2) { background-color: #06474D !important; border-color: #06474D !important; }\n.background--white:has(.dashboard-markdown h2) .dashboard-component-chart-holder { background-color: #06474D !important; }\n.dashboard-markdown h2 { font-size: 26px; font-weight: 700; letter-spacing: -.01em; margin: 0; color: #FFFFFF; }\n.dashboard-markdown h2 + p { font-size: 13.5px; color: #B9DCDD; margin: 4px 0 0; }\n.dashboard-markdown h2 + p code { background: rgba(255,255,255,.12); color: #FFFFFF; }\n[data-test-chart-name=\"Data through\"] { text-align: right; position: relative; }\n[data-test-chart-name=\"Data through\"] .header-title { font-size: 11.5px !important; font-weight: 500 !important;\n color: #B9DCDD !important; justify-content: flex-end; transform: translateX(16px); }\n[data-test-chart-name=\"Data through\"] .header-title a { color: #B9DCDD !important; }\n[data-test-chart-name=\"Data through\"] .superset-legacy-chart-big-number { align-items: flex-end !important; }\n[data-test-chart-name=\"Data through\"] .text-container { align-items: flex-end !important; }\n[data-test-chart-name=\"Data through\"] .header-controls { position: absolute; right: 0; }\n/* filter bar: 'More filters' as an outline button, not the theme's filled primary tint */\n[data-test=\"dropdown-container-btn\"] { background: transparent !important; border: 1px solid #DDDDDD !important;\n color: #6A6A6A !important; box-shadow: none !important; }\n[data-test=\"dropdown-container-btn\"]:hover { border-color: #6A6A6A !important; color: #222222 !important; }\n[data-test=\"dropdown-container-btn\"] .ant-badge-count { background: #F0F0F0 !important; color: #6A6A6A !important; box-shadow: none !important; }\n/* tabs: inactive secondary, active ink on an accent underline */\n.dashboard-component-tabs .ant-tabs-nav::before { border-bottom: 1px solid #DDDDDD; }\n.dashboard-component-tabs .anchor-link-container { display: none; }\n.dashboard-component-tabs .ant-tabs-tab { font-size: 14.5px; padding: 10px 0; color: #6A6A6A; }\n.dashboard-component-tabs .ant-tabs-tab-btn, .dashboard-component-tabs .dragdroppable-tab { padding: 0 !important; margin: 0 !important; }\n.dashboard-component-tabs .ant-tabs-tab-active .ant-tabs-tab-btn { font-weight: 600; color: #222222 !important; }\n.dashboard-component-tabs .ant-tabs-ink-bar { background: #FF385C !important; height: 3px !important; }\n.dashboard-component-tabs .ant-tabs-nav { margin-bottom: 14px !important; } /* clear of the KPI cards' accent edge */\n/* section bands: a full-width band in the masthead colour, an accent left edge, white caps */\n.dashboard-component-header { background: #06474D !important; border-left: 5px solid #FF385C !important;\n border-radius: 2px; padding: 0 14px !important; min-height: 34px; margin-top: 10px; display: flex !important;\n align-items: center !important; }\n.dashboard-component-header .editable-title, .dashboard-component-header .editable-title input { font-size: 13.5px !important;\n font-weight: 600 !important; color: #FFFFFF !important; letter-spacing: .07em !important; text-transform: uppercase; }\n.dashboard-component-header .anchor-link-container { display: none !important; }\n/* the one-line colour keys */\n.dashboard-markdown:has(h5) .dashboard-component-chart-holder,\n.dashboard-markdown:has(h6) .dashboard-component-chart-holder { background-color: transparent !important; border: 0 !important; }\n.dashboard-markdown h5 { font-size: 12px; font-weight: 400; color: #6A6A6A; margin: 0; }\n.dashboard-markdown h6 { font-size: 13px; font-weight: 500; color: #6A6A6A; margin: 0; }\n.dashboard-markdown h6 strong { color: #222222; font-weight: 600; }\n.dashboard-markdown [itemprop]::before { content: \"\"; display: inline-block; width: 10px; height: 10px;\n border-radius: 2px; margin: 0 6px 0 2px; vertical-align: -1px; }\n.dashboard-markdown [itemprop=\"this\"]::before { background: #008489; }\n.dashboard-markdown [itemprop=\"prior\"]::before { background: #9FD8D6; }\n.dashboard-markdown [itemprop=\"solar\"]::before { background: #FFB400; }\n.dashboard-markdown [itemprop=\"up\"]::before { background: #FF385C; }\n.dashboard-markdown [itemprop=\"down\"]::before { background: #008489; }\n.dashboard-markdown [itemprop=\"total\"]::before { background: #717171; }\n.superset-chart-table .cell-bar { background-color: rgba(0, 132, 137, 0.20) !important; }\n/* chart titles */\n.header-title { font-size: 15px !important; font-weight: 600 !important; color: #222222; }\n/* KPI cards: an accent top edge; the number in ink; the delta as coloured text with ▲▼, by meaning */\n.dashboard-component-chart-holder:has([data-test-viz-type^=\"big_number\"]):not(:has([data-test-chart-name=\"Data through\"])) {\n border-top: 3px solid #FF385C !important; }\n.header-line, .subheader-line, .superset-chart-table td { font-variant-numeric: tabular-nums; }\n[data-test-viz-type=\"big_number\"] .header-line,\n[data-test-viz-type=\"big_number_total\"] .header-line { font-size: 36px !important; line-height: 1.1 !important;\n font-weight: 600; letter-spacing: -.015em; }\n[data-test-viz-type=\"big_number\"] .header-line { color: #222222; }\n[data-test-viz-type=\"big_number\"] .subheader-line:not(.subtitle-line) { font-size: 13px !important; font-weight: 600;\n line-height: 1.4 !important; color: #6A6A6A; margin: 4px 0 2px; }\n[data-test-viz-type=\"big_number\"] .subtitle-line,\n[data-test-viz-type=\"big_number_total\"] .subheader-line,\n[data-test-viz-type=\"big_number_total\"] .subtitle-line { font-size: 12.5px !important; color: #6A6A6A; line-height: 1.4 !important; }\n[data-test-viz-type=\"big_number_total\"] .superset-legacy-chart-big-number { justify-content: flex-start !important; }\n[data-test-chart-name=\"Data through\"] .header-line { font-size: 22px !important; color: #FFFFFF !important; }\n[data-test-chart-name=\"Lower-48 demand\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #6A6A6A !important; }\n[data-test-chart-name=\"Lower-48 demand\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Lower-48 demand\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #6A6A6A !important; }\n[data-test-chart-name=\"Lower-48 demand\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Lower-48 evening ramp\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #995000 !important; }\n[data-test-chart-name=\"Lower-48 evening ramp\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Lower-48 evening ramp\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #137333 !important; }\n[data-test-chart-name=\"Lower-48 evening ramp\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Batteries installed\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #137333 !important; }\n[data-test-chart-name=\"Batteries installed\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Batteries installed\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #A50E0E !important; }\n[data-test-chart-name=\"Batteries installed\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Batteries operating\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #137333 !important; }\n[data-test-chart-name=\"Batteries operating\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Batteries operating\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #A50E0E !important; }\n[data-test-chart-name=\"Batteries operating\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Evening ramp, median day\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #995000 !important; }\n[data-test-chart-name=\"Evening ramp, median day\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Evening ramp, median day\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #137333 !important; }\n[data-test-chart-name=\"Evening ramp, median day\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Evening ramp, bad day\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #995000 !important; }\n[data-test-chart-name=\"Evening ramp, bad day\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Evening ramp, bad day\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #137333 !important; }\n[data-test-chart-name=\"Evening ramp, bad day\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n[data-test-chart-name=\"Baseload\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line) { color: #995000 !important; }\n[data-test-chart-name=\"Baseload\"] .superset-legacy-chart-big-number.positive .subheader-line:not(.subtitle-line)::before { content: \"▲ \"; }\n[data-test-chart-name=\"Baseload\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line) { color: #6A6A6A !important; }\n[data-test-chart-name=\"Baseload\"] .superset-legacy-chart-big-number.negative .subheader-line:not(.subtitle-line)::before { content: \"▼ \"; }\n/* tables: readable by standard (body 14 px on 36 px rows, header 13 px semibold), no vertical rules */\n.superset-chart-table table thead th { background: #F0F0F0 !important; color: #222222; font-weight: 600; font-size: 13px !important; }\n.superset-chart-table table th, .superset-chart-table table td { border-left: 0 !important; border-right: 0 !important; }\n.superset-chart-table table tbody td { border-top: 1px solid #EFEFEF; font-size: 14px !important; height: 36px; color: #222222; }", + "cross_filters": true, + "filter_bar_orientation": "horizontal", + "label_colors": { + "Solar": "#FFB400", + "Net load, Jul–Sep 2026": "#008489", + "Net load, Jul–Sep 2025": "#9FD8D6", + "Median day": "#008489", + "Bad day (P90)": "#9FD8D6", + "Outside the band": "#E8710A", + "Inside the band": "#9FD8D6", + "12-month average": "#222222", + "Baseload added": "#008489", + "Peak added": "#9FD8D6", + "Battery, Jul–Sep 2026": "#7B5CE0", + "Battery, Jul–Sep 2025": "#D3C6F5", + "Wind": "#00A699", + "Gas": "#FF8E7A", + "Coal": "#2F4B4F", + "Nuclear": "#C2417A", + "Hydro": "#2E86C1", + "Battery": "#7B5CE0", + "Battery charging": "#D3C6F5", + "Net imports": "#B8C7C9", + "Exports": "#E3EBEC", + "Other": "#C9BFB8", + "Coal, nuclear, hydro": "#2F4B4F", + "California": "#1A73E8", + "Texas": "#F9AB00", + "Southwest": "#00A699", + "Other regions": "#BDC1C6", + "Northwest": "#A142F4", + "Florida": "#E52592", + "Proven rise": "#8C3F00", + "Rising": "#E8710A", + "Flat or falling": "#1E8E3E", + "Covers the ramp": "#1E8E3E", + "Half or more": "#E8710A", + "Under half": "#D93025", + "Within 4 h": "#1E8E3E", + "4–10 h": "#E8710A", + "Over 10 h": "#D93025", + "DOM": "#008489", + "AEP": "#1A73E8", + "CAISO": "#008489", + "PJM": "#1A73E8", + "Closure": "#008489", + "2019 = 100": "#8C8C8C", + "12-month test": "#8C8C8C", + "2024-01": "#9FD8D6", + "2024-02": "#9FD8D6", + "2024-03": "#9FD8D6", + "2024-04": "#9FD8D6", + "2024-05": "#9FD8D6", + "2024-06": "#9FD8D6", + "2024-07": "#9FD8D6", + "2024-08": "#9FD8D6", + "2024-09": "#9FD8D6", + "2024-10": "#9FD8D6", + "2024-11": "#9FD8D6", + "2024-12": "#9FD8D6", + "2025-01": "#9FD8D6", + "2025-02": "#9FD8D6", + "2025-03": "#9FD8D6", + "2025-04": "#9FD8D6", + "2025-05": "#9FD8D6", + "2025-06": "#9FD8D6", + "2025-07": "#9FD8D6", + "2025-08": "#9FD8D6", + "2025-09": "#9FD8D6", + "2025-10": "#9FD8D6", + "2025-11": "#9FD8D6", + "2025-12": "#9FD8D6", + "2026-01": "#9FD8D6", + "2026-02": "#9FD8D6", + "2026-03": "#9FD8D6", + "2026-04": "#9FD8D6", + "2026-05": "#9FD8D6", + "2026-06": "#9FD8D6", + "2026-07": "#9FD8D6", + "2026-08": "#9FD8D6", + "2026-09": "#9FD8D6", + "2026-10": "#9FD8D6", + "2026-11": "#9FD8D6", + "2026-12": "#9FD8D6" + } + }, + "charts": [ + { + "name": "Data through", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "freshness" + }, + "metric": "MAX(data_through_date)", + "date_format": "%a, %-d %b %Y", + "description": "The latest day with all 24 hours of demand reported by every grid operator in EIA-930.", + "width": 3, + "height": 2.4 + }, + { + "name": "Lower-48 demand", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "l48_period" + }, + "display_name": "Demand, TWh", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "month" + } + ], + "metric": "SQL(SUM(demand_twh_all_bas)) AS Demand, TWh", + "time_column": "period_start", + "time_grain": "P1M", + "rolling_type": "sum", + "rolling_periods": 12, + "compare_lag": 12, + "compare_suffix": "vs prior 12 months", + "subtitle": "Lower 48 · 12 months to Sep 2026", + "number_format": ",.0f", + "time_range": "2022-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "Electricity demand summed over every Lower-48 grid operator, trailing 12 months.", + "width": 3, + "height": 4.8 + }, + { + "name": "Lower-48 evening ramp", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "l48_period" + }, + "display_name": "Evening ramp, GW", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "month" + } + ], + "metric": "SQL(MAX(coincident_sunset_ramp_p50_mw) / 1000) AS Evening ramp, GW", + "time_column": "period_start", + "time_grain": "P1M", + "compare_lag": 12, + "compare_suffix": "vs a year earlier", + "subtitle": "Lower 48 together · median day · Sep 2026", + "number_format": ",.1f", + "time_range": "2023-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "The largest 3-hour rise in Lower-48 net load (demand minus solar and wind) after the solar peak, on the median day of the month. Rising means less headroom.", + "width": 3, + "height": 4.8 + }, + { + "name": "Baseload added", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "◇ Baseload added, GW", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "baseload_night_agrees", + "op": "==", + "value": true + } + ], + "metric": "SQL(SUM(baseload_change_mw) / 1000) AS Baseload added, GW", + "subtitle": "Sum of 49 operators · 12 months to Sep 2026", + "number_format": "+,.1f", + "conditional_formatting": [ + { + "operator": ">", + "target": 0, + "color": "#995000" + } + ], + "description": "Change in the 5th percentile of hourly demand (flat load) over 12 months, summed over operators whose night hours agree. Amber: added load means less headroom.", + "width": 3, + "height": 4.8 + }, + { + "name": "Batteries installed", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "battery_capacity_month" + }, + "display_name": "Batteries, GW", + "filters": [ + { + "column": "region_name", + "op": "IS NOT NULL" + } + ], + "metric": "SQL(SUM(operating_battery_mw) / 1000) AS Batteries, GW", + "time_column": "month_start", + "time_grain": "P1M", + "compare_lag": 12, + "compare_suffix": "vs a year earlier", + "subtitle": "Operating · EIA-860M, August 2026", + "number_format": ",.1f", + "time_range": "2023-09-01 : 2026-09-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "Operating battery nameplate capacity, Lower 48, from EIA-860M.", + "width": 3, + "height": 4.8 + }, + { + "name": "Shape of the day: ERCOT", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "ERCOT · 16.9 GW · +28%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "ERCO" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: CAISO", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "CAISO · 15.4 GW · -3%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "CISO" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: PJM", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "PJM · 11.5 GW · +5% · 3-hour rise", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "PJM" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: MISO", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "MISO · 10.2 GW · +16%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "MISO" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: AZPS", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "AZPS · 2.1 GW · +27%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "AZPS" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: NEVP", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "NEVP · 2.1 GW · -10%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "NEVP" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: SOCO", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "SOCO · 3.8 GW · -5%", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "SOCO" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Shape of the day: TVA", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "TVA · 2.8 GW · +14% · 3-hour rise", + "filters": [ + { + "column": "ba", + "op": "==", + "value": "TVA" + }, + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(solar_pct_peak_demand)) AS Solar" + ], + "kind": "area", + "opacity": 0.85, + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(net_load_pct_peak_demand_ly)) AS Net load, Jul–Sep 2025", + "SQL(MAX(net_load_pct_peak_demand)) AS Net load, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "y_axis_min": 0, + "y_axis_max": 100, + "number_format": ",.0f", + "show_legend": false, + "x_axis_title": "Hour of day", + "y_axis_title": "% of daily peak", + "description": "Net load (demand minus solar and wind) as a % of each day's peak demand, by hour, averaged over the quarter. The title gives the median evening ramp and its change; where solar is under a tenth of midday demand, the 3-hour rise.", + "width": 3, + "height": 6 + }, + { + "name": "Siting screen", + "type": "table", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "Operators that added 150 MW+ of baseload · 12 months to Sep 2026", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "baseload_change_mw", + "op": ">=", + "value": 150 + } + ], + "columns": [ + "ba_label", + "region_name", + "row_status", + "baseload_status", + "baseload_change_mw", + "baseload_growth_arrow", + "baseload_growth_2y_arrow", + "baseload_idx2019", + "ramp_p90_share_of_p95_demand", + "cover_ratio", + "npe10_duration_p90_h", + "closure_display", + "row_status_code", + "baseload_status_code", + "cover_band", + "hours_band", + "closure_band" + ], + "hidden": [ + "row_status_code", + "baseload_status_code", + "cover_band", + "hours_band", + "closure_band" + ], + "sort_by": "baseload_change_mw", + "row_limit": 14, + "cell_bars": false, + "column_headers": { + "ba_label": "Operator", + "region_name": "Region", + "row_status": "Status", + "baseload_status": "◇ Baseload", + "baseload_change_mw": "◇ Baseload +MW", + "baseload_growth_arrow": "1 yr", + "baseload_growth_2y_arrow": "2 yr", + "baseload_idx2019": "vs 2019", + "ramp_p90_share_of_p95_demand": "Rise ÷ peak", + "cover_ratio": "◇ Battery ÷ ramp", + "npe10_duration_p90_h": "◇ Hours to cut 10%", + "closure_display": "Closure" + }, + "number_formats": { + "baseload_change_mw": "+,.0f", + "baseload_idx2019": ",.0f", + "ramp_p90_share_of_p95_demand": ".0%", + "cover_ratio": ".2f", + "npe10_duration_p90_h": ".1f", + "closure_display": "+.1%" + }, + "column_widths": { + "ba_label": 90, + "region_name": 110, + "row_status": 80, + "baseload_status": 120 + }, + "column_align": { + "ba_label": "center", + "region_name": "center", + "row_status": "center", + "baseload_status": "center", + "baseload_change_mw": "center", + "baseload_growth_arrow": "center", + "baseload_growth_2y_arrow": "center", + "baseload_idx2019": "center", + "ramp_p90_share_of_p95_demand": "center", + "cover_ratio": "center", + "npe10_duration_p90_h": "center", + "closure_display": "center" + }, + "conditional_formatting": [ + { + "metric": "row_status_code", + "operator": "=", + "target": 1, + "color": "#E6F4EA", + "apply_to": "row" + }, + { + "metric": "row_status_code", + "operator": "=", + "target": 2, + "color": "#FEF7E0", + "apply_to": "row" + }, + { + "metric": "row_status_code", + "operator": "=", + "target": 3, + "color": "#FCE8E6", + "apply_to": "row" + }, + { + "metric": "row_status_code", + "operator": "=", + "target": 1, + "color": "#137333", + "apply_to": "row_status", + "paint": "text" + }, + { + "metric": "row_status_code", + "operator": "=", + "target": 2, + "color": "#995000", + "apply_to": "row_status", + "paint": "text" + }, + { + "metric": "row_status_code", + "operator": "=", + "target": 3, + "color": "#A50E0E", + "apply_to": "row_status", + "paint": "text" + }, + { + "metric": "baseload_status_code", + "operator": "=", + "target": 3, + "color": "#8C3F00", + "apply_to": "baseload_status", + "paint": "text" + }, + { + "metric": "baseload_status_code", + "operator": "=", + "target": 2, + "color": "#995000", + "apply_to": "baseload_status", + "paint": "text" + }, + { + "metric": "baseload_status_code", + "operator": "=", + "target": 1, + "color": "#137333", + "apply_to": "baseload_status", + "paint": "text" + }, + { + "metric": "baseload_status_code", + "operator": "=", + "target": 3, + "color": "#8C3F00", + "apply_to": "baseload_growth_arrow", + "paint": "text" + }, + { + "metric": "baseload_status_code", + "operator": "=", + "target": 2, + "color": "#995000", + "apply_to": "baseload_growth_arrow", + "paint": "text" + }, + { + "metric": "cover_band", + "operator": "=", + "target": 1, + "color": "#137333", + "apply_to": "cover_ratio", + "paint": "text" + }, + { + "metric": "cover_band", + "operator": "=", + "target": 2, + "color": "#995000", + "apply_to": "cover_ratio", + "paint": "text" + }, + { + "metric": "cover_band", + "operator": "=", + "target": 3, + "color": "#A50E0E", + "apply_to": "cover_ratio", + "paint": "text" + }, + { + "metric": "hours_band", + "operator": "=", + "target": 1, + "color": "#137333", + "apply_to": "npe10_duration_p90_h", + "paint": "text" + }, + { + "metric": "hours_band", + "operator": "=", + "target": 2, + "color": "#995000", + "apply_to": "npe10_duration_p90_h", + "paint": "text" + }, + { + "metric": "hours_band", + "operator": "=", + "target": 3, + "color": "#A50E0E", + "apply_to": "npe10_duration_p90_h", + "paint": "text" + }, + { + "metric": "closure_band", + "operator": "=", + "target": 3, + "color": "#A50E0E", + "apply_to": "closure_display", + "paint": "text" + } + ], + "description": "Status: Act when cutting the peak 10% needs over 10 hours of storage or the data doesn't reconcile (closure outside ±2%); Watch when it needs 4–10 hours, batteries cover under half the bad-day ramp, or baseload rose beyond weather (proven); Good otherwise.", + "width": 12, + "height": 13.6 + }, + { + "name": "Evening ramp, median day", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "Evening ramp, GW", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "quarter" + } + ], + "metric": "SQL(MAX(sunset_ramp_p50_cmp_mw) / 1000) AS GW", + "time_column": "period_start", + "time_grain": "P3M", + "compare_lag": 4, + "compare_suffix": "vs Jul–Sep 2025", + "subtitle": "Median day · Jul–Sep 2026", + "number_format": ",.1f", + "time_range": "2023-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "The largest 3-hour rise in net load after the solar peak, median day of the quarter. Compared only where 9 days in 10 had a duck in both years.", + "width": 3, + "height": 4.8 + }, + { + "name": "Evening ramp, bad day", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "Bad-day ramp, GW", + "metric": "SQL(MAX(sunset_ramp_p90_cmp_mw) / 1000) AS GW", + "compare_lag": 12, + "compare_suffix": "vs prior 12 months", + "subtitle": "1 day in 10 · 12 months to Sep 2026", + "number_format": ",.1f", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "t12m" + } + ], + "time_column": "period_end", + "time_grain": "P1M", + "time_range": "2023-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "The evening ramp on 1 day in 10 (P90) over 12 months: what storage is sized to.", + "width": 3, + "height": 4.8 + }, + { + "name": "Hours to cut the peak", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "◇ Hours to cut 10%", + "metric": "SQL(MAX(npe10_duration_p90_h)) AS Hours", + "subtitle": "Of storage · 1 day in 10 · 12 months to Sep 2026", + "number_format": ",.1f", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "t12m" + } + ], + "time_column": "period_end", + "time_grain": "P1M", + "time_range": "2023-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "Hours of storage to cut the daily net-load peak by 10% on 1 day in 10. Within 4 h a 4-hour battery covers it; over 10 h needs long-duration storage.", + "width": 3, + "height": 4.8 + }, + { + "name": "Baseload", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "◇ Baseload, GW", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "t12m" + } + ], + "metric": "SQL(MAX(demand_p5_mw) / 1000) AS GW", + "time_column": "period_end", + "time_grain": "P1M", + "compare_lag": 12, + "compare_suffix": "vs prior 12 months", + "subtitle": "5th percentile of demand · 12 months to Sep 2026", + "number_format": ",.1f", + "time_range": "2023-10-01 : 2026-10-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "description": "The 5th percentile of hourly demand, a proxy for flat load.", + "width": 3, + "height": 4.8 + }, + { + "name": "Net load by hour and month", + "type": "heatmap", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "Net load by hour and month, MW · last 24 months", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "month" + }, + { + "column": "period_start", + "op": ">=", + "value": "2024-10-01" + } + ], + "x_column": "period_key", + "y_column": "hour_axis", + "metric": "SQL(MAX(net_load_mw)) AS Net load, MW", + "x_order": "a_to_z", + "color_scheme": "schemeYlGnBu", + "x_label_every": 3, + "y_label_every": 6, + "left_margin": 40, + "description": "Mean net load by hour of day and month. Darker is more to serve; the evening ramp is the climb from the midday trough to the evening peak.", + "width": 7, + "height": 8 + }, + { + "name": "Daily evening ramps by month", + "type": "box_plot", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "daily_ba" + }, + "display_name": "Daily evening ramp, GW · 12 months to Sep 2026", + "filters": [ + { + "column": "data_date", + "op": ">=", + "value": "2025-10-01" + }, + { + "column": "data_date", + "op": "<=", + "value": "2026-09-30" + }, + { + "column": "sunset_ramp_mw", + "op": "IS NOT NULL" + }, + { + "column": "sunset_month_ok", + "op": "==", + "value": true + } + ], + "metrics": [ + "SQL(MAX(sunset_ramp_mw) / 1000) AS Evening ramp, GW" + ], + "distribute_across": [ + "data_date" + ], + "groupby": [ + "month_key" + ], + "whiskers": [ + 10, + 90 + ], + "row_limit": 1000, + "number_format": ",.0f", + "x_axis_title": "Month", + "y_axis_title": "GW", + "description": "Every day's evening ramp, one box per month: the box is the middle half, the whiskers the 10th and 90th percentile days. Storage is sized to the upper whisker.", + "width": 5, + "height": 8 + }, + { + "name": "Evening ramp bridge", + "type": "waterfall", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "v_sunset_bridge" + }, + "display_name": "What moved the mean day's evening ramp, GW · Jul–Sep 2025 to Jul–Sep 2026", + "filters": [ + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "step", + "metric": "SUM(gw)", + "opening": "Mean day, Jul–Sep 2025", + "steps": [ + "Demand", + "Solar", + "Wind" + ], + "closing": "Mean day, Jul–Sep 2026", + "increase_color": "#FF385C", + "decrease_color": "#008489", + "total_color": "#717171", + "x_axis_title": "Change by source", + "y_axis_title": "GW", + "increase_label": "Steepens the ramp", + "decrease_label": "Eases the ramp", + "show_value": true, + "number_format": ",.1f", + "show_legend": false, + "description": "The change in the mean day's evening ramp split exactly into demand, solar and wind: each step is how much that source's swing across the day's 3-hour window changed.", + "width": 7, + "height": 11.6 + }, + { + "name": "Evening ramp by operator", + "type": "table", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "operator_ramp" + }, + "display_name": "Ten largest evening ramps, GW · Jul–Sep 2026", + "filters": [ + { + "column": "has_duck", + "op": "==", + "value": 1 + } + ], + "columns": [ + "ba_label", + "sunset_ramp_p50_gw", + "sunset_ramp_p50_arrow", + "sunset_ramp_p90_gw", + "sunset_ramp_p50_sign" + ], + "hidden": [ + "sunset_ramp_p50_sign" + ], + "sort_by": "sunset_ramp_p50_gw", + "row_limit": 10, + "page_length": 0, + "search_box": false, + "cell_bars": [ + "sunset_ramp_p90_gw" + ], + "color_by_sign": false, + "column_headers": { + "ba_label": "Operator", + "sunset_ramp_p50_gw": "Median day", + "sunset_ramp_p50_arrow": "vs Jul–Sep 2025", + "sunset_ramp_p90_gw": "1 day in 10" + }, + "number_formats": { + "sunset_ramp_p50_gw": ",.1f", + "sunset_ramp_p90_gw": ",.1f" + }, + "column_align": { + "ba_label": "center", + "sunset_ramp_p50_gw": "center", + "sunset_ramp_p50_arrow": "center", + "sunset_ramp_p90_gw": "center" + }, + "conditional_formatting": [ + { + "metric": "sunset_ramp_p50_sign", + "operator": "=", + "target": 1, + "color": "#995000", + "apply_to": "sunset_ramp_p50_arrow", + "paint": "text" + }, + { + "metric": "sunset_ramp_p50_sign", + "operator": "=", + "target": -1, + "color": "#137333", + "apply_to": "sunset_ramp_p50_arrow", + "paint": "text" + } + ], + "description": "Operators with a duck (solar a tenth of midday demand): the evening ramp on the median day and on 1 day in 10. A rising ramp is amber, a falling one green.", + "width": 5, + "height": 11.6 + }, + { + "name": "Is the change real", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "signal_month" + }, + "display_name": "Evening ramp: each month's change ÷ weather noise", + "filters": [ + { + "column": "metric", + "op": "==", + "value": "sunset_ramp_p50" + }, + { + "column": "month_start", + "op": ">=", + "value": "2024-10-01" + }, + { + "column": "z_noise", + "op": "IS NOT NULL" + } + ], + "x_column": "month_start", + "time_grain": "P1M", + "x_label_format": "%b %y", + "a": { + "metrics": [ + "SQL(MAX(CASE WHEN outside_noise_band THEN z_noise END)) AS Outside the band", + "SQL(MAX(CASE WHEN NOT outside_noise_band THEN z_noise END)) AS Inside the band" + ], + "kind": "bar", + "axis": "primary", + "show_value": true + }, + "b": { + "metrics": [ + "SQL(MAX(CASE WHEN n_z_12m = 12 THEN z_mean_12m END)) AS 12-month average" + ], + "kind": "line", + "axis": "primary" + }, + "annotations": [ + { + "name": "12-month test", + "value": 1.45, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "number_format": ",.1~f", + "show_legend": true, + "x_axis_title": "Month", + "y_axis_title": "Change ÷ weather noise", + "description": "Each month's year-on-year change divided by this operator's own weather noise for that month. Outside the band three months running, or a 12-month average above the dashed line, counts as real. Tested only with four reference years of a duck.", + "width": 7, + "height": 8 + }, + { + "name": "Is it real, by operator", + "type": "table", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "signal_status" + }, + "display_name": "Verdict by operator · 12 months to Sep 2026", + "filters": [ + { + "sql": "baseload_code >= 2 OR rise3h_code >= 2 OR evening_code >= 2" + } + ], + "columns": [ + "ba_label", + "baseload_verdict", + "rise3h_verdict", + "evening_verdict", + "baseload_code", + "rise3h_code", + "evening_code" + ], + "hidden": [ + "baseload_code", + "rise3h_code", + "evening_code" + ], + "sort_by": "baseload_code", + "row_limit": 15, + "page_length": 0, + "search_box": false, + "column_headers": { + "ba_label": "Operator", + "baseload_verdict": "◇ Baseload", + "rise3h_verdict": "3-hour rise", + "evening_verdict": "Evening ramp" + }, + "column_align": { + "ba_label": "center", + "baseload_verdict": "center", + "rise3h_verdict": "center", + "evening_verdict": "center" + }, + "conditional_formatting": [ + { + "metric": "baseload_code", + "operator": "=", + "target": 3, + "color": "#FDD663", + "apply_to": "baseload_verdict" + }, + { + "metric": "baseload_code", + "operator": "=", + "target": 2, + "color": "#FEEFC3", + "apply_to": "baseload_verdict" + }, + { + "metric": "rise3h_code", + "operator": "=", + "target": 3, + "color": "#FDD663", + "apply_to": "rise3h_verdict" + }, + { + "metric": "rise3h_code", + "operator": "=", + "target": 2, + "color": "#FEEFC3", + "apply_to": "rise3h_verdict" + }, + { + "metric": "evening_code", + "operator": "=", + "target": 3, + "color": "#FDD663", + "apply_to": "evening_verdict" + }, + { + "metric": "evening_code", + "operator": "=", + "target": 2, + "color": "#FEEFC3", + "apply_to": "evening_verdict" + } + ], + "description": "Proven: the 12-month test passed (beyond what weather produces 1 time in 100). Watch: outside the weather band three months running. Every operator with a proven or watched rise on any test.", + "width": 5, + "height": 16.3 + }, + { + "name": "Evening ramp since 2018", + "type": "timeseries_line", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "Evening ramp, GW · median and bad day, by month since Jul 2018", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "month" + } + ], + "metrics": [ + "SQL(MAX(sunset_ramp_p50_mw) / 1000) AS Median day", + "SQL(MAX(sunset_ramp_p90_mw) / 1000) AS Bad day (P90)" + ], + "time_column": "period_start", + "time_grain": "P1M", + "x_label_format": "%Y", + "number_format": ",.0f", + "y_axis_min": 0, + "legend_position": "bottom", + "time_range": "2018-07-01 : 2026-10-01", + "x_axis_title": "Month", + "y_axis_title": "GW", + "description": "Months when solar was under 5% of peak demand have no evening ramp and are left blank.", + "width": 7, + "height": 8 + }, + { + "name": "Baseload and peak added by grid operator", + "type": "bar", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "◇ Baseload and peak added, MW · 12 months to Sep 2026", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "baseload_change_mw", + "op": ">=", + "value": 150 + } + ], + "x_column": "ba_label", + "metrics": [ + "SQL(MAX(baseload_change_mw)) AS Baseload added", + "SQL(MAX(peak_change_mw)) AS Peak added" + ], + "orientation": "horizontal", + "row_limit": 13, + "number_format": ",.0f", + "legend_position": "bottom", + "y_axis_min": -500, + "show_value": true, + "x_axis_title": "Grid operator", + "y_axis_title": "MW", + "description": "Baseload is the 5th percentile of hourly demand, peak the 95th. Where they rise together the new load is flat; peak also moves with summer weather.", + "width": 7, + "height": 12 + }, + { + "name": "Baseload against 2019", + "type": "bar", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "◇ Baseload, 2019 = 100 · 12 months to Sep 2026", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "baseload_change_mw", + "op": ">=", + "value": 150 + } + ], + "x_column": "ba_label", + "groupby": "baseload_status", + "metrics": [ + "SQL(MAX(baseload_idx2019)) AS Baseload index" + ], + "stack": true, + "sort_by": "total", + "annotations": [ + { + "name": "2019 = 100", + "value": 100, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "orientation": "horizontal", + "number_format": ",.0f", + "legend_position": "bottom", + "show_value": true, + "x_axis_title": "Grid operator", + "y_axis_title": "Index, 2019 = 100", + "description": "Each operator's baseload against its own 2019, coloured by whether its rise passes the 12-month test.", + "width": 5, + "height": 12 + }, + { + "name": "Load-zone baseload since 2019", + "type": "timeseries_line", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "subregion_period" + }, + "display_name": "PJM load zones: ◇ baseload, 2019 = 100 · trailing 12 months", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "t12m" + }, + { + "column": "subregion", + "op": "IN", + "value": [ + "DOM", + "AEP" + ] + } + ], + "metrics": [ + "SQL(MAX(demand_p5_idx2019)) AS Baseload index" + ], + "groupby": "subregion", + "time_column": "period_end", + "time_grain": "P1M", + "x_label_format": "%Y", + "number_format": ",.0f", + "y_axis_min": 80, + "y_axis_max": 180, + "time_range": "2020-12-01 : 2026-10-01", + "legend_position": "bottom", + "x_axis_title": "Month", + "y_axis_title": "Index, 2019 = 100", + "annotations": [ + { + "name": "2019 = 100", + "value": 100, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "description": "Dominion's Virginia zone (DOM) and American Electric Power's (AEP): lowest hours at night, so their 5th percentile is flat load.", + "width": 12, + "height": 8 + }, + { + "name": "Batteries operating", + "type": "big_number_trend", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "battery_capacity_month" + }, + "display_name": "Batteries operating, GW", + "filters": [ + { + "column": "region_name", + "op": "IS NOT NULL" + } + ], + "metric": "SQL(SUM(operating_battery_mw) / 1000) AS GW", + "time_column": "month_start", + "time_grain": "P1M", + "compare_lag": 12, + "compare_suffix": "vs a year earlier", + "subtitle": "Lower 48 · EIA-860M, August 2026", + "number_format": ",.1f", + "time_range": "2023-09-01 : 2026-09-01", + "trend_color": "#008489", + "y_axis_truncate": true, + "width": 4, + "height": 4.8 + }, + { + "name": "Operators covered", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "Operators whose batteries cover the ramp", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "cover_band", + "op": "==", + "value": 1 + } + ], + "metric": "COUNT(*)", + "subtitle": "Battery MW ≥ the bad-day ramp · of 51", + "number_format": ",.0f", + "conditional_formatting": [ + { + "operator": ">", + "target": 0, + "color": "#137333" + } + ], + "width": 4, + "height": 4.8 + }, + { + "name": "Operators within 4 hours", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "Operators a 4-hour battery covers", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "hours_band", + "op": "==", + "value": 1 + } + ], + "metric": "COUNT(*)", + "subtitle": "Cut the peak 10% in 4 h or less · of 51", + "number_format": ",.0f", + "conditional_formatting": [ + { + "operator": ">", + "target": 0, + "color": "#137333" + } + ], + "width": 4, + "height": 4.8 + }, + { + "name": "Batteries installed by region", + "type": "timeseries_area", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "battery_capacity_month" + }, + "display_name": "Batteries operating by region, GW · EIA-860M, August 2026", + "filters": [ + { + "column": "region_group", + "op": "IS NOT NULL" + } + ], + "metrics": [ + "SQL(SUM(operating_battery_mw) / 1000) AS GW" + ], + "groupby": "region_group", + "time_column": "month_start", + "time_grain": "P1M", + "x_label_format": "%Y", + "number_format": ",.0f", + "stack": true, + "opacity": 0.9, + "time_range": "2020-01-01 : 2026-09-01", + "legend_position": "bottom", + "x_axis_title": "Month", + "y_axis_title": "GW", + "width": 12, + "height": 8 + }, + { + "name": "Battery cover of the bad-day ramp", + "type": "bar", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "◇ Battery MW ÷ bad-day evening ramp · 12 months to Sep 2026", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "sunset_ramp_p90_mw", + "op": ">=", + "value": 1000 + } + ], + "x_column": "ba_label", + "groupby": "cover_band_label", + "stack": true, + "sort_by": "total", + "metrics": [ + "SQL(MAX(cover_ratio)) AS Battery ÷ ramp" + ], + "orientation": "horizontal", + "number_format": ",.2f", + "legend_position": "bottom", + "x_label_every": true, + "show_value": true, + "x_axis_title": "Grid operator", + "y_axis_title": "Battery MW ÷ bad-day ramp", + "annotations": [ + { + "name": "Covers the ramp (1.0)", + "value": 1, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + }, + { + "name": "Half (0.5)", + "value": 0.5, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "description": "Operating battery nameplate MW over the bad-day (P90) evening ramp, for operators whose bad-day ramp is 1 GW or more. Nameplate isn't accredited capacity.", + "width": 6, + "height": 13 + }, + { + "name": "Hours of storage to cut the peak", + "type": "bar", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "siting_ba" + }, + "display_name": "◇ Hours of storage to cut the peak 10% · 1 day in 10 · 12 months to Sep 2026", + "filters": [ + { + "column": "in_roster", + "op": "==", + "value": true + }, + { + "column": "peak_p95_mw", + "op": ">=", + "value": 5000 + } + ], + "x_column": "ba_label", + "groupby": "hours_band_label", + "stack": true, + "sort_by": "total", + "metrics": [ + "SQL(MAX(npe10_duration_p90_h)) AS Hours" + ], + "orientation": "horizontal", + "number_format": ",.1f", + "legend_position": "bottom", + "x_label_every": true, + "show_value": true, + "x_axis_title": "Grid operator", + "y_axis_title": "Hours of storage", + "annotations": [ + { + "name": "4-hour battery", + "value": 4, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + }, + { + "name": "Long duration (10 h)", + "value": 10, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "description": "The energy above 90% of the day's peak net load divided by that 10%, on 1 day in 10: perfect foresight, no losses. Operators with a peak over 5 GW.", + "width": 6, + "height": 13 + }, + { + "name": "What serves 13:00 and 20:00", + "type": "bar", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "fuel_profile_ba" + }, + "display_name": "What supplies 13:00 and 20:00, MW · Jul–Sep 2026", + "filters": [ + { + "column": "is_latest", + "op": "==", + "value": true + }, + { + "column": "closure_ok", + "op": "==", + "value": true + }, + { + "column": "hour_label", + "op": "IN", + "value": [ + "13:00", + "20:00" + ] + }, + { + "column": "fuel_group", + "op": "IS NOT NULL" + } + ], + "x_column": "hour_label", + "metrics": [ + "SQL(SUM(mean_mw)) AS MW" + ], + "groupby": "fuel_group", + "stack": true, + "category_sort": "asc", + "x_label_every": true, + "number_format": ",.0f", + "legend_position": "bottom", + "legend_type": "plain", + "show_value": true, + "only_total": true, + "x_axis_title": "Hour of day", + "y_axis_title": "MW", + "description": "Mean supply by fuel at 13:00, when solar peaks, and 20:00, after it sets. Below zero is where part of it goes, exports and battery charging, so each bar nets to demand. Months whose generation is more than 5% off demand are left out.", + "width": 6, + "height": 9 + }, + { + "name": "Battery output by hour", + "type": "mixed", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "Battery discharge by hour, MW · Jul–Sep 2026 vs Jul–Sep 2025", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "x_column": "hour_label", + "a": { + "metrics": [ + "SQL(MAX(battery_discharge_ly_mw)) AS Battery, Jul–Sep 2025" + ], + "kind": "line", + "axis": "primary" + }, + "b": { + "metrics": [ + "SQL(MAX(battery_discharge_mw)) AS Battery, Jul–Sep 2026" + ], + "kind": "line", + "axis": "primary" + }, + "number_format": ",.0f", + "show_legend": true, + "x_axis_title": "Hour of day", + "y_axis_title": "MW", + "description": "Discharge only: EIA-930 reports some operators' batteries net of charging and others as discharge only (ERCOT switched in Dec 2025).", + "width": 6, + "height": 9 + }, + { + "name": "Solar at 13:00", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "fuel_profile_ba" + }, + "display_name": "Solar at 13:00, share", + "filters": [ + { + "column": "is_latest", + "op": "==", + "value": true + }, + { + "column": "closure_ok", + "op": "==", + "value": true + } + ], + "metric": "SQL(SUM(CASE WHEN fuel_group = 'Solar' AND hour_label = '13:00' THEN mean_mw END) / NULLIF(SUM(CASE WHEN hour_label = '13:00' AND mean_mw > 0 THEN mean_mw END), 0)) AS Solar at 13:00", + "number_format": ".0%", + "subtitle": "Of supply at that hour · Jul–Sep 2026", + "width": 3, + "height": 4.6 + }, + { + "name": "Gas at 20:00", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "fuel_profile_ba" + }, + "display_name": "Gas at 20:00, share", + "filters": [ + { + "column": "is_latest", + "op": "==", + "value": true + }, + { + "column": "closure_ok", + "op": "==", + "value": true + } + ], + "metric": "SQL(SUM(CASE WHEN fuel_group = 'Gas' AND hour_label = '20:00' THEN mean_mw END) / NULLIF(SUM(CASE WHEN hour_label = '20:00' AND mean_mw > 0 THEN mean_mw END), 0)) AS Gas at 20:00", + "number_format": ".0%", + "subtitle": "Of supply at that hour · Jul–Sep 2026", + "width": 3, + "height": 4.6 + }, + { + "name": "Battery at 20:00", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "profile_ba" + }, + "display_name": "Battery at 20:00, MW", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + }, + { + "column": "hour_label", + "op": "==", + "value": "20" + } + ], + "metric": "SQL(MAX(battery_discharge_mw)) AS Battery at 20:00", + "number_format": ",.0f", + "subtitle": "Mean discharge, where reported · Jul–Sep 2026", + "width": 3, + "height": 4.6 + }, + { + "name": "Balance closure", + "type": "big_number_total", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "display_name": "Balance closure", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "quarter" + }, + { + "column": "is_latest", + "op": "==", + "value": true + } + ], + "metric": "SQL(MAX(closure)) AS Closure", + "number_format": ".1%", + "subtitle": "Jul–Sep 2026 · ±2% guardrail", + "conditional_formatting": [ + { + "operator": "<", + "target": -0.02, + "color": "#A50E0E" + }, + { + "operator": ">", + "target": 0.02, + "color": "#A50E0E" + }, + { + "operator": "between", + "target_left": -0.02, + "target_right": 0.02, + "color": "#137333" + } + ], + "description": "(Net generation − interchange − demand) ÷ demand. Green within ±2%; red outside, where fuel shares are approximate. Beyond ±5% the fuel mix is left blank.", + "width": 3, + "height": 4.6 + }, + { + "name": "Generation mix since 2018", + "type": "timeseries_area", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "fuel_ba_period" + }, + "display_name": "Generation mix, share · since Jul 2018", + "filters": [ + { + "column": "period_type", + "op": "==", + "value": "month" + }, + { + "column": "closure_ok", + "op": "==", + "value": true + }, + { + "column": "fuel_group", + "op": "IN", + "value": [ + "Solar", + "Wind", + "Gas", + "Coal, nuclear, hydro", + "Other" + ] + } + ], + "metrics": [ + "SQL(SUM(twh)) AS TWh" + ], + "groupby": "fuel_group", + "contribution": "row", + "stack": true, + "opacity": 0.9, + "time_column": "period_start", + "time_grain": "P1M", + "x_label_format": "%Y", + "number_format": ".0%", + "y_axis_min": 0, + "y_axis_max": 1, + "time_range": "2018-07-01 : 2026-10-01", + "legend_position": "bottom", + "x_axis_title": "Month", + "y_axis_title": "Share of generation", + "width": 12, + "height": 8 + }, + { + "name": "Glossary", + "type": "table", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "glossary" + }, + "display_name": "Glossary · every measure on the dashboard", + "columns": [ + "measure", + "definition", + "formula", + "threshold", + "source", + "sort_order" + ], + "hidden": [ + "sort_order" + ], + "sort_by": "sort_order", + "sort_ascending": true, + "row_limit": 32, + "page_length": 0, + "search_box": false, + "column_headers": { + "measure": "Measure", + "definition": "Definition", + "formula": "Formula", + "threshold": "Status bands", + "source": "Source" + }, + "column_widths": { + "measure": 200, + "definition": 420, + "formula": 300, + "threshold": 260, + "source": 90 + }, + "column_align": { + "measure": "left", + "definition": "left", + "formula": "left", + "threshold": "left", + "source": "left" + }, + "width": 12, + "height": 52 + }, + { + "name": "Data quality by grid operator", + "type": "table", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "dq_ba" + }, + "display_name": "Operators with a month past ±2% closure · latest 12 months", + "filters": [ + { + "column": "closure_abs", + "op": "IS NOT NULL" + } + ], + "columns": [ + "ba_label", + "months_beyond_2pct", + "closure_worst_monthly", + "closure_t12m", + "n_months", + "demand_imputed_share", + "reports_batteries" + ], + "sort_by": "months_beyond_2pct", + "row_limit": 13, + "page_length": 0, + "search_box": false, + "column_headers": { + "ba_label": "Grid operator", + "months_beyond_2pct": "Months past ±2%", + "closure_t12m": "12-month closure", + "closure_worst_monthly": "Worst month", + "n_months": "Months", + "demand_imputed_share": "Demand imputed", + "reports_batteries": "Reports batteries" + }, + "number_formats": { + "closure_t12m": "+.1%", + "closure_worst_monthly": "+.1%", + "demand_imputed_share": ".1%" + }, + "column_align": { + "ba_label": "center", + "months_beyond_2pct": "center", + "closure_worst_monthly": "center", + "closure_t12m": "center", + "n_months": "center", + "demand_imputed_share": "center", + "reports_batteries": "center" + }, + "conditional_formatting": [ + { + "metric": "months_beyond_2pct", + "operator": ">", + "target": 5, + "color": "#FCE8E6", + "apply_to": "row" + }, + { + "metric": "months_beyond_2pct", + "operator": "between", + "target_left": 0, + "target_right": 6, + "color": "#FEF7E0", + "apply_to": "row" + }, + { + "metric": "months_beyond_2pct", + "operator": ">", + "target": 5, + "color": "#A50E0E", + "apply_to": "months_beyond_2pct", + "paint": "text" + } + ], + "description": "Balance closure is (net generation − interchange − demand) ÷ demand and should sit near zero. Red rows: six or more months past ±2%; amber: one to five.", + "width": 7, + "height": 15 + }, + { + "name": "CAISO balance closure", + "type": "timeseries_line", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "dq_ba_month" + }, + "display_name": "Balance closure by month, % · CAISO and PJM · since Jan 2024", + "filters": [ + { + "column": "ba", + "op": "IN", + "value": [ + "CISO", + "PJM" + ] + } + ], + "metrics": [ + "SQL(MAX(closure)) AS Closure" + ], + "groupby": "ba_label", + "time_column": "month_start", + "time_grain": "P1M", + "x_label_format": "%b %y", + "number_format": ".0%", + "time_range": "2024-01-01 : 2026-10-01", + "markers": false, + "legend_position": "bottom", + "x_axis_title": "Month", + "y_axis_title": "Closure, % of demand", + "annotations": [ + { + "name": "+2%", + "value": 0.02, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + }, + { + "name": "−2%", + "value": -0.02, + "style": "dashed", + "color": "#8C8C8C", + "width": 1 + } + ], + "description": "CAISO's fuel mix stops reconciling from Dec 2025 (its ramp uses only demand, solar and wind and is unaffected); PJM runs above +2% each summer.", + "width": 5, + "height": 15 + } + ], + "filters": [ + { + "type": "select", + "name": "Grid operator", + "dataset": { + "database": "grid_warehouse", + "schema": "sundown", + "table": "ba_period" + }, + "column": "ba_label", + "multi": false, + "default": [ + "ERCOT" + ], + "required": true, + "charts": [ + "Evening ramp, median day", + "Evening ramp, bad day", + "Hours to cut the peak", + "Baseload", + "Net load by hour and month", + "Daily evening ramps by month", + "Evening ramp bridge", + "Is the change real", + "Evening ramp since 2018", + "What serves 13:00 and 20:00", + "Battery output by hour", + "Solar at 13:00", + "Gas at 20:00", + "Battery at 20:00", + "Balance closure", + "Generation mix since 2018" + ] + } + ], + "layout": { + "header": [ + { + "row": [ + { + "markdown": "## SUNDOWN\nData-centre siting across the US power grid", + "width": 9, + "height": 2.4 + }, + "Data through" + ], + "background": "white" + } + ], + "tabs": [ + { + "title": "Overview", + "rows": [ + [ + "Lower-48 demand", + "Lower-48 evening ramp", + "Baseload added", + "Batteries installed" + ], + { + "header": "Siting screen", + "size": "medium" + }, + [ + "Siting screen" + ], + { + "header": "Shape of the day", + "size": "medium" + }, + [ + { + "markdown": "###### **Jul–Sep 2026** · **Jul–Sep 2025** · **Solar**", + "width": 12, + "height": 1.4 + } + ], + [ + "Shape of the day: ERCOT", + "Shape of the day: CAISO", + "Shape of the day: PJM", + "Shape of the day: MISO" + ], + [ + "Shape of the day: AZPS", + "Shape of the day: NEVP", + "Shape of the day: SOCO", + "Shape of the day: TVA" + ] + ] + }, + { + "title": "Evening ramp", + "tabs": [ + { + "title": "Shape and drivers", + "rows": [ + [ + "Evening ramp, median day", + "Evening ramp, bad day", + "Hours to cut the peak", + "Baseload" + ], + { + "header": "By hour and by day", + "size": "medium" + }, + [ + "Net load by hour and month", + "Daily evening ramps by month" + ], + { + "header": "What moved, Jul–Sep 2025 to Jul–Sep 2026", + "size": "medium" + }, + [ + { + "markdown": "###### **Steepens the ramp** · **Eases it** · **Mean day**", + "width": 12, + "height": 1.4 + } + ], + [ + "Evening ramp bridge", + "Evening ramp by operator" + ] + ] + }, + { + "title": "Is it real", + "sketch": [ + "HHHHHHHHHHHH", + "SSSSSSSVVVVV", + "SSSSSSSVVVVV", + "SSSSSSSVVVVV", + "SSSSSSSVVVVV", + "LLLLLLLVVVVV", + "LLLLLLLVVVVV", + "LLLLLLLVVVVV", + "LLLLLLLVVVVV", + "LLLLLLLVVVVV" + ], + "legend": { + "H": { + "header": "Change against weather" + }, + "S": "Is the change real", + "V": "Is it real, by operator", + "L": "Evening ramp since 2018" + } + } + ] + }, + { + "title": "Load growth", + "rows": [ + { + "header": "Baseload and peak added", + "size": "medium" + }, + [ + "Baseload and peak added by grid operator", + "Baseload against 2019" + ], + { + "header": "Data-centre zones in PJM", + "size": "medium" + }, + [ + "Load-zone baseload since 2019" + ] + ] + }, + { + "title": "Storage", + "rows": [ + [ + "Batteries operating", + "Operators covered", + "Operators within 4 hours" + ], + { + "header": "Batteries by region", + "size": "medium" + }, + [ + "Batteries installed by region" + ], + { + "header": "Where storage covers the ramp", + "size": "medium" + }, + [ + "Battery cover of the bad-day ramp", + "Hours of storage to cut the peak" + ] + ] + }, + { + "title": "Supply", + "rows": [ + [ + "Solar at 13:00", + "Gas at 20:00", + "Battery at 20:00", + "Balance closure" + ], + { + "header": "Solar peak and after sunset", + "size": "medium" + }, + [ + "What serves 13:00 and 20:00", + "Battery output by hour" + ], + { + "header": "Generation mix since 2018", + "size": "medium" + }, + [ + "Generation mix since 2018" + ] + ] + }, + { + "title": "About", + "rows": [ + { + "header": "Data quality", + "size": "medium" + }, + [ + "Data quality by grid operator", + "CAISO balance closure" + ], + { + "header": "Glossary", + "size": "medium" + }, + [ + "Glossary" + ] + ] + } + ], + "footer": [ + [ + { + "markdown": "##### Source: U.S. Energy Information Administration, EIA-930 Hourly Electric Grid Monitor and EIA-860M (August 2026), public domain · ◇ marks a proxy (Glossary) · Built with Chartwright", + "width": 12, + "height": 1.4 + } + ] + ] + } +} diff --git a/examples/sundown/theme/create_theme.py b/examples/sundown/theme/create_theme.py new file mode 100644 index 0000000..596b4dc --- /dev/null +++ b/examples/sundown/theme/create_theme.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Create or update the "Sundown" theme on a Superset 6.x instance, in a palette. + + SUPERSET_URL=https://superset.example.com SUPERSET_PASSWORD=... python3 create_theme.py [palette] + +sundown-theme.json holds the type, axes and per-chart overrides; ../palettes/.json +(default coral-teal-v2) supplies every colour in it. The spec names the theme +("dashboard.theme": "Sundown"); Chartwright doesn't create it. +""" + +import json +import os +import sys +from pathlib import Path + +import requests + +URL = os.environ["SUPERSET_URL"].rstrip("/") +NAME = "Sundown" +CONFIG = json.loads((Path(__file__).parent / "sundown-theme.json").read_text(encoding="utf-8")) +P = json.loads((Path(__file__).parent.parent / "palettes" / f"{sys.argv[1] if len(sys.argv) > 1 else 'coral-teal-v2'}.json") + .read_text(encoding="utf-8")) +CONFIG["token"].update({"colorPrimary": P["masthead"], "colorLink": P["this"], "colorBgLayout": P["ground"], + "colorBgContainer": P["tile"], "colorBorder": P["rule"], "colorBorderSecondary": P["border"], + "colorText": P["ink"], "colorTextSecondary": P["sec"]}) +E = CONFIG["echartsOptionsOverrides"] +E["legend"]["textStyle"]["color"] = E["xAxis"]["axisLabel"]["color"] = E["yAxis"]["axisLabel"]["color"] = P["sec"] +E["xAxis"]["axisLine"]["lineStyle"]["color"] = P["rule"] +E["yAxis"]["splitLine"]["lineStyle"]["color"] = P["grid"] +E["xAxis"]["nameTextStyle"] = {"color": P["sec"], "fontSize": 12} +E["yAxis"]["nameTextStyle"] = {"color": P["sec"], "fontSize": 12} +CONFIG["echartsOptionsOverridesByChartType"]["box_plot"]["series"]["itemStyle"]["borderColor"] = P["this"] + +s = requests.Session() +r = s.post(f"{URL}/api/v1/security/login", json={"username": "admin", "password": os.environ["SUPERSET_PASSWORD"], + "provider": "db"}) +r.raise_for_status() +s.headers["Authorization"] = f"Bearer {r.json()['access_token']}" +s.headers["X-CSRFToken"] = s.get(f"{URL}/api/v1/security/csrf_token/").json()["result"] +s.headers["Referer"] = URL +themes = s.get(f"{URL}/api/v1/theme/", params={"q": "(page_size:100)"}).json()["result"] +found = [t for t in themes if t["theme_name"] == NAME] +body = {"theme_name": NAME, "json_data": json.dumps(CONFIG)} +r = s.put(f"{URL}/api/v1/theme/{found[0]['id']}", json=body) if found else s.post(f"{URL}/api/v1/theme/", json=body) +if not r.ok: + sys.exit(f"{r.status_code}: {r.text[:600]}") +print(("updated" if found else "created"), NAME, r.json().get("id", found[0]["id"] if found else None)) diff --git a/examples/sundown/theme/sundown-theme.json b/examples/sundown/theme/sundown-theme.json new file mode 100644 index 0000000..1400d9c --- /dev/null +++ b/examples/sundown/theme/sundown-theme.json @@ -0,0 +1,140 @@ +{ + "algorithm": "default", + "token": { + "colorPrimary": "#14213D", + "colorLink": "#0072B2", + "colorBgLayout": "#F3F4F7", + "colorBgContainer": "#FFFFFF", + "colorBorder": "#D9DEE6", + "colorBorderSecondary": "#E5E8EE", + "colorText": "#1B1F27", + "colorTextSecondary": "#5B6472", + "borderRadius": 2, + "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", + "fontUrls": [ + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" + ], + "fontSize": 14, + "fontWeightStrong": 600 + }, + "echartsOptionsOverrides": { + "legend": { + "selector": false, + "icon": "roundRect", + "itemWidth": 11, + "itemHeight": 11, + "textStyle": { + "color": "#5B6472", + "fontSize": 12 + } + }, + "textStyle": { + "fontFamily": "Inter" + }, + "xAxis": { + "axisLabel": { + "color": "#5B6472", + "fontSize": 12 + }, + "axisLine": { + "lineStyle": { + "color": "#D9DEE6" + } + }, + "axisTick": { + "show": false + } + }, + "yAxis": { + "axisLabel": { + "color": "#5B6472", + "fontSize": 12 + }, + "splitLine": { + "lineStyle": { + "color": "#EEF1F5" + } + } + } + }, + "echartsOptionsOverridesByChartType": { + "mixed_timeseries": { + "xAxis": { + "axisLabel": { + "interval": 5 + } + }, + "yAxis": { + "splitNumber": 2, + "nameTextStyle": { + "align": "left" + } + }, + "series": { + "barGap": "-100%" + } + }, + "big_number": { + "series": { + "lineStyle": { + "width": 1.5 + }, + "areaStyle": { + "opacity": 0.08 + } + } + }, + "echarts_area": { + "series": { + "lineStyle": { + "width": 0 + } + }, + "yAxis": { + "splitNumber": 5, + "nameTextStyle": { + "align": "left" + } + } + }, + "echarts_timeseries_bar": { + "series": { + "barCategoryGap": "20%", + "barGap": "15%" + }, + "yAxis": { + "splitNumber": 5 + } + }, + "waterfall": { + "yAxis": { + "scale": false + }, + "xAxis": { + "axisLabel": { + "interval": 0 + } + } + }, + "box_plot": { + "legend": { + "show": false + }, + "series": { + "symbolSize": 4, + "itemStyle": { + "borderColor": "#0072B2", + "borderWidth": 1.5 + } + } + }, + "echarts_timeseries_line": { + "yAxis": { + "splitNumber": 5, + "nameTextStyle": { + "align": "left" + } + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index fea1fcb..f033df6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chartwright" -version = "0.5.0" +version = "0.6.0" description = "Build, verify, and maintain Apache Superset dashboards from small spec files." license = "Apache-2.0" readme = "README.md" diff --git a/skill/SKILL.md b/skill/SKILL.md index da50667..f075a4a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -38,11 +38,9 @@ approximate it with a different mechanism. 1. `CW schema`: read the contract. Surface: 17 chart types (big numbers, timeseries line/bar/area/scatter, categorical bar, pie/donut, table, pivot_table, heatmap, histogram, funnel, treemap, mixed: bars, lines or - filled areas on two axes), metrics as saved names, `AGG(col) [AS Label]` or - pivot_table, heatmap, histogram, funnel, treemap, mixed: bars + a line on - two axes, waterfall: a bridge whose `opening`, `steps` and `closing` keep - its order on 6.1+, box_plot: a distribution per group), metrics as saved names, `AGG(col) [AS Label]` or - `SQL(expression) AS Label`, per-chart `filters` (WHERE: column/op/value + filled areas on two axes, waterfall: a bridge whose `opening`, `steps` and + `closing` keep its order on 6.1+, box_plot: a distribution per group), + metrics as saved names, `AGG(col) [AS Label]` or `SQL(expression) AS Label`, per-chart `filters` (WHERE: column/op/value or `sql`), goal lines (`annotations`) on line/bar/area/scatter/mixed, dashboard-level `filters` (select, time_range, numeric range, time_grain and time_column native filter bar; `dependencies` for cascading; `charts` @@ -64,6 +62,12 @@ approximate it with a different mechanism. spec's `design.audience`. Brain OFF (user said "no design opinions" / "exactly as I specify"): skip the brief, skip step 6, and pass `--design off` to check and apply. + The brief marks two style choices "recommended": titles that name the + measure, unit and window (no typed takeaways, which go stale), and colour by + role (status green/amber/red beside a ▲▼ or a word). They are offers, not + rules: before you write the spec, recommend them to the user in a line each + and follow their answer. A style the user asks for wins; if they don't + answer, use the recommendation and say so in your reply. 3. Write the spec to the specs dir (absolute path). Slug lowercase-kebab; chart names unique. When the user names the team the dashboard is for and the repo has a `standards/` folder, write that team's standard in diff --git a/tests/test_example_sundown.py b/tests/test_example_sundown.py new file mode 100644 index 0000000..96a22ae --- /dev/null +++ b/tests/test_example_sundown.py @@ -0,0 +1,44 @@ +"""The Sundown example (examples/sundown/): its spec compiles, and its builder writes it.""" + +import io +import json +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from chartwright.compiler import compile_bundle +from chartwright.spec import load_spec +from chartwright.testing import stub_resolution + +EXAMPLE = Path(__file__).resolve().parent.parent / "examples" / "sundown" +PALETTES = sorted(p.stem for p in (EXAMPLE / "palettes").glob("*.json")) + + +def _build(palette: str | None = None) -> str: + args = [sys.executable, str(EXAMPLE / "build_spec_sundown.py"), str(EXAMPLE / "facts.json")] + if palette: + args.append(palette) + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + out = subprocess.run(args, capture_output=True, check=True, env=env).stdout + return out.decode("utf-8").replace("\r\n", "\n") + + +def test_the_shipped_spec_compiles(): + spec = load_spec(json.loads((EXAMPLE / "sundown.json").read_text(encoding="utf-8"))) + bundle = zipfile.ZipFile(io.BytesIO(compile_bundle(spec, stub_resolution(spec)))) + assert sum("/charts/" in n for n in bundle.namelist()) == len(spec.charts) + + +def test_the_builder_writes_the_shipped_spec(): + shipped = (EXAMPLE / "sundown.json").read_text(encoding="utf-8").replace("\r\n", "\n") + assert _build() == shipped + + +@pytest.mark.parametrize("palette", PALETTES) +def test_every_palette_builds_a_valid_spec(palette): + spec = load_spec(json.loads(_build(palette))) + assert spec.dashboard.title == "Sundown"