Skip to content

Events

The DERControl scheduling engine: state machine, supersession, randomization.

py20305.events.processor

Top-level event processor orchestrating the DERControl lifecycle.

Called from poll callbacks after resource discovery. Processes DERControls through the event state machine: randomize, classify, upsert, supersede, activate, complete, and prune.

EventProcessor

Orchestrates DERControl event processing.

Wires together EventStore, RandomizationCache, supersession, response posting, DDERC tracking, timers, and control dispatch.

Source code in src/py20305/events/processor.py
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
class EventProcessor:
    """Orchestrates DERControl event processing.

    Wires together EventStore, RandomizationCache, supersession,
    response posting, DDERC tracking, timers, and control dispatch.
    """

    def __init__(
        self,
        http: Sep2Client,
        state: DiscoveredState,
        dispatcher: ControlDispatcher,
        shutdown: asyncio.Event,
        *,
        state_ready: asyncio.Event | None = None,
        group_lookup: Callable[[str], list[str] | None] | None = None,
        comms_loss: CommsLossState | None = None,
        timebase: ServerTimebase | None = None,
        audit: AuditEmitter | None = None,
    ) -> None:
        # ``group_lookup`` is an optional fan-out hook, unset by default.
        # Given a server-side program href, it returns the list of local
        # sub-device LFDIs that program should fan out to, or None to fall
        # back to 1-to-1 dispatch by server-side device href. Bookkeeping
        # (responses, supersession, mode tracking) stays in terms of the
        # server-side hrefs the server actually knows about; only the
        # connector-dispatch step is expanded.
        self._http = http
        self._state = state
        self._dispatcher = dispatcher
        self._shutdown = shutdown
        self._state_ready = state_ready
        self._group_lookup = group_lookup
        self._comms_loss = comms_loss or CommsLossState()
        # Server timebase for time-of-day-sensitive reads (classification,
        # timer firing, response timestamps). The default identity instance
        # behaves exactly like the local clock.
        self._timebase = timebase or ServerTimebase()
        # Lifecycle records for the audit trail. Disabled until the host
        # configures it, so every call below is a no-op by default.
        self._audit = audit if audit is not None else AuditEmitter()
        self._audit_failures = 0
        #: Events recorded as expired or skipped, by mRID, with their program.
        #: They are not stored, so this is what stops each poll recording them
        #: again. Kept while the program still serves them, not by age: both
        #: kinds can be past their end and still served, poll after poll.
        self._audit_unstored_seen: dict[bytes, str] = {}
        self._store = EventStore()
        self._rand_cache = RandomizationCache()
        self._response_tracker = ResponseTracker()
        self._dderc_tracker = DdercTracker()
        self._timer_mgr = EventTimerManager(shutdown)
        self._mode_tracker = ActiveModeTracker()
        self._closed = False
        # Schedule-notification relay (informational push to connectors).
        # Fire-on-change dedup keyed by (stream, mrid_hex) -> last content signature.
        self._relay_snapshots: dict[tuple[str, str], tuple[Any, ...]] = {}
        # Strong refs to in-flight fire-and-forget relay tasks.
        self._relay_tasks: set[asyncio.Task[None]] = set()
        # Strong refs to control dispatches still running past the activation
        # response ceiling. They are shielded from the ceiling's timeout so
        # the device still gets the control; this keeps them from being GC'd
        # mid-flight and gives shutdown something to cancel.
        self._dispatch_tasks: set[asyncio.Task[None]] = set()

    @property
    def closed(self) -> bool:
        """Whether this processor has been shut down."""
        return self._closed

    async def process_controls(self, program_href: str) -> None:
        """Process all DERControls for a given DERProgram.

        Called after polling discovers or refreshes DERControl data.
        """
        if self._closed:
            return

        derp_state = self._state.der_programs.get(program_href)
        if derp_state is None:
            return

        # Relay the program's DefaultDERControl baseline (add/change only).
        self._relay_default_baseline(program_href, derp_state)

        # Server timebase (FSA-scoped): classification against server-epoch
        # eff_start/eff_end must follow the head-end's clock.
        now = int(self._timebase.now(self._fsa_scope(program_href)))
        primacy = derp_state.primacy
        n_controls = len(derp_state.der_controls)
        logger.debug(
            "Processing %d controls for program %s (primacy=%d)",
            n_controls,
            program_href,
            primacy,
        )

        for derc in derp_state.der_controls:
            mrid = derc.m_rid.value
            mrid_short = mrid.hex()[:8]
            interval = derc.interval
            raw_start = interval.start.value
            raw_duration = interval.duration

            # Randomize timing
            start_off, dur_off = self._rand_cache.get_offsets(derc, raw_start, raw_duration)

            # IEEE 10.2.2.3 rule m): successive events must not have gaps
            # from randomization. If a predecessor's raw end == this raw start,
            # override this event's effective start to match predecessor's
            # effective end.
            predecessor = self._find_successive_predecessor(program_href, raw_start)
            if predecessor is not None:
                new_start_off = predecessor.end - raw_start
                if new_start_off != start_off:
                    logger.debug(
                        "Successive event %s: adjusting start offset %d -> %d "
                        "(predecessor %s eff_end=%d)",
                        mrid_short,
                        start_off,
                        new_start_off,
                        predecessor.mrid.hex()[:8],
                        predecessor.end,
                    )
                    start_off = new_start_off
                    self._rand_cache.override_start_offset(mrid, start_off, raw_start, raw_duration)

            eff_start = raw_start + start_off
            eff_duration = raw_duration + dur_off

            # Server status time for tie-breaking
            server_status_time = 0
            if derc.event_status is not None and derc.event_status.date_time is not None:
                server_status_time = derc.event_status.date_time.value

            # Check if already tracked
            existing = self._store.get(mrid)
            if existing is not None:
                # Update the derc reference (server may have updated fields)
                existing.derc = derc
                existing.server_status_time = server_status_time
                if derc.creation_time is not None:
                    existing.creation_time = derc.creation_time.value
                # Check server cancellation
                if self._is_server_cancelled(derc) and existing.state in (
                    EventState.SCHEDULED,
                    EventState.ACTIVE,
                ):
                    logger.debug(
                        "Server cancelled event %s (was %s)", mrid_short, existing.state.value
                    )
                    await self._handle_cancellation(existing)
                    if self._closed:
                        return
                else:
                    # Server refreshed the control's fields without a state
                    # change; relay only if the actionable content changed
                    # (fire-on-change dedup in _emit_relay).
                    self._relay_control_event(existing, "updated")
                continue

            # Classify new event
            eff_end = eff_start + eff_duration

            # Comms-loss recovery: skip events at or before the resume boundary
            # (the window we opted out of during the outage). Only later
            # schedules resume. The boundary outlives the mode: skipped events
            # are not stored, so it must gate every poll until the window has
            # elapsed -- the client clears it only once it is in the past.
            if (
                self._comms_loss.resume_after_epoch is not None
                and eff_start <= self._comms_loss.resume_after_epoch
            ):
                self._audit_unstored(
                    derc, program_href, primacy, eff_start, eff_duration, "skipped"
                )
                logger.debug(
                    "Skipping event %s in opted-out window (start=%d <= resume_after=%d)",
                    mrid_short,
                    eff_start,
                    self._comms_loss.resume_after_epoch,
                )
                continue

            if now > eff_end:
                # IEEE 2030.5 §10.2.2.3 rule j: EXPIRED is only for an event whose
                # end is already past *when first received*. If we already
                # responded to this event (e.g. ran it to COMPLETED and the store
                # then pruned it after its end), a later poll re-discovering it
                # must NOT re-post EXPIRED -- it would contradict the COMPLETED we
                # already sent. (Response posting is also gated on responseRequired
                # inside post_der_response.)
                if self._response_tracker.has_responded(mrid):
                    logger.debug(
                        "Skipping EXPIRED for already-handled event %s "
                        "(re-discovered %ds after end)",
                        mrid_short,
                        now - eff_end,
                    )
                    continue
                logger.debug(
                    "Event %s already expired when received (ended %ds ago)",
                    mrid_short,
                    now - eff_end,
                )
                self._audit_unstored(
                    derc, program_href, primacy, eff_start, eff_duration, "expired"
                )
                await self._post_response(derc, ResponseCode.EXPIRED, program_href)
                if self._closed:
                    return
                continue

            state = EventState.ACTIVE if now >= eff_start else EventState.SCHEDULED
            logger.info(
                "New event %s: state=%s start=T+%ds duration=%ds primacy=%d",
                mrid_short,
                state.value,
                eff_start - now,
                eff_duration,
                primacy,
            )

            # IEEE 10.2.2.3 rule e): use creationTime for tie-breaking
            creation_time = 0
            if derc.creation_time is not None:
                creation_time = derc.creation_time.value

            record = EventRecord(
                mrid=mrid,
                derc=derc,
                program_href=program_href,
                primacy=primacy,
                state=state,
                start=eff_start,
                duration=eff_duration,
                server_status_time=server_status_time,
                creation_time=creation_time,
            )

            # Check server cancellation before adding
            if self._is_server_cancelled(derc):
                record.state = EventState.CANCELLED
                self._store.upsert(record)
                logger.info("Event %s pre-cancelled by server", mrid_short)
                self._audit_transition(record, None, "cancelled")
                self._relay_control_event(record, "cancelled")
                await self._post_response(derc, ResponseCode.CANCELLED, program_href)
                if self._closed:
                    return
                continue

            self._store.upsert(record)
            # New event signals fresh state for affected devices — clear
            # stale DDERC tracker entries across ALL programs so initial
            # DDERC is re-evaluated (a device may belong to multiple programs).
            dev_hrefs = self._state.device_mapping.program_to_devices.get(program_href, [])
            affected_lfdis: set[bytes] = set()
            for dh in dev_hrefs:
                lfdi = self._get_device_lfdi(dh)
                if lfdi is not None:
                    affected_lfdis.add(lfdi)
            if affected_lfdis:
                self._dderc_tracker.clear_devices(affected_lfdis)

            if state == EventState.SCHEDULED:
                await self._handle_new_scheduled(record)
            else:
                await self._handle_late_discovery(record, derp_state)
            if self._closed:
                return

        # IEEE 2030.5-2023 §10.2.2.3 rule p): clients SHALL consider events
        # removed from the server before the end of their Effective Scheduled
        # Period as cancelled. The 2023 edition notes this differs from earlier
        # revisions, where currentStatus=2 was the only cancellation signal.
        #
        # Gated on der_controls_complete: a failed or unparseable DERControl
        # fetch leaves the list empty, and that is indistinguishable from a
        # server that removed every control. Reconciling against an incomplete
        # snapshot would cancel every live event on one transient error.
        if derp_state.der_controls_complete:
            served = {derc.m_rid.value for derc in derp_state.der_controls}
            for record in self._store.all_active_states():
                if record.program_href != program_href or record.mrid in served:
                    continue
                # "before the end of their Effective Scheduled Period" is part
                # of the rule, not a detail: a server may drop an event once it
                # is over, and such an event completed rather than being
                # cancelled. The record can still be ACTIVE here because
                # _on_completion blocks on _state_ready for the length of a
                # rediscovery, and a rediscovery ends by refreshing the list and
                # calling this method -- cancelling would report status 6 for an
                # event that ran its course and suppress the status 3 the
                # blocked completion still owes.
                if now >= record.end:
                    continue
                logger.info(
                    "Event %s removed from program %s while %s -- cancelling (rule p)",
                    record.mrid.hex()[:8],
                    program_href,
                    record.state.value,
                )
                await self._handle_cancellation(record)
                if self._closed:
                    return

        # Run supersession across all non-terminal events
        await self._run_supersession()
        if self._closed:
            return

        # Prune expired
        expired = self._store.prune_expired(now)
        # Drop relay snapshots for events that left the store so the
        # fire-on-change cache doesn't grow without bound (both the control and
        # its doe projection, if any).
        for expired_mrid in expired:
            self._relay_snapshots.pop(("control", expired_mrid.hex()), None)
            self._relay_snapshots.pop(("doe", expired_mrid.hex()), None)
        self._rand_cache.prune(now)
        self._forget_unstored_no_longer_served(program_href)
        self._response_tracker.prune(now)

        # Apply DDERC to devices that have no active events.
        # Per IEEE 2030.5 §10.10, the DefaultDERControl is the baseline
        # operating state and should be active whenever no event overrides it.
        await self._apply_initial_dderc(program_href)

    def _forget_unstored_no_longer_served(self, program_href: str) -> None:
        """Forget expired and skipped events the program no longer serves.

        Only on a complete fetch: an empty list from a failed one would forget
        every event, and the next poll would record them all again.
        """
        derp_state = self._state.der_programs.get(program_href)
        if derp_state is None or not derp_state.der_controls_complete:
            return
        served = {derc.m_rid.value for derc in derp_state.der_controls}
        for mrid, program in list(self._audit_unstored_seen.items()):
            if program == program_href and mrid not in served:
                del self._audit_unstored_seen[mrid]

    def cancel_program(self, program_href: str) -> None:
        """Cancel all events from a removed program (Gap 3: IEEE 8.8.3).

        Transitions all scheduled/active events from the given program to
        CANCELLED and cancels any pending timers.
        """
        cancelled_count = 0
        for record in self._store.all_active_states():
            if record.program_href == program_href:
                mrid_short = record.mrid.hex()[:8]
                logger.info("Cancelling event %s from removed program %s", mrid_short, program_href)
                from_state = record.state.value
                self._store.transition(record.mrid, EventState.CANCELLED)
                self._audit_transition(record, from_state, "cancelled")
                self._timer_mgr.cancel(record.mrid)
                # cancel_program is sync, but runs inside the event loop, so
                # _emit_relay's loop.create_task succeeds.
                self._relay_control_event(record, "cancelled")
                cancelled_count += 1
        # The program is gone -- drop its baseline relay slot so the cache stays
        # bounded by live program count.
        self._relay_snapshots.pop(("default_baseline", program_href), None)
        for mrid, program in list(self._audit_unstored_seen.items()):
            if program == program_href:
                del self._audit_unstored_seen[mrid]
        if cancelled_count:
            logger.info(
                "Cancelled %d event(s) from removed program %s", cancelled_count, program_href
            )

    async def shutdown(self) -> None:
        """Cancel all event timers + in-flight background tasks and mark closed."""
        self._closed = True
        await self._timer_mgr.cancel_all()
        # Cancel/await fire-and-forget relay tasks and any control dispatch
        # still running past the activation ceiling, so shutdown is clean
        # (no "Task was destroyed but it is pending" warnings).
        background = list(self._relay_tasks) + list(self._dispatch_tasks)
        for task in background:
            task.cancel()
        if background:
            await asyncio.gather(*background, return_exceptions=True)

    async def _handle_new_scheduled(self, record: EventRecord) -> None:
        """Post ack and schedule activation timer for a newly discovered scheduled event."""
        mrid_short = record.mrid.hex()[:8]
        logger.debug("Event %s: posting ACK, scheduling activation timer", mrid_short)
        await self._post_response(record.derc, ResponseCode.ACKNOWLEDGED, record.program_href)
        if self._closed:
            return
        self._timer_mgr.schedule_activation(
            record, self._on_activation, now_fn=self._now_fn_for(record.program_href)
        )
        self._relay_control_event(record, "scheduled")
        self._audit_transition(record, None, "scheduled")

    async def _handle_late_discovery(self, record: EventRecord, derp_state: object) -> None:
        """Handle an event discovered after its start time (already active).

        Mirrors the ``_on_activation()`` flow: run supersession BEFORE posting
        ACTIVE so that SUPERSEDED responses go out first when multiple
        overlapping events are discovered late in the same poll cycle.
        """
        mrid_short = record.mrid.hex()[:8]
        if self._comms_loss.active:
            logger.info("Event %s: comms-loss active -- opting out (late discovery)", mrid_short)
            await self._opt_out_event(record, from_state=None)
            return
        logger.info("Event %s: late discovery (already active), posting ACK", mrid_short)
        await self._post_response(record.derc, ResponseCode.ACKNOWLEDGED, record.program_href)
        if self._closed:
            return

        # IEEE 10.2.2.3 rule l): SUPERSEDED before ACTIVE.
        await self._run_supersession()
        if self._closed:
            return

        await self._apply_and_respond(record)
        if self._closed:
            return
        self._relay_control_event(record, "active")
        self._audit_activation(record, from_state=None)

        # Schedule completion timer
        self._timer_mgr.schedule_completion(
            record, self._on_completion, now_fn=self._now_fn_for(record.program_href)
        )

    async def _handle_cancellation(self, record: EventRecord) -> None:
        """Handle server cancellation of an event.

        IEEE 10.2.3.3: Cancellation of an active event applies randomization
        to the abbreviated duration. The wind-down period is
        max(abs(randomizeStart), abs(randomizeDuration)) seconds.
        """
        mrid_short = record.mrid.hex()[:8]
        was_active = record.state == EventState.ACTIVE
        logger.info("Event %s: cancelled (was %s)", mrid_short, record.state.value)
        from_state = record.state.value
        self._store.transition(record.mrid, EventState.CANCELLED)
        self._timer_mgr.cancel(record.mrid)
        self._relay_control_event(record, "cancelled")
        self._audit_transition(record, from_state, "cancelled")
        # A cancellation tells a device the event it was running has been called
        # off. A device that was told the event was rejected was never running it,
        # so it is not told again. An event cancelled while still SCHEDULED
        # never dispatched, so every device in the program still hears about it.
        await self._post_started_device_lifecycle(record, ResponseCode.CANCELLED, mrid_short)

        if was_active:
            wind_down = self._cancellation_wind_down(record)
            if wind_down > 0:
                logger.info(
                    "Event %s: wind-down %ds before DDERC fallback",
                    mrid_short,
                    wind_down,
                )
                self._timer_mgr.schedule_delayed_callback(
                    record, wind_down, self._apply_dderc_fallback, "wind-down"
                )
            else:
                await self._apply_dderc_fallback(record)

    @staticmethod
    def _cancellation_wind_down(record: EventRecord) -> int:
        """Compute wind-down duration for cancellation randomization.

        IEEE 10.2.3.3: abbreviated duration is the greater of
        abs(randomizeStart) and abs(randomizeDuration).
        """
        derc = record.derc
        rs = abs(derc.randomize_start.value) if derc.randomize_start is not None else 0
        rd = abs(derc.randomize_duration.value) if derc.randomize_duration is not None else 0
        return max(rs, rd)

    async def _on_activation(self, record: EventRecord) -> None:
        """Activation timer callback: transition scheduled -> active.

        If a rediscovery lock is configured, waits for any in-progress
        rediscovery to complete before reading state — this keeps timers
        firing on-time while ensuring the device mapping is consistent.
        """
        # Wait for state to be repopulated after _state.clear() during rediscovery
        if self._state_ready is not None and not self._state_ready.is_set():
            await self._state_ready.wait()

        now = self._timebase.now(self._fsa_scope(record.program_href))
        # Elapsed dispatch duration is measured on the monotonic clock: the
        # timebase can jump if a Time observation lands mid-dispatch, which
        # would corrupt (even negate) a timebase-vs-timebase subtraction.
        dispatch_started = time.monotonic()
        mrid_short = record.mrid.hex()[:8]
        if record.state != EventState.SCHEDULED:
            logger.debug("Event %s: activation skipped (state=%s)", mrid_short, record.state.value)
            return
        delta = now - record.start
        logger.info(
            "Event %s: SCHEDULED -> ACTIVE (fired %.2fs %s intended start)",
            mrid_short,
            abs(delta),
            "after" if delta >= 0 else "before",
        )
        self._store.transition(record.mrid, EventState.ACTIVE)

        if self._comms_loss.active:
            logger.info("Event %s: comms-loss active -- opting out instead of applying", mrid_short)
            await self._opt_out_event(record, from_state=EventState.SCHEDULED.value)
            return

        # IEEE 10.2.2.3 rule l): SUPERSEDED SHALL be POSTed at the Effective
        # Start Time of the superseding event.  Run supersession BEFORE
        # dispatching so the SUPERSEDED responses go out ahead of any ACTIVE.
        # Supersession stands whether or not this event's dispatch then
        # succeeds.
        await self._run_supersession()
        if self._closed:
            return

        await self._apply_and_respond(record)
        if self._closed:
            return

        # Absolute "after intended start" follows the server timebase; the
        # elapsed duration uses the monotonic anchor captured above and spans
        # the whole fleet -- individual devices responded as they finished.
        dispatch_done = self._timebase.now(self._fsa_scope(record.program_href))
        logger.info(
            "Event %s: all devices dispatched (%.2fs after intended start, %.2fs for the slowest)",
            mrid_short,
            dispatch_done - record.start,
            time.monotonic() - dispatch_started,
        )
        self._relay_control_event(record, "active")
        self._audit_activation(record, from_state=EventState.SCHEDULED.value)
        self._timer_mgr.schedule_completion(
            record, self._on_completion, now_fn=self._now_fn_for(record.program_href)
        )

    async def _on_completion(self, record: EventRecord) -> None:
        """Completion timer callback: transition active -> completed.

        Waits for any in-progress rediscovery before reading state.
        """
        # Wait for state to be repopulated after _state.clear() during rediscovery
        if self._state_ready is not None and not self._state_ready.is_set():
            await self._state_ready.wait()

        now = self._timebase.now(self._fsa_scope(record.program_href))
        mrid_short = record.mrid.hex()[:8]
        if record.state != EventState.ACTIVE:
            logger.debug("Event %s: completion skipped (state=%s)", mrid_short, record.state.value)
            return
        delta = now - record.end
        logger.info(
            "Event %s: ACTIVE -> COMPLETED (fired %.2fs %s intended end)",
            mrid_short,
            abs(delta),
            "after" if delta >= 0 else "before",
        )
        self._store.transition(record.mrid, EventState.COMPLETED)
        self._relay_control_event(record, "completed")
        self._audit_transition(record, EventState.ACTIVE.value, "completed")
        await self._post_started_device_lifecycle(record, ResponseCode.COMPLETED, mrid_short)
        await self._apply_dderc_fallback(record)

    async def _post_started_device_lifecycle(
        self, record: EventRecord, code: ResponseCode, mrid_short: str
    ) -> None:
        """Post a post-activation lifecycle code to the devices that started.

        Used for COMPLETED and CANCELLED: both say something about how an event
        the device was running ended, so neither applies to a device that was told
        the event was rejected.

        Stated positively rather than as "every program device except the
        superseded and rejected ones", because the
        program's device list is not stable across an event: a rediscovery can
        repopulate it under a different program href, or with different members.
        Subtracting from a list that has since changed produced two wrong answers
        -- a rejected device was told COMPLETED once its program mapping had gone
        (the list was empty, so the any-LFDI fallback fired and never consulted
        the exclusions), and a device added to the program mid-event would be told
        an event completed that it never ran.

        The fallback is kept for the one case it was meant for: an event that never
        dispatched at all, whose ACTIVE also went out against any available LFDI
        (see ``_apply_and_respond``), and an event cancelled while still SCHEDULED,
        which every device in the program needs to hear about. Without a dispatch
        there is nothing to have rejected, so there is no exclusion to honour.
        """
        dispatched = record.applied_devices or record.rejected_devices
        if not dispatched:
            await self._post_response(
                record.derc,
                code,
                record.program_href,
                exclude_devices=record.superseded_devices or None,
            )
            return

        audience = record.applied_devices - record.superseded_devices
        if not audience:
            logger.info(
                "Event %s: no device was running it (%d rejected, %d superseded); posting no %s",
                mrid_short,
                len(record.rejected_devices),
                len(record.superseded_devices),
                code.name,
            )
            return
        await self._post_response_for_devices(record.derc, code, audience)

    async def _run_supersession(self) -> None:
        """Run supersession algorithm and apply transitions.

        IEEE 10.2.2.3 rule l): SUPERSEDED SHALL be POSTed at the Effective
        Start Time of the superseding event.  When the superseding event is
        still SCHEDULED and the superseded event is already ACTIVE, we defer
        all supersession bookkeeping until the superseding event activates
        (handled by ``_on_activation`` calling this method again).
        """
        events = self._store.all_active_states()
        if len(events) < 2:
            return

        device_map = self._build_program_device_map()
        results = compute_supersession(events, device_map)

        for result in results:
            superseded = self._store.get(result.superseded_mrid)
            if superseded is None:
                continue
            if superseded.state in (
                EventState.CANCELLED,
                EventState.SUPERSEDED,
                EventState.COMPLETED,
            ):
                continue

            superseding = self._store.get(result.superseding_mrid)
            if superseding is None:
                continue

            # Defer when the superseding event hasn't started yet and the
            # superseded event is already active.  The active event should
            # run normally until the higher-priority event actually starts.
            if superseding.state == EventState.SCHEDULED and superseded.state == EventState.ACTIVE:
                continue

            # Post per-device SUPERSEDED responses for newly affected devices
            newly_superseded = result.affected_devices - superseded.superseded_devices
            superseded.superseded_devices.update(result.affected_devices)

            # A device that was told this event was rejected was never running it,
            # so a supersession notice would assert a lifecycle step it never
            # reached. Only the *response* is filtered: the bookkeeping set
            # above still records every affected device, because it drives dispatch
            # exclusion, the fully-superseded check below, and the DDERC fallback.
            #
            # Subtraction rather than the positive ``applied_devices`` test used for
            # COMPLETED/CANCELLED: an event still SCHEDULED has applied nothing yet
            # and can legitimately be superseded before it ever starts, so it must
            # still announce.
            announce = newly_superseded - superseded.rejected_devices
            if announce:
                await self._post_response_for_devices(
                    superseded.derc, ResponseCode.SUPERSEDED, announce
                )

            # Track per-mode supersession per device
            if result.affected_modes:
                for dev in result.affected_devices:
                    existing = superseded.superseded_modes.get(dev, frozenset())
                    superseded.superseded_modes[dev] = existing | result.affected_modes

            # If all devices for this program are superseded, fully supersede
            program_devices = device_map.get(superseded.program_href, set())
            fully = bool(program_devices) and superseded.superseded_devices >= program_devices
            if newly_superseded and not fully:
                # Not a transition: the event keeps running on its other
                # devices, so the record leaves its state where it was and
                # says which part was taken over.
                current = "opted_out" if superseded.opted_out else superseded.state.value
                self._audit_transition(
                    superseded,
                    current,
                    current,
                    partial(self._audit_supersession, superseded, result.superseding_mrid, True),
                )
            if fully:
                was_active = superseded.state == EventState.ACTIVE
                logger.info(
                    "Event %s: SUPERSEDED by %s (was %s)",
                    superseded.mrid.hex()[:8],
                    result.superseding_mrid.hex()[:8],
                    superseded.state.value,
                )
                from_state = superseded.state.value
                self._store.transition(superseded.mrid, EventState.SUPERSEDED)
                self._timer_mgr.cancel(superseded.mrid)
                self._relay_control_event(superseded, "superseded")
                self._audit_transition(
                    superseded,
                    from_state,
                    "superseded",
                    partial(self._audit_supersession, superseded, result.superseding_mrid, False),
                )

                # Per-device SUPERSEDED responses were already posted above
                # as each device was added to newly_superseded.
                if was_active:
                    await self._apply_dderc_fallback(superseded)

    def _build_program_device_map(self) -> dict[str, set[str]]:
        """Build mapping from program_href to set of device hrefs."""
        result: dict[str, set[str]] = {}
        for href, devices in self._state.device_mapping.program_to_devices.items():
            result[href] = set(devices)
        return result

    async def _apply_and_respond(self, record: EventRecord) -> None:
        """Dispatch the control, then report each device by its own outcome.

        Status 2 asserts the event started, so it is posted for a device only
        once that device's apply has returned successfully. A failure reports
        the matching rejection code instead and marks the device, which
        keeps it out of the COMPLETED response at the event's end.
        """
        mrid_short = record.mrid.hex()[:8]

        async def respond(dev_href: str, failure: BaseException | None) -> None:
            if failure is None:
                record.applied_devices.add(dev_href)
                await self._post_response_for_devices(record.derc, ResponseCode.ACTIVE, {dev_href})
                return
            record.rejected_devices.add(dev_href)
            # Modes are registered for every target before dispatch, since the
            # outcome isn't known yet. Release this device's registration now that
            # it is: leaving it would have the tracker believe a control that was
            # never applied still owns those modes, so a later event releasing the
            # same mode would look "still covered" and skip its DDERC fallback.
            self._mode_tracker.unregister(dev_href, record.mrid)
            code = response_code_for_dispatch_error(failure)
            logger.info("Event %s: %s rejected -> %s", mrid_short, dev_href, code.name)
            await self._post_response_for_devices(record.derc, code, {dev_href})

        outcomes = await self._apply_control_to_devices(record, on_result=respond)
        if outcomes:
            return
        # No dispatch targets -- the program has no device mapping, so nothing
        # could fail. Keep the existing any-LFDI ACTIVE announcement rather than
        # leaving the server with no notice that the event started.
        await self._post_response(
            record.derc,
            ResponseCode.ACTIVE,
            record.program_href,
            exclude_devices=record.superseded_devices or None,
        )

    async def _apply_control_to_devices(
        self,
        record: EventRecord,
        on_result: Callable[[str, BaseException | None], Awaitable[None]] | None = None,
    ) -> dict[str, BaseException | None]:
        """Apply a DERControl to all devices in the program's group (parallel).

        Returns the per-device outcome keyed by device href: ``None`` where the
        control was applied, otherwise the failure. ``on_result`` is awaited for
        each device as soon as *that* device's dispatch settles, so a caller can
        post its response without waiting on the rest of the fleet.
        """
        devices = self._unique_devices(record.program_href)
        derp_state = self._state.der_programs.get(record.program_href)
        curves = derp_state.der_curves if derp_state else []
        mrid_short = record.mrid.hex()[:8]
        targets = [d for d in devices if d not in record.superseded_devices]
        logger.debug("Event %s: applying control to %d device(s)", mrid_short, len(targets))
        if not targets:
            return {}

        # Track active modes per device for per-mode DDERC fallback
        active_modes = get_active_mode_names(record.derc.dercontrol_base)
        for dev_href in targets:
            self._mode_tracker.register(dev_href, record.mrid, active_modes)

        # A caller may supply a ``group_lookup`` that resolves one
        # server-side EndDevice href to several local sub-device LFDIs,
        # in which case dispatch is by LFDI. Unset by default.
        local_lfdis = self._group_local_lfdis(record.program_href)
        if local_lfdis is not None:
            return await self._dispatch_by_lfdi_group(
                record, local_lfdis, targets, curves, on_result
            )

        async def dispatch_and_report(dev_href: str) -> tuple[str, BaseException | None]:
            failure = await self._dispatch_one(
                mrid_short,
                dev_href,
                self._dispatcher.apply_control(dev_href, record.derc, curves),
                record=record,
            )
            if on_result is not None:
                await on_result(dev_href, failure)
            return dev_href, failure

        pairs = await asyncio.gather(*[dispatch_and_report(d) for d in targets])
        return dict(pairs)

    def _log_late_dispatch(
        self,
        mrid_short: str,
        label: str,
        task: asyncio.Task[None],
        record: EventRecord | None = None,
    ) -> None:
        """Consume and report a dispatch that finished after its rejection was sent.

        Retrieving the exception is the point: nothing awaits these tasks once
        the ceiling has fired, so a failure would sit unretrieved until GC and
        surface as a bare "Task exception was never retrieved". It also answers
        the question the rejection left open -- whether the control eventually
        landed on the device.
        """
        if task.cancelled():
            return
        exc = task.exception()
        if record is not None and self._audit.enabled:
            # The server was told this device rejected the event; this is the
            # record of whether the apply then reached it.
            try:
                self._audit.late_dispatch(
                    event_mrid=record.mrid,
                    lfdi=self._lfdi_or_href(label),
                    applied=exc is None,
                    at=self._timebase.now(self._fsa_scope(record.program_href)),
                    error=None if exc is None else str(exc),
                    error_type=None if exc is None else type(exc).__name__,
                )
            except Exception:
                logger.debug("Audit record for a late dispatch failed", exc_info=True)
        if exc is not None:
            logger.warning("Event %s: late dispatch to %s failed: %s", mrid_short, label, exc)
        else:
            logger.info(
                "Event %s: late dispatch to %s applied after its rejection was sent",
                mrid_short,
                label,
            )

    async def _dispatch_one(
        self,
        mrid_short: str,
        label: str,
        coro: Coroutine[Any, Any, None],
        *,
        record: EventRecord | None = None,
    ) -> BaseException | None:
        """Await one device's dispatch under the concurrency ceiling. Returns the failure.

        The ceiling bounds when this device's *response* goes out, not how long
        the control application gets. On expiry the apply is left running under
        the connector's own deadline -- shielded, so ``wait_for`` does not
        cancel it -- and a rejection is reported for the device now. A slow
        device therefore still receives the control; the client has simply
        stopped claiming the event started on it.
        """
        task = asyncio.ensure_future(coro)
        self._dispatch_tasks.add(task)
        task.add_done_callback(self._dispatch_tasks.discard)
        try:
            await asyncio.wait_for(asyncio.shield(task), timeout=ACTIVATION_DISPATCH_DEADLINE)
        except TimeoutError as exc:
            logger.warning(
                "Event %s: dispatch to %s still running after %.1fs; reporting a "
                "rejection now while the apply continues",
                mrid_short,
                label,
                ACTIVATION_DISPATCH_DEADLINE,
            )
            # Past the ceiling nothing awaits this task, so a later failure would
            # sit unretrieved until GC and surface with no context.
            task.add_done_callback(
                lambda finished: self._log_late_dispatch(mrid_short, label, finished, record)
            )
            return exc
        except Exception as exc:
            logger.warning("Event %s: dispatch to %s failed: %s", mrid_short, label, exc)
            return exc
        return None

    async def _dispatch_by_lfdi_group(
        self,
        record: EventRecord,
        local_lfdis: list[str],
        targets: list[str],
        curves: list[Any],
        on_result: Callable[[str, BaseException | None], Awaitable[None]] | None,
    ) -> dict[str, BaseException | None]:
        """Dispatch by local sub-device LFDI and collapse onto the server's hrefs.

        A group lookup resolves one server-side EndDevice to N local
        sub-devices, but the server only knows the aggregate -- there is no
        response channel for an individual sub-device. So the aggregate is
        reported rejected only when *every* sub-device failed; a partial failure
        still performed the event, and reporting it as unsupported would
        misstate a fleet that largely complied. This collapse also forces a
        barrier here: the aggregate outcome is not known until all N return.
        """
        mrid_short = record.mrid.hex()[:8]
        failures = await asyncio.gather(
            *[
                self._dispatch_one(
                    mrid_short,
                    lfdi,
                    self._dispatcher.apply_control_by_lfdi(lfdi, record.derc, curves),
                    record=record,
                )
                for lfdi in local_lfdis
            ]
        )
        for lfdi, failure in zip(local_lfdis, failures, strict=True):
            (record.applied_local if failure is None else record.rejected_local).add(lfdi)
        failed = [f for f in failures if f is not None]
        aggregate = failed[0] if len(failed) == len(failures) else None
        if failed and aggregate is None:
            logger.info(
                "Event %s: %d/%d sub-devices failed; aggregate still reported ACTIVE",
                mrid_short,
                len(failed),
                len(failures),
            )
        if on_result is not None:
            for dev_href in targets:
                await on_result(dev_href, aggregate)
        return dict.fromkeys(targets, aggregate)

    def _fsa_scope(self, program_href: str) -> str | None:
        """Timebase scope for a program (IEEE 9.2.3): its discovering FSA."""
        ps = self._state.der_programs.get(program_href)
        return ps.discovered_from_fsa_href if ps else None

    def _now_fn_for(self, program_href: str) -> Callable[[], int]:
        """Clock for a program's timers: server timebase, FSA-scoped.

        The scope is bound at creation (a program's FSA parentage is stable
        for a record's lifetime) so the timer loop doesn't re-resolve state
        on every tick.
        """
        tb = self._timebase
        scope = self._fsa_scope(program_href)
        return lambda: int(tb.now(scope))

    def _group_local_lfdis(self, program_href: str) -> list[str] | None:
        """Return local sub-device LFDIs for ``program_href``, if grouped.

        Returns None when no ``group_lookup`` is configured or the callback
        finds no group for this program -- in which case the dispatcher
        dispatches 1-to-1 by server-side href, which is the default.
        """
        if self._group_lookup is None:
            return None
        local_lfdis = self._group_lookup(program_href)
        return local_lfdis if local_lfdis else None

    # ------------------------------------------------------------------
    # Schedule-notification relay (informational push; not a control path)
    # ------------------------------------------------------------------

    def _affected_lfdis_for_program(
        self, program_href: str, exclude_hrefs: set[str] | None = None
    ) -> list[str]:
        """Resolve the hex LFDIs a program's events scope to.

        A supplied group lookup resolves the local sub-device LFDIs
        (`_group_local_lfdis`, already hex `str`); otherwise the program's
        EndDevice hrefs map to LFDIs via `_get_device_lfdi` (`bytes` -> `.hex()`),
        excluding ``exclude_hrefs`` (superseded devices) the same way
        ``_apply_control_to_devices`` does, so the relayed scope matches dispatch.
        """
        local = self._group_local_lfdis(program_href)
        if local is not None:
            return list(local)
        exclude = exclude_hrefs or set()
        out: list[str] = []
        for dev_href in self._state.device_mapping.program_to_devices.get(program_href, []):
            if dev_href in exclude:
                continue
            lfdi = self._get_device_lfdi(dev_href)
            if lfdi is not None:
                out.append(lfdi.hex())
        return out

    def _audit_activation(self, record: EventRecord, *, from_state: str | None) -> None:
        """Record an event going active, once its dispatch has settled.

        Emitted after the per-device outcomes are in, not at the state change,
        so the applied and rejected lists are final. They are the outcome each
        device's dispatch returned, which is what the server was told. Under a
        group lookup they list the local devices one by one, so a partial
        failure the server's single response hides is visible here.
        """

        def outcomes() -> dict[str, Any]:
            if record.applied_local or record.rejected_local:
                return {
                    "applied_lfdis": list(record.applied_local),
                    "rejected_lfdis": list(record.rejected_local),
                }
            return {
                "applied_lfdis": self._lfdis_for_hrefs(record.applied_devices),
                "rejected_lfdis": self._lfdis_for_hrefs(record.rejected_devices),
            }

        self._audit_transition(record, from_state, EventState.ACTIVE.value, outcomes)

    def _audit_supersession(
        self, record: EventRecord, superseded_by: bytes, part: bool
    ) -> dict[str, Any]:
        """A ``superseded`` record's details; for a partial one, what was taken over.

        In the same terms as the rest of the trail: under a group lookup the
        server's EndDevice is the program's local devices, so each of them is
        listed with the modes taken over on the EndDevice.
        """
        if not part:
            return {"superseded_by": superseded_by}
        local = self._group_local_lfdis(record.program_href)
        if local is None:
            return {
                "superseded_by": superseded_by,
                "superseded_lfdis": self._lfdis_for_hrefs(record.superseded_devices),
                "superseded_modes": {
                    self._lfdi_or_href(dev): _wire_mode_names(modes)
                    for dev, modes in record.superseded_modes.items()
                },
            }
        modes: set[str] = set()
        for taken in record.superseded_modes.values():
            modes |= taken
        return {
            "superseded_by": superseded_by,
            "superseded_lfdis": list(local),
            "superseded_modes": (
                {lfdi: _wire_mode_names(modes) for lfdi in local} if modes else {}
            ),
        }

    def _audit_transition(
        self,
        record: EventRecord,
        from_state: str | None,
        to_state: str,
        details: Callable[[], dict[str, Any]] | None = None,
    ) -> None:
        """Hand one lifecycle transition to the audit trail.

        Never raises: the trail must not be able to stop an event from being
        processed, so a failure building the record is logged and dropped.
        ``details`` is a callable for the same reason -- what it computes
        resolves devices, and it runs here, inside the guard, rather than on
        the caller's control path.
        """
        if not self._audit.enabled:
            return
        if record.opted_out and to_state != "opted_out":
            # An opted-out event keeps its stored state, so supersession and
            # classification see it unchanged. Its trail has left that state.
            from_state = "opted_out"
        try:
            self._audit.event_transition(
                event_mrid=record.mrid,
                program_href=record.program_href,
                from_state=from_state,
                to_state=to_state,
                effective_start=record.start,
                effective_duration=record.duration,
                primacy=record.primacy,
                lfdis=self._affected_lfdis_for_program(record.program_href),
                at=self._timebase.now(self._fsa_scope(record.program_href)),
                **(details() if details is not None else {}),
            )
        except Exception:
            self._audit_failures += 1
            log = logger.warning if self._audit_failures == 1 else logger.debug
            log(
                "Audit record for event %s failed; further failures log at debug",
                record.mrid.hex()[:8],
                exc_info=True,
            )

    def _audit_unstored(
        self,
        derc: Dercontrol1,
        program_href: str,
        primacy: int,
        start: int,
        duration: int,
        to_state: str,
    ) -> None:
        """Record an event the store never holds: expired on arrival, or skipped
        in an opted-out window.

        Once per event. Neither is stored, so the next poll meets it again, and
        a trail that recorded it every poll would contradict the rule that a
        re-poll of an unchanged event records nothing.
        """
        if not self._audit.enabled:
            return
        mrid = derc.m_rid.value
        if mrid in self._audit_unstored_seen:
            return
        self._audit_unstored_seen[mrid] = program_href
        transient = EventRecord(
            mrid=mrid,
            derc=derc,
            program_href=program_href,
            primacy=primacy,
            state=EventState.CANCELLED,
            start=start,
            duration=duration,
            server_status_time=0,
        )
        self._audit_transition(transient, None, to_state)

    def _lfdi_or_href(self, dev_href: str) -> str:
        """A device as its hex LFDI, or its href when the LFDI is not known.

        The href is kept rather than dropping the device: an audit record that
        silently loses a device reads as the device never having been involved.
        """
        lfdi = self._get_device_lfdi(dev_href)
        return lfdi.hex() if lfdi is not None else dev_href

    def _lfdis_for_hrefs(self, hrefs: set[str]) -> list[str]:
        return [self._lfdi_or_href(h) for h in hrefs]

    def _relay_control_event(self, record: EventRecord, transition: str) -> None:
        """Relay a DERControl lifecycle change to affected connectors (stream=control)."""
        derc = record.derc
        current_status = derc.event_status.current_status if derc.event_status is not None else None
        randomization = (
            derc.randomize_start.value if derc.randomize_start is not None else None,
            derc.randomize_duration.value if derc.randomize_duration is not None else None,
        )
        notification = ScheduleNotification(
            stream="control",
            transition=transition,
            status=record.state.value,
            current_status=current_status,
            mrid=record.mrid.hex(),
            program_href=record.program_href,
            primacy=record.primacy,
            start=record.start,
            duration=record.duration,
            end=record.end,
            affected_lfdis=self._affected_lfdis_for_program(
                record.program_href, record.superseded_devices
            ),
            randomization=randomization,
            payload=serialize_der_control(derc),
        )
        # Lifecycle transitions are distinct events and always fire; the per-poll
        # "updated" refresh is content-deduped so an unchanged control doesn't re-relay.
        self._emit_relay(notification, dedup=(transition == "updated"))

        # `doe` projection: when this control carries CSIP-AUS envelope limits,
        # also emit a `doe` notification carrying only those limits, so an
        # optimizer can track operating envelopes without setpoint noise. Same
        # envelope/transition as `control`; deduped independently (keyed by
        # ("doe", mrid)) on the envelope alone -- a setpoint-only change updates
        # `control` but not `doe`.
        envelope = _extract_doe_envelope(derc.dercontrol_base)
        if envelope:
            doe = replace(notification, stream="doe", payload={"control_base": envelope})
            self._emit_relay(doe, dedup=(transition == "updated"))

    def _relay_default_baseline(self, program_href: str, derp_state: DerProgramState) -> None:
        """Relay a DefaultDERControl add/change (stream=default_baseline).

        Driven each `process_controls` pass; content-deduped so an unchanged
        baseline doesn't re-relay. First sighting -> ``default_added``, a content
        change -> ``default_updated``. A baseline has no EventStatus/interval, so
        status/timing fields are null.
        """
        dderc = derp_state.default_dercontrol
        if dderc is None:
            return
        mrid = dderc.m_rid.value.hex()
        # One baseline slot per program (not per mRID) so a DDERC mRID swap is a
        # "default_updated" and the cache stays bounded by program count.
        key = ("default_baseline", program_href)
        transition = "default_added" if key not in self._relay_snapshots else "default_updated"
        notification = ScheduleNotification(
            stream="default_baseline",
            transition=transition,
            status=None,
            current_status=None,
            mrid=mrid,
            program_href=program_href,
            primacy=derp_state.primacy,
            start=None,
            duration=None,
            end=None,
            affected_lfdis=self._affected_lfdis_for_program(program_href),
            randomization=(None, None),
            payload=serialize_default_der_control(dderc) or {},
        )
        self._emit_relay(notification, dedup=True, dedup_key=key)

    def _emit_relay(
        self,
        notification: ScheduleNotification,
        *,
        dedup: bool,
        dedup_key: tuple[str, str] | None = None,
    ) -> None:
        """Schedule the relay as a fire-and-forget task, applying fire-on-change.

        The content signature excludes ``transition`` so that the per-poll
        ``updated``/``default_updated`` path suppresses unchanged content while
        genuine lifecycle transitions (which the caller fires with ``dedup=False``)
        always go out.

        ``dedup_key`` overrides the snapshot key. Defaults to ``(stream, mrid)``
        (one slot per event). The baseline path passes ``(stream, program_href)``
        so a program keeps a single baseline slot regardless of mRID churn --
        bounding the cache to program count and turning a DDERC mRID swap into a
        ``default_updated`` rather than a fresh ``default_added`` + leaked slot.
        """
        # Actionable-content signature only: event identity, EventState,
        # effective interval, randomization, scope, and the DERControlBase.
        # Deliberately excludes transition, raw current_status, and the volatile
        # serialized fields (creation_time, event_status.date_time) that churn
        # each poll -- so an unchanged control isn't re-relayed as "updated".
        # ``mrid`` is included because the baseline key is program-scoped, so a
        # DDERC mRID swap must still register as a change.
        content_sig: tuple[Any, ...] = (
            notification.mrid,
            notification.status,
            notification.start,
            notification.duration,
            tuple(sorted(notification.affected_lfdis)),
            notification.randomization,
            json.dumps(notification.payload.get("control_base"), sort_keys=True, default=str),
        )
        key = dedup_key if dedup_key is not None else (notification.stream, notification.mrid)
        if dedup and self._relay_snapshots.get(key) == content_sig:
            return
        self._relay_snapshots[key] = content_sig
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:  # pragma: no cover - no running loop (unexpected sync path)
            return
        task = loop.create_task(
            self._dispatcher.relay_schedule_notification(
                list(notification.affected_lfdis), notification
            )
        )
        self._relay_tasks.add(task)
        task.add_done_callback(self._relay_tasks.discard)

    async def enter_comms_loss(self) -> None:
        """Opt out of every active event and revert devices to the planning limit.

        Loss-of-communications entry: each active event is marked
        opted-out, its timers cancelled, and its devices reverted to DDERC (or
        cleared to the connector safe-default where no DDERC exists). No IEEE
        2030.5 responses are posted -- comms are down. The resume boundary is
        raised to the latest opted-out event end so recovery resumes only the
        schedule that follows it. Events stay in ACTIVE state (the ``opted_out``
        flag is the marker), so supersession and classification are unaffected.
        """
        active = [r for r in self._store.by_state(EventState.ACTIVE) if not r.opted_out]
        if not active:
            return
        boundary = self._comms_loss.resume_after_epoch or 0
        self._comms_loss.resume_after_epoch = max(boundary, max(r.end for r in active))
        # Flag all opted-out first so per-record reverts see no "other active"
        # event still covering a shared device.
        for record in active:
            record.opted_out = True
            self._timer_mgr.cancel(record.mrid)
            self._audit_transition(record, EventState.ACTIVE.value, "opted_out")
        for record in active:
            await self._apply_dderc_fallback(record, clear_if_no_dderc=True)

    async def _opt_out_event(self, record: EventRecord, *, from_state: str | None) -> None:
        """Opt a single event out (comms-loss gate on activation/late discovery).

        Marks the event opted-out, raises the resume boundary to its end,
        cancels its timers, and reverts its devices to DDERC/clear. No response
        is posted.

        ``from_state`` is the state the audit record reports the event leaving:
        ``scheduled`` at activation, ``None`` for an event first seen already
        running.
        """
        record.opted_out = True
        self._audit_transition(record, from_state, "opted_out")
        boundary = self._comms_loss.resume_after_epoch or 0
        self._comms_loss.resume_after_epoch = max(boundary, record.end)
        self._timer_mgr.cancel(record.mrid)
        await self._apply_dderc_fallback(record, clear_if_no_dderc=True)

    async def _apply_dderc_fallback(
        self, record: EventRecord, *, clear_if_no_dderc: bool = False
    ) -> None:
        """Apply per-mode DDERC fallback for modes released by the completing event.

        When an event completes/cancels/supersedes, unregister its modes from the
        tracker. Devices whose released modes are not covered by any other active
        event get DDERC applied. If no modes were tracked (empty DercontrolBase),
        fall back to checking whether other active events cover the device.

        ``clear_if_no_dderc`` is the comms-loss opt-out failsafe: a device
        with no DDERC on any program is dispatched a connector clear instead of
        being left untouched. Opted-out events (``record.opted_out``) are ignored
        when deciding whether another active event still covers a device, so
        opting out every active event reverts the device to its planning limit.
        """
        all_devices = self._unique_devices(record.program_href)
        # Asked in the vocabulary the supersession answered in. ``superseded_modes``
        # is recorded from the keys supersession compares, which leave out connect
        # and energize and do not reach extension elements, so measuring it against
        # every mode the event sets would read a device taken over whole as only
        # partly taken over -- for an event carrying nothing but CSIP-AUS limits,
        # always.
        owned = compared_mode_keys(record.derc.dercontrol_base)
        active_events = [r for r in self._store.by_state(EventState.ACTIVE) if not r.opted_out]
        # Devices with no DDERC anywhere: cleared to the connector safe-default
        # only in the comms-loss opt-out flow; the completion path leaves
        # them untouched as before.
        clear_targets: list[tuple[str, bytes]] = []

        # Unregister modes and compute per-device fallback needs
        needs_fallback: list[str] = []
        released_modes: dict[str, frozenset[str]] = {}
        for dev_href in all_devices:
            # Unregistered for every device, superseded ones included. Leaving
            # the registration would have the tracker believe this event still
            # owns those modes, so the event that took them over would release
            # nothing when it ends and they would stay on the device for good.
            released = self._mode_tracker.unregister(dev_href, record.mrid)
            released_modes[dev_href] = released
            # Dispatching is the separate question. A device another event took
            # over *entirely* is left to it: its activation is the write that
            # moves the device, and a fallback here would name this event as the
            # cause of a setpoint it no longer sets. Entirely, though -- a device
            # goes into ``superseded_devices`` when another event takes over any
            # one of this event's modes there, and the modes that event never
            # took are still this event's to release. Compared per mode.
            # ... and only while another event is actually running on the device.
            # Once the event that took it over has ended too, nothing is left to
            # bring the device back, so the last event to end releases it.
            if (
                dev_href in record.superseded_devices
                and record.superseded_modes.get(dev_href, frozenset()) >= owned
                and self._device_has_other_active(dev_href, record.mrid, active_events)
            ):
                continue
            if released:
                # Some modes were released and not covered by other events
                needs_fallback.append(dev_href)
            elif not self._device_has_other_active(dev_href, record.mrid, active_events):
                # No modes tracked (empty base) but no other active events either
                needs_fallback.append(dev_href)

        if not needs_fallback:
            return

        # For each device, find the highest-priority program with a DDERC.
        # A shared device may belong to multiple programs; on event completion,
        # it should revert to the best (lowest primacy number) DDERC available.
        dderc_targets: list[tuple[str, bytes, DerProgramState, DefaultDercontrol, bool]] = []
        # Modes the ended event set that the default does not carry. The
        # default only writes the modes it names, so these would stay in
        # effect on the device unless they are disabled here.
        withdraw_targets: list[tuple[str, frozenset[str]]] = []
        # Modes carried by the default of a device that falls back to one.
        default_modes: set[str] = set()
        for dev_href in needs_fallback:
            lfdi = self._get_device_lfdi(dev_href)
            if lfdi is None:
                continue
            best = self._best_dderc_program_for_device(dev_href)
            released = released_modes.get(dev_href, frozenset())
            if best is None:
                if clear_if_no_dderc:
                    clear_targets.append((dev_href, lfdi))
                elif released:
                    logger.debug(
                        "Event %s: no DDERC for device %s, disabling the modes it released",
                        record.mrid.hex()[:8],
                        dev_href,
                    )
                    withdraw_targets.append((dev_href, released))
                else:
                    logger.debug(
                        "Event %s: no DDERC for device %s, no revert action taken",
                        record.mrid.hex()[:8],
                        dev_href,
                    )
                continue
            dderc = best.default_dercontrol
            assert dderc is not None  # guaranteed by _best_dderc_program_for_device
            carried = get_active_mode_names(dderc.dercontrol_base)
            # A default is the baseline for the modes no event is controlling,
            # and this project does not let it override one. The default is
            # written whole, so where it names a mode an event that is still
            # running holds, applying it would take that mode from the event
            # that owns it. Withhold it there and disable what this event
            # released instead: the event still holding the device keeps what
            # is its, and its own end brings the default back for everything at
            # once. Recorded as a decision rather than quoted from the standard
            # -- see the note on `_modes_running_in`.
            # The default belongs to ``best.href``, which need not be the ending
            # event's program: under a group lookup it fans out to *that*
            # program's local devices, so that is what the write reaches.
            held = self._modes_running_in(
                self._reach_of(best.href, [dev_href]), active_events
            )
            taken = carried & held
            if taken:
                logger.debug(
                    "Event %s: sending the default for %s without %s, which "
                    "another running event is controlling",
                    record.mrid.hex()[:8],
                    dev_href,
                    sorted(taken),
                )
            # Taken here rather than in the dispatch loop below, which is still
            # before any await: a refresh during dispatch can replace the
            # program's default, and recording the replacement would have the
            # next pass skip a default the device never received.
            effective = self._default_without(dderc, taken) if taken else dderc
            written = carried - taken
            default_modes |= written
            uncovered = released - written
            if uncovered:
                withdraw_targets.append((dev_href, uncovered))
            # Nothing left to send only when the default had modes and every one
            # of them is being controlled. A default that names nothing still
            # goes out: it always did, and masking cannot have emptied it.
            emptied = bool(carried) and not written
            if not emptied and self._dderc_tracker.should_apply(lfdi, best.href, best.primacy):
                dderc_targets.append((dev_href, lfdi, best, effective, bool(taken)))

        # Dispatch DDERC in parallel, then update tracker for successes.
        # With a group lookup supplied, each server-side EDev resolves
        # to the program's local sub-device LFDIs; dispatch by LFDI then.
        # Cache the lookup per target so a mid-await groups rebuild can't
        # change the fan-out width between the build and attribution loops.
        if dderc_targets:
            plans: list[
                tuple[str, bytes, DerProgramState, list[str] | None, DefaultDercontrol, bool]
            ] = [
                (
                    dev_href,
                    lfdi,
                    best_prog,
                    self._group_local_lfdis(best_prog.href),
                    effective,
                    is_partial,
                )
                for dev_href, lfdi, best_prog, effective, is_partial in dderc_targets
            ]
            # Both reasons land on the same DDERC write, and they read very
            # differently in an audit trail: "an event ended so the default came
            # back" versus "the upstream went away so we fell to the planning
            # limit". Distinguishing them here is what makes the recorded origin
            # answer why a setpoint moved.
            origin = (
                CommandOrigin.COMMS_LOSS if self._comms_loss.active else CommandOrigin.DDERC_REAPPLY
            )
            coros: list[Coroutine[Any, Any, None]] = []
            for dev_href, _, best_prog, local_lfdis, dderc, _is_partial in plans:
                if local_lfdis is not None:
                    coros.extend(
                        self._dispatcher.apply_default_control_by_lfdi(
                            lfdi,
                            dderc,
                            best_prog.der_curves,
                            origin=origin,
                            cause_mrid=record.mrid,
                        )
                        for lfdi in local_lfdis
                    )
                else:
                    coros.append(
                        self._dispatcher.apply_default_control(
                            dev_href,
                            dderc,
                            best_prog.der_curves,
                            origin=origin,
                            cause_mrid=record.mrid,
                        )
                    )
            results = await asyncio.gather(*coros, return_exceptions=True)

            # The tracker is keyed by the server-side device LFDI (one entry
            # per dderc_target); collapse per-fan-out results down to one
            # success per target so the tracker isn't double-recorded.
            idx = 0
            for dev_href, lfdi, best_prog, local_lfdis, dderc, is_partial in plans:
                n = len(local_lfdis) if local_lfdis is not None else 1
                target_results = results[idx : idx + n]
                idx += n
                failure: Exception | None = next(
                    (r for r in target_results if isinstance(r, Exception)), None
                )
                if failure is not None:
                    logger.warning(
                        "Event %s: DDERC dispatch to %s failed: %s",
                        record.mrid.hex()[:8],
                        dev_href,
                        failure,
                    )
                elif not is_partial:
                    # The default's mRID, not the ended event's: the
                    # initial-default pass compares against it to skip a
                    # default that is already in place. Not recorded for a
                    # default sent without some of its modes -- the device does
                    # not have that default, and the event still running has to
                    # be able to bring the whole of it back when it ends.
                    self._dderc_tracker.record_application(
                        lfdi, best_prog.href, dderc.m_rid.value, best_prog.primacy
                    )

        if withdraw_targets:
            # Same fan-out as the DDERC path: by local LFDI when a group
            # lookup maps the server-side device to sub-devices, else by href.
            local_lfdis = self._group_local_lfdis(record.program_href)
            withdraw_coros: list[Coroutine[Any, Any, None]] = []
            if local_lfdis is not None:
                # A group lookup is keyed by program, so every server-side
                # device of the program stands for the same local devices. A
                # mode is disabled on them only when no server-side device
                # still needs it: none holds it from another event, and no
                # default restored above carries it. Sending each device's
                # released modes to every local device would disable a mode
                # that another event still controls there.
                still_needed = set(default_modes)
                for dev_href in all_devices:
                    still_needed |= self._mode_tracker.covered(dev_href, excluding=record.mrid)
                modes_for_program: frozenset[str] = (
                    frozenset().union(*(modes for _dev_href, modes in withdraw_targets))
                    - still_needed
                )
                if modes_for_program:
                    withdraw_coros.extend(
                        self._dispatcher.disable_modes_by_lfdi(
                            local, modes_for_program, cause_mrid=record.mrid
                        )
                        for local in dict.fromkeys(local_lfdis)
                    )
            else:
                withdraw_coros.extend(
                    self._dispatcher.disable_modes(dev_href, modes, cause_mrid=record.mrid)
                    for dev_href, modes in withdraw_targets
                )
            withdraw_results = await asyncio.gather(*withdraw_coros, return_exceptions=True)
            for failure in (r for r in withdraw_results if isinstance(r, Exception)):
                logger.warning(
                    "Event %s: disabling released modes failed: %s",
                    record.mrid.hex()[:8],
                    failure,
                )

        if clear_targets:
            # No DDERC to fall back to: clear the device to the connector
            # safe-default. Any fan-out is per-program (the same grouping
            # as the DDERC path above), so it is
            # built once -- not per target device, which would dispatch
            # duplicate clears to every local sub-device. Otherwise clear each
            # server-side device href directly.
            local_lfdis = self._group_local_lfdis(record.program_href)
            clear_coros: list[Coroutine[Any, Any, None]] = []
            if local_lfdis is not None:
                # dict.fromkeys: group_lookup and DeviceMapping.add don't dedup,
                # so guard against dispatching the same clear twice.
                clear_coros.extend(
                    self._dispatcher.clear_control_by_lfdi(local, cause_mrid=record.mrid)
                    for local in dict.fromkeys(local_lfdis)
                )
            else:
                clear_coros.extend(
                    self._dispatcher.clear_control(dev_href, cause_mrid=record.mrid)
                    for dev_href in dict.fromkeys(dev_href for dev_href, _lfdi in clear_targets)
                )
            clear_results = await asyncio.gather(*clear_coros, return_exceptions=True)
            for failure in (r for r in clear_results if isinstance(r, Exception)):
                logger.warning(
                    "Event %s: comms-loss clear dispatch failed: %s",
                    record.mrid.hex()[:8],
                    failure,
                )

    async def _apply_initial_dderc(self, program_href: str) -> None:
        """Apply DDERC to devices with no active events from any program.

        Called at the end of process_controls to ensure devices are in their
        default operating state when no event overrides it. Only applies if:
        - The program has a DefaultDERControl
        - The device has no ACTIVE event from ANY program
        - No wind-down timers are pending (cancelled event with delayed fallback)
        - The DDERC hasn't already been applied (tracked by DdercTracker)
        """
        derp_state = self._state.der_programs.get(program_href)
        if derp_state is None or derp_state.default_dercontrol is None:
            logger.debug(
                "Initial DDERC skip %s: no DDERC (state=%s, dderc=%s)",
                program_href,
                derp_state is not None,
                derp_state.default_dercontrol is not None if derp_state else False,
            )
            return

        # Skip if any cancelled event from this program still has pending
        # timers (wind-down delay before DDERC fallback).
        cancelled = self._store.by_state(EventState.CANCELLED)
        if any(
            evt.program_href == program_href and self._timer_mgr.has_pending(evt.mrid)
            for evt in cancelled
        ):
            logger.debug("Initial DDERC skip %s: cancelled event with pending timer", program_href)
            return

        devices = self._unique_devices(program_href)
        if not devices:
            logger.debug("Initial DDERC skip %s: no devices in mapping", program_href)
            return

        # Build set of devices that have an ACTIVE event in ANY program.
        # These devices are already under event control and must not get DDERC.
        # Opted-out events (comms-loss) don't control their devices -- the device
        # was reverted to DDERC -- so they're excluded here, consistent with
        # _apply_dderc_fallback's "other active" check. A device the event was
        # *rejected* on is the same situation and excluded for the same reason:
        # the control was refused, so nothing is holding that device, and without
        # this it would sit under no control at all for the event's whole duration
        # rather than under its default.
        active_events = [e for e in self._store.by_state(EventState.ACTIVE) if not e.opted_out]
        active_devices: set[str] = set()
        for evt in active_events:
            evt_devs = self._state.device_mapping.program_to_devices.get(evt.program_href, [])
            for d in evt_devs:
                if d not in evt.superseded_devices and d not in evt.rejected_devices:
                    active_devices.add(d)

        curves = derp_state.der_curves
        dderc_mrid = derp_state.default_dercontrol.m_rid.value

        targets: list[tuple[str, bytes]] = []
        for dev_href in devices:
            if dev_href in active_devices:
                continue
            lfdi = self._get_device_lfdi(dev_href)
            if lfdi is None:
                continue

            if self._dderc_tracker.should_apply_initial(
                lfdi, program_href, dderc_mrid, derp_state.primacy
            ):
                targets.append((dev_href, lfdi))
            else:
                logger.debug(
                    "Initial DDERC skip %s: tracker blocked for %s (primacy=%d)",
                    program_href,
                    dev_href,
                    derp_state.primacy,
                )

        if not targets:
            logger.debug("Initial DDERC skip %s: no eligible devices", program_href)
            return

        # A device with an active event is not a target, which is decided one
        # server-side device at a time. Under a group lookup the write is not
        # sent that way: each target fans out to every local device of the
        # program, so a device correctly left out as a target is written to
        # anyway as part of another target's fan-out. Send the default without
        # the modes those events are running, as the end-of-event fallback does.
        held = self._modes_running_in(
            self._reach_of(program_href, [device for device, _ in targets]), active_events
        )
        carried = get_active_mode_names(derp_state.default_dercontrol.dercontrol_base)
        taken = carried & held
        if carried and not (carried - taken):
            logger.debug(
                "Initial DDERC skip %s: every mode it names is being controlled",
                program_href,
            )
            return
        dderc = (
            self._default_without(derp_state.default_dercontrol, taken)
            if taken
            else derp_state.default_dercontrol
        )
        if taken:
            logger.debug(
                "Initial DDERC from %s sent without %s, which a running event controls",
                program_href,
                sorted(taken),
            )

        logger.info(
            "Applying initial DDERC from %s to %d device(s)",
            program_href,
            len(targets),
        )
        # With a group lookup supplied, each server-side EDev resolves
        # to the program's local sub-device LFDIs.
        local_lfdis = self._group_local_lfdis(program_href)
        coros: list[Coroutine[Any, Any, None]] = []
        for dev_href, _ in targets:
            if local_lfdis is not None:
                coros.extend(
                    self._dispatcher.apply_default_control_by_lfdi(lfdi, dderc, curves)
                    for lfdi in local_lfdis
                )
            else:
                coros.append(self._dispatcher.apply_default_control(dev_href, dderc, curves))
        results = await asyncio.gather(*coros, return_exceptions=True)

        per_target_n = len(local_lfdis) if local_lfdis is not None else 1
        idx = 0
        for dev_href, lfdi in targets:
            target_results = results[idx : idx + per_target_n]
            idx += per_target_n
            failure: Exception | None = next(
                (r for r in target_results if isinstance(r, Exception)), None
            )
            if failure is not None:
                logger.warning("Initial DDERC dispatch to %s failed: %s", dev_href, failure)
            else:
                self._dderc_tracker.record_application(
                    lfdi, program_href, dderc_mrid, derp_state.primacy
                )

    @staticmethod
    def _default_without(dderc: DefaultDercontrol, modes: frozenset[str]) -> DefaultDercontrol:
        """The default with ``modes`` taken out of it.

        A default is written whole, so the only way to let it set the modes no
        event is controlling without disturbing the ones an event is, is to
        send a copy that does not name them. Base modes are cleared by field
        name and the CSIP-AUS limits by element name, which is the pair
        :func:`get_active_mode_names` returns them in.
        """
        base = dderc.dercontrol_base
        cleared: dict[str, Any] = {
            name: None for name in modes if name in type(base).model_fields
        }
        kept = [
            element
            for element in (base.other_element or [])
            if doe_element_name(element) not in modes
        ]
        if len(kept) != len(base.other_element or []):
            cleared["other_element"] = kept
        return dderc.model_copy(update={"dercontrol_base": base.model_copy(update=cleared)})

    def _reach_of(self, program_href: str, devices: Sequence[str]) -> frozenset[str]:
        """What a write made for ``devices`` of ``program_href`` actually touches.

        Under a group lookup a program's write goes to its group's local
        devices whichever of its server-side devices it was decided for, so the
        group is the reach and the server-side device it was decided for says
        nothing about where it lands. Without one the write goes to the device
        it names. The two are tagged apart because a local LFDI and a device
        href are both strings and nothing stops them colliding.
        """
        local_lfdis = self._group_local_lfdis(program_href)
        if local_lfdis is not None:
            return frozenset(f"lfdi:{lfdi}" for lfdi in local_lfdis)
        return frozenset(f"href:{device}" for device in devices)

    def _modes_running_in(
        self, reach: frozenset[str], active_events: list[EventRecord]
    ) -> frozenset[str]:
        """The modes running events hold on anything ``reach`` touches.

        Asked of every active event rather than of a device list, because a
        group lookup maps *programs* to local devices: another program can
        cover server-side devices this one never names and still resolve to the
        same local device, and its modes are as much at risk from a write that
        lands there.
        """
        held: set[str] = set()
        for record in active_events:
            devices = list(self._state.device_mapping.program_to_devices.get(
                record.program_href, []
            ))
            if not reach & self._reach_of(record.program_href, devices):
                continue
            # Its registrations on the devices this write touches, not on all
            # of the program's. Without a group lookup the write lands on one
            # device, and an event registered on that program's other devices
            # may not be registered on this one -- rejected there, or
            # superseded there before it started. Counting those would hold a
            # mode back on a device nothing is controlling it on, and the
            # device would sit under neither the event nor the default.
            # With a group lookup the registrations are keyed by server-side
            # device while the write lands on local ones, so there is no
            # per-device correspondence to make and all of them count.
            one = frozenset({record.mrid})
            grouped = self._group_local_lfdis(record.program_href) is not None
            for device in devices:
                if grouped or f"href:{device}" in reach:
                    held |= self._mode_tracker.covered_among(device, one)
        return frozenset(held)

    def _device_has_other_active(
        self, dev_href: str, exclude_mrid: bytes, active_events: list[EventRecord]
    ) -> bool:
        """Check if any active event (other than exclude_mrid) applies to dev_href.

        A device the event was rejected on is not under its control -- the same
        reasoning that excludes superseded devices and opted-out events. Counting
        it would withhold the DDERC fallback on the strength of a control that was
        never applied.
        """
        for evt in active_events:
            if evt.mrid == exclude_mrid:
                continue
            prog_devices = self._state.device_mapping.program_to_devices.get(evt.program_href, [])
            if (
                dev_href in prog_devices
                and dev_href not in evt.superseded_devices
                and dev_href not in evt.rejected_devices
            ):
                return True
        return False

    def _best_dderc_program_for_device(self, dev_href: str) -> DerProgramState | None:
        """Find the highest-priority program with a DDERC that covers this device."""
        best: DerProgramState | None = None
        for prog_href, prog_devices in self._state.device_mapping.program_to_devices.items():
            if dev_href not in prog_devices:
                continue
            derp = self._state.der_programs.get(prog_href)
            if derp is None or derp.default_dercontrol is None:
                continue
            if best is None or derp.primacy < best.primacy:
                best = derp
        return best

    def _unique_devices(self, program_href: str) -> list[str]:
        """The program's devices, each once, in discovery order.

        Every caller of this treats the list as dispatch targets, and a device
        listed twice is written to twice -- which for a real connector means two
        writes to hardware. ``DeviceMapping.add`` no longer admits a repeated
        pair, so this guards the case it cannot: state populated by an embedding
        application rather than by discovery.

        dict.fromkeys rather than a set because order is discovery order, which
        the response fan-out reports in.
        """
        devices = self._state.device_mapping.program_to_devices.get(program_href, [])
        return list(dict.fromkeys(devices))

    def _get_device_lfdi(self, dev_href: str) -> bytes | None:
        """Look up LFDI for a device href."""
        edev_state = self._state.end_devices.get(dev_href)
        if edev_state is not None:
            return edev_state.lfdi
        return None

    async def _post_response(
        self,
        derc: object,
        code: ResponseCode,
        program_href: str | None = None,
        exclude_devices: set[str] | None = None,
    ) -> None:
        """Post a DER response for each device in the program.

        Multi-device conformance (AGG tests) requires one response per device.
        The ResponseTracker deduplicates on ``(mrid, code, lfdi)`` so each
        device gets exactly one response per code.

        Args:
            derc: The DERControl being responded to
            code: The response code
            program_href: The program href containing the control (used to find devices)
            exclude_devices: Device hrefs to skip (e.g. superseded devices)
        """
        if not isinstance(derc, Dercontrol1):
            return

        devices = (
            self._state.device_mapping.program_to_devices.get(program_href, [])
            if program_href
            else []
        )

        if not devices:
            # No program mapping — fall back to any available LFDI.
            if exclude_devices:
                # The fallback guesses a device. With an exclusion set in play we
                # cannot check the guess against it (the mapping that would
                # resolve href -> LFDI is the thing that is missing), and posting
                # a lifecycle code to a device that was superseded or rejected
                # misreports the event. Silence is the safer error here.
                logger.warning(
                    "No device mapping for %s and %d device(s) excluded; skipping the "
                    "any-LFDI %s fallback rather than risk posting to an excluded device",
                    program_href,
                    len(exclude_devices),
                    code.name,
                )
                return
            lfdi = self._get_any_lfdi()
            if lfdi is None:
                logger.warning("No device LFDI available for response posting")
                return
            await post_der_response(
                self._http,
                derc,
                code,
                lfdi,
                self._response_tracker,
                now_ts=int(self._timebase.now()),
            )
            return

        for dev_href in devices:
            if exclude_devices and dev_href in exclude_devices:
                continue
            lfdi = self._get_device_lfdi(dev_href)
            if lfdi is not None:
                await post_der_response(
                    self._http,
                    derc,
                    code,
                    lfdi,
                    self._response_tracker,
                    now_ts=int(self._timebase.now()),
                )

    async def _post_response_for_devices(
        self, derc: object, code: ResponseCode, device_hrefs: frozenset[str] | set[str]
    ) -> None:
        """Post a DER response for specific devices only."""
        if not isinstance(derc, Dercontrol1):
            return
        for dev_href in device_hrefs:
            lfdi = self._get_device_lfdi(dev_href)
            if lfdi is not None:
                await post_der_response(
                    self._http,
                    derc,
                    code,
                    lfdi,
                    self._response_tracker,
                    now_ts=int(self._timebase.now()),
                )

    def _get_any_lfdi(self) -> bytes | None:
        """Get any available device LFDI for response posting."""
        for edev in self._state.end_devices.values():
            return edev.lfdi
        return None

    def _find_successive_predecessor(self, program_href: str, raw_start: int) -> EventRecord | None:
        """Find an event in the same program whose raw end equals raw_start.

        Used for IEEE 10.2.2.3 rule m) successive events detection.
        """
        for record in self._store.all_active_states():
            if record.program_href != program_href:
                continue
            # Check if raw end of predecessor matches raw start of this event
            raw_end = record.derc.interval.start.value + record.derc.interval.duration
            if raw_end == raw_start:
                return record
        return None

    @staticmethod
    def _is_server_cancelled(derc: object) -> bool:
        if not isinstance(derc, Dercontrol1):
            return False
        return derc.event_status is not None and derc.event_status.current_status == 2
