-
Notifications
You must be signed in to change notification settings - Fork 0
feat(scenario aggregation): introduce new feature scenario aggregation #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
de1f722
4dc63ca
9d3d748
76b5251
1162e86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # Copyright (c) 2026, RTE (https://www.rte-france.com) | ||
| # | ||
| # See AUTHORS.txt | ||
| # | ||
| # This Source Code Form is subject to the terms of the Mozilla Public | ||
| # License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| # file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||
| # | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # | ||
| # This file is part of the Antares project. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # Copyright (c) 2026, RTE (https://www.rte-france.com) | ||
| # | ||
| # See AUTHORS.txt | ||
| # | ||
| # This Source Code Form is subject to the terms of the Mozilla Public | ||
| # License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| # file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||
| # | ||
| # SPDX-License-Identifier: MPL-2.0 | ||
| # | ||
| # This file is part of the Antares project. | ||
|
|
||
| import logging | ||
| import os | ||
| import tempfile | ||
| from dataclasses import dataclass | ||
| from enum import Enum | ||
|
|
||
| import polars as pl | ||
|
|
||
| from gems_views_builder.common import PARQUET_COMPRESSION, PARQUET_COMPRESSION_LEVEL, PARQUET_ROW_GROUP_SIZE | ||
| from gems_views_builder.metric_view import MetricView | ||
|
|
||
|
|
||
| class ScenarioOperator(Enum): | ||
| EXP = "exp" | ||
| STD = "std" | ||
| MIN = "min" | ||
| MAX = "max" | ||
|
|
||
|
|
||
| # expectation (exp) == mean | ||
| SCENARIO_AGG_EXPRS = [ | ||
| pl.col("metric_value").mean().alias(ScenarioOperator.EXP.value), | ||
| pl.col("metric_value").std(ddof=0).alias(ScenarioOperator.STD.value), | ||
| pl.col("metric_value").min().alias(ScenarioOperator.MIN.value), | ||
| pl.col("metric_value").max().alias(ScenarioOperator.MAX.value), | ||
| ] | ||
|
|
||
|
|
||
| @dataclass | ||
| class ScenarioAggregator: | ||
| scenario_aggregation: bool | ||
|
|
||
| def run(self, temporal_metric_view: MetricView) -> None: | ||
| file_descriptor, tmp_path = tempfile.mkstemp(suffix=".parquet") | ||
| os.close(file_descriptor) | ||
|
|
||
| if not self.scenario_aggregation: | ||
| logging.info("Scenario aggregation disabled; preserving per-scenario rows") | ||
| ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess we could remove the parenthesis containing the scenario transformations |
||
| pl.scan_parquet(temporal_metric_view.persistence_path) | ||
| .with_columns( | ||
| [ | ||
| pl.lit(False, dtype=pl.Boolean).alias("scenario_aggregation"), | ||
| pl.lit(None, dtype=pl.Utf8).alias("scenario_stat"), | ||
| ] | ||
| ) | ||
| .sink_parquet( | ||
| tmp_path, | ||
| compression=PARQUET_COMPRESSION, | ||
| compression_level=PARQUET_COMPRESSION_LEVEL, | ||
| row_group_size=PARQUET_ROW_GROUP_SIZE, | ||
| ) | ||
| ) | ||
| else: | ||
| logging.info("Aggregating across scenarios (exp/std/min/max)") | ||
| index_columns = ["metric_id", "metric_location", "breakdown_properties", "view_date"] | ||
| ( | ||
| pl.scan_parquet(temporal_metric_view.persistence_path) | ||
| .group_by(index_columns) | ||
| .agg(SCENARIO_AGG_EXPRS) | ||
| .unpivot( | ||
| on=[op.value for op in ScenarioOperator], | ||
| index=index_columns, | ||
| variable_name="scenario_stat", | ||
| value_name="metric_value", | ||
| ) | ||
| .with_columns( | ||
| [ | ||
| pl.lit(None, dtype=pl.Int64).alias("scenario_id"), | ||
| pl.lit(True, dtype=pl.Boolean).alias("scenario_aggregation"), | ||
| ] | ||
| ) | ||
| .sink_parquet( | ||
| tmp_path, | ||
| compression=PARQUET_COMPRESSION, | ||
| compression_level=PARQUET_COMPRESSION_LEVEL, | ||
| row_group_size=PARQUET_ROW_GROUP_SIZE, | ||
| ) | ||
| ) | ||
|
|
||
| os.replace(tmp_path, temporal_metric_view.persistence_path) | ||
| logging.info(f"Scenario aggregation written to {temporal_metric_view.persistence_path}") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,7 @@ class Scope(ViewBuilderBasedModel): | |
|
|
||
| class Aggregation(ViewBuilderBasedModel): | ||
| time: TimeAggregation | None = None | ||
| scenario: bool | None = None | ||
|
|
||
|
|
||
| class CatalogId(ViewBuilderBasedModel): | ||
|
|
@@ -53,7 +54,7 @@ class MetricId(ViewBuilderBasedModel): | |
| class RawViewConfig(ViewBuilderBasedModel): | ||
| id: str | ||
| scope: list[Scope] | ||
| aggregation: list[Aggregation] | ||
| aggregation: Aggregation | ||
| catalog: list[CatalogId] | ||
| metrics: list[MetricId] | ||
|
|
||
|
|
@@ -66,6 +67,7 @@ class ViewConfig: | |
| location_taxonomy_category: str | ||
| catalog_ids: set[str] = field(default_factory=set) | ||
| time_aggregation: TimeAggregation | None = None | ||
| scenario_aggregation: bool = False | ||
| metric_ids: list[str] = field(default_factory=list) | ||
| metrics: list[Metric] = field(default_factory=list) | ||
|
|
||
|
|
@@ -114,13 +116,17 @@ def load_view_config(config_file_path: Path) -> ViewConfig: | |
| raise ValueError( | ||
| f"view_config.yml '{raw_view_config.id}': no calendar configured in scope. One calendar must be configured in scope" | ||
| ) | ||
|
|
||
| view_config = ViewConfig( | ||
| id=raw_view_config.id, | ||
| input_data_path=input_data_path, | ||
| calendar_id=calendar_id, | ||
| location_taxonomy_category=location_taxonomy_category, | ||
| catalog_ids={c.id for c in raw_view_config.catalog}, | ||
| time_aggregation=raw_view_config.aggregation[0].time if raw_view_config.aggregation else None, | ||
| time_aggregation=raw_view_config.aggregation.time if raw_view_config.aggregation.time is not None else None, | ||
| scenario_aggregation=raw_view_config.aggregation.scenario | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. scenario_aggregation=bool(raw_view_config.aggregation.scenario)is shorter than the Same for |
||
| if raw_view_config.aggregation.scenario is not None | ||
| else False, | ||
| metric_ids=[metric.id for metric in raw_view_config.metrics], | ||
| ) | ||
| logging.info( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,11 +12,12 @@ | |
|
|
||
| """ViewBuilder.""" | ||
|
|
||
| from gems_views_builder.aggregators.scenario_aggregator import ScenarioAggregator | ||
| from gems_views_builder.aggregators.terms_aggregator import TermsAggregator | ||
| from gems_views_builder.aggregators.time_aggregator import TimeAggregator | ||
| from gems_views_builder.input.input_data import InputData | ||
| from gems_views_builder.metric_view import MetricView | ||
| from gems_views_builder.metrics_structure_builder import MetricStructureTableBuilder | ||
| from gems_views_builder.terms_aggregator import TermsAggregator | ||
| from gems_views_builder.time_aggregator import TimeAggregator | ||
|
|
||
|
|
||
| class ViewBuilder: | ||
|
|
@@ -30,12 +31,15 @@ def __init__(self, input_data: InputData, metric_structure_table_builder: Metric | |
| # # Aggregator for step 2C | ||
| self.time_aggregator = TimeAggregator(self.input_data.view_config.time_aggregation) | ||
|
|
||
| self.scenario_aggregator = ScenarioAggregator(self.input_data.view_config.scenario_aggregation) | ||
|
|
||
| def build(self) -> list[MetricView]: | ||
| metric_views: list[MetricView] = [] | ||
| for metric in self.input_data.view_config.metrics: | ||
| metric_structure_table = self.metric_structure_table_builder.build(metric) | ||
| metric_view = self.terms_aggregator.run(metric_structure_table, metric) | ||
| temporal_metric_view = self.time_aggregator.run(metric_view, metric) | ||
| self.scenario_aggregator.run(temporal_metric_view) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a specific reason why
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As you can see on We don't have any strict requirements for the If you look at the From my perspective, there's no need to create a new object with identical data. We can simply update the existing object instead.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a comment in the beginning of the function to make the behavior difference explicit : """Rewrites temporal_metric_view's parquet file in place (same path, new content) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a good remark : this step changes the input table whatever the value of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. self.scenario_aggregator.run(temporal_metric_view)What is a bit misleading is that this step doesn't necessarily aggregate the input table by scenario (although it says it does) : case where So I would suggest the following (in views_builder.py) : class ViewBuilder:
def __init__(self, input_data: InputData, ...) -> None:
...
self.scenario_op = makeScenarioOperator(self.input_data.view_config.scenario_aggregation)
def build(self) -> list[MetricView]:
metric_views: list[MetricView] = []
for metric in self.input_data.view_config.metrics:
...
temporal_metric_view = self.time_aggregator.run(metric_view, metric)
scenario_metric_view = to_scenario_view(temporal_metric_view, scenario_op)
metric_views.append(scenario_metric_view)
return metric_viewsNote that :
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question : More generally, can we avoid printing and scanning to/from disc multiple times ? |
||
| metric_views.append(temporal_metric_view) | ||
|
|
||
| return metric_views | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,8 @@ view: | |
| - calendar: calendar_file # calendar_file.csv | ||
|
|
||
| aggregation: | ||
| - time: hour | ||
| time: hour | ||
| scenario: false | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. scenario: falseDo we have to make this change ? Same question for every view-config.yml file in this PR |
||
|
|
||
| catalog: #Pointer to "catalogs" = "Business Metrics definitions files". | ||
| - id: catalog | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to this comment, this class would be turned into a function :
Note that :