@@ -197,27 +197,42 @@ def _grow_small_tree(self, site_index, competition_factor, time_step=5):
197197
198198 # Chapman-Richards predicts cumulative height at age t
199199 # Height(t) = c1 * SI^c2 * (1 - exp(c3 * t))^(c4 * SI^c5)
200-
200+ #
201+ # The NC-128 coefficients may have been calibrated with a different site index
202+ # base age. To ensure Height(base_age=25) = SI, we compute a scaling factor.
203+
201204 # Current age (before growth) - age was already incremented in grow()
202205 current_age = self .age - time_step
203206 future_age = self .age # This is current_age + time_step
204-
205- # Calculate height at current age
207+
208+ # Site index base age for southern pines
209+ base_age = 25
210+
211+ def _raw_chapman_richards (age ):
212+ """Calculate unscaled Chapman-Richards height."""
213+ if age <= 0 :
214+ return 1.0
215+ return (
216+ p ['c1' ] * (site_index ** p ['c2' ]) *
217+ (1.0 - math .exp (p ['c3' ] * age )) **
218+ (p ['c4' ] * (site_index ** p ['c5' ]))
219+ )
220+
221+ # Calculate scaling factor to ensure Height(base_age) = SI
222+ # This corrects for NC-128 coefficients that may use different base ages
223+ raw_height_at_base = _raw_chapman_richards (base_age )
224+ if raw_height_at_base > 0 :
225+ scale_factor = site_index / raw_height_at_base
226+ else :
227+ scale_factor = 1.0
228+
229+ # Calculate scaled heights
206230 if current_age <= 0 :
207231 current_height = 1.0 # Initial height at planting
208232 else :
209- current_height = (
210- p ['c1' ] * (site_index ** p ['c2' ]) *
211- (1.0 - math .exp (p ['c3' ] * current_age )) **
212- (p ['c4' ] * (site_index ** p ['c5' ]))
213- )
214-
215- # Calculate height at future age
216- future_height = (
217- p ['c1' ] * (site_index ** p ['c2' ]) *
218- (1.0 - math .exp (p ['c3' ] * future_age )) **
219- (p ['c4' ] * (site_index ** p ['c5' ]))
220- )
233+ current_height = _raw_chapman_richards (current_age ) * scale_factor
234+
235+ future_height = _raw_chapman_richards (future_age ) * scale_factor
221236
222237 # Height growth is the difference
223238 height_growth = future_height - current_height
@@ -355,9 +370,10 @@ def _grow_large_tree(self, site_index, competition_factor, ba, pbal, slope, aspe
355370
356371 # Update DBH: D_new = sqrt(D_old^2 + DDS)
357372 self .dbh = math .sqrt (self .dbh ** 2 + dds )
358-
359- # Update height using height-diameter relationship
360- self ._update_height_from_dbh ()
373+
374+ # Update height using FVS large-tree height growth model (Section 4.7.2)
375+ # HTG = POTHTG * (0.25 * HGMDCR + 0.75 * HGMDRH)
376+ self ._update_height_large_tree (site_index , time_step , competition_factor )
361377
362378 def _update_crown_ratio_weibull (self , rank , relsdi , competition_factor ):
363379 """Update crown ratio using Weibull-based model.
@@ -425,12 +441,180 @@ def _update_dbh_from_height(self):
425441 def _update_height_from_dbh (self ):
426442 """Update height based on DBH using height-diameter model."""
427443 from .height_diameter import create_height_diameter_model
428-
444+
429445 # Create height-diameter model for this species
430446 hd_model = create_height_diameter_model (self .species )
431-
447+
432448 # Use the default model specified in configuration
433449 self .height = hd_model .predict_height (self .dbh )
450+
451+ def _update_height_large_tree (self , site_index : float , time_step : int = 5 ,
452+ competition_factor : float = 0.0 ):
453+ """Update height using FVS large-tree height growth model (Section 4.7.2).
454+
455+ Implements the FVS Southern variant equations:
456+ HTG = POTHTG * (0.25 * HGMDCR + 0.75 * HGMDRH)
457+
458+ Where:
459+ - POTHTG = potential height growth from site index curve
460+ - HGMDCR = crown ratio modifier (Hoerl's Special Function)
461+ - HGMDRH = relative height modifier (shade tolerance dependent)
462+
463+ A competition modifier is also applied for consistency with stand-level
464+ competition effects on height growth.
465+
466+ Args:
467+ site_index: Site index (base age 25) in feet
468+ time_step: Number of years to grow (default: 5)
469+ competition_factor: Competition factor (0-1), higher = more competition
470+ """
471+ # Calculate potential height growth (POTHTG) using same Chapman-Richards as small-tree
472+ small_tree_params = self .growth_params .get ('small_tree_growth' , {})
473+ if self .species in small_tree_params :
474+ p = small_tree_params [self .species ]
475+ else :
476+ p = small_tree_params .get ('default' , {
477+ 'c1' : 1.1421 ,
478+ 'c2' : 1.0042 ,
479+ 'c3' : - 0.0374 ,
480+ 'c4' : 0.7632 ,
481+ 'c5' : 0.0358
482+ })
483+
484+ # Current age (before growth) - age was already incremented in grow()
485+ current_age = self .age - time_step
486+ future_age = self .age
487+ base_age = 25
488+
489+ def _raw_chapman_richards (age ):
490+ """Calculate unscaled Chapman-Richards height."""
491+ if age <= 0 :
492+ return 1.0
493+ return (
494+ p ['c1' ] * (site_index ** p ['c2' ]) *
495+ (1.0 - math .exp (p ['c3' ] * age )) **
496+ (p ['c4' ] * (site_index ** p ['c5' ]))
497+ )
498+
499+ # Calculate scaling factor to ensure Height(base_age) = SI
500+ raw_height_at_base = _raw_chapman_richards (base_age )
501+ scale_factor = site_index / raw_height_at_base if raw_height_at_base > 0 else 1.0
502+
503+ # Calculate potential height growth
504+ if current_age <= 0 :
505+ current_potential_height = 1.0
506+ else :
507+ current_potential_height = _raw_chapman_richards (current_age ) * scale_factor
508+
509+ future_potential_height = _raw_chapman_richards (future_age ) * scale_factor
510+ pothtg = future_potential_height - current_potential_height
511+
512+ # Calculate crown ratio modifier (HGMDCR) - Equation 4.7.2.2
513+ # HGMDCR = 100 * CR^3.0 * exp(-5.0*CR), bounded < 1.0
514+ cr = self .crown_ratio # Crown ratio as proportion (0-1)
515+ hgmdcr = 100.0 * (cr ** 3.0 ) * math .exp (- 5.0 * cr )
516+ hgmdcr = min (1.0 , hgmdcr ) # Bounded < 1.0
517+
518+ # Calculate relative height modifier (HGMDRH) - Equations 4.7.2.3-4.7.2.7
519+ # Get shade tolerance coefficients for species
520+ shade_coeffs = self ._get_shade_tolerance_coefficients ()
521+
522+ # RELHT = tree height relative to top 40 trees
523+ # Use the potential height at current age (from site index curve) as proxy for top height
524+ # This is more accurate than using site index directly, which represents height at base age
525+ potential_height_at_current_age = current_potential_height
526+ if potential_height_at_current_age > 0 :
527+ relht = min (1.5 , self .height / potential_height_at_current_age )
528+ else :
529+ relht = 1.0
530+
531+ # Calculate HGMDRH using Generalized Chapman-Richards function
532+ rhr = shade_coeffs ['RHR' ]
533+ rhyxs = shade_coeffs ['RHYXS' ]
534+ rhm = shade_coeffs ['RHM' ]
535+ rhb = shade_coeffs ['RHB' ]
536+ rhxs = shade_coeffs ['RHXS' ]
537+ rhk = shade_coeffs ['RHK' ]
538+
539+ # Avoid division by zero and numerical issues
540+ if relht <= rhxs or rhyxs <= 0 or rhm == 1.0 or rhb == 1.0 :
541+ hgmdrh = 1.0 # Default to full modifier for dominant trees
542+ else :
543+ try :
544+ # FCTRKX = ((RHK / RHYXS)^(RHM - 1)) - 1
545+ fctrkx = ((rhk / rhyxs ) ** (rhm - 1.0 )) - 1.0
546+ # FCTRRB = (-1.0 * RHR) / (1 - RHB)
547+ fctrrb = (- 1.0 * rhr ) / (1.0 - rhb )
548+ # FCTRXB = RELHT^(1 - RHB) - RHXS^(1 - RHB)
549+ fctrxb = (relht ** (1.0 - rhb )) - (rhxs ** (1.0 - rhb ))
550+ # FCTRM = 1 / (1 - RHM)
551+ fctrm = 1.0 / (1.0 - rhm )
552+ # HGMDRH = RHK * (1 + FCTRKX * exp(FCTRRB * FCTRXB))^FCTRM
553+ hgmdrh = rhk * ((1.0 + fctrkx * math .exp (fctrrb * fctrxb )) ** fctrm )
554+ hgmdrh = max (0.0 , min (1.0 , hgmdrh )) # Bound to 0-1
555+ except (ValueError , OverflowError , ZeroDivisionError ):
556+ hgmdrh = 1.0
557+
558+ # Calculate height growth: HTG = POTHTG * (0.25 * HGMDCR + 0.75 * HGMDRH)
559+ htg = pothtg * (0.25 * hgmdcr + 0.75 * hgmdrh )
560+
561+ # Apply competition modifier (similar to small-tree model)
562+ # Large trees are affected by competition through crown competition
563+ competition_effects = self .growth_params .get ('competition_effects' ) or {}
564+ large_tree_comp = competition_effects .get ('large_tree_competition' ) or {}
565+ max_reduction = large_tree_comp .get ('max_reduction' , 0.15 )
566+ competition_modifier = 1.0 - (max_reduction * competition_factor )
567+ htg = htg * competition_modifier
568+
569+ # Update height with bounds checking
570+ self .height = max (4.5 , self .height + htg )
571+
572+ def _get_shade_tolerance_coefficients (self ) -> dict :
573+ """Get shade tolerance coefficients for relative height modifier.
574+
575+ Returns coefficients from Table 4.7.2.1 based on species shade tolerance.
576+ """
577+ # Species to shade tolerance mapping (from Table 4.7.2.2)
578+ species_tolerance = {
579+ # Southern pines (all intolerant)
580+ 'LP' : 'Intolerant' , 'SP' : 'Intolerant' , 'SA' : 'Intolerant' , 'LL' : 'Intolerant' ,
581+ 'VP' : 'Intolerant' , 'PP' : 'Intolerant' , 'PD' : 'Intolerant' , 'TM' : 'Intolerant' ,
582+ # Tolerant species
583+ 'PI' : 'Tolerant' , 'BE' : 'Tolerant' , 'RM' : 'Tolerant' , 'SV' : 'Tolerant' ,
584+ 'BU' : 'Tolerant' , 'RD' : 'Tolerant' , 'AS' : 'Tolerant' , 'GA' : 'Tolerant' ,
585+ 'LB' : 'Tolerant' , 'HA' : 'Tolerant' , 'MG' : 'Tolerant' , 'MS' : 'Tolerant' ,
586+ 'ML' : 'Tolerant' , 'MB' : 'Tolerant' , 'BG' : 'Tolerant' , 'HH' : 'Tolerant' ,
587+ 'SD' : 'Tolerant' , 'RA' : 'Tolerant' , 'BD' : 'Tolerant' , 'WE' : 'Tolerant' ,
588+ 'RL' : 'Tolerant' , 'FM' : 'Tolerant' , 'LK' : 'Tolerant' ,
589+ # Very tolerant species
590+ 'FR' : 'Very Tolerant' , 'SR' : 'Very Tolerant' , 'HM' : 'Very Tolerant' ,
591+ 'SM' : 'Very Tolerant' , 'AH' : 'Very Tolerant' , 'DW' : 'Very Tolerant' ,
592+ 'PS' : 'Very Tolerant' , 'AB' : 'Very Tolerant' , 'HY' : 'Very Tolerant' ,
593+ # Intermediate species
594+ 'WP' : 'Intermediate' , 'BY' : 'Intermediate' , 'PC' : 'Intermediate' ,
595+ 'HI' : 'Intermediate' , 'HB' : 'Intermediate' , 'CT' : 'Intermediate' ,
596+ 'MV' : 'Intermediate' , 'SY' : 'Intermediate' , 'WO' : 'Intermediate' ,
597+ 'SK' : 'Intermediate' , 'OV' : 'Intermediate' , 'CO' : 'Intermediate' ,
598+ 'RO' : 'Intermediate' , 'BO' : 'Intermediate' , 'LO' : 'Intermediate' ,
599+ 'EL' : 'Intermediate' , 'AE' : 'Intermediate' , 'OS' : 'Intermediate' ,
600+ 'OH' : 'Intermediate' , 'OT' : 'Intermediate' ,
601+ # Very intolerant species
602+ 'CW' : 'Very Intolerant' , 'BT' : 'Very Intolerant' , 'SO' : 'Very Intolerant' ,
603+ 'BK' : 'Very Intolerant' , 'WI' : 'Very Intolerant' ,
604+ }
605+
606+ # Shade tolerance coefficients (from Table 4.7.2.1)
607+ tolerance_coeffs = {
608+ 'Very Tolerant' : {'RHR' : 20 , 'RHYXS' : 0.20 , 'RHM' : 1.1 , 'RHB' : - 1.10 , 'RHXS' : 0 , 'RHK' : 1 },
609+ 'Tolerant' : {'RHR' : 16 , 'RHYXS' : 0.15 , 'RHM' : 1.1 , 'RHB' : - 1.20 , 'RHXS' : 0 , 'RHK' : 1 },
610+ 'Intermediate' : {'RHR' : 15 , 'RHYXS' : 0.10 , 'RHM' : 1.1 , 'RHB' : - 1.45 , 'RHXS' : 0 , 'RHK' : 1 },
611+ 'Intolerant' : {'RHR' : 13 , 'RHYXS' : 0.05 , 'RHM' : 1.1 , 'RHB' : - 1.60 , 'RHXS' : 0 , 'RHK' : 1 },
612+ 'Very Intolerant' : {'RHR' : 12 , 'RHYXS' : 0.01 , 'RHM' : 1.1 , 'RHB' : - 1.60 , 'RHXS' : 0 , 'RHK' : 1 },
613+ }
614+
615+ # Get tolerance for this species, default to Intermediate
616+ tolerance = species_tolerance .get (self .species , 'Intermediate' )
617+ return tolerance_coeffs .get (tolerance , tolerance_coeffs ['Intermediate' ])
434618
435619 def get_volume (self , volume_type : str = 'total_cubic' ) -> float :
436620 """Calculate tree volume using USFS Volume Estimator Library.
0 commit comments