From 07882537a88f3d7915010bf9987b0c7aa0a9df85 Mon Sep 17 00:00:00 2001 From: Nora Schinkel Date: Mon, 23 Mar 2026 16:42:52 +0100 Subject: [PATCH 1/3] WIP emissions --- Gemfile | 2 +- Gemfile.lock | 24 +++++++++---------- app/models/etsource/dataset.rb | 9 +++++++ app/models/gql/runtime/functions/lookup.rb | 8 +++++++ app/models/qernel/area.rb | 4 ++++ app/models/qernel/emissions.rb | 22 +++++++++++++++++ app/models/qernel/graph.rb | 1 + db/schema.rb | 1 - .../etsource/datasets/nl/emissions.csv | 5 ++++ .../gql/runtime/functions/update_spec.rb | 13 ++++++++++ 10 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 app/models/qernel/emissions.rb create mode 100644 spec/fixtures/etsource/datasets/nl/emissions.csv diff --git a/Gemfile b/Gemfile index 351b5c1ea..056b08d23 100644 --- a/Gemfile +++ b/Gemfile @@ -75,7 +75,7 @@ gem 'ruby-progressbar' # own gems gem 'quintel_merit', ref: '54d2be1', github: 'quintel/merit' -gem 'atlas', ref: '33f32a4', github: 'quintel/atlas' +gem 'atlas', path: '../gems/atlas' #ref: '33f32a4', github: 'quintel/atlas' gem 'fever', ref: '2a91194', github: 'quintel/fever' gem 'refinery', ref: 'c39c9b1', github: 'quintel/refinery' gem 'rubel', ref: 'e36554a', github: 'quintel/rubel' diff --git a/Gemfile.lock b/Gemfile.lock index 7e6feac84..2f05d0845 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,16 +1,3 @@ -GIT - remote: https://github.com/quintel/atlas.git - revision: 33f32a48c868e92f055a68c517c6b9fd5ae01bfa - ref: 33f32a4 - specs: - atlas (1.0.0) - activemodel (>= 7) - activesupport (>= 7) - csv (>= 3) - gpgme (~> 2.0) - turbine-graph (>= 0.1) - virtus (~> 1.0) - GIT remote: https://github.com/quintel/fever.git revision: 2a911947caba3bba1f837ae153654a9d47a6f42d @@ -66,6 +53,17 @@ GIT specs: rubel (0.1.1) +PATH + remote: ../gems/atlas + specs: + atlas (1.0.0) + activemodel (>= 7) + activesupport (>= 7) + csv (>= 3) + gpgme (~> 2.0) + turbine-graph (>= 0.1) + virtus (~> 1.0) + GEM remote: https://rubygems.org/ specs: diff --git a/app/models/etsource/dataset.rb b/app/models/etsource/dataset.rb index 20e3616ab..40a41b518 100644 --- a/app/models/etsource/dataset.rb +++ b/app/models/etsource/dataset.rb @@ -39,6 +39,15 @@ def self.weather_properties(region_code, variant_name) end end + # same as weather? + def self.emissions(area_code) + key = "emissions.#{area_code}" + + NastyCache.instance.fetch(key) do + Atlas::Dataset.find(area_code).emissions.to_h + end + end + def self.region_codes(refresh: false) NastyCache.instance.delete('region_codes') if refresh diff --git a/app/models/gql/runtime/functions/lookup.rb b/app/models/gql/runtime/functions/lookup.rb index cd96ade7c..672f0c990 100644 --- a/app/models/gql/runtime/functions/lookup.rb +++ b/app/models/gql/runtime/functions/lookup.rb @@ -179,6 +179,14 @@ def AREA(*keys) keys.empty? ? scope.graph.area : scope.area(keys.first) end + # Public: Retrieves a single value from the emissions.csv file + def EMISSIONS(sector_key, type = :co2, date = :start_year) + keys = sector_key.to_s.split('.').map(&:to_sym) + keys << type + + scope.graph.emissions.get(keys, date) + end + # Public: Retrieves a single value from the weather_properties.csv file # associated with the currently-selected weather curve set. def WEATHER_PROPERTY(key) diff --git a/app/models/qernel/area.rb b/app/models/qernel/area.rb index 6b31be44a..12ca59d49 100644 --- a/app/models/qernel/area.rb +++ b/app/models/qernel/area.rb @@ -55,6 +55,10 @@ def weather_properties Etsource::Dataset.weather_properties(area_code, weather_curve_set) end + def emissions + fetch(:emissions) { Qernel::Emissions.new(**Etsource::Dataset.emissions(area_code)) } + end + # ----- attributes/methods still used in gqueries. should be properly added to etsource or change gqueries. def co2_emission_1990_billions diff --git a/app/models/qernel/emissions.rb b/app/models/qernel/emissions.rb new file mode 100644 index 000000000..c56d46355 --- /dev/null +++ b/app/models/qernel/emissions.rb @@ -0,0 +1,22 @@ +module Qernel + class Emissions + + # ----- Dataset ------------------------------------------------------------- + + include DatasetAttributes + + def initialize(**attributes) + @dataset_key = @key = :emissions_data + + Emissions.dataset_accessors(*attributes.keys) + + + end + + # private + + + + # Set the dataset attributes + end +end diff --git a/app/models/qernel/graph.rb b/app/models/qernel/graph.rb index f6c688bcd..1a91193db 100644 --- a/app/models/qernel/graph.rb +++ b/app/models/qernel/graph.rb @@ -62,6 +62,7 @@ def graph :area delegate :weather_properties, to: :area + delegate :emissions, to: :area def self.dataset_group_with_name(name) :"#{name}_graph" diff --git a/db/schema.rb b/db/schema.rb index 80c18f5f0..337501b31 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -161,7 +161,6 @@ t.string "user_email" end - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "forecast_storage_orders", "scenarios" add_foreign_key "heat_network_orders", "scenarios" diff --git a/spec/fixtures/etsource/datasets/nl/emissions.csv b/spec/fixtures/etsource/datasets/nl/emissions.csv new file mode 100644 index 000000000..895c4def4 --- /dev/null +++ b/spec/fixtures/etsource/datasets/nl/emissions.csv @@ -0,0 +1,5 @@ +sector,sub_sector,type,1990,start_year +energy,electricity_and_heat_production,other_ghg,20.0,18.0 +energy,electricity_and_heat_production,co2,20.0,18.0 +households,,other_ghg,5.0,7.0 +households,,co2,10.0, diff --git a/spec/models/gql/runtime/functions/update_spec.rb b/spec/models/gql/runtime/functions/update_spec.rb index ead5d43bc..2b9ac9a86 100644 --- a/spec/models/gql/runtime/functions/update_spec.rb +++ b/spec/models/gql/runtime/functions/update_spec.rb @@ -49,4 +49,17 @@ expect { result }.not_to raise_error end end + + describe 'UPDATE(EMISSIONS(households, other_ghg), 10)' do + before { result } + + it 'does not raise an error' do + expect { result }.not_to raise_error + end + + it 'sets the emissions to 10' do + debugger + expect(gql.query_future('EMISSIONS(households, other_ghg)')).to eq(10) + end + end end From 5590da930ae54784336d10c1eab9c0b699354390 Mon Sep 17 00:00:00 2001 From: Nora Schinkel Date: Tue, 31 Mar 2026 17:55:51 +0200 Subject: [PATCH 2/3] Load and cache emission dataset attributes form etsource --- Gemfile | 2 +- Gemfile.lock | 24 +++---- app/models/etsource/dataset.rb | 12 ++-- app/models/etsource/dataset/import.rb | 8 +++ app/models/etsource/loader.rb | 7 +++ app/models/gql/runtime/functions/lookup.rb | 43 +++++++++++-- app/models/qernel/area.rb | 11 ++-- app/models/qernel/dataset.rb | 1 + app/models/qernel/emissions.rb | 63 ++++++++++++++++--- app/models/qernel/graph.rb | 1 + .../gql/runtime/functions/lookup_spec.rb | 34 ++++++++++ .../gql/runtime/functions/update_spec.rb | 3 +- 12 files changed, 172 insertions(+), 37 deletions(-) diff --git a/Gemfile b/Gemfile index 056b08d23..8d5874c99 100644 --- a/Gemfile +++ b/Gemfile @@ -75,7 +75,7 @@ gem 'ruby-progressbar' # own gems gem 'quintel_merit', ref: '54d2be1', github: 'quintel/merit' -gem 'atlas', path: '../gems/atlas' #ref: '33f32a4', github: 'quintel/atlas' +gem 'atlas', ref: '02c849e', github: 'quintel/atlas' gem 'fever', ref: '2a91194', github: 'quintel/fever' gem 'refinery', ref: 'c39c9b1', github: 'quintel/refinery' gem 'rubel', ref: 'e36554a', github: 'quintel/rubel' diff --git a/Gemfile.lock b/Gemfile.lock index 2f05d0845..0f64cc93a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,16 @@ +GIT + remote: https://github.com/quintel/atlas.git + revision: 02c849ef51d3efd5f64095c96ca020280e36fbeb + ref: 02c849e + specs: + atlas (1.0.0) + activemodel (>= 7) + activesupport (>= 7) + csv (>= 3) + gpgme (~> 2.0) + turbine-graph (>= 0.1) + virtus (~> 1.0) + GIT remote: https://github.com/quintel/fever.git revision: 2a911947caba3bba1f837ae153654a9d47a6f42d @@ -53,17 +66,6 @@ GIT specs: rubel (0.1.1) -PATH - remote: ../gems/atlas - specs: - atlas (1.0.0) - activemodel (>= 7) - activesupport (>= 7) - csv (>= 3) - gpgme (~> 2.0) - turbine-graph (>= 0.1) - virtus (~> 1.0) - GEM remote: https://rubygems.org/ specs: diff --git a/app/models/etsource/dataset.rb b/app/models/etsource/dataset.rb index 40a41b518..661582823 100644 --- a/app/models/etsource/dataset.rb +++ b/app/models/etsource/dataset.rb @@ -39,12 +39,12 @@ def self.weather_properties(region_code, variant_name) end end - # same as weather? - def self.emissions(area_code) - key = "emissions.#{area_code}" - - NastyCache.instance.fetch(key) do - Atlas::Dataset.find(area_code).emissions.to_h + # Gets the emission keys from the default dataset + def self.emissions_keys + NastyCache.instance.fetch('emission_keys') do + Atlas::Dataset.find( + Etsource::Config.default_dataset_key + ).emissions.to_hash.keys end end diff --git a/app/models/etsource/dataset/import.rb b/app/models/etsource/dataset/import.rb index 7eb433ccb..692d049d2 100644 --- a/app/models/etsource/dataset/import.rb +++ b/app/models/etsource/dataset/import.rb @@ -79,6 +79,7 @@ def load_dataset_hash { area: load_region_data, + emissions: load_emission_data, carriers: load_carrier_data(precalculated_objects), energy_graph: load_energy_graph_dataset(precalculated_objects), molecules_graph: load_molecules_graph_dataset(precalculated_objects) @@ -114,6 +115,13 @@ def load_region_data { area_data: @atlas_ds.to_hash } end + # Internal: Loads the regions emission data. + # + # Returns a hash. + def load_emission_data + { emissions_data: @atlas_ds.emissions.to_hash } + end + # Internal: Loads the carrier data. # # Returns a hash, each key-pair being a carrier. diff --git a/app/models/etsource/loader.rb b/app/models/etsource/loader.rb index a46f013f0..87afe1bd4 100644 --- a/app/models/etsource/loader.rb +++ b/app/models/etsource/loader.rb @@ -45,6 +45,13 @@ def area_attributes(area_code) end end + def emissions(area_code) + cache("emissions/#{area_code}") do + area_emissions = Atlas::Dataset.find(area_code).emissions + IceNine.deep_freeze!(area_emissions.to_hash.with_indifferent_access) + end + end + # @return [Qernel::Dataset] Dataset to be used for a country. Is in a uncalculated state. def dataset(country) instrument("etsource.loader: dataset(#{country.inspect})") do diff --git a/app/models/gql/runtime/functions/lookup.rb b/app/models/gql/runtime/functions/lookup.rb index 672f0c990..eecda1976 100644 --- a/app/models/gql/runtime/functions/lookup.rb +++ b/app/models/gql/runtime/functions/lookup.rb @@ -179,12 +179,45 @@ def AREA(*keys) keys.empty? ? scope.graph.area : scope.area(keys.first) end - # Public: Retrieves a single value from the emissions.csv file - def EMISSIONS(sector_key, type = :co2, date = :start_year) - keys = sector_key.to_s.split('.').map(&:to_sym) - keys << type + # Returns an attribute {Qernel::Emissions} or {Qernel::Emissions::ScopedSector} + # + # keys - The name of the sector ('industry.metal', or 'households'), + # - The type of emissions (ghg, co2) + # - A year of emission (1990), defaults to start_year + # + # + # EMISSIONS() without any keys returns {Qernel::Emissions} + # + # EMISSIONS() # => + # + # + # EMISSIONS(sector) with just a sector, returns {Qernel::Emissions::ScopedSector} + # + # Which can be used to update emission factors: + # UPDATE(EMISSION(households), ghg, VALUE ) + # UPDATE(EMISSION('industry.metal'), co2, VALUE ) + # + # Examples + # + # EMISSIONS('industry.metal') # => + # + # + # EMISSIONS(sector, type, year) returns an emission value + # + # Examples + # EMISSIONS(households, ghg, 1990) => 2506777 + # + def EMISSIONS(*keys) + return scope.graph.emissions if keys.empty? + + keys[0] = keys.first.to_s.tr('.', '_').to_sym + + return scope.graph.emissions.scope(keys.first) if keys.size == 1 + + # Start year is default in emission attributes, remove if supplied + keys.pop if keys.last == :start_year - scope.graph.emissions.get(keys, date) + scope.graph.emissions[keys.join('_').to_sym] end # Public: Retrieves a single value from the weather_properties.csv file diff --git a/app/models/qernel/area.rb b/app/models/qernel/area.rb index 12ca59d49..8b8f209d3 100644 --- a/app/models/qernel/area.rb +++ b/app/models/qernel/area.rb @@ -18,12 +18,14 @@ class Area dataset_accessors :weather_curve_set dataset_accessors :disabled_sectors - attr_accessor :graph + attr_accessor :graph, :emissions attr_reader :dataset_key, :key def initialize(graph = nil) self.graph = graph unless graph.nil? @dataset_key = @key = :area_data + + @emissions = Qernel::Emissions.new(graph) end # Remove when we replace :area with :area_code @@ -55,9 +57,10 @@ def weather_properties Etsource::Dataset.weather_properties(area_code, weather_curve_set) end - def emissions - fetch(:emissions) { Qernel::Emissions.new(**Etsource::Dataset.emissions(area_code)) } - end + # # TODO: will change after fixing ETScource::Loader + # def emissions + # fetch(:emissions) { Qernel::Emissions.new(**Etsource::Dataset.emissions(area_code)) } + # end # ----- attributes/methods still used in gqueries. should be properly added to etsource or change gqueries. diff --git a/app/models/qernel/dataset.rb b/app/models/qernel/dataset.rb index e548090a6..c1b4254f0 100644 --- a/app/models/qernel/dataset.rb +++ b/app/models/qernel/dataset.rb @@ -26,6 +26,7 @@ def initialize(id = nil) @data = { area: { area_data: {} }, + emissions: { emissions_data: {} }, energy_graph: { graph: {} }, molecules_graph: { graph: {} } } diff --git a/app/models/qernel/emissions.rb b/app/models/qernel/emissions.rb index c56d46355..f4d27efd6 100644 --- a/app/models/qernel/emissions.rb +++ b/app/models/qernel/emissions.rb @@ -1,22 +1,69 @@ module Qernel + # Class for getting and setting sector emmissions + # Behaves much like Qernel::Area, can been seen as an extension of + # area attributes, scoped for emissions class Emissions + include DatasetAttributes - # ----- Dataset ------------------------------------------------------------- + dataset_accessors ::Etsource::Dataset.emissions_keys + attr_accessor :graph - include DatasetAttributes + # TODO: write a spec for scope - def initialize(**attributes) - @dataset_key = @key = :emissions_data + # Queryable object that defers sector methods to main Emissions + # + # GQL uses this to scope the sector for easier queries and input/update + # statements in etsource + class ScopedSector + def initialize(emissions, scope) + @emissions = emissions + @scope = scope + end - Emissions.dataset_accessors(*attributes.keys) + def [](attr_name) + @emissions[scoped_method(attr_name)] + end + def []=(attr_name, value) + @emissions[scoped_method(attr_name)] = value + end - end + def inspect + "" + end + + def scoped_method(method_name) + "#{@scope}_#{method_name}" + end + + def respond_to_missing?(method_name, include_private = false) + data_key = scoped_method(method_name).split('=').first - # private + @emissions.respond_to?(data_key) || super + end + def method_missing(method_name, *args) + data_key = scoped_method(method_name).split('=').first + if data_key == scoped_method(method_name) + @emissions[data_key] + else + @emissions[data_key] = args.first + end + end + end + + def initialize(graph = nil) + self.graph = graph unless graph.nil? + + @dataset_key = @key = :emissions_data + end - # Set the dataset attributes + # Public: define the sector scope for access to the hashed emission keys + # + # Returns a scoped version of the emissions data + def scope(sector) + ScopedSector.new(self, sector) + end end end diff --git a/app/models/qernel/graph.rb b/app/models/qernel/graph.rb index 1a91193db..b405ceeac 100644 --- a/app/models/qernel/graph.rb +++ b/app/models/qernel/graph.rb @@ -163,6 +163,7 @@ def retaining_lifecycle def call_on_each_qernel_object(method_name) self.send(method_name) area.send(method_name) + emissions.send(method_name) carriers.each(&method_name) nodes.each do |n| diff --git a/spec/models/gql/runtime/functions/lookup_spec.rb b/spec/models/gql/runtime/functions/lookup_spec.rb index 3afb98288..6b0589b5e 100644 --- a/spec/models/gql/runtime/functions/lookup_spec.rb +++ b/spec/models/gql/runtime/functions/lookup_spec.rb @@ -14,6 +14,40 @@ module Gql::Runtime::Functions end end + # EMISSIONS + # --------------- + # + + describe 'EMISSIONS()' do + it 'returns a Qernel::Emissions object' do + expect(result).to be_a(Qernel::Emissions) + end + end + + describe 'EMISSIONS(households)' do + it 'returns a Qernel::Emissions object' do + expect(result).to be_a(Qernel::Emissions::ScopedSector) + end + end + + describe "EMISSIONS('industry.metal')" do + it 'returns a Qernel::Emissions object' do + expect(result).to be_a(Qernel::Emissions::ScopedSector) + end + end + + describe 'EMISSIONS(households, co2, 1990)' do + it 'returns a value' do + expect(result).to eq(10) + end + end + + describe "EMISSIONS('energy.electricity_and_heat_production', other_ghg)" do + it 'returns a value' do + expect(result).to eq(18.0) + end + end + # WEATHER_PROPERTY # ---------------- diff --git a/spec/models/gql/runtime/functions/update_spec.rb b/spec/models/gql/runtime/functions/update_spec.rb index 2b9ac9a86..f0391cba2 100644 --- a/spec/models/gql/runtime/functions/update_spec.rb +++ b/spec/models/gql/runtime/functions/update_spec.rb @@ -50,7 +50,7 @@ end end - describe 'UPDATE(EMISSIONS(households, other_ghg), 10)' do + describe 'UPDATE(EMISSIONS(households), other_ghg, 10)' do before { result } it 'does not raise an error' do @@ -58,7 +58,6 @@ end it 'sets the emissions to 10' do - debugger expect(gql.query_future('EMISSIONS(households, other_ghg)')).to eq(10) end end From a79094bb476360e366b070efb029a8c15e052a4a Mon Sep 17 00:00:00 2001 From: louispt1 Date: Wed, 1 Apr 2026 16:00:36 +0200 Subject: [PATCH 3/3] Add comprehensive spec for the Emissions lookup and adjust to handle altered csv structures --- Gemfile | 2 +- Gemfile.lock | 4 +- app/models/gql/runtime/functions/lookup.rb | 36 +- .../etsource/datasets/nl/emissions.csv | 5 - .../datasets/nl/emissions/emissions_1990.csv | 40 ++ .../nl/emissions/emissions_default.csv | 38 + .../gql/runtime/functions/lookup_spec.rb | 131 +++- .../gql/runtime/functions/update_spec.rb | 57 +- spec/models/qernel/area_spec.rb | 37 + spec/models/qernel/emissions_spec.rb | 653 ++++++++++++++++++ spec/models/qernel/graph_spec.rb | 31 + 11 files changed, 1003 insertions(+), 31 deletions(-) delete mode 100644 spec/fixtures/etsource/datasets/nl/emissions.csv create mode 100755 spec/fixtures/etsource/datasets/nl/emissions/emissions_1990.csv create mode 100755 spec/fixtures/etsource/datasets/nl/emissions/emissions_default.csv create mode 100644 spec/models/qernel/area_spec.rb create mode 100644 spec/models/qernel/emissions_spec.rb diff --git a/Gemfile b/Gemfile index 8d5874c99..b1491bad9 100644 --- a/Gemfile +++ b/Gemfile @@ -75,7 +75,7 @@ gem 'ruby-progressbar' # own gems gem 'quintel_merit', ref: '54d2be1', github: 'quintel/merit' -gem 'atlas', ref: '02c849e', github: 'quintel/atlas' +gem 'atlas', ref: '90fb02b', github: 'quintel/atlas' gem 'fever', ref: '2a91194', github: 'quintel/fever' gem 'refinery', ref: 'c39c9b1', github: 'quintel/refinery' gem 'rubel', ref: 'e36554a', github: 'quintel/rubel' diff --git a/Gemfile.lock b/Gemfile.lock index 0f64cc93a..75c040101 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/quintel/atlas.git - revision: 02c849ef51d3efd5f64095c96ca020280e36fbeb - ref: 02c849e + revision: 90fb02bf9a66189502b35351d20fe4dc1a27fe49 + ref: 90fb02b 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 eecda1976..0b1bbf58c 100644 --- a/app/models/gql/runtime/functions/lookup.rb +++ b/app/models/gql/runtime/functions/lookup.rb @@ -181,9 +181,18 @@ def AREA(*keys) # Returns an attribute {Qernel::Emissions} or {Qernel::Emissions::ScopedSector} # - # keys - The name of the sector ('industry.metal', or 'households'), - # - The type of emissions (ghg, co2) - # - A year of emission (1990), defaults to start_year + # Emissions data is loaded from CSV files in ETSource with the following structure: + # etm_sector, etm_subsector, type, ghg, unit, value + # + # Parameters: + # - sector: ETM sector name (e.g., 'households', 'energy.electricity_and_heat_production') + # Dots in sector names are converted to underscores for key generation + # - type: Emission 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 + # + # Key generation combines: sector_[subsector_]type_ghg[_year] + # Note: Unit column from CSV is not included in keys, blank values return nil # # # EMISSIONS() without any keys returns {Qernel::Emissions} @@ -191,31 +200,32 @@ def AREA(*keys) # EMISSIONS() # => # # - # EMISSIONS(sector) with just a sector, returns {Qernel::Emissions::ScopedSector} + # EMISSIONS(sector, type) returns {Qernel::Emissions::ScopedSector} # # Which can be used to update emission factors: - # UPDATE(EMISSION(households), ghg, VALUE ) - # UPDATE(EMISSION('industry.metal'), co2, VALUE ) + # UPDATE(EMISSIONS(households, energetic), co2, VALUE ) + # UPDATE(EMISSIONS('energy.electricity_and_heat_production', energetic), co2, VALUE ) # # Examples # - # EMISSIONS('industry.metal') # => + # EMISSIONS('energy.electricity_and_heat_production', energetic) + # # => # # - # EMISSIONS(sector, type, year) returns an emission value + # EMISSIONS(sector, type, ghg) or EMISSIONS(sector, type, ghg, year) returns an emission value # # Examples - # EMISSIONS(households, ghg, 1990) => 2506777 + # EMISSIONS(households, energetic, other_ghg) # => 12.0 (from emissions_default.csv) + # EMISSIONS(households, energetic, co2, 1990) # => value (from emissions_1990.csv) + # EMISSIONS(energy.electricity_and_heat_production, energetic, other_ghg) # => 18.0 # def EMISSIONS(*keys) return scope.graph.emissions if keys.empty? keys[0] = keys.first.to_s.tr('.', '_').to_sym - return scope.graph.emissions.scope(keys.first) if keys.size == 1 - - # Start year is default in emission attributes, remove if supplied - keys.pop if keys.last == :start_year + # EMISSIONS(sector, type) -> return ScopedSector + return scope.graph.emissions.scope(keys.join('_').to_sym) if keys.size == 2 scope.graph.emissions[keys.join('_').to_sym] end diff --git a/spec/fixtures/etsource/datasets/nl/emissions.csv b/spec/fixtures/etsource/datasets/nl/emissions.csv deleted file mode 100644 index 895c4def4..000000000 --- a/spec/fixtures/etsource/datasets/nl/emissions.csv +++ /dev/null @@ -1,5 +0,0 @@ -sector,sub_sector,type,1990,start_year -energy,electricity_and_heat_production,other_ghg,20.0,18.0 -energy,electricity_and_heat_production,co2,20.0,18.0 -households,,other_ghg,5.0,7.0 -households,,co2,10.0, diff --git a/spec/fixtures/etsource/datasets/nl/emissions/emissions_1990.csv b/spec/fixtures/etsource/datasets/nl/emissions/emissions_1990.csv new file mode 100755 index 000000000..199340ab9 --- /dev/null +++ b/spec/fixtures/etsource/datasets/nl/emissions/emissions_1990.csv @@ -0,0 +1,40 @@ +etm_sector,etm_subsector,type,ghg,unit,value +Energy,Electricity and heat production,energetic,other_ghg,kg,15.0 +Energy,Electricity and heat production,energetic,co2,kg,18.0 +Industry,Refineries,energetic,other_ghg,kg, +Industry,Refineries,energetic,co2,kg, +Energy,Other fuels production,energetic,other_ghg,kg, +Energy,Other fuels production,energetic,co2,kg, +Industry,,energetic,other_ghg,kg, +Industry,,energetic,co2,kg, +National transport,,energetic,other_ghg,kg, +National transport,,energetic,co2,kg, +Other,Other transportation,energetic,co2,kg, +Other,Other transportation,energetic,other_ghg,kg, +Buildings,,energetic,other_ghg,kg, +Buildings,,energetic,co2,kg, +Households,,energetic,other_ghg,kg,7.0 +Households,,energetic,co2,kg,12.0 +Agriculture,,energetic,other_ghg,kg, +Agriculture,,energetic,co2,kg, +Other,,energetic,other_ghg,kg, +Other,,energetic,co2,kg, +Energy,Electricity and heat production,non_energetic,co2,kg,18.0 +Energy,Fugitive emissions,non_energetic,other_ghg,kg, +Energy,Fugitive emissions,non_energetic,co2,kg, +Energy,CCUS,non_energetic,co2,kg, +Industry,,non_energetic,other_ghg,kg, +Industry,,non_energetic,co2,kg, +Energy,Methanol production,non_energetic,other_ghg,kg, +Energy,Methanol production,non_energetic,co2,kg, +Energy,Hydrogen production,non_energetic,other_ghg,kg, +Energy,Hydrogen production,non_energetic,co2,kg, +Agriculture,,non_energetic,co2,kg, +Agriculture,,non_energetic,other_ghg,kg, +LULUCF,,non_energetic,co2,kg, +LULUCF,,non_energetic,other_ghg,kg, +Waste,,non_energetic,co2,kg, +Waste,,non_energetic,other_ghg,kg, +Other,Indirect emissions,non_energetic,co2,kg, +International transport,,energetic,co2,kg, +International transport,,energetic,other_ghg,kg, \ No newline at end of file diff --git a/spec/fixtures/etsource/datasets/nl/emissions/emissions_default.csv b/spec/fixtures/etsource/datasets/nl/emissions/emissions_default.csv new file mode 100755 index 000000000..44b629ce5 --- /dev/null +++ b/spec/fixtures/etsource/datasets/nl/emissions/emissions_default.csv @@ -0,0 +1,38 @@ +etm_sector,etm_subsector,type,ghg,unit,value +Energy,Electricity and heat production,energetic,other_ghg,kg,18.0 +Industry,Refineries,energetic,other_ghg,kg, +Energy,Other fuels production,energetic,other_ghg,kg, +Industry,,energetic,other_ghg,kg, +National transport,,energetic,other_ghg,kg, +Other,Other transportation,energetic,other_ghg,kg, +Buildings,,energetic,other_ghg,kg, +Households,,energetic,other_ghg,kg,7.0 +Households,,energetic,co2,kg,12.0 +Agriculture,,energetic,other_ghg,kg, +Other,,energetic,other_ghg,kg, +Energy,Electricity and heat production,non_energetic,co2,kg,18.0 +Energy,Fugitive emissions,non_energetic,other_ghg,kg, +Energy,Fugitive emissions,non_energetic,co2,kg, +Energy,CCUS,non_energetic,co2,kg, +Industry,Other,non_energetic,other_ghg,kg, +Industry,Other,non_energetic,co2,kg, +Industry,Fertilizers,non_energetic,other_ghg,kg, +Industry,Chemicals,non_energetic,other_ghg,kg, +Industry,Chemicals,non_energetic,co2,kg, +Energy,Methanol production,non_energetic,other_ghg,kg, +Energy,Hydrogen production,non_energetic,other_ghg,kg, +Industry,Steel,non_energetic,co2,kg, +Industry,Steel,non_energetic,other_ghg,kg, +Industry,Aluminium,non_energetic,co2,kg, +Industry,Aluminium,non_energetic,other_ghg,kg, +Industry,Other metals,non_energetic,co2,kg, +Industry,Other metals,non_energetic,other_ghg,kg, +Agriculture,,non_energetic,co2,kg, +Agriculture,,non_energetic,other_ghg,kg, +LULUCF,,non_energetic,co2,kg, +LULUCF,,non_energetic,other_ghg,kg, +Waste,,non_energetic,co2,kg, +Waste,,non_energetic,other_ghg,kg, +Other,Indirect emissions,non_energetic,co2,kg, +International transport,International aviation,energetic,other_ghg,kg, +International transport,International navigation,energetic,other_ghg,kg, diff --git a/spec/models/gql/runtime/functions/lookup_spec.rb b/spec/models/gql/runtime/functions/lookup_spec.rb index 6b0589b5e..d9778600e 100644 --- a/spec/models/gql/runtime/functions/lookup_spec.rb +++ b/spec/models/gql/runtime/functions/lookup_spec.rb @@ -24,30 +24,145 @@ module Gql::Runtime::Functions end end - describe 'EMISSIONS(households)' do - it 'returns a Qernel::Emissions object' do + describe 'EMISSIONS(households, energetic)' do + it 'returns a Qernel::Emissions::ScopedSector' do expect(result).to be_a(Qernel::Emissions::ScopedSector) end end - describe "EMISSIONS('industry.metal')" do - it 'returns a Qernel::Emissions object' do + describe "EMISSIONS('industry.metal', energetic)" do + it 'returns a Qernel::Emissions::ScopedSector' do expect(result).to be_a(Qernel::Emissions::ScopedSector) end end - describe 'EMISSIONS(households, co2, 1990)' do - it 'returns a value' do - expect(result).to eq(10) + describe 'EMISSIONS(households, energetic, co2, 1990)' do + it 'returns a historical value from emissions_1990.csv' do + expect(result).to eq(12.0) end end - describe "EMISSIONS('energy.electricity_and_heat_production', other_ghg)" do + describe "EMISSIONS('energy.electricity_and_heat_production', energetic, other_ghg)" do it 'returns a value' do expect(result).to eq(18.0) end end + describe "EMISSIONS('energy.electricity_and_heat_production', non_energetic, co2)" do + it 'returns a value for non_energetic type' do + expect(result).to eq(18.0) + end + end + + describe "EMISSIONS('energy.electricity_and_heat_production', non_energetic, co2, 1990)" do + it 'returns a historical value with subsector from emissions_1990.csv' do + expect(result).to eq(18.0) + end + end + + describe 'EMISSIONS(households, energetic, co2)' do + it 'returns start_year value when year is omitted' do + expect(result).to eq(12.0) + end + end + + describe 'EMISSIONS(households, energetic, other_ghg, 1990)' do + it 'returns the 1990 value for other_ghg from emissions_1990.csv' do + expect(result).to eq(7.0) + end + end + + describe 'EMISSIONS(nonexistent_sector, energetic, co2)' do + it 'returns nil for non-existent sector' do + expect(result).to be_nil + end + end + + describe 'EMISSIONS(households, energetic, nonexistent_type)' do + it 'returns nil for non-existent emission type' do + expect(result).to be_nil + end + end + + describe 'EMISSIONS(households, energetic, co2, 2050)' do + it 'returns nil for non-existent year (no emissions_2050.csv file)' do + expect(result).to be_nil + end + end + + describe 'EMISSIONS(industry, energetic, co2)' do + it 'returns nil when value is blank in CSV' do + # Assumes industry,,energetic,co2 is blank in fixture + expect(result).to be_nil + end + end + + # Test that dot notation is properly converted to underscores + describe "EMISSIONS('energy.electricity_and_heat_production', energetic)" do + it 'returns a ScopedSector for multi-part sector with dots' do + expect(result).to be_a(Qernel::Emissions::ScopedSector) + end + end + + # Test that start_year parameter is treated as a literal year lookup + # (no special handling - it would look for households_energetic_other_ghg_start_year key) + describe 'EMISSIONS(households, energetic, other_ghg, start_year)' do + it 'returns nil when start_year is used as a literal year parameter' do + # start_year is no longer treated specially, so this looks for + # households_energetic_other_ghg_start_year which does not exist + expect(result).to be_nil + end + end + + describe 'EMISSIONS(households, energetic, other_ghg)' do + it 'returns correct value from emissions_default.csv' do + expect(result).to eq(7.0) + end + end + + describe "EMISSIONS('energy.electricity_and_heat_production', energetic, other_ghg)" do + it 'handles subsector with dots correctly' do + expect(result).to eq(18.0) + end + end + + describe 'EMISSIONS(industry, energetic, other_ghg)' do + it 'returns nil for blank value in CSV (industry has blank values)' do + expect(result).to be_nil + end + end + + describe 'EMISSIONS(international_transport, energetic, other_ghg)' do + it 'handles sector names with underscores correctly' do + expect(result).to be_nil + end + end + + describe "EMISSIONS('international_transport.international_aviation', energetic, other_ghg)" do + it 'handles sector with subsector and underscores correctly' do + expect(result).to be_nil # Blank in fixture + end + end + + describe 'EMISSIONS(lulucf, non_energetic, co2)' do + it 'returns value for LULUCF sector' do + expect(result).to be_nil # Blank in fixture + end + end + + describe 'EMISSIONS(invalid_sector, invalid_type, invalid_ghg)' do + it 'returns nil for completely invalid parameters' do + expect(result).to be_nil + end + end + + # TODO: Nil or error? + describe 'EMISSIONS(households, energetic, co2, 999)' do + it 'returns nil for non-existent numeric year' do + expect(result).to be_nil + end + end + # WEATHER_PROPERTY # ---------------- diff --git a/spec/models/gql/runtime/functions/update_spec.rb b/spec/models/gql/runtime/functions/update_spec.rb index f0391cba2..de8627943 100644 --- a/spec/models/gql/runtime/functions/update_spec.rb +++ b/spec/models/gql/runtime/functions/update_spec.rb @@ -50,7 +50,7 @@ end end - describe 'UPDATE(EMISSIONS(households), other_ghg, 10)' do + describe 'UPDATE(EMISSIONS(households, energetic), other_ghg, 10)' do before { result } it 'does not raise an error' do @@ -58,7 +58,60 @@ end it 'sets the emissions to 10' do - expect(gql.query_future('EMISSIONS(households, other_ghg)')).to eq(10) + expect(gql.query_future('EMISSIONS(households, energetic, other_ghg)')).to eq(10) + end + end + + describe 'UPDATE(EMISSIONS(households, energetic), co2, 500.0)' do + before { result } + + it 'sets the co2 emissions to 500.0' do + expect(gql.query_future('EMISSIONS(households, energetic, co2)')).to eq(500.0) + end + + it 'does not affect other_ghg emissions' do + original = gql.query_future('EMISSIONS(households, energetic, other_ghg)') + result + expect(gql.query_future('EMISSIONS(households, energetic, other_ghg)')).to eq(original) + end + end + + describe "UPDATE(EMISSIONS('energy.electricity_and_heat_production', energetic), co2, 99.0)" do + before { result } + + it 'sets the emissions for nested sector' do + expect(gql.query_future("EMISSIONS('energy.electricity_and_heat_production', energetic, co2)")).to eq(99.0) + end + end + + describe 'UPDATE(EMISSIONS(industry, energetic), co2_1990, 1000.0)' do + before { result } + + it 'sets historical emissions data' do + expect(gql.query_future('EMISSIONS(industry, energetic, co2, 1990)')).to eq(1000.0) + end + + it 'does not affect start_year emissions' do + # Assumes industry co2 start_year was nil or has different value + result + start_year_value = gql.query_future('EMISSIONS(industry, energetic, co2)') + expect(start_year_value).not_to eq(1000.0) unless start_year_value.nil? + end + end + + describe 'UPDATE(EMISSIONS(agriculture, energetic), co2, 0.0)' do + before { result } + + it 'sets emissions to zero' do + expect(gql.query_future('EMISSIONS(agriculture, energetic, co2)')).to eq(0.0) + end + end + + describe 'UPDATE(EMISSIONS(households, energetic), co2, nil)' do + before { result } + + it 'sets emissions to nil' do + expect(gql.query_future('EMISSIONS(households, energetic, co2)')).to be_nil end end end diff --git a/spec/models/qernel/area_spec.rb b/spec/models/qernel/area_spec.rb new file mode 100644 index 000000000..410b77789 --- /dev/null +++ b/spec/models/qernel/area_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +module Qernel + describe Area do + describe 'emissions' do + let(:graph) { Qernel::Graph.new } + let(:area) { graph.area } + + it 'initializes with an emissions object' do + expect(area.emissions).to be_a(Qernel::Emissions) + end + + it 'passes the graph reference to emissions' do + expect(area.emissions.graph).to eq(graph) + end + + it 'maintains the same emissions instance' do + first_call = area.emissions + second_call = area.emissions + expect(first_call.object_id).to eq(second_call.object_id) + end + + it 'allows setting emission values' do + area.emissions.with({}) + area.emissions.dataset_set(:households_co2, 100.0) + expect(area.emissions.dataset_get(:households_co2)).to eq(100.0) + end + + it 'provides scoped access to emissions' do + area.emissions.with({}) + attrs = area.emissions.instance_variable_get(:@dataset_attributes) + attrs['industry_other_ghg'] = 50.0 + expect(area.emissions.scope(:industry)[:other_ghg]).to eq(50.0) + end + end + end +end diff --git a/spec/models/qernel/emissions_spec.rb b/spec/models/qernel/emissions_spec.rb new file mode 100644 index 000000000..c4590ab45 --- /dev/null +++ b/spec/models/qernel/emissions_spec.rb @@ -0,0 +1,653 @@ +require 'spec_helper' + +module Qernel + describe Emissions do + describe '#initialize' do + context 'with a graph' do + let(:graph) { Qernel::Graph.new } + + it 'assigns the graph' do + emissions = Emissions.new(graph) + expect(emissions.graph).to eq(graph) + end + + it 'sets the dataset_key to :emissions_data' do + emissions = Emissions.new(graph) + expect(emissions.dataset_key).to eq(:emissions_data) + end + + it 'sets the internal key to :emissions_data' do + emissions = Emissions.new(graph) + expect(emissions.instance_variable_get(:@key)).to eq(:emissions_data) + end + end + + context 'without a graph' do + it 'does not assign the graph' do + emissions = Emissions.new + expect(emissions.graph).to be_nil + end + + it 'still sets the dataset_key' do + emissions = Emissions.new + expect(emissions.dataset_key).to eq(:emissions_data) + end + end + end + + describe 'dataset_accessors integration' do + let(:emissions) { Emissions.new.with({}) } + + it 'provides access via dataset_get' do + emissions.dataset_set(:households_co2, 123.45) + expect(emissions.dataset_get(:households_co2)).to eq(123.45) + end + + it 'provides access via dataset_set' do + emissions.dataset_set(:industry_other_ghg, 678.90) + expect(emissions.dataset_get(:industry_other_ghg)).to eq(678.90) + end + + it 'supports complex keys with subsectors and years' do + emissions.dataset_set(:energy_electricity_and_heat_production_co2_1990, 1000.0) + expect(emissions.dataset_get(:energy_electricity_and_heat_production_co2_1990)).to eq(1000.0) + end + + it 'returns nil for unset attributes' do + expect(emissions.dataset_get(:nonexistent_sector_co2)).to be_nil + end + + context 'with caching enabled' do + let(:getter) { double('getter', call: 42.0) } + + before do + allow(emissions).to receive(:graph).and_return( + double('graph', cache_dataset_fetch?: true) + ) + end + + it 'caches fetched values' do + first_value = emissions.fetch(:households_co2) { getter.call } + second_value = emissions.fetch(:households_co2) { getter.call } + + expect(first_value).to eq(42.0) + expect(second_value).to eq(42.0) + expect(getter).to have_received(:call).once + end + end + + context 'with lazy evaluation' do + let(:calculator) { double('calculator', call: 99.9) } + + it 'supports dataset_lazy_set' do + emissions.dataset_lazy_set(:agriculture_co2) { calculator.call } + + # First access triggers evaluation + value = emissions.dataset_get(:agriculture_co2) + expect(value).to eq(99.9) + expect(calculator).to have_received(:call).once + end + end + end + + describe '#scope' do + let(:emissions) { Emissions.new.with({}) } + + it 'returns a ScopedSector instance' do + scoped = emissions.scope(:industry) + expect(scoped).to be_a(Emissions::ScopedSector) + end + + it 'creates a scoped sector for the given key' do + scoped = emissions.scope(:households) + expect(scoped.inspect).to eq('') + end + + it 'handles string sector names' do + scoped = emissions.scope('agriculture') + expect(scoped.inspect).to eq('') + end + + it 'handles multi-part sector names with dots' do + scoped = emissions.scope('industry.metal') + expect(scoped.inspect).to eq('') + end + + context 'with scoped read access' do + before do + emissions.dataset_set(:industry_co2, 100.0) + emissions.dataset_set(:industry_other_ghg, 50.0) + emissions.dataset_set(:industry_co2_1990, 200.0) + + emissions.instance_variable_get(:@dataset_attributes)['industry_co2'] = 100.0 + emissions.instance_variable_get(:@dataset_attributes)['industry_other_ghg'] = 50.0 + emissions.instance_variable_get(:@dataset_attributes)['industry_co2_1990'] = 200.0 + end + + it 'provides read access to co2 attribute' do + expect(emissions.scope(:industry)[:co2]).to eq(100.0) + end + + it 'provides read access to other_ghg attribute' do + expect(emissions.scope(:industry)[:other_ghg]).to eq(50.0) + end + + it 'provides read access to historical data' do + expect(emissions.scope(:industry)[:co2_1990]).to eq(200.0) + end + + it 'returns nil for unset attributes' do + expect(emissions.scope(:industry)[:nonexistent]).to be_nil + end + end + + context 'with scoped write access' do + it 'provides write access to co2 attribute' do + emissions.scope(:households)[:co2] = 150.0 + expect(emissions.dataset_get(:households_co2)).to eq(150.0) + end + + it 'provides write access to other_ghg attribute' do + emissions.scope(:households)[:other_ghg] = 75.0 + expect(emissions.dataset_get(:households_other_ghg)).to eq(75.0) + end + + it 'provides write access to historical data' do + emissions.scope(:households)[:co2_1990] = 250.0 + expect(emissions.dataset_get(:households_co2_1990)).to eq(250.0) + end + end + end + + describe '::ScopedSector' do + let(:emissions) { Emissions.new.with({}) } + let(:scoped) { emissions.scope(:agriculture) } + + describe '#initialize' do + it 'stores the emissions reference' do + expect(scoped.instance_variable_get(:@emissions)).to eq(emissions) + end + + it 'stores the scope as provided' do + expect(scoped.instance_variable_get(:@scope)).to eq(:agriculture) + end + end + + describe '#[]' do + before do + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['agriculture_co2'] = 88.0 + attrs['agriculture_other_ghg_1990'] = 44.0 + end + + it 'reads scoped attributes' do + expect(scoped[:co2]).to eq(88.0) + end + + it 'reads scoped attributes with year suffix' do + expect(scoped[:other_ghg_1990]).to eq(44.0) + end + + it 'returns nil for unset attributes' do + expect(scoped[:nonexistent]).to be_nil + end + end + + describe '#[]=' do + it 'sets scoped attributes' do + scoped[:co2] = 123.0 + expect(emissions.dataset_get(:agriculture_co2)).to eq(123.0) + end + + it 'sets scoped attributes with year suffix' do + scoped[:co2_1990] = 456.0 + expect(emissions.dataset_get(:agriculture_co2_1990)).to eq(456.0) + end + + it 'overwrites existing values' do + emissions.dataset_set(:agriculture_other_ghg, 10.0) + scoped[:other_ghg] = 20.0 + expect(emissions.dataset_get(:agriculture_other_ghg)).to eq(20.0) + end + end + + describe '#method_missing' do + before do + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['agriculture_co2'] = 50.0 + attrs['agriculture_other_ghg'] = 25.0 + end + + it 'delegates getter methods to emissions with scoped prefix' do + expect(scoped.co2).to eq(50.0) + end + + it 'delegates getter for other_ghg' do + expect(scoped.other_ghg).to eq(25.0) + end + + it 'delegates getter with year suffix' do + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['agriculture_co2_1990'] = 100.0 + expect(scoped.co2_1990).to eq(100.0) + end + + it 'delegates setter methods to emissions with scoped prefix' do + scoped.co2 = 75.0 + # Setters convert to symbol keys via dataset_set + expect(emissions.dataset_get(:agriculture_co2)).to eq(75.0) + end + + it 'delegates setter for other_ghg' do + scoped.other_ghg = 30.0 + expect(emissions.dataset_get(:agriculture_other_ghg)).to eq(30.0) + end + + it 'delegates setter with year suffix' do + scoped.co2_1990 = 200.0 + expect(emissions.dataset_get(:agriculture_co2_1990)).to eq(200.0) + end + + it 'returns nil for undefined getter methods' do + expect(scoped.nonexistent_attribute).to be_nil + end + end + + describe '#respond_to_missing?' do + before do + # ScopedSector calls scoped_method which converts to string + allow(emissions).to receive(:respond_to?) + .with('agriculture_co2').and_return(true) + allow(emissions).to receive(:respond_to?) + .with('agriculture_co2').and_return(true) + allow(emissions).to receive(:respond_to?) + .with('agriculture_nonexistent').and_return(false) + end + + it 'returns true for valid getter methods' do + expect(scoped.respond_to?(:co2)).to be true + end + + it 'returns true for valid setter methods' do + expect(scoped.respond_to?(:co2=)).to be true + end + + it 'returns false for invalid methods' do + expect(scoped.respond_to?(:nonexistent)).to be false + end + end + + describe '#inspect' do + it 'returns a readable string representation' do + expect(scoped.inspect).to eq('') + end + + it 'includes the scope name for different sectors' do + industry_scoped = emissions.scope(:industry) + expect(industry_scoped.inspect).to eq('') + end + + it 'includes the scope name for multi-part sectors' do + complex_scoped = emissions.scope('energy.electricity') + expect(complex_scoped.inspect).to eq('') + end + end + + context 'with nested sector names' do + let(:nested_scoped) { emissions.scope('industry.metal') } + + before do + emissions.dataset_set(:industry_metal_co2, 300.0) + end + + it 'handles reading with underscore conversion' do + # The scoped method should convert 'industry.metal' + 'co2' to 'industry_metal_co2' + expect(nested_scoped.instance_variable_get(:@scope)).to eq('industry.metal') + end + end + end + + describe 'integration with graph lifecycle' do + let(:graph) { Qernel::Graph.new } + let(:emissions) { graph.area.emissions } + + it 'is accessible via graph.area.emissions' do + expect(emissions).to be_a(Emissions) + end + + it 'has the graph reference set' do + expect(emissions.graph).to eq(graph) + end + + it 'participates in assign_dataset_attributes' do + expect { emissions.assign_dataset_attributes }.not_to raise_error + end + end + + describe 'dynamic accessor methods' do + let(:emissions) { Emissions.new.with({}) } + + context 'when emission keys are loaded' do + before do + emissions.dataset_set(:households_co2, 100.0) + emissions.dataset_set(:industry_other_ghg, 200.0) + end + + it 'allows access via method syntax if accessor is defined' do + if emissions.respond_to?(:households_co2) + expect(emissions.households_co2).to eq(100.0) + end + end + end + end + + describe 'nil and edge case handling' do + let(:emissions) { Emissions.new.with({}) } + + it 'handles nil values gracefully' do + emissions.dataset_set(:sector_co2, nil) + expect(emissions.dataset_get(:sector_co2)).to be_nil + end + + it 'handles zero values' do + emissions.dataset_set(:sector_co2, 0.0) + expect(emissions.dataset_get(:sector_co2)).to eq(0.0) + end + + it 'handles negative values' do + emissions.dataset_set(:sector_co2, -50.0) + expect(emissions.dataset_get(:sector_co2)).to eq(-50.0) + end + + it 'handles very large values' do + emissions.dataset_set(:sector_co2, 1_000_000_000.0) + expect(emissions.dataset_get(:sector_co2)).to eq(1_000_000_000.0) + end + + it 'handles blank subsector notation' do + # Sectors without subsectors should have double underscore reduced to single + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['households_co2'] = 100.0 # not households__co2 + expect(emissions.scope(:households)[:co2]).to eq(100.0) + end + end + + describe 'new CSV structure (multi-file format)' do + let(:emissions) { Emissions.new.with({}) } + + context 'CSV format validation' do + it 'loads emissions from emissions_default.csv with correct column structure' do + emissions.dataset_set(:households_energetic_co2, 12.0) + expect(emissions.dataset_get(:households_energetic_co2)).to eq(12.0) + end + + it 'generates keys including the type column (energetic/non_energetic)' do + # CSV row: energy,electricity_and_heat_production,non_energetic,co2,18,kg + # Expected key: energy_electricity_and_heat_production_non_energetic_co2 + emissions.dataset_set(:energy_electricity_and_heat_production_non_energetic_co2, 18.0) + expect(emissions.dataset_get(:energy_electricity_and_heat_production_non_energetic_co2)).to eq(18.0) + end + + it 'generates keys for other_ghg emission type with energetic type' do + # CSV row: households,,energetic,other_ghg,7,kg + # Expected key: households_energetic_other_ghg + emissions.dataset_set(:households_energetic_other_ghg, 7.0) + expect(emissions.dataset_get(:households_energetic_other_ghg)).to eq(7.0) + end + + it 'handles unit column presence but excludes it from key generation' do + # The 'unit' column exists in CSV but should not appear in emission keys + # agriculture,,energetic,co2,95,kg → agriculture_energetic_co2 (not ..._co2_kg) + emissions.dataset_set(:agriculture_energetic_co2, 95.0) + expect(emissions.dataset_get(:agriculture_energetic_co2)).to eq(95.0) + end + end + + context 'with empty sub_sector field' do + it 'generates keys without double underscores for sectors with blank sub_sector' do + # CSV: households,,energetic,co2,12,kg (blank sub_sector) + # Expected key: households_energetic_co2 (NOT households__energetic_co2) + emissions.dataset_set(:households_energetic_co2, 12.0) + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['households_energetic_co2'] = 12.0 + + expect(emissions.scope(:households_energetic)[:co2]).to eq(12.0) + end + + it 'generates keys without double underscores for agriculture sector' do + # CSV: agriculture,,energetic,co2,95,kg + # Expected key: agriculture_energetic_co2 + emissions.dataset_set(:agriculture_energetic_co2, 95.0) + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['agriculture_energetic_co2'] = 95.0 + + expect(emissions.scope(:agriculture_energetic)[:co2]).to eq(95.0) + end + + it 'handles both co2 and other_ghg for sectors with blank sub_sector' do + emissions.dataset_set(:households_energetic_co2, 12.0) + emissions.dataset_set(:households_energetic_other_ghg, 7.0) + + expect(emissions.dataset_get(:households_energetic_co2)).to eq(12.0) + expect(emissions.dataset_get(:households_energetic_other_ghg)).to eq(7.0) + end + + it 'distinguishes between blank sub_sector and named sub_sector' do + # industry,,energetic,co2,,kg → industry_energetic_co2 (blank value, blank sub_sector) + # industry,metal,energetic,co2,45,kg → industry_metal_energetic_co2 + emissions.dataset_set(:industry_energetic_co2, nil) + emissions.dataset_set(:industry_metal_energetic_co2, 45.0) + + expect(emissions.dataset_get(:industry_energetic_co2)).to be_nil + expect(emissions.dataset_get(:industry_metal_energetic_co2)).to eq(45.0) + end + end + + context 'with sectors containing sub_sectors' do + it 'generates compound keys for nested sectors' do + emissions.dataset_set(:industry_metal_energetic_co2, 45.0) + expect(emissions.dataset_get(:industry_metal_energetic_co2)).to eq(45.0) + end + + it 'handles other_ghg type for nested sectors' do + emissions.dataset_set(:industry_metal_energetic_other_ghg, 28.0) + expect(emissions.dataset_get(:industry_metal_energetic_other_ghg)).to eq(28.0) + end + + it 'allows scoped access to nested sectors with type' do + emissions.dataset_set(:industry_metal_energetic_co2, 45.0) + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['industry_metal_energetic_co2'] = 45.0 + + scoped = emissions.scope(:industry_metal_energetic) + expect(scoped[:co2]).to eq(45.0) + end + end + + context 'with blank values in CSV' do + it 'represents blank CSV values as nil in emission keys' do + # CSV: industry,,energetic,co2,,kg (empty value field) + # Expected: industry_energetic_co2 exists but value is nil + emissions.dataset_set(:industry_energetic_co2, nil) + expect(emissions.dataset_get(:industry_energetic_co2)).to be_nil + end + + it 'distinguishes nil values from missing keys' do + emissions.dataset_set(:industry_energetic_co2, nil) + + expect(emissions.dataset_get(:industry_energetic_co2)).to be_nil + expect(emissions.dataset_get(:nonexistent_sector_energetic_co2)).to be_nil + # TODO: Both return nil but for different reasons - should we error? + end + end + end + + describe 'GQL EMISSIONS function integration' do + let(:graph) { Qernel::Graph.new } + let(:emissions) { graph.area.emissions.with({}) } + + before do + # Simulate loaded emission data from new CSV structure with type included + attrs = emissions.instance_variable_get(:@dataset_attributes) + attrs['households_energetic_co2'] = 12.0 + attrs['households_energetic_other_ghg'] = 7.0 + attrs['energy_electricity_and_heat_production_energetic_other_ghg'] = 18.0 + attrs['energy_electricity_and_heat_production_non_energetic_co2'] = 18.0 + attrs['industry_metal_energetic_co2'] = 45.0 + attrs['industry_metal_energetic_other_ghg'] = 28.0 + attrs['agriculture_energetic_co2'] = 95.0 + attrs['agriculture_energetic_other_ghg'] = 38.0 + attrs['industry_energetic_co2'] = nil # Blank in CSV + + # Also set symbol keys for dataset_get to work + emissions.dataset_set(:households_energetic_co2, 12.0) + emissions.dataset_set(:households_energetic_other_ghg, 7.0) + emissions.dataset_set(:energy_electricity_and_heat_production_energetic_other_ghg, 18.0) + emissions.dataset_set(:energy_electricity_and_heat_production_non_energetic_co2, 18.0) + emissions.dataset_set(:industry_metal_energetic_co2, 45.0) + emissions.dataset_set(:industry_metal_energetic_other_ghg, 28.0) + emissions.dataset_set(:agriculture_energetic_co2, 95.0) + emissions.dataset_set(:agriculture_energetic_other_ghg, 38.0) + emissions.dataset_set(:industry_energetic_co2, nil) + end + + context 'reading emission values via scoped access' do + it 'returns correct value for households energetic co2' do + # GQL: EMISSIONS(households, energetic, co2) + expect(emissions.scope(:households_energetic)[:co2]).to eq(12.0) + end + + it 'returns correct value for households energetic other_ghg' do + # GQL: EMISSIONS(households, energetic, other_ghg) + expect(emissions.scope(:households_energetic)[:other_ghg]).to eq(7.0) + end + + it 'handles nested sectors with type and underscores' do + # GQL: EMISSIONS('energy.electricity_and_heat_production', energetic, other_ghg) + # Note: GQL converts dots to underscores + scoped = emissions.scope(:energy_electricity_and_heat_production_energetic) + expect(scoped[:other_ghg]).to eq(18.0) + end + + it 'distinguishes between energetic and non_energetic types' do + # GQL: EMISSIONS('energy.electricity_and_heat_production', non_energetic, co2) + scoped = emissions.scope(:energy_electricity_and_heat_production_non_energetic) + expect(scoped[:co2]).to eq(18.0) + end + + it 'handles nested industry sectors with type' do + # GQL: EMISSIONS('industry.metal', energetic, co2) + scoped = emissions.scope(:industry_metal_energetic) + expect(scoped[:co2]).to eq(45.0) + end + + it 'returns ScopedSector when sector and type are provided' do + # GQL: EMISSIONS(households, energetic) + scoped = emissions.scope(:households_energetic) + expect(scoped).to be_a(Emissions::ScopedSector) + expect(scoped[:co2]).to eq(12.0) + end + end + + context 'updating emission values via scoped access' do + it 'updates co2 value without affecting other_ghg' do + # GQL: UPDATE(EMISSIONS(households, energetic), co2, 100) + emissions.scope(:households_energetic)[:co2] = 100.0 + + expect(emissions.dataset_get(:households_energetic_co2)).to eq(100.0) + expect(emissions.dataset_get(:households_energetic_other_ghg)).to eq(7.0) + end + + it 'updates other_ghg value without affecting co2' do + # GQL: UPDATE(EMISSIONS(households, energetic), other_ghg, 50) + emissions.scope(:households_energetic)[:other_ghg] = 50.0 + + expect(emissions.dataset_get(:households_energetic_co2)).to eq(12.0) + expect(emissions.dataset_get(:households_energetic_other_ghg)).to eq(50.0) + end + + it 'updates nested sector emissions with type' do + # GQL: UPDATE(EMISSIONS('industry.metal', energetic), co2, 99) + emissions.scope(:industry_metal_energetic)[:co2] = 99.0 + + expect(emissions.dataset_get(:industry_metal_energetic_co2)).to eq(99.0) + expect(emissions.dataset_get(:industry_metal_energetic_other_ghg)).to eq(28.0) + end + + it 'can set emission values to zero' do + # GQL: UPDATE(EMISSIONS(agriculture, energetic), co2, 0.0) + emissions.scope(:agriculture_energetic)[:co2] = 0.0 + + expect(emissions.dataset_get(:agriculture_energetic_co2)).to eq(0.0) + end + + it 'can set emission values to nil' do + # GQL: UPDATE(EMISSIONS(agriculture, energetic), co2, nil) + emissions.scope(:agriculture_energetic)[:co2] = nil + + expect(emissions.dataset_get(:agriculture_energetic_co2)).to be_nil + end + end + + context 'with missing or invalid data' do + it 'returns nil for non-existent sectors' do + # GQL: EMISSIONS(nonexistent_sector, energetic, co2) + scoped = emissions.scope(:nonexistent_sector_energetic) + expect(scoped[:co2]).to be_nil + end + + it 'returns nil for non-existent emission types' do + # GQL: EMISSIONS(households, energetic, nonexistent_type) + scoped = emissions.scope(:households_energetic) + expect(scoped[:nonexistent_type]).to be_nil + end + + it 'returns nil for sectors with blank CSV values' do + # industry,,energetic,co2,,kg → industry_energetic_co2 = nil + # GQL: EMISSIONS(industry, energetic, co2) + scoped = emissions.scope(:industry_energetic) + expect(scoped[:co2]).to be_nil + end + end + + context 'verifying the full emissions object' do + it 'returns the Emissions object when called without arguments' do + # GQL: EMISSIONS() + expect(emissions).to be_a(Emissions) + expect(emissions.graph).to eq(graph) + end + + it 'allows accessing emissions via graph' do + # Verify graph.emissions works (via delegation or direct access) + expect(graph.area.emissions).to eq(emissions) + end + end + end + + describe 'multi-file CSV structure support' do + let(:emissions) { Emissions.new.with({}) } + + context 'with emissions_default.csv as the primary source' do + it 'loads emission data from the default file' do + # emissions_default.csv contains start_year emissions + # Verify keys are generated from this file including type + emissions.dataset_set(:households_energetic_co2, 12.0) + emissions.dataset_set(:agriculture_energetic_other_ghg, 38.0) + + expect(emissions.dataset_get(:households_energetic_co2)).to eq(12.0) + expect(emissions.dataset_get(:agriculture_energetic_other_ghg)).to eq(38.0) + end + + it 'distinguishes between energetic and non_energetic emission types' do + # Each sector can have multiple rows with different types + # Keys include the 'type' column to distinguish them + emissions.dataset_set(:energy_electricity_and_heat_production_energetic_other_ghg, 18.0) + emissions.dataset_set(:energy_electricity_and_heat_production_non_energetic_co2, 18.0) + + expect(emissions.dataset_get(:energy_electricity_and_heat_production_energetic_other_ghg)).to eq(18.0) + expect(emissions.dataset_get(:energy_electricity_and_heat_production_non_energetic_co2)).to eq(18.0) + end + end + end + end +end diff --git a/spec/models/qernel/graph_spec.rb b/spec/models/qernel/graph_spec.rb index 77b9f617e..44b1185e5 100644 --- a/spec/models/qernel/graph_spec.rb +++ b/spec/models/qernel/graph_spec.rb @@ -622,6 +622,37 @@ module Qernel # Check query_interface_spec.rb to see how we update goals through GQL end + + describe Graph, 'emissions integration' do + let(:graph) { Qernel::Graph.new } + + it 'delegates emissions to area' do + expect(graph.emissions).to eq(graph.area.emissions) + end + + it 'provides emissions as a Qernel::Emissions instance' do + expect(graph.emissions).to be_a(Qernel::Emissions) + end + + it 'sets the graph reference in emissions' do + expect(graph.emissions.graph).to eq(graph) + end + + describe '#call_on_each_qernel_object' do + it 'includes emissions in lifecycle methods' do + expect(graph.emissions).to receive(:assign_dataset_attributes) + graph.call_on_each_qernel_object(:assign_dataset_attributes) + end + + it 'calls methods on area, emissions, and graph' do + expect(graph.area).to receive(:test_method) + expect(graph.emissions).to receive(:test_method) + + allow(graph).to receive(:test_method) + graph.call_on_each_qernel_object(:test_method) + end + end + end end end end