Skip to content

Fuel Moisture Models

These modules estimate dead fuel moisture content, which is a key driver of fire behavior. The DeadFuelMoisture class implements the Nelson model, a physics-based 1D finite-difference solver that simulates moisture diffusion through cylindrical fuel sticks based on weather conditions. The IRPGMoistureModel class implements the Incident Response Pocket Guide (IRPG) method for quick fine dead fuel moisture estimation from temperature, humidity, and site condition correction factors.

The DeadFuelMoisture model is used by the core simulation to dynamically update fuel moisture at each time step. The IRPGMoistureModel can be used to sample realistic dead fuel moisture values for the prediction model, where fuel moisture is held constant — by sampling from known weather conditions for the prediction region, the fixed dead_mf parameter in PredictorParams can be set to a value informed by the IRPG tables rather than an arbitrary default.

Dead Fuel Moisture Model.

This module implements the Nelson dead fuel moisture model, which simulates moisture diffusion through cylindrical fuel sticks using implicit finite difference methods.

The model tracks moisture content, temperature, and saturation at discrete nodes along the radius of the fuel stick, updating based on weather conditions.

Performance Note

The inner loop calculations are JIT-compiled using Numba when available. Set EMBRS_DISABLE_JIT=1 to disable JIT compilation for debugging.

DeadFuelMoisture

Nelson dead fuel moisture model for cylindrical fuel sticks.

This class implements a 1D implicit finite difference solver that simulates moisture diffusion through fuel sticks based on weather conditions.

Attributes:

Name Type Description
m_radius

Fuel stick radius (cm)

m_nodes

Number of radial nodes

m_w

Moisture content at each node (g/g)

m_t

Temperature at each node (°C)

m_s

Saturation at each node (fraction)

Source code in embrs/models/dead_fuel_moisture.py
 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
