Skip to content

Base Classes

The embrs.base_classes package provides the abstract interfaces and manager classes that form the backbone of the EMBRS simulation framework. Users extending EMBRS will primarily interact with ControlClass (to implement suppression strategies) and AgentBase (to define agents displayed in visualizations). The remaining classes—BaseFireSim, GridManager, WeatherManager, ControlActionHandler, and BaseVisualizer—are internal infrastructure that advanced users may need to understand when debugging or extending the simulation engine.

Abstract control class for user-defined fire suppression strategies.

Users extend ControlClass to implement custom control logic that interacts with the fire simulation at each time step.

Classes:

Name Description
- ControlClass

Abstract base for fire suppression algorithms.

.. autoclass:: ControlClass :members:

ControlClass

Bases: ABC

Abstract base class for user-defined fire suppression control code.

Subclasses must implement the process_state method, which is called after each simulation iteration to apply suppression actions.

Source code in embrs/base_classes/control_base.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class ControlClass(ABC):
    """Abstract base class for user-defined fire suppression control code.

    Subclasses must implement the process_state method, which is called
    after each simulation iteration to apply suppression actions.
    """

    @abstractmethod
    def process_state(self, fire: FireSim) -> None:
        """Process the current simulation state and apply control actions.

        Called after each simulation iteration. Implement this method to
        access fire state and apply suppression actions such as retardant
        drops, water drops, or fireline construction.

        Args:
            fire (FireSim): The current FireSim instance. Access fire state
                via fire.burning_cells, fire.get_frontier(), fire.curr_time_s, etc.
        """

process_state(fire) abstractmethod

Process the current simulation state and apply control actions.

Called after each simulation iteration. Implement this method to access fire state and apply suppression actions such as retardant drops, water drops, or fireline construction.

Parameters:

Name Type Description Default
fire FireSim

The current FireSim instance. Access fire state via fire.burning_cells, fire.get_frontier(), fire.curr_time_s, etc.

required
Source code in embrs/base_classes/control_base.py
28
29
30
31
32
33
34
35
36
37
38
39
@abstractmethod
def process_state(self, fire: FireSim) -> None:
    """Process the current simulation state and apply control actions.

    Called after each simulation iteration. Implement this method to
    access fire state and apply suppression actions such as retardant
    drops, water drops, or fireline construction.

    Args:
        fire (FireSim): The current FireSim instance. Access fire state
            via fire.burning_cells, fire.get_frontier(), fire.curr_time_s, etc.
    """

Base class for agents displayed in fire simulation.

Agents represent entities (vehicles, personnel, etc.) that can be registered with the simulation and displayed in visualizations.

Classes:

Name Description
- AgentBase

Base class for simulation agents.

.. autoclass:: AgentBase :members:

AgentBase

Base class for agents in user code.

Agent objects must be an instance of this class to be registered with the simulation and displayed in visualizations.

Attributes:

Name Type Description
id

Unique identifier of the agent.

x float

X position in meters within the simulation.

y float

Y position in meters within the simulation.

label str

Label displayed with the agent, or None for no label.

marker str

Matplotlib marker style for display.

color str

Matplotlib color for display.

Source code in embrs/base_classes/agent_base.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class AgentBase:
    """Base class for agents in user code.

    Agent objects must be an instance of this class to be registered with the
    simulation and displayed in visualizations.

    Attributes:
        id: Unique identifier of the agent.
        x (float): X position in meters within the simulation.
        y (float): Y position in meters within the simulation.
        label (str): Label displayed with the agent, or None for no label.
        marker (str): Matplotlib marker style for display.
        color (str): Matplotlib color for display.
    """

    def __init__(self, id: Any, x: float, y: float, label: str = None, marker: str = '*',
                 color: str = 'magenta'):
        """Initialize an agent with position and display properties.

        Args:
            id: Unique identifier of the agent.
            x (float): X position in meters within the simulation.
            y (float): Y position in meters within the simulation.
            label (str, optional): Label displayed with the agent. Defaults to None.
            marker (str, optional): Matplotlib marker style. Defaults to '*'.
            color (str, optional): Matplotlib color. Defaults to 'magenta'.
        """
        self.id = id
        self.x = x
        self.y = y
        self.label = label
        self.marker = marker
        self.color = color

    def to_log_entry(self, timestamp: float) -> AgentLogEntry:
        """Convert agent state to a log entry for recording.

        Args:
            timestamp: Current simulation timestamp.

        Returns:
            AgentLogEntry: Log entry containing the agent's current state.
        """
        entry = AgentLogEntry(
            timestamp=timestamp,
            id=self.id,
            label=self.label,
            x=self.x,
            y=self.y,
            marker=self.marker,
            color=self.color
        )

        return entry

__init__(id, x, y, label=None, marker='*', color='magenta')

Initialize an agent with position and display properties.

Parameters:

Name Type Description Default
id Any

Unique identifier of the agent.

required
x float

X position in meters within the simulation.

required
y float

Y position in meters within the simulation.

required
label str

Label displayed with the agent. Defaults to None.

None
marker str

Matplotlib marker style. Defaults to '*'.

'*'
color str

Matplotlib color. Defaults to 'magenta'.

'magenta'
Source code in embrs/base_classes/agent_base.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def __init__(self, id: Any, x: float, y: float, label: str = None, marker: str = '*',
             color: str = 'magenta'):
    """Initialize an agent with position and display properties.

    Args:
        id: Unique identifier of the agent.
        x (float): X position in meters within the simulation.
        y (float): Y position in meters within the simulation.
        label (str, optional): Label displayed with the agent. Defaults to None.
        marker (str, optional): Matplotlib marker style. Defaults to '*'.
        color (str, optional): Matplotlib color. Defaults to 'magenta'.
    """
    self.id = id
    self.x = x
    self.y = y
    self.label = label
    self.marker = marker
    self.color = color

to_log_entry(timestamp)

Convert agent state to a log entry for recording.

Parameters:

Name Type Description Default
timestamp float

Current simulation timestamp.

required

Returns:

Name Type Description
AgentLogEntry AgentLogEntry

Log entry containing the agent's current state.

Source code in embrs/base_classes/agent_base.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
def to_log_entry(self, timestamp: float) -> AgentLogEntry:
    """Convert agent state to a log entry for recording.

    Args:
        timestamp: Current simulation timestamp.

    Returns:
        AgentLogEntry: Log entry containing the agent's current state.
    """
    entry = AgentLogEntry(
        timestamp=timestamp,
        id=self.id,
        label=self.label,
        x=self.x,
        y=self.y,
        marker=self.marker,
        color=self.color
    )

    return entry

Base class for fire simulation providing shared logic for FireSim and FirePredictor.

This module contains BaseFireSim, which implements the core fire spread simulation mechanics including cell management, fire propagation, weather updates, and control interface elements.

Classes:

Name Description
- BaseFireSim

Core fire simulation logic and state management.

.. autoclass:: BaseFireSim :members:

BaseFireSim

Base class for fire simulation providing shared logic for FireSim and FirePredictor.

Manages the hexagonal cell grid, fire propagation mechanics, weather updates, and control interface elements. Subclassed by FireSim for real-time simulation and FirePredictor for forward prediction.

This class uses composition with several manager classes
  • GridManager: Handles cell grid storage, coordinate conversion, and neighbor lookups.
  • WeatherManager: Handles weather stream and wind forecast management.
  • ControlActionHandler: Handles fire suppression actions (retardant, water, firelines).

Attributes:

Name Type Description
cell_grid ndarray

2D array of Cell objects backing the simulation.

cell_dict Dict[int, Cell]

Dictionary mapping cell IDs to Cell objects.

burning_cells List[Cell]

Currently burning cells.

frontier set

Cell IDs adjacent to burning cells that could ignite.

curr_time_s int

Current simulation time in seconds.

iters int

Number of iterations completed.

finished bool

Whether the simulation has completed.

