diff --git a/app/controllers/api/v3/curve_metadata_controller.rb b/app/controllers/api/v3/curve_metadata_controller.rb new file mode 100644 index 000000000..480c97674 --- /dev/null +++ b/app/controllers/api/v3/curve_metadata_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Api + module V3 + # Provides metadata about available hourly curves and annual exports. + # + # This controller serves as a discovery endpoint for clients to dynamically + # determine which curves and exports are available without hardcoding them. + # The metadata is sourced from CurveMetadataRegistry, which is populated by + # the CurvesController and ExportController classes during initialization. + # + # Design rationale: + # - Metadata lives alongside the controller actions that serve each curve + # - Clients can discover new curves without a code change on their side + # + # A curve's name is repeated across its route, action, serializer and + # registration; + class CurveMetadataController < BaseController + # GET /api/v3/curves/metadata + # + # Returns metadata about all available hourly output curves. + # Each curve includes: + # - name: The curve identifier (matches route and controller method) + # - type: The curve type (merit_curve, price_curve, capacity_curve, etc.) + # - description: Human-readable explanation of the curve contents + # + # Example response: + # { + # "hourly_outputs": [ + # { + # "name": "electricity_profiles", + # "type": "merit_curve", + # "description": "Load on each participant in the electricity merit order" + # }, + # ... + # ] + # } + def curves + render json: { hourly_outputs: CurveMetadataRegistry.all_curves } + end + + # GET /api/v3/exports/metadata + # + # Returns metadata about all available annual exports. + # Each export includes: + # - name: The export identifier (matches route and controller method) + # - description: Human-readable explanation of the export contents + # + # Example response: + # { + # "annual_exports": [ + # { + # "name": "energy_flow", + # "description": "Energy flows by carrier (future year)" + # }, + # ... + # ] + # } + def exports + render json: { annual_exports: CurveMetadataRegistry.all_exports } + end + end + end +end diff --git a/app/controllers/api/v3/curves_controller.rb b/app/controllers/api/v3/curves_controller.rb index 7305f2838..9a168fd6c 100644 --- a/app/controllers/api/v3/curves_controller.rb +++ b/app/controllers/api/v3/curves_controller.rb @@ -19,6 +19,69 @@ class CurvesController < BaseController load_and_authorize_resource :scenario before_action :merit_required + # Register all available curves with metadata + # These registrations provide the single source of truth for curve metadata + # exposed via the /api/v3/curves/metadata endpoint + CurveMetadataRegistry.register_curve( + :electricity_profiles, + type: :merit, + description: 'Load on each participant in the electricity merit order' + ) + + CurveMetadataRegistry.register_curve( + :electricity_price, + type: :price, + description: 'Hourly price of electricity according to the merit order' + ) + + CurveMetadataRegistry.register_curve( + :district_heating_profiles, + type: :load, + description: 'Load on each participant in the heat merit order' + ) + + CurveMetadataRegistry.register_curve( + :agriculture_heat, + type: :merit, + description: 'Load on each participant in the agriculture heat merit order' + ) + + CurveMetadataRegistry.register_curve( + :household_heat, + type: :fever, + description: 'Supply and demand of heat in households, including deficits and surpluses' + ) + + CurveMetadataRegistry.register_curve( + :buildings_heat, + type: :fever, + description: 'Supply and demand of heat in buildings, including deficits and surpluses' + ) + + CurveMetadataRegistry.register_curve( + :hydrogen_profiles, + type: :reconciliation, + description: 'Total demand and supply for hydrogen, with storage demand and supply' + ) + + CurveMetadataRegistry.register_curve( + :network_gas_profiles, + type: :reconciliation, + description: 'Total demand and supply for network gas, with storage demand and supply' + ) + + CurveMetadataRegistry.register_curve( + :residual_load, + type: :query, + description: 'Residual loads of various carriers' + ) + + CurveMetadataRegistry.register_curve( + :hydrogen_integral_cost, + type: :query, + description: 'Levelised costs, production costs per MWh and hourly production per hydrogen production technology' + ) + def electricity_profiles render_csv Curves::ElectricityCSVSerializer.new( @scenario.gql.future_graph, :electricity, :merit_order, diff --git a/app/controllers/api/v3/export_controller.rb b/app/controllers/api/v3/export_controller.rb index 5d102eb0e..5bc43db59 100644 --- a/app/controllers/api/v3/export_controller.rb +++ b/app/controllers/api/v3/export_controller.rb @@ -9,6 +9,59 @@ class ExportController < BaseController authorize!(:read, @scenario) end + # Register all available exports with metadata + # These registrations provide the single source of truth for export metadata + # exposed via the /api/v3/exports/metadata endpoint + CurveMetadataRegistry.register_export( + :energy_flow, + description: 'Energy flows by carrier (future year)' + ) + + CurveMetadataRegistry.register_export( + :energy_flow_present, + description: 'Energy flows by carrier (present year)' + ) + + CurveMetadataRegistry.register_export( + :molecule_flow, + description: 'Molecule/hydrogen flows' + ) + + CurveMetadataRegistry.register_export( + :sankey, + description: 'Sankey diagram data' + ) + + CurveMetadataRegistry.register_export( + :storage_parameters, + description: 'Storage capacity and parameters' + ) + + CurveMetadataRegistry.register_export( + :costs_parameters, + description: 'Cost breakdown by technology' + ) + + CurveMetadataRegistry.register_export( + :electricity_capacities, + description: 'Installed/peak capacity per participant in the electricity merit order' + ) + + CurveMetadataRegistry.register_export( + :hydrogen_capacities, + description: 'Installed/peak capacity for hydrogen participants' + ) + + CurveMetadataRegistry.register_export( + :network_gas_capacities, + description: 'Installed/peak capacity for network gas participants' + ) + + CurveMetadataRegistry.register_export( + :district_heating_capacities, + description: 'Installed/peak capacity per participant in the heat merit order' + ) + # GET /api/v3/scenarios/:id/energy_flow # # Returns a CSV file containing the energetic inputs and outputs of every node in the future graph. diff --git a/app/serializers/causality_curves_csv_serializer.rb b/app/serializers/causality_curves_csv_serializer.rb index 71c7e9a83..3a17ad891 100644 --- a/app/serializers/causality_curves_csv_serializer.rb +++ b/app/serializers/causality_curves_csv_serializer.rb @@ -3,6 +3,11 @@ # The CSV contains the key of each node and the direction of energy # flow (input or output) and the hourly load in MWh. class CausalityCurvesCSVSerializer + # Single CSV row returned when a scenario has no time-resolved (merit/Fever) + # data. Shared by the curve and capacity serializers. + TIME_RESOLVED_DISABLED_MESSAGE = + 'Merit order and time-resolved calculation are not enabled for this scenario' + # Provides support for multiple carriers in the serializer. class Adapter attr_reader :attribute, :carrier @@ -47,10 +52,7 @@ def filename # Returns an array of arrays. def to_csv_rows # Empty CSV if time-resolved calculations are not enabled. - unless @adapter.supported?(@graph) - return [['Merit order and time-resolved calculation are not ' \ - 'enabled for this scenario']] - end + return [[TIME_RESOLVED_DISABLED_MESSAGE]] unless @adapter.supported?(@graph) CurvesCSVSerializer.new( raw_columns, diff --git a/app/serializers/curves/district_heating_csv_serializer.rb b/app/serializers/curves/district_heating_csv_serializer.rb index ecd81bc5d..06eaa46af 100644 --- a/app/serializers/curves/district_heating_csv_serializer.rb +++ b/app/serializers/curves/district_heating_csv_serializer.rb @@ -17,8 +17,7 @@ def initialize(graph, conv_cust = nil) def to_csv_rows # Empty CSV if time-resolved calculations are not enabled. unless Qernel::Plugins::Causality.enabled?(@graph) - return [['Merit order and time-resolved calculation are not ' \ - 'enabled for this scenario']] + return [[CausalityCurvesCSVSerializer::TIME_RESOLVED_DISABLED_MESSAGE]] end CurvesCSVSerializer.new( diff --git a/app/serializers/electricity_csv_serializer.rb b/app/serializers/electricity_csv_serializer.rb new file mode 100644 index 000000000..5ee762520 --- /dev/null +++ b/app/serializers/electricity_csv_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Hourly curve CSV for electricity merit order participants. +# Reuses MeritCSVSerializer filtering (producer/consumer types, +# curtailment exclusion, NodeCustomisation). +class ElectricityCSVSerializer < MeritCSVSerializer + def filename + :electricity_profiles + end +end diff --git a/app/services/curve_metadata_registry.rb b/app/services/curve_metadata_registry.rb new file mode 100644 index 000000000..fee7c7ac9 --- /dev/null +++ b/app/services/curve_metadata_registry.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Registry for curve and export metadata. +# +# Controllers declare their curves/exports here with a small DSL, so the metadata +# lives next to the actions that serve it and powers the /curves/metadata and +# /exports/metadata discovery endpoints. +module CurveMetadataRegistry + CURVE_TYPES = %i[merit price capacity load fever reconciliation query].freeze + + class << self + # Registers a new hourly curve + # + # @param name [Symbol] The curve name (must match controller method and route) + # @param type [Symbol] The curve type (:merit, :price, :capacity, :load, :fever, + # :reconciliation, :query) + # @param description [String] Human-readable description of what the curve contains + def register_curve(name, type:, description:) + curves[name] = { + name: name.to_s, + type: normalize_type(type), + description: description + } + end + + # Registers a new annual export + # + # @param name [Symbol] The export name (must match controller method and route) + # @param description [String] Human-readable description of what the export contains + def register_export(name, description:) + exports[name] = { + name: name.to_s, + description: description + } + end + + # Returns all registered hourly curves as an array of hashes + # + # @return [Array] Array of curve metadata with keys: name, type, description + def all_curves + curves.values + end + + # Returns all registered annual exports as an array of hashes + # + # @return [Array] Array of export metadata with keys: name, description + def all_exports + exports.values + end + + # Clears all registrations + def clear! + curves.clear + exports.clear + end + + private + + def curves + @curves ||= {} + end + + def exports + @exports ||= {} + end + + # Stores a curve type symbol as its '_curve' API string, e.g. + # :merit => 'merit_curve'. + def normalize_type(type) + unless CURVE_TYPES.include?(type) + raise ArgumentError, + "Unknown curve type: #{type}. Valid types: #{CURVE_TYPES.map(&:inspect).join(', ')}" + end + + "#{type}_curve" + end + end +end diff --git a/config/initializers/curve_metadata_registry.rb b/config/initializers/curve_metadata_registry.rb new file mode 100644 index 000000000..e55247f57 --- /dev/null +++ b/config/initializers/curve_metadata_registry.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Ensure CurvesController and ExportController are loaded to populate the registry +# This initializer guarantees that metadata registrations happen at application startup + +Rails.application.config.to_prepare do + CurveMetadataRegistry.clear! + require_dependency 'api/v3/curves_controller' + require_dependency 'api/v3/export_controller' +end diff --git a/config/routes.rb b/config/routes.rb index be028775f..a28a24d8e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,21 +16,24 @@ resources :areas, only: %i[index show] resources :gqueries, only: :index + get 'curves/metadata', to: 'curve_metadata#curves' + get 'exports/metadata', to: 'curve_metadata#exports' + resources :scenarios, only: %i[index show create update destroy] do member do get :batch - get :energy_flow, to: 'export#energy_flow' - get :energy_flow_present, to: 'export#energy_flow_present' - get :molecule_flow, to: 'export#molecule_flow' - get :costs_parameters, to: 'export#costs_parameters' - get :sankey, to: 'export#sankey' - get :storage_parameters, to: 'export#storage_parameters' - get :direct_emissions_present, to: 'export#direct_emissions_present' - get :direct_emissions_future, to: 'export#direct_emissions_future' - get :electricity_capacities, to: 'export#electricity_capacities', as: :electricity_capacities_download - get :hydrogen_capacities, to: 'export#hydrogen_capacities', as: :hydrogen_capacities_download - get :network_gas_capacities, to: 'export#network_gas_capacities', as: :network_gas_capacities_download - get :district_heating_capacities, to: 'export#district_heating_capacities', as: :district_heating_capacities_download + get :energy_flow, to: 'export#energy_flow' + get :energy_flow_present, to: 'export#energy_flow_present' + get :molecule_flow, to: 'export#molecule_flow' + get :costs_parameters, to: 'export#costs_parameters' + get :sankey, to: 'export#sankey' + get :storage_parameters, to: 'export#storage_parameters' + get :direct_emissions_present, to: 'export#direct_emissions_present' + get :direct_emissions_future, to: 'export#direct_emissions_future' + get :electricity_capacities, to: 'export#electricity_capacities', as: :electricity_capacities_download + get :hydrogen_capacities, to: 'export#hydrogen_capacities', as: :hydrogen_capacities_download + get :network_gas_capacities, to: 'export#network_gas_capacities', as: :network_gas_capacities_download + get :district_heating_capacities, to: 'export#district_heating_capacities', as: :district_heating_capacities_download get :merit get :dump put :dashboard diff --git a/spec/controllers/api/v3/curve_metadata_controller_spec.rb b/spec/controllers/api/v3/curve_metadata_controller_spec.rb new file mode 100644 index 000000000..aa5f56da0 --- /dev/null +++ b/spec/controllers/api/v3/curve_metadata_controller_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Api::V3::CurveMetadataController do + describe 'GET curves' do + before do + get :curves, format: :json + end + + it 'is successful' do + expect(response).to be_ok + end + + it 'sets the content type to application/json' do + expect(response.media_type).to eq('application/json') + end + + it 'returns hourly_outputs key' do + json = JSON.parse(response.body) + expect(json).to have_key('hourly_outputs') + end + + it 'returns an array of curve metadata' do + json = JSON.parse(response.body) + expect(json['hourly_outputs']).to be_an(Array) + expect(json['hourly_outputs']).not_to be_empty + end + + it 'includes electricity_profiles in the response' do + json = JSON.parse(response.body) + curve = json['hourly_outputs'].find { |c| c['name'] == 'electricity_profiles' } + + expect(curve).not_to be_nil + expect(curve['name']).to eq('electricity_profiles') + expect(curve['type']).to eq('merit_curve') + expect(curve['description']).to be_present + end + + it 'includes all expected curve attributes' do + json = JSON.parse(response.body) + curve = json['hourly_outputs'].first + + expect(curve).to have_key('name') + expect(curve).to have_key('type') + expect(curve).to have_key('description') + end + + it 'includes all known curve names' do + json = JSON.parse(response.body) + curve_names = json['hourly_outputs'].map { |c| c['name'] } + + expected_names = [ + 'electricity_profiles', + 'electricity_price', + 'district_heating_profiles', + 'agriculture_heat', + 'household_heat', + 'buildings_heat', + 'hydrogen_profiles', + 'network_gas_profiles', + 'residual_load', + 'hydrogen_integral_cost' + ] + + expect(curve_names).to match_array(expected_names) + end + end + + describe 'GET exports' do + before do + get :exports, format: :json + end + + it 'is successful' do + expect(response).to be_ok + end + + it 'sets the content type to application/json' do + expect(response.media_type).to eq('application/json') + end + + it 'returns annual_exports key' do + json = JSON.parse(response.body) + expect(json).to have_key('annual_exports') + end + + it 'returns an array of export metadata' do + json = JSON.parse(response.body) + expect(json['annual_exports']).to be_an(Array) + expect(json['annual_exports']).not_to be_empty + end + + it 'includes energy_flow in the response' do + json = JSON.parse(response.body) + export = json['annual_exports'].find { |e| e['name'] == 'energy_flow' } + + expect(export).not_to be_nil + expect(export['name']).to eq('energy_flow') + expect(export['description']).to be_present + end + + it 'includes all expected export attributes' do + json = JSON.parse(response.body) + export = json['annual_exports'].first + + expect(export).to have_key('name') + expect(export).to have_key('description') + end + + it 'includes all known export names' do + json = JSON.parse(response.body) + export_names = json['annual_exports'].map { |e| e['name'] } + + expected_names = [ + 'energy_flow', + 'energy_flow_present', + 'molecule_flow', + 'sankey', + 'storage_parameters', + 'costs_parameters', + 'electricity_capacities', + 'hydrogen_capacities', + 'network_gas_capacities', + 'district_heating_capacities' + ] + + expect(export_names).to match_array(expected_names) + end + end +end diff --git a/spec/controllers/api/v3/exports_controller_spec.rb b/spec/controllers/api/v3/exports_controller_spec.rb index 374da0fa6..c49e9dc73 100644 --- a/spec/controllers/api/v3/exports_controller_spec.rb +++ b/spec/controllers/api/v3/exports_controller_spec.rb @@ -78,6 +78,69 @@ end end + describe 'GET electricity_capacities.csv' do + let(:rows) do + [%w[key installed_capacity peak_capacity], ['wind_turbine.output (MW)', 8.0, 200.0]] + end + + before do + request.headers.merge!(headers) + allow(Export::ElectricityCapacitiesCSVSerializer).to receive(:new).and_return( + instance_double( + Export::ElectricityCapacitiesCSVSerializer, + filename: :electricity_capacities, + to_csv_rows: rows + ) + ) + get :electricity_capacities, params: { id: scenario.id }, format: :csv + end + + it 'is successful' do + expect(response).to be_ok + end + + it 'sets the content type to text/csv' do + expect(response.media_type).to eq('text/csv') + end + + it 'sets the CSV filename' do + expect(response.headers['Content-Disposition']) + .to include("electricity_capacities.#{scenario.id}.csv") + end + + it 'renders the capacity rows' do + expect(response.body).to include('wind_turbine.output (MW)') + expect(response.body).to include('installed_capacity') + end + end + + describe 'GET district_heating_capacities.csv' do + let(:rows) do + [%w[key installed_capacity peak_capacity], ['heat_network_lt_heatpump.output (MW)', 4.0, 12.0]] + end + + before do + request.headers.merge!(headers) + allow(Export::DistrictHeatingParticipantCapacitiesCSVSerializer).to receive(:new).and_return( + instance_double( + Export::DistrictHeatingParticipantCapacitiesCSVSerializer, + filename: :district_heating_capacities, + to_csv_rows: rows + ) + ) + get :district_heating_capacities, params: { id: scenario.id }, format: :csv + end + + it 'is successful' do + expect(response).to be_ok + end + + it 'sets the CSV filename' do + expect(response.headers['Content-Disposition']) + .to include("district_heating_capacities.#{scenario.id}.csv") + end + end + describe 'GET direct_emissions_present.csv' do before do request.headers.merge!(headers) diff --git a/spec/services/curve_metadata_registry_spec.rb b/spec/services/curve_metadata_registry_spec.rb new file mode 100644 index 000000000..255b0ace2 --- /dev/null +++ b/spec/services/curve_metadata_registry_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe CurveMetadataRegistry do + # The registry is global mutable state populated at boot, so snapshot and + # restore it around examples that register or clear entries. + around do |example| + curves = described_class.instance_variable_get(:@curves)&.dup + exports = described_class.instance_variable_get(:@exports)&.dup + example.run + described_class.instance_variable_set(:@curves, curves) + described_class.instance_variable_set(:@exports, exports) + end + + describe '.register_curve' do + before { described_class.clear! } + + it 'stores the curve with a stringified name and _curve type' do + described_class.register_curve(:my_curve, type: :merit, description: 'desc') + + expect(described_class.all_curves).to eq( + [{ name: 'my_curve', type: 'merit_curve', description: 'desc' }] + ) + end + + it 'raises for an unknown type' do + expect do + described_class.register_curve(:bad, type: :nope, description: 'd') + end.to raise_error(ArgumentError, /Unknown curve type: nope/) + end + end + + describe '.register_export' do + before { described_class.clear! } + + it 'stores the export with a stringified name' do + described_class.register_export(:my_export, description: 'desc') + + expect(described_class.all_exports).to eq([{ name: 'my_export', description: 'desc' }]) + end + end + + describe '.clear!' do + it 'removes all registrations' do + described_class.register_curve(:c, type: :query, description: 'd') + described_class.register_export(:e, description: 'd') + described_class.clear! + + expect(described_class.all_curves).to be_empty + expect(described_class.all_exports).to be_empty + end + end + + # Guards against the registry advertising a curve that cannot be downloaded. + describe 'consistency with routes' do + let(:curve_route_names) do + Rails.application.routes.routes.filter_map do |route| + match = route.path.spec.to_s.match(%r{/curves/([a-z_]+)\(\.:format\)\z}) + match && match[1] + end + end + + it 'registers only curves that have a download route' do + registered = described_class.all_curves.map { |curve| curve[:name] } + missing = registered - curve_route_names + + expect(missing).to be_empty, "registered curves without a route: #{missing.join(', ')}" + end + + # Guards against the registry advertising an export that cannot be downloaded. + let(:export_route_names) do + Rails.application.routes.routes.filter_map do |route| + match = route.path.spec.to_s.match(%r{/scenarios/:id/([a-z_]+)\(\.:format\)\z}) + match && match[1] + end + end + + it 'registers only exports that have a download route' do + registered = described_class.all_exports.map { |export| export[:name] } + missing = registered - export_route_names + + expect(missing).to be_empty, "registered exports without a route: #{missing.join(', ')}" + end + end +end