class DeadFuelMoisture:
    """Nelson dead fuel moisture model for cylindrical fuel sticks.

    This class implements a 1D implicit finite difference solver that simulates
    moisture diffusion through fuel sticks based on weather conditions.

    Attributes:
        m_radius: Fuel stick radius (cm)
        m_nodes: Number of radial nodes
        m_w: Moisture content at each node (g/g)
        m_t: Temperature at each node (°C)
        m_s: Saturation at each node (fraction)
    """

    def __init__(self, radius: float, stv: float, wmx: float, wfilmk: float):
        """Initialize the dead fuel moisture model.

        Args:
            radius (float): Fuel stick radius (cm).
            stv (float): Storm threshold (cm/h).
            wmx (float): Maximum fiber saturation (g/g).
            wfilmk (float): Water film constant.
        """
        self.m_semTime = None
        self.initializeParameters(radius, stv, wmx, wfilmk)

    def initializeParameters(self, radius, stv, wmx, wfilmk):
        """Initialize model parameters based on fuel geometry."""
        self.m_radius = radius
        self.m_density = 0.4
        self.m_length = 41.0
        self.m_dSteps = self.deriveDiffusivitySteps(radius)
        self.m_hc = self.derivePlanarHeatTransferRate(radius)
        self.m_nodes = self.deriveStickNodes(radius)
        self.m_rai0 = self.deriveRainfallRunoffFactor(radius)
        self.m_rai1 = 0.5
        self.m_stca = self.deriveAdsorptionRate(radius)
        self.m_stcd = 0.06
        self.m_mSteps = self.deriveMoistureSteps(radius)
        self.m_stv = stv
        self.m_wmx = wmx
        self.m_wfilmk = wfilmk
        self.m_allowRainfall2 = True
        self.m_allowRainstorm = True
        self.m_pertubateColumn = True
        self.m_rampRai0 = True
        self.m_rdur = 0.0
        self.initializeStick()

    def deriveDiffusivitySteps(self, radius: float) -> int:
        """Derive number of diffusivity sub-steps from stick radius."""
        return int(4.777 + 2.496 / radius ** 1.3)

    def derivePlanarHeatTransferRate(self, radius: float) -> float:
        """Derive planar heat transfer rate from stick radius."""
        return 0.2195 + 0.05260 / radius ** 2.5

    def deriveStickNodes(self, radius: float) -> int:
        """Derive number of radial nodes from stick radius (always odd)."""
        nodes = int(10.727 + 0.1746 / radius)
        if nodes % 2 == 0:
            nodes += 1
        return nodes

    def deriveRainfallRunoffFactor(self, radius: float) -> float:
        """Derive rainfall runoff factor from stick radius."""
        return 0.02822 + 0.1056 / radius ** 2.2

    def deriveAdsorptionRate(self, radius: float) -> float:
        """Derive adsorption rate constant from stick radius."""
        return 0.0004509 + 0.006126 / radius ** 2.6

    def deriveMoistureSteps(self, radius: float) -> int:
        """Derive number of moisture sub-steps from stick radius."""
        return int(9.8202 + 26.865 / radius ** 1.4)

    def initializeStick(self):
        """Initialize the fuel stick state arrays.

        Arrays are stored as numpy arrays for compatibility with JIT kernels.
        """
        # Internodal distance (cm)
        self.m_dx = self.m_radius / (self.m_nodes - 1)
        self.m_dx_2 = self.m_dx * 2

        # Maximum possible stick moisture content (g/g)
        self.m_wmax = (1.0 / self.m_density) - (1.0 / 1.53)

        # Initialize arrays as numpy arrays for JIT compatibility
        # Temperature array - initialized to 20°C (ambient)
        self.m_t = np.full(self.m_nodes, 20.0, dtype=np.float64)

        # Saturation array - initialized to 0
        self.m_s = np.zeros(self.m_nodes, dtype=np.float64)

        # Diffusivity array - initialized to 0
        self.m_d = np.zeros(self.m_nodes, dtype=np.float64)

        # Moisture array - initialized to half the maximum
        self.m_w = np.full(self.m_nodes, 0.5 * self.m_wmx, dtype=np.float64)

        # Nodal radial distances
        self.m_x = np.zeros(self.m_nodes, dtype=np.float64)
        for i in range(self.m_nodes - 1):
            self.m_x[i] = self.m_radius - (self.m_dx * i)
        self.m_x[self.m_nodes - 1] = 0.0

        # Nodal volume fractions
        self.m_v = np.zeros(self.m_nodes, dtype=np.float64)
        ro = self.m_radius
        ri = ro - 0.5 * self.m_dx
        a2 = self.m_radius * self.m_radius
        self.m_v[0] = (ro * ro - ri * ri) / a2
        vwt = self.m_v[0]
        for i in range(1, self.m_nodes - 1):
            ro = ri
            ri = ro - self.m_dx
            self.m_v[i] = (ro * ro - ri * ri) / a2
            vwt += self.m_v[i]
        self.m_v[self.m_nodes - 1] = ri * ri / a2
        vwt += self.m_v[self.m_nodes - 1]

        # Temporary arrays for time stepping (numpy arrays for JIT)
        self.m_Twold = np.zeros(self.m_nodes, dtype=np.float64)
        self.m_Ttold = np.zeros(self.m_nodes, dtype=np.float64)
        self.m_Tsold = np.zeros(self.m_nodes, dtype=np.float64)
        self.m_Tv = np.zeros(self.m_nodes, dtype=np.float64)
        self.m_To = np.zeros(self.m_nodes, dtype=np.float64)
        self.m_Tg = np.zeros(self.m_nodes, dtype=np.float64)

        # Initialize the environment
        self.initializeEnvironment(
            20.0,  # Ambient air temperature (oC)
            0.20,  # Ambient air relative humidity (g/g)
            0.0,   # Solar radiation (W/m2)
            0.0,   # Cumulative rainfall (cm)
            20.0,  # Initial stick temperature (oC)
            0.20,  # Initial stick surface humidity (g/g)
            0.5 * self.m_wmx,  # Initial stick moisture content
            0.0218)  # Initial stick barometric pressure (cal/cm3)
        self.m_init = False

        # Computation optimization parameters
        self.m_hwf = 0.622 * self.m_hc * (Pr / Sc) ** 0.667
        self.m_amlf = self.m_hwf / (0.24 * self.m_density * self.m_radius)
        rcav = 0.5 * Aw * Wl
        self.m_capf = 3600.0 * Pi * St * rcav * rcav / (16.0 * self.m_radius * self.m_radius * self.m_length * self.m_density)
        self.m_vf = St / (self.m_density * Wl * Scr)

    def diffusivity(self, bp):
        """Compute diffusivity using JIT kernel if available."""
        _compute_diffusivity(
            self.m_nodes, self.m_t, self.m_w, self.m_wsa,
            self.m_hf, self.m_density, bp, self.m_d
        )

    # Class-level templates for fast cloning (populated on first use)
    _template_1hr = None
    _template_10hr = None
    _template_100hr = None
    _template_1000hr = None

    # Mutable array attribute names that must be deep-copied when cloning
    _MUTABLE_ARRAYS = (
        'm_t', 'm_s', 'm_d', 'm_w', 'm_x', 'm_v',
        'm_Twold', 'm_Ttold', 'm_Tsold', 'm_Tv', 'm_To', 'm_Tg',
    )

    @classmethod
    def _clone_from_template(cls, template):
        """Create a new instance by cloning a template's state.

        Copies all scalar attributes via dict update (fast) and deep-copies
        only the mutable numpy arrays. Much faster than full __init__ because
        it skips parameter derivation and array initialization math.
        """
        new = object.__new__(cls)
        new.__dict__.update(template.__dict__)
        for attr in cls._MUTABLE_ARRAYS:
            setattr(new, attr, getattr(template, attr).copy())
        return new

    @staticmethod
    def createDeadFuelMoisture1():
        """Create 1-hour fuel moisture model (fine fuels)."""
        if DeadFuelMoisture._template_1hr is None:
            DeadFuelMoisture._template_1hr = DeadFuelMoisture(0.20, 0.006, 0.85, 0.10)
        return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_1hr)

    @staticmethod
    def createDeadFuelMoisture10():
        """Create 10-hour fuel moisture model."""
        if DeadFuelMoisture._template_10hr is None:
            DeadFuelMoisture._template_10hr = DeadFuelMoisture(0.64, 0.05, 0.60, 0.05)
        return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_10hr)

    @staticmethod
    def createDeadFuelMoisture100():
        """Create 100-hour fuel moisture model."""
        if DeadFuelMoisture._template_100hr is None:
            DeadFuelMoisture._template_100hr = DeadFuelMoisture(2.00, 5.0, 0.40, 0.005)
        return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_100hr)

    @staticmethod
    def createDeadFuelMoisture1000():
        """Create 1000-hour fuel moisture model (large logs)."""
        if DeadFuelMoisture._template_1000hr is None:
            DeadFuelMoisture._template_1000hr = DeadFuelMoisture(6.40, 7.5, 0.32, 0.003)
        return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_1000hr)

    def initialized(self):
        """Check if environment has been initialized."""
        return self.m_init

    def initializeEnvironment(self, ta: float, ha: float, sr: float,
                              rc: float, ti: float, hi: float,
                              wi: float, bp: float):
        """Initialize environmental conditions.

        Args:
            ta (float): Ambient air temperature (°C).
            ha (float): Ambient air relative humidity (g/g).
            sr (float): Solar radiation (W/m²).
            rc (float): Cumulative rainfall (cm).
            ti (float): Initial stick temperature (°C).
            hi (float): Initial stick surface humidity (g/g).
            wi (float): Initial stick moisture content (g/g).
            bp (float): Barometric pressure (cal/cm³).
        """
        self.m_ta0 = self.m_ta1 = ta
        self.m_ha0 = self.m_ha1 = ha
        self.m_sv0 = self.m_sv1 = sr / Smv
        self.m_rc0 = self.m_rc1 = rc
        self.m_ra0 = self.m_ra1 = 0.0
        self.m_bp0 = self.m_bp1 = bp

        self.m_hf = hi
        self.m_wfilm = 0.0
        self.m_wsa = wi + 0.1
        self.m_t[:] = ti
        self.m_w[:] = wi
        self.m_s[:] = 0.0

        self.diffusivity(self.m_bp0)
        self.m_init = True

    def meanMoisture(self):
        """Calculate mean moisture using Simpson's rule integration."""
        wea, web = 0.0, 0.0
        wec = self.m_w[0]
        wei = self.m_dx / (3.0 * self.m_radius)
        for i in range(1, self.m_nodes - 1, 2):
            wea = 4.0 * self.m_w[i]
            web = 2.0 * self.m_w[i + 1]
            if (i + 1) == (self.m_nodes - 1):
                web = self.m_w[self.m_nodes - 1]
            wec += web + wea
        wbr = wei * wec
        wbr = min(wbr, self.m_wmx)
        wbr += self.m_wfilm
        return wbr

    def meanWtdMoisture(self):
        """Calculate volume-weighted mean moisture."""
        wbr = np.dot(self.m_w, self.m_v)
        wbr = min(wbr, self.m_wmx)
        wbr += self.m_wfilm
        return wbr

    def meanWtdTemperature(self):
        """Calculate volume-weighted mean temperature."""
        return np.dot(self.m_t, self.m_v)

    def pptRate(self):
        """Get precipitation rate."""
        return (self.m_ra1 / self.m_et) if self.m_et > 0.00 else 0.00

    def setAdsorptionRate(self, adsorptionRate):
        self.m_stca = adsorptionRate

    def setAllowRainfall2(self, allow=True):
        self.m_allowRainfall2 = allow

    def setAllowRainstorm(self, allow=True):
        self.m_allowRainstorm = allow

    def setDesorptionRate(self, desorptionRate):
        self.m_stcd = desorptionRate

    def setDiffusivitySteps(self, diffusivitySteps):
        self.m_dSteps = diffusivitySteps

    def setMaximumLocalMoisture(self, localMaxMc):
        self.m_wmx = localMaxMc

    def setMoistureSteps(self, moistureSteps):
        self.m_mSteps = moistureSteps

    def setPlanarHeatTransferRate(self, planarHeatTransferRate):
        self.m_hc = planarHeatTransferRate

    def setPertubateColumn(self, pertubate=True):
        self.m_pertubateColumn = pertubate

    def setRainfallRunoffFactor(self, rainfallRunoffFactor):
        self.m_rai0 = rainfallRunoffFactor

    def setRampRai0(self, ramp=True):
        self.m_rampRai0 = ramp

    def setStickDensity(self, stickDensity=0.40):
        self.m_density = stickDensity

    def setStickLength(self, stickLength=41.0):
        self.m_length = stickLength

    def setStickNodes(self, stickNodes=11):
        self.m_nodes = stickNodes

    def setWaterFilmContribution(self, waterFilm):
        self.m_wfilm = waterFilm

    def stateName(self):
        """Get the name of the current moisture state."""
        states = [
            "None",             # 0
            "Adsorption",       # 1
            "Desorption",       # 2
            "Condensation1",    # 3
            "Condensation2",    # 4
            "Evaporation",      # 5
            "Rainfall1",        # 6
            "Rainfall2",        # 7
            "Rainstorm",        # 8
            "Stagnation",       # 9
            "Error"             # 10
        ]
        return states[self.m_state]

    @staticmethod
    def uniformRandom(min_val, max_val):
        """Generate uniform random value in range."""
        return (max_val - min_val) * random.random() + min_val

    def update(self, year: int, month: int, day: int, hour: int,
               minute: int, second: int, at: float, rh: float,
               sW: float, rcum: float, bpr: float) -> bool:
        """Update moisture based on datetime and weather.

        Args:
            year (int): Year.
            month (int): Month (1-12).
            day (int): Day of month.
            hour (int): Hour (0-23).
            minute (int): Minute (0-59).
            second (int): Second (0-59).
            at (float): Air temperature (°C).
            rh (float): Relative humidity (fraction).
            sW (float): Solar radiation (W/m²).
            rcum (float): Cumulative rainfall (cm).
            bpr (float): Barometric pressure (cal/cm³).

        Returns:
            bool: True if update succeeded, False otherwise.
        """
        # Determine Julian date for this new observation
        jd0 = self.m_semTime.toordinal() + 1721424.5
        self.m_semTime = datetime.datetime(year, month, day, hour, minute, second)
        jd1 = self.m_semTime.toordinal() + 1721424.5

        # Determine elapsed time (h) between the current and previous dates
        et = 24.0 * (jd1 - jd0)

        # If the Julian date wasn't initialized, or if the new time is less than the old time, assume a 1-h elapsed time.
        if jd1 < jd0:
            et = 1.0

        # Update!
        return self.update_internal(et, at, rh, sW, rcum, bpr)

    def update_internal(self, et: float, at: float, rh: float,
                        sW: float, rcum: float, bpr: float) -> bool:
        """Update moisture state for elapsed time.

        This is the main computational method. Inner loops are JIT-compiled
        when Numba is available. The outer loop uses local variable caching
        and math.exp/math.log for scalar operations to minimize overhead.

        Args:
            et (float): Elapsed time (hours).
            at (float): Air temperature (°C).
            rh (float): Relative humidity (fraction).
            sW (float): Solar radiation (W/m²).
            rcum (float): Cumulative rainfall (cm).
            bpr (float): Barometric pressure (cal/cm³).

        Returns:
            bool: True if update succeeded, False otherwise.
        """
        # Validate inputs
        if et < 0.0000027:
            print(f"DeadFuelMoisture::update() has a regressive elapsed time of {et} hours.")
            return False

        if rcum < self.m_rc1:
            print(f"DeadFuelMoisture::update() has a regressive cumulative rainfall amount of {rcum} cm.")
            self.m_rc1 = rcum
            self.m_ra0 = 0.0
            return False

        if rh < 0.001 or rh > 1.0:
            print(f"DeadFuelMoisture::update() has an out-of-range relative humidity of {rh} g/g.")
            return False

        if at < -60.0 or at > 60.0:
            print(f"DeadFuelMoisture::update() has an out-of-range air temperature of {at} oC.")
            return False

        if sW < 0.0:
            sW = 0.0
        if sW > 2000.0:
            print(f"DeadFuelMoisture::update() has an out-of-range solar insolation of {sW} W/m2.")
            return False

        # Save previous weather values
        ta0 = self.m_ta1
        ha0 = self.m_ha1
        sv0 = self.m_sv1
        rc0 = self.m_rc1
        ra0 = self.m_ra1
        bp0_old = self.m_bp1
        self.m_ta0 = ta0
        self.m_ha0 = ha0
        self.m_sv0 = sv0
        self.m_rc0 = rc0
        self.m_ra0 = ra0
        self.m_bp0 = bp0_old

        # Save current weather values
        sv1 = sW / Smv
        self.m_ta1 = at
        self.m_ha1 = rh
        self.m_sv1 = sv1
        self.m_rc1 = rcum
        self.m_bp1 = bpr
        self.m_et = et

        # Precipitation calculations
        ra1 = rcum - rc0
        self.m_ra1 = ra1
        m_rdur = 0.0 if ra1 < 0.0001 else self.m_rdur
        self.m_rdur = m_rdur
        pptrate = ra1 / et / Pi
        self.m_pptrate = pptrate
        m_mSteps = self.m_mSteps
        mdt = et / m_mSteps
        self.m_mdt = mdt
        mdt_2 = mdt * 2.0
        self.m_mdt_2 = mdt_2
        m_dx = self.m_dx
        self.m_sf = 3600.0 * mdt / (self.m_dx_2 * self.m_density)
        m_dSteps = self.m_dSteps
        ddt = et / m_dSteps
        self.m_ddt = ddt

        rai0 = mdt * self.m_rai0 * (1.0 - math.exp(-100.0 * pptrate))
        if rh < ha0:
            if self.m_rampRai0:
                rai0 *= (1.0 - ((ha0 - rh) / ha0))
            else:
                rai0 *= 0.15
        rai1 = mdt * self.m_rai1 * pptrate

        # Time-stepping control
        ddtNext = ddt
        tt = mdt

        # Perturbation flag
        perturbate = self.m_pertubateColumn

        # Cache instance attributes used in JIT call
        m_nodes = self.m_nodes
        m_w = self.m_w
        m_s = self.m_s
        m_t = self.m_t
        m_d = self.m_d
        m_x = self.m_x
        m_Twold = self.m_Twold
        m_Tsold = self.m_Tsold
        m_Ttold = self.m_Ttold
        m_Tv = self.m_Tv
        m_To = self.m_To
        m_Tg = self.m_Tg
        m_wmax = self.m_wmax
        m_wmx = self.m_wmx
        m_wfilmk = self.m_wfilmk
        m_dx = self.m_dx

        # Main time-stepping loop (JIT-compiled)
        num_steps = int(et / mdt) + 1
        tstate_arr = np.zeros(11, dtype=np.int64)

        result = _update_internal_loop(
            num_steps, et, mdt, mdt_2,
            ta0, at, ha0, rh, sv0, sv1, bp0_old, bpr,
            m_w, m_s, m_t, m_d, m_x,
            m_Twold, m_Tsold, m_Ttold, m_Tv, m_To, m_Tg,
            m_nodes, m_dx, self.m_density,
            m_wmax, m_wmx, m_wfilmk, self.m_vf, self.m_hc, self.m_hwf,
            self.m_stca, self.m_stcd, self.m_stv,
            self.m_allowRainstorm, self.m_allowRainfall2, self.m_amlf, self.m_capf,
            ra1, self.m_rdur, pptrate,
            rai0, rai1,
            ddt, self.m_wsa, self.m_hf,
            perturbate,
            tstate_arr,
            tt, ddtNext
        )

        # Write back scalar results
        self.m_rdur = result[0]
        self.m_wsa = result[1]
        self.m_hf = result[2]
        self.m_wfilm = result[3]
        self.m_state = int(result[4])
        self.m_sem = result[5]

        # Set final state to most common state
        most_common = 0
        max_count = tstate_arr[0]
        for i in range(1, 11):
            if tstate_arr[i] > max_count:
                max_count = tstate_arr[i]
                most_common = i
        self.m_state = most_common
        return True

    def zero(self):
        """Reset all state to zero."""
        self.m_semTime = None
        self.m_density = 0.0
        self.m_dSteps = 0
        self.m_hc = 0.0
        self.m_length = 0.0
        self.m_nodes = 0
        self.m_radius = 0.0
        self.m_rai0 = 0.0
        self.m_rai1 = 0.0
        self.m_stca = 0.0
        self.m_stcd = 0.0
        self.m_mSteps = 0
        self.m_stv = 0.0
        self.m_wfilmk = 0.0
        self.m_wmx = 0.0
        self.m_dx = 0.0
        self.m_wmax = 0.0
        self.m_x = np.array([])
        self.m_v = np.array([])
        self.m_amlf = 0.0
        self.m_capf = 0.0
        self.m_hwf = 0.0
        self.m_dx_2 = 0.0
        self.m_vf = 0.0
        self.m_bp0 = 0.0
        self.m_ha0 = 0.0
        self.m_rc0 = 0.0
        self.m_sv0 = 0.0
        self.m_ta0 = 0.0
        self.m_init = False
        self.m_bp1 = 0.0
        self.m_et = 0.0
        self.m_ha1 = 0.0
        self.m_rc1 = 0.0
        self.m_sv1 = 0.0
        self.m_ta1 = 0.0
        self.m_ddt = 0.0
        self.m_mdt = 0.0
        self.m_mdt_2 = 0.0
        self.m_pptrate = 0.0
        self.m_ra0 = 0.0
        self.m_ra1 = 0.0
        self.m_rdur = 0.0
        self.m_sf = 0.0
        self.m_hf = 0.0
        self.m_wsa = 0.0
        self.m_sem = 0.0
        self.m_wfilm = 0.0
        self.m_t = np.array([])
        self.m_s = np.array([])
        self.m_d = np.array([])
        self.m_w = np.array([])
        self.m_state = 0

    def __str__(self):
        output = []
        output.append(f"m_semTime {self.m_semTime}")
        output.append(f"m_density {self.m_density}")
        output.append(f"m_dSteps {self.m_dSteps}")
        output.append(f"m_hc {self.m_hc}")
        output.append(f"m_length {self.m_length}")
        output.append(f"m_nodes {self.m_nodes}")
        output.append(f"m_radius {self.m_radius}")
        output.append(f"m_rai0 {self.m_rai0}")
        output.append(f"m_rai1 {self.m_rai1}")
        output.append(f"m_stca {self.m_stca}")
        output.append(f"m_stcd {self.m_stcd}")
        output.append(f"m_mSteps {self.m_mSteps}")
        output.append(f"m_stv {self.m_stv}")
        output.append(f"m_wfilmk {self.m_wfilmk}")
        output.append(f"m_wmx {self.m_wmx}")
        output.append(f"m_dx {self.m_dx}")
        output.append(f"m_wmax {self.m_wmax}")
        output.append(f"m_x ({len(self.m_x)}) {' '.join(map(str, self.m_x))}")
        output.append(f"m_v ({len(self.m_v)}) {' '.join(map(str, self.m_v))}")
        output.append(f"m_amlf {self.m_amlf}")
        output.append(f"m_capf {self.m_capf}")
        output.append(f"m_hwf {self.m_hwf}")
        output.append(f"m_dx_2 {self.m_dx_2}")
        output.append(f"m_vf {self.m_vf}")
        output.append(f"m_bp0 {self.m_bp0}")
        output.append(f"m_ha0 {self.m_ha0}")
        output.append(f"m_rc0 {self.m_rc0}")
        output.append(f"m_sv0 {self.m_sv0}")
        output.append(f"m_ta0 {self.m_ta0}")
        output.append(f"m_init {self.m_init}")
        output.append(f"m_bp1 {self.m_bp1}")
        output.append(f"m_et {self.m_et}")
        output.append(f"m_ha1 {self.m_ha1}")
        output.append(f"m_rc1 {self.m_rc1}")
        output.append(f"m_sv1 {self.m_sv1}")
        output.append(f"m_ta1 {self.m_ta1}")
        output.append(f"m_ddt {self.m_ddt}")
        output.append(f"m_mdt {self.m_mdt}")
        output.append(f"m_mdt_2 {self.m_mdt_2}")
        output.append(f"m_pptrate {self.m_pptrate}")
        output.append(f"m_ra0 {self.m_ra0}")
        output.append(f"m_ra1 {self.m_ra1}")
        output.append(f"m_rdur {self.m_rdur}")
        output.append(f"m_sf {self.m_sf}")
        output.append(f"m_hf {self.m_hf}")
        output.append(f"m_wsa {self.m_wsa}")
        output.append(f"m_sem {self.m_sem}")
        output.append(f"m_wfilm {self.m_wfilm}")
        output.append(f"m_t {len(self.m_t)} {' '.join(map(str, self.m_t))}")
        output.append(f"m_s {len(self.m_s)} {' '.join(map(str, self.m_s))}")
        output.append(f"m_d {len(self.m_d)} {' '.join(map(str, self.m_d))}")
        output.append(f"m_w {len(self.m_w)} {' '.join(map(str, self.m_w))}")
        output.append(f"m_state {self.m_state}")
        return '\n'.join(output)

    @staticmethod
    def from_string(input_str: str) -> 'DeadFuelMoisture':
        """Deserialize a DeadFuelMoisture instance from its string representation.

        Args:
            input_str (str): String produced by ``__str__``.

        Returns:
            DeadFuelMoisture: Reconstructed model instance.
        """
        lines = input_str.strip().split('\n')
        r = DeadFuelMoisture(0, "")
        r.m_semTime = datetime.datetime.strptime(lines[0].split()[1], "%Y/%m/%d %H:%M:%S.%f")
        r.m_density = float(lines[1].split()[1])
        r.m_dSteps = int(lines[2].split()[1])
        r.m_hc = float(lines[3].split()[1])
        r.m_length = float(lines[4].split()[1])
        r.m_name = lines[5].split()[1]
        r.m_nodes = int(lines[6].split()[1])
        r.m_radius = float(lines[7].split()[1])
        r.m_rai0 = float(lines[8].split()[1])
        r.m_rai1 = float(lines[9].split()[1])
        r.m_stca = float(lines[10].split()[1])
        r.m_stcd = float(lines[11].split()[1])
        r.m_mSteps = int(lines[12].split()[1])
        r.m_stv = float(lines[13].split()[1])
        r.m_wfilmk = float(lines[14].split()[1])
        r.m_wmx = float(lines[15].split()[1])
        r.m_dx = float(lines[16].split()[1])
        r.m_wmax = float(lines[17].split()[1])
        n = int(lines[18].split()[1])
        r.m_x = np.array([float(lines[19 + i]) for i in range(n)])
        n = int(lines[19 + n].split()[1])
        r.m_v = np.array([float(lines[20 + i]) for i in range(n)])
        r.m_amlf = float(lines[20 + n].split()[1])
        r.m_capf = float(lines[21 + n].split()[1])
        r.m_hwf = float(lines[22 + n].split()[1])
        r.m_dx_2 = float(lines[23 + n].split()[1])
        r.m_vf = float(lines[24 + n].split()[1])
        r.m_bp0 = float(lines[25 + n].split()[1])
        r.m_ha0 = float(lines[26 + n].split()[1])
        r.m_rc0 = float(lines[27 + n].split()[1])
        r.m_sv0 = float(lines[28 + n].split()[1])
        r.m_ta0 = float(lines[29 + n].split()[1])
        r.m_init = lines[30 + n].split()[1] == 'True'
        r.m_bp1 = float(lines[31 + n].split()[1])
        r.m_et = float(lines[32 + n].split()[1])
        r.m_ha1 = float(lines[33 + n].split()[1])
        r.m_rc1 = float(lines[34 + n].split()[1])
        r.m_sv1 = float(lines[35 + n].split()[1])
        r.m_ta1 = float(lines[36 + n].split()[1])
        r.m_ddt = float(lines[37 + n].split()[1])
        r.m_mdt = float(lines[38 + n].split()[1])
        r.m_mdt_2 = float(lines[39 + n].split()[1])
        r.m_pptrate = float(lines[40 + n].split()[1])
        r.m_ra0 = float(lines[41 + n].split()[1])
        r.m_ra1 = float(lines[42 + n].split()[1])
        r.m_rdur = float(lines[43 + n].split()[1])
        r.m_sf = float(lines[44 + n].split()[1])
        r.m_hf = float(lines[45 + n].split()[1])
        r.m_wsa = float(lines[46 + n].split()[1])
        r.m_sem = float(lines[47 + n].split()[1])
        r.m_wfilm = float(lines[48 + n].split()[1])
        n = int(lines[50 + n].split()[1])
        r.m_t = np.array([float(lines[51 + i + n]) for i in range(n)])
        n = int(lines[51 + n + n].split()[1])
        r.m_s = np.array([float(lines[52 + i + n]) for i in range(n)])
        n = int(lines[52 + n + n].split()[1])
        r.m_d = np.array([float(lines[53 + i + n]) for i in range(n)])
        n = int(lines[53 + n + n].split()[1])
        r.m_w = np.array([float(lines[54 + i + n]) for i in range(n)])
        r.m_state = int(lines[55 + n + n].split()[1])
        return r

