From 2b6ec3df31516e0a2cb87a04422ba187792e1688 Mon Sep 17 00:00:00 2001 From: Cameron Wade Date: Mon, 19 Oct 2026 10:56:11 -0300 Subject: [PATCH] Preserve unit commitment output across myopic windows --- temoa/_internal/table_writer.py | 8 +++-- temoa/extensions/myopic/myopic_sequencer.py | 6 ++++ .../unit_commitment/core/data_puller.py | 19 ++++++---- tests/test_full_runs.py | 35 +++++++++++++++++++ tests/testing_data/uc_myopic.sql | 31 ++++++++++++++++ 5 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 tests/testing_data/uc_myopic.sql diff --git a/temoa/_internal/table_writer.py b/temoa/_internal/table_writer.py index 240ed43b..8e209c04 100644 --- a/temoa/_internal/table_writer.py +++ b/temoa/_internal/table_writer.py @@ -63,6 +63,7 @@ 'output_flow_out_summary', 'output_mc_delta', 'output_storage_level', + 'output_unit_commitment', ] OUTPUT_THRESHOLD_DEFAULTS = { @@ -292,7 +293,7 @@ def write_results( self._set_tech_sectors() self.write_objective(model, iteration=iteration) - self.write_capacity_tables(model, iteration=iteration) + self.write_capacity_tables(model, iteration=iteration, append=append) # Poll and Write Emissions if self.config.scenario_mode == TemoaMode.MYOPIC: @@ -479,7 +480,9 @@ def write_emissions(self, iteration: int | None = None) -> None: self._bulk_insert('output_emission', records) self.connection.commit() - def write_capacity_tables(self, model: TemoaModel, iteration: int | None = None) -> None: + def write_capacity_tables( + self, model: TemoaModel, iteration: int | None = None, append: bool = False + ) -> None: if not self.tech_sectors: self._set_tech_sectors() cap_data = poll_capacity_results(model=model, epsilon=self.output_threshold_capacity) @@ -493,6 +496,7 @@ def write_capacity_tables(self, model: TemoaModel, iteration: int | None = None) writer=self, iteration=iteration, epsilon=self.output_threshold_capacity, + append=append, ) def _insert_capacity_results(self, cap_data: CapData, iteration: int | None) -> None: diff --git a/temoa/extensions/myopic/myopic_sequencer.py b/temoa/extensions/myopic/myopic_sequencer.py index 8d01c0c0..fa5c9c34 100644 --- a/temoa/extensions/myopic/myopic_sequencer.py +++ b/temoa/extensions/myopic/myopic_sequencer.py @@ -47,6 +47,7 @@ class MyopicSequencer: 'output_objective', 'output_retired_capacity', 'output_storage_level', + 'output_unit_commitment', ] tables_without_scenario_reference = [ 'myopic_efficiency', @@ -64,6 +65,7 @@ class MyopicSequencer: 'output_net_capacity', 'output_retired_capacity', 'output_storage_level', + 'output_unit_commitment', ] capacity_epsilon: float @@ -651,6 +653,8 @@ def clear_old_results(self) -> None: (scenario_name, f'{scenario_name}-%'), ) except sqlite3.OperationalError as e: + if table == 'output_unit_commitment' and str(e) == f'no such table: {table}': + continue sys.stderr.write(f'Could not clear scenario from table {table}.\n') raise sqlite3.OperationalError from e for table in self.tables_without_scenario_reference: @@ -701,6 +705,8 @@ def clear_results_after(self, period: int) -> None: (period, scenario_name), ) except sqlite3.OperationalError as e: + if table == 'output_unit_commitment' and str(e) == f'no such table: {table}': + continue sys.stderr.write(f'Failed to clear periods from table {table}.\n') raise sqlite3.OperationalError from e diff --git a/temoa/extensions/unit_commitment/core/data_puller.py b/temoa/extensions/unit_commitment/core/data_puller.py index 4c921273..4f89fcf6 100644 --- a/temoa/extensions/unit_commitment/core/data_puller.py +++ b/temoa/extensions/unit_commitment/core/data_puller.py @@ -52,7 +52,11 @@ def poll_commitment_results(model: TemoaModel) -> dict[UCI, tuple[float, float, def write_uc_results( - model: TemoaModel, writer: TableWriter, iteration: int | None, epsilon: float = 1e-5 + model: TemoaModel, + writer: TableWriter, + iteration: int | None, + epsilon: float = 1e-5, + append: bool = False, ) -> None: if writer.tech_sectors is None: raise RuntimeError('Missing tech_sectors') @@ -80,12 +84,13 @@ def write_uc_results( 'units': unit_prop.get_capacity_units(uci.t) if unit_prop else None, } records.append(row) - try: - writer.connection.execute( - 'DELETE FROM output_unit_commitment WHERE scenario == ?', (scenario,) - ) - except sqlite3.OperationalError: - pass + if not append: + try: + writer.connection.execute( + 'DELETE FROM output_unit_commitment WHERE scenario == ?', (scenario,) + ) + except sqlite3.OperationalError: + pass writer._bulk_insert('output_unit_commitment', records) writer.connection.commit() diff --git a/tests/test_full_runs.py b/tests/test_full_runs.py index 05d4f586..8eccef0d 100644 --- a/tests/test_full_runs.py +++ b/tests/test_full_runs.py @@ -17,6 +17,7 @@ from temoa._internal.temoa_sequencer import TemoaSequencer from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel +from tests.conftest import TEST_DATA_PATH, _build_test_db from tests.legacy_test_values import ExpectedVals, test_vals if TYPE_CHECKING: @@ -176,6 +177,40 @@ def test_myopic_utopia( ) +def test_myopic_unit_commitment_outputs(tmp_path: Path) -> None: + database = tmp_path / 'uc_myopic.sqlite' + _build_test_db(database, [TEST_DATA_PATH / 'uc_myopic.sql']) + config = TemoaConfig( + scenario='uc_myopic', + scenario_mode='myopic', + input_database=database, + output_database=database, + output_path=tmp_path, + solver='gurobi_direct', + extensions=['unit_commitment'], + time_sequencing='seasonal_timeslices', + myopic={'view_depth': 2, 'step_size': 1, 'evolving': False}, + silent=True, + ) + + # The windows overlap in 2030. Repeating the run must also replace old output. + for _ in range(2): + TemoaSequencer(config=config).start() + with contextlib.closing(sqlite3.connect(database)) as con: + rows = con.execute( + 'SELECT period, COUNT(*), SUM(online_cap), SUM(start_cap), SUM(stop_cap) ' + 'FROM output_unit_commitment WHERE scenario = ? GROUP BY period ORDER BY period', + (config.scenario,), + ).fetchall() + assert [row[0] for row in rows] == [2020, 2030, 2040] + for _, count, online, started, stopped in rows: + assert count == 2 + # One 1 MW unit supplies both half-year slices at full output. + assert online == pytest.approx(2.0, rel=1e-5) + assert started == pytest.approx(0.0, abs=1e-5) + assert stopped == pytest.approx(0.0, abs=1e-5) + + @pytest.mark.parametrize( 'system_test_run', argvalues=myopic_stress_tests, diff --git a/tests/testing_data/uc_myopic.sql b/tests/testing_data/uc_myopic.sql new file mode 100644 index 00000000..f78048a1 --- /dev/null +++ b/tests/testing_data/uc_myopic.sql @@ -0,0 +1,31 @@ +REPLACE INTO metadata VALUES('DB_MAJOR',4,'DB major version number'); +REPLACE INTO metadata VALUES('DB_MINOR',0,'DB minor version number'); +REPLACE INTO metadata_real VALUES('global_discount_rate',0.05,NULL); +REPLACE INTO metadata_real VALUES('default_loan_rate',0.05,NULL); +REPLACE INTO region VALUES('region',NULL); +REPLACE INTO sector_label VALUES('energy',NULL); +REPLACE INTO commodity_type VALUES('s','source commodity'); +REPLACE INTO commodity_type VALUES('d','demand commodity'); +REPLACE INTO commodity VALUES('source','s',NULL,NULL); +REPLACE INTO commodity VALUES('demand','d',NULL,NULL); +REPLACE INTO technology_type VALUES('p','production technology'); +REPLACE INTO technology(tech,flag,sector) VALUES('generator','p','energy'); +REPLACE INTO time_period_type VALUES('e','existing vintages'); +REPLACE INTO time_period_type VALUES('f','future'); +REPLACE INTO time_period VALUES(0,2020,'f'); +REPLACE INTO time_period VALUES(1,2030,'f'); +REPLACE INTO time_period VALUES(2,2040,'f'); +REPLACE INTO time_period VALUES(3,2050,'f'); +REPLACE INTO time_season VALUES(0,'year',1.0,NULL); +REPLACE INTO time_of_day VALUES(0,'day',12.0,NULL); +REPLACE INTO time_of_day VALUES(1,'night',12.0,NULL); +REPLACE INTO efficiency VALUES('region','source','generator',2020,'demand',1.0,NULL,NULL); +REPLACE INTO capacity_to_activity VALUES('region','generator',8760.0,NULL,NULL); +REPLACE INTO lifetime_tech VALUES('region','generator',40.0,NULL,NULL); +REPLACE INTO cost_invest VALUES('region','generator',2020,1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2020,'demand',8760.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2030,'demand',8760.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2040,'demand',8760.0,NULL,NULL); +REPLACE INTO unit_commitment(region,tech,unit_capacity,min_output_fraction) +VALUES('region','generator',1.0,1.0); +REPLACE INTO unit_commitment_startup_cost VALUES('region','generator',1.0,NULL,NULL);