def test_soil_water_potential_drops_faster_for_small_soil_reservoirs_than_bigger_ones(): for soil_class in hydraulic.def_param_soil().keys(): psi_soil_small = hydraulic.soil_water_potential(psi_soil_init=0., water_withdrawal=1., soil_class=soil_class, soil_total_volume=1., psi_min=-3.) psi_soil_big = hydraulic.soil_water_potential(psi_soil_init=0., water_withdrawal=1., soil_class=soil_class, soil_total_volume=2., psi_min=-3.) assert psi_soil_small < psi_soil_big
def test_soil_water_potential_decreases_as_water_withdrawal_increases(): for soil_class in hydraulic.def_param_soil().keys(): psi_soil = [ hydraulic.soil_water_potential(psi_soil_init=0., water_withdrawal=w, soil_class=soil_class, soil_total_volume=1, psi_min=-3.) for w in arange(0, 3, 0.1) ] assert all(x >= y for x, y in zip(psi_soil, psi_soil[1:]))
def solve_interactions(g, meteo, psi_soil, t_soil, t_sky_eff, vid_collar, vid_base, length_conv, time_conv, rhyzo_total_volume, params, form_factors, simplified_form_factors): """Computes gas-exchange, energy and hydraulic structure of plant's shoot jointly. Args: g: MTG object meteo (DataFrame): forcing meteorological variables psi_soil (float): [MPa] soil (root zone) water potential t_soil (float): [degreeC] soil surface temperature t_sky_eff (float): [degreeC] effective sky temperature vid_collar (int): id of the collar node of the mtg vid_base (int): id of the basal node of the mtg length_conv (float): [-] conversion factor from the `unit_scene_length` to 1 m time_conv (float): [-] conversion factor from meteo data time step to seconds rhyzo_total_volume (float): [m3] volume of the soil occupied with roots params (params): [-] :class:`hydroshoot.params.Params()` object """ unit_scene_length = params.simulation.unit_scene_length hydraulic_structure = params.simulation.hydraulic_structure negligible_shoot_resistance = params.simulation.negligible_shoot_resistance soil_water_deficit = params.simulation.soil_water_deficit energy_budget = params.simulation.energy_budget par_photo = params.exchange.par_photo par_photo_n = params.exchange.par_photo_N par_gs = params.exchange.par_gs rbt = params.exchange.rbt mass_conv = params.hydraulic.MassConv xylem_k_max = params.hydraulic.Kx_dict xylem_k_cavitation = params.hydraulic.par_K_vul psi_min = params.hydraulic.psi_min solo = params.energy.solo irradiance_type2 = params.irradiance.E_type2 leaf_lbl_prefix = params.mtg_api.leaf_lbl_prefix collar_label = params.mtg_api.collar_label soil_class = params.soil.soil_class dist_roots, rad_roots = params.soil.roots temp_step = params.numerical_resolution.t_step psi_step = params.numerical_resolution.psi_step max_iter = params.numerical_resolution.max_iter psi_error_threshold = params.numerical_resolution.psi_error_threshold temp_error_threshold = params.numerical_resolution.t_error_crit modelx, psi_critx, slopex = [ xylem_k_cavitation[ikey] for ikey in ('model', 'fifty_cent', 'sig_slope') ] if hydraulic_structure: assert ( par_gs['model'] != 'vpd' ), "Stomatal conductance model should be linked to the hydraulic strucutre" else: par_gs['model'] = 'vpd' negligible_shoot_resistance = True print "par_gs: 'model' is forced to 'vpd'" print "negligible_shoot_resistance is forced to True." # Initialize all xylem potential values to soil water potential for vtx_id in traversal.pre_order2(g, vid_base): g.node(vtx_id).psi_head = psi_soil # Temperature loop +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ t_error_trace = [] it_step = temp_step # Initialize leaf temperature to air temperature g.properties()['Tlc'] = energy.leaf_temperature_as_air_temperature( g, meteo, leaf_lbl_prefix) for it in range(max_iter): t_prev = deepcopy(g.property('Tlc')) # Hydraulic loop +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ if hydraulic_structure: psi_error_trace = [] ipsi_step = psi_step for ipsi in range(max_iter): psi_prev = deepcopy(g.property('psi_head')) # Compute gas-exchange fluxes. Leaf T and Psi are from prev calc loop exchange.gas_exchange_rates(g, par_photo, par_photo_n, par_gs, meteo, irradiance_type2, leaf_lbl_prefix, rbt) # Compute sap flow and hydraulic properties hydraulic.hydraulic_prop(g, mass_conv=mass_conv, length_conv=length_conv, a=xylem_k_max['a'], b=xylem_k_max['b'], min_kmax=xylem_k_max['min_kmax']) # Update soil water status psi_collar = hydraulic.soil_water_potential( psi_soil, g.node(vid_collar).Flux * time_conv, soil_class, rhyzo_total_volume, psi_min) if soil_water_deficit: psi_collar = max(-1.3, psi_collar) else: psi_collar = max(-0.7, psi_collar) for vid in g.Ancestors(vid_collar): g.node(vid).psi_head = psi_collar # Compute xylem water potential n_iter_psi = hydraulic.xylem_water_potential( g, psi_soil=psi_collar, model=modelx, psi_min=psi_min, psi_error_crit=psi_error_threshold, max_iter=max_iter, length_conv=length_conv, fifty_cent=psi_critx, sig_slope=slopex, dist_roots=dist_roots, rad_roots=rad_roots, negligible_shoot_resistance=negligible_shoot_resistance, start_vid=vid_collar, stop_vid=None, psi_step=psi_step) psi_new = g.property('psi_head') # Evaluate xylem conversion criterion psi_error_dict = {} for vtx_id in g.property('psi_head').keys(): psi_error_dict[vtx_id] = abs(psi_prev[vtx_id] - psi_new[vtx_id]) psi_error = max(psi_error_dict.values()) psi_error_trace.append(psi_error) print 'psi_error = ', round( psi_error, 3 ), ':: Nb_iter = %d' % n_iter_psi, 'ipsi_step = %f' % ipsi_step # Manage temperature step to ensure convergence if psi_error < psi_error_threshold: break else: try: if psi_error_trace[-1] >= psi_error_trace[ -2] - psi_error_threshold: ipsi_step = max(0.05, ipsi_step / 2.) except IndexError: pass psi_new_dict = {} for vtx_id in psi_new.keys(): psix = psi_prev[vtx_id] + ipsi_step * ( psi_new[vtx_id] - psi_prev[vtx_id]) psi_new_dict[vtx_id] = psix g.properties()['psi_head'] = psi_new_dict else: # Compute gas-exchange fluxes. Leaf T and Psi are from prev calc loop exchange.gas_exchange_rates(g, par_photo, par_photo_n, par_gs, meteo, irradiance_type2, leaf_lbl_prefix, rbt) # Compute sap flow and hydraulic properties hydraulic.hydraulic_prop(g, mass_conv=mass_conv, length_conv=length_conv, a=xylem_k_max['a'], b=xylem_k_max['b'], min_kmax=xylem_k_max['min_kmax']) # End Hydraulic loop +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Compute leaf temperature if energy_budget: leaves_length = energy.get_leaves_length( g, leaf_lbl_prefix=leaf_lbl_prefix, unit_scene_length=unit_scene_length) leaf_wind_speed = energy.leaf_wind_as_air_wind( g, meteo, leaf_lbl_prefix) gbH = energy.heat_boundary_layer_conductance( leaves_length, leaf_wind_speed) t_init = g.property('Tlc') ev = g.property('E') ei = g.property('Ei') g.properties()['Tlc'], t_iter = energy.leaf_temperature( g, meteo, t_soil, t_sky_eff, t_init=t_init, form_factors=form_factors, gbh=gbH, ev=ev, ei=ei, solo=solo, ff_type=simplified_form_factors, leaf_lbl_prefix=leaf_lbl_prefix, max_iter=max_iter, t_error_crit=temp_error_threshold, t_step=temp_step) # t_iter_list.append(t_iter) t_new = deepcopy(g.property('Tlc')) # Evaluation of leaf temperature conversion creterion error_dict = { vtx: abs(t_prev[vtx] - t_new[vtx]) for vtx in g.property('Tlc').keys() } t_error = round(max(error_dict.values()), 3) print 't_error = ', t_error, 'counter =', it, 't_iter = ', t_iter, 'it_step = ', it_step t_error_trace.append(t_error) # Manage temperature step to ensure convergence if t_error < temp_error_threshold: break else: assert (it <= max_iter ), 'The energy budget solution did not converge.' try: if t_error_trace[ -1] >= t_error_trace[-2] - temp_error_threshold: it_step = max(0.001, it_step / 2.) except IndexError: pass t_new_dict = {} for vtx_id in t_new.keys(): tx = t_prev[vtx_id] + it_step * (t_new[vtx_id] - t_prev[vtx_id]) t_new_dict[vtx_id] = tx g.properties()['Tlc'] = t_new_dict
def run(g, wd, scene=None, write_result=True, **kwargs): """ Calculates leaf gas and energy exchange in addition to the hydraulic structure of an individual plant. :Parameters: - **g**: a multiscale tree graph object - **wd**: string, working directory - **scene**: PlantGl scene - **kwargs** can include: - **psi_soil**: [MPa] predawn soil water potential - **gdd_since_budbreak**: [°Cd] growing degree-day since bubreak - **sun2scene**: PlantGl scene, when prodivided, a sun object (sphere) is added to it - **soil_size**: [cm] length of squared mesh size """ print('++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++') print('+ Project: ', wd) print('++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++') time_on = datetime.now() # Read user parameters params_path = wd + 'params.json' params = Params(params_path) output_index = params.simulation.output_index # ============================================================================== # Initialisation # ============================================================================== # Climate data meteo_path = wd + params.simulation.meteo meteo_tab = read_csv(meteo_path, sep=';', decimal='.', header=0) meteo_tab.time = DatetimeIndex(meteo_tab.time) meteo_tab = meteo_tab.set_index(meteo_tab.time) # Adding missing data if 'Ca' not in meteo_tab.columns: meteo_tab['Ca'] = [400.] * len(meteo_tab) # ppm [CO2] if 'Pa' not in meteo_tab.columns: meteo_tab['Pa'] = [101.3] * len(meteo_tab) # atmospheric pressure # Determination of the simulation period sdate = datetime.strptime(params.simulation.sdate, "%Y-%m-%d %H:%M:%S") edate = datetime.strptime(params.simulation.edate, "%Y-%m-%d %H:%M:%S") datet = date_range(sdate, edate, freq='H') meteo = meteo_tab.loc[datet, :] time_conv = {'D': 86.4e3, 'H': 3600., 'T': 60., 'S': 1.}[datet.freqstr] # Reading available pre-dawn soil water potential data if 'psi_soil' in kwargs: psi_pd = DataFrame([kwargs['psi_soil']] * len(meteo.time), index=meteo.time, columns=['psi']) else: assert (isfile(wd + 'psi_soil.input')), "The 'psi_soil.input' file is missing." psi_pd = read_csv(wd + 'psi_soil.input', sep=';', decimal='.').set_index('time') psi_pd.index = [datetime.strptime(s, "%Y-%m-%d") for s in psi_pd.index] # Unit length conversion (from scene unit to the standard [m]) unit) unit_scene_length = params.simulation.unit_scene_length length_conv = {'mm': 1.e-3, 'cm': 1.e-2, 'm': 1.}[unit_scene_length] # Determination of cumulative degree-days parameter t_base = params.phenology.t_base budbreak_date = datetime.strptime(params.phenology.emdate, "%Y-%m-%d %H:%M:%S") if 'gdd_since_budbreak' in kwargs: gdd_since_budbreak = kwargs['gdd_since_budbreak'] elif min(meteo_tab.index) <= budbreak_date: tdays = date_range(budbreak_date, sdate, freq='D') tmeteo = meteo_tab.loc[tdays, :].Tac.to_frame() tmeteo = tmeteo.set_index(DatetimeIndex(tmeteo.index).normalize()) df_min = tmeteo.groupby(tmeteo.index).aggregate(np.min).Tac df_max = tmeteo.groupby(tmeteo.index).aggregate(np.max).Tac # df_tt = merge(df_max, df_min, how='inner', left_index=True, right_index=True) # df_tt.columns = ('max', 'min') # df_tt['gdd'] = df_tt.apply(lambda x: 0.5 * (x['max'] + x['min']) - t_base) # gdd_since_budbreak = df_tt['gdd'].cumsum()[-1] df_tt = 0.5 * (df_min + df_max) - t_base gdd_since_budbreak = df_tt.cumsum()[-1] else: raise ValueError('Cumulative degree-days temperature is not provided.') print('GDD since budbreak = %d °Cd' % gdd_since_budbreak) # Determination of perennial structure arms (for grapevine) # arm_vid = {g.node(vid).label: g.node(vid).components()[0]._vid for vid in g.VtxList(Scale=2) if # g.node(vid).label.startswith('arm')} # Soil reservoir dimensions (inter row, intra row, depth) [m] soil_dimensions = params.soil.soil_dimensions soil_total_volume = soil_dimensions[0] * soil_dimensions[1] * soil_dimensions[2] rhyzo_coeff = params.soil.rhyzo_coeff rhyzo_total_volume = rhyzo_coeff * np.pi * min(soil_dimensions[:2]) ** 2 / 4. * soil_dimensions[2] # Counter clockwise angle between the default X-axis direction (South) and # the real direction of X-axis. scene_rotation = params.irradiance.scene_rotation # Sky and cloud temperature [degreeC] t_sky = params.energy.t_sky t_cloud = params.energy.t_cloud # Topological location latitude = params.simulation.latitude longitude = params.simulation.longitude elevation = params.simulation.elevation geo_location = (latitude, longitude, elevation) # Pattern ymax, xmax = map(lambda dim: dim / length_conv, soil_dimensions[:2]) pattern = ((-xmax / 2.0, -ymax / 2.0), (xmax / 2.0, ymax / 2.0)) # Label prefix of the collar internode vtx_label = params.mtg_api.collar_label # Label prefix of the leaves leaf_lbl_prefix = params.mtg_api.leaf_lbl_prefix # Label prefices of stem elements stem_lbl_prefix = params.mtg_api.stem_lbl_prefix E_type = params.irradiance.E_type tzone = params.simulation.tzone turtle_sectors = params.irradiance.turtle_sectors icosphere_level = params.irradiance.icosphere_level turtle_format = params.irradiance.turtle_format energy_budget = params.simulation.energy_budget print('Energy_budget: %s' % energy_budget) # Optical properties opt_prop = params.irradiance.opt_prop print('Hydraulic structure: %s' % params.simulation.hydraulic_structure) psi_min = params.hydraulic.psi_min # Parameters of leaf Nitrogen content-related models Na_dict = params.exchange.Na_dict # Computation of the form factor matrix form_factors = None if energy_budget: print('Computing form factors...') form_factors = energy.form_factors_simplified( g, pattern=pattern, infinite=True, leaf_lbl_prefix=leaf_lbl_prefix, turtle_sectors=turtle_sectors, icosphere_level=icosphere_level, unit_scene_length=unit_scene_length) # Soil class soil_class = params.soil.soil_class print('Soil class: %s' % soil_class) # Rhyzosphere concentric radii determination rhyzo_radii = params.soil.rhyzo_radii rhyzo_number = len(rhyzo_radii) # Add rhyzosphere elements to mtg rhyzo_solution = params.soil.rhyzo_solution print('rhyzo_solution: %s' % rhyzo_solution) if rhyzo_solution: if not any(item.startswith('rhyzo') for item in g.property('label').values()): vid_collar = architecture.mtg_base(g, vtx_label=vtx_label) vid_base = architecture.add_soil_components(g, rhyzo_number, rhyzo_radii, soil_dimensions, soil_class, vtx_label) else: vid_collar = g.node(g.root).vid_collar vid_base = g.node(g.root).vid_base radius_prev = 0. for ivid, vid in enumerate(g.Ancestors(vid_collar)[1:]): radius = rhyzo_radii[ivid] g.node(vid).Length = radius - radius_prev g.node(vid).depth = soil_dimensions[2] / length_conv # [m] g.node(vid).TopDiameter = radius * 2. g.node(vid).BotDiameter = radius * 2. g.node(vid).soil_class = soil_class radius_prev = radius else: # Identifying and attaching the base node of a single MTG vid_collar = architecture.mtg_base(g, vtx_label=vtx_label) vid_base = vid_collar g.node(g.root).vid_base = vid_base g.node(g.root).vid_collar = vid_collar # Initializing sapflow to 0 for vtx_id in traversal.pre_order2(g, vid_base): g.node(vtx_id).Flux = 0. # Addition of a soil element if 'Soil' not in g.properties()['label'].values(): if 'soil_size' in kwargs: if kwargs['soil_size'] > 0.: architecture.add_soil(g, kwargs['soil_size']) else: architecture.add_soil(g, 500.) # Suppression of undesired geometry for light and energy calculations geom_prop = g.properties()['geometry'] vidkeys = [] for vid in g.properties()['geometry']: n = g.node(vid) if not n.label.startswith(('L', 'other', 'soil')): vidkeys.append(vid) [geom_prop.pop(x) for x in vidkeys] g.properties()['geometry'] = geom_prop # Attaching optical properties to MTG elements g = irradiance.optical_prop(g, leaf_lbl_prefix=leaf_lbl_prefix, stem_lbl_prefix=stem_lbl_prefix, wave_band='SW', opt_prop=opt_prop) # Estimation of Nitroen surface-based content according to Prieto et al. (2012) # Estimation of intercepted irradiance over past 10 days: if not 'Na' in g.property_names(): print('Computing Nitrogen profile...') assert (sdate - min( meteo_tab.index)).days >= 10, 'Meteorological data do not cover 10 days prior to simulation date.' ppfd10_date = sdate + timedelta(days=-10) ppfd10t = date_range(ppfd10_date, sdate, freq='H') ppfd10_meteo = meteo_tab.loc[ppfd10t, :] caribu_source, RdRsH_ratio = irradiance.irradiance_distribution(ppfd10_meteo, geo_location, E_type, tzone, turtle_sectors, turtle_format, None, scene_rotation, None) # Compute irradiance interception and absorbtion g, caribu_scene = irradiance.hsCaribu(mtg=g, unit_scene_length=unit_scene_length, source=caribu_source, direct=False, infinite=True, nz=50, ds=0.5, pattern=pattern) g.properties()['Ei10'] = {vid: g.node(vid).Ei * time_conv / 10. / 1.e6 for vid in g.property('Ei').keys()} # Estimation of leaf surface-based nitrogen content: for vid in g.VtxList(Scale=3): if g.node(vid).label.startswith(leaf_lbl_prefix): g.node(vid).Na = exchange.leaf_Na(gdd_since_budbreak, g.node(vid).Ei10, Na_dict['aN'], Na_dict['bN'], Na_dict['aM'], Na_dict['bM']) # Define path to folder output_path = wd + 'output' + output_index + '/' # Save geometry in an external file # HSArc.mtg_save_geometry(scene, output_path) # ============================================================================== # Simulations # ============================================================================== sapflow = [] # sapEast = [] # sapWest = [] an_ls = [] rg_ls = [] psi_stem = {} Tlc_dict = {} Ei_dict = {} an_dict = {} gs_dict = {} # The time loop +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ for date in meteo.time: print("=" * 72) print('Date', date, '\n') # Select of meteo data imeteo = meteo[meteo.time == date] # Add a date index to g g.date = datetime.strftime(date, "%Y%m%d%H%M%S") # Read soil water potntial at midnight if 'psi_soil' in kwargs: psi_soil = kwargs['psi_soil'] else: if date.hour == 0: try: psi_soil_init = psi_pd.loc[date, :][0] psi_soil = psi_soil_init except KeyError: pass # Estimate soil water potntial evolution due to transpiration else: psi_soil = hydraulic.soil_water_potential(psi_soil, g.node(vid_collar).Flux * time_conv, soil_class, soil_total_volume, psi_min) if 'sun2scene' not in kwargs or not kwargs['sun2scene']: sun2scene = None elif kwargs['sun2scene']: sun2scene = display.visu(g, def_elmnt_color_dict=True, scene=Scene()) # Compute irradiance distribution over the scene caribu_source, RdRsH_ratio = irradiance.irradiance_distribution(imeteo, geo_location, E_type, tzone, turtle_sectors, turtle_format, sun2scene, scene_rotation, None) # Compute irradiance interception and absorbtion g, caribu_scene = irradiance.hsCaribu(mtg=g, unit_scene_length=unit_scene_length, source=caribu_source, direct=False, infinite=True, nz=50, ds=0.5, pattern=pattern) # g.properties()['Ei'] = {vid: 1.2 * g.node(vid).Ei for vid in g.property('Ei').keys()} # Trace intercepted irradiance on each time step rg_ls.append(sum([g.node(vid).Ei / (0.48 * 4.6) * surface(g.node(vid).geometry) * (length_conv ** 2) \ for vid in g.property('geometry') if g.node(vid).label.startswith('L')])) # Hack forcing of soil temperture (model of soil temperature under development) t_soil = energy.forced_soil_temperature(imeteo) # Climatic data for energy balance module # TODO: Change the t_sky_eff formula (cf. Gliah et al., 2011, Heat and Mass Transfer, DOI: 10.1007/s00231-011-0780-1) t_sky_eff = RdRsH_ratio * t_cloud + (1 - RdRsH_ratio) * t_sky solver.solve_interactions(g, imeteo, psi_soil, t_soil, t_sky_eff, vid_collar, vid_base, length_conv, time_conv, rhyzo_total_volume, params, form_factors) # Write mtg to an external file if scene is not None: architecture.mtg_save(g, scene, output_path) # Plot stuff.. sapflow.append(g.node(vid_collar).Flux) # sapEast.append(g.node(arm_vid['arm1']).Flux) # sapWest.append(g.node(arm_vid['arm2']).Flux) an_ls.append(g.node(vid_collar).FluxC) psi_stem[date] = deepcopy(g.property('psi_head')) Tlc_dict[date] = deepcopy(g.property('Tlc')) Ei_dict[date] = deepcopy(g.property('Eabs')) an_dict[date] = deepcopy(g.property('An')) gs_dict[date] = deepcopy(g.property('gs')) print('---------------------------') print('psi_soil', round(psi_soil, 4)) print('psi_collar', round(g.node(3).psi_head, 4)) print('psi_leaf', round(np.median([g.node(vid).psi_head for vid in g.property('gs').keys()]), 4)) print('') # print('Rdiff/Rglob ', RdRsH_ratio) # print('t_sky_eff ', t_sky_eff) print('gs', np.median(list(g.property('gs').values()))) print('flux H2O', round(g.node(vid_collar).Flux * 1000. * time_conv, 4)) print('flux C2O', round(g.node(vid_collar).FluxC, 4)) print('Tleaf ', round(np.median([g.node(vid).Tlc for vid in g.property('gs').keys()]), 2), 'Tair ', round(imeteo.Tac[0], 4)) print('') print("=" * 72) # End time loop +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Write output # Plant total transpiration sapflow = [flow * time_conv * 1000. for flow in sapflow] # sapEast, sapWest = [np.array(flow) * time_conv * 1000. for i, flow in enumerate((sapEast, sapWest))] # Median leaf temperature t_ls = [np.median(list(Tlc_dict[date].values())) for date in meteo.time] # Intercepted global radiation rg_ls = np.array(rg_ls) / (soil_dimensions[0] * soil_dimensions[1]) results_dict = { 'Rg': rg_ls, 'An': an_ls, 'E': sapflow, # 'sapEast': sapEast, # 'sapWest': sapWest, 'Tleaf': t_ls } # Results DataFrame results_df = DataFrame(results_dict, index=meteo.time) # Write if write_result: results_df.to_csv(output_path + 'time_series.output', sep=';', decimal='.') time_off = datetime.now() print("") print("beg time", time_on) print("end time", time_off) print("--- Total runtime: %d minute(s) ---" % int((time_off - time_on).seconds / 60.)) return results_df