__init__(radius, stv, wmx, wfilmk)

Initialize the dead fuel moisture model.

Parameters:

Name Type Description Default
radius float

Fuel stick radius (cm).

required
stv float

Storm threshold (cm/h).

required
wmx float

Maximum fiber saturation (g/g).

required
wfilmk float

Water film constant.

required
Source code in embrs/models/dead_fuel_moisture.py
548
549
550
551
552
553
554
555
556
557
558
def __init__(self, radius: float, stv: float, wmx: float, wfilmk: float):
    """Initialize the dead fuel moisture model.

    Args:
        radius (float): Fuel stick radius (cm).
        stv (float): Storm threshold (cm/h).
        wmx (float): Maximum fiber saturation (g/g).
        wfilmk (float): Water film constant.
    """
    self.m_semTime = None
    self.initializeParameters(radius, stv, wmx, wfilmk)

createDeadFuelMoisture1() staticmethod

Create 1-hour fuel moisture model (fine fuels).

Source code in embrs/models/dead_fuel_moisture.py
716
717
718
719
720
721
@staticmethod
def createDeadFuelMoisture1():
    """Create 1-hour fuel moisture model (fine fuels)."""
    if DeadFuelMoisture._template_1hr is None:
        DeadFuelMoisture._template_1hr = DeadFuelMoisture(0.20, 0.006, 0.85, 0.10)
    return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_1hr)