closed property
closed: bool

Whether this processor has been shut down.

process_controls async
process_controls(program_href: str) -> None

Process all DERControls for a given DERProgram.

Called after polling discovers or refreshes DERControl data.

Source code in src/py20305/events/processor.py
async def process_controls(self, program_href: str) -> None:
    """Process all DERControls for a given DERProgram.

    Called after polling discovers or refreshes DERControl data.
    """
    if self._closed:
        return

    derp_state = self._state.der_programs.get(program_href)
    if derp_state is None:
        return

    # Relay the program's DefaultDERControl baseline (add/change only).
    self._relay_default_baseline(program_href, derp_state)

    # Server timebase (FSA-scoped): classification against server-epoch
    # eff_start/eff_end must follow the head-end's clock.
    now = int(self._timebase.now(self._fsa_scope(program_href)))
    primacy = derp_state.primacy
    n_controls = len(derp_state.der_controls)
    logger.debug(
        "Processing %d controls for program %s (primacy=%d)",
        n_controls,
        program_href,
        primacy,
    )

    for derc in derp_state.der_controls:
        mrid = derc.m_rid.value
        mrid_short = mrid.hex()[:8]
        interval = derc.interval
        raw_start = interval.start.value
        raw_duration = interval.duration

        # Randomize timing
        start_off, dur_off = self._rand_cache.get_offsets(derc, raw_start, raw_duration)

        # IEEE 10.2.2.3 rule m): successive events must not have gaps
        # from randomization. If a predecessor's raw end == this raw start,
        # override this event's effective start to match predecessor's
        # effective end.
        predecessor = self._find_successive_predecessor(program_href, raw_start)
        if predecessor is not None:
            new_start_off = predecessor.end - raw_start
            if new_start_off != start_off:
                logger.debug(
                    "Successive event %s: adjusting start offset %d -> %d "
                    "(predecessor %s eff_end=%d)",
                    mrid_short,
                    start_off,
                    new_start_off,
                    predecessor.mrid.hex()[:8],
                    predecessor.end,
                )
                start_off = new_start_off
                self._rand_cache.override_start_offset(mrid, start_off, raw_start, raw_duration)

        eff_start = raw_start + start_off
        eff_duration = raw_duration + dur_off

        # Server status time for tie-breaking
        server_status_time = 0
        if derc.event_status is not None and derc.event_status.date_time is not None:
            server_status_time = derc.event_status.date_time.value

        # Check if already tracked
        existing = self._store.get(mrid)
        if existing is not None:
            # Update the derc reference (server may have updated fields)
            existing.derc = derc
            existing.server_status_time = server_status_time
            if derc.creation_time is not None:
                existing.creation_time = derc.creation_time.value
            # Check server cancellation
            if self._is_server_cancelled(derc) and existing.state in (
                EventState.SCHEDULED,
                EventState.ACTIVE,
            ):
                logger.debug(
                    "Server cancelled event %s (was %s)", mrid_short, existing.state.value
                )
                await self._handle_cancellation(existing)
                if self._closed:
                    return
            else:
                # Server refreshed the control's fields without a state
                # change; relay only if the actionable content changed
                # (fire-on-change dedup in _emit_relay).
                self._relay_control_event(existing, "updated")
            continue

        # Classify new event
        eff_end = eff_start + eff_duration

        # Comms-loss recovery: skip events at or before the resume boundary
        # (the window we opted out of during the outage). Only later
        # schedules resume. The boundary outlives the mode: skipped events
        # are not stored, so it must gate every poll until the window has
        # elapsed -- the client clears it only once it is in the past.
        if (
            self._comms_loss.resume_after_epoch is not None
            and eff_start <= self._comms_loss.resume_after_epoch
        ):
            self._audit_unstored(
                derc, program_href, primacy, eff_start, eff_duration, "skipped"
            )
            logger.debug(
                "Skipping event %s in opted-out window (start=%d <= resume_after=%d)",
                mrid_short,
                eff_start,
                self._comms_loss.resume_after_epoch,
            )
            continue

        if now > eff_end:
            # IEEE 2030.5 §10.2.2.3 rule j: EXPIRED is only for an event whose
            # end is already past *when first received*. If we already
            # responded to this event (e.g. ran it to COMPLETED and the store
            # then pruned it after its end), a later poll re-discovering it
            # must NOT re-post EXPIRED -- it would contradict the COMPLETED we
            # already sent. (Response posting is also gated on responseRequired
            # inside post_der_response.)
            if self._response_tracker.has_responded(mrid):
                logger.debug(
                    "Skipping EXPIRED for already-handled event %s "
                    "(re-discovered %ds after end)",
                    mrid_short,
                    now - eff_end,
                )
                continue
            logger.debug(
                "Event %s already expired when received (ended %ds ago)",
                mrid_short,
                now - eff_end,
            )
            self._audit_unstored(
                derc, program_href, primacy, eff_start, eff_duration, "expired"
            )
            await self._post_response(derc, ResponseCode.EXPIRED, program_href)
            if self._closed:
                return
            continue

        state = EventState.ACTIVE if now >= eff_start else EventState.SCHEDULED
        logger.info(
            "New event %s: state=%s start=T+%ds duration=%ds primacy=%d",
            mrid_short,
            state.value,
            eff_start - now,
            eff_duration,
            primacy,
        )

        # IEEE 10.2.2.3 rule e): use creationTime for tie-breaking
        creation_time = 0
        if derc.creation_time is not None:
            creation_time = derc.creation_time.value

        record = EventRecord(
            mrid=mrid,
            derc=derc,
            program_href=program_href,
            primacy=primacy,
            state=state,
            start=eff_start,
            duration=eff_duration,
            server_status_time=server_status_time,
            creation_time=creation_time,
        )

        # Check server cancellation before adding
        if self._is_server_cancelled(derc):
            record.state = EventState.CANCELLED
            self._store.upsert(record)
            logger.info("Event %s pre-cancelled by server", mrid_short)
            self._audit_transition(record, None, "cancelled")
            self._relay_control_event(record, "cancelled")
            await self._post_response(derc, ResponseCode.CANCELLED, program_href)
            if self._closed:
                return
            continue

        self._store.upsert(record)
        # New event signals fresh state for affected devices — clear
        # stale DDERC tracker entries across ALL programs so initial
        # DDERC is re-evaluated (a device may belong to multiple programs).
        dev_hrefs = self._state.device_mapping.program_to_devices.get(program_href, [])
        affected_lfdis: set[bytes] = set()
        for dh in dev_hrefs:
            lfdi = self._get_device_lfdi(dh)
            if lfdi is not None:
                affected_lfdis.add(lfdi)
        if affected_lfdis:
            self._dderc_tracker.clear_devices(affected_lfdis)

        if state == EventState.SCHEDULED:
            await self._handle_new_scheduled(record)
        else:
            await self._handle_late_discovery(record, derp_state)
        if self._closed:
            return

    # IEEE 2030.5-2023 §10.2.2.3 rule p): clients SHALL consider events
    # removed from the server before the end of their Effective Scheduled
    # Period as cancelled. The 2023 edition notes this differs from earlier
    # revisions, where currentStatus=2 was the only cancellation signal.
    #
    # Gated on der_controls_complete: a failed or unparseable DERControl
    # fetch leaves the list empty, and that is indistinguishable from a
    # server that removed every control. Reconciling against an incomplete
    # snapshot would cancel every live event on one transient error.
    if derp_state.der_controls_complete:
        served = {derc.m_rid.value for derc in derp_state.der_controls}
        for record in self._store.all_active_states():
            if record.program_href != program_href or record.mrid in served:
                continue
            # "before the end of their Effective Scheduled Period" is part
            # of the rule, not a detail: a server may drop an event once it
            # is over, and such an event completed rather than being
            # cancelled. The record can still be ACTIVE here because
            # _on_completion blocks on _state_ready for the length of a
            # rediscovery, and a rediscovery ends by refreshing the list and
            # calling this method -- cancelling would report status 6 for an
            # event that ran its course and suppress the status 3 the
            # blocked completion still owes.
            if now >= record.end:
                continue
            logger.info(
                "Event %s removed from program %s while %s -- cancelling (rule p)",
                record.mrid.hex()[:8],
                program_href,
                record.state.value,
            )
            await self._handle_cancellation(record)
            if self._closed:
                return

    # Run supersession across all non-terminal events
    await self._run_supersession()
    if self._closed:
        return

    # Prune expired
    expired = self._store.prune_expired(now)
    # Drop relay snapshots for events that left the store so the
    # fire-on-change cache doesn't grow without bound (both the control and
    # its doe projection, if any).
    for expired_mrid in expired:
        self._relay_snapshots.pop(("control", expired_mrid.hex()), None)
        self._relay_snapshots.pop(("doe", expired_mrid.hex()), None)
    self._rand_cache.prune(now)
    self._forget_unstored_no_longer_served(program_href)
    self._response_tracker.prune(now)

    # Apply DDERC to devices that have no active events.
    # Per IEEE 2030.5 §10.10, the DefaultDERControl is the baseline
    # operating state and should be active whenever no event overrides it.
    await self._apply_initial_dderc(program_href)
