Skip to content

Decomposition

aircraftdetective.calculations.decomposition

_compute_lmdi_factor_contributions_vectorized

_compute_lmdi_factor_contributions_vectorized(
    aggregate_t1, aggregate_t2, factor_t1, factor_t2
)

Vectorized version of compute_lmdi_factor_contributions.

Parameters:

Name Type Description Default
aggregate_t1 Series | float

Aggregate values at time t1.

required
aggregate_t2 Series | float

Aggregate values at time t2.

required
factor_t1 Series | float

Factor values at time t1.

required
factor_t2 Series | float

Factor values at time t2.

required

Returns:

Type Description
Series

The calculated additive contribution of the factor to the aggregate.

Source code in aircraftdetective/calculations/decomposition.py
def _compute_lmdi_factor_contributions_vectorized(
    aggregate_t1: pd.Series,
    aggregate_t2: pd.Series, 
    factor_t1: pd.Series,
    factor_t2: pd.Series
) -> pd.Series:
    r"""
    Vectorized version of 
    [`compute_lmdi_factor_contributions`][aircraftdetective.calculations.decomposition.compute_lmdi_factor_contributions].

    Parameters
    ----------
    aggregate_t1 : pd.Series | float
        Aggregate values at time t1.
    aggregate_t2 : pd.Series | float
        Aggregate values at time t2.
    factor_t1 : pd.Series | float
        Factor values at time t1.
    factor_t2 : pd.Series | float
        Factor values at time t2.

    Returns
    -------
    pd.Series
        The calculated additive contribution of the factor to the aggregate.
    """
    delta_aggregate = aggregate_t2 - aggregate_t1
    log_diff_aggregate = np.log(aggregate_t2) - np.log(aggregate_t1)

    log_mean_aggregate = np.where(
        delta_aggregate == 0, 
        aggregate_t1,
        delta_aggregate / log_diff_aggregate
    )

    log_ratio_factor = np.log(factor_t2 / factor_t1)
    delta_contribution = log_mean_aggregate * log_ratio_factor

    return pd.Series(delta_contribution).fillna(0.0)

compute_efficiency_disaggregation

compute_efficiency_disaggregation(df)

Computes the LMDI disaggregation of aircraft sub-efficiency factors.

This function decomposes the change in an aggregate efficiency index (e.g., Index(EU)) relative to the baseline year (the first year in the DataFrame) into the additive contributions from its constituent factors (e.g., Index(Engines), Index(Weight)).

The baseline (\(t_1\)) values are taken from the first row of the DataFrame (after sorting by Year). The current row's values are used as \(t_2\).

See Also

compute_lmdi_factor_contributions

References

Ang & Goh (2019), "Index decomposition analysis for comparing emission scenarios: Applications and challenges", Energy Economics, doi:10.1016/j.eneco.2019.06.013 Eqn. (28) and Table 5 in Ang & Zhang (2000), "A survey of index decomposition analysis in energy and environmental studies", Energy, doi:10.1016/S0360-5442(00)00039-6

Parameters:

Name Type Description Default
df DataFrame

DataFrame containing the aircraft efficiency index data. Must contain at least the columns:

  • Year
  • Index(EU)
  • Index(EI)
  • Index(Engines)
  • Index(Weight)
  • Index(Aerodynamics)
  • Index(Operations)
required

Returns:

Name Type Description
df DataFrame

DataFrame with additional columns for the LMDI contributions of each factor to the change in aggregate efficiency from the baseline year.

  • ContributionEU(Engines)
  • ContributionEU(Weight)
  • ContributionEU(Aerodynamics)
  • ContributionEI(Engines)
  • ContributionEI(Weight)
  • ContributionEI(Aerodynamics)
  • ContributionEI(Operations)

Raises:

Type Description
ValueError

If df is not a Pandas DataFrame, is empty, or does not contain the required columns. If any of the required columns contain only NaN values or are not of numeric type. If any Index columns contain non-positive values, which are invalid for LMDI.