createDeadFuelMoisture10() staticmethod

Create 10-hour fuel moisture model.

Source code in embrs/models/dead_fuel_moisture.py
723
724
725
726
727
728
@staticmethod
def createDeadFuelMoisture10():
    """Create 10-hour fuel moisture model."""
    if DeadFuelMoisture._template_10hr is None:
        DeadFuelMoisture._template_10hr = DeadFuelMoisture(0.64, 0.05, 0.60, 0.05)
    return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_10hr)

createDeadFuelMoisture100() staticmethod

Create 100-hour fuel moisture model.

Source code in embrs/models/dead_fuel_moisture.py
730
731
732
733
734
735
@staticmethod
def createDeadFuelMoisture100():
    """Create 100-hour fuel moisture model."""
    if DeadFuelMoisture._template_100hr is None:
        DeadFuelMoisture._template_100hr = DeadFuelMoisture(2.00, 5.0, 0.40, 0.005)
    return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_100hr)

createDeadFuelMoisture1000() staticmethod

Create 1000-hour fuel moisture model (large logs).

Source code in embrs/models/dead_fuel_moisture.py
737
738
739
740
741
742
@staticmethod
def createDeadFuelMoisture1000():
    """Create 1000-hour fuel moisture model (large logs)."""
    if DeadFuelMoisture._template_1000hr is None:
        DeadFuelMoisture._template_1000hr = DeadFuelMoisture(6.40, 7.5, 0.32, 0.003)
    return DeadFuelMoisture._clone_from_template(DeadFuelMoisture._template_1000hr)

deriveAdsorptionRate(radius)

Derive adsorption rate constant from stick radius.

Source code in embrs/models/dead_fuel_moisture.py
602
603
604
def deriveAdsorptionRate(self, radius: float) -> float:
    """Derive adsorption rate constant from stick radius."""
    return 0.0004509 + 0.006126 / radius ** 2.6

deriveDiffusivitySteps(radius)

Derive number of diffusivity sub-steps from stick radius.

Source code in embrs/models/dead_fuel_moisture.py
583
584
585
def deriveDiffusivitySteps(self, radius: float) -> int:
    """Derive number of diffusivity sub-steps from stick radius."""
    return int(4.777 + 2.496 / radius ** 1.3)

deriveMoistureSteps(radius)

Derive number of moisture sub-steps from stick radius.

Source code in embrs/models/dead_fuel_moisture.py
606
607
608
def deriveMoistureSteps(self, radius: float) -> int:
    """Derive number of moisture sub-steps from stick radius."""
    return int(9.8202 + 26.865 / radius ** 1.4)

derivePlanarHeatTransferRate(radius)

Derive planar heat transfer rate from stick radius.

Source code in embrs/models/dead_fuel_moisture.py
587
588
589
def derivePlanarHeatTransferRate(self, radius: float) -> float:
    """Derive planar heat transfer rate from stick radius."""
    return 0.2195 + 0.05260 / radius ** 2.5

deriveRainfallRunoffFactor(radius)

Derive rainfall runoff factor from stick radius.

Source code in embrs/models/dead_fuel_moisture.py
598
599
600
def deriveRainfallRunoffFactor(self, radius: float) -> float:
    """Derive rainfall runoff factor from stick radius."""
    return 0.02822 + 0.1056 / radius ** 2.2

deriveStickNodes(radius)

Derive number of radial nodes from stick radius (always odd).

Source code in embrs/models/dead_fuel_moisture.py
591
592
593
594
595
596
def deriveStickNodes(self, radius: float) -> int:
    """Derive number of radial nodes from stick radius (always odd)."""
    nodes = int(10.727 + 0.1746 / radius)
    if nodes % 2 == 0:
        nodes += 1
    return nodes

diffusivity(bp)

Compute diffusivity using JIT kernel if available.

Source code in embrs/models/dead_fuel_moisture.py
683
684
685
686
687
688
def diffusivity(self, bp):
    """Compute diffusivity using JIT kernel if available."""
    _compute_diffusivity(
        self.m_nodes, self.m_t, self.m_w, self.m_wsa,
        self.m_hf, self.m_density, bp, self.m_d
    )

from_string(input_str) staticmethod

Deserialize a DeadFuelMoisture instance from its string representation.

Parameters:

Name Type Description Default
input_str str

String produced by __str__.

required

Returns:

Name Type Description
DeadFuelMoisture DeadFuelMoisture

Reconstructed model instance.

