Skip to content

Tools Reference

The embrs.tools package provides standalone tools that build on top of the core simulation engine. The primary tool is FirePredictor, which runs forward fire spread predictions with uncertainty modeling and ensemble support. Supporting classes handle forecast pool management for reusing pre-computed wind forecasts across predictions, and serialization for efficient parallel execution.

The ensemble_video utility generates video visualizations of ensemble prediction output.

Fire Predictor

Fire prediction module for EMBRS.

Provides forward fire spread prediction with uncertainty modeling. Supports single predictions, ensemble predictions with parallel execution, and pre-computed forecast pools for efficient rollout scenarios.

Classes:

Name Description
- FirePredictor

Ensemble fire prediction with wind uncertainty.

.. autoclass:: FirePredictor :members:

Anderson13

Bases: Fuel

Anderson 13 standard fire behavior fuel models.

Load fuel properties from the bundled Anderson13.json data file. Model numbers 1-13 are burnable; higher numbers (91, 92, 93, 98, 99) represent non-burnable types.

The JSON data is cached at the class level and loaded only once.

Source code in embrs/models/fuel_models.py
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
class Anderson13(Fuel):
    """Anderson 13 standard fire behavior fuel models.

    Load fuel properties from the bundled ``Anderson13.json`` data file.
    Model numbers 1-13 are burnable; higher numbers (91, 92, 93, 98, 99)
    represent non-burnable types.

    The JSON data is cached at the class level and loaded only once.
    """

    _fuel_models = None  # class-level cache

    @classmethod
    def load_fuel_models(cls):
        """Load Anderson 13 fuel model data from the bundled JSON file.

        Data is cached at the class level after the first call.
        """
        if cls._fuel_models is None:
            json_path = os.path.join(os.path.dirname(__file__), "Anderson13.json")
            with open(json_path, "r") as f:
                cls._fuel_models = json.load(f)

    def __init__(self, model_number: int, live_h_mf: float = 0):
        """Initialize an Anderson 13 fuel model by model number.

        Args:
            model_number (int): Anderson fuel model number (1-13 for
                burnable, 91/92/93/98/99 for non-burnable).
            live_h_mf (float): Live herbaceous fuel moisture (fraction).
                Unused for Anderson 13 (not dynamic). Defaults to 0.

        Raises:
            ValueError: If ``model_number`` is not a valid Anderson 13 model.
        """
        self.load_fuel_models()

        model_number = int(model_number)

        model_id = str(model_number)
        if model_id not in self._fuel_models["names"]:
            raise ValueError(f"{model_number} is not a valid Anderson 13 model number")

        burnable = model_number <= 13
        name = self._fuel_models["names"][model_id]
        dynamic = False

        if not burnable:
            w_0 = None
            s = None
            s_total = None
            mx_dead = None
            fuel_bed_depth = None

        else:
            w_0 = np.array(self._fuel_models["w_0"][model_id])
            s = np.array(self._fuel_models["s"][model_id])
            s_total = self._fuel_models["s_total"][model_id]
            mx_dead = self._fuel_models["mx_dead"][model_id]
            fuel_bed_depth = self._fuel_models["fuel_bed_depth"][model_id]

        super().__init__(name, model_number, burnable, dynamic, w_0, s, s_total, mx_dead, fuel_bed_depth)

    def update_curing(self, live_h_mf: float) -> None:
        """Anderson 13 models have no dynamic curing — no-op."""
        pass

__init__(model_number, live_h_mf=0)

Initialize an Anderson 13 fuel model by model number.

Parameters:

Name Type Description Default
model_number int

Anderson fuel model number (1-13 for burnable, 91/92/93/98/99 for non-burnable).

required
live_h_mf float

Live herbaceous fuel moisture (fraction). Unused for Anderson 13 (not dynamic). Defaults to 0.

0

Raises:

Type Description
ValueError

If model_number is not a valid Anderson 13 model.

Source code in embrs/models/fuel_models.py
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
def __init__(self, model_number: int, live_h_mf: float = 0):
    """Initialize an Anderson 13 fuel model by model number.

    Args:
        model_number (int): Anderson fuel model number (1-13 for
            burnable, 91/92/93/98/99 for non-burnable).
        live_h_mf (float): Live herbaceous fuel moisture (fraction).
            Unused for Anderson 13 (not dynamic). Defaults to 0.

    Raises:
        ValueError: If ``model_number`` is not a valid Anderson 13 model.
    """
    self.load_fuel_models()

    model_number = int(model_number)

    model_id = str(model_number)
    if model_id not in self._fuel_models["names"]:
        raise ValueError(f"{model_number} is not a valid Anderson 13 model number")

    burnable = model_number <= 13
    name = self._fuel_models["names"][model_id]
    dynamic = False

    if not burnable:
        w_0 = None
        s = None
        s_total = None
        mx_dead = None
        fuel_bed_depth = None

    else:
        w_0 = np.array(self._fuel_models["w_0"][model_id])
        s = np.array(self._fuel_models["s"][model_id])
        s_total = self._fuel_models["s_total"][model_id]
        mx_dead = self._fuel_models["mx_dead"][model_id]
        fuel_bed_depth = self._fuel_models["fuel_bed_depth"][model_id]

    super().__init__(name, model_number, burnable, dynamic, w_0, s, s_total, mx_dead, fuel_bed_depth)

load_fuel_models() classmethod

Load Anderson 13 fuel model data from the bundled JSON file.

Data is cached at the class level after the first call.

Source code in embrs/models/fuel_models.py
298
299
300
301
302
303
304
305
306
307
@classmethod
def load_fuel_models(cls):
    """Load Anderson 13 fuel model data from the bundled JSON file.

    Data is cached at the class level after the first call.
    """
    if cls._fuel_models is None:
        json_path = os.path.join(os.path.dirname(__file__), "Anderson13.json")
        with open(json_path, "r") as f:
            cls._fuel_models = json.load(f)

update_curing(live_h_mf)

Anderson 13 models have no dynamic curing — no-op.

Source code in embrs/models/fuel_models.py
349
350
351
def update_curing(self, live_h_mf: float) -> None:
    """Anderson 13 models have no dynamic curing — no-op."""
    pass

Cell

Represents a hexagonal simulation cell in the wildfire model.

Each cell maintains its physical properties (elevation, slope, aspect), fuel characteristics, fire state, and interactions with neighboring cells. Cells are structured in a point-up hexagonal grid to model fire spread dynamics.

Attributes:

Name Type Description
id int

Unique identifier for the cell.

col int

Column index of the cell in the simulation grid.

row int

Row index of the cell in the simulation grid.

cell_size float

Edge length of the hexagonal cell (meters).

cell_area float

Area of the hexagonal cell (square meters).

x_pos float

X-coordinate of the cell in the simulation space (meters).

y_pos float

Y-coordinate of the cell in the simulation space (meters).

elevation_m float

Elevation of the cell (meters).

aspect float

Upslope direction in degrees (0° = North, 90° = East, etc.).

slope_deg float

Slope angle of the terrain at the cell (degrees).

fuel Fuel

Fire Behavior Fuel Model (FBFM) for the cell, from either Anderson 13 or Scott-Burgan 40.

state CellStates

Current fire state (FUEL, FIRE, BURNT).

neighbors dict

Dictionary of adjacent cell neighbors.

burnable_neighbors dict

Subset of neighbors that are in a burnable state.

forecast_wind_speeds list

Forecasted wind speeds in m/s.

forecast_wind_dirs list

Forecasted wind directions in degrees (cartesian).

Source code in embrs/fire_simulator/cell.py
  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
 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