cancel_program
cancel_program(program_href: str) -> None

Cancel all events from a removed program (Gap 3: IEEE 8.8.3).

Transitions all scheduled/active events from the given program to CANCELLED and cancels any pending timers.

Source code in src/py20305/events/processor.py
def cancel_program(self, program_href: str) -> None:
    """Cancel all events from a removed program (Gap 3: IEEE 8.8.3).

    Transitions all scheduled/active events from the given program to
    CANCELLED and cancels any pending timers.
    """
    cancelled_count = 0
    for record in self._store.all_active_states():
        if record.program_href == program_href:
            mrid_short = record.mrid.hex()[:8]
            logger.info("Cancelling event %s from removed program %s", mrid_short, program_href)
            from_state = record.state.value
            self._store.transition(record.mrid, EventState.CANCELLED)
            self._audit_transition(record, from_state, "cancelled")
            self._timer_mgr.cancel(record.mrid)
            # cancel_program is sync, but runs inside the event loop, so
            # _emit_relay's loop.create_task succeeds.
            self._relay_control_event(record, "cancelled")
            cancelled_count += 1
    # The program is gone -- drop its baseline relay slot so the cache stays
    # bounded by live program count.
    self._relay_snapshots.pop(("default_baseline", program_href), None)
    for mrid, program in list(self._audit_unstored_seen.items()):
        if program == program_href:
            del self._audit_unstored_seen[mrid]
    if cancelled_count:
        logger.info(
            "Cancelled %d event(s) from removed program %s", cancelled_count, program_href
        )