Source code in embrs/models/dead_fuel_moisture.py
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
@staticmethod
def from_string(input_str: str) -> 'DeadFuelMoisture':
    """Deserialize a DeadFuelMoisture instance from its string representation.

    Args:
        input_str (str): String produced by ``__str__``.

    Returns:
        DeadFuelMoisture: Reconstructed model instance.
    """
    lines = input_str.strip().split('\n')
    r = DeadFuelMoisture(0, "")
    r.m_semTime = datetime.datetime.strptime(lines[0].split()[1], "%Y/%m/%d %H:%M:%S.%f")
    r.m_density = float(lines[1].split()[1])
    r.m_dSteps = int(lines[2].split()[1])
    r.m_hc = float(lines[3].split()[1])
    r.m_length = float(lines[4].split()[1])
    r.m_name = lines[5].split()[1]
    r.m_nodes = int(lines[6].split()[1])
    r.m_radius = float(lines[7].split()[1])
    r.m_rai0 = float(lines[8].split()[1])
    r.m_rai1 = float(lines[9].split()[1])
    r.m_stca = float(lines[10].split()[1])
    r.m_stcd = float(lines[11].split()[1])
    r.m_mSteps = int(lines[12].split()[1])
    r.m_stv = float(lines[13].split()[1])
    r.m_wfilmk = float(lines[14].split()[1])
    r.m_wmx = float(lines[15].split()[1])
    r.m_dx = float(lines[16].split()[1])
    r.m_wmax = float(lines[17].split()[1])
    n = int(lines[18].split()[1])
    r.m_x = np.array([float(lines[19 + i]) for i in range(n)])
    n = int(lines[19 + n].split()[1])
    r.m_v = np.array([float(lines[20 + i]) for i in range(n)])
    r.m_amlf = float(lines[20 + n].split()[1])
    r.m_capf = float(lines[21 + n].split()[1])
    r.m_hwf = float(lines[22 + n].split()[1])
    r.m_dx_2 = float(lines[23 + n].split()[1])
    r.m_vf = float(lines[24 + n].split()[1])
    r.m_bp0 = float(lines[25 + n].split()[1])
    r.m_ha0 = float(lines[26 + n].split()[1])
    r.m_rc0 = float(lines[27 + n].split()[1])
    r.m_sv0 = float(lines[28 + n].split()[1])
    r.m_ta0 = float(lines[29 + n].split()[1])
    r.m_init = lines[30 + n].split()[1] == 'True'
    r.m_bp1 = float(lines[31 + n].split()[1])
    r.m_et = float(lines[32 + n].split()[1])
    r.m_ha1 = float(lines[33 + n].split()[1])
    r.m_rc1 = float(lines[34 + n].split()[1])
    r.m_sv1 = float(lines[35 + n].split()[1])
    r.m_ta1 = float(lines[36 + n].split()[1])
    r.m_ddt = float(lines[37 + n].split()[1])
    r.m_mdt = float(lines[38 + n].split()[1])
    r.m_mdt_2 = float(lines[39 + n].split()[1])
    r.m_pptrate = float(lines[40 + n].split()[1])
    r.m_ra0 = float(lines[41 + n].split()[1])
    r.m_ra1 = float(lines[42 + n].split()[1])
    r.m_rdur = float(lines[43 + n].split()[1])
    r.m_sf = float(lines[44 + n].split()[1])
    r.m_hf = float(lines[45 + n].split()[1])
    r.m_wsa = float(lines[46 + n].split()[1])
    r.m_sem = float(lines[47 + n].split()[1])
    r.m_wfilm = float(lines[48 + n].split()[1])
    n = int(lines[50 + n].split()[1])
    r.m_t = np.array([float(lines[51 + i + n]) for i in range(n)])
    n = int(lines[51 + n + n].split()[1])
    r.m_s = np.array([float(lines[52 + i + n]) for i in range(n)])
    n = int(lines[52 + n + n].split()[1])
    r.m_d = np.array([float(lines[53 + i + n]) for i in range(n)])
    n = int(lines[53 + n + n].split()[1])
    r.m_w = np.array([float(lines[54 + i + n]) for i in range(n)])
    r.m_state = int(lines[55 + n + n].split()[1])
    return r

initializeEnvironment(ta, ha, sr, rc, ti, hi, wi, bp)

Initialize environmental conditions.

Parameters:

Name Type Description Default
ta float

Ambient air temperature (°C).

required
ha float

Ambient air relative humidity (g/g).

required
sr float

Solar radiation (W/m²).

required
rc float

Cumulative rainfall (cm).

required
ti float

Initial stick temperature (°C).

required
hi float

Initial stick surface humidity (g/g).

required
wi float

Initial stick moisture content (g/g).

required
bp float

Barometric pressure (cal/cm³).

required
Source code in embrs/models/dead_fuel_moisture.py
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
def initializeEnvironment(self, ta: float, ha: float, sr: float,
                          rc: float, ti: float, hi: float,
                          wi: float, bp: float):
    """Initialize environmental conditions.

    Args:
        ta (float): Ambient air temperature (°C).
        ha (float): Ambient air relative humidity (g/g).
        sr (float): Solar radiation (W/m²).
        rc (float): Cumulative rainfall (cm).
        ti (float): Initial stick temperature (°C).
        hi (float): Initial stick surface humidity (g/g).
        wi (float): Initial stick moisture content (g/g).
        bp (float): Barometric pressure (cal/cm³).
    """
    self.m_ta0 = self.m_ta1 = ta
    self.m_ha0 = self.m_ha1 = ha
    self.m_sv0 = self.m_sv1 = sr / Smv
    self.m_rc0 = self.m_rc1 = rc
    self.m_ra0 = self.m_ra1 = 0.0
    self.m_bp0 = self.m_bp1 = bp

    self.m_hf = hi
    self.m_wfilm = 0.0
    self.m_wsa = wi + 0.1
    self.m_t[:] = ti
    self.m_w[:] = wi
    self.m_s[:] = 0.0

    self.diffusivity(self.m_bp0)
    self.m_init = True

initializeParameters(radius, stv, wmx, wfilmk)

Initialize model parameters based on fuel geometry.

Source code in embrs/models/dead_fuel_moisture.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
def initializeParameters(self, radius, stv, wmx, wfilmk):
    """Initialize model parameters based on fuel geometry."""
    self.m_radius = radius
    self.m_density = 0.4
    self.m_length = 41.0
    self.m_dSteps = self.deriveDiffusivitySteps(radius)
    self.m_hc = self.derivePlanarHeatTransferRate(radius)
    self.m_nodes = self.deriveStickNodes(radius)
    self.m_rai0 = self.deriveRainfallRunoffFactor(radius)
    self.m_rai1 = 0.5
    self.m_stca = self.deriveAdsorptionRate(radius)
    self.m_stcd = 0.06
    self.m_mSteps = self.deriveMoistureSteps(radius)
    self.m_stv = stv
    self.m_wmx = wmx
    self.m_wfilmk = wfilmk
    self.m_allowRainfall2 = True
    self.m_allowRainstorm = True
    self.m_pertubateColumn = True
    self.m_rampRai0 = True
    self.m_rdur = 0.0
    self.initializeStick()

initializeStick()

Initialize the fuel stick state arrays.

Arrays are stored as numpy arrays for compatibility with JIT kernels.

Source code in embrs/models/dead_fuel_moisture.py
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
def initializeStick(self):
    """Initialize the fuel stick state arrays.

    Arrays are stored as numpy arrays for compatibility with JIT kernels.
    """
    # Internodal distance (cm)
    self.m_dx = self.m_radius / (self.m_nodes - 1)
    self.m_dx_2 = self.m_dx * 2

    # Maximum possible stick moisture content (g/g)
    self.m_wmax = (1.0 / self.m_density) - (1.0 / 1.53)

    # Initialize arrays as numpy arrays for JIT compatibility
    # Temperature array - initialized to 20°C (ambient)
    self.m_t = np.full(self.m_nodes, 20.0, dtype=np.float64)

    # Saturation array - initialized to 0
    self.m_s = np.zeros(self.m_nodes, dtype=np.float64)

    # Diffusivity array - initialized to 0
    self.m_d = np.zeros(self.m_nodes, dtype=np.float64)

    # Moisture array - initialized to half the maximum
    self.m_w = np.full(self.m_nodes, 0.5 * self.m_wmx, dtype=np.float64)

    # Nodal radial distances
    self.m_x = np.zeros(self.m_nodes, dtype=np.float64)
    for i in range(self.m_nodes - 1):
        self.m_x[i] = self.m_radius - (self.m_dx * i)
    self.m_x[self.m_nodes - 1] = 0.0

    # Nodal volume fractions
    self.m_v = np.zeros(self.m_nodes, dtype=np.float64)
    ro = self.m_radius
    ri = ro - 0.5 * self.m_dx
    a2 = self.m_radius * self.m_radius
    self.m_v[0] = (ro * ro - ri * ri) / a2
    vwt = self.m_v[0]
    for i in range(1, self.m_nodes - 1):
        ro = ri
        ri = ro - self.m_dx
        self.m_v[i] = (ro * ro - ri * ri) / a2
        vwt += self.m_v[i]
    self.m_v[self.m_nodes - 1] = ri * ri / a2
    vwt += self.m_v[self.m_nodes - 1]

    # Temporary arrays for time stepping (numpy arrays for JIT)
    self.m_Twold = np.zeros(self.m_nodes, dtype=np.float64)
    self.m_Ttold = np.zeros(self.m_nodes, dtype=np.float64)
    self.m_Tsold = np.zeros(self.m_nodes, dtype=np.float64)
    self.m_Tv = np.zeros(self.m_nodes, dtype=np.float64)
    self.m_To = np.zeros(self.m_nodes, dtype=np.float64)
    self.m_Tg = np.zeros(self.m_nodes, dtype=np.float64)

    # Initialize the environment
    self.initializeEnvironment(
        20.0,  # Ambient air temperature (oC)
        0.20,  # Ambient air relative humidity (g/g)
        0.0,   # Solar radiation (W/m2)
        0.0,   # Cumulative rainfall (cm)
        20.0,  # Initial stick temperature (oC)
        0.20,  # Initial stick surface humidity (g/g)
        0.5 * self.m_wmx,  # Initial stick moisture content
        0.0218)  # Initial stick barometric pressure (cal/cm3)
    self.m_init = False

    # Computation optimization parameters
    self.m_hwf = 0.622 * self.m_hc * (Pr / Sc) ** 0.667
    self.m_amlf = self.m_hwf / (0.24 * self.m_density * self.m_radius)
    rcav = 0.5 * Aw * Wl
    self.m_capf = 3600.0 * Pi * St * rcav * rcav / (16.0 * self.m_radius * self.m_radius * self.m_length * self.m_density)
    self.m_vf = St / (self.m_density * Wl * Scr)

initialized()

Check if environment has been initialized.

Source code in embrs/models/dead_fuel_moisture.py
744
745
746
def initialized(self):
    """Check if environment has been initialized."""
    return self.m_init