class Cell:
    """Represents a hexagonal simulation cell in the wildfire model.

    Each cell maintains its physical properties (elevation, slope, aspect),
    fuel characteristics, fire state, and interactions with neighboring cells.
    Cells are structured in a **point-up** hexagonal grid to model fire spread dynamics.

    Attributes:
        id (int): Unique identifier for the cell.
        col (int): Column index of the cell in the simulation grid.
        row (int): Row index of the cell in the simulation grid.
        cell_size (float): Edge length of the hexagonal cell (meters).
        cell_area (float): Area of the hexagonal cell (square meters).
        x_pos (float): X-coordinate of the cell in the simulation space (meters).
        y_pos (float): Y-coordinate of the cell in the simulation space (meters).
        elevation_m (float): Elevation of the cell (meters).
        aspect (float): Upslope direction in degrees (0° = North, 90° = East, etc.).
        slope_deg (float): Slope angle of the terrain at the cell (degrees).
        fuel (Fuel): Fire Behavior Fuel Model (FBFM) for the cell, from either Anderson 13 or Scott-Burgan 40.
        state (CellStates): Current fire state (FUEL, FIRE, BURNT).
        neighbors (dict): Dictionary of adjacent cell neighbors.
        burnable_neighbors (dict): Subset of `neighbors` that are in a burnable state.
        forecast_wind_speeds (list): Forecasted wind speeds in m/s.
        forecast_wind_dirs (list): Forecasted wind directions in degrees (cartesian).
    """

    def __init__(self, id: int, col: int, row: int, cell_size: float):
        """Initialize a hexagonal cell with position and geometry.

        Creates a cell at the specified grid position and calculates its spatial
        coordinates based on the hexagonal grid layout. The cell is initialized
        with default values for fire state and fuel properties.

        Args:
            id (int): Unique identifier for this cell.
            col (int): Column index in the simulation grid.
            row (int): Row index in the simulation grid.
            cell_size (float): Edge length of the hexagon in meters.

        Notes:
            - Spatial position is calculated using point-up hexagon geometry.
            - For even rows: x = col * cell_size * sqrt(3)
            - For odd rows: x = (col + 0.5) * cell_size * sqrt(3)
            - y = row * cell_size * 1.5
        """
        self.id = id

        # Deterministic hash based on cell ID (not memory address) so that
        # set iteration order is reproducible across process invocations.
        self._hash = hash(id)

        # Set cell indices
        self._col = col
        self._row = row

        # x_pos, y_pos are the global position of cell in m
        _sqrt3 = 1.7320508075688772  # math.sqrt(3)
        if row % 2 == 0:
            self._x_pos = col * cell_size * _sqrt3
        else:
            self._x_pos = (col + 0.5) * cell_size * _sqrt3

        self._y_pos = row * cell_size * 1.5

        self._cell_size = cell_size # defined as the edge length of hexagon
        self._cell_area = self.calc_cell_area()

        # Flag to track if cell has been treated with long-term fire retardant
        self._retardant = False
        self._retardant_factor = 1.0 # Factor multiplied by rate of spread (0-1)
        self.retardant_expiration_s = -1.0 # Time at which long-term retardant in cell expires

        # Track the total amount of water dropped (if modelled as rain)
        self.local_rain = 0.0

        # Van Wagner energy-balance water suppression (Van Wagner & Taylor 2022)
        self.water_applied_kJ = 0.0   # Cumulative cooling energy from water drops
        self._vw_efficiency = 2.5     # Application efficiency multiplier (Table 4)

        # Width in meters of any fuel discontinuity within cell (road or firebreak)
        self._break_width = 0 

        # Variable to track if fuel discontinuity within cell can be breached
        self.breached = True

        # Track if firebrands have been lofted from cell
        self.lofted = False

        # Source cell coordinates if ignited by ember spotting (set by Embers.flight)
        self.spot_source_xy = None

        # Weak reference to parent BaseFire object
        self._parent = None

        self._arrival_time = -999

    def __hash__(self):
        return self._hash

    def set_parent(self, parent: BaseFireSim) -> None:
        """Sets the parent BaseFire object for this cell.

        Args:
            parent (BaseFireSim): The BaseFire object that owns this cell.
        """
        self._parent = weakref.ref(parent)

    def reset_to_fuel(self):
        """Reset cell to initial FUEL state, preserving terrain/fuel/geometry data.

        Resets all mutable fire-state attributes to their initial values as set
        by ``__init__`` and ``_set_cell_data``. Immutable properties such as
        position, fuel model, elevation, slope, aspect, canopy attributes,
        polygon, wind adjustment factor, and neighbor topology are preserved.

        This is used by FirePredictor to efficiently restore cells to a clean
        state between predictions, avoiding expensive deepcopy operations.

        Side Effects:
            - Resets fire state to CellStates.FUEL
            - Clears all spread tracking arrays
            - Resets suppression effects (retardant, rain, firebreaks)
            - Resets fuel moisture to initial values
            - Restores full neighbor set to _burnable_neighbors
        """
        # Fire state
        self._state = CellStates.FUEL
        self.fully_burning = False
        self._crown_status = CrownStatus.NONE
        self.cfb = 0
        self.reaction_intensity = 0

        # Partial suppression state
        self.disabled_locs = set()
        self._suppression_count = 0

        # Spread arrays — shared read-only constants (replaced when cell ignites)
        self.distances = None
        self.directions = None
        self.end_pts = None
        self._ign_n_loc = None
        self._self_end_points = None
        self.r_h_ss = None
        self.I_h_ss = None
        self.r_t = _RESET_ARRAY_ZERO
        self.fire_spread = _RESET_ARRAY_EMPTY
        self.avg_ros = _RESET_ARRAY_EMPTY
        self.r_ss = _RESET_ARRAY_EMPTY
        self.I_ss = _RESET_ARRAY_ZERO
        self.I_t = _RESET_ARRAY_ZERO
        self.intersections = _RESET_IXN_EMPTY
        self.e = 0
        self.alpha = None
        self.has_steady_state = False

        # Suppression effects — back to __init__ defaults
        self._retardant = False
        self._retardant_factor = 1.0
        self.retardant_expiration_s = -1.0
        self.local_rain = 0.0
        self.water_applied_kJ = 0.0
        self._vw_efficiency = 2.5
        self._break_width = 0
        self.breached = True
        self.lofted = False
        self.spot_source_xy = None
        self._arrival_time = -999

        # Wind forecast
        self.forecast_wind_speeds = []
        self.forecast_wind_dirs = []

        # Moisture — reset to initial values (use cached template if available)
        self.moist_update_time_s = 0
        if self._fuel.burnable:
            if self.dfms:
                for dfm in self.dfms:
                    dfm.initializeStick()
            # Reset moisture array from cached initial values
            self.fmois = self._init_fmois.copy()

        # Restore full neighbor set (remove_neighbors modifies _burnable_neighbors during sim)
        self._burnable_neighbors = dict(self._neighbors)

    def compute_disabled_locs(self):
        """Compute boundary locations consumed by current fire and add to disabled_locs.

        Must be called BEFORE fire-state arrays are cleared. Uses three rules
        to determine which boundary locations (1-12) have been consumed.

        Rule 1: Entry point is consumed.
        Rule 2: Each crossed intersection's exit boundary location is consumed.
        Rule 3: For corner ignitions, adjacent midpoints are consumed if fire
                 has spread past half the distance in that direction.
        """
        n_loc = self._ign_n_loc
        if n_loc is None:
            return

        # Rule 1 — entry point
        if n_loc != 0:
            self.disabled_locs.add(n_loc)

        # Rule 2 — crossed intersections → exit boundary locations
        if self._self_end_points is not None:
            for i in range(len(self.intersections)):
                if self.intersections[i]:
                    self.disabled_locs.add(self._self_end_points[i])

        # Rule 3 — corner adjacent midpoints (even, nonzero n_loc only)
        if n_loc != 0 and n_loc % 2 == 0:
            if len(self.fire_spread) > 0 and self.distances is not None and len(self.distances) > 0:
                if self.fire_spread[0] > self.distances[0] / 2:
                    self.disabled_locs.add((n_loc % 12) + 1)
                if self.fire_spread[-1] > self.distances[-1] / 2:
                    self.disabled_locs.add(((n_loc - 2) % 12) + 1)

    def suppress_to_fuel(self):
        """Suppress cell back to FUEL state, preserving moisture and disabled_locs.

        Similar to reset_to_fuel() but preserves:
        - disabled_locs (accumulated consumed boundary locations)
        - _suppression_count (incremented)
        - Fuel moisture state (fmois, dfms, moist_update_time_s)

        Clears fire-state arrays, VW water state, and crown fire state.
        Restores burnable_neighbors from full neighbor set.
        """
        # Fire state
        self._state = CellStates.FUEL
        self.fully_burning = False
        self._crown_status = CrownStatus.NONE
        self.cfb = 0
        self.reaction_intensity = 0

        # Spread arrays
        self.distances = None
        self.directions = None
        self.end_pts = None
        self._ign_n_loc = None
        self._self_end_points = None
        self.r_h_ss = None
        self.I_h_ss = None
        self.r_t = _RESET_ARRAY_ZERO
        self.fire_spread = _RESET_ARRAY_EMPTY
        self.avg_ros = _RESET_ARRAY_EMPTY
        self.r_ss = _RESET_ARRAY_EMPTY
        self.I_ss = _RESET_ARRAY_ZERO
        self.I_t = _RESET_ARRAY_ZERO
        self.intersections = _RESET_IXN_EMPTY
        self.e = 0
        self.alpha = None
        self.has_steady_state = False

        # Clear VW water suppression state
        self.water_applied_kJ = 0.0
        self._vw_efficiency = 2.5

        # Restore full neighbor set
        self._burnable_neighbors = dict(self._neighbors)

        # Increment suppression count
        self._suppression_count += 1

    @property
    def n_disabled_locs(self) -> int:
        """Number of boundary locations disabled by prior suppression."""
        return len(self.disabled_locs)

    def _set_cell_data(self, cell_data: CellData):
        """Configure cell properties from terrain and fuel data.

        Initializes the cell's physical properties, fuel characteristics, and
        fire spread parameters from a CellData object. This method is called
        during simulation setup after the cell geometry is established.

        Args:
            cell_data (CellData): Container with fuel type, elevation, slope,
                aspect, canopy properties, and initial moisture fractions.

        Side Effects:
            - Sets terrain attributes (elevation, slope, aspect).
            - Sets canopy attributes and calculates wind adjustment factor.
            - Initializes fuel moisture arrays for dead and live fuels.
            - Creates the Shapely polygon representation.
            - Sets cell state to CellStates.FUEL.
        """

        # Set Fuel type
        self._fuel = cell_data.fuel_type

        # z is the elevation of cell in m
        self._elevation_m = cell_data.elevation

        # Upslope direction in degrees - 0 degrees = North
        self.aspect = cell_data.aspect

        # Slope angle in degrees
        self.slope_deg = cell_data.slope_deg

        # Canopy cover as a percentage
        self.canopy_cover = cell_data.canopy_cover

        # Canopy height in meters
        self.canopy_height = cell_data.canopy_height

        # Canopy base height in meters
        self.canopy_base_height = cell_data.canopy_base_height

        # Canopy bulk density in kg/m^3
        self.canopy_bulk_density = cell_data.canopy_bulk_density

        # Check if cell has a canopy
        self.has_canopy = self.canopy_height > 0 and self.canopy_cover > 0

        # Wind adjustment factor based on sheltering condition
        self._set_wind_adj_factor()

        # Set to true when fire has spread across entire cell
        self.fully_burning = False

        self.init_dead_mf = cell_data.init_dead_mf
        self.init_live_h_mf = cell_data.live_h_mf
        self.init_live_w_mf = cell_data.live_w_mf

        self.reaction_intensity = 0

        if self.fuel.burnable:
            self.set_arrays()

        # Default state is fuel
        self._state = CellStates.FUEL

        # Crown fire attribute
        self._crown_status = CrownStatus.NONE

        # Crown fraction burned
        self.cfb = 0

        # Dictionaries to store neighbors
        self._neighbors = {}
        self._burnable_neighbors = {}

        # Last time the moisture was updated in cell
        self.moist_update_time_s = 0

        # Polygon is set externally via batch creation for performance
        self.polygon = None

        # Variables that define spread directions within cell
        self.distances = None
        self.directions = None
        self.end_pts = None
        self._ign_n_loc = None  # Set when cell ignites via get_ign_params()

        # Heading rate of spread and fireline intensity
        self.r_h_ss = None
        self.I_h_ss = None

        # Variables that keep track of elliptical spread within cell
        self.r_t = np.array([0])
        self.fire_spread = np.array([])
        self.avg_ros = np.array([]) # Average rate of spread for current time step
        self.r_ss = np.array([])
        self.I_ss = np.array([0])
        self.I_t = np.array([0])
        self.intersections = np.zeros(0, dtype=np.bool_)
        self.e = 0
        self.alpha = None

        # Partial suppression state
        self.disabled_locs = set()  # boundary locations (1-12) consumed by prior burns
        self._suppression_count = 0
        self._self_end_points = None  # set by get_ign_params, used for disabled-exit checks

        # Boolean defining the cell already has a steady-state ROS
        self.has_steady_state = False

        # Wind forecast and current wind within cell
        self.forecast_wind_speeds = []
        self.forecast_wind_dirs = []

        # Constant defining fire acceleration characteristics
        self.a_a = 0.3 / 60

    def set_arrays(self):
        """Initialize fuel moisture tracking arrays for this cell.

        DFM objects are created lazily on first moisture update to avoid
        allocating ~12 numpy arrays per object for cells that never burn.

        Side Effects:
            - Sets self.wdry and self.sigma from fuel model properties.
            - Initializes self.fmois array with initial moisture fractions.
            - Sets self._dfms_needed tuple for lazy DFM creation.
        """
        indices = self._fuel.rel_indices

        self.wdry = self._fuel.w_n[self._fuel.rel_indices]
        self.sigma = self._fuel.s[self._fuel.rel_indices]

        # Defer DFM creation — store which classes are needed
        self.dfms = []
        self._dfms_needed = (0 in indices, 1 in indices, 2 in indices)

        fmois = np.zeros(6)

        if 0 in indices:
            fmois[0] = self.init_dead_mf[0]
        if 1 in indices:
            fmois[1] = self.init_dead_mf[1]
        if 2 in indices:
            fmois[2] = self.init_dead_mf[2]
        if 3 in indices:
            fmois[3] = self.init_dead_mf[0]
        if 4 in indices:
            fmois[4] = self.init_live_h_mf
        if 5 in indices:
            fmois[5] = self.init_live_w_mf

        self.fmois = np.array(fmois)
        # Cache initial fmois for efficient reset_to_fuel
        self._init_fmois = self.fmois.copy()

    def _ensure_dfms(self):
        """Create DeadFuelMoisture objects on demand (lazy initialization).

        Called before first moisture update. Uses template cloning for fast
        object creation.
        """
        if self.dfms:
            return  # Already initialized

        need_1hr, need_10hr, need_100hr = self._dfms_needed
        if need_1hr:
            self.dfm1 = DeadFuelMoisture.createDeadFuelMoisture1()
            self.dfms.append(self.dfm1)
        if need_10hr:
            self.dfm10 = DeadFuelMoisture.createDeadFuelMoisture10()
            self.dfms.append(self.dfm10)
        if need_100hr:
            self.dfm100 = DeadFuelMoisture.createDeadFuelMoisture100()
            self.dfms.append(self.dfm100)

    def project_distances_to_surf(self, distances: np.ndarray):
        """Project horizontal distances onto the sloped terrain surface.

        Adjusts the flat-ground distances to each cell edge by accounting for
        the slope and aspect of the terrain. This ensures fire spread distances
        are measured along the actual terrain surface.

        Args:
            distances (np.ndarray): Horizontal distances to cell edges in meters.

        Side Effects:
            - Sets self.distances to the slope-adjusted distances in meters.
        """
        slope_rad = np.deg2rad(self.slope_deg)
        aspect = (self.aspect + 180) % 360
        deltas = np.deg2rad(aspect - np.array(self.directions))
        proj = np.sqrt(np.cos(deltas) ** 2 * np.cos(slope_rad) ** 2 + np.sin(deltas) ** 2)
        self.distances = distances / proj

    def get_ign_params(self, n_loc: int):
        """Calculate fire spread directions and distances from an ignition location.

        Computes the radial spread directions from the specified ignition point
        within the cell to each edge or vertex. Initializes arrays for tracking
        rate of spread and fireline intensity in each direction.

        Args:
            n_loc (int): Ignition location index within the cell.
                0=center, 1-6=vertices, 7-12=edge midpoints.

        Side Effects:
            - Sets self.directions: array of compass directions in degrees.
            - Sets self.distances: slope-adjusted distances to cell boundaries.
            - Sets self.end_pts: coordinates of cell boundary points.
            - Initializes self.avg_ros, self.I_t, self.r_t to zero arrays.
        """
        self._ign_n_loc = n_loc
        self.directions, distances, self.end_pts = UtilFuncs.get_ign_parameters(n_loc, self.cell_size)
        self._self_end_points = _derive_self_end_points(n_loc)
        self.project_distances_to_surf(distances)
        self.avg_ros = np.zeros_like(self.directions)
        self.I_t = np.zeros_like(self.directions)
        self.r_t = np.zeros_like(self.directions)

    def _set_wind_forecast(self, wind_speed: np.ndarray, wind_dir: np.ndarray):
        """Store the local forecasted wind speed and direction for the cell.

        Args:
            wind_speed (np.ndarray): Array of forecasted wind speeds in m/s.
            wind_dir (np.ndarray): Array of wind directions in degrees, using
                cartesian convention (0° = blowing toward North/+y,
                90° = blowing toward East/+x).

        Side Effects:
            - Sets self.forecast_wind_speeds with the speed array.
            - Sets self.forecast_wind_dirs with the direction array.
        """ 
        self.forecast_wind_speeds = wind_speed
        self.forecast_wind_dirs = wind_dir

    def _step_moisture(self, weather_stream: WeatherStream, idx: int, update_interval_hr: float = 1):
        """Advance dead fuel moisture calculations by one time interval.

        Updates the moisture content of each dead fuel size class using the
        Nelson dead fuel moisture model. Applies site-specific corrections
        for elevation and calculates local solar radiation.

        Args:
            weather_stream (WeatherStream): Weather data source with temperature,
                humidity, and solar radiation.
            idx (int): Index into the weather stream for current conditions.
            update_interval_hr (float): Time step for moisture update in hours.

        Side Effects:
            - Updates internal state of each DeadFuelMoisture object in self.dfms.
        """
        # Lazy DFM creation — only allocate when first needed
        if not self.dfms:
            self._ensure_dfms()

        elev_ref = weather_stream.ref_elev

        curr_weather = weather_stream.stream[idx]

        bp0 = 0.0218

        t_f_celsius, h_f_frac = apply_site_specific_correction(self, elev_ref, curr_weather)
        solar_radiation = calc_local_solar_radiation(self, curr_weather)

        for i, dfm in enumerate(self.dfms):
            if not dfm.initialized():
                dfm.initializeEnvironment(
                    t_f_celsius, # Intial ambient air temeperature
                    h_f_frac, # Initial ambient air rel. humidity (g/g)
                    solar_radiation, # Initial solar radiation (W/m^2)
                    0, # Initial cumulative rainfall (cm)
                    t_f_celsius, # Initial stick temperature (degrees C)
                    h_f_frac, # Intial stick surface relative humidity (g/g)
                    self.init_dead_mf[0], # Initial stick fuel moisture fraction (g/g)
                    bp0) # Initial stick barometric pressure (cal/cm^3)

            dfm.update_internal(
                update_interval_hr, # Elapsed time since the previous observation (hours)
                t_f_celsius, # Current observation's ambient air temperature (degrees C)
                h_f_frac, # Current observation's ambient air relative humidity (g/g)
                solar_radiation, # Current observation's solar radiation (W/m^2)
                curr_weather.rain + self.local_rain, # Current observation's total cumulative rainfall (cm)
                bp0) # Current observation's stick barometric pressure (cal/cm^3)

    def _update_moisture(self, idx: float, weather_stream: WeatherStream):
        """Update fuel moisture content to the current weather interval.

        Advances the moisture model to the midpoint of the specified weather
        interval and updates the moisture content array. For dynamic fuel models,
        also updates dead herbaceous moisture.

        Args:
            idx (float): Weather interval index (0-based).
            weather_stream (WeatherStream): Weather data source.

        Side Effects:
            - Updates self.fmois array with current moisture fractions.
            - May update dead herbaceous moisture for dynamic fuel models.
        """
        # Make target time the midpoint of the weather interval
        target_time_s = (idx + 0.5) * self._parent().weather_t_step  # Convert index to seconds

        self._catch_up_moisture_to_curr(target_time_s, weather_stream)

        # Update moisture content for each dead fuel moisture class
        self.fmois[0:len(self.dfms)] = [dfm.meanWtdMoisture() for dfm in self.dfms]

        if self.fuel.dynamic:
            # Set dead herbaceous moisture to 1-hr value
            self.fmois[3] = self.fmois[0]

        # Sync live fuel moisture from sim-level GSI-derived values
        # Only when dynamic GSI is active (use_gsi=True); per-fuel-type
        # overrides from fms_has_live are left untouched.
        # Uses getattr for safety when parent is a FirePredictor.
        parent = self._parent()
        if getattr(parent, '_gsi_tracker', None) is not None:
            rel = self._fuel.rel_indices
            if 4 in rel:
                self.fmois[4] = parent._live_h_mf
            if 5 in rel:
                self.fmois[5] = parent._live_w_mf

    def _catch_up_moisture_to_curr(self, target_time_s: float, weather_stream: WeatherStream):
        """Advance moisture calculations from last update time to target time.

        Steps through weather intervals between the last moisture update and
        the target time, calling _step_moisture for each interval. Handles
        partial intervals at the start and end.

        Args:
            target_time_s (float): Target simulation time in seconds.
            weather_stream (WeatherStream): Weather data source.

        Side Effects:
            - Updates self.moist_update_time_s to target_time_s.
            - Updates internal state of DeadFuelMoisture objects.
        """
        if self.moist_update_time_s >= target_time_s:
            return

        curr = self.moist_update_time_s
        weather_t_step = self._parent().weather_t_step # seconds

        # Align to next weather boundary
        interval_end_s = ((curr // weather_t_step) + 1) * weather_t_step
        if interval_end_s > target_time_s:
            interval_end_s = target_time_s

        if curr < interval_end_s:
            idx = int(curr // weather_t_step)
            dt_hr = (interval_end_s - curr) / 3600  # Convert seconds to hours
            self._step_moisture(weather_stream, idx, update_interval_hr=dt_hr)
            curr = interval_end_s

        # Take full interval steps until we reach the target time
        while curr + weather_t_step <= target_time_s:
            idx = int(curr // weather_t_step)
            self._step_moisture(weather_stream, idx, update_interval_hr=weather_t_step / 3600)
            curr += weather_t_step

        if curr < target_time_s:
            idx = int(curr // weather_t_step)
            dt_hr = (target_time_s - curr) / 3600  # Convert seconds to hours
            self._step_moisture(weather_stream, idx, update_interval_hr=dt_hr)
            curr = target_time_s

        self.moist_update_time_s = curr

    def curr_wind(self) -> tuple:
        """Get the current wind speed and direction at this cell.

        Returns the wind conditions for the current weather interval from the
        cell's local wind forecast. For prediction runs, may trigger forecast
        updates if needed.

        Returns:
            tuple: (wind_speed, wind_direction) where speed is in m/s and
                direction is in degrees using cartesian convention
                (0° = blowing toward North/+y, 90° = blowing toward East/+x).
        """
        parent = self._parent()
        w_idx = parent._curr_weather_idx - parent.sim_start_w_idx

        if parent.is_prediction() and len(self.forecast_wind_speeds) <= w_idx:
            parent._set_prediction_forecast(self)

        # Defensive clamp: prevent index-out-of-bounds on forecast arrays
        if w_idx >= len(self.forecast_wind_speeds):
            import warnings
            warnings.warn(
                f"Cell.curr_wind: w_idx ({w_idx}) >= forecast length "
                f"({len(self.forecast_wind_speeds)}) for cell {self.id}. "
                f"Clamping to last available index."
            )
            w_idx = len(self.forecast_wind_speeds) - 1

        curr_wind = (self.forecast_wind_speeds[w_idx], self.forecast_wind_dirs[w_idx])

        return curr_wind

    def _set_elev(self, elevation: float):
        """Sets the elevation of the cell.

        Args:
            elevation (float): Elevation of the terrain at the cell location, in meters.

        Side Effects:
            - Updates the `elevation_m` attribute with the new elevation value.
        """
        self.elevation_m = elevation

    def _set_slope(self, slope: float):
        """Sets the slope of the cell.

        The slope represents the steepness of the terrain at the cell's location.

        Args:
            slope (float): The slope of the terrain in degrees, where 0° represents 
                        flat terrain and higher values indicate steeper inclines.

        Side Effects:
            - Updates the `slope_deg` attribute with the new slope value.
        """
        self.slope_deg = slope

    def _set_aspect(self, aspect: float):
        """Sets the aspect (upslope direction) of the cell.

        Aspect is the compass direction that the upslope direction faces, measured in degrees:
        - 0° = North
        - 90° = East
        - 180° = South
        - 270° = West

        Args:
            aspect (float): The aspect of the terrain in degrees.

        Side Effects:
            - Updates the `aspect` attribute with the new aspect value.
        """
        self.aspect = aspect


    def _set_canopy_cover(self, canopy_cover: float):
        """Sets the canopy cover (as a percentage) of the cell

        Args:
            canopy_cover (float): canopy cover as a percentage
        """
        self.canopy_cover = canopy_cover

    def _set_canopy_height(self, canopy_height: float):
        """Sets the average top of canopy height within the cell in meters

        Args:
            canopy_height (float): average top of canopy height within the cell in meters
        """
        self.canopy_height = canopy_height

    def _set_canopy_base_height(self, canopy_base_height: float):
        """Set the canopy base height for the cell.

        Args:
            canopy_base_height (float): Height from ground to bottom of canopy
                in meters.

        Side Effects:
            - Updates the canopy_base_height attribute.
        """
        self.canopy_base_height = canopy_base_height


    def _set_canopy_bulk_density(self, canopy_bulk_density: float):
        """Set the canopy bulk density for the cell.

        Args:
            canopy_bulk_density (float): Mass of canopy fuel per unit volume
                in kg/m^3.

        Side Effects:
            - Updates the canopy_bulk_density attribute.
        """
        self.canopy_bulk_density = canopy_bulk_density

    def _set_wind_adj_factor(self):
        """Calculate and set the wind adjustment factor for this cell.

        Computes the wind adjustment factor (WAF) based on fuel type and canopy
        characteristics using equations from Albini and Baughman (1979).

        For non-burnable fuels, WAF is set to 1. For burnable fuels:
        - Unsheltered (canopy cover <= 5%): uses fuel depth
        - Sheltered (canopy cover > 5%): uses canopy height and crown fill

        Side Effects:
            - Sets self.wind_adj_factor.
        """

        if not self.fuel.burnable:
            self.wind_adj_factor = 1

        else:
            # Calcuate crown fill portion
            f = (self.canopy_cover / 100) * (np.pi / 12)

            if f <= 0.05:
                # Use un-sheltered WAF equation
                H = self.fuel.fuel_depth_ft
                self.wind_adj_factor = 1.83 / np.log((20 + 0.36*H)/(0.13*H))

            else:
                # Use sheltered WAF equation
                H = self.canopy_height
                self.wind_adj_factor = 0.555/(np.sqrt(f*3.28*H) * np.log((20 + 1.18*H)/(0.43*H)))

    def _set_fuel_type(self, fuel_type: Fuel):
        """Sets the fuel type of the cell based on the selected Fuel Model.

        The fuel type determines the fire spread characteristics of the cell.

        Args:
            fuel_type (Fuel): The fuel classification, based on the Anderson
                                    or Scott Burgan standard fire behavior fuel models.

        Side Effects:
            - Updates the `_fuel` attribute with the specified fuel model.
        """
        self._fuel = fuel_type

        if self._fuel.burnable:
            self.set_arrays()

    def _set_state(self, state: CellStates):
        """Sets the state of the cell.

        The state represents the fire condition of the cell and must be one of:
        - `CellStates.FUEL`: The cell contains unburned fuel.
        - `CellStates.FIRE`: The cell is actively burning.
        - `CellStates.BURNT`: The cell has already burned.

        Args:
            state (CellStates): The new state of the cell.

        Side Effects:
            - Updates the `_state` attribute with the given state.
            - If the state is `CellStates.FIRE`, initializes fire spread-related attributes:
                - `fire_spread`: Tracks fire spread extent in different directions.
                - `r_ss`: Stores steady-state rates of spread.
                - `I_ss`: Stores steady-state fireline intensity values.
        """
        self._state = state

        if self._state == CellStates.FIRE:
            n_dirs = len(self.directions)
            self.fire_spread = np.zeros(n_dirs)
            self.r_ss = np.zeros(n_dirs)
            self.I_ss = np.zeros(n_dirs)
            self.intersections = np.zeros(n_dirs, dtype=np.bool_)

            self.r_h_ss = 0
            self.I_h_ss = 0

    def add_retardant(self, duration_hr: float, effectiveness: float):
        """Apply long-term fire retardant to this cell.

        Marks the cell as treated with retardant, which reduces the rate of
        spread by the effectiveness factor until the retardant expires.

        Args:
            duration_hr (float): Duration of retardant effectiveness in hours.
            effectiveness (float): Reduction factor for rate of spread (0.0-1.0).
                A value of 0.5 reduces ROS by 50%.

        Raises:
            ValueError: If effectiveness is not in range [0, 1].

        Side Effects:
            - Sets self._retardant to True.
            - Sets self._retardant_factor to (1 - effectiveness).
            - Sets self.retardant_expiration_s to expiration time.
        """
        if effectiveness < 0 or effectiveness > 1:
            raise ValueError(f"Retardant effectiveness must be between 0 and 1 ({effectiveness} passed in)")

        self._retardant = True
        self._retardant_factor = 1 - effectiveness

        self.retardant_expiration_s = self._parent().curr_time_s + (duration_hr * 3600)

    def water_drop_as_rain(self, water_depth_cm: float, duration_s: float = 30):
        """Apply a water drop modeled as equivalent rainfall.

        Simulates water delivery by treating the water as cumulative
        rainfall input to the fuel moisture model. Updates moisture state
        immediately.

        Args:
            water_depth_cm (float): Equivalent water depth in centimeters.
            duration_s (float): Duration of the water application in seconds.

        Side Effects:
            - Updates self.local_rain with accumulated water depth.
            - Advances moisture model through the application period.
            - Updates self.fmois with new moisture fractions.

        Notes:
            - No effect on non-burnable fuel types.
        """
        if not self.fuel.burnable:
            return

        parent = self._parent()
        now_idx = parent._curr_weather_idx
        now_s = parent.curr_time_s
        weather_stream = parent._weather_stream

        # 1. Step moisture from last update to the current time
        self._catch_up_moisture_to_curr(now_s, weather_stream)

        # 2. Increment local rain
        self.local_rain += water_depth_cm

        # 3. Step moisture from current time to end of local rain interval
        interval_hr = duration_s / 3600 # Convert seconds to hours
        self._step_moisture(weather_stream, now_idx, update_interval_hr=interval_hr)

        # 4. Store the time so that next update just updates from current time over a weather interval
        self.moist_update_time_s = now_s + duration_s

         # Update moisture content for each dead fuel moisture class
        self.fmois[0:len(self.dfms)] = [dfm.meanWtdMoisture() for dfm in self.dfms]

        if self.fuel.dynamic:
            # Set dead herbaceous moisture to 1-hr value
            self.fmois[3] = self.fmois[0]

        self.has_steady_state = False

    def water_drop_as_moisture_bump(self, moisture_bump: float):
        """Apply a water drop as a direct fuel moisture increase.

        Simulates water delivery by directly increasing the outer node moisture
        of each dead fuel class, then advances the moisture model briefly to
        allow diffusion.

        Args:
            moisture_bump (float): Moisture fraction to add to fuel surface.

        Side Effects:
            - Increases outer node moisture on each DeadFuelMoisture object.
            - Advances moisture model by 30 seconds.
            - Updates self.fmois with new moisture fractions.

        Notes:
            - No effect on non-burnable fuel types.
        """
        if not self.fuel.burnable:
            return

        if not self.dfms:
            self._ensure_dfms()

        # Catch cell up to current time
        parent = self._parent()
        now_idx = parent._curr_weather_idx
        now_s = parent.curr_time_s
        weather_stream = parent._weather_stream
        self._catch_up_moisture_to_curr(now_s, weather_stream)

        for dfm in self.dfms:
            # Add moisture bump to outer node of each dead fuel moisture class
            dfm.m_w[0] += moisture_bump
            dfm.m_w[0] = max(dfm.m_w[0], dfm.m_wmx)

        self._step_moisture(weather_stream, now_idx, update_interval_hr=30/3600)

        # Update moisture content for each dead fuel moisture class
        self.fmois[0:len(self.dfms)] = [dfm.meanWtdMoisture() for dfm in self.dfms]

        if self.fuel.dynamic:
            # Set dead herbaceous moisture to 1-hr value
            self.fmois[3] = self.fmois[0]

        self.has_steady_state = False

    def water_drop_vw(self, volume_L: float, efficiency: float = 2.5,
                       T_a: float = 20.0):
        """Apply water using Van Wagner (2022) energy-balance model.

        Converts water volume to cooling energy (Eq. 1b) and accumulates in
        water_applied_kJ. Moisture injection is applied during iterate() by
        apply_vw_suppression().

        Args:
            volume_L: Water volume in liters (1 L = 1 kg).
            efficiency: Application efficiency multiplier (Table 4, Van Wagner
                2022). Typical range 2.0–4.0. Default 2.5.
            T_a: Ambient air temperature in °C. Default 20.

        Raises:
            ValueError: If volume_L < 0.
        """
        if volume_L < 0:
            raise ValueError(f"Water volume must be >= 0, got {volume_L}")

        if not self.fuel.burnable:
            return

        from embrs.models.van_wagner_water import volume_L_to_energy_kJ

        self._vw_efficiency = efficiency
        energy_kJ = volume_L_to_energy_kJ(volume_L, T_a)
        self.water_applied_kJ += energy_kJ
        self.has_steady_state = False

    def apply_vw_suppression(self):
        """Apply moisture injection from accumulated Van Wagner water energy.

        Called by iterate() for cells with water_applied_kJ > 0. Computes
        suppression ratio from current fire state, then injects moisture
        toward dead_mx proportionally.

        Uses:
            - I_ss (BTU/ft/min) converted to kW/m
            - fuel.w_n_dead + w_n_live (lb/ft²) converted to kg/m²
            - fire_area_m2 property
            - self._vw_efficiency (stored from water_drop_vw call)
            - heat_to_extinguish_kJ() (Eq. 7b + 10b + Table 4)
            - compute_suppression_ratio()
            - compute_moisture_injection()

        Side Effects:
            - Modifies self.fmois
            - Sets self.has_steady_state = False
        """
        from embrs.models.van_wagner_water import (
            heat_to_extinguish_kJ, compute_suppression_ratio,
            compute_moisture_injection
        )
        from embrs.utilities.unit_conversions import BTU_ft_min_to_kW_m, Lbsft2_to_KiSq

        # Convert fireline intensity from BTU/(ft·min) to kW/m
        # I_t is a numpy array; use head-fire value (max)
        I_btu = max(self.I_t) if len(self.I_t) > 0 else 0.0
        I_kW_m = BTU_ft_min_to_kW_m(I_btu)

        # Dead fuel loading in kg/m² (w_n_dead is in lb/ft²)
        W_1_kg_m2 = Lbsft2_to_KiSq(self.fuel.w_n_dead)

        # Current fire area
        area_m2 = self.fire_area_m2

        # Compute energy needed to extinguish
        heat_needed = heat_to_extinguish_kJ(
            I_kW_m, W_1_kg_m2, area_m2,
            efficiency=self._vw_efficiency
        )

        # Suppression ratio
        ratio = compute_suppression_ratio(self.water_applied_kJ, heat_needed)

        # Inject moisture into dead fuel classes toward extinction
        self.fmois = compute_moisture_injection(
            self.fmois, self.fuel.dead_mx, ratio
        )

        self.has_steady_state = False

    def __str__(self) -> str:
        """Returns a formatted string representation of the cell.

        The string includes the cell's ID, coordinates, elevation, fuel type, and state.

        Returns:
            str: A formatted string representing the cell.
        """
        return (f"(id: {self.id}, {self.x_pos}, {self.y_pos}, {self.elevation_m}, "
                f"type: {self.fuel.name}, "
                f"state: {self.state}")

    def calc_hold_prob(self, flame_len_m: float) -> float:
        """Calculate the probability that a fuel break will stop fire spread.

        Uses the Mees et al. (1993) model to estimate the probability that a
        fuel discontinuity (road, firebreak) will prevent fire from crossing.

        Args:
            flame_len_m (float): Flame length at the fire front in meters.

        Returns:
            float: Probability that the fuel break holds (0.0-1.0).
                Returns 0 if no fuel break is present in this cell.

        Notes:
            - Based on Mees, et al. (1993).
        """
        # Mees et. al 1993
        if self._break_width == 0:
            return 0

        x = self._break_width
        m = flame_len_m
        T = 1

        if m == 0:
            return 1

        if m <= 0.61:
            h = 0
        elif 0.61 < m < 2.44:
            h = (m - 0.61)/m
        else:
            h = 0.75

        if x < (h*m):
            prob = 0
        else:
            prob = 1 - np.exp(((x - h*m) * np.log(0.15))/(T*m - h*m))

        return prob

    def calc_cell_area(self) -> float:
        """Calculates the area of the hexagonal cell in square meters.

        The formula for the area of a regular hexagon is:

            Area = (3 * sqrt(3) / 2) * side_length²

        Returns:
            float: The area of the hexagonal cell in square meters.
        """
        area_m2 = (3 * 1.7320508075688772 * self.cell_size ** 2) / 2  # 3*sqrt(3)/2
        return area_m2

    def to_polygon(self) -> Polygon:
        """Generates a Shapely polygon representation of the hexagonal cell.

        The polygon is created in a point-up orientation using the center (`x_pos`, `y_pos`)
        and the hexagon's side length.

        Returns:
            Polygon: A Shapely polygon representing the hexagonal cell.
        """
        l = self.cell_size
        x, y = self.x_pos, self.y_pos

        # Define the vertices for the hexagon in point-up orientation
        hex_coords = [
            (x, y + l),
            (x + (np.sqrt(3) / 2) * l, y + l / 2),
            (x + (np.sqrt(3) / 2) * l, y - l / 2),
            (x, y - l),
            (x - (np.sqrt(3) / 2) * l, y - l / 2),
            (x - (np.sqrt(3) / 2) * l, y + l / 2),
            (x, y + l)  # Close the polygon
        ]

        return Polygon(hex_coords)

    def to_log_entry(self, time: float) -> CellLogEntry:
        """Create a log entry capturing the cell's current state.

        Generates a structured record of the cell's fire behavior, fuel moisture,
        wind conditions, and other properties for logging and playback.

        Args:
            time (float): Current simulation time in seconds.

        Returns:
            CellLogEntry: Dataclass containing cell state for logging.
        """

        if self.fuel.burnable:
            w_n_dead = self.fuel.w_n_dead
            w_n_dead_start = self.fuel.w_n_dead_nominal
            w_n_live = self.fuel.w_n_live
            dfm_1hr = self.fmois[0]
            dfm_10hr = self.fmois[1]
            dfm_100hr = self.fmois[2]
        else:
            w_n_dead = 0
            w_n_dead_start = 0
            w_n_live = 0
            dfm_1hr = 0
            dfm_10hr = 0
            dfm_100hr = 0

        if len(self.r_t) > 0:
            r_t = np.max(self.r_t)
            I_ss = np.max(self.I_ss)
        else:
            r_t = 0
            I_ss = 0

        wind_speed, wind_dir = self.curr_wind()

        entry = CellLogEntry(
            timestamp=time,
            id=self.id,
            x=self.x_pos,
            y=self.y_pos,
            fuel=self.fuel.model_num,
            state=self.state,
            crown_state=self._crown_status,
            w_n_dead=w_n_dead,
            w_n_dead_start=w_n_dead_start,
            w_n_live=w_n_live,
            dfm_1hr=dfm_1hr,
            dfm_10hr=dfm_10hr,
            dfm_100hr=dfm_100hr,
            ros=r_t,
            I_ss=I_ss,
            wind_speed=wind_speed,
            wind_dir=wind_dir,
            retardant=self._retardant,
            arrival_time=self._arrival_time,
            suppression_count=self._suppression_count,
            n_disabled_locs=self.n_disabled_locs
        )

        return entry

    def iter_neighbor_cells(self) -> Iterator[Cell]:
        """Iterate over neighboring Cell objects.

        Yields each adjacent cell by looking up neighbor IDs in the parent
        simulation's cell_dict.

        Yields:
            Cell: Each neighboring cell object.

        Notes:
            - Returns immediately if parent reference is None.
        """
        parent = self._parent()

        if parent is None:
            return

        cell_dict = parent.cell_dict
        for nid in self._neighbors.keys():
            yield cell_dict[nid]

    # ------ Compare operators overloads ------ #
    def __lt__(self, other) -> bool:
        """Compares two cells based on their unique ID.

        This method allows for sorting and comparison of cells using the `<` (less than) operator.

        Args:
            other (Cell): Another cell to compare against.

        Returns:
            bool: `True` if this cell's ID is less than the other cell's ID, `False` otherwise.

        Raises:
            TypeError: If `other` is not an instance of `Cell`.
        """
        if not isinstance(other, type(self)):
            raise TypeError("Comparison must be between two Cell instances.")
        return self.id < other.id

    def __gt__(self, other) -> bool:
        """Compares two cells based on their unique ID.

        This method allows for sorting and comparison of cells using the `>` (greater than) operator.

        Args:
            other (Cell): Another cell to compare against.

        Returns:
            bool: `True` if this cell's ID is greater than the other cell's ID, `False` otherwise.

        Raises:
            TypeError: If `other` is not an instance of `Cell`.
        """
        if not isinstance(other, type(self)):
            raise TypeError("Comparison must be between two Cell instances.")
        return self.id > other.id

    def __getstate__(self) -> dict:
        """Prepare cell state for pickling.

        Excludes the weak reference to parent which cannot be pickled.

        Returns:
            dict: Cell state dictionary with _parent set to None.
        """
        state = self.__dict__.copy()
        # Remove weak reference - will be restored later
        state['_parent'] = None
        return state

    def __setstate__(self, state):
        """Restore cell state after unpickling.

        Args:
            state (dict): Cell state dictionary from __getstate__.
        """
        self.__dict__.update(state)
        # Parent will be set later via cell.set_parent(predictor)

    @property
    def col(self) -> int:
        """Column index of the cell in the simulation grid."""
        return self._col

    @property
    def row(self) -> int:
        """Row index of the cell in the simulation grid."""
        return self._row

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

        Measured as the side length of the hexagon.
        """
        return self._cell_size

    @property
    def cell_area(self) -> float:
        """Area of the cell in square meters."""
        return self._cell_area

    @property
    def fire_area_m2(self) -> float:
        """Fire area within cell via trapezoidal polar integration.

        Computes A = (1/2) Σ Δθ_i · (r_i² + r_{i+1}²) / 2 over the
        discretized spread directions.  For center ignition (n_loc=0) the
        sum includes a closing segment from the last direction back to the
        first.  No area_fraction is needed — the angular range of the
        directions already encodes whether the fire covers the full cell,
        a half-cell, or a 60° sector.

        Clamped to cell_area as an upper bound.

        Returns 0.0 for non-burning cells, cells with no spread data, or
        cells where _ign_n_loc has not yet been set.
        """
        if self._state != CellStates.FIRE or len(self.fire_spread) == 0:
            return 0.0
        if self._ign_n_loc is None:
            return 0.0

        fs = self.fire_spread
        dirs = self.directions
        n = len(fs)

        area = 0.0
        for i in range(n - 1):
            dth = (dirs[i + 1] - dirs[i]) % 360
            area += dth * (fs[i] * fs[i] + fs[i + 1] * fs[i + 1])

        if self._ign_n_loc == 0:
            dth = (dirs[0] - dirs[-1] + 360) % 360
            area += dth * (fs[-1] * fs[-1] + fs[0] * fs[0])

        # 0.25 = (1/2 from polar integral) × (1/2 from trapezoidal average)
        # π/180 converts the degree-valued Δθ to radians
        area *= 0.25 * (np.pi / 180)

        return min(area, self._cell_area)

    @property
    def x_pos(self) -> float:
        """X-coordinate of the cell center in meters.

        Increases left to right in the visualization.
        """
        return self._x_pos

    @property
    def y_pos(self) -> float:
        """Y-coordinate of the cell center in meters.

        Increases bottom to top in the visualization.
        """
        return self._y_pos

    @property
    def elevation_m(self) -> float:
        """Elevation of the cell in meters."""
        return self._elevation_m

    @property
    def fuel(self) -> Fuel:
        """Fuel model for this cell.

        Can be any Anderson or Scott-Burgan fuel model.
        """
        return self._fuel

    @property
    def state(self) -> CellStates:
        """Current fire state of the cell (FUEL, FIRE, or BURNT)."""
        return self._state

    @property
    def neighbors(self) -> dict:
        """Dictionary of adjacent cells.

        Keys are neighbor cell IDs, values are (dx, dy) tuples indicating
        the column and row offset from this cell to the neighbor.
        """
        return self._neighbors

    @property
    def burnable_neighbors(self) -> dict:
        """Dictionary of adjacent cells that are in a burnable state.

        Same format as neighbors: keys are cell IDs, values are (dx, dy) offsets.
        """
        return self._burnable_neighbors

burnable_neighbors property

Dictionary of adjacent cells that are in a burnable state.

Same format as neighbors: keys are cell IDs, values are (dx, dy) offsets.

cell_area property

Area of the cell in square meters.

cell_size property

Size of the cell in meters.

Measured as the side length of the hexagon.

col property

Column index of the cell in the simulation grid.

elevation_m property

Elevation of the cell in meters.

fire_area_m2 property

Fire area within cell via trapezoidal polar integration.

Computes A = (1/2) Σ Δθ_i · (r_i² + r_{i+1}²) / 2 over the discretized spread directions. For center ignition (n_loc=0) the sum includes a closing segment from the last direction back to the first. No area_fraction is needed — the angular range of the directions already encodes whether the fire covers the full cell, a half-cell, or a 60° sector.

Clamped to cell_area as an upper bound.

Returns 0.0 for non-burning cells, cells with no spread data, or cells where _ign_n_loc has not yet been set.

fuel property

Fuel model for this cell.

Can be any Anderson or Scott-Burgan fuel model.

n_disabled_locs property

Number of boundary locations disabled by prior suppression.

neighbors property

Dictionary of adjacent cells.

Keys are neighbor cell IDs, values are (dx, dy) tuples indicating the column and row offset from this cell to the neighbor.

row property

Row index of the cell in the simulation grid.

state property

Current fire state of the cell (FUEL, FIRE, or BURNT).

x_pos property

X-coordinate of the cell center in meters.

Increases left to right in the visualization.

y_pos property

Y-coordinate of the cell center in meters.

Increases bottom to top in the visualization.

__getstate__()

Prepare cell state for pickling.

Excludes the weak reference to parent which cannot be pickled.

Returns:

Name Type Description
dict dict

Cell state dictionary with _parent set to None.

Source code in embrs/fire_simulator/cell.py
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
def __getstate__(self) -> dict:
    """Prepare cell state for pickling.

    Excludes the weak reference to parent which cannot be pickled.

    Returns:
        dict: Cell state dictionary with _parent set to None.
    """
    state = self.__dict__.copy()
    # Remove weak reference - will be restored later
    state['_parent'] = None
    return state

__gt__(other)

Compares two cells based on their unique ID.

This method allows for sorting and comparison of cells using the > (greater than) operator.

Parameters:

Name Type Description Default
other Cell

Another cell to compare against.

required

Returns:

Name Type Description
bool bool

True if this cell's ID is greater than the other cell's ID, False otherwise.

Raises:

Type Description
TypeError

If other is not an instance of Cell.

Source code in embrs/fire_simulator/cell.py
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def __gt__(self, other) -> bool:
    """Compares two cells based on their unique ID.

    This method allows for sorting and comparison of cells using the `>` (greater than) operator.

    Args:
        other (Cell): Another cell to compare against.

    Returns:
        bool: `True` if this cell's ID is greater than the other cell's ID, `False` otherwise.

    Raises:
        TypeError: If `other` is not an instance of `Cell`.
    """
    if not isinstance(other, type(self)):
        raise TypeError("Comparison must be between two Cell instances.")
    return self.id > other.id

__init__(id, col, row, cell_size)

Initialize a hexagonal cell with position and geometry.

Creates a cell at the specified grid position and calculates its spatial coordinates based on the hexagonal grid layout. The cell is initialized with default values for fire state and fuel properties.

Parameters:

Name Type Description Default
id int

Unique identifier for this cell.

required
col int

Column index in the simulation grid.

required
row int

Row index in the simulation grid.

required
cell_size float

Edge length of the hexagon in meters.

required
Notes
  • Spatial position is calculated using point-up hexagon geometry.
  • For even rows: x = col * cell_size * sqrt(3)
  • For odd rows: x = (col + 0.5) * cell_size * sqrt(3)
  • y = row * cell_size * 1.5
Source code in embrs/fire_simulator/cell.py
 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
def __init__(self, id: int, col: int, row: int, cell_size: float):
    """Initialize a hexagonal cell with position and geometry.

    Creates a cell at the specified grid position and calculates its spatial
    coordinates based on the hexagonal grid layout. The cell is initialized
    with default values for fire state and fuel properties.

    Args:
        id (int): Unique identifier for this cell.
        col (int): Column index in the simulation grid.
        row (int): Row index in the simulation grid.
        cell_size (float): Edge length of the hexagon in meters.

    Notes:
        - Spatial position is calculated using point-up hexagon geometry.
        - For even rows: x = col * cell_size * sqrt(3)
        - For odd rows: x = (col + 0.5) * cell_size * sqrt(3)
        - y = row * cell_size * 1.5
    """
    self.id = id

    # Deterministic hash based on cell ID (not memory address) so that
    # set iteration order is reproducible across process invocations.
    self._hash = hash(id)

    # Set cell indices
    self._col = col
    self._row = row

    # x_pos, y_pos are the global position of cell in m
    _sqrt3 = 1.7320508075688772  # math.sqrt(3)
    if row % 2 == 0:
        self._x_pos = col * cell_size * _sqrt3
    else:
        self._x_pos = (col + 0.5) * cell_size * _sqrt3

    self._y_pos = row * cell_size * 1.5

    self._cell_size = cell_size # defined as the edge length of hexagon
    self._cell_area = self.calc_cell_area()

    # Flag to track if cell has been treated with long-term fire retardant
    self._retardant = False
    self._retardant_factor = 1.0 # Factor multiplied by rate of spread (0-1)
    self.retardant_expiration_s = -1.0 # Time at which long-term retardant in cell expires

    # Track the total amount of water dropped (if modelled as rain)
    self.local_rain = 0.0

    # Van Wagner energy-balance water suppression (Van Wagner & Taylor 2022)
    self.water_applied_kJ = 0.0   # Cumulative cooling energy from water drops
    self._vw_efficiency = 2.5     # Application efficiency multiplier (Table 4)

    # Width in meters of any fuel discontinuity within cell (road or firebreak)
    self._break_width = 0 

    # Variable to track if fuel discontinuity within cell can be breached
    self.breached = True

    # Track if firebrands have been lofted from cell
    self.lofted = False

    # Source cell coordinates if ignited by ember spotting (set by Embers.flight)
    self.spot_source_xy = None

    # Weak reference to parent BaseFire object
    self._parent = None

    self._arrival_time = -999

__lt__(other)

Compares two cells based on their unique ID.

This method allows for sorting and comparison of cells using the < (less than) operator.

Parameters:

Name Type Description Default
other Cell

Another cell to compare against.

required

Returns:

Name Type Description
bool bool

True if this cell's ID is less than the other cell's ID, False otherwise.

Raises:

Type Description
TypeError

If other is not an instance of Cell.

Source code in embrs/fire_simulator/cell.py
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
def __lt__(self, other) -> bool:
    """Compares two cells based on their unique ID.

    This method allows for sorting and comparison of cells using the `<` (less than) operator.

    Args:
        other (Cell): Another cell to compare against.

    Returns:
        bool: `True` if this cell's ID is less than the other cell's ID, `False` otherwise.

    Raises:
        TypeError: If `other` is not an instance of `Cell`.
    """
    if not isinstance(other, type(self)):
        raise TypeError("Comparison must be between two Cell instances.")
    return self.id < other.id

__setstate__(state)

Restore cell state after unpickling.

Parameters:

Name Type Description Default
state dict

Cell state dictionary from getstate.

required
Source code in embrs/fire_simulator/cell.py
1331
1332
1333
1334
1335
1336
1337
def __setstate__(self, state):
    """Restore cell state after unpickling.

    Args:
        state (dict): Cell state dictionary from __getstate__.
    """
    self.__dict__.update(state)

__str__()

Returns a formatted string representation of the cell.

The string includes the cell's ID, coordinates, elevation, fuel type, and state.

Returns:

Name Type Description
str str

A formatted string representing the cell.

Source code in embrs/fire_simulator/cell.py
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
def __str__(self) -> str:
    """Returns a formatted string representation of the cell.

    The string includes the cell's ID, coordinates, elevation, fuel type, and state.

    Returns:
        str: A formatted string representing the cell.
    """
    return (f"(id: {self.id}, {self.x_pos}, {self.y_pos}, {self.elevation_m}, "
            f"type: {self.fuel.name}, "
            f"state: {self.state}")

add_retardant(duration_hr, effectiveness)

Apply long-term fire retardant to this cell.

Marks the cell as treated with retardant, which reduces the rate of spread by the effectiveness factor until the retardant expires.

Parameters:

Name Type Description Default
duration_hr float

Duration of retardant effectiveness in hours.

required
effectiveness float

Reduction factor for rate of spread (0.0-1.0). A value of 0.5 reduces ROS by 50%.

required

Raises:

Type Description
ValueError

If effectiveness is not in range [0, 1].

Side Effects
  • Sets self._retardant to True.
  • Sets self._retardant_factor to (1 - effectiveness).
  • Sets self.retardant_expiration_s to expiration time.
Source code in embrs/fire_simulator/cell.py
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
def add_retardant(self, duration_hr: float, effectiveness: float):
    """Apply long-term fire retardant to this cell.

    Marks the cell as treated with retardant, which reduces the rate of
    spread by the effectiveness factor until the retardant expires.

    Args:
        duration_hr (float): Duration of retardant effectiveness in hours.
        effectiveness (float): Reduction factor for rate of spread (0.0-1.0).
            A value of 0.5 reduces ROS by 50%.

    Raises:
        ValueError: If effectiveness is not in range [0, 1].

    Side Effects:
        - Sets self._retardant to True.
        - Sets self._retardant_factor to (1 - effectiveness).
        - Sets self.retardant_expiration_s to expiration time.
    """
    if effectiveness < 0 or effectiveness > 1:
        raise ValueError(f"Retardant effectiveness must be between 0 and 1 ({effectiveness} passed in)")

    self._retardant = True
    self._retardant_factor = 1 - effectiveness

    self.retardant_expiration_s = self._parent().curr_time_s + (duration_hr * 3600)

apply_vw_suppression()

Apply moisture injection from accumulated Van Wagner water energy.

Called by iterate() for cells with water_applied_kJ > 0. Computes suppression ratio from current fire state, then injects moisture toward dead_mx proportionally.

Uses
  • I_ss (BTU/ft/min) converted to kW/m
  • fuel.w_n_dead + w_n_live (lb/ft²) converted to kg/m²
  • fire_area_m2 property
  • self._vw_efficiency (stored from water_drop_vw call)
  • heat_to_extinguish_kJ() (Eq. 7b + 10b + Table 4)
  • compute_suppression_ratio()
  • compute_moisture_injection()
Side Effects
  • Modifies self.fmois
  • Sets self.has_steady_state = False
Source code in embrs/fire_simulator/cell.py
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
def apply_vw_suppression(self):
    """Apply moisture injection from accumulated Van Wagner water energy.

    Called by iterate() for cells with water_applied_kJ > 0. Computes
    suppression ratio from current fire state, then injects moisture
    toward dead_mx proportionally.

    Uses:
        - I_ss (BTU/ft/min) converted to kW/m
        - fuel.w_n_dead + w_n_live (lb/ft²) converted to kg/m²
        - fire_area_m2 property
        - self._vw_efficiency (stored from water_drop_vw call)
        - heat_to_extinguish_kJ() (Eq. 7b + 10b + Table 4)
        - compute_suppression_ratio()
        - compute_moisture_injection()

    Side Effects:
        - Modifies self.fmois
        - Sets self.has_steady_state = False
    """
    from embrs.models.van_wagner_water import (
        heat_to_extinguish_kJ, compute_suppression_ratio,
        compute_moisture_injection
    )
    from embrs.utilities.unit_conversions import BTU_ft_min_to_kW_m, Lbsft2_to_KiSq

    # Convert fireline intensity from BTU/(ft·min) to kW/m
    # I_t is a numpy array; use head-fire value (max)
    I_btu = max(self.I_t) if len(self.I_t) > 0 else 0.0
    I_kW_m = BTU_ft_min_to_kW_m(I_btu)

    # Dead fuel loading in kg/m² (w_n_dead is in lb/ft²)
    W_1_kg_m2 = Lbsft2_to_KiSq(self.fuel.w_n_dead)

    # Current fire area
    area_m2 = self.fire_area_m2

    # Compute energy needed to extinguish
    heat_needed = heat_to_extinguish_kJ(
        I_kW_m, W_1_kg_m2, area_m2,
        efficiency=self._vw_efficiency
    )

    # Suppression ratio
    ratio = compute_suppression_ratio(self.water_applied_kJ, heat_needed)

    # Inject moisture into dead fuel classes toward extinction
    self.fmois = compute_moisture_injection(
        self.fmois, self.fuel.dead_mx, ratio
    )

    self.has_steady_state = False

calc_cell_area()

Calculates the area of the hexagonal cell in square meters.

The formula for the area of a regular hexagon is:

Area = (3 * sqrt(3) / 2) * side_length²

Returns:

Name Type Description
float float

The area of the hexagonal cell in square meters.

Source code in embrs/fire_simulator/cell.py
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
def calc_cell_area(self) -> float:
    """Calculates the area of the hexagonal cell in square meters.

    The formula for the area of a regular hexagon is:

        Area = (3 * sqrt(3) / 2) * side_length²

    Returns:
        float: The area of the hexagonal cell in square meters.
    """
    area_m2 = (3 * 1.7320508075688772 * self.cell_size ** 2) / 2  # 3*sqrt(3)/2
    return area_m2

calc_hold_prob(flame_len_m)

Calculate the probability that a fuel break will stop fire spread.

Uses the Mees et al. (1993) model to estimate the probability that a fuel discontinuity (road, firebreak) will prevent fire from crossing.

Parameters:

Name Type Description Default
flame_len_m float

Flame length at the fire front in meters.

required

Returns:

Name Type Description
float float

Probability that the fuel break holds (0.0-1.0). Returns 0 if no fuel break is present in this cell.

Notes
  • Based on Mees, et al. (1993).
Source code in embrs/fire_simulator/cell.py
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
def calc_hold_prob(self, flame_len_m: float) -> float:
    """Calculate the probability that a fuel break will stop fire spread.

    Uses the Mees et al. (1993) model to estimate the probability that a
    fuel discontinuity (road, firebreak) will prevent fire from crossing.

    Args:
        flame_len_m (float): Flame length at the fire front in meters.

    Returns:
        float: Probability that the fuel break holds (0.0-1.0).
            Returns 0 if no fuel break is present in this cell.

    Notes:
        - Based on Mees, et al. (1993).
    """
    # Mees et. al 1993
    if self._break_width == 0:
        return 0

    x = self._break_width
    m = flame_len_m
    T = 1

    if m == 0:
        return 1

    if m <= 0.61:
        h = 0
    elif 0.61 < m < 2.44:
        h = (m - 0.61)/m
    else:
        h = 0.75

    if x < (h*m):
        prob = 0
    else:
        prob = 1 - np.exp(((x - h*m) * np.log(0.15))/(T*m - h*m))

    return prob

compute_disabled_locs()

Compute boundary locations consumed by current fire and add to disabled_locs.

Must be called BEFORE fire-state arrays are cleared. Uses three rules to determine which boundary locations (1-12) have been consumed.

Rule 1: Entry point is consumed. Rule 2: Each crossed intersection's exit boundary location is consumed. Rule 3: For corner ignitions, adjacent midpoints are consumed if fire has spread past half the distance in that direction.

Source code in embrs/fire_simulator/cell.py
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
def compute_disabled_locs(self):
    """Compute boundary locations consumed by current fire and add to disabled_locs.

    Must be called BEFORE fire-state arrays are cleared. Uses three rules
    to determine which boundary locations (1-12) have been consumed.

    Rule 1: Entry point is consumed.
    Rule 2: Each crossed intersection's exit boundary location is consumed.
    Rule 3: For corner ignitions, adjacent midpoints are consumed if fire
             has spread past half the distance in that direction.
    """
    n_loc = self._ign_n_loc
    if n_loc is None:
        return

    # Rule 1 — entry point
    if n_loc != 0:
        self.disabled_locs.add(n_loc)

    # Rule 2 — crossed intersections → exit boundary locations
    if self._self_end_points is not None:
        for i in range(len(self.intersections)):
            if self.intersections[i]:
                self.disabled_locs.add(self._self_end_points[i])

    # Rule 3 — corner adjacent midpoints (even, nonzero n_loc only)
    if n_loc != 0 and n_loc % 2 == 0:
        if len(self.fire_spread) > 0 and self.distances is not None and len(self.distances) > 0:
            if self.fire_spread[0] > self.distances[0] / 2:
                self.disabled_locs.add((n_loc % 12) + 1)
            if self.fire_spread[-1] > self.distances[-1] / 2:
                self.disabled_locs.add(((n_loc - 2) % 12) + 1)

curr_wind()

Get the current wind speed and direction at this cell.

Returns the wind conditions for the current weather interval from the cell's local wind forecast. For prediction runs, may trigger forecast updates if needed.

Returns:

Name Type Description
tuple tuple

(wind_speed, wind_direction) where speed is in m/s and direction is in degrees using cartesian convention (0° = blowing toward North/+y, 90° = blowing toward East/+x).

Source code in embrs/fire_simulator/cell.py
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
def curr_wind(self) -> tuple:
    """Get the current wind speed and direction at this cell.

    Returns the wind conditions for the current weather interval from the
    cell's local wind forecast. For prediction runs, may trigger forecast
    updates if needed.

    Returns:
        tuple: (wind_speed, wind_direction) where speed is in m/s and
            direction is in degrees using cartesian convention
            (0° = blowing toward North/+y, 90° = blowing toward East/+x).
    """
    parent = self._parent()
    w_idx = parent._curr_weather_idx - parent.sim_start_w_idx

    if parent.is_prediction() and len(self.forecast_wind_speeds) <= w_idx:
        parent._set_prediction_forecast(self)

    # Defensive clamp: prevent index-out-of-bounds on forecast arrays
    if w_idx >= len(self.forecast_wind_speeds):
        import warnings
        warnings.warn(
            f"Cell.curr_wind: w_idx ({w_idx}) >= forecast length "
            f"({len(self.forecast_wind_speeds)}) for cell {self.id}. "
            f"Clamping to last available index."
        )
        w_idx = len(self.forecast_wind_speeds) - 1

    curr_wind = (self.forecast_wind_speeds[w_idx], self.forecast_wind_dirs[w_idx])

    return curr_wind

get_ign_params(n_loc)

Calculate fire spread directions and distances from an ignition location.

Computes the radial spread directions from the specified ignition point within the cell to each edge or vertex. Initializes arrays for tracking rate of spread and fireline intensity in each direction.

Parameters:

Name Type Description Default
n_loc int

Ignition location index within the cell. 0=center, 1-6=vertices, 7-12=edge midpoints.

required
Side Effects
  • Sets self.directions: array of compass directions in degrees.
  • Sets self.distances: slope-adjusted distances to cell boundaries.
  • Sets self.end_pts: coordinates of cell boundary points.
  • Initializes self.avg_ros, self.I_t, self.r_t to zero arrays.
Source code in embrs/fire_simulator/cell.py
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
def get_ign_params(self, n_loc: int):
    """Calculate fire spread directions and distances from an ignition location.

    Computes the radial spread directions from the specified ignition point
    within the cell to each edge or vertex. Initializes arrays for tracking
    rate of spread and fireline intensity in each direction.

    Args:
        n_loc (int): Ignition location index within the cell.
            0=center, 1-6=vertices, 7-12=edge midpoints.

    Side Effects:
        - Sets self.directions: array of compass directions in degrees.
        - Sets self.distances: slope-adjusted distances to cell boundaries.
        - Sets self.end_pts: coordinates of cell boundary points.
        - Initializes self.avg_ros, self.I_t, self.r_t to zero arrays.
    """
    self._ign_n_loc = n_loc
    self.directions, distances, self.end_pts = UtilFuncs.get_ign_parameters(n_loc, self.cell_size)
    self._self_end_points = _derive_self_end_points(n_loc)
    self.project_distances_to_surf(distances)
    self.avg_ros = np.zeros_like(self.directions)
    self.I_t = np.zeros_like(self.directions)
    self.r_t = np.zeros_like(self.directions)

iter_neighbor_cells()

Iterate over neighboring Cell objects.

Yields each adjacent cell by looking up neighbor IDs in the parent simulation's cell_dict.

Yields:

Name Type Description
Cell Cell

Each neighboring cell object.

Notes
  • Returns immediately if parent reference is None.
Source code in embrs/fire_simulator/cell.py
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
def iter_neighbor_cells(self) -> Iterator[Cell]:
    """Iterate over neighboring Cell objects.

    Yields each adjacent cell by looking up neighbor IDs in the parent
    simulation's cell_dict.

    Yields:
        Cell: Each neighboring cell object.

    Notes:
        - Returns immediately if parent reference is None.
    """
    parent = self._parent()

    if parent is None:
        return

    cell_dict = parent.cell_dict
    for nid in self._neighbors.keys():
        yield cell_dict[nid]

project_distances_to_surf(distances)

Project horizontal distances onto the sloped terrain surface.

Adjusts the flat-ground distances to each cell edge by accounting for the slope and aspect of the terrain. This ensures fire spread distances are measured along the actual terrain surface.

Parameters:

Name Type Description Default
distances ndarray

Horizontal distances to cell edges in meters.

required
Side Effects
  • Sets self.distances to the slope-adjusted distances in meters.
Source code in embrs/fire_simulator/cell.py
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
def project_distances_to_surf(self, distances: np.ndarray):
    """Project horizontal distances onto the sloped terrain surface.

    Adjusts the flat-ground distances to each cell edge by accounting for
    the slope and aspect of the terrain. This ensures fire spread distances
    are measured along the actual terrain surface.

    Args:
        distances (np.ndarray): Horizontal distances to cell edges in meters.

    Side Effects:
        - Sets self.distances to the slope-adjusted distances in meters.
    """
    slope_rad = np.deg2rad(self.slope_deg)
    aspect = (self.aspect + 180) % 360
    deltas = np.deg2rad(aspect - np.array(self.directions))
    proj = np.sqrt(np.cos(deltas) ** 2 * np.cos(slope_rad) ** 2 + np.sin(deltas) ** 2)
    self.distances = distances / proj

reset_to_fuel()

Reset cell to initial FUEL state, preserving terrain/fuel/geometry data.

Resets all mutable fire-state attributes to their initial values as set by __init__ and _set_cell_data. Immutable properties such as position, fuel model, elevation, slope, aspect, canopy attributes, polygon, wind adjustment factor, and neighbor topology are preserved.

This is used by FirePredictor to efficiently restore cells to a clean state between predictions, avoiding expensive deepcopy operations.

Side Effects
  • Resets fire state to CellStates.FUEL
  • Clears all spread tracking arrays
  • Resets suppression effects (retardant, rain, firebreaks)
  • Resets fuel moisture to initial values
  • Restores full neighbor set to _burnable_neighbors
Source code in embrs/fire_simulator/cell.py
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
def reset_to_fuel(self):
    """Reset cell to initial FUEL state, preserving terrain/fuel/geometry data.

    Resets all mutable fire-state attributes to their initial values as set
    by ``__init__`` and ``_set_cell_data``. Immutable properties such as
    position, fuel model, elevation, slope, aspect, canopy attributes,
    polygon, wind adjustment factor, and neighbor topology are preserved.

    This is used by FirePredictor to efficiently restore cells to a clean
    state between predictions, avoiding expensive deepcopy operations.

    Side Effects:
        - Resets fire state to CellStates.FUEL
        - Clears all spread tracking arrays
        - Resets suppression effects (retardant, rain, firebreaks)
        - Resets fuel moisture to initial values
        - Restores full neighbor set to _burnable_neighbors
    """
    # Fire state
    self._state = CellStates.FUEL
    self.fully_burning = False
    self._crown_status = CrownStatus.NONE
    self.cfb = 0
    self.reaction_intensity = 0

    # Partial suppression state
    self.disabled_locs = set()
    self._suppression_count = 0

    # Spread arrays — shared read-only constants (replaced when cell ignites)
    self.distances = None
    self.directions = None
    self.end_pts = None
    self._ign_n_loc = None
    self._self_end_points = None
    self.r_h_ss = None
    self.I_h_ss = None
    self.r_t = _RESET_ARRAY_ZERO
    self.fire_spread = _RESET_ARRAY_EMPTY
    self.avg_ros = _RESET_ARRAY_EMPTY
    self.r_ss = _RESET_ARRAY_EMPTY
    self.I_ss = _RESET_ARRAY_ZERO
    self.I_t = _RESET_ARRAY_ZERO
    self.intersections = _RESET_IXN_EMPTY
    self.e = 0
    self.alpha = None
    self.has_steady_state = False

    # Suppression effects — back to __init__ defaults
    self._retardant = False
    self._retardant_factor = 1.0
    self.retardant_expiration_s = -1.0
    self.local_rain = 0.0
    self.water_applied_kJ = 0.0
    self._vw_efficiency = 2.5
    self._break_width = 0
    self.breached = True
    self.lofted = False
    self.spot_source_xy = None
    self._arrival_time = -999

    # Wind forecast
    self.forecast_wind_speeds = []
    self.forecast_wind_dirs = []

    # Moisture — reset to initial values (use cached template if available)
    self.moist_update_time_s = 0
    if self._fuel.burnable:
        if self.dfms:
            for dfm in self.dfms:
                dfm.initializeStick()
        # Reset moisture array from cached initial values
        self.fmois = self._init_fmois.copy()

    # Restore full neighbor set (remove_neighbors modifies _burnable_neighbors during sim)
    self._burnable_neighbors = dict(self._neighbors)

set_arrays()

Initialize fuel moisture tracking arrays for this cell.

DFM objects are created lazily on first moisture update to avoid allocating ~12 numpy arrays per object for cells that never burn.

Side Effects
  • Sets self.wdry and self.sigma from fuel model properties.
  • Initializes self.fmois array with initial moisture fractions.
  • Sets self._dfms_needed tuple for lazy DFM creation.
Source code in embrs/fire_simulator/cell.py
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
def set_arrays(self):
    """Initialize fuel moisture tracking arrays for this cell.

    DFM objects are created lazily on first moisture update to avoid
    allocating ~12 numpy arrays per object for cells that never burn.

    Side Effects:
        - Sets self.wdry and self.sigma from fuel model properties.
        - Initializes self.fmois array with initial moisture fractions.
        - Sets self._dfms_needed tuple for lazy DFM creation.
    """
    indices = self._fuel.rel_indices

    self.wdry = self._fuel.w_n[self._fuel.rel_indices]
    self.sigma = self._fuel.s[self._fuel.rel_indices]

    # Defer DFM creation — store which classes are needed
    self.dfms = []
    self._dfms_needed = (0 in indices, 1 in indices, 2 in indices)

    fmois = np.zeros(6)

    if 0 in indices:
        fmois[0] = self.init_dead_mf[0]
    if 1 in indices:
        fmois[1] = self.init_dead_mf[1]
    if 2 in indices:
        fmois[2] = self.init_dead_mf[2]
    if 3 in indices:
        fmois[3] = self.init_dead_mf[0]
    if 4 in indices:
        fmois[4] = self.init_live_h_mf
    if 5 in indices:
        fmois[5] = self.init_live_w_mf

    self.fmois = np.array(fmois)
    # Cache initial fmois for efficient reset_to_fuel
    self._init_fmois = self.fmois.copy()

set_parent(parent)

Sets the parent BaseFire object for this cell.

Parameters:

Name Type Description Default
parent BaseFireSim

The BaseFire object that owns this cell.

required
Source code in embrs/fire_simulator/cell.py
167
168
169
170
171
172
173
def set_parent(self, parent: BaseFireSim) -> None:
    """Sets the parent BaseFire object for this cell.

    Args:
        parent (BaseFireSim): The BaseFire object that owns this cell.
    """
    self._parent = weakref.ref(parent)

suppress_to_fuel()

Suppress cell back to FUEL state, preserving moisture and disabled_locs.

Similar to reset_to_fuel() but preserves: - disabled_locs (accumulated consumed boundary locations) - _suppression_count (incremented) - Fuel moisture state (fmois, dfms, moist_update_time_s)

Clears fire-state arrays, VW water state, and crown fire state. Restores burnable_neighbors from full neighbor set.

Source code in embrs/fire_simulator/cell.py
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
def suppress_to_fuel(self):
    """Suppress cell back to FUEL state, preserving moisture and disabled_locs.

    Similar to reset_to_fuel() but preserves:
    - disabled_locs (accumulated consumed boundary locations)
    - _suppression_count (incremented)
    - Fuel moisture state (fmois, dfms, moist_update_time_s)

    Clears fire-state arrays, VW water state, and crown fire state.
    Restores burnable_neighbors from full neighbor set.
    """
    # Fire state
    self._state = CellStates.FUEL
    self.fully_burning = False
    self._crown_status = CrownStatus.NONE
    self.cfb = 0
    self.reaction_intensity = 0

    # Spread arrays
    self.distances = None
    self.directions = None
    self.end_pts = None
    self._ign_n_loc = None
    self._self_end_points = None
    self.r_h_ss = None
    self.I_h_ss = None
    self.r_t = _RESET_ARRAY_ZERO
    self.fire_spread = _RESET_ARRAY_EMPTY
    self.avg_ros = _RESET_ARRAY_EMPTY
    self.r_ss = _RESET_ARRAY_EMPTY
    self.I_ss = _RESET_ARRAY_ZERO
    self.I_t = _RESET_ARRAY_ZERO
    self.intersections = _RESET_IXN_EMPTY
    self.e = 0
    self.alpha = None
    self.has_steady_state = False

    # Clear VW water suppression state
    self.water_applied_kJ = 0.0
    self._vw_efficiency = 2.5

    # Restore full neighbor set
    self._burnable_neighbors = dict(self._neighbors)

    # Increment suppression count
    self._suppression_count += 1

to_log_entry(time)

Create a log entry capturing the cell's current state.

Generates a structured record of the cell's fire behavior, fuel moisture, wind conditions, and other properties for logging and playback.

Parameters:

Name Type Description Default
time float

Current simulation time in seconds.

required

Returns:

Name Type Description
CellLogEntry CellLogEntry

Dataclass containing cell state for logging.

Source code in embrs/fire_simulator/cell.py
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
def to_log_entry(self, time: float) -> CellLogEntry:
    """Create a log entry capturing the cell's current state.

    Generates a structured record of the cell's fire behavior, fuel moisture,
    wind conditions, and other properties for logging and playback.

    Args:
        time (float): Current simulation time in seconds.

    Returns:
        CellLogEntry: Dataclass containing cell state for logging.
    """

    if self.fuel.burnable:
        w_n_dead = self.fuel.w_n_dead
        w_n_dead_start = self.fuel.w_n_dead_nominal
        w_n_live = self.fuel.w_n_live
        dfm_1hr = self.fmois[0]
        dfm_10hr = self.fmois[1]
        dfm_100hr = self.fmois[2]
    else:
        w_n_dead = 0
        w_n_dead_start = 0
        w_n_live = 0
        dfm_1hr = 0
        dfm_10hr = 0
        dfm_100hr = 0

    if len(self.r_t) > 0:
        r_t = np.max(self.r_t)
        I_ss = np.max(self.I_ss)
    else:
        r_t = 0
        I_ss = 0

    wind_speed, wind_dir = self.curr_wind()

    entry = CellLogEntry(
        timestamp=time,
        id=self.id,
        x=self.x_pos,
        y=self.y_pos,
        fuel=self.fuel.model_num,
        state=self.state,
        crown_state=self._crown_status,
        w_n_dead=w_n_dead,
        w_n_dead_start=w_n_dead_start,
        w_n_live=w_n_live,
        dfm_1hr=dfm_1hr,
        dfm_10hr=dfm_10hr,
        dfm_100hr=dfm_100hr,
        ros=r_t,
        I_ss=I_ss,
        wind_speed=wind_speed,
        wind_dir=wind_dir,
        retardant=self._retardant,
        arrival_time=self._arrival_time,
        suppression_count=self._suppression_count,
        n_disabled_locs=self.n_disabled_locs
    )

    return entry

to_polygon()

Generates a Shapely polygon representation of the hexagonal cell.

The polygon is created in a point-up orientation using the center (x_pos, y_pos) and the hexagon's side length.

Returns:

Name Type Description
Polygon Polygon

A Shapely polygon representing the hexagonal cell.

Source code in embrs/fire_simulator/cell.py
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
def to_polygon(self) -> Polygon:
    """Generates a Shapely polygon representation of the hexagonal cell.

    The polygon is created in a point-up orientation using the center (`x_pos`, `y_pos`)
    and the hexagon's side length.

    Returns:
        Polygon: A Shapely polygon representing the hexagonal cell.
    """
    l = self.cell_size
    x, y = self.x_pos, self.y_pos

    # Define the vertices for the hexagon in point-up orientation
    hex_coords = [
        (x, y + l),
        (x + (np.sqrt(3) / 2) * l, y + l / 2),
        (x + (np.sqrt(3) / 2) * l, y - l / 2),
        (x, y - l),
        (x - (np.sqrt(3) / 2) * l, y - l / 2),
        (x - (np.sqrt(3) / 2) * l, y + l / 2),
        (x, y + l)  # Close the polygon
    ]

    return Polygon(hex_coords)

water_drop_as_moisture_bump(moisture_bump)

Apply a water drop as a direct fuel moisture increase.

Simulates water delivery by directly increasing the outer node moisture of each dead fuel class, then advances the moisture model briefly to allow diffusion.

Parameters:

Name Type Description Default
moisture_bump float

Moisture fraction to add to fuel surface.

required
Side Effects
  • Increases outer node moisture on each DeadFuelMoisture object.
  • Advances moisture model by 30 seconds.
  • Updates self.fmois with new moisture fractions.
Notes
  • No effect on non-burnable fuel types.
Source code in embrs/fire_simulator/cell.py
 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
def water_drop_as_moisture_bump(self, moisture_bump: float):
    """Apply a water drop as a direct fuel moisture increase.

    Simulates water delivery by directly increasing the outer node moisture
    of each dead fuel class, then advances the moisture model briefly to
    allow diffusion.

    Args:
        moisture_bump (float): Moisture fraction to add to fuel surface.

    Side Effects:
        - Increases outer node moisture on each DeadFuelMoisture object.
        - Advances moisture model by 30 seconds.
        - Updates self.fmois with new moisture fractions.

    Notes:
        - No effect on non-burnable fuel types.
    """
    if not self.fuel.burnable:
        return

    if not self.dfms:
        self._ensure_dfms()

    # Catch cell up to current time
    parent = self._parent()
    now_idx = parent._curr_weather_idx
    now_s = parent.curr_time_s
    weather_stream = parent._weather_stream
    self._catch_up_moisture_to_curr(now_s, weather_stream)

    for dfm in self.dfms:
        # Add moisture bump to outer node of each dead fuel moisture class
        dfm.m_w[0] += moisture_bump
        dfm.m_w[0] = max(dfm.m_w[0], dfm.m_wmx)

    self._step_moisture(weather_stream, now_idx, update_interval_hr=30/3600)

    # Update moisture content for each dead fuel moisture class
    self.fmois[0:len(self.dfms)] = [dfm.meanWtdMoisture() for dfm in self.dfms]

    if self.fuel.dynamic:
        # Set dead herbaceous moisture to 1-hr value
        self.fmois[3] = self.fmois[0]

    self.has_steady_state = False

water_drop_as_rain(water_depth_cm, duration_s=30)

Apply a water drop modeled as equivalent rainfall.

Simulates water delivery by treating the water as cumulative rainfall input to the fuel moisture model. Updates moisture state immediately.

Parameters:

Name Type Description Default
water_depth_cm float

Equivalent water depth in centimeters.

required
duration_s float

Duration of the water application in seconds.

30
Side Effects
  • Updates self.local_rain with accumulated water depth.
  • Advances moisture model through the application period.
  • Updates self.fmois with new moisture fractions.
Notes
  • No effect on non-burnable fuel types.
Source code in embrs/fire_simulator/cell.py
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
def water_drop_as_rain(self, water_depth_cm: float, duration_s: float = 30):
    """Apply a water drop modeled as equivalent rainfall.

    Simulates water delivery by treating the water as cumulative
    rainfall input to the fuel moisture model. Updates moisture state
    immediately.

    Args:
        water_depth_cm (float): Equivalent water depth in centimeters.
        duration_s (float): Duration of the water application in seconds.

    Side Effects:
        - Updates self.local_rain with accumulated water depth.
        - Advances moisture model through the application period.
        - Updates self.fmois with new moisture fractions.

    Notes:
        - No effect on non-burnable fuel types.
    """
    if not self.fuel.burnable:
        return

    parent = self._parent()
    now_idx = parent._curr_weather_idx
    now_s = parent.curr_time_s
    weather_stream = parent._weather_stream

    # 1. Step moisture from last update to the current time
    self._catch_up_moisture_to_curr(now_s, weather_stream)

    # 2. Increment local rain
    self.local_rain += water_depth_cm

    # 3. Step moisture from current time to end of local rain interval
    interval_hr = duration_s / 3600 # Convert seconds to hours
    self._step_moisture(weather_stream, now_idx, update_interval_hr=interval_hr)

    # 4. Store the time so that next update just updates from current time over a weather interval
    self.moist_update_time_s = now_s + duration_s

     # Update moisture content for each dead fuel moisture class
    self.fmois[0:len(self.dfms)] = [dfm.meanWtdMoisture() for dfm in self.dfms]

    if self.fuel.dynamic:
        # Set dead herbaceous moisture to 1-hr value
        self.fmois[3] = self.fmois[0]

    self.has_steady_state = False

water_drop_vw(volume_L, efficiency=2.5, T_a=20.0)

Apply water using Van Wagner (2022) energy-balance model.

Converts water volume to cooling energy (Eq. 1b) and accumulates in water_applied_kJ. Moisture injection is applied during iterate() by apply_vw_suppression().

Parameters:

Name Type Description Default
volume_L float

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

required
efficiency float

Application efficiency multiplier (Table 4, Van Wagner 2022). Typical range 2.0–4.0. Default 2.5.

2.5
T_a float

Ambient air temperature in °C. Default 20.

20.0

Raises:

Type Description
ValueError

If volume_L < 0.

Source code in embrs/fire_simulator/cell.py
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
def water_drop_vw(self, volume_L: float, efficiency: float = 2.5,
                   T_a: float = 20.0):
    """Apply water using Van Wagner (2022) energy-balance model.

    Converts water volume to cooling energy (Eq. 1b) and accumulates in
    water_applied_kJ. Moisture injection is applied during iterate() by
    apply_vw_suppression().

    Args:
        volume_L: Water volume in liters (1 L = 1 kg).
        efficiency: Application efficiency multiplier (Table 4, Van Wagner
            2022). Typical range 2.0–4.0. Default 2.5.
        T_a: Ambient air temperature in °C. Default 20.

    Raises:
        ValueError: If volume_L < 0.
    """
    if volume_L < 0:
        raise ValueError(f"Water volume must be >= 0, got {volume_L}")

    if not self.fuel.burnable:
        return

    from embrs.models.van_wagner_water import volume_L_to_energy_kJ

    self._vw_efficiency = efficiency
    energy_kJ = volume_L_to_energy_kJ(volume_L, T_a)
    self.water_applied_kJ += energy_kJ
    self.has_steady_state = False

CrownStatus

Enumeration of crown fire status values.

Attributes:

Name Type Description
NONE int

No crown fire activity (value: 0).

PASSIVE int

Passive crown fire (value: 1).

ACTIVE int

Active crown fire (value: 2).

Source code in embrs/utilities/fire_util.py
76
77
78
79
80
81
82
83
84
class CrownStatus:
    """Enumeration of crown fire status values.

    Attributes:
        NONE (int): No crown fire activity (value: 0).
        PASSIVE (int): Passive crown fire (value: 1).
        ACTIVE (int): Active crown fire (value: 2).
    """
    NONE, PASSIVE, ACTIVE = 0, 1, 2

FirePredictor

Bases: BaseFireSim

Fire spread predictor with uncertainty modeling.

Extends BaseFireSim to run forward predictions from the current fire state. Supports wind uncertainty via AR(1) perturbations, rate of spread bias, and ensemble predictions with parallel execution.

The predictor maintains a reference to the parent FireSim and synchronizes its state before each prediction. For ensemble predictions, the predictor is serialized and reconstructed in worker processes without the parent reference.

Attributes:

Name Type Description
fire FireSim

Reference to the parent fire simulation. None in workers.

time_horizon_hr float

Prediction duration in hours.

wind_uncertainty_factor float

Scaling factor for wind perturbation (0-1).

wind_speed_bias float

Constant wind speed bias in m/s.

wind_dir_bias float

Constant wind direction bias in degrees.

ros_bias_factor float

Multiplicative factor for rate of spread (0.5-1.5).

dead_mf float

Dead fuel moisture fraction for prediction.

live_mf float

Live fuel moisture fraction for prediction.

model_spotting bool

Whether to model ember spotting.

Source code in embrs/tools/fire_predictor.py
  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
 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
class FirePredictor(BaseFireSim):
    """Fire spread predictor with uncertainty modeling.

    Extends BaseFireSim to run forward predictions from the current fire state.
    Supports wind uncertainty via AR(1) perturbations, rate of spread bias,
    and ensemble predictions with parallel execution.

    The predictor maintains a reference to the parent FireSim and synchronizes
    its state before each prediction. For ensemble predictions, the predictor
    is serialized and reconstructed in worker processes without the parent
    reference.

    Attributes:
        fire (FireSim): Reference to the parent fire simulation. None in workers.
        time_horizon_hr (float): Prediction duration in hours.
        wind_uncertainty_factor (float): Scaling factor for wind perturbation (0-1).
        wind_speed_bias (float): Constant wind speed bias in m/s.
        wind_dir_bias (float): Constant wind direction bias in degrees.
        ros_bias_factor (float): Multiplicative factor for rate of spread (0.5-1.5).
        dead_mf (float): Dead fuel moisture fraction for prediction.
        live_mf (float): Live fuel moisture fraction for prediction.
        model_spotting (bool): Whether to model ember spotting.
    """

    # =========================================================================
    # Initialization & Configuration
    # =========================================================================

    def __init__(self, params: PredictorParams, fire: FireSim) -> None:
        """Initialize fire predictor with parameters and parent simulation.

        Args:
            params (PredictorParams): Configuration for prediction behavior
                including time horizon, uncertainty factors, and fuel moisture.
            fire (FireSim): Parent fire simulation to predict from. The predictor
                synchronizes with this simulation before each prediction run.
        """
        # Live reference to the fire sim
        self.fire = fire
        self.c_size = -1
        self.start_time_s = 0

        # Store original params for serialization
        self._params = params
        self._serialization_data = None  # Will hold snapshot for pickling

        self.set_params(params)

    def set_params(self, params: PredictorParams) -> None:
        """Configure predictor parameters and optionally regenerate cell grid.

        Updates all prediction parameters from the provided PredictorParams.
        If cell_size_m has changed since the last call, regenerates the
        entire cell grid (expensive operation).

        Args:
            params (PredictorParams): New parameter values. All fields are used
                to update internal state.

        Side Effects:
            - Updates all uncertainty and bias parameters
            - May regenerate cell grid if cell_size_m changed
            - Computes nominal ignition probability for spotting
        """
        generate_cell_grid = False

        # How long the prediction will run for
        self.time_horizon_hr = params.time_horizon_hr

        # Uncertainty parameters
        self.wind_uncertainty_factor = params.wind_uncertainty_factor  # [0, 1], autoregression noise

        # Compute constant bias terms
        self.wind_speed_bias = params.wind_speed_bias * params.max_wind_speed_bias
        self.wind_dir_bias = params.wind_dir_bias * params.max_wind_dir_bias
        self.ros_bias_factor = max(min(1 + params.ros_bias, 1.5), 0.5)

        # Compute auto-regressive parameters
        self.beta = self.wind_uncertainty_factor * params.max_beta
        self.wnd_spd_std = params.base_wind_spd_std * self.wind_uncertainty_factor
        self.wnd_dir_std = params.base_wind_dir_std * self.wind_uncertainty_factor

        # If cell size has changed since last set params, regenerate cell grid
        cell_size = params.cell_size_m
        if cell_size != self.c_size:
            generate_cell_grid = True

        # Track cell size
        self.c_size = cell_size

        # Set constant fuel moistures
        self.dead_mf = params.dead_mf
        self.live_mf = params.live_mf

        # Update relevant sim_params for prediction model for initialization
        sim_params = copy.deepcopy(self.fire._sim_params)
        sim_params.cell_size = params.cell_size_m
        sim_params.t_step_s = params.time_step_s
        sim_params.duration_s = self.time_horizon_hr * 3600
        sim_params.init_mf = [params.dead_mf, params.dead_mf, params.dead_mf]
        sim_params.spot_delay_s = params.spot_delay_s
        sim_params.model_spotting = params.model_spotting

        # Set the currently burning cells as the initial ignition region
        burning_cells = [cell for cell in self.fire._burning_cells]

        # Get the merged polygon representing burning cells
        sim_params.map_params.scenario_data.initial_ign = UtilFuncs.get_cell_polygons(burning_cells)
        burnt_region = UtilFuncs.get_cell_polygons(self.fire._burnt_cells)

        # Nominal ignition probability for spotting
        self.nom_ign_prob = self._calc_nominal_prob()

        if generate_cell_grid:
            super().__init__(sim_params, burnt_region=burnt_region)
            # orig_grid/orig_dict reference the same cell objects as _cell_grid/_cell_dict.
            # _set_states() will reset all cells in-place before each prediction.
            self.orig_grid = self._cell_grid
            self.orig_dict = self._cell_dict

    def _apply_member_params(self, params: PredictorParams) -> None:
        """Apply member-specific parameters without regenerating cell grid.

        Lightweight version of set_params() that only updates uncertainty and
        bias parameters, not grid structure. Used for per-member parameter
        customization in ensemble predictions.

        Args:
            params (PredictorParams): Member-specific parameter values.

        Notes:
            cell_size_m is ignored - all members use the predictor's original
            cell size to avoid expensive grid regeneration. A warning is issued
            if cell_size_m differs from the predictor's cell size.

        Side Effects:
            Updates time_horizon_hr, bias factors, AR parameters, fuel moisture,
            and spotting parameters.
        """
        import warnings

        # Warn if cell_size_m differs from predictor's cell size
        if params.cell_size_m != self.c_size:
            warnings.warn(
                f"cell_size_m ({params.cell_size_m}) differs from predictor's "
                f"cell size ({self.c_size}). cell_size_m is ignored in per-member "
                f"params to avoid grid regeneration. Using predictor's cell size."
            )

        # Update time horizon (per-member time horizons supported)
        self.time_horizon_hr = params.time_horizon_hr

        # Update bias terms
        self.wind_speed_bias = params.wind_speed_bias * params.max_wind_speed_bias
        self.wind_dir_bias = params.wind_dir_bias * params.max_wind_dir_bias
        self.ros_bias_factor = max(min(1 + params.ros_bias, 1.5), 0.5)

        # Update auto-regressive parameters
        self.wind_uncertainty_factor = params.wind_uncertainty_factor
        self.beta = self.wind_uncertainty_factor * params.max_beta
        self.wnd_spd_std = params.base_wind_spd_std * self.wind_uncertainty_factor
        self.wnd_dir_std = params.base_wind_dir_std * self.wind_uncertainty_factor

        # Update fuel moisture
        self.dead_mf = params.dead_mf
        self.live_mf = params.live_mf

        # Update spotting parameters
        self.model_spotting = params.model_spotting
        self._spot_delay_s = params.spot_delay_s

        # Store for reference
        self._params = params

    # =========================================================================
    # Main Public Interface
    # =========================================================================

    def run(
        self,
        fire_estimate: StateEstimate = None,
        visualize: bool = False
    ) -> PredictionOutput:
        """Run a single fire spread prediction.

        Executes forward prediction from either the current fire simulation
        state or a provided state estimate. Synchronizes with the parent
        fire simulation, generates perturbed wind forecasts, and iterates
        the fire spread model.

        Args:
            fire_estimate (StateEstimate): Optional state estimate to initialize
                from. If None, uses current fire simulation state. If provided
                with start_time_s, prediction starts from that future time.
            visualize (bool): If True, display prediction on fire visualizer.
                Defaults to False.

        Returns:
            PredictionOutput: Contains spread timeline (cell positions by time),
                flame length, fireline intensity, rate of spread, spread direction,
                crown fire status, hold probabilities, and breach status.

        Raises:
            ValueError: If fire_estimate.start_time_s is in the past or beyond
                weather forecast coverage.
        """
        # Extract custom start time if provided
        custom_start_time_s = None
        if fire_estimate is not None and fire_estimate.start_time_s is not None:
            custom_start_time_s = fire_estimate.start_time_s

        # Catch up time and weather states with the fire sim
        # (works in both main process and worker process)
        self._catch_up_with_fire(custom_start_time_s=custom_start_time_s)

        # Set fire state variables
        self._set_states(fire_estimate)

        # Initialize output containers
        self.spread = {}
        self.flame_len_m = {}
        self.fli_kw_m = {}
        self.ros_ms = {}
        self.spread_dir = {}
        self.active_crown_fire = {}
        self.end_active_crown = {}
        self.all_crown_fire = {}
        self.hold_probs = {}
        self.breaches = {}
        self.active_fire_front = {}
        self.burnt_spread = {}
        self.burnt_locs = []

        # Perform the prediction
        self._prediction_loop()

        if visualize and self.fire is not None:
            self.fire.visualize_prediction(self.spread)

        output = PredictionOutput(
            spread=self.spread,
            flame_len_m=self.flame_len_m,
            fli_kw_m=self.fli_kw_m,
            ros_ms=self.ros_ms,
            spread_dir=self.spread_dir,
            all_crown_fire=self.all_crown_fire,
            active_crown_fire=self.active_crown_fire,
            end_active_crown=self.end_active_crown,
            hold_probs=self.hold_probs,
            breaches=self.breaches,
            active_fire_front=self.active_fire_front,
            burnt_spread=self.burnt_spread
        )

        return output

    def run_ensemble(
        self,
        state_estimates: List[StateEstimate],
        visualize: bool = False,
        num_workers: Optional[int] = None,
        random_seeds: Optional[List[int]] = None,
        return_individual: bool = False,
        predictor_params_list: Optional[List[PredictorParams]] = None,
        vary_wind_per_member: bool = False,
        forecast_pool: Optional[ForecastPool] = None,
        forecast_indices: Optional[List[int]] = None
    ) -> EnsemblePredictionOutput:
        """Run ensemble predictions using multiple initial state estimates.

        Execute predictions in parallel, each starting from a different
        StateEstimate. Results are aggregated into probabilistic burn maps
        and fire behavior statistics.

        Uses custom serialization to efficiently transfer predictor state to
        worker processes without reconstructing the full FireSim.

        Args:
            state_estimates (list[StateEstimate]): Initial fire states for
                each ensemble member. Each may include burning_polys,
                burnt_polys, and optional start_time_s.
            visualize (bool): If True, visualize aggregated burn probability
                on the fire visualizer. Defaults to False.
            num_workers (int): Number of parallel workers. Defaults to cpu_count.
            random_seeds (list[int]): Optional seeds for reproducibility,
                one per state estimate.
            return_individual (bool): If True, include individual PredictionOutput
                objects in the returned EnsemblePredictionOutput. Defaults to False.
            predictor_params_list (list[PredictorParams]): Optional per-member
                parameters. If provided, each member uses its own params.
                Automatically enables vary_wind_per_member unless using
                forecast_pool.
            vary_wind_per_member (bool): If True, each worker generates its own
                perturbed wind forecast. If False, all members share the same
                wind forecast. Ignored when forecast_pool is provided.
            forecast_pool (ForecastPool): Optional pre-computed forecasts.
                Workers use forecasts from pool instead of running WindNinja.
            forecast_indices (list[int]): Optional indices into forecast_pool,
                one per state_estimate. If None, indices are sampled randomly
                with replacement.

        Returns:
            EnsemblePredictionOutput: Aggregated ensemble results including
                burn probability maps, fire behavior statistics, and optional
                individual predictions.

        Raises:
            ValueError: If state_estimates is empty, length mismatches occur,
                or any start_time_s is invalid.
            RuntimeError: If more than 50% of ensemble members fail.
        """
        import multiprocessing as mp
        import warnings
        from concurrent.futures import ProcessPoolExecutor, as_completed
        from tqdm import tqdm

        # Validation
        if not state_estimates:
            raise ValueError("state_estimates cannot be empty")

        if random_seeds is not None and len(random_seeds) != len(state_estimates):
            raise ValueError(
                f"random_seeds length ({len(random_seeds)}) must match "
                f"state_estimates length ({len(state_estimates)})"
            )

        # Validate predictor_params_list length
        if predictor_params_list is not None:
            if len(predictor_params_list) != len(state_estimates):
                raise ValueError(
                    f"predictor_params_list length ({len(predictor_params_list)}) must match "
                    f"state_estimates length ({len(state_estimates)})"
                )
            # If params are provided and no pool, wind MUST vary
            if forecast_pool is None:
                vary_wind_per_member = True

            # Warn about cell_size_m differences
            cell_sizes = set(p.cell_size_m for p in predictor_params_list)
            if len(cell_sizes) > 1 or (len(cell_sizes) == 1 and cell_sizes.pop() != self.c_size):
                warnings.warn(
                    f"Some predictor_params have different cell_size_m values. "
                    f"cell_size_m is ignored in per-member params; all members will use "
                    f"the predictor's cell size ({self.c_size}m)."
                )

        n_ensemble = len(state_estimates)

        # Handle forecast pool
        use_pooled_forecasts = forecast_pool is not None

        if use_pooled_forecasts:
            if forecast_indices is None:
                # Sample indices with replacement (for rollouts)
                forecast_indices = forecast_pool.sample(n_ensemble, replace=True)
                print(f"Sampled forecast indices: {forecast_indices[:5]}{'...' if n_ensemble > 5 else ''}")
            else:
                # Validate explicit indices
                if len(forecast_indices) != n_ensemble:
                    raise ValueError(
                        f"forecast_indices length ({len(forecast_indices)}) must match "
                        f"state_estimates length ({n_ensemble})"
                    )

            # Validate all indices are in range
            for idx in forecast_indices:
                if idx < 0 or idx >= len(forecast_pool):
                    raise ValueError(
                        f"Invalid forecast index {idx}, pool size is {len(forecast_pool)}"
                    )

            # When using pool, wind is pre-computed (not varied per member)
            vary_wind_per_member = False

            print(f"Using forecast pool with {len(forecast_pool)} forecasts")
        else:
            forecast_indices = None

        # Validate all start times before parallel execution
        # Initialize _start_weather_idx for validation calls
        self._start_weather_idx = None
        for i, state_est in enumerate(state_estimates):
            if state_est.start_time_s is not None:
                # Use per-member time horizon if provided, otherwise use predictor's
                member_horizon = (predictor_params_list[i].time_horizon_hr
                                  if predictor_params_list else None)
                try:
                    self._validate_start_time(state_est.start_time_s, member_horizon)
                except ValueError as e:
                    raise ValueError(
                        f"Invalid start_time_s for state_estimate[{i}]: {e}"
                    )

        num_workers = num_workers or mp.cpu_count()

        print(f"Running ensemble prediction:")
        print(f"  - {n_ensemble} ensemble members")
        print(f"  - {num_workers} parallel workers")

        # CRITICAL: Prepare for serialization
        print("Preparing predictor for serialization...")

        # Generate shared wind forecast only if not using pool and not varying per member
        if not use_pooled_forecasts and not vary_wind_per_member:
            self._predict_wind()

        # Prepare workers for serialization
        self.prepare_for_serialization(
            vary_wind=vary_wind_per_member,
            forecast_pool=forecast_pool,
            forecast_indices=forecast_indices
        )

        # Run predictions in parallel
        predictions = []
        failed_count = 0

        with ProcessPoolExecutor(max_workers=num_workers) as executor:
            # Submit all jobs
            futures = {}
            for i, state_est in enumerate(state_estimates):
                seed = random_seeds[i] if random_seeds else None
                member_params = predictor_params_list[i] if predictor_params_list else None

                # Store member index for forecast assignment in worker
                if use_pooled_forecasts:
                    self._serialization_data['member_index'] = i

                # Submit (predictor will be pickled via __getstate__)
                future = executor.submit(
                    _run_ensemble_member_worker,
                    self,
                    state_est,
                    seed,
                    member_params
                )
                futures[future] = i  # Track member index

            # Collect results with progress bar
            collected_forecast_indices = []
            for future in tqdm(as_completed(futures),
                              total=n_ensemble,
                              desc="Ensemble predictions",
                              unit="member"):
                member_idx = futures[future]
                try:
                    result = future.result()
                    if forecast_indices is not None:
                        result.forecast_index = forecast_indices[member_idx]
                        collected_forecast_indices.append(forecast_indices[member_idx])
                    predictions.append(result)
                except Exception as e:
                    failed_count += 1
                    print(f"Warning: Member {member_idx} failed: {e}")

        # Check failure rate
        if len(predictions) == 0:
            raise RuntimeError("All ensemble members failed")

        if failed_count > n_ensemble * 0.5:
            raise RuntimeError(
                f"More than 50% of ensemble members failed "
                f"({failed_count}/{n_ensemble})"
            )

        if failed_count > 0:
            print(f"Completed with {failed_count} failures, "
                  f"{len(predictions)} successful members")

        # Aggregate results
        print("Aggregating ensemble predictions...")
        ensemble_output = _aggregate_ensemble_predictions(predictions)
        ensemble_output.n_ensemble = len(predictions)

        # Stamp forecast indices on ensemble output
        if collected_forecast_indices:
            ensemble_output.forecast_indices = collected_forecast_indices

        # Optionally include individual predictions
        if return_individual:
            ensemble_output.individual_predictions = predictions

        # Optionally visualize
        if visualize:
            self._visualize_ensemble(ensemble_output)

        return ensemble_output

    def generate_forecast_pool(
        self,
        n_forecasts: int,
        num_workers: int = None,
        random_seed: int = None
    ) -> ForecastPool:
        """Generate a pool of perturbed wind forecasts in parallel.

        Create n_forecasts independent wind forecasts, each with different
        AR(1) perturbations applied to the base weather stream. WindNinja
        is called in parallel for efficiency.

        The resulting pool can be reused across multiple ensemble runs. When
        passed to run_ensemble with explicit forecast_indices, each member uses
        the specified forecast. When forecast_indices is omitted, run_ensemble
        samples from the pool with replacement.

        Args:
            n_forecasts (int): Number of forecasts to generate.
            num_workers (int): Number of parallel workers. Defaults to
                min(cpu_count, n_forecasts).
            random_seed (int): Base seed for reproducibility. If None, uses
                random seeds for each forecast.

        Returns:
            ForecastPool: Container with all generated forecasts, base weather
                stream, map parameters, and creation metadata.

        Raises:
            RuntimeError: If called without a fire reference (fire is None).
        """
        if self.fire is None:
            raise RuntimeError("Cannot generate forecast pool without fire reference")

        # Delegate to ForecastPool.generate() which owns the pool generation process
        return ForecastPool.generate(
            fire=self.fire,
            predictor_params=self._params,
            n_forecasts=n_forecasts,
            num_workers=num_workers,
            random_seed=random_seed,
            wind_speed_bias=self._params.wind_speed_bias,
            wind_dir_bias=self._params.wind_dir_bias,
            wind_uncertainty_factor=self.wind_uncertainty_factor,
            verbose=True
        )

    # =========================================================================
    # Core Prediction Logic
    # =========================================================================

    def _prediction_loop(self) -> None:
        """Execute main prediction iteration loop.

        Iterates the fire spread model until end time is reached. For each
        iteration: updates weather, computes steady-state spread rates for
        burning cells, propagates fire to neighbors, handles spotting, and
        records fire behavior metrics.

        Side Effects:
            - Populates spread, flame_len_m, fli_kw_m, ros_ms, spread_dir,
              crown_fire, hold_probs, and breaches dictionaries
            - Updates cell states (FIRE -> BURNT)
            - Sets _finished to True when complete
        """
        self._iters = 0

        # Cache frequently-accessed attributes and methods for tight loop
        weather_changed = self.weather_changed
        ros_bias = self.ros_bias_factor
        update_steady = self.update_steady_state
        propagate = self.propagate_fire
        remove_nbrs = self.remove_neighbors
        set_state = self.set_state_at_cell
        updated_cells = self._updated_cells
        model_spotting = self.model_spotting
        nom_ign_prob = self.nom_ign_prob
        burnt_locs = self.burnt_locs
        BURNT = CellStates.BURNT

        while self._init_iteration():
            fires_still_burning = []
            weather_changed = self.weather_changed

            for cell in self._burning_cells:
                if weather_changed or not cell.has_steady_state:
                    update_steady(cell)
                    # cell.r_t = cell.r_ss * ros_bias
                    # cell.avg_ros = cell.r_ss * ros_bias
                    # cell.I_t = cell.I_ss * ros_bias

                accelerate(cell, self._time_step)
                cell.r_t *= ros_bias
                cell.avg_ros *= ros_bias
                cell.I_t *= ros_bias

                propagate(cell)
                remove_nbrs(cell)

                if cell.fully_burning or len(cell.burnable_neighbors) == 0:
                    set_state(cell, BURNT)
                    burnt_locs.append((cell.x_pos, cell.y_pos))
                else:
                    fires_still_burning.append(cell)

                updated_cells[cell.id] = cell

            if (model_spotting and nom_ign_prob > 0) or self._scheduled_spot_fires:
                self._ignite_spots()

            self.update_control_interface_elements()

            self._burning_cells = list(fires_still_burning)

            updated_cells.clear()

            self._iters += 1

        self._finished = True

    def _init_iteration(self) -> bool:
        """Initialize each prediction iteration.

        Handle first iteration setup (cell ignition) and subsequent iterations
        (fire behavior computation). Updates current time, checks weather,
        and manages ignition queues.

        Returns:
            bool: True if prediction should continue, False if end time reached.

        Behavior:
            - First iteration (iters=0): Sets cell states to FIRE, initializes
              steady-state rates with ros_bias_factor applied.
            - Subsequent iterations: Computes flame length, fireline intensity,
              determines fireline breach, and handles ember lofting.
        """
        self._curr_time_s = (self._iters * self._time_step) + self.start_time_s

        if self._iters == 0:
            self.weather_changed = True
            self._new_ignitions = []

            for cell, loc in self.starting_ignitions:
                if not cell.fuel.burnable:
                    continue

                cell.get_ign_params(loc)
                cell._set_state(CellStates.FIRE)
                surface_fire(cell)
                crown_fire(cell, self.fmc)
                cell.has_steady_state = True

                # Don't model fire acceleration in prediction model
                cell.r_t = cell.r_ss * self.ros_bias_factor
                cell.avg_ros = cell.r_ss * self.ros_bias_factor
                cell.I_t = cell.I_ss * self.ros_bias_factor

                self._updated_cells[cell.id] = cell
                self._new_ignitions.append(cell)

                for neighbor in list(cell.burnable_neighbors.keys()):
                    self._frontier.add(neighbor)

                if self.spread.get(self._curr_time_s) is None:
                    self.spread[self._curr_time_s] = [(cell.x_pos, cell.y_pos)]
                else:
                    self.spread[self._curr_time_s].append((cell.x_pos, cell.y_pos))

        else:
            for cell in self._new_ignitions:
                surface_fire(cell)
                crown_fire(cell, self.fmc)

                flame_len_ft = calc_flame_len(cell)
                flame_len_m = ft_to_m(flame_len_ft)

                self.flame_len_m[(cell.x_pos, cell.y_pos)] = flame_len_m

                if cell._break_width > 0:
                    # Determine if fire will breach fireline contained within cell
                    hold_prob = cell.calc_hold_prob(flame_len_m)
                    rand = np.random.random()
                    cell.breached = rand > hold_prob

                    self.hold_probs[(cell.x_pos, cell.y_pos)] = hold_prob
                    self.breaches[(cell.x_pos, cell.y_pos)] = cell.breached
                else:
                    cell.breached = True

                cell.has_steady_state = True

                for neighbor in list(cell.burnable_neighbors.keys()):
                    self._frontier.add(neighbor)

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

                # Don't model fire acceleration in prediction model
                cell.r_t = cell.r_ss * self.ros_bias_factor
                cell.avg_ros = cell.r_ss * self.ros_bias_factor
                cell.I_t = cell.I_ss * self.ros_bias_factor

                if self.spread.get(self._curr_time_s) is None:
                    self.spread[self._curr_time_s] = [(cell.x_pos, cell.y_pos)]
                else:
                    self.spread[self._curr_time_s].append((cell.x_pos, cell.y_pos))

                self.fli_kw_m[(cell.x_pos, cell.y_pos)] = BTU_ft_min_to_kW_m(np.max(cell.I_ss))
                self.ros_ms[(cell.x_pos, cell.y_pos)] = np.max(cell.r_ss)
                self.spread_dir[(cell.x_pos, cell.y_pos)] = cell.directions[np.argmax(cell.r_ss)]

        # Store all the burnt cells at this time step
        self.burnt_spread[self._curr_time_s] = list(self.burnt_locs)

        # Add any new ignitions to the current set of burning cells
        self._burning_cells.extend(self._new_ignitions)

        # Track active fire front and crown fire locations for this time step
        fire_front = self.active_fire_front.setdefault(self._curr_time_s, [])
        active_crown_fire = self.active_crown_fire.setdefault(self._curr_time_s, [])

        last_iter_crowns = set()
        if self._iters > 0:
            last_iter_crowns = set(self.active_crown_fire.get(self._curr_time_s - self._time_step, []))

        for cell in self._burning_cells:
            fire_front.append((cell.x_pos, cell.y_pos))

            if cell._crown_status != CrownStatus.NONE:
                if (cell.x_pos, cell.y_pos) not in self.all_crown_fire:
                    self.all_crown_fire[(cell.x_pos, cell.y_pos)] = self._curr_time_s

                active_crown_fire.append(((cell.x_pos, cell.y_pos), cell._crown_status))

        burnt_out_crowns = last_iter_crowns - set(c[0] for c in active_crown_fire)

        for loc in burnt_out_crowns:
            if loc not in self.end_active_crown:
                self.end_active_crown[loc] = self._curr_time_s

        # Reset new ignitions
        self._new_ignitions = []

        if self._curr_time_s >= self._end_time:
            return False

        # Check if weather has changed
        self.weather_changed = self._update_weather()

        return True

    def _set_states(self, state_estimate: StateEstimate = None) -> None:
        """Initialize prediction state from fire simulation or state estimate.

        Reset the cell grid to original state and configure initial burning
        and burnt regions based on either the parent fire simulation state
        or a provided StateEstimate.

        Args:
            state_estimate (StateEstimate): Optional state estimate. If None,
                uses current fire simulation state (or serialized state in
                worker processes where fire is None).

        Side Effects:
            - Resets all cells in-place to FUEL state via reset_to_fuel()
            - Points _cell_grid and _cell_dict to orig_grid and orig_dict
            - Resets _burnt_cells, _burning_cells, _updated_cells, and
              _scheduled_spot_fires
            - Fixes weak references in cells to point to self
            - Sets starting_ignitions based on burning regions
        """
        # Reset cell grid in-place: point to the original grid objects and
        # reset all cells to initial FUEL state. This avoids expensive deepcopy
        # while providing the same semantics — each call starts from clean state.
        self._cell_grid = self.orig_grid
        self._cell_dict = self.orig_dict

        # On first run after deserialization, cells are already in initial FUEL
        # state with set_parent already called in __setstate__. Skip the expensive
        # reset loop (~1.1s on 105K-cell grids).
        if getattr(self, '_skip_first_reset', False):
            self._skip_first_reset = False
        else:
            # Reset all cells to initial FUEL state and fix weak references
            for cell in self._cell_dict.values():
                cell.reset_to_fuel()
                cell.set_parent(self)

        self._burnt_cells = []
        self._burning_cells = []
        self._updated_cells = {}
        self._scheduled_spot_fires = {}

        # Sync grid manager with the cell grid
        if hasattr(self, '_grid_manager') and self._grid_manager is not None:
            self._grid_manager._cell_grid = self._cell_grid
            self._grid_manager._cell_dict = self._cell_dict

        if state_estimate is None:
            # Set the burnt cells based on fire state
            # If we're in a worker (self.fire is None), use serialized fire state
            if self.fire is not None:
                if self.fire._burnt_cells:
                    burnt_region = UtilFuncs.get_cell_polygons(self.fire._burnt_cells)
                    self._set_initial_burnt_region(burnt_region)

                # Set the burning cells based on fire state
                burning_cells = [cell for cell in self.fire._burning_cells]
                burning_region = UtilFuncs.get_cell_polygons(burning_cells)
                self._set_initial_ignition(burning_region)
            else:
                # In worker process, use serialized fire state
                if self._serialization_data and 'fire_state' in self._serialization_data:
                    fire_state = self._serialization_data['fire_state']
                    if fire_state['burnt_cell_polygons']:
                        self._set_initial_burnt_region(fire_state['burnt_cell_polygons'])
                    if fire_state['burning_cell_polygons']:
                        self._set_initial_ignition(fire_state['burning_cell_polygons'])

        else:
            # Initialize empty set of starting ignitions
            self.starting_ignitions = set()

            # Set the burnt cells based on provided estimate
            if state_estimate.burnt_polys:
                self._set_initial_burnt_region(state_estimate.burnt_polys)

            # Set the burning cells based on provided estimate
            if state_estimate.burning_polys:
                self._set_initial_ignition(state_estimate.burning_polys)

            # Populate scheduled future ignitions (for staggered strip firing rollouts)
            if state_estimate.scheduled_ignitions:
                for ign_time_s, polys in state_estimate.scheduled_ignitions.items():
                    cells_at_time = []
                    for poly in polys:
                        cells = self.get_cells_at_geometry(poly)
                        cells_at_time.extend(cells)
                    if cells_at_time:
                        self._scheduled_spot_fires[ign_time_s] = cells_at_time

                if not self.model_spotting:
                    self.embers = PerrymanSpotting(0, (self.x_lim, self.y_lim))

    # =========================================================================
    # Weather & Wind
    # =========================================================================

    def _catch_up_with_fire(self, custom_start_time_s: Optional[float] = None) -> None:
        """Synchronize predictor time state with parent fire simulation.

        Set prediction start time, end time, and generate wind forecast.
        In worker processes (fire is None), uses serialized state instead.

        Args:
            custom_start_time_s (float): Optional start time in seconds from
                simulation start. If provided, validates and uses this as the
                prediction start time. If None, uses current fire simulation time.

        Side Effects:
            - Sets _curr_time_s, start_time_s, _end_time
            - Sets _start_weather_idx for custom start times
            - Calls _predict_wind() unless forecast was pre-assigned from pool
        """
        # Store the start weather index for any-time predictions
        self._start_weather_idx = None

        if custom_start_time_s is not None:
            # Validate and compute weather index for custom start time
            start_weather_idx, validated_start_time = self._validate_start_time(custom_start_time_s)
            self._start_weather_idx = start_weather_idx
            self._curr_time_s = validated_start_time
            self.start_time_s = validated_start_time
            self.last_viz_update = validated_start_time
            self._end_time = (self.time_horizon_hr * 3600) + self.start_time_s

            # Only generate wind if not pre-assigned from pool
            if not getattr(self, '_wind_forecast_assigned', False):
                self._predict_wind()
            else:
                self._cap_horizon_to_pool_forecast()

        elif self.fire is not None:
            # In main process: get current state from fire
            self._curr_time_s = self.fire._curr_time_s
            self.start_time_s = self._curr_time_s
            self.last_viz_update = self._curr_time_s
            self._end_time = (self.time_horizon_hr * 3600) + self.start_time_s

            # Only generate wind if not pre-assigned from pool
            if not getattr(self, '_wind_forecast_assigned', False):
                self._predict_wind()
            else:
                self._cap_horizon_to_pool_forecast()
        else:
            # In worker process: use serialized state
            self.start_time_s = self._curr_time_s  # Already set in __setstate__
            self.last_viz_update = self._curr_time_s
            self._end_time = (self.time_horizon_hr * 3600) + self.start_time_s

            # Skip wind generation if forecast assigned from pool
            if getattr(self, '_wind_forecast_assigned', False):
                self._cap_horizon_to_pool_forecast()
                return

            # Generate perturbed wind forecast in worker if varying per member
            # or if wind forecast was not pre-computed
            if getattr(self, '_vary_wind_per_member', False) or self.wind_forecast is None:
                self._predict_wind()

    def _predict_wind(self) -> None:
        """Generate perturbed wind forecast for prediction.

        Delegates weather perturbation to ForecastPool._perturb_weather_stream()
        (single source of truth for AR(1) perturbation logic), then runs
        WindNinja to compute terrain-adjusted wind fields.

        In worker processes (fire is None), uses serialized weather stream.
        Uses _start_weather_idx if set (any-time mode), otherwise uses fire's
        current weather index.

        Side Effects:
            - Sets wind_forecast array with shape (n_times, rows, cols, 2)
              where last dimension is [speed_ms, direction_deg]
            - Sets wind_xpad, wind_ypad for coordinate alignment
            - Updates _weather_stream with perturbed values
            - Creates temporary directory for WindNinja output
        """
        # Get source weather stream
        if self.fire is not None:
            source_stream = self.fire._weather_stream
            # Use custom start weather index if set (any-time mode)
            curr_idx = (self._start_weather_idx if self._start_weather_idx is not None
                       else self.fire._curr_weather_idx)
        else:
            # In worker: use serialized weather stream
            source_stream = self._weather_stream
            # Use custom start weather index if set (any-time mode)
            curr_idx = (self._start_weather_idx if self._start_weather_idx is not None
                       else self._curr_weather_idx)

        self.weather_t_step = source_stream.time_step * 60

        num_indices = int(np.ceil((self.time_horizon_hr * 3600) / self.weather_t_step))

        self._curr_weather_idx = 0
        if self.fire is not None:
            self._last_weather_update = self.fire._last_weather_update
        # else: _last_weather_update already set in __setstate__

        # Clamp num_indices to available stream entries (defensive check)
        end_idx = num_indices + curr_idx
        stream_len = len(source_stream.stream)
        if end_idx + 1 > stream_len:
            import warnings
            old_end_idx = end_idx
            end_idx = stream_len - 1
            # Update time horizon and end time to match capped range
            num_indices = end_idx - curr_idx
            self.time_horizon_hr = num_indices * self.weather_t_step / 3600
            self._end_time = self.start_time_s + self.time_horizon_hr * 3600
            warnings.warn(
                f"FirePredictor._predict_wind: end_idx ({old_end_idx} + 1) exceeds "
                f"stream length ({stream_len}). Capped to {end_idx}, "
                f"time_horizon_hr now {self.time_horizon_hr:.2f}."
            )

        # Delegate perturbation to ForecastPool (single source of truth)
        new_weather_stream, _, _ = ForecastPool._perturb_weather_stream(
            weather_stream=source_stream,
            start_idx=curr_idx,
            num_indices=num_indices,
            speed_seed=None,
            dir_seed=None,
            beta=self.beta,
            wnd_spd_std=self.wnd_spd_std,
            wnd_dir_std=self.wnd_dir_std,
            wind_speed_bias=self.wind_speed_bias,
            wind_dir_bias=self.wind_dir_bias
        )

        # Get map params from fire or from serialized sim_params
        map_params = self.fire._sim_params.map_params if self.fire is not None else self._sim_params.map_params

        # Generate unique temp directory for this worker to avoid race conditions
        # when multiple ensemble members run in parallel
        worker_id = uuid.uuid4().hex[:8]
        custom_temp = os.path.join(temp_file_path, f"worker_{worker_id}")

        self.wind_forecast = run_windninja(new_weather_stream, map_params, custom_temp)
        self.flipud_forecast = np.empty(self.wind_forecast.shape)

        for layer in range(self.wind_forecast.shape[0]):
            self.flipud_forecast[layer] = np.flipud(self.wind_forecast[layer])

        self.wind_forecast = self.flipud_forecast

        # Get mesh resolution from fire or from serialized sim_params
        sim_params = self.fire._sim_params if self.fire is not None else self._sim_params
        self._wind_res = sim_params.weather_input.mesh_resolution
        self._weather_stream = new_weather_stream

        self.wind_xpad, self.wind_ypad = self.calc_wind_padding(self.wind_forecast)

    def _set_prediction_forecast(self, cell) -> None:
        """Set wind forecast for a specific cell based on its position.

        Look up wind speed and direction from the forecast grid at the cell's
        spatial position, accounting for grid resolution and padding offsets.

        Args:
            cell (Cell): Cell to update with wind forecast data.

        Side Effects:
            Calls cell._set_wind_forecast() with interpolated wind values.
        """
        x_wind = max(cell.x_pos - self.wind_xpad, 0)
        y_wind = max(cell.y_pos - self.wind_ypad, 0)

        wind_col = int(np.floor(x_wind / self._wind_res))
        wind_row = int(np.floor(y_wind / self._wind_res))

        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

        wind_speed = self.wind_forecast[:, wind_row, wind_col, 0]
        wind_dir = self.wind_forecast[:, wind_row, wind_col, 1]
        cell._set_wind_forecast(wind_speed, wind_dir)

    # =========================================================================
    # Spotting Model
    # =========================================================================

    def _ignite_spots(self) -> None:
        """Process ember landings and schedule or ignite spot fires.

        Compute ignition probability for each ember landing based on travel
        distance and nominal ignition probability. Schedule successful ignitions
        for future time steps and ignite any previously scheduled spot fires
        that are due.

        Side Effects:
            - Clears embers from the Perryman spotting model
            - Adds entries to _scheduled_spot_fires
            - Appends ignited cells to _new_ignitions
            - Updates cell states to FIRE for ignited spots
        """
        # Decay constant for ignition probability
        lambda_s = 0.005

        # Get all the lofted embers by the Perryman model
        spot_fires = self.embers.embers

        landings = {}
        if spot_fires:
            for spot in spot_fires:
                x = spot['x']
                y = spot['y']
                d = spot['d']

                # Get the cell the ember lands in
                landing_cell = self.get_cell_from_xy(x, y, oob_ok=True)

                if landing_cell is not None and landing_cell.fuel.burnable:
                    # Compute the probability based on how far the ember travelled
                    p_i = self.nom_ign_prob * np.exp(-lambda_s * d)

                    # Add landing probability to dict or update its probability
                    if landings.get(landing_cell.id) is None:
                        landings[landing_cell.id] = 1 - p_i
                    else:
                        landings[landing_cell.id] *= (1 - p_i)

            for cell_id in list(landings.keys()):
                # Determine if the cell will ignite
                rand = np.random.random()
                if rand < (1 - landings[cell_id]):
                    # Schedule ignition
                    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] = [self._cell_dict[cell_id]]
                    else:
                        self._scheduled_spot_fires[ign_time].append(self._cell_dict[cell_id])

        # Clear the embers from the Perryman model
        self.embers.embers = []

        # Ignite any scheduled spot fires
        if self._scheduled_spot_fires:
            pending_times = list(self._scheduled_spot_fires.keys())

            # Check if there are any ignitions which take place this time step
            for time in pending_times:
                if time <= self.curr_time_s:
                    # Ignite the fires scheduled for this time step
                    new_spots = self._scheduled_spot_fires[time]
                    for spot in new_spots:
                        if spot.state == CellStates.FUEL and spot.fuel.burnable:
                            self._new_ignitions.append(spot)
                            spot.get_ign_params(0)
                            spot._set_state(CellStates.FIRE)
                            self._updated_cells[spot.id] = spot

                    # Delete entry from schedule if ignited
                    del self._scheduled_spot_fires[time]

                if time > self.curr_time_s:
                    break

    def _calc_nominal_prob(self) -> float:
        """Calculate nominal ignition probability for spotting.

        Compute base ignition probability using the method from "Ignition
        Probability" (Schroeder 1969) as described in the Perryman spotting
        model paper. Probability depends on dead fuel moisture content.

        Returns:
            float: Nominal ignition probability (0.0-1.0).
        """
        Q_ig = 250 + 1116 * self.dead_mf
        Q_ig_cal = BTU_lb_to_cal_g(Q_ig)

        x = (400 - Q_ig_cal) / 10
        p_i = (0.000048 * x ** 4.3) / 50

        return p_i

    def _validate_start_time(
        self,
        start_time_s: float,
        time_horizon_hr: Optional[float] = None
    ) -> Tuple[int, float]:
        """Validate start time and compute corresponding weather index.

        Check that the requested start time is not in the past and that the
        weather forecast covers the full prediction horizon from that time.

        Args:
            start_time_s (float): Desired start time in seconds from simulation
                start.
            time_horizon_hr (float): Optional time horizon in hours for validation.
                If None, uses self.time_horizon_hr.

        Returns:
            tuple: (weather_index, validated_start_time) where weather_index is
                the index into the weather stream and validated_start_time is
                the confirmed start time in seconds.

        Raises:
            ValueError: If start_time_s is in the past relative to current fire
                time, or if weather forecast doesn't cover the full prediction
                horizon.
        """
        # Use provided time horizon or fall back to instance attribute
        horizon_hr = time_horizon_hr if time_horizon_hr is not None else self.time_horizon_hr
        # Get current fire state (from fire or serialized data)
        if self.fire is not None:
            curr_time_s = self.fire._curr_time_s
            curr_weather_idx = self.fire._curr_weather_idx
            weather_stream = self.fire._weather_stream
        else:
            # In worker process: use serialized state
            fire_state = self._serialization_data['fire_state']
            curr_time_s = fire_state['curr_time_s']
            curr_weather_idx = fire_state['curr_weather_idx']
            weather_stream = self._serialization_data['weather_stream']

        # Constraint 1: start_time >= current fire time
        if start_time_s < curr_time_s:
            raise ValueError(
                f"start_time_s ({start_time_s}s) cannot be earlier than "
                f"current fire time ({curr_time_s}s). Cannot predict from the past."
            )

        # Compute weather time step in seconds
        weather_t_step = weather_stream.time_step * 60

        # Compute weather index for the start time
        time_delta_s = start_time_s - curr_time_s
        weather_offset = int(time_delta_s // weather_t_step)
        start_weather_idx = curr_weather_idx + weather_offset

        # Constraint 2: weather forecast covers full prediction horizon
        total_weather_entries = len(weather_stream.stream)
        prediction_end_time_s = start_time_s + (horizon_hr * 3600)
        prediction_end_offset = int((prediction_end_time_s - curr_time_s) // weather_t_step)
        required_weather_idx = curr_weather_idx + prediction_end_offset

        if required_weather_idx >= total_weather_entries:
            max_end_time_s = curr_time_s + (total_weather_entries - curr_weather_idx - 1) * weather_t_step
            max_start_time_s = max_end_time_s - (horizon_hr * 3600)
            raise ValueError(
                f"Weather forecast does not cover full prediction horizon. "
                f"start_time_s ({start_time_s}s) + time_horizon ({horizon_hr}hr) "
                f"requires weather data until index {required_weather_idx}, but only "
                f"{total_weather_entries} entries available. "
                f"Maximum valid start_time_s is {max_start_time_s}s."
            )

        return (start_weather_idx, start_time_s)

    def _cap_horizon_to_pool_forecast(self) -> None:
        """Cap prediction horizon to the pool forecast's time axis length.

        When using a pre-assigned forecast pool, ensure that time_horizon_hr
        does not exceed the number of time steps available in wind_forecast.
        If it does, warn and cap both time_horizon_hr and _end_time.

        Side Effects:
            - May reduce self.time_horizon_hr and self._end_time.
        """
        if self.wind_forecast is None:
            return
        n_steps = self.wind_forecast.shape[0]
        max_horizon_hr = (n_steps - 1) * self.weather_t_step / 3600
        if self.time_horizon_hr > max_horizon_hr:
            import warnings
            warnings.warn(
                f"FirePredictor._cap_horizon_to_pool_forecast: "
                f"time_horizon_hr ({self.time_horizon_hr:.2f}) exceeds pool forecast "
                f"coverage ({max_horizon_hr:.2f} hr, {n_steps} steps). "
                f"Capping to {max_horizon_hr:.2f} hr."
            )
            self.time_horizon_hr = max_horizon_hr
            self._end_time = self.start_time_s + self.time_horizon_hr * 3600

    # =========================================================================
    # Serialization (for parallel execution)
    # =========================================================================

    def prepare_for_serialization(
        self,
        vary_wind: bool = False,
        forecast_pool: Optional[ForecastPool] = None,
        forecast_indices: Optional[List[int]] = None
    ) -> None:
        """Prepare predictor for parallel execution by extracting serializable data.

        Must be called once before pickling the predictor. Capture the current
        state of the parent FireSim and store it in a serializable format.
        Call this in the main process before spawning workers.

        Args:
            vary_wind (bool): If True, workers generate their own wind forecasts.
                If False, use pre-computed shared wind forecast. Defaults to False.
            forecast_pool (ForecastPool): Optional pre-computed forecast pool.
            forecast_indices (list[int]): Indices mapping each ensemble member
                to a forecast in the pool.

        Raises:
            RuntimeError: If called without a fire reference (fire is None).

        Side Effects:
            Populates _serialization_data dict with fire state, parameters,
            weather stream, and optionally pre-computed wind forecast.
        """
        # Delegate to PredictorSerializer
        PredictorSerializer.prepare_for_serialization(
            self,
            vary_wind=vary_wind,
            forecast_pool=forecast_pool,
            forecast_indices=forecast_indices
        )

    def __getstate__(self) -> dict:
        """Serialize predictor for parallel execution.

        Return only the essential data needed to reconstruct the predictor
        in a worker process. Exclude non-serializable components like the
        parent FireSim reference, visualizer, and logger.

        Returns:
            dict: Minimal state dictionary containing serialization_data,
                orig_grid, orig_dict, and c_size.

        Raises:
            RuntimeError: If prepare_for_serialization() was not called first.
        """
        # Delegate to PredictorSerializer
        return PredictorSerializer.get_state(self)

    def __setstate__(self, state: dict) -> None:
        """Reconstruct predictor in worker process without full initialization.

        Manually restore all attributes that BaseFireSim.__init__() would set,
        but without the expensive cell creation loop. Use pre-built cell
        templates (orig_grid, orig_dict) instead of reconstructing cells from
        map data.

        Args:
            state (dict): State dictionary from __getstate__ containing
                serialization_data, orig_grid, orig_dict, and c_size.

        Side Effects:
            Restores all instance attributes needed for prediction, including
            maps, fuel models, weather stream, and optionally wind forecast.
            Sets fire to None (no parent reference in workers).
        """
        # Delegate to PredictorSerializer
        PredictorSerializer.set_state(self, state)

    # =========================================================================
    # Resource Management
    # =========================================================================

    def cleanup(self) -> None:
        """Release all predictor resources including forecast pools.

        Clears all active forecast pools managed by ForecastPoolManager
        and all prediction output data. Should be called when the predictor
        is no longer needed to free memory.

        This method is safe to call multiple times.

        Example:
            >>> predictor = FirePredictor(params, fire)
            >>> pool = predictor.generate_forecast_pool(30)
            >>> output = predictor.run_ensemble(estimates, forecast_pool=pool)
            >>> # When done with prediction
            >>> predictor.cleanup()
        """
        from embrs.tools.forecast_pool import ForecastPoolManager
        ForecastPoolManager.clear_all()
        self.clear_prediction_data()

    def clear_prediction_data(self) -> None:
        """Clear all prediction output data structures.

        Frees memory used by spread tracking, flame lengths, fire line
        intensities, and other per-timestep data accumulated during prediction.

        This is called automatically by cleanup() but can also be called
        separately to free memory while keeping the predictor usable.

        Note:
            The next call to predict() will re-initialize these data structures,
            so this is safe to call between predictions.
        """
        # Clear spread tracking dictionaries
        if hasattr(self, 'spread'):
            self.spread.clear()
        if hasattr(self, 'flame_len_m'):
            self.flame_len_m.clear()
        if hasattr(self, 'fli_kw_m'):
            self.fli_kw_m.clear()
        if hasattr(self, 'ros_ms'):
            self.ros_ms.clear()
        if hasattr(self, 'spread_dir'):
            self.spread_dir.clear()
        if hasattr(self, 'all_crown_fire'):
            self.all_crown_fire.clear()
        if hasattr(self, 'crown_fire_locs'):
            self.crown_fire_locs.clear()
        if hasattr(self, 'active_crown_fire'):
            self.active_crown_fire.clear()
        if hasattr(self, 'end_active_crown'):
            self.end_active_crown.clear()
        if hasattr(self, 'hold_probs'):
            self.hold_probs.clear()
        if hasattr(self, 'breaches'):
            self.breaches.clear()
        if hasattr(self, 'active_fire_front'):
            self.active_fire_front.clear()
        if hasattr(self, 'burnt_spread'):
            self.burnt_spread.clear()


        # Clear internal state tracking
        if hasattr(self, '_updated_cells'):
            self._updated_cells.clear()
        if hasattr(self, '_scheduled_spot_fires'):
            self._scheduled_spot_fires.clear()

    # =========================================================================
    # Visualization
    # =========================================================================

    def _visualize_ensemble(self, ensemble_output: EnsemblePredictionOutput) -> None:
        """Visualize ensemble prediction results on the fire visualizer.

        Args:
            ensemble_output (EnsemblePredictionOutput): Aggregated ensemble
                results containing burn probability maps.

        Side Effects:
            Calls fire.visualize_ensemble_prediction() to render burn
            probability overlay on the simulation display.
        """
        self.fire.visualize_ensemble_prediction(ensemble_output.burn_probability)

__getstate__()

Serialize predictor for parallel execution.

Return only the essential data needed to reconstruct the predictor in a worker process. Exclude non-serializable components like the parent FireSim reference, visualizer, and logger.

Returns:

Name Type Description
dict dict

Minimal state dictionary containing serialization_data, orig_grid, orig_dict, and c_size.

Raises:

Type Description
RuntimeError

If prepare_for_serialization() was not called first.

Source code in embrs/tools/fire_predictor.py
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
def __getstate__(self) -> dict:
    """Serialize predictor for parallel execution.

    Return only the essential data needed to reconstruct the predictor
    in a worker process. Exclude non-serializable components like the
    parent FireSim reference, visualizer, and logger.

    Returns:
        dict: Minimal state dictionary containing serialization_data,
            orig_grid, orig_dict, and c_size.

    Raises:
        RuntimeError: If prepare_for_serialization() was not called first.
    """
    # Delegate to PredictorSerializer
    return PredictorSerializer.get_state(self)

__init__(params, fire)

Initialize fire predictor with parameters and parent simulation.

Parameters:

Name Type Description Default
params PredictorParams

Configuration for prediction behavior including time horizon, uncertainty factors, and fuel moisture.

required
fire FireSim

Parent fire simulation to predict from. The predictor synchronizes with this simulation before each prediction run.

required
Source code in embrs/tools/fire_predictor.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def __init__(self, params: PredictorParams, fire: FireSim) -> None:
    """Initialize fire predictor with parameters and parent simulation.

    Args:
        params (PredictorParams): Configuration for prediction behavior
            including time horizon, uncertainty factors, and fuel moisture.
        fire (FireSim): Parent fire simulation to predict from. The predictor
            synchronizes with this simulation before each prediction run.
    """
    # Live reference to the fire sim
    self.fire = fire
    self.c_size = -1
    self.start_time_s = 0

    # Store original params for serialization
    self._params = params
    self._serialization_data = None  # Will hold snapshot for pickling

    self.set_params(params)

__setstate__(state)

Reconstruct predictor in worker process without full initialization.

Manually restore all attributes that BaseFireSim.init() would set, but without the expensive cell creation loop. Use pre-built cell templates (orig_grid, orig_dict) instead of reconstructing cells from map data.

Parameters:

Name Type Description Default
state dict

State dictionary from getstate containing serialization_data, orig_grid, orig_dict, and c_size.

required
Side Effects

Restores all instance attributes needed for prediction, including maps, fuel models, weather stream, and optionally wind forecast. Sets fire to None (no parent reference in workers).

Source code in embrs/tools/fire_predictor.py
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
def __setstate__(self, state: dict) -> None:
    """Reconstruct predictor in worker process without full initialization.

    Manually restore all attributes that BaseFireSim.__init__() would set,
    but without the expensive cell creation loop. Use pre-built cell
    templates (orig_grid, orig_dict) instead of reconstructing cells from
    map data.

    Args:
        state (dict): State dictionary from __getstate__ containing
            serialization_data, orig_grid, orig_dict, and c_size.

    Side Effects:
        Restores all instance attributes needed for prediction, including
        maps, fuel models, weather stream, and optionally wind forecast.
        Sets fire to None (no parent reference in workers).
    """
    # Delegate to PredictorSerializer
    PredictorSerializer.set_state(self, state)

cleanup()

Release all predictor resources including forecast pools.

Clears all active forecast pools managed by ForecastPoolManager and all prediction output data. Should be called when the predictor is no longer needed to free memory.

This method is safe to call multiple times.

Example

predictor = FirePredictor(params, fire) pool = predictor.generate_forecast_pool(30) output = predictor.run_ensemble(estimates, forecast_pool=pool)

When done with prediction

predictor.cleanup()

Source code in embrs/tools/fire_predictor.py
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
def cleanup(self) -> None:
    """Release all predictor resources including forecast pools.

    Clears all active forecast pools managed by ForecastPoolManager
    and all prediction output data. Should be called when the predictor
    is no longer needed to free memory.

    This method is safe to call multiple times.

    Example:
        >>> predictor = FirePredictor(params, fire)
        >>> pool = predictor.generate_forecast_pool(30)
        >>> output = predictor.run_ensemble(estimates, forecast_pool=pool)
        >>> # When done with prediction
        >>> predictor.cleanup()
    """
    from embrs.tools.forecast_pool import ForecastPoolManager
    ForecastPoolManager.clear_all()
    self.clear_prediction_data()

clear_prediction_data()

Clear all prediction output data structures.

Frees memory used by spread tracking, flame lengths, fire line intensities, and other per-timestep data accumulated during prediction.

This is called automatically by cleanup() but can also be called separately to free memory while keeping the predictor usable.

Note

The next call to predict() will re-initialize these data structures, so this is safe to call between predictions.

Source code in embrs/tools/fire_predictor.py
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
def clear_prediction_data(self) -> None:
    """Clear all prediction output data structures.

    Frees memory used by spread tracking, flame lengths, fire line
    intensities, and other per-timestep data accumulated during prediction.

    This is called automatically by cleanup() but can also be called
    separately to free memory while keeping the predictor usable.

    Note:
        The next call to predict() will re-initialize these data structures,
        so this is safe to call between predictions.
    """
    # Clear spread tracking dictionaries
    if hasattr(self, 'spread'):
        self.spread.clear()
    if hasattr(self, 'flame_len_m'):
        self.flame_len_m.clear()
    if hasattr(self, 'fli_kw_m'):
        self.fli_kw_m.clear()
    if hasattr(self, 'ros_ms'):
        self.ros_ms.clear()
    if hasattr(self, 'spread_dir'):
        self.spread_dir.clear()
    if hasattr(self, 'all_crown_fire'):
        self.all_crown_fire.clear()
    if hasattr(self, 'crown_fire_locs'):
        self.crown_fire_locs.clear()
    if hasattr(self, 'active_crown_fire'):
        self.active_crown_fire.clear()
    if hasattr(self, 'end_active_crown'):
        self.end_active_crown.clear()
    if hasattr(self, 'hold_probs'):
        self.hold_probs.clear()
    if hasattr(self, 'breaches'):
        self.breaches.clear()
    if hasattr(self, 'active_fire_front'):
        self.active_fire_front.clear()
    if hasattr(self, 'burnt_spread'):
        self.burnt_spread.clear()


    # Clear internal state tracking
    if hasattr(self, '_updated_cells'):
        self._updated_cells.clear()
    if hasattr(self, '_scheduled_spot_fires'):
        self._scheduled_spot_fires.clear()

generate_forecast_pool(n_forecasts, num_workers=None, random_seed=None)

Generate a pool of perturbed wind forecasts in parallel.

Create n_forecasts independent wind forecasts, each with different AR(1) perturbations applied to the base weather stream. WindNinja is called in parallel for efficiency.

The resulting pool can be reused across multiple ensemble runs. When passed to run_ensemble with explicit forecast_indices, each member uses the specified forecast. When forecast_indices is omitted, run_ensemble samples from the pool with replacement.

Parameters:

Name Type Description Default
n_forecasts int

Number of forecasts to generate.

required
num_workers int

Number of parallel workers. Defaults to min(cpu_count, n_forecasts).

None
random_seed int

Base seed for reproducibility. If None, uses random seeds for each forecast.

None

Returns:

Name Type Description
ForecastPool ForecastPool

Container with all generated forecasts, base weather stream, map parameters, and creation metadata.

Raises:

Type Description
RuntimeError

If called without a fire reference (fire is None).

Source code in embrs/tools/fire_predictor.py
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
def generate_forecast_pool(
    self,
    n_forecasts: int,
    num_workers: int = None,
    random_seed: int = None
) -> ForecastPool:
    """Generate a pool of perturbed wind forecasts in parallel.

    Create n_forecasts independent wind forecasts, each with different
    AR(1) perturbations applied to the base weather stream. WindNinja
    is called in parallel for efficiency.

    The resulting pool can be reused across multiple ensemble runs. When
    passed to run_ensemble with explicit forecast_indices, each member uses
    the specified forecast. When forecast_indices is omitted, run_ensemble
    samples from the pool with replacement.

    Args:
        n_forecasts (int): Number of forecasts to generate.
        num_workers (int): Number of parallel workers. Defaults to
            min(cpu_count, n_forecasts).
        random_seed (int): Base seed for reproducibility. If None, uses
            random seeds for each forecast.

    Returns:
        ForecastPool: Container with all generated forecasts, base weather
            stream, map parameters, and creation metadata.

    Raises:
        RuntimeError: If called without a fire reference (fire is None).
    """
    if self.fire is None:
        raise RuntimeError("Cannot generate forecast pool without fire reference")

    # Delegate to ForecastPool.generate() which owns the pool generation process
    return ForecastPool.generate(
        fire=self.fire,
        predictor_params=self._params,
        n_forecasts=n_forecasts,
        num_workers=num_workers,
        random_seed=random_seed,
        wind_speed_bias=self._params.wind_speed_bias,
        wind_dir_bias=self._params.wind_dir_bias,
        wind_uncertainty_factor=self.wind_uncertainty_factor,
        verbose=True
    )

prepare_for_serialization(vary_wind=False, forecast_pool=None, forecast_indices=None)

Prepare predictor for parallel execution by extracting serializable data.

Must be called once before pickling the predictor. Capture the current state of the parent FireSim and store it in a serializable format. Call this in the main process before spawning workers.

Parameters:

Name Type Description Default
vary_wind bool

If True, workers generate their own wind forecasts. If False, use pre-computed shared wind forecast. Defaults to False.

False
forecast_pool ForecastPool

Optional pre-computed forecast pool.

None
forecast_indices list[int]

Indices mapping each ensemble member to a forecast in the pool.

None

Raises:

Type Description
RuntimeError

If called without a fire reference (fire is None).

Side Effects

Populates _serialization_data dict with fire state, parameters, weather stream, and optionally pre-computed wind forecast.

Source code in embrs/tools/fire_predictor.py
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
def prepare_for_serialization(
    self,
    vary_wind: bool = False,
    forecast_pool: Optional[ForecastPool] = None,
    forecast_indices: Optional[List[int]] = None
) -> None:
    """Prepare predictor for parallel execution by extracting serializable data.

    Must be called once before pickling the predictor. Capture the current
    state of the parent FireSim and store it in a serializable format.
    Call this in the main process before spawning workers.

    Args:
        vary_wind (bool): If True, workers generate their own wind forecasts.
            If False, use pre-computed shared wind forecast. Defaults to False.
        forecast_pool (ForecastPool): Optional pre-computed forecast pool.
        forecast_indices (list[int]): Indices mapping each ensemble member
            to a forecast in the pool.

    Raises:
        RuntimeError: If called without a fire reference (fire is None).

    Side Effects:
        Populates _serialization_data dict with fire state, parameters,
        weather stream, and optionally pre-computed wind forecast.
    """
    # Delegate to PredictorSerializer
    PredictorSerializer.prepare_for_serialization(
        self,
        vary_wind=vary_wind,
        forecast_pool=forecast_pool,
        forecast_indices=forecast_indices
    )

run(fire_estimate=None, visualize=False)

Run a single fire spread prediction.

Executes forward prediction from either the current fire simulation state or a provided state estimate. Synchronizes with the parent fire simulation, generates perturbed wind forecasts, and iterates the fire spread model.

Parameters:

Name Type Description Default
fire_estimate StateEstimate

Optional state estimate to initialize from. If None, uses current fire simulation state. If provided with start_time_s, prediction starts from that future time.

None
visualize bool

If True, display prediction on fire visualizer. Defaults to False.

False

Returns:

Name Type Description
PredictionOutput PredictionOutput

Contains spread timeline (cell positions by time), flame length, fireline intensity, rate of spread, spread direction, crown fire status, hold probabilities, and breach status.

Raises:

Type Description
ValueError

If fire_estimate.start_time_s is in the past or beyond weather forecast coverage.

Source code in embrs/tools/fire_predictor.py
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
def run(
    self,
    fire_estimate: StateEstimate = None,
    visualize: bool = False
) -> PredictionOutput:
    """Run a single fire spread prediction.

    Executes forward prediction from either the current fire simulation
    state or a provided state estimate. Synchronizes with the parent
    fire simulation, generates perturbed wind forecasts, and iterates
    the fire spread model.

    Args:
        fire_estimate (StateEstimate): Optional state estimate to initialize
            from. If None, uses current fire simulation state. If provided
            with start_time_s, prediction starts from that future time.
        visualize (bool): If True, display prediction on fire visualizer.
            Defaults to False.

    Returns:
        PredictionOutput: Contains spread timeline (cell positions by time),
            flame length, fireline intensity, rate of spread, spread direction,
            crown fire status, hold probabilities, and breach status.

    Raises:
        ValueError: If fire_estimate.start_time_s is in the past or beyond
            weather forecast coverage.
    """
    # Extract custom start time if provided
    custom_start_time_s = None
    if fire_estimate is not None and fire_estimate.start_time_s is not None:
        custom_start_time_s = fire_estimate.start_time_s

    # Catch up time and weather states with the fire sim
    # (works in both main process and worker process)
    self._catch_up_with_fire(custom_start_time_s=custom_start_time_s)

    # Set fire state variables
    self._set_states(fire_estimate)

    # Initialize output containers
    self.spread = {}
    self.flame_len_m = {}
    self.fli_kw_m = {}
    self.ros_ms = {}
    self.spread_dir = {}
    self.active_crown_fire = {}
    self.end_active_crown = {}
    self.all_crown_fire = {}
    self.hold_probs = {}
    self.breaches = {}
    self.active_fire_front = {}
    self.burnt_spread = {}
    self.burnt_locs = []

    # Perform the prediction
    self._prediction_loop()

    if visualize and self.fire is not None:
        self.fire.visualize_prediction(self.spread)

    output = PredictionOutput(
        spread=self.spread,
        flame_len_m=self.flame_len_m,
        fli_kw_m=self.fli_kw_m,
        ros_ms=self.ros_ms,
        spread_dir=self.spread_dir,
        all_crown_fire=self.all_crown_fire,
        active_crown_fire=self.active_crown_fire,
        end_active_crown=self.end_active_crown,
        hold_probs=self.hold_probs,
        breaches=self.breaches,
        active_fire_front=self.active_fire_front,
        burnt_spread=self.burnt_spread
    )

    return output

run_ensemble(state_estimates, visualize=False, num_workers=None, random_seeds=None, return_individual=False, predictor_params_list=None, vary_wind_per_member=False, forecast_pool=None, forecast_indices=None)

Run ensemble predictions using multiple initial state estimates.

Execute predictions in parallel, each starting from a different StateEstimate. Results are aggregated into probabilistic burn maps and fire behavior statistics.

Uses custom serialization to efficiently transfer predictor state to worker processes without reconstructing the full FireSim.

Parameters:

Name Type Description Default
state_estimates list[StateEstimate]

Initial fire states for each ensemble member. Each may include burning_polys, burnt_polys, and optional start_time_s.

required
visualize bool

If True, visualize aggregated burn probability on the fire visualizer. Defaults to False.

False
num_workers int

Number of parallel workers. Defaults to cpu_count.

None
random_seeds list[int]

Optional seeds for reproducibility, one per state estimate.

None
return_individual bool

If True, include individual PredictionOutput objects in the returned EnsemblePredictionOutput. Defaults to False.

False
predictor_params_list list[PredictorParams]

Optional per-member parameters. If provided, each member uses its own params. Automatically enables vary_wind_per_member unless using forecast_pool.

None
vary_wind_per_member bool

If True, each worker generates its own perturbed wind forecast. If False, all members share the same wind forecast. Ignored when forecast_pool is provided.

False
forecast_pool ForecastPool

Optional pre-computed forecasts. Workers use forecasts from pool instead of running WindNinja.

None
forecast_indices list[int]

Optional indices into forecast_pool, one per state_estimate. If None, indices are sampled randomly with replacement.

None

Returns:

Name Type Description
EnsemblePredictionOutput EnsemblePredictionOutput

Aggregated ensemble results including burn probability maps, fire behavior statistics, and optional individual predictions.

Raises:

Type Description
ValueError

If state_estimates is empty, length mismatches occur, or any start_time_s is invalid.

RuntimeError

If more than 50% of ensemble members fail.

Source code in embrs/tools/fire_predictor.py
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
def run_ensemble(
    self,
    state_estimates: List[StateEstimate],
    visualize: bool = False,
    num_workers: Optional[int] = None,
    random_seeds: Optional[List[int]] = None,
    return_individual: bool = False,
    predictor_params_list: Optional[List[PredictorParams]] = None,
    vary_wind_per_member: bool = False,
    forecast_pool: Optional[ForecastPool] = None,
    forecast_indices: Optional[List[int]] = None
) -> EnsemblePredictionOutput:
    """Run ensemble predictions using multiple initial state estimates.

    Execute predictions in parallel, each starting from a different
    StateEstimate. Results are aggregated into probabilistic burn maps
    and fire behavior statistics.

    Uses custom serialization to efficiently transfer predictor state to
    worker processes without reconstructing the full FireSim.

    Args:
        state_estimates (list[StateEstimate]): Initial fire states for
            each ensemble member. Each may include burning_polys,
            burnt_polys, and optional start_time_s.
        visualize (bool): If True, visualize aggregated burn probability
            on the fire visualizer. Defaults to False.
        num_workers (int): Number of parallel workers. Defaults to cpu_count.
        random_seeds (list[int]): Optional seeds for reproducibility,
            one per state estimate.
        return_individual (bool): If True, include individual PredictionOutput
            objects in the returned EnsemblePredictionOutput. Defaults to False.
        predictor_params_list (list[PredictorParams]): Optional per-member
            parameters. If provided, each member uses its own params.
            Automatically enables vary_wind_per_member unless using
            forecast_pool.
        vary_wind_per_member (bool): If True, each worker generates its own
            perturbed wind forecast. If False, all members share the same
            wind forecast. Ignored when forecast_pool is provided.
        forecast_pool (ForecastPool): Optional pre-computed forecasts.
            Workers use forecasts from pool instead of running WindNinja.
        forecast_indices (list[int]): Optional indices into forecast_pool,
            one per state_estimate. If None, indices are sampled randomly
            with replacement.

    Returns:
        EnsemblePredictionOutput: Aggregated ensemble results including
            burn probability maps, fire behavior statistics, and optional
            individual predictions.

    Raises:
        ValueError: If state_estimates is empty, length mismatches occur,
            or any start_time_s is invalid.
        RuntimeError: If more than 50% of ensemble members fail.
    """
    import multiprocessing as mp
    import warnings
    from concurrent.futures import ProcessPoolExecutor, as_completed
    from tqdm import tqdm

    # Validation
    if not state_estimates:
        raise ValueError("state_estimates cannot be empty")

    if random_seeds is not None and len(random_seeds) != len(state_estimates):
        raise ValueError(
            f"random_seeds length ({len(random_seeds)}) must match "
            f"state_estimates length ({len(state_estimates)})"
        )

    # Validate predictor_params_list length
    if predictor_params_list is not None:
        if len(predictor_params_list) != len(state_estimates):
            raise ValueError(
                f"predictor_params_list length ({len(predictor_params_list)}) must match "
                f"state_estimates length ({len(state_estimates)})"
            )
        # If params are provided and no pool, wind MUST vary
        if forecast_pool is None:
            vary_wind_per_member = True

        # Warn about cell_size_m differences
        cell_sizes = set(p.cell_size_m for p in predictor_params_list)
        if len(cell_sizes) > 1 or (len(cell_sizes) == 1 and cell_sizes.pop() != self.c_size):
            warnings.warn(
                f"Some predictor_params have different cell_size_m values. "
                f"cell_size_m is ignored in per-member params; all members will use "
                f"the predictor's cell size ({self.c_size}m)."
            )

    n_ensemble = len(state_estimates)

    # Handle forecast pool
    use_pooled_forecasts = forecast_pool is not None

    if use_pooled_forecasts:
        if forecast_indices is None:
            # Sample indices with replacement (for rollouts)
            forecast_indices = forecast_pool.sample(n_ensemble, replace=True)
            print(f"Sampled forecast indices: {forecast_indices[:5]}{'...' if n_ensemble > 5 else ''}")
        else:
            # Validate explicit indices
            if len(forecast_indices) != n_ensemble:
                raise ValueError(
                    f"forecast_indices length ({len(forecast_indices)}) must match "
                    f"state_estimates length ({n_ensemble})"
                )

        # Validate all indices are in range
        for idx in forecast_indices:
            if idx < 0 or idx >= len(forecast_pool):
                raise ValueError(
                    f"Invalid forecast index {idx}, pool size is {len(forecast_pool)}"
                )

        # When using pool, wind is pre-computed (not varied per member)
        vary_wind_per_member = False

        print(f"Using forecast pool with {len(forecast_pool)} forecasts")
    else:
        forecast_indices = None

    # Validate all start times before parallel execution
    # Initialize _start_weather_idx for validation calls
    self._start_weather_idx = None
    for i, state_est in enumerate(state_estimates):
        if state_est.start_time_s is not None:
            # Use per-member time horizon if provided, otherwise use predictor's
            member_horizon = (predictor_params_list[i].time_horizon_hr
                              if predictor_params_list else None)
            try:
                self._validate_start_time(state_est.start_time_s, member_horizon)
            except ValueError as e:
                raise ValueError(
                    f"Invalid start_time_s for state_estimate[{i}]: {e}"
                )

    num_workers = num_workers or mp.cpu_count()

    print(f"Running ensemble prediction:")
    print(f"  - {n_ensemble} ensemble members")
    print(f"  - {num_workers} parallel workers")

    # CRITICAL: Prepare for serialization
    print("Preparing predictor for serialization...")

    # Generate shared wind forecast only if not using pool and not varying per member
    if not use_pooled_forecasts and not vary_wind_per_member:
        self._predict_wind()

    # Prepare workers for serialization
    self.prepare_for_serialization(
        vary_wind=vary_wind_per_member,
        forecast_pool=forecast_pool,
        forecast_indices=forecast_indices
    )

    # Run predictions in parallel
    predictions = []
    failed_count = 0

    with ProcessPoolExecutor(max_workers=num_workers) as executor:
        # Submit all jobs
        futures = {}
        for i, state_est in enumerate(state_estimates):
            seed = random_seeds[i] if random_seeds else None
            member_params = predictor_params_list[i] if predictor_params_list else None

            # Store member index for forecast assignment in worker
            if use_pooled_forecasts:
                self._serialization_data['member_index'] = i

            # Submit (predictor will be pickled via __getstate__)
            future = executor.submit(
                _run_ensemble_member_worker,
                self,
                state_est,
                seed,
                member_params
            )
            futures[future] = i  # Track member index

        # Collect results with progress bar
        collected_forecast_indices = []
        for future in tqdm(as_completed(futures),
                          total=n_ensemble,
                          desc="Ensemble predictions",
                          unit="member"):
            member_idx = futures[future]
            try:
                result = future.result()
                if forecast_indices is not None:
                    result.forecast_index = forecast_indices[member_idx]
                    collected_forecast_indices.append(forecast_indices[member_idx])
                predictions.append(result)
            except Exception as e:
                failed_count += 1
                print(f"Warning: Member {member_idx} failed: {e}")

    # Check failure rate
    if len(predictions) == 0:
        raise RuntimeError("All ensemble members failed")

    if failed_count > n_ensemble * 0.5:
        raise RuntimeError(
            f"More than 50% of ensemble members failed "
            f"({failed_count}/{n_ensemble})"
        )

    if failed_count > 0:
        print(f"Completed with {failed_count} failures, "
              f"{len(predictions)} successful members")

    # Aggregate results
    print("Aggregating ensemble predictions...")
    ensemble_output = _aggregate_ensemble_predictions(predictions)
    ensemble_output.n_ensemble = len(predictions)

    # Stamp forecast indices on ensemble output
    if collected_forecast_indices:
        ensemble_output.forecast_indices = collected_forecast_indices

    # Optionally include individual predictions
    if return_individual:
        ensemble_output.individual_predictions = predictions

    # Optionally visualize
    if visualize:
        self._visualize_ensemble(ensemble_output)

    return ensemble_output

set_params(params)

Configure predictor parameters and optionally regenerate cell grid.

Updates all prediction parameters from the provided PredictorParams. If cell_size_m has changed since the last call, regenerates the entire cell grid (expensive operation).

Parameters:

Name Type Description Default
params PredictorParams

New parameter values. All fields are used to update internal state.

required
Side Effects
  • Updates all uncertainty and bias parameters
  • May regenerate cell grid if cell_size_m changed
  • Computes nominal ignition probability for spotting
Source code in embrs/tools/fire_predictor.py
 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
def set_params(self, params: PredictorParams) -> None:
    """Configure predictor parameters and optionally regenerate cell grid.

    Updates all prediction parameters from the provided PredictorParams.
    If cell_size_m has changed since the last call, regenerates the
    entire cell grid (expensive operation).

    Args:
        params (PredictorParams): New parameter values. All fields are used
            to update internal state.

    Side Effects:
        - Updates all uncertainty and bias parameters
        - May regenerate cell grid if cell_size_m changed
        - Computes nominal ignition probability for spotting
    """
    generate_cell_grid = False

    # How long the prediction will run for
    self.time_horizon_hr = params.time_horizon_hr

    # Uncertainty parameters
    self.wind_uncertainty_factor = params.wind_uncertainty_factor  # [0, 1], autoregression noise

    # Compute constant bias terms
    self.wind_speed_bias = params.wind_speed_bias * params.max_wind_speed_bias
    self.wind_dir_bias = params.wind_dir_bias * params.max_wind_dir_bias
    self.ros_bias_factor = max(min(1 + params.ros_bias, 1.5), 0.5)

    # Compute auto-regressive parameters
    self.beta = self.wind_uncertainty_factor * params.max_beta
    self.wnd_spd_std = params.base_wind_spd_std * self.wind_uncertainty_factor
    self.wnd_dir_std = params.base_wind_dir_std * self.wind_uncertainty_factor

    # If cell size has changed since last set params, regenerate cell grid
    cell_size = params.cell_size_m
    if cell_size != self.c_size:
        generate_cell_grid = True

    # Track cell size
    self.c_size = cell_size

    # Set constant fuel moistures
    self.dead_mf = params.dead_mf
    self.live_mf = params.live_mf

    # Update relevant sim_params for prediction model for initialization
    sim_params = copy.deepcopy(self.fire._sim_params)
    sim_params.cell_size = params.cell_size_m
    sim_params.t_step_s = params.time_step_s
    sim_params.duration_s = self.time_horizon_hr * 3600
    sim_params.init_mf = [params.dead_mf, params.dead_mf, params.dead_mf]
    sim_params.spot_delay_s = params.spot_delay_s
    sim_params.model_spotting = params.model_spotting

    # Set the currently burning cells as the initial ignition region
    burning_cells = [cell for cell in self.fire._burning_cells]

    # Get the merged polygon representing burning cells
    sim_params.map_params.scenario_data.initial_ign = UtilFuncs.get_cell_polygons(burning_cells)
    burnt_region = UtilFuncs.get_cell_polygons(self.fire._burnt_cells)

    # Nominal ignition probability for spotting
    self.nom_ign_prob = self._calc_nominal_prob()

    if generate_cell_grid:
        super().__init__(sim_params, burnt_region=burnt_region)
        # orig_grid/orig_dict reference the same cell objects as _cell_grid/_cell_dict.
        # _set_states() will reset all cells in-place before each prediction.
        self.orig_grid = self._cell_grid
        self.orig_dict = self._cell_dict

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_R10(cell)

Compute no-wind no-slope ROS for Fuel Model 10 (Rothermel 1991).

Calculate the base ROS using Anderson 13 Fuel Model 10 properties and the cell's current fuel moisture. This value is used as a reference for the crown fire spread rate calculation.

Parameters:

Name Type Description Default
cell Cell

Cell providing fuel moisture (fmois).

required

Returns:

Name Type Description
float float

Base ROS for Fuel Model 10 (ft/min).

Source code in embrs/models/crown_model.py
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
def calc_R10(cell: Cell) -> float:
    """Compute no-wind no-slope ROS for Fuel Model 10 (Rothermel 1991).

    Calculate the base ROS using Anderson 13 Fuel Model 10 properties
    and the cell's current fuel moisture. This value is used as a
    reference for the crown fire spread rate calculation.

    Args:
        cell (Cell): Cell providing fuel moisture (``fmois``).

    Returns:
        float: Base ROS for Fuel Model 10 (ft/min).
    """
    fuel = _get_fuel_model_10()

    # Ensure fuel moisture dimensions are right for Rothermel calcs
    fmois = cell.fmois
    if len(fuel.rel_indices) != len(fmois):
        # If there is only one fuel moisture value make it that for all 3 classes
        if len(fmois) == 1:
            fmois = np.append(fmois, np.array([fmois[0], fmois[0]]))

        # If there are two, make the third the same as the second
        elif len(fmois) == 2:
            fmois = np.append(fmois, fmois[1]) 

    R_0, _ = calc_r_0(fuel, fmois)

    return R_0

calc_crown_eccentricity(wind_slope_vec_mag)

Compute crown fire ellipse eccentricity from wind/slope vector magnitude.

Similar to the surface fire eccentricity but uses different exponential coefficients and converts input from ft/min to mph.

Based on Alexander, M. E. (1985). Estimating the length-to-breadth ratio of elliptical forest fire patterns. Pages 287-304 in Proceedings of the Eighth Conference on Fire and Forest Meteorology. SAF Publication 85-04.

Parameters:

Name Type Description Default
wind_slope_vec_mag float

Combined wind/slope vector magnitude (ft/min).

required

Returns:

Name Type Description
float float

Crown fire ellipse eccentricity in [0, 1).

Source code in embrs/models/crown_model.py
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
def calc_crown_eccentricity(wind_slope_vec_mag: float) -> float:
    """Compute crown fire ellipse eccentricity from wind/slope vector magnitude.

    Similar to the surface fire eccentricity but uses different exponential
    coefficients and converts input from ft/min to mph.

    Based on Alexander, M. E. (1985). Estimating the length-to-breadth ratio
    of elliptical forest fire patterns. Pages 287-304 in Proceedings of the
    Eighth Conference on Fire and Forest Meteorology. SAF Publication 85-04.

    Args:
        wind_slope_vec_mag (float): Combined wind/slope vector magnitude
            (ft/min).

    Returns:
        float: Crown fire ellipse eccentricity in [0, 1).
    """
    wind_slope_vec_mag = ft_min_to_mph(wind_slope_vec_mag) # convert to mph

    z = 0.936 * math.exp(0.1147 * wind_slope_vec_mag) + 0.461 * math.exp(-0.0692 * wind_slope_vec_mag) - 0.397

    e = math.sqrt(z**2 - 1) / z

    return e

calc_crown_propagation(cell, r_actual, alpha, vec_mag, sfc, cfb)

Compute directional crown fire ROS and fireline intensity.

Calculate crown fireline intensity (Scott & Reinhardt 2001, Eq. 22), crown fire eccentricity, and resolve ROS and intensity along all spread directions.

Parameters:

Name Type Description Default
cell Cell

Cell providing canopy and spread direction data.

required
r_actual float

Actual crown fire ROS (m/min).

required
alpha float

Crown fire spread heading (radians).

required
vec_mag float

Wind/slope vector magnitude (ft/min).

required
sfc float

Surface fuel consumed (kg/m²).

required
cfb float

Crown fraction burned in [0, 1].

required

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/crown_model.py
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
def calc_crown_propagation(cell: Cell, r_actual: float, alpha: float,
                           vec_mag: float, sfc: float,
                           cfb: float) -> Tuple[np.ndarray, np.ndarray]:
    """Compute directional crown fire ROS and fireline intensity.

    Calculate crown fireline intensity (Scott & Reinhardt 2001, Eq. 22),
    crown fire eccentricity, and resolve ROS and intensity along all
    spread directions.

    Args:
        cell (Cell): Cell providing canopy and spread direction data.
        r_actual (float): Actual crown fire ROS (m/min).
        alpha (float): Crown fire spread heading (radians).
        vec_mag (float): Wind/slope vector magnitude (ft/min).
        sfc (float): Surface fuel consumed (kg/m²).
        cfb (float): Crown fraction burned in [0, 1].

    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.
    """
    # Calculate Fireline intensity (Based on Equation 22 of Scott Reinhardt Crown Fire [RMRS-RP-29])
    clb = crown_loading_burned(cell, cfb)
    I_h = crown_intensity(r_actual, sfc, clb)

    # Calculate Eccentricity
    e = calc_crown_eccentricity(vec_mag)
    cell.e = e

    # calculate ros and I along each direction based on e and alpha
    r_list, I_list = calc_vals_for_all_directions(cell, r_actual, 0, alpha, e, I_h=I_h)

    # return values in m/s and BTU/ft/min
    return r_list, I_list

calc_crown_vector(cell, R10)

Compute crown fire maximum ROS and spread direction.

Combine the Fuel Model 10 base ROS (R10) with wind and slope effects, then scale by the 3.34 crown fire multiplier (Rothermel 1991). Wind speed is reduced by 0.4 for the crown fire calculation.

Parameters:

Name Type Description Default
cell Cell

Cell providing wind, slope, and fuel data.

required
R10 float

No-wind no-slope Fuel Model 10 ROS (ft/min).

required

Returns:

Type Description
Tuple[float, float, float]

Tuple[float, float, float]: (R_cmax, crown_dir, vec_mag) where R_cmax is maximum crown fire ROS (ft/min), crown_dir is the spread heading (radians), and vec_mag is the wind/slope vector magnitude (ft/min).

Source code in embrs/models/crown_model.py
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
def calc_crown_vector(cell: Cell, R10: float) -> Tuple[float, float, float]:
    """Compute crown fire maximum ROS and spread direction.

    Combine the Fuel Model 10 base ROS (``R10``) with wind and slope
    effects, then scale by the 3.34 crown fire multiplier
    (Rothermel 1991). Wind speed is reduced by 0.4 for the crown fire
    calculation.

    Args:
        cell (Cell): Cell providing wind, slope, and fuel data.
        R10 (float): No-wind no-slope Fuel Model 10 ROS (ft/min).

    Returns:
        Tuple[float, float, float]: ``(R_cmax, crown_dir, vec_mag)`` where
            ``R_cmax`` is maximum crown fire ROS (ft/min), ``crown_dir`` is
            the spread heading (radians), and ``vec_mag`` is the
            wind/slope vector magnitude (ft/min).
    """
    wind_speed, wind_dir = cell.curr_wind()

    wind_ft_min = m_s_to_ft_min(wind_speed) # ft/min
    phi_w = calc_wind_factor(cell.fuel, wind_ft_min * 0.4) # Reduce wind speed by 0.4 to get R_10 (Rothermel 1991)

    slope_rad = math.radians(cell.slope_deg)
    phi_s = calc_slope_factor(cell.fuel, slope_rad)

    slope_speed = calc_slope_speed(cell, phi_s)

    if cell.slope_deg > 0:
        vec_speed, vec_mag, vec_dir = get_wind_slope_vector(cell, phi_w, phi_s, slope_speed)
        vec_ros = R10 * (1 + vec_speed)

    else:
        vec_dir = wind_dir
        vec_mag = wind_ft_min * 0.5
        vec_ros = R10 * (1 + phi_w)

    vec_ros *= 3.34 # R10 * 3.34 to get crown fire spread rate

    return vec_ros, math.radians(vec_dir), vec_mag # ft/min, radians, ft/min

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_slope_speed(cell, phi_s)

Compute equivalent wind speed from slope factor.

Invert the wind factor equation to find the wind speed that would produce the same spread effect as the slope factor.

Parameters:

Name Type Description Default
cell Cell

Cell providing fuel model with wind coefficients.

required
phi_s float

Slope factor (dimensionless).

required

Returns:

Name Type Description
float float

Equivalent slope wind speed (ft/min).

Source code in embrs/models/crown_model.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def calc_slope_speed(cell: Cell, phi_s: float) -> float:
    """Compute equivalent wind speed from slope factor.

    Invert the wind factor equation to find the wind speed that would
    produce the same spread effect as the slope factor.

    Args:
        cell (Cell): Cell providing fuel model with wind coefficients.
        phi_s (float): Slope factor (dimensionless).

    Returns:
        float: Equivalent slope wind speed (ft/min).
    """
    fuel = cell.fuel

    part1 = fuel.C * fuel.sav_ratio ** -fuel.E
    slope_speed = (phi_s/part1) ** (1/fuel.B)

    return slope_speed # ft/min

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

crown_fire(cell, fmc)

Evaluate crown fire initiation and update cell spread parameters.

Check whether the surface fireline intensity exceeds the Van Wagner (1977) crown fire initiation threshold. If so, determine whether the crown fire is passive or active, compute crown fire ROS, and update the cell's spread and intensity arrays.

Parameters:

Name Type Description Default
cell Cell

Burning cell with surface fire ROS (r_ss) and fireline intensity (I_ss) already computed. Must have canopy attributes (canopy_base_height, canopy_bulk_density, canopy_height).

required
fmc float

Foliar moisture content (percent).

required
Side Effects

Updates cell._crown_status, cell.cfb, cell.a_a, cell.r_ss, cell.I_ss, cell.r_h_ss, and cell.e. Sets crown status to NONE, PASSIVE, or ACTIVE.

Source code in embrs/models/crown_model.py
 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
def crown_fire(cell: Cell, fmc: float):
    """Evaluate crown fire initiation and update cell spread parameters.

    Check whether the surface fireline intensity exceeds the Van Wagner
    (1977) crown fire initiation threshold. If so, determine whether the
    crown fire is passive or active, compute crown fire ROS, and update
    the cell's spread and intensity arrays.

    Args:
        cell (Cell): Burning cell with surface fire ROS (``r_ss``) and
            fireline intensity (``I_ss``) already computed. Must have
            canopy attributes (``canopy_base_height``, ``canopy_bulk_density``,
            ``canopy_height``).
        fmc (float): Foliar moisture content (percent).

    Side Effects:
        Updates ``cell._crown_status``, ``cell.cfb``, ``cell.a_a``,
        ``cell.r_ss``, ``cell.I_ss``, ``cell.r_h_ss``, and ``cell.e``.
        Sets crown status to NONE, PASSIVE, or ACTIVE.
    """
    # Return if crown fire not possible
    if not cell.has_canopy:
        return

    # Calculate crown fire intensity threshold
    I_o = (0.01 * cell.canopy_base_height * (460 + 25.9 * fmc))**(3/2) # kW/m

    # Get the max rate of spread and fireline intensity within the cell
    R_m_s = float(np.max(cell.r_t))
    R = R_m_s * 60 # R in m/min
    I_btu_ft_min = float(np.max(cell.I_t))
    I_t = BTU_ft_min_to_kW_m(I_btu_ft_min) # I in kw/m

    # Check if fireline intensity is high enough to initiate crown fire
    if I_t >= I_o:
        # Surface fire will initiate a crown fire
        # Check if crown should be passive or active

        # Threshold for active crown spread rate (Alexander 1988)
        rac = 3.0 / cell.canopy_bulk_density # m/min

        # Critical surface fire spread rate
        R_0 = I_o * (R/I_t) # m/min

        # Surface fuel consumed 
        sfc = I_t / (300 * R) # kg/m^2

        # CFB scaling exponent
        a_c = -math.log(0.1) / (0.9 * (rac - R_0))

        # Crown fraction burned, proportion of the trees involved in crowning phase
        cfb = 1 - math.exp(-a_c * (R - R_0))

        # Set the crown fraction burned in the cell
        cell.cfb = cfb

        set_accel_constant(cell, cfb)

        # Forward surface fire spread rate for fuel model 10 using 0.4 wind reduction factor
        R_10 = calc_R10(cell)

        # Calculate maximum crown fire spread rate and the wind slope vector
        R_cmax, crown_dir, vec_mag = calc_crown_vector(cell, R_10)
        R_cmax = ft_min_to_m_s(R_cmax) * 60 # R_10 in m/min

        if cell._crown_status != CrownStatus.NONE:
            # Check if surface fire intensity too low in already burning crown fires
            t_r = 384 / cell.fuel.sav_ratio
            H_a = cell.reaction_intensity * t_r
            if (R_cmax * H_a) < I_o:
                cell._crown_status = CrownStatus.NONE
                return

        r_actual = R + cfb * (R_cmax - R) # m/min

        if r_actual >= rac:
            # Active crown fire
            # Actual active crown fire spread rate
            cell._crown_status = CrownStatus.ACTIVE

        else:
            # Passive crown fire
            # Passive crown fire rate set to surface rate of spread
            r_actual = cell.r_h_ss * 60 # m/min
            cell._crown_status = CrownStatus.PASSIVE

        # Set rate of spread based on crown fire equations
        cell.r_ss, cell.I_ss = calc_crown_propagation(cell, r_actual, crown_dir, vec_mag, sfc, cfb)
        cell.r_h_ss = float(np.max(cell.r_ss))

    else:
        cell._crown_status = CrownStatus.NONE

crown_intensity(R, sfc, clb)

Compute crown fireline intensity (Rothermel 1991, pp. 10-11).

Parameters:

Name Type Description Default
R float

Crown fire ROS (m/min). Converted to ft/s internally.

required
sfc float

Surface fuel consumed (kg/m²).

required
clb float

Crown loading burned (kg/m²).

required

Returns:

Name Type Description
float float

Crown fireline intensity (BTU/ft/min).

Source code in embrs/models/crown_model.py
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
def crown_intensity(R: float, sfc: float, clb: float) -> float:
    """Compute crown fireline intensity (Rothermel 1991, pp. 10-11).

    Args:
        R (float): Crown fire ROS (m/min). Converted to ft/s internally.
        sfc (float): Surface fuel consumed (kg/m²).
        clb (float): Crown loading burned (kg/m²).

    Returns:
        float: Crown fireline intensity (BTU/ft/min).
    """
    # Convert R to ft/s
    R /= (0.3048 * 60.0) # m/min to ft/s

    I_h = abs(R * (sfc + clb) * 1586.01) # btu/ft/s

    I_h *= 60 # convert to btu/ft/min

    return I_h

crown_loading_burned(cell, cfb)

Compute crown fuel loading consumed by the crown fire.

Based on Van Wagner (1990): crown load burned = CFB * CBD * (CH - CBH).

Parameters:

Name Type Description Default
cell Cell

Cell with canopy_bulk_density (kg/m³), canopy_height (meters), and canopy_base_height (meters).

required
cfb float

Crown fraction burned in [0, 1].

required

Returns:

Name Type Description
float float

Crown loading burned (kg/m²).

Source code in embrs/models/crown_model.py
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def crown_loading_burned(cell: Cell, cfb: float) -> float:
    """Compute crown fuel loading consumed by the crown fire.

    Based on Van Wagner (1990): crown load burned = CFB * CBD * (CH - CBH).

    Args:
        cell (Cell): Cell with ``canopy_bulk_density`` (kg/m³),
            ``canopy_height`` (meters), and ``canopy_base_height`` (meters).
        cfb (float): Crown fraction burned in [0, 1].

    Returns:
        float: Crown loading burned (kg/m²).
    """
    cbd = cell.canopy_bulk_density
    ch = cell.canopy_height
    cbh = cell.canopy_base_height

    # Compute crown loading burned (kg/m2)
    crown_load_burned = cfb * cbd * abs(ch - cbh) # Van Wagner 1990

    return crown_load_burned

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

get_wind_slope_vector(cell, phi_w, phi_s, slope_speed)

Compute the combined wind and slope vector for crown fire spread.

Resolve wind and slope influences into a resultant speed, magnitude, and direction using the law of cosines. Used by calc_crown_vector.

Parameters:

Name Type Description Default
cell Cell

Cell providing current wind and aspect.

required
phi_w float

Wind factor (dimensionless).

required
phi_s float

Slope factor (dimensionless).

required
slope_speed float

Equivalent slope wind speed (ft/min).

required

Returns:

Type Description
Tuple[float, float, float]

Tuple[float, float, float]: (vec_speed, vec_mag, vec_dir) where vec_speed is the combined factor magnitude (dimensionless), vec_mag is the resultant speed (ft/min), and vec_dir is the resultant direction (degrees, compass convention).

Source code in embrs/models/crown_model.py
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
def get_wind_slope_vector(cell: Cell, phi_w: float, phi_s: float,
                          slope_speed: float) -> Tuple[float, float, float]:
    """Compute the combined wind and slope vector for crown fire spread.

    Resolve wind and slope influences into a resultant speed, magnitude,
    and direction using the law of cosines. Used by ``calc_crown_vector``.

    Args:
        cell (Cell): Cell providing current wind and aspect.
        phi_w (float): Wind factor (dimensionless).
        phi_s (float): Slope factor (dimensionless).
        slope_speed (float): Equivalent slope wind speed (ft/min).

    Returns:
        Tuple[float, float, float]: ``(vec_speed, vec_mag, vec_dir)`` where
            ``vec_speed`` is the combined factor magnitude (dimensionless),
            ``vec_mag`` is the resultant speed (ft/min), and ``vec_dir``
            is the resultant direction (degrees, compass convention).
    """
    wind_speed, wind_dir = cell.curr_wind()

    angle = abs(wind_dir - cell.aspect) # degrees

    wind_speed_ft_min = m_s_to_ft_min(wind_speed) # ft/min
    wind_speed = 0.5 * wind_speed_ft_min

    angle_rad = math.radians(angle)
    if angle != 180:
        cos_angle = math.cos(angle_rad)
        vec_speed = math.sqrt(phi_w**2 + phi_s**2 + 2 * phi_w * phi_s * cos_angle)
        vec_mag = math.sqrt(wind_speed ** 2 + slope_speed ** 2 + (2 * wind_speed * slope_speed * cos_angle))

    else:
        vec_speed = abs(phi_s - phi_w)
        vec_mag = abs(slope_speed - wind_speed)

    if phi_s >= phi_w:
        aside = phi_w
        bside = phi_s
        cside = vec_speed
    else:
        aside = phi_s
        bside = phi_w
        cside = vec_speed

    if bside != 0 and cside != 0:
        vangle = (aside**2 - bside**2 - cside**2) / (-2 * bside * cside)

        if vangle > 1:
            vangle = 1
        else:
            if vangle < 0:
                vangle = math.pi
            else:
                vangle = math.acos(vangle)
                vangle = abs(vangle)

    else:
        vangle = 0

    vangle = math.degrees(vangle)

    if angle < 90:
        if angle > 0:
            if phi_w >= phi_s:
                vec_dir = wind_dir - vangle
            else:
                vec_dir = cell.aspect + vangle

        else:
            if phi_w >= phi_s:
                vec_dir = wind_dir + vangle
            else:
                vec_dir = cell.aspect - vangle
    else:
        if angle > 0:
            if phi_w >= phi_s:
                vec_dir = wind_dir + vangle
            else:
                vec_dir = cell.aspect - vangle
        else:
            if phi_w >= phi_s:
                vec_dir = wind_dir - vangle
            else:
                vec_dir = cell.aspect + vangle
    if vec_dir < 0:
        vec_dir += 360

    if vec_dir > 360:
        vec_dir -= 360

    return vec_speed, vec_mag, vec_dir # ft/min, degrees

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

njit_if_enabled(**jit_kwargs)

Decorator that applies Numba njit if available and enabled.

Equivalent to jit_if_enabled(nopython=True, **jit_kwargs). Use this for functions that must run in nopython mode.

Parameters:

Name Type Description Default
**jit_kwargs Any

Keyword arguments to pass to numba.njit.

{}

Returns:

Name Type Description
Callable Callable

Decorated function (JIT-compiled if enabled, else unchanged).

Source code in embrs/utilities/numba_utils.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def njit_if_enabled(**jit_kwargs: Any) -> Callable:
    """Decorator that applies Numba njit if available and enabled.

    Equivalent to jit_if_enabled(nopython=True, **jit_kwargs).
    Use this for functions that must run in nopython mode.

    Args:
        **jit_kwargs: Keyword arguments to pass to numba.njit.

    Returns:
        Callable: Decorated function (JIT-compiled if enabled, else unchanged).
    """
    def decorator(func: Callable) -> Callable:
        if NUMBA_AVAILABLE and not DISABLE_JIT:
            return njit(**jit_kwargs)(func)
        else:
            return func
    return decorator

set_accel_constant(cell, cfb)

Set the fire acceleration constant based on crown fraction burned.

Compute a crown-fire-adjusted acceleration constant and store it on the cell. The formula reduces acceleration as CFB increases.

Parameters:

Name Type Description Default
cell Cell

Cell to update.

required
cfb float

Crown fraction burned in [0, 1].

required
Side Effects

Sets cell.a_a (1/s).

Source code in embrs/models/crown_model.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def set_accel_constant(cell: Cell, cfb: float):
    """Set the fire acceleration constant based on crown fraction burned.

    Compute a crown-fire-adjusted acceleration constant and store it on
    the cell. The formula reduces acceleration as CFB increases.

    Args:
        cell (Cell): Cell to update.
        cfb (float): Crown fraction burned in [0, 1].

    Side Effects:
        Sets ``cell.a_a`` (1/s).
    """
    a = (0.3 - 18.8 * (cfb ** 2.5) * math.exp(-8 * cfb)) / 60

    cell.a_a = a

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)

Forecast Pool

Forecast pool management for ensemble fire predictions.

This module provides classes for managing pre-computed wind forecast pools that can be reused across multiple ensemble fire predictions.

Classes:

Name Description
- ForecastData

Container for a single wind forecast with metadata.

- ForecastPool

Collection of forecasts with pool size management.

- ForecastPoolManager

Global manager for active forecast pools.

The forecast pool system allows efficient reuse of WindNinja computations across global predictions and rollout scenarios.

Example

from embrs.tools.forecast_pool import ForecastPool

Create a forecast pool from a fire predictor

pool = ForecastPool.generate( ... fire=fire_sim, ... predictor_params=params, ... n_forecasts=30, ... num_workers=4 ... )

Use the pool in ensemble predictions

output = predictor.run_ensemble( ... state_estimates=estimates, ... forecast_pool=pool ... )

ForecastData dataclass

Container for a single wind forecast and its generating parameters.

Stores a WindNinja output array along with the perturbation parameters used to generate it, enabling reproducibility and reuse of forecasts across ensemble predictions.

Attributes:

Name Type Description
wind_forecast ndarray

WindNinja output array. Shape: (n_timesteps, height, width, 2) where [..., 0] = speed (m/s), [..., 1] = direction (degrees).

weather_stream 'WeatherStream'

The perturbed weather stream used to generate this forecast.

wind_speed_bias float

Constant wind speed bias applied (m/s).

wind_dir_bias float

Constant wind direction bias applied (degrees).

speed_error_seed int

Random seed used for AR(1) speed noise.

dir_error_seed int

Random seed used for AR(1) direction noise.

forecast_id int

Unique identifier for this forecast within the pool.

generation_time float

Unix timestamp when forecast was generated.

Source code in embrs/tools/forecast_pool.py
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
@dataclass
class ForecastData:
    """Container for a single wind forecast and its generating parameters.

    Stores a WindNinja output array along with the perturbation parameters
    used to generate it, enabling reproducibility and reuse of forecasts
    across ensemble predictions.

    Attributes:
        wind_forecast: WindNinja output array.
            Shape: (n_timesteps, height, width, 2) where [..., 0] = speed (m/s),
            [..., 1] = direction (degrees).
        weather_stream: The perturbed weather stream used to generate this forecast.
        wind_speed_bias: Constant wind speed bias applied (m/s).
        wind_dir_bias: Constant wind direction bias applied (degrees).
        speed_error_seed: Random seed used for AR(1) speed noise.
        dir_error_seed: Random seed used for AR(1) direction noise.
        forecast_id: Unique identifier for this forecast within the pool.
        generation_time: Unix timestamp when forecast was generated.
    """

    wind_forecast: np.ndarray
    weather_stream: 'WeatherStream'
    wind_speed_bias: float
    wind_dir_bias: float
    speed_error_seed: int
    dir_error_seed: int
    forecast_id: int
    generation_time: float

    def memory_usage(self) -> int:
        """Estimate memory usage in bytes."""
        return self.wind_forecast.nbytes

memory_usage()

Estimate memory usage in bytes.

Source code in embrs/tools/forecast_pool.py
196
197
198
def memory_usage(self) -> int:
    """Estimate memory usage in bytes."""
    return self.wind_forecast.nbytes

ForecastPool dataclass

A collection of pre-computed wind forecasts for ensemble use.

Provides storage and sampling methods for a pool of perturbed wind forecasts that can be reused across global predictions and rollouts.

The ForecastPool class now owns the pool generation process, making it the central point for creating and managing forecast pools.

Attributes:

Name Type Description
forecasts List[ForecastData]

List of ForecastData objects.

base_weather_stream 'WeatherStream'

Original unperturbed weather stream.

map_params 'MapParams'

Map parameters used for WindNinja.

predictor_params 'PredictorParams'

Predictor parameters at time of pool creation.

created_at_time_s float

Simulation time (seconds) when pool was created.

forecast_start_datetime 'datetime'

Local datetime that index 0 of forecasts corresponds to.

Example

Create a pool from fire simulation

pool = ForecastPool.generate( ... fire=fire_sim, ... predictor_params=params, ... n_forecasts=30 ... )

Sample forecast indices for ensemble

indices = pool.sample(10, seed=42)

Get a specific forecast

forecast = pool.get_forecast(0)

Source code in embrs/tools/forecast_pool.py
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
@dataclass
class ForecastPool:
    """A collection of pre-computed wind forecasts for ensemble use.

    Provides storage and sampling methods for a pool of perturbed wind
    forecasts that can be reused across global predictions and rollouts.

    The ForecastPool class now owns the pool generation process, making
    it the central point for creating and managing forecast pools.

    Attributes:
        forecasts: List of ForecastData objects.
        base_weather_stream: Original unperturbed weather stream.
        map_params: Map parameters used for WindNinja.
        predictor_params: Predictor parameters at time of pool creation.
        created_at_time_s: Simulation time (seconds) when pool was created.
        forecast_start_datetime: Local datetime that index 0 of forecasts
            corresponds to.

    Example:
        >>> # Create a pool from fire simulation
        >>> pool = ForecastPool.generate(
        ...     fire=fire_sim,
        ...     predictor_params=params,
        ...     n_forecasts=30
        ... )
        >>>
        >>> # Sample forecast indices for ensemble
        >>> indices = pool.sample(10, seed=42)
        >>>
        >>> # Get a specific forecast
        >>> forecast = pool.get_forecast(0)
    """

    forecasts: List[ForecastData]
    base_weather_stream: 'WeatherStream'
    map_params: 'MapParams'
    predictor_params: 'PredictorParams'
    created_at_time_s: float
    forecast_start_datetime: 'datetime'
    effective_horizon_hr: float = 0.0
    pool_start_weather_idx: int = 0

    def __post_init__(self):
        """Register this pool with the manager after creation."""
        ForecastPoolManager.register(self)

    def __len__(self) -> int:
        """Return the number of forecasts in the pool."""
        return len(self.forecasts)

    def __getitem__(self, idx: int) -> ForecastData:
        """Get a forecast by index."""
        return self.forecasts[idx]

    def sample(self, n: int, replace: bool = True, seed: Optional[int] = None) -> List[int]:
        """Sample n indices from the pool.

        Args:
            n: Number of indices to sample.
            replace: If True, sample with replacement (default). If False,
                n must not exceed pool size.
            seed: Random seed for reproducibility.

        Returns:
            List of forecast indices.

        Raises:
            ValueError: If replace=False and n > pool size.
        """
        rng = np.random.default_rng(seed)
        return rng.choice(len(self.forecasts), size=n, replace=replace).tolist()

    def get_forecast(self, idx: int) -> ForecastData:
        """Get a specific forecast by index.

        Args:
            idx: Index of the forecast to retrieve.

        Returns:
            ForecastData at the specified index.
        """
        return self.forecasts[idx]

    def get_weather_scenarios(self) -> List['WeatherStream']:
        """Return all perturbed weather streams for time window calculation.

        Returns:
            List of WeatherStream objects, one per forecast in the pool.
        """
        return [f.weather_stream for f in self.forecasts]

    def memory_usage(self) -> int:
        """Estimate memory usage in bytes."""
        total = 0
        for forecast in self.forecasts:
            total += forecast.memory_usage()
        return total

    def _cleanup(self) -> None:
        """Release memory for this pool.

        Clears the forecasts list to allow garbage collection of the
        potentially large wind forecast arrays.
        """
        self.forecasts.clear()

    def close(self) -> None:
        """Explicitly close this pool and release memory.

        Unregisters from the manager and clears forecasts.
        """
        ForecastPoolManager.unregister(self)
        self._cleanup()

    # =========================================================================
    # Class method for pool generation
    # =========================================================================

    @classmethod
    def generate(
        cls,
        fire: 'FireSim',
        predictor_params: 'PredictorParams',
        n_forecasts: int,
        num_workers: Optional[int] = None,
        random_seed: Optional[int] = None,
        wind_speed_bias: float = 0.0,
        wind_dir_bias: float = 0.0,
        wind_uncertainty_factor: float = 0.0,
        verbose: bool = True
    ) -> 'ForecastPool':
        """Generate a pool of perturbed wind forecasts in parallel.

        Create n_forecasts independent wind forecasts, each with different
        AR(1) perturbations applied to the base weather stream. WindNinja
        is called in parallel for efficiency.

        This class method centralizes all pool generation logic, making
        ForecastPool the owner of the entire pool creation process.

        Args:
            fire: Fire simulation to generate forecasts from.
            predictor_params: Predictor parameters for time horizon and settings.
            n_forecasts: Number of forecasts to generate.
            num_workers: Number of parallel workers. Defaults to
                min(cpu_count, n_forecasts).
            random_seed: Base seed for reproducibility. If None, uses
                random seeds for each forecast.
            wind_speed_bias: Constant wind speed bias in m/s.
            wind_dir_bias: Constant wind direction bias in degrees.
            wind_uncertainty_factor: Scaling factor for AR(1) noise (0-1).
            verbose: Whether to print progress messages.

        Returns:
            ForecastPool: Container with all generated forecasts, base weather
                stream, map parameters, and creation metadata.

        Raises:
            ValueError: If fire is None or n_forecasts < 1.

        Example:
            >>> pool = ForecastPool.generate(
            ...     fire=fire_sim,
            ...     predictor_params=params,
            ...     n_forecasts=30,
            ...     random_seed=42
            ... )
        """
        if fire is None:
            raise ValueError("Cannot generate forecast pool without fire reference")
        if n_forecasts < 1:
            raise ValueError("n_forecasts must be at least 1")

        from concurrent.futures import ProcessPoolExecutor, as_completed

        # Import tqdm only if available
        try:
            from tqdm import tqdm
            use_tqdm = verbose
        except ImportError:
            use_tqdm = False

        num_workers = num_workers or min(cpu_count(), n_forecasts)

        # Compute AR(1) parameters from predictor_params
        max_beta = predictor_params.max_beta
        base_wind_spd_std = predictor_params.base_wind_spd_std
        base_wind_dir_std = predictor_params.base_wind_dir_std

        beta = wind_uncertainty_factor * max_beta
        wnd_spd_std = base_wind_spd_std * wind_uncertainty_factor
        wnd_dir_std = base_wind_dir_std * wind_uncertainty_factor

        # Compute effective biases
        max_wind_speed_bias = predictor_params.max_wind_speed_bias
        max_wind_dir_bias = predictor_params.max_wind_dir_bias
        effective_speed_bias = wind_speed_bias * max_wind_speed_bias
        effective_dir_bias = wind_dir_bias * max_wind_dir_bias

        # Set up reproducible seeds if requested
        if random_seed is not None:
            base_rng = np.random.default_rng(random_seed)
            seeds = [
                (int(base_rng.integers(0, 2**31)), int(base_rng.integers(0, 2**31)))
                for _ in range(n_forecasts)
            ]
        else:
            seeds = [(None, None) for _ in range(n_forecasts)]

        # Get current state from fire simulation
        curr_weather_idx = fire._curr_weather_idx
        base_weather_stream = fire._weather_stream
        map_params = fire._sim_params.map_params
        time_horizon_hr = predictor_params.time_horizon_hr

        # Compute number of weather indices needed
        weather_t_step = base_weather_stream.time_step * 60  # seconds
        num_indices = int(np.ceil((time_horizon_hr * 3600) / weather_t_step))

        # Cap num_indices to available weather entries
        available_entries = len(base_weather_stream.stream) - curr_weather_idx
        if num_indices + 1 > available_entries:
            warnings.warn(
                f"ForecastPool.generate: requested {num_indices + 1} weather entries "
                f"(num_indices={num_indices} + 1) but only {available_entries} available "
                f"from idx {curr_weather_idx}. Capping num_indices to "
                f"{max(0, available_entries - 1)}."
            )
            num_indices = max(0, available_entries - 1)

        effective_horizon_hr = num_indices * weather_t_step / 3600

        # Prepare tasks
        tasks = []
        for i, (speed_seed, dir_seed) in enumerate(seeds):
            # Generate perturbed weather stream
            perturbed_stream, used_speed_seed, used_dir_seed = cls._perturb_weather_stream(
                weather_stream=base_weather_stream,
                start_idx=curr_weather_idx,
                num_indices=num_indices,
                speed_seed=speed_seed,
                dir_seed=dir_seed,
                beta=beta,
                wnd_spd_std=wnd_spd_std,
                wnd_dir_std=wnd_dir_std,
                wind_speed_bias=effective_speed_bias,
                wind_dir_bias=effective_dir_bias
            )

            tasks.append(_ForecastGenerationTask(
                forecast_id=i,
                perturbed_stream=perturbed_stream,
                map_params=map_params,
                wind_speed_bias=effective_speed_bias,
                wind_dir_bias=effective_dir_bias,
                speed_seed=used_speed_seed,
                dir_seed=used_dir_seed
            ))

        # Generate forecasts in parallel
        forecasts = [None] * n_forecasts

        if verbose:
            print(f"Generating forecast pool: {n_forecasts} forecasts using {num_workers} workers...")

        with ProcessPoolExecutor(max_workers=num_workers) as executor:
            future_to_idx = {
                executor.submit(_generate_single_forecast, task): task.forecast_id
                for task in tasks
            }

            if use_tqdm:
                iterator = tqdm(as_completed(future_to_idx), total=n_forecasts,
                               desc="WindNinja forecasts")
            else:
                iterator = as_completed(future_to_idx)

            for future in iterator:
                idx = future_to_idx[future]
                try:
                    forecast_data = future.result()
                    forecasts[idx] = forecast_data
                except Exception as e:
                    if verbose:
                        print(f"Forecast {idx} failed: {e}")
                    raise

        # Get the datetime that index 0 of the forecasts corresponds to
        forecast_start_datetime = base_weather_stream.stream_times[curr_weather_idx]

        return cls(
            forecasts=forecasts,
            base_weather_stream=copy.deepcopy(base_weather_stream),
            map_params=copy.deepcopy(map_params),
            predictor_params=copy.deepcopy(predictor_params),
            created_at_time_s=fire._curr_time_s,
            forecast_start_datetime=forecast_start_datetime,
            effective_horizon_hr=effective_horizon_hr,
            pool_start_weather_idx=curr_weather_idx
        )

    @staticmethod
    def _perturb_weather_stream(
        weather_stream: 'WeatherStream',
        start_idx: int,
        num_indices: int,
        speed_seed: Optional[int],
        dir_seed: Optional[int],
        beta: float,
        wnd_spd_std: float,
        wnd_dir_std: float,
        wind_speed_bias: float,
        wind_dir_bias: float
    ) -> tuple:
        """Apply AR(1) perturbation to a weather stream.

        Creates a copy of the weather stream with biases and autoregressive
        noise applied to wind speed and direction.

        Args:
            weather_stream: Original weather stream to perturb.
            start_idx: Starting index in the stream.
            num_indices: Number of entries to include.
            speed_seed: Random seed for speed perturbation.
            dir_seed: Random seed for direction perturbation.
            beta: AR(1) autoregression coefficient.
            wnd_spd_std: Standard deviation for speed noise.
            wnd_dir_std: Standard deviation for direction noise.
            wind_speed_bias: Constant speed bias.
            wind_dir_bias: Constant direction bias.

        Returns:
            tuple: (perturbed_stream, speed_seed_used, dir_seed_used)
        """
        from embrs.utilities.data_classes import WeatherEntry

        new_weather_stream = copy.copy(weather_stream)

        end_idx = num_indices + start_idx + 1  # +1 for inclusive end

        # Clamp end_idx to stream length
        stream_len = len(weather_stream.stream)
        if end_idx > stream_len:
            warnings.warn(
                f"ForecastPool._perturb_weather_stream: end_idx ({end_idx}) exceeds "
                f"stream length ({stream_len}). Clamping to {stream_len}."
            )
            end_idx = stream_len

        # Generate seeds if not provided
        if speed_seed is None:
            speed_seed = np.random.randint(0, 2**31)
        if dir_seed is None:
            dir_seed = np.random.randint(0, 2**31)

        # Create separate RNGs for reproducibility
        speed_rng = np.random.default_rng(speed_seed)
        dir_rng = np.random.default_rng(dir_seed)

        speed_error = 0.0
        dir_error = 0.0
        new_stream = []

        for entry in weather_stream.stream[start_idx:end_idx]:
            new_entry = WeatherEntry(
                wind_speed=max(0.0, entry.wind_speed + speed_error + wind_speed_bias),
                wind_dir_deg=(entry.wind_dir_deg + dir_error + wind_dir_bias) % 360,
                temp=entry.temp,
                rel_humidity=entry.rel_humidity,
                cloud_cover=entry.cloud_cover,
                rain=entry.rain,
                dni=entry.dni,
                dhi=entry.dhi,
                ghi=entry.ghi,
                solar_zenith=entry.solar_zenith,
                solar_azimuth=entry.solar_azimuth
            )
            new_stream.append(new_entry)

            # Update errors using AR(1) process
            speed_error = beta * speed_error + speed_rng.normal(0, wnd_spd_std)
            dir_error = beta * dir_error + dir_rng.normal(0, wnd_dir_std)

        new_weather_stream.stream = new_stream
        new_weather_stream.sim_start_idx = 0
        return new_weather_stream, speed_seed, dir_seed

__getitem__(idx)

Get a forecast by index.

Source code in embrs/tools/forecast_pool.py
328
329
330
def __getitem__(self, idx: int) -> ForecastData:
    """Get a forecast by index."""
    return self.forecasts[idx]

__len__()

Return the number of forecasts in the pool.

Source code in embrs/tools/forecast_pool.py
324
325
326
def __len__(self) -> int:
    """Return the number of forecasts in the pool."""
    return len(self.forecasts)

__post_init__()

Register this pool with the manager after creation.

Source code in embrs/tools/forecast_pool.py
320
321
322
def __post_init__(self):
    """Register this pool with the manager after creation."""
    ForecastPoolManager.register(self)

close()

Explicitly close this pool and release memory.

Unregisters from the manager and clears forecasts.

Source code in embrs/tools/forecast_pool.py
384
385
386
387
388
389
390
def close(self) -> None:
    """Explicitly close this pool and release memory.

    Unregisters from the manager and clears forecasts.
    """
    ForecastPoolManager.unregister(self)
    self._cleanup()

generate(fire, predictor_params, n_forecasts, num_workers=None, random_seed=None, wind_speed_bias=0.0, wind_dir_bias=0.0, wind_uncertainty_factor=0.0, verbose=True) classmethod

Generate a pool of perturbed wind forecasts in parallel.

Create n_forecasts independent wind forecasts, each with different AR(1) perturbations applied to the base weather stream. WindNinja is called in parallel for efficiency.

This class method centralizes all pool generation logic, making ForecastPool the owner of the entire pool creation process.

Parameters:

Name Type Description Default
fire 'FireSim'

Fire simulation to generate forecasts from.

required
predictor_params 'PredictorParams'

Predictor parameters for time horizon and settings.

required
n_forecasts int

Number of forecasts to generate.

required
num_workers Optional[int]

Number of parallel workers. Defaults to min(cpu_count, n_forecasts).

None
random_seed Optional[int]

Base seed for reproducibility. If None, uses random seeds for each forecast.

None
wind_speed_bias float

Constant wind speed bias in m/s.

0.0
wind_dir_bias float

Constant wind direction bias in degrees.

0.0
wind_uncertainty_factor float

Scaling factor for AR(1) noise (0-1).

0.0
verbose bool

Whether to print progress messages.

True

Returns:

Name Type Description
ForecastPool 'ForecastPool'

Container with all generated forecasts, base weather stream, map parameters, and creation metadata.

Raises:

Type Description
ValueError

If fire is None or n_forecasts < 1.

Example

pool = ForecastPool.generate( ... fire=fire_sim, ... predictor_params=params, ... n_forecasts=30, ... random_seed=42 ... )

Source code in embrs/tools/forecast_pool.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
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
@classmethod
def generate(
    cls,
    fire: 'FireSim',
    predictor_params: 'PredictorParams',
    n_forecasts: int,
    num_workers: Optional[int] = None,
    random_seed: Optional[int] = None,
    wind_speed_bias: float = 0.0,
    wind_dir_bias: float = 0.0,
    wind_uncertainty_factor: float = 0.0,
    verbose: bool = True
) -> 'ForecastPool':
    """Generate a pool of perturbed wind forecasts in parallel.

    Create n_forecasts independent wind forecasts, each with different
    AR(1) perturbations applied to the base weather stream. WindNinja
    is called in parallel for efficiency.

    This class method centralizes all pool generation logic, making
    ForecastPool the owner of the entire pool creation process.

    Args:
        fire: Fire simulation to generate forecasts from.
        predictor_params: Predictor parameters for time horizon and settings.
        n_forecasts: Number of forecasts to generate.
        num_workers: Number of parallel workers. Defaults to
            min(cpu_count, n_forecasts).
        random_seed: Base seed for reproducibility. If None, uses
            random seeds for each forecast.
        wind_speed_bias: Constant wind speed bias in m/s.
        wind_dir_bias: Constant wind direction bias in degrees.
        wind_uncertainty_factor: Scaling factor for AR(1) noise (0-1).
        verbose: Whether to print progress messages.

    Returns:
        ForecastPool: Container with all generated forecasts, base weather
            stream, map parameters, and creation metadata.

    Raises:
        ValueError: If fire is None or n_forecasts < 1.

    Example:
        >>> pool = ForecastPool.generate(
        ...     fire=fire_sim,
        ...     predictor_params=params,
        ...     n_forecasts=30,
        ...     random_seed=42
        ... )
    """
    if fire is None:
        raise ValueError("Cannot generate forecast pool without fire reference")
    if n_forecasts < 1:
        raise ValueError("n_forecasts must be at least 1")

    from concurrent.futures import ProcessPoolExecutor, as_completed

    # Import tqdm only if available
    try:
        from tqdm import tqdm
        use_tqdm = verbose
    except ImportError:
        use_tqdm = False

    num_workers = num_workers or min(cpu_count(), n_forecasts)

    # Compute AR(1) parameters from predictor_params
    max_beta = predictor_params.max_beta
    base_wind_spd_std = predictor_params.base_wind_spd_std
    base_wind_dir_std = predictor_params.base_wind_dir_std

    beta = wind_uncertainty_factor * max_beta
    wnd_spd_std = base_wind_spd_std * wind_uncertainty_factor
    wnd_dir_std = base_wind_dir_std * wind_uncertainty_factor

    # Compute effective biases
    max_wind_speed_bias = predictor_params.max_wind_speed_bias
    max_wind_dir_bias = predictor_params.max_wind_dir_bias
    effective_speed_bias = wind_speed_bias * max_wind_speed_bias
    effective_dir_bias = wind_dir_bias * max_wind_dir_bias

    # Set up reproducible seeds if requested
    if random_seed is not None:
        base_rng = np.random.default_rng(random_seed)
        seeds = [
            (int(base_rng.integers(0, 2**31)), int(base_rng.integers(0, 2**31)))
            for _ in range(n_forecasts)
        ]
    else:
        seeds = [(None, None) for _ in range(n_forecasts)]

    # Get current state from fire simulation
    curr_weather_idx = fire._curr_weather_idx
    base_weather_stream = fire._weather_stream
    map_params = fire._sim_params.map_params
    time_horizon_hr = predictor_params.time_horizon_hr

    # Compute number of weather indices needed
    weather_t_step = base_weather_stream.time_step * 60  # seconds
    num_indices = int(np.ceil((time_horizon_hr * 3600) / weather_t_step))

    # Cap num_indices to available weather entries
    available_entries = len(base_weather_stream.stream) - curr_weather_idx
    if num_indices + 1 > available_entries:
        warnings.warn(
            f"ForecastPool.generate: requested {num_indices + 1} weather entries "
            f"(num_indices={num_indices} + 1) but only {available_entries} available "
            f"from idx {curr_weather_idx}. Capping num_indices to "
            f"{max(0, available_entries - 1)}."
        )
        num_indices = max(0, available_entries - 1)

    effective_horizon_hr = num_indices * weather_t_step / 3600

    # Prepare tasks
    tasks = []
    for i, (speed_seed, dir_seed) in enumerate(seeds):
        # Generate perturbed weather stream
        perturbed_stream, used_speed_seed, used_dir_seed = cls._perturb_weather_stream(
            weather_stream=base_weather_stream,
            start_idx=curr_weather_idx,
            num_indices=num_indices,
            speed_seed=speed_seed,
            dir_seed=dir_seed,
            beta=beta,
            wnd_spd_std=wnd_spd_std,
            wnd_dir_std=wnd_dir_std,
            wind_speed_bias=effective_speed_bias,
            wind_dir_bias=effective_dir_bias
        )

        tasks.append(_ForecastGenerationTask(
            forecast_id=i,
            perturbed_stream=perturbed_stream,
            map_params=map_params,
            wind_speed_bias=effective_speed_bias,
            wind_dir_bias=effective_dir_bias,
            speed_seed=used_speed_seed,
            dir_seed=used_dir_seed
        ))

    # Generate forecasts in parallel
    forecasts = [None] * n_forecasts

    if verbose:
        print(f"Generating forecast pool: {n_forecasts} forecasts using {num_workers} workers...")

    with ProcessPoolExecutor(max_workers=num_workers) as executor:
        future_to_idx = {
            executor.submit(_generate_single_forecast, task): task.forecast_id
            for task in tasks
        }

        if use_tqdm:
            iterator = tqdm(as_completed(future_to_idx), total=n_forecasts,
                           desc="WindNinja forecasts")
        else:
            iterator = as_completed(future_to_idx)

        for future in iterator:
            idx = future_to_idx[future]
            try:
                forecast_data = future.result()
                forecasts[idx] = forecast_data
            except Exception as e:
                if verbose:
                    print(f"Forecast {idx} failed: {e}")
                raise

    # Get the datetime that index 0 of the forecasts corresponds to
    forecast_start_datetime = base_weather_stream.stream_times[curr_weather_idx]

    return cls(
        forecasts=forecasts,
        base_weather_stream=copy.deepcopy(base_weather_stream),
        map_params=copy.deepcopy(map_params),
        predictor_params=copy.deepcopy(predictor_params),
        created_at_time_s=fire._curr_time_s,
        forecast_start_datetime=forecast_start_datetime,
        effective_horizon_hr=effective_horizon_hr,
        pool_start_weather_idx=curr_weather_idx
    )

get_forecast(idx)

Get a specific forecast by index.

Parameters:

Name Type Description Default
idx int

Index of the forecast to retrieve.

required

Returns:

Type Description
ForecastData

ForecastData at the specified index.

Source code in embrs/tools/forecast_pool.py
350
351
352
353
354
355
356
357
358
359
def get_forecast(self, idx: int) -> ForecastData:
    """Get a specific forecast by index.

    Args:
        idx: Index of the forecast to retrieve.

    Returns:
        ForecastData at the specified index.
    """
    return self.forecasts[idx]

get_weather_scenarios()

Return all perturbed weather streams for time window calculation.

Returns:

Type Description
List['WeatherStream']

List of WeatherStream objects, one per forecast in the pool.

Source code in embrs/tools/forecast_pool.py
361
362
363
364
365
366
367
def get_weather_scenarios(self) -> List['WeatherStream']:
    """Return all perturbed weather streams for time window calculation.

    Returns:
        List of WeatherStream objects, one per forecast in the pool.
    """
    return [f.weather_stream for f in self.forecasts]

memory_usage()

Estimate memory usage in bytes.

Source code in embrs/tools/forecast_pool.py
369
370
371
372
373
374
def memory_usage(self) -> int:
    """Estimate memory usage in bytes."""
    total = 0
    for forecast in self.forecasts:
        total += forecast.memory_usage()
    return total

sample(n, replace=True, seed=None)

Sample n indices from the pool.

Parameters:

Name Type Description Default
n int

Number of indices to sample.

required
replace bool

If True, sample with replacement (default). If False, n must not exceed pool size.

True
seed Optional[int]

Random seed for reproducibility.

None

Returns:

Type Description
List[int]

List of forecast indices.

Raises:

Type Description
ValueError

If replace=False and n > pool size.

Source code in embrs/tools/forecast_pool.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
def sample(self, n: int, replace: bool = True, seed: Optional[int] = None) -> List[int]:
    """Sample n indices from the pool.

    Args:
        n: Number of indices to sample.
        replace: If True, sample with replacement (default). If False,
            n must not exceed pool size.
        seed: Random seed for reproducibility.

    Returns:
        List of forecast indices.

    Raises:
        ValueError: If replace=False and n > pool size.
    """
    rng = np.random.default_rng(seed)
    return rng.choice(len(self.forecasts), size=n, replace=replace).tolist()

ForecastPoolManager

Manages active forecast pools to prevent unbounded memory growth.

This class tracks all active ForecastPool instances and enforces a maximum number of active pools. When a new pool is created and the limit is exceeded, the oldest pool is automatically cleaned up.

Class Attributes

MAX_ACTIVE_POOLS (int): Maximum number of active pools (default: 3). _active_pools (List[ForecastPool]): Currently active pools. _enabled (bool): Whether pool management is enabled.

Example

Check current pool count

print(f"Active pools: {ForecastPoolManager.pool_count()}")

Clear all pools when done

ForecastPoolManager.clear_all()

Source code in embrs/tools/forecast_pool.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
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
class ForecastPoolManager:
    """Manages active forecast pools to prevent unbounded memory growth.

    This class tracks all active ForecastPool instances and enforces a
    maximum number of active pools. When a new pool is created and the
    limit is exceeded, the oldest pool is automatically cleaned up.

    Class Attributes:
        MAX_ACTIVE_POOLS (int): Maximum number of active pools (default: 3).
        _active_pools (List[ForecastPool]): Currently active pools.
        _enabled (bool): Whether pool management is enabled.

    Example:
        >>> # Check current pool count
        >>> print(f"Active pools: {ForecastPoolManager.pool_count()}")
        >>>
        >>> # Clear all pools when done
        >>> ForecastPoolManager.clear_all()
    """

    MAX_ACTIVE_POOLS: int = 3
    _active_pools: List['ForecastPool'] = []
    _enabled: bool = True

    @classmethod
    def register(cls, pool: 'ForecastPool') -> None:
        """Register a new pool and evict oldest if over limit.

        Args:
            pool: The ForecastPool to register.
        """
        if not cls._enabled:
            return

        # Evict oldest pool if we're at capacity
        while len(cls._active_pools) >= cls.MAX_ACTIVE_POOLS:
            oldest = cls._active_pools.pop(0)
            oldest._cleanup()

        cls._active_pools.append(pool)

    @classmethod
    def unregister(cls, pool: 'ForecastPool') -> None:
        """Unregister a pool without cleanup.

        Args:
            pool: The ForecastPool to unregister.
        """
        if pool in cls._active_pools:
            cls._active_pools.remove(pool)

    @classmethod
    def clear_all(cls) -> None:
        """Clear all active pools and release memory."""
        for pool in cls._active_pools:
            pool._cleanup()
        cls._active_pools.clear()

    @classmethod
    def pool_count(cls) -> int:
        """Return the number of active pools."""
        return len(cls._active_pools)

    @classmethod
    def set_max_pools(cls, n: int) -> None:
        """Set the maximum number of active pools.

        Args:
            n: Maximum number of active pools (must be >= 1).

        Raises:
            ValueError: If n < 1.
        """
        if n < 1:
            raise ValueError("MAX_ACTIVE_POOLS must be at least 1")
        cls.MAX_ACTIVE_POOLS = n

        # Evict excess pools if needed
        while len(cls._active_pools) > cls.MAX_ACTIVE_POOLS:
            oldest = cls._active_pools.pop(0)
            oldest._cleanup()

    @classmethod
    def disable(cls) -> None:
        """Disable automatic pool management."""
        cls._enabled = False

    @classmethod
    def enable(cls) -> None:
        """Enable automatic pool management."""
        cls._enabled = True

    @classmethod
    def get_active_pools(cls) -> List['ForecastPool']:
        """Return list of active pools (read-only copy)."""
        return list(cls._active_pools)

    @classmethod
    def memory_usage(cls) -> int:
        """Estimate total memory usage of all active pools in bytes."""
        total = 0
        for pool in cls._active_pools:
            total += pool.memory_usage()
        return total

clear_all() classmethod

Clear all active pools and release memory.

Source code in embrs/tools/forecast_pool.py
107
108
109
110
111
112
@classmethod
def clear_all(cls) -> None:
    """Clear all active pools and release memory."""
    for pool in cls._active_pools:
        pool._cleanup()
    cls._active_pools.clear()

disable() classmethod

Disable automatic pool management.

Source code in embrs/tools/forecast_pool.py
138
139
140
141
@classmethod
def disable(cls) -> None:
    """Disable automatic pool management."""
    cls._enabled = False

enable() classmethod

Enable automatic pool management.

Source code in embrs/tools/forecast_pool.py
143
144
145
146
@classmethod
def enable(cls) -> None:
    """Enable automatic pool management."""
    cls._enabled = True

get_active_pools() classmethod

Return list of active pools (read-only copy).

Source code in embrs/tools/forecast_pool.py
148
149
150
151
@classmethod
def get_active_pools(cls) -> List['ForecastPool']:
    """Return list of active pools (read-only copy)."""
    return list(cls._active_pools)

memory_usage() classmethod

Estimate total memory usage of all active pools in bytes.

Source code in embrs/tools/forecast_pool.py
153
154
155
156
157
158
159
@classmethod
def memory_usage(cls) -> int:
    """Estimate total memory usage of all active pools in bytes."""
    total = 0
    for pool in cls._active_pools:
        total += pool.memory_usage()
    return total

pool_count() classmethod

Return the number of active pools.

Source code in embrs/tools/forecast_pool.py
114
115
116
117
@classmethod
def pool_count(cls) -> int:
    """Return the number of active pools."""
    return len(cls._active_pools)

register(pool) classmethod

Register a new pool and evict oldest if over limit.

Parameters:

Name Type Description Default
pool 'ForecastPool'

The ForecastPool to register.

required
Source code in embrs/tools/forecast_pool.py
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
@classmethod
def register(cls, pool: 'ForecastPool') -> None:
    """Register a new pool and evict oldest if over limit.

    Args:
        pool: The ForecastPool to register.
    """
    if not cls._enabled:
        return

    # Evict oldest pool if we're at capacity
    while len(cls._active_pools) >= cls.MAX_ACTIVE_POOLS:
        oldest = cls._active_pools.pop(0)
        oldest._cleanup()

    cls._active_pools.append(pool)

set_max_pools(n) classmethod

Set the maximum number of active pools.

Parameters:

Name Type Description Default
n int

Maximum number of active pools (must be >= 1).

required

Raises:

Type Description
ValueError

If n < 1.

Source code in embrs/tools/forecast_pool.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
@classmethod
def set_max_pools(cls, n: int) -> None:
    """Set the maximum number of active pools.

    Args:
        n: Maximum number of active pools (must be >= 1).

    Raises:
        ValueError: If n < 1.
    """
    if n < 1:
        raise ValueError("MAX_ACTIVE_POOLS must be at least 1")
    cls.MAX_ACTIVE_POOLS = n

    # Evict excess pools if needed
    while len(cls._active_pools) > cls.MAX_ACTIVE_POOLS:
        oldest = cls._active_pools.pop(0)
        oldest._cleanup()

unregister(pool) classmethod

Unregister a pool without cleanup.

Parameters:

Name Type Description Default
pool 'ForecastPool'

The ForecastPool to unregister.

required
Source code in embrs/tools/forecast_pool.py
 97
 98
 99
100
101
102
103
104
105
@classmethod
def unregister(cls, pool: 'ForecastPool') -> None:
    """Unregister a pool without cleanup.

    Args:
        pool: The ForecastPool to unregister.
    """
    if pool in cls._active_pools:
        cls._active_pools.remove(pool)

Predictor Serializer

Serialization utilities for FirePredictor multiprocessing.

This module provides the PredictorSerializer class which handles all serialization and deserialization logic for FirePredictor instances, enabling efficient parallel execution of ensemble predictions.

The serializer extracts the minimal state needed to reconstruct a predictor in worker processes without transferring the full FireSim reference or rebuilding the cell grid from scratch.

Classes:

Name Description
- PredictorSerializer

Handles FirePredictor serialization/deserialization.

Example

from embrs.tools.predictor_serializer import PredictorSerializer

Prepare for parallel execution

PredictorSerializer.prepare_for_serialization(predictor, vary_wind=False)

Get minimal state for pickling

state = PredictorSerializer.get_state(predictor)

Restore state in worker process

PredictorSerializer.set_state(predictor, state)

PredictorSerializer

Handles serialization and deserialization for FirePredictor multiprocessing.

This class owns the entire serialization process for FirePredictor, including: - Capturing fire simulation state for worker processes - Creating minimal pickle-compatible state dictionaries - Reconstructing predictor state without full FireSim initialization

The serializer is designed to work with Python's pickle module and multiprocessing, ensuring efficient transfer of predictor state to worker processes for parallel ensemble predictions.

Class Methods

prepare_for_serialization: Capture state from parent FireSim. get_state: Return minimal state dict for pickling (getstate). set_state: Restore predictor from state dict (setstate).

Example

In main process before spawning workers

PredictorSerializer.prepare_for_serialization( ... predictor, ... vary_wind=False, ... forecast_pool=pool ... )

Predictor can now be pickled and sent to workers

import pickle pickled = pickle.dumps(predictor)

Source code in embrs/tools/predictor_serializer.py
 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
class PredictorSerializer:
    """Handles serialization and deserialization for FirePredictor multiprocessing.

    This class owns the entire serialization process for FirePredictor,
    including:
    - Capturing fire simulation state for worker processes
    - Creating minimal pickle-compatible state dictionaries
    - Reconstructing predictor state without full FireSim initialization

    The serializer is designed to work with Python's pickle module and
    multiprocessing, ensuring efficient transfer of predictor state to
    worker processes for parallel ensemble predictions.

    Class Methods:
        prepare_for_serialization: Capture state from parent FireSim.
        get_state: Return minimal state dict for pickling (__getstate__).
        set_state: Restore predictor from state dict (__setstate__).

    Example:
        >>> # In main process before spawning workers
        >>> PredictorSerializer.prepare_for_serialization(
        ...     predictor,
        ...     vary_wind=False,
        ...     forecast_pool=pool
        ... )
        >>>
        >>> # Predictor can now be pickled and sent to workers
        >>> import pickle
        >>> pickled = pickle.dumps(predictor)
    """

    @staticmethod
    def prepare_for_serialization(
        predictor: 'FirePredictor',
        vary_wind: bool = False,
        forecast_pool: Optional['ForecastPool'] = None,
        forecast_indices: Optional[List[int]] = None
    ) -> None:
        """Prepare predictor for parallel execution by extracting serializable data.

        Must be called once before pickling the predictor. Captures the current
        state of the parent FireSim and stores it in a serializable format.

        Args:
            predictor: FirePredictor instance to prepare.
            vary_wind: If True, workers generate their own wind forecasts.
                If False, use pre-computed shared wind forecast.
            forecast_pool: Optional pre-computed forecast pool.
            forecast_indices: Indices mapping each ensemble member to a
                forecast in the pool.

        Raises:
            RuntimeError: If called without a fire reference (fire is None).

        Side Effects:
            Populates predictor._serialization_data dict with fire state,
            parameters, weather stream, and optionally pre-computed wind forecast.
        """
        from embrs.utilities.fire_util import UtilFuncs

        if predictor.fire is None:
            raise RuntimeError("Cannot prepare predictor without fire reference")

        fire = predictor.fire

        # Extract fire state at prediction start
        fire_state = {
            'curr_time_s': fire._curr_time_s,
            'curr_weather_idx': fire._curr_weather_idx,
            'last_weather_update': fire._last_weather_update,
            'burning_cell_polygons': UtilFuncs.get_cell_polygons(fire._burning_cells),
            'burnt_cell_polygons': (UtilFuncs.get_cell_polygons(fire._burnt_cells)
                                   if fire._burnt_cells else None),
        }

        # Deep copy simulation parameters
        sim_params_copy = copy.deepcopy(fire._sim_params)
        weather_stream_copy = copy.deepcopy(fire._weather_stream)

        # Store all serializable data
        serialization_data = {
            'sim_params': sim_params_copy,
            'predictor_params': copy.deepcopy(predictor._params),
            'fire_state': fire_state,
            'weather_stream': weather_stream_copy,
            'vary_wind_per_member': vary_wind,

            # Predictor-specific attributes
            'time_horizon_hr': predictor.time_horizon_hr,
            'wind_uncertainty_factor': predictor.wind_uncertainty_factor,
            'wind_speed_bias': predictor.wind_speed_bias,
            'wind_dir_bias': predictor.wind_dir_bias,
            'ros_bias_factor': predictor.ros_bias_factor,
            'beta': predictor.beta,
            'wnd_spd_std': predictor.wnd_spd_std,
            'wnd_dir_std': predictor.wnd_dir_std,
            'dead_mf': predictor.dead_mf,
            'live_mf': predictor.live_mf,
            'nom_ign_prob': predictor.nom_ign_prob,

            # Elevation data (always needed)
            'coarse_elevation': predictor.coarse_elevation,
        }

        # Add forecast pool information
        if forecast_pool is not None:
            serialization_data['use_pooled_forecast'] = True
            serialization_data['forecast_pool'] = forecast_pool
            serialization_data['forecast_indices'] = forecast_indices
        else:
            serialization_data['use_pooled_forecast'] = False
            serialization_data['forecast_pool'] = None
            serialization_data['forecast_indices'] = None

        # Only include pre-computed wind forecast if not varying per member
        # and not using a forecast pool
        if not vary_wind and forecast_pool is None:
            serialization_data['wind_forecast'] = predictor.wind_forecast
            serialization_data['flipud_forecast'] = predictor.flipud_forecast
            serialization_data['wind_xpad'] = predictor.wind_xpad
            serialization_data['wind_ypad'] = predictor.wind_ypad

        predictor._serialization_data = serialization_data

    @staticmethod
    def get_state(predictor: 'FirePredictor') -> Dict[str, Any]:
        """Serialize predictor for parallel execution (__getstate__).

        Return only the essential data needed to reconstruct the predictor
        in a worker process. Excludes non-serializable components like the
        parent FireSim reference, visualizer, and logger.

        Args:
            predictor: FirePredictor instance to serialize.

        Returns:
            Minimal state dictionary containing serialization_data,
            orig_grid, orig_dict, and c_size.

        Raises:
            RuntimeError: If prepare_for_serialization() was not called first.
        """
        if predictor._serialization_data is None:
            raise RuntimeError(
                "Must call prepare_for_serialization() before pickling. "
                "This ensures all necessary state is captured from the parent FireSim."
            )

        # Return minimal state
        state = {
            'serialization_data': predictor._serialization_data,
            'orig_grid': predictor.orig_grid,  # Template cells (pre-built)
            'orig_dict': predictor.orig_dict,  # Template cells (pre-built)
            'c_size': predictor.c_size,
        }

        return state

    @staticmethod
    def set_state(predictor: 'FirePredictor', state: Dict[str, Any]) -> None:
        """Reconstruct predictor in worker process without full initialization.

        Manually restores all attributes that BaseFireSim.__init__() would set,
        but without the expensive cell creation loop. Uses pre-built cell
        templates (orig_grid, orig_dict) instead of reconstructing cells from
        map data.

        Args:
            predictor: FirePredictor instance to restore (typically empty/new).
            state: State dictionary from get_state() containing serialization_data,
                orig_grid, orig_dict, and c_size.

        Side Effects:
            Restores all instance attributes needed for prediction, including
            maps, fuel models, weather stream, and optionally wind forecast.
            Sets fire to None (no parent reference in workers).
        """
        from embrs.models.perryman_spot import PerrymanSpotting
        from embrs.models.fuel_models import Anderson13, ScottBurgan40
        from embrs.base_classes.grid_manager import GridManager
        from embrs.base_classes.weather_manager import WeatherManager

        # Extract serialization data
        data = state['serialization_data']
        sim_params = data['sim_params']

        # =====================================================================
        # Phase 1: Restore FirePredictor-specific attributes
        # =====================================================================
        predictor.fire = None  # No parent fire in worker
        predictor.c_size = state['c_size']
        predictor._params = data['predictor_params']
        predictor._serialization_data = data

        predictor.time_horizon_hr = data['time_horizon_hr']
        predictor.wind_uncertainty_factor = data['wind_uncertainty_factor']
        predictor.wind_speed_bias = data['wind_speed_bias']
        predictor.wind_dir_bias = data['wind_dir_bias']
        predictor.ros_bias_factor = data['ros_bias_factor']
        predictor.beta = data['beta']
        predictor.wnd_spd_std = data['wnd_spd_std']
        predictor.wnd_dir_std = data['wnd_dir_std']
        predictor.dead_mf = data['dead_mf']
        predictor.live_mf = data['live_mf']
        predictor.nom_ign_prob = data['nom_ign_prob']
        predictor._start_weather_idx = None  # Will be set by _catch_up_with_fire if needed

        # =====================================================================
        # Phase 2: Restore BaseFireSim attributes (manually, without __init__)
        # =====================================================================

        # From BaseFireSim.__init__ lines 45-88
        predictor.display_frequency = 300
        predictor._sim_params = sim_params
        predictor.sim_start_w_idx = 0
        predictor._curr_weather_idx = 0
        predictor._last_weather_update = data['fire_state']['last_weather_update']
        predictor.weather_changed = True
        predictor._curr_time_s = data['fire_state']['curr_time_s']
        predictor._iters = 0
        predictor.logger = None  # No logger in worker
        predictor._visualizer = None  # No visualizer in worker
        predictor._finished = False

        # Pre-allocated buffer for propagate_fire JIT kernel
        predictor._new_ixn_buf = np.empty(12, dtype=np.int64)

        # Empty containers (will be populated by _set_states)
        predictor._updated_cells = {}
        predictor._cell_dict = {}
        predictor._long_term_retardants = set()
        predictor._active_water_drops = []
        predictor._burning_cells = []
        predictor._new_ignitions = []
        predictor._burnt_cells = set()
        predictor._frontier = set()
        predictor._fire_break_cells = []
        predictor._active_firelines = {}
        predictor._new_fire_break_cache = []
        predictor.starting_ignitions = set()
        predictor._urban_cells = []
        predictor._scheduled_spot_fires = {}

        # From _parse_sim_params (lines 252-353)
        map_params = sim_params.map_params
        predictor._cell_size = predictor._params.cell_size_m
        predictor._sim_duration = sim_params.duration_s
        predictor._time_step = predictor._params.time_step_s
        predictor._init_mf = predictor._params.dead_mf
        predictor._fuel_moisture_map = getattr(sim_params, 'fuel_moisture_map', {})
        predictor._fms_has_live = getattr(sim_params, 'fms_has_live', False)
        predictor._init_live_h_mf = getattr(sim_params, 'live_h_mf', 0.0)
        predictor._init_live_w_mf = getattr(sim_params, 'live_w_mf', 0.0)
        predictor._size = map_params.size()
        predictor._shape = map_params.shape(predictor._cell_size)
        predictor._roads = map_params.roads
        predictor.coarse_elevation = data['coarse_elevation']

        # Fuel class selection
        fbfm_type = map_params.fbfm_type
        if fbfm_type == "Anderson":
            predictor.FuelClass = Anderson13
        elif fbfm_type == "ScottBurgan":
            predictor.FuelClass = ScottBurgan40
        else:
            raise ValueError(f"FBFM Type {fbfm_type} not supported")

        # Map data (from lcp_data, but already in sim_params)
        lcp_data = map_params.lcp_data
        predictor._elevation_map = np.flipud(lcp_data.elevation_map)
        predictor._slope_map = np.flipud(lcp_data.slope_map)
        predictor._aspect_map = np.flipud(lcp_data.aspect_map)
        predictor._fuel_map = np.flipud(lcp_data.fuel_map)
        predictor._cc_map = np.flipud(lcp_data.canopy_cover_map)
        predictor._ch_map = np.flipud(lcp_data.canopy_height_map)
        predictor._cbh_map = np.flipud(lcp_data.canopy_base_height_map)
        predictor._cbd_map = np.flipud(lcp_data.canopy_bulk_density_map)
        predictor._data_res = lcp_data.resolution

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

        # Datetime and orientation
        predictor._start_datetime = sim_params.weather_input.start_datetime
        predictor._north_dir_deg = map_params.geo_info.north_angle_deg

        # Initialize grid manager for geometry operations
        predictor._grid_manager = GridManager(
            num_rows=predictor._shape[0],
            num_cols=predictor._shape[1],
            cell_size=predictor._cell_size
        )

        # Check for pooled forecast mode
        predictor._use_pooled_forecast = data.get('use_pooled_forecast', False)
        predictor._wind_forecast_assigned = False

        predictor._wind_res = sim_params.weather_input.mesh_resolution

        # Initialize weather manager for wind padding calculations
        predictor._weather_manager = WeatherManager(
            weather_stream=None,  # Will be set later
            wind_forecast=None,   # Will be set later
            wind_res=predictor._wind_res,
            sim_size=predictor._size
        )

        if predictor._use_pooled_forecast:
            PredictorSerializer._restore_pooled_forecast(predictor, data)
        else:
            PredictorSerializer._restore_standard_forecast(predictor, data)

        # Spotting parameters
        predictor.model_spotting = predictor._params.model_spotting
        predictor._spot_ign_prob = 0.0
        if predictor.model_spotting:
            predictor._canopy_species = sim_params.canopy_species
            predictor._dbh_cm = sim_params.dbh_cm
            predictor._spot_ign_prob = sim_params.spot_ign_prob
            predictor._min_spot_distance = sim_params.min_spot_dist
            predictor._spot_delay_s = predictor._params.spot_delay_s

        # Moisture (prediction model specific)
        predictor.fmc = 100  # Prediction model default

        # =====================================================================
        # Phase 3: Restore cell templates (CRITICAL - uses pre-built cells)
        # =====================================================================

        # Use the serialized templates instead of reconstructing
        predictor.orig_grid = state['orig_grid']
        predictor.orig_dict = state['orig_dict']

        # Initialize cell_grid to the template shape
        predictor._cell_grid = np.empty(predictor._shape, dtype=object)
        predictor._grid_width = predictor._cell_grid.shape[1] - 1
        predictor._grid_height = predictor._cell_grid.shape[0] - 1

        # Fix weak references in cells (point to self instead of original fire)
        for cell in predictor.orig_dict.values():
            cell.set_parent(predictor)

        # Sync grid manager's internal state with restored cells
        predictor._grid_manager._cell_grid = predictor.orig_grid
        predictor._grid_manager._cell_dict = predictor.orig_dict

        # Flag to skip redundant reset_to_fuel on first run — cells are already
        # in initial FUEL state from the template, and set_parent was just called.
        predictor._skip_first_reset = True

        # =====================================================================
        # Phase 4: Rebuild lightweight components
        # =====================================================================

        size = map_params.size()

        # Rebuild spotting model (PerrymanSpotting for prediction)
        if predictor.model_spotting:
            predictor.embers = PerrymanSpotting(predictor._spot_delay_s, size)

    @staticmethod
    def _restore_pooled_forecast(predictor: 'FirePredictor', data: Dict[str, Any]) -> None:
        """Restore wind forecast from a pre-computed forecast pool.

        Args:
            predictor: FirePredictor instance to update.
            data: Serialization data dictionary.
        """
        forecast_pool = data['forecast_pool']
        forecast_indices = data['forecast_indices']
        member_idx = data.get('member_index', 0)

        # Get the assigned forecast for this member
        forecast_idx = forecast_indices[member_idx]
        assigned_forecast = forecast_pool.get_forecast(forecast_idx)

        # Set wind forecast from pool
        predictor.wind_forecast = assigned_forecast.wind_forecast
        predictor.flipud_forecast = assigned_forecast.wind_forecast  # Already flipped in pool generation
        predictor._weather_stream = assigned_forecast.weather_stream
        predictor.weather_t_step = predictor._weather_stream.time_step * 60

        # Update weather manager with actual wind forecast
        predictor._weather_manager._wind_forecast = predictor.wind_forecast
        predictor._weather_manager._weather_stream = predictor._weather_stream

        # Calculate padding using weather manager
        predictor.wind_xpad, predictor.wind_ypad = predictor._weather_manager.calc_wind_padding(predictor.wind_forecast)
        predictor._weather_manager._wind_xpad = predictor.wind_xpad
        predictor._weather_manager._wind_ypad = predictor.wind_ypad

        # Mark that wind is already set
        predictor._wind_forecast_assigned = True
        predictor._vary_wind_per_member = False

    @staticmethod
    def _restore_standard_forecast(predictor: 'FirePredictor', data: Dict[str, Any]) -> None:
        """Restore wind forecast from serialization data or mark for generation.

        Args:
            predictor: FirePredictor instance to update.
            data: Serialization data dictionary.
        """
        # Check if wind should be computed per-member or restored from serialization
        predictor._vary_wind_per_member = data.get('vary_wind_per_member', False)

        if not predictor._vary_wind_per_member:
            # Use pre-computed wind (shared across all members)
            predictor.wind_forecast = data['wind_forecast']
            predictor.flipud_forecast = data['flipud_forecast']
            predictor.wind_xpad = data['wind_xpad']
            predictor.wind_ypad = data['wind_ypad']

            # Update weather manager with pre-computed wind
            predictor._weather_manager._wind_forecast = predictor.wind_forecast
            predictor._weather_manager._wind_xpad = predictor.wind_xpad
            predictor._weather_manager._wind_ypad = predictor.wind_ypad
        else:
            # Wind will be computed later by _predict_wind() in _catch_up_with_fire
            predictor.wind_forecast = None
            predictor.flipud_forecast = None
            predictor.wind_xpad = None
            predictor.wind_ypad = None

        # Weather stream from serialized data
        predictor._weather_stream = data['weather_stream']
        predictor.weather_t_step = predictor._weather_stream.time_step * 60
        predictor._weather_manager._weather_stream = predictor._weather_stream

get_state(predictor) staticmethod

Serialize predictor for parallel execution (getstate).

Return only the essential data needed to reconstruct the predictor in a worker process. Excludes non-serializable components like the parent FireSim reference, visualizer, and logger.

Parameters:

Name Type Description Default
predictor 'FirePredictor'

FirePredictor instance to serialize.

required

Returns:

Type Description
Dict[str, Any]

Minimal state dictionary containing serialization_data,

Dict[str, Any]

orig_grid, orig_dict, and c_size.

Raises:

Type Description
RuntimeError

If prepare_for_serialization() was not called first.

Source code in embrs/tools/predictor_serializer.py
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
@staticmethod
def get_state(predictor: 'FirePredictor') -> Dict[str, Any]:
    """Serialize predictor for parallel execution (__getstate__).

    Return only the essential data needed to reconstruct the predictor
    in a worker process. Excludes non-serializable components like the
    parent FireSim reference, visualizer, and logger.

    Args:
        predictor: FirePredictor instance to serialize.

    Returns:
        Minimal state dictionary containing serialization_data,
        orig_grid, orig_dict, and c_size.

    Raises:
        RuntimeError: If prepare_for_serialization() was not called first.
    """
    if predictor._serialization_data is None:
        raise RuntimeError(
            "Must call prepare_for_serialization() before pickling. "
            "This ensures all necessary state is captured from the parent FireSim."
        )

    # Return minimal state
    state = {
        'serialization_data': predictor._serialization_data,
        'orig_grid': predictor.orig_grid,  # Template cells (pre-built)
        'orig_dict': predictor.orig_dict,  # Template cells (pre-built)
        'c_size': predictor.c_size,
    }

    return state

prepare_for_serialization(predictor, vary_wind=False, forecast_pool=None, forecast_indices=None) staticmethod

Prepare predictor for parallel execution by extracting serializable data.

Must be called once before pickling the predictor. Captures the current state of the parent FireSim and stores it in a serializable format.

Parameters:

Name Type Description Default
predictor 'FirePredictor'

FirePredictor instance to prepare.

required
vary_wind bool

If True, workers generate their own wind forecasts. If False, use pre-computed shared wind forecast.

False
forecast_pool Optional['ForecastPool']

Optional pre-computed forecast pool.

None
forecast_indices Optional[List[int]]

Indices mapping each ensemble member to a forecast in the pool.

None

Raises:

Type Description
RuntimeError

If called without a fire reference (fire is None).

Side Effects

Populates predictor._serialization_data dict with fire state, parameters, weather stream, and optionally pre-computed wind forecast.

Source code in embrs/tools/predictor_serializer.py
 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
@staticmethod
def prepare_for_serialization(
    predictor: 'FirePredictor',
    vary_wind: bool = False,
    forecast_pool: Optional['ForecastPool'] = None,
    forecast_indices: Optional[List[int]] = None
) -> None:
    """Prepare predictor for parallel execution by extracting serializable data.

    Must be called once before pickling the predictor. Captures the current
    state of the parent FireSim and stores it in a serializable format.

    Args:
        predictor: FirePredictor instance to prepare.
        vary_wind: If True, workers generate their own wind forecasts.
            If False, use pre-computed shared wind forecast.
        forecast_pool: Optional pre-computed forecast pool.
        forecast_indices: Indices mapping each ensemble member to a
            forecast in the pool.

    Raises:
        RuntimeError: If called without a fire reference (fire is None).

    Side Effects:
        Populates predictor._serialization_data dict with fire state,
        parameters, weather stream, and optionally pre-computed wind forecast.
    """
    from embrs.utilities.fire_util import UtilFuncs

    if predictor.fire is None:
        raise RuntimeError("Cannot prepare predictor without fire reference")

    fire = predictor.fire

    # Extract fire state at prediction start
    fire_state = {
        'curr_time_s': fire._curr_time_s,
        'curr_weather_idx': fire._curr_weather_idx,
        'last_weather_update': fire._last_weather_update,
        'burning_cell_polygons': UtilFuncs.get_cell_polygons(fire._burning_cells),
        'burnt_cell_polygons': (UtilFuncs.get_cell_polygons(fire._burnt_cells)
                               if fire._burnt_cells else None),
    }

    # Deep copy simulation parameters
    sim_params_copy = copy.deepcopy(fire._sim_params)
    weather_stream_copy = copy.deepcopy(fire._weather_stream)

    # Store all serializable data
    serialization_data = {
        'sim_params': sim_params_copy,
        'predictor_params': copy.deepcopy(predictor._params),
        'fire_state': fire_state,
        'weather_stream': weather_stream_copy,
        'vary_wind_per_member': vary_wind,

        # Predictor-specific attributes
        'time_horizon_hr': predictor.time_horizon_hr,
        'wind_uncertainty_factor': predictor.wind_uncertainty_factor,
        'wind_speed_bias': predictor.wind_speed_bias,
        'wind_dir_bias': predictor.wind_dir_bias,
        'ros_bias_factor': predictor.ros_bias_factor,
        'beta': predictor.beta,
        'wnd_spd_std': predictor.wnd_spd_std,
        'wnd_dir_std': predictor.wnd_dir_std,
        'dead_mf': predictor.dead_mf,
        'live_mf': predictor.live_mf,
        'nom_ign_prob': predictor.nom_ign_prob,

        # Elevation data (always needed)
        'coarse_elevation': predictor.coarse_elevation,
    }

    # Add forecast pool information
    if forecast_pool is not None:
        serialization_data['use_pooled_forecast'] = True
        serialization_data['forecast_pool'] = forecast_pool
        serialization_data['forecast_indices'] = forecast_indices
    else:
        serialization_data['use_pooled_forecast'] = False
        serialization_data['forecast_pool'] = None
        serialization_data['forecast_indices'] = None

    # Only include pre-computed wind forecast if not varying per member
    # and not using a forecast pool
    if not vary_wind and forecast_pool is None:
        serialization_data['wind_forecast'] = predictor.wind_forecast
        serialization_data['flipud_forecast'] = predictor.flipud_forecast
        serialization_data['wind_xpad'] = predictor.wind_xpad
        serialization_data['wind_ypad'] = predictor.wind_ypad

    predictor._serialization_data = serialization_data

set_state(predictor, state) staticmethod

Reconstruct predictor in worker process without full initialization.

Manually restores all attributes that BaseFireSim.init() would set, but without the expensive cell creation loop. Uses pre-built cell templates (orig_grid, orig_dict) instead of reconstructing cells from map data.

Parameters:

Name Type Description Default
predictor 'FirePredictor'

FirePredictor instance to restore (typically empty/new).

required
state Dict[str, Any]

State dictionary from get_state() containing serialization_data, orig_grid, orig_dict, and c_size.

required
Side Effects

Restores all instance attributes needed for prediction, including maps, fuel models, weather stream, and optionally wind forecast. Sets fire to None (no parent reference in workers).

Source code in embrs/tools/predictor_serializer.py
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
@staticmethod
def set_state(predictor: 'FirePredictor', state: Dict[str, Any]) -> None:
    """Reconstruct predictor in worker process without full initialization.

    Manually restores all attributes that BaseFireSim.__init__() would set,
    but without the expensive cell creation loop. Uses pre-built cell
    templates (orig_grid, orig_dict) instead of reconstructing cells from
    map data.

    Args:
        predictor: FirePredictor instance to restore (typically empty/new).
        state: State dictionary from get_state() containing serialization_data,
            orig_grid, orig_dict, and c_size.

    Side Effects:
        Restores all instance attributes needed for prediction, including
        maps, fuel models, weather stream, and optionally wind forecast.
        Sets fire to None (no parent reference in workers).
    """
    from embrs.models.perryman_spot import PerrymanSpotting
    from embrs.models.fuel_models import Anderson13, ScottBurgan40
    from embrs.base_classes.grid_manager import GridManager
    from embrs.base_classes.weather_manager import WeatherManager

    # Extract serialization data
    data = state['serialization_data']
    sim_params = data['sim_params']

    # =====================================================================
    # Phase 1: Restore FirePredictor-specific attributes
    # =====================================================================
    predictor.fire = None  # No parent fire in worker
    predictor.c_size = state['c_size']
    predictor._params = data['predictor_params']
    predictor._serialization_data = data

    predictor.time_horizon_hr = data['time_horizon_hr']
    predictor.wind_uncertainty_factor = data['wind_uncertainty_factor']
    predictor.wind_speed_bias = data['wind_speed_bias']
    predictor.wind_dir_bias = data['wind_dir_bias']
    predictor.ros_bias_factor = data['ros_bias_factor']
    predictor.beta = data['beta']
    predictor.wnd_spd_std = data['wnd_spd_std']
    predictor.wnd_dir_std = data['wnd_dir_std']
    predictor.dead_mf = data['dead_mf']
    predictor.live_mf = data['live_mf']
    predictor.nom_ign_prob = data['nom_ign_prob']
    predictor._start_weather_idx = None  # Will be set by _catch_up_with_fire if needed

    # =====================================================================
    # Phase 2: Restore BaseFireSim attributes (manually, without __init__)
    # =====================================================================

    # From BaseFireSim.__init__ lines 45-88
    predictor.display_frequency = 300
    predictor._sim_params = sim_params
    predictor.sim_start_w_idx = 0
    predictor._curr_weather_idx = 0
    predictor._last_weather_update = data['fire_state']['last_weather_update']
    predictor.weather_changed = True
    predictor._curr_time_s = data['fire_state']['curr_time_s']
    predictor._iters = 0
    predictor.logger = None  # No logger in worker
    predictor._visualizer = None  # No visualizer in worker
    predictor._finished = False

    # Pre-allocated buffer for propagate_fire JIT kernel
    predictor._new_ixn_buf = np.empty(12, dtype=np.int64)

    # Empty containers (will be populated by _set_states)
    predictor._updated_cells = {}
    predictor._cell_dict = {}
    predictor._long_term_retardants = set()
    predictor._active_water_drops = []
    predictor._burning_cells = []
    predictor._new_ignitions = []
    predictor._burnt_cells = set()
    predictor._frontier = set()
    predictor._fire_break_cells = []
    predictor._active_firelines = {}
    predictor._new_fire_break_cache = []
    predictor.starting_ignitions = set()
    predictor._urban_cells = []
    predictor._scheduled_spot_fires = {}

    # From _parse_sim_params (lines 252-353)
    map_params = sim_params.map_params
    predictor._cell_size = predictor._params.cell_size_m
    predictor._sim_duration = sim_params.duration_s
    predictor._time_step = predictor._params.time_step_s
    predictor._init_mf = predictor._params.dead_mf
    predictor._fuel_moisture_map = getattr(sim_params, 'fuel_moisture_map', {})
    predictor._fms_has_live = getattr(sim_params, 'fms_has_live', False)
    predictor._init_live_h_mf = getattr(sim_params, 'live_h_mf', 0.0)
    predictor._init_live_w_mf = getattr(sim_params, 'live_w_mf', 0.0)
    predictor._size = map_params.size()
    predictor._shape = map_params.shape(predictor._cell_size)
    predictor._roads = map_params.roads
    predictor.coarse_elevation = data['coarse_elevation']

    # Fuel class selection
    fbfm_type = map_params.fbfm_type
    if fbfm_type == "Anderson":
        predictor.FuelClass = Anderson13
    elif fbfm_type == "ScottBurgan":
        predictor.FuelClass = ScottBurgan40
    else:
        raise ValueError(f"FBFM Type {fbfm_type} not supported")

    # Map data (from lcp_data, but already in sim_params)
    lcp_data = map_params.lcp_data
    predictor._elevation_map = np.flipud(lcp_data.elevation_map)
    predictor._slope_map = np.flipud(lcp_data.slope_map)
    predictor._aspect_map = np.flipud(lcp_data.aspect_map)
    predictor._fuel_map = np.flipud(lcp_data.fuel_map)
    predictor._cc_map = np.flipud(lcp_data.canopy_cover_map)
    predictor._ch_map = np.flipud(lcp_data.canopy_height_map)
    predictor._cbh_map = np.flipud(lcp_data.canopy_base_height_map)
    predictor._cbd_map = np.flipud(lcp_data.canopy_bulk_density_map)
    predictor._data_res = lcp_data.resolution

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

    # Datetime and orientation
    predictor._start_datetime = sim_params.weather_input.start_datetime
    predictor._north_dir_deg = map_params.geo_info.north_angle_deg

    # Initialize grid manager for geometry operations
    predictor._grid_manager = GridManager(
        num_rows=predictor._shape[0],
        num_cols=predictor._shape[1],
        cell_size=predictor._cell_size
    )

    # Check for pooled forecast mode
    predictor._use_pooled_forecast = data.get('use_pooled_forecast', False)
    predictor._wind_forecast_assigned = False

    predictor._wind_res = sim_params.weather_input.mesh_resolution

    # Initialize weather manager for wind padding calculations
    predictor._weather_manager = WeatherManager(
        weather_stream=None,  # Will be set later
        wind_forecast=None,   # Will be set later
        wind_res=predictor._wind_res,
        sim_size=predictor._size
    )

    if predictor._use_pooled_forecast:
        PredictorSerializer._restore_pooled_forecast(predictor, data)
    else:
        PredictorSerializer._restore_standard_forecast(predictor, data)

    # Spotting parameters
    predictor.model_spotting = predictor._params.model_spotting
    predictor._spot_ign_prob = 0.0
    if predictor.model_spotting:
        predictor._canopy_species = sim_params.canopy_species
        predictor._dbh_cm = sim_params.dbh_cm
        predictor._spot_ign_prob = sim_params.spot_ign_prob
        predictor._min_spot_distance = sim_params.min_spot_dist
        predictor._spot_delay_s = predictor._params.spot_delay_s

    # Moisture (prediction model specific)
    predictor.fmc = 100  # Prediction model default

    # =====================================================================
    # Phase 3: Restore cell templates (CRITICAL - uses pre-built cells)
    # =====================================================================

    # Use the serialized templates instead of reconstructing
    predictor.orig_grid = state['orig_grid']
    predictor.orig_dict = state['orig_dict']

    # Initialize cell_grid to the template shape
    predictor._cell_grid = np.empty(predictor._shape, dtype=object)
    predictor._grid_width = predictor._cell_grid.shape[1] - 1
    predictor._grid_height = predictor._cell_grid.shape[0] - 1

    # Fix weak references in cells (point to self instead of original fire)
    for cell in predictor.orig_dict.values():
        cell.set_parent(predictor)

    # Sync grid manager's internal state with restored cells
    predictor._grid_manager._cell_grid = predictor.orig_grid
    predictor._grid_manager._cell_dict = predictor.orig_dict

    # Flag to skip redundant reset_to_fuel on first run — cells are already
    # in initial FUEL state from the template, and set_parent was just called.
    predictor._skip_first_reset = True

    # =====================================================================
    # Phase 4: Rebuild lightweight components
    # =====================================================================

    size = map_params.size()

    # Rebuild spotting model (PerrymanSpotting for prediction)
    if predictor.model_spotting:
        predictor.embers = PerrymanSpotting(predictor._spot_delay_s, size)

Ensemble Video

Ensemble Prediction Video Generator

Creates professional video visualizations of ensemble fire prediction output, showing burn probability evolution over time with hexagonal cell polygons.

create_ensemble_video(ensemble_output, cell_size, output_path='ensemble_prediction.mp4', map_size=None, fps=10, dpi=150, title='Ensemble Fire Spread Prediction', figsize=(12, 10), colormap='YlOrRd', show_progress=True)

Create a video visualization of ensemble prediction burn probability over time.

For each time step, plots all cells that have been predicted to burn by any ensemble member, colored by their burn probability. Suitable for presentations.

Parameters:

Name Type Description Default
ensemble_output EnsemblePredictionOutput

EnsemblePredictionOutput from FirePredictor.run_ensemble()

required
cell_size float

Size of hexagonal cells in meters (side length)

required
output_path str

Path to save the video file (default: "ensemble_prediction.mp4")

'ensemble_prediction.mp4'
map_size Optional[Tuple[float, float]]

Tuple of (width_m, height_m) for the map. If None, computed from data.

None
fps int

Frames per second for the video (default: 10)

10
dpi int

Resolution of the video (default: 150)

150
title str

Title displayed on the video (default: "Ensemble Fire Spread Prediction")

'Ensemble Fire Spread Prediction'
figsize Tuple[float, float]

Figure size in inches (default: (12, 10))

(12, 10)
colormap str

Matplotlib colormap name (default: "YlOrRd" - yellow to orange to red)

'YlOrRd'
show_progress bool

Print progress updates during video creation (default: True)

True

Returns:

Type Description
str

Path to the saved video file

Example

from embrs.tools.ensemble_video import create_ensemble_video result = predictor.run_ensemble(state_estimates, ...) video_path = create_ensemble_video( ... result, ... cell_size=45.0, ... output_path="my_prediction.mp4" ... )

Source code in embrs/utilities/ensemble_video.py
 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
def create_ensemble_video(
    ensemble_output: EnsemblePredictionOutput,
    cell_size: float,
    output_path: str = "ensemble_prediction.mp4",
    map_size: Optional[Tuple[float, float]] = None,
    fps: int = 10,
    dpi: int = 150,
    title: str = "Ensemble Fire Spread Prediction",
    figsize: Tuple[float, float] = (12, 10),
    colormap: str = "YlOrRd",
    show_progress: bool = True
) -> str:
    """
    Create a video visualization of ensemble prediction burn probability over time.

    For each time step, plots all cells that have been predicted to burn by any
    ensemble member, colored by their burn probability. Suitable for presentations.

    Args:
        ensemble_output: EnsemblePredictionOutput from FirePredictor.run_ensemble()
        cell_size: Size of hexagonal cells in meters (side length)
        output_path: Path to save the video file (default: "ensemble_prediction.mp4")
        map_size: Tuple of (width_m, height_m) for the map. If None, computed from data.
        fps: Frames per second for the video (default: 10)
        dpi: Resolution of the video (default: 150)
        title: Title displayed on the video (default: "Ensemble Fire Spread Prediction")
        figsize: Figure size in inches (default: (12, 10))
        colormap: Matplotlib colormap name (default: "YlOrRd" - yellow to orange to red)
        show_progress: Print progress updates during video creation (default: True)

    Returns:
        Path to the saved video file

    Example:
        >>> from embrs.tools.ensemble_video import create_ensemble_video
        >>> result = predictor.run_ensemble(state_estimates, ...)
        >>> video_path = create_ensemble_video(
        ...     result,
        ...     cell_size=45.0,
        ...     output_path="my_prediction.mp4"
        ... )
    """
    # Use non-interactive backend for video creation
    mpl.use('Agg')

    burn_probability = ensemble_output.burn_probability
    n_ensemble = ensemble_output.n_ensemble

    # Get sorted time steps
    time_steps = sorted(burn_probability.keys())
    if not time_steps:
        raise ValueError("No time steps in ensemble output")

    if show_progress:
        print(f"Creating ensemble video with {len(time_steps)} time steps...")

    # Compute map bounds from all cell positions across all time steps
    all_positions = set()
    for time_s in time_steps:
        all_positions.update(burn_probability[time_s].keys())

    if not all_positions:
        raise ValueError("No cells with burn probability data")

    xs, ys = zip(*all_positions)

    # Add padding for hexagon extent
    hex_padding = cell_size * 1.5

    if map_size is not None:
        x_min, x_max = 0, map_size[0]
        y_min, y_max = 0, map_size[1]
    else:
        x_min = min(xs) - hex_padding
        x_max = max(xs) + hex_padding
        y_min = min(ys) - hex_padding
        y_max = max(ys) + hex_padding

    # Pre-compute all hexagon patches
    if show_progress:
        print("Pre-computing hexagon geometries...")

    hex_cache = {}
    for pos in all_positions:
        x, y = pos
        hex_poly = create_hexagon_polygon(x, y, cell_size)
        hex_coords = list(hex_poly.exterior.coords)
        hex_cache[pos] = mpatches.Polygon(hex_coords, closed=True)

    # Setup figure with professional styling
    fig, ax = plt.subplots(figsize=figsize, facecolor='white')

    # Set fixed axis limits
    ax.set_xlim(x_min, x_max)
    ax.set_ylim(y_min, y_max)
    ax.set_aspect('equal')

    # Professional axis styling
    ax.set_xlabel('X Position (m)', fontsize=12, fontweight='bold')
    ax.set_ylabel('Y Position (m)', fontsize=12, fontweight='bold')
    ax.tick_params(labelsize=10)
    ax.grid(True, alpha=0.3, linestyle='--')

    # Add light gray background
    ax.set_facecolor('#f5f5f5')

    # Setup colormap and normalization
    cmap = plt.cm.get_cmap(colormap)
    norm = mcolors.Normalize(vmin=0, vmax=1)

    # Add colorbar
    sm = ScalarMappable(cmap=cmap, norm=norm)
    sm.set_array([])
    cbar = fig.colorbar(sm, ax=ax, shrink=0.8, aspect=30, pad=0.02)
    cbar.set_label('Burn Probability', fontsize=12, fontweight='bold')
    cbar.ax.tick_params(labelsize=10)

    # Add ensemble info text
    info_text = ax.text(
        0.02, 0.98,
        f'Ensemble: {n_ensemble} members',
        transform=ax.transAxes,
        fontsize=10,
        verticalalignment='top',
        bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, edgecolor='gray')
    )

    # Title and time text (will be updated)
    title_text = ax.set_title(title, fontsize=14, fontweight='bold', pad=10)
    time_text = ax.text(
        0.98, 0.98,
        '',
        transform=ax.transAxes,
        fontsize=11,
        verticalalignment='top',
        horizontalalignment='right',
        bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, edgecolor='gray')
    )

    # Statistics text box
    stats_text = ax.text(
        0.02, 0.02,
        '',
        transform=ax.transAxes,
        fontsize=9,
        verticalalignment='bottom',
        family='monospace',
        bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, edgecolor='gray')
    )

    plt.tight_layout()

    # Create video writer
    metadata = {
        'title': title,
        'artist': 'EMBRS Fire Simulation',
        'comment': f'Ensemble prediction with {n_ensemble} members'
    }
    writer = FFMpegWriter(fps=fps, metadata=metadata, bitrate=5000)

    # Reference to current patch collection
    current_collection = None

    # Start time for relative time display
    start_time_s = time_steps[0]

    if show_progress:
        print(f"Rendering {len(time_steps)} frames...")

    with writer.saving(fig, output_path, dpi=dpi):
        for i, time_s in enumerate(time_steps):
            # Get probabilities for this time step
            probs = burn_probability[time_s]

            if not probs:
                continue

            # Remove previous collection
            if current_collection is not None:
                current_collection.remove()

            # Create patches for cells with burn probability
            patches = []
            colors = []

            for pos, prob in probs.items():
                if pos in hex_cache:
                    # Create a new patch (can't reuse patches in collections)
                    hex_coords = list(hex_cache[pos].get_xy())
                    patch = mpatches.Polygon(hex_coords, closed=True)
                    patches.append(patch)
                    colors.append(prob)

            if patches:
                # Create patch collection with colors
                collection = PatchCollection(
                    patches,
                    cmap=cmap,
                    norm=norm,
                    edgecolors='black',
                    linewidths=0.3,
                    alpha=0.9
                )
                collection.set_array(np.array(colors))
                current_collection = ax.add_collection(collection)

            # Update time display
            elapsed_s = time_s - start_time_s
            elapsed_hr = elapsed_s / 3600
            time_text.set_text(f'Time: {elapsed_hr:.2f} hr\n({elapsed_s:.0f} s)')

            # Update statistics
            if probs:
                prob_values = list(probs.values())
                high_prob = sum(1 for p in prob_values if p >= 0.8)
                med_prob = sum(1 for p in prob_values if 0.5 <= p < 0.8)
                low_prob = sum(1 for p in prob_values if p < 0.5)

                stats_str = (
                    f'Cells: {len(probs):,}\n'
                    f'High prob (≥80%): {high_prob:,}\n'
                    f'Med prob (50-80%): {med_prob:,}\n'
                    f'Low prob (<50%): {low_prob:,}'
                )
                stats_text.set_text(stats_str)

            # Grab frame
            writer.grab_frame()

            # Progress update
            if show_progress and (i + 1) % max(1, len(time_steps) // 10) == 0:
                print(f"  Progress: {i + 1}/{len(time_steps)} frames ({100*(i+1)/len(time_steps):.0f}%)")

    plt.close(fig)

    # Get absolute path for output
    abs_path = os.path.abspath(output_path)

    if show_progress:
        print(f"\nVideo saved to: {abs_path}")
        print(f"  Duration: {len(time_steps)/fps:.1f} seconds")
        print(f"  Resolution: {int(figsize[0]*dpi)} x {int(figsize[1]*dpi)}")

    return abs_path

create_ensemble_video_from_predictor(predictor, ensemble_output, output_path='ensemble_prediction.mp4', **kwargs)

Convenience function to create ensemble video using predictor's cell size and map info.

Parameters:

Name Type Description Default
predictor FirePredictor

FirePredictor instance.

required
ensemble_output EnsemblePredictionOutput

Output from run_ensemble().

required
output_path str

Path to save the video.

'ensemble_prediction.mp4'
**kwargs Any

Additional arguments passed to create_ensemble_video().

{}

Returns:

Name Type Description
str str

Path to the saved video file.

Source code in embrs/utilities/ensemble_video.py
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
def create_ensemble_video_from_predictor(
    predictor: 'FirePredictor',
    ensemble_output: EnsemblePredictionOutput,
    output_path: str = "ensemble_prediction.mp4",
    **kwargs: Any
) -> str:
    """Convenience function to create ensemble video using predictor's cell size and map info.

    Args:
        predictor (FirePredictor): FirePredictor instance.
        ensemble_output (EnsemblePredictionOutput): Output from run_ensemble().
        output_path (str): Path to save the video.
        **kwargs (Any): Additional arguments passed to create_ensemble_video().

    Returns:
        str: Path to the saved video file.
    """
    # Get cell size from predictor
    cell_size = predictor.c_size

    # Try to get map size from predictor
    map_size = None
    if hasattr(predictor, '_size'):
        map_size = predictor._size

    return create_ensemble_video(
        ensemble_output=ensemble_output,
        cell_size=cell_size,
        output_path=output_path,
        map_size=map_size,
        **kwargs
    )

create_hexagon_polygon(x, y, cell_size)

Create a hexagonal polygon in point-up orientation.

Parameters:

Name Type Description Default
x float

X coordinate of cell center (meters)

required
y float

Y coordinate of cell center (meters)

required
cell_size float

Side length of hexagon (meters)

required

Returns:

Type Description
Polygon

Shapely Polygon representing the hexagonal cell

Source code in embrs/utilities/ensemble_video.py
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
def create_hexagon_polygon(x: float, y: float, cell_size: float) -> Polygon:
    """Create a hexagonal polygon in point-up orientation.

    Args:
        x: X coordinate of cell center (meters)
        y: Y coordinate of cell center (meters)
        cell_size: Side length of hexagon (meters)

    Returns:
        Shapely Polygon representing the hexagonal cell
    """
    l = cell_size
    sqrt3_2 = np.sqrt(3) / 2

    hex_coords = [
        (x, y + l),
        (x + sqrt3_2 * l, y + l / 2),
        (x + sqrt3_2 * l, y - l / 2),
        (x, y - l),
        (x - sqrt3_2 * l, y - l / 2),
        (x - sqrt3_2 * l, y + l / 2),
        (x, y + l)
    ]

    return Polygon(hex_coords)