shutdown async
shutdown() -> None

Cancel all event timers + in-flight background tasks and mark closed.

Source code in src/py20305/events/processor.py
async def shutdown(self) -> None:
    """Cancel all event timers + in-flight background tasks and mark closed."""
    self._closed = True
    await self._timer_mgr.cancel_all()
    # Cancel/await fire-and-forget relay tasks and any control dispatch
    # still running past the activation ceiling, so shutdown is clean
    # (no "Task was destroyed but it is pending" warnings).
    background = list(self._relay_tasks) + list(self._dispatch_tasks)
    for task in background:
        task.cancel()
    if background:
        await asyncio.gather(*background, return_exceptions=True)
enter_comms_loss async
enter_comms_loss() -> None

Opt out of every active event and revert devices to the planning limit.

Loss-of-communications entry: each active event is marked opted-out, its timers cancelled, and its devices reverted to DDERC (or cleared to the connector safe-default where no DDERC exists). No IEEE 2030.5 responses are posted -- comms are down. The resume boundary is raised to the latest opted-out event end so recovery resumes only the schedule that follows it. Events stay in ACTIVE state (the opted_out flag is the marker), so supersession and classification are unaffected.

Source code in src/py20305/events/processor.py
async def enter_comms_loss(self) -> None:
    """Opt out of every active event and revert devices to the planning limit.

    Loss-of-communications entry: each active event is marked
    opted-out, its timers cancelled, and its devices reverted to DDERC (or
    cleared to the connector safe-default where no DDERC exists). No IEEE
    2030.5 responses are posted -- comms are down. The resume boundary is
    raised to the latest opted-out event end so recovery resumes only the
    schedule that follows it. Events stay in ACTIVE state (the ``opted_out``
    flag is the marker), so supersession and classification are unaffected.
    """
    active = [r for r in self._store.by_state(EventState.ACTIVE) if not r.opted_out]
    if not active:
        return
    boundary = self._comms_loss.resume_after_epoch or 0
    self._comms_loss.resume_after_epoch = max(boundary, max(r.end for r in active))
    # Flag all opted-out first so per-record reverts see no "other active"
    # event still covering a shared device.
    for record in active:
        record.opted_out = True
        self._timer_mgr.cancel(record.mrid)
        self._audit_transition(record, EventState.ACTIVE.value, "opted_out")
    for record in active:
        await self._apply_dderc_fallback(record, clear_if_no_dderc=True)