meanMoisture()

Calculate mean moisture using Simpson's rule integration.

Source code in embrs/models/dead_fuel_moisture.py
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
def meanMoisture(self):
    """Calculate mean moisture using Simpson's rule integration."""
    wea, web = 0.0, 0.0
    wec = self.m_w[0]
    wei = self.m_dx / (3.0 * self.m_radius)
    for i in range(1, self.m_nodes - 1, 2):
        wea = 4.0 * self.m_w[i]
        web = 2.0 * self.m_w[i + 1]
        if (i + 1) == (self.m_nodes - 1):
            web = self.m_w[self.m_nodes - 1]
        wec += web + wea
    wbr = wei * wec
    wbr = min(wbr, self.m_wmx)
    wbr += self.m_wfilm
    return wbr

meanWtdMoisture()

Calculate volume-weighted mean moisture.

Source code in embrs/models/dead_fuel_moisture.py
796
797
798
799
800
801
def meanWtdMoisture(self):
    """Calculate volume-weighted mean moisture."""
    wbr = np.dot(self.m_w, self.m_v)
    wbr = min(wbr, self.m_wmx)
    wbr += self.m_wfilm
    return wbr

meanWtdTemperature()

Calculate volume-weighted mean temperature.

Source code in embrs/models/dead_fuel_moisture.py
803
804
805
def meanWtdTemperature(self):
    """Calculate volume-weighted mean temperature."""
    return np.dot(self.m_t, self.m_v)

pptRate()

Get precipitation rate.

Source code in embrs/models/dead_fuel_moisture.py
807
808
809
def pptRate(self):
    """Get precipitation rate."""
    return (self.m_ra1 / self.m_et) if self.m_et > 0.00 else 0.00

stateName()

Get the name of the current moisture state.

Source code in embrs/models/dead_fuel_moisture.py
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
def stateName(self):
    """Get the name of the current moisture state."""
    states = [
        "None",             # 0
        "Adsorption",       # 1
        "Desorption",       # 2
        "Condensation1",    # 3
        "Condensation2",    # 4
        "Evaporation",      # 5
        "Rainfall1",        # 6
        "Rainfall2",        # 7
        "Rainstorm",        # 8
        "Stagnation",       # 9
        "Error"             # 10
    ]
    return states[self.m_state]

uniformRandom(min_val, max_val) staticmethod

Generate uniform random value in range.

Source code in embrs/models/dead_fuel_moisture.py
873
874
875
876
@staticmethod
def uniformRandom(min_val, max_val):
    """Generate uniform random value in range."""
    return (max_val - min_val) * random.random() + min_val

update(year, month, day, hour, minute, second, at, rh, sW, rcum, bpr)

Update moisture based on datetime and weather.

Parameters:

Name Type Description Default
year int

Year.

required
month int

Month (1-12).

required
day int

Day of month.

required
hour int

Hour (0-23).

required
minute int

Minute (0-59).

required
second int

Second (0-59).

required
at float

Air temperature (°C).

required
rh float

Relative humidity (fraction).

required
sW float

Solar radiation (W/m²).

required
rcum float

Cumulative rainfall (cm).

required
bpr float

Barometric pressure (cal/cm³).

required

Returns:

Name Type Description
bool bool

True if update succeeded, False otherwise.

Source code in embrs/models/dead_fuel_moisture.py
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
def update(self, year: int, month: int, day: int, hour: int,
           minute: int, second: int, at: float, rh: float,
           sW: float, rcum: float, bpr: float) -> bool:
    """Update moisture based on datetime and weather.

    Args:
        year (int): Year.
        month (int): Month (1-12).
        day (int): Day of month.
        hour (int): Hour (0-23).
        minute (int): Minute (0-59).
        second (int): Second (0-59).
        at (float): Air temperature (°C).
        rh (float): Relative humidity (fraction).
        sW (float): Solar radiation (W/m²).
        rcum (float): Cumulative rainfall (cm).
        bpr (float): Barometric pressure (cal/cm³).

    Returns:
        bool: True if update succeeded, False otherwise.
    """
    # Determine Julian date for this new observation
    jd0 = self.m_semTime.toordinal() + 1721424.5
    self.m_semTime = datetime.datetime(year, month, day, hour, minute, second)
    jd1 = self.m_semTime.toordinal() + 1721424.5

    # Determine elapsed time (h) between the current and previous dates
    et = 24.0 * (jd1 - jd0)

    # If the Julian date wasn't initialized, or if the new time is less than the old time, assume a 1-h elapsed time.
    if jd1 < jd0:
        et = 1.0

    # Update!
    return self.update_internal(et, at, rh, sW, rcum, bpr)

update_internal(et, at, rh, sW, rcum, bpr)

Update moisture state for elapsed time.

This is the main computational method. Inner loops are JIT-compiled when Numba is available. The outer loop uses local variable caching and math.exp/math.log for scalar operations to minimize overhead.

Parameters:

Name Type Description Default
et float

Elapsed time (hours).

required
at float

Air temperature (°C).

required
rh float

Relative humidity (fraction).

required
sW float

Solar radiation (W/m²).

required
rcum float

Cumulative rainfall (cm).

required
bpr float

Barometric pressure (cal/cm³).

required

Returns:

Name Type Description
bool bool

True if update succeeded, False otherwise.

Source code in embrs/models/dead_fuel_moisture.py
 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
def update_internal(self, et: float, at: float, rh: float,
                    sW: float, rcum: float, bpr: float) -> bool:
    """Update moisture state for elapsed time.

    This is the main computational method. Inner loops are JIT-compiled
    when Numba is available. The outer loop uses local variable caching
    and math.exp/math.log for scalar operations to minimize overhead.

    Args:
        et (float): Elapsed time (hours).
        at (float): Air temperature (°C).
        rh (float): Relative humidity (fraction).
        sW (float): Solar radiation (W/m²).
        rcum (float): Cumulative rainfall (cm).
        bpr (float): Barometric pressure (cal/cm³).

    Returns:
        bool: True if update succeeded, False otherwise.
    """
    # Validate inputs
    if et < 0.0000027:
        print(f"DeadFuelMoisture::update() has a regressive elapsed time of {et} hours.")
        return False

    if rcum < self.m_rc1:
        print(f"DeadFuelMoisture::update() has a regressive cumulative rainfall amount of {rcum} cm.")
        self.m_rc1 = rcum
        self.m_ra0 = 0.0
        return False

    if rh < 0.001 or rh > 1.0:
        print(f"DeadFuelMoisture::update() has an out-of-range relative humidity of {rh} g/g.")
        return False

    if at < -60.0 or at > 60.0:
        print(f"DeadFuelMoisture::update() has an out-of-range air temperature of {at} oC.")
        return False

    if sW < 0.0:
        sW = 0.0
    if sW > 2000.0:
        print(f"DeadFuelMoisture::update() has an out-of-range solar insolation of {sW} W/m2.")
        return False

    # Save previous weather values
    ta0 = self.m_ta1
    ha0 = self.m_ha1
    sv0 = self.m_sv1
    rc0 = self.m_rc1
    ra0 = self.m_ra1
    bp0_old = self.m_bp1
    self.m_ta0 = ta0
    self.m_ha0 = ha0
    self.m_sv0 = sv0
    self.m_rc0 = rc0
    self.m_ra0 = ra0
    self.m_bp0 = bp0_old

    # Save current weather values
    sv1 = sW / Smv
    self.m_ta1 = at
    self.m_ha1 = rh
    self.m_sv1 = sv1
    self.m_rc1 = rcum
    self.m_bp1 = bpr
    self.m_et = et

    # Precipitation calculations
    ra1 = rcum - rc0
    self.m_ra1 = ra1
    m_rdur = 0.0 if ra1 < 0.0001 else self.m_rdur
    self.m_rdur = m_rdur
    pptrate = ra1 / et / Pi
    self.m_pptrate = pptrate
    m_mSteps = self.m_mSteps
    mdt = et / m_mSteps
    self.m_mdt = mdt
    mdt_2 = mdt * 2.0
    self.m_mdt_2 = mdt_2
    m_dx = self.m_dx
    self.m_sf = 3600.0 * mdt / (self.m_dx_2 * self.m_density)
    m_dSteps = self.m_dSteps
    ddt = et / m_dSteps
    self.m_ddt = ddt

    rai0 = mdt * self.m_rai0 * (1.0 - math.exp(-100.0 * pptrate))
    if rh < ha0:
        if self.m_rampRai0:
            rai0 *= (1.0 - ((ha0 - rh) / ha0))
        else:
            rai0 *= 0.15
    rai1 = mdt * self.m_rai1 * pptrate

    # Time-stepping control
    ddtNext = ddt
    tt = mdt

    # Perturbation flag
    perturbate = self.m_pertubateColumn

    # Cache instance attributes used in JIT call
    m_nodes = self.m_nodes
    m_w = self.m_w
    m_s = self.m_s
    m_t = self.m_t
    m_d = self.m_d
    m_x = self.m_x
    m_Twold = self.m_Twold
    m_Tsold = self.m_Tsold
    m_Ttold = self.m_Ttold
    m_Tv = self.m_Tv
    m_To = self.m_To
    m_Tg = self.m_Tg
    m_wmax = self.m_wmax
    m_wmx = self.m_wmx
    m_wfilmk = self.m_wfilmk
    m_dx = self.m_dx

    # Main time-stepping loop (JIT-compiled)
    num_steps = int(et / mdt) + 1
    tstate_arr = np.zeros(11, dtype=np.int64)

    result = _update_internal_loop(
        num_steps, et, mdt, mdt_2,
        ta0, at, ha0, rh, sv0, sv1, bp0_old, bpr,
        m_w, m_s, m_t, m_d, m_x,
        m_Twold, m_Tsold, m_Ttold, m_Tv, m_To, m_Tg,
        m_nodes, m_dx, self.m_density,
        m_wmax, m_wmx, m_wfilmk, self.m_vf, self.m_hc, self.m_hwf,
        self.m_stca, self.m_stcd, self.m_stv,
        self.m_allowRainstorm, self.m_allowRainfall2, self.m_amlf, self.m_capf,
        ra1, self.m_rdur, pptrate,
        rai0, rai1,
        ddt, self.m_wsa, self.m_hf,
        perturbate,
        tstate_arr,
        tt, ddtNext
    )

    # Write back scalar results
    self.m_rdur = result[0]
    self.m_wsa = result[1]
    self.m_hf = result[2]
    self.m_wfilm = result[3]
    self.m_state = int(result[4])
    self.m_sem = result[5]

    # Set final state to most common state
    most_common = 0
    max_count = tstate_arr[0]
    for i in range(1, 11):
        if tstate_arr[i] > max_count:
            max_count = tstate_arr[i]
            most_common = i
    self.m_state = most_common
    return True