Source code in embrs/base_classes/base_fire.py
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 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
class BaseFireSim:
    """Base class for fire simulation providing shared logic for FireSim and FirePredictor.

    Manages the hexagonal cell grid, fire propagation mechanics, weather updates,
    and control interface elements. Subclassed by FireSim for real-time simulation
    and FirePredictor for forward prediction.

    This class uses composition with several manager classes:
        - GridManager: Handles cell grid storage, coordinate conversion, and neighbor lookups.
        - WeatherManager: Handles weather stream and wind forecast management.
        - ControlActionHandler: Handles fire suppression actions (retardant, water, firelines).

    Attributes:
        cell_grid (np.ndarray): 2D array of Cell objects backing the simulation.
        cell_dict (Dict[int, Cell]): Dictionary mapping cell IDs to Cell objects.
        burning_cells (List[Cell]): Currently burning cells.
        frontier (set): Cell IDs adjacent to burning cells that could ignite.
        curr_time_s (int): Current simulation time in seconds.
        iters (int): Number of iterations completed.
        finished (bool): Whether the simulation has completed.
    """

    def __init__(self, sim_params: SimParams, burnt_region: list = None):
        """Initialize the fire simulation.

        Creates the hexagonal cell grid, populates cells with terrain and
        fuel data, sets up weather and wind forecasts, and applies initial
        ignitions and fire breaks.

        Args:
            sim_params (SimParams): Simulation configuration parameters.
            burnt_region (list, optional): List of Shapely geometries defining
                pre-burnt regions. Defaults to None.
        """
        # Check if current instance is a prediction model
        prediction = self.is_prediction()

        if prediction:
            print("Initializing prediction model backing array...")

        else:
            print("Initializing fire sim backing array...")

        # Constant parameters
        self.display_frequency = 300
        self._sim_params = sim_params

        # Weather manager placeholder (initialized in _parse_sim_params after weather data is loaded)
        self._weather_manager = None

        # Store sim input values in class variables (also initializes weather manager)
        self._parse_sim_params(sim_params)

        # Variables to keep track of sim progress
        self._curr_time_s = 0
        self._iters = 0

        # Variables to store logger and visualizer object
        self.logger = None
        self._visualizer = None

        # Track whether sim is finished or not
        self._finished = False

        # Containers for keeping track of updates to cells
        self._updated_cells = {}

        # Containers for fire spread tracking (note: _cell_dict is set up with grid manager below)
        self._burning_cells = []
        self._new_ignitions = []
        self._suppressed_cells = []
        self._burnt_cells = set()
        self._frontier = set()
        self.starting_ignitions = set()
        self._urban_cells = []

        # Pre-allocated buffer for propagate_fire new intersection indices (12 hex dirs)
        self._new_ixn_buf = np.empty(12, dtype=np.int64)

        # Crown fire containers
        self._scheduled_spot_fires = {}

        # Control action handler placeholder (initialized after grid manager)
        self._control_handler = None

        # Set up grid manager for cell storage and lookup
        self._grid_manager = GridManager(
            num_rows=self._shape[0],
            num_cols=self._shape[1],
            cell_size=self._cell_size
        )
        # Backward-compatible references (delegate to grid manager)
        self._cell_grid = self._grid_manager.cell_grid
        self._grid_width = self._grid_manager._grid_width
        self._grid_height = self._grid_manager._grid_height
        self._cell_dict = self._grid_manager.cell_dict

        # Set up control action handler for fire suppression operations
        self._control_handler = ControlActionHandler(
            grid_manager=self._grid_manager,
            cell_size=self._cell_size,
            time_step=self._time_step,
            fuel_class_factory=self.FuelClass
        )
        self._control_handler.set_updated_cells_ref(self._updated_cells)
        self._control_handler.set_time_accessor(lambda: self._curr_time_s)
        self._control_handler.logger = self.logger
        self._control_handler._visualizer = self._visualizer

        # Transfer scenario fire break data to control handler
        for fire_break, width, id in self._fire_breaks:
            self._control_handler._fire_breaks.append((fire_break, width, id))
            self._control_handler._fire_break_dict[id] = (fire_break, width)

        # Backward-compatible references (delegate to control handler)
        self._long_term_retardants = self._control_handler.long_term_retardants
        self._active_water_drops = self._control_handler.active_water_drops
        self._fire_break_cells = self._control_handler.fire_break_cells
        self._active_firelines = self._control_handler.active_firelines
        self._new_fire_break_cache = self._control_handler.new_fire_break_cache
        self._fire_breaks = self._control_handler.fire_breaks
        self.fire_break_dict = self._control_handler.fire_break_dict

        if not prediction: # Regular FireSim
            if self._fms_has_live:
                live_h_mf = self._init_live_h_mf
                live_w_mf = self._init_live_w_mf
            else:
                # Set live moisture and foliar moisture to weather stream values
                live_h_mf = self._weather_stream.live_h_mf
                live_w_mf = self._weather_stream.live_w_mf
            self.fmc = self._weather_stream.fmc

        else:
            # Prediction model has live_mf as an attribute
            live_h_mf = self.live_mf
            live_w_mf = self.live_mf
            self.fmc = 100

        if self.model_spotting:
            # Limits to pass into spotting models
            limits = (self.x_lim, self.y_lim)
            if not prediction:
                # Spot fire modelling class
                self.embers = Embers(self._spot_ign_prob, self._canopy_species, self._dbh_cm, self._min_spot_distance, limits, self.get_cell_from_xy)

            else:
                self.embers = PerrymanSpotting(self._spot_delay_s, limits)


        # Store references needed by the cell factory
        self._lcp_data = sim_params.map_params.lcp_data
        self._live_h_mf = live_h_mf
        self._live_w_mf = live_w_mf

        # Cache fuel model instances by (fuel_key, live_h_mf) to avoid
        # creating duplicate objects. Typically ~40 unique fuel types vs 100K+ cells.
        self._fuel_cache = {}

        # Pre-compute terrain data for all cells using vectorized operations
        self._precompute_terrain_data()

        # Populate cell_grid with cells using the grid manager
        total_cells = self._shape[0] * self._shape[1]
        with tqdm(total=total_cells, desc="Initializing cells") as pbar:
            self._grid_manager.init_grid(
                cell_factory=self._create_cell,
                progress_callback=pbar.update
            )

        # Batch-create all cell polygons using vectorized Shapely operations
        self._batch_create_polygons()

        # Clear pre-computed data to free memory
        self._clear_precomputed_terrain_data()

        # Populate neighbors field for each cell with pointers to each of its neighbors
        self._grid_manager.add_cell_neighbors()

        # Set initial ignitions
        self._set_initial_ignition(self.initial_ignition)

        # Set burnt cells
        if burnt_region is not None:
            self._set_initial_burnt_region(burnt_region)

        # Overwrite urban cells to their neighbors (road modelling handles fire spread through roads)
        for cell in self._urban_cells:
            self._overwrite_urban_fuel(cell)

        # Apply fire breaks
        self._set_firebreaks()

        # Apply Roads 
        self._set_roads()

        print("Base initialization complete...")

    def _create_cell(self, cell_id: int, col: int, row: int) -> Cell:
        """Factory method to create and initialize a cell.

        Creates a Cell object and populates it with terrain data, fuel type,
        moisture values, and wind forecast data from the simulation maps.

        Uses pre-computed terrain data when available (from _precompute_terrain_data)
        for better performance on large grids.

        Args:
            cell_id: Unique identifier for the cell.
            col: Column index in the grid.
            row: Row index in the grid.

        Returns:
            Fully initialized Cell object.
        """
        # Initialize cell object
        new_cell = Cell(cell_id, col, row, self._cell_size)

        # Set cell's parent reference to the current sim instance
        new_cell.set_parent(self)

        # Initialize cell data class
        cell_data = CellData()

        cell_x, cell_y = new_cell.x_pos, new_cell.y_pos

        # Use pre-computed terrain data if available (vectorized extraction)
        if hasattr(self, '_precomputed_fuel') and self._precomputed_fuel is not None:
            # Get terrain values from pre-computed arrays
            fuel_key = self._precomputed_fuel[row, col]
            cell_data.elevation = self._precomputed_elevation[row, col]
            cell_data.aspect = self._precomputed_aspect[row, col]
            cell_data.slope_deg = self._precomputed_slope[row, col]
            cell_data.canopy_cover = self._precomputed_cc[row, col]
            cell_data.canopy_height = self._precomputed_ch[row, col]
            cell_data.canopy_base_height = self._precomputed_cbh[row, col]
            cell_data.canopy_bulk_density = self._precomputed_cbd[row, col]
        else:
            # Fallback to per-cell computation (for backwards compatibility)
            # Get row and col of data arrays corresponding to cell
            data_col = int(np.floor(cell_x / self._data_res))
            data_row = int(np.floor(cell_y / self._data_res))

            # Ensure data row and col in bounds
            data_col = min(data_col, self._lcp_data.cols - 1)
            data_row = min(data_row, self._lcp_data.rows - 1)

            # Get terrain values from maps
            fuel_key = self._fuel_map[data_row, data_col]
            cell_data.elevation = self._elevation_map[data_row, data_col]
            cell_data.aspect = self._aspect_map[data_row, data_col]
            cell_data.slope_deg = self._slope_map[data_row, data_col]
            cell_data.canopy_cover = self._cc_map[data_row, data_col]
            cell_data.canopy_height = self._ch_map[data_row, data_col]
            cell_data.canopy_base_height = self._cbh_map[data_row, data_col]
            cell_data.canopy_bulk_density = self._cbd_map[data_row, data_col]

        # Store elevation in coarse grid
        self.coarse_elevation[row, col] = cell_data.elevation

        # Set initial moisture values (default)
        cell_data.init_dead_mf = self._init_mf
        cell_data.live_h_mf = self._live_h_mf
        cell_data.live_w_mf = self._live_w_mf
        if self._fuel_moisture_map.get(fuel_key) is not None:
            mf_vals = self._fuel_moisture_map[fuel_key]
            cell_data.init_dead_mf = mf_vals[:3]
            if self._fms_has_live and len(mf_vals) >= 5:
                cell_data.live_h_mf = mf_vals[3]
                cell_data.live_w_mf = mf_vals[4]

        cache_key = (fuel_key, cell_data.live_h_mf)
        fuel = self._fuel_cache.get(cache_key)
        if fuel is None:
            fuel = self.FuelClass(fuel_key, cell_data.live_h_mf)
            self._fuel_cache[cache_key] = fuel
        cell_data.fuel_type = fuel

        # Get data for cell
        new_cell._set_cell_data(cell_data)

        # If the fuel type is urban add it to urban cell list
        if fuel_key == 91:
            self._urban_cells.append(new_cell)

        # Set wind forecast in cell using weather manager
        wind_speed, wind_dir = self._weather_manager.get_cell_wind(cell_x, cell_y)
        new_cell._set_wind_forecast(wind_speed, wind_dir)

        return new_cell

    def _precompute_terrain_data(self) -> None:
        """Pre-compute terrain data for all cells using vectorized operations.

        This method uses numpy array operations to extract terrain data for all
        cells at once, avoiding per-cell lookups during grid initialization.
        The pre-computed data is stored in instance attributes and used by
        _create_cell during grid population.

        Performance Note:
            This vectorized approach is significantly faster than per-cell
            lookups, especially for large grids (1000x1000+).
        """
        # Compute all cell positions using grid manager
        all_x, all_y = self._grid_manager.compute_all_cell_positions()

        # Store positions for cell factory
        self._precomputed_x = all_x
        self._precomputed_y = all_y

        # Compute data array indices for all cells
        data_row_idx, data_col_idx = self._grid_manager.compute_data_indices(
            all_x, all_y,
            self._data_res,
            self._lcp_data.rows,
            self._lcp_data.cols
        )

        # Store indices for cell factory
        self._precomputed_data_row = data_row_idx
        self._precomputed_data_col = data_col_idx

        # Vectorized extraction of all terrain data
        # Using fancy indexing to extract values for all cells at once
        self._precomputed_elevation = self._elevation_map[data_row_idx, data_col_idx]
        self._precomputed_aspect = self._aspect_map[data_row_idx, data_col_idx]
        self._precomputed_slope = self._slope_map[data_row_idx, data_col_idx]
        self._precomputed_fuel = self._fuel_map[data_row_idx, data_col_idx]
        self._precomputed_cc = self._cc_map[data_row_idx, data_col_idx]
        self._precomputed_ch = self._ch_map[data_row_idx, data_col_idx]
        self._precomputed_cbh = self._cbh_map[data_row_idx, data_col_idx]
        self._precomputed_cbd = self._cbd_map[data_row_idx, data_col_idx]

    def _batch_create_polygons(self) -> None:
        """Create all cell polygons in a single vectorized Shapely call.

        Computes hex vertex coordinates for all cells using numpy, then calls
        shapely.polygons() once instead of creating 100K+ individual Polygon
        objects. This avoids Shapely's per-call inspect.signature overhead.
        """
        rows, cols = self._shape
        total = rows * cols
        cell_size = self._cell_size
        sqrt3_half = _math.sqrt(3) / 2 * cell_size

        # Build coordinate array: (total, 7, 2) — 7 vertices (closed ring)
        coords = np.empty((total, 7, 2), dtype=np.float64)
        idx = 0
        for row in range(rows):
            for col in range(cols):
                cell = self._cell_grid[row, col]
                x, y = cell.x_pos, cell.y_pos
                coords[idx, 0] = (x, y + cell_size)
                coords[idx, 1] = (x + sqrt3_half, y + cell_size / 2)
                coords[idx, 2] = (x + sqrt3_half, y - cell_size / 2)
                coords[idx, 3] = (x, y - cell_size)
                coords[idx, 4] = (x - sqrt3_half, y - cell_size / 2)
                coords[idx, 5] = (x - sqrt3_half, y + cell_size / 2)
                coords[idx, 6] = (x, y + cell_size)  # close ring
                idx += 1

        # Create all polygons in one C-level batch call
        rings = shapely_linearrings(coords)
        polys = shapely_polygons(rings)

        # Assign back to cells
        idx = 0
        for row in range(rows):
            for col in range(cols):
                self._cell_grid[row, col].polygon = polys[idx]
                idx += 1

    def _clear_precomputed_terrain_data(self) -> None:
        """Clear pre-computed terrain data to free memory.

        Called after grid initialization is complete since the pre-computed
        arrays are no longer needed.
        """
        # Clear large arrays to free memory
        self._precomputed_x = None
        self._precomputed_y = None
        self._precomputed_data_row = None
        self._precomputed_data_col = None
        self._precomputed_elevation = None
        self._precomputed_aspect = None
        self._precomputed_slope = None
        self._precomputed_fuel = None
        self._precomputed_cc = None
        self._precomputed_ch = None
        self._precomputed_cbh = None
        self._precomputed_cbd = None

    def _parse_sim_params(self, sim_params: SimParams):
        """Parse simulation parameters and initialize internal state.

        Extracts configuration from SimParams including cell size, duration,
        fuel maps, weather data, and spotting parameters. Initializes the
        wind forecast and weather stream.

        Args:
            sim_params (SimParams): Simulation configuration parameters.

        Raises:
            ValueError: If the FBFM fuel model type is not supported.
        """
        # Load general sim params
        self._cell_size = sim_params.cell_size
        self._sim_duration = sim_params.duration_s
        self._time_step = sim_params.t_step_s
        self._init_mf = sim_params.init_mf
        self._fuel_moisture_map = getattr(sim_params, 'fuel_moisture_map', {})
        self._fms_has_live = getattr(sim_params, 'fms_has_live', False)
        self._init_live_h_mf = getattr(sim_params, 'live_h_mf', 0.0)
        self._init_live_w_mf = getattr(sim_params, 'live_w_mf', 0.0)

        # Load map params
        map_params = sim_params.map_params
        self._size = map_params.size()
        self._shape = map_params.shape(self._cell_size)
        self._roads = map_params.roads
        self.coarse_elevation = np.empty(self._shape)

        fbfm_type = map_params.fbfm_type
        if fbfm_type == "Anderson":
            self.FuelClass = Anderson13

        elif fbfm_type == "ScottBurgan":
            self.FuelClass = ScottBurgan40

        else:
            raise ValueError(f"FBFM Type {fbfm_type} not supported")

        # Load DataProductParams for each data product
        lcp_data = map_params.lcp_data

        # Get map for each data product
        self._elevation_map = np.flipud(lcp_data.elevation_map)
        self._slope_map = np.flipud(lcp_data.slope_map)
        self._aspect_map = np.flipud(lcp_data.aspect_map)
        self._fuel_map = np.flipud(lcp_data.fuel_map)
        self._cc_map = np.flipud(lcp_data.canopy_cover_map)
        self._ch_map = np.flipud(lcp_data.canopy_height_map)
        self._cbh_map = np.flipud(lcp_data.canopy_base_height_map)
        self._cbd_map = np.flipud(lcp_data.canopy_bulk_density_map)

        # Get resolution for data products
        self._data_res = lcp_data.resolution

        # Load scenario specific data
        scenario = map_params.scenario_data
        self._fire_breaks = list(zip(scenario.fire_breaks, scenario.break_widths, scenario.break_ids))
        self.fire_break_dict = {id: (fire_break, break_width) 
                                for fire_break, break_width, id in list(zip(scenario.fire_breaks, scenario.break_widths, scenario.break_ids))}
        self._initial_ignition = scenario.initial_ign

        # Grab starting datetime
        self._start_datetime = sim_params.weather_input.start_datetime

        # Grab north direction
        self._north_dir_deg = map_params.geo_info.north_angle_deg

        # If loading from lcp file change aspect to uphill direction
        self._aspect_map = (180 + self._aspect_map) % 360 

        if self.is_prediction():
            # Prediction models use placeholder wind data initially
            self._wind_res = 10e10
            self._weather_manager = WeatherManager(
                weather_stream=None,
                wind_res=self._wind_res,
                sim_size=self._size
            )
            self._weather_manager.curr_weather_idx = 0
        else:
            # Generate a weather stream
            self._weather_stream = WeatherStream(
                sim_params.weather_input, sim_params.map_params.geo_info, use_gsi=not self._fms_has_live
            )

            # Get wind data
            self._wind_res = sim_params.weather_input.mesh_resolution
            raw_forecast = run_windninja(self._weather_stream, sim_params.map_params)

            # Flip forecast layers
            flipped_forecast = np.empty(raw_forecast.shape)
            for layer in range(raw_forecast.shape[0]):
                flipped_forecast[layer] = np.flipud(raw_forecast[layer])

            # Create weather manager with the processed forecast
            self._weather_manager = WeatherManager(
                weather_stream=self._weather_stream,
                wind_forecast=flipped_forecast,
                wind_res=self._wind_res,
                sim_size=self._size
            )

        # Backward-compatible references to weather manager state
        self.sim_start_w_idx = self._weather_manager.sim_start_w_idx
        self._curr_weather_idx = self._weather_manager.curr_weather_idx
        self._last_weather_update = self._weather_manager.last_weather_update
        self.weather_changed = self._weather_manager.weather_changed
        self.weather_t_step = self._weather_manager.weather_t_step
        self.wind_xpad = self._weather_manager.wind_xpad
        self.wind_ypad = self._weather_manager.wind_ypad
        self.wind_forecast = self._weather_manager.wind_forecast

        self._burn_area_threshold = getattr(sim_params, 'burn_area_threshold', 0.75)

        self.model_spotting = sim_params.model_spotting
        self._spot_ign_prob = 0.0

        if self.model_spotting:
            self._canopy_species = sim_params.canopy_species
            self._dbh_cm = sim_params.dbh_cm
            self._spot_ign_prob = sim_params.spot_ign_prob
            self._min_spot_distance = sim_params.min_spot_dist
            self._spot_delay_s = sim_params.spot_delay_s

        else:
            self._canopy_species = None
            self._dbh_cm = None
            self._spot_ign_prob = None
            self._min_spot_distance = None
            self._spot_delay_s = None

    def _add_cell_neighbors(self):
        """Populate neighbor references for all cells in the grid.

        For each cell, determines its neighbors based on hexagonal grid
        geometry (even/odd row offset) and stores neighbor IDs with their
        relative positions.

        Note: This method delegates to GridManager.add_cell_neighbors().
        """
        self._grid_manager.add_cell_neighbors()

    def remove_neighbors(self, cell: Cell):
        """Remove non-burnable neighbors from a cell's burnable neighbors list.

        Filters out neighbors that are no longer in the FUEL state from
        the cell's burnable_neighbors dictionary.

        Args:
            cell (Cell): Cell whose burnable neighbors should be updated.
        """
        # Remove any neighbors which are no longer burnable
        neighbors_to_rem = []
        for n_id in cell.burnable_neighbors:
            neighbor = self._cell_dict[n_id]

            if neighbor.state == CellStates.BURNT:
                neighbors_to_rem.append(n_id)

        if neighbors_to_rem:
            for n_id in neighbors_to_rem:
                del cell.burnable_neighbors[n_id]

    def update_steady_state(self, cell: Cell):
        """Update steady-state rate of spread for a burning cell.

        Checks for crown fire conditions and calculates surface or crown
        fire spread rates. Also triggers ember lofting for spotting if
        crown fire is active.

        Args:
            cell (Cell): Burning cell to update.
        """
        # Checks if fire in cell meets threshold for crown fire, calls calc_propagation_in_cell using the crown ROS if active crown fire
        crown_fire(cell, self.fmc)

        if cell._crown_status != CrownStatus.ACTIVE:
            # Update values for cells that are not active crown fires
            surface_fire(cell)

        cell.has_steady_state = True

        if self.model_spotting:
            if self.is_firesim() and cell._crown_status != CrownStatus.NONE and self._spot_ign_prob > 0:
                if not cell.lofted:
                    self.embers.loft(cell, self.curr_time_m)

            elif self.is_prediction() and cell._crown_status != CrownStatus.NONE and self._spot_ign_prob > 0:
                if not cell.lofted:
                    self.embers.loft(cell)

    def propagate_fire(self, cell: Cell):
        """Propagate fire spread from a burning cell to its neighbors.

        Updates fire spread extent along each direction, checks for
        intersections with neighbor boundaries, and triggers ignition
        of neighboring cells when fire reaches them.

        Uses a JIT-compiled inner loop to avoid Python overhead on the
        12-element direction arrays.

        Args:
            cell (Cell): Burning cell from which to propagate fire.
        """
        intersections = cell.intersections
        r_t = cell.r_t
        new_ixn_buf = self._new_ixn_buf

        result = _propagate_fire_core(
            cell.fire_spread, cell.avg_ros, cell.distances,
            intersections, self._time_step,
            r_t, cell.r_ss, self._iters != 0, new_ixn_buf
        )

        if result == -1:
            # All ROS zero — suppression scenario
            fire_area_ratio = cell.fire_area_m2 / cell.cell_area

            if fire_area_ratio >= self._burn_area_threshold:
                cell.fully_burning = True  # Will become BURNT next iteration
                return

            # Compute which boundary locations are consumed (before clearing arrays)
            cell.compute_disabled_locs()

            if cell.n_disabled_locs >= 11:
                cell.fully_burning = True  # <=1 loc remaining, can't propagate
                return

            # Partial suppression: return cell to FUEL
            cell.suppress_to_fuel()
            self._suppressed_cells.append(cell)
            return

        if result < -1:
            # Fully burning (all directions crossed) with some new intersections
            n_new = -(result + 2)
            cell.fully_burning = True
        else:
            n_new = result

        # Process new intersections — ignite neighbors
        if n_new > 0 and cell.breached:
            directions = cell.directions
            end_pts = cell.end_pts
            ignite = self.ignite_neighbors
            self_end_points = cell._self_end_points
            disabled = cell.disabled_locs
            for j in range(n_new):
                i = int(new_ixn_buf[j])
                # Check if exit boundary location is disabled
                if self_end_points is not None and self_end_points[i] in disabled:
                    continue
                ignite(cell, r_t[i], directions[i], end_pts[i])

    def ignite_neighbors(self, cell: Cell, r_gamma: float, gamma: float, end_point: list):
        """Attempt to ignite neighboring cells reached by fire spread.

        For each endpoint where fire has reached a neighbor boundary,
        checks if the neighbor is burnable and applies ignition if
        conditions are met.

        Args:
            cell (Cell): Source cell spreading fire.
            r_gamma (float): Rate of spread in direction gamma (m/s).
            gamma (float): Spread direction angle (degrees).
            end_point (list): List of (location, neighbor_letter) tuples
                indicating where fire reached neighbor boundaries.
        """
        # Loop through end points
        for pt in end_point:

            # Get the location of the potential ignition on the neighbor
            n_loc = pt[0]

            # Get the Cell object of the neighbor
            neighbor = self.get_neighbor_from_end_point(cell, pt)

            if neighbor:
                # Check that neighbor state is burnable
                if neighbor.state == CellStates.FUEL and neighbor.fuel.burnable:
                    # Check that the entry point is not disabled
                    if n_loc in neighbor.disabled_locs:
                        continue
                    # Make ignition calculation
                    if self.is_firesim():
                        neighbor._update_moisture(self._curr_weather_idx, self._weather_stream)

                    # Check that ignition ros is greater than no wind no slope ros
                    if neighbor._retardant_factor > 0:
                        self._new_ignitions.append(neighbor)
                        neighbor.get_ign_params(n_loc)
                        neighbor._set_state(CellStates.FIRE)

                        if cell._crown_status == CrownStatus.ACTIVE and neighbor.has_canopy:
                            neighbor._crown_status = CrownStatus.ACTIVE

                        self.set_surface_accel_constant(neighbor)
                        surface_fire(neighbor)

                        r_eff = self.calc_ignition_ros(cell, neighbor, r_gamma)

                        neighbor.r_t, _ = calc_vals_for_all_directions(neighbor, r_eff, -999, neighbor.alpha, neighbor.e)

                        self._updated_cells[neighbor.id] = neighbor

                        if neighbor.id in self._frontier:
                            self._frontier.remove(neighbor.id)

                    else:
                        if neighbor.id not in self._frontier:
                            self._frontier.add(neighbor.id)

    def get_neighbor_from_end_point(self, cell: Cell, end_point: Tuple[int, str]) -> Cell:
        """Get the neighbor cell corresponding to an endpoint location.

        Args:
            cell (Cell): Source cell from which to find neighbor.
            end_point (Tuple[int, str]): Tuple of (location, neighbor_letter)
                where neighbor_letter indicates relative position.

        Returns:
            Cell: The neighboring Cell if it exists and is burnable, None otherwise.
        """
        # Get the letter representing the neighbor location relative to cell
        neighbor_letter = end_point[1]

        # Get neighbor based on neighbor_letter
        if cell._row % 2 == 0:
            diff_to_letter_map = HexGridMath.even_neighbor_letters

        else:
            diff_to_letter_map = HexGridMath.odd_neighbor_letters

        # Get the row and col difference between cell and neighbor
        dx, dy = diff_to_letter_map[neighbor_letter]

        row_n = int(cell.row + dy)
        col_n = int(cell.col + dx)

        if self._grid_height >= row_n >=0 and self._grid_width >= col_n >= 0:
            # Retrieve neighbor from cell grid
            neighbor = self._cell_grid[row_n, col_n]

            # If neighbor in cell's neighbors return it
            if neighbor.id in cell.burnable_neighbors:
                return neighbor

        return None

    def calc_ignition_ros(self, cell: Cell, neighbor: Cell, r_gamma: float) -> float:
        """Calculate the ignition rate of spread for a neighbor cell.

        Uses heat source from the spreading cell and heat sink from the
        receiving neighbor to compute the effective ignition ROS.

        Args:
            cell (Cell): Source cell spreading fire.
            neighbor (Cell): Neighbor cell being ignited.
            r_gamma (float): Rate of spread from source cell (m/s).

        Returns:
            float: Ignition rate of spread (ft/min).
        """
        # Get the rate of spread in ft/min
        r_ft_min = m_s_to_ft_min(r_gamma)

        # Get the heat source in the direction of question by eliminating denominator
        heat_source = r_ft_min * calc_heat_sink(cell.fuel, cell.fmois)

        # Get the heat sink using the neighbors fuel and moisture content
        heat_sink = calc_heat_sink(neighbor.fuel, neighbor.fmois)

        # Calculate a ignition rate of spread
        r_ign = heat_source / heat_sink

        return r_ign

    def propagate_embers(self):
        """Propagate embers from crown fires and ignite spot fires.

        Simulates ember flight from lofted embers, schedules spot fire
        ignitions with delay, and ignites previously scheduled spots
        when their ignition time is reached.
        """
        spot_fires = self.embers.flight(self.curr_time_s + (self.time_step/60))

        if spot_fires:
            # Schedule spot fires using the ignition delay
            for spot in spot_fires:
                ign_time = self.curr_time_s + self._spot_delay_s

                if self._scheduled_spot_fires.get(ign_time) is None:
                    self._scheduled_spot_fires[ign_time] = [spot]
                else:
                    self._scheduled_spot_fires[ign_time].append(spot)

        if self._scheduled_spot_fires:
            # Ignite spots that have been scheduled previously
            pending_times = list(self._scheduled_spot_fires.keys())

            for time in pending_times:
                if time <= self.curr_time_s:
                    # All cells with this time should be ignited
                    new_spots = self._scheduled_spot_fires[time]

                    for spot in new_spots:
                        self._new_ignitions.append(spot)
                        self._updated_cells[spot.id] = spot

                    del self._scheduled_spot_fires[time]

                if time > self.curr_time_s:
                    break

    def update_control_interface_elements(self):
        """Update all active control interface elements.

        Processes long-term retardants, active fireline construction,
        and water drops that need updates based on elapsed time.
        """
        if self._long_term_retardants:
            self.update_long_term_retardants()

        if self._active_firelines:
            self._update_active_firelines()

        if self._active_water_drops:
            self._update_active_water_drops()

    def _update_active_water_drops(self):
        """Update active water drops and remove those whose effect has diminished.

        Removes cells from active water drops when their moisture content
        falls below 50% of their fuel's extinction moisture.
        """
        def should_keep_water_drop(cell) -> bool:
            """Check if water drop effect should continue."""
            if self.is_firesim():
                cell._update_moisture(self._curr_weather_idx, self._weather_stream)
            dead_mf, _ = get_characteristic_moistures(cell.fuel, cell.fmois)
            return dead_mf >= cell.fuel.dead_mx * 0.5

        self._active_water_drops = [
            cell for cell in self._active_water_drops
            if should_keep_water_drop(cell)
        ]

    def update_long_term_retardants(self):
        """Update long-term retardant effects and remove expired retardants.

        Checks retardant expiration times and removes retardant effects
        from cells whose retardant has expired.

        Note: This method delegates to ControlActionHandler.update_long_term_retardants().
        """
        self._control_handler.update_long_term_retardants(self._curr_time_s)

    def calc_wind_padding(self, forecast: np.ndarray) -> Tuple[float, float]:
        """Calculate padding offsets between wind forecast grid and simulation grid.

        The wind forecast grid may not align exactly with the simulation
        boundaries. This calculates the x and y offsets needed to center
        the forecast within the simulation domain.

        Args:
            forecast (np.ndarray): Wind forecast array with shape
                (time_steps, rows, cols, 2) where last dimension is (speed, direction).

        Returns:
            Tuple[float, float]: (x_padding, y_padding) in meters.

        Note: This method delegates to WeatherManager.calc_wind_padding().
        """
        return self._weather_manager.calc_wind_padding(forecast)

    def _set_roads(self):
        """Apply road data to the simulation grid.

        Sets cells along roads to urban fuel type for wide roads, or
        overwrites urban fuel with neighboring fuel types for narrow roads.
        Roads contribute to fire break width calculations.
        """
        if self.roads is not None:
            for road, _, road_width in self.roads:
                for road_x, road_y in zip(road[0], road[1]):
                    road_cell = self.get_cell_from_xy(road_x, road_y, oob_ok = True)

                    if road_cell is not None:
                        if road_width > self._cell_size:
                            # Set to urban fuel type
                            road_cell._set_fuel_type(self.FuelClass(91))
                        else:
                            if road_cell._fuel.model_num == 91:
                                self._overwrite_urban_fuel(road_cell)

                        road_cell._break_width += road_width

                        if road_cell.state == CellStates.FIRE:
                            road_cell._set_state(CellStates.FUEL)

    def _overwrite_urban_fuel(self, cell: Cell):
        """Replace urban fuel type with the most common neighboring fuel type.

        Used to ensure urban cells (roads) inherit burnable fuel properties
        from their surroundings for fire spread calculations.

        Args:
            cell (Cell): Cell with urban fuel type to overwrite.
        """
        fuel_types = []
        for id in cell.neighbors.keys():
            neighbor = self._cell_dict[id]
            fuel_num = neighbor._fuel.model_num
            if neighbor._fuel.burnable:
                fuel_types.append(fuel_num)

        if fuel_types:
            counts = np.bincount(fuel_types)
            new_fuel_num = np.argmax(counts)

            cell._set_fuel_type(self.FuelClass(new_fuel_num))

    def _set_firebreaks(self):
        """Apply all configured fire breaks to the simulation grid.

        Iterates through fire break definitions and applies each to
        the cells along the break geometry.
        """
        for line, break_width, _ in self.fire_breaks:
            self._apply_firebreak(line, break_width)

    def _apply_firebreak(self, line: LineString, break_width: float):
        """Apply a fire break along a line geometry.

        Adds cells along the line to the fire break list and increments
        their break width. Cells with total break width exceeding cell
        size are converted to urban (non-burnable) fuel type.

        Args:
            line (LineString): Shapely LineString defining the fire break path.
            break_width (float): Width of the fire break in meters.

        Note: This method delegates to ControlActionHandler._apply_firebreak().
        """
        self._control_handler._apply_firebreak(line, break_width)


    def _set_initial_ignition(self, geometries: list):
        """Set initial fire ignition locations from geometry definitions.

        Converts geometry objects (Points, LineStrings, Polygons) to cells
        and adds them to the starting ignitions set.

        Args:
            geometries (list): List of Shapely geometry objects defining
                initial ignition locations.
        """

        all_cells = []
        for geom in geometries:
            cells = self.get_cells_at_geometry(geom)
            all_cells.extend(cells)

        for cell in all_cells:
            self.starting_ignitions.add((cell, 0))

    def _set_initial_burnt_region(self, geometries: list):
        """Set initial burnt regions from geometry definitions.

        Converts geometry objects to cells and sets their state to BURNT.

        Args:
            geometries (list): List of Shapely geometry objects defining
                pre-burnt regions.
        """

        all_cells = []
        for geom in geometries:
            cells = self.get_cells_at_geometry(geom)
            all_cells.extend(cells)

        for cell in all_cells:
            cell._set_state(CellStates.BURNT)


    def get_frontier(self) -> set:
        """Get set of cell IDs at the fire frontier.

        The frontier consists of FUEL cells adjacent to burning cells
        that could potentially ignite. Cells completely surrounded by
        fire are removed from the frontier.

        Note:
            This method has the side effect of pruning cells that are
            completely surrounded by fire from the internal frontier set.

        Returns:
            set: Cell IDs of cells at the fire frontier.
        """
        def has_fuel_neighbor(cell_id: int) -> bool:
            """Check if cell has at least one FUEL neighbor."""
            cell = self.cell_dict[cell_id]
            for neighbor_id in cell.burnable_neighbors:
                neighbor = self.cell_dict[neighbor_id]
                if neighbor.state == CellStates.FUEL:
                    return True
            return False

        # Filter frontier to keep only cells with at least one FUEL neighbor
        self._frontier = {
            c for c in self._frontier
            if has_fuel_neighbor(c)
        }

        return self._frontier

    def get_frontier_cells(self) -> List['Cell']:
        """Get list of cells at the fire frontier.

        The frontier consists of FUEL cells adjacent to burning cells
        that could potentially ignite. Cells completely surrounded by
        fire are removed from the frontier.

        This method is more efficient than get_frontier() when you need
        to access cell properties, as it avoids a second round of
        dictionary lookups.

        Note:
            This method has the side effect of pruning cells that are
            completely surrounded by fire from the internal frontier set.

        Returns:
            list[Cell]: Cells at the fire frontier.
        """
        cell_dict = self.cell_dict
        filtered_frontier = set()
        frontier_cells = []

        for cell_id in self._frontier:
            cell = cell_dict[cell_id]
            # Check if cell has at least one FUEL neighbor
            has_fuel_neighbor = False
            for neighbor_id in cell.burnable_neighbors:
                if cell_dict[neighbor_id].state == CellStates.FUEL:
                    has_fuel_neighbor = True
                    break
            if has_fuel_neighbor:
                filtered_frontier.add(cell_id)
                frontier_cells.append(cell)

        self._frontier = filtered_frontier
        return frontier_cells

    def get_frontier_positions(self) -> List[Tuple[float, float]]:
        """Get list of (x, y) positions of cells at the fire frontier.

        The frontier consists of FUEL cells adjacent to burning cells
        that could potentially ignite. Cells completely surrounded by
        fire are removed from the frontier.

        This method is optimized for collecting frontier positions,
        performing filtering and coordinate extraction in a single pass.

        Note:
            This method has the side effect of pruning cells that are
            completely surrounded by fire from the internal frontier set.

        Returns:
            list[tuple[float, float]]: (x_pos, y_pos) for each frontier cell.
        """
        cell_dict = self.cell_dict
        filtered_frontier = set()
        positions = []

        for cell_id in self._frontier:
            cell = cell_dict[cell_id]
            # Check if cell has at least one FUEL neighbor
            has_fuel_neighbor = False
            for neighbor_id in cell.burnable_neighbors:
                if cell_dict[neighbor_id].state == CellStates.FUEL:
                    has_fuel_neighbor = True
                    break
            if has_fuel_neighbor:
                filtered_frontier.add(cell_id)
                positions.append((cell.x_pos, cell.y_pos))

        self._frontier = filtered_frontier
        return positions

    @property
    def frontier(self) -> set:
        """Set of cell IDs at the fire frontier.

        .. deprecated::
            Use :meth:`get_frontier` instead. This property has side effects
            (modifies internal state) which is unexpected for a property.
            It will be removed in a future version.

        Returns:
            set: Cell IDs of cells at the fire frontier.
        """
        import warnings
        warnings.warn(
            "The 'frontier' property is deprecated. Use 'get_frontier()' method instead.",
            DeprecationWarning,
            stacklevel=2
        )
        return self.get_frontier()

    def get_avg_fire_coord(self) -> Tuple[float, float]:
        """Get the average position of all burning cells.

        If there is more than one independent fire, includes cells from all fires.

        Returns:
            Tuple[float, float]: Average position as (x_avg, y_avg) in meters.
        """

        x_coords = np.array([cell.x_pos for cell in self._burning_cells])
        y_coords = np.array([cell.y_pos for cell in self._burning_cells])

        return np.mean(x_coords), np.mean(y_coords)

    def hex_round(self, q: float, r: float) -> Tuple[int, int]:
        """Rounds floating point hex coordinates to their nearest integer hex coordinates.

        Args:
            q (float): q coordinate in hex coordinate system
            r (float): r coordinate in hex coordinate system

        Returns:
            Tuple[int, int]: (q, r) integer coordinates of the nearest hex cell

        Note: This method delegates to GridManager.hex_round().
        """
        return self._grid_manager.hex_round(q, r)

    def get_cell_from_xy(self, x_m: float, y_m: float, oob_ok: bool = False) -> Cell:
        """Returns the cell in the sim that contains the point (x_m, y_m) in the cartesian
        plane.

        (0,0) is considered the lower left corner of the sim window, x increases to the
        right, y increases up.

        Args:
            x_m (float): x position of the desired point in units of meters
            y_m (float): y position of the desired point in units of meters
            oob_ok (bool, optional): whether out of bounds input is ok, if set to `True` out of bounds input
                                   will return None. Defaults to False.

        Raises:
            ValueError: oob_ok is `False` and (x_m, y_m) is out of the sim bounds

        Returns:
            Cell: Cell at the requested point, returns `None` if the point is out of bounds and oob_ok is `True`

        Note: This method delegates to GridManager.get_cell_from_xy().
        """
        # Ensure grid manager has current logger reference
        self._grid_manager.logger = self.logger
        return self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok)

    def get_cell_from_indices(self, row: int, col: int) -> Cell:
        """Returns the cell in the sim at the indices [row, col] in the cell_grid.

        Columns increase left to right in the sim visualization window, rows increase bottom to
        top.

        Args:
            row (int): row index of the desired cell
            col (int): col index of the desired cell

        Raises:
            TypeError: if row or col is not of type int
            ValueError: if row or col is out of the array bounds

        Returns:
            Cell: Cell instance at the indices [row, col] in the cell_grid

        Note: This method delegates to GridManager.get_cell_from_indices().
        """
        # Ensure grid manager has current logger reference
        self._grid_manager.logger = self.logger
        return self._grid_manager.get_cell_from_indices(row, col)

    # Functions for setting state of cells
    def set_state_at_xy(self, x_m: float, y_m: float, state: CellStates) -> None:
        """Set the state of the cell at the point (x_m, y_m) in the Cartesian plane.

        Args:
            x_m (float): x position of the desired point in meters
            y_m (float): y position of the desired point in meters
            state (CellStates): desired state to set the cell to (CellStates.FIRE,
                               CellStates.FUEL, or CellStates.BURNT)
        """
        cell = self.get_cell_from_xy(x_m, y_m, oob_ok=True)
        self.set_state_at_cell(cell, state)

    def set_state_at_indices(self, row: int, col: int, state: CellStates) -> None:
        """Set the state of the cell at the indices [row, col] in the cell_grid.

        Args:
            row (int): row index of the desired cell
            col (int): col index of the desired cell
            state (CellStates): desired state to set the cell to (CellStates.FIRE,
                               CellStates.FUEL, or CellStates.BURNT)
        """
        cell = self.get_cell_from_indices(row, col)
        self.set_state_at_cell(cell, state)

    def set_state_at_cell(self, cell: Cell, state: CellStates) -> None:
        """Set the state of the specified cell

        Args:
            cell (Cell): Cell object whose state is to be changed
            state (CellStates): desired state to set the cell to (CellStates.FIRE,
                               CellStates.FUEL, or CellStates.BURNT)

        Raises:
            TypeError: if 'cell' is not of type Cell
            ValueError: if 'cell' is not a valid Cell in the current fire Sim
            TypeError: if 'state' is not a valid CellStates value
        """
        if not isinstance(cell, Cell):
            msg = f"'cell' must be of type 'Cell' not {type(cell)}"

            if self.logger:
                self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                        f"{msg} Program terminated.")

            raise TypeError(msg)

        if cell.id not in self._cell_dict:
            msg = f"{cell} is not a valid cell in the current fire Sim"

            if self.logger:
                self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                        f"{msg} Program terminated.")

            raise ValueError(msg)

        if not isinstance(state, int) or 0 > state > 2:
            msg = (
                f"{state} is not a valid cell state. Must be of type CellStates. "
                f"Valid states: fireUtil.CellStates.BURNT, fireUtil.CellStates.FUEL, "
                f"fireUtil.CellStates.FIRE or 0, 1, 2"
            )

            if self.logger:
                self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                        f"{msg} Program terminated.")

            raise TypeError(msg)

        # Set new state
        cell._set_state(state)

    def set_ignition_at_xy(self, x_m: float, y_m: float) -> None:
        """Ignite the cell at the specified coordinates.

        Args:
            x_m (float): X position in meters.
            y_m (float): Y position in meters.
        """

        # Get cell from specified x, y location
        cell = self.get_cell_from_xy(x_m, y_m, oob_ok=True)
        if cell is not None:
            # Set ignition at cell
            self.set_ignition_at_cell(cell)

    def set_ignition_at_indices(self, row: int, col: int) -> None:
        """Ignite the cell at the specified grid indices.

        Args:
            row (int): Row index in the cell grid.
            col (int): Column index in the cell grid.
        """
        # Get cell from specified indices
        cell = self.get_cell_from_indices(row, col)

        # Set ignition at cell
        self.set_ignition_at_cell(cell)

    def set_ignition_at_cell(self, cell: Cell) -> None:
        """Ignite the specified cell.

        Sets the cell to the FIRE state and adds it to the new ignitions list.

        Args:
            cell (Cell): Cell to ignite.
        """

        # Set ignition at cell
        cell.get_ign_params(0)
        self.set_state_at_cell(cell, CellStates.FIRE)
        self._new_ignitions.append(cell)

    def add_retardant_at_xy(self, x_m: float, y_m: float, duration_hr: float, effectiveness: float) -> None:
        """Apply long-term fire retardant at the specified coordinates.

        Args:
            x_m (float): X position in meters.
            y_m (float): Y position in meters.
            duration_hr (float): Duration of retardant effect in hours.
            effectiveness (float): Retardant effectiveness factor (0.0-1.0).

        Note: This method delegates to ControlActionHandler.add_retardant_at_xy().
        """
        self._control_handler.add_retardant_at_xy(x_m, y_m, duration_hr, effectiveness)

    def add_retardant_at_indices(self, row: int, col: int, duration_hr: float, effectiveness: float) -> None:
        """Apply long-term fire retardant at the specified grid indices.

        Args:
            row (int): Row index in the cell grid.
            col (int): Column index in the cell grid.
            duration_hr (float): Duration of retardant effect in hours.
            effectiveness (float): Retardant effectiveness factor (0.0-1.0).

        Note: This method delegates to ControlActionHandler.add_retardant_at_indices().
        """
        self._control_handler.add_retardant_at_indices(row, col, duration_hr, effectiveness)

    def add_retardant_at_cell(self, cell: Cell, duration_hr: float, effectiveness: float) -> None:
        """Apply long-term fire retardant to the specified cell.

        Effectiveness is clamped to the range [0.0, 1.0]. Only applies
        to burnable cells.

        Args:
            cell (Cell): Cell to apply retardant to.
            duration_hr (float): Duration of retardant effect in hours.
            effectiveness (float): Retardant effectiveness factor (0.0-1.0).

        Note: This method delegates to ControlActionHandler.add_retardant_at_cell().
        """
        self._control_handler.add_retardant_at_cell(cell, duration_hr, effectiveness)

    def water_drop_at_xy_as_rain(self, x_m: float, y_m: float, water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall at the specified coordinates.

        Args:
            x_m (float): X position in meters.
            y_m (float): Y position in meters.
            water_depth_cm (float): Equivalent rainfall depth in centimeters.

        Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_rain().
        """
        self._control_handler.water_drop_at_xy_as_rain(x_m, y_m, water_depth_cm)

    def water_drop_at_indices_as_rain(self, row: int, col: int, water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall at the specified grid indices.

        Args:
            row (int): Row index in the cell grid.
            col (int): Column index in the cell grid.
            water_depth_cm (float): Equivalent rainfall depth in centimeters.

        Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_rain().
        """
        self._control_handler.water_drop_at_indices_as_rain(row, col, water_depth_cm)

    def water_drop_at_cell_as_rain(self, cell: Cell, water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall to the specified cell.

        Only applies to burnable cells. Adds cell to active water drops
        for moisture tracking.

        Args:
            cell (Cell): Cell to apply water to.
            water_depth_cm (float): Equivalent rainfall depth in centimeters.

        Raises:
            ValueError: If water_depth_cm is negative.

        Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_rain().
        """
        self._control_handler.water_drop_at_cell_as_rain(cell, water_depth_cm)

    def water_drop_at_xy_as_moisture_bump(self, x_m: float, y_m: float, moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase at the specified coordinates.

        Args:
            x_m (float): X position in meters.
            y_m (float): Y position in meters.
            moisture_inc (float): Moisture content increase as a fraction.

        Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_moisture_bump().
        """
        self._control_handler.water_drop_at_xy_as_moisture_bump(x_m, y_m, moisture_inc)

    def water_drop_at_indices_as_moisture_bump(self, row: int, col: int, moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase at the specified grid indices.

        Args:
            row (int): Row index in the cell grid.
            col (int): Column index in the cell grid.
            moisture_inc (float): Moisture content increase as a fraction.

        Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_moisture_bump().
        """
        self._control_handler.water_drop_at_indices_as_moisture_bump(row, col, moisture_inc)

    def water_drop_at_cell_as_moisture_bump(self, cell: Cell, moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase to the specified cell.

        Only applies to burnable cells. Adds cell to active water drops
        for moisture tracking.

        Args:
            cell (Cell): Cell to apply water to.
            moisture_inc (float): Moisture content increase as a fraction.

        Raises:
            ValueError: If moisture_inc is negative.

        Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_moisture_bump().
        """
        self._control_handler.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

    def water_drop_at_xy_vw(self, x_m: float, y_m: float, volume_L: float,
                             efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop at the specified coordinates.

        Args:
            x_m (float): X position in meters.
            y_m (float): Y position in meters.
            volume_L (float): Water volume in liters (1 L = 1 kg).
            efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
            T_a (float): Ambient air temperature in °C. Default 20.

        Note: This method delegates to ControlActionHandler.water_drop_at_xy_vw().
        """
        self._control_handler.water_drop_at_xy_vw(x_m, y_m, volume_L, efficiency, T_a)

    def water_drop_at_indices_vw(self, row: int, col: int, volume_L: float,
                                  efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop at the specified grid indices.

        Args:
            row (int): Row index in the cell grid.
            col (int): Column index in the cell grid.
            volume_L (float): Water volume in liters (1 L = 1 kg).
            efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
            T_a (float): Ambient air temperature in °C. Default 20.

        Note: This method delegates to ControlActionHandler.water_drop_at_indices_vw().
        """
        self._control_handler.water_drop_at_indices_vw(row, col, volume_L, efficiency, T_a)

    def water_drop_at_cell_vw(self, cell: 'Cell', volume_L: float,
                               efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop to the specified cell.

        Args:
            cell (Cell): Cell to apply water to.
            volume_L (float): Water volume in liters (1 L = 1 kg).
            efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
            T_a (float): Ambient air temperature in °C. Default 20.

        Raises:
            ValueError: If volume_L is negative.

        Note: This method delegates to ControlActionHandler.water_drop_at_cell_vw().
        """
        self._control_handler.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

    def construct_fireline(self, line: LineString, width_m: float, construction_rate: float = None, id: str = None) -> str:
        """Construct a fire break along a line geometry.

        If construction_rate is None, the fire break is applied instantly.
        Otherwise, it is constructed progressively over time.

        Args:
            line (LineString): Shapely LineString defining the fire break path.
            width_m (float): Width of the fire break in meters.
            construction_rate (float, optional): Construction rate in m/s.
                If None, fire break is applied instantly.
            id (str, optional): Unique identifier for the fire break.
                Auto-generated if not provided.

        Returns:
            str: Identifier of the constructed fire break.

        Note: This method delegates to ControlActionHandler.construct_fireline().
        """
        return self._control_handler.construct_fireline(
            line=line,
            width_m=width_m,
            construction_rate=construction_rate,
            fireline_id=id,
            curr_time_s=self.curr_time_s
        )

    def stop_fireline_construction(self, fireline_id: str) -> None:
        """Stop construction of an active fireline.

        Finalizes the partially constructed fireline and adds it to the
        permanent fire breaks list.

        Args:
            fireline_id (str): Identifier of the fireline to stop constructing.

        Note: This method delegates to ControlActionHandler.stop_fireline_construction().
        """
        self._control_handler.stop_fireline_construction(fireline_id)

    def _update_active_firelines(self) -> None:
        """Update progress of active fireline construction.

        Extends partially constructed fire lines based on their
        construction rate. Completes fire lines that reach their
        full length.

        Note: This method delegates to ControlActionHandler.update_active_firelines().
        """
        self._control_handler.update_active_firelines()

    def truncate_linestring(self, line: LineString, length: float) -> LineString:
        """Truncate a LineString to the specified length.

        Args:
            line (LineString): Original line to truncate.
            length (float): Desired length in meters.

        Returns:
            LineString: Truncated line, or original if length exceeds line length.

        Note: This method delegates to ControlActionHandler._truncate_linestring().
        """
        return self._control_handler._truncate_linestring(line, length)

    def get_cells_at_geometry(self, geom: Union[Polygon, LineString, Point]) -> List[Cell]:
        """Get all cells that intersect with the given geometry.

        Args:
            geom (Union[Polygon, LineString, Point]): Shapely geometry to check
                for cell intersections.

        Returns:
            List[Cell]: List of Cell objects that intersect with the geometry.

        Raises:
            ValueError: If geometry type is not Polygon, LineString, or Point.

        Note: This method delegates to GridManager.get_cells_at_geometry().
        """
        return self._grid_manager.get_cells_at_geometry(geom)

    def set_surface_accel_constant(self, cell: Cell):
        """Sets the surface acceleration constant for a burning cell based on the state of its neighbors.

        If a cell has any burning neighbors it is modelled as a line fire.

        Args:
            cell (Cell): Cell object to set the surface acceleration constant for
        """
        # Only set for non-active crown fires, active crown fire acceleration handled in crown model
        if cell._crown_status != CrownStatus.ACTIVE: 
            for n_id in cell.neighbors.keys():
                neighbor = self._cell_dict[n_id]
                if neighbor.state == CellStates.FIRE:
                    # Model as a line fire
                    cell.a_a = 0.3 / 60 # convert to 1 /sec
                    return

            # Model as a point fire
            cell.a_a = 0.115 / 60 # convert to 1 / sec

    def get_action_entries(self, logger: bool = False) -> List[ActionsEntry]:
        """Get action entries for logging active control actions.

        Collects current state of long-term retardants, active firelines,
        water drops, and newly constructed fire breaks.

        Args:
            logger (bool, optional): Whether call is from the logger.
                Affects cache cleanup. Defaults to False.

        Returns:
            List[ActionsEntry]: List of action entries for current actions.
        """

        entries = []
        if self._long_term_retardants:
            lt_xs = []
            lt_ys = []
            e_vals = []

            # Collect relevant values for long term retardants
            for cell in self._long_term_retardants:
                lt_xs.append(cell.x_pos)
                lt_ys.append(cell.y_pos)
                e_vals.append(cell._retardant_factor)

            # Create single action entry
            entries.append(ActionsEntry(
                timestamp=self.curr_time_s,
                action_type="long_term_retardant",
                x_coords= lt_xs,
                y_coords= lt_ys,
                effectiveness= e_vals
            ))

        if self._active_firelines:
            for id in list(self._active_firelines.keys()):
                fireline = self._active_firelines[id]
                # For each fireline, collect relevant information on its current state
                # Create an entry for each line
                entries.append(ActionsEntry(
                    timestamp=self.curr_time_s,
                    action_type="fireline_construction",
                    x_coords= [coord[0] for coord in fireline["partial_line"].coords],
                    y_coords= [coord[1] for coord in fireline["partial_line"].coords],
                    width=fireline["width"]
                ))

        if self._active_water_drops:
            w_xs = []
            w_ys = []
            e_vals = []

            # Collect relevant values for water drops
            for cell in self._active_water_drops.copy():
                # Get the fraction of extinction moisture at the treated cell
                dead_mf, _ = get_characteristic_moistures(cell.fuel, cell.fmois)
                frac = dead_mf/cell.fuel.dead_mx

                # Only include cells at more than 50% of moisture extinction
                if frac > 0.5 and cell.state == CellStates.FUEL:
                    w_xs.append(cell.x_pos)
                    w_ys.append(cell.y_pos)
                    e_vals.append(dead_mf/cell.fuel.dead_mx)

            # Create single entry of water drops
            entries.append(ActionsEntry(
                timestamp=self.curr_time_s,
                action_type="short_term_suppressant",
                x_coords=w_xs,
                y_coords=w_ys,
                effectiveness=e_vals
            ))

        # Check if there are any instanteously constructed firelines
        if self._new_fire_break_cache:
            for entry in self._new_fire_break_cache:
                # Create an entry for each fire break
                entries.append(ActionsEntry(
                    timestamp=entry["time"],
                    action_type="fireline_construction",
                    x_coords=[coord[0] for coord in entry["line"].coords],
                    y_coords=[coord[1] for coord in entry["line"].coords],
                    width=entry["width"]
                ))

                # Only remove it from cache if it has been registered by both logger
                # and the visualizer
                if logger:
                    entry["logged"] = True
                    # Check if visualizer is being used
                    if not self._visualizer:
                        entry["visualized"] = True
                else:
                    entry["visualized"] = True
                    # Check if logger is being used
                    if not self.logger:
                        entry["logged"] = True

            # Filter cache to keep only entries not yet fully processed
            self._new_fire_break_cache = [
                entry for entry in self._new_fire_break_cache
                if not (entry["logged"] and entry["visualized"])
            ]

        return entries

    def get_prediction_entry(self) -> PredictionEntry:
        """Get prediction entry for logging current prediction state.

        Returns:
            PredictionEntry: Entry containing current time and prediction data.
        """
        return PredictionEntry(self._curr_time_s, self.curr_prediction)

    def is_firesim(self) -> bool:
        """Check if this instance is a FireSim (real-time simulation).

        Returns:
            bool: True if this is a FireSim instance.
        """
        return self.__class__.__name__ == "FireSim"

    def is_prediction(self) -> bool:
        """Check if this instance is a FirePredictor (forward prediction).

        Returns:
            bool: True if this is a FirePredictor instance.
        """
        return self.__class__.__name__ == "FirePredictor"

    def add_agent(self, agent: AgentBase) -> None:
        """Add agent to the simulation's registered agent list.

        Registered agents are logged and displayed in visualizations.

        Args:
            agent (AgentBase): Agent to register with the simulation.

        Raises:
            TypeError: If agent is not an instance of AgentBase.
        """
        if isinstance(agent, AgentBase):
            self._agent_list.append(agent)
            self._agents_added = True
            if self.logger:
                self.logger.log_message(f"Agent with id {agent.id} added to agent list.")
        else:
            msg = "'agent' must be an instance of 'AgentBase' or a subclass"

            if self.logger:
                self.logger.log_message(f"Following erorr occurred in 'FireSim.add_agent(): "
                                        f"{msg} Program terminated.")
            raise TypeError(msg)

    @property
    def cell_grid(self) -> np.ndarray:
        """2D array of all the cells in the sim at the current instant.
        """
        return self._cell_grid

    @property
    def cell_dict(self) -> Dict[int, Cell]:
        """Dictionary mapping cell IDs to their respective :class:`~fire_simulator.cell.Cell` instances.
        """
        return self._cell_dict

    @property
    def iters(self) -> int:
        """Number of iterations run so far by the sim
        """
        return self._iters

    @property
    def curr_time_s(self) -> int:
        """Current sim time in seconds
        """
        return self._curr_time_s

    @property
    def curr_time_m(self) -> float:
        """Current sim time in minutes
        """
        return self.curr_time_s/60

    @property
    def curr_time_h(self) -> float:
        """Current sim time in hours
        """
        return self.curr_time_m/60

    @property
    def time_step(self) -> int:
        """Time-step of the sim. Number of seconds per iteration
        """
        return self._time_step

    @property
    def shape(self) -> Tuple[int, int]:
        """Shape of the sim's backing array in (rows, cols)
        """
        return self._shape

    @property
    def size(self) -> Tuple[float, float]:
        """Size of the sim region (width_m, height_m)
        """
        return self._size

    @property
    def x_lim(self) -> float:
        """Max x coordinate in the sim's map in meters
        """
        return self._size[0]

    @property
    def y_lim(self) -> float:
        """Max y coordinate in the sim's map in meters
        """
        return self._size[1]

    @property
    def cell_size(self) -> float:
        """Size of each cell in the simulation.

        Measured as the distance in meters between two parallel sides of the regular hexagon cells.
        """
        return self._cell_size

    @property
    def burning_cells(self) -> List[Cell]:
        """List of currently burning cells at the time called.

        Returns:
            List[Cell]: All cell objects currently in the FIRE state.
        """
        return self._burning_cells

    @property
    def sim_duration(self) -> float:
        """Duration of time (in seconds) the simulation should run for, the sim will
        run for this duration unless the fire is extinguished before the duration has passed.
        """
        return self._sim_duration


    @property
    def roads(self) -> list:
        """Road data for the simulation.

        Returns:
            list: List of (road_coords, road_type, road_width) tuples, or None.
        """
        return self._roads

    @property
    def fire_break_cells(self) -> list:
        """List of :class:`~fire_simulator.cell.Cell` objects that fall along fire breaks
        """
        return self._fire_break_cells

    @property
    def fire_breaks(self) -> list:
        """List of fire breaks in the simulation.

        Returns:
            list: List of (LineString, width, id) tuples for each fire break.
        """
        return self._fire_breaks

    @property
    def finished(self) -> bool:
        """`True` if the simulation is finished running. `False` otherwise
        """
        return self._finished

    @property
    def initial_ignition(self) -> list:
        """List of shapely polygons that were initially ignited at the start of the sim
        """
        return self._initial_ignition

    def _update_weather(self) -> bool:
        """Updates the current wind conditions based on the forecast.

        This method checks whether the time elapsed since the last wind update exceeds
        the wind forecast time step. If so, it updates the wind index and retrieves
        the next forecasted wind condition. If the forecast has no remaining entries,
        it raises a ValueError.

        Returns:
            bool: True if the wind conditions were updated, False otherwise.

        Raises:
            ValueError: If the wind forecast runs out of entries.

        Side Effects:
            - Updates _last_wind_update to the current simulation time.
            - Increments _curr_weather_idx to the next wind forecast entry.
            - Resets _curr_weather_idx to 0 if out of bounds and raises an error.

        Note: This method delegates to WeatherManager.update_weather().
        """
        weather_changed = self._weather_manager.update_weather(self.curr_time_s)

        # Update backward-compatible references
        self._curr_weather_idx = self._weather_manager.curr_weather_idx
        self._last_weather_update = self._weather_manager.last_weather_update
        self.weather_changed = self._weather_manager.weather_changed

        return weather_changed

burning_cells property

List of currently burning cells at the time called.

Returns:

Type Description
List[Cell]

List[Cell]: All cell objects currently in the FIRE state.

cell_dict property

Dictionary mapping cell IDs to their respective :class:~fire_simulator.cell.Cell instances.

cell_grid property

2D array of all the cells in the sim at the current instant.

cell_size property

Size of each cell in the simulation.

Measured as the distance in meters between two parallel sides of the regular hexagon cells.

curr_time_h property

Current sim time in hours

curr_time_m property

Current sim time in minutes

curr_time_s property

Current sim time in seconds

finished property

True if the simulation is finished running. False otherwise

fire_break_cells property

List of :class:~fire_simulator.cell.Cell objects that fall along fire breaks

fire_breaks property

List of fire breaks in the simulation.

Returns:

Name Type Description
list list

List of (LineString, width, id) tuples for each fire break.

frontier property

Set of cell IDs at the fire frontier.

.. deprecated:: Use :meth:get_frontier instead. This property has side effects (modifies internal state) which is unexpected for a property. It will be removed in a future version.

Returns:

Name Type Description
set set

Cell IDs of cells at the fire frontier.

initial_ignition property

List of shapely polygons that were initially ignited at the start of the sim

iters property

Number of iterations run so far by the sim

roads property

Road data for the simulation.

Returns:

Name Type Description
list list

List of (road_coords, road_type, road_width) tuples, or None.

shape property

Shape of the sim's backing array in (rows, cols)

sim_duration property

Duration of time (in seconds) the simulation should run for, the sim will run for this duration unless the fire is extinguished before the duration has passed.

size property

Size of the sim region (width_m, height_m)

time_step property

Time-step of the sim. Number of seconds per iteration

x_lim property

Max x coordinate in the sim's map in meters

y_lim property

Max y coordinate in the sim's map in meters

__init__(sim_params, burnt_region=None)

Initialize the fire simulation.

Creates the hexagonal cell grid, populates cells with terrain and fuel data, sets up weather and wind forecasts, and applies initial ignitions and fire breaks.

Parameters:

Name Type Description Default
sim_params SimParams

Simulation configuration parameters.

required
burnt_region list

List of Shapely geometries defining pre-burnt regions. Defaults to None.

None
Source code in embrs/base_classes/base_fire.py
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
def __init__(self, sim_params: SimParams, burnt_region: list = None):
    """Initialize the fire simulation.

    Creates the hexagonal cell grid, populates cells with terrain and
    fuel data, sets up weather and wind forecasts, and applies initial
    ignitions and fire breaks.

    Args:
        sim_params (SimParams): Simulation configuration parameters.
        burnt_region (list, optional): List of Shapely geometries defining
            pre-burnt regions. Defaults to None.
    """
    # Check if current instance is a prediction model
    prediction = self.is_prediction()

    if prediction:
        print("Initializing prediction model backing array...")

    else:
        print("Initializing fire sim backing array...")

    # Constant parameters
    self.display_frequency = 300
    self._sim_params = sim_params

    # Weather manager placeholder (initialized in _parse_sim_params after weather data is loaded)
    self._weather_manager = None

    # Store sim input values in class variables (also initializes weather manager)
    self._parse_sim_params(sim_params)

    # Variables to keep track of sim progress
    self._curr_time_s = 0
    self._iters = 0

    # Variables to store logger and visualizer object
    self.logger = None
    self._visualizer = None

    # Track whether sim is finished or not
    self._finished = False

    # Containers for keeping track of updates to cells
    self._updated_cells = {}

    # Containers for fire spread tracking (note: _cell_dict is set up with grid manager below)
    self._burning_cells = []
    self._new_ignitions = []
    self._suppressed_cells = []
    self._burnt_cells = set()
    self._frontier = set()
    self.starting_ignitions = set()
    self._urban_cells = []

    # Pre-allocated buffer for propagate_fire new intersection indices (12 hex dirs)
    self._new_ixn_buf = np.empty(12, dtype=np.int64)

    # Crown fire containers
    self._scheduled_spot_fires = {}

    # Control action handler placeholder (initialized after grid manager)
    self._control_handler = None

    # Set up grid manager for cell storage and lookup
    self._grid_manager = GridManager(
        num_rows=self._shape[0],
        num_cols=self._shape[1],
        cell_size=self._cell_size
    )
    # Backward-compatible references (delegate to grid manager)
    self._cell_grid = self._grid_manager.cell_grid
    self._grid_width = self._grid_manager._grid_width
    self._grid_height = self._grid_manager._grid_height
    self._cell_dict = self._grid_manager.cell_dict

    # Set up control action handler for fire suppression operations
    self._control_handler = ControlActionHandler(
        grid_manager=self._grid_manager,
        cell_size=self._cell_size,
        time_step=self._time_step,
        fuel_class_factory=self.FuelClass
    )
    self._control_handler.set_updated_cells_ref(self._updated_cells)
    self._control_handler.set_time_accessor(lambda: self._curr_time_s)
    self._control_handler.logger = self.logger
    self._control_handler._visualizer = self._visualizer

    # Transfer scenario fire break data to control handler
    for fire_break, width, id in self._fire_breaks:
        self._control_handler._fire_breaks.append((fire_break, width, id))
        self._control_handler._fire_break_dict[id] = (fire_break, width)

    # Backward-compatible references (delegate to control handler)
    self._long_term_retardants = self._control_handler.long_term_retardants
    self._active_water_drops = self._control_handler.active_water_drops
    self._fire_break_cells = self._control_handler.fire_break_cells
    self._active_firelines = self._control_handler.active_firelines
    self._new_fire_break_cache = self._control_handler.new_fire_break_cache
    self._fire_breaks = self._control_handler.fire_breaks
    self.fire_break_dict = self._control_handler.fire_break_dict

    if not prediction: # Regular FireSim
        if self._fms_has_live:
            live_h_mf = self._init_live_h_mf
            live_w_mf = self._init_live_w_mf
        else:
            # Set live moisture and foliar moisture to weather stream values
            live_h_mf = self._weather_stream.live_h_mf
            live_w_mf = self._weather_stream.live_w_mf
        self.fmc = self._weather_stream.fmc

    else:
        # Prediction model has live_mf as an attribute
        live_h_mf = self.live_mf
        live_w_mf = self.live_mf
        self.fmc = 100

    if self.model_spotting:
        # Limits to pass into spotting models
        limits = (self.x_lim, self.y_lim)
        if not prediction:
            # Spot fire modelling class
            self.embers = Embers(self._spot_ign_prob, self._canopy_species, self._dbh_cm, self._min_spot_distance, limits, self.get_cell_from_xy)

        else:
            self.embers = PerrymanSpotting(self._spot_delay_s, limits)


    # Store references needed by the cell factory
    self._lcp_data = sim_params.map_params.lcp_data
    self._live_h_mf = live_h_mf
    self._live_w_mf = live_w_mf

    # Cache fuel model instances by (fuel_key, live_h_mf) to avoid
    # creating duplicate objects. Typically ~40 unique fuel types vs 100K+ cells.
    self._fuel_cache = {}

    # Pre-compute terrain data for all cells using vectorized operations
    self._precompute_terrain_data()

    # Populate cell_grid with cells using the grid manager
    total_cells = self._shape[0] * self._shape[1]
    with tqdm(total=total_cells, desc="Initializing cells") as pbar:
        self._grid_manager.init_grid(
            cell_factory=self._create_cell,
            progress_callback=pbar.update
        )

    # Batch-create all cell polygons using vectorized Shapely operations
    self._batch_create_polygons()

    # Clear pre-computed data to free memory
    self._clear_precomputed_terrain_data()

    # Populate neighbors field for each cell with pointers to each of its neighbors
    self._grid_manager.add_cell_neighbors()

    # Set initial ignitions
    self._set_initial_ignition(self.initial_ignition)

    # Set burnt cells
    if burnt_region is not None:
        self._set_initial_burnt_region(burnt_region)

    # Overwrite urban cells to their neighbors (road modelling handles fire spread through roads)
    for cell in self._urban_cells:
        self._overwrite_urban_fuel(cell)

    # Apply fire breaks
    self._set_firebreaks()

    # Apply Roads 
    self._set_roads()

    print("Base initialization complete...")

add_agent(agent)

Add agent to the simulation's registered agent list.

Registered agents are logged and displayed in visualizations.

Parameters:

Name Type Description Default
agent AgentBase

Agent to register with the simulation.

required

Raises:

Type Description
TypeError

If agent is not an instance of AgentBase.

Source code in embrs/base_classes/base_fire.py
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
def add_agent(self, agent: AgentBase) -> None:
    """Add agent to the simulation's registered agent list.

    Registered agents are logged and displayed in visualizations.

    Args:
        agent (AgentBase): Agent to register with the simulation.

    Raises:
        TypeError: If agent is not an instance of AgentBase.
    """
    if isinstance(agent, AgentBase):
        self._agent_list.append(agent)
        self._agents_added = True
        if self.logger:
            self.logger.log_message(f"Agent with id {agent.id} added to agent list.")
    else:
        msg = "'agent' must be an instance of 'AgentBase' or a subclass"

        if self.logger:
            self.logger.log_message(f"Following erorr occurred in 'FireSim.add_agent(): "
                                    f"{msg} Program terminated.")
        raise TypeError(msg)

add_retardant_at_cell(cell, duration_hr, effectiveness)

Apply long-term fire retardant to the specified cell.

Effectiveness is clamped to the range [0.0, 1.0]. Only applies to burnable cells.

Parameters:

Name Type Description Default
cell Cell

Cell to apply retardant to.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required

Note: This method delegates to ControlActionHandler.add_retardant_at_cell().

Source code in embrs/base_classes/base_fire.py
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
def add_retardant_at_cell(self, cell: Cell, duration_hr: float, effectiveness: float) -> None:
    """Apply long-term fire retardant to the specified cell.

    Effectiveness is clamped to the range [0.0, 1.0]. Only applies
    to burnable cells.

    Args:
        cell (Cell): Cell to apply retardant to.
        duration_hr (float): Duration of retardant effect in hours.
        effectiveness (float): Retardant effectiveness factor (0.0-1.0).

    Note: This method delegates to ControlActionHandler.add_retardant_at_cell().
    """
    self._control_handler.add_retardant_at_cell(cell, duration_hr, effectiveness)

add_retardant_at_indices(row, col, duration_hr, effectiveness)

Apply long-term fire retardant at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required

Note: This method delegates to ControlActionHandler.add_retardant_at_indices().

Source code in embrs/base_classes/base_fire.py
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
def add_retardant_at_indices(self, row: int, col: int, duration_hr: float, effectiveness: float) -> None:
    """Apply long-term fire retardant at the specified grid indices.

    Args:
        row (int): Row index in the cell grid.
        col (int): Column index in the cell grid.
        duration_hr (float): Duration of retardant effect in hours.
        effectiveness (float): Retardant effectiveness factor (0.0-1.0).

    Note: This method delegates to ControlActionHandler.add_retardant_at_indices().
    """
    self._control_handler.add_retardant_at_indices(row, col, duration_hr, effectiveness)

add_retardant_at_xy(x_m, y_m, duration_hr, effectiveness)

Apply long-term fire retardant at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required

Note: This method delegates to ControlActionHandler.add_retardant_at_xy().

Source code in embrs/base_classes/base_fire.py
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
def add_retardant_at_xy(self, x_m: float, y_m: float, duration_hr: float, effectiveness: float) -> None:
    """Apply long-term fire retardant at the specified coordinates.

    Args:
        x_m (float): X position in meters.
        y_m (float): Y position in meters.
        duration_hr (float): Duration of retardant effect in hours.
        effectiveness (float): Retardant effectiveness factor (0.0-1.0).

    Note: This method delegates to ControlActionHandler.add_retardant_at_xy().
    """
    self._control_handler.add_retardant_at_xy(x_m, y_m, duration_hr, effectiveness)

calc_ignition_ros(cell, neighbor, r_gamma)

Calculate the ignition rate of spread for a neighbor cell.

Uses heat source from the spreading cell and heat sink from the receiving neighbor to compute the effective ignition ROS.

Parameters:

Name Type Description Default
cell Cell

Source cell spreading fire.

required
neighbor Cell

Neighbor cell being ignited.

required
r_gamma float

Rate of spread from source cell (m/s).

required

Returns:

Name Type Description
float float

Ignition rate of spread (ft/min).

Source code in embrs/base_classes/base_fire.py
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
def calc_ignition_ros(self, cell: Cell, neighbor: Cell, r_gamma: float) -> float:
    """Calculate the ignition rate of spread for a neighbor cell.

    Uses heat source from the spreading cell and heat sink from the
    receiving neighbor to compute the effective ignition ROS.

    Args:
        cell (Cell): Source cell spreading fire.
        neighbor (Cell): Neighbor cell being ignited.
        r_gamma (float): Rate of spread from source cell (m/s).

    Returns:
        float: Ignition rate of spread (ft/min).
    """
    # Get the rate of spread in ft/min
    r_ft_min = m_s_to_ft_min(r_gamma)

    # Get the heat source in the direction of question by eliminating denominator
    heat_source = r_ft_min * calc_heat_sink(cell.fuel, cell.fmois)

    # Get the heat sink using the neighbors fuel and moisture content
    heat_sink = calc_heat_sink(neighbor.fuel, neighbor.fmois)

    # Calculate a ignition rate of spread
    r_ign = heat_source / heat_sink

    return r_ign

calc_wind_padding(forecast)

Calculate padding offsets between wind forecast grid and simulation grid.

The wind forecast grid may not align exactly with the simulation boundaries. This calculates the x and y offsets needed to center the forecast within the simulation domain.

Parameters:

Name Type Description Default
forecast ndarray

Wind forecast array with shape (time_steps, rows, cols, 2) where last dimension is (speed, direction).

required

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: (x_padding, y_padding) in meters.

Note: This method delegates to WeatherManager.calc_wind_padding().

Source code in embrs/base_classes/base_fire.py
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
def calc_wind_padding(self, forecast: np.ndarray) -> Tuple[float, float]:
    """Calculate padding offsets between wind forecast grid and simulation grid.

    The wind forecast grid may not align exactly with the simulation
    boundaries. This calculates the x and y offsets needed to center
    the forecast within the simulation domain.

    Args:
        forecast (np.ndarray): Wind forecast array with shape
            (time_steps, rows, cols, 2) where last dimension is (speed, direction).

    Returns:
        Tuple[float, float]: (x_padding, y_padding) in meters.

    Note: This method delegates to WeatherManager.calc_wind_padding().
    """
    return self._weather_manager.calc_wind_padding(forecast)

construct_fireline(line, width_m, construction_rate=None, id=None)

Construct a fire break along a line geometry.

If construction_rate is None, the fire break is applied instantly. Otherwise, it is constructed progressively over time.

Parameters:

Name Type Description Default
line LineString

Shapely LineString defining the fire break path.

required
width_m float

Width of the fire break in meters.

required
construction_rate float

Construction rate in m/s. If None, fire break is applied instantly.

None
id str

Unique identifier for the fire break. Auto-generated if not provided.

None

Returns:

Name Type Description
str str

Identifier of the constructed fire break.

Note: This method delegates to ControlActionHandler.construct_fireline().

Source code in embrs/base_classes/base_fire.py
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
def construct_fireline(self, line: LineString, width_m: float, construction_rate: float = None, id: str = None) -> str:
    """Construct a fire break along a line geometry.

    If construction_rate is None, the fire break is applied instantly.
    Otherwise, it is constructed progressively over time.

    Args:
        line (LineString): Shapely LineString defining the fire break path.
        width_m (float): Width of the fire break in meters.
        construction_rate (float, optional): Construction rate in m/s.
            If None, fire break is applied instantly.
        id (str, optional): Unique identifier for the fire break.
            Auto-generated if not provided.

    Returns:
        str: Identifier of the constructed fire break.

    Note: This method delegates to ControlActionHandler.construct_fireline().
    """
    return self._control_handler.construct_fireline(
        line=line,
        width_m=width_m,
        construction_rate=construction_rate,
        fireline_id=id,
        curr_time_s=self.curr_time_s
    )

get_action_entries(logger=False)

Get action entries for logging active control actions.

Collects current state of long-term retardants, active firelines, water drops, and newly constructed fire breaks.

Parameters:

Name Type Description Default
logger bool

Whether call is from the logger. Affects cache cleanup. Defaults to False.

False

Returns:

Type Description
List[ActionsEntry]

List[ActionsEntry]: List of action entries for current actions.

Source code in embrs/base_classes/base_fire.py
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
def get_action_entries(self, logger: bool = False) -> List[ActionsEntry]:
    """Get action entries for logging active control actions.

    Collects current state of long-term retardants, active firelines,
    water drops, and newly constructed fire breaks.

    Args:
        logger (bool, optional): Whether call is from the logger.
            Affects cache cleanup. Defaults to False.

    Returns:
        List[ActionsEntry]: List of action entries for current actions.
    """

    entries = []
    if self._long_term_retardants:
        lt_xs = []
        lt_ys = []
        e_vals = []

        # Collect relevant values for long term retardants
        for cell in self._long_term_retardants:
            lt_xs.append(cell.x_pos)
            lt_ys.append(cell.y_pos)
            e_vals.append(cell._retardant_factor)

        # Create single action entry
        entries.append(ActionsEntry(
            timestamp=self.curr_time_s,
            action_type="long_term_retardant",
            x_coords= lt_xs,
            y_coords= lt_ys,
            effectiveness= e_vals
        ))

    if self._active_firelines:
        for id in list(self._active_firelines.keys()):
            fireline = self._active_firelines[id]
            # For each fireline, collect relevant information on its current state
            # Create an entry for each line
            entries.append(ActionsEntry(
                timestamp=self.curr_time_s,
                action_type="fireline_construction",
                x_coords= [coord[0] for coord in fireline["partial_line"].coords],
                y_coords= [coord[1] for coord in fireline["partial_line"].coords],
                width=fireline["width"]
            ))

    if self._active_water_drops:
        w_xs = []
        w_ys = []
        e_vals = []

        # Collect relevant values for water drops
        for cell in self._active_water_drops.copy():
            # Get the fraction of extinction moisture at the treated cell
            dead_mf, _ = get_characteristic_moistures(cell.fuel, cell.fmois)
            frac = dead_mf/cell.fuel.dead_mx

            # Only include cells at more than 50% of moisture extinction
            if frac > 0.5 and cell.state == CellStates.FUEL:
                w_xs.append(cell.x_pos)
                w_ys.append(cell.y_pos)
                e_vals.append(dead_mf/cell.fuel.dead_mx)

        # Create single entry of water drops
        entries.append(ActionsEntry(
            timestamp=self.curr_time_s,
            action_type="short_term_suppressant",
            x_coords=w_xs,
            y_coords=w_ys,
            effectiveness=e_vals
        ))

    # Check if there are any instanteously constructed firelines
    if self._new_fire_break_cache:
        for entry in self._new_fire_break_cache:
            # Create an entry for each fire break
            entries.append(ActionsEntry(
                timestamp=entry["time"],
                action_type="fireline_construction",
                x_coords=[coord[0] for coord in entry["line"].coords],
                y_coords=[coord[1] for coord in entry["line"].coords],
                width=entry["width"]
            ))

            # Only remove it from cache if it has been registered by both logger
            # and the visualizer
            if logger:
                entry["logged"] = True
                # Check if visualizer is being used
                if not self._visualizer:
                    entry["visualized"] = True
            else:
                entry["visualized"] = True
                # Check if logger is being used
                if not self.logger:
                    entry["logged"] = True

        # Filter cache to keep only entries not yet fully processed
        self._new_fire_break_cache = [
            entry for entry in self._new_fire_break_cache
            if not (entry["logged"] and entry["visualized"])
        ]

    return entries

get_avg_fire_coord()

Get the average position of all burning cells.

If there is more than one independent fire, includes cells from all fires.

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: Average position as (x_avg, y_avg) in meters.

Source code in embrs/base_classes/base_fire.py
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
def get_avg_fire_coord(self) -> Tuple[float, float]:
    """Get the average position of all burning cells.

    If there is more than one independent fire, includes cells from all fires.

    Returns:
        Tuple[float, float]: Average position as (x_avg, y_avg) in meters.
    """

    x_coords = np.array([cell.x_pos for cell in self._burning_cells])
    y_coords = np.array([cell.y_pos for cell in self._burning_cells])

    return np.mean(x_coords), np.mean(y_coords)

get_cell_from_indices(row, col)

Returns the cell in the sim at the indices [row, col] in the cell_grid.

Columns increase left to right in the sim visualization window, rows increase bottom to top.

Parameters:

Name Type Description Default
row int

row index of the desired cell

required
col int

col index of the desired cell

required

Raises:

Type Description
TypeError

if row or col is not of type int

ValueError

if row or col is out of the array bounds

Returns:

Name Type Description
Cell Cell

Cell instance at the indices [row, col] in the cell_grid

Note: This method delegates to GridManager.get_cell_from_indices().

Source code in embrs/base_classes/base_fire.py
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
def get_cell_from_indices(self, row: int, col: int) -> Cell:
    """Returns the cell in the sim at the indices [row, col] in the cell_grid.

    Columns increase left to right in the sim visualization window, rows increase bottom to
    top.

    Args:
        row (int): row index of the desired cell
        col (int): col index of the desired cell

    Raises:
        TypeError: if row or col is not of type int
        ValueError: if row or col is out of the array bounds

    Returns:
        Cell: Cell instance at the indices [row, col] in the cell_grid

    Note: This method delegates to GridManager.get_cell_from_indices().
    """
    # Ensure grid manager has current logger reference
    self._grid_manager.logger = self.logger
    return self._grid_manager.get_cell_from_indices(row, col)

get_cell_from_xy(x_m, y_m, oob_ok=False)

Returns the cell in the sim that contains the point (x_m, y_m) in the cartesian plane.

(0,0) is considered the lower left corner of the sim window, x increases to the right, y increases up.

Parameters:

Name Type Description Default
x_m float

x position of the desired point in units of meters

required
y_m float

y position of the desired point in units of meters

required
oob_ok bool

whether out of bounds input is ok, if set to True out of bounds input will return None. Defaults to False.

False

Raises:

Type Description
ValueError

oob_ok is False and (x_m, y_m) is out of the sim bounds

Returns:

Name Type Description
Cell Cell

Cell at the requested point, returns None if the point is out of bounds and oob_ok is True

Note: This method delegates to GridManager.get_cell_from_xy().

Source code in embrs/base_classes/base_fire.py
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
def get_cell_from_xy(self, x_m: float, y_m: float, oob_ok: bool = False) -> Cell:
    """Returns the cell in the sim that contains the point (x_m, y_m) in the cartesian
    plane.

    (0,0) is considered the lower left corner of the sim window, x increases to the
    right, y increases up.

    Args:
        x_m (float): x position of the desired point in units of meters
        y_m (float): y position of the desired point in units of meters
        oob_ok (bool, optional): whether out of bounds input is ok, if set to `True` out of bounds input
                               will return None. Defaults to False.

    Raises:
        ValueError: oob_ok is `False` and (x_m, y_m) is out of the sim bounds

    Returns:
        Cell: Cell at the requested point, returns `None` if the point is out of bounds and oob_ok is `True`

    Note: This method delegates to GridManager.get_cell_from_xy().
    """
    # Ensure grid manager has current logger reference
    self._grid_manager.logger = self.logger
    return self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok)

get_cells_at_geometry(geom)

Get all cells that intersect with the given geometry.

Parameters:

Name Type Description Default
geom Union[Polygon, LineString, Point]

Shapely geometry to check for cell intersections.

required

Returns:

Type Description
List[Cell]

List[Cell]: List of Cell objects that intersect with the geometry.

Raises:

Type Description
ValueError

If geometry type is not Polygon, LineString, or Point.

Note: This method delegates to GridManager.get_cells_at_geometry().

Source code in embrs/base_classes/base_fire.py
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
def get_cells_at_geometry(self, geom: Union[Polygon, LineString, Point]) -> List[Cell]:
    """Get all cells that intersect with the given geometry.

    Args:
        geom (Union[Polygon, LineString, Point]): Shapely geometry to check
            for cell intersections.

    Returns:
        List[Cell]: List of Cell objects that intersect with the geometry.

    Raises:
        ValueError: If geometry type is not Polygon, LineString, or Point.

    Note: This method delegates to GridManager.get_cells_at_geometry().
    """
    return self._grid_manager.get_cells_at_geometry(geom)

get_frontier()

Get set of cell IDs at the fire frontier.

The frontier consists of FUEL cells adjacent to burning cells that could potentially ignite. Cells completely surrounded by fire are removed from the frontier.

Note

This method has the side effect of pruning cells that are completely surrounded by fire from the internal frontier set.

Returns:

Name Type Description
set set

Cell IDs of cells at the fire frontier.

Source code in embrs/base_classes/base_fire.py
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
def get_frontier(self) -> set:
    """Get set of cell IDs at the fire frontier.

    The frontier consists of FUEL cells adjacent to burning cells
    that could potentially ignite. Cells completely surrounded by
    fire are removed from the frontier.

    Note:
        This method has the side effect of pruning cells that are
        completely surrounded by fire from the internal frontier set.

    Returns:
        set: Cell IDs of cells at the fire frontier.
    """
    def has_fuel_neighbor(cell_id: int) -> bool:
        """Check if cell has at least one FUEL neighbor."""
        cell = self.cell_dict[cell_id]
        for neighbor_id in cell.burnable_neighbors:
            neighbor = self.cell_dict[neighbor_id]
            if neighbor.state == CellStates.FUEL:
                return True
        return False

    # Filter frontier to keep only cells with at least one FUEL neighbor
    self._frontier = {
        c for c in self._frontier
        if has_fuel_neighbor(c)
    }

    return self._frontier

get_frontier_cells()

Get list of cells at the fire frontier.

The frontier consists of FUEL cells adjacent to burning cells that could potentially ignite. Cells completely surrounded by fire are removed from the frontier.

This method is more efficient than get_frontier() when you need to access cell properties, as it avoids a second round of dictionary lookups.

Note

This method has the side effect of pruning cells that are completely surrounded by fire from the internal frontier set.

Returns:

Type Description
List[Cell]

list[Cell]: Cells at the fire frontier.

Source code in embrs/base_classes/base_fire.py
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
def get_frontier_cells(self) -> List['Cell']:
    """Get list of cells at the fire frontier.

    The frontier consists of FUEL cells adjacent to burning cells
    that could potentially ignite. Cells completely surrounded by
    fire are removed from the frontier.

    This method is more efficient than get_frontier() when you need
    to access cell properties, as it avoids a second round of
    dictionary lookups.

    Note:
        This method has the side effect of pruning cells that are
        completely surrounded by fire from the internal frontier set.

    Returns:
        list[Cell]: Cells at the fire frontier.
    """
    cell_dict = self.cell_dict
    filtered_frontier = set()
    frontier_cells = []

    for cell_id in self._frontier:
        cell = cell_dict[cell_id]
        # Check if cell has at least one FUEL neighbor
        has_fuel_neighbor = False
        for neighbor_id in cell.burnable_neighbors:
            if cell_dict[neighbor_id].state == CellStates.FUEL:
                has_fuel_neighbor = True
                break
        if has_fuel_neighbor:
            filtered_frontier.add(cell_id)
            frontier_cells.append(cell)

    self._frontier = filtered_frontier
    return frontier_cells

get_frontier_positions()

Get list of (x, y) positions of cells at the fire frontier.

The frontier consists of FUEL cells adjacent to burning cells that could potentially ignite. Cells completely surrounded by fire are removed from the frontier.

This method is optimized for collecting frontier positions, performing filtering and coordinate extraction in a single pass.

Note

This method has the side effect of pruning cells that are completely surrounded by fire from the internal frontier set.

Returns:

Type Description
List[Tuple[float, float]]

list[tuple[float, float]]: (x_pos, y_pos) for each frontier cell.

Source code in embrs/base_classes/base_fire.py
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
def get_frontier_positions(self) -> List[Tuple[float, float]]:
    """Get list of (x, y) positions of cells at the fire frontier.

    The frontier consists of FUEL cells adjacent to burning cells
    that could potentially ignite. Cells completely surrounded by
    fire are removed from the frontier.

    This method is optimized for collecting frontier positions,
    performing filtering and coordinate extraction in a single pass.

    Note:
        This method has the side effect of pruning cells that are
        completely surrounded by fire from the internal frontier set.

    Returns:
        list[tuple[float, float]]: (x_pos, y_pos) for each frontier cell.
    """
    cell_dict = self.cell_dict
    filtered_frontier = set()
    positions = []

    for cell_id in self._frontier:
        cell = cell_dict[cell_id]
        # Check if cell has at least one FUEL neighbor
        has_fuel_neighbor = False
        for neighbor_id in cell.burnable_neighbors:
            if cell_dict[neighbor_id].state == CellStates.FUEL:
                has_fuel_neighbor = True
                break
        if has_fuel_neighbor:
            filtered_frontier.add(cell_id)
            positions.append((cell.x_pos, cell.y_pos))

    self._frontier = filtered_frontier
    return positions

get_neighbor_from_end_point(cell, end_point)

Get the neighbor cell corresponding to an endpoint location.

Parameters:

Name Type Description Default
cell Cell

Source cell from which to find neighbor.

required
end_point Tuple[int, str]

Tuple of (location, neighbor_letter) where neighbor_letter indicates relative position.

required

Returns:

Name Type Description
Cell Cell

The neighboring Cell if it exists and is burnable, None otherwise.

Source code in embrs/base_classes/base_fire.py
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
def get_neighbor_from_end_point(self, cell: Cell, end_point: Tuple[int, str]) -> Cell:
    """Get the neighbor cell corresponding to an endpoint location.

    Args:
        cell (Cell): Source cell from which to find neighbor.
        end_point (Tuple[int, str]): Tuple of (location, neighbor_letter)
            where neighbor_letter indicates relative position.

    Returns:
        Cell: The neighboring Cell if it exists and is burnable, None otherwise.
    """
    # Get the letter representing the neighbor location relative to cell
    neighbor_letter = end_point[1]

    # Get neighbor based on neighbor_letter
    if cell._row % 2 == 0:
        diff_to_letter_map = HexGridMath.even_neighbor_letters

    else:
        diff_to_letter_map = HexGridMath.odd_neighbor_letters

    # Get the row and col difference between cell and neighbor
    dx, dy = diff_to_letter_map[neighbor_letter]

    row_n = int(cell.row + dy)
    col_n = int(cell.col + dx)

    if self._grid_height >= row_n >=0 and self._grid_width >= col_n >= 0:
        # Retrieve neighbor from cell grid
        neighbor = self._cell_grid[row_n, col_n]

        # If neighbor in cell's neighbors return it
        if neighbor.id in cell.burnable_neighbors:
            return neighbor

    return None

get_prediction_entry()

Get prediction entry for logging current prediction state.

Returns:

Name Type Description
PredictionEntry PredictionEntry

Entry containing current time and prediction data.

Source code in embrs/base_classes/base_fire.py
1770
1771
1772
1773
1774
1775
1776
def get_prediction_entry(self) -> PredictionEntry:
    """Get prediction entry for logging current prediction state.

    Returns:
        PredictionEntry: Entry containing current time and prediction data.
    """
    return PredictionEntry(self._curr_time_s, self.curr_prediction)

hex_round(q, r)

Rounds floating point hex coordinates to their nearest integer hex coordinates.

Parameters:

Name Type Description Default
q float

q coordinate in hex coordinate system

required
r float

r coordinate in hex coordinate system

required

Returns:

Type Description
Tuple[int, int]

Tuple[int, int]: (q, r) integer coordinates of the nearest hex cell

Note: This method delegates to GridManager.hex_round().

Source code in embrs/base_classes/base_fire.py
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
def hex_round(self, q: float, r: float) -> Tuple[int, int]:
    """Rounds floating point hex coordinates to their nearest integer hex coordinates.

    Args:
        q (float): q coordinate in hex coordinate system
        r (float): r coordinate in hex coordinate system

    Returns:
        Tuple[int, int]: (q, r) integer coordinates of the nearest hex cell

    Note: This method delegates to GridManager.hex_round().
    """
    return self._grid_manager.hex_round(q, r)

ignite_neighbors(cell, r_gamma, gamma, end_point)

Attempt to ignite neighboring cells reached by fire spread.

For each endpoint where fire has reached a neighbor boundary, checks if the neighbor is burnable and applies ignition if conditions are met.

Parameters:

Name Type Description Default
cell Cell

Source cell spreading fire.

required
r_gamma float

Rate of spread in direction gamma (m/s).

required
gamma float

Spread direction angle (degrees).

required
end_point list

List of (location, neighbor_letter) tuples indicating where fire reached neighbor boundaries.

required
Source code in embrs/base_classes/base_fire.py
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
def ignite_neighbors(self, cell: Cell, r_gamma: float, gamma: float, end_point: list):
    """Attempt to ignite neighboring cells reached by fire spread.

    For each endpoint where fire has reached a neighbor boundary,
    checks if the neighbor is burnable and applies ignition if
    conditions are met.

    Args:
        cell (Cell): Source cell spreading fire.
        r_gamma (float): Rate of spread in direction gamma (m/s).
        gamma (float): Spread direction angle (degrees).
        end_point (list): List of (location, neighbor_letter) tuples
            indicating where fire reached neighbor boundaries.
    """
    # Loop through end points
    for pt in end_point:

        # Get the location of the potential ignition on the neighbor
        n_loc = pt[0]

        # Get the Cell object of the neighbor
        neighbor = self.get_neighbor_from_end_point(cell, pt)

        if neighbor:
            # Check that neighbor state is burnable
            if neighbor.state == CellStates.FUEL and neighbor.fuel.burnable:
                # Check that the entry point is not disabled
                if n_loc in neighbor.disabled_locs:
                    continue
                # Make ignition calculation
                if self.is_firesim():
                    neighbor._update_moisture(self._curr_weather_idx, self._weather_stream)

                # Check that ignition ros is greater than no wind no slope ros
                if neighbor._retardant_factor > 0:
                    self._new_ignitions.append(neighbor)
                    neighbor.get_ign_params(n_loc)
                    neighbor._set_state(CellStates.FIRE)

                    if cell._crown_status == CrownStatus.ACTIVE and neighbor.has_canopy:
                        neighbor._crown_status = CrownStatus.ACTIVE

                    self.set_surface_accel_constant(neighbor)
                    surface_fire(neighbor)

                    r_eff = self.calc_ignition_ros(cell, neighbor, r_gamma)

                    neighbor.r_t, _ = calc_vals_for_all_directions(neighbor, r_eff, -999, neighbor.alpha, neighbor.e)

                    self._updated_cells[neighbor.id] = neighbor

                    if neighbor.id in self._frontier:
                        self._frontier.remove(neighbor.id)

                else:
                    if neighbor.id not in self._frontier:
                        self._frontier.add(neighbor.id)

is_firesim()

Check if this instance is a FireSim (real-time simulation).

Returns:

Name Type Description
bool bool

True if this is a FireSim instance.

Source code in embrs/base_classes/base_fire.py
1778
1779
1780
1781
1782
1783
1784
def is_firesim(self) -> bool:
    """Check if this instance is a FireSim (real-time simulation).

    Returns:
        bool: True if this is a FireSim instance.
    """
    return self.__class__.__name__ == "FireSim"

is_prediction()

Check if this instance is a FirePredictor (forward prediction).

Returns:

Name Type Description
bool bool

True if this is a FirePredictor instance.

Source code in embrs/base_classes/base_fire.py
1786
1787
1788
1789
1790
1791
1792
def is_prediction(self) -> bool:
    """Check if this instance is a FirePredictor (forward prediction).

    Returns:
        bool: True if this is a FirePredictor instance.
    """
    return self.__class__.__name__ == "FirePredictor"

propagate_embers()

Propagate embers from crown fires and ignite spot fires.

Simulates ember flight from lofted embers, schedules spot fire ignitions with delay, and ignites previously scheduled spots when their ignition time is reached.

Source code in embrs/base_classes/base_fire.py
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
def propagate_embers(self):
    """Propagate embers from crown fires and ignite spot fires.

    Simulates ember flight from lofted embers, schedules spot fire
    ignitions with delay, and ignites previously scheduled spots
    when their ignition time is reached.
    """
    spot_fires = self.embers.flight(self.curr_time_s + (self.time_step/60))

    if spot_fires:
        # Schedule spot fires using the ignition delay
        for spot in spot_fires:
            ign_time = self.curr_time_s + self._spot_delay_s

            if self._scheduled_spot_fires.get(ign_time) is None:
                self._scheduled_spot_fires[ign_time] = [spot]
            else:
                self._scheduled_spot_fires[ign_time].append(spot)

    if self._scheduled_spot_fires:
        # Ignite spots that have been scheduled previously
        pending_times = list(self._scheduled_spot_fires.keys())

        for time in pending_times:
            if time <= self.curr_time_s:
                # All cells with this time should be ignited
                new_spots = self._scheduled_spot_fires[time]

                for spot in new_spots:
                    self._new_ignitions.append(spot)
                    self._updated_cells[spot.id] = spot

                del self._scheduled_spot_fires[time]

            if time > self.curr_time_s:
                break

propagate_fire(cell)

Propagate fire spread from a burning cell to its neighbors.

Updates fire spread extent along each direction, checks for intersections with neighbor boundaries, and triggers ignition of neighboring cells when fire reaches them.

Uses a JIT-compiled inner loop to avoid Python overhead on the 12-element direction arrays.

Parameters:

Name Type Description Default
cell Cell

Burning cell from which to propagate fire.

required
Source code in embrs/base_classes/base_fire.py
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
def propagate_fire(self, cell: Cell):
    """Propagate fire spread from a burning cell to its neighbors.

    Updates fire spread extent along each direction, checks for
    intersections with neighbor boundaries, and triggers ignition
    of neighboring cells when fire reaches them.

    Uses a JIT-compiled inner loop to avoid Python overhead on the
    12-element direction arrays.

    Args:
        cell (Cell): Burning cell from which to propagate fire.
    """
    intersections = cell.intersections
    r_t = cell.r_t
    new_ixn_buf = self._new_ixn_buf

    result = _propagate_fire_core(
        cell.fire_spread, cell.avg_ros, cell.distances,
        intersections, self._time_step,
        r_t, cell.r_ss, self._iters != 0, new_ixn_buf
    )

    if result == -1:
        # All ROS zero — suppression scenario
        fire_area_ratio = cell.fire_area_m2 / cell.cell_area

        if fire_area_ratio >= self._burn_area_threshold:
            cell.fully_burning = True  # Will become BURNT next iteration
            return

        # Compute which boundary locations are consumed (before clearing arrays)
        cell.compute_disabled_locs()

        if cell.n_disabled_locs >= 11:
            cell.fully_burning = True  # <=1 loc remaining, can't propagate
            return

        # Partial suppression: return cell to FUEL
        cell.suppress_to_fuel()
        self._suppressed_cells.append(cell)
        return

    if result < -1:
        # Fully burning (all directions crossed) with some new intersections
        n_new = -(result + 2)
        cell.fully_burning = True
    else:
        n_new = result

    # Process new intersections — ignite neighbors
    if n_new > 0 and cell.breached:
        directions = cell.directions
        end_pts = cell.end_pts
        ignite = self.ignite_neighbors
        self_end_points = cell._self_end_points
        disabled = cell.disabled_locs
        for j in range(n_new):
            i = int(new_ixn_buf[j])
            # Check if exit boundary location is disabled
            if self_end_points is not None and self_end_points[i] in disabled:
                continue
            ignite(cell, r_t[i], directions[i], end_pts[i])

remove_neighbors(cell)

Remove non-burnable neighbors from a cell's burnable neighbors list.

Filters out neighbors that are no longer in the FUEL state from the cell's burnable_neighbors dictionary.

Parameters:

Name Type Description Default
cell Cell

Cell whose burnable neighbors should be updated.

required
Source code in embrs/base_classes/base_fire.py
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
def remove_neighbors(self, cell: Cell):
    """Remove non-burnable neighbors from a cell's burnable neighbors list.

    Filters out neighbors that are no longer in the FUEL state from
    the cell's burnable_neighbors dictionary.

    Args:
        cell (Cell): Cell whose burnable neighbors should be updated.
    """
    # Remove any neighbors which are no longer burnable
    neighbors_to_rem = []
    for n_id in cell.burnable_neighbors:
        neighbor = self._cell_dict[n_id]

        if neighbor.state == CellStates.BURNT:
            neighbors_to_rem.append(n_id)

    if neighbors_to_rem:
        for n_id in neighbors_to_rem:
            del cell.burnable_neighbors[n_id]

set_ignition_at_cell(cell)

Ignite the specified cell.

Sets the cell to the FIRE state and adds it to the new ignitions list.

Parameters:

Name Type Description Default
cell Cell

Cell to ignite.

required
Source code in embrs/base_classes/base_fire.py
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
def set_ignition_at_cell(self, cell: Cell) -> None:
    """Ignite the specified cell.

    Sets the cell to the FIRE state and adds it to the new ignitions list.

    Args:
        cell (Cell): Cell to ignite.
    """

    # Set ignition at cell
    cell.get_ign_params(0)
    self.set_state_at_cell(cell, CellStates.FIRE)
    self._new_ignitions.append(cell)

set_ignition_at_indices(row, col)

Ignite the cell at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
Source code in embrs/base_classes/base_fire.py
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
def set_ignition_at_indices(self, row: int, col: int) -> None:
    """Ignite the cell at the specified grid indices.

    Args:
        row (int): Row index in the cell grid.
        col (int): Column index in the cell grid.
    """
    # Get cell from specified indices
    cell = self.get_cell_from_indices(row, col)

    # Set ignition at cell
    self.set_ignition_at_cell(cell)

set_ignition_at_xy(x_m, y_m)

Ignite the cell at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
Source code in embrs/base_classes/base_fire.py
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
def set_ignition_at_xy(self, x_m: float, y_m: float) -> None:
    """Ignite the cell at the specified coordinates.

    Args:
        x_m (float): X position in meters.
        y_m (float): Y position in meters.
    """

    # Get cell from specified x, y location
    cell = self.get_cell_from_xy(x_m, y_m, oob_ok=True)
    if cell is not None:
        # Set ignition at cell
        self.set_ignition_at_cell(cell)

set_state_at_cell(cell, state)

Set the state of the specified cell

Parameters:

Name Type Description Default
cell Cell

Cell object whose state is to be changed

required
state CellStates

desired state to set the cell to (CellStates.FIRE, CellStates.FUEL, or CellStates.BURNT)

required

Raises:

Type Description
TypeError

if 'cell' is not of type Cell

ValueError

if 'cell' is not a valid Cell in the current fire Sim

TypeError

if 'state' is not a valid CellStates value

Source code in embrs/base_classes/base_fire.py
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
def set_state_at_cell(self, cell: Cell, state: CellStates) -> None:
    """Set the state of the specified cell

    Args:
        cell (Cell): Cell object whose state is to be changed
        state (CellStates): desired state to set the cell to (CellStates.FIRE,
                           CellStates.FUEL, or CellStates.BURNT)

    Raises:
        TypeError: if 'cell' is not of type Cell
        ValueError: if 'cell' is not a valid Cell in the current fire Sim
        TypeError: if 'state' is not a valid CellStates value
    """
    if not isinstance(cell, Cell):
        msg = f"'cell' must be of type 'Cell' not {type(cell)}"

        if self.logger:
            self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                    f"{msg} Program terminated.")

        raise TypeError(msg)

    if cell.id not in self._cell_dict:
        msg = f"{cell} is not a valid cell in the current fire Sim"

        if self.logger:
            self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                    f"{msg} Program terminated.")

        raise ValueError(msg)

    if not isinstance(state, int) or 0 > state > 2:
        msg = (
            f"{state} is not a valid cell state. Must be of type CellStates. "
            f"Valid states: fireUtil.CellStates.BURNT, fireUtil.CellStates.FUEL, "
            f"fireUtil.CellStates.FIRE or 0, 1, 2"
        )

        if self.logger:
            self.logger.log_message(f"Following erorr occurred in 'FireSim.set_state_at_cell(): "
                                    f"{msg} Program terminated.")

        raise TypeError(msg)

    # Set new state
    cell._set_state(state)

set_state_at_indices(row, col, state)

Set the state of the cell at the indices [row, col] in the cell_grid.

Parameters:

Name Type Description Default
row int

row index of the desired cell

required
col int

col index of the desired cell

required
state CellStates

desired state to set the cell to (CellStates.FIRE, CellStates.FUEL, or CellStates.BURNT)

required
Source code in embrs/base_classes/base_fire.py
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
def set_state_at_indices(self, row: int, col: int, state: CellStates) -> None:
    """Set the state of the cell at the indices [row, col] in the cell_grid.

    Args:
        row (int): row index of the desired cell
        col (int): col index of the desired cell
        state (CellStates): desired state to set the cell to (CellStates.FIRE,
                           CellStates.FUEL, or CellStates.BURNT)
    """
    cell = self.get_cell_from_indices(row, col)
    self.set_state_at_cell(cell, state)

set_state_at_xy(x_m, y_m, state)

Set the state of the cell at the point (x_m, y_m) in the Cartesian plane.

Parameters:

Name Type Description Default
x_m float

x position of the desired point in meters

required
y_m float

y position of the desired point in meters

required
state CellStates

desired state to set the cell to (CellStates.FIRE, CellStates.FUEL, or CellStates.BURNT)

required
Source code in embrs/base_classes/base_fire.py
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
def set_state_at_xy(self, x_m: float, y_m: float, state: CellStates) -> None:
    """Set the state of the cell at the point (x_m, y_m) in the Cartesian plane.

    Args:
        x_m (float): x position of the desired point in meters
        y_m (float): y position of the desired point in meters
        state (CellStates): desired state to set the cell to (CellStates.FIRE,
                           CellStates.FUEL, or CellStates.BURNT)
    """
    cell = self.get_cell_from_xy(x_m, y_m, oob_ok=True)
    self.set_state_at_cell(cell, state)

set_surface_accel_constant(cell)

Sets the surface acceleration constant for a burning cell based on the state of its neighbors.

If a cell has any burning neighbors it is modelled as a line fire.

Parameters:

Name Type Description Default
cell Cell

Cell object to set the surface acceleration constant for

required
Source code in embrs/base_classes/base_fire.py
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
def set_surface_accel_constant(self, cell: Cell):
    """Sets the surface acceleration constant for a burning cell based on the state of its neighbors.

    If a cell has any burning neighbors it is modelled as a line fire.

    Args:
        cell (Cell): Cell object to set the surface acceleration constant for
    """
    # Only set for non-active crown fires, active crown fire acceleration handled in crown model
    if cell._crown_status != CrownStatus.ACTIVE: 
        for n_id in cell.neighbors.keys():
            neighbor = self._cell_dict[n_id]
            if neighbor.state == CellStates.FIRE:
                # Model as a line fire
                cell.a_a = 0.3 / 60 # convert to 1 /sec
                return

        # Model as a point fire
        cell.a_a = 0.115 / 60 # convert to 1 / sec

stop_fireline_construction(fireline_id)

Stop construction of an active fireline.

Finalizes the partially constructed fireline and adds it to the permanent fire breaks list.

Parameters:

Name Type Description Default
fireline_id str

Identifier of the fireline to stop constructing.

required

Note: This method delegates to ControlActionHandler.stop_fireline_construction().

Source code in embrs/base_classes/base_fire.py
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
def stop_fireline_construction(self, fireline_id: str) -> None:
    """Stop construction of an active fireline.

    Finalizes the partially constructed fireline and adds it to the
    permanent fire breaks list.

    Args:
        fireline_id (str): Identifier of the fireline to stop constructing.

    Note: This method delegates to ControlActionHandler.stop_fireline_construction().
    """
    self._control_handler.stop_fireline_construction(fireline_id)

truncate_linestring(line, length)

Truncate a LineString to the specified length.

Parameters:

Name Type Description Default
line LineString

Original line to truncate.

required
length float

Desired length in meters.

required

Returns:

Name Type Description
LineString LineString

Truncated line, or original if length exceeds line length.

Note: This method delegates to ControlActionHandler._truncate_linestring().

Source code in embrs/base_classes/base_fire.py
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
def truncate_linestring(self, line: LineString, length: float) -> LineString:
    """Truncate a LineString to the specified length.

    Args:
        line (LineString): Original line to truncate.
        length (float): Desired length in meters.

    Returns:
        LineString: Truncated line, or original if length exceeds line length.

    Note: This method delegates to ControlActionHandler._truncate_linestring().
    """
    return self._control_handler._truncate_linestring(line, length)

update_control_interface_elements()

Update all active control interface elements.

Processes long-term retardants, active fireline construction, and water drops that need updates based on elapsed time.

Source code in embrs/base_classes/base_fire.py
907
908
909
910
911
912
913
914
915
916
917
918
919
920
def update_control_interface_elements(self):
    """Update all active control interface elements.

    Processes long-term retardants, active fireline construction,
    and water drops that need updates based on elapsed time.
    """
    if self._long_term_retardants:
        self.update_long_term_retardants()

    if self._active_firelines:
        self._update_active_firelines()

    if self._active_water_drops:
        self._update_active_water_drops()

update_long_term_retardants()

Update long-term retardant effects and remove expired retardants.

Checks retardant expiration times and removes retardant effects from cells whose retardant has expired.

Note: This method delegates to ControlActionHandler.update_long_term_retardants().

Source code in embrs/base_classes/base_fire.py
940
941
942
943
944
945
946
947
948
def update_long_term_retardants(self):
    """Update long-term retardant effects and remove expired retardants.

    Checks retardant expiration times and removes retardant effects
    from cells whose retardant has expired.

    Note: This method delegates to ControlActionHandler.update_long_term_retardants().
    """
    self._control_handler.update_long_term_retardants(self._curr_time_s)

update_steady_state(cell)

Update steady-state rate of spread for a burning cell.

Checks for crown fire conditions and calculates surface or crown fire spread rates. Also triggers ember lofting for spotting if crown fire is active.

Parameters:

Name Type Description Default
cell Cell

Burning cell to update.

required
Source code in embrs/base_classes/base_fire.py
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
def update_steady_state(self, cell: Cell):
    """Update steady-state rate of spread for a burning cell.

    Checks for crown fire conditions and calculates surface or crown
    fire spread rates. Also triggers ember lofting for spotting if
    crown fire is active.

    Args:
        cell (Cell): Burning cell to update.
    """
    # Checks if fire in cell meets threshold for crown fire, calls calc_propagation_in_cell using the crown ROS if active crown fire
    crown_fire(cell, self.fmc)

    if cell._crown_status != CrownStatus.ACTIVE:
        # Update values for cells that are not active crown fires
        surface_fire(cell)

    cell.has_steady_state = True

    if self.model_spotting:
        if self.is_firesim() and cell._crown_status != CrownStatus.NONE and self._spot_ign_prob > 0:
            if not cell.lofted:
                self.embers.loft(cell, self.curr_time_m)

        elif self.is_prediction() and cell._crown_status != CrownStatus.NONE and self._spot_ign_prob > 0:
            if not cell.lofted:
                self.embers.loft(cell)

water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

Apply water drop as direct moisture increase to the specified cell.

Only applies to burnable cells. Adds cell to active water drops for moisture tracking.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
moisture_inc float

Moisture content increase as a fraction.

required

Raises:

Type Description
ValueError

If moisture_inc is negative.

Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_moisture_bump().

Source code in embrs/base_classes/base_fire.py
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
def water_drop_at_cell_as_moisture_bump(self, cell: Cell, moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase to the specified cell.

    Only applies to burnable cells. Adds cell to active water drops
    for moisture tracking.

    Args:
        cell (Cell): Cell to apply water to.
        moisture_inc (float): Moisture content increase as a fraction.

    Raises:
        ValueError: If moisture_inc is negative.

    Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_moisture_bump().
    """
    self._control_handler.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

water_drop_at_cell_as_rain(cell, water_depth_cm)

Apply water drop as equivalent rainfall to the specified cell.

Only applies to burnable cells. Adds cell to active water drops for moisture tracking.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required

Raises:

Type Description
ValueError

If water_depth_cm is negative.

Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_rain().

Source code in embrs/base_classes/base_fire.py
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
def water_drop_at_cell_as_rain(self, cell: Cell, water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall to the specified cell.

    Only applies to burnable cells. Adds cell to active water drops
    for moisture tracking.

    Args:
        cell (Cell): Cell to apply water to.
        water_depth_cm (float): Equivalent rainfall depth in centimeters.

    Raises:
        ValueError: If water_depth_cm is negative.

    Note: This method delegates to ControlActionHandler.water_drop_at_cell_as_rain().
    """
    self._control_handler.water_drop_at_cell_as_rain(cell, water_depth_cm)

water_drop_at_cell_vw(cell, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop to the specified cell.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0

Raises:

Type Description
ValueError

If volume_L is negative.

Note: This method delegates to ControlActionHandler.water_drop_at_cell_vw().

Source code in embrs/base_classes/base_fire.py
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
def water_drop_at_cell_vw(self, cell: 'Cell', volume_L: float,
                           efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop to the specified cell.

    Args:
        cell (Cell): Cell to apply water to.
        volume_L (float): Water volume in liters (1 L = 1 kg).
        efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
        T_a (float): Ambient air temperature in °C. Default 20.

    Raises:
        ValueError: If volume_L is negative.

    Note: This method delegates to ControlActionHandler.water_drop_at_cell_vw().
    """
    self._control_handler.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

water_drop_at_indices_as_moisture_bump(row, col, moisture_inc)

Apply water drop as direct moisture increase at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
moisture_inc float

Moisture content increase as a fraction.

required

Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_moisture_bump().

Source code in embrs/base_classes/base_fire.py
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
def water_drop_at_indices_as_moisture_bump(self, row: int, col: int, moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase at the specified grid indices.

    Args:
        row (int): Row index in the cell grid.
        col (int): Column index in the cell grid.
        moisture_inc (float): Moisture content increase as a fraction.

    Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_moisture_bump().
    """
    self._control_handler.water_drop_at_indices_as_moisture_bump(row, col, moisture_inc)

water_drop_at_indices_as_rain(row, col, water_depth_cm)

Apply water drop as equivalent rainfall at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required

Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_rain().

Source code in embrs/base_classes/base_fire.py
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
def water_drop_at_indices_as_rain(self, row: int, col: int, water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall at the specified grid indices.

    Args:
        row (int): Row index in the cell grid.
        col (int): Column index in the cell grid.
        water_depth_cm (float): Equivalent rainfall depth in centimeters.

    Note: This method delegates to ControlActionHandler.water_drop_at_indices_as_rain().
    """
    self._control_handler.water_drop_at_indices_as_rain(row, col, water_depth_cm)

water_drop_at_indices_vw(row, col, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0

Note: This method delegates to ControlActionHandler.water_drop_at_indices_vw().

Source code in embrs/base_classes/base_fire.py
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
def water_drop_at_indices_vw(self, row: int, col: int, volume_L: float,
                              efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop at the specified grid indices.

    Args:
        row (int): Row index in the cell grid.
        col (int): Column index in the cell grid.
        volume_L (float): Water volume in liters (1 L = 1 kg).
        efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
        T_a (float): Ambient air temperature in °C. Default 20.

    Note: This method delegates to ControlActionHandler.water_drop_at_indices_vw().
    """
    self._control_handler.water_drop_at_indices_vw(row, col, volume_L, efficiency, T_a)

water_drop_at_xy_as_moisture_bump(x_m, y_m, moisture_inc)

Apply water drop as direct moisture increase at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
moisture_inc float

Moisture content increase as a fraction.

required

Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_moisture_bump().

Source code in embrs/base_classes/base_fire.py
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
def water_drop_at_xy_as_moisture_bump(self, x_m: float, y_m: float, moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase at the specified coordinates.

    Args:
        x_m (float): X position in meters.
        y_m (float): Y position in meters.
        moisture_inc (float): Moisture content increase as a fraction.

    Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_moisture_bump().
    """
    self._control_handler.water_drop_at_xy_as_moisture_bump(x_m, y_m, moisture_inc)

water_drop_at_xy_as_rain(x_m, y_m, water_depth_cm)

Apply water drop as equivalent rainfall at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required

Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_rain().

Source code in embrs/base_classes/base_fire.py
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
def water_drop_at_xy_as_rain(self, x_m: float, y_m: float, water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall at the specified coordinates.

    Args:
        x_m (float): X position in meters.
        y_m (float): Y position in meters.
        water_depth_cm (float): Equivalent rainfall depth in centimeters.

    Note: This method delegates to ControlActionHandler.water_drop_at_xy_as_rain().
    """
    self._control_handler.water_drop_at_xy_as_rain(x_m, y_m, water_depth_cm)

water_drop_at_xy_vw(x_m, y_m, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0

Note: This method delegates to ControlActionHandler.water_drop_at_xy_vw().

Source code in embrs/base_classes/base_fire.py
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
def water_drop_at_xy_vw(self, x_m: float, y_m: float, volume_L: float,
                         efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop at the specified coordinates.

    Args:
        x_m (float): X position in meters.
        y_m (float): Y position in meters.
        volume_L (float): Water volume in liters (1 L = 1 kg).
        efficiency (float): Application efficiency multiplier (Table 4). Default 2.5.
        T_a (float): Ambient air temperature in °C. Default 20.

    Note: This method delegates to ControlActionHandler.water_drop_at_xy_vw().
    """
    self._control_handler.water_drop_at_xy_vw(x_m, y_m, volume_L, efficiency, T_a)

Fuel

Base fuel model for Rothermel fire spread calculations.

Encapsulate the physical properties of a fuel type and precompute derived constants used in the Rothermel (1972) equations. Non-burnable fuel types (e.g., water, urban) store only name and model number.

All internal units follow the Rothermel convention: loading in lb/ft², surface-area-to-volume ratio in 1/ft, fuel depth in ft, heat content in BTU/lb.

Attributes:

Name Type Description
name str

Human-readable fuel model name.

model_num int

Numeric fuel model identifier.

burnable bool

Whether this fuel can sustain fire.

dynamic bool

Whether herbaceous fuel transfer is applied.

load ndarray

Fuel loading per class (tons/acre), shape (6,). Order: [1h, 10h, 100h, dead herb, live herb, live woody].

s ndarray

Surface-area-to-volume ratio per class (1/ft), shape (6,).

sav_ratio int

Characteristic SAV ratio (1/ft).

dead_mx float

Dead fuel moisture of extinction (fraction).

fuel_depth_ft float

Fuel bed depth (feet).

heat_content float

Heat content (BTU/lb), default 8000.

rho_p float

Particle density (lb/ft³), default 32.

Source code in embrs/models/fuel_models.py
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
class Fuel:
    """Base fuel model for Rothermel fire spread calculations.

    Encapsulate the physical properties of a fuel type and precompute
    derived constants used in the Rothermel (1972) equations. Non-burnable
    fuel types (e.g., water, urban) store only name and model number.

    All internal units follow the Rothermel convention:
    loading in lb/ft², surface-area-to-volume ratio in 1/ft, fuel depth in
    ft, heat content in BTU/lb.

    Attributes:
        name (str): Human-readable fuel model name.
        model_num (int): Numeric fuel model identifier.
        burnable (bool): Whether this fuel can sustain fire.
        dynamic (bool): Whether herbaceous fuel transfer is applied.
        load (np.ndarray): Fuel loading per class (tons/acre), shape (6,).
            Order: [1h, 10h, 100h, dead herb, live herb, live woody].
        s (np.ndarray): Surface-area-to-volume ratio per class (1/ft),
            shape (6,).
        sav_ratio (int): Characteristic SAV ratio (1/ft).
        dead_mx (float): Dead fuel moisture of extinction (fraction).
        fuel_depth_ft (float): Fuel bed depth (feet).
        heat_content (float): Heat content (BTU/lb), default 8000.
        rho_p (float): Particle density (lb/ft³), default 32.
    """

    def __init__(self, name: str, model_num: int, burnable: bool, dynamic: bool, w_0: np.ndarray,
                 s: np.ndarray, s_total: int, dead_mx: float, fuel_depth: float):
        """Initialize a fuel model.

        Args:
            name (str): Human-readable fuel model name.
            model_num (int): Numeric identifier for the fuel model.
            burnable (bool): Whether this fuel can sustain fire.
            dynamic (bool): Whether herbaceous transfer applies.
            w_0 (np.ndarray): Fuel loading per class (tons/acre), shape (6,).
                None for non-burnable models.
            s (np.ndarray): SAV ratio per class (1/ft), shape (6,).
                None for non-burnable models.
            s_total (int): Characteristic SAV ratio (1/ft).
            dead_mx (float): Dead fuel moisture of extinction (fraction).
            fuel_depth (float): Fuel bed depth (feet).
        """
        self.name = name
        self.model_num = model_num
        self.burnable = burnable
        self.dynamic = dynamic
        self.rel_indices = []

        if self.burnable:
            # Standard constants
            self.s_T = 0.055
            self.s_e = 0.010
            self.rho_p = 32
            self.heat_content = 8000 # btu/lb (used for live and dead)

            # Load data defining the fuel type
            self.load = w_0
            self.s = s
            self.sav_ratio = s_total
            self.fuel_depth_ft = fuel_depth
            self.dead_mx = dead_mx

            # Compute weighting factors
            self.compute_f_and_g_weights()

            # Compute f live and f dead
            self.f_dead_arr = self.f_ij[0, 0:4]
            self.f_live_arr = self.f_ij[1, 4:]

            # Compute g live and g dead
            self.g_dead_arr = self.g_ij[0, 0:4]
            self.g_live_arr = self.g_ij[1, 4:]

            # Compute the net fuel loading
            self.w_0 = TPA_to_Lbsft2(self.load)
            w_n = self.w_0 * (1 - self.s_T)
            self.set_fuel_loading(w_n)

            # Store nominal net dead loading
            self.w_n_dead_nominal = self.w_n_dead

            # Compute helpful constants for rothermel equations
            self.beta = np.sum(self.w_0) / 32 / self.fuel_depth_ft
            self.beta_op = 3.348 / (self.sav_ratio ** 0.8189)
            self.rat = self.beta / self.beta_op
            self.A = 133 * self.sav_ratio ** (-0.7913)
            self.gammax = (self.sav_ratio ** 1.5) / (495 + 0.0594 * self.sav_ratio ** 1.5)
            self.gamma = self.gammax * (self.rat ** self.A) * math.exp(self.A*(1-self.rat))
            self.rho_b = np.sum(self.w_0) / self.fuel_depth_ft
            self.flux_ratio = self.calc_flux_ratio()
            self.E, self.B, self.C = self.calc_E_B_C()
            self.W = self.calc_W(w_0)

            # Mark indices that are relevant for this fuel model
            for i in range(6):
                if self.w_0[i] > 0:
                    self.rel_indices.append(i)

            self.rel_indices = np.array(self.rel_indices)
            self.num_classes = len(self.rel_indices)

    def calc_flux_ratio(self) -> float:
        """Compute propagating flux ratio for the Rothermel equation.

        Returns:
            float: Propagating flux ratio (dimensionless).
        """
        packing_ratio = self.rho_b / self.rho_p
        flux_ratio = (192 + 0.2595*self.sav_ratio)**(-1) * math.exp((0.792 + 0.681*math.sqrt(self.sav_ratio))*(packing_ratio + 0.1))

        return flux_ratio

    def calc_E_B_C(self) -> tuple:
        """Compute wind factor coefficients E, B, and C.

        These coefficients parameterize the wind factor equation in the
        Rothermel model as a function of the characteristic SAV ratio.

        Returns:
            Tuple[float, float, float]: ``(E, B, C)`` wind factor
                coefficients (dimensionless).
        """

        sav_ratio = self.sav_ratio

        E = 0.715 * math.exp(-3.59e-4 * sav_ratio)
        B = 0.02526 * sav_ratio ** 0.54
        C = 7.47 * math.exp(-0.133 * sav_ratio**0.55)

        return E, B, C

    def compute_f_and_g_weights(self):
        """Compute fuel class weighting factors f_ij, g_ij, and category fractions f_i.

        Derive weighting arrays from fuel loading and SAV ratios. ``f_ij``
        gives fractional area weights within dead/live categories. ``g_ij``
        gives SAV-bin-based moisture weighting factors. ``f_i`` gives the
        dead vs. live category fractions.

        Side Effects:
            Sets ``self.f_ij`` (2×6), ``self.g_ij`` (2×6), and
            ``self.f_i`` (2,) arrays.
        """
        f_ij = np.zeros((2, 6))
        g_ij = np.zeros((2, 6))
        f_i = np.zeros(2)

        # ── Class grouping ─────────────────────────────────────────────
        dead_indices = [0, 1, 2, 3]  # 1h, 10h, 100h, dead herb
        live_indices = [4, 5]       # live herb, live woody

        # ── Compute a[i] = load[i] * SAV[i] / 32 ──────────────────────
        a_dead = np.array([
            self.load[i] * self.s[i] / 32.0 for i in dead_indices
        ])
        a_live = np.array([
            self.load[i] * self.s[i] / 32.0 for i in live_indices
        ])

        a_sum_dead = np.sum(a_dead)
        a_sum_live = np.sum(a_live)

        # ── f_ij (fractional weights) ─────────────────────────────────
        for idx in dead_indices:
            f_ij[0, idx] = a_dead[dead_indices.index(idx)] / a_sum_dead if a_sum_dead > 0 else 0.0
        for idx in live_indices:
            f_ij[1, idx] = a_live[live_indices.index(idx)] / a_sum_live if a_sum_live > 0 else 0.0

        # ── g_ij (SAV bin-based moisture weighting) ───────────────────
        def sav_bin(sav):
            if sav >= 1200.0: return 0
            elif sav >= 192.0: return 1
            elif sav >= 96.0: return 2
            elif sav >= 48.0: return 3
            elif sav >= 16.0: return 4
            else: return -1

        # Dead gx bin sums
        gx_dead = np.zeros(5)
        for i in dead_indices:
            bin_idx = sav_bin(self.s[i])
            if bin_idx >= 0:
                gx_dead[bin_idx] += f_ij[0, i]

        # Live gx bin sums
        gx_live = np.zeros(5)
        for i in live_indices:
            bin_idx = sav_bin(self.s[i])
            if bin_idx >= 0:
                gx_live[bin_idx] += f_ij[1, i]

        for i in dead_indices:
            bin_idx = sav_bin(self.s[i])
            g_ij[0, i] = gx_dead[bin_idx] if bin_idx >= 0 else 0.0

        for i in live_indices:
            bin_idx = sav_bin(self.s[i])
            g_ij[1, i] = gx_live[bin_idx] if bin_idx >= 0 else 0.0

        f_i[0] = a_sum_dead / (a_sum_dead + a_sum_live)
        f_i[1] = 1.0 - f_i[0]

        self.f_ij = f_ij
        self.g_ij = g_ij
        self.f_i = f_i

    def set_fuel_loading(self, w_n: np.ndarray):
        """Set net fuel loading and recompute weighted dead/live net loadings.

        Args:
            w_n (np.ndarray): Net fuel loading per class (lb/ft²), shape (6,).

        Side Effects:
            Updates ``self.w_n``, ``self.w_n_dead``, and ``self.w_n_live``.
        """
        self.w_n = w_n
        self.w_n_dead = np.dot(self.g_dead_arr, self.w_n[0:4])
        self.w_n_live = np.dot(self.g_live_arr, self.w_n[4:])

    def calc_W(self, w_0_tpa: np.ndarray) -> float:
        """Compute dead-to-live fuel loading ratio W.

        W is used to determine live fuel moisture of extinction. Returns
        ``np.inf`` when there is no live fuel loading (denominator is zero).

        Args:
            w_0_tpa (np.ndarray): Fuel loading per class (tons/acre),
                shape (6,).

        Returns:
            float: Dead-to-live loading ratio (dimensionless), or ``np.inf``
                if no live fuel is present.
        """
        w = w_0_tpa
        s = self.s

        num = 0

        for i in range(4):
            if s[i] != 0:
                num += w[i] * math.exp(-138/s[i])

        den = 0
        for i in range(4, 6):
            if s[i] != 0:
                den += w[i] * math.exp(-500/s[i])

        if den == 0:
            W = math.inf # Live moisture does not apply here

        else:
            W = num/den

        return W

__init__(name, model_num, burnable, dynamic, w_0, s, s_total, dead_mx, fuel_depth)

Initialize a fuel model.

Parameters:

Name Type Description Default
name str

Human-readable fuel model name.

required
model_num int

Numeric identifier for the fuel model.

required
burnable bool

Whether this fuel can sustain fire.

required
dynamic bool

Whether herbaceous transfer applies.

required
w_0 ndarray

Fuel loading per class (tons/acre), shape (6,). None for non-burnable models.

required
s ndarray

SAV ratio per class (1/ft), shape (6,). None for non-burnable models.

required
s_total int

Characteristic SAV ratio (1/ft).

required
dead_mx float

Dead fuel moisture of extinction (fraction).

required
fuel_depth float

Fuel bed depth (feet).

required
Source code in embrs/models/fuel_models.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def __init__(self, name: str, model_num: int, burnable: bool, dynamic: bool, w_0: np.ndarray,
             s: np.ndarray, s_total: int, dead_mx: float, fuel_depth: float):
    """Initialize a fuel model.

    Args:
        name (str): Human-readable fuel model name.
        model_num (int): Numeric identifier for the fuel model.
        burnable (bool): Whether this fuel can sustain fire.
        dynamic (bool): Whether herbaceous transfer applies.
        w_0 (np.ndarray): Fuel loading per class (tons/acre), shape (6,).
            None for non-burnable models.
        s (np.ndarray): SAV ratio per class (1/ft), shape (6,).
            None for non-burnable models.
        s_total (int): Characteristic SAV ratio (1/ft).
        dead_mx (float): Dead fuel moisture of extinction (fraction).
        fuel_depth (float): Fuel bed depth (feet).
    """
    self.name = name
    self.model_num = model_num
    self.burnable = burnable
    self.dynamic = dynamic
    self.rel_indices = []

    if self.burnable:
        # Standard constants
        self.s_T = 0.055
        self.s_e = 0.010
        self.rho_p = 32
        self.heat_content = 8000 # btu/lb (used for live and dead)

        # Load data defining the fuel type
        self.load = w_0
        self.s = s
        self.sav_ratio = s_total
        self.fuel_depth_ft = fuel_depth
        self.dead_mx = dead_mx

        # Compute weighting factors
        self.compute_f_and_g_weights()

        # Compute f live and f dead
        self.f_dead_arr = self.f_ij[0, 0:4]
        self.f_live_arr = self.f_ij[1, 4:]

        # Compute g live and g dead
        self.g_dead_arr = self.g_ij[0, 0:4]
        self.g_live_arr = self.g_ij[1, 4:]

        # Compute the net fuel loading
        self.w_0 = TPA_to_Lbsft2(self.load)
        w_n = self.w_0 * (1 - self.s_T)
        self.set_fuel_loading(w_n)

        # Store nominal net dead loading
        self.w_n_dead_nominal = self.w_n_dead

        # Compute helpful constants for rothermel equations
        self.beta = np.sum(self.w_0) / 32 / self.fuel_depth_ft
        self.beta_op = 3.348 / (self.sav_ratio ** 0.8189)
        self.rat = self.beta / self.beta_op
        self.A = 133 * self.sav_ratio ** (-0.7913)
        self.gammax = (self.sav_ratio ** 1.5) / (495 + 0.0594 * self.sav_ratio ** 1.5)
        self.gamma = self.gammax * (self.rat ** self.A) * math.exp(self.A*(1-self.rat))
        self.rho_b = np.sum(self.w_0) / self.fuel_depth_ft
        self.flux_ratio = self.calc_flux_ratio()
        self.E, self.B, self.C = self.calc_E_B_C()
        self.W = self.calc_W(w_0)

        # Mark indices that are relevant for this fuel model
        for i in range(6):
            if self.w_0[i] > 0:
                self.rel_indices.append(i)

        self.rel_indices = np.array(self.rel_indices)
        self.num_classes = len(self.rel_indices)

calc_E_B_C()

Compute wind factor coefficients E, B, and C.

These coefficients parameterize the wind factor equation in the Rothermel model as a function of the characteristic SAV ratio.

Returns:

Type Description
tuple

Tuple[float, float, float]: (E, B, C) wind factor coefficients (dimensionless).

Source code in embrs/models/fuel_models.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def calc_E_B_C(self) -> tuple:
    """Compute wind factor coefficients E, B, and C.

    These coefficients parameterize the wind factor equation in the
    Rothermel model as a function of the characteristic SAV ratio.

    Returns:
        Tuple[float, float, float]: ``(E, B, C)`` wind factor
            coefficients (dimensionless).
    """

    sav_ratio = self.sav_ratio

    E = 0.715 * math.exp(-3.59e-4 * sav_ratio)
    B = 0.02526 * sav_ratio ** 0.54
    C = 7.47 * math.exp(-0.133 * sav_ratio**0.55)

    return E, B, C

calc_W(w_0_tpa)

Compute dead-to-live fuel loading ratio W.

W is used to determine live fuel moisture of extinction. Returns np.inf when there is no live fuel loading (denominator is zero).

Parameters:

Name Type Description Default
w_0_tpa ndarray

Fuel loading per class (tons/acre), shape (6,).

required

Returns:

Name Type Description
float float

Dead-to-live loading ratio (dimensionless), or np.inf if no live fuel is present.

Source code in embrs/models/fuel_models.py
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
def calc_W(self, w_0_tpa: np.ndarray) -> float:
    """Compute dead-to-live fuel loading ratio W.

    W is used to determine live fuel moisture of extinction. Returns
    ``np.inf`` when there is no live fuel loading (denominator is zero).

    Args:
        w_0_tpa (np.ndarray): Fuel loading per class (tons/acre),
            shape (6,).

    Returns:
        float: Dead-to-live loading ratio (dimensionless), or ``np.inf``
            if no live fuel is present.
    """
    w = w_0_tpa
    s = self.s

    num = 0

    for i in range(4):
        if s[i] != 0:
            num += w[i] * math.exp(-138/s[i])

    den = 0
    for i in range(4, 6):
        if s[i] != 0:
            den += w[i] * math.exp(-500/s[i])

    if den == 0:
        W = math.inf # Live moisture does not apply here

    else:
        W = num/den

    return W

calc_flux_ratio()

Compute propagating flux ratio for the Rothermel equation.

Returns:

Name Type Description
float float

Propagating flux ratio (dimensionless).

Source code in embrs/models/fuel_models.py
132
133
134
135
136
137
138
139
140
141
def calc_flux_ratio(self) -> float:
    """Compute propagating flux ratio for the Rothermel equation.

    Returns:
        float: Propagating flux ratio (dimensionless).
    """
    packing_ratio = self.rho_b / self.rho_p
    flux_ratio = (192 + 0.2595*self.sav_ratio)**(-1) * math.exp((0.792 + 0.681*math.sqrt(self.sav_ratio))*(packing_ratio + 0.1))

    return flux_ratio

compute_f_and_g_weights()

Compute fuel class weighting factors f_ij, g_ij, and category fractions f_i.

Derive weighting arrays from fuel loading and SAV ratios. f_ij gives fractional area weights within dead/live categories. g_ij gives SAV-bin-based moisture weighting factors. f_i gives the dead vs. live category fractions.

Side Effects

Sets self.f_ij (2×6), self.g_ij (2×6), and self.f_i (2,) arrays.

Source code in embrs/models/fuel_models.py
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
def compute_f_and_g_weights(self):
    """Compute fuel class weighting factors f_ij, g_ij, and category fractions f_i.

    Derive weighting arrays from fuel loading and SAV ratios. ``f_ij``
    gives fractional area weights within dead/live categories. ``g_ij``
    gives SAV-bin-based moisture weighting factors. ``f_i`` gives the
    dead vs. live category fractions.

    Side Effects:
        Sets ``self.f_ij`` (2×6), ``self.g_ij`` (2×6), and
        ``self.f_i`` (2,) arrays.
    """
    f_ij = np.zeros((2, 6))
    g_ij = np.zeros((2, 6))
    f_i = np.zeros(2)

    # ── Class grouping ─────────────────────────────────────────────
    dead_indices = [0, 1, 2, 3]  # 1h, 10h, 100h, dead herb
    live_indices = [4, 5]       # live herb, live woody

    # ── Compute a[i] = load[i] * SAV[i] / 32 ──────────────────────
    a_dead = np.array([
        self.load[i] * self.s[i] / 32.0 for i in dead_indices
    ])
    a_live = np.array([
        self.load[i] * self.s[i] / 32.0 for i in live_indices
    ])

    a_sum_dead = np.sum(a_dead)
    a_sum_live = np.sum(a_live)

    # ── f_ij (fractional weights) ─────────────────────────────────
    for idx in dead_indices:
        f_ij[0, idx] = a_dead[dead_indices.index(idx)] / a_sum_dead if a_sum_dead > 0 else 0.0
    for idx in live_indices:
        f_ij[1, idx] = a_live[live_indices.index(idx)] / a_sum_live if a_sum_live > 0 else 0.0

    # ── g_ij (SAV bin-based moisture weighting) ───────────────────
    def sav_bin(sav):
        if sav >= 1200.0: return 0
        elif sav >= 192.0: return 1
        elif sav >= 96.0: return 2
        elif sav >= 48.0: return 3
        elif sav >= 16.0: return 4
        else: return -1

    # Dead gx bin sums
    gx_dead = np.zeros(5)
    for i in dead_indices:
        bin_idx = sav_bin(self.s[i])
        if bin_idx >= 0:
            gx_dead[bin_idx] += f_ij[0, i]

    # Live gx bin sums
    gx_live = np.zeros(5)
    for i in live_indices:
        bin_idx = sav_bin(self.s[i])
        if bin_idx >= 0:
            gx_live[bin_idx] += f_ij[1, i]

    for i in dead_indices:
        bin_idx = sav_bin(self.s[i])
        g_ij[0, i] = gx_dead[bin_idx] if bin_idx >= 0 else 0.0

    for i in live_indices:
        bin_idx = sav_bin(self.s[i])
        g_ij[1, i] = gx_live[bin_idx] if bin_idx >= 0 else 0.0

    f_i[0] = a_sum_dead / (a_sum_dead + a_sum_live)
    f_i[1] = 1.0 - f_i[0]

    self.f_ij = f_ij
    self.g_ij = g_ij
    self.f_i = f_i

set_fuel_loading(w_n)

Set net fuel loading and recompute weighted dead/live net loadings.

Parameters:

Name Type Description Default
w_n ndarray

Net fuel loading per class (lb/ft²), shape (6,).

required
Side Effects

Updates self.w_n, self.w_n_dead, and self.w_n_live.

Source code in embrs/models/fuel_models.py
237
238
239
240
241
242
243
244
245
246
247
248
def set_fuel_loading(self, w_n: np.ndarray):
    """Set net fuel loading and recompute weighted dead/live net loadings.

    Args:
        w_n (np.ndarray): Net fuel loading per class (lb/ft²), shape (6,).

    Side Effects:
        Updates ``self.w_n``, ``self.w_n_dead``, and ``self.w_n_live``.
    """
    self.w_n = w_n
    self.w_n_dead = np.dot(self.g_dead_arr, self.w_n[0:4])
    self.w_n_live = np.dot(self.g_live_arr, self.w_n[4:])

BTU_ft2_min_to_kW_m2(f_btu_ft2_min)

Convert heat flux from BTU/(ft^2*min) to kW/m^2.

Parameters:

Name Type Description Default
f_btu_ft2_min float

Heat flux in BTU/(ft^2*min).

required

Returns:

Name Type Description
float float

Heat flux in kW/m^2.

Source code in embrs/utilities/unit_conversions.py
220
221
222
223
224
225
226
227
228
229
def BTU_ft2_min_to_kW_m2(f_btu_ft2_min: float) -> float:
    """Convert heat flux from BTU/(ft^2*min) to kW/m^2.

    Args:
        f_btu_ft2_min (float): Heat flux in BTU/(ft^2*min).

    Returns:
        float: Heat flux in kW/m^2.
    """
    return f_btu_ft2_min * _BTU_FT2_MIN_TO_KW_M2

BTU_ft_min_to_kW_m(f_btu_ft_min)

Convert fireline intensity from BTU/(ft*min) to kW/m.

Parameters:

Name Type Description Default
f_btu_ft_min float

Fireline intensity in BTU/(ft*min).

required

Returns:

Name Type Description
float float

Fireline intensity in kW/m.

Source code in embrs/utilities/unit_conversions.py
232
233
234
235
236
237
238
239
240
241
def BTU_ft_min_to_kW_m(f_btu_ft_min: float) -> float:
    """Convert fireline intensity from BTU/(ft*min) to kW/m.

    Args:
        f_btu_ft_min (float): Fireline intensity in BTU/(ft*min).

    Returns:
        float: Fireline intensity in kW/m.
    """
    return f_btu_ft_min * _BTU_FT_MIN_TO_KW_M

BTU_ft_min_to_kcal_s_m(f_btu_ft_min)

Convert fireline intensity from BTU/(ftmin) to kcal/(sm).

Parameters:

Name Type Description Default
f_btu_ft_min float

Fireline intensity in BTU/(ft*min).

required

Returns:

Name Type Description
float float

Fireline intensity in kcal/(s*m).

Source code in embrs/utilities/unit_conversions.py
244
245
246
247
248
249
250
251
252
253
def BTU_ft_min_to_kcal_s_m(f_btu_ft_min: float) -> float:
    """Convert fireline intensity from BTU/(ft*min) to kcal/(s*m).

    Args:
        f_btu_ft_min (float): Fireline intensity in BTU/(ft*min).

    Returns:
        float: Fireline intensity in kcal/(s*m).
    """
    return f_btu_ft_min * _BTU_FT_MIN_TO_KCAL_S_M

BTU_lb_to_cal_g(f_btu_lb)

Convert heat content from BTU/lb to cal/g.

Parameters:

Name Type Description Default
f_btu_lb float

Heat content in BTU/lb.

required

Returns:

Name Type Description
float float

Heat content in cal/g.

Source code in embrs/utilities/unit_conversions.py
272
273
274
275
276
277
278
279
280
281
def BTU_lb_to_cal_g(f_btu_lb: float) -> float:
    """Convert heat content from BTU/lb to cal/g.

    Args:
        f_btu_lb (float): Heat content in BTU/lb.

    Returns:
        float: Heat content in cal/g.
    """
    return f_btu_lb * _BTU_LB_TO_CAL_G

F_to_C(f_f)

Convert temperature from Fahrenheit to Celsius.

Parameters:

Name Type Description Default
f_f float

Temperature in degrees Fahrenheit.

required

Returns:

Name Type Description
float float

Temperature in degrees Celsius.

Source code in embrs/utilities/unit_conversions.py
48
49
50
51
52
53
54
55
56
57
def F_to_C(f_f: float) -> float:
    """Convert temperature from Fahrenheit to Celsius.

    Args:
        f_f (float): Temperature in degrees Fahrenheit.

    Returns:
        float: Temperature in degrees Celsius.
    """
    return (5.0 / 9.0) * (f_f - 32.0)

KiSq_to_Lbsft2(f_kisq)

Convert fuel loading from kg/m^2 to lb/ft^2.

Parameters:

Name Type Description Default
f_kisq float

Fuel loading in kg/m^2.

required

Returns:

Name Type Description
float float

Fuel loading in lb/ft^2.

Source code in embrs/utilities/unit_conversions.py
156
157
158
159
160
161
162
163
164
165
def KiSq_to_Lbsft2(f_kisq: float) -> float:
    """Convert fuel loading from kg/m^2 to lb/ft^2.

    Args:
        f_kisq (float): Fuel loading in kg/m^2.

    Returns:
        float: Fuel loading in lb/ft^2.
    """
    return f_kisq * _KISQ_TO_LBSFT2

KiSq_to_TPA(f_kisq)

Convert fuel loading from kg/m^2 to tons per acre.

Parameters:

Name Type Description Default
f_kisq float

Fuel loading in kg/m^2.

required

Returns:

Name Type Description
float float

Fuel loading in tons per acre.

Source code in embrs/utilities/unit_conversions.py
204
205
206
207
208
209
210
211
212
213
def KiSq_to_TPA(f_kisq: float) -> float:
    """Convert fuel loading from kg/m^2 to tons per acre.

    Args:
        f_kisq (float): Fuel loading in kg/m^2.

    Returns:
        float: Fuel loading in tons per acre.
    """
    return f_kisq * _KISQ_TO_TPA

Lbsft2_to_KiSq(f_libsft2)

Convert fuel loading from lb/ft^2 to kg/m^2.

Parameters:

Name Type Description Default
f_libsft2 float

Fuel loading in lb/ft^2.

required

Returns:

Name Type Description
float float

Fuel loading in kg/m^2.

Source code in embrs/utilities/unit_conversions.py
144
145
146
147
148
149
150
151
152
153
def Lbsft2_to_KiSq(f_libsft2: float) -> float:
    """Convert fuel loading from lb/ft^2 to kg/m^2.

    Args:
        f_libsft2 (float): Fuel loading in lb/ft^2.

    Returns:
        float: Fuel loading in kg/m^2.
    """
    return f_libsft2 * _LBSFT2_TO_KISQ

Lbsft2_to_TPA(f_lbsft2)

Convert fuel loading from lb/ft^2 to tons per acre.

Parameters:

Name Type Description Default
f_lbsft2 float

Fuel loading in lb/ft^2.

required

Returns:

Name Type Description
float float

Fuel loading in tons per acre.

Source code in embrs/utilities/unit_conversions.py
192
193
194
195
196
197
198
199
200
201
def Lbsft2_to_TPA(f_lbsft2: float) -> float:
    """Convert fuel loading from lb/ft^2 to tons per acre.

    Args:
        f_lbsft2 (float): Fuel loading in lb/ft^2.

    Returns:
        float: Fuel loading in tons per acre.
    """
    return f_lbsft2 * _LBSFT2_TO_TPA

TPA_to_KiSq(f_tpa)

Convert fuel loading from tons per acre to kg/m^2.

Parameters:

Name Type Description Default
f_tpa float

Fuel loading in tons per acre.

required

Returns:

Name Type Description
float float

Fuel loading in kg/m^2.

Source code in embrs/utilities/unit_conversions.py
168
169
170
171
172
173
174
175
176
177
def TPA_to_KiSq(f_tpa: float) -> float:
    """Convert fuel loading from tons per acre to kg/m^2.

    Args:
        f_tpa (float): Fuel loading in tons per acre.

    Returns:
        float: Fuel loading in kg/m^2.
    """
    return f_tpa / _TPA_TO_KISQ_DIVISOR

TPA_to_Lbsft2(f_tpa)

Convert fuel loading from tons per acre to lb/ft^2.

Parameters:

Name Type Description Default
f_tpa float

Fuel loading in tons per acre.

required

Returns:

Name Type Description
float float

Fuel loading in lb/ft^2.

Source code in embrs/utilities/unit_conversions.py
180
181
182
183
184
185
186
187
188
189
def TPA_to_Lbsft2(f_tpa: float) -> float:
    """Convert fuel loading from tons per acre to lb/ft^2.

    Args:
        f_tpa (float): Fuel loading in tons per acre.

    Returns:
        float: Fuel loading in lb/ft^2.
    """
    return f_tpa * _TPA_TO_LBSFT2

accelerate(cell, time_step)

Apply fire acceleration toward steady-state ROS.

Update the transient rate of spread (cell.r_t) and average ROS (cell.avg_ros) for each spread direction using the exponential acceleration model (McAlpine 1989). Directions already at or above steady-state are clamped.

Uses a JIT-compiled inner loop to avoid numpy dispatch overhead on small (12-element) arrays.

Parameters:

Name Type Description Default
cell Cell

Burning cell with r_ss, r_t, a_a set.

required
time_step float

Simulation time step in seconds.

required
Side Effects

Updates cell.r_t, cell.avg_ros, and cell.I_t in-place.

Source code in embrs/models/rothermel.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def accelerate(cell: Cell, time_step: float):
    """Apply fire acceleration toward steady-state ROS.

    Update the transient rate of spread (``cell.r_t``) and average ROS
    (``cell.avg_ros``) for each spread direction using the exponential
    acceleration model (McAlpine 1989). Directions already at or above
    steady-state are clamped.

    Uses a JIT-compiled inner loop to avoid numpy dispatch overhead on
    small (12-element) arrays.

    Args:
        cell (Cell): Burning cell with ``r_ss``, ``r_t``, ``a_a`` set.
        time_step (float): Simulation time step in seconds.

    Side Effects:
        Updates ``cell.r_t``, ``cell.avg_ros``, and ``cell.I_t`` in-place.
    """
    _accelerate_core(cell.r_t, cell.r_ss, cell.avg_ros, cell.I_t, cell.I_ss,
                     cell.a_a, float(time_step))

cal_g_to_BTU_lb(f_cal_g)

Convert heat content from cal/g to BTU/lb.

Parameters:

Name Type Description Default
f_cal_g float

Heat content in cal/g.

required

Returns:

Name Type Description
float float

Heat content in BTU/lb.

Source code in embrs/utilities/unit_conversions.py
260
261
262
263
264
265
266
267
268
269
def cal_g_to_BTU_lb(f_cal_g: float) -> float:
    """Convert heat content from cal/g to BTU/lb.

    Args:
        f_cal_g (float): Heat content in cal/g.

    Returns:
        float: Heat content in BTU/lb.
    """
    return f_cal_g * _CAL_G_TO_BTU_LB

calc_I_r(fuel, dead_moist_damping, live_moist_damping)

Compute reaction intensity from fuel properties and moisture damping.

Reaction intensity is the rate of heat release per unit area of the flaming front (Rothermel 1972, Eq. 27).

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with net fuel loadings, heat content, and optimum reaction velocity (gamma).

required
dead_moist_damping float

Dead fuel moisture damping coefficient in [0, 1].

required
live_moist_damping float

Live fuel moisture damping coefficient in [0, 1].

required

Returns:

Name Type Description
float float

Reaction intensity (BTU/ft²/min).

Source code in embrs/models/rothermel.py
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
def calc_I_r(fuel: Fuel, dead_moist_damping: float, live_moist_damping: float) -> float:
    """Compute reaction intensity from fuel properties and moisture damping.

    Reaction intensity is the rate of heat release per unit area of the
    flaming front (Rothermel 1972, Eq. 27).

    Args:
        fuel (Fuel): Fuel model with net fuel loadings, heat content, and
            optimum reaction velocity (``gamma``).
        dead_moist_damping (float): Dead fuel moisture damping coefficient
            in [0, 1].
        live_moist_damping (float): Live fuel moisture damping coefficient
            in [0, 1].

    Returns:
        float: Reaction intensity (BTU/ft²/min).
    """
    mineral_damping = calc_mineral_damping()

    dead_calc = fuel.w_n_dead * fuel.heat_content * dead_moist_damping * mineral_damping
    live_calc = fuel.w_n_live * fuel.heat_content * live_moist_damping * mineral_damping

    I_r = fuel.gamma * (dead_calc + live_calc)

    return I_r

calc_eccentricity(fuel, R_h, R_0)

Compute fire ellipse eccentricity from effective wind speed.

Convert the effective wind speed to m/s, then compute the length-to- breadth ratio z and derive eccentricity. Capped at z = 8.0 following Anderson (1983).

Parameters:

Name Type Description Default
fuel Fuel

Fuel model for effective wind speed calculation.

required
R_h float

Head-fire rate of spread (ft/min).

required
R_0 float

No-wind, no-slope base ROS (ft/min).

required

Returns:

Name Type Description
float float

Fire ellipse eccentricity in [0, 1).

Source code in embrs/models/rothermel.py
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
def calc_eccentricity(fuel: Fuel, R_h: float, R_0: float) -> float:
    """Compute fire ellipse eccentricity from effective wind speed.

    Convert the effective wind speed to m/s, then compute the length-to-
    breadth ratio ``z`` and derive eccentricity. Capped at ``z = 8.0``
    following Anderson (1983).

    Args:
        fuel (Fuel): Fuel model for effective wind speed calculation.
        R_h (float): Head-fire rate of spread (ft/min).
        R_0 (float): No-wind, no-slope base ROS (ft/min).

    Returns:
        float: Fire ellipse eccentricity in [0, 1).
    """
    u_e = calc_effective_wind_speed(fuel, R_h, R_0)
    u_e_ms = ft_min_to_m_s(u_e)
    z = 0.936 * math.exp(0.2566 * u_e_ms) + 0.461 * math.exp(-0.1548 * u_e_ms) - 0.397
    z = min(z, 8.0)
    e = math.sqrt(z**2 - 1) / z

    return e

calc_effective_wind_factor(R_h, R_0)

Compute the effective wind factor from head-fire and base ROS.

The effective wind factor (phi_e) represents the combined influence of wind and slope as if it were a single wind-only factor.

Parameters:

Name Type Description Default
R_h float

Head-fire rate of spread (ft/min).

required
R_0 float

No-wind, no-slope base ROS (ft/min).

required

Returns:

Name Type Description
float float

Effective wind factor (dimensionless).

Source code in embrs/models/rothermel.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
def calc_effective_wind_factor(R_h: float, R_0: float) -> float:
    """Compute the effective wind factor from head-fire and base ROS.

    The effective wind factor (phi_e) represents the combined influence of
    wind and slope as if it were a single wind-only factor.

    Args:
        R_h (float): Head-fire rate of spread (ft/min).
        R_0 (float): No-wind, no-slope base ROS (ft/min).

    Returns:
        float: Effective wind factor (dimensionless).
    """
    phi_e = (R_h / R_0) - 1

    return phi_e

calc_effective_wind_speed(fuel, R_h, R_0)

Compute the effective wind speed from the effective wind factor.

Invert the wind factor equation to recover the equivalent wind speed that produces the same effect as the combined wind and slope.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with wind coefficients B, C, E, and packing ratio rat.

required
R_h float

Head-fire rate of spread (ft/min).

required
R_0 float

No-wind, no-slope base ROS (ft/min).

required

Returns:

Name Type Description
float float

Effective wind speed (ft/min). Returns 0 when R_h <= R_0.

Source code in embrs/models/rothermel.py
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
def calc_effective_wind_speed(fuel: Fuel, R_h: float, R_0: float) -> float:
    """Compute the effective wind speed from the effective wind factor.

    Invert the wind factor equation to recover the equivalent wind speed
    that produces the same effect as the combined wind and slope.

    Args:
        fuel (Fuel): Fuel model with wind coefficients ``B``, ``C``, ``E``,
            and packing ratio ``rat``.
        R_h (float): Head-fire rate of spread (ft/min).
        R_0 (float): No-wind, no-slope base ROS (ft/min).

    Returns:
        float: Effective wind speed (ft/min). Returns 0 when ``R_h <= R_0``.
    """


    if R_h <= R_0:
        phi_e = 0

    else: 
        phi_e = calc_effective_wind_factor(R_h, R_0)

    u_e = ((phi_e * (fuel.rat**fuel.E))/fuel.C) ** (1/fuel.B)

    return u_e

calc_flame_len(cell)

Estimate flame length from maximum fireline intensity.

For surface fires, uses Brown and Davis (1973) correlation. For crown fires, uses Thomas (1963) correlation.

Parameters:

Name Type Description Default
cell Cell

Cell with I_ss (BTU/ft/min) and _crown_status.

required

Returns:

Name Type Description
float float

Flame length in feet.

Source code in embrs/models/rothermel.py
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
def calc_flame_len(cell: Cell) -> float:
    """Estimate flame length from maximum fireline intensity.

    For surface fires, uses Brown and Davis (1973) correlation. For crown
    fires, uses Thomas (1963) correlation.

    Args:
        cell (Cell): Cell with ``I_ss`` (BTU/ft/min) and ``_crown_status``.

    Returns:
        float: Flame length in feet.
    """
    # Fireline intensity in Btu/ft/min
    fli = float(np.max(cell.I_ss))
    fli /= 60 # convert to Btu/ft/s

    if cell._crown_status == CrownStatus.NONE:
        # Surface fire
        # Brown and Davis 1973 pg. 175
        flame_len_ft = 0.45 * fli ** (0.46)

    else:
        flame_len_ft = (0.2 * (fli ** (2/3))) # in feet

    return flame_len_ft

calc_heat_sink(fuel, m_f)

Compute heat sink term for the Rothermel spread equation.

The heat sink represents the energy required to raise the fuel ahead of the fire front to ignition temperature, weighted by fuel class properties and moisture contents.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with bulk density, weighting factors, and surface-area-to-volume ratios.

required
m_f ndarray

Fuel moisture content array of shape (6,) as fractions.

required

Returns:

Name Type Description
float float

Heat sink (BTU/ft³).

Source code in embrs/models/rothermel.py
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
def calc_heat_sink(fuel: Fuel, m_f: np.ndarray) -> float:
    """Compute heat sink term for the Rothermel spread equation.

    The heat sink represents the energy required to raise the fuel ahead
    of the fire front to ignition temperature, weighted by fuel class
    properties and moisture contents.

    Args:
        fuel (Fuel): Fuel model with bulk density, weighting factors, and
            surface-area-to-volume ratios.
        m_f (np.ndarray): Fuel moisture content array of shape (6,) as
            fractions.

    Returns:
        float: Heat sink (BTU/ft³).
    """
    Q_ig = 250 + 1116 * m_f


    # Compute the heat sink term as per the equation
    heat_sink = 0

    dead_sum = 0
    for j in range(4):
        if fuel.s[j] != 0:
            dead_sum += fuel.f_dead_arr[j] * math.exp(-138/fuel.s[j]) * Q_ig[j]

    heat_sink += fuel.f_i[0] * dead_sum

    live_sum = 0
    for j in range(2):
        if fuel.s[4+j] != 0:
            live_sum += fuel.f_live_arr[j] * math.exp(-138/fuel.s[4+j]) * Q_ig[4+j]

    heat_sink += fuel.f_i[1] * live_sum
    heat_sink *= fuel.rho_b

    return heat_sink

calc_live_mx(fuel, m_f)

Compute live fuel moisture of extinction.

Determine the threshold moisture content above which live fuels will not sustain combustion, based on the ratio of dead-to-live fuel loading (fuel.W) and the dead characteristic moisture.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with loading ratio W and dead_mx.

required
m_f float

Weighted characteristic dead fuel moisture (fraction).

required

Returns:

Name Type Description
float float

Live fuel moisture of extinction (fraction). Clamped to be at least fuel.dead_mx.

Source code in embrs/models/rothermel.py
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
def calc_live_mx(fuel: Fuel, m_f: float) -> float:
    """Compute live fuel moisture of extinction.

    Determine the threshold moisture content above which live fuels will
    not sustain combustion, based on the ratio of dead-to-live fuel loading
    (``fuel.W``) and the dead characteristic moisture.

    Args:
        fuel (Fuel): Fuel model with loading ratio ``W`` and ``dead_mx``.
        m_f (float): Weighted characteristic dead fuel moisture (fraction).

    Returns:
        float: Live fuel moisture of extinction (fraction). Clamped to be
            at least ``fuel.dead_mx``.
    """
    W = fuel.W

    if W == math.inf:
        return fuel.dead_mx

    num = 0
    den = 0
    for i in range(4):
        if fuel.s[i] != 0:
            num += m_f * fuel.load[i] * math.exp(-138/fuel.s[i])
            den += fuel.load[i] * math.exp(-138/fuel.s[i])

    mf_dead = num/den

    mx = 2.9 * W * (1 - mf_dead / fuel.dead_mx) - 0.226

    return max(mx, fuel.dead_mx)

calc_mineral_damping(s_e=0.01)

Compute mineral damping coefficient.

Parameters:

Name Type Description Default
s_e float

Effective mineral content (fraction). Defaults to 0.010 (standard value for wildland fuels).

0.01

Returns:

Name Type Description
float float

Mineral damping coefficient (dimensionless).

Source code in embrs/models/rothermel.py
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def calc_mineral_damping(s_e: float = 0.010) -> float:
    """Compute mineral damping coefficient.

    Args:
        s_e (float): Effective mineral content (fraction). Defaults to
            0.010 (standard value for wildland fuels).

    Returns:
        float: Mineral damping coefficient (dimensionless).
    """
    if s_e == 0.010:
        return _MINERAL_DAMPING_DEFAULT

    mineral_damping = 0.174 * s_e ** (-0.19)

    return mineral_damping

calc_moisture_damping(m_f, m_x)

Compute moisture damping coefficient for dead or live fuel.

Evaluates a cubic polynomial in the moisture ratio m_f / m_x (Rothermel 1972, Eq. 29). Returns 0 when moisture of extinction is zero or when the polynomial evaluates to a negative value.

Parameters:

Name Type Description Default
m_f float

Characteristic fuel moisture content (fraction).

required
m_x float

Moisture of extinction (fraction).

required

Returns:

Name Type Description
float float

Moisture damping coefficient in [0, 1].

Source code in embrs/models/rothermel.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
def calc_moisture_damping(m_f: float, m_x: float) -> float:
    """Compute moisture damping coefficient for dead or live fuel.

    Evaluates a cubic polynomial in the moisture ratio ``m_f / m_x``
    (Rothermel 1972, Eq. 29). Returns 0 when moisture of extinction is
    zero or when the polynomial evaluates to a negative value.

    Args:
        m_f (float): Characteristic fuel moisture content (fraction).
        m_x (float): Moisture of extinction (fraction).

    Returns:
        float: Moisture damping coefficient in [0, 1].
    """
    if m_x == 0:
        return 0

    r_m = m_f / m_x

    # Horner's form: fewer multiplications than expanded polynomial
    moist_damping = 1 + r_m * (-2.59 + r_m * (5.11 - 3.52 * r_m))

    return max(0, moist_damping)

calc_r_0(fuel, m_f)

Compute no-wind, no-slope base rate of spread and reaction intensity.

Evaluate the Rothermel (1972) equations for base ROS using fuel properties and moisture content. This is the fundamental spread rate before wind and slope adjustments.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with precomputed constants.

required
m_f ndarray

Fuel moisture content array of shape (6,) with entries [1h, 10h, 100h, dead herb, live herb, live woody] as fractions (g water / g fuel).

required

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: (R_0, I_r) where R_0 is base ROS (ft/min) and I_r is reaction intensity (BTU/ft²/min).

Source code in embrs/models/rothermel.py
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
def calc_r_0(fuel: Fuel, m_f: np.ndarray) -> Tuple[float, float]:
    """Compute no-wind, no-slope base rate of spread and reaction intensity.

    Evaluate the Rothermel (1972) equations for base ROS using fuel
    properties and moisture content. This is the fundamental spread rate
    before wind and slope adjustments.

    Args:
        fuel (Fuel): Fuel model with precomputed constants.
        m_f (np.ndarray): Fuel moisture content array of shape (6,) with
            entries [1h, 10h, 100h, dead herb, live herb, live woody] as
            fractions (g water / g fuel).

    Returns:
        Tuple[float, float]: ``(R_0, I_r)`` where ``R_0`` is base ROS
            (ft/min) and ``I_r`` is reaction intensity (BTU/ft²/min).
    """
    # Calculate moisture damping constants
    dead_mf, live_mf = get_characteristic_moistures(fuel, m_f)
    live_mx = calc_live_mx(fuel, dead_mf)
    live_moisture_damping = calc_moisture_damping(live_mf, live_mx)
    dead_moisture_damping = calc_moisture_damping(dead_mf, fuel.dead_mx)

    I_r = calc_I_r(fuel, dead_moisture_damping, live_moisture_damping)
    heat_sink = calc_heat_sink(fuel, m_f)

    R_0 = (I_r * fuel.flux_ratio)/heat_sink

    return R_0, I_r # ft/min, BTU/ft^2-min

calc_r_h(cell, R_0=None, I_r=None)

Compute head-fire rate of spread combining wind and slope effects.

Resolve the wind and slope vectors to determine the maximum spread direction (alpha) and head-fire ROS (R_h). Wind speed is capped at 0.9 × reaction intensity per Rothermel's wind limit.

Parameters:

Name Type Description Default
cell Cell

Cell with wind, slope, fuel, and moisture data.

required
R_0 float

Pre-computed no-wind, no-slope ROS (ft/min). Computed internally if None.

None
I_r float

Pre-computed reaction intensity (BTU/ft²/min). Computed internally if None.

None

Returns:

Type Description
Tuple[float, float, float, float]

Tuple[float, float, float, float]: (R_h, R_0, I_r, alpha) where R_h is head-fire ROS (ft/min), R_0 is base ROS (ft/min), I_r is reaction intensity (BTU/ft²/min), and alpha is the combined wind/slope heading (radians).

Side Effects

May update cell.aspect when slope is zero (set to wind direction).

Source code in embrs/models/rothermel.py
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
def calc_r_h(cell: Cell, R_0: float = None, I_r: float = None) -> Tuple[float, float, float, float]:
    """Compute head-fire rate of spread combining wind and slope effects.

    Resolve the wind and slope vectors to determine the maximum spread
    direction (``alpha``) and head-fire ROS (``R_h``). Wind speed is capped
    at 0.9 × reaction intensity per Rothermel's wind limit.

    Args:
        cell (Cell): Cell with wind, slope, fuel, and moisture data.
        R_0 (float, optional): Pre-computed no-wind, no-slope ROS (ft/min).
            Computed internally if None.
        I_r (float, optional): Pre-computed reaction intensity
            (BTU/ft²/min). Computed internally if None.

    Returns:
        Tuple[float, float, float, float]: ``(R_h, R_0, I_r, alpha)`` where
            ``R_h`` is head-fire ROS (ft/min), ``R_0`` is base ROS (ft/min),
            ``I_r`` is reaction intensity (BTU/ft²/min), and ``alpha`` is the
            combined wind/slope heading (radians).

    Side Effects:
        May update ``cell.aspect`` when slope is zero (set to wind direction).
    """
    wind_speed_m_s, wind_dir_deg = cell.curr_wind()

    wind_speed_ft_min = m_s_to_ft_min(wind_speed_m_s)

    wind_speed_ft_min *= cell.wind_adj_factor

    slope_angle_deg = cell.slope_deg
    slope_dir_deg = cell.aspect

    if slope_angle_deg == 0:
        rel_wind_dir_deg = 0
        cell.aspect = wind_dir_deg

    elif wind_speed_m_s == 0:
        rel_wind_dir_deg = 0
        wind_dir_deg = cell.aspect

    else:
        rel_wind_dir_deg = wind_dir_deg - slope_dir_deg
        if rel_wind_dir_deg < 0:
            rel_wind_dir_deg += 360

    rel_wind_dir = math.radians(rel_wind_dir_deg)
    slope_angle = math.radians(slope_angle_deg)

    fuel = cell.fuel
    m_f = cell.fmois

    if R_0 is None or I_r is None:
        R_0, I_r = calc_r_0(fuel, m_f)

    if R_0 <= 1e-12:
        # No spread in this cell
        return 0, 0, 0, 0

    # Enforce maximum wind speed
    U_max = 0.9 * I_r
    wind_speed_ft_min = min(U_max, wind_speed_ft_min)

    phi_w = calc_wind_factor(fuel, wind_speed_ft_min)
    phi_s = calc_slope_factor(fuel, slope_angle)

    vec_speed, alpha = calc_wind_slope_vec(R_0, phi_w, phi_s, rel_wind_dir)

    R_h = R_0 + vec_speed

    return R_h, R_0, I_r, alpha

calc_slope_factor(fuel, phi)

Compute the slope factor (phi_s) for the Rothermel spread equation.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with bulk density rho_b and particle density rho_p.

required
phi float

Slope angle (radians).

required

Returns:

Name Type Description
float float

Dimensionless slope factor (phi_s).

Source code in embrs/models/rothermel.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def calc_slope_factor(fuel: Fuel, phi: float) -> float:
    """Compute the slope factor (phi_s) for the Rothermel spread equation.

    Args:
        fuel (Fuel): Fuel model with bulk density ``rho_b`` and particle
            density ``rho_p``.
        phi (float): Slope angle (radians).

    Returns:
        float: Dimensionless slope factor (phi_s).
    """
    packing_ratio = fuel.rho_b / fuel.rho_p

    phi_s = 5.275 * (packing_ratio ** (-0.3)) * math.tan(phi) ** 2

    return phi_s

calc_vals_for_all_directions(cell, R_h, I_r, alpha, e, I_h=None)

Compute ROS and fireline intensity along all spread directions.

Use the fire ellipse (eccentricity e) and the combined wind/slope heading alpha to resolve the head-fire ROS into each of the cell's spread directions.

Parameters:

Name Type Description Default
cell Cell

Cell providing directions and fuel properties.

required
R_h float

Head-fire rate of spread (ft/min for surface, m/min for crown fire).

required
I_r float

Reaction intensity (BTU/ft²/min). Ignored when I_h is provided.

required
alpha float

Combined wind/slope heading in radians, relative to the cell's aspect (upslope direction).

required
e float

Fire ellipse eccentricity in [0, 1).

required
I_h float

Head-fire fireline intensity (BTU/ft/min). When provided, directional intensities are scaled from this value instead of being computed from I_r.

None

Returns:

Type Description
Tuple[ndarray, ndarray]

Tuple[np.ndarray, np.ndarray]: (r_list, I_list) where r_list is ROS in m/s per direction and I_list is fireline intensity in BTU/ft/min per direction.

Source code in embrs/models/rothermel.py
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
def calc_vals_for_all_directions(cell: Cell, R_h: float, I_r: float, alpha: float,
                                 e: float, I_h: float = None) -> Tuple[np.ndarray, np.ndarray]:
    """Compute ROS and fireline intensity along all spread directions.

    Use the fire ellipse (eccentricity ``e``) and the combined wind/slope
    heading ``alpha`` to resolve the head-fire ROS into each of the cell's
    spread directions.

    Args:
        cell (Cell): Cell providing directions and fuel properties.
        R_h (float): Head-fire rate of spread (ft/min for surface, m/min
            for crown fire).
        I_r (float): Reaction intensity (BTU/ft²/min). Ignored when
            ``I_h`` is provided.
        alpha (float): Combined wind/slope heading in radians, relative to
            the cell's aspect (upslope direction).
        e (float): Fire ellipse eccentricity in [0, 1).
        I_h (float, optional): Head-fire fireline intensity (BTU/ft/min).
            When provided, directional intensities are scaled from this
            value instead of being computed from ``I_r``.

    Returns:
        Tuple[np.ndarray, np.ndarray]: ``(r_list, I_list)`` where
            ``r_list`` is ROS in m/s per direction and ``I_list`` is
            fireline intensity in BTU/ft/min per direction.
    """
    spread_directions = np.deg2rad(cell.directions)

    gamma = np.abs(((alpha + np.deg2rad(cell.aspect)) - spread_directions) % (2*np.pi))
    gamma = np.minimum(gamma, 2*np.pi - gamma)

    R_gamma = R_h * ((1 - e)/(1 - e * np.cos(gamma)))

    if I_h is None:
        t_r = 384 / cell.fuel.sav_ratio
        H_a = I_r * t_r
        I_gamma = H_a * R_gamma # BTU/ft/min

    else:
        I_gamma = I_h * (R_gamma / R_h) # BTU/ft/min

    r_list = ft_min_to_m_s(R_gamma)
    return r_list, I_gamma

calc_wind_factor(fuel, wind_speed)

Compute the wind factor (phi_w) for the Rothermel spread equation.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model with precomputed wind coefficients B, C, E, and packing ratio rat.

required
wind_speed float

Midflame wind speed (ft/min).

required

Returns:

Name Type Description
float float

Dimensionless wind factor (phi_w).

Source code in embrs/models/rothermel.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def calc_wind_factor(fuel: Fuel, wind_speed: float) -> float:
    """Compute the wind factor (phi_w) for the Rothermel spread equation.

    Args:
        fuel (Fuel): Fuel model with precomputed wind coefficients
            ``B``, ``C``, ``E``, and packing ratio ``rat``.
        wind_speed (float): Midflame wind speed (ft/min).

    Returns:
        float: Dimensionless wind factor (phi_w).
    """
    phi_w = fuel.C * (wind_speed ** fuel.B) * fuel.rat ** (-fuel.E)

    return phi_w

calc_wind_slope_vec(R_0, phi_w, phi_s, angle)

Compute the combined wind and slope vector magnitude and direction.

Resolve wind and slope spread factors into a single resultant vector using Rothermel's vector addition method.

Parameters:

Name Type Description Default
R_0 float

No-wind, no-slope ROS (ft/min).

required
phi_w float

Wind factor (dimensionless).

required
phi_s float

Slope factor (dimensionless).

required
angle float

Angle between wind and upslope directions (radians).

required

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: (vec_mag, vec_dir) where vec_mag is the combined wind/slope spread increment (ft/min) and vec_dir is the direction of the resultant vector (radians).

Source code in embrs/models/rothermel.py
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
def calc_wind_slope_vec(R_0: float, phi_w: float, phi_s: float, angle: float) -> Tuple[float, float]:
    """Compute the combined wind and slope vector magnitude and direction.

    Resolve wind and slope spread factors into a single resultant vector
    using Rothermel's vector addition method.

    Args:
        R_0 (float): No-wind, no-slope ROS (ft/min).
        phi_w (float): Wind factor (dimensionless).
        phi_s (float): Slope factor (dimensionless).
        angle (float): Angle between wind and upslope directions (radians).

    Returns:
        Tuple[float, float]: ``(vec_mag, vec_dir)`` where ``vec_mag`` is the
            combined wind/slope spread increment (ft/min) and ``vec_dir`` is
            the direction of the resultant vector (radians).
    """
    d_w = R_0 * phi_w
    d_s = R_0 * phi_s

    x = d_s + d_w * math.cos(angle)
    y = d_w * math.sin(angle)
    vec_mag = math.sqrt(x**2 + y**2)

    if vec_mag == 0:
        vec_dir = 0

    else:
        vec_dir = math.atan2(y, x)

    return vec_mag, vec_dir

ft_min_to_m_s(f_ft_min)

Convert speed from feet per minute to meters per second.

Parameters:

Name Type Description Default
f_ft_min float

Speed in ft/min.

required

Returns:

Name Type Description
float float

Speed in m/s.

Source code in embrs/utilities/unit_conversions.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def ft_min_to_m_s(f_ft_min: float) -> float:
    """Convert speed from feet per minute to meters per second.

    Args:
        f_ft_min (float): Speed in ft/min.

    Returns:
        float: Speed in m/s.
    """
    return f_ft_min * _FT_MIN_TO_M_S

ft_min_to_mph(f_ft_min)

Convert speed from feet per minute to miles per hour.

Parameters:

Name Type Description Default
f_ft_min float

Speed in ft/min.

required

Returns:

Name Type Description
float float

Speed in mph.

Source code in embrs/utilities/unit_conversions.py
116
117
118
119
120
121
122
123
124
125
def ft_min_to_mph(f_ft_min: float) -> float:
    """Convert speed from feet per minute to miles per hour.

    Args:
        f_ft_min (float): Speed in ft/min.

    Returns:
        float: Speed in mph.
    """
    return f_ft_min * _FT_MIN_TO_MPH

ft_to_m(f_ft)

Convert length from feet to meters.

Parameters:

Name Type Description Default
f_ft float

Length in feet.

required

Returns:

Name Type Description
float float

Length in meters.

Source code in embrs/utilities/unit_conversions.py
76
77
78
79
80
81
82
83
84
85
def ft_to_m(f_ft: float) -> float:
    """Convert length from feet to meters.

    Args:
        f_ft (float): Length in feet.

    Returns:
        float: Length in meters.
    """
    return f_ft * _FT_TO_M

get_characteristic_moistures(fuel, m_f)

Compute weighted characteristic dead and live fuel moisture contents.

Use fuel weighting factors (f_dead_arr, f_live_arr) to collapse the per-class moisture array into single dead and live values.

Parameters:

Name Type Description Default
fuel Fuel

Fuel model providing weighting arrays.

required
m_f ndarray

Moisture content array of shape (6,) as fractions.

required

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: (dead_mf, live_mf) weighted characteristic moisture contents for dead and live fuel categories.

Source code in embrs/models/rothermel.py
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
def get_characteristic_moistures(fuel: Fuel, m_f: np.ndarray) -> Tuple[float, float]:
    """Compute weighted characteristic dead and live fuel moisture contents.

    Use fuel weighting factors (``f_dead_arr``, ``f_live_arr``) to collapse
    the per-class moisture array into single dead and live values.

    Args:
        fuel (Fuel): Fuel model providing weighting arrays.
        m_f (np.ndarray): Moisture content array of shape (6,) as fractions.

    Returns:
        Tuple[float, float]: ``(dead_mf, live_mf)`` weighted characteristic
            moisture contents for dead and live fuel categories.
    """
    dead_mf = np.dot(fuel.f_dead_arr, m_f[0:4])
    live_mf = np.dot(fuel.f_live_arr, m_f[4:])

    return dead_mf, live_mf

m_s_to_ft_min(m_s)

Convert speed from meters per second to feet per minute.

Parameters:

Name Type Description Default
m_s float

Speed in m/s.

required

Returns:

Name Type Description
float float

Speed in ft/min.

Source code in embrs/utilities/unit_conversions.py
104
105
106
107
108
109
110
111
112
113
def m_s_to_ft_min(m_s: float) -> float:
    """Convert speed from meters per second to feet per minute.

    Args:
        m_s (float): Speed in m/s.

    Returns:
        float: Speed in ft/min.
    """
    return m_s * _M_S_TO_FT_MIN

m_to_ft(f_m)

Convert length from meters to feet.

Parameters:

Name Type Description Default
f_m float

Length in meters.

required

Returns:

Name Type Description
float float

Length in feet.

Source code in embrs/utilities/unit_conversions.py
64
65
66
67
68
69
70
71
72
73
def m_to_ft(f_m: float) -> float:
    """Convert length from meters to feet.

    Args:
        f_m (float): Length in meters.

    Returns:
        float: Length in feet.
    """
    return f_m * _M_TO_FT

mph_to_ft_min(f_mph)

Convert speed from miles per hour to feet per minute.

Parameters:

Name Type Description Default
f_mph float

Speed in mph.

required

Returns:

Name Type Description
float float

Speed in ft/min.

Source code in embrs/utilities/unit_conversions.py
128
129
130
131
132
133
134
135
136
137
def mph_to_ft_min(f_mph: float) -> float:
    """Convert speed from miles per hour to feet per minute.

    Args:
        f_mph (float): Speed in mph.

    Returns:
        float: Speed in ft/min.
    """
    return f_mph * _MPH_TO_FT_MIN

surface_fire(cell)

Compute steady-state surface fire ROS and fireline intensity for a cell.

Calculate the head-fire rate of spread (R_h), then resolve spread rates and fireline intensities along all 12 spread directions using fire ellipse geometry. Results are stored directly on the cell object.

Parameters:

Name Type Description Default
cell Cell

Cell to evaluate. Must have fuel, moisture, wind, slope, and direction attributes populated.

required
Side Effects

Sets cell.r_ss (m/s), cell.I_ss (BTU/ft/min), cell.r_h_ss (m/s), cell.reaction_intensity (BTU/ft²/min), cell.alpha (radians), and cell.e (eccentricity) on the cell.

Source code in embrs/models/rothermel.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
def surface_fire(cell: Cell):
    """Compute steady-state surface fire ROS and fireline intensity for a cell.

    Calculate the head-fire rate of spread (R_h), then resolve spread rates and
    fireline intensities along all 12 spread directions using fire ellipse
    geometry. Results are stored directly on the cell object.

    Args:
        cell (Cell): Cell to evaluate. Must have fuel, moisture, wind, slope,
            and direction attributes populated.

    Side Effects:
        Sets ``cell.r_ss`` (m/s), ``cell.I_ss`` (BTU/ft/min),
        ``cell.r_h_ss`` (m/s), ``cell.reaction_intensity`` (BTU/ft²/min),
        ``cell.alpha`` (radians), and ``cell.e`` (eccentricity) on the cell.
    """
    R_h, R_0, I_r, alpha = calc_r_h(cell)

    cell.alpha = alpha
    spread_directions = np.deg2rad(cell.directions)

    if R_h < R_0 or R_0 == 0:
        cell.r_ss = np.zeros_like(spread_directions)
        cell.I_ss = np.zeros_like(spread_directions)
        cell.r_h_ss = 0.0
        cell.reaction_intensity = 0
        cell.e = 0
        return

    cell.reaction_intensity = I_r

    e = calc_eccentricity(cell.fuel, R_h, R_0)
    cell.e = e

    r_list, I_list = calc_vals_for_all_directions(cell, R_h, I_r, alpha, e)

    # r in m/s, I in btu/ft/min
    cell.r_ss = r_list
    cell.I_ss = I_list
    cell.r_h_ss = np.max(r_list)

Grid management for hexagonal fire simulation.

This module provides the GridManager class which handles all grid-related operations for the fire simulation, including cell storage, coordinate conversion, neighbor calculations, and geometry operations.

Classes:

Name Description
- GridManager

Manages the hexagonal cell grid for fire simulation.

GridManager

Manages the hexagonal cell grid for fire simulation.

Handles grid initialization, cell storage, coordinate conversion between Cartesian and grid indices, neighbor calculations, and geometry-based cell lookups.

Attributes:

Name Type Description
cell_grid ndarray

2D array of Cell objects.

cell_dict Dict[int, Cell]

Dictionary mapping cell IDs to Cell objects.

shape Tuple[int, int]

Grid dimensions (num_rows, num_cols).

cell_size float

Edge length of hexagonal cells in meters.

Source code in embrs/base_classes/grid_manager.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
class GridManager:
    """Manages the hexagonal cell grid for fire simulation.

    Handles grid initialization, cell storage, coordinate conversion between
    Cartesian and grid indices, neighbor calculations, and geometry-based
    cell lookups.

    Attributes:
        cell_grid (np.ndarray): 2D array of Cell objects.
        cell_dict (Dict[int, Cell]): Dictionary mapping cell IDs to Cell objects.
        shape (Tuple[int, int]): Grid dimensions (num_rows, num_cols).
        cell_size (float): Edge length of hexagonal cells in meters.
    """

    def __init__(self,
                 num_rows: int,
                 num_cols: int,
                 cell_size: float):
        """Initialize the grid manager.

        Creates the backing array for the hexagonal cell grid but does not
        populate cells. Use init_grid() to populate with Cell objects.

        Args:
            num_rows: Number of rows in the grid.
            num_cols: Number of columns in the grid.
            cell_size: Edge length of hexagonal cells in meters.
        """
        self._num_rows = num_rows
        self._num_cols = num_cols
        self._cell_size = cell_size

        self._shape = (num_rows, num_cols)
        self._cell_grid = np.empty(self._shape, dtype=object)
        self._grid_width = num_cols - 1
        self._grid_height = num_rows - 1

        self._cell_dict: Dict[int, 'Cell'] = {}

        # Reference to logger for error messages (set by parent)
        self.logger = None

        # Spatial index (built lazily or after init_grid)
        self._strtree = None
        self._strtree_cells = None

    @property
    def cell_grid(self) -> np.ndarray:
        """2D array of Cell objects."""
        return self._cell_grid

    @property
    def cell_dict(self) -> Dict[int, 'Cell']:
        """Dictionary mapping cell IDs to Cell objects."""
        return self._cell_dict

    @property
    def shape(self) -> Tuple[int, int]:
        """Grid dimensions (num_rows, num_cols)."""
        return self._shape

    @property
    def cell_size(self) -> float:
        """Edge length of hexagonal cells in meters."""
        return self._cell_size

    @property
    def num_rows(self) -> int:
        """Number of rows in the grid."""
        return self._num_rows

    @property
    def num_cols(self) -> int:
        """Number of columns in the grid."""
        return self._num_cols

    def set_cell(self, row: int, col: int, cell: 'Cell') -> None:
        """Place a cell in the grid at the specified position.

        Args:
            row: Row index.
            col: Column index.
            cell: Cell object to place.
        """
        self._cell_grid[row, col] = cell
        self._cell_dict[cell.id] = cell

    def init_grid(self,
                  cell_factory: Callable[[int, int, int], 'Cell'],
                  progress_callback: Optional[Callable[[int], None]] = None) -> None:
        """Initialize the grid by creating cells using the provided factory.

        Iterates through all grid positions and calls the cell factory to
        create each cell. The factory is responsible for creating fully
        initialized Cell objects with terrain data, fuel types, etc.

        Args:
            cell_factory: Callable that takes (cell_id, col, row) and returns
                a fully initialized Cell object. The factory should handle:
                - Creating the Cell with correct position
                - Setting terrain data (elevation, slope, aspect, etc.)
                - Setting fuel type and moisture values
                - Setting wind forecast data
                - Any other cell initialization
            progress_callback: Optional callable that takes the number of cells
                processed (1 per call) for progress tracking. Can be used with
                tqdm or other progress indicators.

        Example:
            def my_cell_factory(cell_id, col, row):
                cell = Cell(cell_id, col, row, cell_size)
                cell.set_parent(sim)
                # ... initialize cell data ...
                return cell

            grid_manager.init_grid(my_cell_factory, pbar.update)
        """
        cell_id = 0
        for col in range(self._num_cols):
            for row in range(self._num_rows):
                cell = cell_factory(cell_id, col, row)
                self.set_cell(row, col, cell)
                cell_id += 1

                if progress_callback is not None:
                    progress_callback(1)

    def _build_spatial_index(self) -> None:
        """Build R-tree spatial index over all cell polygons.

        Creates a Shapely STRtree from all cell polygons for efficient
        spatial queries. Must be called after all cells have been created
        and have valid polygon attributes.

        Side Effects:
            Sets self._strtree and self._strtree_cells.
        """
        cells = []
        polygons = []
        for row in range(self._shape[0]):
            for col in range(self._shape[1]):
                cell = self._cell_grid[row, col]
                cells.append(cell)
                polygons.append(cell.polygon)
        self._strtree_cells = cells
        self._strtree = STRtree(polygons)

    def hex_round(self, q: float, r: float) -> Tuple[int, int]:
        """Round floating point hex coordinates to their nearest integer hex coordinates.

        Uses cube coordinate rounding to find the nearest valid hexagonal cell.
        The algorithm ensures the cube coordinate constraint (q + r + s = 0)
        is maintained.

        Args:
            q: q coordinate in hex coordinate system.
            r: r coordinate in hex coordinate system.

        Returns:
            Tuple of (q, r) integer coordinates of the nearest hex cell.
        """
        s = -q - r
        q_r = round(q)
        r_r = round(r)
        s_r = round(s)
        q_diff = abs(q_r - q)
        r_diff = abs(r_r - r)
        s_diff = abs(s_r - s)

        if q_diff > r_diff and q_diff > s_diff:
            q_r = -r_r - s_r
        elif r_diff > s_diff:
            r_r = -q_r - s_r
        else:
            s_r = -q_r - r_r

        return (int(q_r), int(r_r))

    def get_cell_from_xy(self, x_m: float, y_m: float, oob_ok: bool = False) -> Optional['Cell']:
        """Return the cell containing the point (x_m, y_m) in Cartesian coordinates.

        Converts Cartesian coordinates to hexagonal grid indices and returns
        the cell at that position.

        Args:
            x_m: x position in meters. (0,0) is lower-left corner.
            y_m: y position in meters. y increases upward.
            oob_ok: If True, return None for out-of-bounds coordinates.
                   If False, raise ValueError.

        Returns:
            Cell at the requested point, or None if out of bounds and oob_ok=True.

        Raises:
            ValueError: If coordinates are out of bounds and oob_ok=False.
        """
        try:
            if x_m < 0 or y_m < 0:
                if not oob_ok:
                    raise IndexError("x and y coordinates must be positive")
                else:
                    return None

            q = (np.sqrt(3)/3 * x_m - 1/3 * y_m) / self._cell_size
            r = (2/3 * y_m) / self._cell_size

            q, r = self.hex_round(q, r)

            row = r
            col = q + row//2

            estimated_cell = self._cell_grid[row, col]
            return estimated_cell

        except IndexError:
            if not oob_ok:
                msg = f'Point ({x_m}, {y_m}) is outside the grid.'
                if self.logger:
                    self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_xy()': {msg}")
                raise ValueError(msg)

            return None

    def get_cell_from_indices(self, row: int, col: int) -> 'Cell':
        """Return the cell at grid indices [row, col].

        Columns increase left to right, rows increase bottom to top.

        Args:
            row: Row index of the desired cell.
            col: Column index of the desired cell.

        Returns:
            Cell at the specified indices.

        Raises:
            TypeError: If row or col is not an integer.
            ValueError: If row or col is out of bounds.
        """
        if not isinstance(row, int) or not isinstance(col, int):
            msg = (f"Row and column must be integer index values. "
                f"Input was {type(row)}, {type(col)}")

            if self.logger:
                self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_indices(): "
                                        f"{msg} Program terminated.")
            raise TypeError(msg)

        if col < 0 or row < 0 or row >= self._grid_height or col >= self._grid_width:
            msg = (f"Out of bounds error. {row}, {col} "
                f"are out of bounds for grid of size "
                f"{self._grid_height}, {self._grid_width}")

            if self.logger:
                self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_indices(): "
                                        f"{msg} Program terminated.")
            raise ValueError(msg)

        return self._cell_grid[row, col]

    def get_cells_at_geometry(self, geom: Union[Polygon, LineString, Point]) -> List['Cell']:
        """Get all cells that intersect with the given geometry.

        Supports Point, LineString, and Polygon geometries from Shapely.

        Args:
            geom: Shapely geometry to check for cell intersections.

        Returns:
            List of Cell objects that intersect with the geometry.

        Raises:
            ValueError: If geometry type is not supported.
        """
        cells = set()

        if isinstance(geom, Polygon):
            # Lazily build spatial index if not yet constructed
            if self._strtree is None:
                self._build_spatial_index()

            # STRtree query with predicate='intersects' returns cells that
            # overlap the geometry (not just bounding-box hits)
            indices = self._strtree.query(geom, predicate='intersects')
            for i in indices:
                cells.add(self._strtree_cells[i])

        elif isinstance(geom, LineString):
            length = geom.length
            step_size = self._cell_size / 4.0
            num_steps = int(length/step_size) + 1

            for i in range(num_steps):
                point = geom.interpolate(i * step_size)
                cell = self.get_cell_from_xy(point.x, point.y, oob_ok=True)
                if cell is not None:
                    cells.add(cell)

        elif isinstance(geom, Point):
            x, y = geom.x, geom.y
            cell = self.get_cell_from_xy(x, y, oob_ok=True)
            if cell is not None:
                cells.add(cell)

        else:
            raise ValueError(f"Unknown geometry type: {type(geom)}")

        return list(cells)

    def add_cell_neighbors(self) -> None:
        """Populate neighbor references for all cells in the grid.

        For each cell, determines its neighbors based on hexagonal grid
        geometry (even/odd row offset pattern) and stores neighbor IDs
        with their relative positions.
        """
        for j in range(self._shape[1]):
            for i in range(self._shape[0]):
                cell = self._cell_grid[i][j]

                neighbors = {}
                if cell.row % 2 == 0:
                    neighborhood = HexGridMath.even_neighborhood
                else:
                    neighborhood = HexGridMath.odd_neighborhood

                for dx, dy in neighborhood:
                    row_n = int(cell.row + dy)
                    col_n = int(cell.col + dx)

                    if self._grid_height >= row_n >= 0 and self._grid_width >= col_n >= 0:
                        neighbor_id = self._cell_grid[row_n, col_n].id
                        neighbors[neighbor_id] = (dx, dy)

                cell._neighbors = neighbors
                cell._burnable_neighbors = dict(neighbors)

    def get_cells_in_radius(self, center_x: float, center_y: float,
                            radius: float) -> List['Cell']:
        """Get all cells within a given radius of a center point.

        Args:
            center_x: x coordinate of center point in meters.
            center_y: y coordinate of center point in meters.
            radius: Radius in meters.

        Returns:
            List of Cell objects within the specified radius.
        """
        cells = []

        # Calculate bounding box in grid coordinates
        min_row = max(0, int((center_y - radius) // (self._cell_size * 1.5)))
        max_row = min(self._shape[0] - 1, int((center_y + radius) // (self._cell_size * 1.5)) + 1)
        min_col = max(0, int((center_x - radius) // (self._cell_size * np.sqrt(3))))
        max_col = min(self._shape[1] - 1, int((center_x + radius) // (self._cell_size * np.sqrt(3))) + 1)

        radius_sq = radius * radius

        for row in range(min_row, max_row + 1):
            for col in range(min_col, max_col + 1):
                cell = self._cell_grid[row, col]
                dx = cell.x_pos - center_x
                dy = cell.y_pos - center_y

                if dx*dx + dy*dy <= radius_sq:
                    cells.append(cell)

        return cells

    def compute_all_cell_positions(self) -> Tuple[np.ndarray, np.ndarray]:
        """Pre-compute world coordinates for all cell centers.

        Uses vectorized operations to compute x,y positions for all cells
        in the grid based on hexagonal geometry.

        The formula matches Cell.__init__:
        - Even row: x = col * cell_size * sqrt(3)
        - Odd row: x = (col + 0.5) * cell_size * sqrt(3)
        - y = row * cell_size * 1.5

        Returns:
            Tuple of (all_x, all_y) where each is a 2D numpy array with
            shape (num_rows, num_cols) containing the cell center coordinates.
        """
        # Create meshgrid of row and column indices
        rows, cols = np.meshgrid(
            np.arange(self._num_rows),
            np.arange(self._num_cols),
            indexing='ij'
        )

        # Compute cell centers using hexagonal grid geometry
        # Matching Cell.__init__ formula exactly
        hex_width = np.sqrt(3) * self._cell_size

        # x position: col * hex_width for even rows, (col + 0.5) * hex_width for odd rows
        all_x = (cols + 0.5 * (rows % 2)) * hex_width

        # y position: row * cell_size * 1.5
        all_y = rows * self._cell_size * 1.5

        return all_x, all_y

    def compute_data_indices(self, all_x: np.ndarray, all_y: np.ndarray,
                             data_res: float, data_rows: int, data_cols: int
                             ) -> Tuple[np.ndarray, np.ndarray]:
        """Convert cell positions to terrain data array indices.

        Vectorized computation of which terrain data pixels correspond to
        each cell center.

        Args:
            all_x: 2D array of cell x coordinates.
            all_y: 2D array of cell y coordinates.
            data_res: Resolution of terrain data in meters per pixel.
            data_rows: Number of rows in terrain data arrays.
            data_cols: Number of columns in terrain data arrays.

        Returns:
            Tuple of (data_row_indices, data_col_indices) as 2D integer arrays.
        """
        # Convert world coordinates to data array indices
        data_col_indices = np.floor(all_x / data_res).astype(np.int32)
        data_row_indices = np.floor(all_y / data_res).astype(np.int32)

        # Clip to valid range
        np.clip(data_col_indices, 0, data_cols - 1, out=data_col_indices)
        np.clip(data_row_indices, 0, data_rows - 1, out=data_row_indices)

        return data_row_indices, data_col_indices

cell_dict property

Dictionary mapping cell IDs to Cell objects.

cell_grid property

2D array of Cell objects.

cell_size property

Edge length of hexagonal cells in meters.

num_cols property

Number of columns in the grid.

num_rows property

Number of rows in the grid.

shape property

Grid dimensions (num_rows, num_cols).

__init__(num_rows, num_cols, cell_size)

Initialize the grid manager.

Creates the backing array for the hexagonal cell grid but does not populate cells. Use init_grid() to populate with Cell objects.

Parameters:

Name Type Description Default
num_rows int

Number of rows in the grid.

required
num_cols int

Number of columns in the grid.

required
cell_size float

Edge length of hexagonal cells in meters.

required
Source code in embrs/base_classes/grid_manager.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def __init__(self,
             num_rows: int,
             num_cols: int,
             cell_size: float):
    """Initialize the grid manager.

    Creates the backing array for the hexagonal cell grid but does not
    populate cells. Use init_grid() to populate with Cell objects.

    Args:
        num_rows: Number of rows in the grid.
        num_cols: Number of columns in the grid.
        cell_size: Edge length of hexagonal cells in meters.
    """
    self._num_rows = num_rows
    self._num_cols = num_cols
    self._cell_size = cell_size

    self._shape = (num_rows, num_cols)
    self._cell_grid = np.empty(self._shape, dtype=object)
    self._grid_width = num_cols - 1
    self._grid_height = num_rows - 1

    self._cell_dict: Dict[int, 'Cell'] = {}

    # Reference to logger for error messages (set by parent)
    self.logger = None

    # Spatial index (built lazily or after init_grid)
    self._strtree = None
    self._strtree_cells = None

add_cell_neighbors()

Populate neighbor references for all cells in the grid.

For each cell, determines its neighbors based on hexagonal grid geometry (even/odd row offset pattern) and stores neighbor IDs with their relative positions.

Source code in embrs/base_classes/grid_manager.py
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
def add_cell_neighbors(self) -> None:
    """Populate neighbor references for all cells in the grid.

    For each cell, determines its neighbors based on hexagonal grid
    geometry (even/odd row offset pattern) and stores neighbor IDs
    with their relative positions.
    """
    for j in range(self._shape[1]):
        for i in range(self._shape[0]):
            cell = self._cell_grid[i][j]

            neighbors = {}
            if cell.row % 2 == 0:
                neighborhood = HexGridMath.even_neighborhood
            else:
                neighborhood = HexGridMath.odd_neighborhood

            for dx, dy in neighborhood:
                row_n = int(cell.row + dy)
                col_n = int(cell.col + dx)

                if self._grid_height >= row_n >= 0 and self._grid_width >= col_n >= 0:
                    neighbor_id = self._cell_grid[row_n, col_n].id
                    neighbors[neighbor_id] = (dx, dy)

            cell._neighbors = neighbors
            cell._burnable_neighbors = dict(neighbors)

compute_all_cell_positions()

Pre-compute world coordinates for all cell centers.

Uses vectorized operations to compute x,y positions for all cells in the grid based on hexagonal geometry.

The formula matches Cell.init: - Even row: x = col * cell_size * sqrt(3) - Odd row: x = (col + 0.5) * cell_size * sqrt(3) - y = row * cell_size * 1.5

Returns:

Type Description
ndarray

Tuple of (all_x, all_y) where each is a 2D numpy array with

ndarray

shape (num_rows, num_cols) containing the cell center coordinates.

Source code in embrs/base_classes/grid_manager.py
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
def compute_all_cell_positions(self) -> Tuple[np.ndarray, np.ndarray]:
    """Pre-compute world coordinates for all cell centers.

    Uses vectorized operations to compute x,y positions for all cells
    in the grid based on hexagonal geometry.

    The formula matches Cell.__init__:
    - Even row: x = col * cell_size * sqrt(3)
    - Odd row: x = (col + 0.5) * cell_size * sqrt(3)
    - y = row * cell_size * 1.5

    Returns:
        Tuple of (all_x, all_y) where each is a 2D numpy array with
        shape (num_rows, num_cols) containing the cell center coordinates.
    """
    # Create meshgrid of row and column indices
    rows, cols = np.meshgrid(
        np.arange(self._num_rows),
        np.arange(self._num_cols),
        indexing='ij'
    )

    # Compute cell centers using hexagonal grid geometry
    # Matching Cell.__init__ formula exactly
    hex_width = np.sqrt(3) * self._cell_size

    # x position: col * hex_width for even rows, (col + 0.5) * hex_width for odd rows
    all_x = (cols + 0.5 * (rows % 2)) * hex_width

    # y position: row * cell_size * 1.5
    all_y = rows * self._cell_size * 1.5

    return all_x, all_y

compute_data_indices(all_x, all_y, data_res, data_rows, data_cols)

Convert cell positions to terrain data array indices.

Vectorized computation of which terrain data pixels correspond to each cell center.

Parameters:

Name Type Description Default
all_x ndarray

2D array of cell x coordinates.

required
all_y ndarray

2D array of cell y coordinates.

required
data_res float

Resolution of terrain data in meters per pixel.

required
data_rows int

Number of rows in terrain data arrays.

required
data_cols int

Number of columns in terrain data arrays.

required

Returns:

Type Description
Tuple[ndarray, ndarray]

Tuple of (data_row_indices, data_col_indices) as 2D integer arrays.

Source code in embrs/base_classes/grid_manager.py
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
def compute_data_indices(self, all_x: np.ndarray, all_y: np.ndarray,
                         data_res: float, data_rows: int, data_cols: int
                         ) -> Tuple[np.ndarray, np.ndarray]:
    """Convert cell positions to terrain data array indices.

    Vectorized computation of which terrain data pixels correspond to
    each cell center.

    Args:
        all_x: 2D array of cell x coordinates.
        all_y: 2D array of cell y coordinates.
        data_res: Resolution of terrain data in meters per pixel.
        data_rows: Number of rows in terrain data arrays.
        data_cols: Number of columns in terrain data arrays.

    Returns:
        Tuple of (data_row_indices, data_col_indices) as 2D integer arrays.
    """
    # Convert world coordinates to data array indices
    data_col_indices = np.floor(all_x / data_res).astype(np.int32)
    data_row_indices = np.floor(all_y / data_res).astype(np.int32)

    # Clip to valid range
    np.clip(data_col_indices, 0, data_cols - 1, out=data_col_indices)
    np.clip(data_row_indices, 0, data_rows - 1, out=data_row_indices)

    return data_row_indices, data_col_indices

get_cell_from_indices(row, col)

Return the cell at grid indices [row, col].

Columns increase left to right, rows increase bottom to top.

Parameters:

Name Type Description Default
row int

Row index of the desired cell.

required
col int

Column index of the desired cell.

required

Returns:

Type Description
Cell

Cell at the specified indices.

Raises:

Type Description
TypeError

If row or col is not an integer.

ValueError

If row or col is out of bounds.

Source code in embrs/base_classes/grid_manager.py
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
def get_cell_from_indices(self, row: int, col: int) -> 'Cell':
    """Return the cell at grid indices [row, col].

    Columns increase left to right, rows increase bottom to top.

    Args:
        row: Row index of the desired cell.
        col: Column index of the desired cell.

    Returns:
        Cell at the specified indices.

    Raises:
        TypeError: If row or col is not an integer.
        ValueError: If row or col is out of bounds.
    """
    if not isinstance(row, int) or not isinstance(col, int):
        msg = (f"Row and column must be integer index values. "
            f"Input was {type(row)}, {type(col)}")

        if self.logger:
            self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_indices(): "
                                    f"{msg} Program terminated.")
        raise TypeError(msg)

    if col < 0 or row < 0 or row >= self._grid_height or col >= self._grid_width:
        msg = (f"Out of bounds error. {row}, {col} "
            f"are out of bounds for grid of size "
            f"{self._grid_height}, {self._grid_width}")

        if self.logger:
            self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_indices(): "
                                    f"{msg} Program terminated.")
        raise ValueError(msg)

    return self._cell_grid[row, col]

get_cell_from_xy(x_m, y_m, oob_ok=False)

Return the cell containing the point (x_m, y_m) in Cartesian coordinates.

Converts Cartesian coordinates to hexagonal grid indices and returns the cell at that position.

Parameters:

Name Type Description Default
x_m float

x position in meters. (0,0) is lower-left corner.

required
y_m float

y position in meters. y increases upward.

required
oob_ok bool

If True, return None for out-of-bounds coordinates. If False, raise ValueError.

False

Returns:

Type Description
Optional[Cell]

Cell at the requested point, or None if out of bounds and oob_ok=True.

Raises:

Type Description
ValueError

If coordinates are out of bounds and oob_ok=False.

Source code in embrs/base_classes/grid_manager.py
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
def get_cell_from_xy(self, x_m: float, y_m: float, oob_ok: bool = False) -> Optional['Cell']:
    """Return the cell containing the point (x_m, y_m) in Cartesian coordinates.

    Converts Cartesian coordinates to hexagonal grid indices and returns
    the cell at that position.

    Args:
        x_m: x position in meters. (0,0) is lower-left corner.
        y_m: y position in meters. y increases upward.
        oob_ok: If True, return None for out-of-bounds coordinates.
               If False, raise ValueError.

    Returns:
        Cell at the requested point, or None if out of bounds and oob_ok=True.

    Raises:
        ValueError: If coordinates are out of bounds and oob_ok=False.
    """
    try:
        if x_m < 0 or y_m < 0:
            if not oob_ok:
                raise IndexError("x and y coordinates must be positive")
            else:
                return None

        q = (np.sqrt(3)/3 * x_m - 1/3 * y_m) / self._cell_size
        r = (2/3 * y_m) / self._cell_size

        q, r = self.hex_round(q, r)

        row = r
        col = q + row//2

        estimated_cell = self._cell_grid[row, col]
        return estimated_cell

    except IndexError:
        if not oob_ok:
            msg = f'Point ({x_m}, {y_m}) is outside the grid.'
            if self.logger:
                self.logger.log_message(f"Following error occurred in 'GridManager.get_cell_from_xy()': {msg}")
            raise ValueError(msg)

        return None

get_cells_at_geometry(geom)

Get all cells that intersect with the given geometry.

Supports Point, LineString, and Polygon geometries from Shapely.

Parameters:

Name Type Description Default
geom Union[Polygon, LineString, Point]

Shapely geometry to check for cell intersections.

required

Returns:

Type Description
List[Cell]

List of Cell objects that intersect with the geometry.

Raises:

Type Description
ValueError

If geometry type is not supported.

Source code in embrs/base_classes/grid_manager.py
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
def get_cells_at_geometry(self, geom: Union[Polygon, LineString, Point]) -> List['Cell']:
    """Get all cells that intersect with the given geometry.

    Supports Point, LineString, and Polygon geometries from Shapely.

    Args:
        geom: Shapely geometry to check for cell intersections.

    Returns:
        List of Cell objects that intersect with the geometry.

    Raises:
        ValueError: If geometry type is not supported.
    """
    cells = set()

    if isinstance(geom, Polygon):
        # Lazily build spatial index if not yet constructed
        if self._strtree is None:
            self._build_spatial_index()

        # STRtree query with predicate='intersects' returns cells that
        # overlap the geometry (not just bounding-box hits)
        indices = self._strtree.query(geom, predicate='intersects')
        for i in indices:
            cells.add(self._strtree_cells[i])

    elif isinstance(geom, LineString):
        length = geom.length
        step_size = self._cell_size / 4.0
        num_steps = int(length/step_size) + 1

        for i in range(num_steps):
            point = geom.interpolate(i * step_size)
            cell = self.get_cell_from_xy(point.x, point.y, oob_ok=True)
            if cell is not None:
                cells.add(cell)

    elif isinstance(geom, Point):
        x, y = geom.x, geom.y
        cell = self.get_cell_from_xy(x, y, oob_ok=True)
        if cell is not None:
            cells.add(cell)

    else:
        raise ValueError(f"Unknown geometry type: {type(geom)}")

    return list(cells)

get_cells_in_radius(center_x, center_y, radius)

Get all cells within a given radius of a center point.

Parameters:

Name Type Description Default
center_x float

x coordinate of center point in meters.

required
center_y float

y coordinate of center point in meters.

required
radius float

Radius in meters.

required

Returns:

Type Description
List[Cell]

List of Cell objects within the specified radius.

Source code in embrs/base_classes/grid_manager.py
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
def get_cells_in_radius(self, center_x: float, center_y: float,
                        radius: float) -> List['Cell']:
    """Get all cells within a given radius of a center point.

    Args:
        center_x: x coordinate of center point in meters.
        center_y: y coordinate of center point in meters.
        radius: Radius in meters.

    Returns:
        List of Cell objects within the specified radius.
    """
    cells = []

    # Calculate bounding box in grid coordinates
    min_row = max(0, int((center_y - radius) // (self._cell_size * 1.5)))
    max_row = min(self._shape[0] - 1, int((center_y + radius) // (self._cell_size * 1.5)) + 1)
    min_col = max(0, int((center_x - radius) // (self._cell_size * np.sqrt(3))))
    max_col = min(self._shape[1] - 1, int((center_x + radius) // (self._cell_size * np.sqrt(3))) + 1)

    radius_sq = radius * radius

    for row in range(min_row, max_row + 1):
        for col in range(min_col, max_col + 1):
            cell = self._cell_grid[row, col]
            dx = cell.x_pos - center_x
            dy = cell.y_pos - center_y

            if dx*dx + dy*dy <= radius_sq:
                cells.append(cell)

    return cells

hex_round(q, r)

Round floating point hex coordinates to their nearest integer hex coordinates.

Uses cube coordinate rounding to find the nearest valid hexagonal cell. The algorithm ensures the cube coordinate constraint (q + r + s = 0) is maintained.

Parameters:

Name Type Description Default
q float

q coordinate in hex coordinate system.

required
r float

r coordinate in hex coordinate system.

required

Returns:

Type Description
Tuple[int, int]

Tuple of (q, r) integer coordinates of the nearest hex cell.

Source code in embrs/base_classes/grid_manager.py
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
def hex_round(self, q: float, r: float) -> Tuple[int, int]:
    """Round floating point hex coordinates to their nearest integer hex coordinates.

    Uses cube coordinate rounding to find the nearest valid hexagonal cell.
    The algorithm ensures the cube coordinate constraint (q + r + s = 0)
    is maintained.

    Args:
        q: q coordinate in hex coordinate system.
        r: r coordinate in hex coordinate system.

    Returns:
        Tuple of (q, r) integer coordinates of the nearest hex cell.
    """
    s = -q - r
    q_r = round(q)
    r_r = round(r)
    s_r = round(s)
    q_diff = abs(q_r - q)
    r_diff = abs(r_r - r)
    s_diff = abs(s_r - s)

    if q_diff > r_diff and q_diff > s_diff:
        q_r = -r_r - s_r
    elif r_diff > s_diff:
        r_r = -q_r - s_r
    else:
        s_r = -q_r - r_r

    return (int(q_r), int(r_r))

init_grid(cell_factory, progress_callback=None)

Initialize the grid by creating cells using the provided factory.

Iterates through all grid positions and calls the cell factory to create each cell. The factory is responsible for creating fully initialized Cell objects with terrain data, fuel types, etc.

Parameters:

Name Type Description Default
cell_factory Callable[[int, int, int], Cell]

Callable that takes (cell_id, col, row) and returns a fully initialized Cell object. The factory should handle: - Creating the Cell with correct position - Setting terrain data (elevation, slope, aspect, etc.) - Setting fuel type and moisture values - Setting wind forecast data - Any other cell initialization

required
progress_callback Optional[Callable[[int], None]]

Optional callable that takes the number of cells processed (1 per call) for progress tracking. Can be used with tqdm or other progress indicators.

None
Example

def my_cell_factory(cell_id, col, row): cell = Cell(cell_id, col, row, cell_size) cell.set_parent(sim) # ... initialize cell data ... return cell

grid_manager.init_grid(my_cell_factory, pbar.update)

Source code in embrs/base_classes/grid_manager.py
109
110
111
112
113
114
115
116
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
def init_grid(self,
              cell_factory: Callable[[int, int, int], 'Cell'],
              progress_callback: Optional[Callable[[int], None]] = None) -> None:
    """Initialize the grid by creating cells using the provided factory.

    Iterates through all grid positions and calls the cell factory to
    create each cell. The factory is responsible for creating fully
    initialized Cell objects with terrain data, fuel types, etc.

    Args:
        cell_factory: Callable that takes (cell_id, col, row) and returns
            a fully initialized Cell object. The factory should handle:
            - Creating the Cell with correct position
            - Setting terrain data (elevation, slope, aspect, etc.)
            - Setting fuel type and moisture values
            - Setting wind forecast data
            - Any other cell initialization
        progress_callback: Optional callable that takes the number of cells
            processed (1 per call) for progress tracking. Can be used with
            tqdm or other progress indicators.

    Example:
        def my_cell_factory(cell_id, col, row):
            cell = Cell(cell_id, col, row, cell_size)
            cell.set_parent(sim)
            # ... initialize cell data ...
            return cell

        grid_manager.init_grid(my_cell_factory, pbar.update)
    """
    cell_id = 0
    for col in range(self._num_cols):
        for row in range(self._num_rows):
            cell = cell_factory(cell_id, col, row)
            self.set_cell(row, col, cell)
            cell_id += 1

            if progress_callback is not None:
                progress_callback(1)

set_cell(row, col, cell)

Place a cell in the grid at the specified position.

Parameters:

Name Type Description Default
row int

Row index.

required
col int

Column index.

required
cell Cell

Cell object to place.

required
Source code in embrs/base_classes/grid_manager.py
 98
 99
100
101
102
103
104
105
106
107
def set_cell(self, row: int, col: int, cell: 'Cell') -> None:
    """Place a cell in the grid at the specified position.

    Args:
        row: Row index.
        col: Column index.
        cell: Cell object to place.
    """
    self._cell_grid[row, col] = cell
    self._cell_dict[cell.id] = cell

Weather management for fire simulation.

This module provides the WeatherManager class which handles all weather-related operations for the fire simulation, including weather stream management, wind forecast handling, and weather update logic.

Classes:

Name Description
- WeatherManager

Manages weather data and forecasts for fire simulation.

WeatherManager

Manages weather data and forecasts for fire simulation.

Handles weather stream management, wind forecast data, and weather update timing. Used by BaseFireSim to track and update weather conditions during simulation.

Attributes:

Name Type Description
weather_stream WeatherStream

The weather stream object providing weather data over time.

curr_weather_idx int

Current index in the weather stream.

wind_forecast ndarray

Wind forecast array with shape (time_steps, rows, cols, 2) where last dimension is (speed, direction).

wind_res float

Wind resolution in meters.

Source code in embrs/base_classes/weather_manager.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
class WeatherManager:
    """Manages weather data and forecasts for fire simulation.

    Handles weather stream management, wind forecast data, and weather
    update timing. Used by BaseFireSim to track and update weather conditions
    during simulation.

    Attributes:
        weather_stream (WeatherStream): The weather stream object providing
            weather data over time.
        curr_weather_idx (int): Current index in the weather stream.
        wind_forecast (np.ndarray): Wind forecast array with shape
            (time_steps, rows, cols, 2) where last dimension is (speed, direction).
        wind_res (float): Wind resolution in meters.
    """

    def __init__(self,
                 weather_stream: Optional['WeatherStream'] = None,
                 wind_forecast: Optional[np.ndarray] = None,
                 wind_res: float = 100.0,
                 sim_size: Tuple[float, float] = (0.0, 0.0)):
        """Initialize the weather manager.

        Args:
            weather_stream: WeatherStream object providing weather data.
                Can be None for prediction models that don't use weather stream.
            wind_forecast: Wind forecast array. If None, defaults to zeros.
            wind_res: Wind resolution in meters.
            sim_size: Simulation domain size (width, height) in meters.
        """
        self._weather_stream = weather_stream
        self._wind_res = wind_res
        self._sim_size = sim_size

        # Weather stream index tracking
        if weather_stream is not None:
            self._sim_start_w_idx = weather_stream.sim_start_idx
            self._curr_weather_idx = weather_stream.sim_start_idx
            self._weather_t_step = weather_stream.time_step * 60  # convert minutes to seconds
        else:
            self._sim_start_w_idx = 0
            self._curr_weather_idx = 0
            self._weather_t_step = 3600  # default 1 hour

        # Weather update timing
        self._last_weather_update = 0
        self._weather_changed = True

        # Wind forecast handling
        if wind_forecast is not None:
            self._wind_forecast = wind_forecast
            self._wind_xpad, self._wind_ypad = self.calc_wind_padding(wind_forecast)
        else:
            # Default to zeros for prediction models
            self._wind_forecast = np.zeros((1, 1, 1, 2))
            self._wind_xpad = 0.0
            self._wind_ypad = 0.0

        # Reference to logger for error messages (set by parent)
        self.logger = None

    @property
    def weather_stream(self) -> Optional['WeatherStream']:
        """The weather stream object."""
        return self._weather_stream

    @property
    def curr_weather_idx(self) -> int:
        """Current index in the weather stream."""
        return self._curr_weather_idx

    @curr_weather_idx.setter
    def curr_weather_idx(self, value: int) -> None:
        """Set the current weather index."""
        self._curr_weather_idx = value

    @property
    def sim_start_w_idx(self) -> int:
        """Weather stream index at simulation start."""
        return self._sim_start_w_idx

    @property
    def weather_t_step(self) -> float:
        """Weather time step in seconds."""
        return self._weather_t_step

    @property
    def last_weather_update(self) -> float:
        """Timestamp of last weather update in seconds."""
        return self._last_weather_update

    @last_weather_update.setter
    def last_weather_update(self, value: float) -> None:
        """Set the last weather update timestamp."""
        self._last_weather_update = value

    @property
    def weather_changed(self) -> bool:
        """Whether weather has changed since last check."""
        return self._weather_changed

    @weather_changed.setter
    def weather_changed(self, value: bool) -> None:
        """Set the weather changed flag."""
        self._weather_changed = value

    @property
    def wind_forecast(self) -> np.ndarray:
        """Wind forecast array."""
        return self._wind_forecast

    @wind_forecast.setter
    def wind_forecast(self, value: np.ndarray) -> None:
        """Set the wind forecast array and recalculate padding."""
        self._wind_forecast = value
        if value is not None:
            self._wind_xpad, self._wind_ypad = self.calc_wind_padding(value)

    @property
    def wind_res(self) -> float:
        """Wind resolution in meters."""
        return self._wind_res

    @property
    def wind_xpad(self) -> float:
        """Wind x-axis padding in meters."""
        return self._wind_xpad

    @property
    def wind_ypad(self) -> float:
        """Wind y-axis padding in meters."""
        return self._wind_ypad

    def update_weather(self, curr_time_s: float) -> bool:
        """Updates the current wind conditions based on the forecast.

        This method checks whether the time elapsed since the last wind update
        exceeds the wind forecast time step. If so, it updates the wind index
        and retrieves the next forecasted wind condition. If the forecast has
        no remaining entries, it raises a ValueError.

        Args:
            curr_time_s: Current simulation time in seconds.

        Returns:
            bool: True if the wind conditions were updated, False otherwise.

        Raises:
            ValueError: If the wind forecast runs out of entries.

        Side Effects:
            - Updates _last_weather_update to the current simulation time.
            - Increments _curr_weather_idx to the next wind forecast entry.
            - Resets _curr_weather_idx to 0 if out of bounds and raises an error.
        """
        # Check if a wind forecast time step has elapsed since last update
        weather_changed = curr_time_s - self._last_weather_update >= self._weather_t_step

        if weather_changed:
            # Reset last wind update to current time
            self._last_weather_update = curr_time_s

            # Increment wind index
            self._curr_weather_idx += 1

            # Check for out of bounds index
            if self._weather_stream is not None:
                if self._curr_weather_idx >= len(self._weather_stream.stream):
                    self._curr_weather_idx = 0
                    raise ValueError("Weather forecast has no more entries!")

        self._weather_changed = weather_changed
        return weather_changed

    def calc_wind_padding(self, forecast: np.ndarray) -> Tuple[float, float]:
        """Calculate padding offsets between wind forecast grid and simulation grid.

        The wind forecast grid may not align exactly with the simulation
        boundaries. This calculates the x and y offsets needed to center
        the forecast within the simulation domain.

        Args:
            forecast: Wind forecast array with shape
                (time_steps, rows, cols, 2) where last dimension is (speed, direction).

        Returns:
            Tuple[float, float]: (x_padding, y_padding) in meters.
        """
        forecast_rows = forecast[0, :, :, 0].shape[0]
        forecast_cols = forecast[0, :, :, 1].shape[1]

        forecast_height = forecast_rows * self._wind_res
        forecast_width = forecast_cols * self._wind_res

        xpad = (self._sim_size[0] - forecast_width) / 2
        ypad = (self._sim_size[1] - forecast_height) / 2

        return xpad, ypad

    def get_wind_indices(self, cell_x: float, cell_y: float) -> Tuple[int, int]:
        """Get wind forecast array indices for a cell position.

        Args:
            cell_x: Cell x position in meters.
            cell_y: Cell y position in meters.

        Returns:
            Tuple[int, int]: (wind_row, wind_col) indices into wind forecast array.
        """
        x_wind = max(cell_x - self._wind_xpad, 0)
        y_wind = max(cell_y - self._wind_ypad, 0)

        wind_col = int(np.floor(x_wind / self._wind_res))
        wind_row = int(np.floor(y_wind / self._wind_res))

        # Clamp to forecast bounds
        if wind_row > self._wind_forecast.shape[1] - 1:
            wind_row = self._wind_forecast.shape[1] - 1
        if wind_col > self._wind_forecast.shape[2] - 1:
            wind_col = self._wind_forecast.shape[2] - 1

        return wind_row, wind_col

    def get_cell_wind(self, cell_x: float, cell_y: float) -> Tuple[np.ndarray, np.ndarray]:
        """Get wind speed and direction arrays for a cell position.

        Args:
            cell_x: Cell x position in meters.
            cell_y: Cell y position in meters.

        Returns:
            Tuple[np.ndarray, np.ndarray]: (wind_speed, wind_dir) arrays
                across all forecast time steps.
        """
        wind_row, wind_col = self.get_wind_indices(cell_x, cell_y)

        wind_speed = self._wind_forecast[:, wind_row, wind_col, 0]
        wind_dir = self._wind_forecast[:, wind_row, wind_col, 1]

        return wind_speed, wind_dir

curr_weather_idx property writable

Current index in the weather stream.

last_weather_update property writable

Timestamp of last weather update in seconds.

sim_start_w_idx property

Weather stream index at simulation start.

weather_changed property writable

Whether weather has changed since last check.

weather_stream property

The weather stream object.

weather_t_step property

Weather time step in seconds.

wind_forecast property writable

Wind forecast array.

wind_res property

Wind resolution in meters.

wind_xpad property

Wind x-axis padding in meters.

wind_ypad property

Wind y-axis padding in meters.

__init__(weather_stream=None, wind_forecast=None, wind_res=100.0, sim_size=(0.0, 0.0))

Initialize the weather manager.

Parameters:

Name Type Description Default
weather_stream Optional[WeatherStream]

WeatherStream object providing weather data. Can be None for prediction models that don't use weather stream.

None
wind_forecast Optional[ndarray]

Wind forecast array. If None, defaults to zeros.

None
wind_res float

Wind resolution in meters.

100.0
sim_size Tuple[float, float]

Simulation domain size (width, height) in meters.

(0.0, 0.0)
Source code in embrs/base_classes/weather_manager.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
def __init__(self,
             weather_stream: Optional['WeatherStream'] = None,
             wind_forecast: Optional[np.ndarray] = None,
             wind_res: float = 100.0,
             sim_size: Tuple[float, float] = (0.0, 0.0)):
    """Initialize the weather manager.

    Args:
        weather_stream: WeatherStream object providing weather data.
            Can be None for prediction models that don't use weather stream.
        wind_forecast: Wind forecast array. If None, defaults to zeros.
        wind_res: Wind resolution in meters.
        sim_size: Simulation domain size (width, height) in meters.
    """
    self._weather_stream = weather_stream
    self._wind_res = wind_res
    self._sim_size = sim_size

    # Weather stream index tracking
    if weather_stream is not None:
        self._sim_start_w_idx = weather_stream.sim_start_idx
        self._curr_weather_idx = weather_stream.sim_start_idx
        self._weather_t_step = weather_stream.time_step * 60  # convert minutes to seconds
    else:
        self._sim_start_w_idx = 0
        self._curr_weather_idx = 0
        self._weather_t_step = 3600  # default 1 hour

    # Weather update timing
    self._last_weather_update = 0
    self._weather_changed = True

    # Wind forecast handling
    if wind_forecast is not None:
        self._wind_forecast = wind_forecast
        self._wind_xpad, self._wind_ypad = self.calc_wind_padding(wind_forecast)
    else:
        # Default to zeros for prediction models
        self._wind_forecast = np.zeros((1, 1, 1, 2))
        self._wind_xpad = 0.0
        self._wind_ypad = 0.0

    # Reference to logger for error messages (set by parent)
    self.logger = None

calc_wind_padding(forecast)

Calculate padding offsets between wind forecast grid and simulation grid.

The wind forecast grid may not align exactly with the simulation boundaries. This calculates the x and y offsets needed to center the forecast within the simulation domain.

Parameters:

Name Type Description Default
forecast ndarray

Wind forecast array with shape (time_steps, rows, cols, 2) where last dimension is (speed, direction).

required

Returns:

Type Description
Tuple[float, float]

Tuple[float, float]: (x_padding, y_padding) in meters.

Source code in embrs/base_classes/weather_manager.py
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
def calc_wind_padding(self, forecast: np.ndarray) -> Tuple[float, float]:
    """Calculate padding offsets between wind forecast grid and simulation grid.

    The wind forecast grid may not align exactly with the simulation
    boundaries. This calculates the x and y offsets needed to center
    the forecast within the simulation domain.

    Args:
        forecast: Wind forecast array with shape
            (time_steps, rows, cols, 2) where last dimension is (speed, direction).

    Returns:
        Tuple[float, float]: (x_padding, y_padding) in meters.
    """
    forecast_rows = forecast[0, :, :, 0].shape[0]
    forecast_cols = forecast[0, :, :, 1].shape[1]

    forecast_height = forecast_rows * self._wind_res
    forecast_width = forecast_cols * self._wind_res

    xpad = (self._sim_size[0] - forecast_width) / 2
    ypad = (self._sim_size[1] - forecast_height) / 2

    return xpad, ypad

get_cell_wind(cell_x, cell_y)

Get wind speed and direction arrays for a cell position.

Parameters:

Name Type Description Default
cell_x float

Cell x position in meters.

required
cell_y float

Cell y position in meters.

required

Returns:

Type Description
Tuple[ndarray, ndarray]

Tuple[np.ndarray, np.ndarray]: (wind_speed, wind_dir) arrays across all forecast time steps.

Source code in embrs/base_classes/weather_manager.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
def get_cell_wind(self, cell_x: float, cell_y: float) -> Tuple[np.ndarray, np.ndarray]:
    """Get wind speed and direction arrays for a cell position.

    Args:
        cell_x: Cell x position in meters.
        cell_y: Cell y position in meters.

    Returns:
        Tuple[np.ndarray, np.ndarray]: (wind_speed, wind_dir) arrays
            across all forecast time steps.
    """
    wind_row, wind_col = self.get_wind_indices(cell_x, cell_y)

    wind_speed = self._wind_forecast[:, wind_row, wind_col, 0]
    wind_dir = self._wind_forecast[:, wind_row, wind_col, 1]

    return wind_speed, wind_dir

get_wind_indices(cell_x, cell_y)

Get wind forecast array indices for a cell position.

Parameters:

Name Type Description Default
cell_x float

Cell x position in meters.

required
cell_y float

Cell y position in meters.

required

Returns:

Type Description
Tuple[int, int]

Tuple[int, int]: (wind_row, wind_col) indices into wind forecast array.

Source code in embrs/base_classes/weather_manager.py
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def get_wind_indices(self, cell_x: float, cell_y: float) -> Tuple[int, int]:
    """Get wind forecast array indices for a cell position.

    Args:
        cell_x: Cell x position in meters.
        cell_y: Cell y position in meters.

    Returns:
        Tuple[int, int]: (wind_row, wind_col) indices into wind forecast array.
    """
    x_wind = max(cell_x - self._wind_xpad, 0)
    y_wind = max(cell_y - self._wind_ypad, 0)

    wind_col = int(np.floor(x_wind / self._wind_res))
    wind_row = int(np.floor(y_wind / self._wind_res))

    # Clamp to forecast bounds
    if wind_row > self._wind_forecast.shape[1] - 1:
        wind_row = self._wind_forecast.shape[1] - 1
    if wind_col > self._wind_forecast.shape[2] - 1:
        wind_col = self._wind_forecast.shape[2] - 1

    return wind_row, wind_col

update_weather(curr_time_s)

Updates the current wind conditions based on the forecast.

This method checks whether the time elapsed since the last wind update exceeds the wind forecast time step. If so, it updates the wind index and retrieves the next forecasted wind condition. If the forecast has no remaining entries, it raises a ValueError.

Parameters:

Name Type Description Default
curr_time_s float

Current simulation time in seconds.

required

Returns:

Name Type Description
bool bool

True if the wind conditions were updated, False otherwise.

Raises:

Type Description
ValueError

If the wind forecast runs out of entries.

Side Effects
  • Updates _last_weather_update to the current simulation time.
  • Increments _curr_weather_idx to the next wind forecast entry.
  • Resets _curr_weather_idx to 0 if out of bounds and raises an error.
Source code in embrs/base_classes/weather_manager.py
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
def update_weather(self, curr_time_s: float) -> bool:
    """Updates the current wind conditions based on the forecast.

    This method checks whether the time elapsed since the last wind update
    exceeds the wind forecast time step. If so, it updates the wind index
    and retrieves the next forecasted wind condition. If the forecast has
    no remaining entries, it raises a ValueError.

    Args:
        curr_time_s: Current simulation time in seconds.

    Returns:
        bool: True if the wind conditions were updated, False otherwise.

    Raises:
        ValueError: If the wind forecast runs out of entries.

    Side Effects:
        - Updates _last_weather_update to the current simulation time.
        - Increments _curr_weather_idx to the next wind forecast entry.
        - Resets _curr_weather_idx to 0 if out of bounds and raises an error.
    """
    # Check if a wind forecast time step has elapsed since last update
    weather_changed = curr_time_s - self._last_weather_update >= self._weather_t_step

    if weather_changed:
        # Reset last wind update to current time
        self._last_weather_update = curr_time_s

        # Increment wind index
        self._curr_weather_idx += 1

        # Check for out of bounds index
        if self._weather_stream is not None:
            if self._curr_weather_idx >= len(self._weather_stream.stream):
                self._curr_weather_idx = 0
                raise ValueError("Weather forecast has no more entries!")

    self._weather_changed = weather_changed
    return weather_changed

Control action handling for fire simulation.

This module provides the ControlActionHandler class which manages all fire suppression control actions including retardant application, water drops, and fireline construction.

Classes:

Name Description
- ControlActionHandler

Handles fire suppression control actions.

ControlActionHandler

Handles fire suppression control actions for fire simulation.

Manages retardant application, water drops, and fireline construction. Tracks active suppression effects and handles their updates over time.

Attributes:

Name Type Description
long_term_retardants Set[Cell]

Cells with active long-term retardant.

active_water_drops List[Cell]

Cells with active water drop effects.

active_firelines Dict

Firelines currently under construction.

fire_break_cells List[Cell]

Cells along fire breaks.

fire_breaks List

Completed fire breaks as (line, width, id) tuples.

Source code in embrs/base_classes/control_handler.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
class ControlActionHandler:
    """Handles fire suppression control actions for fire simulation.

    Manages retardant application, water drops, and fireline construction.
    Tracks active suppression effects and handles their updates over time.

    Attributes:
        long_term_retardants (Set[Cell]): Cells with active long-term retardant.
        active_water_drops (List[Cell]): Cells with active water drop effects.
        active_firelines (Dict): Firelines currently under construction.
        fire_break_cells (List[Cell]): Cells along fire breaks.
        fire_breaks (List): Completed fire breaks as (line, width, id) tuples.
    """

    def __init__(self,
                 grid_manager: 'GridManager',
                 cell_size: float,
                 time_step: float,
                 fuel_class_factory: Callable[[int], object]):
        """Initialize the control action handler.

        Args:
            grid_manager: GridManager instance for cell lookups.
            cell_size: Cell size in meters.
            time_step: Simulation time step in seconds.
            fuel_class_factory: Callable that creates a fuel class from a model number.
        """
        self._grid_manager = grid_manager
        self._cell_size = cell_size
        self._time_step = time_step
        self._fuel_class_factory = fuel_class_factory

        # Control action containers
        self._long_term_retardants: Set['Cell'] = set()
        self._active_water_drops: List['Cell'] = []
        self._active_firelines: Dict[str, dict] = {}
        self._fire_break_cells: List['Cell'] = []
        self._fire_breaks: List = []
        self._fire_break_dict: Dict[str, tuple] = {}
        self._new_fire_break_cache: List[dict] = []

        # Reference to track updated cells (set by parent)
        self._updated_cells: Dict[int, 'Cell'] = {}

        # Current time accessor (set by parent)
        self._get_curr_time: Callable[[], float] = lambda: 0

        # Reference to logger and visualizer for cache cleanup
        self.logger = None
        self._visualizer = None

    @property
    def long_term_retardants(self) -> Set['Cell']:
        """Cells with active long-term retardant."""
        return self._long_term_retardants

    @property
    def active_water_drops(self) -> List['Cell']:
        """Cells with active water drop effects."""
        return self._active_water_drops

    @property
    def active_firelines(self) -> Dict[str, dict]:
        """Firelines currently under construction."""
        return self._active_firelines

    @property
    def fire_break_cells(self) -> List['Cell']:
        """Cells along fire breaks."""
        return self._fire_break_cells

    @property
    def fire_breaks(self) -> List:
        """Completed fire breaks as (line, width, id) tuples."""
        return self._fire_breaks

    @property
    def fire_break_dict(self) -> Dict[str, tuple]:
        """Dictionary mapping fire break IDs to (line, width) tuples."""
        return self._fire_break_dict

    @property
    def new_fire_break_cache(self) -> List[dict]:
        """Cache of newly constructed fire breaks for logging/visualization."""
        return self._new_fire_break_cache

    def set_updated_cells_ref(self, updated_cells: Dict[int, 'Cell']) -> None:
        """Set reference to the simulation's updated cells dictionary.

        Args:
            updated_cells: Dictionary to track cells that have been modified.
        """
        self._updated_cells = updated_cells

    def set_time_accessor(self, time_func: Callable[[], float]) -> None:
        """Set the function to get current simulation time.

        Args:
            time_func: Callable that returns current time in seconds.
        """
        self._get_curr_time = time_func

    def add_retardant_at_xy(self, x_m: float, y_m: float,
                            duration_hr: float, effectiveness: float) -> None:
        """Apply long-term fire retardant at the specified coordinates.

        Args:
            x_m: X position in meters.
            y_m: Y position in meters.
            duration_hr: Duration of retardant effect in hours.
            effectiveness: Retardant effectiveness factor (0.0-1.0).
        """
        cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
        if cell is not None:
            self.add_retardant_at_cell(cell, duration_hr, effectiveness)

    def add_retardant_at_indices(self, row: int, col: int,
                                  duration_hr: float, effectiveness: float) -> None:
        """Apply long-term fire retardant at the specified grid indices.

        Args:
            row: Row index in the cell grid.
            col: Column index in the cell grid.
            duration_hr: Duration of retardant effect in hours.
            effectiveness: Retardant effectiveness factor (0.0-1.0).
        """
        cell = self._grid_manager.get_cell_from_indices(row, col)
        self.add_retardant_at_cell(cell, duration_hr, effectiveness)

    def add_retardant_at_cell(self, cell: 'Cell', duration_hr: float,
                               effectiveness: float) -> None:
        """Apply long-term fire retardant to the specified cell.

        Effectiveness is clamped to the range [0.0, 1.0]. Only applies
        to burnable cells.

        Args:
            cell: Cell to apply retardant to.
            duration_hr: Duration of retardant effect in hours.
            effectiveness: Retardant effectiveness factor (0.0-1.0).
        """
        # Ensure that effectiveness is between 0 and 1
        effectiveness = min(max(effectiveness, 0), 1)

        if cell.fuel.burnable:
            cell.add_retardant(duration_hr, effectiveness)
            self._long_term_retardants.add(cell)
            self._updated_cells[cell.id] = cell

    def update_long_term_retardants(self, curr_time_s: float) -> None:
        """Update long-term retardant effects and remove expired retardants.

        Args:
            curr_time_s: Current simulation time in seconds.
        """
        # First, clear retardant from expired cells and track them as updated
        for cell in self._long_term_retardants:
            if cell.retardant_expiration_s <= curr_time_s:
                cell._retardant = False
                cell._retardant_factor = 1.0
                cell.retardant_expiration_s = -1.0
                self._updated_cells[cell.id] = cell

        # Then filter to keep only non-expired cells
        self._long_term_retardants = {
            cell for cell in self._long_term_retardants
            if cell._retardant  # Still has retardant (wasn't cleared above)
        }

    def water_drop_at_xy_as_rain(self, x_m: float, y_m: float,
                                  water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall at the specified coordinates.

        Args:
            x_m: X position in meters.
            y_m: Y position in meters.
            water_depth_cm: Equivalent rainfall depth in centimeters.
        """
        cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
        if cell is not None:
            self.water_drop_at_cell_as_rain(cell, water_depth_cm)

    def water_drop_at_indices_as_rain(self, row: int, col: int,
                                       water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall at the specified grid indices.

        Args:
            row: Row index in the cell grid.
            col: Column index in the cell grid.
            water_depth_cm: Equivalent rainfall depth in centimeters.
        """
        cell = self._grid_manager.get_cell_from_indices(row, col)
        self.water_drop_at_cell_as_rain(cell, water_depth_cm)

    def water_drop_at_cell_as_rain(self, cell: 'Cell', water_depth_cm: float) -> None:
        """Apply water drop as equivalent rainfall to the specified cell.

        Only applies to burnable cells. Adds cell to active water drops
        for moisture tracking.

        Args:
            cell: Cell to apply water to.
            water_depth_cm: Equivalent rainfall depth in centimeters.

        Raises:
            ValueError: If water_depth_cm is negative.
        """
        if water_depth_cm < 0:
            raise ValueError(f"Water depth must be >=0, {water_depth_cm} passed in")

        if cell.fuel.burnable:
            cell.water_drop_as_rain(water_depth_cm)
            self._active_water_drops.append(cell)
            self._updated_cells[cell.id] = cell

    def water_drop_at_xy_as_moisture_bump(self, x_m: float, y_m: float,
                                           moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase at the specified coordinates.

        Args:
            x_m: X position in meters.
            y_m: Y position in meters.
            moisture_inc: Moisture content increase as a fraction.
        """
        cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
        if cell is not None:
            self.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

    def water_drop_at_indices_as_moisture_bump(self, row: int, col: int,
                                                moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase at the specified grid indices.

        Args:
            row: Row index in the cell grid.
            col: Column index in the cell grid.
            moisture_inc: Moisture content increase as a fraction.
        """
        cell = self._grid_manager.get_cell_from_indices(row, col)
        self.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

    def water_drop_at_cell_as_moisture_bump(self, cell: 'Cell',
                                             moisture_inc: float) -> None:
        """Apply water drop as direct moisture increase to the specified cell.

        Only applies to burnable cells. Adds cell to active water drops
        for moisture tracking.

        Args:
            cell: Cell to apply water to.
            moisture_inc: Moisture content increase as a fraction.

        Raises:
            ValueError: If moisture_inc is negative.
        """
        if moisture_inc < 0:
            raise ValueError(f"Moisture increase must be >0, {moisture_inc} passed in")

        if cell.fuel.burnable:
            cell.water_drop_as_moisture_bump(moisture_inc)
            self._active_water_drops.append(cell)
            self._updated_cells[cell.id] = cell

    def water_drop_at_xy_vw(self, x_m: float, y_m: float, volume_L: float,
                             efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop at the specified coordinates.

        Args:
            x_m: X position in meters.
            y_m: Y position in meters.
            volume_L: Water volume in liters (1 L = 1 kg).
            efficiency: Application efficiency multiplier (Table 4). Default 2.5.
            T_a: Ambient air temperature in °C. Default 20.
        """
        cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
        if cell is not None:
            self.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

    def water_drop_at_indices_vw(self, row: int, col: int, volume_L: float,
                                  efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop at the specified grid indices.

        Args:
            row: Row index in the cell grid.
            col: Column index in the cell grid.
            volume_L: Water volume in liters (1 L = 1 kg).
            efficiency: Application efficiency multiplier (Table 4). Default 2.5.
            T_a: Ambient air temperature in °C. Default 20.
        """
        cell = self._grid_manager.get_cell_from_indices(row, col)
        self.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

    def water_drop_at_cell_vw(self, cell: 'Cell', volume_L: float,
                               efficiency: float = 2.5, T_a: float = 20.0) -> None:
        """Apply Van Wagner energy-balance water drop to the specified cell.

        Only applies to burnable cells. Adds cell to active water drops
        for tracking.

        Args:
            cell: Cell to apply water to.
            volume_L: Water volume in liters (1 L = 1 kg).
            efficiency: Application efficiency multiplier (Table 4). Default 2.5.
            T_a: Ambient air temperature in °C. Default 20.

        Raises:
            ValueError: If volume_L is negative.
        """
        if volume_L < 0:
            raise ValueError(f"Water volume must be >= 0, got {volume_L}")

        if cell.fuel.burnable:
            cell.water_drop_vw(volume_L, efficiency, T_a)
            self._active_water_drops.append(cell)
            self._updated_cells[cell.id] = cell

    def construct_fireline(self, line: LineString, width_m: float,
                           construction_rate: Optional[float] = None,
                           fireline_id: Optional[str] = None,
                           curr_time_s: float = 0) -> str:
        """Construct a fire break along a line geometry.

        If construction_rate is None, the fire break is applied instantly.
        Otherwise, it is constructed progressively over time.

        Args:
            line: Shapely LineString defining the fire break path.
            width_m: Width of the fire break in meters.
            construction_rate: Construction rate in m/s. If None, instant.
            fireline_id: Unique identifier. Auto-generated if not provided.
            curr_time_s: Current simulation time in seconds.

        Returns:
            Identifier of the constructed fire break.
        """
        if construction_rate is None:
            # Add fire break instantly
            self._apply_firebreak(line, width_m)

            if fireline_id is None:
                fireline_id = str(len(self._fire_breaks) + 1)

            self._fire_breaks.append((line, width_m, fireline_id))
            self._fire_break_dict[fireline_id] = (line, width_m)

            # Add to cache for visualization and logging
            cache_entry = {
                "id": fireline_id,
                "line": line,
                "width": width_m,
                "time": curr_time_s,
                "logged": False,
                "visualized": False
            }
            self._new_fire_break_cache.append(cache_entry)
        else:
            if fireline_id is None:
                fireline_id = str(len(self._fire_breaks) + len(self._active_firelines) + 1)

            # Create an active fireline to be updated over time
            self._active_firelines[fireline_id] = {
                "line": line,
                "width": width_m,
                "rate": construction_rate,
                "progress": 0.0,
                "partial_line": LineString([]),
                "cells": set()
            }

        return fireline_id

    def stop_fireline_construction(self, fireline_id: str) -> None:
        """Stop construction of an active fireline.

        Finalizes the partially constructed fireline and adds it to the
        permanent fire breaks list.

        Args:
            fireline_id: Identifier of the fireline to stop constructing.
        """
        if self._active_firelines.get(fireline_id) is not None:
            fireline = self._active_firelines[fireline_id]
            partial_line = fireline["partial_line"]
            self._fire_breaks.append((partial_line, fireline["width"], fireline_id))
            self._fire_break_dict[fireline_id] = (partial_line, fireline["width"])
            del self._active_firelines[fireline_id]

    def update_active_firelines(self) -> None:
        """Update progress of active fireline construction.

        Extends partially constructed fire lines based on their
        construction rate. Completes fire lines that reach their
        full length.
        """
        step_size = self._cell_size / 4.0
        firelines_to_remove = []

        for fid in list(self._active_firelines.keys()):
            fireline = self._active_firelines[fid]

            full_line = fireline["line"]
            length = full_line.length

            # Get the progress from previous update
            prev_progress = fireline["progress"]

            # Update progress based on line construction rate
            fireline["progress"] += fireline["rate"] * self._time_step

            # Cap progress at full length
            fireline["progress"] = min(fireline["progress"], length)

            # Interpolate new points between prev_progress and current progress
            num_steps = int(fireline["progress"] / step_size)
            prev_steps = int(prev_progress / step_size)

            for i in range(prev_steps, num_steps):
                # Get the interpolated point
                point = fireline["line"].interpolate(i * step_size)

                # Find the cell containing the new point
                cell = self._grid_manager.get_cell_from_xy(point.x, point.y, oob_ok=True)

                if cell is not None:
                    # Add cell to fire break cell container
                    if cell not in self._fire_break_cells:
                        self._fire_break_cells.append(cell)

                    # Add cell to the line's container
                    if cell not in fireline["cells"]:
                        cell._break_width += fireline["width"]
                        fireline["cells"].add(cell)

                        # Set to urban fuel if break width exceeds cell size
                        if cell._break_width > self._cell_size:
                            cell._set_fuel_type(self._fuel_class_factory(91))

            # If line has met its full length, add to permanent and remove from active
            if fireline["progress"] == length:
                fireline["partial_line"] = full_line
                self._fire_breaks.append((full_line, fireline["width"], fid))
                self._fire_break_dict[fid] = (full_line, fireline["width"])
                firelines_to_remove.append(fid)
            else:
                # Store the truncated line based on progress
                fireline["partial_line"] = self._truncate_linestring(
                    fireline["line"], fireline["progress"]
                )

        # Remove completed firelines from active
        for fireline_id in firelines_to_remove:
            del self._active_firelines[fireline_id]

    def _apply_firebreak(self, line: LineString, break_width: float) -> None:
        """Apply a fire break along a line geometry.

        Args:
            line: Shapely LineString defining the fire break path.
            break_width: Width of the fire break in meters.
        """
        cells = self._grid_manager.get_cells_at_geometry(line)

        for cell in cells:
            if cell not in self._fire_break_cells:
                self._fire_break_cells.append(cell)

            cell._break_width += break_width
            if cell._break_width > self._cell_size:
                cell._set_fuel_type(self._fuel_class_factory(91))

    def _truncate_linestring(self, line: LineString, length: float) -> LineString:
        """Truncate a LineString to the specified length.

        Args:
            line: Original line to truncate.
            length: Desired length in meters.

        Returns:
            Truncated line, or original if length exceeds line length.
        """
        if length <= 0:
            return LineString([line.coords[0]])
        if length >= line.length:
            return line

        coords = list(line.coords)
        accumulated = 0.0
        new_coords = [coords[0]]

        for i in range(1, len(coords)):
            seg = LineString([coords[i - 1], coords[i]])
            seg_len = seg.length
            if accumulated + seg_len >= length:
                ratio = (length - accumulated) / seg_len
                x = coords[i - 1][0] + ratio * (coords[i][0] - coords[i - 1][0])
                y = coords[i - 1][1] + ratio * (coords[i][1] - coords[i - 1][1])
                new_coords.append((x, y))
                break
            else:
                new_coords.append(coords[i])
                accumulated += seg_len

        return LineString(new_coords)

active_firelines property

Firelines currently under construction.

active_water_drops property

Cells with active water drop effects.

fire_break_cells property

Cells along fire breaks.

fire_break_dict property

Dictionary mapping fire break IDs to (line, width) tuples.

fire_breaks property

Completed fire breaks as (line, width, id) tuples.

long_term_retardants property

Cells with active long-term retardant.

new_fire_break_cache property

Cache of newly constructed fire breaks for logging/visualization.

__init__(grid_manager, cell_size, time_step, fuel_class_factory)

Initialize the control action handler.

Parameters:

Name Type Description Default
grid_manager GridManager

GridManager instance for cell lookups.

required
cell_size float

Cell size in meters.

required
time_step float

Simulation time step in seconds.

required
fuel_class_factory Callable[[int], object]

Callable that creates a fuel class from a model number.

required
Source code in embrs/base_classes/control_handler.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
def __init__(self,
             grid_manager: 'GridManager',
             cell_size: float,
             time_step: float,
             fuel_class_factory: Callable[[int], object]):
    """Initialize the control action handler.

    Args:
        grid_manager: GridManager instance for cell lookups.
        cell_size: Cell size in meters.
        time_step: Simulation time step in seconds.
        fuel_class_factory: Callable that creates a fuel class from a model number.
    """
    self._grid_manager = grid_manager
    self._cell_size = cell_size
    self._time_step = time_step
    self._fuel_class_factory = fuel_class_factory

    # Control action containers
    self._long_term_retardants: Set['Cell'] = set()
    self._active_water_drops: List['Cell'] = []
    self._active_firelines: Dict[str, dict] = {}
    self._fire_break_cells: List['Cell'] = []
    self._fire_breaks: List = []
    self._fire_break_dict: Dict[str, tuple] = {}
    self._new_fire_break_cache: List[dict] = []

    # Reference to track updated cells (set by parent)
    self._updated_cells: Dict[int, 'Cell'] = {}

    # Current time accessor (set by parent)
    self._get_curr_time: Callable[[], float] = lambda: 0

    # Reference to logger and visualizer for cache cleanup
    self.logger = None
    self._visualizer = None

add_retardant_at_cell(cell, duration_hr, effectiveness)

Apply long-term fire retardant to the specified cell.

Effectiveness is clamped to the range [0.0, 1.0]. Only applies to burnable cells.

Parameters:

Name Type Description Default
cell Cell

Cell to apply retardant to.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required
Source code in embrs/base_classes/control_handler.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def add_retardant_at_cell(self, cell: 'Cell', duration_hr: float,
                           effectiveness: float) -> None:
    """Apply long-term fire retardant to the specified cell.

    Effectiveness is clamped to the range [0.0, 1.0]. Only applies
    to burnable cells.

    Args:
        cell: Cell to apply retardant to.
        duration_hr: Duration of retardant effect in hours.
        effectiveness: Retardant effectiveness factor (0.0-1.0).
    """
    # Ensure that effectiveness is between 0 and 1
    effectiveness = min(max(effectiveness, 0), 1)

    if cell.fuel.burnable:
        cell.add_retardant(duration_hr, effectiveness)
        self._long_term_retardants.add(cell)
        self._updated_cells[cell.id] = cell

add_retardant_at_indices(row, col, duration_hr, effectiveness)

Apply long-term fire retardant at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required
Source code in embrs/base_classes/control_handler.py
135
136
137
138
139
140
141
142
143
144
145
146
def add_retardant_at_indices(self, row: int, col: int,
                              duration_hr: float, effectiveness: float) -> None:
    """Apply long-term fire retardant at the specified grid indices.

    Args:
        row: Row index in the cell grid.
        col: Column index in the cell grid.
        duration_hr: Duration of retardant effect in hours.
        effectiveness: Retardant effectiveness factor (0.0-1.0).
    """
    cell = self._grid_manager.get_cell_from_indices(row, col)
    self.add_retardant_at_cell(cell, duration_hr, effectiveness)

add_retardant_at_xy(x_m, y_m, duration_hr, effectiveness)

Apply long-term fire retardant at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
duration_hr float

Duration of retardant effect in hours.

required
effectiveness float

Retardant effectiveness factor (0.0-1.0).

required
Source code in embrs/base_classes/control_handler.py
121
122
123
124
125
126
127
128
129
130
131
132
133
def add_retardant_at_xy(self, x_m: float, y_m: float,
                        duration_hr: float, effectiveness: float) -> None:
    """Apply long-term fire retardant at the specified coordinates.

    Args:
        x_m: X position in meters.
        y_m: Y position in meters.
        duration_hr: Duration of retardant effect in hours.
        effectiveness: Retardant effectiveness factor (0.0-1.0).
    """
    cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
    if cell is not None:
        self.add_retardant_at_cell(cell, duration_hr, effectiveness)

construct_fireline(line, width_m, construction_rate=None, fireline_id=None, curr_time_s=0)

Construct a fire break along a line geometry.

If construction_rate is None, the fire break is applied instantly. Otherwise, it is constructed progressively over time.

Parameters:

Name Type Description Default
line LineString

Shapely LineString defining the fire break path.

required
width_m float

Width of the fire break in meters.

required
construction_rate Optional[float]

Construction rate in m/s. If None, instant.

None
fireline_id Optional[str]

Unique identifier. Auto-generated if not provided.

None
curr_time_s float

Current simulation time in seconds.

0

Returns:

Type Description
str

Identifier of the constructed fire break.

Source code in embrs/base_classes/control_handler.py
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
def construct_fireline(self, line: LineString, width_m: float,
                       construction_rate: Optional[float] = None,
                       fireline_id: Optional[str] = None,
                       curr_time_s: float = 0) -> str:
    """Construct a fire break along a line geometry.

    If construction_rate is None, the fire break is applied instantly.
    Otherwise, it is constructed progressively over time.

    Args:
        line: Shapely LineString defining the fire break path.
        width_m: Width of the fire break in meters.
        construction_rate: Construction rate in m/s. If None, instant.
        fireline_id: Unique identifier. Auto-generated if not provided.
        curr_time_s: Current simulation time in seconds.

    Returns:
        Identifier of the constructed fire break.
    """
    if construction_rate is None:
        # Add fire break instantly
        self._apply_firebreak(line, width_m)

        if fireline_id is None:
            fireline_id = str(len(self._fire_breaks) + 1)

        self._fire_breaks.append((line, width_m, fireline_id))
        self._fire_break_dict[fireline_id] = (line, width_m)

        # Add to cache for visualization and logging
        cache_entry = {
            "id": fireline_id,
            "line": line,
            "width": width_m,
            "time": curr_time_s,
            "logged": False,
            "visualized": False
        }
        self._new_fire_break_cache.append(cache_entry)
    else:
        if fireline_id is None:
            fireline_id = str(len(self._fire_breaks) + len(self._active_firelines) + 1)

        # Create an active fireline to be updated over time
        self._active_firelines[fireline_id] = {
            "line": line,
            "width": width_m,
            "rate": construction_rate,
            "progress": 0.0,
            "partial_line": LineString([]),
            "cells": set()
        }

    return fireline_id

set_time_accessor(time_func)

Set the function to get current simulation time.

Parameters:

Name Type Description Default
time_func Callable[[], float]

Callable that returns current time in seconds.

required
Source code in embrs/base_classes/control_handler.py
113
114
115
116
117
118
119
def set_time_accessor(self, time_func: Callable[[], float]) -> None:
    """Set the function to get current simulation time.

    Args:
        time_func: Callable that returns current time in seconds.
    """
    self._get_curr_time = time_func

set_updated_cells_ref(updated_cells)

Set reference to the simulation's updated cells dictionary.

Parameters:

Name Type Description Default
updated_cells Dict[int, Cell]

Dictionary to track cells that have been modified.

required
Source code in embrs/base_classes/control_handler.py
105
106
107
108
109
110
111
def set_updated_cells_ref(self, updated_cells: Dict[int, 'Cell']) -> None:
    """Set reference to the simulation's updated cells dictionary.

    Args:
        updated_cells: Dictionary to track cells that have been modified.
    """
    self._updated_cells = updated_cells

stop_fireline_construction(fireline_id)

Stop construction of an active fireline.

Finalizes the partially constructed fireline and adds it to the permanent fire breaks list.

Parameters:

Name Type Description Default
fireline_id str

Identifier of the fireline to stop constructing.

required
Source code in embrs/base_classes/control_handler.py
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def stop_fireline_construction(self, fireline_id: str) -> None:
    """Stop construction of an active fireline.

    Finalizes the partially constructed fireline and adds it to the
    permanent fire breaks list.

    Args:
        fireline_id: Identifier of the fireline to stop constructing.
    """
    if self._active_firelines.get(fireline_id) is not None:
        fireline = self._active_firelines[fireline_id]
        partial_line = fireline["partial_line"]
        self._fire_breaks.append((partial_line, fireline["width"], fireline_id))
        self._fire_break_dict[fireline_id] = (partial_line, fireline["width"])
        del self._active_firelines[fireline_id]

update_active_firelines()

Update progress of active fireline construction.

Extends partially constructed fire lines based on their construction rate. Completes fire lines that reach their full length.

Source code in embrs/base_classes/control_handler.py
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
def update_active_firelines(self) -> None:
    """Update progress of active fireline construction.

    Extends partially constructed fire lines based on their
    construction rate. Completes fire lines that reach their
    full length.
    """
    step_size = self._cell_size / 4.0
    firelines_to_remove = []

    for fid in list(self._active_firelines.keys()):
        fireline = self._active_firelines[fid]

        full_line = fireline["line"]
        length = full_line.length

        # Get the progress from previous update
        prev_progress = fireline["progress"]

        # Update progress based on line construction rate
        fireline["progress"] += fireline["rate"] * self._time_step

        # Cap progress at full length
        fireline["progress"] = min(fireline["progress"], length)

        # Interpolate new points between prev_progress and current progress
        num_steps = int(fireline["progress"] / step_size)
        prev_steps = int(prev_progress / step_size)

        for i in range(prev_steps, num_steps):
            # Get the interpolated point
            point = fireline["line"].interpolate(i * step_size)

            # Find the cell containing the new point
            cell = self._grid_manager.get_cell_from_xy(point.x, point.y, oob_ok=True)

            if cell is not None:
                # Add cell to fire break cell container
                if cell not in self._fire_break_cells:
                    self._fire_break_cells.append(cell)

                # Add cell to the line's container
                if cell not in fireline["cells"]:
                    cell._break_width += fireline["width"]
                    fireline["cells"].add(cell)

                    # Set to urban fuel if break width exceeds cell size
                    if cell._break_width > self._cell_size:
                        cell._set_fuel_type(self._fuel_class_factory(91))

        # If line has met its full length, add to permanent and remove from active
        if fireline["progress"] == length:
            fireline["partial_line"] = full_line
            self._fire_breaks.append((full_line, fireline["width"], fid))
            self._fire_break_dict[fid] = (full_line, fireline["width"])
            firelines_to_remove.append(fid)
        else:
            # Store the truncated line based on progress
            fireline["partial_line"] = self._truncate_linestring(
                fireline["line"], fireline["progress"]
            )

    # Remove completed firelines from active
    for fireline_id in firelines_to_remove:
        del self._active_firelines[fireline_id]

update_long_term_retardants(curr_time_s)

Update long-term retardant effects and remove expired retardants.

Parameters:

Name Type Description Default
curr_time_s float

Current simulation time in seconds.

required
Source code in embrs/base_classes/control_handler.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def update_long_term_retardants(self, curr_time_s: float) -> None:
    """Update long-term retardant effects and remove expired retardants.

    Args:
        curr_time_s: Current simulation time in seconds.
    """
    # First, clear retardant from expired cells and track them as updated
    for cell in self._long_term_retardants:
        if cell.retardant_expiration_s <= curr_time_s:
            cell._retardant = False
            cell._retardant_factor = 1.0
            cell.retardant_expiration_s = -1.0
            self._updated_cells[cell.id] = cell

    # Then filter to keep only non-expired cells
    self._long_term_retardants = {
        cell for cell in self._long_term_retardants
        if cell._retardant  # Still has retardant (wasn't cleared above)
    }

water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

Apply water drop as direct moisture increase to the specified cell.

Only applies to burnable cells. Adds cell to active water drops for moisture tracking.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
moisture_inc float

Moisture content increase as a fraction.

required

Raises:

Type Description
ValueError

If moisture_inc is negative.

Source code in embrs/base_classes/control_handler.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def water_drop_at_cell_as_moisture_bump(self, cell: 'Cell',
                                         moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase to the specified cell.

    Only applies to burnable cells. Adds cell to active water drops
    for moisture tracking.

    Args:
        cell: Cell to apply water to.
        moisture_inc: Moisture content increase as a fraction.

    Raises:
        ValueError: If moisture_inc is negative.
    """
    if moisture_inc < 0:
        raise ValueError(f"Moisture increase must be >0, {moisture_inc} passed in")

    if cell.fuel.burnable:
        cell.water_drop_as_moisture_bump(moisture_inc)
        self._active_water_drops.append(cell)
        self._updated_cells[cell.id] = cell

water_drop_at_cell_as_rain(cell, water_depth_cm)

Apply water drop as equivalent rainfall to the specified cell.

Only applies to burnable cells. Adds cell to active water drops for moisture tracking.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required

Raises:

Type Description
ValueError

If water_depth_cm is negative.

Source code in embrs/base_classes/control_handler.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def water_drop_at_cell_as_rain(self, cell: 'Cell', water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall to the specified cell.

    Only applies to burnable cells. Adds cell to active water drops
    for moisture tracking.

    Args:
        cell: Cell to apply water to.
        water_depth_cm: Equivalent rainfall depth in centimeters.

    Raises:
        ValueError: If water_depth_cm is negative.
    """
    if water_depth_cm < 0:
        raise ValueError(f"Water depth must be >=0, {water_depth_cm} passed in")

    if cell.fuel.burnable:
        cell.water_drop_as_rain(water_depth_cm)
        self._active_water_drops.append(cell)
        self._updated_cells[cell.id] = cell

water_drop_at_cell_vw(cell, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop to the specified cell.

Only applies to burnable cells. Adds cell to active water drops for tracking.

Parameters:

Name Type Description Default
cell Cell

Cell to apply water to.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0

Raises:

Type Description
ValueError

If volume_L is negative.

Source code in embrs/base_classes/control_handler.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def water_drop_at_cell_vw(self, cell: 'Cell', volume_L: float,
                           efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop to the specified cell.

    Only applies to burnable cells. Adds cell to active water drops
    for tracking.

    Args:
        cell: Cell to apply water to.
        volume_L: Water volume in liters (1 L = 1 kg).
        efficiency: Application efficiency multiplier (Table 4). Default 2.5.
        T_a: Ambient air temperature in °C. Default 20.

    Raises:
        ValueError: If volume_L is negative.
    """
    if volume_L < 0:
        raise ValueError(f"Water volume must be >= 0, got {volume_L}")

    if cell.fuel.burnable:
        cell.water_drop_vw(volume_L, efficiency, T_a)
        self._active_water_drops.append(cell)
        self._updated_cells[cell.id] = cell

water_drop_at_indices_as_moisture_bump(row, col, moisture_inc)

Apply water drop as direct moisture increase at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
moisture_inc float

Moisture content increase as a fraction.

required
Source code in embrs/base_classes/control_handler.py
247
248
249
250
251
252
253
254
255
256
257
def water_drop_at_indices_as_moisture_bump(self, row: int, col: int,
                                            moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase at the specified grid indices.

    Args:
        row: Row index in the cell grid.
        col: Column index in the cell grid.
        moisture_inc: Moisture content increase as a fraction.
    """
    cell = self._grid_manager.get_cell_from_indices(row, col)
    self.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

water_drop_at_indices_as_rain(row, col, water_depth_cm)

Apply water drop as equivalent rainfall at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required
Source code in embrs/base_classes/control_handler.py
201
202
203
204
205
206
207
208
209
210
211
def water_drop_at_indices_as_rain(self, row: int, col: int,
                                   water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall at the specified grid indices.

    Args:
        row: Row index in the cell grid.
        col: Column index in the cell grid.
        water_depth_cm: Equivalent rainfall depth in centimeters.
    """
    cell = self._grid_manager.get_cell_from_indices(row, col)
    self.water_drop_at_cell_as_rain(cell, water_depth_cm)

water_drop_at_indices_vw(row, col, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop at the specified grid indices.

Parameters:

Name Type Description Default
row int

Row index in the cell grid.

required
col int

Column index in the cell grid.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0
Source code in embrs/base_classes/control_handler.py
296
297
298
299
300
301
302
303
304
305
306
307
308
def water_drop_at_indices_vw(self, row: int, col: int, volume_L: float,
                              efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop at the specified grid indices.

    Args:
        row: Row index in the cell grid.
        col: Column index in the cell grid.
        volume_L: Water volume in liters (1 L = 1 kg).
        efficiency: Application efficiency multiplier (Table 4). Default 2.5.
        T_a: Ambient air temperature in °C. Default 20.
    """
    cell = self._grid_manager.get_cell_from_indices(row, col)
    self.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

water_drop_at_xy_as_moisture_bump(x_m, y_m, moisture_inc)

Apply water drop as direct moisture increase at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
moisture_inc float

Moisture content increase as a fraction.

required
Source code in embrs/base_classes/control_handler.py
234
235
236
237
238
239
240
241
242
243
244
245
def water_drop_at_xy_as_moisture_bump(self, x_m: float, y_m: float,
                                       moisture_inc: float) -> None:
    """Apply water drop as direct moisture increase at the specified coordinates.

    Args:
        x_m: X position in meters.
        y_m: Y position in meters.
        moisture_inc: Moisture content increase as a fraction.
    """
    cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
    if cell is not None:
        self.water_drop_at_cell_as_moisture_bump(cell, moisture_inc)

water_drop_at_xy_as_rain(x_m, y_m, water_depth_cm)

Apply water drop as equivalent rainfall at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
water_depth_cm float

Equivalent rainfall depth in centimeters.

required
Source code in embrs/base_classes/control_handler.py
188
189
190
191
192
193
194
195
196
197
198
199
def water_drop_at_xy_as_rain(self, x_m: float, y_m: float,
                              water_depth_cm: float) -> None:
    """Apply water drop as equivalent rainfall at the specified coordinates.

    Args:
        x_m: X position in meters.
        y_m: Y position in meters.
        water_depth_cm: Equivalent rainfall depth in centimeters.
    """
    cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
    if cell is not None:
        self.water_drop_at_cell_as_rain(cell, water_depth_cm)

water_drop_at_xy_vw(x_m, y_m, volume_L, efficiency=2.5, T_a=20.0)

Apply Van Wagner energy-balance water drop at the specified coordinates.

Parameters:

Name Type Description Default
x_m float

X position in meters.

required
y_m float

Y position in meters.

required
volume_L float

Water volume in liters (1 L = 1 kg).

required
efficiency float

Application efficiency multiplier (Table 4). Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0
Source code in embrs/base_classes/control_handler.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def water_drop_at_xy_vw(self, x_m: float, y_m: float, volume_L: float,
                         efficiency: float = 2.5, T_a: float = 20.0) -> None:
    """Apply Van Wagner energy-balance water drop at the specified coordinates.

    Args:
        x_m: X position in meters.
        y_m: Y position in meters.
        volume_L: Water volume in liters (1 L = 1 kg).
        efficiency: Application efficiency multiplier (Table 4). Default 2.5.
        T_a: Ambient air temperature in °C. Default 20.
    """
    cell = self._grid_manager.get_cell_from_xy(x_m, y_m, oob_ok=True)
    if cell is not None:
        self.water_drop_at_cell_vw(cell, volume_L, efficiency, T_a)

Base visualization functionality for fire simulation display.

Provides common visualization components including grid rendering, weather display, static map elements (roads, firebreaks), and prediction overlays.

Classes:

Name Description
- BaseVisualizer

Base class for simulation visualization.

.. autoclass:: BaseVisualizer :members:

BaseVisualizer

Base class for fire simulation visualization.

Provides common visualization functionality including hexagonal grid rendering, weather data display, static elements (roads, firebreaks, elevation contours), and prediction overlays.

Attributes:

Name Type Description
fig

Matplotlib figure object.

h_ax

Main axes for the hexagonal grid display.

render bool

Whether to render to screen (False for headless).

cell_size float

Size of hexagonal cells in meters.

width_m float

Simulation width in meters.

height_m float

Simulation height in meters.

Source code in embrs/base_classes/base_visualizer.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
class BaseVisualizer:
    """Base class for fire simulation visualization.

    Provides common visualization functionality including hexagonal grid
    rendering, weather data display, static elements (roads, firebreaks,
    elevation contours), and prediction overlays.

    Attributes:
        fig: Matplotlib figure object.
        h_ax: Main axes for the hexagonal grid display.
        render (bool): Whether to render to screen (False for headless).
        cell_size (float): Size of hexagonal cells in meters.
        width_m (float): Simulation width in meters.
        height_m (float): Simulation height in meters.
    """

    def __init__(self, params: VisualizerInputs, render=True):
        """Initialize the visualizer with simulation parameters.

        Args:
            params (VisualizerInputs): Configuration parameters for visualization.
            render (bool, optional): Whether to render to screen. Use False
                for headless operation. Defaults to True.
        """
        self.render = render

        if not self.render:
            mpl.use('Agg')  # Use a non-interactive backend if not rendering

        else:
            mpl.use('tkAgg')

        self.grid_height = params.sim_shape[0]
        self.grid_width = params.sim_shape[1]
        self.cell_size = params.cell_size
        self.coarse_elevation = params.elevation
        self.width_m = params.sim_size[0]
        self.height_m = params.sim_size[1]

        self.roads = params.roads
        self.fire_breaks = params.fire_breaks

        self.wind_forecast = params.wind_forecast
        self.wind_res = params.wind_resolution
        self.wind_t_step = params.wind_t_step
        self.wind_idx = -1
        self.wind_quiver = None
        self.wind_xpad = params.wind_xpad
        self.wind_ypad = params.wind_ypad

        self.temp_forecast = params.temp_forecast
        self.rh_forecast = params.rh_forecast
        self.forecast_t_step = params.forecast_t_step
        self.forecast_idx = -1

        self.north_dir_deg = params.north_dir_deg
        self._start_datetime = params.start_datetime

        self.scale_bar_km = params.scale_bar_km
        self.show_legend = params.show_legend
        self.show_wind_cbar = params.show_wind_cbar
        self.show_wind_field = params.show_wind_field
        self.show_weather_data = params.show_weather_data
        self.show_temp_in_F = params.show_temp_in_F

        self.retardant_art = None
        self.water_drop_art = None
        self.agent_art = []
        self.agent_labels = []
        self.legend_elements = []

        self.show_compass = params.show_compass
        init_entries = params.init_entries

        self._process_weather()
        self._setup_figure()
        self._setup_grid(init_entries)

        if render:
            self.fig.canvas.draw()
            plt.pause(1)

        self.initial_state = self.fig.canvas.copy_from_bbox(self.h_ax.bbox)

    def _process_weather(self):
        """Process weather data for visualization.

        Calculates global wind speed normalization, wind grid coordinates,
        and converts temperature units if needed.
        """
        if self.show_wind_field:
            # Calculate global max speed across all time steps for consistent coloring
            all_speeds = [forecast[:, :, 0] for forecast in self.wind_forecast]
            self.global_max_speed = max(np.max(s) for s in all_speeds)
            self.wind_norm = mcolors.Normalize(vmin=0, vmax=self.global_max_speed)

            # Pre-compute the downsampled grid coordinates
            self._compute_wind_grid()

        if self.show_weather_data:
            if not self.show_temp_in_F:
                self.temp_forecast = [np.round(F_to_C(temp), 1) for temp in self.temp_forecast]

    def _compute_wind_grid(self):
        """Compute the downsampled wind grid coordinates for quiver plot.

        Creates a fixed-size grid of arrow positions regardless of domain size,
        ensuring consistent arrow density across different map sizes.
        """
        # Get wind forecast dimensions (rows, cols) from first time step
        wind_rows = self.wind_forecast[0].shape[0]
        wind_cols = self.wind_forecast[0].shape[1]

        # Calculate the actual extent of the wind forecast in the simulation domain
        wind_width = wind_cols * self.wind_res
        wind_height = wind_rows * self.wind_res

        # Create coordinate arrays for the full wind grid
        # Wind grid starts at (wind_xpad, wind_ypad) and has resolution wind_res
        x_coords = np.linspace(
            self.wind_xpad + self.wind_res / 2,
            self.wind_xpad + wind_width - self.wind_res / 2,
            wind_cols
        )
        y_coords = np.linspace(
            self.wind_ypad + self.wind_res / 2,
            self.wind_ypad + wind_height - self.wind_res / 2,
            wind_rows
        )

        # Calculate downsampling step to achieve target arrow count
        # We want approximately WIND_ARROWS_PER_AXIS arrows in each direction
        x_step = max(1, wind_cols // WIND_ARROWS_PER_AXIS)
        y_step = max(1, wind_rows // WIND_ARROWS_PER_AXIS)

        # Downsample coordinates
        self.wind_x_display = x_coords[::x_step]
        self.wind_y_display = y_coords[::y_step]

        # Store step sizes for extracting matching data
        self.wind_x_step = x_step
        self.wind_y_step = y_step

        # Create meshgrid for quiver plot
        self.wind_X, self.wind_Y = np.meshgrid(self.wind_x_display, self.wind_y_display)

    def _init_wind_field(self):
        """Initialize the wind field quiver plot.

        Creates the initial quiver plot with arrows colored by wind speed.
        Called during static element initialization if show_wind_field is True.
        """
        if not self.show_wind_field or self.wind_forecast is None:
            return

        # Get initial wind data (time index 0)
        speed_grid, u_grid, v_grid = self._get_downsampled_wind_data(0)

        # Get colors from colormap based on wind speed
        cmap = plt.cm.turbo
        colors = cmap(self.wind_norm(speed_grid.ravel()))

        # Create quiver plot with color-coded arrows
        # scale_units='inches' makes arrow size constant in screen space,
        # independent of domain size or weather conditions
        self.wind_quiver = self.h_ax.quiver(
            self.wind_X, self.wind_Y,
            u_grid, v_grid,
            color=colors,
            scale=WIND_ARROW_SCALE,
            scale_units='inches',
            width=WIND_ARROW_WIDTH,
            headwidth=4,
            headlength=5,
            headaxislength=4,
            zorder=2,
            alpha=0.8
        )

        self.wind_idx = 0

    def _get_downsampled_wind_data(self, time_idx: int):
        """Extract downsampled wind speed and direction components.

        Args:
            time_idx: Time index into the wind forecast array.

        Returns:
            tuple: (speed_grid, u_grid, v_grid) - downsampled speed and
                velocity components (u=east, v=north).
        """
        # Get full wind data for this time step
        # wind_forecast shape: (time_steps, rows, cols, 2) where [.., 0]=speed, [.., 1]=direction
        speed_full = self.wind_forecast[time_idx][:, :, 0]
        direction_full = self.wind_forecast[time_idx][:, :, 1]

        # Downsample to match display grid
        speed_grid = speed_full[::self.wind_y_step, ::self.wind_x_step]
        direction_grid = direction_full[::self.wind_y_step, ::self.wind_x_step]

        # Wrap wind angle
        direction_to = direction_grid % 360
        math_angle_rad = np.deg2rad(direction_to)

        # Calculate u (east) and v (north) components
        # Normalize so arrow lengths are based on speed relative to max
        u_grid = np.sin(math_angle_rad)
        v_grid = np.cos(math_angle_rad)

        return speed_grid, u_grid, v_grid

    def _update_wind_field(self, sim_time_s: float):
        """Update the wind field visualization for the current time step.

        Args:
            sim_time_s: Current simulation time in seconds.

        Returns:
            bool: True if the wind field was updated, False otherwise.
        """
        if not self.show_wind_field or self.wind_quiver is None:
            return False

        # Calculate current wind time index
        new_wind_idx = int(np.floor(sim_time_s / self.wind_t_step))

        # Clamp to available forecast range
        new_wind_idx = min(new_wind_idx, len(self.wind_forecast) - 1)

        if new_wind_idx == self.wind_idx:
            return False

        self.wind_idx = new_wind_idx

        # Get updated wind data
        speed_grid, u_grid, v_grid = self._get_downsampled_wind_data(new_wind_idx)

        # Update quiver arrows
        self.wind_quiver.set_UVC(u_grid, v_grid)

        # Update colors based on new speeds
        cmap = plt.cm.turbo
        colors = cmap(self.wind_norm(speed_grid.ravel()))
        self.wind_quiver.set_color(colors)

        return True

    def _setup_figure(self):
        """Set up the matplotlib figure and axes."""
        if self.render:
            plt.ion()

        self.fig = plt.figure(figsize=(9, 8))
        self.h_ax = self.fig.add_axes([0.05, 0.05, 0.9, 0.9])

        self.h_ax.set_aspect('equal')
        self.h_ax.axis([0, self.width_m, 0, self.height_m])
        plt.tick_params(left=False, right=False, bottom=False,
                        labelleft=False, labelbottom=False)

    def _setup_grid(self, init_entries: list[CellLogEntry]) -> None:
        """Initialize the hexagonal grid display.

        Creates polygon patches for all cells and sets initial colors
        based on cell state and fuel type.

        Args:
            init_entries (list[CellLogEntry]): Initial cell state entries.
        """
        # Pre-create all polygons and store them
        self.all_polygons = []
        fuel_types_seen = set()
        for entry in init_entries:
            polygon = mpatches.RegularPolygon((entry.x, entry.y),
                                              numVertices=6, radius=self.cell_size, orientation=0)
            self.all_polygons.append(polygon)

            if entry.state == CellStates.FUEL:
                fuel_color = fc.fuel_color_mapping[entry.fuel]
                if fuel_color not in fuel_types_seen:
                    fuel_types_seen.add(fuel_color)
                    legend_patch = mpatches.Patch(color=fuel_color, label=fc.fuel_names[entry.fuel])
                    self.legend_elements.append((entry.fuel, legend_patch))

        # Assign initial facecolors based on cell state
        self.cell_colors = [self._get_cell_color(entry) for entry in init_entries]
        self.cell_id_to_index = {
            entry.id: i for i, entry in enumerate(init_entries)
        }

        # Create a single PatchCollection for all cells
        self.all_cells_coll = PatchCollection(self.all_polygons, facecolors=self.cell_colors, zorder=1)
        self.h_ax.add_collection(self.all_cells_coll)

        self._init_static_elements()

    def _get_cell_color(self, entry: CellLogEntry):
        """Get the display color for a cell based on its state.

        Args:
            entry (CellLogEntry): Cell state entry.

        Returns:
            tuple: RGBA color tuple for the cell.
        """
        if entry.state == CellStates.FUEL:
            base_color = mcolors.to_rgba(fc.fuel_color_mapping[entry.fuel])
            fuel_frac = entry.w_n_dead / entry.w_n_dead_start if entry.w_n_dead_start > 0 else 1.0

            if entry.n_disabled_locs > 0:
                # Dim proportionally: fully-disabled (12/12) → 0 brightness
                dim_factor = 1.0 - (entry.n_disabled_locs / 12.0)
                fuel_frac *= dim_factor

            return tuple(np.array(base_color) * fuel_frac)
        elif entry.state == CellStates.FIRE:
            return mcolors.to_rgba('#F97306')
        elif entry.state == CellStates.BURNT:
            return mcolors.to_rgba('k')
        elif entry.state == CellStates.CROWN:
            return mcolors.to_rgba('magenta')
        else:
            return (0, 0, 0, 0)  # transparent for inactive cells

    def update_grid(self, sim_time_s: float, entries: list[CellLogEntry], agents: list[AgentLogEntry] = [], actions: list[ActionsEntry] = []) -> None:
        """Update the grid display with new cell states.

        Args:
            sim_time_s (float): Current simulation time in seconds.
            entries (list[CellLogEntry]): Updated cell state entries.
            agents (list[AgentLogEntry], optional): Agent positions. Defaults to [].
            actions (list[ActionsEntry], optional): Active control actions. Defaults to [].
        """
        # Update wind field if needed
        self._update_wind_field(sim_time_s)

        # Update weather data if needed
        weather_idx = int(np.floor(sim_time_s / self.forecast_t_step))
        if self.show_weather_data and weather_idx != self.forecast_idx and weather_idx < len(self.temp_forecast):
            self.forecast_idx = weather_idx
            temp_unit = "F" if self.show_temp_in_F else "C"
            t_rounded = np.round(float(self.temp_forecast[self.forecast_idx]), 1)
            rh_rounded = np.round(float(self.rh_forecast[self.forecast_idx]), 1)
            weather_str = f"Temp: {t_rounded} °{temp_unit}, RH: {rh_rounded} %"
            self.weather_text.set_text(weather_str)

        # Update only changed cells
        for entry in entries:
            idx = self.cell_id_to_index[entry.id]
            self.cell_colors[idx] = self._get_cell_color(entry)

        self.all_cells_coll.set_facecolors(self.cell_colors)

         # Draw dynamic elements
        if agents:
            for agent_art in self.agent_art:
                agent_art.remove()
            self.agent_art.clear()

            for agent in agents:
                scatter = self.h_ax.scatter(agent.x, agent.y, marker=agent.marker, color=agent.color, zorder=5)
                self.agent_art.append(scatter)

        if actions:
            for action in actions:
                if action.action_type == 'fireline_construction':
                    self.h_ax.plot(action.x_coords, action.y_coords, color='blue', linewidth=self.meters_to_points(action.width) * 5, zorder=4)
                elif action.action_type == 'long_term_retardant':
                    if self.retardant_art:
                        self.retardant_art.remove()
                    self.retardant_art = self.h_ax.scatter(action.x_coords, action.y_coords, marker='h', s=self.meters_to_points(6*self.cell_size), c=action.effectiveness, cmap='Reds_r', vmin=0, vmax=1, zorder=4)
                elif action.action_type == 'short_term_suppressant':
                    if self.water_drop_art:
                        self.water_drop_art.remove()
                    self.water_drop_art = self.h_ax.scatter(action.x_coords, action.y_coords, marker='h', s=self.meters_to_points(6*self.cell_size), c=action.effectiveness, cmap='Blues', vmin=0.5, vmax=1, zorder=4)


        # Update time displays
        sim_datetime = self._start_datetime + timedelta(seconds=sim_time_s)
        self.datetime_text.set_text(sim_datetime.strftime("%Y-%m-%d %H:%M"))
        self.elapsed_text.set_text(util.get_time_str(sim_time_s))

        if self.render:
            self.fig.canvas.restore_region(self.initial_state)
            self.h_ax.draw_artist(self.all_cells_coll)
            self.fig.canvas.blit(self.h_ax.bbox)
            self.fig.canvas.flush_events()
            plt.pause(0.001)


    def close(self):
        """Close the visualization figure."""
        if self.fig and plt.fignum_exists(self.fig.number):
            plt.close(self.fig)
            self.fig = None

    def reset_figure(self, done=False):
        """Reset the visualization figure.

        Args:
            done (bool, optional): If True, only closes without reinitializing.
                Defaults to False.
        """
        self.close()
        if done:
            return
        self.h_ax.clear()
        self._setup_figure()
        self._init_static_elements()
        self.h_ax.add_collection(self.all_cells_coll)
        self.fig.canvas.draw()
        self.initial_state = self.fig.canvas.copy_from_bbox(self.h_ax.bbox)
        self.fig.canvas.blit(self.h_ax.bbox)
        self.fig.canvas.flush_events()

    def _init_static_elements(self):
        """Initialize static visualization elements.

        Draws elevation contours, weather display, compass, time displays,
        roads, fire breaks, legend, scale bar, and wind field.
        """
        # === Wind field (draw first so it's behind other elements) ===
        if self.show_wind_field:
            self._init_wind_field()

        # === Elevation contour ===
        x = np.arange(0, self.grid_width)
        y = np.arange(0, self.grid_height)
        X, Y = np.meshgrid(x, y)
        cont = self.h_ax.contour(X * self.cell_size * np.sqrt(3), Y * self.cell_size * 1.5,
                                self.coarse_elevation, colors='k')
        self.h_ax.clabel(cont, inline=True, fontsize=10, zorder=2)

        if self.show_weather_data:
            self.weather_box = mpatches.FancyBboxPatch((0.02, 0.90), 0.25, 0.0000125/2, transform=self.h_ax.transAxes,
                                                   boxstyle='square,pad=0.02',
                                                    facecolor='white', edgecolor='black',
                                                    linewidth=1, zorder=3, alpha=0.75)

            self.weather_text = self.h_ax.text(0.01, 0.90, '',
                                        transform=self.h_ax.transAxes,
                                        ha='left', va='center',
                                        fontsize=10, zorder=4)

            self.h_ax.add_patch(self.weather_box)

        if self.show_compass:
            # === Compass ===
            lx = 0.02
            ly = 0.84

            if self.show_weather_data:
                ly -= 0.04

            self.compass_box = mpatches.FancyBboxPatch((lx, ly), 0.06, 0.06,
                                                    transform=self.h_ax.transAxes,
                                                    boxstyle='square,pad=0.02',
                                                    facecolor='white', edgecolor='black',
                                                    linewidth=1, zorder=3, alpha=0.75)
            self.h_ax.add_patch(self.compass_box)
            cx, cy = lx + 0.03, ly + 0.03  # center of box

            # Compass arrow
            arrow_len = 0.025
            dx = np.sin(np.deg2rad(self.north_dir_deg)) * arrow_len
            dy = np.cos(np.deg2rad(self.north_dir_deg)) * arrow_len
            self.arrow_obj = self.h_ax.arrow(cx, cy - 0.035, dx, dy,
                                            transform=self.h_ax.transAxes,
                                            width=0.004, head_width=0.015,
                                            color='red', zorder=4)
            self.compassheader = self.h_ax.text(cx, cy + 0.03, 'N',
                                                transform=self.h_ax.transAxes,
                                                ha='center', va='center',
                                                fontsize=10, weight='bold', color='red')

        self.datetime_box = mpatches.FancyBboxPatch((0.02, 0.98), 0.25, 0.0000125/2, transform=self.h_ax.transAxes,
                                                    boxstyle='square,pad=0.02',
                                                    facecolor='white', edgecolor='black',
                                                    linewidth=1, zorder=3, alpha=0.75)

        self.h_ax.add_patch(self.datetime_box)

        # # === Date/time box ===
        self.datetime_text = self.h_ax.text(0.045, 0.98, '', transform=self.h_ax.transAxes,
                                            ha='left', va='center',
                                            fontsize=10, zorder=4)

        # === Elapsed time ===
        self.elapsed_box = mpatches.FancyBboxPatch((0.02, 0.94), 0.25, 0.0000125/2, transform=self.h_ax.transAxes,
                                                   boxstyle='square,pad=0.02',
                                                    facecolor='white', edgecolor='black',
                                                    linewidth=1, zorder=3, alpha=0.75)

        self.h_ax.add_patch(self.elapsed_box)

        self.timeheader = self.h_ax.text(0.01, 0.94, 'elapsed:',
                                        transform=self.h_ax.transAxes,
                                        ha='left', va='center', fontsize=10, zorder=4)
        self.elapsed_text = self.h_ax.text(0.125, 0.94, '',
                                        transform=self.h_ax.transAxes,
                                        ha='left', va='center',
                                        fontsize=10, zorder=4)

        # === Roads ===
        if self.roads is not None:
            added_colors = set()
            for road, road_type, road_width in self.roads:
                x, y = road[0], road[1]
                road_color = rc.road_color_mapping[road_type]
                self.h_ax.plot(x, y, color=road_color, linewidth=self.meters_to_points(road_width) * 5, zorder=2)
                if road_color not in added_colors and self.show_legend:
                    added_colors.add(road_color)
                    legend_patch = mpatches.Patch(color=road_color, label=f"Road - {road_type}")
                    self.legend_elements.append((204 + np.where(np.array(rc.major_road_types) == road_type)[0][0], legend_patch))

        # === Firebreaks ===
        for fire_break, break_width, _ in self.fire_breaks:
            if isinstance(fire_break, LineString):
                x, y = fire_break.xy
                self.h_ax.plot(x, y, color='blue', linewidth=self.meters_to_points(break_width) * 5, zorder=2)

        # === Legend ===
        if self.legend_elements and self.show_legend:
            sorted_patches = [patch for _, patch in sorted(self.legend_elements, key=lambda x: x[0])]
            self.h_ax.legend(handles=sorted_patches, loc='upper right', borderaxespad=0)

        # === Scale bar ===
        bar_length = self.scale_bar_km * 1000  # meters
        if self.scale_bar_km < 1:
            scale_label = f"{int(bar_length)} m"
        else:
            scale_label = f"{self.scale_bar_km:.1f} km"

        # Line for the scale bar
        line = Line2D([0, bar_length], [0, 0], color='black', linewidth=2, solid_capstyle='butt')
        line_box = AuxTransformBox(self.h_ax.transData)
        line_box.add_artist(line)

        # Text label
        text = TextArea(scale_label, textprops=dict(color='black', fontsize=10))

        # Stack line and label vertically
        packed = VPacker(children=[line_box, text], align="center", pad=0, sep=2)

        # Anchor with background patch (frameon=True makes a white box)
        scalebar_box = AnchoredOffsetbox(loc='lower left',
                                        child=packed,
                                        pad=0.4,
                                        frameon=True,
                                        borderpad=0.5)
        scalebar_box.patch.set_facecolor('white')
        scalebar_box.patch.set_alpha(0.75)
        scalebar_box.zorder = 4

        self.h_ax.add_artist(scalebar_box)

        if self.show_wind_field and self.show_wind_cbar:
            sm = ScalarMappable(norm=self.wind_norm, cmap='turbo')
            sm.set_array([])
            self.wind_cbar = self.fig.colorbar(
                sm, ax=self.h_ax, orientation='vertical', shrink=0.7,
                pad=0.02, label='Wind speed (m/s)'
            )

            self.wind_cbar.ax.tick_params(labelsize=8)


    def meters_to_points(self, meters: float) -> float:
        """Convert meters to matplotlib points for sizing elements.

        Args:
            meters (float): Distance in meters.

        Returns:
            float: Equivalent size in matplotlib points.
        """
        fig_width_inch, _ = self.fig.get_size_inches()
        meters_per_inch = self.width_m / fig_width_inch
        meters_per_point = meters_per_inch / 72
        return meters / meters_per_point


    def visualize_prediction(self, prediction):
        """Visualize a fire spread prediction overlay.

        Displays predicted fire arrival times as colored points on the grid,
        with colors indicating arrival time.

        Args:
            prediction (dict): Dictionary mapping timestamps (seconds) to lists
                of (x, y) coordinate tuples where fire is predicted to arrive.
        """
        # Clear any existing prediction visualization
        if hasattr(self, 'prediction_scatter'):
            self.prediction_scatter.remove()
            delattr(self, 'prediction_scatter')

        time_steps = sorted(prediction.keys())
        if not time_steps:
            return

        cmap = mpl.cm.get_cmap("viridis")
        norm = plt.Normalize(time_steps[0], time_steps[-1])

        # Collect all points and their corresponding times
        all_points = []
        all_times = []
        for time in time_steps:
            points = prediction[time]
            all_points.extend(points)
            all_times.extend([time] * len(points))

        if all_points:
            x, y = zip(*all_points)
            self.prediction_scatter = self.h_ax.scatter(x, y, c=all_times, cmap=cmap, norm=norm, 
                                                        s=1, zorder=1)
            self.fig.canvas.draw()


    def visualize_ensemble_prediction(self, burn_probability):
        """Visualize ensemble burn probability overlay.

        Displays the final burn probability from an ensemble prediction,
        with color intensity indicating probability of burning.

        Args:
            burn_probability (dict): Dictionary mapping timestamps to
                dictionaries of {(x, y): probability} representing cumulative
                burn probability at each time step. Uses the final time step.
        """
        # Clear any existing prediction visualization
        if hasattr(self, 'prediction_scatter'):
            self.prediction_scatter.remove()
            delattr(self, 'prediction_scatter')

        # Get the final time step (latest prediction)
        time_steps = sorted(burn_probability.keys())
        if not time_steps:
            return

        final_time = time_steps[-1]
        final_probs = burn_probability[final_time]

        if not final_probs:
            return

        # Extract points and their probabilities
        points = list(final_probs.keys())
        probs = [final_probs[point] for point in points]

        # Create colormap for probabilities (0 to 1)
        cmap = mpl.cm.get_cmap("hot")  # 'hot' colormap: dark to bright
        norm = plt.Normalize(0, 1)

        x, y = zip(*points)
        self.prediction_scatter = self.h_ax.scatter(
            x, y, 
            c=probs, 
            cmap=cmap, 
            norm=norm, 
            s=1, 
            zorder=1,
            alpha=0.8
        )

        # Add colorbar if not already present
        if not hasattr(self, 'prediction_colorbar'):
            self.prediction_colorbar = self.fig.colorbar(
                self.prediction_scatter, 
                ax=self.h_ax, 
                label='Burn Probability'
            )
        else:
            self.prediction_colorbar.update_normal(self.prediction_scatter)

        self.fig.canvas.draw()

__init__(params, render=True)

Initialize the visualizer with simulation parameters.

Parameters:

Name Type Description Default
params VisualizerInputs

Configuration parameters for visualization.

required
render bool

Whether to render to screen. Use False for headless operation. Defaults to True.

True
Source code in embrs/base_classes/base_visualizer.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def __init__(self, params: VisualizerInputs, render=True):
    """Initialize the visualizer with simulation parameters.

    Args:
        params (VisualizerInputs): Configuration parameters for visualization.
        render (bool, optional): Whether to render to screen. Use False
            for headless operation. Defaults to True.
    """
    self.render = render

    if not self.render:
        mpl.use('Agg')  # Use a non-interactive backend if not rendering

    else:
        mpl.use('tkAgg')

    self.grid_height = params.sim_shape[0]
    self.grid_width = params.sim_shape[1]
    self.cell_size = params.cell_size
    self.coarse_elevation = params.elevation
    self.width_m = params.sim_size[0]
    self.height_m = params.sim_size[1]

    self.roads = params.roads
    self.fire_breaks = params.fire_breaks

    self.wind_forecast = params.wind_forecast
    self.wind_res = params.wind_resolution
    self.wind_t_step = params.wind_t_step
    self.wind_idx = -1
    self.wind_quiver = None
    self.wind_xpad = params.wind_xpad
    self.wind_ypad = params.wind_ypad

    self.temp_forecast = params.temp_forecast
    self.rh_forecast = params.rh_forecast
    self.forecast_t_step = params.forecast_t_step
    self.forecast_idx = -1

    self.north_dir_deg = params.north_dir_deg
    self._start_datetime = params.start_datetime

    self.scale_bar_km = params.scale_bar_km
    self.show_legend = params.show_legend
    self.show_wind_cbar = params.show_wind_cbar
    self.show_wind_field = params.show_wind_field
    self.show_weather_data = params.show_weather_data
    self.show_temp_in_F = params.show_temp_in_F

    self.retardant_art = None
    self.water_drop_art = None
    self.agent_art = []
    self.agent_labels = []
    self.legend_elements = []

    self.show_compass = params.show_compass
    init_entries = params.init_entries

    self._process_weather()
    self._setup_figure()
    self._setup_grid(init_entries)

    if render:
        self.fig.canvas.draw()
        plt.pause(1)

    self.initial_state = self.fig.canvas.copy_from_bbox(self.h_ax.bbox)

close()

Close the visualization figure.

Source code in embrs/base_classes/base_visualizer.py
432
433
434
435
436
def close(self):
    """Close the visualization figure."""
    if self.fig and plt.fignum_exists(self.fig.number):
        plt.close(self.fig)
        self.fig = None

meters_to_points(meters)

Convert meters to matplotlib points for sizing elements.

Parameters:

Name Type Description Default
meters float

Distance in meters.

required

Returns:

Name Type Description
float float

Equivalent size in matplotlib points.

Source code in embrs/base_classes/base_visualizer.py
609
610
611
612
613
614
615
616
617
618
619
620
621
def meters_to_points(self, meters: float) -> float:
    """Convert meters to matplotlib points for sizing elements.

    Args:
        meters (float): Distance in meters.

    Returns:
        float: Equivalent size in matplotlib points.
    """
    fig_width_inch, _ = self.fig.get_size_inches()
    meters_per_inch = self.width_m / fig_width_inch
    meters_per_point = meters_per_inch / 72
    return meters / meters_per_point

reset_figure(done=False)

Reset the visualization figure.

Parameters:

Name Type Description Default
done bool

If True, only closes without reinitializing. Defaults to False.

False
Source code in embrs/base_classes/base_visualizer.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def reset_figure(self, done=False):
    """Reset the visualization figure.

    Args:
        done (bool, optional): If True, only closes without reinitializing.
            Defaults to False.
    """
    self.close()
    if done:
        return
    self.h_ax.clear()
    self._setup_figure()
    self._init_static_elements()
    self.h_ax.add_collection(self.all_cells_coll)
    self.fig.canvas.draw()
    self.initial_state = self.fig.canvas.copy_from_bbox(self.h_ax.bbox)
    self.fig.canvas.blit(self.h_ax.bbox)
    self.fig.canvas.flush_events()

update_grid(sim_time_s, entries, agents=[], actions=[])

Update the grid display with new cell states.

Parameters:

Name Type Description Default
sim_time_s float

Current simulation time in seconds.

required
entries list[CellLogEntry]

Updated cell state entries.

required
agents list[AgentLogEntry]

Agent positions. Defaults to [].

[]
actions list[ActionsEntry]

Active control actions. Defaults to [].

[]
Source code in embrs/base_classes/base_visualizer.py
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
def update_grid(self, sim_time_s: float, entries: list[CellLogEntry], agents: list[AgentLogEntry] = [], actions: list[ActionsEntry] = []) -> None:
    """Update the grid display with new cell states.

    Args:
        sim_time_s (float): Current simulation time in seconds.
        entries (list[CellLogEntry]): Updated cell state entries.
        agents (list[AgentLogEntry], optional): Agent positions. Defaults to [].
        actions (list[ActionsEntry], optional): Active control actions. Defaults to [].
    """
    # Update wind field if needed
    self._update_wind_field(sim_time_s)

    # Update weather data if needed
    weather_idx = int(np.floor(sim_time_s / self.forecast_t_step))
    if self.show_weather_data and weather_idx != self.forecast_idx and weather_idx < len(self.temp_forecast):
        self.forecast_idx = weather_idx
        temp_unit = "F" if self.show_temp_in_F else "C"
        t_rounded = np.round(float(self.temp_forecast[self.forecast_idx]), 1)
        rh_rounded = np.round(float(self.rh_forecast[self.forecast_idx]), 1)
        weather_str = f"Temp: {t_rounded} °{temp_unit}, RH: {rh_rounded} %"
        self.weather_text.set_text(weather_str)

    # Update only changed cells
    for entry in entries:
        idx = self.cell_id_to_index[entry.id]
        self.cell_colors[idx] = self._get_cell_color(entry)

    self.all_cells_coll.set_facecolors(self.cell_colors)

     # Draw dynamic elements
    if agents:
        for agent_art in self.agent_art:
            agent_art.remove()
        self.agent_art.clear()

        for agent in agents:
            scatter = self.h_ax.scatter(agent.x, agent.y, marker=agent.marker, color=agent.color, zorder=5)
            self.agent_art.append(scatter)

    if actions:
        for action in actions:
            if action.action_type == 'fireline_construction':
                self.h_ax.plot(action.x_coords, action.y_coords, color='blue', linewidth=self.meters_to_points(action.width) * 5, zorder=4)
            elif action.action_type == 'long_term_retardant':
                if self.retardant_art:
                    self.retardant_art.remove()
                self.retardant_art = self.h_ax.scatter(action.x_coords, action.y_coords, marker='h', s=self.meters_to_points(6*self.cell_size), c=action.effectiveness, cmap='Reds_r', vmin=0, vmax=1, zorder=4)
            elif action.action_type == 'short_term_suppressant':
                if self.water_drop_art:
                    self.water_drop_art.remove()
                self.water_drop_art = self.h_ax.scatter(action.x_coords, action.y_coords, marker='h', s=self.meters_to_points(6*self.cell_size), c=action.effectiveness, cmap='Blues', vmin=0.5, vmax=1, zorder=4)


    # Update time displays
    sim_datetime = self._start_datetime + timedelta(seconds=sim_time_s)
    self.datetime_text.set_text(sim_datetime.strftime("%Y-%m-%d %H:%M"))
    self.elapsed_text.set_text(util.get_time_str(sim_time_s))

    if self.render:
        self.fig.canvas.restore_region(self.initial_state)
        self.h_ax.draw_artist(self.all_cells_coll)
        self.fig.canvas.blit(self.h_ax.bbox)
        self.fig.canvas.flush_events()
        plt.pause(0.001)

visualize_ensemble_prediction(burn_probability)

Visualize ensemble burn probability overlay.

Displays the final burn probability from an ensemble prediction, with color intensity indicating probability of burning.

Parameters:

Name Type Description Default
burn_probability dict

Dictionary mapping timestamps to dictionaries of {(x, y): probability} representing cumulative burn probability at each time step. Uses the final time step.

required
Source code in embrs/base_classes/base_visualizer.py
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
def visualize_ensemble_prediction(self, burn_probability):
    """Visualize ensemble burn probability overlay.

    Displays the final burn probability from an ensemble prediction,
    with color intensity indicating probability of burning.

    Args:
        burn_probability (dict): Dictionary mapping timestamps to
            dictionaries of {(x, y): probability} representing cumulative
            burn probability at each time step. Uses the final time step.
    """
    # Clear any existing prediction visualization
    if hasattr(self, 'prediction_scatter'):
        self.prediction_scatter.remove()
        delattr(self, 'prediction_scatter')

    # Get the final time step (latest prediction)
    time_steps = sorted(burn_probability.keys())
    if not time_steps:
        return

    final_time = time_steps[-1]
    final_probs = burn_probability[final_time]

    if not final_probs:
        return

    # Extract points and their probabilities
    points = list(final_probs.keys())
    probs = [final_probs[point] for point in points]

    # Create colormap for probabilities (0 to 1)
    cmap = mpl.cm.get_cmap("hot")  # 'hot' colormap: dark to bright
    norm = plt.Normalize(0, 1)

    x, y = zip(*points)
    self.prediction_scatter = self.h_ax.scatter(
        x, y, 
        c=probs, 
        cmap=cmap, 
        norm=norm, 
        s=1, 
        zorder=1,
        alpha=0.8
    )

    # Add colorbar if not already present
    if not hasattr(self, 'prediction_colorbar'):
        self.prediction_colorbar = self.fig.colorbar(
            self.prediction_scatter, 
            ax=self.h_ax, 
            label='Burn Probability'
        )
    else:
        self.prediction_colorbar.update_normal(self.prediction_scatter)

    self.fig.canvas.draw()

visualize_prediction(prediction)

Visualize a fire spread prediction overlay.

Displays predicted fire arrival times as colored points on the grid, with colors indicating arrival time.

Parameters:

Name Type Description Default
prediction dict

Dictionary mapping timestamps (seconds) to lists of (x, y) coordinate tuples where fire is predicted to arrive.

required
Source code in embrs/base_classes/base_visualizer.py
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
def visualize_prediction(self, prediction):
    """Visualize a fire spread prediction overlay.

    Displays predicted fire arrival times as colored points on the grid,
    with colors indicating arrival time.

    Args:
        prediction (dict): Dictionary mapping timestamps (seconds) to lists
            of (x, y) coordinate tuples where fire is predicted to arrive.
    """
    # Clear any existing prediction visualization
    if hasattr(self, 'prediction_scatter'):
        self.prediction_scatter.remove()
        delattr(self, 'prediction_scatter')

    time_steps = sorted(prediction.keys())
    if not time_steps:
        return

    cmap = mpl.cm.get_cmap("viridis")
    norm = plt.Normalize(time_steps[0], time_steps[-1])

    # Collect all points and their corresponding times
    all_points = []
    all_times = []
    for time in time_steps:
        points = prediction[time]
        all_points.extend(points)
        all_times.extend([time] * len(points))

    if all_points:
        x, y = zip(*all_points)
        self.prediction_scatter = self.h_ax.scatter(x, y, c=all_times, cmap=cmap, norm=norm, 
                                                    s=1, zorder=1)
        self.fig.canvas.draw()