py20305.events.state_machine

Event state machine: EventState enum, EventRecord, and EventStore.

EventRecord dataclass

Single event tracked in the store, keyed by mRID.

Source code in src/py20305/events/state_machine.py
@dataclass
class EventRecord:
    """Single event tracked in the store, keyed by mRID."""

    mrid: bytes
    derc: Dercontrol1
    program_href: str
    primacy: int
    state: EventState
    start: int  # effective (post-randomization) epoch seconds
    duration: int  # effective (post-randomization) seconds
    server_status_time: int  # EventStatus.dateTime (legacy, kept for compat)
    creation_time: int = 0  # Event.creationTime for IEEE tie-breaking
    superseded_devices: set[str] = field(default_factory=set)
    superseded_modes: dict[str, frozenset[str]] = field(default_factory=dict)
    #: Devices whose control dispatch failed at activation, so they were sent a
    #: rejection (251/252/253) instead of ACTIVE. They never ran the event, so they
    #: are excluded from the COMPLETED response at its end.
    rejected_devices: set[str] = field(default_factory=set)
    #: Devices that were told the event started -- their dispatch returned
    #: successfully. COMPLETED is posted to exactly this set, rather than to the
    #: program's current device list minus the rejected ones: the device list can
    #: change under a mid-event rediscovery, and a device that was never told
    #: ACTIVE must not be told the event completed. Empty alongside an empty
    #: ``rejected_devices`` means dispatch never had a target at all, which is the
    #: one case where the any-LFDI fallback still announces the lifecycle.
    applied_devices: set[str] = field(default_factory=set)
    #: Under a group lookup, each local device's dispatch outcome by its LFDI,
    #: for the audit trail. The two sets above are in the server's terms: one
    #: EndDevice stands for several local devices, its response reports them as
    #: one, and a partial failure there still reads as applied. Empty without a
    #: group lookup, where the sets above already name each device.
    applied_local: set[str] = field(default_factory=set)
    rejected_local: set[str] = field(default_factory=set)
    #: Set when the event was opted out during loss-of-communications mode: its
    #: control is not applied (or is reverted to DDERC) and no response is
    #: posted. Stays set until the record is pruned (recovery clears only the
    #: shared CommsLossState, not this flag) so the event never resumes and stays
    #: excluded from "other active" DDERC checks. The state stays SCHEDULED/ACTIVE
    #: so supersession/classification are unaffected.
    opted_out: bool = False

    @property
    def end(self) -> int:
        return self.start + self.duration