zero()

Reset all state to zero.

Source code in embrs/models/dead_fuel_moisture.py
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
def zero(self):
    """Reset all state to zero."""
    self.m_semTime = None
    self.m_density = 0.0
    self.m_dSteps = 0
    self.m_hc = 0.0
    self.m_length = 0.0
    self.m_nodes = 0
    self.m_radius = 0.0
    self.m_rai0 = 0.0
    self.m_rai1 = 0.0
    self.m_stca = 0.0
    self.m_stcd = 0.0
    self.m_mSteps = 0
    self.m_stv = 0.0
    self.m_wfilmk = 0.0
    self.m_wmx = 0.0
    self.m_dx = 0.0
    self.m_wmax = 0.0
    self.m_x = np.array([])
    self.m_v = np.array([])
    self.m_amlf = 0.0
    self.m_capf = 0.0
    self.m_hwf = 0.0
    self.m_dx_2 = 0.0
    self.m_vf = 0.0
    self.m_bp0 = 0.0
    self.m_ha0 = 0.0
    self.m_rc0 = 0.0
    self.m_sv0 = 0.0
    self.m_ta0 = 0.0
    self.m_init = False
    self.m_bp1 = 0.0
    self.m_et = 0.0
    self.m_ha1 = 0.0
    self.m_rc1 = 0.0
    self.m_sv1 = 0.0
    self.m_ta1 = 0.0
    self.m_ddt = 0.0
    self.m_mdt = 0.0
    self.m_mdt_2 = 0.0
    self.m_pptrate = 0.0
    self.m_ra0 = 0.0
    self.m_ra1 = 0.0
    self.m_rdur = 0.0
    self.m_sf = 0.0
    self.m_hf = 0.0
    self.m_wsa = 0.0
    self.m_sem = 0.0
    self.m_wfilm = 0.0
    self.m_t = np.array([])
    self.m_s = np.array([])
    self.m_d = np.array([])
    self.m_w = np.array([])
    self.m_state = 0

IRPG fine dead fuel moisture estimation model.

Implement the Incident Response Pocket Guide (IRPG) method for estimating fine dead fuel moisture (FDFM) from temperature, relative humidity, and site condition correction factors (shading, aspect, slope, elevation, time of day, and month).

Classes:

Name Description
- FuelMoisturePriors

Prior probability distributions for site conditions.

- IRPGMoistureModel

FDFM estimation combining RFM lookup and stochastic correction factors.

References

National Wildfire Coordinating Group. (2014). Incident Response Pocket Guide (IRPG). PMS 461, NFES 1077.

FuelMoisturePriors dataclass

Probability distributions for stochastic sampling of site conditions.

Attributes:

Name Type Description
p_shaded float

Probability that the site is shaded (0 to 1)

aspect_probs List[float]

Probabilities for aspects [N, E, S, W], must sum to 1

slope_probs List[float]

Probabilities for slope bins [0-30%, 31%+], must sum to 1

elev_probs List[float]

Probabilities for elevation relation [Below, Level, Above], must sum to 1

Source code in embrs/models/irpg_moisture_model.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@dataclass
class FuelMoisturePriors:
    """Probability distributions for stochastic sampling of site conditions.

    Attributes:
        p_shaded: Probability that the site is shaded (0 to 1)
        aspect_probs: Probabilities for aspects [N, E, S, W], must sum to 1
        slope_probs: Probabilities for slope bins [0-30%, 31%+], must sum to 1
        elev_probs: Probabilities for elevation relation [Below, Level, Above], must sum to 1
    """
    p_shaded: float = 0.5
    aspect_probs: List[float] = field(default_factory=lambda: [0.25, 0.25, 0.25, 0.25])  # [N, E, S, W]
    slope_probs: List[float] = field(default_factory=lambda: [0.5, 0.5])  # [0-30, 31+]
    elev_probs: List[float] = field(default_factory=lambda: [1/3, 1/3, 1/3])  # [B, L, A]

IRPGMoistureModel

IRPG-based fine dead fuel moisture estimation model.

Combines Reference Fuel Moisture (RFM) lookup from temperature and relative humidity with stochastic correction factors (Δ) based on site conditions (shading, aspect, slope, elevation, time of day, and month).

Example

priors = FuelMoisturePriors(p_shaded=0.3) model = IRPGMoistureModel(priors) rng = np.random.default_rng(42) fdfm = model.sample_fdfm(T=75.0, RH=35.0, month=7, local_time_hr=14, rng=rng)