Source code in aircraftdetective/calculations/decomposition.py
def compute_efficiency_disaggregation(df: pd.DataFrame) -> pd.DataFrame:
    r"""
    Computes the LMDI disaggregation of aircraft sub-efficiency factors.

    This function decomposes the change in an aggregate efficiency index
    (e.g., `Index(EU)`) relative to the baseline year (the first year in
    the DataFrame) into the additive contributions from its constituent factors
    (e.g., `Index(Engines)`, `Index(Weight)`).

    The baseline ($t_1$) values are taken from the first row of the
    DataFrame (after sorting by `Year`). The current row's values are used as $t_2$.

    See Also
    --------
    [`compute_lmdi_factor_contributions`][aircraftdetective.calculations.decomposition.compute_lmdi_factor_contributions]

    References
    ----------
    Ang & Goh (2019), "Index decomposition analysis for comparing emission scenarios: Applications and challenges", 
    _Energy Economics_, doi:[10.1016/j.eneco.2019.06.013](https://doi.org/10.1016/j.eneco.2019.06.013) 
    Eqn. (28) and Table 5 in Ang & Zhang (2000), "A survey of index decomposition analysis in energy and environmental studies",
    _Energy_, doi:[10.1016/S0360-5442(00)00039-6](https://doi.org/10.1016/S0360-5442(00)00039-6)

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame containing the aircraft efficiency index data. 
        Must contain at least the columns:

        - `Year`
        - `Index(EU)`
        - `Index(EI)`
        - `Index(Engines)`
        - `Index(Weight)`
        - `Index(Aerodynamics)`
        - `Index(Operations)`

    Returns
    -------
    df : pd.DataFrame
        DataFrame with additional columns for the LMDI contributions
        of each factor to the change in aggregate efficiency from the
        baseline year.

        - `ContributionEU(Engines)`
        - `ContributionEU(Weight)`
        - `ContributionEU(Aerodynamics)`
        - `ContributionEI(Engines)`
        - `ContributionEI(Weight)`
        - `ContributionEI(Aerodynamics)`
        - `ContributionEI(Operations)`

    Raises
    ------
    ValueError
        If `df` is not a Pandas DataFrame, is empty, or does not contain the required columns. 
        If any of the required columns contain only NaN values or are not of numeric type.
        If any Index columns contain non-positive values, which are invalid for LMDI.

    """
    if not isinstance(df, pd.DataFrame) or df.empty:
            raise ValueError("Input `df` must be a non-empty Pandas DataFrame.")

    list_required_cols = [
        'Year',
        'Index(EU)',
        'Index(EI)',
        'Index(Engines)',
        'Index(Weight)',
        'Index(Aerodynamics)',
        'Index(Operations)',
    ]

    for col in list_required_cols:
        if col not in df.columns:
            raise ValueError(f"Required column '{col}' not found in df columns")
        if df[col].isnull().all():
            raise ValueError(f"Column '{col}' cannot be all NaN")

        # --- FIX 1: Check ALL columns for numeric type ---
        if not pd.api.types.is_numeric_dtype(df[col]):
            raise ValueError(f"Column '{col}' must be of a numeric type.")

    # --- FIX 2: Check ONLY Index columns for non-positive values ---
    numeric_cols = [c for c in list_required_cols if c not in ['Year']]
    if (df[numeric_cols] <= 0).any().any():
            raise ValueError("All Index columns must contain positive values for LMDI.")

    df_func = df.copy()
    df_func = df_func.sort_values(by='Year', ascending=True)

    map_aggregates = {
        'EU': ['Index(Engines)', 'Index(Weight)', 'Index(Aerodynamics)'],
        'EI': ['Index(Engines)', 'Index(Weight)', 'Index(Aerodynamics)', 'Index(Operations)']
    }

    baseline_row = df_func.iloc[0]

    for aggregate_type, list_factors in map_aggregates.items():
        col_name_aggregate = f'Index({aggregate_type})'
        col_aggregate = df_func[col_name_aggregate]
        val_aggregate_t1 = baseline_row[col_name_aggregate]

        for factor in list_factors:
            col_factor = df_func[factor]
            val_factor_t1 = baseline_row[factor]

            # Create output column name, e.g., "ContributionEU(Engines)"
            factor_suffix = factor.replace('Index(', '').replace(')', '')
            output_col = f"Contribution{aggregate_type}({factor_suffix})"

            df_func[output_col] = _compute_lmdi_factor_contributions_vectorized(
                aggregate_t1=val_aggregate_t1,
                aggregate_t2=col_aggregate,
                factor_t1=val_factor_t1,
                factor_t2=col_factor
            )

    return df_func