EventStore

Single-dict event store keyed by mRID.

Replaces the original's 4+ separate dicts, eliminating move-related races.

Source code in src/py20305/events/state_machine.py
class EventStore:
    """Single-dict event store keyed by mRID.

    Replaces the original's 4+ separate dicts, eliminating move-related races.
    """

    def __init__(self) -> None:
        self._events: dict[bytes, EventRecord] = {}

    def upsert(self, record: EventRecord) -> EventRecord:
        """Insert or update an event record. Returns the stored record."""
        self._events[record.mrid] = record
        return record

    def get(self, mrid: bytes) -> EventRecord | None:
        return self._events.get(mrid)

    def by_state(self, state: EventState) -> list[EventRecord]:
        return [r for r in self._events.values() if r.state == state]

    def transition(self, mrid: bytes, new_state: EventState) -> EventRecord:
        """Transition an event to a new state. Raises KeyError if not found."""
        record = self._events[mrid]
        record.state = new_state
        return record

    def all_active_states(self) -> list[EventRecord]:
        """Return events that are scheduled or active (for supersession)."""
        return [
            r for r in self._events.values() if r.state in (EventState.SCHEDULED, EventState.ACTIVE)
        ]

    def prune_expired(self, now: int, grace: int = 60) -> list[bytes]:
        """Remove events whose end time + grace period has passed.

        The grace window keeps a just-ended event in the store long enough that
        an immediate re-poll still recognises it as already-handled rather than
        re-classifying it as a new (expired) event. Returns list of pruned mRIDs.
        """
        expired: list[bytes] = []
        for mrid, record in list(self._events.items()):
            if record.end + grace < now:
                expired.append(mrid)
                del self._events[mrid]
        return expired

    def __len__(self) -> int:
        return len(self._events)

    def __contains__(self, mrid: bytes) -> bool:
        return mrid in self._events
upsert
upsert(record: EventRecord) -> EventRecord

Insert or update an event record. Returns the stored record.

Source code in src/py20305/events/state_machine.py
def upsert(self, record: EventRecord) -> EventRecord:
    """Insert or update an event record. Returns the stored record."""
    self._events[record.mrid] = record
    return record
transition
transition(mrid: bytes, new_state: EventState) -> EventRecord

Transition an event to a new state. Raises KeyError if not found.

Source code in src/py20305/events/state_machine.py
def transition(self, mrid: bytes, new_state: EventState) -> EventRecord:
    """Transition an event to a new state. Raises KeyError if not found."""
    record = self._events[mrid]
    record.state = new_state
    return record
all_active_states
all_active_states() -> list[EventRecord]

Return events that are scheduled or active (for supersession).

Source code in src/py20305/events/state_machine.py
def all_active_states(self) -> list[EventRecord]:
    """Return events that are scheduled or active (for supersession)."""
    return [
        r for r in self._events.values() if r.state in (EventState.SCHEDULED, EventState.ACTIVE)
    ]
prune_expired
prune_expired(now: int, grace: int = 60) -> list[bytes]

Remove events whose end time + grace period has passed.

The grace window keeps a just-ended event in the store long enough that an immediate re-poll still recognises it as already-handled rather than re-classifying it as a new (expired) event. Returns list of pruned mRIDs.

Source code in src/py20305/events/state_machine.py
def prune_expired(self, now: int, grace: int = 60) -> list[bytes]:
    """Remove events whose end time + grace period has passed.

    The grace window keeps a just-ended event in the store long enough that
    an immediate re-poll still recognises it as already-handled rather than
    re-classifying it as a new (expired) event. Returns list of pruned mRIDs.
    """
    expired: list[bytes] = []
    for mrid, record in list(self._events.items()):
        if record.end + grace < now:
            expired.append(mrid)
            del self._events[mrid]
    return expired

py20305.events.supersession

Pure-function supersession algorithm for DERControl events.

Compares all pairs of active/scheduled events. If two events have overlapping time ranges and identical DERControlBase parameters (excluding opModConnect/opModEnergize), the lower-primacy event supersedes the higher-primacy one. Same primacy: newer creationTime wins (IEEE 10.2.2.3 rule e), with server_status_time as fallback.

Fixes from original: - Returns ALL superseded pairs (original only returned the last pair) - Status=4 is handled per-event, not by clearing all results

SupersessionResult dataclass

One supersession relationship: superseding event beats superseded event.

Source code in src/py20305/events/supersession.py
@dataclass(frozen=True)
class SupersessionResult:
    """One supersession relationship: superseding event beats superseded event."""

    superseded_mrid: bytes
    superseding_mrid: bytes
    affected_devices: frozenset[str]
    superseded_program_href: str = ""
    superseding_program_href: str = ""
    affected_modes: frozenset[str] = frozenset()

compared_mode_keys

compared_mode_keys(base: DercontrolBase) -> frozenset[str]

Return the modes supersession compares, as it compares them.

DERControlBase field names, leaving out connect and energize and not seeing extension elements -- narrower than :func:~py20305.events.modes_bitmask.get_active_mode_names, which names every mode an event sets. Anything deciding what a supersession covered has to ask in this vocabulary: superseded_modes is recorded from it, so comparing it against the wider set says a device was only partly taken over whenever an event carries a mode this cannot see.

Source code in src/py20305/events/supersession.py
def compared_mode_keys(base: DercontrolBase) -> frozenset[str]:
    """Return the modes supersession compares, as it compares them.

    `DERControlBase` field names, leaving out connect and energize and not
    seeing extension elements -- narrower than
    :func:`~py20305.events.modes_bitmask.get_active_mode_names`, which names
    every mode an event sets. Anything deciding what a supersession covered
    has to ask in this vocabulary: ``superseded_modes`` is recorded from it,
    so comparing it against the wider set says a device was only partly taken
    over whenever an event carries a mode this cannot see.
    """
    keys: list[str] = []
    for name in DercontrolBase.model_fields:
        if name in _EXCLUDED_FIELDS:
            continue
        if name.startswith(("dercontrol_base_", "other_element", "any_attributes")):
            continue
        val = getattr(base, name, None)
        if val is not None:
            keys.append(name)
    return frozenset(keys)

compute_supersession

compute_supersession(events: list[EventRecord], program_to_devices: dict[str, set[str]]) -> list[SupersessionResult]

Compute all supersession pairs among the given events.

Parameters:

Name Type Description Default
events list[EventRecord]

List of active/scheduled EventRecords to compare.

required
program_to_devices dict[str, set[str]]

Mapping from program_href to set of device hrefs belonging to that program's FSA group.

required

Returns:

Type Description
list[SupersessionResult]

List of SupersessionResult for each superseded pair.

Source code in src/py20305/events/supersession.py
def compute_supersession(
    events: list[EventRecord],
    program_to_devices: dict[str, set[str]],
) -> list[SupersessionResult]:
    """Compute all supersession pairs among the given events.

    Args:
        events: List of active/scheduled EventRecords to compare.
        program_to_devices: Mapping from program_href to set of device hrefs
            belonging to that program's FSA group.

    Returns:
        List of SupersessionResult for each superseded pair.
    """
    results: list[SupersessionResult] = []
    checked: set[tuple[bytes, bytes]] = set()

    for i, a in enumerate(events):
        for b in events[i + 1 :]:
            pair_key = (min(a.mrid, b.mrid), max(a.mrid, b.mrid))
            if pair_key in checked:
                continue
            checked.add(pair_key)

            if not _time_overlaps(a, b):
                continue

            # Per-mode supersession: intersecting modes supersede independently.
            # Exact match (including both-empty) or non-empty overlap triggers supersession.
            params_a = compared_mode_keys(a.derc.dercontrol_base)
            params_b = compared_mode_keys(b.derc.dercontrol_base)
            overlap = params_a & params_b
            if params_a != params_b and not overlap:
                continue

            # Determine winner: lower primacy number wins;
            # same primacy: newer creationTime wins (IEEE 10.2.2.3 rule e)
            if a.primacy < b.primacy:
                winner, loser = a, b
            elif b.primacy < a.primacy:
                winner, loser = b, a
            else:
                # Use creation_time (preferred per IEEE), fall back to
                # server_status_time for events that lack creationTime.
                a_time = a.creation_time or a.server_status_time
                b_time = b.creation_time or b.server_status_time
                if a_time >= b_time:
                    winner, loser = a, b
                else:
                    winner, loser = b, a

            # Compute affected devices: intersection of both programs' device sets
            devs_winner = program_to_devices.get(winner.program_href, set())
            devs_loser = program_to_devices.get(loser.program_href, set())
            affected = frozenset(devs_winner & devs_loser)

            if affected:
                results.append(
                    SupersessionResult(
                        superseded_mrid=loser.mrid,
                        superseding_mrid=winner.mrid,
                        affected_devices=affected,
                        superseded_program_href=loser.program_href,
                        superseding_program_href=winner.program_href,
                        affected_modes=overlap,
                    )
                )

    return results

py20305.events.randomization

Deterministic per-mRID randomization of event start time and duration.

RandomizationCache

Cache randomized offsets per mRID for session determinism.

Offsets are generated once per mRID and reused on subsequent calls. The cache is prunable to avoid unbounded memory growth.

Source code in src/py20305/events/randomization.py
class RandomizationCache:
    """Cache randomized offsets per mRID for session determinism.

    Offsets are generated once per mRID and reused on subsequent calls.
    The cache is prunable to avoid unbounded memory growth.
    """

    def __init__(self) -> None:
        self._cache: dict[bytes, tuple[int, int]] = {}
        self._expiry: dict[bytes, int] = {}

    def get_offsets(self, event: RandomizableEvent, start: int, duration: int) -> tuple[int, int]:
        """Return (start_offset, duration_offset) for the given randomizable event.

        Works for any ``RandomizableEvent`` (DERControl, TimeTariffInterval, ...)
        since it only reads the base ``m_rid`` / ``randomize_start`` /
        ``randomize_duration`` fields. Offsets are cached per mRID. Range is
        [0, abs(value)] with sign preserved from the randomization field. Zero
        randomization fields produce zero offsets.
        """
        mrid = event.m_rid.value
        if mrid in self._cache:
            return self._cache[mrid]

        start_offset = self._compute_offset(event.randomize_start)
        dur_offset = self._compute_offset(event.randomize_duration)

        self._cache[mrid] = (start_offset, dur_offset)
        # Track expiry based on effective end time
        self._expiry[mrid] = start + start_offset + duration + dur_offset
        return (start_offset, dur_offset)

    def prune(self, now: int, grace: int = 10) -> None:
        """Remove cache entries for events that have expired."""
        expired = [mrid for mrid, end in self._expiry.items() if end + grace < now]
        for mrid in expired:
            del self._cache[mrid]
            del self._expiry[mrid]

    @staticmethod
    def _compute_offset(value: OneHourRangeType | None) -> int:
        """Compute a random offset from the randomization field value.

        Range is [0, abs(value)], sign preserved. Returns 0 if value is
        None or 0.
        """
        if value is None or value.value == 0:
            return 0
        raw = value.value
        magnitude = abs(raw)
        sign = 1 if raw > 0 else -1
        return sign * random.randint(0, magnitude)

    def override_start_offset(
        self,
        mrid: bytes,
        new_start_offset: int,
        raw_start: int,
        raw_duration: int,
    ) -> None:
        """Override the cached start offset for an event.

        Used by IEEE 10.2.2.3 rule m) to prevent randomization gaps
        between successive events: the earlier event's effective end time
        becomes the later event's effective start time.
        """
        if mrid not in self._cache:
            return
        _, dur_offset = self._cache[mrid]
        self._cache[mrid] = (new_start_offset, dur_offset)
        self._expiry[mrid] = raw_start + new_start_offset + raw_duration + dur_offset

    def __len__(self) -> int:
        return len(self._cache)
