From 815986122df28094863136e0f1af4a4a670ea21d Mon Sep 17 00:00:00 2001 From: aaccensi Date: Wed, 10 Jun 2026 15:49:13 +0200 Subject: [PATCH 1/4] Add hourly curve capacity exports for electricity, hydrogen, gas, and heat networks --- app/controllers/api/v3/curves_controller.rb | 27 ++++++ ...k_participant_capacities_csv_serializer.rb | 27 ++++++ .../merit_order_capacities_csv_serializer.rb | 8 ++ .../participant_capacities_csv_serializer.rb | 46 +++++++++ ...econciliation_capacities_csv_serializer.rb | 8 ++ ...ticipant_capacities_csv_serializer_spec.rb | 96 +++++++++++++++++++ 6 files changed, 212 insertions(+) create mode 100644 app/serializers/heat_network_participant_capacities_csv_serializer.rb create mode 100644 app/serializers/merit_order_capacities_csv_serializer.rb create mode 100644 app/serializers/participant_capacities_csv_serializer.rb create mode 100644 app/serializers/reconciliation_capacities_csv_serializer.rb create mode 100644 spec/serializers/participant_capacities_csv_serializer_spec.rb diff --git a/app/controllers/api/v3/curves_controller.rb b/app/controllers/api/v3/curves_controller.rb index 7305f2838..60d3dba5e 100644 --- a/app/controllers/api/v3/curves_controller.rb +++ b/app/controllers/api/v3/curves_controller.rb @@ -119,6 +119,33 @@ def hydrogen_integral_cost ) end + def merit_order_capacities + render_csv MeritOrderCapacitiesCSVSerializer.new( + @scenario.gql.future_graph, :electricity, :merit_order, + MeritCSVSerializer::NodeCustomisation.new( + 'merit_order_csv_include', 'merit_order_csv_exclude' + ) + ) + end + + def hydrogen_capacities + render_csv ReconciliationCapacitiesCSVSerializer.new( + @scenario.gql.future_graph, :hydrogen + ) + end + + def network_gas_capacities + render_csv ReconciliationCapacitiesCSVSerializer.new( + @scenario.gql.future_graph, :network_gas + ) + end + + def heat_network_capacities + render_csv HeatNetworkParticipantCapacitiesCSVSerializer.new( + @scenario.gql.future_graph + ) + end + private def merit_required diff --git a/app/serializers/heat_network_participant_capacities_csv_serializer.rb b/app/serializers/heat_network_participant_capacities_csv_serializer.rb new file mode 100644 index 000000000..8952e4a1b --- /dev/null +++ b/app/serializers/heat_network_participant_capacities_csv_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Creates a capacity CSV for all three heat network carriers (lt, mt, ht) +# combined into a single file, matching the structure of HeatNetworkCSVSerializer. +class HeatNetworkParticipantCapacitiesCSVSerializer + attr_reader :filename + + def initialize(graph) + @filename = :heat_network_capacities + @graph = graph + end + + def to_csv_rows + unless Qernel::Plugins::Causality.enabled?(@graph) + return [['Merit order and time-resolved calculation are not enabled for this scenario']] + end + + header = %w[key installed_capacity peak_capacity] + data_rows = %i[lt mt ht].flat_map do |network| + MeritOrderCapacitiesCSVSerializer.new( + @graph, :steam_hot_water, :"heat_network_#{network}", prefix: network + ).to_csv_rows[1..] + end + + [header, *data_rows] + end +end diff --git a/app/serializers/merit_order_capacities_csv_serializer.rb b/app/serializers/merit_order_capacities_csv_serializer.rb new file mode 100644 index 000000000..cbf24f2aa --- /dev/null +++ b/app/serializers/merit_order_capacities_csv_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Capacity CSV for electricity merit order participants. +# Reuses MeritCSVSerializer filtering (producer/consumer types, +# curtailment exclusion, NodeCustomisation). +class MeritOrderCapacitiesCSVSerializer < MeritCSVSerializer + include ParticipantCapacitiesCSVSerializer +end diff --git a/app/serializers/participant_capacities_csv_serializer.rb b/app/serializers/participant_capacities_csv_serializer.rb new file mode 100644 index 000000000..9ecd15be3 --- /dev/null +++ b/app/serializers/participant_capacities_csv_serializer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +# Provides to_csv_rows and filename for capacity exports. When included in a +# CausalityCurvesCSVSerializer subclass, produces a CSV with one row per +# participant (producer or consumer): key, installed_capacity, peak_capacity. +# +# The key matches the column header used in the corresponding hourly curve +# export (e.g. "agriculture_chp_engine_biogas.output (MW)"). +module ParticipantCapacitiesCSVSerializer + def filename + :"#{@adapter.attribute}_capacities" + end + + def to_csv_rows + unless @adapter.supported?(@graph) + return [['Merit order and time-resolved calculation are not enabled for this scenario']] + end + + header = %w[key installed_capacity peak_capacity] + [header, *producer_rows, *consumer_rows] + end + + private + + def producer_rows + producers.map { |node| row_for(node, :output) } + end + + def consumer_rows + consumers.map { |node| row_for(node, :input) } + end + + def row_for(node, direction) + installed = node.node_api.public_send("#{@adapter.carrier}_#{direction}_conversion") * + node.node_api.input_capacity * + node.node_api.number_of_units + + peak = @adapter.node_curve(node, direction)&.max || 0.0 + + [ + "#{@prefix}#{node.key}.#{direction} (MW)", + installed.round(4), + peak.round(4) + ] + end +end diff --git a/app/serializers/reconciliation_capacities_csv_serializer.rb b/app/serializers/reconciliation_capacities_csv_serializer.rb new file mode 100644 index 000000000..82ed19f6a --- /dev/null +++ b/app/serializers/reconciliation_capacities_csv_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Capacity CSV for hydrogen and network gas participants. +# Reuses ReconciliationCSVSerializer filtering (broader producer/consumer +# types including storage, import, export, etc). +class ReconciliationCapacitiesCSVSerializer < ReconciliationCSVSerializer + include ParticipantCapacitiesCSVSerializer +end diff --git a/spec/serializers/participant_capacities_csv_serializer_spec.rb b/spec/serializers/participant_capacities_csv_serializer_spec.rb new file mode 100644 index 000000000..d03fbb098 --- /dev/null +++ b/spec/serializers/participant_capacities_csv_serializer_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ParticipantCapacitiesCSVSerializer do + # MeritOrderCapacitiesCSVSerializer is used as the concrete vehicle to exercise + # the module's logic; ReconciliationCapacitiesCSVSerializer would work equally. + let(:serializer_class) { MeritOrderCapacitiesCSVSerializer } + let(:graph) { instance_double(Qernel::Graph) } + + let(:producer_api) do + instance_double(Qernel::NodeApi::Base, input_capacity: 2.0, number_of_units: 10.0) + end + let(:producer_node) do + instance_double(Qernel::Node, key: 'wind_turbine', node_api: producer_api) + end + + let(:consumer_api) do + instance_double(Qernel::NodeApi::Base, input_capacity: 1.0, number_of_units: 5.0) + end + let(:consumer_node) do + instance_double(Qernel::Node, key: 'electrolyser', node_api: consumer_api) + end + + let(:producer_curve) { [100.0, 200.0, 50.0] + ([0.0] * 8757) } + let(:consumer_curve) { [80.0, 150.0, 30.0] + ([0.0] * 8757) } + + let(:serializer) { serializer_class.new(graph, :electricity, :merit_order) } + + before do + allow(Qernel::Plugins::Causality).to receive(:enabled?).with(graph).and_return(true) + allow(producer_api).to receive(:public_send).with('electricity_output_conversion').and_return(0.4) + allow(consumer_api).to receive(:public_send).with('electricity_input_conversion').and_return(0.9) + allow(serializer).to receive(:producers).and_return([producer_node]) + allow(serializer).to receive(:consumers).and_return([consumer_node]) + allow(serializer.instance_variable_get(:@adapter)) + .to receive(:node_curve).with(producer_node, :output).and_return(producer_curve) + allow(serializer.instance_variable_get(:@adapter)) + .to receive(:node_curve).with(consumer_node, :input).and_return(consumer_curve) + end + + describe '#filename' do + it 'appends _capacities to the attribute name' do + expect(serializer.filename).to eq(:merit_order_capacities) + end + end + + describe '#to_csv_rows' do + subject(:rows) { serializer.to_csv_rows } + + it 'includes the header row' do + expect(rows[0]).to eq(%w[key installed_capacity peak_capacity]) + end + + it 'includes the producer row' do + # 0.4 * 2.0 * 10.0 = 8.0, peak = 200.0 + expect(rows[1]).to eq(['wind_turbine.output (MW)', 8.0, 200.0]) + end + + it 'includes the consumer row' do + # 0.9 * 1.0 * 5.0 = 4.5, peak = 150.0 + expect(rows[2]).to eq(['electrolyser.input (MW)', 4.5, 150.0]) + end + + context 'when causality is not enabled' do + before { allow(Qernel::Plugins::Causality).to receive(:enabled?).with(graph).and_return(false) } + + it 'returns a single error row' do + expect(rows).to eq([['Merit order and time-resolved calculation are not enabled for this scenario']]) + end + end + + context 'when a node has zero units' do + let(:producer_api) do + instance_double(Qernel::NodeApi::Base, input_capacity: 2.0, number_of_units: 0.0) + end + + before { allow(producer_api).to receive(:public_send).with('electricity_output_conversion').and_return(0.4) } + + it 'reports 0.0 installed_capacity' do + expect(rows[1][1]).to eq(0.0) + end + end + + context 'when the curve is nil' do + before do + allow(serializer.instance_variable_get(:@adapter)) + .to receive(:node_curve).with(producer_node, :output).and_return(nil) + end + + it 'reports 0.0 peak_capacity' do + expect(rows[1][2]).to eq(0.0) + end + end + end +end From 18bad088f9795c761fb6f1f14d92ca4dac874eaf Mon Sep 17 00:00:00 2001 From: aaccensi Date: Mon, 15 Jun 2026 12:25:24 +0200 Subject: [PATCH 2/4] Rename curve exports to use consistent _profiles and _capacities suffixes --- app/controllers/api/v3/curves_controller.rb | 4 ++-- .../electricity_capacities_csv_serializer.rb | 8 ++++++++ app/serializers/electricity_csv_serializer.rb | 10 ++++++++++ ...at_network_participant_capacities_csv_serializer.rb | 2 +- app/serializers/merit_capacities_csv_serializer.rb | 8 ++++++++ .../merit_order_capacities_csv_serializer.rb | 8 -------- .../participant_capacities_csv_serializer_spec.rb | 4 ++-- 7 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 app/serializers/electricity_capacities_csv_serializer.rb create mode 100644 app/serializers/electricity_csv_serializer.rb create mode 100644 app/serializers/merit_capacities_csv_serializer.rb delete mode 100644 app/serializers/merit_order_capacities_csv_serializer.rb diff --git a/app/controllers/api/v3/curves_controller.rb b/app/controllers/api/v3/curves_controller.rb index 60d3dba5e..48fc63b25 100644 --- a/app/controllers/api/v3/curves_controller.rb +++ b/app/controllers/api/v3/curves_controller.rb @@ -119,8 +119,8 @@ def hydrogen_integral_cost ) end - def merit_order_capacities - render_csv MeritOrderCapacitiesCSVSerializer.new( + def electricity_capacities + render_csv ElectricityCapacitiesCSVSerializer.new( @scenario.gql.future_graph, :electricity, :merit_order, MeritCSVSerializer::NodeCustomisation.new( 'merit_order_csv_include', 'merit_order_csv_exclude' diff --git a/app/serializers/electricity_capacities_csv_serializer.rb b/app/serializers/electricity_capacities_csv_serializer.rb new file mode 100644 index 000000000..827cbbbfd --- /dev/null +++ b/app/serializers/electricity_capacities_csv_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Capacity CSV for electricity merit order participants. +class ElectricityCapacitiesCSVSerializer < MeritCapacitiesCSVSerializer + def filename + :electricity_capacities + end +end 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/serializers/heat_network_participant_capacities_csv_serializer.rb b/app/serializers/heat_network_participant_capacities_csv_serializer.rb index 8952e4a1b..ba9c6ebf8 100644 --- a/app/serializers/heat_network_participant_capacities_csv_serializer.rb +++ b/app/serializers/heat_network_participant_capacities_csv_serializer.rb @@ -17,7 +17,7 @@ def to_csv_rows header = %w[key installed_capacity peak_capacity] data_rows = %i[lt mt ht].flat_map do |network| - MeritOrderCapacitiesCSVSerializer.new( + MeritCapacitiesCSVSerializer.new( @graph, :steam_hot_water, :"heat_network_#{network}", prefix: network ).to_csv_rows[1..] end diff --git a/app/serializers/merit_capacities_csv_serializer.rb b/app/serializers/merit_capacities_csv_serializer.rb new file mode 100644 index 000000000..e76a16ce9 --- /dev/null +++ b/app/serializers/merit_capacities_csv_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Capacity CSV for merit order participants. Reuses MeritCSVSerializer +# filtering (producer/consumer types, curtailment exclusion, NodeCustomisation). +# Subclass and override +filename+ to produce a named capacity export. +class MeritCapacitiesCSVSerializer < MeritCSVSerializer + include ParticipantCapacitiesCSVSerializer +end diff --git a/app/serializers/merit_order_capacities_csv_serializer.rb b/app/serializers/merit_order_capacities_csv_serializer.rb deleted file mode 100644 index cbf24f2aa..000000000 --- a/app/serializers/merit_order_capacities_csv_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Capacity CSV for electricity merit order participants. -# Reuses MeritCSVSerializer filtering (producer/consumer types, -# curtailment exclusion, NodeCustomisation). -class MeritOrderCapacitiesCSVSerializer < MeritCSVSerializer - include ParticipantCapacitiesCSVSerializer -end diff --git a/spec/serializers/participant_capacities_csv_serializer_spec.rb b/spec/serializers/participant_capacities_csv_serializer_spec.rb index d03fbb098..d09e47e99 100644 --- a/spec/serializers/participant_capacities_csv_serializer_spec.rb +++ b/spec/serializers/participant_capacities_csv_serializer_spec.rb @@ -3,9 +3,9 @@ require 'spec_helper' RSpec.describe ParticipantCapacitiesCSVSerializer do - # MeritOrderCapacitiesCSVSerializer is used as the concrete vehicle to exercise + # MeritCapacitiesCSVSerializer is used as the concrete vehicle to exercise # the module's logic; ReconciliationCapacitiesCSVSerializer would work equally. - let(:serializer_class) { MeritOrderCapacitiesCSVSerializer } + let(:serializer_class) { MeritCapacitiesCSVSerializer } let(:graph) { instance_double(Qernel::Graph) } let(:producer_api) do From 3a8d6916398b7c3bad6831f75fe11e498788fa96 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Tue, 16 Jun 2026 10:35:41 +0200 Subject: [PATCH 3/4] Curve meta controller, registry and spec --- .../api/v3/curve_metadata_controller.rb | 64 +++++++++ app/controllers/api/v3/curves_controller.rb | 90 ++++++++---- app/controllers/api/v3/export_controller.rb | 53 +++++++ .../causality_curves_csv_serializer.rb | 10 +- .../curves/district_heating_csv_serializer.rb | 3 +- .../electricity_capacities_csv_serializer.rb | 8 -- ...k_participant_capacities_csv_serializer.rb | 27 ---- .../merit_capacities_csv_serializer.rb | 8 -- .../participant_capacities_csv_serializer.rb | 46 ------ ...econciliation_capacities_csv_serializer.rb | 8 -- app/services/curve_metadata_registry.rb | 98 +++++++++++++ .../initializers/curve_metadata_registry.rb | 12 ++ config/routes.rb | 12 +- .../api/v3/curve_metadata_controller_spec.rb | 131 ++++++++++++++++++ .../api/v3/exports_controller_spec.rb | 63 +++++++++ ...ticipant_capacities_csv_serializer_spec.rb | 96 ------------- spec/services/curve_metadata_registry_spec.rb | 86 ++++++++++++ 17 files changed, 585 insertions(+), 230 deletions(-) create mode 100644 app/controllers/api/v3/curve_metadata_controller.rb delete mode 100644 app/serializers/electricity_capacities_csv_serializer.rb delete mode 100644 app/serializers/heat_network_participant_capacities_csv_serializer.rb delete mode 100644 app/serializers/merit_capacities_csv_serializer.rb delete mode 100644 app/serializers/participant_capacities_csv_serializer.rb delete mode 100644 app/serializers/reconciliation_capacities_csv_serializer.rb create mode 100644 app/services/curve_metadata_registry.rb create mode 100644 config/initializers/curve_metadata_registry.rb create mode 100644 spec/controllers/api/v3/curve_metadata_controller_spec.rb delete mode 100644 spec/serializers/participant_capacities_csv_serializer_spec.rb create mode 100644 spec/services/curve_metadata_registry_spec.rb 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..b5dc15e14 --- /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; a request spec guards that these stay in sync. + 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 48fc63b25..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, @@ -119,33 +182,6 @@ def hydrogen_integral_cost ) end - def electricity_capacities - render_csv ElectricityCapacitiesCSVSerializer.new( - @scenario.gql.future_graph, :electricity, :merit_order, - MeritCSVSerializer::NodeCustomisation.new( - 'merit_order_csv_include', 'merit_order_csv_exclude' - ) - ) - end - - def hydrogen_capacities - render_csv ReconciliationCapacitiesCSVSerializer.new( - @scenario.gql.future_graph, :hydrogen - ) - end - - def network_gas_capacities - render_csv ReconciliationCapacitiesCSVSerializer.new( - @scenario.gql.future_graph, :network_gas - ) - end - - def heat_network_capacities - render_csv HeatNetworkParticipantCapacitiesCSVSerializer.new( - @scenario.gql.future_graph - ) - end - private def merit_required 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_capacities_csv_serializer.rb b/app/serializers/electricity_capacities_csv_serializer.rb deleted file mode 100644 index 827cbbbfd..000000000 --- a/app/serializers/electricity_capacities_csv_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Capacity CSV for electricity merit order participants. -class ElectricityCapacitiesCSVSerializer < MeritCapacitiesCSVSerializer - def filename - :electricity_capacities - end -end diff --git a/app/serializers/heat_network_participant_capacities_csv_serializer.rb b/app/serializers/heat_network_participant_capacities_csv_serializer.rb deleted file mode 100644 index ba9c6ebf8..000000000 --- a/app/serializers/heat_network_participant_capacities_csv_serializer.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# Creates a capacity CSV for all three heat network carriers (lt, mt, ht) -# combined into a single file, matching the structure of HeatNetworkCSVSerializer. -class HeatNetworkParticipantCapacitiesCSVSerializer - attr_reader :filename - - def initialize(graph) - @filename = :heat_network_capacities - @graph = graph - end - - def to_csv_rows - unless Qernel::Plugins::Causality.enabled?(@graph) - return [['Merit order and time-resolved calculation are not enabled for this scenario']] - end - - header = %w[key installed_capacity peak_capacity] - data_rows = %i[lt mt ht].flat_map do |network| - MeritCapacitiesCSVSerializer.new( - @graph, :steam_hot_water, :"heat_network_#{network}", prefix: network - ).to_csv_rows[1..] - end - - [header, *data_rows] - end -end diff --git a/app/serializers/merit_capacities_csv_serializer.rb b/app/serializers/merit_capacities_csv_serializer.rb deleted file mode 100644 index e76a16ce9..000000000 --- a/app/serializers/merit_capacities_csv_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Capacity CSV for merit order participants. Reuses MeritCSVSerializer -# filtering (producer/consumer types, curtailment exclusion, NodeCustomisation). -# Subclass and override +filename+ to produce a named capacity export. -class MeritCapacitiesCSVSerializer < MeritCSVSerializer - include ParticipantCapacitiesCSVSerializer -end diff --git a/app/serializers/participant_capacities_csv_serializer.rb b/app/serializers/participant_capacities_csv_serializer.rb deleted file mode 100644 index 9ecd15be3..000000000 --- a/app/serializers/participant_capacities_csv_serializer.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -# Provides to_csv_rows and filename for capacity exports. When included in a -# CausalityCurvesCSVSerializer subclass, produces a CSV with one row per -# participant (producer or consumer): key, installed_capacity, peak_capacity. -# -# The key matches the column header used in the corresponding hourly curve -# export (e.g. "agriculture_chp_engine_biogas.output (MW)"). -module ParticipantCapacitiesCSVSerializer - def filename - :"#{@adapter.attribute}_capacities" - end - - def to_csv_rows - unless @adapter.supported?(@graph) - return [['Merit order and time-resolved calculation are not enabled for this scenario']] - end - - header = %w[key installed_capacity peak_capacity] - [header, *producer_rows, *consumer_rows] - end - - private - - def producer_rows - producers.map { |node| row_for(node, :output) } - end - - def consumer_rows - consumers.map { |node| row_for(node, :input) } - end - - def row_for(node, direction) - installed = node.node_api.public_send("#{@adapter.carrier}_#{direction}_conversion") * - node.node_api.input_capacity * - node.node_api.number_of_units - - peak = @adapter.node_curve(node, direction)&.max || 0.0 - - [ - "#{@prefix}#{node.key}.#{direction} (MW)", - installed.round(4), - peak.round(4) - ] - end -end diff --git a/app/serializers/reconciliation_capacities_csv_serializer.rb b/app/serializers/reconciliation_capacities_csv_serializer.rb deleted file mode 100644 index 82ed19f6a..000000000 --- a/app/serializers/reconciliation_capacities_csv_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -# Capacity CSV for hydrogen and network gas participants. -# Reuses ReconciliationCSVSerializer filtering (broader producer/consumer -# types including storage, import, export, etc). -class ReconciliationCapacitiesCSVSerializer < ReconciliationCSVSerializer - include ParticipantCapacitiesCSVSerializer -end diff --git a/app/services/curve_metadata_registry.rb b/app/services/curve_metadata_registry.rb new file mode 100644 index 000000000..217cf70eb --- /dev/null +++ b/app/services/curve_metadata_registry.rb @@ -0,0 +1,98 @@ +# 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. +# +# A curve's name still appears in several places (route, controller action, +# serializer filename and the registration below) which are not auto-derived from +# one another, so a request spec asserts every registered curve has a matching +# download route to catch drift. +module CurveMetadataRegistry + # Curve type symbols, stored as '_curve' strings. Extend when adding a + # new curve type. + 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 + # + # @example + # CurveMetadataRegistry.register_curve( + # :electricity_profiles, + # type: :merit, + # description: 'Load on each participant in the electricity merit order' + # ) + 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 + # + # @example + # CurveMetadataRegistry.register_export( + # :energy_flow, + # description: 'Energy flows by carrier (future year)' + # ) + 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 (useful for testing) + 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..156b3b959 --- /dev/null +++ b/config/initializers/curve_metadata_registry.rb @@ -0,0 +1,12 @@ +# 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 + # Start from a clean slate so reloaded controllers (in development) don't leave + # stale registrations behind, then force-load them to repopulate the registry. + 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..bcbd1ca49 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,10 @@ resources :areas, only: %i[index show] resources :gqueries, only: :index + # Metadata endpoints for available curves and exports + 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 @@ -27,10 +31,10 @@ 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 :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/serializers/participant_capacities_csv_serializer_spec.rb b/spec/serializers/participant_capacities_csv_serializer_spec.rb deleted file mode 100644 index d09e47e99..000000000 --- a/spec/serializers/participant_capacities_csv_serializer_spec.rb +++ /dev/null @@ -1,96 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ParticipantCapacitiesCSVSerializer do - # MeritCapacitiesCSVSerializer is used as the concrete vehicle to exercise - # the module's logic; ReconciliationCapacitiesCSVSerializer would work equally. - let(:serializer_class) { MeritCapacitiesCSVSerializer } - let(:graph) { instance_double(Qernel::Graph) } - - let(:producer_api) do - instance_double(Qernel::NodeApi::Base, input_capacity: 2.0, number_of_units: 10.0) - end - let(:producer_node) do - instance_double(Qernel::Node, key: 'wind_turbine', node_api: producer_api) - end - - let(:consumer_api) do - instance_double(Qernel::NodeApi::Base, input_capacity: 1.0, number_of_units: 5.0) - end - let(:consumer_node) do - instance_double(Qernel::Node, key: 'electrolyser', node_api: consumer_api) - end - - let(:producer_curve) { [100.0, 200.0, 50.0] + ([0.0] * 8757) } - let(:consumer_curve) { [80.0, 150.0, 30.0] + ([0.0] * 8757) } - - let(:serializer) { serializer_class.new(graph, :electricity, :merit_order) } - - before do - allow(Qernel::Plugins::Causality).to receive(:enabled?).with(graph).and_return(true) - allow(producer_api).to receive(:public_send).with('electricity_output_conversion').and_return(0.4) - allow(consumer_api).to receive(:public_send).with('electricity_input_conversion').and_return(0.9) - allow(serializer).to receive(:producers).and_return([producer_node]) - allow(serializer).to receive(:consumers).and_return([consumer_node]) - allow(serializer.instance_variable_get(:@adapter)) - .to receive(:node_curve).with(producer_node, :output).and_return(producer_curve) - allow(serializer.instance_variable_get(:@adapter)) - .to receive(:node_curve).with(consumer_node, :input).and_return(consumer_curve) - end - - describe '#filename' do - it 'appends _capacities to the attribute name' do - expect(serializer.filename).to eq(:merit_order_capacities) - end - end - - describe '#to_csv_rows' do - subject(:rows) { serializer.to_csv_rows } - - it 'includes the header row' do - expect(rows[0]).to eq(%w[key installed_capacity peak_capacity]) - end - - it 'includes the producer row' do - # 0.4 * 2.0 * 10.0 = 8.0, peak = 200.0 - expect(rows[1]).to eq(['wind_turbine.output (MW)', 8.0, 200.0]) - end - - it 'includes the consumer row' do - # 0.9 * 1.0 * 5.0 = 4.5, peak = 150.0 - expect(rows[2]).to eq(['electrolyser.input (MW)', 4.5, 150.0]) - end - - context 'when causality is not enabled' do - before { allow(Qernel::Plugins::Causality).to receive(:enabled?).with(graph).and_return(false) } - - it 'returns a single error row' do - expect(rows).to eq([['Merit order and time-resolved calculation are not enabled for this scenario']]) - end - end - - context 'when a node has zero units' do - let(:producer_api) do - instance_double(Qernel::NodeApi::Base, input_capacity: 2.0, number_of_units: 0.0) - end - - before { allow(producer_api).to receive(:public_send).with('electricity_output_conversion').and_return(0.4) } - - it 'reports 0.0 installed_capacity' do - expect(rows[1][1]).to eq(0.0) - end - end - - context 'when the curve is nil' do - before do - allow(serializer.instance_variable_get(:@adapter)) - .to receive(:node_curve).with(producer_node, :output).and_return(nil) - end - - it 'reports 0.0 peak_capacity' do - expect(rows[1][2]).to eq(0.0) - end - end - end -end 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 From 30577d1d9ebd6cced6d9f8e9f09d2d5478e7a467 Mon Sep 17 00:00:00 2001 From: louispt1 Date: Mon, 29 Jun 2026 08:34:17 +0200 Subject: [PATCH 4/4] Refinements --- .../api/v3/curve_metadata_controller.rb | 2 +- app/services/curve_metadata_registry.rb | 22 +------------------ .../initializers/curve_metadata_registry.rb | 2 -- config/routes.rb | 17 +++++++------- 4 files changed, 10 insertions(+), 33 deletions(-) diff --git a/app/controllers/api/v3/curve_metadata_controller.rb b/app/controllers/api/v3/curve_metadata_controller.rb index b5dc15e14..480c97674 100644 --- a/app/controllers/api/v3/curve_metadata_controller.rb +++ b/app/controllers/api/v3/curve_metadata_controller.rb @@ -14,7 +14,7 @@ module V3 # - 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; a request spec guards that these stay in sync. + # registration; class CurveMetadataController < BaseController # GET /api/v3/curves/metadata # diff --git a/app/services/curve_metadata_registry.rb b/app/services/curve_metadata_registry.rb index 217cf70eb..fee7c7ac9 100644 --- a/app/services/curve_metadata_registry.rb +++ b/app/services/curve_metadata_registry.rb @@ -5,14 +5,7 @@ # 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. -# -# A curve's name still appears in several places (route, controller action, -# serializer filename and the registration below) which are not auto-derived from -# one another, so a request spec asserts every registered curve has a matching -# download route to catch drift. module CurveMetadataRegistry - # Curve type symbols, stored as '_curve' strings. Extend when adding a - # new curve type. CURVE_TYPES = %i[merit price capacity load fever reconciliation query].freeze class << self @@ -22,13 +15,6 @@ class << self # @param type [Symbol] The curve type (:merit, :price, :capacity, :load, :fever, # :reconciliation, :query) # @param description [String] Human-readable description of what the curve contains - # - # @example - # CurveMetadataRegistry.register_curve( - # :electricity_profiles, - # type: :merit, - # description: 'Load on each participant in the electricity merit order' - # ) def register_curve(name, type:, description:) curves[name] = { name: name.to_s, @@ -41,12 +27,6 @@ def register_curve(name, type:, description:) # # @param name [Symbol] The export name (must match controller method and route) # @param description [String] Human-readable description of what the export contains - # - # @example - # CurveMetadataRegistry.register_export( - # :energy_flow, - # description: 'Energy flows by carrier (future year)' - # ) def register_export(name, description:) exports[name] = { name: name.to_s, @@ -68,7 +48,7 @@ def all_exports exports.values end - # Clears all registrations (useful for testing) + # Clears all registrations def clear! curves.clear exports.clear diff --git a/config/initializers/curve_metadata_registry.rb b/config/initializers/curve_metadata_registry.rb index 156b3b959..e55247f57 100644 --- a/config/initializers/curve_metadata_registry.rb +++ b/config/initializers/curve_metadata_registry.rb @@ -4,8 +4,6 @@ # This initializer guarantees that metadata registrations happen at application startup Rails.application.config.to_prepare do - # Start from a clean slate so reloaded controllers (in development) don't leave - # stale registrations behind, then force-load them to repopulate the registry. CurveMetadataRegistry.clear! require_dependency 'api/v3/curves_controller' require_dependency 'api/v3/export_controller' diff --git a/config/routes.rb b/config/routes.rb index bcbd1ca49..a28a24d8e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,21 +16,20 @@ resources :areas, only: %i[index show] resources :gqueries, only: :index - # Metadata endpoints for available curves and exports 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 :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