compute_efficiency_improvement_metrics

compute_efficiency_improvement_metrics(df)

Computes the relative improvement \(\Delta\%x\) between times \(t=0\) and \(t=T\) $$ \Delta\%x=\frac{x(t=T)-x(t=0)}{x(t=0)} \times 100 [\%] $$ and the index \(I_x\) between times \(t=0\) and \(t=T\) $$ I_x=\frac{x(t=T)}{x(t=0)} $$ of relevant aircraft efficiency metrics in a dataframe.

Given an input DataFrame, the function computes relative improvements and indices of the overall aircraft efficiency metrics energy use (EU) and energy intensity (EI) as well as the aircraft sub-efficiencies thrust-specific fuel consumption (TSFC), weight per available seat (OEW/Exit Limit) and lift-to-drag ratio (L/D), all compared to the first year available in the dataframe.

Notes

Aircraft energy use \(E_U\) as per Eqn. (4) in Babikian et al. (2002) is proportional to the product of thrust-specific fuel consumption, the weight weight per available seat and the lift-to-drag ratio: $$ E_U [\text{J/ASK}] \propto TSFC \times \frac{W}{pax} \times \bigg(\frac{L}{D}\bigg)^{-1} $$ Aircraft energy intensity \(E_I\) as per Eqn. (1) in Babikian et al. (2002) is defined as the energy use per revenue passenger kilometer (RPK): $$ E_I [\text{J/RPK}] = \frac{E_U [\text{J/ASK}]}{SLF} $$ where

Symbol Unit Description
\(E_U\) J/km Energy use per available seat km
\(E_I\) J/km Energy intensity per revenue passenger km
\(TSFC\) N/(W·s) Thrust-specific fuel consumption
\(W/pax\) N Weight per available seat (OEW/Exit Limit)
\(L/D\) - Lift-to-drag ratio
\(SLF\) - Seat load factor

Overall efficiency (technological only) can also be written in terms of aircraft sub-efficiencies as: $$ \eta_{tech} \propto \eta_{eng} \times \eta_{aero} \times \eta_{struct} $$ and overall efficiecy (technological and operational) can also be written in terms of aircraft sub-efficiencies as: $$ \eta_{tech+ops} \propto \eta_{eng} \times \eta_{aero} \times \eta_{struct} \times \eta_{ops} $$ where

Symbol Definition Description
\(\eta_{tot}\) \(\propto E_U^{-1}\) Total efficiency
\(\eta_{eng}\) \(\propto TSFC^{-1}\) Engine efficiency
\(\eta_{aero}\) \(\propto L/D\) Aerodynamic efficiency
\(\eta_{struct}\) \(\propto (OEW/\text{Exit Limit})^{-1}\) Structural efficiency
\(\eta_{ops}\) \(\propto SLF\) Operational efficiency
Warning

Since, for example, \(\eta_{aero}\propto L/D\), the relative improvement is calculated as: $$ \Delta\%(L/D) = \frac{(L/D)(t=T)-(L/D)(t=0)}{(L/D)(t=0)} \times 100 [\%] $$ and $$ I_{L/D} = \frac{(L/D)(t=T)}{(L/D)(t=0)} $$ while for \(\eta_{eng}\propto TSFC^{-1}\), the relative improvement is calculated as: $$ \Delta\%(TSFC) = \frac{(TSFC)(t=T)-(TSFC)(t=0)}{(TSFC)(t=0)} \times 100 [\%] $$ and $$ I_{TSFC} = \frac{(TSFC)(t=T)}{(TSFC)(t=0)} $$ and thus a decrease in TSFC results in an increase in engine efficiency.

References

Babikian et al. (2002), "The historical fuel efficiency characteristics of regional aircraft from technological, operational, and cost perspectives", Journal of Air Transport Management, doi:10.1016/S0969-6997(02)00020-0