Source code in embrs/models/irpg_moisture_model.py
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
class IRPGMoistureModel:
    """IRPG-based fine dead fuel moisture estimation model.

    Combines Reference Fuel Moisture (RFM) lookup from temperature and relative
    humidity with stochastic correction factors (Δ) based on site conditions
    (shading, aspect, slope, elevation, time of day, and month).

    Example:
        >>> priors = FuelMoisturePriors(p_shaded=0.3)
        >>> model = IRPGMoistureModel(priors)
        >>> rng = np.random.default_rng(42)
        >>> fdfm = model.sample_fdfm(T=75.0, RH=35.0, month=7, local_time_hr=14, rng=rng)
    """

    def __init__(self, priors: FuelMoisturePriors):
        """Initialize the model with prior distributions.

        Args:
            priors: FuelMoisturePriors instance with probability distributions
                   for site condition sampling.

        Raises:
            ValueError: If priors have invalid lengths or don't sum to ~1.0
        """
        self._validate_priors(priors)
        self.priors = priors

    def _validate_priors(self, priors: FuelMoisturePriors) -> None:
        """Validate that priors have correct structure and sum to ~1.0."""
        tol = 1e-6

        if not 0.0 <= priors.p_shaded <= 1.0:
            raise ValueError(f"p_shaded must be in [0, 1], got {priors.p_shaded}")

        if len(priors.aspect_probs) != 4:
            raise ValueError(f"aspect_probs must have 4 elements, got {len(priors.aspect_probs)}")
        if abs(sum(priors.aspect_probs) - 1.0) > tol:
            raise ValueError(f"aspect_probs must sum to 1.0, got {sum(priors.aspect_probs)}")

        if len(priors.slope_probs) != 2:
            raise ValueError(f"slope_probs must have 2 elements, got {len(priors.slope_probs)}")
        if abs(sum(priors.slope_probs) - 1.0) > tol:
            raise ValueError(f"slope_probs must sum to 1.0, got {sum(priors.slope_probs)}")

        if len(priors.elev_probs) != 3:
            raise ValueError(f"elev_probs must have 3 elements, got {len(priors.elev_probs)}")
        if abs(sum(priors.elev_probs) - 1.0) > tol:
            raise ValueError(f"elev_probs must sum to 1.0, got {sum(priors.elev_probs)}")

    def rfm(self, T: float, RH: float) -> float:
        """Look up Reference Fuel Moisture from temperature and relative humidity.

        Args:
            T: Temperature in degrees Fahrenheit
            RH: Relative humidity as percentage (0-100)

        Returns:
            Reference Fuel Moisture as a float (typically 1-14%)
        """
        # Clamp inputs to valid ranges
        rh = min(100.0, max(0.0, RH))
        t = max(10.0, T)  # Table starts at 10°F

        # Find bin indices using bisect
        rh_idx = bisect.bisect_right(RH_EDGES, rh) - 1
        t_idx = bisect.bisect_right(T_EDGES, t) - 1

        # Clamp indices to valid range (handles edge cases)
        rh_idx = max(0, min(rh_idx, len(RFM_TABLE) - 1))
        t_idx = max(0, min(t_idx, len(RFM_TABLE[0]) - 1))

        return float(RFM_TABLE[rh_idx][t_idx])

    @staticmethod
    def _month_to_table_id(month: int) -> str:
        """Map month (1-12) to IRPG correction table ID (B, C, or D).

        Table B: May, June, July (summer)
        Table C: Feb, Mar, Apr, Aug, Sep, Oct (transition seasons)
        Table D: Nov, Dec, Jan (winter)

        Args:
            month: Month as integer 1-12

        Returns:
            Table ID string: "B", "C", or "D"
        """
        return MONTH_TO_TABLE[month]

    @staticmethod
    def _time_hr_to_time_bin(local_time_hr: int) -> str:
        """Map hour of day to IRPG time bin.

        Time bins represent "greater than or equal to" thresholds:
        - 0800>: 8:00 AM to 9:59 AM (hours 8-9)
        - 1000>: 10:00 AM to 11:59 AM (hours 10-11)
        - 1200>: 12:00 PM to 1:59 PM (hours 12-13)
        - 1400>: 2:00 PM to 3:59 PM (hours 14-15)
        - 1600>: 4:00 PM to 5:59 PM (hours 16-17)
        - 1800>: 6:00 PM onwards or before 8:00 AM (hours 18+ or <8)

        Args:
            local_time_hr: Hour of day in 24-hour format (0-23)

        Returns:
            Time bin string
        """
        if local_time_hr < 8:
            return "1800>"  # Before 8 AM uses evening values
        if local_time_hr < 10:
            return "0800>"
        if local_time_hr < 12:
            return "1000>"
        if local_time_hr < 14:
            return "1200>"
        if local_time_hr < 16:
            return "1400>"
        if local_time_hr < 18:
            return "1600>"
        return "1800>"

    def _delta_lookup_exposed(self, table_id: str, aspect: str, slope_bin: str,
                              time_bin: str, elev_rel: str) -> int:
        """Look up delta correction value for exposed site.

        Args:
            table_id: "B", "C", or "D"
            aspect: "N", "E", "S", or "W"
            slope_bin: "0_30" or "31_plus"
            time_bin: One of TIME_BINS
            elev_rel: "B", "L", or "A"

        Returns:
            Integer correction value from table
        """
        row_idx = DELTA_EXPOSED_ROW_MAP[(aspect, slope_bin)]
        col_idx = DELTA_COL_MAP[(time_bin, elev_rel)]
        return DELTA_TABLES[table_id]["exposed"][row_idx][col_idx]

    def _delta_lookup_shaded(self, table_id: str, aspect: str,
                             time_bin: str, elev_rel: str) -> int:
        """Look up delta correction value for shaded site.

        Shaded sites don't use slope differentiation.

        Args:
            table_id: "B", "C", or "D"
            aspect: "N", "E", "S", or "W"
            time_bin: One of TIME_BINS
            elev_rel: "B", "L", or "A"

        Returns:
            Integer correction value from table
        """
        row_idx = DELTA_SHADED_ROW_MAP[aspect]
        col_idx = DELTA_COL_MAP[(time_bin, elev_rel)]
        return DELTA_TABLES[table_id]["shaded"][row_idx][col_idx]

    def sample_delta(self, month: int, local_time_hr: int,
                     rng: np.random.Generator) -> float:
        """Sample a correction factor (Δ) based on site conditions.

        Stochastically samples shading, aspect, slope, and elevation
        according to the configured priors, then looks up the appropriate
        correction value from IRPG tables.

        Args:
            month: Month as integer 1-12
            local_time_hr: Hour of day in 24-hour format (0-23)
            rng: NumPy random Generator for reproducible sampling

        Returns:
            Correction factor as a float (typically 0-6)
        """
        table_id = self._month_to_table_id(month)
        time_bin = self._time_hr_to_time_bin(local_time_hr)

        # Sample site conditions
        is_shaded = rng.random() < self.priors.p_shaded
        aspect_idx = rng.choice(4, p=self.priors.aspect_probs)
        aspect = ASPECTS[aspect_idx]
        elev_idx = rng.choice(3, p=self.priors.elev_probs)
        elev_rel = ELEV_REL[elev_idx]

        if is_shaded:
            delta = self._delta_lookup_shaded(table_id, aspect, time_bin, elev_rel)
        else:
            slope_idx = rng.choice(2, p=self.priors.slope_probs)
            slope_bin = SLOPE_BINS[slope_idx]
            delta = self._delta_lookup_exposed(table_id, aspect, slope_bin, time_bin, elev_rel)

        return float(delta)

    def sample_fdfm(self, T: float, RH: float, month: int, local_time_hr: int,
                    rng: np.random.Generator) -> float:
        """Sample Fine Dead Fuel Moisture (FDFM) percentage.

        Combines Reference Fuel Moisture lookup with stochastically sampled
        correction factors based on site conditions.

        Args:
            T: Temperature in degrees Fahrenheit
            RH: Relative humidity as percentage (0-100)
            month: Month as integer 1-12
            local_time_hr: Hour of day in 24-hour format (0-23)
            rng: NumPy random Generator for reproducible sampling

        Returns:
            Fine dead fuel moisture as percentage (float)
        """
        rfm = self.rfm(T, RH)
        delta = self.sample_delta(month, local_time_hr, rng)
        return rfm + delta

__init__(priors)

Initialize the model with prior distributions.

Parameters:

Name Type Description Default
priors FuelMoisturePriors

FuelMoisturePriors instance with probability distributions for site condition sampling.

required

Raises:

Type Description
ValueError

If priors have invalid lengths or don't sum to ~1.0

Source code in embrs/models/irpg_moisture_model.py
228
229
230
231
232
233
234
235
236
237
238
239
def __init__(self, priors: FuelMoisturePriors):
    """Initialize the model with prior distributions.

    Args:
        priors: FuelMoisturePriors instance with probability distributions
               for site condition sampling.

    Raises:
        ValueError: If priors have invalid lengths or don't sum to ~1.0
    """
    self._validate_priors(priors)
    self.priors = priors

rfm(T, RH)

Look up Reference Fuel Moisture from temperature and relative humidity.

Parameters:

Name Type Description Default
T float

Temperature in degrees Fahrenheit

required
RH float

Relative humidity as percentage (0-100)

required

Returns:

Type Description
float

Reference Fuel Moisture as a float (typically 1-14%)

Source code in embrs/models/irpg_moisture_model.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def rfm(self, T: float, RH: float) -> float:
    """Look up Reference Fuel Moisture from temperature and relative humidity.

    Args:
        T: Temperature in degrees Fahrenheit
        RH: Relative humidity as percentage (0-100)

    Returns:
        Reference Fuel Moisture as a float (typically 1-14%)
    """
    # Clamp inputs to valid ranges
    rh = min(100.0, max(0.0, RH))
    t = max(10.0, T)  # Table starts at 10°F

    # Find bin indices using bisect
    rh_idx = bisect.bisect_right(RH_EDGES, rh) - 1
    t_idx = bisect.bisect_right(T_EDGES, t) - 1

    # Clamp indices to valid range (handles edge cases)
    rh_idx = max(0, min(rh_idx, len(RFM_TABLE) - 1))
    t_idx = max(0, min(t_idx, len(RFM_TABLE[0]) - 1))

    return float(RFM_TABLE[rh_idx][t_idx])

sample_delta(month, local_time_hr, rng)

Sample a correction factor (Δ) based on site conditions.

Stochastically samples shading, aspect, slope, and elevation according to the configured priors, then looks up the appropriate correction value from IRPG tables.

Parameters:

Name Type Description Default
month int

Month as integer 1-12

required
local_time_hr int

Hour of day in 24-hour format (0-23)

required
rng Generator

NumPy random Generator for reproducible sampling

required

Returns:

Type Description
float

Correction factor as a float (typically 0-6)

Source code in embrs/models/irpg_moisture_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
def sample_delta(self, month: int, local_time_hr: int,
                 rng: np.random.Generator) -> float:
    """Sample a correction factor (Δ) based on site conditions.

    Stochastically samples shading, aspect, slope, and elevation
    according to the configured priors, then looks up the appropriate
    correction value from IRPG tables.

    Args:
        month: Month as integer 1-12
        local_time_hr: Hour of day in 24-hour format (0-23)
        rng: NumPy random Generator for reproducible sampling

    Returns:
        Correction factor as a float (typically 0-6)
    """
    table_id = self._month_to_table_id(month)
    time_bin = self._time_hr_to_time_bin(local_time_hr)

    # Sample site conditions
    is_shaded = rng.random() < self.priors.p_shaded
    aspect_idx = rng.choice(4, p=self.priors.aspect_probs)
    aspect = ASPECTS[aspect_idx]
    elev_idx = rng.choice(3, p=self.priors.elev_probs)
    elev_rel = ELEV_REL[elev_idx]

    if is_shaded:
        delta = self._delta_lookup_shaded(table_id, aspect, time_bin, elev_rel)
    else:
        slope_idx = rng.choice(2, p=self.priors.slope_probs)
        slope_bin = SLOPE_BINS[slope_idx]
        delta = self._delta_lookup_exposed(table_id, aspect, slope_bin, time_bin, elev_rel)

    return float(delta)

sample_fdfm(T, RH, month, local_time_hr, rng)

Sample Fine Dead Fuel Moisture (FDFM) percentage.

Combines Reference Fuel Moisture lookup with stochastically sampled correction factors based on site conditions.

Parameters:

Name Type Description Default
T float

Temperature in degrees Fahrenheit

required
RH float

Relative humidity as percentage (0-100)

required
month int

Month as integer 1-12

required
local_time_hr int

Hour of day in 24-hour format (0-23)

required
rng Generator

NumPy random Generator for reproducible sampling

required

Returns:

Type Description
float

Fine dead fuel moisture as percentage (float)

Source code in embrs/models/irpg_moisture_model.py
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
def sample_fdfm(self, T: float, RH: float, month: int, local_time_hr: int,
                rng: np.random.Generator) -> float:
    """Sample Fine Dead Fuel Moisture (FDFM) percentage.

    Combines Reference Fuel Moisture lookup with stochastically sampled
    correction factors based on site conditions.

    Args:
        T: Temperature in degrees Fahrenheit
        RH: Relative humidity as percentage (0-100)
        month: Month as integer 1-12
        local_time_hr: Hour of day in 24-hour format (0-23)
        rng: NumPy random Generator for reproducible sampling

    Returns:
        Fine dead fuel moisture as percentage (float)
    """
    rfm = self.rfm(T, RH)
    delta = self.sample_delta(month, local_time_hr, rng)
    return rfm + delta