<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Ryan Wu on Medium]]></title>
        <description><![CDATA[Stories by Ryan Wu on Medium]]></description>
        <link>https://medium.com/@ryanwu2002?source=rss-7cbefb04ecc9------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*z5AyHXgw-jQd7vMM</url>
            <title>Stories by Ryan Wu on Medium</title>
            <link>https://medium.com/@ryanwu2002?source=rss-7cbefb04ecc9------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 14:41:19 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@ryanwu2002/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Using Graph Neural Networks to Solve the Unit Commitment Problem]]></title>
            <link>https://medium.com/stanford-cs224w/using-graph-neural-networks-to-solve-the-unit-commitment-problem-f18a628798b1?source=rss-7cbefb04ecc9------2</link>
            <guid isPermaLink="false">https://medium.com/p/f18a628798b1</guid>
            <category><![CDATA[optimization]]></category>
            <category><![CDATA[unit-commitment]]></category>
            <category><![CDATA[mixed-integer-programming]]></category>
            <category><![CDATA[power-grid]]></category>
            <category><![CDATA[graph-neural-networks]]></category>
            <dc:creator><![CDATA[Ryan Wu]]></dc:creator>
            <pubDate>Tue, 03 Feb 2026 02:53:39 GMT</pubDate>
            <atom:updated>2026-02-03T02:53:39.429Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A Stanford CS224W Course Project by Elijah Schacter and Ryan Wu</em></p><p>Modern power grids are vast, interconnected systems composed of thousands of generators, transmission assets, and load centers operating in real time. System operators are tasked with maintaining a delicate balance between electricity supply and demand at every moment, since even small imbalances can cause deviations in system frequency, risking equipment damage or large-scale blackouts. To prevent such instability, operators must continuously clear electricity markets and dispatch resources in a way that satisfies physical, economic, and regulatory constraints. As renewables, battery storage, and electric vehicle charging play larger roles in the grid, managing resource dispatch also becomes an increasingly sophisticated challenge.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*WG2lXcOF1-h-pvvj" /><figcaption>Photo by <a href="https://unsplash.com/@publicpowerorg?utm_source=medium&amp;utm_medium=referral">American Public Power Association</a> on <a href="https://unsplash.com/?utm_source=medium&amp;utm_medium=referral">Unsplash</a></figcaption></figure><p>A naive first attempt at solving the problem might involve getting a generating capacity and price (in $/MW) for every power plant, and assigning loads to plants in a way which minimizes total cost. However, this neglects several important constraints. One is that transmission lines have capacity limits, so there’s no point generating power if it can’t be brought to the consumer. Another is that power plants don’t work like the dimmer switch on a lightbulb — turning them on and off have costs, and power plants can’t ramp between generation loads instantly. Telling plant operators when they need to turn on and how fast to ramp ahead of time is known as the <em>unit commitment problem</em>. This is the challenge we’re focused on solving with machine learning techniques.</p><h4>Unit Commitment</h4><p>But first, what makes the unit commitment problem so difficult to solve efficiently? The unit commitment constraint effectively functions as a binary mask on top of the base generation capacity problem, so that for every commitment mask (of what power plants are on/off), the optimal generation for each plant must be solved as a subproblem. This problem formulation is nonconvex, which means that local minima are not necessarily global minima. When the grid size grows to hundreds of potential plants to turn on or off, the number of possible masks to solve grows <em>combinatorially</em>, making exhaustive search impossible. Instead, one of two options is typically used: mixed-integer solvers or heuristic commitment.</p><p>Mixed integer solvers include the binary commitment mask as an optimizable variable, which must be treated as an integer constraint. This problem formulation is solved by many commercially available codes, which typically use a “branch and bound” algorithm. Describing branch and bound is a bit beyond the scope of a single post, so refer to a resource such as <a href="https://web.stanford.edu/class/ee392o/bb.pdf">this</a> for more information. [1] Practically, mixed integer solvers offer optimality guarantees and much better performance compared to exhaustive search, but still scale poorly with increasing decision variable count and are significantly slower than solving the equivalent problem without the binary constraint. A sample problem formulation for the unit commitment problem as a mixed integer program is shown below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Ms9UY_3R1VYtXb8CGKC8zw.png" /></figure><p>The mixed-integer formulation is typically suitable for grid operators in real-time operation, but the computational cost begins to grow when running forward-looking simulations that are essential for grid planning and determining the economic value of future projects. Currently, one of the industry-standard methods for solving that problem is to use a heuristic commitment map. Unlike in the mixed integer program, the unit commitment is solved prior to the optimization with a heuristic that looks forward 24–72 hours. With the unit commitment constraint set by the heuristic, the problem becomes convex and can be solved rapidly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HTmPPRzsLj0HFY9KHrQ_7w.png" /></figure><p>Given the relative simplicity of the second method combining a heuristic with a convex linear program solve, it is useful to look at the heuristic in more detail. A good heuristic should take in the relevant problem constraints and weights (such as capacity limits, startup costs, projected demand), and output a binary commitment map which produces both a feasible and near-optimal solution (compared to the more expensive mixed-integer program). For applications which require a large number of evaluations such as grid planning, the heuristic must also evaluate rapidly and scale well with increasing grid complexity.</p><h4>Graph Neural Networks</h4><p>For this, we turned to graph neural networks (GNNs). The structure of the transmission lines, generators, and loads within a power grid take the form of a graph. Loads and generators are located at buses which serve as nodes in the graph, and transmission lines, represented by edges, connect the buses. Graph neural networks are machine learning architectures uniquely suited to take advantage of the connectivity of graph structures to perform learned inference tasks at the level of individual nodes and edges or the graph as a whole. The complexity of a GNN can be linear in the number of edges and inductive over changes in graph structure. These properties allow for training data to be drawn from a variety of sample power grid architectures with favorable scaling to realistic large power grids.</p><p>Graph neural networks have had past successes in power transmission problems. Optimal power flow (OPF) is a classic problem in transmission grid operation, describing where to optimally generate power given a realistic transmission grid and load distributed over that grid. Solving the OPF problem is prohibitively expensive in real time, so grid operators typically solve simplified versions with associated costs in accuracy. <a href="https://arxiv.org/abs/2403.17660">CANOS (Constraint-Augmented Neural OPF Solver) </a>is a graph neural network architecture which generates near-optimal solutions while scaling well to utility-scale grid sizes. [2] Additionally, GNNs have been applied to <a href="https://arxiv.org/html/2402.02827v2">graph-level tasks over power grids</a>, such as predicting whether a given grid operating condition is susceptible to cascading failures. [3]</p><p>In the case of the unit commitment heuristic, we want the model to predict whether a given generator is committed at a specific time, given the same information which is provided to the mixed integer optimization problem. This is a node-level inference problem, where the GNN predicts a binary classification denoting the commitment status of a generator node. Before we start looking at GNN architectures, let’s first check out the training data we’ll use for the problem.</p><h4>UnitCommitment.jl and PSTCA</h4><p>The data we’ll be using for training will come from the <a href="https://anl-ceeesa.github.io/UnitCommitment.jl/0.4/">UnitCommitment.jl package</a>. [4] The package compiles benchmark instances of the Unit Commitment problem, as well as a formulation for solving them with mixed integer programming techniques. Specifically, the package uses the security constrained unit commitment formulation, which attempts to find the optimal unit commitment which are robust to a set of <em>contingencies</em>, which dictate changes to the network architecture such as downed transmission lines. These contingencies will be useful later for augmenting our dataset. For the ground truth unit commitment solutions, we will use the built-in tools in UnitCommitment.jl.</p><p>The specific dataset we are using is the <a href="https://labs.ece.uw.edu/pstca/">Power Systems Test Case Archive </a>(PSTCA), compiled by the University of Washington and translated into the standard UnitCommitment.jl json data format. [5] These cases represent portions of the grid maintained by the American Electric Power System in the 1960s, and range in size from networks with 14 buses up to 300 buses. There is one variation for each day of a sample year, with demand projected at each bus over the next 36 hours starting from midnight of the given day. Take a look below for a snippet of a json file describing a simple 14-bus case.</p><p>We see in the header some information about the case setup:</p><pre>&quot;SOURCE&quot;: &quot;- University of Washington, Dept. of Electrical Engineering. \&quot;Power Systems Test Case Archive\&quot;. Available at: http://www.ee.washington.edu/research/pstca/ (Accessed: Nov 14, 2020)\n\n- D. Zimmerman, C. E. Murillo-Sandnchez and R. J. Thomas. \&quot;Matpower: Steady-state operations, planning, and analysis tools forpower systems research and education\&quot;, IEEE Transactions on PowerSystems, vol. 26, no. 1, pp. 12 \u201319, Feb. 2011. DOI: 10.1109/TPWRS.2010.2051168\n\n- A. S. Xavier, A. M. Kazachkov, O. Yurdakul, F. Qiu. \&quot;UnitCommitment.jl: A Julia/JuMP Optimization Package for Security-Constrained Unit Commitment (Version 0.3)\&quot;. Zenodo (2022). DOI: 10.5281/zenodo.4269874\n&quot;,<br>&quot;Parameters&quot;: {<br>  &quot;Version&quot;: &quot;0.3&quot;,<br>  &quot;Power balance penalty ($/MW)&quot;: 1000.0,<br>  &quot;Time horizon (h)&quot;: 36<br>}</pre><p>Then, the generators in the case are listed:</p><pre>&quot;Generators&quot;: {<br>    &quot;g1&quot;: {<br>      &quot;Bus&quot;: &quot;b1&quot;,<br>      &quot;Production cost curve (MW)&quot;: [<br>        36.75123365792352,<br>        110.10634590601819,<br>        183.46145815411288,<br>        256.8165704022076,<br>        330.1716826503023<br>      ],<br>      &quot;Production cost curve ($)&quot;: [<br>        1160.1370560288465,<br>        4051.592909653724,<br>        7310.413973593571,<br>        10614.711162105745,<br>        14293.941140664052<br>      ],<br>      &quot;Startup costs ($)&quot;: [<br>        25056.51,<br>        29059.15,<br>        34238.45<br>      ],<br>      &quot;Startup delays (h)&quot;: [<br>        1,<br>        2,<br>        4<br>      ],<br>      &quot;Ramp up limit (MW)&quot;: 231.12,<br>      &quot;Ramp down limit (MW)&quot;: 231.12,<br>      &quot;Startup limit (MW)&quot;: 231.12,<br>      &quot;Shutdown limit (MW)&quot;: 231.12,<br>      &quot;Minimum uptime (h)&quot;: 1,<br>      &quot;Minimum downtime (h)&quot;: 1,<br>      &quot;Reserve eligibility&quot;: [<br>        &quot;r1&quot;<br>      ],<br>      &quot;Initial status (h)&quot;: 24,<br>      &quot;Initial power (MW)&quot;: 230.74887999999999<br>    },<br># etc.<br>}</pre><p>A piecewise linear cost curve is used to interpolate between electricity production amount and generation cost. Similarly, leaving a power plant shut off for longer periods could incur larger costs when restarting, so another piecewise linear cost curve is used to represent the cost of starting the plant back up after being shut down for a given number of hours.</p><p>Finally, we can look at the transmission lines, which connect pairs of buses, and projected loads on buses over the next 36 hours:</p><pre>&quot;Transmission lines&quot;: {<br>  &quot;l1&quot;: {<br>    &quot;Source bus&quot;: &quot;b1&quot;,<br>    &quot;Target bus&quot;: &quot;b2&quot;,<br>    &quot;Reactance (ohms)&quot;: 0.05917,<br>    &quot;Susceptance (S)&quot;: 29.49686<br>  },<br>#etc.<br>}<br>&quot;Buses&quot;: {<br>    &quot;b1&quot;: {<br>      &quot;Load (MW)&quot;: 0.0<br>    },<br>    &quot;b2&quot;: {<br>      &quot;Load (MW)&quot;: [<br>        19.33301,<br>        18.57311,<br>        18.06675,<br>        17.76778,<br>        17.95852,<br>        18.33015,<br>        19.37086,<br>        20.13001,<br>        #etc.<br>      ]<br>    },<br>#etc.<br>}</pre><p>Next up, we need to transform this data into a format usable for our GNN.</p><h4>Generating Training Data</h4><p>To get the ground truth solution from a given UnitCommitment.jl case, we can solve the problem with the built-in `optimize()` method. This generates a binary mask for each of the 36 lookahead hours given projected demand as the solution, which we will then use to train the GNN. For those interested, we are using the HiGHS mixed integer solver within the JuMP framework, and the solution strategy is described in a <a href="https://ieeexplore.ieee.org/document/8613085">2019 paper by Xavier et. al.</a> [6]</p><pre>using HiGHS<br>using UnitCommitment<br><br># load a benchmark case<br>instance = UnitCommitment.read_benchmark(&quot;matpower/case14/2017-01-01&quot;)<br><br># build optimization model<br>model = UnitCommitment.build_model(instance = instance, optimizer = HiGHS.Optimizer)<br><br># solve the built model<br>UnitCommitment.optimize!(model)<br><br># extract the solution<br>solution = UnitCommitment.solution(model)</pre><p>The solution is printed out to a json file, which contains the variable “is on” that we care about , which is our binary unit commitment.</p><pre>&quot;Is on&quot;: {<br>  &quot;g1&quot;: [<br>    1.0,<br>    1.0,<br>    1.0,<br>    1.0,<br>    1.0,<br># etc.<br>  ],<br>}</pre><p>Let’s take a look at a sample input and solution graph. In the figure below, the generator buses are represented in orange and the load buses in blue. Each bus is connected to others by transmission lines which are represented by a lumped element resistance and susceptance. Additional node-level information is not depicted, such as startup costs and ramping rates for the generation assets.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yo0PW-GDPnRELxvg507DTg.png" /></figure><p>A solution graph is depicted below, corresponding to the unit commitment for the case above. This serves as a binary mask over all of the generator nodes, which becomes the optimization objective for the GNN. Note that only buses containing generator nodes are affected by the commitment status.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AuFz0pEbRdGz2zn-1UuvYA.png" /></figure><p>However, so far we’ve only looked at the 4 available power grid architectures within the benchmark instances (14, 57, 118, and 300 bus networks) of which we only have 365 examples each. To make sure our GNN can adapt to unseen transmission grid structures, we take advantage of the <em>contingencies</em>. UnitCommitment.jl contains a set of available contingencies for each transmission network case, which consists of all of the single transmission line removals which can occur without making the transmission network a disjoint graph. We sample five random contingencies for each data point, solve them, and add them to the dataset for training.</p><p>Now that we have solved cases, these must be converted into a format compatible with PyTorch Geometric, the machine learning package we plan on using to implement the GNNs. We use the following code to do so. First, we encode generator nodes and connect them to buses:</p><pre>generators = data[&quot;Generators&quot;]<br>buses = data[&quot;Buses&quot;]<br>lines = data[&quot;Transmission lines&quot;]<br><br>data_hetero = HeteroData()<br><br># Build generator features<br>gen_features = []<br>gen_bus_idx = []<br>for g_name, g in generators.items():<br>    if len(g.get(&quot;Startup costs ($)&quot;, [])) &lt; 3:<br>        g[&quot;Startup costs ($)&quot;] = prev_startup_costs.get(g_name, [0.0]*3)<br>    else:<br>        prev_startup_costs[g_name] = g[&quot;Startup costs ($)&quot;]<br><br>    if len(g.get(&quot;Startup delays (h)&quot;, [])) &lt; 3:<br>        g[&quot;Startup delays (h)&quot;] = prev_startup_delays.get(g_name, [0.0]*3)<br>    else:<br>        prev_startup_delays[g_name] = g[&quot;Startup delays (h)&quot;]<br><br>    features = (<br>        g[&quot;Production cost curve (MW)&quot;]<br>        + g[&quot;Production cost curve ($)&quot;]<br>        + g[&quot;Startup costs ($)&quot;]<br>        + g[&quot;Startup delays (h)&quot;]<br>        + [ <br>            g[&quot;Ramp up limit (MW)&quot;],<br>            g[&quot;Ramp down limit (MW)&quot;],<br>            g[&quot;Startup limit (MW)&quot;],<br>            g[&quot;Shutdown limit (MW)&quot;],<br>            g[&quot;Minimum uptime (h)&quot;],<br>            g[&quot;Minimum downtime (h)&quot;],<br>            g[&quot;Initial status (h)&quot;],<br>            g[&quot;Initial power (MW)&quot;]<br>        ]<br>    )<br>    gen_features.append(features)<br>    gen_bus_idx.append(bus2idx[g[&quot;Bus&quot;]])<br><br>gen_features = torch.tensor(gen_features, dtype=torch.float)<br>gen_features = (gen_features - gen_features.mean(dim=0)) / (gen_features.std(dim=0) + 1e-6)<br><br>data_hetero[&quot;generator&quot;].x = gen_features<br>src = torch.arange(len(gen_bus_idx))<br>dst = torch.tensor(gen_bus_idx)<br>data_hetero[&quot;generator&quot;, &quot;produces_at&quot;, &quot;bus&quot;].edge_index = torch.stack([src, dst], dim=0)<br>data_hetero[&quot;bus&quot;, &quot;served_by&quot;, &quot;generator&quot;].edge_index = torch.stack([dst, src], dim=0)</pre><p>Then, we add in bus features for load and transmission:</p><pre>bus_features = []<br>for bus_data in buses.values():<br>    load_seq = bus_data[&quot;Load (MW)&quot;]<br>    if isinstance(load_seq, (int, float)):<br>        load_seq = [load_seq] * lookahead_hours<br>    elif len(load_seq) &lt; lookahead_hours:<br>        load_seq = list(load_seq) + [0.0]*(lookahead_hours - len(load_seq))<br>    bus_features.append(load_seq)<br><br>bus_features = torch.tensor(bus_features, dtype=torch.float)<br>data_hetero[&quot;bus&quot;].x = bus_features<br><br># Build transmission edges, removing failed lines if specified<br>bus_edge_index = []<br>bus_edge_attr = []<br>failed_line_set = set(failed_lines) if failed_lines else set()<br><br>for line_id, props in lines.items():<br>    # Skip failed lines in contingency scenarios<br>    if line_id in failed_line_set:<br>        continue<br>        <br>    src = bus2idx[props[&quot;Source bus&quot;]]<br>    dst = bus2idx[props[&quot;Target bus&quot;]]<br>    bus_edge_index.append([src, dst])<br>    bus_edge_index.append([dst, src])<br>    bus_edge_attr.append([props[&quot;Reactance (ohms)&quot;], props[&quot;Susceptance (S)&quot;]])<br>    bus_edge_attr.append([props[&quot;Reactance (ohms)&quot;], props[&quot;Susceptance (S)&quot;]])<br><br>if bus_edge_index:  # Only add if edges exist<br>    bus_edge_index = torch.tensor(bus_edge_index, dtype=torch.long).T<br>    bus_edge_attr = torch.tensor(bus_edge_attr, dtype=torch.float)<br>    data_hetero[&quot;bus&quot;, &quot;transmission&quot;, &quot;bus&quot;].edge_index = bus_edge_index<br>    data_hetero[&quot;bus&quot;, &quot;transmission&quot;, &quot;bus&quot;].edge_attr = bus_edge_attr</pre><p>Finally, we add in reserve nodes. We haven’t talked about them much, but they essentially serve as an additional requirement for generation capacity across the entire grid which is ready to go, forecasted out over the next 36 hours.</p><pre>reserve_features = []<br>reserve_names = list(data.get(&quot;Reserves&quot;, {}).keys())<br>reserve_name2idx = {r: i for i, r in enumerate(reserve_names)}<br><br>for r_name in reserve_names:<br>    r_data = data[&quot;Reserves&quot;][r_name]<br>    amount_seq = r_data[&quot;Amount (MW)&quot;]<br>    if len(amount_seq) &lt; lookahead_hours:<br>        amount_seq = list(amount_seq) + [0.0]*(lookahead_hours - len(amount_seq))<br>    reserve_features.append(amount_seq)<br><br>if reserve_features:<br>    reserve_features = torch.tensor(reserve_features, dtype=torch.float)<br>    data_hetero[&quot;reserve&quot;].x = reserve_features<br><br>reserve_src, reserve_dst = [], []<br>for r_idx, r_name in enumerate(reserve_names):<br>    for g_idx, g in enumerate(generators.values()):<br>        if r_name in g.get(&quot;Reserve eligibility&quot;, []):<br>            reserve_src.append(r_idx)<br>            reserve_dst.append(g_idx)<br><br>if reserve_src:<br>    data_hetero[&quot;reserve&quot;, &quot;backed_by&quot;, &quot;generator&quot;].edge_index = torch.tensor(<br>        [reserve_src, reserve_dst], dtype=torch.long<br>    )</pre><h3>Model Architecture</h3><p>Our model can be decomposed into four distinct parts:</p><h4>1. Load in MLP</h4><p>First, we use two MLP layers to project the 24 generator features to a 128 dimensional space. Both the reserve and load node types have no feature information and thus ignore the load-in step. Instead they are initialized to random 128-dimensional vectors.</p><h4>2. Structural GNN</h4><p>The structural encoder is a two-layer HEATConv network. <a href="https://arxiv.org/abs/2106.07161">This model</a> augments the Graph Attention Transformer model with edge features and heterogenous node types, all of which are necessary to realize the interactions between generators, nodes, and transmission lines. [7] Each HEAT layer performs heterogeneous, edge-aware 4-head attention over the power-system graph. The update rule in each layer resembles (skip-connection not included here but tacked on to the second layer):</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lkajBE3mHYncnr4FOOjKdQ.png" /></figure><p>Importantly, line parameters such as reactance and susceptance enter directly through edge-attribute embeddings.</p><p>Since power networks are highly interconnected, we discovered that deep GNNs (more than 3 layers) suffer from oversmoothing, where all node embeddings become indistinguishable. Using two HEAT layers performed best in the example systems, but additional layers may be acceptable in larger systems that are less interconnected. Below is a little code snippet showing an individual HEAT layer and the forward pass of the GNN.</p><pre>self.heat1 = HEATConv(<br>            in_channels=hidden_dim, out_channels=hidden_dim,<br>            num_node_types=self.num_node_types, num_edge_types=self.num_edge_types,<br>            edge_type_emb_dim=edge_emb_dim, edge_dim=2, edge_attr_emb_dim=edge_emb_dim,<br>            heads=num_heads, concat=False<br>        )</pre><pre>x = self.heat1(x, edge_index, node_type, edge_type, edge_attr)<br>x1 = torch.relu(x)<br>x = self.heat2(x1, edge_index, node_type, edge_type, edge_attr)<br>x_out = torch.relu(x) + x1<br>x_out = self.dropout(x_out)</pre><h4><strong>3. Temporal Layer</strong></h4><p>After structural message passing is completed by the two-layer HEAT encoder, temporal dynamics are modeled outside of the GNN using a lightweight convolutional sequence model (GRU was also used, but didn’t show any advantages). This design strictly separates structural learning from temporal learning, preventing any form of time leakage through message passing.</p><p>Once the GNN runs, load and reserve 36-hour trajectories are introduced through a learned linear projection. These projected sequences are concatenated to the corresponding static load and reserve embeddings to form temporal node representations. Generator nodes do not receive direct temporal inputs. Instead, their static embeddings are broadcast across time, reflecting the fact that their physical characteristics do not change over the horizon. The resulting temporal node sequences are processed by a two-layer 1D convolutional network. Below is the construction of the temporal layers and how we implement them:</p><pre>if temporal_method == &#39;1d_conv&#39;:<br>    self.temporal = nn.Sequential(<br>        nn.Conv1d(hidden_dim, hidden_dim, kernel_size=5, padding=2),<br>        nn.BatchNorm1d(hidden_dim),<br>        nn.ReLU(),<br>        nn.Dropout(dropout),<br>        nn.Conv1d(hidden_dim, hidden_dim, kernel_size=3, padding=1),<br>        nn.BatchNorm1d(hidden_dim),<br>        nn.ReLU()<br>    )</pre><pre># Get structural embeddings from GNN<br>z_graph, _, offsets = self.encoder(x_dict_static, graph.edge_index_dict, graph.edge_attr_dict)<br><br># Extract generator structural embeddings (only computed once!)<br>gen_emb_static = z_graph[offsets[&#39;generator&#39;]:offsets[&#39;generator&#39;]+n_gen]  # [n_gen, hidden_dim]<br>bus_emb_static = z_graph[offsets[&#39;bus&#39;]:offsets[&#39;bus&#39;]+n_bus]  # [n_bus, hidden_dim]<br>res_emb_static = z_graph[offsets[&#39;reserve&#39;]:offsets[&#39;reserve&#39;]+n_reserve]  # [n_reserve, hidden_dim]<br><br># Build temporal sequences efficiently (vectorized over time)<br>bus_temporal = bus_feats.unsqueeze(-1)  # [n_bus, T, 1]<br>res_temporal = res_feats.unsqueeze(-1)  # [n_reserve, T, 1]<br><br># Project temporal features<br>bus_temporal_emb = self.bus_temporal_proj(bus_temporal)  # [n_bus, T, hidden_dim]<br>res_temporal_emb = self.reserve_temporal_proj(res_temporal)  # [n_reserve, T, hidden_dim]<br><br># Combine structural + temporal for bus and reserve<br>bus_emb_seq = bus_emb_static.unsqueeze(1) + bus_temporal_emb  # [n_bus, T, hidden_dim]<br>res_emb_seq = res_emb_static.unsqueeze(1) + res_temporal_emb  # [n_reserve, T, hidden_dim]<br><br># Generators: expand static embedding across time (no temporal features for generators)<br>gen_emb_seq = gen_emb_static.unsqueeze(1).expand(-1, self.T, -1)  # [n_gen, T, hidden_dim]<br><br># Concatenate all nodes<br>z_seq = torch.cat([gen_emb_seq, bus_emb_seq, res_emb_seq], dim=0)  # [total_nodes, T, hidden_dim]</pre><pre># Apply temporal model<br>if self.temporal_method == &#39;1d_conv&#39;:<br>    # Conv1d expects [batch, channels, length]<br>    z_seq_t = z_seq.permute(0, 2, 1)  # [total_nodes, hidden_dim, T]<br>    z_temporal = self.temporal(z_seq_t)  # [total_nodes, hidden_dim, T]<br>    z_temporal = z_temporal.permute(0, 2, 1)  # [total_nodes, T, hidden_dim]</pre><h4>4. Decoder</h4><p>Finally, each temporal node embedding is decoded through a small MLP to produce 36-step unit-commitment. Only generator predictions are retained as outputs:</p><pre># Decoder<br>self.decoder = nn.Sequential(<br>    nn.Linear(hidden_dim, hidden_dim // 2),<br>    nn.ReLU(),<br>    nn.Dropout(dropout),<br>    nn.Linear(hidden_dim // 2, output_dim)<br>)</pre><pre># Decode to predictions<br>out_seq = self.decoder(z_temporal)  # [total_nodes, T, 1]<br>out_seq = out_seq.permute(1, 0, 2)  # [T, total_nodes, 1]<br><br># Extract only generator predictions<br>gen_predictions = out_seq[:, :n_gen, :]  # [T, n_gen, 1]</pre><p>We use binary cross entropy loss as the metric to backpropagate for the accuracy of the unit commitment predictions. The full model flow is shown in the diagram below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cGJAtKVLXknB9kHpmQKUKA.png" /><figcaption>Flow chart depicting how different features and layers are used in the GNN prediction architecture</figcaption></figure><h3><strong>Results</strong></h3><p>Our main benchmark was a simple ridge regression. To do this we simply flattened the inputs (generator features, 36-hour load and reserve lookahead) and used them to predict the flattened unit commitments. In an effort to speed up training we reduced the input features to one hundred dimensions using an efficient PCA algorithm. However, this ridge model needed to be trained separately on each different case as it had abysmal accuracy between different grid set ups. As a result, we could not use it to provide a reasonable benchmark against a mix of systems, but this inability to adapt to new systems highlights the main advantage of our model: flexibility.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*99g-pZFYzpYeSl56vBy_dw.png" /></figure><p>While these results may look lackluster at first glance, they are in fact extremely strong. It is important to remember that not only does the ridge model completely fail to adapt to new grid structures, it also cannot handle contingencies. Contingencies are when something goes wrong in the grid (a line or a generator goes out) and they happen all the time. A strong power model needs to be able to handle these issues and still produce valid unit commitment instructions. While the ridge can be reformulated to accommodate line contingencies by zero-padding, the resulting performance (not shown in the table above) hovers between 70–85% depending on the active sets of contingencies in the dataset. We expect that on larger grids with more unique outage combinations and complex dependencies, ML methods that are structure-agnostic (like ridge) will simply not be able to cut it.</p><p>Back to the table above, it is impressive that our generalized model–which can work on a variety of different grid structures and accommodate any combination of contingency/outage situations–performs almost on par with the ridge model which can only perform under strict fixed settings.</p><h4>Next Steps</h4><p>We believe that with a few small (albeit time consuming) improvements, our model could be used as a much faster alternative to the MIP or an ugly heuristic that is currently employed to solve the unit commitment problem. First, we need to get our code factorized to run on GPUs to save time. After factorizing the code properly, we expect training time to be drastically reduced. With this reduction, we could train the model on considerably larger grids, with many years of data and a wide variety of outage contingencies. By doing so, we believe that we could create a single model that could generalize to all sorts of different power grids. Lastly, we plan to compare our unit commitment output to real MIP unit commitment output on dispatch. What exactly does this mean? Once units are committed, they are then dispatched in real time so that supply meets demand. Only units that were committed in advance are allowed to be dispatched, hence the importance of getting unit commitment correct. Conveniently, this dispatch model is a simple LP and allows us to quickly compare whether our unit commitment model leads to system failures and energy costs on par with the traditional MIP and heuristic unit commitment models.</p><p>As highlighted throughout this post, our end goal is speed. Even if our model produces slightly worse results than the traditional MIP or heuristic models, we expect it to run orders of magnitude faster. Why is speed so important? While real-time operations place more emphasis on accuracy, speed is much more important for forward-looking studies. Each study contains hundreds of different weather, load, and generation scenarios over thousands of hours. That’s not even taking into account that each of these scenario-hours rely on random probabilistic outage draws and done via Monte Carlo simulation! As a result, the electrical-grid study space is largely bottlenecked by the MIP/heuristic unit commitment problem and is in desperate need of a faster alternative. Solving it via the model we proposed (if properly factored for GPUs) compared to the heuristic could conservatively increase the speed of each iteration by 50 times from 500 ms to somewhere around 10 ms. We hope that by continuing to build out this model together over the next quarter, we can provide the electricity industry with a much-needed speedy alternative.</p><h4>Learn More</h4><p>If you want to learn more about the project and play around with the tools and datasets, we have a<a href="https://colab.research.google.com/drive/1SwvjBrdK08eUFfY_lzWxk_S-pZ3QgX5t?usp=sharing"> Colab demonstrating the workflow here</a>. We also host the code and training data <a href="https://github.com/ryanwu4/unit-commitment-gnn">on a Github repo here</a>.</p><h4>References</h4><p>[1] Boyd, S., Ghosh, A., &amp; Magnani, A. (2003, November 1). <em>Branch and bound methods</em> (Notes for EE392o, Stanford University). Stanford University. <a href="https://web.stanford.edu/class/ee392o/bb.pdf?utm_source=chatgpt.com">https://web.stanford.edu/class/ee392o/bb.pdf</a></p><p>[2] Piloto, L., Liguori, S., Madjiheurem, S., Zgubic, M., Lovett, S., Tomlinson, H., Elster, S., Apps, C., &amp; Witherspoon, S. (2024). <em>CANOS: A fast and scalable neural AC-OPF solver robust to N-1 perturbations</em> (arXiv:2403.17660) [Preprint]. arXiv. <a href="https://arxiv.org/abs/2403.17660?utm_source=chatgpt.com">https://arxiv.org/abs/2403.17660</a></p><p>[3] Varbella, A., Amara, K., Gjorgiev, B., El-Assady, M., &amp; Sansavini, G. (2024). <em>PowerGraph: A power grid benchmark dataset for graph neural networks</em> (arXiv:2402.02827v2) [Preprint]. arXiv. <a href="https://arxiv.org/abs/2402.02827v2">https://arxiv.org/abs/2402.02827v2</a></p><p>[4] Xavier, A. S., Kazachkov, A. M., Yurdakul, O., He, J., &amp; Qiu, F. (2025). <em>UnitCommitment.jl: A Julia/JuMP optimization package for security-constrained unit commitment</em> (Version 0.4.2) [Computer software]. Zenodo. <a href="https://doi.org/10.5281/zenodo.17737778">https://doi.org/10.5281/zenodo.17737778</a></p><p>[5] University of Washington Electrical &amp; Computer Engineering. (n.d.). <em>Power Systems Test Case Archive</em> [Data repository]. University of Washington. <a href="https://labs.ece.uw.edu/pstca/?utm_source=chatgpt.com">https://labs.ece.uw.edu/pstca/</a></p><p>[6] Xavier, Á. S., Qiu, F., Wang, F., &amp; Thimmapuram, P. R. (2019). <em>Transmission constraint filtering in large-scale security-constrained unit commitment</em>. <em>IEEE Transactions on Power Systems, 34</em>(3), 2457–2460. <a href="https://doi.org/10.1109/TPWRS.2019.2892620">https://doi.org/10.1109/TPWRS.2019.2892620</a></p><p>[7] Mo, X., Xing, Y., &amp; Lv, C. (2021). <em>Heterogeneous edge-enhanced graph attention network for multi-agent trajectory prediction</em> (arXiv:2106.07161) [Preprint]. arXiv. <a href="https://arxiv.org/abs/2106.07161?utm_source=chatgpt.com">https://arxiv.org/abs/2106.07161</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f18a628798b1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/stanford-cs224w/using-graph-neural-networks-to-solve-the-unit-commitment-problem-f18a628798b1">Using Graph Neural Networks to Solve the Unit Commitment Problem</a> was originally published in <a href="https://medium.com/stanford-cs224w">Stanford CS224W: Machine Learning with Graphs</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>