get_offsets
get_offsets(event: RandomizableEvent, start: int, duration: int) -> tuple[int, int]

Return (start_offset, duration_offset) for the given randomizable event.

Works for any RandomizableEvent (DERControl, TimeTariffInterval, ...) since it only reads the base m_rid / randomize_start / randomize_duration fields. Offsets are cached per mRID. Range is [0, abs(value)] with sign preserved from the randomization field. Zero randomization fields produce zero offsets.

Source code in src/py20305/events/randomization.py
def get_offsets(self, event: RandomizableEvent, start: int, duration: int) -> tuple[int, int]:
    """Return (start_offset, duration_offset) for the given randomizable event.

    Works for any ``RandomizableEvent`` (DERControl, TimeTariffInterval, ...)
    since it only reads the base ``m_rid`` / ``randomize_start`` /
    ``randomize_duration`` fields. Offsets are cached per mRID. Range is
    [0, abs(value)] with sign preserved from the randomization field. Zero
    randomization fields produce zero offsets.
    """
    mrid = event.m_rid.value
    if mrid in self._cache:
        return self._cache[mrid]

    start_offset = self._compute_offset(event.randomize_start)
    dur_offset = self._compute_offset(event.randomize_duration)

    self._cache[mrid] = (start_offset, dur_offset)
    # Track expiry based on effective end time
    self._expiry[mrid] = start + start_offset + duration + dur_offset
    return (start_offset, dur_offset)
prune
prune(now: int, grace: int = 10) -> None

Remove cache entries for events that have expired.

Source code in src/py20305/events/randomization.py
def prune(self, now: int, grace: int = 10) -> None:
    """Remove cache entries for events that have expired."""
    expired = [mrid for mrid, end in self._expiry.items() if end + grace < now]
    for mrid in expired:
        del self._cache[mrid]
        del self._expiry[mrid]
override_start_offset
override_start_offset(mrid: bytes, new_start_offset: int, raw_start: int, raw_duration: int) -> None

Override the cached start offset for an event.

Used by IEEE 10.2.2.3 rule m) to prevent randomization gaps between successive events: the earlier event's effective end time becomes the later event's effective start time.

Source code in src/py20305/events/randomization.py
def override_start_offset(
    self,
    mrid: bytes,
    new_start_offset: int,
    raw_start: int,
    raw_duration: int,
) -> None:
    """Override the cached start offset for an event.

    Used by IEEE 10.2.2.3 rule m) to prevent randomization gaps
    between successive events: the earlier event's effective end time
    becomes the later event's effective start time.
    """
    if mrid not in self._cache:
        return
    _, dur_offset = self._cache[mrid]
    self._cache[mrid] = (new_start_offset, dur_offset)
    self._expiry[mrid] = raw_start + new_start_offset + raw_duration + dur_offset

py20305.events.response

DER response building and posting to the IEEE 2030.5 server.

ResponseCode

Bases: IntEnum

IEEE 2030.5 Table 31 response status codes for DER function set.

Source code in src/py20305/events/response.py
class ResponseCode(enum.IntEnum):
    """IEEE 2030.5 Table 31 response status codes for DER function set."""

    ACKNOWLEDGED = 1
    ACTIVE = 2
    COMPLETED = 3
    OPT_OUT = 4
    OPT_IN = 5
    CANCELLED = 6
    SUPERSEDED = 7
    PARTIAL_OPT_OUT = 8
    PARTIAL_OPT_IN = 9
    COMPLETE_NO_PARTICIPATION = 10
    SUPERSEDED_ALTERNATE_SERVER = 12
    SUPERSEDED_ALTERNATE_PROGRAM = 13
    RESUMED = 14
    NOT_SUPPORTED = 251
    NOT_APPLICABLE = 252
    INVALID = 253
    EXPIRED = 254

ResponseTracker

Deduplicates DER response posts and provides expiry-based pruning.

Dedup key is (mrid, code, lfdi) so that the same event can produce separate responses for different devices (required by multi-device AGG conformance tests).

