diff --git a/Gemfile b/Gemfile index 424c05c42..8e73e6328 100644 --- a/Gemfile +++ b/Gemfile @@ -73,7 +73,7 @@ gem 'ruby-progressbar' # own gems gem 'quintel_merit', ref: 'aae77e0', github: 'quintel/merit' -gem 'atlas', ref: '21a6046', github: 'quintel/atlas' +gem 'atlas', ref: '5504fac', github: 'quintel/atlas' gem 'fever', ref: '2afebd1', github: 'quintel/fever' gem 'refinery', ref: '36b8e34', github: 'quintel/refinery' gem 'rubel', ref: '9fe7010', github: 'quintel/rubel' diff --git a/Gemfile.lock b/Gemfile.lock index 21c208276..bb84c0b47 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/quintel/atlas.git - revision: 21a60468b711e700ec2964a4f3c9c942f5ba3c7b - ref: 21a6046 + revision: 5504fac349b1b25fda2ef285b044b025fbfbdb1e + ref: 5504fac specs: atlas (1.0.0) activemodel (>= 7) diff --git a/app/models/gql/runtime/functions/lookup.rb b/app/models/gql/runtime/functions/lookup.rb index 6c979bdad..501ae566f 100644 --- a/app/models/gql/runtime/functions/lookup.rb +++ b/app/models/gql/runtime/functions/lookup.rb @@ -133,8 +133,6 @@ def MSECTOR(*keys) # Returns an Array with {Qernel::Node} for given energy use. # - # See Qernel::Node::USES - # # Examples # # USE(energetic) @@ -321,16 +319,17 @@ def DATASET_CURVE(key) # Returns an attribute {Qernel::Emissions} or {Qernel::Emissions::ScopedSector} or a value. # # Emissions data is loaded from CSV files in ETSource with the following structure: - # etm_sector, etm_subsector, use, ghg, unit, value + # etm_sector, etm_subsector, use, ghg, year, unit, value # # Parameters: # - sector: ETM sector name (e.g., 'households', 'buildings_non_specified') # Dashes/dots in sector names are converted to underscores for key generation # - use: Emission use type (energetic, non_energetic) - REQUIRED when accessing values # - ghg: GHG type (co2, other_ghg) - optional - # - year: Year of emission (e.g., 1990) - optional, reads from emissions_YEAR.csv files + # - year: Pass 1990 to read the historic baseline; the present year is + # implicit and needs no argument # - # Key generation combines: sector_[subsector_]use_ghg[_year] + # Key generation combines: sector_[subsector_]use_ghg[_1990] # Note: Unit column from CSV is not included in keys, blank values return nil # # EMISSIONS() without any keys returns {Qernel::Emissions} @@ -351,8 +350,8 @@ def DATASET_CURVE(key) # EMISSIONS(sector, use, ghg) or EMISSIONS(sector, use, ghg, year) returns an emission value # # Examples - # EMISSIONS(households, energetic, other_ghg) # => 12.0 (from emissions.csv) - # EMISSIONS(households, energetic, co2, 1990) # => value (from emissions_1990.csv) + # EMISSIONS(households, energetic, other_ghg) # => 12.0 (present year) + # EMISSIONS(households, energetic, co2, 1990) # => value for 1990 # EMISSIONS(buildings_non_specified, energetic, other_ghg) # => 18.0 # def EMISSIONS(*keys) @@ -364,8 +363,11 @@ def EMISSIONS(*keys) # EMISSIONS(sector, use) -> return ScopedSector for UPDATE operations return scope.graph.emissions.scope(keys.join('_').to_sym) if keys.size == 2 - # EMISSIONS(sector, use, ghg [, year]) -> return value - scope.graph.emissions[keys.join('_').to_sym] + # EMISSIONS(sector, use, ghg [, year]) -> return value. + # The present year is implicit; only 1990 is suffixed with a year. + scope.graph.emissions[ + [*keys.first(3), (1990 if keys[3].to_i == 1990)].compact.join('_').to_sym + ] end end end diff --git a/app/models/qernel/emissions.rb b/app/models/qernel/emissions.rb index e300c2b27..84696c7be 100644 --- a/app/models/qernel/emissions.rb +++ b/app/models/qernel/emissions.rb @@ -1,13 +1,23 @@ module Qernel - # Class for getting and setting emissions data + # Class for getting and setting emissions data. # Behaves much like Qernel::Area, can be seen as an extension of - # area attributes, scoped for emissions + # area attributes, scoped for emissions. + # + # == Data Structure # # Emissions data is loaded from CSV files in ETSource with structure: - # etm_sector, etm_subsector, use, ghg, unit, value + # etm_sector, etm_subsector, use, ghg, year, unit, value + # + # Values are stored flat, keyed as: sector_subsector_use_ghg[_1990] + # Examples: + # - buildings_non_specified_energetic_other_ghg + # - energy_electricity_and_heat_production_energetic_co2_1990 + # + # == Year Handling # - # Keys are generated as: sector_[subsector_]use_ghg[_year] - # Example: buildings_non_specified_energetic_co2 + # The present year is implicit and carries no suffix. Only the historic + # 1990 baseline is suffixed with its year, so both can coexist in the + # same dataset. class Emissions include DatasetAttributes @@ -20,12 +30,13 @@ class Emissions # statements in etsource # # Example: - # EMISSIONS(households, energetic) returns a ScopedSector + # EMISSIONS(households_non_specified, energetic) returns a ScopedSector # Then UPDATE can call: scoped.co2 = 100.0 class ScopedSector - def initialize(emissions, scope) + def initialize(emissions, scope, year = nil) @emissions = emissions @scope = scope + @year = year end def [](attr_name) @@ -41,29 +52,39 @@ def inspect end def scoped_method(method_name) - "#{@scope}_#{method_name}" + attr = method_name.to_s.delete_suffix('=') + @year.to_i == 1990 ? "#{@scope}_#{attr}_1990" : "#{@scope}_#{attr}" end def respond_to_missing?(method_name, include_private = false) - data_key = scoped_method(method_name).split('=').first - - @emissions.respond_to?(data_key) || super + if method_name.to_s.end_with?('=') + scope_exists? + else + @emissions.respond_to?(scoped_method(method_name)) || super + end end def method_missing(method_name, *args) - data_key = scoped_method(method_name).split('=').first.to_sym + key = scoped_method(method_name).to_sym - # Validate the key exists for both getters and setters - unless @emissions.respond_to?(data_key) - raise NoMethodError, "undefined method `#{method_name}' for #{inspect}" - end + if method_name.to_s.end_with?('=') + unless scope_exists? + raise NoMethodError, "undefined method `#{method_name}' for #{inspect}" + end - if data_key.to_s == scoped_method(method_name) - @emissions[data_key] + @emissions[key] = args.first else - @emissions[data_key] = args.first + @emissions[key] end end + + private + + # Scoped writes work for any region that has the scope. + def scope_exists? + prefix = "#{@scope}_" + @emissions.dataset_attributes.keys.any? { |key| key.to_s.start_with?(prefix) } + end end def initialize(graph = nil) @@ -74,9 +95,12 @@ def initialize(graph = nil) # Public: define the sector scope for access to the hashed emission keys # + # sector - Scope identifier (e.g., :buildings_non_specified_energetic). + # year - Optional year. Pass 1990 to target the historic baseline. + # # Returns a scoped version of the emissions data - def scope(sector) - ScopedSector.new(self, sector) + def scope(sector, year = nil) + ScopedSector.new(self, sector, year) end end end diff --git a/app/models/qernel/node_api/direct_emissions.rb b/app/models/qernel/node_api/direct_emissions.rb index e486c5ffc..deb5c1a32 100644 --- a/app/models/qernel/node_api/direct_emissions.rb +++ b/app/models/qernel/node_api/direct_emissions.rb @@ -183,6 +183,28 @@ def direct_reporting_emissions_total_ghg_emissions end end + # CO2 utilisation at this node. + # + # Fossil CO2 input utilisation. + # + # @return [Float, nil] CO2 utilisation in kg, or nil if node is not in emissions group + def direct_reporting_emissions_co2_utilisation + with_emissions_node do + direct_co2_input_utilisation_fossil + end + end + + # Biogenic CO2 emissions at this node. + # + # Biogenic CO2 emissions from production. + # + # @return [Float, nil] Biogenic CO2 emissions in kg, or nil if node is not in emissions group + def direct_reporting_emissions_biogenic_co2_emissions + with_emissions_node do + direct_co2_output_production_emissions_biogenic + end + end + private # Yields the given block only if the node belongs to the :emissions group. diff --git a/app/models/qernel/node_api/molecule_emissions.rb b/app/models/qernel/node_api/molecule_emissions.rb index ed6ba446e..e8bb48976 100644 --- a/app/models/qernel/node_api/molecule_emissions.rb +++ b/app/models/qernel/node_api/molecule_emissions.rb @@ -75,6 +75,28 @@ def direct_reporting_emissions_total_ghg_emissions end end + # CO2 utilisation at this node. + # + # Fossil CO2 input utilisation. + # + # @return [Float, nil] CO2 utilisation in kg, or nil if node is not in emissions group + def direct_reporting_emissions_co2_utilisation + with_emissions_node do + 0.0 + end + end + + # Biogenic CO2 emissions at this node. + # + # Biogenic CO2 emissions from production. + # + # @return [Float, nil] Biogenic CO2 emissions in kg, or nil if node is not in emissions group + def direct_reporting_emissions_biogenic_co2_emissions + with_emissions_node do + 0.0 + end + end + private # Yields the given block only if the node belongs to the :emissions group. diff --git a/spec/fixtures/etsource/datasets/nl/emissions.csv b/spec/fixtures/etsource/datasets/nl/emissions.csv index c4c204046..0d1d7a66d 100644 --- a/spec/fixtures/etsource/datasets/nl/emissions.csv +++ b/spec/fixtures/etsource/datasets/nl/emissions.csv @@ -1,9 +1,19 @@ -etm_sector,etm_subsector,use,ghg,unit,value -Energy,Electricity and heat production,energetic,other_ghg,kg CO2eq,18.0 -Energy,Fugitive emissions,non_energetic,co2,kg CO2eq,20.0 -Households,Non-specified,energetic,other_ghg,kg CO2eq,7.0 -Buildings,Non-specified,energetic,other_ghg,kg CO2eq,2796620.0 -Industry,Non-specified,energetic,other_ghg,kg CO2eq,100.0 -Agriculture,Non-specified,energetic,other_ghg,kg CO2eq,50.0 -Agriculture,Non-specified,non_energetic,co2,kg CO2eq,75.0 -Waste,Non-specified,non_energetic,co2,kg CO2eq,25.0 +etm_sector,etm_subsector,use,ghg,year,unit,value +Energy,Electricity and heat production,energetic,other_ghg,2023,kg CO2eq,18.0 +Energy,Electricity and heat production,energetic,other_ghg,1990,kg CO2eq,25.0 +Energy,Fugitive emissions,non_energetic,co2,2023,kg CO2eq,20.0 +Energy,Fugitive emissions,non_energetic,co2,1990,kg CO2eq,30.0 +Households,Non-specified,energetic,other_ghg,2023,kg CO2eq,7.0 +Households,Non-specified,energetic,other_ghg,1990,kg CO2eq,10.0 +Buildings,Non-specified,energetic,other_ghg,2023,kg CO2eq,2796620.0 +Buildings,Non-specified,energetic,other_ghg,1990,kg CO2eq,3000000.0 +Industry,Non-specified,energetic,other_ghg,2023,kg CO2eq,100.0 +Industry,Non-specified,energetic,other_ghg,1990,kg CO2eq,150.0 +Agriculture,Non-specified,energetic,other_ghg,2023,kg CO2eq,50.0 +Agriculture,Non-specified,energetic,other_ghg,1990,kg CO2eq,75.0 +Agriculture,Non-specified,non_energetic,other_ghg,2023,kg CO2eq,100.0 +Agriculture,Non-specified,non_energetic,other_ghg,1990,kg CO2eq,125.0 +Agriculture,Non-specified,non_energetic,co2,2023,kg CO2eq,75.0 +Agriculture,Non-specified,non_energetic,co2,1990,kg CO2eq,100.0 +Waste,Non-specified,non_energetic,co2,2023,kg CO2eq,25.0 +Waste,Non-specified,non_energetic,co2,1990,kg CO2eq,35.0 diff --git a/spec/models/etsource/dataset/import_spec.rb b/spec/models/etsource/dataset/import_spec.rb new file mode 100644 index 000000000..c9e9db180 --- /dev/null +++ b/spec/models/etsource/dataset/import_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Etsource::Dataset::Import, :etsource_fixture do + describe '#load_emission_data' do + let(:emissions) { described_class.new('nl').send(:load_emission_data) } + + it 'loads flat emission values keyed by joined CSV columns' do + expect(emissions[:emissions_data][:energy_fugitive_emissions_non_energetic_co2]) + .to eq(20.0) + end + end +end diff --git a/spec/models/gql/runtime/functions/lookup_spec.rb b/spec/models/gql/runtime/functions/lookup_spec.rb index 60fc6ec9e..22a5c8e97 100644 --- a/spec/models/gql/runtime/functions/lookup_spec.rb +++ b/spec/models/gql/runtime/functions/lookup_spec.rb @@ -3,6 +3,7 @@ module Gql::Runtime::Functions describe Lookup, :etsource_fixture do let(:gql) { Scenario.default.gql(prepare: true) } + let(:molecule_graph) { gql.future.molecules } let(:result) do |example| gql.query_future(example.metadata[:example_group][:description]) @@ -51,21 +52,27 @@ module Gql::Runtime::Functions end describe "EMISSIONS(buildings_non_specified, energetic, other_ghg)" do - it 'returns the emission value' do + it 'returns the present-year emission value' do expect(result).to eq(2796620.0) end end describe "EMISSIONS(energy_electricity_and_heat_production, energetic, other_ghg)" do - it 'returns the emission value' do + it 'returns the present-year emission value' do expect(result).to eq(18.0) end end describe 'EMISSIONS(households_non_specified, energetic, other_ghg)' do - it 'returns the emission value' do + it 'returns the present-year emission value' do expect(result).to eq(7.0) end end + + describe 'EMISSIONS(households_non_specified, energetic, other_ghg, 1990)' do + it 'returns the 1990 baseline emission value' do + expect(result).to eq(10.0) + end + end end end diff --git a/spec/models/gql/runtime/functions/update_spec.rb b/spec/models/gql/runtime/functions/update_spec.rb index aace84d43..09d848d27 100644 --- a/spec/models/gql/runtime/functions/update_spec.rb +++ b/spec/models/gql/runtime/functions/update_spec.rb @@ -69,13 +69,6 @@ end end - describe 'UPDATE(EMISSIONS(invalid_sector, energetic), co2, 100.0)' do - it 'raises an error when the emission key does not exist in the dataset' do - # The full key 'invalid_sector_energetic_co2' does not exist in the dataset - expect { result }.to raise_error(Gql::CommandError) - end - end - describe 'UPDATE(EMISSIONS(energy_fugitive_emissions, non_energetic), co2, 0.0)' do before { result } diff --git a/spec/models/qernel/emissions_spec.rb b/spec/models/qernel/emissions_spec.rb index dc696a88b..0e8751453 100644 --- a/spec/models/qernel/emissions_spec.rb +++ b/spec/models/qernel/emissions_spec.rb @@ -2,6 +2,16 @@ module Qernel describe Emissions do + let(:dataset) do + Qernel::Dataset.new(1).tap do |ds| + ds.data[:emissions] = { emissions_data: data } + end + end + + let(:graph) { double('Graph', dataset: dataset) } + let(:emissions) { Emissions.new(graph).tap(&:assign_dataset_attributes) } + let(:data) { {} } + describe '#initialize' do context 'with a graph' do let(:graph) { Qernel::Graph.new } @@ -31,21 +41,46 @@ module Qernel end describe '#scope' do - let(:emissions) { Emissions.new.with({}) } - it 'returns a ScopedSector instance' do - scoped = emissions.scope(:households_energetic) + scoped = emissions.scope(:households_non_specified_energetic) expect(scoped).to be_a(Emissions::ScopedSector) end it 'sets the correct scope' do - scoped = emissions.scope(:agriculture_energetic) - expect(scoped.instance_variable_get(:@scope)).to eq(:agriculture_energetic) + scoped = emissions.scope(:agriculture_non_specified_energetic) + expect(scoped.instance_variable_get(:@scope)).to eq(:agriculture_non_specified_energetic) + end + end + + describe 'scope validation' do + context 'with valid scope' do + it 'allows creating a ScopedSector instance' do + expect { emissions.scope(:households_non_specified_energetic) }.not_to raise_error + end + end + + context 'with invalid scope (does not exist in dataset)' do + it 'allows creating the ScopedSector instance' do + # Note: Validation happens on setter, not on scope() call + expect { emissions.scope(:invalid_sector_energetic) }.not_to raise_error + end + + it 'raises NoMethodError when trying to set a value' do + invalid_scope = emissions.scope(:invalid_sector_energetic) + expect { invalid_scope.co2 = 100.0 }.to raise_error( + NoMethodError, + /undefined method `co2=' for / + ) + end + + it 'returns nil when trying to get a value (no validation on getter)' do + invalid_scope = emissions.scope(:invalid_sector_energetic) + expect(invalid_scope.co2).to be_nil + end end end describe Emissions::ScopedSector do - let(:emissions) { Emissions.new.with({}) } let(:scoped) { emissions.scope(:households_non_specified_energetic) } before do @@ -53,64 +88,74 @@ module Qernel emissions[:agriculture_non_specified_energetic_other_ghg] = 25.0 end - describe '#method_missing' do - it 'delegates getter methods to emissions with scoped prefix' do + describe 'GHG accessors' do + it 'reads values with the scoped prefix' do expect(scoped.other_ghg).to eq(50.0) end - it 'delegates getter for a different scope' do + it 'reads values for a different scope' do other_scoped = emissions.scope(:agriculture_non_specified_energetic) expect(other_scoped.other_ghg).to eq(25.0) end - it 'delegates setter methods to emissions with scoped prefix' do + it 'writes values with the scoped prefix' do scoped.other_ghg = 75.0 - # Setters convert to symbol keys via dataset_set expect(emissions.dataset_get(:households_non_specified_energetic_other_ghg)).to eq(75.0) end - it 'delegates setter for a different scope' do + it 'writes values for a different scope' do other_scoped = emissions.scope(:agriculture_non_specified_energetic) other_scoped.other_ghg = 30.0 expect(emissions.dataset_get(:agriculture_non_specified_energetic_other_ghg)).to eq(30.0) end - it 'raises NoMethodError for undefined getter methods' do - expect { scoped.nonexistent_attribute }.to raise_error(NoMethodError) - expect { scoped.invalid_emission }.to raise_error(NoMethodError) - end - - it 'raises NoMethodError for setter keys that do not exist in the dataset' do - expect { scoped.arbitrary_key = 100.0 }.to raise_error(NoMethodError) - expect { scoped.custom_emission_type = 200.0 }.to raise_error(NoMethodError) - expect { scoped.nonexistent = 300.0 }.to raise_error(NoMethodError) + it 'returns nil when the scope has no value for the GHG' do + expect(scoped.co2).to be_nil end - it 'allows setters for emission keys that exist in the dataset' do - expect { scoped.other_ghg = 2.0 }.not_to raise_error - expect(emissions.dataset_get(:households_non_specified_energetic_other_ghg)).to eq(2.0) + it 'allows setting a GHG which has no value yet (runtime UPDATE values)' do + scoped.co2 = 300.0 + expect(emissions.dataset_get(:households_non_specified_energetic_co2)).to eq(300.0) end end - describe '#respond_to_missing?' do - it 'returns true for valid GHG types that exist' do - expect(scoped.respond_to?(:other_ghg)).to be true - end - - it 'returns true for setter methods where the key exists in dataset' do - expect(scoped.respond_to?(:other_ghg=)).to be true - end + describe 'year targeting' do + it 'reads and writes the requested year' do + scoped_1990 = emissions.scope(:households_non_specified_energetic, 1990) + scoped_1990.other_ghg = 12.0 - it 'returns false for setter methods where the key does not exist in dataset' do - expect(scoped.respond_to?(:invalid_key=)).to be false - expect(scoped.respond_to?(:co2=)).to be false # co2 doesn't exist for this scope - expect(scoped.respond_to?(:arbitrary_name=)).to be false + expect(emissions.dataset_get(:households_non_specified_energetic_other_ghg_1990)).to eq(12.0) + expect(scoped_1990.other_ghg).to eq(12.0) + expect(scoped.other_ghg).to eq(50.0) end + end - it 'returns false for getter methods where the key does not exist in dataset' do - expect(scoped.respond_to?(:invalid_key)).to be false - expect(scoped.respond_to?(:co2)).to be false # co2 doesn't exist for this scope - expect(scoped.respond_to?(:nonexistent_attribute)).to be false + describe 'scope existence validation' do + context 'with scope that does not exist in dataset' do + before do + # Create a scope that has no matching keys in dataset + @invalid_scoped = emissions.scope(:nonexistent_sector_energetic) + end + + it 'raises NoMethodError when setting any GHG value' do + expect { @invalid_scoped.co2 = 100.0 }.to raise_error(NoMethodError) + expect { @invalid_scoped.other_ghg = 50.0 }.to raise_error(NoMethodError) + end + + it 'returns nil when getting any GHG value (no validation)' do + expect(@invalid_scoped.co2).to be_nil + expect(@invalid_scoped.other_ghg).to be_nil + end + + it 'does not respond to setter methods' do + expect(@invalid_scoped.respond_to?(:co2=)).to be false + expect(@invalid_scoped.respond_to?(:other_ghg=)).to be false + end + + it 'does not respond to getter methods' do + expect(@invalid_scoped.respond_to?(:co2)).to be false + expect(@invalid_scoped.respond_to?(:other_ghg)).to be false + end end end @@ -141,8 +186,7 @@ module Qernel describe '#inspect' do it 'returns a readable string representation' do - scoped = emissions.scope(:households_energetic) - expect(scoped.inspect).to eq('') + expect(scoped.inspect).to eq('') end end @@ -153,6 +197,8 @@ module Qernel emissions[:energy_fugitive_emissions_non_energetic_co2] = 0.0 emissions[:energy_electricity_and_heat_production_energetic_other_ghg] = 0.0 emissions[:buildings_non_specified_energetic_other_ghg] = 0.0 + emissions[:agriculture_non_specified_energetic_other_ghg] = 0.0 + emissions[:agriculture_non_specified_non_energetic_co2] = 0.0 end it 'handles zero values' do @@ -178,7 +224,6 @@ module Qernel scoped.co2 = 100.0 expect(emissions.dataset_get(:energy_fugitive_emissions_non_energetic_co2)).to eq(100.0) - # Test agriculture keys that actually exist in default dataset ag_energetic = emissions.scope(:agriculture_non_specified_energetic) ag_energetic.other_ghg = 200.0 expect(emissions.dataset_get(:agriculture_non_specified_energetic_other_ghg)).to eq(200.0) diff --git a/spec/models/qernel/node_api/direct_emissions_spec.rb b/spec/models/qernel/node_api/direct_emissions_spec.rb index fc0c06c5d..1d6b0a8c5 100644 --- a/spec/models/qernel/node_api/direct_emissions_spec.rb +++ b/spec/models/qernel/node_api/direct_emissions_spec.rb @@ -2462,4 +2462,193 @@ end end end + + describe '#direct_reporting_emissions_co2_utilisation' do + context 'with fossil CO2 input utilisation' do + # Create a graph: + # [CO2 Source] -> [CO2 Utilisation Plant] -> [Terminus] + # Plant uses fossil CO2 as input for production + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:co2_utilisation_plant, groups: [:emissions], co2_utilisation_per_mj: 0.08) + builder.add(:co2_source, groups: [:primary_energy_demand]) + + builder.connect(:co2_source, :co2_utilisation_plant, :co2, type: :share) + builder.connect(:co2_utilisation_plant, :terminus, :electricity, type: :share) + + builder.carrier_attrs(:co2, co2_conversion_per_mj: 0.0) + builder.carrier_attrs(:electricity, co2_conversion_per_mj: 0.0) + end + end + + let(:graph) { builder.to_qernel } + let(:co2_plant) { graph.node(:co2_utilisation_plant) } + + it 'reports CO2 utilisation equal to fossil input utilisation' do + # Total useful output: 100 MJ + # Fossil CO2 utilisation: 100 MJ * 0.08 kg/MJ = 8.0 kg CO2 + # CO2 Utilisation (reporting) = 8.0 kg CO2 + expect(co2_plant).to have_query_value(:direct_reporting_emissions_co2_utilisation, 8.0) + end + + it 'equals direct_co2_input_utilisation_fossil' do + fossil_util = co2_plant.query.direct_co2_input_utilisation_fossil + utilisation = co2_plant.query.direct_reporting_emissions_co2_utilisation + + expect(utilisation).to eq(fossil_util) + end + end + + context 'with zero fossil CO2 utilisation' do + # Create a graph: + # [Producer] -> [Plant without CO2 utilisation] -> [Terminus] + # Plant has no CO2 utilisation attribute + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:regular_plant, groups: [:emissions]) + builder.add(:producer, groups: [:primary_energy_demand]) + + builder.connect(:producer, :regular_plant, :natural_gas, type: :share) + builder.connect(:regular_plant, :terminus, :electricity, type: :share) + + builder.carrier_attrs(:natural_gas, co2_conversion_per_mj: 0.0564) + builder.carrier_attrs(:electricity, co2_conversion_per_mj: 0.0) + end + end + + let(:graph) { builder.to_qernel } + let(:regular_plant) { graph.node(:regular_plant) } + + it 'reports zero CO2 utilisation' do + # No CO2 utilisation = 0 kg CO2 + expect(regular_plant).to have_query_value(:direct_reporting_emissions_co2_utilisation, 0.0) + end + + it 'equals direct_co2_input_utilisation_fossil (zero)' do + fossil_util = regular_plant.query.direct_co2_input_utilisation_fossil + utilisation = regular_plant.query.direct_reporting_emissions_co2_utilisation + + expect(utilisation).to eq(fossil_util) + expect(utilisation).to eq(0.0) + end + end + + context 'with a non-emissions node' do + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:non_emissions_node) + builder.add(:producer, groups: [:primary_energy_demand]) + + builder.connect(:producer, :non_emissions_node, :natural_gas, type: :share) + builder.connect(:non_emissions_node, :terminus, :electricity, type: :share) + + builder.carrier_attrs(:natural_gas, co2_conversion_per_mj: 0.0564) + end + end + + let(:graph) { builder.to_qernel } + let(:non_emissions_node) { graph.node(:non_emissions_node) } + + it 'returns nil for non-emissions nodes' do + expect(non_emissions_node.query.direct_reporting_emissions_co2_utilisation).to be_nil + end + end + end + + describe '#direct_reporting_emissions_biogenic_co2_emissions' do + context 'with biogenic production emissions' do + # Create a graph: + # [Biogenic Waste Producer] -> [Biogenic Plant] -> [Terminus] + # Plant produces biogenic CO2 emissions during production + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:biogenic_plant, groups: [:emissions]) + builder.add(:biogenic_waste_producer, groups: [:primary_energy_demand]) + + builder.connect(:biogenic_waste_producer, :biogenic_plant, :biogenic_waste, type: :share) + builder.connect(:biogenic_plant, :terminus, :heat, type: :share) + + builder.carrier_attrs(:biogenic_waste, potential_co2_conversion_per_mj: 0.05) + builder.carrier_attrs(:heat, potential_co2_conversion_per_mj: 0.0) + end + end + + let(:graph) { builder.to_qernel } + let(:biogenic_plant) { graph.node(:biogenic_plant) } + + it 'reports biogenic CO2 emissions equal to biogenic production emissions' do + # Biogenic: 100 MJ * 0.05 kg/MJ = 5.0 kg CO2 + # Biogenic CO2 emissions (reporting) = 5.0 kg CO2 + expect(biogenic_plant).to have_query_value(:direct_reporting_emissions_biogenic_co2_emissions, 5.0) + end + + it 'equals direct_co2_output_production_emissions_biogenic' do + biogenic = biogenic_plant.query.direct_co2_output_production_emissions_biogenic + biogenic_reporting = biogenic_plant.query.direct_reporting_emissions_biogenic_co2_emissions + + expect(biogenic_reporting).to eq(biogenic) + end + end + + context 'with zero biogenic emissions' do + # Create a graph: + # [Fossil Producer] -> [Fossil Plant] -> [Terminus] + # Plant has no biogenic inputs + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:fossil_plant, groups: [:emissions]) + builder.add(:fossil_producer, groups: [:primary_energy_demand]) + + builder.connect(:fossil_producer, :fossil_plant, :natural_gas, type: :share) + builder.connect(:fossil_plant, :terminus, :electricity, type: :share) + + builder.carrier_attrs(:natural_gas, co2_conversion_per_mj: 0.0564) + builder.carrier_attrs(:electricity, co2_conversion_per_mj: 0.0) + end + end + + let(:graph) { builder.to_qernel } + let(:fossil_plant) { graph.node(:fossil_plant) } + + it 'reports zero biogenic CO2 emissions' do + # No biogenic inputs = 0 kg CO2 + expect(fossil_plant).to have_query_value(:direct_reporting_emissions_biogenic_co2_emissions, 0.0) + end + + it 'equals direct_co2_output_production_emissions_biogenic (zero)' do + biogenic = fossil_plant.query.direct_co2_output_production_emissions_biogenic + biogenic_reporting = fossil_plant.query.direct_reporting_emissions_biogenic_co2_emissions + + expect(biogenic_reporting).to eq(biogenic) + expect(biogenic_reporting).to eq(0.0) + end + end + + context 'with a non-emissions node' do + let(:builder) do + TestGraphBuilder.new.tap do |builder| + builder.add(:terminus, demand: 100) + builder.add(:non_emissions_node) + builder.add(:producer, groups: [:primary_energy_demand]) + + builder.connect(:producer, :non_emissions_node, :natural_gas, type: :share) + builder.connect(:non_emissions_node, :terminus, :electricity, type: :share) + + builder.carrier_attrs(:natural_gas, co2_conversion_per_mj: 0.0564) + end + end + + let(:graph) { builder.to_qernel } + let(:non_emissions_node) { graph.node(:non_emissions_node) } + + it 'returns nil for non-emissions nodes' do + expect(non_emissions_node.query.direct_reporting_emissions_biogenic_co2_emissions).to be_nil + end + end + end end diff --git a/spec/models/qernel/node_api/molecule_emissions_spec.rb b/spec/models/qernel/node_api/molecule_emissions_spec.rb index ec8ce1518..f1d6de08c 100644 --- a/spec/models/qernel/node_api/molecule_emissions_spec.rb +++ b/spec/models/qernel/node_api/molecule_emissions_spec.rb @@ -190,6 +190,38 @@ end end + describe '#direct_reporting_emissions_co2_utilisation' do + context 'node in emissions group' do + it 'returns 0.0' do + expect(node.query.direct_reporting_emissions_co2_utilisation).to eq(0.0) + end + end + + context 'node not in emissions group' do + let(:node_groups) { [] } + + it 'returns nil' do + expect(node.query.direct_reporting_emissions_co2_utilisation).to be_nil + end + end + end + + describe '#direct_reporting_emissions_biogenic_co2_emissions' do + context 'node in emissions group' do + it 'returns 0.0' do + expect(node.query.direct_reporting_emissions_biogenic_co2_emissions).to eq(0.0) + end + end + + context 'node not in emissions group' do + let(:node_groups) { [] } + + it 'returns nil' do + expect(node.query.direct_reporting_emissions_biogenic_co2_emissions).to be_nil + end + end + end + describe '#ghg_carrier' do context 'with a co2 input slot' do before do @@ -222,6 +254,8 @@ expect(node.query.direct_reporting_emissions_co2_capture).to be_nil expect(node.query.direct_reporting_emissions_other_ghg_emissions).to be_nil expect(node.query.direct_reporting_emissions_total_ghg_emissions).to be_nil + expect(node.query.direct_reporting_emissions_co2_utilisation).to be_nil + expect(node.query.direct_reporting_emissions_biogenic_co2_emissions).to be_nil end end end