diff --git a/Gemfile b/Gemfile index 351b5c1ea..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: '33f32a4', 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 7e6feac84..75c040101 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ GIT remote: https://github.com/quintel/atlas.git - revision: 33f32a48c868e92f055a68c517c6b9fd5ae01bfa - ref: 33f32a4 + revision: 90fb02bf9a66189502b35351d20fe4dc1a27fe49 + ref: 90fb02b specs: atlas (1.0.0) activemodel (>= 7) diff --git a/app/models/etsource/dataset.rb b/app/models/etsource/dataset.rb index 20e3616ab..661582823 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 + # 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 + def self.region_codes(refresh: false) NastyCache.instance.delete('region_codes') if refresh 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 cd96ade7c..0b1bbf58c 100644 --- a/app/models/gql/runtime/functions/lookup.rb +++ b/app/models/gql/runtime/functions/lookup.rb @@ -179,6 +179,57 @@ def AREA(*keys) keys.empty? ? scope.graph.area : scope.area(keys.first) end + # Returns an attribute {Qernel::Emissions} or {Qernel::Emissions::ScopedSector} + # + # 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} + # + # EMISSIONS() # => + # + # + # EMISSIONS(sector, type) returns {Qernel::Emissions::ScopedSector} + # + # Which can be used to update emission factors: + # UPDATE(EMISSIONS(households, energetic), co2, VALUE ) + # UPDATE(EMISSIONS('energy.electricity_and_heat_production', energetic), co2, VALUE ) + # + # Examples + # + # EMISSIONS('energy.electricity_and_heat_production', energetic) + # # => + # + # + # EMISSIONS(sector, type, ghg) or EMISSIONS(sector, type, ghg, year) returns an emission value + # + # Examples + # 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 + + # 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 + # 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..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,6 +57,11 @@ def weather_properties Etsource::Dataset.weather_properties(area_code, weather_curve_set) 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. def co2_emission_1990_billions 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 new file mode 100644 index 000000000..f4d27efd6 --- /dev/null +++ b/app/models/qernel/emissions.rb @@ -0,0 +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_accessors ::Etsource::Dataset.emissions_keys + attr_accessor :graph + + # TODO: write a spec for scope + + # 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 + + def [](attr_name) + @emissions[scoped_method(attr_name)] + end + + def []=(attr_name, value) + @emissions[scoped_method(attr_name)] = value + 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 + + @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 + + # 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 f6c688bcd..b405ceeac 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" @@ -162,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/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/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 3afb98288..d9778600e 100644 --- a/spec/models/gql/runtime/functions/lookup_spec.rb +++ b/spec/models/gql/runtime/functions/lookup_spec.rb @@ -14,6 +14,155 @@ 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, energetic)' do + it 'returns a Qernel::Emissions::ScopedSector' do + expect(result).to be_a(Qernel::Emissions::ScopedSector) + end + end + + 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, 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', 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 ead5d43bc..de8627943 100644 --- a/spec/models/gql/runtime/functions/update_spec.rb +++ b/spec/models/gql/runtime/functions/update_spec.rb @@ -49,4 +49,69 @@ expect { result }.not_to raise_error end end + + describe 'UPDATE(EMISSIONS(households, energetic), 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 + 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