fvs-python 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. fvs_python-0.2.3.dist-info/METADATA +254 -0
  2. fvs_python-0.2.3.dist-info/RECORD +149 -0
  3. fvs_python-0.2.3.dist-info/WHEEL +5 -0
  4. fvs_python-0.2.3.dist-info/licenses/LICENSE +21 -0
  5. fvs_python-0.2.3.dist-info/top_level.txt +1 -0
  6. pyfvs/__init__.py +107 -0
  7. pyfvs/bark_ratio.py +323 -0
  8. pyfvs/cfg/CFG_README.md +73 -0
  9. pyfvs/cfg/dbh_bounding_table_4_7_1_8.json +103 -0
  10. pyfvs/cfg/ecounit_coefficients_table_4_7_1_5.json +981 -0
  11. pyfvs/cfg/ecounit_coefficients_table_4_7_1_6.json +856 -0
  12. pyfvs/cfg/forest_type_mapping_table_4_7_1_4.json +38 -0
  13. pyfvs/cfg/fortype_coefficients_table_4_7_1_3.json +1183 -0
  14. pyfvs/cfg/functional_forms.yaml +111 -0
  15. pyfvs/cfg/growth_model_parameters.yaml +98 -0
  16. pyfvs/cfg/plant_values_table_4_7_1_7.json +15 -0
  17. pyfvs/cfg/site_index_transformation.yaml +113 -0
  18. pyfvs/cfg/sn_bark_ratio_coefficients.json +115 -0
  19. pyfvs/cfg/sn_crown_competition_factor.json +109 -0
  20. pyfvs/cfg/sn_crown_ratio_coefficients.json +956 -0
  21. pyfvs/cfg/sn_crown_width_coefficients.json +1664 -0
  22. pyfvs/cfg/sn_diameter_growth_coefficients.json +300 -0
  23. pyfvs/cfg/sn_height_diameter_coefficients.json +97 -0
  24. pyfvs/cfg/sn_large_tree_diameter_growth.json +191 -0
  25. pyfvs/cfg/sn_large_tree_height_growth.json +229 -0
  26. pyfvs/cfg/sn_large_tree_height_growth_coefficients.json +1187 -0
  27. pyfvs/cfg/sn_mortality_model.json +176 -0
  28. pyfvs/cfg/sn_regeneration_model.json +252 -0
  29. pyfvs/cfg/sn_relative_site_index.json +263 -0
  30. pyfvs/cfg/sn_small_tree_height_growth.json +879 -0
  31. pyfvs/cfg/sn_species_codes_table.json +728 -0
  32. pyfvs/cfg/sn_stand_density_index.json +398 -0
  33. pyfvs/cfg/species/ab_american_basswood.yaml +251 -0
  34. pyfvs/cfg/species/ae_american_elm.yaml +240 -0
  35. pyfvs/cfg/species/ah_american_hornbeam.yaml +250 -0
  36. pyfvs/cfg/species/ap_american_plum.yaml +251 -0
  37. pyfvs/cfg/species/as_american_sycamore.yaml +253 -0
  38. pyfvs/cfg/species/ba_black_ash.yaml +254 -0
  39. pyfvs/cfg/species/bb_basswood.yaml +254 -0
  40. pyfvs/cfg/species/bc_black_cherry.yaml +254 -0
  41. pyfvs/cfg/species/bd_sweet_birch.yaml +252 -0
  42. pyfvs/cfg/species/be_american_beech.yaml +251 -0
  43. pyfvs/cfg/species/bg_black_gum.yaml +252 -0
  44. pyfvs/cfg/species/bj_blue_jay.yaml +254 -0
  45. pyfvs/cfg/species/bk_sugar_maple.yaml +251 -0
  46. pyfvs/cfg/species/bn_butternut.yaml +252 -0
  47. pyfvs/cfg/species/bo_red_maple.yaml +255 -0
  48. pyfvs/cfg/species/bt_bigtooth_aspen.yaml +254 -0
  49. pyfvs/cfg/species/bu_buckeye.yaml +252 -0
  50. pyfvs/cfg/species/by_bald_cypress.yaml +255 -0
  51. pyfvs/cfg/species/ca_american_chestnut.yaml +254 -0
  52. pyfvs/cfg/species/cb_cucumber_tree.yaml +254 -0
  53. pyfvs/cfg/species/ck_virginia_pine.yaml +254 -0
  54. pyfvs/cfg/species/co_pond_cypress.yaml +251 -0
  55. pyfvs/cfg/species/ct_catalpa.yaml +251 -0
  56. pyfvs/cfg/species/cw_chestnut_oak.yaml +253 -0
  57. pyfvs/cfg/species/dw_dogwood.yaml +250 -0
  58. pyfvs/cfg/species/el_american_hornbeam.yaml +254 -0
  59. pyfvs/cfg/species/fm_flowering_dogwood.yaml +251 -0
  60. pyfvs/cfg/species/fr_fraser_fir.yaml +247 -0
  61. pyfvs/cfg/species/ga_green_ash.yaml +254 -0
  62. pyfvs/cfg/species/ha_hawthorn.yaml +252 -0
  63. pyfvs/cfg/species/hb_hornbeam.yaml +254 -0
  64. pyfvs/cfg/species/hh_dogwood.yaml +251 -0
  65. pyfvs/cfg/species/hi_hickory_species.yaml +252 -0
  66. pyfvs/cfg/species/hl_holly.yaml +254 -0
  67. pyfvs/cfg/species/hm_eastern_hemlock.yaml +246 -0
  68. pyfvs/cfg/species/hy_holly.yaml +252 -0
  69. pyfvs/cfg/species/ju_eastern_juniper.yaml +247 -0
  70. pyfvs/cfg/species/lb_loblolly_bay.yaml +254 -0
  71. pyfvs/cfg/species/lk_laurel_oak.yaml +254 -0
  72. pyfvs/cfg/species/ll_longleaf_pine.yaml +265 -0
  73. pyfvs/cfg/species/lo_silver_maple.yaml +252 -0
  74. pyfvs/cfg/species/lp_loblolly_pine.yaml +268 -0
  75. pyfvs/cfg/species/mb_mountain_birch.yaml +250 -0
  76. pyfvs/cfg/species/mg_magnolia.yaml +251 -0
  77. pyfvs/cfg/species/ml_maple_leaf.yaml +254 -0
  78. pyfvs/cfg/species/ms_maple_species.yaml +247 -0
  79. pyfvs/cfg/species/mv_magnolia_vine.yaml +254 -0
  80. pyfvs/cfg/species/oh_other_hardwood.yaml +231 -0
  81. pyfvs/cfg/species/os_other_softwood.yaml +232 -0
  82. pyfvs/cfg/species/ot_other_tree.yaml +210 -0
  83. pyfvs/cfg/species/ov_overcup_oak.yaml +254 -0
  84. pyfvs/cfg/species/pc_pond_cypress.yaml +254 -0
  85. pyfvs/cfg/species/pd_pitch_pine.yaml +245 -0
  86. pyfvs/cfg/species/pi_pine_species.yaml +246 -0
  87. pyfvs/cfg/species/po_american_beech.yaml +254 -0
  88. pyfvs/cfg/species/pp_pond_pine.yaml +246 -0
  89. pyfvs/cfg/species/ps_persimmon.yaml +251 -0
  90. pyfvs/cfg/species/pu_pond_pine.yaml +249 -0
  91. pyfvs/cfg/species/qs_flowering_dogwood.yaml +254 -0
  92. pyfvs/cfg/species/ra_red_ash.yaml +245 -0
  93. pyfvs/cfg/species/rd_redbud.yaml +251 -0
  94. pyfvs/cfg/species/rl_red_elm.yaml +240 -0
  95. pyfvs/cfg/species/rm_red_maple.yaml +256 -0
  96. pyfvs/cfg/species/ro_eastern_hemlock.yaml +255 -0
  97. pyfvs/cfg/species/sa_slash_pine.yaml +265 -0
  98. pyfvs/cfg/species/sb_sweet_birch.yaml +255 -0
  99. pyfvs/cfg/species/sd_sand_pine.yaml +251 -0
  100. pyfvs/cfg/species/sk_swamp_oak.yaml +253 -0
  101. pyfvs/cfg/species/sm_sugar_maple.yaml +252 -0
  102. pyfvs/cfg/species/sn_loblolly_pine.yaml +254 -0
  103. pyfvs/cfg/species/so_southern_oak.yaml +253 -0
  104. pyfvs/cfg/species/sp_shortleaf_pine.yaml +267 -0
  105. pyfvs/cfg/species/sr_spruce_pine.yaml +246 -0
  106. pyfvs/cfg/species/ss_basswood.yaml +251 -0
  107. pyfvs/cfg/species/su_sweetgum.yaml +255 -0
  108. pyfvs/cfg/species/sv_silver_maple.yaml +255 -0
  109. pyfvs/cfg/species/sy_sycamore.yaml +254 -0
  110. pyfvs/cfg/species/tm_tamarack.yaml +246 -0
  111. pyfvs/cfg/species/to_tulip_oak.yaml +254 -0
  112. pyfvs/cfg/species/ts_tulip_tree.yaml +253 -0
  113. pyfvs/cfg/species/vp_virginia_pine.yaml +248 -0
  114. pyfvs/cfg/species/wa_white_ash.yaml +254 -0
  115. pyfvs/cfg/species/we_white_elm.yaml +250 -0
  116. pyfvs/cfg/species/wi_willow.yaml +248 -0
  117. pyfvs/cfg/species/wk_water_oak.yaml +254 -0
  118. pyfvs/cfg/species/wn_walnut.yaml +254 -0
  119. pyfvs/cfg/species/wo_white_oak.yaml +256 -0
  120. pyfvs/cfg/species/wp_white_pine.yaml +250 -0
  121. pyfvs/cfg/species/wt_water_tupelo.yaml +254 -0
  122. pyfvs/cfg/species/yp_yellow_poplar.yaml +261 -0
  123. pyfvs/cfg/species_config.yaml +106 -0
  124. pyfvs/clark_profile.py +323 -0
  125. pyfvs/competition.py +332 -0
  126. pyfvs/config_loader.py +375 -0
  127. pyfvs/crown_competition_factor.py +464 -0
  128. pyfvs/crown_ratio.py +377 -0
  129. pyfvs/crown_width.py +512 -0
  130. pyfvs/data_export.py +356 -0
  131. pyfvs/ecological_unit.py +272 -0
  132. pyfvs/exceptions.py +86 -0
  133. pyfvs/fia_integration.py +876 -0
  134. pyfvs/forest_type.py +253 -0
  135. pyfvs/growth_plots.py +579 -0
  136. pyfvs/harvest.py +603 -0
  137. pyfvs/height_diameter.py +248 -0
  138. pyfvs/large_tree_height_growth.py +822 -0
  139. pyfvs/logging_config.py +213 -0
  140. pyfvs/main.py +99 -0
  141. pyfvs/mortality.py +431 -0
  142. pyfvs/parameters.py +121 -0
  143. pyfvs/simulation_engine.py +386 -0
  144. pyfvs/stand.py +1004 -0
  145. pyfvs/stand_metrics.py +436 -0
  146. pyfvs/stand_output.py +552 -0
  147. pyfvs/tree.py +756 -0
  148. pyfvs/validation.py +190 -0
  149. pyfvs/volume_library.py +761 -0