Parameters:

Name Type Description Default
df DataFrame

DataFrame containing the aircraft sub-efficiency data.
Must contain at least the columns:

  • Year
  • Type
  • Energy Use (per ASK)
  • Energy Intensity (per RPK)
  • TSFC (cruise)
  • OEW/Exit Limit
  • L/D
  • SLF
required

Returns:

Name Type Description
df DataFrame

DataFrame with additional columns for relative improvements in EU, TSFC, OEW/Exit Limit, and L/D:

  • Index(EU)
  • Index(EI)
  • Index(Engines)
  • Index(Weight)
  • Index(Aerodynamics)
  • Index(Operations)
  • Percent(EU)
  • Percent(EI)
  • Percent(Engines)
  • Percent(Weight)
  • Percent(Aerodynamics)
  • Percent(Operations)

Raises:

Type Description
ValueError

If df is not a Pandas DataFrame, is empty, or does not contain the required columns.
If any of the required columns contain only NaN values or are not of numeric type.

Example

Editor (session: default) Run
import pandas as pd
from aircraftdetective.calculations.decomposition import compute_efficiency_improvement_metrics
Output Clear

Source code in aircraftdetective/calculations/decomposition.py
def compute_efficiency_improvement_metrics(df: pd.DataFrame) -> pd.DataFrame:
    r"""
    Computes the relative improvement $\Delta\%x$ between times $t=0$ and $t=T$
    $$
    \Delta\%x=\frac{x(t=T)-x(t=0)}{x(t=0)} \times 100 [\%]
    $$
    and the index $I_x$ between times $t=0$ and $t=T$
    $$
    I_x=\frac{x(t=T)}{x(t=0)}
    $$
    of relevant aircraft efficiency metrics in a dataframe.

    Given an input DataFrame, the function computes [relative improvements](https://en.wikipedia.org/wiki/Percentage) 
    and [indices](https://en.wikipedia.org/wiki/Index_(economics)) 
    of the overall aircraft efficiency metrics energy use (EU) and energy intensity (EI) 
    as well as the aircraft sub-efficiencies thrust-specific fuel consumption (TSFC), 
    weight per available seat (OEW/Exit Limit) and lift-to-drag ratio (L/D), 
    all compared to the first year available in the dataframe.

    Notes
    -----
    Aircraft energy use $E_U$ as per Eqn. (4) in [Babikian et al. (2002)](https://doi.org/10.1016/S0969-6997(02)00020-0)
    is proportional to the product of thrust-specific fuel consumption,
    the weight weight per available seat and the lift-to-drag ratio:
    $$
    E_U [\text{J/ASK}] \propto TSFC \times \frac{W}{pax} \times \bigg(\frac{L}{D}\bigg)^{-1} 
    $$
    Aircraft energy intensity $E_I$ as per Eqn. (1) in [Babikian et al. (2002)](https://doi.org/10.1016/S0969-6997(02)00020-0)
    is defined as the energy use per revenue passenger kilometer (RPK):
    $$
    E_I [\text{J/RPK}] = \frac{E_U [\text{J/ASK}]}{SLF}
    $$
    where

    | Symbol        | Unit        | Description                         | 
    |---------------|-------------|-------------------------------------|
    | $E_U$         | J/km        | Energy use _per available seat km_    |
    | $E_I$         | J/km        | Energy intensity _per revenue passenger km_ |
    | $TSFC$        | N/(W·s)     | Thrust-specific fuel consumption    |
    | $W/pax$       | N           | Weight per available seat (OEW/Exit Limit) |
    | $L/D$         | -           | Lift-to-drag ratio                  |
    | $SLF$         | -           | Seat load factor                    |

    Overall efficiency (technological only) can also be written in terms of aircraft sub-efficiencies as:
    $$
    \eta_{tech} \propto \eta_{eng} \times \eta_{aero} \times \eta_{struct}
    $$
    and overall efficiecy (technological and operational) can also be written in terms of aircraft sub-efficiencies as:
    $$
    \eta_{tech+ops} \propto \eta_{eng} \times \eta_{aero} \times \eta_{struct} \times \eta_{ops}
    $$
    where

    | Symbol          | Definition                      | Description            | 
    |-----------------|---------------------------------|------------------------|
    | $\eta_{tot}$    | $\propto E_U^{-1}$              | Total efficiency       |
    | $\eta_{eng}$    | $\propto TSFC^{-1}$             | Engine efficiency      |
    | $\eta_{aero}$   | $\propto L/D$                   | Aerodynamic efficiency |
    | $\eta_{struct}$ | $\propto (OEW/\text{Exit Limit})^{-1}$ | Structural efficiency  |
    | $\eta_{ops}$    | $\propto SLF$                   | Operational efficiency |

    Warning
    -------
    Since, for example, $\eta_{aero}\propto L/D$, the relative improvement
    is calculated as:
    $$
    \Delta\%(L/D) = \frac{(L/D)(t=T)-(L/D)(t=0)}{(L/D)(t=0)} \times 100 [\%]
    $$
    and
    $$
    I_{L/D} = \frac{(L/D)(t=T)}{(L/D)(t=0)}
    $$
    while for $\eta_{eng}\propto TSFC^{-1}$, the relative improvement is calculated as:
    $$
    \Delta\%(TSFC) = \frac{(TSFC)(t=T)-(TSFC)(t=0)}{(TSFC)(t=0)} \times 100 [\%]
    $$
    and
    $$
    I_{TSFC} = \frac{(TSFC)(t=T)}{(TSFC)(t=0)}
    $$
    and thus a _decrease_ in TSFC results in an _increase_ in engine efficiency. 

    References
    ----------
    Babikian et al. (2002), "The historical fuel efficiency characteristics of regional aircraft from technological, operational, and cost perspectives",
    _Journal of Air Transport Management_, doi:[10.1016/S0969-6997(02)00020-0](https://doi.org/10.1016/S0969-6997(02)00020-0)

    Parameters
    ----------
    df : pd.DataFrame
        DataFrame containing the aircraft sub-efficiency data.  
        Must contain at least the columns:

        - `Year`
        - `Type`
        - `Energy Use (per ASK)`
        - `Energy Intensity (per RPK)`
        - `TSFC (cruise)`
        - `OEW/Exit Limit`
        - `L/D`
        - `SLF`

    Returns
    -------
    df : pd.DataFrame
        DataFrame with additional columns for relative improvements in EU, TSFC, OEW/Exit Limit, and L/D:

        - `Index(EU)`
        - `Index(EI)`
        - `Index(Engines)`
        - `Index(Weight)`
        - `Index(Aerodynamics)`
        - `Index(Operations)`
        - `Percent(EU)`
        - `Percent(EI)`
        - `Percent(Engines)`
        - `Percent(Weight)`
        - `Percent(Aerodynamics)`
        - `Percent(Operations)`

    Raises
    ------
    ValueError
        If `df` is not a Pandas DataFrame, is empty, or does not contain the required columns.  
        If any of the required columns contain only NaN values or are not of numeric type.

    Example
    -------
    ```pyodide install='aircraftdetective'
    import pandas as pd
    from aircraftdetective.calculations.decomposition import compute_efficiency_improvement_metrics
    ```
    """
    if not isinstance(df, pd.DataFrame) or df.empty:
            raise ValueError("Input `df` must be a non-empty Pandas DataFrame.")

    list_required_cols = [
        'Year',
        'Type',
        'Energy Use (per ASK)',
        'Energy Intensity (per RPK)',
        'TSFC (cruise)',
        'OEW/Exit Limit',
        'L/D',
        'SLF',
    ]
    for col in list_required_cols:
        if col not in df.columns:
            raise ValueError(f"Required column '{col}' not found in df columns")
        if df[col].isnull().all():
            raise ValueError(f"Column '{col}' cannot be all NaN")
        # Corrected check to use 'Year'
        if col not in ['Year', 'Type'] and not pd.api.types.is_numeric_dtype(df[col]):
            raise ValueError(f"Column '{col}' must be of a numeric type.")

    df_func = df.copy()
    # Corrected sort to use 'Year'
    df_func.sort_values(by='Year', ascending=True, inplace=True)
    grouped = df_func.groupby('Type', group_keys=False)

    metrics_inverse = {
        'Energy Use (per ASK)': True,    # lower is better
        'Energy Intensity (per RPK)': True,  # lower is better
        'TSFC (cruise)': True,           # lower is better
        'OEW/Exit Limit': True,          # lower is better
        'L/D': False,                    # higher is better
        'SLF': False                     # higher is better
    }

    # Map input metric columns to their desired output name suffixes
    metric_mapping = {
        'Energy Use (per ASK)': 'EU',
        'Energy Intensity (per RPK)': 'EI',
        'TSFC (cruise)': 'Engines',
        'OEW/Exit Limit': 'Weight',
        'L/D': 'Aerodynamics',
        'SLF': 'Operations'
    }

    # Get the list of metric columns from the mapping
    metrics = list(metric_mapping.keys())

    baselines = {}
    for metric in metrics:
        baselines[metric] = grouped[metric].transform(
            lambda g: g.dropna().iloc[0] if g.notna().any() else np.nan
        )

    # Loop using the metric_mapping to create the new column names
    for metric, output_suffix in metric_mapping.items():
        s = df_func[metric]
        x0 = baselines[metric]
        inverse = metrics_inverse[metric]

        # Directional Index (>1 if improved)
        # lower-better:  x0/x
        # higher-better: x/x0
        idx_col = f'Index({output_suffix})'
        with np.errstate(divide='ignore', invalid='ignore'):
            if inverse:
                df_func[idx_col] = np.where(s != 0, x0 / s, np.nan)
            else:
                df_func[idx_col] = np.where(x0 != 0, s / x0, np.nan)

        # Directional Percent (>0 if improved)
        # lower-better:  (x0/x - 1) * 100
        # higher-better: (x/x0 - 1) * 100
        pct_col = f'Percent({output_suffix})'
        with np.errstate(divide='ignore', invalid='ignore'):
            if inverse:
                df_func[pct_col] = np.where(s != 0, (x0 / s - 1.0) * 100.0, np.nan)
            else:
                df_func[pct_col] = np.where(x0 != 0, (s / x0 - 1.0) * 100.0, np.nan)

    return df_func