Source code in src/py20305/events/response.py
class ResponseTracker:
    """Deduplicates DER response posts and provides expiry-based pruning.

    Dedup key is ``(mrid, code, lfdi)`` so that the same event can produce
    separate responses for different devices (required by multi-device AGG
    conformance tests).
    """

    def __init__(self) -> None:
        self._sent: dict[tuple[bytes, ResponseCode, bytes], int] = {}
        #: Keys claimed by a POST that has not returned, each mapped to a future
        #: resolved with whether that POST landed. Held apart from ``_sent`` so
        #: an age prune cannot evict a response still in flight.
        self._in_flight: dict[tuple[bytes, ResponseCode, bytes], asyncio.Future[bool]] = {}

    def already_sent(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool:
        """True if this response was sent, or is being sent right now.

        In-flight posts count, so a concurrent caller does not send a second
        copy. :meth:`has_responded` answers the narrower question -- whether
        anything has actually reached the server for this event -- and the two
        therefore disagree for the duration of a POST.
        """
        key = (mrid, code, lfdi)
        return key in self._sent or key in self._in_flight

    async def reserve(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool:
        """Claim this key, waiting out anyone already posting it.

        Returns True when the caller must post, False when the response is
        already the server's. A caller that arrives while another is posting
        waits for that POST's outcome rather than being turned away: the DER
        status-2 path runs once per state transition and has no retry driver,
        so giving up on the strength of someone else's attempt would let a
        failed POST mean the response is never sent at all.

        The check and the claim have no await between them, so the claim itself
        is atomic on one event loop.
        """
        key = (mrid, code, lfdi)
        while True:
            if key in self._sent:
                return False
            in_flight = self._in_flight.get(key)
            if in_flight is None:
                self._in_flight[key] = asyncio.get_running_loop().create_future()
                return True
            # Someone else holds it. Their outcome decides ours: if their POST
            # landed there is nothing to do, and if it did not, this caller
            # goes back around and claims the key itself.
            if await asyncio.shield(in_flight):
                return False

    def release(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> None:
        """Give up a claim whose POST never landed, so a waiter can take it."""
        in_flight = self._in_flight.pop((mrid, code, lfdi), None)
        if in_flight is not None and not in_flight.done():
            in_flight.set_result(False)

    def has_responded(self, mrid: bytes) -> bool:
        """True if any response (any code, any device) was already sent for *mrid*.

        Used to suppress a spurious EXPIRED on an event we already handled (e.g.
        ran to COMPLETED) that the event store pruned and a later poll
        re-discovered -- IEEE 2030.5 §10.2.2.3 rule j scopes EXPIRED to events
        already past their end *when first received*.
        """
        return any(sent_mrid == mrid for sent_mrid, _code, _lfdi in self._sent)

    def mark_sent(self, mrid: bytes, code: ResponseCode, lfdi: bytes, now: int) -> None:
        key = (mrid, code, lfdi)
        self._sent[key] = now
        in_flight = self._in_flight.pop(key, None)
        if in_flight is not None and not in_flight.done():
            in_flight.set_result(True)

    def prune(self, now: int, max_age: int = 7200) -> None:
        """Remove entries older than max_age seconds."""
        expired = [k for k, ts in self._sent.items() if now - ts > max_age]
        for k in expired:
            del self._sent[k]

    def retain_mrids(self, live_mrids: set[bytes]) -> None:
        """Drop dedup entries whose event mRID is not in *live_mrids*.

        Liveness-scoped eviction (vs :meth:`prune`'s age-based one) suits callers
        that re-scan a known set of events each cycle and must never re-post for a
        still-present event: an age prune could drop a long-lived event's entry
        and cause a duplicate post, whereas this only forgets events that are gone.
        """
        stale = [key for key in self._sent if key[0] not in live_mrids]
        for key in stale:
            del self._sent[key]

    def __len__(self) -> int:
        return len(self._sent)
already_sent
already_sent(mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool

True if this response was sent, or is being sent right now.

In-flight posts count, so a concurrent caller does not send a second copy. :meth:has_responded answers the narrower question -- whether anything has actually reached the server for this event -- and the two therefore disagree for the duration of a POST.

Source code in src/py20305/events/response.py
def already_sent(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool:
    """True if this response was sent, or is being sent right now.

    In-flight posts count, so a concurrent caller does not send a second
    copy. :meth:`has_responded` answers the narrower question -- whether
    anything has actually reached the server for this event -- and the two
    therefore disagree for the duration of a POST.
    """
    key = (mrid, code, lfdi)
    return key in self._sent or key in self._in_flight
reserve async
reserve(mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool

Claim this key, waiting out anyone already posting it.

Returns True when the caller must post, False when the response is already the server's. A caller that arrives while another is posting waits for that POST's outcome rather than being turned away: the DER status-2 path runs once per state transition and has no retry driver, so giving up on the strength of someone else's attempt would let a failed POST mean the response is never sent at all.

The check and the claim have no await between them, so the claim itself is atomic on one event loop.

Source code in src/py20305/events/response.py
async def reserve(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> bool:
    """Claim this key, waiting out anyone already posting it.

    Returns True when the caller must post, False when the response is
    already the server's. A caller that arrives while another is posting
    waits for that POST's outcome rather than being turned away: the DER
    status-2 path runs once per state transition and has no retry driver,
    so giving up on the strength of someone else's attempt would let a
    failed POST mean the response is never sent at all.

    The check and the claim have no await between them, so the claim itself
    is atomic on one event loop.
    """
    key = (mrid, code, lfdi)
    while True:
        if key in self._sent:
            return False
        in_flight = self._in_flight.get(key)
        if in_flight is None:
            self._in_flight[key] = asyncio.get_running_loop().create_future()
            return True
        # Someone else holds it. Their outcome decides ours: if their POST
        # landed there is nothing to do, and if it did not, this caller
        # goes back around and claims the key itself.
        if await asyncio.shield(in_flight):
            return False
release
release(mrid: bytes, code: ResponseCode, lfdi: bytes) -> None

Give up a claim whose POST never landed, so a waiter can take it.

Source code in src/py20305/events/response.py
def release(self, mrid: bytes, code: ResponseCode, lfdi: bytes) -> None:
    """Give up a claim whose POST never landed, so a waiter can take it."""
    in_flight = self._in_flight.pop((mrid, code, lfdi), None)
    if in_flight is not None and not in_flight.done():
        in_flight.set_result(False)
has_responded
has_responded(mrid: bytes) -> bool

True if any response (any code, any device) was already sent for mrid.

Used to suppress a spurious EXPIRED on an event we already handled (e.g. ran to COMPLETED) that the event store pruned and a later poll re-discovered -- IEEE 2030.5 §10.2.2.3 rule j scopes EXPIRED to events already past their end when first received.

Source code in src/py20305/events/response.py
def has_responded(self, mrid: bytes) -> bool:
    """True if any response (any code, any device) was already sent for *mrid*.

    Used to suppress a spurious EXPIRED on an event we already handled (e.g.
    ran to COMPLETED) that the event store pruned and a later poll
    re-discovered -- IEEE 2030.5 §10.2.2.3 rule j scopes EXPIRED to events
    already past their end *when first received*.
    """
    return any(sent_mrid == mrid for sent_mrid, _code, _lfdi in self._sent)
prune
prune(now: int, max_age: int = 7200) -> None

Remove entries older than max_age seconds.

Source code in src/py20305/events/response.py
def prune(self, now: int, max_age: int = 7200) -> None:
    """Remove entries older than max_age seconds."""
    expired = [k for k, ts in self._sent.items() if now - ts > max_age]
    for k in expired:
        del self._sent[k]
retain_mrids
retain_mrids(live_mrids: set[bytes]) -> None

Drop dedup entries whose event mRID is not in live_mrids.

Liveness-scoped eviction (vs :meth:prune's age-based one) suits callers that re-scan a known set of events each cycle and must never re-post for a still-present event: an age prune could drop a long-lived event's entry and cause a duplicate post, whereas this only forgets events that are gone.

Source code in src/py20305/events/response.py
def retain_mrids(self, live_mrids: set[bytes]) -> None:
    """Drop dedup entries whose event mRID is not in *live_mrids*.

    Liveness-scoped eviction (vs :meth:`prune`'s age-based one) suits callers
    that re-scan a known set of events each cycle and must never re-post for a
    still-present event: an age prune could drop a long-lived event's entry
    and cause a duplicate post, whereas this only forgets events that are gone.
    """
    stale = [key for key in self._sent if key[0] not in live_mrids]
    for key in stale:
        del self._sent[key]

response_required_allows

response_required_allows(response_required: bytes | None, code: ResponseCode) -> bool

Whether the event's responseRequired bitfield requests a code response.

IEEE 2030.5 §8.10.3.1: a client SHALL only POST a response when the corresponding bit is set. None/empty/0x00 all mean "no bits set" -> no response requested.

Source code in src/py20305/events/response.py
def response_required_allows(response_required: bytes | None, code: ResponseCode) -> bool:
    """Whether the event's responseRequired bitfield requests a *code* response.

    IEEE 2030.5 §8.10.3.1: a client SHALL only POST a response when the
    corresponding bit is set. ``None``/empty/``0x00`` all mean "no bits set" ->
    no response requested.
    """
    flags = int.from_bytes(response_required or b"\x00", "big")
    return bool(flags & _required_bit_for(code))

response_code_for_dispatch_error

response_code_for_dispatch_error(exc: BaseException) -> ResponseCode

Map an unsuccessful control dispatch onto the code to report.

An unclassified failure falls through to NOT_SUPPORTED: the client lacks a precise reason, but the control still was not applied, and reporting ACTIVE for it would assert something that did not happen. The in-process connector path does not raise the typed control errors, so its failures land here until those connectors are migrated.

Note the fall-through is deliberately a capability limit, never status 4: an opt-out has to be an explicit OptOutError, so no unrecognized error can be misreported to the head-end as a customer decision.

Source code in src/py20305/events/response.py
def response_code_for_dispatch_error(exc: BaseException) -> ResponseCode:
    """Map an unsuccessful control dispatch onto the code to report.

    An unclassified failure falls through to ``NOT_SUPPORTED``: the client
    lacks a precise reason, but the control still was not applied,
    and reporting ACTIVE for it would assert something that did not happen. The
    in-process connector path does not raise the typed control errors, so its
    failures land here until those connectors are migrated.

    Note the fall-through is deliberately a capability limit, never status 4: an
    opt-out has to be an explicit ``OptOutError``, so no unrecognized error can
    be misreported to the head-end as a customer decision.
    """
    for exc_type, code in _DISPATCH_ERROR_CODES:
        if isinstance(exc, exc_type):
            return code
    return ResponseCode.NOT_SUPPORTED

translate_code_for_revision

translate_code_for_revision(code: ResponseCode, *, server_2018_compat: bool) -> ResponseCode

Map code onto the IEEE 2030.5 revision the server speaks.

Source code in src/py20305/events/response.py
def translate_code_for_revision(code: ResponseCode, *, server_2018_compat: bool) -> ResponseCode:
    """Map *code* onto the IEEE 2030.5 revision the server speaks."""
    if not server_2018_compat:
        return code
    return _CODE_2018_OVERRIDES.get(code, code)

post_der_response async

post_der_response(http: Sep2Client, derc: Dercontrol1, code: ResponseCode, lfdi: bytes, tracker: ResponseTracker, *, now_ts: int | None = None) -> None

Post a DERControlResponse to the server.

Uses replyTo from the DERControl. If replyTo is absent the response is skipped (the server failed to provide a destination, per IEEE 2030.5 Section 8.10.3.1).

now_ts sets createdDateTime (and the tracker stamp); the event processor passes server-timebase time so the server sees its own clock. Defaults to the local clock.

Source code in src/py20305/events/response.py
async def post_der_response(
    http: Sep2Client,
    derc: Dercontrol1,
    code: ResponseCode,
    lfdi: bytes,
    tracker: ResponseTracker,
    *,
    now_ts: int | None = None,
) -> None:
    """Post a DERControlResponse to the server.

    Uses replyTo from the DERControl.  If replyTo is absent the response is
    skipped (the server failed to provide a destination, per IEEE 2030.5
    Section 8.10.3.1).

    ``now_ts`` sets ``createdDateTime`` (and the tracker stamp); the event
    processor passes server-timebase time so the server sees its own clock.
    Defaults to the local clock.
    """
    mrid = derc.m_rid.value
    # Resolve before gating and dedup so both act on the value that actually
    # goes on the wire: under 2018 two codes collapse onto one, and the server
    # must not receive it twice.
    code = _resolve_wire_code(derc, code, server_2018_compat=http.server_2018_compat, mrid=mrid)
    if not response_required_allows(derc.response_required, code):
        logger.debug(
            "responseRequired=%s does not request %s; skipping response mrid=%s",
            (derc.response_required or b"\x00").hex(),
            code.name,
            mrid.hex()[:8],
        )
        return

    path = derc.reply_to
    if not path:
        logger.warning(
            "No replyTo on control mrid=%s; skipping %s response", mrid.hex()[:8], code.name
        )
        return

    # Reserved rather than checked: the per-device fan-out posts concurrently,
    # and a check that the POST separates from its mark lets two responses out
    # for one (mrid, code, lfdi).
    if not await tracker.reserve(mrid, code, lfdi):
        logger.debug("Skipping duplicate response mrid=%s code=%s", mrid.hex(), code.name)
        return

    # Everything from here sits inside the release: a raise while building the
    # response would otherwise leak the claim, and nothing prunes it.
    sent = False
    try:
        now_ts = now_ts if now_ts is not None else int(time.time())
        # modesResponded was added in IEEE 2030.5-2023; omit for 2018 servers.
        modes = None if http.server_2018_compat else build_modes_responded(derc.dercontrol_base)
        response = DercontrolResponse(
            end_device_lfdi=lfdi,
            status=code.value,
            subject=MRidtype(value=mrid),
            created_date_time=TimeType(value=now_ts),
            modes_responded=modes,
        )
        await http.post(path, response)
        tracker.mark_sent(mrid, code, lfdi, now_ts)
        sent = True
        logger.info(
            "Response posted: mrid=%s lfdi=%s %s -> %s",
            mrid.hex()[:8],
            lfdi.hex()[:8].upper(),
            code.name,
            path,
        )
    except Exception:
        logger.warning(
            "Failed to post response mrid=%s code=%s", mrid.hex()[:8], code.name, exc_info=True
        )
    finally:
        # In `finally`, not in the `except`: cancellation raises
        # CancelledError, which is not an Exception, and `_in_flight` is never
        # pruned -- so a claim released only on Exception would survive a
        # shutdown and silence this response for the life of the process.
        # Cancellation still propagates; only the claim is given back.
        if not sent:
            tracker.release(mrid, code, lfdi)

post_price_response async

post_price_response(http: Sep2Client, interval: TimeTariffInterval1, code: ResponseCode, lfdi: bytes, tracker: ResponseTracker, *, now_ts: int | None = None) -> None

Post a PriceResponse for a consumed TimeTariffInterval.

IEEE 2030.5 Price function set: when a TimeTariffInterval's responseRequired bitfield requests it, the price-responsive client acknowledges receipt. Reuses the DER response machinery -- the responseRequired bit gating (:func:response_required_allows) and the per-(mrid, code, lfdi) dedup tracker -- but builds a PriceResponse (no DER modesResponded).

now_ts sets createdDateTime (and the tracker stamp); the tariff processor passes FSA-scoped server-timebase time so the server sees its own clock (Response.createdDateTime is relative to the event's Time server). Defaults to the local clock.

Source code in src/py20305/events/response.py
async def post_price_response(
    http: Sep2Client,
    interval: TimeTariffInterval1,
    code: ResponseCode,
    lfdi: bytes,
    tracker: ResponseTracker,
    *,
    now_ts: int | None = None,
) -> None:
    """Post a PriceResponse for a consumed TimeTariffInterval.

    IEEE 2030.5 Price function set: when a TimeTariffInterval's responseRequired
    bitfield requests it, the price-responsive client acknowledges receipt.
    Reuses the DER response machinery -- the responseRequired bit gating
    (:func:`response_required_allows`) and the per-(mrid, code, lfdi) dedup
    tracker -- but builds a ``PriceResponse`` (no DER modesResponded).

    ``now_ts`` sets ``createdDateTime`` (and the tracker stamp); the tariff
    processor passes FSA-scoped server-timebase time so the server sees its own
    clock (Response.createdDateTime is relative to the event's Time server).
    Defaults to the local clock.
    """
    mrid = interval.m_rid.value
    if not response_required_allows(interval.response_required, code):
        logger.debug(
            "responseRequired=%s does not request %s; skipping price response mrid=%s",
            (interval.response_required or b"\x00").hex(),
            code.name,
            mrid.hex()[:8],
        )
        return

    path = interval.reply_to
    if not path:
        logger.warning(
            "No replyTo on tariff interval mrid=%s; skipping %s response",
            mrid.hex()[:8],
            code.name,
        )
        return

    if not await tracker.reserve(mrid, code, lfdi):
        logger.debug("Skipping duplicate price response mrid=%s code=%s", mrid.hex(), code.name)
        return

    sent = False
    try:
        now_ts = now_ts if now_ts is not None else int(time.time())
        response = PriceResponse(
            end_device_lfdi=lfdi,
            status=code.value,
            subject=MRidtype(value=mrid),
            created_date_time=TimeType(value=now_ts),
        )
        await http.post(path, response)
        tracker.mark_sent(mrid, code, lfdi, now_ts)
        sent = True
        logger.info(
            "Price response posted: mrid=%s lfdi=%s %s -> %s",
            mrid.hex()[:8],
            lfdi.hex()[:8].upper(),
            code.name,
            path,
        )
    except Exception:
        logger.warning(
            "Failed to post price response mrid=%s code=%s",
            mrid.hex()[:8],
            code.name,
            exc_info=True,
        )
    finally:
        # Same reason as the DER path: cancellation is not an Exception, and a
        # leaked claim is permanent.
        if not sent:
            tracker.release(mrid, code, lfdi)

py20305.events.dispatch

Control dispatcher protocol for applying DER controls to devices.

NullDispatcher is the no-op default; ConnectorDispatcher is the implementation backed by real connectors.

ControlDispatcher

Bases: Protocol

Interface for applying DER controls to devices.

Source code in src/py20305/events/dispatch.py
class ControlDispatcher(Protocol):
    """Interface for applying DER controls to devices."""

    async def apply_control(
        self,
        device_href: str,
        derc: Dercontrol1,
        curves: list[Dercurve1],
    ) -> None:
        """Apply a DERControl's settings to a device."""
        ...

    async def apply_default_control(
        self,
        device_href: str,
        dderc: DefaultDercontrol,
        curves: list[Dercurve1],
        *,
        origin: str = CommandOrigin.DDERC_REAPPLY,
        cause_mrid: bytes | None = None,
    ) -> None:
        """Apply default DER control (DDERC fallback) to a device.

        ``origin`` says why the default is being applied -- an event ending and
        an upstream outage land on the same write and read very differently in
        an audit trail. ``cause_mrid`` names the event whose end brought the
        default back, or ``None`` when no single event did.
        """
        ...

    async def clear_control(
        self,
        device_href: str,
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        """Clear active control from a device.

        ``cause_mrid`` names the event whose opt-out led to the clear.
        """
        ...

    async def apply_control_by_lfdi(
        self,
        lfdi: str,
        derc: Dercontrol1,
        curves: list[Dercurve1],
    ) -> None:
        """Apply a DERControl directly to a device identified by LFDI.

        Used when a caller supplies a group lookup, so a single
        server-side EndDevice href resolves to several locally-managed
        sub-device LFDIs.
        """
        ...

    async def apply_default_control_by_lfdi(
        self,
        lfdi: str,
        dderc: DefaultDercontrol,
        curves: list[Dercurve1],
        *,
        origin: str = CommandOrigin.DDERC_REAPPLY,
        cause_mrid: bytes | None = None,
    ) -> None:
        """Apply DDERC directly to a device identified by LFDI."""
        ...

    async def clear_control_by_lfdi(
        self,
        lfdi: str,
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        """Clear active control from a device identified by LFDI.

        The by-LFDI counterpart of ``clear_control``, used by the comms-loss
        safe-default when a server-side EndDevice href resolves to several
        locally-managed sub-device LFDIs.
        """
        ...

    async def disable_modes(
        self,
        device_href: str,
        modes: frozenset[str],
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        """Disable the named modes on a device at the end of an event.

        ``modes`` are the names the event processor tracks (DERControlBase
        field names and CSIP-AUS limit element names); ``cause_mrid`` names
        the event that ended.
        """
        ...

    async def disable_modes_by_lfdi(
        self,
        lfdi: str,
        modes: frozenset[str],
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        """The by-LFDI counterpart of ``disable_modes``."""
        ...

    async def relay_schedule_notification(
        self,
        lfdis: list[str],
        notification: ScheduleNotification,
    ) -> None:
        """Relay a schedule notification to each connector owning an affected LFDI.

        Informational push (see ScheduleNotification). Resolves connectors,
        de-duplicates connector instances (one connector may own several LFDIs),
        and isolates per-connector errors so a throwing connector can't affect
        event processing or the others.
        """
        ...
apply_control async
apply_control(device_href: str, derc: Dercontrol1, curves: list[Dercurve1]) -> None

Apply a DERControl's settings to a device.

Source code in src/py20305/events/dispatch.py
async def apply_control(
    self,
    device_href: str,
    derc: Dercontrol1,
    curves: list[Dercurve1],
) -> None:
    """Apply a DERControl's settings to a device."""
    ...
apply_default_control async
apply_default_control(device_href: str, dderc: DefaultDercontrol, curves: list[Dercurve1], *, origin: str = CommandOrigin.DDERC_REAPPLY, cause_mrid: bytes | None = None) -> None

Apply default DER control (DDERC fallback) to a device.

origin says why the default is being applied -- an event ending and an upstream outage land on the same write and read very differently in an audit trail. cause_mrid names the event whose end brought the default back, or None when no single event did.

Source code in src/py20305/events/dispatch.py
async def apply_default_control(
    self,
    device_href: str,
    dderc: DefaultDercontrol,
    curves: list[Dercurve1],
    *,
    origin: str = CommandOrigin.DDERC_REAPPLY,
    cause_mrid: bytes | None = None,
) -> None:
    """Apply default DER control (DDERC fallback) to a device.

    ``origin`` says why the default is being applied -- an event ending and
    an upstream outage land on the same write and read very differently in
    an audit trail. ``cause_mrid`` names the event whose end brought the
    default back, or ``None`` when no single event did.
    """
    ...
clear_control async
clear_control(device_href: str, *, cause_mrid: bytes | None = None) -> None

Clear active control from a device.

cause_mrid names the event whose opt-out led to the clear.

Source code in src/py20305/events/dispatch.py
async def clear_control(
    self,
    device_href: str,
    *,
    cause_mrid: bytes | None = None,
) -> None:
    """Clear active control from a device.

    ``cause_mrid`` names the event whose opt-out led to the clear.
    """
    ...
apply_control_by_lfdi async
apply_control_by_lfdi(lfdi: str, derc: Dercontrol1, curves: list[Dercurve1]) -> None

Apply a DERControl directly to a device identified by LFDI.

Used when a caller supplies a group lookup, so a single server-side EndDevice href resolves to several locally-managed sub-device LFDIs.

Source code in src/py20305/events/dispatch.py
async def apply_control_by_lfdi(
    self,
    lfdi: str,
    derc: Dercontrol1,
    curves: list[Dercurve1],
) -> None:
    """Apply a DERControl directly to a device identified by LFDI.

    Used when a caller supplies a group lookup, so a single
    server-side EndDevice href resolves to several locally-managed
    sub-device LFDIs.
    """
    ...
apply_default_control_by_lfdi async
apply_default_control_by_lfdi(lfdi: str, dderc: DefaultDercontrol, curves: list[Dercurve1], *, origin: str = CommandOrigin.DDERC_REAPPLY, cause_mrid: bytes | None = None) -> None

Apply DDERC directly to a device identified by LFDI.

Source code in src/py20305/events/dispatch.py
async def apply_default_control_by_lfdi(
    self,
    lfdi: str,
    dderc: DefaultDercontrol,
    curves: list[Dercurve1],
    *,
    origin: str = CommandOrigin.DDERC_REAPPLY,
    cause_mrid: bytes | None = None,
) -> None:
    """Apply DDERC directly to a device identified by LFDI."""
    ...
clear_control_by_lfdi async
clear_control_by_lfdi(lfdi: str, *, cause_mrid: bytes | None = None) -> None

Clear active control from a device identified by LFDI.

The by-LFDI counterpart of clear_control, used by the comms-loss safe-default when a server-side EndDevice href resolves to several locally-managed sub-device LFDIs.

Source code in src/py20305/events/dispatch.py
async def clear_control_by_lfdi(
    self,
    lfdi: str,
    *,
    cause_mrid: bytes | None = None,
) -> None:
    """Clear active control from a device identified by LFDI.

    The by-LFDI counterpart of ``clear_control``, used by the comms-loss
    safe-default when a server-side EndDevice href resolves to several
    locally-managed sub-device LFDIs.
    """
    ...
disable_modes async
disable_modes(device_href: str, modes: frozenset[str], *, cause_mrid: bytes | None = None) -> None

Disable the named modes on a device at the end of an event.

modes are the names the event processor tracks (DERControlBase field names and CSIP-AUS limit element names); cause_mrid names the event that ended.

Source code in src/py20305/events/dispatch.py
async def disable_modes(
    self,
    device_href: str,
    modes: frozenset[str],
    *,
    cause_mrid: bytes | None = None,
) -> None:
    """Disable the named modes on a device at the end of an event.

    ``modes`` are the names the event processor tracks (DERControlBase
    field names and CSIP-AUS limit element names); ``cause_mrid`` names
    the event that ended.
    """
    ...
disable_modes_by_lfdi async
disable_modes_by_lfdi(lfdi: str, modes: frozenset[str], *, cause_mrid: bytes | None = None) -> None

The by-LFDI counterpart of disable_modes.

Source code in src/py20305/events/dispatch.py
async def disable_modes_by_lfdi(
    self,
    lfdi: str,
    modes: frozenset[str],
    *,
    cause_mrid: bytes | None = None,
) -> None:
    """The by-LFDI counterpart of ``disable_modes``."""
    ...
relay_schedule_notification async
relay_schedule_notification(lfdis: list[str], notification: ScheduleNotification) -> None

Relay a schedule notification to each connector owning an affected LFDI.

Informational push (see ScheduleNotification). Resolves connectors, de-duplicates connector instances (one connector may own several LFDIs), and isolates per-connector errors so a throwing connector can't affect event processing or the others.

Source code in src/py20305/events/dispatch.py
async def relay_schedule_notification(
    self,
    lfdis: list[str],
    notification: ScheduleNotification,
) -> None:
    """Relay a schedule notification to each connector owning an affected LFDI.

    Informational push (see ScheduleNotification). Resolves connectors,
    de-duplicates connector instances (one connector may own several LFDIs),
    and isolates per-connector errors so a throwing connector can't affect
    event processing or the others.
    """
    ...

NullDispatcher

Dispatcher that accepts every control and does nothing with it.

Source code in src/py20305/events/dispatch.py
class NullDispatcher:
    """Dispatcher that accepts every control and does nothing with it."""

    async def apply_control(
        self,
        device_href: str,
        derc: Dercontrol1,
        curves: list[Dercurve1],
    ) -> None:
        pass

    async def apply_default_control(
        self,
        device_href: str,
        dderc: DefaultDercontrol,
        curves: list[Dercurve1],
        *,
        origin: str = CommandOrigin.DDERC_REAPPLY,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def clear_control(
        self,
        device_href: str,
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def apply_control_by_lfdi(
        self,
        lfdi: str,
        derc: Dercontrol1,
        curves: list[Dercurve1],
    ) -> None:
        pass

    async def apply_default_control_by_lfdi(
        self,
        lfdi: str,
        dderc: DefaultDercontrol,
        curves: list[Dercurve1],
        *,
        origin: str = CommandOrigin.DDERC_REAPPLY,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def clear_control_by_lfdi(
        self,
        lfdi: str,
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def disable_modes(
        self,
        device_href: str,
        modes: frozenset[str],
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def disable_modes_by_lfdi(
        self,
        lfdi: str,
        modes: frozenset[str],
        *,
        cause_mrid: bytes | None = None,
    ) -> None:
        pass

    async def relay_schedule_notification(
        self,
        lfdis: list[str],
        notification: ScheduleNotification,
    ) -> None:
        pass