pyfvs/tree.py ADDED
@@ -0,0 +1,756 @@
1
+ """
2
+ Tree class representing an individual tree.
3
+ Implements both small-tree and large-tree growth models.
4
+ """
5
+ import math
6
+ import yaml
7
+ import numpy as np
8
+ from scipy.stats import weibull_min
9
+ from pathlib import Path
10
+ from typing import Dict, Any, Optional
11
+ from .validation import ParameterValidator
12
+ from .logging_config import get_logger, log_model_transition
13
+
14
+ class Tree:
15
+ def __init__(self, dbh, height, species="LP", age=0, crown_ratio=0.85):
16
+ """Initialize a tree with basic measurements.
17
+
18
+ Args:
19
+ dbh: Diameter at breast height (inches)
20
+ height: Total height (feet)
21
+ species: Species code (e.g., "LP" for loblolly pine)
22
+ age: Tree age in years
23
+ crown_ratio: Initial crown ratio (proportion of tree height with live crown)
24
+ """
25
+ # Validate parameters
26
+ validated = ParameterValidator.validate_tree_parameters(
27
+ dbh, height, age, crown_ratio, species
28
+ )
29
+
30
+ self.dbh = validated['dbh']
31
+ self.height = validated['height']
32
+ self.species = species
33
+ self.age = validated['age']
34
+ self.crown_ratio = validated['crown_ratio']
35
+
36
+ # Initialize optional ecounit and forest_type (set during grow())
37
+ self._ecounit = None
38
+ self._forest_type = None
39
+
40
+ # Set up logging
41
+ self.logger = get_logger(__name__)
42
+
43
+ # Check height-DBH relationship (disabled warning for small seedlings)
44
+ if self.height > 4.5 and not ParameterValidator.check_height_dbh_relationship(self.dbh, self.height):
45
+ self.logger.warning(
46
+ f"Unusual height-DBH relationship: DBH={self.dbh}, Height={self.height}"
47
+ )
48
+
49
+ # Load configuration
50
+ self._load_config()
51
+
52
+ def _load_config(self):
53
+ """Load configuration using the new config loader."""
54
+ from .config_loader import get_config_loader
55
+
56
+ loader = get_config_loader()
57
+
58
+ # Load species-specific parameters
59
+ self.species_params = loader.load_species_config(self.species)
60
+
61
+ # Load functional forms and site index parameters
62
+ self.functional_forms = loader.functional_forms
63
+ self.site_index_params = loader.site_index_params
64
+
65
+ # Load growth model parameters
66
+ try:
67
+ growth_params_file = loader.cfg_dir / 'growth_model_parameters.yaml'
68
+ self.growth_params = loader._load_config_file(growth_params_file)
69
+ except Exception:
70
+ # Fallback to defaults if file not found
71
+ self.growth_params = {
72
+ 'growth_transitions': {'small_to_large_tree': {'xmin': 1.0, 'xmax': 3.0}},
73
+ 'small_tree_growth': {'default': {
74
+ 'c1': 1.1421, 'c2': 1.0042, 'c3': -0.0374, 'c4': 0.7632, 'c5': 0.0358
75
+ }}
76
+ }
77
+
78
+ def grow(self, site_index: float, competition_factor: float, rank: float = 0.5, relsdi: float = 5.0, ba: float = 100, pbal: float = 50, slope: float = 0.05, aspect: float = 0, time_step: int = 5, ecounit: str = None, forest_type: str = None) -> None:
79
+ """Grow the tree for the specified number of years.
80
+
81
+ Args:
82
+ site_index: Site index (base age 25) in feet
83
+ competition_factor: Competition factor (0-1)
84
+ rank: Tree's rank in diameter distribution (0-1)
85
+ relsdi: Relative stand density index (0-12)
86
+ ba: Stand basal area (sq ft/acre)
87
+ pbal: Plot basal area in larger trees (sq ft/acre)
88
+ slope: Ground slope (proportion)
89
+ aspect: Aspect in radians
90
+ time_step: Number of years to grow the tree (default: 5)
91
+ ecounit: Ecological unit code (e.g., "232", "M231") - passed from Stand
92
+ forest_type: Forest type group (e.g., "FTYLPN") - passed from Stand
93
+ """
94
+ # Store ecounit and forest_type for use in growth methods
95
+ self._ecounit = ecounit
96
+ self._forest_type = forest_type
97
+ # Validate growth parameters
98
+ validated = ParameterValidator.validate_growth_parameters(
99
+ site_index, competition_factor, ba, pbal, rank, relsdi,
100
+ slope, aspect, time_step, self.species
101
+ )
102
+
103
+ # Use validated parameters
104
+ site_index = validated['site_index']
105
+ competition_factor = validated['competition_factor']
106
+ ba = validated['ba']
107
+ pbal = validated['pbal']
108
+ rank = validated['rank']
109
+ relsdi = validated['relsdi']
110
+ slope = validated['slope']
111
+ aspect = validated['aspect']
112
+ time_step = validated['time_step']
113
+
114
+ # Store initial values before any changes
115
+ initial_age = self.age
116
+ initial_dbh = self.dbh
117
+ initial_height = self.height
118
+
119
+ # Get transition parameters from config
120
+ transition_params = self.growth_params['growth_transitions']['small_to_large_tree']
121
+ xmin = transition_params['xmin'] # minimum DBH for transition (inches)
122
+ xmax = transition_params['xmax'] # maximum DBH for transition (inches)
123
+
124
+ # Calculate weight for blending growth models based on initial DBH
125
+ # Use smoothstep function for smoother transition (reduces discontinuities)
126
+ if initial_dbh < xmin:
127
+ weight = 0.0
128
+ model_used = "small_tree"
129
+ elif initial_dbh > xmax:
130
+ weight = 1.0
131
+ model_used = "large_tree"
132
+ else:
133
+ # Smoothstep function: 3t² - 2t³ where t = (dbh - xmin) / (xmax - xmin)
134
+ t = (initial_dbh - xmin) / (xmax - xmin)
135
+ weight = t * t * (3.0 - 2.0 * t)
136
+ model_used = "blended"
137
+
138
+ # Log model transition if crossing threshold
139
+ if initial_dbh < xmin and self.dbh >= xmin:
140
+ log_model_transition(self.logger, f"{self.species}_{id(self)}",
141
+ "small_tree", "blended", self.dbh)
142
+ elif initial_dbh < xmax and self.dbh >= xmax:
143
+ log_model_transition(self.logger, f"{self.species}_{id(self)}",
144
+ "blended", "large_tree", self.dbh)
145
+
146
+ # Temporarily increment age for growth calculations
147
+ self.age = initial_age + time_step
148
+
149
+ # Calculate small tree growth
150
+ self._grow_small_tree(site_index, competition_factor, time_step)
151
+ small_dbh = self.dbh
152
+ small_height = self.height
153
+
154
+ # Reset to initial state for large tree model
155
+ self.dbh = initial_dbh
156
+ self.height = initial_height
157
+
158
+ # Calculate large tree growth
159
+ self._grow_large_tree(site_index, competition_factor, ba, pbal, slope, aspect, time_step)
160
+ large_dbh = self.dbh
161
+ large_height = self.height
162
+
163
+ # Blend results based on initial DBH
164
+ self.dbh = (1 - weight) * small_dbh + weight * large_dbh
165
+ self.height = (1 - weight) * small_height + weight * large_height
166
+
167
+ # Ensure age is properly set after growth
168
+ self.age = initial_age + time_step
169
+
170
+ # Update crown ratio using Weibull model (pass time_step for proper scaling)
171
+ self._update_crown_ratio_weibull(rank, relsdi, competition_factor, time_step)
172
+
173
+ def _grow_small_tree(self, site_index, competition_factor, time_step=5):
174
+ """Implement small tree height growth model using Chapman-Richards function.
175
+
176
+ The Chapman-Richards model (NC-128 form) predicts cumulative height at
177
+ a given age, not periodic growth. Height growth is calculated as the
178
+ difference between heights at future age and current age:
179
+
180
+ Height(t) = c1 * SI^c2 * (1 - exp(c3 * t))^(c4 * SI^c5)
181
+ HeightGrowth = Height(age + time_step) - Height(age)
182
+
183
+ **Time Step Handling:** Unlike the large tree diameter growth model which
184
+ requires explicit scaling (DDS * time_step/5), the Chapman-Richards
185
+ equation naturally handles any time step because it calculates cumulative
186
+ height at discrete ages. The growth increment is simply the difference
187
+ between the height curves at two points in time.
188
+
189
+ **Ecological Unit Effect:** Unlike the large-tree DDS model which applies
190
+ ecounit as an additive term in ln(DDS), the small-tree model does NOT
191
+ apply an ecounit modifier. This is because:
192
+ 1. Site Index already incorporates regional productivity - SI=55 means
193
+ height at base age 25 = 55 feet, regardless of region
194
+ 2. The Chapman-Richards curve is calibrated to match SI at base age
195
+ 3. The FVS large-tree height growth (POTHTG) also does NOT apply ecounit
196
+ 4. Ecounit affects DIAMETER growth (DDS), not height growth
197
+
198
+ Args:
199
+ site_index: Site index (base age 25) in feet
200
+ competition_factor: Competition factor (0-1)
201
+ time_step: Number of years to grow (any positive integer)
202
+ """
203
+ # Get parameters from config
204
+ small_tree_params = self.growth_params.get('small_tree_growth', {})
205
+ if self.species in small_tree_params:
206
+ p = small_tree_params[self.species]
207
+ else:
208
+ p = small_tree_params.get('default', {
209
+ 'c1': 1.1421,
210
+ 'c2': 1.0042,
211
+ 'c3': -0.0374,
212
+ 'c4': 0.7632,
213
+ 'c5': 0.0358
214
+ })
215
+
216
+ # Chapman-Richards predicts cumulative height at age t
217
+ # Height(t) = c1 * SI^c2 * (1 - exp(c3 * t))^(c4 * SI^c5)
218
+ #
219
+ # The NC-128 coefficients may have been calibrated with a different site index
220
+ # base age. To ensure Height(base_age=25) = SI, we compute a scaling factor.
221
+
222
+ # Current age (before growth) - age was already incremented in grow()
223
+ current_age = self.age - time_step
224
+ future_age = self.age # This is current_age + time_step
225
+
226
+ # Site index base age for southern pines
227
+ base_age = 25
228
+
229
+ def _raw_chapman_richards(age):
230
+ """Calculate unscaled Chapman-Richards height."""
231
+ if age <= 0:
232
+ return 1.0
233
+ return (
234
+ p['c1'] * (site_index ** p['c2']) *
235
+ (1.0 - math.exp(p['c3'] * age)) **
236
+ (p['c4'] * (site_index ** p['c5']))
237
+ )
238
+
239
+ # Calculate scaling factor to ensure Height(base_age) = SI
240
+ # This corrects for NC-128 coefficients that may use different base ages
241
+ raw_height_at_base = _raw_chapman_richards(base_age)
242
+ if raw_height_at_base > 0:
243
+ scale_factor = site_index / raw_height_at_base
244
+ else:
245
+ scale_factor = 1.0
246
+
247
+ # Calculate scaled heights
248
+ if current_age <= 0:
249
+ current_height = 1.0 # Initial height at planting
250
+ else:
251
+ current_height = _raw_chapman_richards(current_age) * scale_factor
252
+
253
+ future_height = _raw_chapman_richards(future_age) * scale_factor
254
+
255
+ # Height growth is the difference
256
+ height_growth = future_height - current_height
257
+
258
+ # NOTE: No ecounit modifier is applied to height growth. Site Index
259
+ # already incorporates regional productivity through the Chapman-Richards
260
+ # curve. However, the ecounit effect IS applied to DIAMETER growth below.
261
+
262
+ # Apply a modifier for competition (subtle effect for small trees)
263
+ # Small trees are less affected by competition than large trees
264
+ max_reduction = self.growth_params.get('competition_effects', {}).get(
265
+ 'small_tree_competition', {}).get('max_reduction', 0.2)
266
+ competition_modifier = 1.0 - (max_reduction * competition_factor)
267
+ actual_growth = height_growth * competition_modifier
268
+
269
+ # Update height with bounds checking
270
+ self.height = max(4.5, self.height + actual_growth)
271
+
272
+ # Get ecological unit effect for DIAMETER growth (not height)
273
+ # This matches how FVS applies ecounit to the DDS equation for large trees
274
+ ecounit_multiplier = 1.0
275
+ if self._ecounit is not None:
276
+ from .ecological_unit import get_ecounit_effect
277
+ ecounit_effect = get_ecounit_effect(self.species, self._ecounit)
278
+ # Convert additive ln(DDS) effect to multiplicative diameter increment effect
279
+ # For M231 with LP: ecounit_effect = 0.790, exp(0.790) ≈ 2.2x growth
280
+ ecounit_multiplier = math.exp(ecounit_effect)
281
+
282
+ # Save original DBH before height-diameter update
283
+ original_dbh = self.dbh
284
+
285
+ # Calculate new DBH from height using height-diameter relationship
286
+ self._update_dbh_from_height()
287
+
288
+ # Apply ecounit effect to the DBH INCREMENT (not to height)
289
+ # This ensures regional productivity affects diameter growth
290
+ if ecounit_multiplier != 1.0 and self.dbh > original_dbh:
291
+ dbh_increment = self.dbh - original_dbh
292
+ adjusted_increment = dbh_increment * ecounit_multiplier
293
+ self.dbh = original_dbh + adjusted_increment
294
+
295
+ def _grow_large_tree(self, site_index, competition_factor, ba, pbal, slope, aspect, time_step=5):
296
+ """Implement large tree diameter growth model using official FVS-SN equations.
297
+
298
+ Based on USDA Forest Service FVS Southern Variant (SN) from dgf.f:
299
+ ln(DDS) = CONSPP + INTERC + LDBH*ln(D) + DBH2*D^2 + LCRWN*ln(CR)
300
+ + HREL*RELHT + PLTB*BA + PNTBL*PBAL
301
+ + [forest_type_terms] + [eco_unit_terms] + [plant_effect]
302
+
303
+ Where CONSPP = ISIO*SI + TANS*SLOPE + FCOS*SLOPE*cos(ASPECT) + FSIN*SLOPE*sin(ASPECT)
304
+
305
+ Args:
306
+ site_index: Site index (base age 50) in feet
307
+ competition_factor: Competition factor (0-1)
308
+ ba: Stand basal area (sq ft/acre), minimum 25.0
309
+ pbal: Plot basal area in larger trees (sq ft/acre)
310
+ slope: Ground slope as tangent (rise/run)
311
+ aspect: Aspect in radians
312
+ time_step: Number of years to grow (default: 5)
313
+ """
314
+ # Get diameter growth coefficients from species config
315
+ dg_config = self.species_params.get('diameter_growth', {})
316
+ p = dg_config.get('coefficients', {})
317
+
318
+ # Apply FVS bounds
319
+ # BA minimum is 25.0 in FVS
320
+ ba_bounded = max(25.0, ba)
321
+
322
+ # Crown ratio for FVS must be integer percentage (0-100), minimum 25
323
+ # Our crown_ratio is stored as proportion (0-1), convert to percentage
324
+ cr_pct = max(25.0, self.crown_ratio * 100.0)
325
+
326
+ # Relative height (RELHT) = HT/AVH, capped at 1.5 in FVS
327
+ # AVH is the average height of the 40 largest trees (top height)
328
+ # For individual tree growth without stand context, assume codominant (relht = 1.0)
329
+ # This is appropriate for:
330
+ # - Plantation trees that are uniformly managed
331
+ # - Dominant/codominant trees in natural stands
332
+ # When called from Stand.grow(), the Stand should pass actual top height
333
+ # via the _top_height attribute if competition-based height reduction is needed
334
+ if hasattr(self, '_top_height') and self._top_height is not None and self._top_height > 0:
335
+ # Use actual stand top height (passed from Stand)
336
+ relht = min(1.5, self.height / self._top_height)
337
+ else:
338
+ # Default: assume codominant tree (no height suppression)
339
+ # This is appropriate for plantations and dominant trees
340
+ relht = 1.0
341
+
342
+ # Get forest type effect - use passed forest_type or fall back to species config
343
+ fortype_config = self.species_params.get('fortype', {})
344
+ if self._forest_type is not None:
345
+ # Use passed forest type from Stand
346
+ from .forest_type import get_forest_type_effect
347
+ fortype_effect = get_forest_type_effect(self.species, self._forest_type)
348
+ else:
349
+ # Fall back to species config base forest type
350
+ fortype_effect = fortype_config.get('coefficients', {}).get(
351
+ fortype_config.get('base_fortype', 'FTYLPN'), 0.0
352
+ )
353
+
354
+ # Get ecological unit effect - use passed ecounit or fall back to species config
355
+ if self._ecounit is not None:
356
+ # Use passed ecounit from Stand
357
+ from .ecological_unit import get_ecounit_effect
358
+ ecounit_effect = get_ecounit_effect(self.species, self._ecounit)
359
+ else:
360
+ # Fall back to species config base ecounit (typically 0.0)
361
+ ecounit_config = self.species_params.get('ecounit', {}).get('table_4_7_1_5', {})
362
+ ecounit_effect = ecounit_config.get('coefficients', {}).get(
363
+ ecounit_config.get('base_ecounit', '232'), 0.0
364
+ )
365
+
366
+ # Get plant effect from species config
367
+ plant_config = self.species_params.get('plant', {})
368
+ plant_effect = plant_config.get('value', 0.0)
369
+
370
+ # Also check growth_params for managed plantation setting
371
+ planting_effects = self.growth_params.get('large_tree_modifiers', {}).get('planting_effect', {})
372
+ if self.species in planting_effects:
373
+ plant_effect = planting_effects[self.species]
374
+
375
+ # Build the diameter growth equation following official FVS structure
376
+ # Coefficient mapping from config (b1-b11) to FVS variables:
377
+ # b1 = INTERC (intercept)
378
+ # b2 = LDBH (ln(DBH) coefficient)
379
+ # b3 = DBH2 (DBH^2 coefficient)
380
+ # b4 = LCRWN (ln(CR) coefficient)
381
+ # b5 = HREL (relative height coefficient)
382
+ # b6 = ISIO (site index coefficient)
383
+ # b7 = PLTB (basal area coefficient) - NOTE: config may have wrong value
384
+ # b8 = PNTBL (point basal area larger coefficient)
385
+ # b9 = TANS (slope tangent coefficient)
386
+ # b10 = FCOS (slope*cos(aspect) coefficient)
387
+ # b11 = FSIN (slope*sin(aspect) coefficient)
388
+
389
+ # Check for new-style coefficient names first, fall back to b1-b11
390
+ interc = p.get('INTERC', p.get('b1', 0.0))
391
+ ldbh = p.get('LDBH', p.get('b2', 0.0))
392
+ dbh2 = p.get('DBH2', p.get('b3', 0.0))
393
+ lcrwn = p.get('LCRWN', p.get('b4', 0.0))
394
+ hrel = p.get('HREL', p.get('b5', 0.0))
395
+ isio = p.get('ISIO', p.get('b6', 0.0))
396
+ pltb = p.get('PLTB', p.get('b7', 0.0))
397
+ pntbl = p.get('PNTBL', p.get('b8', 0.0))
398
+ tans = p.get('TANS', p.get('b9', 0.0))
399
+ fcos = p.get('FCOS', p.get('b10', 0.0))
400
+ fsin = p.get('FSIN', p.get('b11', 0.0))
401
+
402
+ # Calculate CONSPP (site and topographic terms)
403
+ # CONSPP = ISIO*SI + TANS*SLOPE + FCOS*SLOPE*cos(ASPECT) + FSIN*SLOPE*sin(ASPECT)
404
+ conspp = (
405
+ isio * site_index +
406
+ tans * slope +
407
+ fcos * slope * math.cos(aspect) +
408
+ fsin * slope * math.sin(aspect)
409
+ )
410
+
411
+ # Calculate ln(DDS) - change in squared diameter (inside bark)
412
+ # Main equation: ln(DDS) = CONSPP + INTERC + LDBH*ln(D) + DBH2*D^2
413
+ # + LCRWN*ln(CR) + HREL*RELHT + PLTB*BA + PNTBL*PBAL
414
+ # + [forest_type] + [eco_unit] + [plant_effect]
415
+ ln_dds = (
416
+ conspp +
417
+ interc +
418
+ ldbh * math.log(self.dbh) +
419
+ dbh2 * self.dbh**2 +
420
+ lcrwn * math.log(cr_pct) +
421
+ hrel * relht +
422
+ pltb * ba_bounded +
423
+ pntbl * pbal +
424
+ fortype_effect +
425
+ ecounit_effect +
426
+ plant_effect
427
+ )
428
+
429
+ # Apply FVS minimum bound for ln(DDS)
430
+ ln_dds = max(-9.21, ln_dds)
431
+
432
+ # Convert ln(DDS) to diameter growth
433
+ # DDS is change in squared diameter (inside bark) over growth period
434
+ # Scale growth based on time_step (model is calibrated for 5-year growth)
435
+ dds = math.exp(ln_dds) * (time_step / 5.0)
436
+
437
+ # FVS applies DDS to inside-bark diameter, then converts back to outside-bark
438
+ # From dgdriv.f: D=DBH(I)*BRATIO(...); DG=(SQRT(DSQ+DDS)-D)
439
+ # We must convert DBH to inside-bark, apply DDS, then convert back
440
+ from .bark_ratio import create_bark_ratio_model
441
+ bark_model = create_bark_ratio_model(self.species)
442
+
443
+ # Get bark ratio (DIB/DOB) for current tree
444
+ bark_ratio = bark_model.calculate_bark_ratio(self.dbh)
445
+
446
+ # Convert to inside-bark diameter
447
+ dib_old = self.dbh * bark_ratio
448
+ dib_old_sq = dib_old * dib_old
449
+
450
+ # Apply DDS to inside-bark diameter
451
+ dib_new = math.sqrt(dib_old_sq + dds)
452
+
453
+ # Convert back to outside-bark (DBH)
454
+ self.dbh = dib_new / bark_ratio
455
+
456
+ # Update height using FVS large-tree height growth model (Section 4.7.2)
457
+ # HTG = POTHTG * (0.25 * HGMDCR + 0.75 * HGMDRH)
458
+ self._update_height_large_tree(
459
+ site_index=site_index,
460
+ ba=ba_bounded,
461
+ pbal=pbal,
462
+ slope=slope,
463
+ aspect=aspect,
464
+ relht=relht,
465
+ time_step=time_step,
466
+ competition_factor=competition_factor
467
+ )
468
+
469
+ def _update_crown_ratio_weibull(self, rank, relsdi, competition_factor, time_step=5):
470
+ """Update crown ratio using Weibull-based model with FVS-style change calculation.
471
+
472
+ FVS calculates crown ratio CHANGE, not absolute CR:
473
+ 1. Predict "old" CR from start-of-cycle conditions
474
+ 2. Predict "new" CR from end-of-cycle conditions
475
+ 3. Change = new_prediction - old_prediction
476
+ 4. Apply change to actual CR
477
+
478
+ This prevents rapid crown ratio collapse that occurs when
479
+ replacing CR with predicted values directly.
480
+
481
+ Args:
482
+ rank: Tree's rank in diameter distribution (0-1)
483
+ relsdi: Relative stand density index (0-12)
484
+ competition_factor: Competition factor (0-1)
485
+ time_step: Growth cycle length in years (default 5)
486
+ """
487
+ from .crown_ratio import create_crown_ratio_model
488
+
489
+ # Create crown ratio model for this species
490
+ cr_model = create_crown_ratio_model(self.species)
491
+
492
+ # Calculate CCF from competition factor (rough approximation)
493
+ ccf = 100.0 + 100.0 * competition_factor
494
+
495
+ try:
496
+ # Predict crown ratio for current conditions (end of growth cycle)
497
+ predicted_cr = cr_model.predict_individual_crown_ratio(rank, relsdi, ccf)
498
+
499
+ # FVS-style change calculation:
500
+ # The change is bounded to prevent dramatic swings
501
+ # Maximum change is typically 5% per 5-year cycle (1% per year)
502
+ # Scale by time_step to handle different cycle lengths
503
+ max_change_per_cycle = 0.05 * (time_step / 5.0)
504
+
505
+ # Calculate change from current CR toward predicted CR
506
+ change = predicted_cr - self.crown_ratio
507
+
508
+ # Bound the change
509
+ bounded_change = max(-max_change_per_cycle,
510
+ min(max_change_per_cycle, change))
511
+
512
+ # Apply change to current crown ratio
513
+ new_cr = self.crown_ratio + bounded_change
514
+
515
+ # Apply age-related reduction (small gradual decrease with age)
516
+ # Scale by time_step to maintain consistent rate regardless of cycle length
517
+ cr_params = self.growth_params.get('crown_ratio', {})
518
+ age_reduction_rate = cr_params.get('age_reduction', {}).get('rate', 0.001)
519
+ age_reduction = age_reduction_rate * (time_step / 5.0) # Scale per cycle
520
+ new_cr = new_cr * (1.0 - age_reduction)
521
+
522
+ # Ensure reasonable bounds
523
+ self.crown_ratio = max(0.15, min(0.95, new_cr))
524
+ except Exception:
525
+ # Fallback to simpler update if crown ratio calculation fails
526
+ # Use gradual reduction rather than replacement
527
+ # Scale by time_step (2% per 5-year cycle = 0.4% per year)
528
+ reduction = 0.02 * (time_step / 5.0)
529
+ self.crown_ratio = max(0.15, min(0.95,
530
+ self.crown_ratio * (1.0 - reduction)))
531
+
532
+ def _update_dbh_from_height(self):
533
+ """Update DBH based on height using height-diameter model."""
534
+ from .height_diameter import create_height_diameter_model
535
+
536
+ # Create height-diameter model for this species
537
+ hd_model = create_height_diameter_model(self.species)
538
+
539
+ # Store original DBH to ensure we don't decrease it
540
+ original_dbh = self.dbh
541
+
542
+ if self.height <= 4.5:
543
+ dbw = hd_model.hd_params['curtis_arney']['dbw']
544
+ self.dbh = max(original_dbh, dbw) # Set to minimum DBH, but never decrease
545
+ else:
546
+ # Solve for DBH given target height
547
+ dbh = hd_model.solve_dbh_from_height(
548
+ target_height=self.height,
549
+ initial_dbh=self.dbh
550
+ )
551
+
552
+ # Ensure DBH never decreases
553
+ self.dbh = max(original_dbh, dbh)
554
+
555
+ def _update_height_from_dbh(self):
556
+ """Update height based on DBH using height-diameter model."""
557
+ from .height_diameter import create_height_diameter_model
558
+
559
+ # Create height-diameter model for this species
560
+ hd_model = create_height_diameter_model(self.species)
561
+
562
+ # Use the default model specified in configuration
563
+ self.height = hd_model.predict_height(self.dbh)
564
+
565
+ def _update_height_large_tree(
566
+ self,
567
+ site_index: float,
568
+ ba: float = 25.0,
569
+ pbal: float = 0.0,
570
+ slope: float = 0.0,
571
+ aspect: float = 0.0,
572
+ relht: float = 1.0,
573
+ time_step: int = 5,
574
+ competition_factor: float = 0.0
575
+ ):
576
+ """Update height using FVS large-tree height growth model (Section 4.7.2).
577
+
578
+ Delegates to the large_tree_height_growth module which implements the
579
+ FVS Southern variant equations:
580
+ HTG = POTHTG * (0.25 * HGMDCR + 0.75 * HGMDRH)
581
+
582
+ Where:
583
+ - POTHTG = potential height growth from site index curve
584
+ - HGMDCR = crown ratio modifier (Hoerl's Special Function)
585
+ - HGMDRH = relative height modifier (shade tolerance dependent)
586
+
587
+ Args:
588
+ site_index: Site index (base age 25) in feet
589
+ ba: Stand basal area (sq ft/acre)
590
+ pbal: Plot basal area in larger trees (sq ft/acre)
591
+ slope: Ground slope as tangent (rise/run)
592
+ aspect: Aspect in radians
593
+ relht: Relative height (tree height / top height)
594
+ time_step: Number of years to grow (default: 5)
595
+ competition_factor: Competition factor (0-1), higher = more competition
596
+ """
597
+ from .large_tree_height_growth import calculate_large_tree_height_growth
598
+
599
+ # Calculate height growth using the module
600
+ htg = calculate_large_tree_height_growth(
601
+ species_code=self.species,
602
+ dbh=self.dbh,
603
+ crown_ratio=self.crown_ratio,
604
+ relative_height=relht,
605
+ site_index=site_index,
606
+ basal_area=ba,
607
+ pbal=pbal,
608
+ slope=slope,
609
+ aspect=aspect,
610
+ tree_age=self.age,
611
+ tree_height=self.height
612
+ )
613
+
614
+ # Scale for time step (module returns 5-year growth)
615
+ htg = htg * (time_step / 5.0)
616
+
617
+ # Apply competition modifier (consistent with previous implementation)
618
+ competition_effects = self.growth_params.get('competition_effects') or {}
619
+ large_tree_comp = competition_effects.get('large_tree_competition') or {}
620
+ max_reduction = large_tree_comp.get('max_reduction', 0.15)
621
+ competition_modifier = 1.0 - (max_reduction * competition_factor)
622
+ htg = htg * competition_modifier
623
+
624
+ # Update height with bounds checking
625
+ self.height = max(4.5, self.height + htg)
626
+
627
+ def get_volume(self, volume_type: str = 'total_cubic') -> float:
628
+ """Calculate tree volume using USFS Volume Estimator Library.
629
+
630
+ Args:
631
+ volume_type: Type of volume to return. Options:
632
+ - 'total_cubic': Total cubic volume (default)
633
+ - 'merchantable_cubic': Merchantable cubic volume
634
+ - 'board_foot': Board foot volume
635
+ - 'green_weight': Green weight in pounds
636
+ - 'dry_weight': Dry weight in pounds
637
+ - 'biomass_main_stem': Main stem biomass
638
+
639
+ Returns:
640
+ Volume in specified units
641
+ """
642
+ from .volume_library import calculate_tree_volume
643
+
644
+ # Calculate volume using NVEL if available, fallback otherwise
645
+ result = calculate_tree_volume(
646
+ dbh=self.dbh,
647
+ height=self.height,
648
+ species_code=self.species
649
+ )
650
+
651
+ # Return requested volume type
652
+ volume_mapping = {
653
+ 'total_cubic': result.total_cubic_volume,
654
+ 'gross_cubic': result.gross_cubic_volume,
655
+ 'net_cubic': result.net_cubic_volume,
656
+ 'merchantable_cubic': result.merchantable_cubic_volume,
657
+ 'board_foot': result.board_foot_volume,
658
+ 'cord': result.cord_volume,
659
+ 'green_weight': result.green_weight,
660
+ 'dry_weight': result.dry_weight,
661
+ 'sawlog_cubic': result.sawlog_cubic_volume,
662
+ 'sawlog_board_foot': result.sawlog_board_foot,
663
+ 'biomass_main_stem': result.biomass_main_stem,
664
+ 'biomass_live_branches': result.biomass_live_branches,
665
+ 'biomass_foliage': result.biomass_foliage
666
+ }
667
+
668
+ return volume_mapping.get(volume_type, result.total_cubic_volume)
669
+
670
+ def get_volume_detailed(self) -> dict:
671
+ """Get detailed volume breakdown for the tree.
672
+
673
+ Returns:
674
+ Dictionary with all volume types and biomass estimates
675
+ """
676
+ from .volume_library import calculate_tree_volume
677
+
678
+ result = calculate_tree_volume(
679
+ dbh=self.dbh,
680
+ height=self.height,
681
+ species_code=self.species
682
+ )
683
+
684
+ return result.to_dict()
685
+
686
+ def to_tree_record(self, tree_id: int = 0, year: int = 0,
687
+ ba_percentile: float = 0.0, pbal: float = 0.0,
688
+ prev_dbh: Optional[float] = None,
689
+ prev_height: Optional[float] = None) -> Dict[str, Any]:
690
+ """Convert tree to FVS-compatible tree record format.
691
+
692
+ Creates a dictionary matching the FVS_TreeList database table schema
693
+ for compatibility with FVS output processing tools.
694
+
695
+ Args:
696
+ tree_id: Unique tree identifier within stand
697
+ year: Stand age/simulation year
698
+ ba_percentile: Basal area percentile (rank) 0-100
699
+ pbal: Point basal area in larger trees (sq ft/acre)
700
+ prev_dbh: Previous period DBH (for growth calculation)
701
+ prev_height: Previous period height (for growth calculation)
702
+
703
+ Returns:
704
+ Dictionary with FVS_TreeList compatible columns:
705
+ - TreeId: Tree identifier
706
+ - Species: Species code
707
+ - Year: Simulation year
708
+ - TPA: Trees per acre (expansion factor)
709
+ - DBH: Diameter at breast height (inches)
710
+ - DG: Diameter growth since last period (inches)
711
+ - Ht: Total height (feet)
712
+ - HtG: Height growth since last period (feet)
713
+ - PctCr: Crown ratio as percent (0-100)
714
+ - CrWidth: Crown width (feet)
715
+ - Age: Tree age (years)
716
+ - BAPctile: Basal area percentile
717
+ - PtBAL: Point basal area larger
718
+ - TcuFt: Total cubic foot volume
719
+ - McuFt: Merchantable cubic foot volume
720
+ - BdFt: Board foot volume (Doyle)
721
+ """
722
+ from .crown_width import calculate_open_crown_width
723
+
724
+ # Calculate growth since last period
725
+ dg = self.dbh - prev_dbh if prev_dbh is not None else 0.0
726
+ htg = self.height - prev_height if prev_height is not None else 0.0
727
+
728
+ # Calculate crown width
729
+ try:
730
+ cr_width = calculate_open_crown_width(self.species, self.dbh)
731
+ except Exception:
732
+ cr_width = self.dbh * 1.5 # Fallback estimate
733
+
734
+ # Get volumes
735
+ total_cubic = self.get_volume('total_cubic')
736
+ merch_cubic = self.get_volume('merchantable_cubic')
737
+ board_feet = self.get_volume('board_foot')
738
+
739
+ return {
740
+ 'TreeId': tree_id,
741
+ 'Species': self.species,
742
+ 'Year': year,
743
+ 'TPA': 1.0, # Each tree object represents 1 tree/acre
744
+ 'DBH': round(self.dbh, 2),
745
+ 'DG': round(dg, 3),
746
+ 'Ht': round(self.height, 1),
747
+ 'HtG': round(htg, 2),
748
+ 'PctCr': round(self.crown_ratio * 100, 1),
749
+ 'CrWidth': round(cr_width, 1),
750
+ 'Age': self.age,
751
+ 'BAPctile': round(ba_percentile, 1),
752
+ 'PtBAL': round(pbal, 2),
753
+ 'TcuFt': round(total_cubic, 2),
754
+ 'McuFt': round(merch_cubic, 2),
755
+ 'BdFt': round(board_feet, 1)
756
+ }