compute_lmdi_factor_contributions

compute_lmdi_factor_contributions(
    aggregate_t1, aggregate_t2, factor_t1, factor_t2
)

Computes the contributions of changes in factors to the change in an aggregate according to the additive logarithmic mean Divisia index method I (LMDI-I).

Given values for an aggregate \(C\) and a factor \(C_i\) $$ C = C_1 \times C_2 \times ... \times C_n $$ at two points in time \((t_1, t_2)\), computes the contribution \(\Delta C_i\) of the change in factor \(C_i\) to the change in total \(\Delta C\) through the additive logarithmic mean Divisia index method I (LMDI-I). $$ \Delta C_i = \left(\frac{C(t_2) - C(t_1)}{\ln C(t_2) - \ln C(t_1)}\right) \times \ln\left(\frac{C_i(t_2)}{C_i(t_1)}\right) $$ where

Symbol Unit Description
\(C\) - aggregate
\(C_i\) - factor \(i\)
Notes

The LDMI-I method is additive, which means that the contributions sum up to the total change: $$ \Delta C = \sum_i \Delta C_i $$

References

Ang & Goh (2019), "Index decomposition analysis for comparing emission scenarios: Applications and challenges", Energy Economics, doi:10.1016/j.eneco.2019.06.013
Eqn. (28) and Table 5 in Ang & Zhang (2000), "A survey of index decomposition analysis in energy and environmental studies", Energy, doi:10.1016/S0360-5442(00)00039-6

