diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e37b3..d492bb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Lint with flake8 run: | pip install flake8 - find . -name "*.py" | flake8 --count --max-complexity=5 \ + find . -name "*.py" | flake8 --count --max-complexity=8 \ --show-source --statistics - name: Install and test with pytest run: | diff --git a/iam_units/__init__.py b/iam_units/__init__.py index 71a5954..6827b94 100644 --- a/iam_units/__init__.py +++ b/iam_units/__init__.py @@ -21,61 +21,75 @@ def convert_gwp(metric, quantity, *species): - """Convert *quantity* between emissions *species* with a GWP *metric*. + """Convert *quantity* between GHG *species* with a GWP *metric*. Parameters ---------- - metric : 'SARGWP100' or 'AR4GWP100' or 'AR5GWP100' - Metric conversion factors to use. + metric : 'SARGWP100' or 'AR4GWP100' or 'AR5GWP100' or None + Metric conversion factors to use. May be :obj:`None` if the input and + output species are the same. quantity : str or pint.Quantity or tuple - Quantity to convert. If a tuple, the arguments are passed to the - :class:`pint.Quantity` constructor. + Quantity to convert. If a tuple of (magnitude, unit), these are passed + as arguments to :class:`pint.Quantity`. species : sequence of str, length 1 or 2 - Output, or input and output emissions species, e.g. ('CH4', 'CO2') to + Output, or (input, output) species symbols, e.g. ('CH4', 'CO2') to convert mass of CH₄ to GWP-equivalent mass of CO₂. If only the output - species is provided, *quantity* must contain the name of the input - species in some location, e.g. 'tonne CH4 / year'. + species is provided, *quantity* must contain the symbol of the input + species in some location, e.g. '1.0 tonne CH4 / year'. Returns ------- pint.Quantity `quantity` converted from the input to output species. """ - # Handle *species* + # Handle *species*: either (in, out) or only out try: - # Split a 2-tuple species_in, species_out = species except ValueError: if len(species) != 1: raise ValueError('Must provide (from, to) or (to,) species') - # Only output emissions species provided species_in, species_out = None, species[0] - # Split *quantity* if it is a tuple: + # Split *quantity* if it is a tuple. After this step: # - *mag* is the magnitude, or None. # - *expr* is a string expression for either just the units, or the entire - # quantity including magnitude. + # quantity, including magnitude, as a str or pint.Quantity. mag, expr = quantity if isinstance(quantity, tuple) else (None, quantity) + # If species_in wasn't provided, then *expr* must contain it if not species_in: - # *expr* must contain the input species; extract it using the regex + # Extract it using the regex, then re-assemble the expression for the + # units or whole quantity q0, species_in, q1 = emissions.pattern.split(expr, maxsplit=1) - # Re-assemble the expression for the units or whole quantity expr = q0 + q1 + # *metric* can only be None if the input and output species symbols are + # identical or equivalent + if metric is None: + if (species_in == species_out or + any({species_in, species_out} <= g for g in emissions.EQUIV)): + metric = 'AR5GWP100' + elif species_in in species_out: + # Eg. 'CO2' in 'CO2 / a'. This is both a DimensionalityError and a + # ValueError (no metric); raise the former for pyam compat + raise pint.DimensionalityError(species_in, species_out) + else: + msg = f'Must provide GWP metric for ({species_in}, {species_out})' + raise ValueError(msg) + # Ensure a pint.Quantity object: - # - If tuple input was given, use the 2-arg constructor. - # - If not, use the 1-arg form to convert a string. - # - If the input was already a Quantity, this is a no-op. + # - If *quantity* was a tuple, use the 2-arg constructor. + # - If a str, use the 1-arg form to parse it. + # - If already a pint.Quantity, this is a no-op. args = (expr,) if mag is None else (mag, expr) quantity = registry.Quantity(*args) - # Intermediate units with the same dimensionality as *quantity*, except - # '[mass]' replaced with the dummy unit '_gwp' + # Construct intermediate units with the same dimensionality as *quantity*, + # except '[mass]' replaced with the dummy unit '_gwp' dummy = quantity.units / registry.Unit('tonne / _gwp') - # Convert to GWP dummy units using 'a' for the input species; then back to - # the input units using 'a' for the output species. + # Convert to dummy units using 'a' for the input species; then back to the + # input units using 'a' for the output species. return quantity.to(dummy, metric, _a=f'a_{species_in}') \ .to(quantity.units, metric, _a=f'a_{species_out}') diff --git a/iam_units/data/emissions/emissions.txt b/iam_units/data/emissions/emissions.txt index 59a421e..9dfa4f1 100644 --- a/iam_units/data/emissions/emissions.txt +++ b/iam_units/data/emissions/emissions.txt @@ -1,18 +1,25 @@ -# Dummy base unit used for GWP conversions of [mass] -> [mass]. This unit has -# no physical meaning. +# Dummy base unit used for GWP conversions of [mass] -> [_GWP] -> [mass]. +# This unit has no physical meaning and should not be used on its own. + _gwp = [_GWP] -# The reference species, CO2, has a conversion factor of 1 -a_CO2 = 1.0 = a_CO2e +# The reference species, CO2, has a conversion factor of 1. + +a_CO2 = 1.0 + +# Conversion to/from carbon equivalent is the same regardless of metric. -# Conversion to/from carbon equivalent is the same regardless of metric -a_C = 44. / 12 = a_Ce +a_C = 44. / 12 + +# Define: +# - Equivalents, e.g. a_CO2e = a_CO2. +# - Conversion factors for each species, with NaN values. pint requires +# that this is done before setting context-specific values. -# Define conversion factors for each species with NaN values. pint requires -# that this is done outside of setting specific values for a context. @import species.txt # Define contexts for each set of metrics + @import AR5GWP100.txt @import AR4GWP100.txt @import SARGWP100.txt diff --git a/iam_units/data/emissions/species.txt b/iam_units/data/emissions/species.txt index 5842d1f..fd26803 100644 --- a/iam_units/data/emissions/species.txt +++ b/iam_units/data/emissions/species.txt @@ -2,6 +2,10 @@ # python -m iam_units.update emissions # DO NOT ALTER THIS FILE MANUALLY! +a_CO2_eq = a_CO2 +a_CO2e = a_CO2 +a_CO2eq = a_CO2 +a_Ce = a_C a_C10F18 = NaN a_C2F6 = NaN a_C3F8 = NaN diff --git a/iam_units/emissions.py b/iam_units/emissions.py index 41987ca..a0d916a 100644 --- a/iam_units/emissions.py +++ b/iam_units/emissions.py @@ -7,7 +7,9 @@ # All recognised emission species usable with convert_gwp(). See *pattern*. SPECIES = [ 'CO2', + 'CO2_eq', 'CO2e', + 'CO2eq', 'C', 'Ce', 'C10F18', @@ -96,6 +98,12 @@ 'cC4F8', ] +# Pairs of emission species symbols that are interchangeable. +EQUIV = [ + set(['CO2', 'CO2_eq', 'CO2e', 'CO2eq']), + set(['C', 'Ce']), + ] + # Regular expression for one *SPECIES* in a pint-compatible unit string. pattern = re.compile( '(?<=[ -])(' diff --git a/iam_units/test_all.py b/iam_units/test_all.py index 447c1b6..2242a5d 100644 --- a/iam_units/test_all.py +++ b/iam_units/test_all.py @@ -53,14 +53,6 @@ def test_kt(): pint.UnitRegistry()('kt').to('Mt') -# 1 tonne of CH4 converted to CO2 equivalent by different metrics -EMI_DATA = [ - ('AR5GWP100', 28), - ('AR4GWP100', 25), - ('SARGWP100', 21) -] - - def test_emissions_internal(): # Dummy units can be created registry('0.5 _gwp').dimensionality == {'[_GWP]': 1.0} @@ -79,24 +71,36 @@ def test_emissions_internal(): 'Mt {} / a', # Mass rate 'kt {} / (ha * yr)', # Mass flux ]) -@pytest.mark.parametrize('metric, expected_value', EMI_DATA) -@pytest.mark.parametrize('species_out', ['CO2', 'CO2e']) -def test_convert_gwp(units, metric, expected_value, species_out): +@pytest.mark.parametrize('metric, species_in, species_out, expected_value', [ + ('AR5GWP100', 'CH4', 'CO2', 28), + ('AR5GWP100', 'CH4', 'CO2e', 28), + ('AR4GWP100', 'CH4', 'CO2', 25), + ('SARGWP100', 'CH4', 'CO2', 21), + + # Same-species conversion with metric=None and compatible names + (None, 'CO2', 'CO2_eq', 1.), + (None, 'CO2eq', 'CO2e', 1.), + + # Species names which are substrings of one another match correctly + ('AR5GWP100', 'HFC143', 'CO2', 328.0), + ('AR5GWP100', 'HFC143a', 'CO2', 4800.0), +]) +def test_convert_gwp(units, metric, species_in, species_out, expected_value): # Bare masses can be converted qty = registry.Quantity(1.0, units.format('')) expected = registry(f'{expected_value} {units}') - assert convert_gwp(metric, qty, 'CH4', species_out) == expected + assert convert_gwp(metric, qty, species_in, species_out) == expected # '[mass] [speciesname] (/ [time])' can be converted; the input species is # extracted from the *qty* argument - qty = f'1.0 ' + units.format('CH4') + qty = f'1.0 ' + units.format(species_in) expected = registry(f'{expected_value} {units}') assert convert_gwp(metric, qty, species_out) == expected # Tuple of (vector magnitude, unit expression) can be converted where the # the unit expression contains the input species name arr = np.array([1.0, 2.5, 0.1]) - qty = (arr, units.format('CH4')) + qty = (arr, units.format(species_in)) expected = arr * expected_value # Conversion works diff --git a/iam_units/update.py b/iam_units/update.py index 3cadc05..4aa6a5d 100644 --- a/iam_units/update.py +++ b/iam_units/update.py @@ -1,3 +1,4 @@ +from itertools import chain from pathlib import Path import pandas as pd @@ -50,6 +51,11 @@ '{{symbols}}', ] +# Sets of symbols that refer to the same species and are interchangeable. +EQUIV = [ + set({{equiv}}), + ] + # Regular expression for one *SPECIES* in a pint-compatible unit string. pattern = re.compile( '(?<=[ -])(' @@ -57,6 +63,12 @@ + r')(?=[ -/]|[^\w]|$)') """ +# Equivalents: different symbols for the same species. +_EMI_EQUIV = [ + ['CO2', 'CO2_eq', 'CO2e', 'CO2eq'], + ['C', 'Ce'], +] + def emissions(): """Update emissions definitions files.""" @@ -71,32 +83,39 @@ def emissions(): .melt(id_vars=['Species', 'Symbol'], var_name='metric') \ .dropna(subset=['value']) - # Write the file containing the species defs + # List of symbols requiring a GWP context to covert symbols = sorted(data['Symbol'].unique()) - with open(data_path / 'species.txt', 'w') as f: - f.write(_EMI_HEADER + '\n') - [f.write(f'a_{symbol} = NaN\n') for symbol in symbols] + + # Format and write the species defs file + lines = [_EMI_HEADER] + for group in _EMI_EQUIV: + lines.extend(f'a_{s} = a_{group[0]}' for s in group[1:]) + lines.extend(f'a_{s} = NaN' for s in symbols) + lines.append('') + (data_path / 'species.txt').write_text('\n'.join(lines)) # Write a Python module with a regex matching the species names - symbols = ['CO2', 'CO2e', 'C', 'Ce'] + symbols - symbols = "',\n '".join(symbols) - (BASE_PATH / 'emissions.py').write_text(_EMI_CODE.format(**locals())) + + # Prepare list including all symbols + all_symbols = list(chain(*_EMI_EQUIV, symbols)) + + # Format and write + code = _EMI_CODE.format( + symbols="',\n '".join(all_symbols), + equiv="),\n set(".join(map(repr, _EMI_EQUIV)), + ) + (BASE_PATH / 'emissions.py').write_text(code) # Write one file containing a context for each metric for metric, _data in data.groupby('metric'): # Conversion factor definitions - defs = [] - for _, row in _data.iterrows(): - defs.append(f'a_{row.Symbol} = {row.value}') + defs = [f'a_{row.Symbol} = {row.value}' for _, row in _data.iterrows()] - # Join to a single string - defs = '\n '.join(defs) + # Format the template with the definitions + content = _EMI_DATA.format(metric=metric, defs='\n '.join(defs)) # Write to file - (data_path / f'{metric}.txt').write_text( - # Format the template with the definitions - _EMI_DATA.format(**locals()) - ) + (data_path / f'{metric}.txt').write_text(content) if __name__ == '__main__':