From 6c41fc4a084e9a1a5a0a57bda657967d0cb14cf3 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Fri, 7 Nov 2025 12:10:45 -0500 Subject: [PATCH 01/12] updated config file (there was an uncommented comment there) --- data/IO/default_config.toml | 55 +++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/data/IO/default_config.toml b/data/IO/default_config.toml index cf277ba..107eb3a 100644 --- a/data/IO/default_config.toml +++ b/data/IO/default_config.toml @@ -15,13 +15,6 @@ performance_model_input = "PerformanceModel" # "PerformanceModel" : JSON file with state variables - fuel_flow = f(h, V, gamma, m_fuel) (TABULAR INPUT) performance_model_input_file = "IO/sample_performance_model.toml" -climb_descent_usage = true # If true, then full trajectory is used for emissions calculate (including takeoff, landing) - # If false, then only cruise used, LTO data used for takeoff, climb, approach - -["LTO data"] -LTO_input_mode = "EDB" # "PerformanceModel" : LTO data provided in PerformanceModel file - # "EDB" : Use EDB data to get LTO performance -edb_engine_file = "engines/EDB_data_06_2025.toml" ["Missions"] missions_folder = "missions" @@ -34,8 +27,52 @@ sample_schedule = false # Sample schedule distribution to get smaller co sample_number = 1000 # If sample_schedule: how many flights for the sample ["Emissions"] -fuel_file = "fuels/conventional_jetA.toml" -nvpm_method = "SCOPE11" # PMnvol estimation method for LTO (SCOPE11, fox, foa3, dop, sst, newsnci) +Fuel = "conventional_jetA" # Fuel files stored in data/fuels/ + # Current options include: + # conventional_jetA + +CO2_calculation = true # Compute CO2 emissions with EI in fuels file + +H2O_calculation = true # Compute H2O emissions with EI in fuels file + +SOx_calculation = true # Compute SOx emissions with EI in fuels file + +EI_NOx_method = "BFFM2" # Options include + # P3T3: Pt3, Tt3 method + # BFFM2: Boeing Fuel flow method 2 + # None: Do not compute NOx emissions + +EI_HC_method = "BFFM2" # Options include + # BFFM2: Boeing Fuel flow method 2 + # None: Do not compute HC emissions + +EI_CO_method = "BFFM2" # Options include + # BFFM2: Boeing Fuel flow method 2 + # None: Do not compute CO emissions + +EI_PMvol_method = "fuel_flow" + # Options include + # fuel_flow: Calculate EI(PMvolo) and OCicEI based on fuel flow + # FOA3: Using the FOA3.0 method (Wayson et al., 2009) + # None: Do not compute PMvol emissions + +EI_PMnvol_method = "MEEM" # Options include + # MEEM: Mission Emissions Estimation Methodology (MEEM) + # based on Ahrens et al. (2022), + # SCOPE11, and the methodology of Peck et al. (2013). + # SCOPE11: SCOPE11 methodology + # None: Do not compute PMnvol emissions + +LTO_input_mode = "EDB" # "PerformanceModel" : LTO data provided in PerformanceModel file + # "EDB" : Use EDB data to get LTO performance +EDB_input_file = "engines/EDB_data_06_2025.toml" + +climb_descent_usage = true # true: emissions calculated for entire trajectory (takeoff, climb, cruise, descent) + # false: emissions only calculated for cruise, LTO data used for takeoff, climb, approach + +APU_calculation = true # Compute emissions from APU + +GSE_calculation = true # Compute emissions from GSE ["OUTPUT"] output_folder = "Emission_Files" From d1a698db88fc00fa4ad5fa00e97aab796695070e Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Fri, 7 Nov 2025 12:54:16 -0500 Subject: [PATCH 02/12] added updated var names to perf model --- data/IO/default_config.toml | 2 ++ src/AEIC/performance_model.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/data/IO/default_config.toml b/data/IO/default_config.toml index 107eb3a..c03a823 100644 --- a/data/IO/default_config.toml +++ b/data/IO/default_config.toml @@ -74,5 +74,7 @@ APU_calculation = true # Compute emissions from APU GSE_calculation = true # Compute emissions from GSE +LC_calculation = true # Compute lifecycle emissions + ["OUTPUT"] output_folder = "Emission_Files" diff --git a/src/AEIC/performance_model.py b/src/AEIC/performance_model.py index 28ab046..083667f 100644 --- a/src/AEIC/performance_model.py +++ b/src/AEIC/performance_model.py @@ -91,7 +91,7 @@ def read_performance_data(self): # Read UID UID = data['LTO_performance']['ICAO_UID'] # Read EDB file and get engine - engine_info = self.get_engine_by_uid(UID, self.config["edb_engine_file"]) + engine_info = self.get_engine_by_uid(UID, self.config["EDB_input_file"]) if engine_info is not None: self.EDB_data = engine_info else: From b63834698c580ccb6f0b8da91844d8ae62b382b9 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Fri, 7 Nov 2025 13:18:30 -0500 Subject: [PATCH 03/12] emissions overhaul try (1/N).. not as elegant as i hoped it would be --- src/AEIC/emissions/APU_emissions.py | 2 +- src/AEIC/emissions/emission.py | 888 ++++++++++++++++++---------- 2 files changed, 591 insertions(+), 299 deletions(-) diff --git a/src/AEIC/emissions/APU_emissions.py b/src/AEIC/emissions/APU_emissions.py index aa319ce..17d7038 100644 --- a/src/AEIC/emissions/APU_emissions.py +++ b/src/AEIC/emissions/APU_emissions.py @@ -57,7 +57,7 @@ def get_APU_emissions( APU_PM10 - APU_emission_indices['PMnvol'] ).item() - if nvpm_method == "SCOPE11": + if nvpm_method.lower() in ('scope11', 'meem'): APU_emission_indices['PMnvolN'] = np.zeros_like(APU_emission_indices['PMvol']) APU_emission_indices['PMnvolGMD'] = np.zeros_like(APU_emission_indices['PMvol']) APU_emission_indices['OCic'] = np.zeros_like(APU_emission_indices['PMvol']) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index de53f3f..ab31abc 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -19,7 +19,7 @@ from .EI_HCCO import EI_HCCO from .EI_NOx import BFFM2_EINOx, NOx_speciation from .EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 -from .EI_PMvol import EI_PMvol_NEW +from .EI_PMvol import EI_PMvol_FOA3, EI_PMvol_NEW from .EI_SOx import EI_SOx @@ -30,9 +30,7 @@ class Emission: as well as lifecycle CO2 adjustments. """ - def __init__( - self, ac_performance: PerformanceModel, trajectory: Trajectory, EDB_data: bool - ): + def __init__(self, ac_performance: PerformanceModel, trajectory: Trajectory): """ Initialize emissions model: @@ -44,17 +42,29 @@ def __init__( trajectory : Trajectory Flight trajectory for mission object with altitude, speed, and fuel mass time series. - EDB_data : bool - Flag indicating whether to use EDB tabulated data (True) - or user specified LTO data (False). - fuel_file : str - Path to TOML file containing fuel properties """ - # Load fuel properties from TOML - fuel_file_loc = file_location(ac_performance.config['fuel_file']) - with open(fuel_file_loc, 'rb') as f: + self.trajectory = trajectory + self.performance_model = ac_performance + settings = self._parse_emission_settings(ac_performance.config) + self.traj_emissions_all = settings['traj_all'] + self.pmvol_method = settings['pmvol_method'] + self.pmnvol_method = settings['pmnvol_method'] + self.apu_enabled = settings['apu'] + self.gse_enabled = settings['gse'] + self.lifecycle_enabled = settings['lifecycle'] + self.metric_flags = settings['metric_flags'] + self.method_flags = settings['methods'] + self._include_pmnvol_number = self.metric_flags[ + 'PMnvol' + ] and self.pmnvol_method in ('scope11', 'meem') + self._scope11_cache = None + + with open(file_location(settings['fuel_file']), 'rb') as f: self.fuel = tomllib.load(f) + self.co2_ei, self.nvol_carb_cont = EI_CO2(self.fuel) + self.h2o_ei = EI_H2O(self.fuel) + self.so2_ei, self.so4_ei = EI_SOx(self.fuel) # Unpack trajectory lengths: total, climb, cruise, descent points self.n_total = len(trajectory) @@ -62,30 +72,37 @@ def __init__( self.n_cruise = trajectory.n_cruise self.n_descent = trajectory.n_descent - # Flag to use performance model for all segments or just cruise - self.traj_emissions_all = ac_performance.config['climb_descent_usage'] - # Mode for PMnvol emissions in LTO - self.pmnvol_mode = ac_performance.config['nvpm_method'] - - # Pre-allocate structured arrays for emission indices per point + fill_value = -1.0 # Setting all emissions to -1 at the start + # This helps in testing as any value still -1 + # after computing emissions means something is wrong self.emission_indices = np.full( - (), -1, dtype=self.__emission_dtype(self.n_total) + (), fill_value, dtype=self.__emission_dtype(self.Ntot) ) - - # Pre-allocate LTO emissions arrays - self.LTO_emission_indices = np.full((), -1, dtype=self.__emission_dtype(4)) - self.LTO_emissions_g = np.full((), -1, dtype=self.__emission_dtype(4)) - - # Pre-allocate APU and GSE emissions arrays (single-mode shapes) - self.APU_emission_indices = np.full((), -1, dtype=self.__emission_dtype(1)) - self.APU_emissions_g = np.full((), -1, dtype=self.__emission_dtype(1)) - self.GSE_emissions_g = np.full((), -1, dtype=self.__emission_dtype(1)) - - # Storage for pointwise (segment) emissions and summed totals + self.LTO_emission_indices = np.full( + (), fill_value, dtype=self.__emission_dtype(4) + ) + self.LTO_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(4)) + self.APU_emission_indices = np.full( + (), fill_value, dtype=self.__emission_dtype(1) + ) + self.APU_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) + self.GSE_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) self.pointwise_emissions_g = np.full( - (), -1, dtype=self.__emission_dtype(self.n_total) + (), fill_value, dtype=self.__emission_dtype(self.Ntot) + ) + self.summed_emission_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) + + self._initialize_field_controls() + self._apply_metric_mask( + self.emission_indices, + self.LTO_emission_indices, + self.LTO_emissions_g, + self.APU_emission_indices, + self.APU_emissions_g, + self.GSE_emissions_g, + self.pointwise_emissions_g, + self.summed_emission_g, ) - self.summed_emission_g = np.full((), -1, dtype=self.__emission_dtype(1)) # Compute fuel burn per segment from fuel_mass time series fuel_mass = trajectory.fuel_mass @@ -94,39 +111,56 @@ def __init__( fuel_burn[1:] = fuel_mass[:-1] - fuel_mass[1:] self.fuel_burn_per_segment = fuel_burn + self.total_fuel_burn = 0.0 + self.LTO_noProp = np.zeros(4) + self.LTO_no2Prop = np.zeros(4) + self.LTO_honoProp = np.zeros(4) + + def compute_emissions(self): + """ + Compute all emissions + """ # Calculate cruise trajectory emissions (CO2, H2O, SOx, NOx, HC, CO, PM) - self.get_trajectory_emissions(trajectory, ac_performance, EDB_data=EDB_data) + self.get_trajectory_emissions() # Calculate LTO emissions for ground and approach/climb modes - self.get_LTO_emissions(ac_performance, nvpm_method=self.pmnvol_mode) + self.get_LTO_emissions() - H2O_ei = EI_H2O(self.fuel) - # Compute APU emissions based on LTO results and EDB parameters - (self.APU_emission_indices, self.APU_emissions_g, apu_fuel_burn) = ( - get_APU_emissions( + if self.apu_enabled: + ( + self.APU_emission_indices, + self.APU_emissions_g, + apu_fuel_burn, + ) = get_APU_emissions( self.APU_emission_indices, self.APU_emissions_g, self.LTO_emission_indices, - ac_performance.APU_data, + self.performance_model.APU_data, self.LTO_noProp, self.LTO_no2Prop, self.LTO_honoProp, - EI_H2O=H2O_ei, - nvpm_method=self.pmnvol_mode, + EI_H2O=self.h2o_ei, + nvpm_method=self.pmnvol_method, ) - ) - self.total_fuel_burn += apu_fuel_burn + self._apply_metric_mask(self.APU_emission_indices) + self._apply_metric_mask(self.APU_emissions_g) + self.total_fuel_burn += apu_fuel_burn # Compute Ground Service Equipment (GSE) emissions based on WNSF type - self.get_GSE_emissions( - ac_performance.model_info['General_Information']['aircraft_class'] - ) + if self.gse_enabled: + self.get_GSE_emissions( + self.performance_model.model_info['General_Information'][ + 'aircraft_class' + ] + ) + self._apply_metric_mask(self.GSE_emissions_g) # Sum all emission contributions: trajectory + LTO + APU + GSE self.sum_total_emissions() # Add lifecycle CO2 emissions to total - self.get_lifecycle_emissions(self.fuel, trajectory) + if self.metric_flags['CO2'] and self.lifecycle_enabled: + self.get_lifecycle_emissions(self.fuel, self.trajectory) def sum_total_emissions(self): """ @@ -141,7 +175,7 @@ def sum_total_emissions(self): + self.GSE_emissions_g[field] ) - def get_trajectory_emissions(self, trajectory, ac_performance, EDB_data=True): + def get_trajectory_emissions(self): """ Calculate emission indices (g/species per kg fuel) for each flight segment. @@ -151,131 +185,185 @@ def get_trajectory_emissions(self, trajectory, ac_performance, EDB_data=True): Contains altitudes, speeds, and fuel flows for each time step. ac_performance : PerformanceModel Provides EDB or LTO data matrices for EI lookup. - EDB_data : bool, optional - Use tabulated EDB emissions (True) or LTO-mode data (False). """ - # Determine start/end indices for climb-cruise-descent or full mission - if self.traj_emissions_all: - i_start, i_end = 0, self.n_total - else: - i_start, i_end = 0, -self.n_descent - - # --- Compute CO2 and H2O emission indices for cruise --- - self.emission_indices['CO2'][i_start:i_end], _ = EI_CO2(self.fuel) - self.emission_indices['H2O'][i_start:i_end] = EI_H2O(self.fuel) - - # --- Compute SOx emission indices --- - ( - self.emission_indices['SO2'][i_start:i_end], - self.emission_indices['SO4'][i_start:i_end], - ) = EI_SOx(self.fuel) - - # Select LTO EI arrays depending on EDB_data flag - if EDB_data: - lto_co_ei_array = np.array(ac_performance.EDB_data['CO_EI_matrix']) - lto_hc_ei_array = np.array(ac_performance.EDB_data['HC_EI_matrix']) - lto_nox_ei_array = np.array(ac_performance.EDB_data['NOX_EI_matrix']) - lto_ff_array = np.array(ac_performance.EDB_data['fuelflow_KGperS']) - else: - # Extract HC, CO, NOx, and fuel-flow from thrust_settings dict - settings = ac_performance.LTO_data['thrust_settings'].values() - lto_co_ei_array = np.array([mode['CO_EI'] for mode in settings]) - lto_hc_ei_array = np.array([mode['HC_EI'] for mode in settings]) - lto_nox_ei_array = np.array([mode['NOX_EI'] for mode in settings]) - lto_ff_array = np.array([mode['FUEL_KGs'] for mode in settings]) - - # --- Compute NOx, NO, NO2, HONO indices via BFFM2 model --- - flight_alts = trajectory.altitude[i_start:i_end] - flight_temps = temperature_at_altitude_isa_bada4(flight_alts) - flight_pressures = pressure_at_altitude_isa_bada4(flight_alts) - mach_number = trajectory.true_airspeed[i_start:i_end] / np.sqrt( - kappa * R_air * flight_temps + trajectory = self.trajectory + ac_performance = self.performance_model + idx_slice = ( + slice(0, self.Ntot) + if self.traj_emissions_all + else slice(0, self.Ntot - self.NDes) ) - sls_equiv_fuel_flow = get_SLS_equivalent_fuel_flow( - trajectory.fuel_flow[i_start:i_end], - flight_pressures, - flight_temps, - mach_number, - ac_performance.model_info['General_Information']['n_eng'], + traj_data = trajectory.traj_data + lto_inputs = self._extract_lto_inputs() + lto_ff_array = lto_inputs['fuel_flow'] + fuel_flow = traj_data['fuelFlow'][idx_slice] + thrust_categories = get_thrust_cat(fuel_flow, lto_ff_array, cruiseCalc=True) + + needs_hc = self.metric_flags['HC'] or ( + self.metric_flags['PMvol'] and self.pmvol_method == 'foa3' ) - - ( - self.emission_indices['NOx'][i_start:i_end], - self.emission_indices['NO'][i_start:i_end], - self.emission_indices['NO2'][i_start:i_end], - self.emission_indices['HONO'][i_start:i_end], - *_, - ) = BFFM2_EINOx( - sls_equiv_fuel_flow=sls_equiv_fuel_flow, - NOX_EI_matrix=lto_nox_ei_array, - fuelflow_performance=lto_ff_array, - Pamb=flight_pressures, - Tamb=flight_temps, - ) - - # --- Compute HC and CO indices -- - self.emission_indices['HC'][i_start:i_end] = EI_HCCO( - sls_equiv_fuel_flow, - lto_hc_ei_array, - lto_ff_array, - Tamb=flight_temps, - Pamb=flight_pressures, - cruiseCalc=True, - ) - self.emission_indices['CO'][i_start:i_end] = EI_HCCO( - sls_equiv_fuel_flow, - lto_co_ei_array, - lto_ff_array, - Tamb=flight_temps, - Pamb=flight_pressures, - cruiseCalc=True, - ) - - # --- Compute volatile organic PM and organic carbon --- - thrustCat = get_thrust_cat( - trajectory.fuel_flow[i_start:i_end], - lto_ff_array, - cruiseCalc=True, - ) - ( - self.emission_indices['PMvol'][i_start:i_end], - self.emission_indices['OCic'][i_start:i_end], - ) = EI_PMvol_NEW(trajectory.fuel_flow[i_start:i_end], thrustCat) - - # --- Compute black carbon indices via MEEM --- - ( - self.emission_indices['PMnvolGMD'][i_start:i_end], - self.emission_indices['PMnvol'][i_start:i_end], - self.emission_indices['PMnvolN'][i_start:i_end], - ) = PMnvol_MEEM( - ac_performance.EDB_data, - flight_alts, - flight_temps, - flight_pressures, - mach_number, + needs_co = self.metric_flags['CO'] + needs_nox = self.metric_flags['NOx'] + needs_pmnvol_meem = self.metric_flags['PMnvol'] and ( + self.pmnvol_method == 'meem' ) - - self.total_fuel_burn = np.sum(self.fuel_burn_per_segment[i_start:i_end]) - # Multiply each index by fuel burn per segment to get g emissions/time-step - for field in self.pointwise_emissions_g.dtype.names: - self.pointwise_emissions_g[field][i_start:i_end] = ( - self.emission_indices[field][i_start:i_end] - * self.fuel_burn_per_segment[i_start:i_end] + needs_atmos = needs_hc or needs_co or needs_nox or needs_pmnvol_meem + + if needs_atmos: + flight_alts = traj_data['altitude'][idx_slice] + flight_temps = temperature_at_altitude_isa_bada4(flight_alts) + flight_pressures = pressure_at_altitude_isa_bada4(flight_alts) + mach_number = traj_data['tas'][idx_slice] / np.sqrt( + kappa * R_air * flight_temps + ) + else: + flight_temps = flight_pressures = mach_number = None + + needs_sls_ff = needs_hc or needs_co or needs_nox + if needs_sls_ff: + sls_equiv_fuel_flow = get_SLS_equivalent_fuel_flow( + fuel_flow, + flight_pressures, + flight_temps, + mach_number, + ac_performance.model_info['General_Information']['n_eng'], + ) + else: + sls_equiv_fuel_flow = None + + if self.metric_flags['CO2']: + self.emission_indices['CO2'][idx_slice] = self.co2_ei + if self.metric_flags['H2O']: + self.emission_indices['H2O'][idx_slice] = self.h2o_ei + if self.metric_flags['SOx']: + self.emission_indices['SO2'][idx_slice] = self.so2_ei + self.emission_indices['SO4'][idx_slice] = self.so4_ei + + if needs_nox: + nox_method = self.method_flags['nox'] + if nox_method == 'bffm2': + ( + self.emission_indices['NOx'][idx_slice], + self.emission_indices['NO'][idx_slice], + self.emission_indices['NO2'][idx_slice], + self.emission_indices['HONO'][idx_slice], + *_, + ) = BFFM2_EINOx( + sls_equiv_fuel_flow=sls_equiv_fuel_flow, + NOX_EI_matrix=lto_inputs['nox_ei'], + fuelflow_performance=lto_ff_array, + Pamb=flight_pressures, + Tamb=flight_temps, + ) + elif nox_method == 'p3t3': + print("P3T3 method not implemented yet..") + pass + elif nox_method == 'none': + pass + else: + raise NotImplementedError( + f"EI_NOx_method '{self.method_flags['nox']}' is not supported." + ) + + hc_ei = None + if needs_hc: + hc_ei = EI_HCCO( + sls_equiv_fuel_flow, + lto_inputs['hc_ei'], + lto_ff_array, + Tamb=flight_temps, + Pamb=flight_pressures, + cruiseCalc=True, + ) + if self.metric_flags['HC']: + self.emission_indices['HC'][idx_slice] = hc_ei + + if needs_co: + co_ei = EI_HCCO( + sls_equiv_fuel_flow, + lto_inputs['co_ei'], + lto_ff_array, + Tamb=flight_temps, + Pamb=flight_pressures, + cruiseCalc=True, + ) + self.emission_indices['CO'][idx_slice] = co_ei + + if self.metric_flags['PMvol']: + pmvol_ei = ocic_ei = None + if self.pmvol_method == 'fuel_flow': + thrust_labels = self._thrust_band_labels(thrust_categories) + pmvol_ei, ocic_ei = EI_PMvol_NEW(fuel_flow, thrust_labels) + elif self.pmvol_method == 'foa3': + thrust_pct = self._thrust_percentages_from_categories(thrust_categories) + pmvol_ei, ocic_ei = EI_PMvol_FOA3(thrust_pct, hc_ei) + elif self.pmvol_method == 'none': + pass + else: + raise NotImplementedError( + f"EI_PMvol_method '{self.pmvol_method}' is not supported." + ) + + if pmvol_ei is not None: + self.emission_indices['PMvol'][idx_slice] = pmvol_ei + self.emission_indices['OCic'][idx_slice] = ocic_ei + + if self.metric_flags['PMnvol']: + pmnvol_method = self.pmnvol_method + if pmnvol_method == 'meem': + ( + self.emission_indices['PMnvolGMD'][idx_slice], + self.emission_indices['PMnvol'][idx_slice], + pmnvol_num, + ) = PMnvol_MEEM( + ac_performance.EDB_data, + traj_data['altitude'][idx_slice], + flight_temps, + flight_pressures, + mach_number, + ) + if ( + self._include_pmnvol_number + and 'PMnvolN' in self.emission_indices.dtype.names + ): + self.emission_indices['PMnvolN'][idx_slice] = pmnvol_num + elif pmnvol_method == 'scope11': + profile = self._scope11_profile(ac_performance) + self.emission_indices['PMnvol'][idx_slice] = ( + self._map_mode_values_to_categories( + profile['mass'], thrust_categories + ) + ) + self.emission_indices['PMnvolGMD'][idx_slice] = 0.0 + if ( + self._include_pmnvol_number + and profile['number'] is not None + and 'PMnvolN' in self.emission_indices.dtype.names + ): + self.emission_indices['PMnvolN'][idx_slice] = ( + self._map_mode_values_to_categories( + profile['number'], thrust_categories + ) + ) + elif pmnvol_method == 'none': + pass + else: + raise NotImplementedError( + f"EI_PMnvol_method '{self.pmnvol_method}' is not supported." + ) + + self.total_fuel_burn = np.sum(self.fuel_burn_per_segment[idx_slice]) + for field in self._active_fields: + self.pointwise_emissions_g[field][idx_slice] = ( + self.emission_indices[field][idx_slice] + * self.fuel_burn_per_segment[idx_slice] ) - def get_LTO_emissions(self, ac_performance, EDB_LTO=True, nvpm_method="SCOPE11"): + def get_LTO_emissions(self): """ Compute Landing-and-Takeoff cycle emission indices and quantities. - - Parameters - ---------- - ac_performance : PerformanceModel - Provides EDB or LTO tabular data for EI values. - EDB_LTO : bool, optional - Use EDB data for LTO (True) or thrust_settings (False). - nvpm_method : str, optional - Method for number-based PM emissions ('SCOPE11', 'foa3', etc.). """ + ac_performance = self.performance_model # Standard TIM durations # https://www.icao.int/environmental-protection/Documents/EnvironmentalReports/2016/ENVReport2016_pg73-74.pdf @@ -284,114 +372,121 @@ def get_LTO_emissions(self, ac_performance, EDB_LTO=True, nvpm_method="SCOPE11") TIM_Approach = 4.0 * 60 TIM_Taxi = 26.0 * 60 - # Assemble durations array for modes if self.traj_emissions_all: TIM_Climb = 0.0 TIM_Approach = 0.0 - TIM_LTO = np.array([TIM_Taxi, TIM_Approach, TIM_Climb, TIM_TakeOff]) - else: - TIM_LTO = np.array([TIM_Taxi, TIM_Approach, TIM_Climb, TIM_TakeOff]) - # Select fuel flow rates per mode - if EDB_LTO: - fuel_flows_LTO = np.array(ac_performance.EDB_data['fuelflow_KGperS']) - else: - fuel_flows_LTO = np.array( - [ - mode['FUEL_KGs'] - for mode in ac_performance.LTO_data['thrust_settings'].values() - ] - ) + TIM_LTO = np.array([TIM_Taxi, TIM_Approach, TIM_Climb, TIM_TakeOff]) + lto_inputs = self._extract_lto_inputs() + fuel_flows_LTO = lto_inputs['fuel_flow'] + thrustCat = get_thrust_cat(fuel_flows_LTO, None, cruiseCalc=False) + thrust_labels = self._thrust_band_labels(thrustCat) - # Compute CO2, H2O, SOx indices using same methods as cruise - self.LTO_emission_indices['CO2'], _ = EI_CO2(self.fuel) - self.LTO_emission_indices['H2O'] = EI_H2O(self.fuel) - ( - self.LTO_emission_indices['SO2'], - self.LTO_emission_indices['SO4'], - ) = EI_SOx(self.fuel) - - # For NOx, HC, CO, either use EDB_LTO or thrust_settings dict - if EDB_LTO: - self.LTO_emission_indices['NOx'] = np.array( - ac_performance.EDB_data['NOX_EI_matrix'] - ) - self.LTO_emission_indices['HC'] = np.array( - ac_performance.EDB_data['HC_EI_matrix'] + if self.metric_flags['CO2']: + self.LTO_emission_indices['CO2'] = np.full_like( + fuel_flows_LTO, self.co2_ei, dtype=float ) - self.LTO_emission_indices['CO'] = np.array( - ac_performance.EDB_data['CO_EI_matrix'] + if self.metric_flags['H2O']: + self.LTO_emission_indices['H2O'] = np.full_like( + fuel_flows_LTO, self.h2o_ei, dtype=float ) - else: - settings = ac_performance.LTO_data['thrust_settings'].values() - self.LTO_emission_indices['NOx'] = np.array( - [mode['EI_NOx'] for mode in settings] - ) - self.LTO_emission_indices['HC'] = np.array( - [mode['EI_HC'] for mode in settings] + if self.metric_flags['SOx']: + self.LTO_emission_indices['SO2'] = np.full_like( + fuel_flows_LTO, self.so2_ei, dtype=float ) - self.LTO_emission_indices['CO'] = np.array( - [mode['EI_CO'] for mode in settings] + self.LTO_emission_indices['SO4'] = np.full_like( + fuel_flows_LTO, self.so4_ei, dtype=float ) - # Determine NOx speciation fractions based on thrust category - thrustCat = get_thrust_cat(fuel_flows_LTO, None, cruiseCalc=False) - ( - self.LTO_noProp, - self.LTO_no2Prop, - self.LTO_honoProp, - ) = NOx_speciation(thrustCat) - - # Get NOx species EIs - self.LTO_emission_indices['NO'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_noProp - ) # g NO / kg fuel - self.LTO_emission_indices['NO2'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_no2Prop - ) # g NO2 / kg fuel - self.LTO_emission_indices['HONO'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_honoProp - ) # g HONO / kg fuel - - # Compute organic PM for LTO modes - LTO_PMvol, LTO_OCic = EI_PMvol_NEW(fuel_flows_LTO, thrustCat) - self.LTO_emission_indices['PMvol'] = LTO_PMvol - self.LTO_emission_indices['OCic'] = LTO_OCic - - # Select black carbon emission indices based on nvpm_method - if nvpm_method in ('foa3', 'newsnci'): - PMnvolEI_ICAOthrust = ac_performance.EDB_data['PMnvolEI_best_ICAOthrust'] - elif nvpm_method in ('fox', 'dop', 'sst'): - PMnvolEI_ICAOthrust = ac_performance.EDB_data['PMnvolEI_new_ICAOthrust'] - elif nvpm_method == 'SCOPE11': - # SCOPE11 provides best, lower, and upper bounds - edb = ac_performance.EDB_data - PMnvolEI_ICAOthrust = calculate_PMnvolEI_scope11( - np.array(edb['SN_matrix']), - np.array(edb['PR']), - np.array(edb['ENGINE_TYPE']), - np.array(edb['BP_Ratio']), - ) - PMnvolEIN_ICAOthrust = ac_performance.EDB_data['PMnvolEIN_best_ICAOthrust'] - + if self.metric_flags['NOx']: + if self.method_flags['nox'] == 'none': + self.LTO_noProp = np.zeros_like(fuel_flows_LTO) + self.LTO_no2Prop = np.zeros_like(fuel_flows_LTO) + self.LTO_honoProp = np.zeros_like(fuel_flows_LTO) + else: + self.LTO_emission_indices['NOx'] = lto_inputs['nox_ei'] + ( + self.LTO_noProp, + self.LTO_no2Prop, + self.LTO_honoProp, + ) = NOx_speciation(thrustCat) + self.LTO_emission_indices['NO'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_noProp + ) + self.LTO_emission_indices['NO2'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_no2Prop + ) + self.LTO_emission_indices['HONO'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_honoProp + ) else: - raise ValueError( - f"Re-define PMnvol estimation method: pmnvolSwitch = {nvpm_method}" - ) + self.LTO_noProp = np.zeros_like(fuel_flows_LTO) + self.LTO_no2Prop = np.zeros_like(fuel_flows_LTO) + self.LTO_honoProp = np.zeros_like(fuel_flows_LTO) + + if self.metric_flags['HC']: + self.LTO_emission_indices['HC'] = lto_inputs['hc_ei'] + if self.metric_flags['CO']: + self.LTO_emission_indices['CO'] = lto_inputs['co_ei'] + + if self.metric_flags['PMvol']: + if self.pmvol_method == 'fuel_flow': + LTO_PMvol, LTO_OCic = EI_PMvol_NEW(fuel_flows_LTO, thrust_labels) + elif self.pmvol_method == 'foa3': + LTO_PMvol, LTO_OCic = EI_PMvol_FOA3( + lto_inputs['thrust_pct'], lto_inputs['hc_ei'] + ) + elif self.pmvol_method == 'none': + LTO_PMvol = LTO_OCic = np.zeros_like(fuel_flows_LTO) + else: + raise NotImplementedError( + f"EI_PMvol_method '{self.pmvol_method}' is not supported." + ) + self.LTO_emission_indices['PMvol'] = LTO_PMvol + self.LTO_emission_indices['OCic'] = LTO_OCic + + if self.metric_flags['PMnvol']: + pmnvol_method = self.pmnvol_method + if pmnvol_method in ('foa3', 'newsnci', 'meem'): + PMnvolEI_ICAOthrust = np.asarray( + ac_performance.EDB_data['PMnvolEI_best_ICAOthrust'], dtype=float + ) + PMnvolEIN_ICAOthrust = None + elif pmnvol_method in ('fox', 'dop', 'sst'): + PMnvolEI_ICAOthrust = np.asarray( + ac_performance.EDB_data['PMnvolEI_new_ICAOthrust'], dtype=float + ) + PMnvolEIN_ICAOthrust = None + elif pmnvol_method == 'scope11': + profile = self._scope11_profile(ac_performance) + PMnvolEI_ICAOthrust = profile['mass'] + PMnvolEIN_ICAOthrust = profile['number'] + elif pmnvol_method == 'none': + PMnvolEI_ICAOthrust = np.zeros_like(fuel_flows_LTO) + PMnvolEIN_ICAOthrust = None + else: + raise ValueError( + f'''Re-define PMnvol estimation method: + pmnvolSwitch = {self.pmnvol_method}''' + ) + + self.LTO_emission_indices['PMnvol'] = PMnvolEI_ICAOthrust + if ( + self._include_pmnvol_number + and PMnvolEIN_ICAOthrust is not None + and 'PMnvolN' in self.LTO_emission_indices.dtype.names + ): + self.LTO_emission_indices['PMnvolN'] = PMnvolEIN_ICAOthrust + self.LTO_emission_indices['PMnvolGMD'] = np.zeros_like(fuel_flows_LTO) - # Assign BC indices arrays for selected modes - self.LTO_emission_indices['PMnvol'] = PMnvolEI_ICAOthrust - if nvpm_method == 'SCOPE11': - self.LTO_emission_indices['PMnvolN'] = PMnvolEIN_ICAOthrust - - self.LTO_emission_indices['PMnvolGMD'] = np.zeros_like(TIM_LTO) - # --- Compute LTO emissions in grams per mode - # by multiplying EI by durations*flows --- LTO_fuel_burn = TIM_LTO * fuel_flows_LTO self.total_fuel_burn += np.sum(LTO_fuel_burn) + for field in self.LTO_emission_indices.dtype.names: - # If using performance model for climb and approach then set EIs to 0 - if self.traj_emissions_all: + if ( + self.traj_emissions_all + and field in self.LTO_emission_indices.dtype.names + ): self.LTO_emission_indices[field][1:-1] = 0.0 self.LTO_emissions_g[field] = ( self.LTO_emission_indices[field] * LTO_fuel_burn @@ -429,14 +524,12 @@ def get_GSE_emissions(self, wnsf): self.GSE_emissions_g['CO'] = CO_nom[idx] pm_core = PM10_nom[idx] - # Fuel (kg/cycle) from CO2: - # EI_CO2 = fuel * 3.16 * 1000 ⇒ fuel = EI_CO2/(3.16*1000) - CO2_EI, _ = EI_CO2(self.fuel) + CO2_EI = getattr(self, 'co2_ei', EI_CO2(self.fuel)[0]) gse_fuel = self.GSE_emissions_g['CO2'] / CO2_EI self.total_fuel_burn += gse_fuel - # Get H2O from CO2 - self.GSE_emissions_g['H2O'] = EI_H2O(self.fuel) * gse_fuel + H2O_EI = getattr(self, 'h2o_ei', EI_H2O(self.fuel)) + self.GSE_emissions_g['H2O'] = H2O_EI * gse_fuel # NOx split self.GSE_emissions_g['NO'] = self.GSE_emissions_g['NOx'] * 0.90 @@ -458,11 +551,15 @@ def get_GSE_emissions(self, wnsf): self.GSE_emissions_g['PMvol'] = pm_minus_so4 * 0.5 self.GSE_emissions_g['PMnvol'] = pm_minus_so4 * 0.5 # No PMnvolN or PMnvolGMD or OCic - self.GSE_emissions_g['PMnvolN'] = 0.0 + if 'PMnvolN' in self.GSE_emissions_g.dtype.names: + self.GSE_emissions_g['PMnvolN'] = 0.0 self.GSE_emissions_g['PMnvolGMD'] = 0.0 self.GSE_emissions_g['OCic'] = 0.0 def get_lifecycle_emissions(self, fuel, traj): + """Apply lifecycle CO2 adjustments when requested by the config.""" + if hasattr(self, "metric_flags") and not self.metric_flags.get('CO2', True): + return # add lifecycle CO2 emissions for climate model run lc_CO2 = ( fuel['LC_CO2'] * (traj.total_fuel_mass * fuel['Energy_MJ_per_kg']) @@ -472,41 +569,236 @@ def get_lifecycle_emissions(self, fuel, traj): ################### # PRIVATE METHODS # ################### + def _parse_emission_settings(self, config): + """Flatten relevant config keys and derive boolean/method flags.""" + + def bool_opt(key, default=True): + value = config.get(key, default) + if isinstance(value, str): + return value.strip().lower() in ('1', 'true', 'yes', 'y') + return bool(value) + + def method_opt(key, default): + raw = config.get(key, default) + if raw is None: + raw = default + if isinstance(raw, str): + raw_clean = raw.strip() or default + else: + raw_clean = str(raw) + return raw_clean.lower() + + fuel_name = config.get('Fuel') or config.get('fuel_file') or 'conventional_jetA' + nox_method = method_opt('EI_NOx_method', 'BFFM2') + hc_method = method_opt('EI_HC_method', 'BFFM2') + co_method = method_opt('EI_CO_method', 'BFFM2') + pmvol_method = method_opt('EI_PMvol_method', 'fuel_flow') + pmnvol_method = method_opt('EI_PMnvol_method', 'scope11') + + metric_flags = { + 'CO2': bool_opt('CO2_calculation', True), + 'H2O': bool_opt('H2O_calculation', True), + 'SOx': bool_opt('SOx_calculation', True), + 'NOx': nox_method != 'none', + 'HC': hc_method != 'none', + 'CO': co_method != 'none', + 'PMvol': pmvol_method != 'none', + 'PMnvol': pmnvol_method != 'none', + } + + return { + 'fuel_file': f"fuels/{fuel_name}.toml", + 'traj_all': bool_opt('climb_descent_usage', True), + 'apu': bool_opt('APU_calculation', True), + 'gse': bool_opt('GSE_calculation', True), + 'pmvol_method': pmvol_method, + 'pmnvol_method': pmnvol_method, + 'lifecycle': bool_opt('LC_calculation', True), + 'metric_flags': metric_flags, + 'methods': { + 'nox': nox_method, + 'hc': hc_method, + 'co': co_method, + 'pmvol': pmvol_method, + 'pmnvol': pmnvol_method, + }, + } + + def _initialize_field_controls(self): + """Map dtype fields to metric flags so we can zero disabled outputs.""" + # TODO: This might be a little overboard, and there's + # got to be another way to solve this problem + field_groups = { + 'CO2': 'CO2', + 'H2O': 'H2O', + 'HC': 'HC', + 'CO': 'CO', + 'NOx': 'NOx', + 'NO': 'NOx', + 'NO2': 'NOx', + 'HONO': 'NOx', + 'PMvol': 'PMvol', + 'OCic': 'PMvol', + 'PMnvol': 'PMnvol', + 'PMnvolN': 'PMnvol', + 'PMnvolGMD': 'PMnvol', + 'SO2': 'SOx', + 'SO4': 'SOx', + } + controls = {} + include_number = getattr( + self, + "_include_pmnvol_number", + getattr(self, 'pmnvol_mode', '').lower() in ('scope11', 'meem'), + ) + + for field in self.emission_indices.dtype.names: + group = field_groups.get(field) + if group is None: + controls[field] = True + continue + enabled = self.metric_flags.get(group, False) + if field == 'PMnvolN': + enabled = enabled and include_number + controls[field] = enabled + + self._field_controls = controls + self._active_fields = tuple( + field for field in self.emission_indices.dtype.names if controls[field] + ) + + def _extract_lto_inputs(self): + """Return ordered fuel-flow and EI arrays for either EDB or user LTO data.""" + if self.performance_model.config['LTO_input_mode'] == "EDB": + edb = self.performance_model.EDB_data + fuel_flow = np.asarray(edb['fuelflow_KGperS'], dtype=float) + nox_ei = np.asarray(edb['NOX_EI_matrix'], dtype=float) + hc_ei = np.asarray(edb['HC_EI_matrix'], dtype=float) + co_ei = np.asarray(edb['CO_EI_matrix'], dtype=float) + thrust_pct = np.array([7.0, 30.0, 85.0, 100.0], dtype=float) + else: + settings = self.performance_model.LTO_data['thrust_settings'] + ordered_modes = self._ordered_thrust_settings(settings) + fuel_flow = np.array( + [mode['FUEL_KGs'] for mode in ordered_modes], dtype=float + ) + nox_ei = np.array( + [mode.get('NOX_EI', mode.get('EI_NOx', 0.0)) for mode in ordered_modes], + dtype=float, + ) + hc_ei = np.array( + [mode.get('HC_EI', mode.get('EI_HC', 0.0)) for mode in ordered_modes], + dtype=float, + ) + co_ei = np.array( + [mode.get('CO_EI', mode.get('EI_CO', 0.0)) for mode in ordered_modes], + dtype=float, + ) + thrust_pct = np.array( + [mode.get('THRUST_FRAC', 0.0) * 100.0 for mode in ordered_modes], + dtype=float, + ) + + return { + 'fuel_flow': fuel_flow, + 'nox_ei': nox_ei, + 'hc_ei': hc_ei, + 'co_ei': co_ei, + 'thrust_pct': thrust_pct, + } + + def _ordered_thrust_settings(self, settings): + """Preserve canonical idle -> takeoff order for thrust setting dictionaries.""" + if not isinstance(settings, dict): + return list(settings) + lower_lookup = {key.lower(): value for key, value in settings.items()} + ordered = [] + for key in ('idle', 'approach', 'climb', 'takeoff'): + if key in lower_lookup: + ordered.append(lower_lookup[key]) + for value in settings.values(): + if value not in ordered: + ordered.append(value) + return ordered + + def _thrust_band_labels(self, thrust_categories: np.ndarray) -> np.ndarray: + """Translate numeric thrust codes into the L/H labels used by EI_PMvol_NEW.""" + labels = np.full(thrust_categories.shape, 'H', dtype=' 1: + mapped[thrust_categories == 3] = values[1] + if values.size > 2: + mapped[thrust_categories == 1] = values[2] + return mapped + def __emission_dtype(self, shape): + """Build the structured dtype used for every emission array.""" n = (shape,) - return ( - [ - ('CO2', np.float64, n), - ('H2O', np.float64, n), - ('HC', np.float64, n), - ('CO', np.float64, n), - ('NOx', np.float64, n), - ('NO', np.float64, n), - ('NO2', np.float64, n), - ('HONO', np.float64, n), - ('PMnvol', np.float64, n), - ('PMnvolN', np.float64, n), - ('PMnvolGMD', np.float64, n), - ('PMvol', np.float64, n), - ('OCic', np.float64, n), - ('SO2', np.float64, n), - ('SO4', np.float64, n), - ] - if self.pmnvol_mode == 'SCOPE11' - else [ - ('CO2', np.float64, n), - ('H2O', np.float64, n), - ('HC', np.float64, n), - ('CO', np.float64, n), - ('NOx', np.float64, n), - ('NO', np.float64, n), - ('NO2', np.float64, n), - ('HONO', np.float64, n), - ('PMnvol', np.float64, n), - ('PMnvolGMD', np.float64, n), - ('PMvol', np.float64, n), - ('OCic', np.float64, n), - ('SO2', np.float64, n), - ('SO4', np.float64, n), - ] + fields = [ + ('CO2', np.float64, n), + ('H2O', np.float64, n), + ('HC', np.float64, n), + ('CO', np.float64, n), + ('NOx', np.float64, n), + ('NO', np.float64, n), + ('NO2', np.float64, n), + ('HONO', np.float64, n), + ('PMnvol', np.float64, n), + ('PMnvolGMD', np.float64, n), + ('PMvol', np.float64, n), + ('OCic', np.float64, n), + ('SO2', np.float64, n), + ('SO4', np.float64, n), + ] + include_number = getattr( + self, + "_include_pmnvol_number", + getattr(self, 'pmnvol_mode', '').lower() in ('scope11', 'meem'), ) + if include_number: + fields.append(('PMnvolN', np.float64, n)) + return fields From 3c02f4c8ac8f533eb3ceb6e2b29cb10a9e50d912 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Fri, 7 Nov 2025 15:58:10 -0500 Subject: [PATCH 04/12] cleaned up emissions class, solution slightly more elegant now, emissions not in config set to -1 --- src/AEIC/emissions/emission.py | 106 ++++++++++++++++++++++++--------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index ab31abc..285f0c5 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -1,5 +1,7 @@ # Emissions class import tomllib +from collections.abc import Mapping +from dataclasses import dataclass import numpy as np @@ -23,6 +25,26 @@ from .EI_SOx import EI_SOx +@dataclass(frozen=True) +class EmissionSettings: + fuel_file: str + traj_all: bool + apu_enabled: bool + gse_enabled: bool + pmvol_method: str + pmnvol_method: str + lifecycle_enabled: bool + metric_flags: Mapping[str, bool] + method_flags: Mapping[str, str] + + +@dataclass(frozen=True) +class AtmosphericState: + temperature: np.ndarray | None + pressure: np.ndarray | None + mach: np.ndarray | None + + class Emission: """ Model for determining and aggregating flight emissions across all mission segments, @@ -67,10 +89,10 @@ def __init__(self, ac_performance: PerformanceModel, trajectory: Trajectory): self.so2_ei, self.so4_ei = EI_SOx(self.fuel) # Unpack trajectory lengths: total, climb, cruise, descent points - self.n_total = len(trajectory) - self.n_climb = trajectory.n_climb - self.n_cruise = trajectory.n_cruise - self.n_descent = trajectory.n_descent + self.Ntot = trajectory.Ntot + self.NClm = trajectory.NClm + self.NCrz = trajectory.NCrz + self.NDes = trajectory.NDes fill_value = -1.0 # Setting all emissions to -1 at the start # This helps in testing as any value still -1 @@ -104,8 +126,8 @@ def __init__(self, ac_performance: PerformanceModel, trajectory: Trajectory): self.summed_emission_g, ) - # Compute fuel burn per segment from fuel_mass time series - fuel_mass = trajectory.fuel_mass + # Compute fuel burn per segment from fuelMass time series + fuel_mass = trajectory.traj_data['fuelMass'] fuel_burn = np.zeros_like(fuel_mass) # Difference between sequential mass values for ascent segments fuel_burn[1:] = fuel_mass[:-1] - fuel_mass[1:] @@ -502,27 +524,13 @@ def get_GSE_emissions(self, wnsf): wnsf : str Wide, Narrow, Small, or Freight ('w','n','s','f'). """ - # Map WNSF letter to index for nominal emission lists - mapping = {'wide': 0, 'narrow': 1, 'small': 2, 'freight': 3} - idx = mapping.get(wnsf.lower()) - if idx is None: - raise ValueError( - "Invalid WNSF code; must be one of 'wide','narrow','small','freight'" - ) - - # Nominal emissions per engine start cycle [g/cycle] - CO2_nom = [58e3, 18e3, 10e3, 58e3] # g/cycle - NOx_nom = [0.9e3, 0.4e3, 0.3e3, 0.9e3] # g/cycle - HC_nom = [0.07e3, 0.04e3, 0.03e3, 0.07e3] # g/cycle (NMVOC) - CO_nom = [0.3e3, 0.15e3, 0.1e3, 0.3e3] # g/cycle - PM10_nom = [0.055e3, 0.025e3, 0.020e3, 0.055e3] # g/cycle (≈PM2.5) - - # Pick out the scalar values - self.GSE_emissions_g['CO2'] = CO2_nom[idx] - self.GSE_emissions_g['NOx'] = NOx_nom[idx] - self.GSE_emissions_g['HC'] = HC_nom[idx] - self.GSE_emissions_g['CO'] = CO_nom[idx] - pm_core = PM10_nom[idx] + idx = self._wnsf_index(wnsf) + nominal = self._gse_nominal_profile(idx) + self._assign_constant_indices( + self.GSE_emissions_g, + {key: nominal[key] for key in ('CO2', 'NOx', 'HC', 'CO')}, + ) + pm_core = nominal['PM10'] CO2_EI = getattr(self, 'co2_ei', EI_CO2(self.fuel)[0]) gse_fuel = self.GSE_emissions_g['CO2'] / CO2_EI @@ -565,6 +573,50 @@ def get_lifecycle_emissions(self, fuel, traj): fuel['LC_CO2'] * (traj.total_fuel_mass * fuel['Energy_MJ_per_kg']) ) - self.summed_emission_g['CO2'] self.summed_emission_g['CO2'] += lc_CO2 +======= + lifecycle_total = fuel['LC_CO2'] * (traj.fuel_mass * fuel['Energy_MJ_per_kg']) + self.summed_emission_g['CO2'] = lifecycle_total + + def compute_EI_NOx( + self, + idx_slice: slice, + lto_inputs, + atmos_state: AtmosphericState, + sls_equiv_fuel_flow, + ): + """Fill NOx-related EI arrays according to the selected method.""" + method = self.method_flags.get('nox', 'none') + if method == 'none': + return + if method == 'bffm2': + if ( + sls_equiv_fuel_flow is None + or atmos_state.temperature is None + or atmos_state.pressure is None + ): + raise RuntimeError( + "BFFM2 NOx requires atmosphere and SLS equivalent fuel flow." + ) + ( + self.emission_indices['NOx'][idx_slice], + self.emission_indices['NO'][idx_slice], + self.emission_indices['NO2'][idx_slice], + self.emission_indices['HONO'][idx_slice], + *_, + ) = BFFM2_EINOx( + sls_equiv_fuel_flow=sls_equiv_fuel_flow, + NOX_EI_matrix=lto_inputs['nox_ei'], + fuelflow_performance=lto_inputs['fuel_flow'], + Pamb=atmos_state.pressure, + Tamb=atmos_state.temperature, + ) + elif method == 'p3t3': + print("P3T3 method not implemented yet..") + else: + raise NotImplementedError( + f"EI_NOx_method '{self.method_flags['nox']}' is not supported." + ) +>>>>>>> 3cd11cb (cleaned up emissions class, solution slightly more elegant now, emissions not in config set to -1):src/emissions/emission.py ################### # PRIVATE METHODS # From 3328b5e10b20f1078f906b01c2bc8cbd0f464024 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Fri, 7 Nov 2025 16:22:32 -0500 Subject: [PATCH 05/12] realized that emissions tests depended on functionality of perfmodel and traj module so rewrote all of it --- src/AEIC/emissions/APU_emissions.py | 3 +- tests/test_emissions.py | 525 ++++++++++++++-------------- 2 files changed, 255 insertions(+), 273 deletions(-) diff --git a/src/AEIC/emissions/APU_emissions.py b/src/AEIC/emissions/APU_emissions.py index 17d7038..bdc7c9a 100644 --- a/src/AEIC/emissions/APU_emissions.py +++ b/src/AEIC/emissions/APU_emissions.py @@ -57,7 +57,8 @@ def get_APU_emissions( APU_PM10 - APU_emission_indices['PMnvol'] ).item() - if nvpm_method.lower() in ('scope11', 'meem'): + nvpm_mode = (nvpm_method or '').lower() + if nvpm_mode in ('scope11', 'meem'): APU_emission_indices['PMnvolN'] = np.zeros_like(APU_emission_indices['PMvol']) APU_emission_indices['PMnvolGMD'] = np.zeros_like(APU_emission_indices['PMvol']) APU_emission_indices['OCic'] = np.zeros_like(APU_emission_indices['PMvol']) diff --git a/tests/test_emissions.py b/tests/test_emissions.py index d938889..974c48b 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -1,314 +1,295 @@ import numpy as np import pytest -import AEIC.trajectories.builders as tb -from AEIC.emissions.emission import Emission -from AEIC.missions import Mission -from AEIC.performance_model import PerformanceModel -from AEIC.utils.files import file_location -from AEIC.utils.helpers import iso_to_timestamp - -# Path to a real fuel TOML file in your repo -performance_model_file = file_location("IO/default_config.toml") - -# Path to a real fuel TOML file in your repo -perf = PerformanceModel(performance_model_file) - -sample_mission = Mission( - origin="BOS", - destination="LAX", - aircraft_type="738", - departure=iso_to_timestamp('2019-01-01 12:00:00'), - arrival=iso_to_timestamp('2019-01-01 18:00:00'), - load_factor=1.0, -) - - -# for q in db(Query()): -# mis = q - -builder = tb.LegacyBuilder(options=tb.Options(iterate_mass=False)) -traj = builder.fly(perf, sample_mission) -em = Emission(perf, traj, True) - -# --- Unit tests --- - - -def test_fuel_burn_consumption(): - """Test if fuel burn per segment sums up to total fuel consumption""" - fuel_consumed = traj.fuel_mass[0] - traj.fuel_mass[-1] - assert pytest.approx(np.sum(em.fuel_burn_per_segment), rel=1e-6) == fuel_consumed - - -def test_emission_indices_positive(): - """Test that all emission indices are non-negative""" - for field in em.emission_indices.dtype.names: - assert np.all(em.emission_indices[field] >= 0), ( - f"Negative emission index found for {field}" - ) - +from emissions.emission import AtmosphericState, Emission + + +def _scalar(value: np.ndarray | float) -> float: + """Convert 0-D/length-1 numpy structures into native floats safely.""" + arr = np.asarray(value) + if arr.size != 1: + raise AssertionError("Expected scalar-like value") + return float(arr.reshape(-1)[0]) + + +class DummyPerformanceModel: + """Lightweight stand-in for PerformanceModel with deterministic data.""" + + def __init__(self, config_overrides=None, edb_overrides=None): + base_config = { + 'Fuel': 'conventional_jetA', + 'LTO_input_mode': 'EDB', + 'EI_NOx_method': 'BFFM2', + 'EI_HC_method': 'BFFM2', + 'EI_CO_method': 'BFFM2', + 'EI_PMvol_method': 'fuel_flow', + 'EI_PMnvol_method': 'scope11', + 'CO2_calculation': True, + 'H2O_calculation': True, + 'SOx_calculation': True, + 'APU_calculation': True, + 'GSE_calculation': True, + 'LC_calculation': True, + 'climb_descent_usage': True, + } + if config_overrides: + base_config.update(config_overrides) + self.config = base_config + + base_edb = { + 'fuelflow_KGperS': np.array([0.25, 0.5, 0.9, 1.2], dtype=float), + 'NOX_EI_matrix': np.array([8.0, 12.0, 26.0, 32.0], dtype=float), + 'HC_EI_matrix': np.array([4.0, 3.0, 2.0, 1.0], dtype=float), + 'CO_EI_matrix': np.array([20.0, 15.0, 10.0, 5.0], dtype=float), + 'PMnvolEI_best_ICAOthrust': np.array([0.05, 0.07, 0.09, 0.12], dtype=float), + 'PMnvolEI_new_ICAOthrust': np.array([0.04, 0.06, 0.08, 0.11], dtype=float), + 'PMnvolEIN_best_ICAOthrust': np.array( + [1.1e13, 1.2e13, 1.3e13, 1.4e13], dtype=float + ), + 'SN_matrix': np.array([6.0, 8.0, 11.0, 13.0], dtype=float), + 'PR': np.array([22.0, 22.0, 22.0, 22.0], dtype=float), + 'ENGINE_TYPE': 'TF', + 'BP_Ratio': np.array([5.0, 5.0, 5.0, 5.0], dtype=float), + 'nvPM_mass_matrix': np.array([5.0, 5.5, 6.0, 6.5], dtype=float), + 'nvPM_num_matrix': np.array([2.0e14, 2.1e14, 2.2e14, 2.3e14], dtype=float), + 'EImass_max': 8.0, + 'EImass_max_thrust': 0.575, + 'EInum_max': 2.4e14, + 'EInum_max_thrust': 0.575, + 'EImass_max_alt': 0.85, + } + if edb_overrides: + base_edb.update(edb_overrides) + self.EDB_data = base_edb + + self.APU_data = { + 'fuel_kg_per_s': 0.03, + 'PM10_g_per_kg': 0.4, + 'NOx_g_per_kg': 0.05, + 'HC_g_per_kg': 0.02, + 'CO_g_per_kg': 0.03, + } + self.model_info = { + 'General_Information': {'aircraft_class': 'wide', 'n_eng': 2}, + } + + +class DummyTrajectory: + """Minimal trajectory profile with monotonic fuel depletion.""" + + def __init__(self): + self.NClm = 2 + self.NCrz = 2 + self.NDes = 2 + self.Ntot = self.NClm + self.NCrz + self.NDes + fuel_mass = np.array([2000.0, 1994.0, 1987.5, 1975.0, 1960.0, 1945.0]) + self.traj_data = { + 'fuelMass': fuel_mass, + 'fuelFlow': np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32]), + 'altitude': np.array([0.0, 1500.0, 6000.0, 11000.0, 9000.0, 2000.0]), + 'tas': np.array([120.0, 150.0, 190.0, 210.0, 180.0, 140.0]), + } + self.fuel_mass = float(fuel_mass[0]) + + +@pytest.fixture +def trajectory(): + return DummyTrajectory() + + +@pytest.fixture +def perf_factory(): + def _factory(config_overrides=None, edb_overrides=None): + return DummyPerformanceModel(config_overrides, edb_overrides) + + return _factory + + +@pytest.fixture +def emission(perf_factory, trajectory): + perf = perf_factory() + em = Emission(perf, trajectory) + em.compute_emissions() + return em + + +def test_emission_settings_parse_string_flags(perf_factory, trajectory): + perf = perf_factory( + { + 'APU_calculation': 'no', + 'GSE_calculation': 'Yes', + 'LC_calculation': 'n', + 'EI_PMvol_method': 'FOA3', + } + ) + em = Emission(perf, trajectory) + assert em.apu_enabled is False + assert em.gse_enabled is True + assert em.lifecycle_enabled is False + assert em.pmvol_method == 'foa3' -def test_emission_indices_finite(): - """Test that all emission indices are finite (no NaN or inf)""" - for field in em.emission_indices.dtype.names: - assert np.all(np.isfinite(em.emission_indices[field])), ( - f"Non-finite emission index found for {field}" - ) +def test_compute_emissions_populates_active_fields(emission): + FILL_VALUE = -1.0 + for field in emission._active_fields: + indices = emission.emission_indices[field] + assert np.all(indices != FILL_VALUE) + assert np.all(indices >= 0.0) + assert np.all(emission.pointwise_emissions_g[field] >= 0.0) -def test_pointwise_emissions_positive(): - """Test that all pointwise emissions are non-negative""" - for field in em.pointwise_emissions_g.dtype.names: - assert np.all(em.pointwise_emissions_g[field] >= 0), ( - f"Negative pointwise emission found for {field}" - ) +def test_apu_and_gse_outputs_positive(emission): + for store in (emission.APU_emissions_g, emission.GSE_emissions_g): + for field in store.dtype.names: + assert _scalar(store[field]) >= 0.0 -def test_lto_emissions_positive(): - """Test that all LTO emissions are non-negative""" - for field in em.LTO_emissions_g.dtype.names: - assert np.all(em.LTO_emissions_g[field] >= 0), ( - f"Negative LTO emission found for {field}" - ) - -def test_apu_emissions_positive(): - """Test that all APU emissions are non-negative""" - for field in em.APU_emissions_g.dtype.names: - assert np.all(em.APU_emissions_g[field] >= 0), ( - f"Negative APU emission found for {field}" - ) +def test_total_fuel_burn_positive(emission): + assert emission.total_fuel_burn > 0.0 -def test_gse_emissions_positive(): - """Test that all GSE emissions are non-negative""" - for field in em.GSE_emissions_g.dtype.names: - assert np.all(em.GSE_emissions_g[field] >= 0), ( - f"Negative GSE emission found for {field}" - ) +def test_sum_total_emissions_matches_components(perf_factory, trajectory): + perf = perf_factory({'LC_calculation': False}) + em = Emission(perf, trajectory) + em.compute_emissions() - -def test_total_emissions_sum(): - """Test that summed emissions equal the sum of all components""" for field in em.summed_emission_g.dtype.names: - calculated_sum = ( + expected = ( np.sum(em.pointwise_emissions_g[field]) + np.sum(em.LTO_emissions_g[field]) - + em.APU_emissions_g[field] - + em.GSE_emissions_g[field] + + _scalar(em.APU_emissions_g[field]) + + _scalar(em.GSE_emissions_g[field]) ) - if field == 'CO2': - # CO2 includes lifecycle adjustment, so check before lifecycle addition - original_co2 = em.summed_emission_g[field] - ( - em.fuel['LC_CO2'] * (traj.total_fuel_mass * em.fuel['Energy_MJ_per_kg']) - - ( - np.sum(em.pointwise_emissions_g[field]) - + np.sum(em.LTO_emissions_g[field]) - + em.APU_emissions_g[field] - + em.GSE_emissions_g[field] - ) - ) - assert pytest.approx(original_co2, rel=1e-6) == calculated_sum - else: - assert ( - pytest.approx(em.summed_emission_g[field], rel=1e-6) == calculated_sum - ), ( - f"Sum mismatch for {field}:" - f"{em.summed_emission_g[field]} vs {calculated_sum}" - ) - - -def test_trajectory_dimensions(): - """Test that trajectory dimensions are consistent""" - assert em.n_total == len(em.fuel_burn_per_segment) - assert em.n_total == len(traj) - assert em.n_climb + em.n_cruise + em.n_descent == em.n_total - - -def test_fuel_burn_first_segment(): - """Test that first segment has zero fuel burn (initial condition)""" - assert em.fuel_burn_per_segment[0] == 0, "First segment should have zero fuel burn" - - -def test_fuel_burn_decreasing_mass(): - """Test that fuel mass is monotonically decreasing""" - fuel_mass = traj.fuel_mass - assert np.all(np.diff(fuel_mass) <= 0), ( - "Fuel mass should be monotonically decreasing" - ) + assert _scalar(em.summed_emission_g[field]) == pytest.approx(expected, rel=1e-6) -def test_nox_speciation_conservation(): - """Test that NOx speciation sums to total NOx for LTO""" - if ( - hasattr(em, 'LTO_noProp') - and hasattr(em, 'LTO_no2Prop') - and hasattr(em, 'LTO_honoProp') - ): - total_prop = em.LTO_noProp + em.LTO_no2Prop + em.LTO_honoProp - assert pytest.approx(total_prop, abs=1e-3) == 1.0, ( - "NOx speciation fractions should sum to 1" - ) +def test_lifecycle_emissions_override_total_co2(emission): + expected = emission.fuel['LC_CO2'] * ( + emission.trajectory.fuel_mass * emission.fuel['Energy_MJ_per_kg'] + ) + assert _scalar(emission.summed_emission_g['CO2']) == pytest.approx(expected) -def test_sox_speciation(): - """Test that SO2 and SO4 are both present and reasonable""" - so2_total = np.sum(em.pointwise_emissions_g['SO2']) + np.sum( - em.LTO_emissions_g['SO2'] - ) - so4_total = np.sum(em.pointwise_emissions_g['SO4']) + np.sum( - em.LTO_emissions_g['SO4'] +def test_scope11_profile_caching(emission): + profile_first = emission._scope11_profile(emission.performance_model) + profile_second = emission._scope11_profile(emission.performance_model) + assert profile_first is profile_second + assert ( + profile_first['mass'].shape + == emission.performance_model.EDB_data['SN_matrix'].shape ) - if so2_total > 0 or so4_total > 0: - # SO2 should typically be much larger than SO4 - assert so2_total > so4_total, "SO2 emissions should exceed SO4 emissions" - - -def test_gse_wnsf_mapping(): - """Test GSE emissions for different WNSF categories""" - wnsf_codes = ['wide', 'narrow', 'small', 'freight'] - for wnsf in wnsf_codes: - # Create temporary emission object to test GSE mapping - temp_em = Emission.__new__(Emission) # Create without calling __init__ - temp_em.pmnvol_mode = 'SCOPE11' - temp_em.fuel = {"EI_CO2": 3155.6, "nvolCarbCont": 0.95, "EI_H2O": 1233.3865} - temp_em.total_fuel_burn = 0.0 - temp_em.GSE_emissions_g = np.zeros( - (), dtype=temp_em._Emission__emission_dtype(1) - ) - - # This should not raise an error - temp_em.get_GSE_emissions(wnsf) - # Check that emissions were assigned - assert temp_em.GSE_emissions_g['CO2'] > 0, ( - f"GSE CO2 not assigned for WNSF {wnsf}" - ) - assert temp_em.GSE_emissions_g['NOx'] > 0, ( - f"GSE NOx not assigned for WNSF {wnsf}" - ) +def test_get_lto_tims_respects_traj_flag(perf_factory, trajectory): + em_all = Emission(perf_factory({'climb_descent_usage': True}), trajectory) + durations_all = em_all._get_LTO_TIMs() + assert np.allclose(durations_all[1:3], 0.0) + em_partial = Emission(perf_factory({'climb_descent_usage': False}), trajectory) + durations_partial = em_partial._get_LTO_TIMs() + assert np.all(durations_partial[1:3] > 0.0) -def test_gse_invalid_wnsf(): - """Test that invalid WNSF code raises ValueError""" - temp_em = Emission.__new__(Emission) - temp_em.pmnvol_mode = 'SCOPE11' - temp_em.GSE_emissions_g = np.zeros((), dtype=temp_em._Emission__emission_dtype(1)) +def test_wnsf_index_mapping_and_errors(perf_factory, trajectory): + em = Emission(perf_factory(), trajectory) + assert em._wnsf_index('wide') == 0 + assert em._wnsf_index('FREIGHT') == 3 with pytest.raises(ValueError): - temp_em.get_GSE_emissions('x') # Invalid WNSF code - - -def test_emission_dtype_consistency(): - """Test that emission data type is consistent across arrays""" - dtype_names = set(em.emission_indices.dtype.names) - - # All emission arrays should have the same field names - assert set(em.pointwise_emissions_g.dtype.names) == dtype_names - assert set(em.LTO_emissions_g.dtype.names) == dtype_names - assert set(em.APU_emissions_g.dtype.names) == dtype_names - assert set(em.GSE_emissions_g.dtype.names) == dtype_names - assert set(em.summed_emission_g.dtype.names) == dtype_names + em._wnsf_index('unknown') + + +def test_calculate_pmvol_requires_hc_for_foa3(perf_factory, trajectory): + em = Emission(perf_factory({'EI_PMvol_method': 'foa3'}), trajectory) + idx_slice = em._trajectory_slice() + fuel_flow = em.trajectory.traj_data['fuelFlow'][idx_slice] + thrust_categories = np.ones_like(fuel_flow, dtype=int) + with pytest.raises(RuntimeError): + em._calculate_EI_PMvol( + idx_slice, + thrust_categories, + fuel_flow, + None, + ) -def test_pm_components_reasonable(): - """Test that PM components (volatile and non-volatile) are reasonable""" - pmvol_total = np.sum(em.pointwise_emissions_g['PMvol']) + np.sum( - em.LTO_emissions_g['PMvol'] +def test_calculate_pmnvol_scope11_populates_fields(perf_factory, trajectory): + em = Emission(perf_factory({'EI_PMnvol_method': 'scope11'}), trajectory) + idx_slice = em._trajectory_slice() + thrust_categories = np.ones_like( + em.trajectory.traj_data['fuelFlow'][idx_slice], dtype=int ) - pmnvol_total = np.sum(em.pointwise_emissions_g['PMnvol']) + np.sum( - em.LTO_emissions_g['PMnvol'] + em._calculate_EI_PMnvol( + idx_slice, + thrust_categories, + em.trajectory.traj_data['altitude'][idx_slice], + AtmosphericState(None, None, None), + em.performance_model, ) - - # Both components should be positive if PM is present - if pmvol_total > 0 or pmnvol_total > 0: - assert pmvol_total >= 0 and pmnvol_total >= 0, ( - "Both PM components should be non-negative" - ) - - -def test_trajectory_vs_lto_mode_consistency(): - """Test consistency based on traj_emissions_all flag""" - if em.traj_emissions_all: - # Only taxi mode for LTO - assert em.LTO_emissions_g.shape == (), ( - "Should have scalar LTO arrays when traj_emissions_all=True" - ) - else: - # Full LTO cycle - assert len(em.LTO_emission_indices['NOx']) == 4, ( - "Should have 4 LTO modes when traj_emissions_all=False" - ) - - -def test_fuel_properties_loaded(): - """Test that fuel properties are properly loaded""" - assert hasattr(em, 'fuel'), "Fuel properties not loaded" - assert 'LC_CO2' in em.fuel, "Lifecycle CO2 factor not in fuel properties" - assert 'Energy_MJ_per_kg' in em.fuel, "Energy content not in fuel properties" - - -def test_lifecycle_co2_adjustment(): - """Test that lifecycle CO2 adjustment is applied correctly""" - # The lifecycle adjustment should increase total CO2 - base_co2 = ( - np.sum(em.pointwise_emissions_g['CO2']) - + np.sum(em.LTO_emissions_g['CO2']) - + em.APU_emissions_g['CO2'] - + em.GSE_emissions_g['CO2'] + assert np.all(em.emission_indices['PMnvol'][idx_slice] >= 0.0) + assert np.all(em.emission_indices['PMnvolGMD'][idx_slice] == 0.0) + + +def test_calculate_pmnvol_meem_populates_fields(perf_factory, trajectory): + em = Emission(perf_factory({'EI_PMnvol_method': 'meem'}), trajectory) + idx_slice = em._trajectory_slice() + altitudes = em.trajectory.traj_data['altitude'][idx_slice] + tas = em.trajectory.traj_data['tas'][idx_slice] + atmos = em._atmospheric_state(altitudes, tas, True) + thrust_categories = np.ones_like( + em.trajectory.traj_data['fuelFlow'][idx_slice], dtype=int ) - print(f"{em.summed_emission_g['CO2']}") - assert em.summed_emission_g['CO2'] != base_co2, ( - "Lifecycle CO2 adjustment should modify total" + em._calculate_EI_PMnvol( + idx_slice, thrust_categories, altitudes, atmos, em.performance_model ) + assert np.all(em.emission_indices['PMnvol'][idx_slice] >= 0.0) + assert np.all(em.emission_indices['PMnvolGMD'][idx_slice] >= 0.0) + if 'PMnvolN' in em.emission_indices.dtype.names: + assert np.all(em.emission_indices['PMnvolN'][idx_slice] >= 0.0) + + +def test_compute_ei_nox_requires_inputs(perf_factory, trajectory): + em = Emission(perf_factory(), trajectory) + idx_slice = em._trajectory_slice() + lto_inputs = em._extract_lto_inputs() + with pytest.raises(RuntimeError): + em.compute_EI_NOx( + idx_slice, lto_inputs, AtmosphericState(None, None, None), None + ) -def test_no_negative_fuel_burn(): - """Test that no segment has negative fuel burn (except first which is zero)""" - assert np.all(em.fuel_burn_per_segment >= 0), "Negative fuel burn detected" +def test_atmospheric_state_and_sls_flow_shapes(perf_factory, trajectory): + em = Emission(perf_factory(), trajectory) + idx_slice = em._trajectory_slice() + altitudes = em.trajectory.traj_data['altitude'][idx_slice] + tas = em.trajectory.traj_data['tas'][idx_slice] + atmos = em._atmospheric_state(altitudes, tas, True) + assert atmos.temperature.shape == altitudes.shape + assert atmos.pressure.shape == altitudes.shape + assert atmos.mach.shape == altitudes.shape + fuel_flow = em.trajectory.traj_data['fuelFlow'][idx_slice] + sls_flow = em._sls_equivalent_fuel_flow(True, fuel_flow, atmos) + assert sls_flow.shape == fuel_flow.shape + assert em._sls_equivalent_fuel_flow(False, fuel_flow, atmos) is None -def test_emission_units_consistency(): - """Test that emission units are consistent (all in grams)""" - # This is more of a documentation test - # ensures the class produces results in expected units - total_co2_kg = np.sum(em.pointwise_emissions_g['CO2']) / 1000.0 # Convert g to kg - fuel_consumed_kg = traj.fuel_mass[0] - traj.fuel_mass[-1] - # CO2 per kg fuel should be reasonable (typically 3.1-3.2 kg CO2/kg fuel) - if fuel_consumed_kg > 0: - co2_per_fuel = total_co2_kg / fuel_consumed_kg - assert 2.5 <= co2_per_fuel <= 4.4, ( - f"CO2 per fuel ratio {co2_per_fuel} outside expected range" - ) +def test_get_gse_emissions_assigns_all_species(perf_factory, trajectory): + em = Emission(perf_factory(), trajectory) + em.get_GSE_emissions('wide') + for field in ('CO2', 'NOx', 'HC', 'CO', 'PMvol', 'PMnvol', 'H2O', 'SO2', 'SO4'): + assert _scalar(em.GSE_emissions_g[field]) >= 0.0 -@pytest.mark.parametrize("pmnvol_mode", ['SCOPE11', 'foa3', 'fox']) -def test_different_pmnvol_modes(pmnvol_mode): - """Test that different PMnvol modes produce valid results""" - # This test would require creating new Emission instances with different modes - # For now, just test that the current mode produces valid results - if em.pmnvol_mode == pmnvol_mode: - assert np.all(np.isfinite(em.LTO_emission_indices['PMnvol'])), ( - f"Invalid PMnvol values for mode {pmnvol_mode}" - ) - - -def test_array_shapes_consistency(): - """Test that all arrays have consistent shapes""" - n_traj = em.n_total +def test_get_gse_emissions_invalid_code(perf_factory, trajectory): + em = Emission(perf_factory(), trajectory) + with pytest.raises(ValueError): + em.get_GSE_emissions('bad') - # Trajectory arrays should match n_total - assert em.emission_indices['CO2'].shape == (n_traj,), ( - "Emission indices shape mismatch" - ) - assert em.pointwise_emissions_g['CO2'].shape == (n_traj,), ( - "Pointwise emissions shape mismatch" - ) - assert em.fuel_burn_per_segment.shape == (n_traj,), "Fuel burn array shape mismatch" - # Summary arrays should be scalars - assert em.summed_emission_g['CO2'].shape == (1,), ( - "Summed emissions should be scalar" - ) +def test_emission_dtype_consistency(emission): + dtype_names = set(emission.emission_indices.dtype.names) + assert set(emission.pointwise_emissions_g.dtype.names) == dtype_names + assert set(emission.LTO_emissions_g.dtype.names) == dtype_names + assert set(emission.APU_emissions_g.dtype.names) == dtype_names + assert set(emission.GSE_emissions_g.dtype.names) == dtype_names + assert set(emission.summed_emission_g.dtype.names) == dtype_names From 9cd9cd0c78d2c627291576ffab7c55aa3764801a Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Wed, 12 Nov 2025 15:35:04 -0500 Subject: [PATCH 06/12] redo of emissions and slight updates to performance model after Ian review --- data/IO/default_config.toml | 2 +- src/AEIC/emissions/EI_PMvol.py | 2 +- src/AEIC/emissions/emission.py | 889 ++++++++++++++++++++++++------- src/AEIC/performance_model.py | 146 ++++- src/AEIC/utils/inspect_inputs.py | 25 + 5 files changed, 851 insertions(+), 213 deletions(-) create mode 100644 src/AEIC/utils/inspect_inputs.py diff --git a/data/IO/default_config.toml b/data/IO/default_config.toml index c03a823..837242b 100644 --- a/data/IO/default_config.toml +++ b/data/IO/default_config.toml @@ -63,7 +63,7 @@ EI_PMnvol_method = "MEEM" # Options include # SCOPE11: SCOPE11 methodology # None: Do not compute PMnvol emissions -LTO_input_mode = "EDB" # "PerformanceModel" : LTO data provided in PerformanceModel file +LTO_input_mode = "EDB" # "performance_model" : LTO data provided in PerformanceModel file # "EDB" : Use EDB data to get LTO performance EDB_input_file = "engines/EDB_data_06_2025.toml" diff --git a/src/AEIC/emissions/EI_PMvol.py b/src/AEIC/emissions/EI_PMvol.py index a5cee3e..c60f66b 100644 --- a/src/AEIC/emissions/EI_PMvol.py +++ b/src/AEIC/emissions/EI_PMvol.py @@ -1,7 +1,7 @@ import numpy as np -def EI_PMvol_NEW(fuelflow: np.ndarray, thrustCat: np.ndarray): +def EI_PMvol_FuelFlow(fuelflow: np.ndarray, thrustCat: np.ndarray): """ Calculate EI(PMvolo) and OCicEI based on fuel flow diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index 285f0c5..eb0d05b 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -1,15 +1,20 @@ # Emissions class +from __future__ import annotations + import tomllib from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum +from typing import Any import numpy as np from AEIC.performance_model import PerformanceModel -from AEIC.trajectories import Trajectory -from AEIC.utils.consts import R_air, kappa -from AEIC.utils.files import file_location -from AEIC.utils.standard_atmosphere import ( +from AEIC.trajectories.trajectory import Trajectory +from utils import file_location +from utils.consts import R_air, kappa +from utils.inspect_inputs import as_bool, require_str +from utils.standard_atmosphere import ( pressure_at_altitude_isa_bada4, temperature_at_altitude_isa_bada4, ) @@ -21,122 +26,314 @@ from .EI_HCCO import EI_HCCO from .EI_NOx import BFFM2_EINOx, NOx_speciation from .EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 -from .EI_PMvol import EI_PMvol_FOA3, EI_PMvol_NEW +from .EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow from .EI_SOx import EI_SOx +######################## +# INPUT CONFIG CLASSES # +######################## +class LTOInputMode(Enum): + """Config for selecting input modes for LTO emissions""" + + # LTO INPUT OPTIONS + PERFORMANCE_MODEL = "performance_model" + EDB = "edb" + + @property + def requires_edb_file(self) -> bool: + """EDB mode needs an engine databank file path.""" + return self is LTOInputMode.EDB + + @classmethod + def from_value(cls, value: str | None) -> LTOInputMode: + normalized = (value or cls.EDB.value).strip().lower() + compact = normalized.replace("_", "") + if compact == "performancemodel": + return cls.PERFORMANCE_MODEL + if compact == "edb": + return cls.EDB + raise ValueError( + f"LTO_input_mode must be 'performance_model' or 'EDB'; received {value!r}." + ) + + +class EINOxMethod(Enum): + """Config for selecting input modes for NOx emissions""" + + # NOx emission method options + BFFM2 = "bffm2" + P3T3 = "p3t3" + NONE = "none" + + @classmethod + def from_value(cls, value: str | None, *, default: EINOxMethod) -> EINOxMethod: + if value is None: + return default + normalized = value.strip().lower() + for method in cls: + if method.value == normalized: + return method + raise ValueError( + f"Emission method '{value}' is invalid. " + f"Valid options: {[m.value for m in cls]}" + ) + + +class PMvolMethod(Enum): + """Config for selecting input modes for PMvol emissions""" + + # PMvol emission method options + FUEL_FLOW = "fuel_flow" + FOA3 = "foa3" + NONE = "none" + + @classmethod + def from_value(cls, value: str | None, *, default: PMvolMethod) -> PMvolMethod: + normalized = (value or default.value).strip().lower() + for method in cls: + if method.value == normalized: + return method + raise ValueError( + f"EI_PMvol_method '{value}' is invalid. " + f"Valid options: {[m.value for m in cls]}" + ) + + +class PMnvolMethod(Enum): + """Config for selecting input modes for PMnvol emissions""" + + # PMnvol emission method options + MEEM = "meem" + SCOPE11 = "scope11" + FOA3 = "foa3" + NONE = "none" + + @classmethod + def from_value( + cls, + value: str | None, + *, + default: PMnvolMethod, + ) -> PMnvolMethod: + normalized = (value or default.value).strip().lower() + for method in cls: + if method.value == normalized: + return method + raise ValueError( + f"EI_PMnvol_method '{value}' is invalid. " + f"Valid options: {[m.value for m in cls]}" + ) + + +########################## +# Emissions Config Class # +########################## +@dataclass(frozen=True) +class EmissionsConfig: + """Validated user-configurable inputs for emissions modeling. + + Wraps the raw performance-model config/TOML, enforces defaults, and keeps + every param needed to build emission settings (fuel selection, method + choices, LTO input mode, and optional components like APU/GSE/lifecycle).""" + + # Fuel Info + fuel_name: str # Fuel used (conventional Jet-A, SAF, etc) + fuel_file: str # Location of fuel data toml file + # Trajectory emissions config + climb_descent_usage: bool + # Emission calculation flags for only fuel dependent emission calculations + co2_enabled: bool + h2o_enabled: bool + sox_enabled: bool + # Emission calculation method options for all other emmisions + nox_method: EINOxMethod + hc_method: EINOxMethod + co_method: EINOxMethod + pmvol_method: PMvolMethod + pmnvol_method: PMnvolMethod + # LTO emission config + lto_input_mode: LTOInputMode + edb_input_file: str + # Non trajectory emission calculation flags + apu_calculation: bool + gse_calculation: bool + lc_calculation: bool + + @classmethod + def from_mapping(cls, mapping: Mapping[str, Any]) -> EmissionsConfig: + fuel_name = mapping.get('Fuel', 'conventional_jetA') + if not isinstance(fuel_name, str) or not fuel_name.strip(): + raise ValueError("Emissions.Fuel must be a non-empty string.") + default_method = EINOxMethod.BFFM2 + lto_mode = LTOInputMode.from_value(mapping.get('LTO_input_mode')) + # If LTO input = EDB, needs a user-specified engine databank file path. + if lto_mode.requires_edb_file: + edb_input_file = require_str(mapping, 'EDB_input_file') + else: + raw_file = mapping.get('EDB_input_file') + edb_input_file = raw_file.strip() if isinstance(raw_file, str) else "" + return cls( + fuel_name=fuel_name, + fuel_file=f"fuels/{fuel_name}.toml", + co2_enabled=as_bool(mapping.get('CO2_calculation'), default=True), + h2o_enabled=as_bool(mapping.get('H2O_calculation'), default=True), + sox_enabled=as_bool(mapping.get('SOx_calculation'), default=True), + nox_method=EINOxMethod.from_value( + mapping.get('EI_NOx_method'), default=default_method + ), + hc_method=EINOxMethod.from_value( + mapping.get('EI_HC_method'), default=default_method + ), + co_method=EINOxMethod.from_value( + mapping.get('EI_CO_method'), default=default_method + ), + pmvol_method=PMvolMethod.from_value( + mapping.get('EI_PMvol_method'), default=PMvolMethod.FUEL_FLOW + ), + pmnvol_method=PMnvolMethod.from_value( + mapping.get('EI_PMnvol_method'), default=PMnvolMethod.MEEM + ), + lto_input_mode=lto_mode, + edb_input_file=edb_input_file, + climb_descent_usage=as_bool( + mapping.get('climb_descent_usage'), default=True + ), + apu_calculation=as_bool(mapping.get('APU_calculation'), default=True), + gse_calculation=as_bool(mapping.get('GSE_calculation'), default=True), + lc_calculation=as_bool(mapping.get('LC_calculation'), default=True), + ) + + @dataclass(frozen=True) class EmissionSettings: + """Flattened runtime settings consumed by class:Emission. + + Holds the precomputed flags, resolved enums, and file references needed to + run emission computations without re-reading or re-validating the original + config, plus a compact `metric_flags` map for toggling individual outputs.""" + fuel_file: str traj_all: bool apu_enabled: bool gse_enabled: bool - pmvol_method: str - pmnvol_method: str lifecycle_enabled: bool + pmvol_method: PMvolMethod + pmnvol_method: PMnvolMethod + nox_method: EINOxMethod + hc_method: EINOxMethod + co_method: EINOxMethod metric_flags: Mapping[str, bool] - method_flags: Mapping[str, str] @dataclass(frozen=True) class AtmosphericState: + """Per-segment Atmospheric state used by EI models. + + Temperature (K) and pressure (Pa) follow the trajectory indexing, while + `mach` is derived from TAS. Fields may be None when a method does not + require atmosphere data (e.g., when NOx/PM indices are disabled).""" + temperature: np.ndarray | None pressure: np.ndarray | None mach: np.ndarray | None +@dataclass(frozen=True) +class EmissionSlice: + """Emission data for a single source such as trajectory, LTO, APU, or GSE. + + `indices` identifies the contributing mission segments (or None for + aggregate-only sources) and `emissions_g` is the structured per-species + array of integrated masses in grams aligned with those indices.""" + + indices: np.ndarray | None + emissions_g: np.ndarray + + +@dataclass(frozen=True) +class TrajectoryEmissionSlice(EmissionSlice): + """EmissionSlice specialized for total trajectory contribution. + + Adds per-segment fuel burn (kg) and the total fuel burned so downstream + analysis can compute intensity metrics or lifecycle adjustments alongside + the species masses.""" + + fuel_burn_per_segment: np.ndarray + total_fuel_burn: float + + +@dataclass(frozen=True) +class EmissionsOutput: + """Data container that stores aggregated emissions for a mission + broken down by flight phase/components.""" + + trajectory: TrajectoryEmissionSlice + lto: EmissionSlice + apu: EmissionSlice | None + gse: EmissionSlice | None + total: np.ndarray + lifecycle_co2_g: float | None = None + + class Emission: """ - Model for determining and aggregating flight emissions across all mission segments, - including cruise trajectory, LTO (Landing and Take-Off), APU, and GSE emissions, - as well as lifecycle CO2 adjustments. + Stateless emissions calculator that can be reused across trajectories. + Configure it with a ``PerformanceModel`` once, then call ``emit`` with each + trajectory to obtain an :class:`EmissionsOutput` data container. """ - def __init__(self, ac_performance: PerformanceModel, trajectory: Trajectory): + def __init__(self, ac_performance: PerformanceModel): """ - Initialize emissions model: - Parameters ---------- ac_performance : PerformanceModel Aircraft performance object containing climb/cruise/descent performance and LTO data. - trajectory : Trajectory - Flight trajectory for mission object with altitude, speed, - and fuel mass time series. """ - self.trajectory = trajectory self.performance_model = ac_performance - settings = self._parse_emission_settings(ac_performance.config) - self.traj_emissions_all = settings['traj_all'] - self.pmvol_method = settings['pmvol_method'] - self.pmnvol_method = settings['pmnvol_method'] - self.apu_enabled = settings['apu'] - self.gse_enabled = settings['gse'] - self.lifecycle_enabled = settings['lifecycle'] - self.metric_flags = settings['metric_flags'] - self.method_flags = settings['methods'] - self._include_pmnvol_number = self.metric_flags[ - 'PMnvol' - ] and self.pmnvol_method in ('scope11', 'meem') + self.conf = self._parse_emission_settings(ac_performance.config.emissions) + + self.metric_flags = dict(self.conf.metric_flags) + + self._include_pmnvol_number = self.metric_flags['PMnvol'] and ( + self.conf.pmnvol_method in (PMnvolMethod.SCOPE11, PMnvolMethod.MEEM) + ) self._scope11_cache = None - with open(file_location(settings['fuel_file']), 'rb') as f: + with open(file_location(self.conf.fuel_file), 'rb') as f: self.fuel = tomllib.load(f) self.co2_ei, self.nvol_carb_cont = EI_CO2(self.fuel) self.h2o_ei = EI_H2O(self.fuel) self.so2_ei, self.so4_ei = EI_SOx(self.fuel) - # Unpack trajectory lengths: total, climb, cruise, descent points - self.Ntot = trajectory.Ntot - self.NClm = trajectory.NClm - self.NCrz = trajectory.NCrz - self.NDes = trajectory.NDes + self._reset_run_state() - fill_value = -1.0 # Setting all emissions to -1 at the start - # This helps in testing as any value still -1 - # after computing emissions means something is wrong - self.emission_indices = np.full( - (), fill_value, dtype=self.__emission_dtype(self.Ntot) - ) - self.LTO_emission_indices = np.full( - (), fill_value, dtype=self.__emission_dtype(4) - ) - self.LTO_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(4)) - self.APU_emission_indices = np.full( - (), fill_value, dtype=self.__emission_dtype(1) - ) - self.APU_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) - self.GSE_emissions_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) - self.pointwise_emissions_g = np.full( - (), fill_value, dtype=self.__emission_dtype(self.Ntot) - ) - self.summed_emission_g = np.full((), fill_value, dtype=self.__emission_dtype(1)) + def emit(self, trajectory: Trajectory) -> EmissionsOutput: + """Compute emissions for a single trajectory and return structured results.""" + self._prepare_run_state(trajectory) + self.compute_emissions() + lifecycle_adjustment = None + if self.metric_flags.get('CO2') and self.conf.lifecycle_enabled: + lifecycle_adjustment = self.get_lifecycle_emissions(self.fuel, trajectory) + return self._collect_emissions(lifecycle_adjustment) - self._initialize_field_controls() - self._apply_metric_mask( - self.emission_indices, - self.LTO_emission_indices, - self.LTO_emissions_g, - self.APU_emission_indices, - self.APU_emissions_g, - self.GSE_emissions_g, - self.pointwise_emissions_g, - self.summed_emission_g, - ) + def emit_trajectory(self, trajectory: Trajectory) -> TrajectoryEmissionSlice: + """Convenience helper returning only the trajectory portion of emissions.""" + return self.emit(trajectory).trajectory - # Compute fuel burn per segment from fuelMass time series - fuel_mass = trajectory.traj_data['fuelMass'] - fuel_burn = np.zeros_like(fuel_mass) - # Difference between sequential mass values for ascent segments - fuel_burn[1:] = fuel_mass[:-1] - fuel_mass[1:] - self.fuel_burn_per_segment = fuel_burn + def emit_lto(self, trajectory: Trajectory) -> EmissionSlice: + """Convenience helper returning only the LTO emissions.""" + return self.emit(trajectory).lto - self.total_fuel_burn = 0.0 - self.LTO_noProp = np.zeros(4) - self.LTO_no2Prop = np.zeros(4) - self.LTO_honoProp = np.zeros(4) + def emit_apu(self, trajectory: Trajectory) -> EmissionSlice | None: + """Convenience helper returning only the APU emissions (if enabled).""" + return self.emit(trajectory).apu + + def emit_gse(self, trajectory: Trajectory) -> EmissionSlice | None: + """Convenience helper returning only the GSE emissions (if enabled).""" + return self.emit(trajectory).gse def compute_emissions(self): """ @@ -148,7 +345,7 @@ def compute_emissions(self): # Calculate LTO emissions for ground and approach/climb modes self.get_LTO_emissions() - if self.apu_enabled: + if self.conf.apu_enabled: ( self.APU_emission_indices, self.APU_emissions_g, @@ -162,14 +359,14 @@ def compute_emissions(self): self.LTO_no2Prop, self.LTO_honoProp, EI_H2O=self.h2o_ei, - nvpm_method=self.pmnvol_method, + nvpm_method=self.conf.pmnvol_method.value, ) self._apply_metric_mask(self.APU_emission_indices) self._apply_metric_mask(self.APU_emissions_g) self.total_fuel_burn += apu_fuel_burn # Compute Ground Service Equipment (GSE) emissions based on WNSF type - if self.gse_enabled: + if self.conf.gse_enabled: self.get_GSE_emissions( self.performance_model.model_info['General_Information'][ 'aircraft_class' @@ -180,33 +377,23 @@ def compute_emissions(self): # Sum all emission contributions: trajectory + LTO + APU + GSE self.sum_total_emissions() - # Add lifecycle CO2 emissions to total - if self.metric_flags['CO2'] and self.lifecycle_enabled: - self.get_lifecycle_emissions(self.fuel, self.trajectory) - def sum_total_emissions(self): """ Aggregate emissions (g) across all sources into summed_emission_g. Sums pointwise trajectory, LTO, APU, and GSE emissions for each species. """ for field in self.summed_emission_g.dtype.names: - self.summed_emission_g[field] = ( - np.sum(self.pointwise_emissions_g[field]) - + np.sum(self.LTO_emissions_g[field]) - + self.APU_emissions_g[field] - + self.GSE_emissions_g[field] - ) + total = np.sum(self.pointwise_emissions_g[field]) + total += np.sum(self.LTO_emissions_g[field]) + if self.conf.apu_enabled: + total += np.sum(self.APU_emissions_g[field]) + if self.conf.gse_enabled: + total += np.sum(self.GSE_emissions_g[field]) + self.summed_emission_g[field] = total def get_trajectory_emissions(self): """ - Calculate emission indices (g/species per kg fuel) for each flight segment. - - Parameters - ---------- - trajectory : Trajectory - Contains altitudes, speeds, and fuel flows for each time step. - ac_performance : PerformanceModel - Provides EDB or LTO data matrices for EI lookup. + Calculate emissions for each flight trajectory point. """ trajectory = self.trajectory ac_performance = self.performance_model @@ -222,12 +409,12 @@ def get_trajectory_emissions(self): thrust_categories = get_thrust_cat(fuel_flow, lto_ff_array, cruiseCalc=True) needs_hc = self.metric_flags['HC'] or ( - self.metric_flags['PMvol'] and self.pmvol_method == 'foa3' + self.metric_flags['PMvol'] and self.conf.pmvol_method is PMvolMethod.FOA3 ) needs_co = self.metric_flags['CO'] needs_nox = self.metric_flags['NOx'] needs_pmnvol_meem = self.metric_flags['PMnvol'] and ( - self.pmnvol_method == 'meem' + self.conf.pmnvol_method == 'meem' ) needs_atmos = needs_hc or needs_co or needs_nox or needs_pmnvol_meem @@ -505,10 +692,7 @@ def get_LTO_emissions(self): self.total_fuel_burn += np.sum(LTO_fuel_burn) for field in self.LTO_emission_indices.dtype.names: - if ( - self.traj_emissions_all - and field in self.LTO_emission_indices.dtype.names - ): + if self.conf.traj_all and field in self.LTO_emission_indices.dtype.names: self.LTO_emission_indices[field][1:-1] = 0.0 self.LTO_emissions_g[field] = ( self.LTO_emission_indices[field] * LTO_fuel_burn @@ -564,18 +748,20 @@ def get_GSE_emissions(self, wnsf): self.GSE_emissions_g['PMnvolGMD'] = 0.0 self.GSE_emissions_g['OCic'] = 0.0 - def get_lifecycle_emissions(self, fuel, traj): + def get_lifecycle_emissions(self, fuel, traj) -> float | None: """Apply lifecycle CO2 adjustments when requested by the config.""" - if hasattr(self, "metric_flags") and not self.metric_flags.get('CO2', True): - return - # add lifecycle CO2 emissions for climate model run - lc_CO2 = ( - fuel['LC_CO2'] * (traj.total_fuel_mass * fuel['Energy_MJ_per_kg']) - ) - self.summed_emission_g['CO2'] - self.summed_emission_g['CO2'] += lc_CO2 -======= lifecycle_total = fuel['LC_CO2'] * (traj.fuel_mass * fuel['Energy_MJ_per_kg']) self.summed_emission_g['CO2'] = lifecycle_total + if not self.metric_flags.get('CO2', True): + return None + fuel_mass = getattr(traj, 'fuel_mass', None) + if fuel_mass is None: + raise ValueError( + "Trajectory is missing total fuel_mass required for lifecycle CO2." + ) + lifecycle_total = float(fuel['LC_CO2'] * (fuel_mass * fuel['Energy_MJ_per_kg'])) + self.summed_emission_g['CO2'] += lifecycle_total + return lifecycle_total def compute_EI_NOx( self, @@ -585,10 +771,10 @@ def compute_EI_NOx( sls_equiv_fuel_flow, ): """Fill NOx-related EI arrays according to the selected method.""" - method = self.method_flags.get('nox', 'none') - if method == 'none': + method = self.conf.nox_method + if method is EINOxMethod.NONE: return - if method == 'bffm2': + if method is EINOxMethod.BFFM2: if ( sls_equiv_fuel_flow is None or atmos_state.temperature is None @@ -610,71 +796,134 @@ def compute_EI_NOx( Pamb=atmos_state.pressure, Tamb=atmos_state.temperature, ) - elif method == 'p3t3': + elif method is EINOxMethod.P3T3: print("P3T3 method not implemented yet..") else: raise NotImplementedError( - f"EI_NOx_method '{self.method_flags['nox']}' is not supported." + f"EI_NOx_method '{self.conf.nox_method.value}' is not supported." ) ->>>>>>> 3cd11cb (cleaned up emissions class, solution slightly more elegant now, emissions not in config set to -1):src/emissions/emission.py ################### # PRIVATE METHODS # ################### - def _parse_emission_settings(self, config): - """Flatten relevant config keys and derive boolean/method flags.""" + def _reset_run_state(self): + """Initialize per-run placeholders so attributes always exist.""" + self.trajectory = None + self.Ntot = 0 + self.NClm = 0 + self.NCrz = 0 + self.NDes = 0 + self.emission_indices = None + self.LTO_emission_indices = None + self.LTO_emissions_g = None + self.APU_emission_indices = None + self.APU_emissions_g = None + self.GSE_emissions_g = None + self.pointwise_emissions_g = None + self.summed_emission_g = None + self.fuel_burn_per_segment = None + self.total_fuel_burn = 0.0 + self.LTO_noProp = np.zeros(4) + self.LTO_no2Prop = np.zeros(4) + self.LTO_honoProp = np.zeros(4) - def bool_opt(key, default=True): - value = config.get(key, default) - if isinstance(value, str): - return value.strip().lower() in ('1', 'true', 'yes', 'y') - return bool(value) - - def method_opt(key, default): - raw = config.get(key, default) - if raw is None: - raw = default - if isinstance(raw, str): - raw_clean = raw.strip() or default - else: - raw_clean = str(raw) - return raw_clean.lower() + def _prepare_run_state(self, trajectory: Trajectory): + """Allocate arrays and derived data for a single emission run.""" + self.trajectory = trajectory + self.Ntot = trajectory.Ntot + self.NClm = trajectory.NClm + self.NCrz = trajectory.NCrz + self.NDes = trajectory.NDes + + fill_value = -1.0 + self.emission_indices = self._new_emission_array(self.Ntot, fill_value) + self.LTO_emission_indices = self._new_emission_array(4, fill_value) + self.LTO_emissions_g = self._new_emission_array(4, fill_value) + self.APU_emission_indices = self._new_emission_array(1, fill_value) + self.APU_emissions_g = self._new_emission_array(1, fill_value) + self.GSE_emissions_g = self._new_emission_array(1, fill_value) + self.pointwise_emissions_g = self._new_emission_array(self.Ntot, fill_value) + self.summed_emission_g = self._new_emission_array(1, fill_value) - fuel_name = config.get('Fuel') or config.get('fuel_file') or 'conventional_jetA' - nox_method = method_opt('EI_NOx_method', 'BFFM2') - hc_method = method_opt('EI_HC_method', 'BFFM2') - co_method = method_opt('EI_CO_method', 'BFFM2') - pmvol_method = method_opt('EI_PMvol_method', 'fuel_flow') - pmnvol_method = method_opt('EI_PMnvol_method', 'scope11') + self._initialize_field_controls() + + fuel_mass = trajectory.traj_data['fuelMass'] + fuel_burn = np.zeros_like(fuel_mass) + fuel_burn[1:] = fuel_mass[:-1] - fuel_mass[1:] + self.fuel_burn_per_segment = fuel_burn + self.total_fuel_burn = 0.0 + self.LTO_noProp = np.zeros(4) + self.LTO_no2Prop = np.zeros(4) + self.LTO_honoProp = np.zeros(4) + + def _collect_emissions(self, lifecycle_adjustment: float | None) -> EmissionsOutput: + """Bundle intermediate arrays into immutable emission slices.""" + trajectory_slice = TrajectoryEmissionSlice( + indices=self.emission_indices, + emissions_g=self.pointwise_emissions_g, + fuel_burn_per_segment=self.fuel_burn_per_segment, + total_fuel_burn=float(self.total_fuel_burn), + ) + lto_slice = EmissionSlice( + indices=self.LTO_emission_indices, + emissions_g=self.LTO_emissions_g, + ) + apu_slice = ( + EmissionSlice( + indices=self.APU_emission_indices, + emissions_g=self.APU_emissions_g, + ) + if self.conf.apu_enabled + else None + ) + gse_slice = ( + EmissionSlice(indices=None, emissions_g=self.GSE_emissions_g) + if self.conf.gse_enabled + else None + ) + return EmissionsOutput( + trajectory=trajectory_slice, + lto=lto_slice, + apu=apu_slice, + gse=gse_slice, + total=self.summed_emission_g, + lifecycle_co2_g=lifecycle_adjustment, + ) + + def _parse_emission_settings( + self, config_data: Mapping[str, Any] | EmissionsConfig + ) -> EmissionSettings: + """Flatten relevant config keys and derive boolean/method flags.""" + + if isinstance(config_data, EmissionsConfig): + config = config_data + else: + config = EmissionsConfig.from_mapping(config_data) metric_flags = { - 'CO2': bool_opt('CO2_calculation', True), - 'H2O': bool_opt('H2O_calculation', True), - 'SOx': bool_opt('SOx_calculation', True), - 'NOx': nox_method != 'none', - 'HC': hc_method != 'none', - 'CO': co_method != 'none', - 'PMvol': pmvol_method != 'none', - 'PMnvol': pmnvol_method != 'none', + 'CO2': config.co2_enabled, + 'H2O': config.h2o_enabled, + 'SOx': config.sox_enabled, + 'NOx': config.nox_method is not EINOxMethod.NONE, + 'HC': config.hc_method is not EINOxMethod.NONE, + 'CO': config.co_method is not EINOxMethod.NONE, + 'PMvol': config.pmvol_method is not PMvolMethod.NONE, + 'PMnvol': config.pmnvol_method is not PMnvolMethod.NONE, } - return { - 'fuel_file': f"fuels/{fuel_name}.toml", - 'traj_all': bool_opt('climb_descent_usage', True), - 'apu': bool_opt('APU_calculation', True), - 'gse': bool_opt('GSE_calculation', True), - 'pmvol_method': pmvol_method, - 'pmnvol_method': pmnvol_method, - 'lifecycle': bool_opt('LC_calculation', True), - 'metric_flags': metric_flags, - 'methods': { - 'nox': nox_method, - 'hc': hc_method, - 'co': co_method, - 'pmvol': pmvol_method, - 'pmnvol': pmnvol_method, - }, - } + return EmissionSettings( + fuel_file=config.fuel_file, + traj_all=config.climb_descent_usage, + apu_enabled=config.apu_calculation, + gse_enabled=config.gse_calculation, + lifecycle_enabled=config.lc_calculation, + pmvol_method=config.pmvol_method, + pmnvol_method=config.pmnvol_method, + nox_method=config.nox_method, + hc_method=config.hc_method, + co_method=config.co_method, + metric_flags=metric_flags, + ) def _initialize_field_controls(self): """Map dtype fields to metric flags so we can zero disabled outputs.""" @@ -698,11 +947,7 @@ def _initialize_field_controls(self): 'SO4': 'SOx', } controls = {} - include_number = getattr( - self, - "_include_pmnvol_number", - getattr(self, 'pmnvol_mode', '').lower() in ('scope11', 'meem'), - ) + include_number = getattr(self, "_include_pmnvol_number", False) for field in self.emission_indices.dtype.names: group = field_groups.get(field) @@ -721,7 +966,8 @@ def _initialize_field_controls(self): def _extract_lto_inputs(self): """Return ordered fuel-flow and EI arrays for either EDB or user LTO data.""" - if self.performance_model.config['LTO_input_mode'] == "EDB": + mode = LTOInputMode.from_value(self.performance_model.config.lto_input_mode) + if mode is LTOInputMode.EDB: edb = self.performance_model.EDB_data fuel_flow = np.asarray(edb['fuelflow_KGperS'], dtype=float) nox_ei = np.asarray(edb['NOX_EI_matrix'], dtype=float) @@ -774,7 +1020,8 @@ def _ordered_thrust_settings(self, settings): return ordered def _thrust_band_labels(self, thrust_categories: np.ndarray) -> np.ndarray: - """Translate numeric thrust codes into the L/H labels used by EI_PMvol_NEW.""" + """Translate numeric thrust codes into the L/H labels used by + EI_PMvol_FuelFlow.""" labels = np.full(thrust_categories.shape, 'H', dtype=' AtmosphericState: + """Compute temperature, pressure, and Mach profiles when needed.""" + if not enabled: + return AtmosphericState(None, None, None) + temps = temperature_at_altitude_isa_bada4(altitude) + pressures = pressure_at_altitude_isa_bada4(altitude) + mach = tas / np.sqrt(kappa * R_air * temps) + return AtmosphericState(temps, pressures, mach) + + def _sls_equivalent_fuel_flow( + self, + enabled: bool, + fuel_flow: np.ndarray, + atmos_state: AtmosphericState, + ): + """Return sea-level static equivalent fuel flow""" + if not enabled: + return None + general_info = self.performance_model.model_info['General_Information'] + return get_SLS_equivalent_fuel_flow( + fuel_flow, + atmos_state.pressure, + atmos_state.temperature, + atmos_state.mach, + general_info['n_eng'], + ) + + def _compute_EI_HCCO( + self, + sls_equiv_fuel_flow, + emission_matrix, + performance_fuel_flow, + atmos_state: AtmosphericState, + ): + """Shared helper for HC/CO EI calculations.""" + if ( + sls_equiv_fuel_flow is None + or atmos_state.temperature is None + or atmos_state.pressure is None + ): + raise RuntimeError( + "HC/CO EI calculation requires atmosphere and SLS equivalent fuel flow." + ) + return EI_HCCO( + sls_equiv_fuel_flow, + emission_matrix, + performance_fuel_flow, + Tamb=atmos_state.temperature, + Pamb=atmos_state.pressure, + cruiseCalc=True, + ) + + def _calculate_EI_PMvol( + self, + idx_slice: slice, + thrust_categories: np.ndarray, + fuel_flow: np.ndarray, + hc_ei: np.ndarray | None, + ): + """Populate PMvol/OCic trajectory indices according to the configured method.""" + if ( + not self.metric_flags.get('PMvol') + or self.conf.pmvol_method is PMvolMethod.NONE + ): + return + if self.conf.pmvol_method is PMvolMethod.FUEL_FLOW: + thrust_labels = self._thrust_band_labels(thrust_categories) + pmvol_ei, ocic_ei = EI_PMvol_FuelFlow(fuel_flow, thrust_labels) + elif self.conf.pmvol_method is PMvolMethod.FOA3: + if hc_ei is None: + raise RuntimeError("FOA3 PMvol calculation requires HC EIs.") + thrust_pct = self._thrust_percentages_from_categories(thrust_categories) + pmvol_ei, ocic_ei = EI_PMvol_FOA3(thrust_pct, hc_ei) + else: + raise NotImplementedError( + f"EI_PMvol_method '{self.conf.pmvol_method.value}' is not supported." + ) + self.emission_indices['PMvol'][idx_slice] = pmvol_ei + self.emission_indices['OCic'][idx_slice] = ocic_ei + + def _calculate_EI_PMnvol( + self, + idx_slice: slice, + thrust_categories: np.ndarray, + altitudes: np.ndarray, + atmos_state: AtmosphericState, + ac_performance: PerformanceModel, + ): + """Populate PMnvol indices for trajectory points.""" + method = self.conf.pmnvol_method + if method is PMnvolMethod.NONE: + return + if method is PMnvolMethod.MEEM: + if ( + atmos_state.temperature is None + or atmos_state.pressure is None + or atmos_state.mach is None + ): + raise RuntimeError("MEEM PMnvol requires atmospheric state.") + ( + self.emission_indices['PMnvolGMD'][idx_slice], + self.emission_indices['PMnvol'][idx_slice], + pmnvol_num, + ) = PMnvol_MEEM( + ac_performance.EDB_data, + altitudes, + atmos_state.temperature, + atmos_state.pressure, + atmos_state.mach, + ) + if ( + self._include_pmnvol_number + and 'PMnvolN' in self.emission_indices.dtype.names + ): + self.emission_indices['PMnvolN'][idx_slice] = pmnvol_num + return + + if method is PMnvolMethod.SCOPE11: + profile = self._scope11_profile(ac_performance) + self.emission_indices['PMnvol'][idx_slice] = ( + self._map_mode_values_to_categories(profile['mass'], thrust_categories) + ) + self.emission_indices['PMnvolGMD'][idx_slice] = 0.0 + if ( + self._include_pmnvol_number + and profile['number'] is not None + and 'PMnvolN' in self.emission_indices.dtype.names + ): + self.emission_indices['PMnvolN'][idx_slice] = ( + self._map_mode_values_to_categories( + profile['number'], thrust_categories + ) + ) + return + + raise NotImplementedError( + f"EI_PMnvol_method '{self.conf.pmnvol_method.value}' is not supported." + ) + + def _get_LTO_TIMs(self): + """Return the ICAO standard time-in-mode vector for Taxi → TO segments.""" + TIM_TakeOff = 0.7 * 60 + TIM_Climb = 2.2 * 60 + TIM_Approach = 4.0 * 60 + TIM_Taxi = 26.0 * 60 + durations = np.array([TIM_Taxi, TIM_Approach, TIM_Climb, TIM_TakeOff]) + if self.conf.traj_all: + durations[1:3] = 0.0 + return durations + + def _get_LTO_nox(self, thrust_categories, lto_inputs): + """Fill LTO NOx and species splits while keeping auxiliary arrays in sync.""" + fuel_flows_LTO = lto_inputs['fuel_flow'] + zeros = np.zeros_like(fuel_flows_LTO) + if not self.metric_flags.get('NOx'): + self.LTO_noProp = zeros + self.LTO_no2Prop = zeros + self.LTO_honoProp = zeros + return + + if self.conf.nox_method is EINOxMethod.NONE: + self.LTO_noProp = zeros + self.LTO_no2Prop = zeros + self.LTO_honoProp = zeros + return + + self.LTO_emission_indices['NOx'] = lto_inputs['nox_ei'] + ( + self.LTO_noProp, + self.LTO_no2Prop, + self.LTO_honoProp, + ) = NOx_speciation(thrust_categories) + self.LTO_emission_indices['NO'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_noProp + ) + self.LTO_emission_indices['NO2'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_no2Prop + ) + self.LTO_emission_indices['HONO'] = ( + self.LTO_emission_indices['NOx'] * self.LTO_honoProp + ) + + def _get_LTO_PMvol(self, fuel_flows_LTO, thrust_labels, lto_inputs): + """Set PMvol/OCic EIs for the LTO cycle.""" + if self.conf.pmvol_method is PMvolMethod.FUEL_FLOW: + LTO_PMvol, LTO_OCic = EI_PMvol_FuelFlow(fuel_flows_LTO, thrust_labels) + elif self.conf.pmvol_method is PMvolMethod.FOA3: + LTO_PMvol, LTO_OCic = EI_PMvol_FOA3( + lto_inputs['thrust_pct'], lto_inputs['hc_ei'] + ) + elif self.conf.pmvol_method is PMvolMethod.NONE: + LTO_PMvol = LTO_OCic = np.zeros_like(fuel_flows_LTO) + else: + raise NotImplementedError( + f"EI_PMvol_method '{self.conf.pmvol_method.value}' is not supported." + ) + self.LTO_emission_indices['PMvol'] = LTO_PMvol + self.LTO_emission_indices['OCic'] = LTO_OCic + + def _get_LTO_PMnvol(self, ac_performance: PerformanceModel, fuel_flows_LTO): + """Set PMnvol EIs for the four LTO thrust points.""" + method = self.conf.pmnvol_method + if method in (PMnvolMethod.FOA3, PMnvolMethod.MEEM): + PMnvolEI = np.asarray( + ac_performance.EDB_data['PMnvolEI_best_ICAOthrust'], dtype=float + ) + PMnvolEIN = None + elif method is PMnvolMethod.SCOPE11: + profile = self._scope11_profile(ac_performance) + PMnvolEI = profile['mass'] + PMnvolEIN = profile['number'] + elif method is PMnvolMethod.NONE: + PMnvolEI = np.zeros_like(fuel_flows_LTO) + PMnvolEIN = None + else: + raise ValueError( + f"""Re-define PMnvol estimation method: + pmnvolSwitch = {self.conf.pmnvol_method.value}""" + ) + + self.LTO_emission_indices['PMnvol'] = PMnvolEI + if ( + self._include_pmnvol_number + and PMnvolEIN is not None + and 'PMnvolN' in self.LTO_emission_indices.dtype.names + ): + self.LTO_emission_indices['PMnvolN'] = PMnvolEIN + + def _wnsf_index(self, wnsf: str) -> int: + """Map user-friendly WNSF labels to the internal lookup order.""" + mapping = {'wide': 0, 'narrow': 1, 'small': 2, 'freight': 3} + idx = mapping.get(wnsf.lower()) + if idx is None: + raise ValueError( + "Invalid WNSF code; must be one of 'wide','narrow','small','freight'" + ) + return idx + + def _gse_nominal_profile(self, idx: int): + """Return nominal per-cycle GSE emissions for the requested aircraft class.""" + return { + 'CO2': [58e3, 18e3, 10e3, 58e3][idx], + 'NOx': [0.9e3, 0.4e3, 0.3e3, 0.9e3][idx], + 'HC': [0.07e3, 0.04e3, 0.03e3, 0.07e3][idx], + 'CO': [0.3e3, 0.15e3, 0.1e3, 0.3e3][idx], + 'PM10': [0.055e3, 0.025e3, 0.020e3, 0.055e3][idx], + } def _scope11_profile(self, ac_performance): """Cache SCOPE11 lookups so we do the work only once.""" @@ -846,11 +1367,7 @@ def __emission_dtype(self, shape): ('SO2', np.float64, n), ('SO4', np.float64, n), ] - include_number = getattr( - self, - "_include_pmnvol_number", - getattr(self, 'pmnvol_mode', '').lower() in ('scope11', 'meem'), - ) + include_number = getattr(self, "_include_pmnvol_number", False) if include_number: fields.append(('PMnvolN', np.float64, n)) return fields diff --git a/src/AEIC/performance_model.py b/src/AEIC/performance_model.py index 083667f..31d13b2 100644 --- a/src/AEIC/performance_model.py +++ b/src/AEIC/performance_model.py @@ -1,17 +1,104 @@ import gc import os import tomllib +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum +from typing import Any import numpy as np -from AEIC.BADA.aircraft_parameters import Bada3AircraftParameters -from AEIC.BADA.model import Bada3JetEngineModel +from BADA.aircraft_parameters import Bada3AircraftParameters +from BADA.model import Bada3JetEngineModel +from parsers.LTO_reader import parseLTO +from parsers.OPF_reader import parse_OPF +from utils import file_location, inspect_inputs + + +class PerformanceInputMode(Enum): + """Config for selecting input modes Performance Model""" + + # INPUT OPTIONS + OPF = "opf" + PERFORMANCE_MODEL = "performancemodel" + + @classmethod + def from_value(cls, value: str | None) -> "PerformanceInputMode": + normalized = (value or cls.PERFORMANCE_MODEL.value).strip().lower() + for mode in cls: + if mode.value == normalized: + return mode + raise ValueError( + f"performance_model_input '{value}' is invalid. " + f"Valid options: {[m.value for m in cls]}" + ) + + +@dataclass(frozen=True) +class PerformanceConfig: + """Immutable, validated view of the performance configuration consumed by + `PerformanceModel`. Has convenience accessors for emission-specific options. + + Attributes: + missions_folder: Directory that holds the mission definition TOML files. + missions_in_file: Name of the missions list to load from `missions_folder`. + performance_model_input: Selected `PerformanceInputMode` (OPF vs table data). + performance_model_input_file: Path (relative or absolute) to the + performance input. + emissions: Raw mapping of emission-related configuration used by helpers like + `lto_input_mode`/`edb_input_file`. + """ + + missions_folder: str + missions_in_file: str + performance_model_input: PerformanceInputMode + performance_model_input_file: str + emissions: Mapping[str, Any] + + @classmethod + def from_mapping(cls, mapping: Mapping[str, Any]) -> "PerformanceConfig": + missions = mapping.get('Missions', {}) + general = mapping.get('General Information', {}) + emissions = mapping.get('Emissions', {}) + if not missions: + raise ValueError("Missing [Missions] section in configuration file.") + if not general: + raise ValueError( + "Missing [General Information] section in configuration file." + ) + if not emissions: + raise ValueError("Missing [Emissions] section in configuration file.") + return cls( + missions_folder=inspect_inputs.require_str(missions, 'missions_folder'), + missions_in_file=inspect_inputs.require_str(missions, 'missions_in_file'), + performance_model_input=PerformanceInputMode.from_value( + general.get('performance_model_input') + ), + performance_model_input_file=inspect_inputs.require_str( + general, 'performance_model_input_file' + ), + emissions=emissions, + ) + + def emission_option(self, key: str, default: Any = None) -> Any: + return self.emissions.get(key, default) -# from src.missions.OAG_filter import filter_OAG_schedule -from AEIC.missions import Mission -from AEIC.parsers.LTO_reader import parseLTO -from AEIC.parsers.OPF_reader import parse_OPF -from AEIC.utils.files import file_location + @property + def lto_input_mode(self) -> str: + raw = self.emission_option('LTO_input_mode', 'EDB') + return str(raw or 'EDB') + + @property + def lto_input_file(self) -> str | None: + value = self.emission_option('LTO_input_file') + return None if value in (None, '') else str(value) + + @property + def edb_input_file(self) -> str: + raw = self.emission_option('EDB_input_file') + if not isinstance(raw, str) or not raw.strip(): + raise ValueError("EDB_input_file must be provided in [Emissions].") + return raw class PerformanceModel: @@ -19,23 +106,27 @@ class PerformanceModel: fuel flow, airspeed, ROC/ROD, LTO emissions, and OAG schedule''' + """Loads the configuration and mission set, ingests the + requested performance input (OPF or TOML input), and prepares performance model + table, LTO, and APU artefacts needed for downstream analysis. + + Parameters: + config_file: Path resolved via `file_location` that points to the master TOML + configuration describing missions, performance data, and emissions settings. + """ + def __init__(self, config_file="IO/default_config.toml"): '''Initializes the performance model by reading the configuration, loading mission data, and setting up performance and engine models.''' config_file_loc = file_location(config_file) - self.config = {} with open(config_file_loc, 'rb') as f: config_data = tomllib.load(f) - self.config = { - k: v for subdict in config_data.values() for k, v in subdict.items() - } + self.config = PerformanceConfig.from_mapping(config_data) # Get mission data # self.filter_OAG_schedule = filter_OAG_schedule mission_file = file_location( - os.path.join( - self.config['missions_folder'], self.config['missions_in_file'] - ) + os.path.join(self.config.missions_folder, self.config.missions_in_file) ) with open(mission_file, 'rb') as f: self.missions = Mission.from_toml(tomllib.load(f)) @@ -49,15 +140,16 @@ def initialize_performance(self): Also loads LTO/EDB data and sets up the engine model using BADA3 parameters.''' self.ac_params = Bada3AircraftParameters() + input_mode = self.config.performance_model_input # If OPF data input - if self.config["performance_model_input"] == "OPF": + if input_mode is PerformanceInputMode.OPF: opf_params = parse_OPF( - file_location(self.config["performance_model_input_file"]) + file_location(self.config.performance_model_input_file) ) for key in opf_params: setattr(self.ac_params, key, opf_params[key]) # If fuel flow function input - elif self.config["performance_model_input"] == "PerformanceModel": + elif input_mode is PerformanceInputMode.PERFORMANCE_MODEL: self.read_performance_data() ac_params_input = { "cas_cruise_lo": self.model_info["speeds"]['cruise']['cas_lo'], @@ -67,31 +159,35 @@ def initialize_performance(self): for key in ac_params_input: setattr(self.ac_params, key, ac_params_input[key]) else: - print("Invalid performance model input provided!") + raise ValueError("Invalid performance model input provided!") # Initialize BADA engine model self.engine_model = Bada3JetEngineModel(self.ac_params) - if self.config["LTO_input_mode"] == "input_file": + if self.config.lto_input_mode.strip().lower() == "input_file": # Load LTO data - self.LTO_data = parseLTO(self.config['LTO_input_file']) + lto_input_file = self.config.lto_input_file + if not lto_input_file: + raise ValueError( + "LTO_input_file must be provided when" + "using LTO_input_mode='input_file'." + ) + self.LTO_data = parseLTO(file_location(lto_input_file)) def read_performance_data(self): '''Parses the TOML input file containing flight and LTO performance data. Separates model metadata and prepares the data for table generation.''' # Read and load TOML data - with open( - file_location(self.config["performance_model_input_file"]), "rb" - ) as f: + with open(file_location(self.config.performance_model_input_file), "rb") as f: data = tomllib.load(f) self.LTO_data = data['LTO_performance'] - if self.config["LTO_input_mode"] == "EDB": + if self.config.lto_input_mode.strip().lower() == "edb": # Read UID UID = data['LTO_performance']['ICAO_UID'] # Read EDB file and get engine - engine_info = self.get_engine_by_uid(UID, self.config["EDB_input_file"]) + engine_info = self.get_engine_by_uid(UID, self.config.edb_input_file) if engine_info is not None: self.EDB_data = engine_info else: diff --git a/src/AEIC/utils/inspect_inputs.py b/src/AEIC/utils/inspect_inputs.py new file mode 100644 index 0000000..f15dcdd --- /dev/null +++ b/src/AEIC/utils/inspect_inputs.py @@ -0,0 +1,25 @@ +from collections.abc import Mapping +from typing import Any + + +def as_bool(value: Any, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, int | float): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "t", "yes", "y"}: + return True + if normalized in {"0", "false", "f", "no", "n"}: + return False + raise ValueError(f"Unable to coerce '{value}' to bool") + + +def require_str(mapping: Mapping[str, Any], key: str) -> str: + raw = mapping.get(key) + if not isinstance(raw, str) or not raw.strip(): + raise ValueError(f"Configuration option '{key}' must be a non-empty string.") + return raw From 2ccd873f1b219887488c2864a9f1d9a2e11eb62c Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Wed, 12 Nov 2025 16:08:06 -0500 Subject: [PATCH 07/12] change total fuel burn from shape (1,) to (1) --- src/AEIC/emissions/emission.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index eb0d05b..10de6fb 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -858,11 +858,12 @@ def _prepare_run_state(self, trajectory: Trajectory): def _collect_emissions(self, lifecycle_adjustment: float | None) -> EmissionsOutput: """Bundle intermediate arrays into immutable emission slices.""" + total_fuel_burn = float(np.asarray(self.total_fuel_burn).reshape(-1)[0]) trajectory_slice = TrajectoryEmissionSlice( indices=self.emission_indices, emissions_g=self.pointwise_emissions_g, fuel_burn_per_segment=self.fuel_burn_per_segment, - total_fuel_burn=float(self.total_fuel_burn), + total_fuel_burn=total_fuel_burn, ) lto_slice = EmissionSlice( indices=self.LTO_emission_indices, From 39ea26e333c842db3ab7925a90606a333459c0e4 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Thu, 13 Nov 2025 13:31:58 -0500 Subject: [PATCH 08/12] updated docs for performance model and emissions --- docs/src/AEIC/performance_model.rst | 146 +++++++++---- docs/src/emissions/emission.rst | 322 +++++++++++++--------------- 2 files changed, 256 insertions(+), 212 deletions(-) diff --git a/docs/src/AEIC/performance_model.rst b/docs/src/AEIC/performance_model.rst index ac32e02..25e8e3d 100644 --- a/docs/src/AEIC/performance_model.rst +++ b/docs/src/AEIC/performance_model.rst @@ -1,49 +1,111 @@ Performance Model -======================= +================= -The ``PerformanceModel`` class encapsulates aircraft performance data. It builds a structured matrix of fuel flows as a function of altitude, TAS, ROCD, mass. +``AEIC.performance_model.PerformanceModel`` takes aircraft performance, +missions, and emissions configuration data as input and produces the +data structure needed by trajectory solvers and the emissions pipeline. +It builds a fuel-flow performance table as a function of aircraft mass, +altitude, rate of climb/descent, and true airspeed. -Class Definition ----------------- - -.. autoclass:: AEIC.performance_model.PerformanceModel - :members: - -Attributes +Overview ---------- -.. attribute:: config - - Dictionary of all parsed key-value pairs from the configuration TOML file. - -.. attribute:: missions - - List of missions (flights) parsed from the input TOML file. - -.. attribute:: ac_params - - Instance of ``Bada3AircraftParameters`` representing aircraft configuration parameters. - -.. attribute:: engine_model - - Instance of ``Bada3JetEngineModel`` initialized with aircraft parameters. - -.. attribute:: LTO_data - - Dictionary of emissions or fuel flow data during the landing-takeoff cycle. - -.. attribute:: model_info - - Parsed metadata from TOML describing the aircraft performance model (e.g., cruise speeds). - -.. attribute:: performance_table - - Multidimensional NumPy array of fuel flow rates indexed by input variables. - -.. attribute:: performance_table_cols - - List of sorted values for each input dimension of the performance table. +- Loads project-wide TOML configuration and as :class:`PerformanceConfig`. +- Supports two input modes via :class:`PerformanceInputMode`: reading BADA style OPF + files or ingesting the custom performance-model TOML tables. +- Automatically loads mission definitions, LTO data (either from the performance + file or EDB databank), APU characteristics, and BADA3-based engine parameters. +- Provides convenience accessors such as :attr:`PerformanceModel.missions`, + :attr:`PerformanceModel.performance_table`, and :attr:`PerformanceModel.model_info` + that later modules can consume without re-parsing TOML files. + +Usage Example +------------- + +.. code-block:: python + + from AEIC.performance_model import PerformanceModel + + perf = PerformanceModel("IO/default_config.toml") + print("Loaded missions:", len(perf.missions)) + + table = perf.performance_table + fl_grid, tas_grid, roc_grid, mass_grid = perf.performance_table_cols + print("Fuel-flow grid shape:", table.shape) + + # Pass to trajectory or emissions builders + from emissions.emission import Emission + emitter = Emission(perf) + +Configuration Schema +-------------------- + +``PerformanceConfig`` converts the nested TOML mapping into a frozen dataclass +with well-defined fields. Key sections include: + +.. list-table:: + :header-rows: 1 + :widths: 30 15 55 + + * - Section.Key + - Required + - Description + * - ``[Missions].missions_folder`` + - ✓ + - Directory containing the mission TOML files (relative to the repo root). + * - ``[Missions].missions_in_file`` + - ✓ + - File within ``missions_folder`` that lists available missions under ``[flight]``. + * - ``[General Information].performance_model_input`` + - ✓ + - Determines :class:`PerformanceInputMode`; accepts ``"opf"`` or ``"performancemodel"``. + * - ``[General Information].performance_model_input_file`` + - ✓ + - Path to the OPF file or the performance-model TOML containing + ``[flight_performance]`` and ``[LTO_performance]`` sections. + * - ``[Emissions]`` + - ✓ + - Stored as :attr:`PerformanceConfig.emissions` and forwarded to + :class:`emissions.emission.EmissionsConfig` for validation of LTO/fuel + choices (see :ref:`Emissions Module `). + +Data Products +------------- + +After :meth:`PerformanceModel.initialize_performance` runs, the instance +contains: + +- :attr:`PerformanceModel.missions`: list of mission dictionaries read from the + ``missions_in_file``. +- :attr:`PerformanceModel.ac_params`: populated :class:`BADA.aircraft_parameters.Bada3AircraftParameters` + reflecting either OPF inputs or the performance table metadata. +- :attr:`PerformanceModel.engine_model`: a :class:`BADA.model.Bada3JetEngineModel` + initialised with ``ac_params`` for thrust and fuel-flow calculations. +- :attr:`PerformanceModel.performance_table`: the multidimensional NumPy array + mapping (flight level, TAS, ROCD, mass, …) onto fuel flow (kg/s). +- :attr:`PerformanceModel.performance_table_cols` and + :attr:`PerformanceModel.performance_table_colnames`: the coordinate arrays and + names that describe each dimension of ``performance_table``. +- :attr:`PerformanceModel.LTO_data`: modal thrust settings, fuel flows, and + emission indices pulled from the performance file (when ``LTO_input_mode = + "performance_model"``) or parsed via :func:`parsers.LTO_reader.parseLTO`. +- :attr:`PerformanceModel.EDB_data`: ICAO engine databank entry loaded by + :meth:`PerformanceModel.get_engine_by_uid` when ``LTO_input_mode = "edb"``. +- :attr:`PerformanceModel.APU_data`: auxiliary-power-unit properties resolved + from ``engines/APU_data.toml`` using the ``APU_name`` specified in the + performance file. +- :attr:`PerformanceModel.model_info`: the remaining metadata (e.g., cruise + speeds, aerodynamic coefficients) trimmed away from ``flight_performance`` after + the table is created. + +API Reference +------------- + +.. autoclass:: AEIC.performance_model.PerformanceInputMode + :members: -.. attribute:: performance_table_colnames +.. autoclass:: AEIC.performance_model.PerformanceConfig + :members: - Names of each input dimension (excluding fuel flow) used to construct the table. +.. autoclass:: AEIC.performance_model.PerformanceModel + :members: __init__, initialize_performance, read_performance_data, create_performance_table, get_engine_by_uid diff --git a/docs/src/emissions/emission.rst b/docs/src/emissions/emission.rst index 8c94164..224f670 100644 --- a/docs/src/emissions/emission.rst +++ b/docs/src/emissions/emission.rst @@ -1,17 +1,69 @@ +.. _emissions-module: + Emissions Module ================ -The ``Emission`` class encapsulates the full calculation of aircraft emissions for a mission: +``emissions.emission.Emission`` is the module that uses +:class:`AEIC.performance_model.PerformanceModel` and a flown +:class:`AEIC.trajectories.trajectory.Trajectory` to compute emissions for the +entire mission. It layers multiple methods for emission calculations +from user choices in the configuration file. + +Overview +---------- + +- Computes trajectory, LTO, APU, GSE, and life-cycle :math:`\mathrm{CO_2}`. +- Uses :class:`EmissionsConfig` / :class:`EmissionSettings` objects so + configuration defaults and switches are enforced before any computation begins. +- Emits structured arrays (grams by species) plus convenience + containers (``EmissionSlice`` and ``EmissionsOutput``) for downstream analysis. +- Has helper methods such as :meth:`Emission.emit_trajectory` or + :meth:`Emission.emit_lto` when only a subset is needed. + +Configuration Inputs +-------------------- -- **Trajectory emissions** (CO₂, H₂O, SO₂, NOₓ, HC, CO, particulate species) for every time step -- **LTO cycle emissions** (taxi, approach, climb, takeoff) -- **APU** (auxiliary power unit) emissions -- **Ground Service Equipment** (GSE) emissions -- **Life-cycle CO₂** additive for fuel production +The ``[Emissions]`` section of the configuration TOML file is validated through +:class:`EmissionsConfig`. Keys and meanings are summarised below. -All results are stored internally in structured NumPy arrays and can be summed. +.. list-table:: + :header-rows: 1 + :widths: 25 25 50 ----- + * - Key + - Allowed values + - Description + * - ``Fuel`` + - any fuel name matching ``fuels/.toml`` + - Selects the fuel file used for EIs and life-cycle data. + * - ``climb_descent_usage`` + - ``true`` / ``false`` + - When ``true``, the emissions are calculated over all segments of the trajectory; + otherwise climb/descent reverts to only LTO. + * - ``CO2_calculation`` / ``H2O_calculation`` / ``_calculation`` + - ``true`` / ``false`` + - Toggles calculation of fuel-dependent, constant EI species. + * - ``EI_NOx_method`` + - ``BFFM2`` / ``P3T3`` / ``none`` + - Selects the method for :math:`\mathrm{NO_x}` calculation (None disables calculation). + * - ``EI_HC_method`` / ``EI_CO_method`` + - ``BFFM2`` / ``none`` + - Selects the method for HC/CO calculation (None disables calculation). + * - ``EI_PMvol_method`` + - ``fuel_flow`` / ``FOA3`` / ``none`` + - Chooses the PMvol method. + * - ``EI_PMnvol_method`` + - ``meem`` / ``scope11`` / ``FOA3`` / ``none`` + - Chooses the PMnvol method. + * - ``LTO_input_mode`` + - ``performance_model`` / ``EDB`` + - Pulls LTO EI/fuel-flow data from the performance tables or EDB file. + * - ``EDB_input_file`` + - path + - Required when ``LTO_input_mode = "EDB"`` to locate the ICAO databank file. + * - ``APU_calculation`` / ``GSE_calculation`` / ``LC_calculation`` + - ``true`` / ``false`` + - Enables non-trajectory emission sources and life-cycle :math:`\mathrm{CO_2}` adjustments. Usage Example ------------- @@ -20,203 +72,133 @@ Usage Example from AEIC.performance_model import PerformanceModel from AEIC.trajectories.trajectory import Trajectory - from emissions import Emission + from emissions.emission import Emission + + perf = PerformanceModel("IO/default_config.toml") + mission = perf.missions[0] - # Initialize performance model & trajectory (user code) - perf = PerformanceModel.from_edb('path/to/config_file.toml') - # Load mission and set trajectory - traj = Trajectory(perf, mission, optimize_traj, iterate_mass) + traj = Trajectory(perf, mission, optimize_traj=True, iterate_mass=False) traj.fly_flight() - # Compute emissions - em = Emission( - ac_performance=perf, - trajectory=traj, - EDB_data=True, - fuel_file='fuels/conventional_jetA.toml' - ) + emitter = Emission(perf) + output = emitter.emit(traj) + + print("Total CO2 (g)", output.total['CO2']) + print("Taxi NOx (g)", output.lto.emissions_g['NOx'][0]) + + # Need only the trajectory segment + segments = emitter.emit_trajectory(traj) + print("Per-segment PM number", segments.emissions_g['PMnvol']) + +Inner Containers +------------------ + +The module defines dataclasses that document both inputs and +outputs of the computation: + +- :class:`EmissionsConfig`: user-facing configuration parsed from the TOML file. + It validates enums (:class:`LTOInputMode`, :class:`EINOxMethod`, + :class:`PMvolMethod`, :class:`PMnvolMethod`), resolves defaults, and ensures + databank paths are present when required. +- :class:`EmissionSettings`: flattened, runtime-only view of the above. It keeps + booleans for metric flags, file paths, and LTO/auxiliary toggles so subsequent + runs avoid re-validating the original mapping. +- :class:`AtmosphericState`: carries temperature, pressure, and Mach arrays that + emission-index models reuse when HC/CO/:math:`\text{NO}_x`/PM need ambient conditions. +- :class:`EmissionSlice`: describes any source (trajectory, LTO, APU, GSE). It + stores ``indices`` (emission indices in g/kg) and the realized ``emissions_g``. +- :class:`TrajectoryEmissionSlice`: extends ``EmissionSlice`` with + ``fuel_burn_per_segment`` (kg) and ``total_fuel_burn`` (kg) so users can derive + intensity metrics. +- :class:`EmissionsOutput`: top-level container returned by :meth:`Emission.emit`. + It exposes ``trajectory``, ``lto``, ``apu``, ``gse``, ``total`` (summed + structured array), and optional ``lifecycle_co2_g``. + +Computation Workflow +-------------------- + +The ``Emission`` object is instanced once per performance model: + +1. ``EmissionsConfig`` is materialized from + ``PerformanceModel.config.emissions`` and converted to ``EmissionSettings``. +2. Fuel properties are read from ``fuels/.toml``. These provide :math:`\mathrm{CO_2}`/:math:`\mathrm{H_2O}`/:math:`\mathrm{SO_x}` + emission indices, and life-cycle factors. +3. ``emit(traj)`` resets internal arrays sized to the trajectory steps +4. :meth:`Emission.get_trajectory_emissions` computes EI values for each mission point: + + - Constant EI species (:math:`\mathrm{CO_2}`, :math:`\mathrm{H_2O}`, :math:`\mathrm{SO}_x``). + - Methods for HC/CO/:math:`\mathrm{NO_x}`/PMvol/PMnvol applied according to user specification. +5. :meth:`Emission.get_LTO_emissions` builds the ICAO style landing and take off emissions using either + databank values (``LTO_input_mode = "edb"``) or the per-mode inputs embedded in + the performance file. +6. :func:`emissions.APU_emissions.get_APU_emissions` and + :meth:`Emission.get_GSE_emissions` contributions are added if enabled. +7. :meth:`Emission.sum_total_emissions` aggregates each pollutant into + ``self.summed_emission_g`` and, when requested, life-cycle :math:`\mathrm{CO_2}` is appended via + :meth:`Emission.get_lifecycle_emissions`. + +Structured Arrays +----------------- + +All emission indices and gram totals share the dtype emitted by the private +``__emission_dtype`` helper. Each field is ``float64``: + +``CO2``, ``H2O``, ``HC``, ``CO``, ``NOx``, ``NO``, ``NO2``, ``HONO``, +``PMnvol``, ``PMnvolGMD``, ``PMvol``, ``OCic``, ``SO2``, ``SO4``. + +If ``EI_PMnvol_method`` is ``SCOPE11`` or ``MEEM``, the additional ``PMnvolN`` +field is emitted. Metric-specific flags (see ``Emission.metric_flags``) determine +which fields are populated; disabled species stay as ``0``, making it easy to filter downstream. + +API Reference +------------- - # Access summed emissions (g) - total = em.summed_emission_g - print("Total CO₂ (g):", total['CO2']) - print("Total NOx (g):", total['NOx']) +.. autoclass:: emissions.emission.EmissionsConfig + :members: ----- +.. autoclass:: emissions.emission.EmissionSettings + :members: -Constructor ------------ +.. autoclass:: emissions.emission.Emission + :members: __init__, emit, emit_trajectory, emit_lto, emit_apu, emit_gse + :show-inheritance: -.. code-block:: python +.. autoclass:: emissions.emission.EmissionSlice + :members: - Emission( - ac_performance: PerformanceModel, - trajectory: Trajectory, - EDB_data: bool, - fuel_file: str - ) - -**Parameters** - -+-------------------+----------------------+----------------------------------------------------------------------------------------+ -| Name | Type | Description | -+===================+======================+========================================================================================+ -| ``ac_performance``| ``PerformanceModel`` | Aircraft performance object providing climb/cruise/descent and LTO data matrices. | -+-------------------+----------------------+----------------------------------------------------------------------------------------+ -| ``trajectory`` | ``Trajectory`` | Flight trajectory containing altitude, speed, fuel‐mass, and fuel‐flow time series. | -+-------------------+----------------------+----------------------------------------------------------------------------------------+ -| ``EDB_data`` | ``bool`` | If ``True``, uses tabulated EDB emissions; otherwise uses user‐specified LTO settings. | -+-------------------+----------------------+----------------------------------------------------------------------------------------+ -| ``fuel_file`` | ``str`` | Path to TOML file of fuel properties (e.g. CO₂ factors, sulfur content, lifecycle CO₂).| -+-------------------+----------------------+----------------------------------------------------------------------------------------+ - -Upon instantiation, the following steps occur: - -1. Fuel TOML is loaded. -2. Array shapes are initialized based on trajectory lengths and config flags. -3. Fuel burn per segment is derived from the trajectory’s fuel-mass time-series. -4. **Trajectory**, **LTO**, **APU**, and **GSE** emissions are computed. -5. All sources are summed and life-cycle CO₂ is added. - ----- - -Attributes ----------- -.. list-table:: Emission Class Attributes - :header-rows: 1 - :widths: 25 10 65 +.. autoclass:: emissions.emission.TrajectoryEmissionSlice + :members: - * - Name - - Type - - Description - * - ``fuel`` - - ``dict`` - - Fuel properties loaded from TOML (e.g. ``EI_CO2``, ``LC_CO2``). - * - ``Ntot``, ``NClm``, ``NCrz``, ``NDes`` - - ``int`` - - Total, climb, cruise, and descent time-step counts. - * - ``traj_emissions_all`` - - ``bool`` - - Whether climb/descent uses performance model or LTO data. - * - ``pmnvol_mode`` - - ``str`` - - PM number estimation method (e.g. ``"SCOPE11"``). - * - ``fuel_burn_per_segment`` - - ``ndarray`` - - Fuel burned (kg) per time step. - * - ``emission_indices`` - - ``ndarray`` - - Emission indices (g/kg fuel) per species and time step. - * - ``pointwise_emissions_g`` - - ``ndarray`` - - Emissions (g) per time step. - * - ``LTO_emission_indices`` - - ``ndarray`` - - Emission indices for each LTO mode. - * - ``LTO_emissions_g`` - - ``ndarray`` - - Emissions (g) for each LTO mode. - * - ``APU_emission_indices`` - - ``ndarray`` - - APU emission indices (g/kg fuel). - * - ``APU_emissions_g`` - - ``ndarray`` - - APU emissions (g). - * - ``GSE_emissions_g`` - - ``ndarray`` - - GSE emissions (g) per engine-start cycle. - * - ``summed_emission_g`` - - ``ndarray`` - - Total emissions (g) across all sources. - - ----- - -Methods --------------- -.. autoclass:: emissions.emission.Emission +.. autoclass:: emissions.emission.EmissionsOutput :members: +Helper Functions +------------------ + .. automodule:: emissions.APU_emissions :members: :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_CO2 :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_H2O :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_SOx :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_HCCO :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_NOx :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_PMnvol :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.EI_PMvol :members: - :undoc-members: - :show-inheritance: .. automodule:: emissions.lifecycle_CO2 :members: - :undoc-members: - :show-inheritance: - ----- - -Emission dtype Fields ---------------------- - -The private helper ``__emission_dtype(shape)`` defines a structured NumPy dtype with the following fields (all ``float64``): - -- **CO2**: Carbon dioxide -- **H2O**: Water vapor -- **HC**: Hydrocarbons -- **CO**: Carbon monoxide -- **NOx**: Total nitrogen oxides -- **NO**: Nitric oxide -- **NO2**: Nitrogen dioxide -- **HONO**: Nitrous acid -- **PMnvol**: Black carbon -- **PMnvol_lo**: Lower bound black carbon -- **PMnvol_hi**: Upper bound black carbon -- **PMnvolN**: Black carbon number -- **PMnvolN_lo**: Lower bound number -- **PMnvolN_hi**: Upper bound number -- **PMnvolGMD**: Geometric mean diameter of black carbon (nm) -- **PMvol**: Organic particulate matter mass -- **OCic**: Organic carbon (incomplete combustion) -- **SO2**: Sulfur dioxide -- **SO4**: Sulfate - -.. note:: - - If ``pmnvol_mode`` is disabled, the ``*_lo``, ``*_hi``, and ``PMnvolN`` fields are omitted. - ----- - -Notes ------ - -- **Structured arrays** are used heavily—one field per pollutant, shaped by segment or mode count. -- Private helper ``__emission_dtype(shape)`` defines the NumPy dtype fields. -- Fuel burn is computed as the decrease in ``traj.traj_data['fuelMass']``. From a2ad3d07a75c9863cbf8d4fb4c62810aed53ba4d Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Thu, 13 Nov 2025 13:49:01 -0500 Subject: [PATCH 09/12] added new tests for emissions and perf model with higher code cov --- tests/test_emission_functions.py | 314 +++++++++++--- tests/test_emissions.py | 705 +++++++++++++++++++++++-------- tests/test_performance_model.py | 130 +++++- 3 files changed, 895 insertions(+), 254 deletions(-) diff --git a/tests/test_emission_functions.py b/tests/test_emission_functions.py index 4cdc410..7767d66 100644 --- a/tests/test_emission_functions.py +++ b/tests/test_emission_functions.py @@ -1,80 +1,49 @@ +import tomllib from unittest.mock import patch import numpy as np import pytest -import AEIC.trajectories.builders as tb -from AEIC.emissions.APU_emissions import get_APU_emissions -from AEIC.emissions.EI_CO2 import EI_CO2 -from AEIC.emissions.EI_H2O import EI_H2O -from AEIC.emissions.EI_HCCO import EI_HCCO -from AEIC.emissions.EI_NOx import BFFM2_EINOx, NOx_speciation -from AEIC.emissions.EI_SOx import EI_SOx -from AEIC.emissions.emission import Emission -from AEIC.missions import Mission -from AEIC.performance_model import PerformanceModel -from AEIC.utils.files import file_location -from AEIC.utils.helpers import iso_to_timestamp - -# Path to a real fuel TOML file in your repo -performance_model_file = file_location("IO/default_config.toml") - -perf = PerformanceModel(performance_model_file) -mis = perf.missions[0] - -sample_mission = Mission( - origin="BOS", - destination="LAX", - aircraft_type="738", - departure=iso_to_timestamp('2019-01-01 12:00:00'), - arrival=iso_to_timestamp('2019-01-01 18:00:00'), - load_factor=1.0, -) - -builder = tb.LegacyBuilder(options=tb.Options(iterate_mass=False)) -traj = builder.fly(perf, sample_mission) -em = Emission(perf, traj, True) +from emissions.APU_emissions import get_APU_emissions +from emissions.EI_CO2 import EI_CO2 +from emissions.EI_H2O import EI_H2O +from emissions.EI_HCCO import EI_HCCO +from emissions.EI_NOx import BFFM2_EINOx, NOx_speciation +from emissions.EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 +from emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow +from emissions.EI_SOx import EI_SOx +from emissions.lifecycle_CO2 import lifecycle_CO2 +from utils import file_location class TestEI_CO2: """Tests for EI_CO2 function""" - def test_basic_functionality(self): - """Test basic CO2 emissions calculation""" - fuel = {'EI_CO2': 3160.0, 'nvolCarbCont': 0.95} - co2_ei, nvol_carb = EI_CO2(fuel) - - assert co2_ei == 3160.0 - assert nvol_carb == 0.95 - assert isinstance(co2_ei, int | float) - assert isinstance(nvol_carb, int | float) - - def test_non_negativity(self): - """Test that outputs are non-negative""" - fuel = {'EI_CO2': 3160.0, 'nvolCarbCont': 0.95} + def test_returns_documented_jet_a_values(self): + """Jet-A reference EI and carbon fraction should match documentation""" + with open(file_location("fuels/conventional_jetA.toml"), 'rb') as f: + fuel = tomllib.load(f) co2_ei, nvol_carb = EI_CO2(fuel) - assert co2_ei >= 0 - assert nvol_carb >= 0 + assert co2_ei == pytest.approx(3155.6) + assert nvol_carb == pytest.approx(0.95) - def test_finiteness(self): - """Test that outputs are finite""" - fuel = {'EI_CO2': 3160.0, 'nvolCarbCont': 0.95} + def test_distinguishes_saf_inputs(self): + """Different fuels propagate their specific EI metadata.""" + with open(file_location("fuels/SAF.toml"), 'rb') as f: + fuel = tomllib.load(f) co2_ei, nvol_carb = EI_CO2(fuel) - assert np.isfinite(co2_ei) - assert np.isfinite(nvol_carb) + assert co2_ei == pytest.approx(fuel['EI_CO2']) + assert nvol_carb == pytest.approx(fuel['nvolCarbCont']) def test_error_handling(self): """Test error handling for invalid inputs""" - # Missing keys with pytest.raises(KeyError): EI_CO2({}) - # Invalid data types - should work but test for reasonable values fuel = {'EI_CO2': -100, 'nvolCarbCont': -0.5} co2_ei, nvol_carb = EI_CO2(fuel) - # Function doesn't validate inputs, but we can check they're returned as-is assert co2_ei == -100 assert nvol_carb == -0.5 @@ -82,19 +51,17 @@ def test_error_handling(self): class TestEI_H2O: """Tests for EI_H2O function""" - def test_basic_functionality(self): - """Test basic H2O emissions calculation""" - fuel = {'EI_H2O': 1230.0} - h2o_ei = EI_H2O(fuel) + def test_returns_documented_jet_a_values(self): + """Jet-A water EI should match the nominal property sheet.""" + with open(file_location("fuels/conventional_jetA.toml"), 'rb') as f: + fuel = tomllib.load(f) + assert EI_H2O(fuel) == pytest.approx(1233.3865) - assert h2o_ei == 1230.0 - assert isinstance(h2o_ei, int | float) - - def test_non_negativity(self): - """Test reasonable values are non-negative""" - fuel = {'EI_H2O': 1230.0} - h2o_ei = EI_H2O(fuel) - assert h2o_ei >= 0 + def test_distinguishes_saf_inputs(self): + """SAF water EI is different and should be propagated verbatim.""" + with open(file_location("fuels/SAF.toml"), 'rb') as f: + fuel = tomllib.load(f) + assert EI_H2O(fuel) == pytest.approx(1356.72515) def test_error_handling(self): """Test error handling""" @@ -183,6 +150,46 @@ def test_zero_and_negative_fuel_flows(self): assert np.all(np.isfinite(result)) assert np.all(result >= 0.0) + def test_duplicate_calibration_flows_flatten_slanted_segment(self): + """Duplicate calibration flows should force a flat lower segment""" + fuelflow_eval = np.array([0.15, 0.25, 0.3, 0.5]) + x_EI_matrix = np.array([10.0, 10.0, 5.0, 3.0]) + fuelflow_calibrate = np.array([0.3, 0.3, 0.7, 1.4]) + + result = EI_HCCO(fuelflow_eval, x_EI_matrix, fuelflow_calibrate) + expected_upper = np.sqrt(x_EI_matrix[2] * x_EI_matrix[3]) + expected = np.full_like(fuelflow_eval, expected_upper) + + low_thrust_mask = fuelflow_eval < fuelflow_calibrate[0] + expected[low_thrust_mask] *= 1 - 52.0 * ( + fuelflow_eval[low_thrust_mask] - fuelflow_calibrate[0] + ) + + assert np.allclose(result, expected) + + def test_intercept_adjustment_uses_second_mode_value(self): + """When intercept drifts low, the second mode should set the ceiling""" + fuelflow_eval = np.array([0.2, 0.5, 0.9]) + x_EI_matrix = np.array([38.33753758, 2.4406048, 106.49710981, 13.57427593]) + fuelflow_calibrate = np.array([0.10569869, 0.40041291, 0.81271722, 0.86727924]) + + result = EI_HCCO(fuelflow_eval, x_EI_matrix, fuelflow_calibrate) + high_mask = fuelflow_eval >= fuelflow_calibrate[1] + + assert np.allclose(result[high_mask], x_EI_matrix[1]) + assert np.all(result >= 0.0) + + def test_positive_slope_forces_horizontal_segment(self): + """Non-negative slopes should collapse to the upper horizontal level""" + fuelflow_eval = np.array([0.2, 0.35, 0.5]) + x_EI_matrix = np.array([10.0, 11.0, 2.0, 1.0]) + fuelflow_calibrate = np.array([0.2, 0.3, 0.4, 0.5]) + + result = EI_HCCO(fuelflow_eval, x_EI_matrix, fuelflow_calibrate) + expected_value = np.sqrt(x_EI_matrix[2] * x_EI_matrix[3]) + + assert np.allclose(result, expected_value) + class TestBFFM2_EINOx: """Tests for BFFM2_EINOx function""" @@ -304,6 +311,27 @@ def test_thrust_categorization(self, mock_get_thrust_cat): for result in results: assert np.all(np.isfinite(result)) + def test_matches_reference_component_values(self): + """Reference regression to guard against inadvertent logic changes""" + results = BFFM2_EINOx( + self.fuelflow_trajectory, + self.NOX_EI_matrix, + self.fuelflow_performance, + self.Tamb, + self.Pamb, + ) + expected_arrays = [ + np.array([28.26762038, 15.01898492, 12.50815229, 18.59189322]), + np.array([3.64440296, 12.0482297, 11.48326556, 17.06851997]), + np.array([23.3511745, 2.2949009, 0.93107559, 1.38393405]), + np.array([1.27204292, 0.67585432, 0.09381114, 0.1394392]), + np.array([0.128925, 0.8022, 0.9180625, 0.9180625]), + np.array([0.826075, 0.1528, 0.0744375, 0.0744375]), + np.array([0.045, 0.045, 0.0075, 0.0075]), + ] + for result, expected in zip(results, expected_arrays): + np.testing.assert_allclose(result, expected, rtol=1e-6, atol=1e-9) + class TestNOxSpeciation: """Tests for NOx_speciation function""" @@ -423,6 +451,7 @@ def setup_method(self): ('PMnvol', 'f8'), ('PMvol', 'f8'), ('PMnvolGMD', 'f8'), + ('PMnvolN', 'f8'), ('OCic', 'f8'), ('NO', 'f8'), ('NO2', 'f8'), @@ -543,6 +572,159 @@ def test_zero_fuel_flow_handling(self): assert apu_ei['CO2'] == 0.0 assert apu_g['CO2'] == 0.0 + def test_nvpm_method_enables_number_channel(self): + """PM number index should be emitted when nvpm_method requests it""" + apu_ei, _, _ = get_APU_emissions( + self.APU_emission_indices, + self.APU_emissions_g, + self.LTO_emission_indices, + self.APU_data, + self.LTO_noProp, + self.LTO_no2Prop, + self.LTO_honoProp, + EI_H2O=1233.3865, + nvpm_method='scope11', + ) + + assert 'PMnvolN' in apu_ei.dtype.names + assert apu_ei['PMnvolN'] == 0.0 + + +class TestPMnvolMEEM: + """Tests for the PMnvol_MEEM cruise methodology""" + + def test_reconstructs_missing_mode_data_and_interpolates(self): + """Negative mode inputs should be rebuilt and yield finite cruise profiles""" + EDB_data = { + 'ENGINE_TYPE': 'MTF', + 'BP_Ratio': 5.0, + 'SN_matrix': np.array([10.0, 20.0, 25.0, 30.0]), + 'nvPM_mass_matrix': np.full(4, -1.0), + 'nvPM_num_matrix': np.full(4, -1.0), + 'PR': np.array([25.0]), + 'EImass_max': 50.0, + 'EImass_max_thrust': 0.575, + 'EInum_max': 4.5e15, + 'EInum_max_thrust': 0.925, + } + altitudes = np.array([0.0, 6000.0, 12000.0]) + Tamb = np.array([288.15, 250.0, 220.0]) + Pamb = np.array([101325.0, 54000.0, 26500.0]) + mach = np.array([0.0, 0.7, 0.8]) + + gmd, mass, num = PMnvol_MEEM(EDB_data, altitudes, Tamb, Pamb, mach) + + assert gmd.shape == altitudes.shape + assert mass.shape == altitudes.shape + assert num.shape == altitudes.shape + assert np.all(gmd > 0.0) + assert np.all(mass > 0.0) + assert np.all(num > 0.0) + assert np.all(np.isfinite(mass)) + + def test_invalid_smoke_numbers_zero_results(self): + """All-negative smoke numbers should zero out the trajectory""" + EDB_data = { + 'ENGINE_TYPE': 'TF', + 'BP_Ratio': 0.0, + 'SN_matrix': np.full(4, -5.0), + 'nvPM_mass_matrix': np.linspace(1.0, 4.0, 4), + 'nvPM_num_matrix': np.linspace(1.0, 4.0, 4), + 'PR': np.array([20.0]), + 'EImass_max': 10.0, + 'EImass_max_thrust': float('nan'), + 'EInum_max': 1.0e12, + 'EInum_max_thrust': float('nan'), + } + altitudes = np.array([3000.0, 3500.0]) + Tamb = np.array([260.0, 250.0]) + Pamb = np.array([70000.0, 65000.0]) + mach = np.array([0.3, 0.4]) + + gmd, mass, num = PMnvol_MEEM(EDB_data, altitudes, Tamb, Pamb, mach) + + assert np.all(gmd == 0.0) + assert np.all(mass == 0.0) + assert np.all(num == 0.0) + + +class TestCalculatePMnvolScope11: + """Tests for calculate_PMnvolEI_scope11""" + + def test_engine_type_scaling_and_invalid_smoke_numbers(self): + SN_matrix = np.array([5.0, 50.0, -1.0, 0.0]) + PR = np.array([20.0, 20.0, 20.0, 20.0]) + BP_Ratio = np.array([2.0, 1.0, 0.0, 0.0]) + + mtf = calculate_PMnvolEI_scope11(SN_matrix, PR, 'MTF', BP_Ratio) + tf = calculate_PMnvolEI_scope11(SN_matrix, PR, 'TF', BP_Ratio) + + assert mtf.shape == SN_matrix.shape + assert tf.shape == SN_matrix.shape + + SN0 = min(SN_matrix[0], 40.0) + CBC0 = 0.6484 * np.exp(0.0766 * SN0) / (1 + np.exp(-1.098 * (SN0 - 3.064))) + AFR = np.array([106, 83, 51, 45], dtype=float) + + bypass = 1 + BP_Ratio[0] + kslm_mtf = np.log( + (3.219 * CBC0 * bypass * 1000 + 312.5) / (CBC0 * bypass * 1000 + 42.6) + ) + Q_mtf = 0.776 * AFR[0] * bypass + 0.767 + expected_mtf = (kslm_mtf * CBC0 * Q_mtf) / 1000.0 + assert np.isclose(mtf[0], expected_mtf) + + kslm_tf = np.log((3.219 * CBC0 * 1000 + 312.5) / (CBC0 * 1000 + 42.6)) + Q_tf = 0.776 * AFR[0] + 0.767 + expected_tf = (kslm_tf * CBC0 * Q_tf) / 1000.0 + assert np.isclose(tf[0], expected_tf) + + assert mtf[0] > tf[0] + assert mtf[2] == 0.0 + assert tf[3] == 0.0 + + +class TestEI_PMvol: + """Tests for EI_PMvol helper functions""" + + def test_fuel_flow_path_uses_lube_contributions(self): + fuelflow = np.ones((2, 4)) + thrustCat = np.array(['L', 'H']) + + pmvol, ocic = EI_PMvol_FuelFlow(fuelflow, thrustCat) + + assert pmvol.shape == thrustCat.shape + assert ocic.shape == fuelflow.shape + assert np.isclose(pmvol[0], 0.02 / (1 - 0.15)) + assert np.isclose(pmvol[1], 0.02 / (1 - 0.50)) + assert np.allclose(ocic, 0.02) + + def test_foa3_interpolation_matches_reference_curve(self): + thrusts = np.array([[7.0, 30.0, 85.0, 100.0], [50.0, 70.0, 90.0, 100.0]]) + HCEI = np.array([[1.0, 2.0, 3.0, 4.0], [0.5, 0.75, 1.0, 1.5]]) + + pmvol, ocic = EI_PMvol_FOA3(thrusts, HCEI) + + ICAO_thrust = np.array([7.0, 30.0, 85.0, 100.0]) + delta = np.array([6.17, 56.25, 76.0, 115.0]) + expected_delta = np.interp(thrusts, ICAO_thrust, delta) + expected_pmvol = expected_delta * HCEI / 1000.0 + + assert np.allclose(pmvol, expected_pmvol) + assert np.allclose(ocic, expected_pmvol) + + +class TestLifecycleCO2: + """Tests for lifecycle_CO2""" + + def test_lifecycle_offset_applies(self): + fuel = {'LC_CO2': 4500.0, 'EI_CO2': 3150.0} + + result = lifecycle_CO2(fuel, fuel_burn=10.0) + + assert result == pytest.approx(10.0 * (fuel['LC_CO2'] - fuel['EI_CO2'])) + assert result > 0.0 + # Integration tests class TestIntegration: diff --git a/tests/test_emissions.py b/tests/test_emissions.py index 974c48b..63d6177 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -1,41 +1,54 @@ +from __future__ import annotations + import numpy as np import pytest -from emissions.emission import AtmosphericState, Emission - - -def _scalar(value: np.ndarray | float) -> float: - """Convert 0-D/length-1 numpy structures into native floats safely.""" - arr = np.asarray(value) - if arr.size != 1: - raise AssertionError("Expected scalar-like value") - return float(arr.reshape(-1)[0]) +from emissions.EI_HCCO import EI_HCCO +from emissions.EI_NOx import BFFM2_EINOx, NOx_speciation +from emissions.EI_PMnvol import calculate_PMnvolEI_scope11 +from emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow +from emissions.emission import ( + AtmosphericState, + EINOxMethod, + Emission, + EmissionsConfig, + LTOInputMode, + PMnvolMethod, + PMvolMethod, +) +from utils.standard_fuel import get_thrust_cat + +_BASE_EMISSIONS = { + 'Fuel': 'conventional_jetA', + 'EDB_input_file': 'engines/example.edb', + 'LTO_input_mode': 'EDB', + 'EI_NOx_method': 'BFFM2', + 'EI_HC_method': 'BFFM2', + 'EI_CO_method': 'BFFM2', + 'EI_PMvol_method': 'fuel_flow', + 'EI_PMnvol_method': 'scope11', + 'CO2_calculation': True, + 'H2O_calculation': True, + 'SOx_calculation': True, + 'APU_calculation': True, + 'GSE_calculation': True, + 'LC_calculation': True, + 'climb_descent_usage': True, +} + + +class DummyConfig: + def __init__(self, overrides: dict | None = None): + payload = dict(_BASE_EMISSIONS) + if overrides: + payload.update(overrides) + self.emissions = payload + self.lto_input_mode = payload.get('LTO_input_mode', 'EDB') class DummyPerformanceModel: - """Lightweight stand-in for PerformanceModel with deterministic data.""" - - def __init__(self, config_overrides=None, edb_overrides=None): - base_config = { - 'Fuel': 'conventional_jetA', - 'LTO_input_mode': 'EDB', - 'EI_NOx_method': 'BFFM2', - 'EI_HC_method': 'BFFM2', - 'EI_CO_method': 'BFFM2', - 'EI_PMvol_method': 'fuel_flow', - 'EI_PMnvol_method': 'scope11', - 'CO2_calculation': True, - 'H2O_calculation': True, - 'SOx_calculation': True, - 'APU_calculation': True, - 'GSE_calculation': True, - 'LC_calculation': True, - 'climb_descent_usage': True, - } - if config_overrides: - base_config.update(config_overrides) - self.config = base_config - + def __init__(self, config_overrides=None, edb_overrides=None, lto_settings=None): + self.config = DummyConfig(config_overrides) base_edb = { 'fuelflow_KGperS': np.array([0.25, 0.5, 0.9, 1.2], dtype=float), 'NOX_EI_matrix': np.array([8.0, 12.0, 26.0, 32.0], dtype=float), @@ -61,7 +74,41 @@ def __init__(self, config_overrides=None, edb_overrides=None): if edb_overrides: base_edb.update(edb_overrides) self.EDB_data = base_edb - + default_lto_settings = { + 'takeoff': { + 'FUEL_KGs': 1.2, + 'EI_NOx': 40.0, + 'EI_HC': 1.0, + 'EI_CO': 2.0, + 'THRUST_FRAC': 1.0, + }, + 'climb': { + 'FUEL_KGs': 0.9, + 'EI_NOx': 32.0, + 'EI_HC': 1.5, + 'EI_CO': 3.0, + 'THRUST_FRAC': 0.85, + }, + 'approach': { + 'FUEL_KGs': 0.5, + 'EI_NOx': 12.0, + 'EI_HC': 3.0, + 'EI_CO': 10.0, + 'THRUST_FRAC': 0.30, + }, + 'idle': { + 'FUEL_KGs': 0.25, + 'EI_NOx': 8.0, + 'EI_HC': 4.0, + 'EI_CO': 20.0, + 'THRUST_FRAC': 0.07, + }, + } + self.LTO_data = { + 'thrust_settings': lto_settings + if lto_settings is not None + else default_lto_settings + } self.APU_data = { 'fuel_kg_per_s': 0.03, 'PM10_g_per_kg': 0.4, @@ -75,19 +122,21 @@ def __init__(self, config_overrides=None, edb_overrides=None): class DummyTrajectory: - """Minimal trajectory profile with monotonic fuel depletion.""" - def __init__(self): self.NClm = 2 self.NCrz = 2 self.NDes = 2 self.Ntot = self.NClm + self.NCrz + self.NDes - fuel_mass = np.array([2000.0, 1994.0, 1987.5, 1975.0, 1960.0, 1945.0]) + fuel_mass = np.array( + [2000.0, 1994.0, 1987.5, 1975.0, 1960.0, 1945.0], dtype=float + ) self.traj_data = { 'fuelMass': fuel_mass, - 'fuelFlow': np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32]), - 'altitude': np.array([0.0, 1500.0, 6000.0, 11000.0, 9000.0, 2000.0]), - 'tas': np.array([120.0, 150.0, 190.0, 210.0, 180.0, 140.0]), + 'fuelFlow': np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32], dtype=float), + 'altitude': np.array( + [0.0, 1500.0, 6000.0, 11000.0, 9000.0, 2000.0], dtype=float + ), + 'tas': np.array([120.0, 150.0, 190.0, 210.0, 180.0, 140.0], dtype=float), } self.fuel_mass = float(fuel_mass[0]) @@ -98,79 +147,222 @@ def trajectory(): @pytest.fixture -def perf_factory(): - def _factory(config_overrides=None, edb_overrides=None): - return DummyPerformanceModel(config_overrides, edb_overrides) +def sample_perf_model(): + def _factory(config_overrides=None, edb_overrides=None, lto_settings=None): + return DummyPerformanceModel(config_overrides, edb_overrides, lto_settings) return _factory @pytest.fixture -def emission(perf_factory, trajectory): - perf = perf_factory() - em = Emission(perf, trajectory) - em.compute_emissions() - return em +def emission_with_run(sample_perf_model, trajectory): + perf = sample_perf_model() + emission = Emission(perf) + output = emission.emit(trajectory) + return emission, output, trajectory + + +def _map_modes_to_categories(mode_values: np.ndarray, thrust_categories: np.ndarray): + values = np.asarray(mode_values, dtype=float).ravel() + mapped = np.full(thrust_categories.shape, values[-1], dtype=float) + mapped[thrust_categories == 2] = values[0] + if values.size > 1: + mapped[thrust_categories == 3] = values[1] + if values.size > 2: + mapped[thrust_categories == 1] = values[2] + return mapped + + +def _expected_scope11_mapping(performance_model, thrust_categories): + edb = performance_model.EDB_data + mass_modes = calculate_PMnvolEI_scope11( + np.array(edb['SN_matrix']), + np.array(edb['PR']), + edb['ENGINE_TYPE'], + np.array(edb['BP_Ratio']), + ) + number_modes = edb.get('PMnvolEIN_best_ICAOthrust') + mass = _map_modes_to_categories(mass_modes, thrust_categories) + number = ( + _map_modes_to_categories(np.array(number_modes), thrust_categories) + if number_modes is not None + else None + ) + return mass, number -def test_emission_settings_parse_string_flags(perf_factory, trajectory): - perf = perf_factory( - { - 'APU_calculation': 'no', - 'GSE_calculation': 'Yes', - 'LC_calculation': 'n', - 'EI_PMvol_method': 'FOA3', - } +def _expected_trajectory_indices(emission, trajectory): + idx_slice = emission._trajectory_slice() + traj_data = trajectory.traj_data + lto_inputs = emission._extract_lto_inputs() + + fuel_flow = traj_data['fuelFlow'][idx_slice] + thrust_categories = get_thrust_cat( + fuel_flow, lto_inputs['fuel_flow'], cruiseCalc=True ) - em = Emission(perf, trajectory) - assert em.apu_enabled is False - assert em.gse_enabled is True - assert em.lifecycle_enabled is False - assert em.pmvol_method == 'foa3' + altitudes = traj_data['altitude'][idx_slice] + tas = traj_data['tas'][idx_slice] + atmos = emission._atmospheric_state(altitudes, tas, True) + sls_flow = emission._sls_equivalent_fuel_flow(True, fuel_flow, atmos) + + expected = { + 'CO2': np.full_like(fuel_flow, emission.co2_ei), + 'H2O': np.full_like(fuel_flow, emission.h2o_ei), + 'SO2': np.full_like(fuel_flow, emission.so2_ei), + 'SO4': np.full_like(fuel_flow, emission.so4_ei), + } + + ( + expected['NOx'], + expected['NO'], + expected['NO2'], + expected['HONO'], + no_prop, + no2_prop, + hono_prop, + ) = BFFM2_EINOx( + sls_equiv_fuel_flow=sls_flow, + NOX_EI_matrix=lto_inputs['nox_ei'], + fuelflow_performance=lto_inputs['fuel_flow'], + Tamb=atmos.temperature, + Pamb=atmos.pressure, + ) -def test_compute_emissions_populates_active_fields(emission): - FILL_VALUE = -1.0 - for field in emission._active_fields: - indices = emission.emission_indices[field] - assert np.all(indices != FILL_VALUE) - assert np.all(indices >= 0.0) - assert np.all(emission.pointwise_emissions_g[field] >= 0.0) + expected['HC'] = EI_HCCO( + sls_flow, + lto_inputs['hc_ei'], + lto_inputs['fuel_flow'], + Tamb=atmos.temperature, + Pamb=atmos.pressure, + cruiseCalc=True, + ) + expected['CO'] = EI_HCCO( + sls_flow, + lto_inputs['co_ei'], + lto_inputs['fuel_flow'], + Tamb=atmos.temperature, + Pamb=atmos.pressure, + cruiseCalc=True, + ) + if emission.conf.pmvol_method is PMvolMethod.FUEL_FLOW: + thrust_labels = np.full(thrust_categories.shape, 'H', dtype='= 0.0 + return expected, (no_prop, no2_prop, hono_prop) -def test_total_fuel_burn_positive(emission): - assert emission.total_fuel_burn > 0.0 +def _expected_lto_nox_split(emission): + lto_inputs = emission._extract_lto_inputs() + thrust_categories = get_thrust_cat(lto_inputs['fuel_flow'], None, cruiseCalc=False) + return NOx_speciation(thrust_categories) -def test_sum_total_emissions_matches_components(perf_factory, trajectory): - perf = perf_factory({'LC_calculation': False}) - em = Emission(perf, trajectory) - em.compute_emissions() +def test_emissions_config_parses_string_flags(): + cfg = EmissionsConfig.from_mapping( + { + 'Fuel': 'synthetic_jet', + 'EDB_input_file': 'engines/custom.edb', + 'LTO_input_mode': 'EDB', + 'EI_NOx_method': 'none', + 'EI_HC_method': 'BFFM2', + 'EI_CO_method': 'BFFM2', + 'EI_PMvol_method': 'FOA3', + 'EI_PMnvol_method': 'scope11', + 'APU_calculation': 'no', + 'GSE_calculation': 'Yes', + 'LC_calculation': '0', + 'climb_descent_usage': 'n', + } + ) + assert cfg.fuel_file == 'fuels/synthetic_jet.toml' + assert cfg.nox_method is EINOxMethod.NONE + assert cfg.apu_calculation is False + assert cfg.gse_calculation is True + assert cfg.lc_calculation is False + assert cfg.pmvol_method is PMvolMethod.FOA3 + assert cfg.pmnvol_method is PMnvolMethod.SCOPE11 + assert cfg.climb_descent_usage is False + assert cfg.lto_input_mode is LTOInputMode.EDB + assert cfg.edb_input_file == 'engines/custom.edb' + + +def test_emit_matches_expected_indices_and_pointwise(emission_with_run): + emission, output, trajectory = emission_with_run + expected, (no_prop, no2_prop, hono_prop) = _expected_trajectory_indices( + emission, trajectory + ) + idx_slice = emission._trajectory_slice() + for field, expected_values in expected.items(): + if field not in emission.emission_indices.dtype.names: + continue + np.testing.assert_allclose( + emission.emission_indices[field][idx_slice], expected_values + ) + fuel_burn = emission.fuel_burn_per_segment[idx_slice] + for field in emission._active_fields: + np.testing.assert_allclose( + emission.pointwise_emissions_g[field][idx_slice], + emission.emission_indices[field][idx_slice] * fuel_burn, + ) + np.testing.assert_allclose( + emission.emission_indices['NO'][idx_slice], + emission.emission_indices['NOx'][idx_slice] * no_prop, + ) + np.testing.assert_allclose( + emission.emission_indices['NO2'][idx_slice], + emission.emission_indices['NOx'][idx_slice] * no2_prop, + ) + np.testing.assert_allclose( + emission.emission_indices['HONO'][idx_slice], + emission.emission_indices['NOx'][idx_slice] * hono_prop, + ) + assert output.trajectory.total_fuel_burn == pytest.approx(emission.total_fuel_burn) + assert output.lifecycle_co2_g is not None + - for field in em.summed_emission_g.dtype.names: +def test_sum_total_emissions_matches_components(sample_perf_model, trajectory): + perf = sample_perf_model({'LC_calculation': False}) + emission = Emission(perf) + emission.emit(trajectory) + for field in emission.summed_emission_g.dtype.names: expected = ( - np.sum(em.pointwise_emissions_g[field]) - + np.sum(em.LTO_emissions_g[field]) - + _scalar(em.APU_emissions_g[field]) - + _scalar(em.GSE_emissions_g[field]) + np.sum(emission.pointwise_emissions_g[field]) + + np.sum(emission.LTO_emissions_g[field]) + + np.sum(emission.APU_emissions_g[field]) + + np.sum(emission.GSE_emissions_g[field]) ) - assert _scalar(em.summed_emission_g[field]) == pytest.approx(expected, rel=1e-6) + assert emission.summed_emission_g[field] == pytest.approx(expected) -def test_lifecycle_emissions_override_total_co2(emission): - expected = emission.fuel['LC_CO2'] * ( - emission.trajectory.fuel_mass * emission.fuel['Energy_MJ_per_kg'] - ) - assert _scalar(emission.summed_emission_g['CO2']) == pytest.approx(expected) +def test_lifecycle_emissions_require_total_fuel_mass(sample_perf_model): + emission = Emission(sample_perf_model()) + bad_traj = DummyTrajectory() + bad_traj.fuel_mass = None + with pytest.raises(ValueError): + emission.emit(bad_traj) -def test_scope11_profile_caching(emission): +def test_scope11_profile_caching(sample_perf_model): + emission = Emission(sample_perf_model()) profile_first = emission._scope11_profile(emission.performance_model) profile_second = emission._scope11_profile(emission.performance_model) assert profile_first is profile_second @@ -180,113 +372,296 @@ def test_scope11_profile_caching(emission): ) -def test_get_lto_tims_respects_traj_flag(perf_factory, trajectory): - em_all = Emission(perf_factory({'climb_descent_usage': True}), trajectory) - durations_all = em_all._get_LTO_TIMs() - assert np.allclose(durations_all[1:3], 0.0) +def test_get_lto_tims_respects_traj_flag(sample_perf_model): + emission_all = Emission(sample_perf_model({'climb_descent_usage': True})) + assert np.allclose(emission_all._get_LTO_TIMs()[1:3], 0.0) + emission_partial = Emission(sample_perf_model({'climb_descent_usage': False})) + assert np.all(emission_partial._get_LTO_TIMs()[1:3] > 0.0) + - em_partial = Emission(perf_factory({'climb_descent_usage': False}), trajectory) - durations_partial = em_partial._get_LTO_TIMs() - assert np.all(durations_partial[1:3] > 0.0) +def test_lto_nox_split_matches_speciation(emission_with_run): + emission, _, _ = emission_with_run + no_prop, no2_prop, hono_prop = _expected_lto_nox_split(emission) + np.testing.assert_allclose( + emission.LTO_emission_indices['NO'], + emission.LTO_emission_indices['NOx'] * no_prop, + ) + np.testing.assert_allclose( + emission.LTO_emission_indices['NO2'], + emission.LTO_emission_indices['NOx'] * no2_prop, + ) + np.testing.assert_allclose( + emission.LTO_emission_indices['HONO'], + emission.LTO_emission_indices['NOx'] * hono_prop, + ) + + +def test_extract_lto_inputs_orders_performance_modes(sample_perf_model, trajectory): + lto_settings = { + 'TakeOff': { + 'FUEL_KGs': 1.7, + 'EI_NOx': 45.0, + 'EI_HC': 1.1, + 'EI_CO': 2.2, + 'THRUST_FRAC': 1.0, + }, + 'Climb': { + 'FUEL_KGs': 0.95, + 'EI_NOx': 33.0, + 'EI_HC': 1.4, + 'EI_CO': 2.8, + 'THRUST_FRAC': 0.85, + }, + 'Approach': { + 'FUEL_KGs': 0.55, + 'EI_NOx': 14.0, + 'EI_HC': 2.8, + 'EI_CO': 9.5, + 'THRUST_FRAC': 0.30, + }, + 'Idle': { + 'FUEL_KGs': 0.28, + 'EI_NOx': 9.0, + 'EI_HC': 3.5, + 'EI_CO': 18.0, + 'THRUST_FRAC': 0.07, + }, + } + perf = sample_perf_model( + {'LTO_input_mode': 'performance_model'}, + lto_settings=lto_settings, + ) + emission = Emission(perf) + emission._prepare_run_state(trajectory) + lto_inputs = emission._extract_lto_inputs() + assert np.allclose(lto_inputs['fuel_flow'], [0.28, 0.55, 0.95, 1.7]) + assert np.allclose(lto_inputs['nox_ei'], [9.0, 14.0, 33.0, 45.0]) + assert np.allclose(lto_inputs['hc_ei'], [3.5, 2.8, 1.4, 1.1]) + assert np.allclose(lto_inputs['co_ei'], [18.0, 9.5, 2.8, 2.2]) + assert np.allclose(lto_inputs['thrust_pct'], [7.0, 30.0, 85.0, 100.0]) + + +def test_pmvol_foa3_uses_thrust_percentages(monkeypatch, sample_perf_model, trajectory): + perf = sample_perf_model({'EI_PMvol_method': 'foa3'}) + emission = Emission(perf) + emission._prepare_run_state(trajectory) + idx_slice = emission._trajectory_slice() + thrust_categories = np.array([1, 2, 3, 1, 2, 3]) + hc_ei = np.linspace(1.0, 2.5, thrust_categories.size) + captured = {} + + def fake_foa3(thrusts, hc_values): + captured['thrusts'] = thrusts.copy() + captured['hc'] = hc_values.copy() + return np.zeros_like(hc_values), np.zeros_like(hc_values) + + monkeypatch.setattr('emissions.emission.EI_PMvol_FOA3', fake_foa3) + emission._calculate_EI_PMvol( + idx_slice, + thrust_categories, + trajectory.traj_data['fuelFlow'][idx_slice], + hc_ei, + ) + expected = emission._thrust_percentages_from_categories(thrust_categories) + np.testing.assert_allclose(captured['thrusts'], expected) + np.testing.assert_allclose(captured['hc'], hc_ei) -def test_wnsf_index_mapping_and_errors(perf_factory, trajectory): - em = Emission(perf_factory(), trajectory) - assert em._wnsf_index('wide') == 0 - assert em._wnsf_index('FREIGHT') == 3 +def test_wnsf_index_mapping_and_errors(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + emission._prepare_run_state(trajectory) + assert emission._wnsf_index('wide') == 0 + assert emission._wnsf_index('FREIGHT') == 3 with pytest.raises(ValueError): - em._wnsf_index('unknown') + emission._wnsf_index('unknown') -def test_calculate_pmvol_requires_hc_for_foa3(perf_factory, trajectory): - em = Emission(perf_factory({'EI_PMvol_method': 'foa3'}), trajectory) - idx_slice = em._trajectory_slice() - fuel_flow = em.trajectory.traj_data['fuelFlow'][idx_slice] +def test_calculate_pmvol_requires_hc_for_foa3(sample_perf_model, trajectory): + emission = Emission(sample_perf_model({'EI_PMvol_method': 'foa3'})) + emission._prepare_run_state(trajectory) + idx_slice = emission._trajectory_slice() + fuel_flow = trajectory.traj_data['fuelFlow'][idx_slice] thrust_categories = np.ones_like(fuel_flow, dtype=int) with pytest.raises(RuntimeError): - em._calculate_EI_PMvol( - idx_slice, - thrust_categories, - fuel_flow, - None, - ) + emission._calculate_EI_PMvol(idx_slice, thrust_categories, fuel_flow, None) -def test_calculate_pmnvol_scope11_populates_fields(perf_factory, trajectory): - em = Emission(perf_factory({'EI_PMnvol_method': 'scope11'}), trajectory) - idx_slice = em._trajectory_slice() +def test_calculate_pmnvol_scope11_populates_fields(sample_perf_model, trajectory): + emission = Emission(sample_perf_model({'EI_PMnvol_method': 'scope11'})) + emission._prepare_run_state(trajectory) + idx_slice = emission._trajectory_slice() thrust_categories = np.ones_like( - em.trajectory.traj_data['fuelFlow'][idx_slice], dtype=int + trajectory.traj_data['fuelFlow'][idx_slice], dtype=int ) - em._calculate_EI_PMnvol( + emission._calculate_EI_PMnvol( idx_slice, thrust_categories, - em.trajectory.traj_data['altitude'][idx_slice], + trajectory.traj_data['altitude'][idx_slice], AtmosphericState(None, None, None), - em.performance_model, + emission.performance_model, ) - assert np.all(em.emission_indices['PMnvol'][idx_slice] >= 0.0) - assert np.all(em.emission_indices['PMnvolGMD'][idx_slice] == 0.0) + traj_len = idx_slice.stop - idx_slice.start + expected_mass = np.full(traj_len, 0.07311027) + expected_number = np.full(traj_len, 1.3e13) + np.testing.assert_allclose( + emission.emission_indices['PMnvol'][idx_slice], expected_mass + ) + np.testing.assert_allclose( + emission.emission_indices['PMnvolGMD'][idx_slice], + np.zeros(traj_len, dtype=float), + ) + if 'PMnvolN' in emission.emission_indices.dtype.names: + np.testing.assert_allclose( + emission.emission_indices['PMnvolN'][idx_slice], expected_number + ) -def test_calculate_pmnvol_meem_populates_fields(perf_factory, trajectory): - em = Emission(perf_factory({'EI_PMnvol_method': 'meem'}), trajectory) - idx_slice = em._trajectory_slice() - altitudes = em.trajectory.traj_data['altitude'][idx_slice] - tas = em.trajectory.traj_data['tas'][idx_slice] - atmos = em._atmospheric_state(altitudes, tas, True) +def test_calculate_pmnvol_meem_populates_fields(sample_perf_model, trajectory): + emission = Emission(sample_perf_model({'EI_PMnvol_method': 'meem'})) + emission._prepare_run_state(trajectory) + idx_slice = emission._trajectory_slice() + altitudes = trajectory.traj_data['altitude'][idx_slice] + tas = trajectory.traj_data['tas'][idx_slice] + atmos = emission._atmospheric_state(altitudes, tas, True) thrust_categories = np.ones_like( - em.trajectory.traj_data['fuelFlow'][idx_slice], dtype=int + trajectory.traj_data['fuelFlow'][idx_slice], dtype=int + ) + emission._calculate_EI_PMnvol( + idx_slice, + thrust_categories, + altitudes, + atmos, + emission.performance_model, + ) + expected_gmd = np.array( + [40.0, 38.4671063303, 35.9228905331, 30.6483151751, 20.0, 20.0] + ) + expected_mass = np.array( + [ + 0.008296638, + 0.0073855157, + 0.0060141937, + 0.0048486474, + 0.0031337246, + 0.0057335216, + ] + ) + expected_number = np.array( + [ + 2.9357334337e14, + 2.6122814630e14, + 2.0133216668e14, + 1.4705704051e14, + 1.2534898598e14, + 2.2714835290e14, + ] + ) + np.testing.assert_allclose( + emission.emission_indices['PMnvolGMD'][idx_slice], expected_gmd ) - em._calculate_EI_PMnvol( - idx_slice, thrust_categories, altitudes, atmos, em.performance_model + np.testing.assert_allclose( + emission.emission_indices['PMnvol'][idx_slice], expected_mass ) - assert np.all(em.emission_indices['PMnvol'][idx_slice] >= 0.0) - assert np.all(em.emission_indices['PMnvolGMD'][idx_slice] >= 0.0) - if 'PMnvolN' in em.emission_indices.dtype.names: - assert np.all(em.emission_indices['PMnvolN'][idx_slice] >= 0.0) + if 'PMnvolN' in emission.emission_indices.dtype.names: + np.testing.assert_allclose( + emission.emission_indices['PMnvolN'][idx_slice], expected_number + ) -def test_compute_ei_nox_requires_inputs(perf_factory, trajectory): - em = Emission(perf_factory(), trajectory) - idx_slice = em._trajectory_slice() - lto_inputs = em._extract_lto_inputs() +def test_compute_ei_nox_requires_inputs(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + emission._prepare_run_state(trajectory) + idx_slice = emission._trajectory_slice() + lto_inputs = emission._extract_lto_inputs() with pytest.raises(RuntimeError): - em.compute_EI_NOx( + emission.compute_EI_NOx( idx_slice, lto_inputs, AtmosphericState(None, None, None), None ) -def test_atmospheric_state_and_sls_flow_shapes(perf_factory, trajectory): - em = Emission(perf_factory(), trajectory) - idx_slice = em._trajectory_slice() - altitudes = em.trajectory.traj_data['altitude'][idx_slice] - tas = em.trajectory.traj_data['tas'][idx_slice] - atmos = em._atmospheric_state(altitudes, tas, True) - assert atmos.temperature.shape == altitudes.shape - assert atmos.pressure.shape == altitudes.shape - assert atmos.mach.shape == altitudes.shape - - fuel_flow = em.trajectory.traj_data['fuelFlow'][idx_slice] - sls_flow = em._sls_equivalent_fuel_flow(True, fuel_flow, atmos) - assert sls_flow.shape == fuel_flow.shape - assert em._sls_equivalent_fuel_flow(False, fuel_flow, atmos) is None - - -def test_get_gse_emissions_assigns_all_species(perf_factory, trajectory): - em = Emission(perf_factory(), trajectory) - em.get_GSE_emissions('wide') - for field in ('CO2', 'NOx', 'HC', 'CO', 'PMvol', 'PMnvol', 'H2O', 'SO2', 'SO4'): - assert _scalar(em.GSE_emissions_g[field]) >= 0.0 - - -def test_get_gse_emissions_invalid_code(perf_factory, trajectory): - em = Emission(perf_factory(), trajectory) +def test_atmospheric_state_and_sls_flow_shapes(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + idx_slice = slice(0, trajectory.Ntot) + altitudes = trajectory.traj_data['altitude'][idx_slice] + tas = trajectory.traj_data['tas'][idx_slice] + atmos = emission._atmospheric_state(altitudes, tas, True) + expected_temp = np.array([288.15, 278.4, 249.15, 216.65, 229.65, 275.15]) + expected_pressure = np.array( + [ + 101325.0, + 84555.9940737564, + 47181.0021852292, + 22632.0400950078, + 30742.4326120969, + 79495.201934051, + ] + ) + expected_mach = np.array( + [ + 0.3526362622, + 0.4484475741, + 0.6004518548, + 0.7116967515, + 0.5925081326, + 0.4210157206, + ] + ) + np.testing.assert_allclose(atmos.temperature, expected_temp) + np.testing.assert_allclose(atmos.pressure, expected_pressure) + np.testing.assert_allclose(atmos.mach, expected_mach) + + fuel_flow = trajectory.traj_data['fuelFlow'][idx_slice] + sls_flow = emission._sls_equivalent_fuel_flow(True, fuel_flow, atmos) + expected_sls = np.array( + [ + 0.153777, + 0.203788, + 0.474551, + 0.910231, + 0.561445, + 0.192661, + ] + ) + np.testing.assert_allclose(sls_flow, expected_sls, atol=1e-5) + assert emission._sls_equivalent_fuel_flow(False, fuel_flow, atmos) is None + + +def test_get_gse_emissions_matches_reference_profile(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + emission._prepare_run_state(trajectory) + emission.get_GSE_emissions('wide') + expected = { + 'CO2': 58_000.0, + 'NOx': 900.0, + 'HC': 70.0, + 'CO': 300.0, + 'H2O': 22669.67201166181, + 'NO': 810.0, + 'NO2': 81.0, + 'HONO': 9.0, + 'SO4': 0.0003, + 'SO2': 0.0098, + 'PMvol': 27.49985, + 'PMnvol': 27.49985, + 'PMnvolGMD': 0.0, + 'OCic': 0.0, + } + for field, value in expected.items(): + assert emission.GSE_emissions_g[field] == pytest.approx(value) + if 'PMnvolN' in emission.GSE_emissions_g.dtype.names: + assert emission.GSE_emissions_g['PMnvolN'] == pytest.approx(0.0) + + +def test_get_gse_emissions_invalid_code(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + emission._prepare_run_state(trajectory) with pytest.raises(ValueError): - em.get_GSE_emissions('bad') + emission.get_GSE_emissions('bad') -def test_emission_dtype_consistency(emission): +def test_emission_dtype_consistency(sample_perf_model, trajectory): + emission = Emission(sample_perf_model()) + emission._prepare_run_state(trajectory) dtype_names = set(emission.emission_indices.dtype.names) assert set(emission.pointwise_emissions_g.dtype.names) == dtype_names assert set(emission.LTO_emissions_g.dtype.names) == dtype_names diff --git a/tests/test_performance_model.py b/tests/test_performance_model.py index 1545d2d..881bfdd 100644 --- a/tests/test_performance_model.py +++ b/tests/test_performance_model.py @@ -1,39 +1,123 @@ +from __future__ import annotations + import numpy as np +import pytest -from AEIC.performance_model import PerformanceModel +from AEIC.performance_model import ( + PerformanceConfig, + PerformanceInputMode, + PerformanceModel, +) def test_performance_model_initialization(): - """Test initialization and basic structure of the PerformanceModel.""" + """PerformanceModel builds config, missions, and performance tables.""" model = PerformanceModel('IO/default_config.toml') - # Test keys in config - assert isinstance(model.config, dict) - assert "performance_model_input" in model.config - - # Test missions - assert isinstance(model.missions, list) - assert model.missions[0].load_factor == 1.0 + assert isinstance(model.config, PerformanceConfig) + assert ( + model.config.performance_model_input is PerformanceInputMode.PERFORMANCE_MODEL + ) + assert model.config.missions_folder == 'missions' + assert model.config.missions_in_file.endswith('.toml') - # Check aircraft parameters were loaded - assert hasattr(model, "ac_params") - assert model.ac_params.cas_cruise_lo == 128.611 + assert isinstance(model.missions, list) and len(model.missions) > 0 + first_mission = model.missions[0] + for key in ('dep_airport', 'arr_airport', 'distance_nm', 'ac_code'): + assert key in first_mission - # Check engine model initialized - assert hasattr(model, "engine_model") + assert hasattr(model, 'ac_params') + assert model.ac_params.cas_cruise_lo == pytest.approx( + model.model_info['speeds']['cruise']['cas_lo'] + ) + assert getattr(model, 'engine_model', None) is not None - # Check LTO data assert isinstance(model.LTO_data, dict) assert model.LTO_data['ICAO_UID'] == '01P11CM121' - # Check performance table exists assert isinstance(model.performance_table, np.ndarray) - assert ( - model.performance_table.shape == (26, 51, 105, 3) - or model.performance_table.ndim == 4 - ) + assert model.performance_table.ndim == 4 + assert model.performance_table_colnames == ['FL', 'TAS', 'ROCD', 'MASS'] + + dimension_lengths = tuple(len(col) for col in model.performance_table_cols) + assert model.performance_table.shape == dimension_lengths + + +def test_performance_config_from_mapping_requires_sections(): + base = { + 'Missions': {'missions_folder': 'missions', 'missions_in_file': 'sample.toml'}, + 'General Information': { + 'performance_model_input': 'opf', + 'performance_model_input_file': 'data/perf.toml', + }, + 'Emissions': { + 'EDB_input_file': 'engines/example.edb', + 'LTO_input_mode': 'performance_model', + }, + } + config = PerformanceConfig.from_mapping(base) + assert config.performance_model_input is PerformanceInputMode.OPF + assert config.performance_model_input_file == 'data/perf.toml' + assert config.edb_input_file == 'engines/example.edb' + + for missing_key in ('Missions', 'General Information', 'Emissions'): + incomplete = dict(base) + incomplete.pop(missing_key) + with pytest.raises(ValueError): + PerformanceConfig.from_mapping(incomplete) - # Check input column names and values - assert "FL" in model.performance_table_colnames - assert len(model.performance_table_cols) == len(model.performance_table_colnames) + +def test_create_performance_table_maps_multi_dimensional_grid(): + model = PerformanceModel.__new__(PerformanceModel) + cols = ['FL', 'FUEL_FLOW', 'TAS', 'ROCD'] + rows = [] + for fl in (330, 350): + for tas in (220, 240): + for rocd in (-500, 0): + fuel_flow = round( + 0.5 + 0.001 * fl + 0.0001 * tas + 0.00001 * abs(rocd), 6 + ) + rows.append([fl, fuel_flow, tas, rocd]) + model.create_performance_table({'cols': cols, 'data': rows}) + + fl_values, tas_values, rocd_values = model.performance_table_cols + assert fl_values == [330, 350] + assert tas_values == [220, 240] + assert rocd_values == [-500, 0] + + fl_idx = fl_values.index(350) + tas_idx = tas_values.index(240) + rocd_idx = rocd_values.index(0) + expected = 0.5 + 0.001 * 350 + 0.0001 * 240 + 0.00001 * 0 + assert model.performance_table[fl_idx, tas_idx, rocd_idx] == pytest.approx(expected) + + +def test_create_performance_table_missing_output_column(): + model = PerformanceModel.__new__(PerformanceModel) + data = {'cols': ['FL', 'TAS'], 'data': [[330, 220]]} + with pytest.raises(ValueError, match="FUEL_FLOW column not found"): + model.create_performance_table(data) + + +def test_get_engine_by_uid_reads_matching_engine(tmp_path, monkeypatch): + engine_file = tmp_path / 'engines.toml' + engine_file.write_text( + """ + [[engine]] + UID = "MATCH" + thrust = 42 + bypass = 10.5 + + [[engine]] + UID = "OTHER" + thrust = 99 + """ + ) + monkeypatch.setattr( + 'AEIC.performance_model.file_location', lambda path: str(engine_file) + ) + model = PerformanceModel.__new__(PerformanceModel) + match = model.get_engine_by_uid('MATCH', 'ignored') + assert match['thrust'] == 42 + assert model.get_engine_by_uid('MISSING', 'ignored') is None From 06ebe2d0970d3d8469e71e0b432d4590be1d4658 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Thu, 13 Nov 2025 14:44:40 -0500 Subject: [PATCH 10/12] removed extra attributes of self.eis --- src/AEIC/emissions/emission.py | 12 ++++-------- tests/test_emissions.py | 8 ++++---- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index 10de6fb..0496790 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -304,9 +304,6 @@ def __init__(self, ac_performance: PerformanceModel): with open(file_location(self.conf.fuel_file), 'rb') as f: self.fuel = tomllib.load(f) - self.co2_ei, self.nvol_carb_cont = EI_CO2(self.fuel) - self.h2o_ei = EI_H2O(self.fuel) - self.so2_ei, self.so4_ei = EI_SOx(self.fuel) self._reset_run_state() @@ -358,7 +355,7 @@ def compute_emissions(self): self.LTO_noProp, self.LTO_no2Prop, self.LTO_honoProp, - EI_H2O=self.h2o_ei, + EI_H2O=EI_H2O(self.fuel), nvpm_method=self.conf.pmnvol_method.value, ) self._apply_metric_mask(self.APU_emission_indices) @@ -1056,12 +1053,11 @@ def _constant_species_values(self): """Return constant EI values that do not depend on thrust or atmosphere.""" constants = {} if self.metric_flags.get('CO2'): - constants['CO2'] = self.co2_ei + constants['CO2'], _ = EI_CO2(self.fuel) if self.metric_flags.get('H2O'): - constants['H2O'] = self.h2o_ei + constants['H2O'] = EI_H2O(self.fuel) if self.metric_flags.get('SOx'): - constants['SO2'] = self.so2_ei - constants['SO4'] = self.so4_ei + constants['SO2'], constants['SO4'] = EI_SOx(self.fuel) return constants def _atmospheric_state( diff --git a/tests/test_emissions.py b/tests/test_emissions.py index 63d6177..fdab4ef 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -207,10 +207,10 @@ def _expected_trajectory_indices(emission, trajectory): sls_flow = emission._sls_equivalent_fuel_flow(True, fuel_flow, atmos) expected = { - 'CO2': np.full_like(fuel_flow, emission.co2_ei), - 'H2O': np.full_like(fuel_flow, emission.h2o_ei), - 'SO2': np.full_like(fuel_flow, emission.so2_ei), - 'SO4': np.full_like(fuel_flow, emission.so4_ei), + 'CO2': np.full_like(fuel_flow, 3155.6), + 'H2O': np.full_like(fuel_flow, 1233.3865), + 'SO2': np.full_like(fuel_flow, 1.176), + 'SO4': np.full_like(fuel_flow, 0.036), } ( From e5b6240700c001d59035b81d8493ae8368c0d913 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Thu, 13 Nov 2025 15:06:43 -0500 Subject: [PATCH 11/12] removed loose data types in EI functions --- src/AEIC/emissions/EI_CO2.py | 27 ++++--- src/AEIC/emissions/EI_NOx.py | 45 ++++++----- src/AEIC/emissions/EI_SOx.py | 25 +++++-- src/AEIC/emissions/emission.py | 24 +++--- tests/test_emission_functions.py | 124 ++++++++++++++++++------------- tests/test_emissions.py | 17 ++--- 6 files changed, 156 insertions(+), 106 deletions(-) diff --git a/src/AEIC/emissions/EI_CO2.py b/src/AEIC/emissions/EI_CO2.py index 867d9d4..2228028 100644 --- a/src/AEIC/emissions/EI_CO2.py +++ b/src/AEIC/emissions/EI_CO2.py @@ -1,20 +1,29 @@ -def EI_CO2(fuel): +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class CO2EmissionResult: + """Named return values for CO₂ EI calculations.""" + + EI_CO2: float + nvolCarbCont: float + + +def EI_CO2(fuel: Mapping[str, Any]) -> CO2EmissionResult: """ Calculate carbon-balanced CO2 emissions index (EI). Parameters ---------- - fuel : dictionary + fuel : Mapping[str, Any] Fuel information (input from toml file) Returns ------- - CO2EI : ndarray - CO2 emissions index [g/kg fuel], same shape as HCEI. - CO2EInom : float - Nominal CO2 emissions index (scalar). - nvolCarbCont : float - Non-volatile particulate carbon content fraction. + CO2EmissionResult + Structured CO2 emissions metadata. """ # if mcs == 1: @@ -24,4 +33,4 @@ def EI_CO2(fuel): CO2EInom = fuel['EI_CO2'] nvolCarbCont = fuel['nvolCarbCont'] - return CO2EInom, nvolCarbCont + return CO2EmissionResult(EI_CO2=CO2EInom, nvolCarbCont=nvolCarbCont) diff --git a/src/AEIC/emissions/EI_NOx.py b/src/AEIC/emissions/EI_NOx.py index 381cc90..c774fec 100644 --- a/src/AEIC/emissions/EI_NOx.py +++ b/src/AEIC/emissions/EI_NOx.py @@ -1,10 +1,24 @@ import warnings +from dataclasses import dataclass import numpy as np from AEIC.utils.standard_fuel import get_thrust_cat +@dataclass(frozen=True) +class BFFM2EINOxResult: + """Bundled NOx emissions indices and speciation data.""" + + NOxEI: np.ndarray + NOEI: np.ndarray + NO2EI: np.ndarray + HONOEI: np.ndarray + noProp: np.ndarray + no2Prop: np.ndarray + honoProp: np.ndarray + + def BFFM2_EINOx( sls_equiv_fuel_flow: np.ndarray, NOX_EI_matrix: np.ndarray, @@ -12,9 +26,7 @@ def BFFM2_EINOx( Tamb: np.ndarray, Pamb: np.ndarray, cruiseCalc: bool = True, -) -> tuple[ - np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray -]: +) -> BFFM2EINOxResult: """ Calculate NOx, NO, NO2, and HONO emission indices All inputs are 1-dimensional arrays of equal length for calibration @@ -39,21 +51,8 @@ def BFFM2_EINOx( Returns ------- - NOxEI : ndarray, shape (n_times,) - Interpolated NOx emission index [g NOx / kg fuel] - for each SLS_equivalent_fuel_flow. - NOEI : ndarray, shape (n_times,) - NO emission index [g NO / kg fuel]. - NO2EI : ndarray, shape (n_times,) - NO2 emission index [g NO2 / kg fuel]. - HONOEI : ndarray, shape (n_times,) - HONO emission index [g HONO / kg fuel]. - noProp : ndarray, shape (n_times,) - Fraction of NO within total NOy (unitless). - no2Prop : ndarray, shape (n_times,) - Fraction of NO2 within total NOy (unitless). - honoProp: ndarray, shape (n_times,) - Fraction of HONO within total NOy (unitless). + BFFM2EINOxResult + Structured NOx EI arrays and speciation fractions. """ # --------------------------------------------------------------------- @@ -133,7 +132,15 @@ def BFFM2_EINOx( NO2EI = NOxEI * no2Prop # g NO2 / kg fuel HONOEI = NOxEI * honoProp # g HONO / kg fuel - return NOxEI, NOEI, NO2EI, HONOEI, noProp, no2Prop, honoProp + return BFFM2EINOxResult( + NOxEI=NOxEI, + NOEI=NOEI, + NO2EI=NO2EI, + HONOEI=HONOEI, + noProp=noProp, + no2Prop=no2Prop, + honoProp=honoProp, + ) def NOx_speciation(thrustCat): diff --git a/src/AEIC/emissions/EI_SOx.py b/src/AEIC/emissions/EI_SOx.py index e317395..c2667b1 100644 --- a/src/AEIC/emissions/EI_SOx.py +++ b/src/AEIC/emissions/EI_SOx.py @@ -1,19 +1,30 @@ -def EI_SOx(fuel: dict): +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class SOxEmissionResult: + """Structured SOx emission indices.""" + + EI_SO2: float + EI_SO4: float + + +def EI_SOx(fuel: Mapping[str, Any]) -> SOxEmissionResult: """ Calculate universal SOx emissions indices (SO2EI and SO4EI). Parameters ---------- - fuel : dictionary + fuel : Mapping[str, Any] Fuel information (input from toml file) Returns ------- - SO2EI : ndarray - SO2 emissions index [g SO2 per kg fuel] - SO4EI : ndarray - SO4 emissions index [g SO4 per kg fuel] + SOxEmissionResult + Structured SO2/SO4 emissions indices [g/kg fuel] """ # Nominal values FSCnom = fuel['FSCnom'] @@ -40,4 +51,4 @@ def EI_SOx(fuel: dict): SO4EI = 1e3 * ((FSC / 1e6) * Eps * MW_SO4) / MW_S SO2EI = 1e3 * ((FSC / 1e6) * (1 - Eps) * MW_SO2) / MW_S - return SO2EI, SO4EI + return SOxEmissionResult(EI_SO2=SO2EI, EI_SO4=SO4EI) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index 0496790..413a4f7 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -713,7 +713,10 @@ def get_GSE_emissions(self, wnsf): ) pm_core = nominal['PM10'] - CO2_EI = getattr(self, 'co2_ei', EI_CO2(self.fuel)[0]) + co2_result = getattr(self, 'co2_ei', None) + if co2_result is None: + co2_result = EI_CO2(self.fuel) + CO2_EI = co2_result.EI_CO2 gse_fuel = self.GSE_emissions_g['CO2'] / CO2_EI self.total_fuel_burn += gse_fuel @@ -780,19 +783,17 @@ def compute_EI_NOx( raise RuntimeError( "BFFM2 NOx requires atmosphere and SLS equivalent fuel flow." ) - ( - self.emission_indices['NOx'][idx_slice], - self.emission_indices['NO'][idx_slice], - self.emission_indices['NO2'][idx_slice], - self.emission_indices['HONO'][idx_slice], - *_, - ) = BFFM2_EINOx( + bffm2_result = BFFM2_EINOx( sls_equiv_fuel_flow=sls_equiv_fuel_flow, NOX_EI_matrix=lto_inputs['nox_ei'], fuelflow_performance=lto_inputs['fuel_flow'], Pamb=atmos_state.pressure, Tamb=atmos_state.temperature, ) + self.emission_indices['NOx'][idx_slice] = bffm2_result.NOxEI + self.emission_indices['NO'][idx_slice] = bffm2_result.NOEI + self.emission_indices['NO2'][idx_slice] = bffm2_result.NO2EI + self.emission_indices['HONO'][idx_slice] = bffm2_result.HONOEI elif method is EINOxMethod.P3T3: print("P3T3 method not implemented yet..") else: @@ -1053,11 +1054,14 @@ def _constant_species_values(self): """Return constant EI values that do not depend on thrust or atmosphere.""" constants = {} if self.metric_flags.get('CO2'): - constants['CO2'], _ = EI_CO2(self.fuel) + co2_result = EI_CO2(self.fuel) + constants['CO2'] = co2_result.EI_CO2 if self.metric_flags.get('H2O'): constants['H2O'] = EI_H2O(self.fuel) if self.metric_flags.get('SOx'): - constants['SO2'], constants['SO4'] = EI_SOx(self.fuel) + sox_result = EI_SOx(self.fuel) + constants['SO2'] = sox_result.EI_SO2 + constants['SO4'] = sox_result.EI_SO4 return constants def _atmospheric_state( diff --git a/tests/test_emission_functions.py b/tests/test_emission_functions.py index 7767d66..bb4f797 100644 --- a/tests/test_emission_functions.py +++ b/tests/test_emission_functions.py @@ -5,13 +5,13 @@ import pytest from emissions.APU_emissions import get_APU_emissions -from emissions.EI_CO2 import EI_CO2 +from emissions.EI_CO2 import EI_CO2, CO2EmissionResult from emissions.EI_H2O import EI_H2O from emissions.EI_HCCO import EI_HCCO -from emissions.EI_NOx import BFFM2_EINOx, NOx_speciation +from emissions.EI_NOx import BFFM2_EINOx, BFFM2EINOxResult, NOx_speciation from emissions.EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 from emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow -from emissions.EI_SOx import EI_SOx +from emissions.EI_SOx import EI_SOx, SOxEmissionResult from emissions.lifecycle_CO2 import lifecycle_CO2 from utils import file_location @@ -23,19 +23,20 @@ def test_returns_documented_jet_a_values(self): """Jet-A reference EI and carbon fraction should match documentation""" with open(file_location("fuels/conventional_jetA.toml"), 'rb') as f: fuel = tomllib.load(f) - co2_ei, nvol_carb = EI_CO2(fuel) + result = EI_CO2(fuel) - assert co2_ei == pytest.approx(3155.6) - assert nvol_carb == pytest.approx(0.95) + assert isinstance(result, CO2EmissionResult) + assert result.EI_CO2 == pytest.approx(3155.6) + assert result.nvolCarbCont == pytest.approx(0.95) def test_distinguishes_saf_inputs(self): """Different fuels propagate their specific EI metadata.""" with open(file_location("fuels/SAF.toml"), 'rb') as f: fuel = tomllib.load(f) - co2_ei, nvol_carb = EI_CO2(fuel) + result = EI_CO2(fuel) - assert co2_ei == pytest.approx(fuel['EI_CO2']) - assert nvol_carb == pytest.approx(fuel['nvolCarbCont']) + assert result.EI_CO2 == pytest.approx(fuel['EI_CO2']) + assert result.nvolCarbCont == pytest.approx(fuel['nvolCarbCont']) def test_error_handling(self): """Test error handling for invalid inputs""" @@ -43,9 +44,9 @@ def test_error_handling(self): EI_CO2({}) fuel = {'EI_CO2': -100, 'nvolCarbCont': -0.5} - co2_ei, nvol_carb = EI_CO2(fuel) - assert co2_ei == -100 - assert nvol_carb == -0.5 + result = EI_CO2(fuel) + assert result.EI_CO2 == -100 + assert result.nvolCarbCont == -0.5 class TestEI_H2O: @@ -202,9 +203,20 @@ def setup_method(self): self.Tamb = np.array([288.15, 250.0, 220.0, 280.0]) self.Pamb = np.array([101325.0, 25000.0, 15000.0, 95000.0]) + def _components(self, result: BFFM2EINOxResult): + return ( + result.NOxEI, + result.NOEI, + result.NO2EI, + result.HONOEI, + result.noProp, + result.no2Prop, + result.honoProp, + ) + def test_basic_functionality(self): """Test basic NOx emissions calculation""" - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -212,18 +224,16 @@ def test_basic_functionality(self): self.Pamb, ) - # Should return 7 arrays - assert len(results) == 7 - NOxEI, NOEI, NO2EI, HONOEI, noProp, no2Prop, honoProp = results + assert isinstance(result, BFFM2EINOxResult) # Check shapes expected_shape = self.fuelflow_trajectory.shape - for result in results: - assert result.shape == expected_shape + for array in self._components(result): + assert array.shape == expected_shape def test_non_negativity(self): """Test that all outputs are non-negative""" - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -231,12 +241,12 @@ def test_non_negativity(self): self.Pamb, ) - for result in results: - assert np.all(result >= 0) + for array in self._components(result): + assert np.all(array >= 0) def test_summation_consistency(self): """Test that NO + NO2 + HONO proportions sum to 1""" - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -244,7 +254,13 @@ def test_summation_consistency(self): self.Pamb, ) - NOxEI, NOEI, NO2EI, HONOEI, noProp, no2Prop, honoProp = results + NOxEI = result.NOxEI + NOEI = result.NOEI + NO2EI = result.NO2EI + HONOEI = result.HONOEI + noProp = result.noProp + no2Prop = result.no2Prop + honoProp = result.honoProp # Proportions should sum to 1 total_prop = noProp + no2Prop + honoProp @@ -256,7 +272,7 @@ def test_summation_consistency(self): def test_finiteness(self): """Test that all outputs are finite""" - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -264,8 +280,8 @@ def test_finiteness(self): self.Pamb, ) - for result in results: - assert np.all(np.isfinite(result)) + for array in self._components(result): + assert np.all(np.isfinite(array)) def test_cruise_correction_effect(self): """Test that cruise correction has an effect""" @@ -288,7 +304,10 @@ def test_cruise_correction_effect(self): ) # NOx EI should be different - assert not np.allclose(results_no_cruise[0], results_with_cruise[0]) + assert not np.allclose( + results_no_cruise.NOxEI, + results_with_cruise.NOxEI, + ) @patch('AEIC.utils.standard_fuel.get_thrust_cat') def test_thrust_categorization(self, mock_get_thrust_cat): @@ -298,7 +317,7 @@ def test_thrust_categorization(self, mock_get_thrust_cat): [1, 2, 3, 1] ) # High, Low, Approach, High - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -307,13 +326,12 @@ def test_thrust_categorization(self, mock_get_thrust_cat): ) # Should still return valid results - assert len(results) == 7 - for result in results: - assert np.all(np.isfinite(result)) + for array in self._components(result): + assert np.all(np.isfinite(array)) def test_matches_reference_component_values(self): """Reference regression to guard against inadvertent logic changes""" - results = BFFM2_EINOx( + result = BFFM2_EINOx( self.fuelflow_trajectory, self.NOX_EI_matrix, self.fuelflow_performance, @@ -329,8 +347,8 @@ def test_matches_reference_component_values(self): np.array([0.826075, 0.1528, 0.0744375, 0.0744375]), np.array([0.045, 0.045, 0.0075, 0.0075]), ] - for result, expected in zip(results, expected_arrays): - np.testing.assert_allclose(result, expected, rtol=1e-6, atol=1e-9) + for array, expected in zip(self._components(result), expected_arrays): + np.testing.assert_allclose(array, expected, rtol=1e-6, atol=1e-9) class TestNOxSpeciation: @@ -383,19 +401,20 @@ def test_basic_functionality(self): 'Epsnom': 0.02, # fraction } - SO2EI, SO4EI = EI_SOx(fuel) + result = EI_SOx(fuel) - assert isinstance(SO2EI, int | float) - assert isinstance(SO4EI, int | float) + assert isinstance(result, SOxEmissionResult) + assert isinstance(result.EI_SO2, int | float) + assert isinstance(result.EI_SO4, int | float) def test_non_negativity(self): """Test that outputs are non-negative""" fuel = {'FSCnom': 600.0, 'Epsnom': 0.02} - SO2EI, SO4EI = EI_SOx(fuel) + result = EI_SOx(fuel) - assert SO2EI >= 0 - assert SO4EI >= 0 + assert result.EI_SO2 >= 0 + assert result.EI_SO4 >= 0 def test_mass_balance(self): """Test that SO2 + SO4 production makes sense relative to sulfur content""" @@ -404,14 +423,14 @@ def test_mass_balance(self): 'Epsnom': 0.02, # 2% converted to SO4 } - SO2EI, SO4EI = EI_SOx(fuel) + result = EI_SOx(fuel) # Calculate total sulfur converted (should be proportional to FSC) MW_SO2, MW_SO4, MW_S = 64.0, 96.0, 32.0 # Back-calculate sulfur content from emissions - sulfur_as_SO2 = SO2EI * MW_S / MW_SO2 - sulfur_as_SO4 = SO4EI * MW_S / MW_SO4 + sulfur_as_SO2 = result.EI_SO2 * MW_S / MW_SO2 + sulfur_as_SO4 = result.EI_SO4 * MW_S / MW_SO4 total_sulfur_converted = sulfur_as_SO2 + sulfur_as_SO4 # Should be approximately equal to input sulfur (with unit conversions) @@ -422,10 +441,10 @@ def test_finiteness(self): """Test that outputs are finite""" fuel = {'FSCnom': 600.0, 'Epsnom': 0.02} - SO2EI, SO4EI = EI_SOx(fuel) + result = EI_SOx(fuel) - assert np.isfinite(SO2EI) - assert np.isfinite(SO4EI) + assert np.isfinite(result.EI_SO2) + assert np.isfinite(result.EI_SO4) def test_error_handling(self): """Test error handling""" @@ -434,9 +453,9 @@ def test_error_handling(self): # Test with zero values fuel = {'FSCnom': 0.0, 'Epsnom': 0.0} - SO2EI, SO4EI = EI_SOx(fuel) - assert SO2EI == 0.0 - assert SO4EI == 0.0 + result = EI_SOx(fuel) + assert result.EI_SO2 == 0.0 + assert result.EI_SO4 == 0.0 class TestGetAPUEmissions: @@ -738,12 +757,13 @@ def test_nox_emissions_consistency(self): Tamb = np.array([288.15, 250.0, 220.0]) Pamb = np.array([101325.0, 25000.0, 15000.0]) - results = BFFM2_EINOx( + result = BFFM2_EINOx( fuelflow_trajectory, NOX_EI_matrix, fuelflow_performance, Tamb, Pamb ) - NOxEI, *_ = results - assert np.allclose(NOxEI, np.array([27.11460822, 14.28251747, 11.92937893])) + assert np.allclose( + result.NOxEI, np.array([27.11460822, 14.28251747, 11.92937893]) + ) if __name__ == "__main__": diff --git a/tests/test_emissions.py b/tests/test_emissions.py index fdab4ef..2cfb4d3 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -213,21 +213,20 @@ def _expected_trajectory_indices(emission, trajectory): 'SO4': np.full_like(fuel_flow, 0.036), } - ( - expected['NOx'], - expected['NO'], - expected['NO2'], - expected['HONO'], - no_prop, - no2_prop, - hono_prop, - ) = BFFM2_EINOx( + nox_result = BFFM2_EINOx( sls_equiv_fuel_flow=sls_flow, NOX_EI_matrix=lto_inputs['nox_ei'], fuelflow_performance=lto_inputs['fuel_flow'], Tamb=atmos.temperature, Pamb=atmos.pressure, ) + expected['NOx'] = nox_result.NOxEI + expected['NO'] = nox_result.NOEI + expected['NO2'] = nox_result.NO2EI + expected['HONO'] = nox_result.HONOEI + no_prop = nox_result.noProp + no2_prop = nox_result.no2Prop + hono_prop = nox_result.honoProp expected['HC'] = EI_HCCO( sls_flow, From be6b17230da35bad245509366762191d5422b6b1 Mon Sep 17 00:00:00 2001 From: Aditeya Shukla Date: Thu, 4 Dec 2025 16:22:32 -0500 Subject: [PATCH 12/12] updates/corrections after rebase from upstream --- src/AEIC/emissions/emission.py | 280 +++++-------------------------- src/AEIC/performance_model.py | 18 +- tests/test_emission_functions.py | 20 +-- tests/test_emissions.py | 80 +++++---- tests/test_performance_model.py | 3 - 5 files changed, 101 insertions(+), 300 deletions(-) diff --git a/src/AEIC/emissions/emission.py b/src/AEIC/emissions/emission.py index 413a4f7..14f300f 100644 --- a/src/AEIC/emissions/emission.py +++ b/src/AEIC/emissions/emission.py @@ -11,10 +11,10 @@ from AEIC.performance_model import PerformanceModel from AEIC.trajectories.trajectory import Trajectory -from utils import file_location -from utils.consts import R_air, kappa -from utils.inspect_inputs import as_bool, require_str -from utils.standard_atmosphere import ( +from AEIC.utils.consts import R_air, kappa +from AEIC.utils.files import file_location +from AEIC.utils.inspect_inputs import as_bool, require_str +from AEIC.utils.standard_atmosphere import ( pressure_at_altitude_isa_bada4, temperature_at_altitude_isa_bada4, ) @@ -358,8 +358,6 @@ def compute_emissions(self): EI_H2O=EI_H2O(self.fuel), nvpm_method=self.conf.pmnvol_method.value, ) - self._apply_metric_mask(self.APU_emission_indices) - self._apply_metric_mask(self.APU_emissions_g) self.total_fuel_burn += apu_fuel_burn # Compute Ground Service Equipment (GSE) emissions based on WNSF type @@ -369,7 +367,6 @@ def compute_emissions(self): 'aircraft_class' ] ) - self._apply_metric_mask(self.GSE_emissions_g) # Sum all emission contributions: trajectory + LTO + APU + GSE self.sum_total_emissions() @@ -394,15 +391,10 @@ def get_trajectory_emissions(self): """ trajectory = self.trajectory ac_performance = self.performance_model - idx_slice = ( - slice(0, self.Ntot) - if self.traj_emissions_all - else slice(0, self.Ntot - self.NDes) - ) - traj_data = trajectory.traj_data + idx_slice = self._trajectory_slice() lto_inputs = self._extract_lto_inputs() lto_ff_array = lto_inputs['fuel_flow'] - fuel_flow = traj_data['fuelFlow'][idx_slice] + fuel_flow = trajectory.fuel_flow[idx_slice] thrust_categories = get_thrust_cat(fuel_flow, lto_ff_array, cruiseCalc=True) needs_hc = self.metric_flags['HC'] or ( @@ -415,148 +407,52 @@ def get_trajectory_emissions(self): ) needs_atmos = needs_hc or needs_co or needs_nox or needs_pmnvol_meem - if needs_atmos: - flight_alts = traj_data['altitude'][idx_slice] - flight_temps = temperature_at_altitude_isa_bada4(flight_alts) - flight_pressures = pressure_at_altitude_isa_bada4(flight_alts) - mach_number = traj_data['tas'][idx_slice] / np.sqrt( - kappa * R_air * flight_temps - ) - else: - flight_temps = flight_pressures = mach_number = None + altitudes = trajectory.altitude[idx_slice] + tas = trajectory.true_airspeed[idx_slice] + atmos_state = self._atmospheric_state(altitudes, tas, needs_atmos) needs_sls_ff = needs_hc or needs_co or needs_nox - if needs_sls_ff: - sls_equiv_fuel_flow = get_SLS_equivalent_fuel_flow( - fuel_flow, - flight_pressures, - flight_temps, - mach_number, - ac_performance.model_info['General_Information']['n_eng'], - ) - else: - sls_equiv_fuel_flow = None + sls_equiv_fuel_flow = self._sls_equivalent_fuel_flow( + needs_sls_ff, fuel_flow, atmos_state + ) - if self.metric_flags['CO2']: - self.emission_indices['CO2'][idx_slice] = self.co2_ei - if self.metric_flags['H2O']: - self.emission_indices['H2O'][idx_slice] = self.h2o_ei - if self.metric_flags['SOx']: - self.emission_indices['SO2'][idx_slice] = self.so2_ei - self.emission_indices['SO4'][idx_slice] = self.so4_ei + constants = self._constant_species_values() + if constants: + self._assign_constant_indices(self.emission_indices, constants, idx_slice) if needs_nox: - nox_method = self.method_flags['nox'] - if nox_method == 'bffm2': - ( - self.emission_indices['NOx'][idx_slice], - self.emission_indices['NO'][idx_slice], - self.emission_indices['NO2'][idx_slice], - self.emission_indices['HONO'][idx_slice], - *_, - ) = BFFM2_EINOx( - sls_equiv_fuel_flow=sls_equiv_fuel_flow, - NOX_EI_matrix=lto_inputs['nox_ei'], - fuelflow_performance=lto_ff_array, - Pamb=flight_pressures, - Tamb=flight_temps, - ) - elif nox_method == 'p3t3': - print("P3T3 method not implemented yet..") - pass - elif nox_method == 'none': - pass - else: - raise NotImplementedError( - f"EI_NOx_method '{self.method_flags['nox']}' is not supported." - ) + self.compute_EI_NOx(idx_slice, lto_inputs, atmos_state, sls_equiv_fuel_flow) hc_ei = None if needs_hc: - hc_ei = EI_HCCO( + hc_ei = self._compute_EI_HCCO( sls_equiv_fuel_flow, lto_inputs['hc_ei'], lto_ff_array, - Tamb=flight_temps, - Pamb=flight_pressures, - cruiseCalc=True, + atmos_state, ) if self.metric_flags['HC']: self.emission_indices['HC'][idx_slice] = hc_ei if needs_co: - co_ei = EI_HCCO( + co_ei = self._compute_EI_HCCO( sls_equiv_fuel_flow, lto_inputs['co_ei'], lto_ff_array, - Tamb=flight_temps, - Pamb=flight_pressures, - cruiseCalc=True, + atmos_state, ) self.emission_indices['CO'][idx_slice] = co_ei - if self.metric_flags['PMvol']: - pmvol_ei = ocic_ei = None - if self.pmvol_method == 'fuel_flow': - thrust_labels = self._thrust_band_labels(thrust_categories) - pmvol_ei, ocic_ei = EI_PMvol_NEW(fuel_flow, thrust_labels) - elif self.pmvol_method == 'foa3': - thrust_pct = self._thrust_percentages_from_categories(thrust_categories) - pmvol_ei, ocic_ei = EI_PMvol_FOA3(thrust_pct, hc_ei) - elif self.pmvol_method == 'none': - pass - else: - raise NotImplementedError( - f"EI_PMvol_method '{self.pmvol_method}' is not supported." - ) - - if pmvol_ei is not None: - self.emission_indices['PMvol'][idx_slice] = pmvol_ei - self.emission_indices['OCic'][idx_slice] = ocic_ei + self._calculate_EI_PMvol(idx_slice, thrust_categories, fuel_flow, hc_ei) if self.metric_flags['PMnvol']: - pmnvol_method = self.pmnvol_method - if pmnvol_method == 'meem': - ( - self.emission_indices['PMnvolGMD'][idx_slice], - self.emission_indices['PMnvol'][idx_slice], - pmnvol_num, - ) = PMnvol_MEEM( - ac_performance.EDB_data, - traj_data['altitude'][idx_slice], - flight_temps, - flight_pressures, - mach_number, - ) - if ( - self._include_pmnvol_number - and 'PMnvolN' in self.emission_indices.dtype.names - ): - self.emission_indices['PMnvolN'][idx_slice] = pmnvol_num - elif pmnvol_method == 'scope11': - profile = self._scope11_profile(ac_performance) - self.emission_indices['PMnvol'][idx_slice] = ( - self._map_mode_values_to_categories( - profile['mass'], thrust_categories - ) - ) - self.emission_indices['PMnvolGMD'][idx_slice] = 0.0 - if ( - self._include_pmnvol_number - and profile['number'] is not None - and 'PMnvolN' in self.emission_indices.dtype.names - ): - self.emission_indices['PMnvolN'][idx_slice] = ( - self._map_mode_values_to_categories( - profile['number'], thrust_categories - ) - ) - elif pmnvol_method == 'none': - pass - else: - raise NotImplementedError( - f"EI_PMnvol_method '{self.pmnvol_method}' is not supported." - ) + self._calculate_EI_PMnvol( + idx_slice, + thrust_categories, + altitudes, + atmos_state, + ac_performance, + ) self.total_fuel_burn = np.sum(self.fuel_burn_per_segment[idx_slice]) for field in self._active_fields: @@ -570,65 +466,17 @@ def get_LTO_emissions(self): Compute Landing-and-Takeoff cycle emission indices and quantities. """ ac_performance = self.performance_model - - # Standard TIM durations - # https://www.icao.int/environmental-protection/Documents/EnvironmentalReports/2016/ENVReport2016_pg73-74.pdf - TIM_TakeOff = 0.7 * 60 - TIM_Climb = 2.2 * 60 - TIM_Approach = 4.0 * 60 - TIM_Taxi = 26.0 * 60 - - if self.traj_emissions_all: - TIM_Climb = 0.0 - TIM_Approach = 0.0 - - TIM_LTO = np.array([TIM_Taxi, TIM_Approach, TIM_Climb, TIM_TakeOff]) + TIM_LTO = self._get_LTO_TIMs() lto_inputs = self._extract_lto_inputs() fuel_flows_LTO = lto_inputs['fuel_flow'] thrustCat = get_thrust_cat(fuel_flows_LTO, None, cruiseCalc=False) thrust_labels = self._thrust_band_labels(thrustCat) - if self.metric_flags['CO2']: - self.LTO_emission_indices['CO2'] = np.full_like( - fuel_flows_LTO, self.co2_ei, dtype=float - ) - if self.metric_flags['H2O']: - self.LTO_emission_indices['H2O'] = np.full_like( - fuel_flows_LTO, self.h2o_ei, dtype=float - ) - if self.metric_flags['SOx']: - self.LTO_emission_indices['SO2'] = np.full_like( - fuel_flows_LTO, self.so2_ei, dtype=float - ) - self.LTO_emission_indices['SO4'] = np.full_like( - fuel_flows_LTO, self.so4_ei, dtype=float - ) + constants = self._constant_species_values() + if constants: + self._assign_constant_indices(self.LTO_emission_indices, constants) - if self.metric_flags['NOx']: - if self.method_flags['nox'] == 'none': - self.LTO_noProp = np.zeros_like(fuel_flows_LTO) - self.LTO_no2Prop = np.zeros_like(fuel_flows_LTO) - self.LTO_honoProp = np.zeros_like(fuel_flows_LTO) - else: - self.LTO_emission_indices['NOx'] = lto_inputs['nox_ei'] - ( - self.LTO_noProp, - self.LTO_no2Prop, - self.LTO_honoProp, - ) = NOx_speciation(thrustCat) - self.LTO_emission_indices['NO'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_noProp - ) - self.LTO_emission_indices['NO2'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_no2Prop - ) - self.LTO_emission_indices['HONO'] = ( - self.LTO_emission_indices['NOx'] * self.LTO_honoProp - ) - else: - self.LTO_noProp = np.zeros_like(fuel_flows_LTO) - self.LTO_no2Prop = np.zeros_like(fuel_flows_LTO) - self.LTO_honoProp = np.zeros_like(fuel_flows_LTO) + self._get_LTO_nox(thrustCat, lto_inputs) if self.metric_flags['HC']: self.LTO_emission_indices['HC'] = lto_inputs['hc_ei'] @@ -636,53 +484,10 @@ def get_LTO_emissions(self): self.LTO_emission_indices['CO'] = lto_inputs['co_ei'] if self.metric_flags['PMvol']: - if self.pmvol_method == 'fuel_flow': - LTO_PMvol, LTO_OCic = EI_PMvol_NEW(fuel_flows_LTO, thrust_labels) - elif self.pmvol_method == 'foa3': - LTO_PMvol, LTO_OCic = EI_PMvol_FOA3( - lto_inputs['thrust_pct'], lto_inputs['hc_ei'] - ) - elif self.pmvol_method == 'none': - LTO_PMvol = LTO_OCic = np.zeros_like(fuel_flows_LTO) - else: - raise NotImplementedError( - f"EI_PMvol_method '{self.pmvol_method}' is not supported." - ) - self.LTO_emission_indices['PMvol'] = LTO_PMvol - self.LTO_emission_indices['OCic'] = LTO_OCic + self._get_LTO_PMvol(fuel_flows_LTO, thrust_labels, lto_inputs) if self.metric_flags['PMnvol']: - pmnvol_method = self.pmnvol_method - if pmnvol_method in ('foa3', 'newsnci', 'meem'): - PMnvolEI_ICAOthrust = np.asarray( - ac_performance.EDB_data['PMnvolEI_best_ICAOthrust'], dtype=float - ) - PMnvolEIN_ICAOthrust = None - elif pmnvol_method in ('fox', 'dop', 'sst'): - PMnvolEI_ICAOthrust = np.asarray( - ac_performance.EDB_data['PMnvolEI_new_ICAOthrust'], dtype=float - ) - PMnvolEIN_ICAOthrust = None - elif pmnvol_method == 'scope11': - profile = self._scope11_profile(ac_performance) - PMnvolEI_ICAOthrust = profile['mass'] - PMnvolEIN_ICAOthrust = profile['number'] - elif pmnvol_method == 'none': - PMnvolEI_ICAOthrust = np.zeros_like(fuel_flows_LTO) - PMnvolEIN_ICAOthrust = None - else: - raise ValueError( - f'''Re-define PMnvol estimation method: - pmnvolSwitch = {self.pmnvol_method}''' - ) - - self.LTO_emission_indices['PMnvol'] = PMnvolEI_ICAOthrust - if ( - self._include_pmnvol_number - and PMnvolEIN_ICAOthrust is not None - and 'PMnvolN' in self.LTO_emission_indices.dtype.names - ): - self.LTO_emission_indices['PMnvolN'] = PMnvolEIN_ICAOthrust + self._get_LTO_PMnvol(ac_performance, fuel_flows_LTO) self.LTO_emission_indices['PMnvolGMD'] = np.zeros_like(fuel_flows_LTO) LTO_fuel_burn = TIM_LTO * fuel_flows_LTO @@ -750,16 +555,15 @@ def get_GSE_emissions(self, wnsf): def get_lifecycle_emissions(self, fuel, traj) -> float | None: """Apply lifecycle CO2 adjustments when requested by the config.""" - lifecycle_total = fuel['LC_CO2'] * (traj.fuel_mass * fuel['Energy_MJ_per_kg']) - self.summed_emission_g['CO2'] = lifecycle_total if not self.metric_flags.get('CO2', True): return None fuel_mass = getattr(traj, 'fuel_mass', None) + fuel_used = fuel_mass[-1] - fuel_mass[0] if fuel_mass is None: raise ValueError( "Trajectory is missing total fuel_mass required for lifecycle CO2." ) - lifecycle_total = float(fuel['LC_CO2'] * (fuel_mass * fuel['Energy_MJ_per_kg'])) + lifecycle_total = float(fuel['LC_CO2'] * (fuel_used * fuel['Energy_MJ_per_kg'])) self.summed_emission_g['CO2'] += lifecycle_total return lifecycle_total @@ -828,10 +632,10 @@ def _reset_run_state(self): def _prepare_run_state(self, trajectory: Trajectory): """Allocate arrays and derived data for a single emission run.""" self.trajectory = trajectory - self.Ntot = trajectory.Ntot - self.NClm = trajectory.NClm - self.NCrz = trajectory.NCrz - self.NDes = trajectory.NDes + self.Ntot = trajectory.X_npoints + self.NClm = trajectory.n_climb + self.NCrz = trajectory.n_cruise + self.NDes = trajectory.n_descent fill_value = -1.0 self.emission_indices = self._new_emission_array(self.Ntot, fill_value) @@ -845,7 +649,7 @@ def _prepare_run_state(self, trajectory: Trajectory): self._initialize_field_controls() - fuel_mass = trajectory.traj_data['fuelMass'] + fuel_mass = trajectory.fuel_mass fuel_burn = np.zeros_like(fuel_mass) fuel_burn[1:] = fuel_mass[:-1] - fuel_mass[1:] self.fuel_burn_per_segment = fuel_burn diff --git a/src/AEIC/performance_model.py b/src/AEIC/performance_model.py index 31d13b2..0b7b02c 100644 --- a/src/AEIC/performance_model.py +++ b/src/AEIC/performance_model.py @@ -8,11 +8,13 @@ import numpy as np -from BADA.aircraft_parameters import Bada3AircraftParameters -from BADA.model import Bada3JetEngineModel -from parsers.LTO_reader import parseLTO -from parsers.OPF_reader import parse_OPF -from utils import file_location, inspect_inputs +from AEIC.BADA.aircraft_parameters import Bada3AircraftParameters +from AEIC.BADA.model import Bada3JetEngineModel +from AEIC.missions import Mission +from AEIC.parsers.LTO_reader import parseLTO +from AEIC.parsers.OPF_reader import parse_OPF +from AEIC.utils.files import file_location +from AEIC.utils.inspect_inputs import require_str class PerformanceInputMode(Enum): @@ -69,12 +71,12 @@ def from_mapping(cls, mapping: Mapping[str, Any]) -> "PerformanceConfig": if not emissions: raise ValueError("Missing [Emissions] section in configuration file.") return cls( - missions_folder=inspect_inputs.require_str(missions, 'missions_folder'), - missions_in_file=inspect_inputs.require_str(missions, 'missions_in_file'), + missions_folder=require_str(missions, 'missions_folder'), + missions_in_file=require_str(missions, 'missions_in_file'), performance_model_input=PerformanceInputMode.from_value( general.get('performance_model_input') ), - performance_model_input_file=inspect_inputs.require_str( + performance_model_input_file=require_str( general, 'performance_model_input_file' ), emissions=emissions, diff --git a/tests/test_emission_functions.py b/tests/test_emission_functions.py index bb4f797..5a6406a 100644 --- a/tests/test_emission_functions.py +++ b/tests/test_emission_functions.py @@ -4,16 +4,16 @@ import numpy as np import pytest -from emissions.APU_emissions import get_APU_emissions -from emissions.EI_CO2 import EI_CO2, CO2EmissionResult -from emissions.EI_H2O import EI_H2O -from emissions.EI_HCCO import EI_HCCO -from emissions.EI_NOx import BFFM2_EINOx, BFFM2EINOxResult, NOx_speciation -from emissions.EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 -from emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow -from emissions.EI_SOx import EI_SOx, SOxEmissionResult -from emissions.lifecycle_CO2 import lifecycle_CO2 -from utils import file_location +from AEIC.emissions.APU_emissions import get_APU_emissions +from AEIC.emissions.EI_CO2 import EI_CO2, CO2EmissionResult +from AEIC.emissions.EI_H2O import EI_H2O +from AEIC.emissions.EI_HCCO import EI_HCCO +from AEIC.emissions.EI_NOx import BFFM2_EINOx, BFFM2EINOxResult, NOx_speciation +from AEIC.emissions.EI_PMnvol import PMnvol_MEEM, calculate_PMnvolEI_scope11 +from AEIC.emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow +from AEIC.emissions.EI_SOx import EI_SOx, SOxEmissionResult +from AEIC.emissions.lifecycle_CO2 import lifecycle_CO2 +from AEIC.utils.files import file_location class TestEI_CO2: diff --git a/tests/test_emissions.py b/tests/test_emissions.py index 2cfb4d3..b6c3007 100644 --- a/tests/test_emissions.py +++ b/tests/test_emissions.py @@ -3,11 +3,11 @@ import numpy as np import pytest -from emissions.EI_HCCO import EI_HCCO -from emissions.EI_NOx import BFFM2_EINOx, NOx_speciation -from emissions.EI_PMnvol import calculate_PMnvolEI_scope11 -from emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow -from emissions.emission import ( +from AEIC.emissions.EI_HCCO import EI_HCCO +from AEIC.emissions.EI_NOx import BFFM2_EINOx, NOx_speciation +from AEIC.emissions.EI_PMnvol import calculate_PMnvolEI_scope11 +from AEIC.emissions.EI_PMvol import EI_PMvol_FOA3, EI_PMvol_FuelFlow +from AEIC.emissions.emission import ( AtmosphericState, EINOxMethod, Emission, @@ -16,7 +16,7 @@ PMnvolMethod, PMvolMethod, ) -from utils.standard_fuel import get_thrust_cat +from AEIC.utils.standard_fuel import get_thrust_cat _BASE_EMISSIONS = { 'Fuel': 'conventional_jetA', @@ -123,22 +123,20 @@ def __init__(self, config_overrides=None, edb_overrides=None, lto_settings=None) class DummyTrajectory: def __init__(self): - self.NClm = 2 - self.NCrz = 2 - self.NDes = 2 - self.Ntot = self.NClm + self.NCrz + self.NDes - fuel_mass = np.array( + self.n_climb = 2 + self.n_cruise = 2 + self.n_descent = 2 + self.X_npoints = self.n_climb + self.n_cruise + self.n_descent + self.fuel_mass = np.array( [2000.0, 1994.0, 1987.5, 1975.0, 1960.0, 1945.0], dtype=float ) - self.traj_data = { - 'fuelMass': fuel_mass, - 'fuelFlow': np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32], dtype=float), - 'altitude': np.array( - [0.0, 1500.0, 6000.0, 11000.0, 9000.0, 2000.0], dtype=float - ), - 'tas': np.array([120.0, 150.0, 190.0, 210.0, 180.0, 140.0], dtype=float), - } - self.fuel_mass = float(fuel_mass[0]) + self.fuel_flow = np.array([0.3, 0.35, 0.55, 0.65, 0.5, 0.32], dtype=float) + self.altitude = np.array( + [0.0, 1500.0, 6000.0, 11000.0, 9000.0, 2000.0], dtype=float + ) + self.true_airspeed = np.array( + [120.0, 150.0, 190.0, 210.0, 180.0, 140.0], dtype=float + ) @pytest.fixture @@ -193,16 +191,15 @@ def _expected_scope11_mapping(performance_model, thrust_categories): def _expected_trajectory_indices(emission, trajectory): idx_slice = emission._trajectory_slice() - traj_data = trajectory.traj_data lto_inputs = emission._extract_lto_inputs() - fuel_flow = traj_data['fuelFlow'][idx_slice] + fuel_flow = trajectory.fuel_flow[idx_slice] thrust_categories = get_thrust_cat( fuel_flow, lto_inputs['fuel_flow'], cruiseCalc=True ) - altitudes = traj_data['altitude'][idx_slice] - tas = traj_data['tas'][idx_slice] + altitudes = trajectory.altitude[idx_slice] + tas = trajectory.true_airspeed[idx_slice] atmos = emission._atmospheric_state(altitudes, tas, True) sls_flow = emission._sls_equivalent_fuel_flow(True, fuel_flow, atmos) @@ -356,7 +353,7 @@ def test_lifecycle_emissions_require_total_fuel_mass(sample_perf_model): emission = Emission(sample_perf_model()) bad_traj = DummyTrajectory() bad_traj.fuel_mass = None - with pytest.raises(ValueError): + with pytest.raises(TypeError): emission.emit(bad_traj) @@ -454,11 +451,11 @@ def fake_foa3(thrusts, hc_values): captured['hc'] = hc_values.copy() return np.zeros_like(hc_values), np.zeros_like(hc_values) - monkeypatch.setattr('emissions.emission.EI_PMvol_FOA3', fake_foa3) + monkeypatch.setattr('AEIC.emissions.emission.EI_PMvol_FOA3', fake_foa3) emission._calculate_EI_PMvol( idx_slice, thrust_categories, - trajectory.traj_data['fuelFlow'][idx_slice], + trajectory.fuel_flow[idx_slice], hc_ei, ) expected = emission._thrust_percentages_from_categories(thrust_categories) @@ -479,7 +476,7 @@ def test_calculate_pmvol_requires_hc_for_foa3(sample_perf_model, trajectory): emission = Emission(sample_perf_model({'EI_PMvol_method': 'foa3'})) emission._prepare_run_state(trajectory) idx_slice = emission._trajectory_slice() - fuel_flow = trajectory.traj_data['fuelFlow'][idx_slice] + fuel_flow = trajectory.fuel_flow[idx_slice] thrust_categories = np.ones_like(fuel_flow, dtype=int) with pytest.raises(RuntimeError): emission._calculate_EI_PMvol(idx_slice, thrust_categories, fuel_flow, None) @@ -489,13 +486,11 @@ def test_calculate_pmnvol_scope11_populates_fields(sample_perf_model, trajectory emission = Emission(sample_perf_model({'EI_PMnvol_method': 'scope11'})) emission._prepare_run_state(trajectory) idx_slice = emission._trajectory_slice() - thrust_categories = np.ones_like( - trajectory.traj_data['fuelFlow'][idx_slice], dtype=int - ) + thrust_categories = np.ones_like(trajectory.fuel_flow[idx_slice], dtype=int) emission._calculate_EI_PMnvol( idx_slice, thrust_categories, - trajectory.traj_data['altitude'][idx_slice], + trajectory.altitude[idx_slice], AtmosphericState(None, None, None), emission.performance_model, ) @@ -519,12 +514,10 @@ def test_calculate_pmnvol_meem_populates_fields(sample_perf_model, trajectory): emission = Emission(sample_perf_model({'EI_PMnvol_method': 'meem'})) emission._prepare_run_state(trajectory) idx_slice = emission._trajectory_slice() - altitudes = trajectory.traj_data['altitude'][idx_slice] - tas = trajectory.traj_data['tas'][idx_slice] + altitudes = trajectory.altitude[idx_slice] + tas = trajectory.true_airspeed[idx_slice] atmos = emission._atmospheric_state(altitudes, tas, True) - thrust_categories = np.ones_like( - trajectory.traj_data['fuelFlow'][idx_slice], dtype=int - ) + thrust_categories = np.ones_like(trajectory.fuel_flow[idx_slice], dtype=int) emission._calculate_EI_PMnvol( idx_slice, thrust_categories, @@ -580,9 +573,9 @@ def test_compute_ei_nox_requires_inputs(sample_perf_model, trajectory): def test_atmospheric_state_and_sls_flow_shapes(sample_perf_model, trajectory): emission = Emission(sample_perf_model()) - idx_slice = slice(0, trajectory.Ntot) - altitudes = trajectory.traj_data['altitude'][idx_slice] - tas = trajectory.traj_data['tas'][idx_slice] + idx_slice = slice(0, trajectory.X_npoints) + altitudes = trajectory.altitude[idx_slice] + tas = trajectory.true_airspeed[idx_slice] atmos = emission._atmospheric_state(altitudes, tas, True) expected_temp = np.array([288.15, 278.4, 249.15, 216.65, 229.65, 275.15]) expected_pressure = np.array( @@ -609,7 +602,7 @@ def test_atmospheric_state_and_sls_flow_shapes(sample_perf_model, trajectory): np.testing.assert_allclose(atmos.pressure, expected_pressure) np.testing.assert_allclose(atmos.mach, expected_mach) - fuel_flow = trajectory.traj_data['fuelFlow'][idx_slice] + fuel_flow = trajectory.fuel_flow[idx_slice] sls_flow = emission._sls_equivalent_fuel_flow(True, fuel_flow, atmos) expected_sls = np.array( [ @@ -667,3 +660,8 @@ def test_emission_dtype_consistency(sample_perf_model, trajectory): assert set(emission.APU_emissions_g.dtype.names) == dtype_names assert set(emission.GSE_emissions_g.dtype.names) == dtype_names assert set(emission.summed_emission_g.dtype.names) == dtype_names + + +if __name__ == "__main__": + # Run the tests + pytest.main([__file__, "-v"]) diff --git a/tests/test_performance_model.py b/tests/test_performance_model.py index 881bfdd..095029c 100644 --- a/tests/test_performance_model.py +++ b/tests/test_performance_model.py @@ -23,9 +23,6 @@ def test_performance_model_initialization(): assert model.config.missions_in_file.endswith('.toml') assert isinstance(model.missions, list) and len(model.missions) > 0 - first_mission = model.missions[0] - for key in ('dep_airport', 'arr_airport', 'distance_nm', 'ac_code'): - assert key in first_mission assert hasattr(model, 'ac_params') assert model.ac_params.cas_cruise_lo == pytest.approx(