Parameters:

Name Type Description Default
aggregate_t1 float

Value of the aggregate \(C\) at time \(t_1\)

required
aggregate_t2 float

Value of the aggregate \(C\) at time \(t_2\)

required
factor_t1 float

Value of the factor \(C_i\) at time \(t_1\)

required
factor_t2 float

Values of the factor \(C_i\) at time \(t_2\)

required

Returns:

Type Description
float

Contribution of change in sub-efficiency to change in total efficiency

Example

Editor (session: default) Run
aggregate_t1 = 8.0
aggregate_t2 = 64.0
factor_1_t1 = 2.0
factor_1_t2 = 4.0
factor_2_t1 = 4.0
factor_2_t2 = 16.0
delta_factor_1 = _compute_lmdi_factor_contributions(aggregate_t1, aggregate_t2, factor_1_t1, factor_1_t2)
delta_factor_2 = _compute_lmdi_factor_contributions(aggregate_t1, aggregate_t2, factor_2_t1, factor_2_t2)
Output Clear

Source code in aircraftdetective/calculations/decomposition.py
def compute_lmdi_factor_contributions(
    aggregate_t1: float,
    aggregate_t2: float,
    factor_t1: float,
    factor_t2: float
) -> float:
    r"""
    Computes the contributions of changes in factors to the change in an aggregate
    according to the additive logarithmic mean Divisia index method I (LMDI-I).

    Given values for an aggregate $C$ and a factor $C_i$
    $$
    C = C_1 \times C_2 \times ... \times C_n
    $$
    at two points in time $(t_1, t_2)$,
    computes the contribution $\Delta C_i$ of the change in factor $C_i$
    to the change in total $\Delta C$ through the
    additive logarithmic mean Divisia index method I (LMDI-I).
    $$
    \Delta C_i = \left(\frac{C(t_2) - C(t_1)}{\ln C(t_2) - \ln C(t_1)}\right) \times \ln\left(\frac{C_i(t_2)}{C_i(t_1)}\right)
    $$
    where

    | Symbol | Unit     | Description |
    |--------|----------|-------------|
    | $C$    | -        | aggregate   |
    | $C_i$  | -        | factor $i$  |

    Notes
    -----
    The LDMI-I method is _additive_, which means that the contributions sum up to the total change:
    $$
    \Delta C = \sum_i \Delta C_i
    $$

    References
    ----------
    Ang & Goh (2019), "Index decomposition analysis for comparing emission scenarios: Applications and challenges", 
    _Energy Economics_, doi:[10.1016/j.eneco.2019.06.013](https://doi.org/10.1016/j.eneco.2019.06.013)  
    Eqn. (28) and Table 5 in Ang & Zhang (2000), "A survey of index decomposition analysis in energy and environmental studies",
    _Energy_, doi:[10.1016/S0360-5442(00)00039-6](https://doi.org/10.1016/S0360-5442(00)00039-6)

    Parameters
    ----------
    aggregate_t1 : float
        Value of the aggregate $C$ at time $t_1$
    aggregate_t2 : float
        Value of the aggregate $C$ at time $t_2$
    factor_t1 : float
        Value of the factor $C_i$ at time $t_1$
    factor_t2 : float
        Values of the factor $C_i$ at time $t_2$

    Returns
    -------
    float
        Contribution of change in sub-efficiency to change in total efficiency

    Example
    -------
    ```pyodide install='aircraftdetective'
    aggregate_t1 = 8.0
    aggregate_t2 = 64.0
    factor_1_t1 = 2.0
    factor_1_t2 = 4.0
    factor_2_t1 = 4.0
    factor_2_t2 = 16.0
    delta_factor_1 = _compute_lmdi_factor_contributions(aggregate_t1, aggregate_t2, factor_1_t1, factor_1_t2)
    delta_factor_2 = _compute_lmdi_factor_contributions(aggregate_t1, aggregate_t2, factor_2_t1, factor_2_t2)
    ```
    """
    if aggregate_t1 <= 0 or aggregate_t2 <= 0 or factor_t1 <= 0 or factor_t2 <= 0:
        raise ValueError("LMDI inputs (aggregates and factors) must be positive.")

    if factor_t1 == factor_t2:
        return 0.0  # No change in factor, so contribution is zero

    if aggregate_t1 == aggregate_t2:
        log_mean_aggregate = aggregate_t1
    else:
        delta_aggregate = aggregate_t2 - aggregate_t1
        log_mean_aggregate = delta_aggregate / (math.log(aggregate_t2) - math.log(aggregate_t1))

    delta_factor = log_mean_aggregate * math.log(factor_t2 / factor_t1)

    return delta_factor