From 56d586f56f2babc4567d4551e14a6bfa8e69fc4f Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 29 Jun 2026 16:00:36 -0400 Subject: [PATCH 01/23] Move schema components out of tutorial database for SSOT Signed-off-by: Davey Elder --- temoa/cli.py | 36 +- temoa/tutorial_assets/utopia.sql | 1925 +++++++----------------------- tests/test_cli.py | 2 +- tests/testing_data/utopia_v3.sql | 1558 ++++++++++++++++++++++++ 4 files changed, 2038 insertions(+), 1483 deletions(-) create mode 100644 tests/testing_data/utopia_v3.sql diff --git a/temoa/cli.py b/temoa/cli.py index 371b0d733..cbcc234c1 100644 --- a/temoa/cli.py +++ b/temoa/cli.py @@ -557,8 +557,8 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None """ Copy tutorial resource files directly to target locations. - The database is generated from the SQL source file to ensure it uses - the latest schema with unit-compliant data (single source of truth). + The database is generated by applying the canonical v4 schema first, + then loading tutorial data SQL. Args: target_config: Path where configuration file should be copied @@ -572,6 +572,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None config_resource = base / 'config_sample.toml' sql_resource = base / 'utopia.sql' mc_settings_resource = base / 'mc_settings.csv' + schema_resource = resources.files('temoa.db_schema') / 'temoa_schema_v4.sql' # Copy configuration file with config_resource.open('rb') as source: @@ -582,11 +583,20 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None if target_database.exists(): target_database.unlink() - # Generate database from SQL source (single source of truth) + # Generate database from canonical schema and tutorial data source + schema_content = schema_resource.read_text(encoding='utf-8') sql_content = sql_resource.read_text(encoding='utf-8') with sqlite3.connect(target_database) as conn: + conn.executescript(schema_content) + conn.execute('PRAGMA foreign_keys = OFF;') conn.executescript(sql_content) + conn.execute('PRAGMA foreign_keys = ON;') + fk_violations = conn.execute('PRAGMA foreign_key_check;').fetchall() + if fk_violations: + raise sqlite3.IntegrityError( + f'Foreign key check failed after tutorial data load: {fk_violations[:5]}' + ) # Copy Monte Carlo settings with mc_settings_resource.open('rb') as source: @@ -600,6 +610,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None fallback_config = Path(__file__).parent / 'tutorial_assets' / 'config_sample.toml' fallback_sql = Path(__file__).parent / 'tutorial_assets' / 'utopia.sql' fallback_mc = Path(__file__).parent / 'tutorial_assets' / 'mc_settings.csv' + fallback_schema = Path(__file__).parent / 'db_schema' / 'temoa_schema_v4.sql' if not fallback_config.exists(): raise FileNotFoundError( @@ -613,6 +624,12 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None f'SQL: {fallback_sql}' ) from e + if not fallback_schema.exists(): + raise FileNotFoundError( + f'Schema not found. Tried package resources and fallback path:\n' + f'Schema: {fallback_schema}' + ) from e + # Copy config file using fallback path shutil.copy2(fallback_config, target_config) @@ -620,10 +637,19 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None if target_database.exists(): target_database.unlink() - # Generate database from SQL source + # Generate database from canonical schema and tutorial data source + sql_content = fallback_sql.read_text(encoding='utf-8') with sqlite3.connect(target_database) as conn: - conn.executescript(fallback_sql.read_text(encoding='utf-8')) + conn.executescript(fallback_schema.read_text(encoding='utf-8')) + conn.execute('PRAGMA foreign_keys = OFF;') + conn.executescript(sql_content) + conn.execute('PRAGMA foreign_keys = ON;') + fk_violations = conn.execute('PRAGMA foreign_key_check;').fetchall() + if fk_violations: + raise sqlite3.IntegrityError( + f'Foreign key check failed after tutorial data load: {fk_violations[:5]}' + ) from e # Copy mc_settings from fallback shutil.copy2(fallback_mc, target_config.parent / 'mc_settings.csv') diff --git a/temoa/tutorial_assets/utopia.sql b/temoa/tutorial_assets/utopia.sql index a58fc9910..1814f34f9 100644 --- a/temoa/tutorial_assets/utopia.sql +++ b/temoa/tutorial_assets/utopia.sql @@ -1,1478 +1,449 @@ -BEGIN TRANSACTION; -CREATE TABLE capacity_credit -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER, - credit REAL, - notes TEXT, - PRIMARY KEY (region, period, tech, vintage), - CHECK (credit >= 0 AND credit <= 1) -); -CREATE TABLE capacity_factor_process -( - region TEXT, - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER, - factor REAL, - notes TEXT, - PRIMARY KEY (region, season, tod, tech, vintage), - CHECK (factor >= 0 AND factor <= 1) -); -INSERT INTO "capacity_factor_process" VALUES('utopia','inter','day','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','inter','night','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','winter','day','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','winter','night','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','summer','day','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','summer','night','E31',2000,0.2753,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','inter','day','E31',2010,0.2756,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','inter','night','E31',2010,0.2756,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','winter','day','E31',2010,0.2756,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','winter','night','E31',2010,0.2756,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','summer','day','E31',2010,0.2756,''); -INSERT INTO "capacity_factor_process" VALUES('utopia','summer','night','E31',2010,0.2756,''); -CREATE TABLE capacity_factor_tech -( - region TEXT, - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - tech TEXT - REFERENCES technology (tech), - factor REAL, - notes TEXT, - PRIMARY KEY (region, season, tod, tech), - CHECK (factor >= 0 AND factor <= 1) -); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','day','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','night','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','day','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','night','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','day','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','night','E01',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','day','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','night','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','day','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','night','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','day','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','night','E21',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','day','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','night','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','day','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','night','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','day','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','night','E31',0.275,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','day','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','night','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','day','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','night','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','day','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','night','E51',0.17,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','day','E70',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','inter','night','E70',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','day','E70',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','winter','night','E70',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','day','E70',0.8,''); -INSERT INTO "capacity_factor_tech" VALUES('utopia','summer','night','E70',0.8,''); -CREATE TABLE capacity_to_activity -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - c2a REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech) -); -INSERT INTO "capacity_to_activity" VALUES('utopia','E01',31.54,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','E21',31.54,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','E31',31.54,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','E51',31.54,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','E70',31.54,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','RHE',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','RHO',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','RL1',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','SRE',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','TXD',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','TXE',1.0,'PJ / (GW * year)',''); -INSERT INTO "capacity_to_activity" VALUES('utopia','TXG',1.0,'PJ / (GW * year)',''); -CREATE TABLE commodity -( - name TEXT - PRIMARY KEY, - flag TEXT - REFERENCES commodity_type (label), - description TEXT, - units TEXT -); -INSERT INTO "commodity" VALUES('ethos','s','# dummy commodity to supply inputs','PJ'); -INSERT INTO "commodity" VALUES('DSL','p','# diesel','PJ'); -INSERT INTO "commodity" VALUES('ELC','p','# electricity','PJ'); -INSERT INTO "commodity" VALUES('FEQ','p','# fossil equivalent','PJ'); -INSERT INTO "commodity" VALUES('GSL','p','# gasoline','PJ'); -INSERT INTO "commodity" VALUES('HCO','p','# coal','PJ'); -INSERT INTO "commodity" VALUES('HYD','p','# water','PJ'); -INSERT INTO "commodity" VALUES('OIL','p','# crude oil','PJ'); -INSERT INTO "commodity" VALUES('URN','p','# uranium','PJ'); -INSERT INTO "commodity" VALUES('co2','e','#CO2 emissions','Mt'); -INSERT INTO "commodity" VALUES('nox','e','#NOX emissions','Mt'); -INSERT INTO "commodity" VALUES('RH','d','# residential heating','PJ'); -INSERT INTO "commodity" VALUES('RL','d','# residential lighting','PJ'); -INSERT INTO "commodity" VALUES('TX','d','# transportation','PJ'); -CREATE TABLE commodity_type -( - label TEXT - PRIMARY KEY, - description TEXT -); -INSERT INTO "commodity_type" VALUES('w','waste commodity'); -INSERT INTO "commodity_type" VALUES('wa','waste annual commodity'); -INSERT INTO "commodity_type" VALUES('wp','waste physical commodity'); -INSERT INTO "commodity_type" VALUES('a','annual commodity'); -INSERT INTO "commodity_type" VALUES('s','source commodity'); -INSERT INTO "commodity_type" VALUES('p','physical commodity'); -INSERT INTO "commodity_type" VALUES('e','emissions commodity'); -INSERT INTO "commodity_type" VALUES('d','demand commodity'); -CREATE TABLE construction_input -( - region TEXT, - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - value REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, input_comm, tech, vintage) -); -CREATE TABLE cost_emission -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - emis_comm TEXT NOT NULL - REFERENCES commodity (name), - cost REAL NOT NULL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, emis_comm) -); -CREATE TABLE cost_fixed -( - region TEXT NOT NULL, - period INTEGER NOT NULL - REFERENCES time_period (period), - tech TEXT NOT NULL - REFERENCES technology (tech), - vintage INTEGER NOT NULL - REFERENCES time_period (period), - cost REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, tech, vintage) -); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E01',1960,40.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E01',1970,40.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E01',1980,40.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E01',1990,40.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E01',1970,70.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E01',1980,70.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E01',1990,70.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E01',2000,70.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E01',1980,100.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E01',1990,100.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E01',2000,100.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E01',2010,100.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E21',2000,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E21',2000,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E21',2010,500.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E31',2000,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E31',2000,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E31',2010,75.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E51',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E51',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E51',2010,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E70',1960,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E70',1970,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E70',1970,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'E70',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E70',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'E70',2010,30.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'RHO',1970,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'RHO',1980,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'RHO',1980,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'RHO',2000,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'RHO',2000,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'RHO',2010,1.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'RL1',1980,9.46,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'RL1',1990,9.46,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'RL1',2000,9.46,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'RL1',2010,9.46,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXD',1970,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXD',1980,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXD',1990,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXD',1980,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXD',1990,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXD',2000,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXD',2000,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXD',2010,52.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXE',1990,100.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXE',1990,90.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXE',2000,90.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXE',2000,80.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXE',2010,80.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXG',1970,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXG',1980,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',1990,'TXG',1990,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXG',1980,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXG',1990,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2000,'TXG',2000,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXG',2000,48.0,'Mdollar / (PJ^2 / GW / year)',''); -INSERT INTO "cost_fixed" VALUES('utopia',2010,'TXG',2010,48.0,'Mdollar / (PJ^2 / GW / year)',''); -CREATE TABLE cost_invest -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - cost REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech, vintage) -); -INSERT INTO "cost_invest" VALUES('utopia','E01',1990,2000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E01',2000,1300.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E01',2010,1200.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E21',1990,5000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E21',2000,5000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E21',2010,5000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E31',1990,3000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E31',2000,3000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E31',2010,3000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E51',1990,900.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E51',2000,900.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E51',2010,900.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E70',1990,1000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E70',2000,1000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','E70',2010,1000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHE',1990,90.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHE',2000,90.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHE',2010,90.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHO',1990,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHO',2000,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','RHO',2010,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','SRE',1990,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','SRE',2000,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','SRE',2010,100.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXD',1990,1044.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXD',2000,1044.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXD',2010,1044.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXE',1990,2000.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXE',2000,1750.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXE',2010,1500.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXG',1990,1044.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXG',2000,1044.0,'Mdollar / (PJ^2 / GW)',''); -INSERT INTO "cost_invest" VALUES('utopia','TXG',2010,1044.0,'Mdollar / (PJ^2 / GW)',''); -CREATE TABLE cost_variable -( - region TEXT NOT NULL, - period INTEGER NOT NULL - REFERENCES time_period (period), - tech TEXT NOT NULL - REFERENCES technology (tech), - vintage INTEGER NOT NULL - REFERENCES time_period (period), - cost REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, tech, vintage) -); -INSERT INTO "cost_variable" VALUES('utopia',1990,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E01',1960,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E01',1970,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E01',1980,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E01',1990,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E01',1970,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E01',1980,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E01',1990,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E01',2000,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E01',1980,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E01',1990,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E01',2000,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E01',2010,0.3,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E21',1990,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E21',1990,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E21',1990,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E21',2000,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E21',2000,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E21',2010,1.5,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E70',1960,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E70',1970,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E70',1980,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'E70',1990,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E70',1970,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E70',1980,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E70',1990,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'E70',2000,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E70',1980,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E70',1990,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E70',2000,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'E70',2010,0.4,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',1990,'SRE',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'SRE',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2000,'SRE',2000,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'SRE',1990,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'SRE',2000,10.0,'Mdollar / (PJ)',''); -INSERT INTO "cost_variable" VALUES('utopia',2010,'SRE',2010,10.0,'Mdollar / (PJ)',''); -CREATE TABLE demand -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - commodity TEXT - REFERENCES commodity (name), - demand REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, commodity) -); -INSERT INTO "demand" VALUES('utopia',1990,'RH',25.2,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2000,'RH',37.8,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2010,'RH',56.7,'PJ',''); -INSERT INTO "demand" VALUES('utopia',1990,'RL',5.6,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2000,'RL',8.4,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2010,'RL',12.6,'PJ',''); -INSERT INTO "demand" VALUES('utopia',1990,'TX',5.2,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2000,'TX',7.8,'PJ',''); -INSERT INTO "demand" VALUES('utopia',2010,'TX',11.69,'PJ',''); -CREATE TABLE demand_specific_distribution -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - demand_name TEXT - REFERENCES commodity (name), - dsd REAL, - notes TEXT, - PRIMARY KEY (region, period, season, tod, demand_name), - CHECK (dsd >= 0 AND dsd <= 1) -); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','day','RH',0.12,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','night','RH',0.06,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','day','RH',0.5467,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','night','RH',0.2733,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'summer','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'summer','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','day','RL',0.5,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','night','RL',0.1,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','day','RH',0.12,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','night','RH',0.06,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','day','RH',0.5467,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','night','RH',0.2733,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'summer','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'summer','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','day','RL',0.5,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','night','RL',0.1,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','day','RH',0.12,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','night','RH',0.06,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','day','RH',0.5467,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','night','RH',0.2733,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'summer','day','RL',0.15,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'summer','night','RL',0.05,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','day','RL',0.5,''); -INSERT INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','night','RL',0.1,''); -CREATE TABLE efficiency -( - region TEXT, - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - efficiency REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, input_comm, tech, vintage, output_comm), - CHECK (efficiency > 0) -); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPDSL1',1990,'DSL',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPGSL1',1990,'GSL',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPHCO1',1990,'HCO',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPOIL1',1990,'OIL',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPURN1',1990,'URN',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPFEQ',1990,'FEQ',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','ethos','IMPHYD',1990,'HYD',1.0,'PJ / (PJ)',''); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',1960,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',1970,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',1980,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HCO','E01',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','FEQ','E21',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','FEQ','E21',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','FEQ','E21',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','URN','E21',1990,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); -INSERT INTO "efficiency" VALUES('utopia','URN','E21',2000,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); -INSERT INTO "efficiency" VALUES('utopia','URN','E21',2010,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); -INSERT INTO "efficiency" VALUES('utopia','HYD','E31',1980,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HYD','E31',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HYD','E31',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','HYD','E31',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',1960,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',1970,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',1980,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',1990,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',2000,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','DSL','E70',2010,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); -INSERT INTO "efficiency" VALUES('utopia','ELC','E51',1980,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); -INSERT INTO "efficiency" VALUES('utopia','ELC','E51',1990,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); -INSERT INTO "efficiency" VALUES('utopia','ELC','E51',2000,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); -INSERT INTO "efficiency" VALUES('utopia','ELC','E51',2010,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RHE',1990,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RHE',2000,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RHE',2010,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','RHO',1970,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','RHO',1980,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','RHO',1990,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','RHO',2000,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','RHO',2010,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RL1',1980,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RL1',1990,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RL1',2000,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','RL1',2010,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',1990,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',2000,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',2010,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',1990,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',2000,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','OIL','SRE',2010,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); -INSERT INTO "efficiency" VALUES('utopia','DSL','TXD',1970,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','TXD',1980,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','TXD',1990,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','TXD',2000,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','DSL','TXD',2010,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','TXE',1990,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','TXE',2000,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','ELC','TXE',2010,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','GSL','TXG',1970,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','GSL','TXG',1980,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','GSL','TXG',1990,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','GSL','TXG',2000,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -INSERT INTO "efficiency" VALUES('utopia','GSL','TXG',2010,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); -CREATE TABLE efficiency_variable -( - region TEXT, - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - efficiency REAL, - notes TEXT, - PRIMARY KEY (region, season, tod, input_comm, tech, vintage, output_comm), - CHECK (efficiency > 0) -); -CREATE TABLE emission_activity -( - region TEXT, - emis_comm TEXT - REFERENCES commodity (name), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - activity REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, emis_comm, input_comm, tech, vintage, output_comm) -); -INSERT INTO "emission_activity" VALUES('utopia','co2','ethos','IMPDSL1',1990,'DSL',0.075,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','co2','ethos','IMPGSL1',1990,'GSL',0.075,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','co2','ethos','IMPHCO1',1990,'HCO',8.9e-02,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','co2','ethos','IMPOIL1',1990,'OIL',0.075,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1970,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1980,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1990,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',2000,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',2010,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1970,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1980,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1990,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',2000,'TX',1.0,'Mt / (PJ)',''); -INSERT INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',2010,'TX',1.0,'Mt / (PJ)',''); -CREATE TABLE emission_embodied -( - region TEXT, - emis_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - value REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, emis_comm, tech, vintage) -); -CREATE TABLE emission_end_of_life -( - region TEXT, - emis_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - value REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, emis_comm, tech, vintage) -); -CREATE TABLE end_of_life_output -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - value REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech, vintage, output_comm) -); -CREATE TABLE existing_capacity -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - capacity REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech, vintage) -); -INSERT INTO "existing_capacity" VALUES('utopia','E01',1960,0.175,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E01',1970,0.175,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E01',1980,0.15,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E31',1980,0.1,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E51',1980,0.5,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E70',1960,0.05,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E70',1970,0.05,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','E70',1980,0.2,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','RHO',1970,12.5,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','RHO',1980,12.5,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','RL1',1980,5.6,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','TXD',1970,0.4,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','TXD',1980,0.2,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','TXG',1970,3.1,'GW',''); -INSERT INTO "existing_capacity" VALUES('utopia','TXG',1980,1.5,'GW',''); -CREATE TABLE lifetime_process -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - lifetime REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech, vintage) -); -INSERT INTO "lifetime_process" VALUES('utopia','RL1',1980,20.0,'year','#forexistingcap'); -INSERT INTO "lifetime_process" VALUES('utopia','TXD',1970,30.0,'year','#forexistingcap'); -INSERT INTO "lifetime_process" VALUES('utopia','TXD',1980,30.0,'year','#forexistingcap'); -INSERT INTO "lifetime_process" VALUES('utopia','TXG',1970,30.0,'year','#forexistingcap'); -INSERT INTO "lifetime_process" VALUES('utopia','TXG',1980,30.0,'year','#forexistingcap'); -CREATE TABLE lifetime_survival_curve -( - region TEXT NOT NULL, - period INTEGER NOT NULL, - tech TEXT NOT NULL - REFERENCES technology (tech), - vintage INTEGER NOT NULL - REFERENCES time_period (period), - fraction REAL, - notes TEXT, - PRIMARY KEY (region, period, tech, vintage) -); -CREATE TABLE lifetime_tech -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - lifetime REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech) -); -INSERT INTO "lifetime_tech" VALUES('utopia','E01',40.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','E21',40.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','E31',100.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','E51',100.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','E70',40.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','RHE',30.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','RHO',30.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','RL1',10.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','SRE',50.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','TXD',15.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','TXE',15.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','TXG',15.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPDSL1',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPGSL1',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPHCO1',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPOIL1',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPURN1',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPHYD',1000.0,'year',''); -INSERT INTO "lifetime_tech" VALUES('utopia','IMPFEQ',1000.0,'year',''); -CREATE TABLE limit_activity -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - activity REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, tech_or_group, operator) -); -CREATE TABLE limit_activity_share -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - sub_group TEXT, - super_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - share REAL, - notes TEXT, - PRIMARY KEY (region, period, sub_group, super_group, operator) -); -CREATE TABLE limit_annual_capacity_factor -( - region TEXT, - tech_or_group TEXT, - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - factor REAL, - notes TEXT, - PRIMARY KEY (region, tech_or_group, vintage, output_comm, operator), - CHECK (factor >= 0 AND factor <= 1) -); -CREATE TABLE limit_capacity -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - capacity REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, tech_or_group, operator) -); -INSERT INTO "limit_capacity" VALUES('utopia',1990,'E31','ge',0.13,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2000,'E31','ge',0.13,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2010,'E31','ge',0.13,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',1990,'SRE','ge',0.1,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',1990,'E31','le',0.13,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2000,'E31','le',0.17,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2010,'E31','le',2.1e-01,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',1990,'RHE','le',0.0,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',1990,'TXD','le',0.6,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2000,'TXD','le',1.76,'GW',''); -INSERT INTO "limit_capacity" VALUES('utopia',2010,'TXD','le',4.76,'GW',''); -CREATE TABLE limit_capacity_share -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - sub_group TEXT, - super_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - share REAL, - notes TEXT, - PRIMARY KEY (region, period, sub_group, super_group, operator) -); -CREATE TABLE limit_degrowth_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_degrowth_new_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_degrowth_new_capacity_delta -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_emission -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - emis_comm TEXT - REFERENCES commodity (name), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - value REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, period, emis_comm, operator) -); -CREATE TABLE limit_growth_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_growth_new_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_growth_new_capacity_delta -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_new_capacity -( - region TEXT, - tech_or_group TEXT, - vintage INTEGER - REFERENCES time_period (period), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - new_cap REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, vintage, operator) -); -CREATE TABLE limit_new_capacity_share -( - region TEXT, - sub_group TEXT, - super_group TEXT, - vintage INTEGER - REFERENCES time_period (period), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - share REAL, - notes TEXT, - PRIMARY KEY (region, sub_group, super_group, vintage, operator) -); -CREATE TABLE limit_resource -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - cum_act REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE limit_seasonal_capacity_factor -( - region TEXT - REFERENCES region (region), - season TEXT - REFERENCES time_season (season), - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - factor REAL, - notes TEXT, - PRIMARY KEY(region, season, tech_or_group, operator) -); -CREATE TABLE limit_storage_level_fraction -( - region TEXT, - season TEXT, - tod TEXT - REFERENCES time_of_day (tod), - tech TEXT - REFERENCES technology (tech), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - fraction REAL, - notes TEXT, - CHECK (fraction >= 0 AND fraction <= 1), - PRIMARY KEY(region, season, tod, tech, operator) -); -CREATE TABLE limit_tech_input_split -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - proportion REAL, - notes TEXT, - PRIMARY KEY (region, period, input_comm, tech, operator) -); -CREATE TABLE limit_tech_input_split_annual -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - proportion REAL, - notes TEXT, - PRIMARY KEY (region, period, input_comm, tech, operator) -); -CREATE TABLE limit_tech_output_split -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - tech TEXT - REFERENCES technology (tech), - output_comm TEXT - REFERENCES commodity (name), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - proportion REAL, - notes TEXT, - PRIMARY KEY (region, period, tech, output_comm, operator) -); -INSERT INTO "limit_tech_output_split" VALUES('utopia',1990,'SRE','DSL','ge',0.7,''); -INSERT INTO "limit_tech_output_split" VALUES('utopia',2000,'SRE','DSL','ge',0.7,''); -INSERT INTO "limit_tech_output_split" VALUES('utopia',2010,'SRE','DSL','ge',0.7,''); -INSERT INTO "limit_tech_output_split" VALUES('utopia',1990,'SRE','GSL','ge',0.3,''); -INSERT INTO "limit_tech_output_split" VALUES('utopia',2000,'SRE','GSL','ge',0.3,''); -INSERT INTO "limit_tech_output_split" VALUES('utopia',2010,'SRE','GSL','ge',0.3,''); -CREATE TABLE limit_tech_output_split_annual -( - region TEXT, - period INTEGER - REFERENCES time_period (period), - tech TEXT - REFERENCES technology (tech), - output_comm TEXT - REFERENCES commodity (name), - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - proportion REAL, - notes TEXT, - PRIMARY KEY (region, period, tech, output_comm, operator) -); -CREATE TABLE linked_tech -( - primary_region TEXT, - primary_tech TEXT - REFERENCES technology (tech), - emis_comm TEXT - REFERENCES commodity (name), - driven_tech TEXT - REFERENCES technology (tech), - notes TEXT, - PRIMARY KEY (primary_region, primary_tech, emis_comm) -); -CREATE TABLE loan_lifetime_process -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - lifetime REAL, - units TEXT, - notes TEXT, - PRIMARY KEY (region, tech, vintage) -); -CREATE TABLE loan_rate -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - rate REAL, - notes TEXT, - PRIMARY KEY (region, tech, vintage) -); -CREATE TABLE metadata -( - element TEXT, - value INT, - notes TEXT, - PRIMARY KEY (element) -); -INSERT INTO "metadata" VALUES('DB_MAJOR',4,''); -INSERT INTO "metadata" VALUES('DB_MINOR',0,''); -CREATE TABLE metadata_real -( - element TEXT, - value REAL, - notes TEXT, - - PRIMARY KEY (element) -); -INSERT INTO "metadata_real" VALUES('default_loan_rate',0.05,'Default Loan Rate if not specified in loan_rate table'); -INSERT INTO "metadata_real" VALUES('global_discount_rate',0.05,''); -CREATE TABLE myopic_efficiency -( - base_year integer, - region text, - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - efficiency real, - lifetime integer, - PRIMARY KEY (region, input_comm, tech, vintage, output_comm) -); -CREATE TABLE operator -( - operator TEXT PRIMARY KEY, - notes TEXT -); -INSERT INTO "operator" VALUES('e','equal to'); -INSERT INTO "operator" VALUES('le','less than or equal to'); -INSERT INTO "operator" VALUES('ge','greater than or equal to'); -CREATE TABLE output_built_capacity -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - capacity REAL, - units TEXT, - PRIMARY KEY (region, scenario, tech, vintage) -); -CREATE TABLE output_cost -( - scenario TEXT, - region TEXT, - sector TEXT REFERENCES sector_label (sector), - period INTEGER REFERENCES time_period (period), - tech TEXT REFERENCES technology (tech), - vintage INTEGER REFERENCES time_period (period), - d_invest REAL, - d_fixed REAL, - d_var REAL, - d_emiss REAL, - invest REAL, - fixed REAL, - var REAL, - emiss REAL, - units TEXT, - PRIMARY KEY (scenario, region, period, tech, vintage), - FOREIGN KEY (vintage) REFERENCES time_period (period), - FOREIGN KEY (tech) REFERENCES technology (tech) -); -CREATE TABLE output_curtailment -( - scenario TEXT, - region TEXT, - sector TEXT, - period INTEGER - REFERENCES time_period (period), - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - curtailment REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) -); -CREATE TABLE output_dual_variable -( - scenario TEXT, - constraint_name TEXT, - dual REAL, - PRIMARY KEY (constraint_name, scenario) -); -CREATE TABLE output_emission -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - emis_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - emission REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, emis_comm, tech, vintage) -); -CREATE TABLE output_flow_in -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - flow REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) -); -CREATE TABLE output_flow_out -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - season TEXT - REFERENCES time_season (season), - tod TEXT - REFERENCES time_of_day (tod), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - flow REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) -); -CREATE TABLE output_flow_out_summary -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - input_comm TEXT - REFERENCES commodity (name), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - output_comm TEXT - REFERENCES commodity (name), - flow REAL, - PRIMARY KEY (scenario, region, period, input_comm, tech, vintage, output_comm) -); -CREATE TABLE output_net_capacity -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - capacity REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, tech, vintage) -); -CREATE TABLE output_objective -( - scenario TEXT, - objective_name TEXT, - total_system_cost REAL -); -CREATE TABLE output_retired_capacity -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - cap_eol REAL, - cap_early REAL, - units TEXT, - PRIMARY KEY (region, scenario, period, tech, vintage) -); -CREATE TABLE output_storage_level -( - scenario TEXT, - region TEXT, - sector TEXT - REFERENCES sector_label (sector), - period INTEGER - REFERENCES time_period (period), - season TEXT, - tod TEXT - REFERENCES time_of_day (tod), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER - REFERENCES time_period (period), - level REAL, - units TEXT, - PRIMARY KEY (scenario, region, period, season, tod, tech, vintage) -); -CREATE TABLE planning_reserve_margin -( - region TEXT - PRIMARY KEY - REFERENCES region (region), - margin REAL, - notes TEXT -); -CREATE TABLE ramp_down_hourly -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - rate REAL, - notes TEXT, - PRIMARY KEY (region, tech) -); -CREATE TABLE ramp_up_hourly -( - region TEXT, - tech TEXT - REFERENCES technology (tech), - rate REAL, - notes TEXT, - PRIMARY KEY (region, tech) -); -CREATE TABLE region -( - region TEXT - PRIMARY KEY, - notes TEXT -); -INSERT INTO "region" VALUES('utopia',NULL); -CREATE TABLE reserve_capacity_derate -( - region TEXT, - season TEXT - REFERENCES time_season (season), - tech TEXT - REFERENCES technology (tech), - vintage INTEGER, - factor REAL, - notes TEXT, - PRIMARY KEY (region, season, tech, vintage), - CHECK (factor >= 0 AND factor <= 1) -); -CREATE TABLE rps_requirement -( - region TEXT NOT NULL - REFERENCES region (region), - period INTEGER NOT NULL - REFERENCES time_period (period), - tech_group TEXT NOT NULL - REFERENCES tech_group (group_name), - requirement REAL NOT NULL, - notes TEXT -); -CREATE TABLE sector_label -( - sector TEXT PRIMARY KEY, - notes TEXT -); -INSERT INTO "sector_label" VALUES('supply',NULL); -INSERT INTO "sector_label" VALUES('electric',NULL); -INSERT INTO "sector_label" VALUES('transport',NULL); -INSERT INTO "sector_label" VALUES('commercial',NULL); -INSERT INTO "sector_label" VALUES('residential',NULL); -INSERT INTO "sector_label" VALUES('industrial',NULL); -CREATE TABLE storage_duration -( - region TEXT, - tech TEXT, - duration REAL, - notes TEXT, - PRIMARY KEY (region, tech) -); -CREATE TABLE tech_group -( - group_name TEXT - PRIMARY KEY, - notes TEXT -); -CREATE TABLE tech_group_member -( - group_name TEXT - REFERENCES tech_group (group_name), - tech TEXT - REFERENCES technology (tech), - PRIMARY KEY (group_name, tech) -); -CREATE TABLE technology -( - tech TEXT NOT NULL PRIMARY KEY, - flag TEXT NOT NULL, - sector TEXT, - category TEXT, - sub_category TEXT, - unlim_cap INTEGER NOT NULL DEFAULT 0, - annual INTEGER NOT NULL DEFAULT 0, - reserve INTEGER NOT NULL DEFAULT 0, - curtail INTEGER NOT NULL DEFAULT 0, - retire INTEGER NOT NULL DEFAULT 0, - flex INTEGER NOT NULL DEFAULT 0, - exchange INTEGER NOT NULL DEFAULT 0, - seas_stor INTEGER NOT NULL DEFAULT 0, - description TEXT, - FOREIGN KEY (flag) REFERENCES technology_type (label) -); -INSERT INTO "technology" VALUES('IMPDSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported diesel'); -INSERT INTO "technology" VALUES('IMPGSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported gasoline'); -INSERT INTO "technology" VALUES('IMPHCO1','p','supply','coal','',1,0,0,0,0,0,0,0,' imported coal'); -INSERT INTO "technology" VALUES('IMPOIL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported crude oil'); -INSERT INTO "technology" VALUES('IMPURN1','p','supply','nuclear','',1,0,0,0,0,0,0,0,' imported uranium'); -INSERT INTO "technology" VALUES('IMPFEQ','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported fossil equivalent'); -INSERT INTO "technology" VALUES('IMPHYD','p','supply','hydro','',1,0,0,0,0,0,0,0,' imported water -- doesnt exist in Utopia'); -INSERT INTO "technology" VALUES('E01','pb','electric','coal','',0,0,0,0,0,0,0,0,' coal power plant'); -INSERT INTO "technology" VALUES('E21','pb','electric','nuclear','',0,0,0,0,0,0,0,0,' nuclear power plant'); -INSERT INTO "technology" VALUES('E31','pb','electric','hydro','',0,0,0,0,0,0,0,0,' hydro power'); -INSERT INTO "technology" VALUES('E51','ps','electric','electric','',0,0,0,0,0,0,0,0,' electric storage'); -INSERT INTO "technology" VALUES('E70','p','electric','petroleum','',0,0,0,0,0,0,0,0,' diesel power plant'); -INSERT INTO "technology" VALUES('RHE','p','residential','electric','',0,0,0,0,0,0,0,0,' electric residential heating'); -INSERT INTO "technology" VALUES('RHO','p','residential','petroleum','',0,0,0,0,0,0,0,0,' diesel residential heating'); -INSERT INTO "technology" VALUES('RL1','p','residential','electric','',0,0,0,0,0,0,0,0,' residential lighting'); -INSERT INTO "technology" VALUES('SRE','p','supply','petroleum','',0,0,0,0,0,0,0,0,' crude oil processor'); -INSERT INTO "technology" VALUES('TXD','p','transport','petroleum','',0,0,0,0,0,0,0,0,' diesel powered vehicles'); -INSERT INTO "technology" VALUES('TXE','p','transport','electric','',0,0,0,0,0,0,0,0,' electric powered vehicles'); -INSERT INTO "technology" VALUES('TXG','p','transport','petroleum','',0,0,0,0,0,0,0,0,' gasoline powered vehicles'); -CREATE TABLE technology_type -( - label TEXT - PRIMARY KEY, - description TEXT -); -INSERT INTO "technology_type" VALUES('p','production technology'); -INSERT INTO "technology_type" VALUES('pb','baseload production technology'); -INSERT INTO "technology_type" VALUES('ps','storage production technology'); -CREATE TABLE time_of_day -( - sequence INTEGER UNIQUE, - tod TEXT - PRIMARY KEY, - hours REAL NOT NULL DEFAULT 1, - notes TEXT, - CHECK (hours > 0) -); -INSERT INTO "time_of_day" (sequence, tod, hours) VALUES(1,'day',16); -INSERT INTO "time_of_day" (sequence, tod, hours) VALUES(2,'night',8); -CREATE TABLE time_period -( - sequence INTEGER UNIQUE, - period INTEGER - PRIMARY KEY, - flag TEXT - REFERENCES time_period_type (label) -); -INSERT INTO "time_period" VALUES(1,1960,'e'); -INSERT INTO "time_period" VALUES(2,1970,'e'); -INSERT INTO "time_period" VALUES(3,1980,'e'); -INSERT INTO "time_period" VALUES(4,1990,'f'); -INSERT INTO "time_period" VALUES(5,2000,'f'); -INSERT INTO "time_period" VALUES(6,2010,'f'); -INSERT INTO "time_period" VALUES(7,2020,'f'); -CREATE TABLE time_period_type -( - label TEXT - PRIMARY KEY, - description TEXT -); -INSERT INTO "time_period_type" VALUES('e','existing vintages'); -INSERT INTO "time_period_type" VALUES('f','future'); -CREATE TABLE time_season -( - sequence INTEGER UNIQUE, - season TEXT, - segment_fraction REAL NOT NULL, - notes TEXT, - PRIMARY KEY (season), - CHECK (segment_fraction >= 0 AND segment_fraction <= 1) -); -INSERT INTO "time_season" VALUES(0,'inter',0.25,NULL); -INSERT INTO "time_season" VALUES(1,'summer',0.25,NULL); -INSERT INTO "time_season" VALUES(2,'winter',0.5,NULL); -CREATE TABLE time_season_sequential -( - sequence INTEGER UNIQUE, - seas_seq TEXT, - season TEXT REFERENCES time_season(season), - segment_fraction REAL NOT NULL, - notes TEXT, - PRIMARY KEY (seas_seq), - CHECK (segment_fraction >= 0 AND segment_fraction <= 1) -); -CREATE INDEX region_tech_vintage ON myopic_efficiency (region, tech, vintage); +BEGIN TRANSACTION; +REPLACE INTO "capacity_factor_process" VALUES('utopia','inter','day','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','inter','night','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','winter','day','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','winter','night','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','summer','day','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','summer','night','E31',2000,0.2753,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','inter','day','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','inter','night','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','winter','day','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','winter','night','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','summer','day','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_process" VALUES('utopia','summer','night','E31',2010,0.2756,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','day','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','night','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','day','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','night','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','day','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','night','E01',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','day','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','night','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','day','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','night','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','day','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','night','E21',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','day','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','night','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','day','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','night','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','day','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','night','E31',0.275,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','day','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','night','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','day','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','night','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','day','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','night','E51',0.17,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','day','E70',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','inter','night','E70',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','day','E70',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','winter','night','E70',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','day','E70',0.8,''); +REPLACE INTO "capacity_factor_tech" VALUES('utopia','summer','night','E70',0.8,''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','E01',31.54,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','E21',31.54,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','E31',31.54,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','E51',31.54,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','E70',31.54,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','RHE',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','RHO',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','RL1',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','SRE',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','TXD',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','TXE',1.0,'PJ / (GW * year)',''); +REPLACE INTO "capacity_to_activity" VALUES('utopia','TXG',1.0,'PJ / (GW * year)',''); +REPLACE INTO "commodity" VALUES('ethos','s','# dummy commodity to supply inputs','PJ'); +REPLACE INTO "commodity" VALUES('DSL','p','# diesel','PJ'); +REPLACE INTO "commodity" VALUES('ELC','p','# electricity','PJ'); +REPLACE INTO "commodity" VALUES('FEQ','p','# fossil equivalent','PJ'); +REPLACE INTO "commodity" VALUES('GSL','p','# gasoline','PJ'); +REPLACE INTO "commodity" VALUES('HCO','p','# coal','PJ'); +REPLACE INTO "commodity" VALUES('HYD','p','# water','PJ'); +REPLACE INTO "commodity" VALUES('OIL','p','# crude oil','PJ'); +REPLACE INTO "commodity" VALUES('URN','p','# uranium','PJ'); +REPLACE INTO "commodity" VALUES('co2','e','#CO2 emissions','Mt'); +REPLACE INTO "commodity" VALUES('nox','e','#NOX emissions','Mt'); +REPLACE INTO "commodity" VALUES('RH','d','# residential heating','PJ'); +REPLACE INTO "commodity" VALUES('RL','d','# residential lighting','PJ'); +REPLACE INTO "commodity" VALUES('TX','d','# transportation','PJ'); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E01',1960,40.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E01',1970,40.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E01',1980,40.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E01',1990,40.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E01',1970,70.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E01',1980,70.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E01',1990,70.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E01',2000,70.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E01',1980,100.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E01',1990,100.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E01',2000,100.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E01',2010,100.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E21',1990,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E21',2000,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E21',2000,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E21',2010,500.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E31',2000,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E31',1980,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E31',1990,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E31',2000,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E31',2010,75.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E51',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E51',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E51',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E51',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E51',2010,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E70',1960,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E70',1970,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E70',1970,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'E70',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E70',1980,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E70',1990,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E70',2000,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'E70',2010,30.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'RHO',1970,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'RHO',1980,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'RHO',1980,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'RHO',2000,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'RHO',1990,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'RHO',2000,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'RHO',2010,1.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'RL1',1980,9.46,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'RL1',1990,9.46,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'RL1',2000,9.46,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'RL1',2010,9.46,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXD',1970,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXD',1980,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXD',1990,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXD',1980,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXD',1990,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXD',2000,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXD',2000,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXD',2010,52.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXE',1990,100.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXE',1990,90.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXE',2000,90.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXE',2000,80.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXE',2010,80.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXG',1970,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXG',1980,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',1990,'TXG',1990,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXG',1980,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXG',1990,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2000,'TXG',2000,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXG',2000,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_fixed" VALUES('utopia',2010,'TXG',2010,48.0,'Mdollar / (PJ^2 / GW / year)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E01',1990,2000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E01',2000,1300.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E01',2010,1200.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E21',1990,5000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E21',2000,5000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E21',2010,5000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E31',1990,3000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E31',2000,3000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E31',2010,3000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E51',1990,900.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E51',2000,900.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E51',2010,900.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E70',1990,1000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E70',2000,1000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','E70',2010,1000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHE',1990,90.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHE',2000,90.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHE',2010,90.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHO',1990,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHO',2000,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','RHO',2010,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','SRE',1990,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','SRE',2000,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','SRE',2010,100.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXD',1990,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXD',2000,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXD',2010,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXE',1990,2000.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXE',2000,1750.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXE',2010,1500.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXG',1990,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXG',2000,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_invest" VALUES('utopia','TXG',2010,1044.0,'Mdollar / (PJ^2 / GW)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'IMPDSL1',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'IMPGSL1',1990,15.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'IMPHCO1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'IMPOIL1',1990,8.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'IMPURN1',1990,2.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E01',1960,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E01',1970,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E01',1980,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E01',1990,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E01',1970,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E01',1980,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E01',1990,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E01',2000,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E01',1980,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E01',1990,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E01',2000,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E01',2010,0.3,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E21',1990,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E21',1990,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E21',1990,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E21',2000,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E21',2000,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E21',2010,1.5,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E70',1960,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E70',1970,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E70',1980,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'E70',1990,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E70',1970,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E70',1980,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E70',1990,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'E70',2000,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E70',1980,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E70',1990,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E70',2000,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'E70',2010,0.4,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',1990,'SRE',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'SRE',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2000,'SRE',2000,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'SRE',1990,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'SRE',2000,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "cost_variable" VALUES('utopia',2010,'SRE',2010,10.0,'Mdollar / (PJ)',''); +REPLACE INTO "demand" VALUES('utopia',1990,'RH',25.2,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2000,'RH',37.8,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2010,'RH',56.7,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',1990,'RL',5.6,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2000,'RL',8.4,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2010,'RL',12.6,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',1990,'TX',5.2,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2000,'TX',7.8,'PJ',''); +REPLACE INTO "demand" VALUES('utopia',2010,'TX',11.69,'PJ',''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','day','RH',0.12,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','night','RH',0.06,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','day','RH',0.5467,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','night','RH',0.2733,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'inter','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'summer','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'summer','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','day','RL',0.5,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',1990,'winter','night','RL',0.1,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','day','RH',0.12,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','night','RH',0.06,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','day','RH',0.5467,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','night','RH',0.2733,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'inter','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'summer','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'summer','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','day','RL',0.5,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2000,'winter','night','RL',0.1,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','day','RH',0.12,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','night','RH',0.06,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','day','RH',0.5467,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','night','RH',0.2733,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'inter','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'summer','day','RL',0.15,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'summer','night','RL',0.05,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','day','RL',0.5,''); +REPLACE INTO "demand_specific_distribution" VALUES('utopia',2010,'winter','night','RL',0.1,''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPDSL1',1990,'DSL',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPGSL1',1990,'GSL',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPHCO1',1990,'HCO',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPOIL1',1990,'OIL',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPURN1',1990,'URN',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPFEQ',1990,'FEQ',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','ethos','IMPHYD',1990,'HYD',1.0,'PJ / (PJ)',''); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',1960,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',1970,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',1980,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HCO','E01',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','FEQ','E21',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','FEQ','E21',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','FEQ','E21',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','URN','E21',1990,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); +REPLACE INTO "efficiency" VALUES('utopia','URN','E21',2000,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); +REPLACE INTO "efficiency" VALUES('utopia','URN','E21',2010,'ELC',0.4,'PJ / (PJ)','# 1/2.5'); +REPLACE INTO "efficiency" VALUES('utopia','HYD','E31',1980,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HYD','E31',1990,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HYD','E31',2000,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','HYD','E31',2010,'ELC',0.32,'PJ / (PJ)','# 1/3.125'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',1960,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',1970,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',1980,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',1990,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',2000,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','E70',2010,'ELC',0.294,'PJ / (PJ)','# 1/3.4'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','E51',1980,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','E51',1990,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','E51',2000,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','E51',2010,'ELC',0.72,'PJ / (PJ)','# 1/1.3889'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RHE',1990,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RHE',2000,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RHE',2010,'RH',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','RHO',1970,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','RHO',1980,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','RHO',1990,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','RHO',2000,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','RHO',2010,'RH',0.7,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RL1',1980,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RL1',1990,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RL1',2000,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','RL1',2010,'RL',1.0,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',1990,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',2000,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',2010,'DSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',1990,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',2000,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','OIL','SRE',2010,'GSL',1.0,'PJ / (PJ)','# direct translation from PRC_INP2, PRC_OUT'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','TXD',1970,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','TXD',1980,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','TXD',1990,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','TXD',2000,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','DSL','TXD',2010,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','TXE',1990,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','TXE',2000,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','ELC','TXE',2010,'TX',0.827,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','GSL','TXG',1970,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','GSL','TXG',1980,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','GSL','TXG',1990,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','GSL','TXG',2000,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "efficiency" VALUES('utopia','GSL','TXG',2010,'TX',0.231,'PJ / (PJ)','# direct translation from DMD_EFF'); +REPLACE INTO "emission_activity" VALUES('utopia','co2','ethos','IMPDSL1',1990,'DSL',0.075,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','co2','ethos','IMPGSL1',1990,'GSL',0.075,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','co2','ethos','IMPHCO1',1990,'HCO',8.9e-02,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','co2','ethos','IMPOIL1',1990,'OIL',0.075,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1970,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1980,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',1990,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',2000,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','DSL','TXD',2010,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1970,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1980,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',1990,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',2000,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "emission_activity" VALUES('utopia','nox','GSL','TXG',2010,'TX',1.0,'Mt / (PJ)',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E01',1960,0.175,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E01',1970,0.175,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E01',1980,0.15,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E31',1980,0.1,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E51',1980,0.5,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E70',1960,0.05,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E70',1970,0.05,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','E70',1980,0.2,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','RHO',1970,12.5,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','RHO',1980,12.5,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','RL1',1980,5.6,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','TXD',1970,0.4,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','TXD',1980,0.2,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','TXG',1970,3.1,'GW',''); +REPLACE INTO "existing_capacity" VALUES('utopia','TXG',1980,1.5,'GW',''); +REPLACE INTO "lifetime_process" VALUES('utopia','RL1',1980,20.0,'year','#forexistingcap'); +REPLACE INTO "lifetime_process" VALUES('utopia','TXD',1970,30.0,'year','#forexistingcap'); +REPLACE INTO "lifetime_process" VALUES('utopia','TXD',1980,30.0,'year','#forexistingcap'); +REPLACE INTO "lifetime_process" VALUES('utopia','TXG',1970,30.0,'year','#forexistingcap'); +REPLACE INTO "lifetime_process" VALUES('utopia','TXG',1980,30.0,'year','#forexistingcap'); +REPLACE INTO "lifetime_tech" VALUES('utopia','E01',40.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','E21',40.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','E31',100.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','E51',100.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','E70',40.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','RHE',30.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','RHO',30.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','RL1',10.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','SRE',50.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','TXD',15.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','TXE',15.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','TXG',15.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPDSL1',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPGSL1',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPHCO1',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPOIL1',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPURN1',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPHYD',1000.0,'year',''); +REPLACE INTO "lifetime_tech" VALUES('utopia','IMPFEQ',1000.0,'year',''); +REPLACE INTO "limit_capacity" VALUES('utopia',1990,'E31','ge',0.13,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2000,'E31','ge',0.13,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2010,'E31','ge',0.13,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',1990,'SRE','ge',0.1,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',1990,'E31','le',0.13,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2000,'E31','le',0.17,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2010,'E31','le',2.1e-01,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',1990,'RHE','le',0.0,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',1990,'TXD','le',0.6,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2000,'TXD','le',1.76,'GW',''); +REPLACE INTO "limit_capacity" VALUES('utopia',2010,'TXD','le',4.76,'GW',''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',1990,'SRE','DSL','ge',0.7,''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',2000,'SRE','DSL','ge',0.7,''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',2010,'SRE','DSL','ge',0.7,''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',1990,'SRE','GSL','ge',0.3,''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',2000,'SRE','GSL','ge',0.3,''); +REPLACE INTO "limit_tech_output_split" VALUES('utopia',2010,'SRE','GSL','ge',0.3,''); +REPLACE INTO "metadata_real" VALUES('default_loan_rate',0.05,'Default Loan Rate if not specified in loan_rate table'); +REPLACE INTO "metadata_real" VALUES('global_discount_rate',0.05,''); +REPLACE INTO "region" VALUES('utopia',NULL); +REPLACE INTO "sector_label" VALUES('supply',NULL); +REPLACE INTO "sector_label" VALUES('electric',NULL); +REPLACE INTO "sector_label" VALUES('transport',NULL); +REPLACE INTO "sector_label" VALUES('commercial',NULL); +REPLACE INTO "sector_label" VALUES('residential',NULL); +REPLACE INTO "sector_label" VALUES('industrial',NULL); +REPLACE INTO "technology" VALUES('IMPDSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported diesel'); +REPLACE INTO "technology" VALUES('IMPGSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported gasoline'); +REPLACE INTO "technology" VALUES('IMPHCO1','p','supply','coal','',1,0,0,0,0,0,0,0,' imported coal'); +REPLACE INTO "technology" VALUES('IMPOIL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported crude oil'); +REPLACE INTO "technology" VALUES('IMPURN1','p','supply','nuclear','',1,0,0,0,0,0,0,0,' imported uranium'); +REPLACE INTO "technology" VALUES('IMPFEQ','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported fossil equivalent'); +REPLACE INTO "technology" VALUES('IMPHYD','p','supply','hydro','',1,0,0,0,0,0,0,0,' imported water -- doesnt exist in Utopia'); +REPLACE INTO "technology" VALUES('E01','pb','electric','coal','',0,0,0,0,0,0,0,0,' coal power plant'); +REPLACE INTO "technology" VALUES('E21','pb','electric','nuclear','',0,0,0,0,0,0,0,0,' nuclear power plant'); +REPLACE INTO "technology" VALUES('E31','pb','electric','hydro','',0,0,0,0,0,0,0,0,' hydro power'); +REPLACE INTO "technology" VALUES('E51','ps','electric','electric','',0,0,0,0,0,0,0,0,' electric storage'); +REPLACE INTO "technology" VALUES('E70','p','electric','petroleum','',0,0,0,0,0,0,0,0,' diesel power plant'); +REPLACE INTO "technology" VALUES('RHE','p','residential','electric','',0,0,0,0,0,0,0,0,' electric residential heating'); +REPLACE INTO "technology" VALUES('RHO','p','residential','petroleum','',0,0,0,0,0,0,0,0,' diesel residential heating'); +REPLACE INTO "technology" VALUES('RL1','p','residential','electric','',0,0,0,0,0,0,0,0,' residential lighting'); +REPLACE INTO "technology" VALUES('SRE','p','supply','petroleum','',0,0,0,0,0,0,0,0,' crude oil processor'); +REPLACE INTO "technology" VALUES('TXD','p','transport','petroleum','',0,0,0,0,0,0,0,0,' diesel powered vehicles'); +REPLACE INTO "technology" VALUES('TXE','p','transport','electric','',0,0,0,0,0,0,0,0,' electric powered vehicles'); +REPLACE INTO "technology" VALUES('TXG','p','transport','petroleum','',0,0,0,0,0,0,0,0,' gasoline powered vehicles'); +REPLACE INTO "time_of_day" (sequence, tod, hours) VALUES(1,'day',16); +REPLACE INTO "time_of_day" (sequence, tod, hours) VALUES(2,'night',8); +REPLACE INTO "time_period" VALUES(1,1960,'e'); +REPLACE INTO "time_period" VALUES(2,1970,'e'); +REPLACE INTO "time_period" VALUES(3,1980,'e'); +REPLACE INTO "time_period" VALUES(4,1990,'f'); +REPLACE INTO "time_period" VALUES(5,2000,'f'); +REPLACE INTO "time_period" VALUES(6,2010,'f'); +REPLACE INTO "time_period" VALUES(7,2020,'f'); +REPLACE INTO "time_season" VALUES(0,'inter',0.25,NULL); +REPLACE INTO "time_season" VALUES(1,'summer',0.25,NULL); +REPLACE INTO "time_season" VALUES(2,'winter',0.5,NULL); COMMIT; diff --git a/tests/test_cli.py b/tests/test_cli.py index 19144fd40..5ba1fa1ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,7 +12,7 @@ # Path to the configuration file template we will use for tests. TESTING_CONFIGS_DIR = Path(__file__).parent / 'testing_configs' -UTOPIA_SQL_FIXTURE = Path(__file__).parent.parent / 'temoa' / 'tutorial_assets' / 'utopia.sql' +UTOPIA_SQL_FIXTURE = Path(__file__).parent / 'testing_data' / 'utopia_v3.sql' UTOPIA_CONFIG_TEMPLATE = TESTING_CONFIGS_DIR / 'config_utopia.toml' diff --git a/tests/testing_data/utopia_v3.sql b/tests/testing_data/utopia_v3.sql new file mode 100644 index 000000000..c480d93b6 --- /dev/null +++ b/tests/testing_data/utopia_v3.sql @@ -0,0 +1,1558 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE MetaData +( + element TEXT, + value INT, + notes TEXT, + PRIMARY KEY (element) +); +INSERT INTO MetaData VALUES('DB_MAJOR',3,'DB major version number'); +INSERT INTO MetaData VALUES('DB_MINOR',1,'DB minor version number'); +INSERT INTO MetaData VALUES ('days_per_period', 365, 'count of days in each period'); +CREATE TABLE MetaDataReal +( + element TEXT, + value REAL, + notes TEXT, + + PRIMARY KEY (element) +); +INSERT INTO MetaDataReal VALUES('default_loan_rate',0.05000000000000000277,'Default Loan Rate if not specified in LoanRate table'); +INSERT INTO MetaDataReal VALUES('global_discount_rate',0.05000000000000000277,''); +CREATE TABLE OutputDualVariable +( + scenario TEXT, + constraint_name TEXT, + dual REAL, + PRIMARY KEY (constraint_name, scenario) +); +CREATE TABLE OutputObjective +( + scenario TEXT, + objective_name TEXT, + total_system_cost REAL +); +CREATE TABLE SeasonLabel +( + season TEXT PRIMARY KEY, + notes TEXT +); +INSERT INTO SeasonLabel VALUES('inter',NULL); +INSERT INTO SeasonLabel VALUES('summer',NULL); +INSERT INTO SeasonLabel VALUES('winter',NULL); +CREATE TABLE SectorLabel +( + sector TEXT PRIMARY KEY, + notes TEXT +); +INSERT INTO SectorLabel VALUES('supply',NULL); +INSERT INTO SectorLabel VALUES('electric',NULL); +INSERT INTO SectorLabel VALUES('transport',NULL); +INSERT INTO SectorLabel VALUES('commercial',NULL); +INSERT INTO SectorLabel VALUES('residential',NULL); +INSERT INTO SectorLabel VALUES('industrial',NULL); +CREATE TABLE CapacityCredit +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER, + credit REAL, + notes TEXT, + PRIMARY KEY (region, period, tech, vintage), + CHECK (credit >= 0 AND credit <= 1) +); +CREATE TABLE CapacityFactorProcess +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER, + factor REAL, + notes TEXT, + PRIMARY KEY (region, period, season, tod, tech, vintage), + CHECK (factor >= 0 AND factor <= 1) +); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'inter','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'inter','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'winter','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'winter','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'summer','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2000,'summer','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'inter','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'inter','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'winter','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'winter','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'summer','day','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'summer','night','E31',2000,0.2752999999999999892,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'inter','day','E31',2010,0.2756000000000000116,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'inter','night','E31',2010,0.2756000000000000116,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'winter','day','E31',2010,0.2756000000000000116,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'winter','night','E31',2010,0.2756000000000000116,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'summer','day','E31',2010,0.2756000000000000116,''); +INSERT INTO CapacityFactorProcess VALUES('utopia',2010,'summer','night','E31',2010,0.2756000000000000116,''); +CREATE TABLE CapacityFactorTech +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + tech TEXT + REFERENCES Technology (tech), + factor REAL, + notes TEXT, + PRIMARY KEY (region, period, season, tod, tech), + CHECK (factor >= 0 AND factor <= 1) +); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'inter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'winter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',1990,'summer','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'inter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'winter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2000,'summer','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','day','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','night','E01',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','day','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','night','E21',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','day','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','night','E31',0.2750000000000000222,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','day','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','night','E51',0.1700000000000000122,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'inter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'winter','night','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','day','E70',0.8000000000000000444,''); +INSERT INTO CapacityFactorTech VALUES('utopia',2010,'summer','night','E70',0.8000000000000000444,''); +CREATE TABLE CapacityToActivity +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + c2a REAL, + notes TEXT, + PRIMARY KEY (region, tech) +); +INSERT INTO CapacityToActivity VALUES('utopia','E01',31.53999999999999915,''); +INSERT INTO CapacityToActivity VALUES('utopia','E21',31.53999999999999915,''); +INSERT INTO CapacityToActivity VALUES('utopia','E31',31.53999999999999915,''); +INSERT INTO CapacityToActivity VALUES('utopia','E51',31.53999999999999915,''); +INSERT INTO CapacityToActivity VALUES('utopia','E70',31.53999999999999915,''); +INSERT INTO CapacityToActivity VALUES('utopia','RHE',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','RHO',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','RL1',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','SRE',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','TXD',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','TXE',1.0,''); +INSERT INTO CapacityToActivity VALUES('utopia','TXG',1.0,''); +CREATE TABLE Commodity +( + name TEXT + PRIMARY KEY, + flag TEXT + REFERENCES CommodityType (label), + description TEXT +); +INSERT INTO Commodity VALUES('ethos','s','# dummy commodity to supply inputs (makes graph easier to read)'); +INSERT INTO Commodity VALUES('DSL','p','# diesel'); +INSERT INTO Commodity VALUES('ELC','p','# electricity'); +INSERT INTO Commodity VALUES('FEQ','p','# fossil equivalent'); +INSERT INTO Commodity VALUES('GSL','p','# gasoline'); +INSERT INTO Commodity VALUES('HCO','p','# coal'); +INSERT INTO Commodity VALUES('HYD','p','# water'); +INSERT INTO Commodity VALUES('OIL','p','# crude oil'); +INSERT INTO Commodity VALUES('URN','p','# uranium'); +INSERT INTO Commodity VALUES('co2','e','#CO2 emissions'); +INSERT INTO Commodity VALUES('nox','e','#NOX emissions'); +INSERT INTO Commodity VALUES('RH','d','# residential heating'); +INSERT INTO Commodity VALUES('RL','d','# residential lighting'); +INSERT INTO Commodity VALUES('TX','d','# transportation'); +CREATE TABLE CommodityType +( + label TEXT + PRIMARY KEY, + description TEXT +); +INSERT INTO CommodityType VALUES('w','waste commodity'); +INSERT INTO CommodityType VALUES('wa','waste annual commodity'); +INSERT INTO CommodityType VALUES('wp','waste physical commodity'); +INSERT INTO CommodityType VALUES('a','annual commodity'); +INSERT INTO CommodityType VALUES('s','source commodity'); +INSERT INTO CommodityType VALUES('p','physical commodity'); +INSERT INTO CommodityType VALUES('e','emissions commodity'); +INSERT INTO CommodityType VALUES('d','demand commodity'); +CREATE TABLE ConstructionInput +( + region TEXT, + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + value REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, input_comm, tech, vintage) +); +CREATE TABLE CostEmission +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + emis_comm TEXT NOT NULL + REFERENCES Commodity (name), + cost REAL NOT NULL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, emis_comm) +); +CREATE TABLE CostFixed +( + region TEXT NOT NULL, + period INTEGER NOT NULL + REFERENCES TimePeriod (period), + tech TEXT NOT NULL + REFERENCES Technology (tech), + vintage INTEGER NOT NULL + REFERENCES TimePeriod (period), + cost REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, tech, vintage) +); +INSERT INTO CostFixed VALUES('utopia',1990,'E01',1960,40.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E01',1970,40.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E01',1980,40.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E01',1990,40.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E01',1970,70.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E01',1980,70.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E01',1990,70.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E01',2000,70.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E01',1980,100.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E01',1990,100.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E01',2000,100.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E01',2010,100.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E21',1990,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E21',1990,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E21',1990,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E21',2000,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E21',2000,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E21',2010,500.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E31',1980,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E31',1990,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E31',1980,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E31',1990,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E31',2000,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E31',1980,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E31',1990,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E31',2000,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E31',2010,75.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E51',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E51',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E51',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E51',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E51',2000,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E51',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E51',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E51',2000,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E51',2010,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E70',1960,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E70',1970,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E70',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'E70',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E70',1970,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E70',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E70',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'E70',2000,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E70',1980,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E70',1990,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E70',2000,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'E70',2010,30.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'RHO',1970,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'RHO',1980,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'RHO',1990,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'RHO',1980,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'RHO',1990,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'RHO',2000,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'RHO',1990,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'RHO',2000,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'RHO',2010,1.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'RL1',1980,9.46000000000000086,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'RL1',1990,9.46000000000000086,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'RL1',2000,9.46000000000000086,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'RL1',2010,9.46000000000000086,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXD',1970,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXD',1980,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXD',1990,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXD',1980,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXD',1990,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXD',2000,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXD',2000,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXD',2010,52.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXE',1990,100.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXE',1990,90.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXE',2000,90.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXE',2000,80.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXE',2010,80.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXG',1970,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXG',1980,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',1990,'TXG',1990,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXG',1980,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXG',1990,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2000,'TXG',2000,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXG',2000,48.0,'',''); +INSERT INTO CostFixed VALUES('utopia',2010,'TXG',2010,48.0,'',''); +CREATE TABLE CostInvest +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + cost REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech, vintage) +); +INSERT INTO CostInvest VALUES('utopia','E01',1990,2000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E01',2000,1300.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E01',2010,1200.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E21',1990,5000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E21',2000,5000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E21',2010,5000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E31',1990,3000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E31',2000,3000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E31',2010,3000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E51',1990,900.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E51',2000,900.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E51',2010,900.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E70',1990,1000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E70',2000,1000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','E70',2010,1000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHE',1990,90.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHE',2000,90.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHE',2010,90.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHO',1990,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHO',2000,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','RHO',2010,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','SRE',1990,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','SRE',2000,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','SRE',2010,100.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXD',1990,1044.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXD',2000,1044.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXD',2010,1044.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXE',1990,2000.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXE',2000,1750.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXE',2010,1500.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXG',1990,1044.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXG',2000,1044.0,'',''); +INSERT INTO CostInvest VALUES('utopia','TXG',2010,1044.0,'',''); +CREATE TABLE CostVariable +( + region TEXT NOT NULL, + period INTEGER NOT NULL + REFERENCES TimePeriod (period), + tech TEXT NOT NULL + REFERENCES Technology (tech), + vintage INTEGER NOT NULL + REFERENCES TimePeriod (period), + cost REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, tech, vintage) +); +INSERT INTO CostVariable VALUES('utopia',1990,'IMPDSL1',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'IMPDSL1',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'IMPDSL1',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'IMPGSL1',1990,15.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'IMPGSL1',1990,15.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'IMPGSL1',1990,15.0,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'IMPHCO1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'IMPHCO1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'IMPHCO1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'IMPOIL1',1990,8.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'IMPOIL1',1990,8.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'IMPOIL1',1990,8.0,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'IMPURN1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'IMPURN1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'IMPURN1',1990,2.0,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E01',1960,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E01',1970,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E01',1980,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E01',1990,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E01',1970,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E01',1980,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E01',1990,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E01',2000,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E01',1980,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E01',1990,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E01',2000,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E01',2010,0.2999999999999999889,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E21',1990,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E21',1990,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E21',1990,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E21',2000,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E21',2000,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E21',2010,1.5,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E70',1960,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E70',1970,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E70',1980,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'E70',1990,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E70',1970,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E70',1980,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E70',1990,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'E70',2000,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E70',1980,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E70',1990,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E70',2000,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'E70',2010,0.4000000000000000222,'',''); +INSERT INTO CostVariable VALUES('utopia',1990,'SRE',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'SRE',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2000,'SRE',2000,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'SRE',1990,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'SRE',2000,10.0,'',''); +INSERT INTO CostVariable VALUES('utopia',2010,'SRE',2010,10.0,'',''); +CREATE TABLE Demand +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + commodity TEXT + REFERENCES Commodity (name), + demand REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, commodity) +); +INSERT INTO Demand VALUES('utopia',1990,'RH',25.19999999999999929,'',''); +INSERT INTO Demand VALUES('utopia',2000,'RH',37.79999999999999715,'',''); +INSERT INTO Demand VALUES('utopia',2010,'RH',56.69999999999999574,'',''); +INSERT INTO Demand VALUES('utopia',1990,'RL',5.599999999999999645,'',''); +INSERT INTO Demand VALUES('utopia',2000,'RL',8.400000000000000355,'',''); +INSERT INTO Demand VALUES('utopia',2010,'RL',12.59999999999999965,'',''); +INSERT INTO Demand VALUES('utopia',1990,'TX',5.200000000000000177,'',''); +INSERT INTO Demand VALUES('utopia',2000,'TX',7.799999999999999823,'',''); +INSERT INTO Demand VALUES('utopia',2010,'TX',11.68999999999999951,'',''); +CREATE TABLE DemandSpecificDistribution +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + demand_name TEXT + REFERENCES Commodity (name), + dsd REAL, + notes TEXT, + PRIMARY KEY (region, period, season, tod, demand_name), + CHECK (dsd >= 0 AND dsd <= 1) +); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'inter','day','RH',0.1199999999999999956,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'inter','night','RH',0.05999999999999999778,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'winter','day','RH',0.5466999999999999638,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'winter','night','RH',0.2732999999999999874,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'inter','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'inter','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'summer','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'summer','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'winter','day','RL',0.5,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',1990,'winter','night','RL',0.1000000000000000055,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'inter','day','RH',0.1199999999999999956,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'inter','night','RH',0.05999999999999999778,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'winter','day','RH',0.5466999999999999638,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'winter','night','RH',0.2732999999999999874,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'inter','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'inter','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'summer','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'summer','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'winter','day','RL',0.5,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2000,'winter','night','RL',0.1000000000000000055,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'inter','day','RH',0.1199999999999999956,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'inter','night','RH',0.05999999999999999778,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'winter','day','RH',0.5466999999999999638,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'winter','night','RH',0.2732999999999999874,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'inter','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'inter','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'summer','day','RL',0.1499999999999999945,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'summer','night','RL',0.05000000000000000277,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'winter','day','RL',0.5,''); +INSERT INTO DemandSpecificDistribution VALUES('utopia',2010,'winter','night','RL',0.1000000000000000055,''); +CREATE TABLE EndOfLifeOutput +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + value REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech, vintage, output_comm) +); +CREATE TABLE Efficiency +( + region TEXT, + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + efficiency REAL, + notes TEXT, + PRIMARY KEY (region, input_comm, tech, vintage, output_comm), + CHECK (efficiency > 0) +); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPDSL1',1990,'DSL',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPGSL1',1990,'GSL',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPHCO1',1990,'HCO',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPOIL1',1990,'OIL',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPURN1',1990,'URN',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPFEQ',1990,'FEQ',1.0,''); +INSERT INTO Efficiency VALUES('utopia','ethos','IMPHYD',1990,'HYD',1.0,''); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',1960,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',1970,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',1980,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',1990,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',2000,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HCO','E01',2010,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','FEQ','E21',1990,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','FEQ','E21',2000,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','FEQ','E21',2010,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','URN','E21',1990,'ELC',0.4000000000000000222,'# 1/2.5'); +INSERT INTO Efficiency VALUES('utopia','URN','E21',2000,'ELC',0.4000000000000000222,'# 1/2.5'); +INSERT INTO Efficiency VALUES('utopia','URN','E21',2010,'ELC',0.4000000000000000222,'# 1/2.5'); +INSERT INTO Efficiency VALUES('utopia','HYD','E31',1980,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HYD','E31',1990,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HYD','E31',2000,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','HYD','E31',2010,'ELC',0.3200000000000000066,'# 1/3.125'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',1960,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',1970,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',1980,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',1990,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',2000,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','DSL','E70',2010,'ELC',0.2939999999999999836,'# 1/3.4'); +INSERT INTO Efficiency VALUES('utopia','ELC','E51',1980,'ELC',0.7199999999999999734,'# 1/1.3889'); +INSERT INTO Efficiency VALUES('utopia','ELC','E51',1990,'ELC',0.7199999999999999734,'# 1/1.3889'); +INSERT INTO Efficiency VALUES('utopia','ELC','E51',2000,'ELC',0.7199999999999999734,'# 1/1.3889'); +INSERT INTO Efficiency VALUES('utopia','ELC','E51',2010,'ELC',0.7199999999999999734,'# 1/1.3889'); +INSERT INTO Efficiency VALUES('utopia','ELC','RHE',1990,'RH',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RHE',2000,'RH',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RHE',2010,'RH',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','RHO',1970,'RH',0.6999999999999999556,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','RHO',1980,'RH',0.6999999999999999556,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','RHO',1990,'RH',0.6999999999999999556,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','RHO',2000,'RH',0.6999999999999999556,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','RHO',2010,'RH',0.6999999999999999556,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RL1',1980,'RL',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RL1',1990,'RL',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RL1',2000,'RL',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','RL1',2010,'RL',1.0,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',1990,'DSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',2000,'DSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',2010,'DSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',1990,'GSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',2000,'GSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','OIL','SRE',2010,'GSL',1.0,'# direct translation from PRC_INP2, PRC_OUT'); +INSERT INTO Efficiency VALUES('utopia','DSL','TXD',1970,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','TXD',1980,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','TXD',1990,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','TXD',2000,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','DSL','TXD',2010,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','TXE',1990,'TX',0.8269999999999999574,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','TXE',2000,'TX',0.8269999999999999574,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','ELC','TXE',2010,'TX',0.8269999999999999574,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','GSL','TXG',1970,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','GSL','TXG',1980,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','GSL','TXG',1990,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','GSL','TXG',2000,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +INSERT INTO Efficiency VALUES('utopia','GSL','TXG',2010,'TX',0.2310000000000000108,'# direct translation from DMD_EFF'); +CREATE TABLE EfficiencyVariable +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + efficiency REAL, + notes TEXT, + PRIMARY KEY (region, period, season, tod, input_comm, tech, vintage, output_comm), + CHECK (efficiency > 0) +); +CREATE TABLE EmissionActivity +( + region TEXT, + emis_comm TEXT + REFERENCES Commodity (name), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + activity REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, emis_comm, input_comm, tech, vintage, output_comm) +); +INSERT INTO EmissionActivity VALUES('utopia','co2','ethos','IMPDSL1',1990,'DSL',0.07499999999999999723,'',''); +INSERT INTO EmissionActivity VALUES('utopia','co2','ethos','IMPGSL1',1990,'GSL',0.07499999999999999723,'',''); +INSERT INTO EmissionActivity VALUES('utopia','co2','ethos','IMPHCO1',1990,'HCO',0.0889999999999999819,'',''); +INSERT INTO EmissionActivity VALUES('utopia','co2','ethos','IMPOIL1',1990,'OIL',0.07499999999999999723,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','DSL','TXD',1970,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','DSL','TXD',1980,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','DSL','TXD',1990,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','DSL','TXD',2000,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','DSL','TXD',2010,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','GSL','TXG',1970,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','GSL','TXG',1980,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','GSL','TXG',1990,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','GSL','TXG',2000,'TX',1.0,'',''); +INSERT INTO EmissionActivity VALUES('utopia','nox','GSL','TXG',2010,'TX',1.0,'',''); +CREATE TABLE EmissionEmbodied +( + region TEXT, + emis_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + value REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, emis_comm, tech, vintage) +); +CREATE TABLE EmissionEndOfLife +( + region TEXT, + emis_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + value REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, emis_comm, tech, vintage) +); +CREATE TABLE ExistingCapacity +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + capacity REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech, vintage) +); +INSERT INTO ExistingCapacity VALUES('utopia','E01',1960,0.1749999999999999889,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E01',1970,0.1749999999999999889,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E01',1980,0.1499999999999999945,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E31',1980,0.1000000000000000055,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E51',1980,0.5,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E70',1960,0.05000000000000000277,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E70',1970,0.05000000000000000277,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','E70',1980,0.2000000000000000111,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','RHO',1970,12.5,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','RHO',1980,12.5,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','RL1',1980,5.599999999999999645,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','TXD',1970,0.4000000000000000222,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','TXD',1980,0.2000000000000000111,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','TXG',1970,3.100000000000000088,'',''); +INSERT INTO ExistingCapacity VALUES('utopia','TXG',1980,1.5,'',''); +CREATE TABLE TechGroup +( + group_name TEXT + PRIMARY KEY, + notes TEXT +); +CREATE TABLE LoanLifetimeProcess +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + lifetime REAL, + notes TEXT, + PRIMARY KEY (region, tech, vintage) +); +CREATE TABLE LoanRate +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + rate REAL, + notes TEXT, + PRIMARY KEY (region, tech, vintage) +); +CREATE TABLE LifetimeProcess +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + lifetime REAL, + notes TEXT, + PRIMARY KEY (region, tech, vintage) +); +INSERT INTO LifetimeProcess VALUES('utopia','RL1',1980,20.0,'#forexistingcap'); +INSERT INTO LifetimeProcess VALUES('utopia','TXD',1970,30.0,'#forexistingcap'); +INSERT INTO LifetimeProcess VALUES('utopia','TXD',1980,30.0,'#forexistingcap'); +INSERT INTO LifetimeProcess VALUES('utopia','TXG',1970,30.0,'#forexistingcap'); +INSERT INTO LifetimeProcess VALUES('utopia','TXG',1980,30.0,'#forexistingcap'); +CREATE TABLE LifetimeTech +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + lifetime REAL, + notes TEXT, + PRIMARY KEY (region, tech) +); +INSERT INTO LifetimeTech VALUES('utopia','E01',40.0,''); +INSERT INTO LifetimeTech VALUES('utopia','E21',40.0,''); +INSERT INTO LifetimeTech VALUES('utopia','E31',100.0,''); +INSERT INTO LifetimeTech VALUES('utopia','E51',100.0,''); +INSERT INTO LifetimeTech VALUES('utopia','E70',40.0,''); +INSERT INTO LifetimeTech VALUES('utopia','RHE',30.0,''); +INSERT INTO LifetimeTech VALUES('utopia','RHO',30.0,''); +INSERT INTO LifetimeTech VALUES('utopia','RL1',10.0,''); +INSERT INTO LifetimeTech VALUES('utopia','SRE',50.0,''); +INSERT INTO LifetimeTech VALUES('utopia','TXD',15.0,''); +INSERT INTO LifetimeTech VALUES('utopia','TXE',15.0,''); +INSERT INTO LifetimeTech VALUES('utopia','TXG',15.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPDSL1',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPGSL1',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPHCO1',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPOIL1',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPURN1',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPHYD',1000.0,''); +INSERT INTO LifetimeTech VALUES('utopia','IMPFEQ',1000.0,''); +CREATE TABLE Operator +( + operator TEXT PRIMARY KEY, + notes TEXT +); +INSERT INTO Operator VALUES('e','equal to'); +INSERT INTO Operator VALUES('le','less than or equal to'); +INSERT INTO Operator VALUES('ge','greater than or equal to'); +CREATE TABLE LimitGrowthCapacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitDegrowthCapacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitGrowthNewCapacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitDegrowthNewCapacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitGrowthNewCapacityDelta +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitDegrowthNewCapacityDelta +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitStorageLevelFraction +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + fraction REAL, + notes TEXT, + PRIMARY KEY(region, period, season, tod, tech, vintage, operator) +); +CREATE TABLE LimitActivity +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + activity REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, tech_or_group, operator) +); +CREATE TABLE LimitActivityShare +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + sub_group TEXT, + super_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + share REAL, + notes TEXT, + PRIMARY KEY (region, period, sub_group, super_group, operator) +); +CREATE TABLE LimitAnnualCapacityFactor +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + factor REAL, + notes TEXT, + PRIMARY KEY (region, tech, vintage, operator), + CHECK (factor >= 0 AND factor <= 1) +); +CREATE TABLE LimitCapacity +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + capacity REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, tech_or_group, operator) +); +INSERT INTO LimitCapacity VALUES('utopia',1990,'E31','ge',0.1300000000000000044,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2000,'E31','ge',0.1300000000000000044,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2010,'E31','ge',0.1300000000000000044,'',''); +INSERT INTO LimitCapacity VALUES('utopia',1990,'SRE','ge',0.1000000000000000055,'',''); +INSERT INTO LimitCapacity VALUES('utopia',1990,'E31','le',0.1300000000000000044,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2000,'E31','le',0.1700000000000000122,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2010,'E31','le',0.21000000000000002,'',''); +INSERT INTO LimitCapacity VALUES('utopia',1990,'RHE','le',0.0,'',''); +INSERT INTO LimitCapacity VALUES('utopia',1990,'TXD','le',0.5999999999999999778,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2000,'TXD','le',1.760000000000000008,'',''); +INSERT INTO LimitCapacity VALUES('utopia',2010,'TXD','le',4.759999999999999787,'',''); +CREATE TABLE LimitCapacityShare +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + sub_group TEXT, + super_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + share REAL, + notes TEXT, + PRIMARY KEY (region, period, sub_group, super_group, operator) +); +CREATE TABLE LimitNewCapacity +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + new_cap REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, tech_or_group, operator) +); +CREATE TABLE LimitNewCapacityShare +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + sub_group TEXT, + super_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + share REAL, + notes TEXT, + PRIMARY KEY (region, period, sub_group, super_group, operator) +); +CREATE TABLE LimitResource +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + cum_act REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE LimitSeasonalCapacityFactor +( + region TEXT + REFERENCES Region (region), + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tech TEXT + REFERENCES Technology (tech), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + factor REAL, + notes TEXT, + PRIMARY KEY(region, period, season, tech, operator) +); +CREATE TABLE LimitTechInputSplit +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + proportion REAL, + notes TEXT, + PRIMARY KEY (region, period, input_comm, tech, operator) +); +CREATE TABLE LimitTechInputSplitAnnual +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + proportion REAL, + notes TEXT, + PRIMARY KEY (region, period, input_comm, tech, operator) +); +CREATE TABLE LimitTechOutputSplit +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech TEXT + REFERENCES Technology (tech), + output_comm TEXT + REFERENCES Commodity (name), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + proportion REAL, + notes TEXT, + PRIMARY KEY (region, period, tech, output_comm, operator) +); +INSERT INTO LimitTechOutputSplit VALUES('utopia',1990,'SRE','DSL','ge',0.6999999999999999556,''); +INSERT INTO LimitTechOutputSplit VALUES('utopia',2000,'SRE','DSL','ge',0.6999999999999999556,''); +INSERT INTO LimitTechOutputSplit VALUES('utopia',2010,'SRE','DSL','ge',0.6999999999999999556,''); +INSERT INTO LimitTechOutputSplit VALUES('utopia',1990,'SRE','GSL','ge',0.2999999999999999889,''); +INSERT INTO LimitTechOutputSplit VALUES('utopia',2000,'SRE','GSL','ge',0.2999999999999999889,''); +INSERT INTO LimitTechOutputSplit VALUES('utopia',2010,'SRE','GSL','ge',0.2999999999999999889,''); +CREATE TABLE LimitTechOutputSplitAnnual +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + tech TEXT + REFERENCES Technology (tech), + output_comm TEXT + REFERENCES Commodity (name), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + proportion REAL, + notes TEXT, + PRIMARY KEY (region, period, tech, output_comm, operator) +); +CREATE TABLE LimitEmission +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + emis_comm TEXT + REFERENCES Commodity (name), + operator TEXT NOT NULL DEFAULT "le" + REFERENCES Operator (operator), + value REAL, + units TEXT, + notes TEXT, + PRIMARY KEY (region, period, emis_comm, operator) +); +CREATE TABLE LinkedTech +( + primary_region TEXT, + primary_tech TEXT + REFERENCES Technology (tech), + emis_comm TEXT + REFERENCES Commodity (name), + driven_tech TEXT + REFERENCES Technology (tech), + notes TEXT, + PRIMARY KEY (primary_region, primary_tech, emis_comm) +); +CREATE TABLE OutputCurtailment +( + scenario TEXT, + region TEXT, + sector TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES TimePeriod (period), + tod TEXT + REFERENCES TimeOfDay (tod), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + curtailment REAL, + PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) +); +CREATE TABLE OutputNetCapacity +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + capacity REAL, + PRIMARY KEY (region, scenario, period, tech, vintage) +); +CREATE TABLE OutputBuiltCapacity +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + capacity REAL, + PRIMARY KEY (region, scenario, tech, vintage) +); +CREATE TABLE OutputRetiredCapacity +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + cap_eol REAL, + cap_early REAL, + PRIMARY KEY (region, scenario, period, tech, vintage) +); +CREATE TABLE OutputFlowIn +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + flow REAL, + PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) +); +CREATE TABLE OutputFlowOut +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + input_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + output_comm TEXT + REFERENCES Commodity (name), + flow REAL, + PRIMARY KEY (region, scenario, period, season, tod, input_comm, tech, vintage, output_comm) +); +CREATE TABLE OutputStorageLevel +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + level REAL, + PRIMARY KEY (scenario, region, period, season, tod, tech, vintage) +); +CREATE TABLE PlanningReserveMargin +( + region TEXT + PRIMARY KEY + REFERENCES Region (region), + margin REAL, + notes TEXT +); +CREATE TABLE RampDownHourly +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + rate REAL, + notes TEXT, + PRIMARY KEY (region, tech) +); +CREATE TABLE RampUpHourly +( + region TEXT, + tech TEXT + REFERENCES Technology (tech), + rate REAL, + notes TEXT, + PRIMARY KEY (region, tech) +); +CREATE TABLE Region +( + region TEXT + PRIMARY KEY, + notes TEXT +); +INSERT INTO Region VALUES('utopia',NULL); +CREATE TABLE ReserveCapacityDerate +( + region TEXT, + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER, + factor REAL, + notes TEXT, + PRIMARY KEY (region, period, season, tech, vintage), + CHECK (factor >= 0 AND factor <= 1) +); +CREATE TABLE TimeSegmentFraction +( + period INTEGER + REFERENCES TimePeriod (period), + season TEXT + REFERENCES SeasonLabel (season), + tod TEXT + REFERENCES TimeOfDay (tod), + segfrac REAL, + notes TEXT, + PRIMARY KEY (period, season, tod), + CHECK (segfrac >= 0 AND segfrac <= 1) +); +INSERT INTO TimeSegmentFraction VALUES(1990,'inter','day',0.166699999999999987,'# I-D'); +INSERT INTO TimeSegmentFraction VALUES(1990,'inter','night',0.08329999999999999905,'# I-N'); +INSERT INTO TimeSegmentFraction VALUES(1990,'summer','day',0.166699999999999987,'# S-D'); +INSERT INTO TimeSegmentFraction VALUES(1990,'summer','night',0.08329999999999999905,'# S-N'); +INSERT INTO TimeSegmentFraction VALUES(1990,'winter','day',0.3332999999999999852,'# W-D'); +INSERT INTO TimeSegmentFraction VALUES(1990,'winter','night',0.166699999999999987,'# W-N'); +INSERT INTO TimeSegmentFraction VALUES(2000,'inter','day',0.166699999999999987,'# I-D'); +INSERT INTO TimeSegmentFraction VALUES(2000,'inter','night',0.08329999999999999905,'# I-N'); +INSERT INTO TimeSegmentFraction VALUES(2000,'summer','day',0.166699999999999987,'# S-D'); +INSERT INTO TimeSegmentFraction VALUES(2000,'summer','night',0.08329999999999999905,'# S-N'); +INSERT INTO TimeSegmentFraction VALUES(2000,'winter','day',0.3332999999999999852,'# W-D'); +INSERT INTO TimeSegmentFraction VALUES(2000,'winter','night',0.166699999999999987,'# W-N'); +INSERT INTO TimeSegmentFraction VALUES(2010,'inter','day',0.166699999999999987,'# I-D'); +INSERT INTO TimeSegmentFraction VALUES(2010,'inter','night',0.08329999999999999905,'# I-N'); +INSERT INTO TimeSegmentFraction VALUES(2010,'summer','day',0.166699999999999987,'# S-D'); +INSERT INTO TimeSegmentFraction VALUES(2010,'summer','night',0.08329999999999999905,'# S-N'); +INSERT INTO TimeSegmentFraction VALUES(2010,'winter','day',0.3332999999999999852,'# W-D'); +INSERT INTO TimeSegmentFraction VALUES(2010,'winter','night',0.166699999999999987,'# W-N'); +CREATE TABLE StorageDuration +( + region TEXT, + tech TEXT, + duration REAL, + notes TEXT, + PRIMARY KEY (region, tech) +); +CREATE TABLE LifetimeSurvivalCurve +( + region TEXT NOT NULL, + period INTEGER NOT NULL, + tech TEXT NOT NULL + REFERENCES Technology (tech), + vintage INTEGER NOT NULL + REFERENCES TimePeriod (period), + fraction REAL, + notes TEXT, + PRIMARY KEY (region, period, tech, vintage) +); +CREATE TABLE TechnologyType +( + label TEXT + PRIMARY KEY, + description TEXT +); +INSERT INTO TechnologyType VALUES('p','production technology'); +INSERT INTO TechnologyType VALUES('pb','baseload production technology'); +INSERT INTO TechnologyType VALUES('ps','storage production technology'); +CREATE TABLE TimeOfDay +( + sequence INTEGER UNIQUE, + tod TEXT + PRIMARY KEY +); +INSERT INTO TimeOfDay VALUES(1,'day'); +INSERT INTO TimeOfDay VALUES(2,'night'); +CREATE TABLE TimePeriod +( + sequence INTEGER UNIQUE, + period INTEGER + PRIMARY KEY, + flag TEXT + REFERENCES TimePeriodType (label) +); +INSERT INTO TimePeriod VALUES(1,1960,'e'); +INSERT INTO TimePeriod VALUES(2,1970,'e'); +INSERT INTO TimePeriod VALUES(3,1980,'e'); +INSERT INTO TimePeriod VALUES(4,1990,'f'); +INSERT INTO TimePeriod VALUES(5,2000,'f'); +INSERT INTO TimePeriod VALUES(6,2010,'f'); +INSERT INTO TimePeriod VALUES(7,2020,'f'); +CREATE TABLE TimeSeason +( + period INTEGER + REFERENCES TimePeriod (period), + sequence INTEGER, + season TEXT + REFERENCES SeasonLabel (season), + notes TEXT, + PRIMARY KEY (period, sequence, season) +); +INSERT INTO TimeSeason VALUES(1990,1,'inter',NULL); +INSERT INTO TimeSeason VALUES(1990,2,'summer',NULL); +INSERT INTO TimeSeason VALUES(1990,3,'winter',NULL); +INSERT INTO TimeSeason VALUES(2000,1,'inter',NULL); +INSERT INTO TimeSeason VALUES(2000,2,'summer',NULL); +INSERT INTO TimeSeason VALUES(2000,3,'winter',NULL); +INSERT INTO TimeSeason VALUES(2010,1,'inter',NULL); +INSERT INTO TimeSeason VALUES(2010,2,'summer',NULL); +INSERT INTO TimeSeason VALUES(2010,3,'winter',NULL); +CREATE TABLE TimeSeasonSequential +( + period INTEGER + REFERENCES TimePeriod (period), + sequence INTEGER, + seas_seq TEXT, + season TEXT + REFERENCES SeasonLabel (season), + num_days REAL NOT NULL, + notes TEXT, + PRIMARY KEY (period, sequence, seas_seq, season), + CHECK (num_days > 0) +); +CREATE TABLE TimePeriodType +( + label TEXT + PRIMARY KEY, + description TEXT +); +INSERT INTO TimePeriodType VALUES('e','existing vintages'); +INSERT INTO TimePeriodType VALUES('f','future'); +CREATE TABLE OutputEmission +( + scenario TEXT, + region TEXT, + sector TEXT + REFERENCES SectorLabel (sector), + period INTEGER + REFERENCES TimePeriod (period), + emis_comm TEXT + REFERENCES Commodity (name), + tech TEXT + REFERENCES Technology (tech), + vintage INTEGER + REFERENCES TimePeriod (period), + emission REAL, + PRIMARY KEY (region, scenario, period, emis_comm, tech, vintage) +); +CREATE TABLE RPSRequirement +( + region TEXT NOT NULL + REFERENCES Region (region), + period INTEGER NOT NULL + REFERENCES TimePeriod (period), + tech_group TEXT NOT NULL + REFERENCES TechGroup (group_name), + requirement REAL NOT NULL, + notes TEXT +); +CREATE TABLE TechGroupMember +( + group_name TEXT + REFERENCES TechGroup (group_name), + tech TEXT + REFERENCES Technology (tech), + PRIMARY KEY (group_name, tech) +); +CREATE TABLE Technology +( + tech TEXT NOT NULL PRIMARY KEY, + flag TEXT NOT NULL, + sector TEXT, + category TEXT, + sub_category TEXT, + unlim_cap INTEGER NOT NULL DEFAULT 0, + annual INTEGER NOT NULL DEFAULT 0, + reserve INTEGER NOT NULL DEFAULT 0, + curtail INTEGER NOT NULL DEFAULT 0, + retire INTEGER NOT NULL DEFAULT 0, + flex INTEGER NOT NULL DEFAULT 0, + exchange INTEGER NOT NULL DEFAULT 0, + seas_stor INTEGER NOT NULL DEFAULT 0, + description TEXT, + FOREIGN KEY (flag) REFERENCES TechnologyType (label) +); +INSERT INTO Technology VALUES('IMPDSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported diesel'); +INSERT INTO Technology VALUES('IMPGSL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported gasoline'); +INSERT INTO Technology VALUES('IMPHCO1','p','supply','coal','',1,0,0,0,0,0,0,0,' imported coal'); +INSERT INTO Technology VALUES('IMPOIL1','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported crude oil'); +INSERT INTO Technology VALUES('IMPURN1','p','supply','nuclear','',1,0,0,0,0,0,0,0,' imported uranium'); +INSERT INTO Technology VALUES('IMPFEQ','p','supply','petroleum','',1,0,0,0,0,0,0,0,' imported fossil equivalent'); +INSERT INTO Technology VALUES('IMPHYD','p','supply','hydro','',1,0,0,0,0,0,0,0,' imported water -- doesnt exist in Utopia'); +INSERT INTO Technology VALUES('E01','pb','electric','coal','',0,0,0,0,0,0,0,0,' coal power plant'); +INSERT INTO Technology VALUES('E21','pb','electric','nuclear','',0,0,0,0,0,0,0,0,' nuclear power plant'); +INSERT INTO Technology VALUES('E31','pb','electric','hydro','',0,0,0,0,0,0,0,0,' hydro power'); +INSERT INTO Technology VALUES('E51','ps','electric','electric','',0,0,0,0,0,0,0,0,' electric storage'); +INSERT INTO Technology VALUES('E70','p','electric','petroleum','',0,0,0,0,0,0,0,0,' diesel power plant'); +INSERT INTO Technology VALUES('RHE','p','residential','electric','',0,0,0,0,0,0,0,0,' electric residential heating'); +INSERT INTO Technology VALUES('RHO','p','residential','petroleum','',0,0,0,0,0,0,0,0,' diesel residential heating'); +INSERT INTO Technology VALUES('RL1','p','residential','electric','',0,0,0,0,0,0,0,0,' residential lighting'); +INSERT INTO Technology VALUES('SRE','p','supply','petroleum','',0,0,0,0,0,0,0,0,' crude oil processor'); +INSERT INTO Technology VALUES('TXD','p','transport','petroleum','',0,0,0,0,0,0,0,0,' diesel powered vehicles'); +INSERT INTO Technology VALUES('TXE','p','transport','electric','',0,0,0,0,0,0,0,0,' electric powered vehicles'); +INSERT INTO Technology VALUES('TXG','p','transport','petroleum','',0,0,0,0,0,0,0,0,' gasoline powered vehicles'); +CREATE TABLE OutputCost +( + scenario TEXT, + region TEXT, + sector TEXT REFERENCES SectorLabel (sector), + period INTEGER REFERENCES TimePeriod (period), + tech TEXT REFERENCES Technology (tech), + vintage INTEGER REFERENCES TimePeriod (period), + d_invest REAL, + d_fixed REAL, + d_var REAL, + d_emiss REAL, + invest REAL, + fixed REAL, + var REAL, + emiss REAL, + PRIMARY KEY (scenario, region, period, tech, vintage), + FOREIGN KEY (vintage) REFERENCES TimePeriod (period), + FOREIGN KEY (tech) REFERENCES Technology (tech) +); +COMMIT; From 591d261a6886732d7ef3a03c17ef3b3006dee9a8 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 29 Jun 2026 16:07:14 -0400 Subject: [PATCH 02/23] Create a framework for extending model components Signed-off-by: Davey Elder --- docs/source/computational_implementation.rst | 5 + docs/source/extensions.rst | 232 ++++++++++++++++++ docs/source/index.rst | 1 + temoa/_internal/run_actions.py | 3 +- temoa/_internal/temoa_sequencer.py | 8 +- temoa/core/config.py | 6 + temoa/core/model.py | 13 +- temoa/data_io/component_manifest.py | 8 +- temoa/data_io/hybrid_loader.py | 29 ++- temoa/extensions/framework.py | 218 ++++++++++++++++ temoa/extensions/method_of_morris/morris.py | 2 +- .../method_of_morris/morris_evaluate.py | 6 +- .../mga_sequencer.py | 5 +- temoa/extensions/myopic/myopic_sequencer.py | 1 + .../single_vector_mga/sv_mga_sequencer.py | 1 + .../stochastics/scenario_creator.py | 2 +- temoa/extensions/template/__init__.py | 21 ++ .../template/components/__init__.py | 0 .../template/components/example_limit.py | 62 +++++ temoa/extensions/template/core/__init__.py | 0 temoa/extensions/template/core/model.py | 71 ++++++ temoa/extensions/template/data_manifest.py | 44 ++++ temoa/extensions/template/extension.py | 34 +++ temoa/extensions/template/tables.sql | 14 ++ temoa/tutorial_assets/config_sample.toml | 6 + tests/conftest.py | 8 + 26 files changed, 785 insertions(+), 15 deletions(-) create mode 100644 docs/source/extensions.rst create mode 100644 temoa/extensions/framework.py create mode 100644 temoa/extensions/template/__init__.py create mode 100644 temoa/extensions/template/components/__init__.py create mode 100644 temoa/extensions/template/components/example_limit.py create mode 100644 temoa/extensions/template/core/__init__.py create mode 100644 temoa/extensions/template/core/model.py create mode 100644 temoa/extensions/template/data_manifest.py create mode 100644 temoa/extensions/template/extension.py create mode 100644 temoa/extensions/template/tables.sql diff --git a/docs/source/computational_implementation.rst b/docs/source/computational_implementation.rst index 023964f3c..943452766 100644 --- a/docs/source/computational_implementation.rst +++ b/docs/source/computational_implementation.rst @@ -320,6 +320,11 @@ dimensionality of 3, and (following the :ref:`naming scheme :code:`region`, the second an element of :code:`time_optimize`, and third a demand commodity. +.. seealso:: + The same component pattern (index set, parameter, and constraint declared in a + ``model.py`` and implemented in component modules) can be packaged as an + optional, self-contained add-on. See :ref:`extensions` for the extension + framework and a copy-from template. diff --git a/docs/source/extensions.rst b/docs/source/extensions.rst new file mode 100644 index 000000000..5e8e1a9c2 --- /dev/null +++ b/docs/source/extensions.rst @@ -0,0 +1,232 @@ +.. _extensions: + +Extension Framework +=================== + +Temoa includes a lightweight framework for adding **optional model components** +without modifying the core model. An extension can contribute its own database +tables, Pyomo components (sets, parameters, and constraints), and data-loading +rules. Extensions are declared once and then enabled per run through +configuration. + +This page describes how the framework works and how to author a new extension. +A ready-to-copy scaffold lives at ``temoa/extensions/template``. + +Overview +-------- + +Use an extension when you want to add modeling capability that is: + +* **Optional** -- only active when explicitly enabled. +* **Self-contained** -- owns its own tables and model components. +* **Non-invasive** -- adds to the core model rather than editing it. + +If a feature is fundamental to every Temoa run, it belongs in +``temoa/components`` (a core component) instead of an extension. + +Each extension is described by a single :class:`ExtensionSpec` (declarative +metadata plus hook functions). The spec is registered with the framework, after +which users can enable the extension by id. + +Lifecycle +--------- + +When a run is configured with ``extensions = ["..."]``, the framework threads the +enabled specs through configuration, model construction, and data loading: + +.. code-block:: text + + config: extensions = ["my_ext"] + | + v + resolve_extension_specs() validate ids -> ExtensionSpec list + | + v + TemoaModel(extensions=[...]) + | + +--> apply_model_extension_hooks() + | calls spec.register_model_components(model) + | -> attaches Params / Sets / Constraints to the model + | + v + HybridLoader + +--> ensure_enabled_extension_tables_exist() (offer to append schema) + +--> assert_disabled_extension_tables_are_empty() + +--> merge_regional_group_tables() + +--> build_manifest() -> appends spec.build_manifest_items(model) + -> loads each owned table into its component + +The relevant code lives in :mod:`temoa.extensions.framework`, +:mod:`temoa.core.model`, and :mod:`temoa.data_io.hybrid_loader`. + +``ExtensionSpec`` reference +--------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Field + - Purpose + * - ``extension_id`` + - Unique, lowercase id. This is what users put in ``extensions = [...]``. + * - ``owned_tables`` + - Tuple of database tables owned exclusively by this extension. Used by the + disabled/enabled table guards. + * - ``regional_group_tables`` + - Map of ``table -> column`` for tables whose region column may hold a + regional *group* name. Merged into the loader's regional-group handling. + * - ``register_model_components`` + - Hook ``Callable[[TemoaModel], None]`` that attaches model components. + * - ``build_manifest_items`` + - Hook ``Callable[[TemoaModel], list[LoadItem]]`` describing how to load the + extension's data. + * - ``schema_sql_path`` + - Path to a ``.sql`` file applied (with consent) when the extension is + enabled but its tables are missing. + * - ``fail_if_tables_populated_when_disabled`` + - When ``True``, loading fails if the extension is disabled but its owned + tables contain data, preventing silently-ignored inputs. + +Recommended package layout +-------------------------- + +Mirror the structure of the core model (``temoa/core`` + ``temoa/components``) so +extension code is organized the same way as the rest of the codebase: + +.. code-block:: text + + temoa/extensions// + __init__.py # re-export the ExtensionSpec + extension.py # the ExtensionSpec definition + data_manifest.py # build_manifest_items() + tables.sql # CREATE TABLE IF NOT EXISTS for owned tables + core/ + __init__.py + model.py # typing subtype + register_model_components() + components/ + __init__.py + .py # one module per constraint family + +Centralize the component *declarations* in ``core/model.py`` (just as +``temoa/core/model.py`` does) and keep the index-set and constraint-rule logic in +``components/`` modules (just as ``temoa/components`` does). + +.. _extensions-typing: + +The typing pattern +------------------ + +Core model components are declared as attribute assignments inside +``TemoaModel.__init__`` (for example ``self.time_optimize = Set(...)``), which is +why the type checker knows about them. An extension instead adds attributes from +*outside* the class, so without help the type checker reports +``"TemoaModel" has no attribute ...`` and provides no autocomplete. + +Three rules make typing carry over cleanly: + +1. **Declare a ``TYPE_CHECKING``-only subtype.** In ``core/model.py``, define a + subclass of ``TemoaModel`` that annotates every component the extension adds. + It inherits all core attributes, so component code sees both core and + extension members. + + .. code-block:: python + + if TYPE_CHECKING: + from temoa.core.model import TemoaModel + + class ExampleModel(TemoaModel): + example_new_capacity_limit: Param + example_new_capacity_limit_constraint_rpt: Set + example_new_capacity_limit_constraint: Constraint + +2. **Annotate component functions with the subtype.** Index-set and + constraint-rule functions take ``model: ExampleModel``. This restores both + mypy coverage and editor autocomplete. + +3. **Keep spec hooks on the base type and ``cast`` internally.** Functions stored + on the ``ExtensionSpec`` (``register_model_components`` and + ``build_manifest_items``) must keep ``model: TemoaModel`` to match the hook + callable types. Narrowing the parameter is a contravariance error. ``cast`` + once at the top: + + .. code-block:: python + + def register_model_components(model: TemoaModel) -> None: + m = cast('ExampleModel', model) + m.example_new_capacity_limit = Param(...) + +.. note:: + Extension attribute names share the single ``TemoaModel`` namespace at + runtime. Keep them unique across extensions to avoid collisions. + +Adding a new extension +---------------------- + +#. **Copy the template.** Duplicate ``temoa/extensions/template`` to + ``temoa/extensions//``. +#. **Rename ids and tables.** Update ``extension_id``, ``owned_tables``, + ``regional_group_tables``, params, sets, and constraints to your domain. +#. **Declare components.** Add annotations to the typing subtype in + ``core/model.py`` and create the components in ``register_model_components``. +#. **Write the constraint logic.** Add index-set and rule functions under + ``components/`` and annotate them with your subtype. +#. **Describe data loading.** Add one ``LoadItem`` per owned table in + ``data_manifest.py``. +#. **Define the schema.** Add a ``CREATE TABLE IF NOT EXISTS`` per owned table to + ``tables.sql``. +#. **Register the spec.** Import your spec in + :func:`temoa.extensions.framework.get_known_extension_specs` and add it to the + ``specs`` list. (Until you do this, the extension is inert -- enabling it + raises an "Unknown extension id" error.) +#. **Enable it.** Add the id to your configuration TOML: + + .. code-block:: toml + + extensions = [""] + +Data loading and ``LoadItem`` +----------------------------- + +``build_manifest_items`` returns one :class:`temoa.data_io.loader_manifest.LoadItem` +per database table the extension reads. Common fields: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Field + - Purpose + * - ``component`` + - The Pyomo ``Set`` or ``Param`` to populate. + * - ``table`` + - Source table name in the database. + * - ``columns`` + - Columns to select; for a ``Param`` the final column is the value. + * - ``index_length`` + - Number of leading columns that form the index. + * - ``validator_name`` / ``validation_map`` + - Source-trace validation: a viable-set name on the loader and which index + columns it applies to. + * - ``is_table_required`` + - Set ``False`` for optional extension inputs so a missing table is not an + error. + +Verification +------------ + +After authoring an extension, confirm: + +#. **Types** -- ``mypy temoa/extensions/`` reports no issues. +#. **Imports** -- ``python -c "import temoa.extensions..extension"``. +#. **Wiring** -- a model built with the extension enabled attaches the expected + components, and the test suite passes. + +The template extension +----------------------- + +``temoa/extensions/template`` is a complete, type-checked, but deliberately +**unregistered** scaffold. Because it is not listed in +``get_known_extension_specs``, it cannot be enabled until you register it, so it +never affects normal runs. Copy the folder as the starting point for a new +extension; every file carries ``# TEMPLATE:`` comments explaining what to change. diff --git a/docs/source/index.rst b/docs/source/index.rst index f9043f39d..3d7f9e3b4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -27,6 +27,7 @@ Temoa Project Documentation myopic stochastics unit_checking + extensions .. toctree:: :maxdepth: 1 diff --git a/temoa/_internal/run_actions.py b/temoa/_internal/run_actions.py index d0299a1bb..af2bc0ddd 100644 --- a/temoa/_internal/run_actions.py +++ b/temoa/_internal/run_actions.py @@ -124,6 +124,7 @@ def build_instance( silent: bool = False, keep_lp_file: bool = False, lp_path: Path | None = None, + extensions: Iterable[str] | None = None, ) -> TemoaModel: """ Build a Temoa Instance from data @@ -134,7 +135,7 @@ def build_instance( :param model_name: Optional name for this instance :return: a built TemoaModel """ - model = TemoaModel() + model = TemoaModel(extensions=tuple(extensions or ())) model.dual = Suffix(direction=Suffix.IMPORT) # self.model.rc = Suffix(direction=Suffix.IMPORT) diff --git a/temoa/_internal/temoa_sequencer.py b/temoa/_internal/temoa_sequencer.py index 08ae1290b..bd44044e3 100644 --- a/temoa/_internal/temoa_sequencer.py +++ b/temoa/_internal/temoa_sequencer.py @@ -140,7 +140,11 @@ def build_model(self) -> TemoaModel: tune_sqlite_connection(con, self.config) hybrid_loader = HybridLoader(db_connection=con, config=self.config) data_portal = hybrid_loader.load_data_portal(myopic_index=None) - instance = build_instance(data_portal, silent=self.config.silent) + instance = build_instance( + data_portal, + silent=self.config.silent, + extensions=self.config.extensions, + ) logger.info('Model build process complete.') return instance @@ -220,6 +224,7 @@ def _run_check_mode(self) -> None: silent=self.config.silent, keep_lp_file=self.config.save_lp_file, lp_path=self.config.output_path, + extensions=self.config.extensions, ) if not self.config.price_check: logger.warning('Price check is automatically enabled for CHECK mode.') @@ -238,6 +243,7 @@ def _run_perfect_foresight(self) -> None: silent=self.config.silent, keep_lp_file=self.config.save_lp_file, lp_path=self.config.output_path, + extensions=self.config.extensions, ) if self.config.price_check: price_checker(instance) diff --git a/temoa/core/config.py b/temoa/core/config.py index de3e34302..449024bd8 100644 --- a/temoa/core/config.py +++ b/temoa/core/config.py @@ -5,6 +5,7 @@ from pathlib import Path from temoa.core.modes import TemoaMode +from temoa.extensions.framework import normalize_extension_ids, resolve_extension_specs logger = getLogger(__name__) @@ -71,6 +72,7 @@ def __init__( output_threshold_emission: float | None = None, output_threshold_cost: float | None = None, sqlite: dict[str, object] | None = None, + extensions: list[str] | tuple[str, ...] | None = None, ): if '-' in scenario: raise ValueError( @@ -160,6 +162,9 @@ def __init__( self.output_threshold_emission = output_threshold_emission self.output_threshold_cost = output_threshold_cost self.sqlite_inputs = sqlite or {} + self.extensions = normalize_extension_ids(extensions) + # Validate extension ids eagerly so config failures happen before model build. + resolve_extension_specs(self.extensions) # SQLite performance settings # journal_mode: DELETE | TRUNCATE | PERSIST | MEMORY | WAL | OFF @@ -326,6 +331,7 @@ def __repr__(self) -> str: msg += '{:>{}s}: {}\n'.format('Scenario', width, self.scenario) msg += '{:>{}s}: {}\n'.format('Scenario mode', width, self.scenario_mode.name) + msg += '{:>{}s}: {}\n'.format('Enabled extensions', width, ', '.join(self.extensions)) msg += '{:>{}s}: {}\n'.format('Config file', width, self.config_file) msg += '{:>{}s}: {}\n'.format('Data source', width, self.input_database) msg += '{:>{}s}: {}\n'.format('Output database target', width, self.output_database) diff --git a/temoa/core/model.py b/temoa/core/model.py index 85f48ea3a..29a801de2 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -9,6 +9,7 @@ """ import logging +from collections.abc import Sequence from typing import TYPE_CHECKING from pyomo.core import BuildCheck, Set, Var @@ -39,6 +40,7 @@ technology, time, ) +from temoa.extensions.framework import apply_model_extension_hooks, resolve_extension_specs from temoa.model_checking.validators import ( no_slash_or_pipe, region_check, @@ -100,8 +102,15 @@ class TemoaModel(AbstractModel): # this is used in several places outside this class, and this provides no-build access to it default_lifetime_tech = 40 - def __init__(self, *args: object, **kwargs: object) -> None: + def __init__( + self, + *args: object, + extensions: Sequence[str] | None = None, + **kwargs: object, + ) -> None: AbstractModel.__init__(self, *args, **kwargs) + self.enabled_extensions = tuple(extensions or ()) + self.extension_specs = resolve_extension_specs(self.enabled_extensions) ################################################ # Internally used Data Containers # @@ -1150,6 +1159,8 @@ def __init__(self, *args: object, **kwargs: object) -> None: rule=emissions.linked_emissions_tech_constraint, ) + apply_model_extension_hooks(self, self.extension_specs) + self.progress_marker_9 = BuildAction(['Finished Constraints'], rule=progress_check) diff --git a/temoa/data_io/component_manifest.py b/temoa/data_io/component_manifest.py index 7a97e2bad..4f7334c42 100644 --- a/temoa/data_io/component_manifest.py +++ b/temoa/data_io/component_manifest.py @@ -16,11 +16,14 @@ to add a new `LoadItem` to this manifest. """ +from collections.abc import Sequence + from temoa.core.model import TemoaModel from temoa.data_io.loader_manifest import LoadItem +from temoa.extensions.framework import append_extension_manifest_items, resolve_extension_specs -def build_manifest(model: TemoaModel) -> list[LoadItem]: +def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None) -> list[LoadItem]: """ Builds the manifest of all data components to be loaded into the Pyomo model. @@ -794,4 +797,5 @@ def build_manifest(model: TemoaModel) -> list[LoadItem]: is_table_required=False, ), ] - return manifest + extension_specs = resolve_extension_specs(extension_ids) + return append_extension_manifest_items(model, manifest, extension_specs) diff --git a/temoa/data_io/hybrid_loader.py b/temoa/data_io/hybrid_loader.py index f657ed68d..1a70ccbaa 100644 --- a/temoa/data_io/hybrid_loader.py +++ b/temoa/data_io/hybrid_loader.py @@ -34,6 +34,12 @@ from temoa.core.model import TemoaModel from temoa.core.modes import TemoaMode from temoa.data_io.component_manifest import build_manifest +from temoa.extensions.framework import ( + assert_disabled_extension_tables_are_empty, + ensure_enabled_extension_tables_exist, + merge_regional_group_tables, + resolve_extension_specs, +) from temoa.extensions.myopic.myopic_index import MyopicIndex from temoa.model_checking import element_checker, network_model_data from temoa.model_checking.commodity_network_manager import CommodityNetworkManager @@ -49,7 +55,7 @@ logger = getLogger(__name__) # A manifest of tables that may contain region groups, used by a custom loader. -tables_with_regional_groups = { +BASE_REGIONAL_GROUP_TABLES = { 'limit_annual_capacity_factor': 'region', 'limit_emission': 'region', 'limit_seasonal_capacity_factor': 'region', @@ -90,10 +96,21 @@ def __init__(self, db_connection: Connection, config: TemoaConfig) -> None: self.con = db_connection self.config = config self.myopic_index: MyopicIndex | None = None + self.extension_specs = resolve_extension_specs(self.config.extensions) + self.model = TemoaModel(extensions=self.config.extensions) + self.tables_with_regional_groups = merge_regional_group_tables( + BASE_REGIONAL_GROUP_TABLES, self.extension_specs + ) + ensure_enabled_extension_tables_exist( + self.con, + self.extension_specs, + input_database=str(self.config.input_database), + silent=self.config.silent, + ) + assert_disabled_extension_tables_are_empty(self.con, self.extension_specs) # Build the data loading manifest and a name-based map for quick lookup - model = TemoaModel() - self.manifest = build_manifest(model) + self.manifest = build_manifest(self.model, extension_ids=self.config.extensions) self.manifest_map = {item.component.name: item for item in self.manifest} # --- Data containers and filters populated during loading --- @@ -205,7 +222,7 @@ def create_data_dict(self, myopic_index: MyopicIndex | None = None) -> dict[str, data: dict[str, object] = {} cur = self.con.cursor() - model = TemoaModel() + model = self.model # Load critical time sets first, as they index other components if myopic_index: @@ -525,7 +542,7 @@ def _load_regional_global_indices( """ Aggregates region and group names from the Region table and all Limit tables. """ - model = TemoaModel() + model = self.model cur = self.con.cursor() regions_and_groups: set[str] = set() @@ -534,7 +551,7 @@ def _load_regional_global_indices( t[0] for t in cur.execute('SELECT region FROM main.region').fetchall() ) - for table, field_name in tables_with_regional_groups.items(): + for table, field_name in self.tables_with_regional_groups.items(): if self.table_exists(table): regions_and_groups.update( t[0] for t in cur.execute(f'SELECT {field_name} FROM main.{table}').fetchall() diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py new file mode 100644 index 000000000..9a6551113 --- /dev/null +++ b/temoa/extensions/framework.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from pathlib import Path +from dataclasses import dataclass, field +from sqlite3 import Connection +from typing import TYPE_CHECKING + +from collections.abc import Callable + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from temoa.core.model import TemoaModel + from temoa.data_io.loader_manifest import LoadItem + +ModelHook = Callable[['TemoaModel'], None] +ManifestHook = Callable[['TemoaModel'], list['LoadItem']] + + +@dataclass(frozen=True) +class ExtensionSpec: + """Declarative metadata and hooks for an optional modeling extension.""" + + extension_id: str + owned_tables: tuple[str, ...] = () + regional_group_tables: dict[str, str] = field(default_factory=dict) + register_model_components: ModelHook | None = None + build_manifest_items: ManifestHook | None = None + schema_sql_path: str | None = None + fail_if_tables_populated_when_disabled: bool = False + + +def normalize_extension_ids(extension_ids: Sequence[str] | None) -> tuple[str, ...]: + """Normalize configured extension ids while preserving user-provided order.""" + if not extension_ids: + return () + + normalized: list[str] = [] + seen: set[str] = set() + for ext_id in extension_ids: + if not isinstance(ext_id, str): + raise ValueError(f'Extension ids must be strings. Received: {type(ext_id).__name__}') + cleaned = ext_id.strip().lower() + if not cleaned: + continue + if cleaned not in seen: + normalized.append(cleaned) + seen.add(cleaned) + + return tuple(normalized) + + +def get_known_extension_specs() -> dict[str, ExtensionSpec]: + """Return all extension specs known to this installation.""" + from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION + + # TEMPLATE: To activate a new extension copied from ``temoa/extensions/template``, + # import its spec here and add it to ``specs`` below, e.g.: + # from temoa.extensions..extension import + # specs = [GROWTH_RATES_EXTENSION, ] + specs = [GROWTH_RATES_EXTENSION] + return {spec.extension_id: spec for spec in specs} + + +def resolve_extension_specs(extension_ids: Sequence[str] | None) -> tuple[ExtensionSpec, ...]: + """Validate enabled extension ids and return corresponding specs in user order.""" + normalized_ids = normalize_extension_ids(extension_ids) + known = get_known_extension_specs() + unknown = [ext_id for ext_id in normalized_ids if ext_id not in known] + if unknown: + known_ids = ', '.join(sorted(known)) + unknown_ids = ', '.join(sorted(unknown)) + raise ValueError( + f'Unknown extension id(s): {unknown_ids}. Known extension ids: {known_ids}.' + ) + + return tuple(known[ext_id] for ext_id in normalized_ids) + + +def apply_model_extension_hooks(model: TemoaModel, specs: Sequence[ExtensionSpec]) -> None: + """Attach extension-owned model components to a model instance.""" + for spec in specs: + if spec.register_model_components is not None: + spec.register_model_components(model) + + +def append_extension_manifest_items( + model: TemoaModel, manifest: list[LoadItem], specs: Sequence[ExtensionSpec] +) -> list[LoadItem]: + """Append extension-specific manifest items to the base manifest.""" + merged = list(manifest) + for spec in specs: + if spec.build_manifest_items is not None: + merged.extend(spec.build_manifest_items(model)) + return merged + + +def merge_regional_group_tables( + base_tables: Mapping[str, str], specs: Sequence[ExtensionSpec] +) -> dict[str, str]: + """Merge base regional-group table map with extension-contributed entries.""" + merged = dict(base_tables) + for spec in specs: + for table_name, field_name in spec.regional_group_tables.items(): + existing = merged.get(table_name) + if existing is not None and existing != field_name: + raise ValueError( + f"Regional-group table '{table_name}' has conflicting field mappings: " + f"'{existing}' vs '{field_name}' from extension '{spec.extension_id}'." + ) + merged[table_name] = field_name + return merged + + +def assert_disabled_extension_tables_are_empty( + con: Connection, enabled_specs: Sequence[ExtensionSpec] +) -> None: + """Fail if disabled extensions with strict guards own tables populated with data.""" + enabled_ids = {spec.extension_id for spec in enabled_specs} + for spec in get_known_extension_specs().values(): + if spec.extension_id in enabled_ids: + continue + if not spec.fail_if_tables_populated_when_disabled: + continue + + populated: list[str] = [] + for table in spec.owned_tables: + if _table_has_rows(con, table): + populated.append(table) + + if populated: + table_list = ', '.join(sorted(populated)) + raise RuntimeError( + f"Extension '{spec.extension_id}' is not enabled, but extension-owned table(s) " + f'contain data: {table_list}. Enable the extension or remove those rows.' + ) + + +def ensure_enabled_extension_tables_exist( + con: Connection, + enabled_specs: Sequence[ExtensionSpec], + *, + input_database: str, + silent: bool, +) -> None: + """Ensure enabled extensions have required tables, offering to append schema if missing.""" + for spec in enabled_specs: + missing_tables = [table for table in spec.owned_tables if not _table_exists(con, table)] + if not missing_tables: + continue + + missing_list = ', '.join(sorted(missing_tables)) + if not spec.schema_sql_path: + raise RuntimeError( + f"Extension '{spec.extension_id}' is enabled, but required table(s) are missing: " + f'{missing_list}. No schema SQL path is registered for this extension.' + ) + + should_apply = False + if not silent: + prompt = ( + f"Extension '{spec.extension_id}' is enabled but missing table(s): {missing_list}. " + f"Append schema from '{spec.schema_sql_path}' to input database '{input_database}' now? " + '[y/N]: ' + ) + response = input(prompt).strip().lower() + should_apply = response in {'y', 'yes'} + + if not should_apply: + raise RuntimeError( + f"Extension '{spec.extension_id}' is enabled, but required table(s) are missing: " + f"{missing_list}. Re-run and accept the prompt, or append schema manually from " + f"'{spec.schema_sql_path}' to '{input_database}'." + ) + + _append_extension_schema(con, spec) + still_missing = [table for table in spec.owned_tables if not _table_exists(con, table)] + if still_missing: + still_missing_list = ', '.join(sorted(still_missing)) + raise RuntimeError( + f"Schema append for extension '{spec.extension_id}' completed but table(s) are still " + f'missing: {still_missing_list}.' + ) + + +def _table_has_rows(con: Connection, table_name: str) -> bool: + cur = con.cursor() + table_exists = cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", (table_name,) + ).fetchone() + if not table_exists: + return False + + query = f'SELECT 1 FROM main.{table_name} LIMIT 1' + return cur.execute(query).fetchone() is not None + + +def _table_exists(con: Connection, table_name: str) -> bool: + cur = con.cursor() + exists = cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?", (table_name,) + ).fetchone() + return bool(exists) + + +def _append_extension_schema(con: Connection, spec: ExtensionSpec) -> None: + if spec.schema_sql_path is None: + raise RuntimeError(f"Extension '{spec.extension_id}' has no schema SQL path configured.") + + schema_path = Path(spec.schema_sql_path) + if not schema_path.is_file(): + raise FileNotFoundError( + f"Schema SQL file for extension '{spec.extension_id}' not found: {schema_path}" + ) + + sql = schema_path.read_text(encoding='utf-8') + con.executescript(sql) + con.commit() diff --git a/temoa/extensions/method_of_morris/morris.py b/temoa/extensions/method_of_morris/morris.py index 77d770f1c..4bb14ddcb 100644 --- a/temoa/extensions/method_of_morris/morris.py +++ b/temoa/extensions/method_of_morris/morris.py @@ -38,7 +38,7 @@ def evaluate(param_names: dict[int, list[Any]], param_values: Any, raise ValueError(f'Unrecognized parameter: {names[0]}') dp = DataPortal(data_dict={None: data}) - instance = run_actions.build_instance(loaded_portal=dp) + instance = run_actions.build_instance(loaded_portal=dp, extensions=config.extensions) mdl, res = run_actions.solve_instance(instance=instance, solver_name=config.solver_name) status = run_actions.check_solve_status(res) if not status: diff --git a/temoa/extensions/method_of_morris/morris_evaluate.py b/temoa/extensions/method_of_morris/morris_evaluate.py index 21b03febf..1460b7757 100644 --- a/temoa/extensions/method_of_morris/morris_evaluate.py +++ b/temoa/extensions/method_of_morris/morris_evaluate.py @@ -67,7 +67,11 @@ def evaluate(param_info: dict[int, list[Any]], mm_sample: Any, data: dict[str, A logger.debug('\n '.join(log_entry)) dp = DataPortal(data_dict={None: data}) - instance = run_actions.build_instance(loaded_portal=dp, silent=True) + instance = run_actions.build_instance( + loaded_portal=dp, + silent=True, + extensions=config.extensions, + ) mdl, res = run_actions.solve_instance( instance=instance, solver_name=config.solver_name, silent=True ) diff --git a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py index 83b228fec..080d06974 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py +++ b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py @@ -164,7 +164,10 @@ def start(self) -> None: hybrid_loader = HybridLoader(db_connection=self.con, config=self.config) data_portal: DataPortal = hybrid_loader.load_data_portal(myopic_index=None) instance: TemoaModel = build_instance( - loaded_portal=data_portal, model_name=self.config.scenario, silent=self.config.silent + loaded_portal=data_portal, + model_name=self.config.scenario, + silent=self.config.silent, + extensions=self.config.extensions, ) if self.config.price_check: good_prices = price_checker(instance) diff --git a/temoa/extensions/myopic/myopic_sequencer.py b/temoa/extensions/myopic/myopic_sequencer.py index fbee15c45..e671c3759 100644 --- a/temoa/extensions/myopic/myopic_sequencer.py +++ b/temoa/extensions/myopic/myopic_sequencer.py @@ -252,6 +252,7 @@ def start(self) -> None: keep_lp_file=self.config.save_lp_file, lp_path=self.config.output_path / ''.join(('LP', str(idx.base_year))), # base year folder + extensions=self.config.extensions, ) # 8. Run checks... diff --git a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py index 8498390bf..937aeb65c 100644 --- a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py +++ b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py @@ -79,6 +79,7 @@ def start(self) -> None: silent=self.config.silent, keep_lp_file=self.config.save_lp_file, lp_path=lp_path, + extensions=self.config.extensions, ) if self.config.price_check: good_prices = price_checker(instance) diff --git a/temoa/extensions/stochastics/scenario_creator.py b/temoa/extensions/stochastics/scenario_creator.py index 757fa2a70..3dc0b6591 100644 --- a/temoa/extensions/stochastics/scenario_creator.py +++ b/temoa/extensions/stochastics/scenario_creator.py @@ -96,7 +96,7 @@ def scenario_creator(scenario_name: str, **kwargs: Any) -> Any: # 3. Build instance data_portal = HybridLoader.data_portal_from_data(data_dict) - instance = build_instance(data_portal, silent=True) + instance = build_instance(data_portal, silent=True, extensions=temoa_config.extensions) # 4. Attach root node (Stage 1) periods = sorted(instance.time_optimize) diff --git a/temoa/extensions/template/__init__.py b/temoa/extensions/template/__init__.py new file mode 100644 index 000000000..fb2119770 --- /dev/null +++ b/temoa/extensions/template/__init__.py @@ -0,0 +1,21 @@ +"""Template extension package. + +TEMPLATE: This is a copy-from scaffold for building a new optional Temoa +modeling extension. It is intentionally NOT registered in +``temoa.extensions.framework.get_known_extension_specs``, so it is inert: +importing it works and it type-checks, but it cannot be enabled until you +register it. + +To create a real extension: + 1. Copy this whole folder to ``temoa/extensions//``. + 2. Rename the ``extension_id``, tables, params, and constraints. + 3. Register the spec in ``get_known_extension_specs`` (see framework.py). + 4. Add your tables to the database schema / ``tables.sql``. + 5. Enable it via config: ``extensions = [""]``. + +See ``docs/source/extensions.rst`` for the full guide. +""" + +from temoa.extensions.template.extension import EXAMPLE_EXTENSION + +__all__ = ['EXAMPLE_EXTENSION'] diff --git a/temoa/extensions/template/components/__init__.py b/temoa/extensions/template/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/template/components/example_limit.py b/temoa/extensions/template/components/example_limit.py new file mode 100644 index 000000000..a00570bce --- /dev/null +++ b/temoa/extensions/template/components/example_limit.py @@ -0,0 +1,62 @@ +"""Example constraint family for the template extension. + +TEMPLATE: A component module holds the index-set function(s) and constraint +rule(s) for a single family of constraints, mirroring the modules under +``temoa/components``. Group related constraints together; create one module per +family. + +This example caps the new capacity built in each period for a technology (or +technology group) in a region. Replace it with your own logic. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology + +if TYPE_CHECKING: + from temoa.extensions.template.core.model import ExampleModel + from temoa.types import ExprLike, Period, Region, Technology + + +def example_new_capacity_limit_indices( + model: ExampleModel, +) -> set[tuple[Region, Period, Technology]]: + """Build the sparse (region, period, tech-or-group) index for the constraint. + + TEMPLATE: Annotate ``model`` with the extension subtype (``ExampleModel``) so + the extension-owned param ``example_new_capacity_limit`` and its + ``sparse_keys()`` method are visible to the type checker. + """ + return { + (r, p, t) + for r, t in model.example_new_capacity_limit.sparse_keys() + for p in model.time_optimize + } + + +def example_new_capacity_limit_constraint_rule( + model: ExampleModel, r: Region, p: Period, t: Technology +) -> ExprLike: + """Cap total new capacity built in period ``p`` for region/group ``r``/``t``.""" + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + limit = value(model.example_new_capacity_limit[r, t]) + + new_cap_rtv = model.v_new_capacity + new_cap = quicksum( + new_cap_rtv[_r, _t, _v] + for _r, _t, _v in new_cap_rtv.keys() + if _r in regions and _t in techs and _v == p + ) + + if isinstance(new_cap, (int, float)): + # No decision variables in this period/region/group: nothing to constrain. + return Constraint.Skip + + return new_cap <= limit diff --git a/temoa/extensions/template/core/__init__.py b/temoa/extensions/template/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/template/core/model.py b/temoa/extensions/template/core/model.py new file mode 100644 index 000000000..e66a92916 --- /dev/null +++ b/temoa/extensions/template/core/model.py @@ -0,0 +1,71 @@ +"""Type-checking model subtype and component registration for the template extension. + +TEMPLATE: This file plays the same role as ``temoa/core/model.py`` does for the +core model: it declares the extension-owned model components (so the type +checker knows about them) and attaches them to a live ``TemoaModel`` instance. + +Two things live here: + 1. ``ExampleModel`` - a ``TYPE_CHECKING``-only subtype of ``TemoaModel`` that + declares every Param/Set/Constraint this extension adds. It exists purely + for static typing; nothing instantiates it. + 2. ``register_model_components`` - the runtime hook that attaches those + components to the core model. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pyomo.environ import Any, Constraint, Param, Set + +from temoa.extensions.template.components import example_limit + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + + class ExampleModel(TemoaModel): + """TemoaModel extended with template-extension components. + + TEMPLATE: Declare one annotation per component you create in + ``register_model_components`` below. Inheriting from ``TemoaModel`` means + all core sets/params/vars (e.g. ``time_optimize``, ``v_new_capacity``) + are already visible to the type checker here and in your component + modules. + """ + + # Params (loaded from the database via data_manifest.py) + example_new_capacity_limit: Param + + # Constraint index sets + example_new_capacity_limit_constraint_rpt: Set + + # Constraints + example_new_capacity_limit_constraint: Constraint + + +def register_model_components(model: TemoaModel) -> None: + """Attach template-extension components to the core Temoa model. + + TEMPLATE: Keep the public signature as ``model: TemoaModel`` so this stays + assignable to ``ExtensionSpec.register_model_components`` (a + ``Callable[[TemoaModel], None]``). Narrowing the parameter to ``ExampleModel`` + would be a contravariance error in mypy. Instead, ``cast`` once and operate on + the typed alias ``m`` below. + """ + m = cast('ExampleModel', model) + + # Param: a per-(region, tech-or-group) cap on cumulative new capacity. + m.example_new_capacity_limit = Param( + m.regional_global_indices, m.tech_or_group, within=Any + ) + + # Sparse index set for the constraint, built from the param's populated keys. + m.example_new_capacity_limit_constraint_rpt = Set( + dimen=3, initialize=example_limit.example_new_capacity_limit_indices + ) + + # The constraint itself. + m.example_new_capacity_limit_constraint = Constraint( + m.example_new_capacity_limit_constraint_rpt, + rule=example_limit.example_new_capacity_limit_constraint_rule, + ) diff --git a/temoa/extensions/template/data_manifest.py b/temoa/extensions/template/data_manifest.py new file mode 100644 index 000000000..e9c8ac02a --- /dev/null +++ b/temoa/extensions/template/data_manifest.py @@ -0,0 +1,44 @@ +"""Data-loading manifest for the template extension. + +TEMPLATE: ``build_manifest_items`` returns one ``LoadItem`` per database table +this extension reads. The loader uses each item to query, validate, and populate +the matching Pyomo component declared in core/model.py. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from temoa.data_io.loader_manifest import LoadItem + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.template.core.model import ExampleModel + + +def build_manifest_items(model: TemoaModel) -> list[LoadItem]: + """Return the LoadItems for the template extension. + + TEMPLATE: As with ``register_model_components``, keep the public parameter + typed as ``TemoaModel`` (to match ``ExtensionSpec.build_manifest_items``) and + ``cast`` to the extension subtype so the extension-owned components type-check. + """ + m = cast('ExampleModel', model) + return [ + LoadItem( + # The Pyomo component to populate. + component=m.example_new_capacity_limit, + # Source table name. + table='example_new_capacity_limit', + # Columns to select; for a Param the final column is the value. + columns=['region', 'tech_or_group', 'value'], + # Number of leading columns that form the index (here: region, tech). + index_length=2, + # Source-trace validator on the loader (filters to viable r/t pairs). + validator_name='viable_rt', + # Which index columns to validate (region=0, tech=1). + validation_map=(0, 1), + # Extension tables are optional inputs, so do not require the table. + is_table_required=False, + ), + ] diff --git a/temoa/extensions/template/extension.py b/temoa/extensions/template/extension.py new file mode 100644 index 000000000..9c8509d85 --- /dev/null +++ b/temoa/extensions/template/extension.py @@ -0,0 +1,34 @@ +"""Declarative spec that wires the template extension into Temoa. + +TEMPLATE: ``ExtensionSpec`` is pure metadata plus hook functions. Temoa reads it +to know which tables the extension owns, how to guard them, which model +components to attach, and how to load the extension's data. +""" + +from __future__ import annotations + +from pathlib import Path + +from temoa.extensions.framework import ExtensionSpec +from temoa.extensions.template.core.model import register_model_components +from temoa.extensions.template.data_manifest import build_manifest_items + +EXAMPLE_EXTENSION = ExtensionSpec( + # Unique, lowercase id. This is what users put in ``extensions = [...]``. + extension_id='template', + # Database tables owned exclusively by this extension. + owned_tables=('example_new_capacity_limit',), + # Tables whose ``region`` column may contain a regional group name. Maps + # table -> the column that holds the region/group. Merged into the loader's + # regional-group handling. + regional_group_tables={'example_new_capacity_limit': 'region'}, + # Hook: attach model components to the core model (see core/model.py). + register_model_components=register_model_components, + # Hook: describe how to load this extension's data (see data_manifest.py). + build_manifest_items=build_manifest_items, + # Schema applied (with consent) when enabled but tables are missing. + schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), + # If True, loading fails when this extension is DISABLED but its tables hold + # data, preventing silently-ignored inputs. + fail_if_tables_populated_when_disabled=True, +) diff --git a/temoa/extensions/template/tables.sql b/temoa/extensions/template/tables.sql new file mode 100644 index 000000000..bc8e3a75d --- /dev/null +++ b/temoa/extensions/template/tables.sql @@ -0,0 +1,14 @@ +-- TEMPLATE: Schema for the template extension's owned tables. +-- One CREATE TABLE per entry in ``owned_tables`` (see extension.py). Use +-- ``IF NOT EXISTS`` so the schema can be appended to an existing database when +-- the extension is enabled but its tables are missing. + +CREATE TABLE IF NOT EXISTS example_new_capacity_limit +( + region TEXT, + tech_or_group TEXT, + value REAL NOT NULL DEFAULT 0, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group) +); diff --git a/temoa/tutorial_assets/config_sample.toml b/temoa/tutorial_assets/config_sample.toml index 6b522212d..df3d7b20a 100644 --- a/temoa/tutorial_assets/config_sample.toml +++ b/temoa/tutorial_assets/config_sample.toml @@ -18,6 +18,12 @@ scenario = "zulu" # [perfect_foresight, MGA, myopic, method_of_morris, build_only, check, monte_carlo] scenario_mode = "perfect_foresight" +# Optional extensions list +# Use this to enable modular model features that are not part of the base core model. +# Example: extensions = ["unit_commitment", "growth_rates"] +# Leave empty for default core-only behavior. +extensions = [] + # Input database (Mandatory) input_database = "utopia.sqlite" diff --git a/tests/conftest.py b/tests/conftest.py index 0e263b9c0..5714aa84e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from temoa._internal.temoa_sequencer import TemoaSequencer from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel +from temoa.extensions.framework import get_known_extension_specs logger = logging.getLogger(__name__) @@ -57,6 +58,13 @@ def _build_test_db( # Force FK OFF again as schema file might turn it on at the end con.execute('PRAGMA foreign_keys = OFF') + # 1b. Load extension-owned tables so data scripts can populate them. + # Extension tables are not part of the central schema, so apply each + # known extension's schema (CREATE TABLE IF NOT EXISTS) here. + for spec in get_known_extension_specs().values(): + if spec.schema_sql_path: + con.executescript(Path(spec.schema_sql_path).read_text(encoding='utf-8')) + # 2. Load data scripts for script_path in data_scripts: with open(script_path) as f: From 16fe576bca1944718b29f99abaa07c1ab0cec763 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 6 Jul 2026 10:37:03 -0400 Subject: [PATCH 03/23] Move growth rate constraints to an extension Signed-off-by: Davey Elder --- docs/source/extensions.rst | 14 + docs/source/extensions/growth_rates.rst | 99 ++++ docs/source/mathematical_formulation.rst | 64 --- docs/source/param_desc_and_tables.rst | 14 - temoa/components/limits.py | 445 ------------------ temoa/core/model.py | 65 --- temoa/data_io/component_manifest.py | 60 --- temoa/data_io/hybrid_loader.py | 6 - temoa/db_schema/temoa_schema_v4.sql | 72 --- temoa/extensions/growth_rates/__init__.py | 3 + .../growth_rates/components/__init__.py | 0 .../components/growth_capacity.py | 123 +++++ .../components/growth_new_capacity.py | 121 +++++ .../components/growth_new_capacity_delta.py | 148 ++++++ .../extensions/growth_rates/core/__init__.py | 0 temoa/extensions/growth_rates/core/model.py | 115 +++++ .../extensions/growth_rates/data_manifest.py | 76 +++ temoa/extensions/growth_rates/extension.py | 32 ++ temoa/extensions/growth_rates/tables.sql | 72 +++ .../config_myopic_capacities.toml | 1 + tests/testing_data/mediumville_sets.json | 6 - tests/testing_data/test_system_sets.json | 6 - tests/testing_data/utopia_sets.json | 6 - 23 files changed, 804 insertions(+), 744 deletions(-) create mode 100644 docs/source/extensions/growth_rates.rst create mode 100644 temoa/extensions/growth_rates/__init__.py create mode 100644 temoa/extensions/growth_rates/components/__init__.py create mode 100644 temoa/extensions/growth_rates/components/growth_capacity.py create mode 100644 temoa/extensions/growth_rates/components/growth_new_capacity.py create mode 100644 temoa/extensions/growth_rates/components/growth_new_capacity_delta.py create mode 100644 temoa/extensions/growth_rates/core/__init__.py create mode 100644 temoa/extensions/growth_rates/core/model.py create mode 100644 temoa/extensions/growth_rates/data_manifest.py create mode 100644 temoa/extensions/growth_rates/extension.py create mode 100644 temoa/extensions/growth_rates/tables.sql diff --git a/docs/source/extensions.rst b/docs/source/extensions.rst index 5e8e1a9c2..32f72454e 100644 --- a/docs/source/extensions.rst +++ b/docs/source/extensions.rst @@ -230,3 +230,17 @@ The template extension ``get_known_extension_specs``, it cannot be enabled until you register it, so it never affects normal runs. Copy the folder as the starting point for a new extension; every file carries ``# TEMPLATE:`` comments explaining what to change. + +.. _extension-catalog: + +Available extensions +-------------------- + +The extensions that ship with Temoa are documented on their own pages below. +Each page describes the extension's parameters and constraints. This list grows +as new extensions are added. + +.. toctree:: + :maxdepth: 1 + + extensions/growth_rates diff --git a/docs/source/extensions/growth_rates.rst b/docs/source/extensions/growth_rates.rst new file mode 100644 index 000000000..261b1e742 --- /dev/null +++ b/docs/source/extensions/growth_rates.rst @@ -0,0 +1,99 @@ +.. _extension-growth-rates: + +Growth Rate Limits +================================== + +The **growth_rates** extension adds optional constraints that bound how quickly +the capacity (or new capacity) of a technology or technology group may change +between consecutive model periods. It is disabled by default and enabled per +run through configuration: + +.. code-block:: toml + + extensions = ["growth_rates"] + +Its parameters live in the extension-owned tables of the same name and are loaded +only when the extension is enabled. See :ref:`extensions` for how the extension +framework wires these components into the model. + +Parameters +---------- + +.. csv-table:: + :header: "Parameter", "Database Table", "Model Element", "Notes" + :widths: 15, 20, 25, 40 + + ":math:`\text{LGC}_{r,t}`", ":code:`limit_growth_capacity`", ":code:`limit_growth_capacity`", "capacity growth rate limits; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\text{LDGC}_{r,t}`", ":code:`limit_degrowth_capacity`", ":code:`limit_degrowth_capacity`", "capacity degrowth rate limits; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\text{LGNC}_{r,t}`", ":code:`limit_growth_new_capacity`", ":code:`limit_growth_new_capacity`", "new capacity growth rate limits; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\text{LDGNC}_{r,t}`", ":code:`limit_degrowth_new_capacity`", ":code:`limit_degrowth_new_capacity`", "new capacity degrowth rate limits; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\mathrm{LGNC}_{\Delta,r,t}`", ":code:`limit_growth_new_capacity_delta`", ":code:`limit_growth_new_capacity_delta`", "new capacity growth acceleration limits; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\mathrm{LDGNC}_{\Delta,r,t}`", ":code:`limit_degrowth_new_capacity_delta`", ":code:`limit_degrowth_new_capacity_delta`", "new capacity degrowth deceleration limits; :code:`tech_or_group` column accepts a technology name or group name" + + +limit_growth_capacity +~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LGC}_{r \in R, t \in T}` + +The :code:`limit_growth_capacity` parameter defines the maximum annual rate at +which the total capacity of a technology (or group) can grow between periods. +The :code:`tech_or_group` column accepts a technology name or group name. + + +limit_degrowth_capacity +~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LDGC}_{r \in R, t \in T}` + +The :code:`limit_degrowth_capacity` parameter defines the maximum annual rate +at which the total capacity of a technology (or group) can shrink between +periods. The :code:`tech_or_group` column accepts a technology name or group name. + + +limit_growth_new_capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LGNC}_{r \in R, t \in T}` + +The :code:`limit_growth_new_capacity` parameter constrains the rate of increase +in new capacity deployment between consecutive periods. + + +limit_degrowth_new_capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LDGNC}_{r \in R, t \in T}` + +The :code:`limit_degrowth_new_capacity` parameter constrains the rate of decrease +in new capacity deployment between consecutive periods. + + +limit_growth_new_capacity_delta +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`\mathrm{LGNC}_{\Delta,r \in R, t \in T}` + +The :code:`limit_growth_new_capacity_delta` parameter constrains the acceleration +of new capacity growth between periods. This essentially adds "inertia" to the +growth of new capacity deployment. + + +limit_degrowth_new_capacity_delta +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`\mathrm{LDGNC}_{\Delta,r \in R, t \in T}` + +The :code:`limit_degrowth_new_capacity_delta` parameter constrains the +deceleration of new capacity degrowth between periods, essentially adding +"inertia" to the degrowth of new capacity deployment. + + +Constraints +----------- + +.. autofunction:: temoa.extensions.growth_rates.components.growth_capacity.limit_growth_capacity + +.. autofunction:: temoa.extensions.growth_rates.components.growth_new_capacity.limit_growth_new_capacity + +.. autofunction:: temoa.extensions.growth_rates.components.growth_new_capacity_delta.limit_growth_new_capacity_delta diff --git a/docs/source/mathematical_formulation.rst b/docs/source/mathematical_formulation.rst index cc4d3da10..86bd4b4fc 100644 --- a/docs/source/mathematical_formulation.rst +++ b/docs/source/mathematical_formulation.rst @@ -711,35 +711,6 @@ controls the bound direction. The group columns accept either a single technology name or a technology group name. -limit_degrowth_capacity -~~~~~~~~~~~~~~~~~~~~~~~ - -:math:`{LDGC}_{r \in R, t \in T}` - -The :code:`limit_degrowth_capacity` parameter defines the maximum annual rate -at which the total capacity of a technology (or group) can shrink between -periods. The :code:`tech_or_group` column accepts a technology name or group name. - - -limit_degrowth_new_capacity -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:math:`{LDGNC}_{r \in R, t \in T}` - -The :code:`limit_degrowth_new_capacity` parameter constrains the rate of decrease -in new capacity deployment between consecutive periods. - - -limit_degrowth_new_capacity_delta -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:math:`\mathrm{LDGNC}_{\Delta,r \in R, t \in T}` - -The :code:`limit_degrowth_new_capacity_delta` parameter constrains the -deceleration of new capacity degrowth between periods, essentially adding -"intertia" to the degrowth of new capacity deployment. - - limit_emission ~~~~~~~~~~~~~~ @@ -751,35 +722,6 @@ fits within the modeler-specified limit on emission :math:`e` in time period bound, or equality. -limit_growth_capacity -~~~~~~~~~~~~~~~~~~~~~ - -:math:`{LGC}_{r \in R, t \in T}` - -The :code:`limit_growth_capacity` parameter defines the maximum annual rate at -which the total capacity of a technology (or group) can grow between periods. -The :code:`tech_or_group` column accepts a technology name or group name. - - -limit_growth_new_capacity -~~~~~~~~~~~~~~~~~~~~~~~~~ - -:math:`{LGNC}_{r \in R, t \in T}` - -The :code:`limit_growth_new_capacity` parameter constrains the rate of increase -in new capacity deployment between consecutive periods. - - -limit_growth_new_capacity_delta -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:math:`\mathrm{LGNC}_{\Delta,r \in R, t \in T}` - -The :code:`limit_growth_new_capacity_delta` parameter constrains the acceleration -of new capacity growth between periods. This essentially adds "inertia" to the -growth of new capacity deployment. - - limit_new_capacity ~~~~~~~~~~~~~~~~~~ @@ -1437,12 +1379,6 @@ User-Specific Constraints .. autofunction:: temoa.components.storage.limit_storage_fraction_constraint -.. autofunction:: temoa.components.limits.limit_growth_capacity - -.. autofunction:: temoa.components.limits.limit_growth_new_capacity - -.. autofunction:: temoa.components.limits.limit_growth_new_capacity_delta - General Caveats --------------- diff --git a/docs/source/param_desc_and_tables.rst b/docs/source/param_desc_and_tables.rst index 40ff0737d..aef95320e 100644 --- a/docs/source/param_desc_and_tables.rst +++ b/docs/source/param_desc_and_tables.rst @@ -94,20 +94,6 @@ capacity, activity and emissions**. ":math:`\text{LE}_{r,p,e}`", ":code:`limit_emission`", ":code:`limit_emission`", "limit on emissions by region and period" ":math:`\text{LS}_{r,t}`", ":code:`limit_resource`", ":code:`limit_resource`", "cumulative activity limit across time periods (not supported in myopic); :code:`tech_or_group` column accepts a technology name or group name" -Parameters in the table below relate to the specification of **growth and degrowth -limits**. - -.. csv-table:: - :header: "Parameter", "Database Table", "Model Element", "Notes" - :widths: 15, 20, 25, 40 - - ":math:`\text{LGC}_{r,t}`", ":code:`limit_growth_capacity`", ":code:`limit_growth_capacity`", "capacity growth rate limits; :code:`tech_or_group` column accepts a technology name or group name" - ":math:`\text{LDGC}_{r,t}`", ":code:`limit_degrowth_capacity`", ":code:`limit_degrowth_capacity`", "capacity degrowth rate limits; :code:`tech_or_group` column accepts a technology name or group name" - ":math:`\text{LGNC}_{r,t}`", ":code:`limit_growth_new_capacity`", ":code:`limit_growth_new_capacity`", "new capacity growth rate limits; :code:`tech_or_group` column accepts a technology name or group name" - ":math:`\text{LDGNC}_{r,t}`", ":code:`limit_degrowth_new_capacity`", ":code:`limit_degrowth_new_capacity`", "new capacity degrowth rate limits; :code:`tech_or_group` column accepts a technology name or group name" - ":math:`\mathrm{LGNC}_{\Delta,r,t}`", ":code:`limit_growth_new_capacity_delta`", ":code:`limit_growth_new_capacity_delta`", "new capacity growth acceleration limits; :code:`tech_or_group` column accepts a technology name or group name" - ":math:`\mathrm{LDGNC}_{\Delta,r,t}`", ":code:`limit_degrowth_new_capacity_delta`", ":code:`limit_degrowth_new_capacity_delta`", "new capacity degrowth deceleration limits; :code:`tech_or_group` column accepts a technology name or group name" - Parameters in the table below relate to the specification of **operational and split limits**. diff --git a/temoa/components/limits.py b/temoa/components/limits.py index 857cb1489..0145073ea 100644 --- a/temoa/components/limits.py +++ b/temoa/components/limits.py @@ -22,7 +22,6 @@ import temoa.components.technology as technology from temoa.components.utils import ( Operator, - get_adjusted_existing_capacity, get_variable_efficiency, operator_expression, ) @@ -135,64 +134,6 @@ def limit_tech_output_split_average_constraint_indices( } -def limit_growth_capacity_indices(model: TemoaModel) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_growth_capacity.sparse_keys() - for p in model.time_optimize - } - - -def limit_degrowth_capacity_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_degrowth_capacity.sparse_keys() - for p in model.time_optimize - } - - -def limit_growth_new_capacity_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_growth_new_capacity.sparse_keys() - for p in model.time_optimize - } - - -def limit_degrowth_new_capacity_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_degrowth_new_capacity.sparse_keys() - for p in model.time_optimize - } - - -def limit_growth_new_capacity_delta_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_growth_new_capacity_delta.sparse_keys() - for p in model.time_optimize - } - - -def limit_degrowth_new_capacity_delta_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Technology, str]]: - return { - (r, p, t, op) - for r, t, op in model.limit_degrowth_new_capacity_delta.sparse_keys() - for p in model.time_optimize - } - - def limit_seasonal_capacity_factor_constraint_indices( model: TemoaModel, ) -> set[tuple[Region, Period, Season, Technology, str]]: @@ -953,392 +894,6 @@ def limit_emission_constraint( return expr -def limit_growth_capacity_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp up rate of available capacity""" - return limit_growth_capacity(model, r, p, t, op, False) - - -def limit_degrowth_capacity_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp down rate of available capacity""" - return limit_growth_capacity(model, r, p, t, op, True) - - -def limit_growth_capacity( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str, degrowth: bool = False -) -> ExprLike: - r""" - Constrain the change of capacity available between periods. - Forces the model to ramp up and down the availability of new technologies - more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional - (rate, :math:`R_{r,t}`) terms. This can be defined for a technology group - instead of one technology, in which case, capacity available is summed over - all technologies in the group. In the first period, previous available - capacity :math:`\mathbf{CAPAVL}_{r,p,t}` is replaced by previous existing - capacity, if any can be found. - - .. math:: - :label: Limit (De)Growth Capacity - - \begin{aligned}\text{Growth:}\\ - &\mathbf{CAPAVL}_{r,p,t} - \quad \le, \ge, \text{or} = \quad - S_{r,t} + (1+R_{r,t}) \cdot \mathbf{CAPAVL}_{r,p_{prev},t} - \end{aligned} - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacity}} - - - \begin{aligned}\text{Degrowth:}\\ - &\mathbf{CAPAVL}_{r,p_{prev},t} - \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot \mathbf{CAPAVL}_{r,p,t} - \end{aligned} - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacity}} - """ - - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - - growth = model.limit_degrowth_capacity if degrowth else model.limit_growth_capacity - rate = 1 + value(growth[r, t, op][0]) - seed = value(growth[r, t, op][1]) - cap_rpt = model.v_capacity_available_by_period_and_tech - - # relevant r, p, t indices - cap_indices = {(_r, _p, _t) for _r, _p, _t in cap_rpt.keys() if _t in techs and _r in regions} - # periods the technology can have capacity in this region (sorted) - periods = sorted({_p for _r, _p, _t in cap_rpt}) - - if len(periods) == 0: - if p == model.time_optimize.first(): - msg = ( - 'Tried to set {}rowthCapacity constraint {} but there are no periods where this ' - 'technology is available in this region. Constraint skipped.' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.warning(msg) - return Constraint.Skip - - # Only warn in p0 so we dont dump multiple warnings - if p == periods[0]: - if seed == 0: - msg = ( - 'No constant term (seed) provided for {}rowthCapacity constraint {}. ' - 'No capacity will be built in any period following one with zero capacity.' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.info(msg) - gaps = [ - _p - for _p in model.time_optimize - if _p not in periods and min(periods) < _p < max(periods) - ] - if gaps: - msg = ( - 'Constructing {}rowthCapacity constraint {} and there are period gaps in which' - 'capacity cannot exist in this region ({}). Capacity in these periods ' - 'will be treated as zero which may cause infeasibility or other problems.' - ).format('Deg' if degrowth else 'G', (r, t), gaps) - logger.warning(msg) - - # sum available capacity in this period - capacity = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p) - - if p == model.time_optimize.first(): - # First future period. Grab available capacity in last existing period - p_prev = model.time_exist.last() - capacity_prev = sum( - get_adjusted_existing_capacity(model, _r, _t, _v) - * value(model.process_life_frac[_r, p_prev, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions - and _t in techs - and _v + value(model.lifetime_process[_r, _t, _v]) > p_prev - ) - else: - # Otherwise, grab previous future period - p_prev = model.time_optimize.prev(p) - capacity_prev = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p_prev) - - if degrowth: - expr = operator_expression(capacity_prev, Operator(op), seed + capacity * rate) - else: - expr = operator_expression(capacity, Operator(op), seed + capacity_prev * rate) - - # Check if any variables are actually included before returning - if isinstance(expr, bool): - return Constraint.Skip - return expr - - -def limit_growth_new_capacity_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp up rate of new capacity deployment""" - return limit_growth_new_capacity(model, r, p, t, op, False) - - -def limit_degrowth_new_capacity_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp down rate of new capacity deployment""" - return limit_growth_new_capacity(model, r, p, t, op, True) - - -def limit_growth_new_capacity( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str, degrowth: bool = False -) -> ExprLike: - r""" - Constrain the change of new capacity deployed between periods. - Forces the model to ramp up and down the deployment of new technologies - more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional - (rate, :math:`R_{r,t}`) terms. This can be defined for a technology group - instead of one technology, in which case, new capacity is summed over - all technologies in the group. In the first period, previous new capacity - :math:`\mathbf{NCAP}_{r,t,v_prev}` is replaced by previous existing capacity, - if any can be found. - - .. math:: - :label: Limit (De)Growth New Capacity - - \begin{aligned}\text{Growth:}\\ - &\mathbf{NCAP}_{r,t,v} - \quad \le, \ge, \text{or} = \quad - S_{r,t} + (1+R_{r,t}) \cdot \mathbf{NCAP}_{r,t,v_{prev}} - \text{ where } v=p - \end{aligned} - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacity}} - - \begin{aligned}\text{Degrowth:}\\ - &\mathbf{NCAP}_{r,t,v_{prev}} - \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot \mathbf{NCAP}_{r,t,v} - \text{ where } v=p - \end{aligned} - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacity}} - """ - - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - - growth = model.limit_degrowth_new_capacity if degrowth else model.limit_growth_new_capacity - rate = 1 + value(growth[r, t, op][0]) - seed = value(growth[r, t, op][1]) - new_cap_rtv = model.v_new_capacity - - # relevant r, t, v indices - cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} - # periods the technology can be built in this region (sorted) - periods = sorted({_v for _r, _t, _v in cap_rtv}) - - if len(periods) == 0: - if p == model.time_optimize.first(): - msg = ( - 'Tried to set {}rowthNewCapacity constraint {} but there are no periods where this ' - 'technology can be built in this region. Constraint skipped.' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.warning(msg) - return Constraint.Skip - - # Only warn in p0 so we dont dump multiple warnings - if p == periods[0]: - if seed == 0: - msg = ( - 'No constant term (seed) provided for {}rowthNewCapacity constraint {}. ' - 'No capacity will be built in any period following one with zero new capacity.' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.info(msg) - gaps = [ - _p - for _p in model.time_optimize - if _p not in periods and min(periods) < _p < max(periods) - ] - if gaps: - msg = ( - 'Constructing {}rowthNewCapacity constraint {} and there are period gaps in which' - 'new capacity cannot be built in this region ({}). New capacity in these periods ' - 'will be treated as zero which may cause infeasibility or other problems.' - ).format('Deg' if degrowth else 'G', (r, t), gaps) - logger.warning(msg) - - # sum new capacity in this period - new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) - - if p == model.time_optimize.first(): - # First future period. Grab last existing vintage - p_prev = model.time_exist.last() - new_cap_prev = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev - ) - else: - # Otherwise, grab previous future vintage - p_prev = model.time_optimize.prev(p) - new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) - - if degrowth: - expr = operator_expression(new_cap_prev, Operator(op), seed + new_cap * rate) - else: - expr = operator_expression(new_cap, Operator(op), seed + new_cap_prev * rate) - - # Check if any variables are actually included before returning - if isinstance(expr, bool): - return Constraint.Skip - return expr - - -def limit_growth_new_capacity_delta_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp up rate of change in new capacity deployment""" - return limit_growth_new_capacity_delta(model, r, p, t, op, False) - - -def limit_degrowth_new_capacity_delta_constraint_rule( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str -) -> ExprLike: - r"""Constrain ramp down rate of change in new capacity deployment""" - return limit_growth_new_capacity_delta(model, r, p, t, op, True) - - -def limit_growth_new_capacity_delta( - model: TemoaModel, r: Region, p: Period, t: Technology, op: str, degrowth: bool = False -) -> ExprLike: - r""" - Constrain the acceleration of new capacity deployed between periods. - Forces the model to ramp up and down the change in deployment of new technologies - more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional - (rate, :math:`R_{r,t}`) terms. It is recommended to leave the rate term empty - as it would prevent the possibility of inflection in the rate of deployment. - This constraint can be defined for a technology group instead of one technology, - in which case, new capacity is summed over all technologies in the group. In the - first period, previous new capacities are replaced by previous existing capacities, - if any can be found. - - .. math:: - :label: Limit (De)Growth New Capacity Delta - - \begin{aligned}\text{Growth:}\\ - &\mathbf{NCAP}_{r,t,v_i} - \mathbf{NCAP}_{r,t,v_{i-1}} - \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot - (\mathbf{NCAP}_{r,t,v_{i-1}} - \mathbf{NCAP}_{r,t,v_{i-2}}) - \end{aligned} - - \text{ where } v_i=p - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacityDelta}} - - \begin{aligned}\text{Degrowth:}\\ - &\mathbf{NCAP}_{r,t,v_{i-1}} - \mathbf{NCAP}_{r,t,v_{i-2}} - \quad \le, \ge, \text{or} = \quad - S_{r,t} + (1+R_{r,t}) \cdot (\mathbf{NCAP}_{r,t,v_i} - \mathbf{NCAP}_{r,t,v_{i-1}}) - \end{aligned} - - \text{ where } v_i=p - - \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacityDelta}} - """ - - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - - growth = ( - model.limit_degrowth_new_capacity_delta - if degrowth - else model.limit_growth_new_capacity_delta - ) - rate = 1 + value(growth[r, t, op][0]) - seed = value(growth[r, t, op][1]) - new_cap_rtv = model.v_new_capacity - - # relevant r, t, v indices - cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} - # periods the technology can be built in this region (sorted) - periods = sorted({_v for _r, _t, _v in cap_rtv}) - - if len(periods) == 0: - if p == model.time_optimize.first(): - msg = ( - 'Tried to set {}rowthNewCapacityDelta constraint {} but there are no periods where ' - 'this technology can be built in this region. Constraint skipped.' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.warning(msg) - return Constraint.Skip - - # Only warn in p0 so we dont dump multiple warnings - if p == periods[0]: - if seed == 0: - msg = ( - 'No constant term (seed) provided for {}rowthNewCapacityDelta constraint {}. ' - 'This is not recommended as deployment rates cannot inflect (change from ' - 'accelerating to decelerating or vice-versa).' - ).format('Deg' if degrowth else 'G', (r, t)) - logger.warning(msg) - gaps = [ - _p - for _p in model.time_optimize - if _p not in periods and min(periods) < _p < max(periods) - ] - if gaps: - msg = ( - 'Constructing {}rowthNewCapacityDelta constraint {} and there are period gaps in ' - 'which new capacity cannot be built in this region ({}). New capacity in these ' - 'periods will be treated as zero which may cause infeasibility or other problems.' - ).format('Deg' if degrowth else 'G', (r, t), gaps) - logger.warning(msg) - - # sum new capacity in this period - new_cap = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) - - if p == model.time_optimize.first(): - # First planning period, pull last two existing vintages - p_prev = model.time_exist.last() - new_cap_prev = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev - ) - p_prev2 = model.time_exist.prev(p_prev) - new_cap_prev2 = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev2 - ) - else: - # Not the first future period. Grab previous future period - p_prev = model.time_optimize.prev(p) - new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) - if p == model.time_optimize.at(2): # apparently pyomo sets are indexed 1-based - # Second future period, grab last existing vintage - p_prev2 = model.time_exist.last() - new_cap_prev2 = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev2 - ) - else: - # At least the third future period. Grab last two future vintages - p_prev2 = model.time_optimize.prev(p_prev) - new_cap_prev2 = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2) - - nc_delta_prev = new_cap_prev - new_cap_prev2 - nc_delta = new_cap - new_cap_prev - - if degrowth: - expr = operator_expression(nc_delta_prev, Operator(op), seed + nc_delta * rate) - else: - expr = operator_expression(nc_delta, Operator(op), seed + nc_delta_prev * rate) - - # Check if any variables are actually included before returning - if isinstance(expr, bool): - return Constraint.Skip - return expr - - def limit_activity_constraint( model: TemoaModel, r: Region, p: Period, t: Technology, op: str ) -> ExprLike: diff --git a/temoa/core/model.py b/temoa/core/model.py index 29a801de2..10f0a77b5 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -15,7 +15,6 @@ from pyomo.core import BuildCheck, Set, Var from pyomo.environ import ( AbstractModel, - Any, BuildAction, Constraint, Integers, @@ -635,25 +634,6 @@ def __init__( self.limit_annual_capacity_factor_constraint_rtvo, validate=validate_0to1 ) - self.limit_growth_capacity = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_degrowth_capacity = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_growth_new_capacity = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_degrowth_new_capacity = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_growth_new_capacity_delta = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_degrowth_new_capacity_delta = Param( - self.regional_global_indices, self.tech_or_group, self.operator, within=Any - ) - self.limit_emission_constraint_rpe = Set( within=self.regional_global_indices * self.time_optimize @@ -1002,51 +982,6 @@ def __init__( ['Starting LimitGrowth and Activity Constraints'], rule=progress_check ) - self.limit_growth_capacity_constraint_rpt = Set( - dimen=4, initialize=limits.limit_growth_capacity_indices - ) - self.limit_growth_capacity_constraint = Constraint( - self.limit_growth_capacity_constraint_rpt, - rule=limits.limit_growth_capacity_constraint_rule, - ) - self.limit_degrowth_capacity_constraint_rpt = Set( - dimen=4, initialize=limits.limit_degrowth_capacity_indices - ) - self.limit_degrowth_capacity_constraint = Constraint( - self.limit_degrowth_capacity_constraint_rpt, - rule=limits.limit_degrowth_capacity_constraint_rule, - ) - - self.limit_growth_new_capacity_constraint_rpt = Set( - dimen=4, initialize=limits.limit_growth_new_capacity_indices - ) - self.limit_growth_new_capacity_constraint = Constraint( - self.limit_growth_new_capacity_constraint_rpt, - rule=limits.limit_growth_new_capacity_constraint_rule, - ) - self.limit_degrowth_new_capacity_constraint_rpt = Set( - dimen=4, initialize=limits.limit_degrowth_new_capacity_indices - ) - self.limit_degrowth_new_capacity_constraint = Constraint( - self.limit_degrowth_new_capacity_constraint_rpt, - rule=limits.limit_degrowth_new_capacity_constraint_rule, - ) - - self.limit_growth_new_capacity_delta_constraint_rpt = Set( - dimen=4, initialize=limits.limit_growth_new_capacity_delta_indices - ) - self.limit_growth_new_capacity_delta_constraint = Constraint( - self.limit_growth_new_capacity_delta_constraint_rpt, - rule=limits.limit_growth_new_capacity_delta_constraint_rule, - ) - self.limit_degrowth_new_capacity_delta_constraint_rpt = Set( - dimen=4, initialize=limits.limit_degrowth_new_capacity_delta_indices - ) - self.limit_degrowth_new_capacity_delta_constraint = Constraint( - self.limit_degrowth_new_capacity_delta_constraint_rpt, - rule=limits.limit_degrowth_new_capacity_delta_constraint_rule, - ) - self.limit_activity_constraint = Constraint( self.limit_activity_constraint_rpt, rule=limits.limit_activity_constraint ) diff --git a/temoa/data_io/component_manifest.py b/temoa/data_io/component_manifest.py index 4f7334c42..c02513f90 100644 --- a/temoa/data_io/component_manifest.py +++ b/temoa/data_io/component_manifest.py @@ -657,66 +657,6 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1, 2), is_table_required=False, ), - LoadItem( - component=model.limit_growth_capacity, - table='limit_growth_capacity', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), - LoadItem( - component=model.limit_growth_new_capacity, - table='limit_growth_new_capacity', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), - LoadItem( - component=model.limit_growth_new_capacity_delta, - table='limit_growth_new_capacity_delta', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), - LoadItem( - component=model.limit_degrowth_capacity, - table='limit_degrowth_capacity', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), - LoadItem( - component=model.limit_degrowth_new_capacity, - table='limit_degrowth_new_capacity', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), - LoadItem( - component=model.limit_degrowth_new_capacity_delta, - table='limit_degrowth_new_capacity_delta', - columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], - index_length=3, - validator_name='viable_rt', - validation_map=(0, 1), - is_period_filtered=False, - is_table_required=False, - ), LoadItem( component=model.limit_resource, table='limit_resource', diff --git a/temoa/data_io/hybrid_loader.py b/temoa/data_io/hybrid_loader.py index 1a70ccbaa..8bb73ba20 100644 --- a/temoa/data_io/hybrid_loader.py +++ b/temoa/data_io/hybrid_loader.py @@ -66,12 +66,6 @@ 'limit_capacity_share': 'region', 'limit_new_capacity_share': 'region', 'limit_resource': 'region', - 'limit_growth_capacity': 'region', - 'limit_degrowth_capacity': 'region', - 'limit_growth_new_capacity': 'region', - 'limit_degrowth_new_capacity': 'region', - 'limit_growth_new_capacity_delta': 'region', - 'limit_degrowth_new_capacity_delta': 'region', } diff --git a/temoa/db_schema/temoa_schema_v4.sql b/temoa/db_schema/temoa_schema_v4.sql index 77b146356..69eef34a5 100644 --- a/temoa/db_schema/temoa_schema_v4.sql +++ b/temoa/db_schema/temoa_schema_v4.sql @@ -389,78 +389,6 @@ CREATE TABLE IF NOT EXISTS operator REPLACE INTO operator VALUES('e','equal to'); REPLACE INTO operator VALUES('le','less than or equal to'); REPLACE INTO operator VALUES('ge','greater than or equal to'); -CREATE TABLE IF NOT EXISTS limit_growth_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE IF NOT EXISTS limit_degrowth_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE IF NOT EXISTS limit_growth_new_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE IF NOT EXISTS limit_degrowth_new_capacity -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE IF NOT EXISTS limit_growth_new_capacity_delta -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); -CREATE TABLE IF NOT EXISTS limit_degrowth_new_capacity_delta -( - region TEXT, - tech_or_group TEXT, - operator TEXT NOT NULL DEFAULT "le" - REFERENCES operator (operator), - rate REAL NOT NULL DEFAULT 0, - seed REAL NOT NULL DEFAULT 0, - seed_units TEXT, - notes TEXT, - PRIMARY KEY (region, tech_or_group, operator) -); CREATE TABLE IF NOT EXISTS limit_storage_level_fraction ( region TEXT, diff --git a/temoa/extensions/growth_rates/__init__.py b/temoa/extensions/growth_rates/__init__.py new file mode 100644 index 000000000..7156dc7f8 --- /dev/null +++ b/temoa/extensions/growth_rates/__init__.py @@ -0,0 +1,3 @@ +from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION + +__all__ = ['GROWTH_RATES_EXTENSION'] diff --git a/temoa/extensions/growth_rates/components/__init__.py b/temoa/extensions/growth_rates/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/growth_rates/components/growth_capacity.py b/temoa/extensions/growth_rates/components/growth_capacity.py new file mode 100644 index 000000000..14a15ca64 --- /dev/null +++ b/temoa/extensions/growth_rates/components/growth_capacity.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.utils import Operator, get_adjusted_existing_capacity, operator_expression + +if TYPE_CHECKING: + from temoa.extensions.growth_rates.core.model import GrowthRatesModel + from temoa.types import ExprLike, Period, Region, Technology + + +def limit_growth_capacity_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_growth_capacity.sparse_keys() + for p in model.time_optimize + } + + +def limit_degrowth_capacity_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_degrowth_capacity.sparse_keys() + for p in model.time_optimize + } + + +def limit_growth_capacity_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_capacity(model, r, p, t, op, False) + + +def limit_degrowth_capacity_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_capacity(model, r, p, t, op, True) + + +def limit_growth_capacity( + model: GrowthRatesModel, + r: Region, + p: Period, + t: Technology, + op: str, + degrowth: bool = False, +) -> ExprLike: + r""" + Constrain the change of capacity available between periods. + Forces the model to ramp up and down the availability of new technologies + more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional + (rate, :math:`R_{r,t}`) terms. This can be defined for a technology group + instead of one technology, in which case, capacity available is summed over + all technologies in the group. In the first period, previous available + capacity :math:`\mathbf{CAPAVL}_{r,p,t}` is replaced by previous existing + capacity, if any can be found. + + .. math:: + :label: Limit (De)Growth Capacity + + \begin{aligned}\text{Growth:}\\ + &\mathbf{CAPAVL}_{r,p,t} + \quad \le, \ge, \text{or} = \quad + S_{r,t} + (1+R_{r,t}) \cdot \mathbf{CAPAVL}_{r,p_{prev},t} + \end{aligned} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacity}} + + + \begin{aligned}\text{Degrowth:}\\ + &\mathbf{CAPAVL}_{r,p_{prev},t} + \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot \mathbf{CAPAVL}_{r,p,t} + \end{aligned} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacity}} + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + growth = model.limit_degrowth_capacity if degrowth else model.limit_growth_capacity + rate = 1 + value(growth[r, t, op][0]) + seed = value(growth[r, t, op][1]) + cap_rpt = model.v_capacity_available_by_period_and_tech + + cap_indices = {(_r, _p, _t) for _r, _p, _t in cap_rpt.keys() if _t in techs and _r in regions} + periods = sorted({_p for _r, _p, _t in cap_rpt}) + + if len(periods) == 0: + return Constraint.Skip + + capacity = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p) + + if p == model.time_optimize.first(): + p_prev = model.time_exist.last() + capacity_prev = sum( + get_adjusted_existing_capacity(model, _r, _t, _v) + * value(model.process_life_frac[_r, p_prev, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions + and _t in techs + and _v + value(model.lifetime_process[_r, _t, _v]) > p_prev + ) + else: + p_prev = model.time_optimize.prev(p) + capacity_prev = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p_prev) + + if degrowth: + expr = operator_expression(capacity_prev, Operator(op), seed + capacity * rate) + else: + expr = operator_expression(capacity, Operator(op), seed + capacity_prev * rate) + + if isinstance(expr, bool): + return Constraint.Skip + return expr diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity.py b/temoa/extensions/growth_rates/components/growth_new_capacity.py new file mode 100644 index 000000000..e0115be0b --- /dev/null +++ b/temoa/extensions/growth_rates/components/growth_new_capacity.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.utils import Operator, operator_expression + +if TYPE_CHECKING: + from temoa.extensions.growth_rates.core.model import GrowthRatesModel + from temoa.types import ExprLike, Period, Region, Technology + + +def limit_growth_new_capacity_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_growth_new_capacity.sparse_keys() + for p in model.time_optimize + } + + +def limit_degrowth_new_capacity_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_degrowth_new_capacity.sparse_keys() + for p in model.time_optimize + } + + +def limit_growth_new_capacity_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_new_capacity(model, r, p, t, op, False) + + +def limit_degrowth_new_capacity_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_new_capacity(model, r, p, t, op, True) + + +def limit_growth_new_capacity( + model: GrowthRatesModel, + r: Region, + p: Period, + t: Technology, + op: str, + degrowth: bool = False, +) -> ExprLike: + r""" + Constrain the change of new capacity deployed between periods. + Forces the model to ramp up and down the deployment of new technologies + more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional + (rate, :math:`R_{r,t}`) terms. This can be defined for a technology group + instead of one technology, in which case, new capacity is summed over + all technologies in the group. In the first period, previous new capacity + :math:`\mathbf{NCAP}_{r,t,v_prev}` is replaced by previous existing capacity, + if any can be found. + + .. math:: + :label: Limit (De)Growth New Capacity + + \begin{aligned}\text{Growth:}\\ + &\mathbf{NCAP}_{r,t,v} + \quad \le, \ge, \text{or} = \quad + S_{r,t} + (1+R_{r,t}) \cdot \mathbf{NCAP}_{r,t,v_{prev}} + \text{ where } v=p + \end{aligned} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacity}} + + \begin{aligned}\text{Degrowth:}\\ + &\mathbf{NCAP}_{r,t,v_{prev}} + \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot \mathbf{NCAP}_{r,t,v} + \text{ where } v=p + \end{aligned} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacity}} + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + growth = model.limit_degrowth_new_capacity if degrowth else model.limit_growth_new_capacity + rate = 1 + value(growth[r, t, op][0]) + seed = value(growth[r, t, op][1]) + new_cap_rtv = model.v_new_capacity + + cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} + periods = sorted({_v for _r, _t, _v in cap_rtv}) + + if len(periods) == 0: + return Constraint.Skip + + new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + + if p == model.time_optimize.first(): + p_prev = model.time_exist.last() + new_cap_prev = sum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev + ) + else: + p_prev = model.time_optimize.prev(p) + new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + + if degrowth: + expr = operator_expression(new_cap_prev, Operator(op), seed + new_cap * rate) + else: + expr = operator_expression(new_cap, Operator(op), seed + new_cap_prev * rate) + + if isinstance(expr, bool): + return Constraint.Skip + return expr diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py new file mode 100644 index 000000000..c06b12901 --- /dev/null +++ b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.utils import Operator, operator_expression + +if TYPE_CHECKING: + from temoa.extensions.growth_rates.core.model import GrowthRatesModel + from temoa.types import ExprLike, Period, Region, Technology + + +def limit_growth_new_capacity_delta_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_growth_new_capacity_delta.sparse_keys() + for p in model.time_optimize + } + + +def limit_degrowth_new_capacity_delta_indices( + model: GrowthRatesModel, +) -> set[tuple[Region, Period, Technology, str]]: + return { + (r, p, t, op) + for r, t, op in model.limit_degrowth_new_capacity_delta.sparse_keys() + for p in model.time_optimize + } + + +def limit_growth_new_capacity_delta_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_new_capacity_delta(model, r, p, t, op, False) + + +def limit_degrowth_new_capacity_delta_constraint_rule( + model: GrowthRatesModel, r: Region, p: Period, t: Technology, op: str +) -> ExprLike: + return limit_growth_new_capacity_delta(model, r, p, t, op, True) + + +def limit_growth_new_capacity_delta( + model: GrowthRatesModel, + r: Region, + p: Period, + t: Technology, + op: str, + degrowth: bool = False, +) -> ExprLike: + r""" + Constrain the acceleration of new capacity deployed between periods. + Forces the model to ramp up and down the change in deployment of new technologies + more smoothly. Has constant (seed, :math:`S_{r,t}`) and proportional + (rate, :math:`R_{r,t}`) terms. It is recommended to leave the rate term empty + as it would prevent the possibility of inflection in the rate of deployment. + This constraint can be defined for a technology group instead of one technology, + in which case, new capacity is summed over all technologies in the group. In the + first period, previous new capacities are replaced by previous existing capacities, + if any can be found. + + .. math:: + :label: Limit (De)Growth New Capacity Delta + + \begin{aligned}\text{Growth:}\\ + &\mathbf{NCAP}_{r,t,v_i} - \mathbf{NCAP}_{r,t,v_{i-1}} + \quad \le, \ge, \text{or} = \quad S_{r,t} + (1+R_{r,t}) \cdot + (\mathbf{NCAP}_{r,t,v_{i-1}} - \mathbf{NCAP}_{r,t,v_{i-2}}) + \end{aligned} + + \text{ where } v_i=p + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_growth\_capacityDelta}} + + \begin{aligned}\text{Degrowth:}\\ + &\mathbf{NCAP}_{r,t,v_{i-1}} - \mathbf{NCAP}_{r,t,v_{i-2}} + \quad \le, \ge, \text{or} = \quad + S_{r,t} + (1+R_{r,t}) \cdot (\mathbf{NCAP}_{r,t,v_i} - \mathbf{NCAP}_{r,t,v_{i-1}}) + \end{aligned} + + \text{ where } v_i=p + + \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacityDelta}} + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + growth = ( + model.limit_degrowth_new_capacity_delta + if degrowth + else model.limit_growth_new_capacity_delta + ) + rate = 1 + value(growth[r, t, op][0]) + seed = value(growth[r, t, op][1]) + new_cap_rtv = model.v_new_capacity + + cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} + periods = sorted({_v for _r, _t, _v in cap_rtv}) + + if len(periods) == 0: + return Constraint.Skip + + new_cap = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + + if p == model.time_optimize.first(): + p_prev = model.time_exist.last() + new_cap_prev = sum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev + ) + p_prev2 = model.time_exist.prev(p_prev) + new_cap_prev2 = sum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev2 + ) + else: + p_prev = model.time_optimize.prev(p) + new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + if p == model.time_optimize.at(2): + p_prev2 = model.time_exist.last() + new_cap_prev2 = sum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev2 + ) + else: + p_prev2 = model.time_optimize.prev(p_prev) + new_cap_prev2 = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2) + + nc_delta_prev = new_cap_prev - new_cap_prev2 + nc_delta = new_cap - new_cap_prev + + if degrowth: + expr = operator_expression(nc_delta_prev, Operator(op), seed + nc_delta * rate) + else: + expr = operator_expression(nc_delta, Operator(op), seed + nc_delta_prev * rate) + + if isinstance(expr, bool): + return Constraint.Skip + return expr diff --git a/temoa/extensions/growth_rates/core/__init__.py b/temoa/extensions/growth_rates/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/growth_rates/core/model.py b/temoa/extensions/growth_rates/core/model.py new file mode 100644 index 000000000..30d846d9f --- /dev/null +++ b/temoa/extensions/growth_rates/core/model.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pyomo.environ import Any, Constraint, Param, Set + +from temoa.extensions.growth_rates.components import ( + growth_capacity, + growth_new_capacity, + growth_new_capacity_delta, +) + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + + class GrowthRatesModel(TemoaModel): + """TemoaModel extended with growth-rates components. + + This subtype exists only for static type checking. At runtime the + growth-rates components are attached to the core ``TemoaModel`` + instance by :func:`register_model_components`. + """ + + # Params + limit_growth_capacity: Param + limit_degrowth_capacity: Param + limit_growth_new_capacity: Param + limit_degrowth_new_capacity: Param + limit_growth_new_capacity_delta: Param + limit_degrowth_new_capacity_delta: Param + + # Constraint index sets + limit_growth_capacity_constraint_rpt: Set + limit_degrowth_capacity_constraint_rpt: Set + limit_growth_new_capacity_constraint_rpt: Set + limit_degrowth_new_capacity_constraint_rpt: Set + limit_growth_new_capacity_delta_constraint_rpt: Set + limit_degrowth_new_capacity_delta_constraint_rpt: Set + + # Constraints + limit_growth_capacity_constraint: Constraint + limit_degrowth_capacity_constraint: Constraint + limit_growth_new_capacity_constraint: Constraint + limit_degrowth_new_capacity_constraint: Constraint + limit_growth_new_capacity_delta_constraint: Constraint + limit_degrowth_new_capacity_delta_constraint: Constraint + + +def register_model_components(model: TemoaModel) -> None: + """Register growth rates model components on the core Temoa model.""" + m = cast('GrowthRatesModel', model) + + m.limit_growth_capacity = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + m.limit_degrowth_capacity = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + m.limit_growth_new_capacity = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + m.limit_degrowth_new_capacity = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + m.limit_growth_new_capacity_delta = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + m.limit_degrowth_new_capacity_delta = Param( + m.regional_global_indices, m.tech_or_group, m.operator, within=Any + ) + + m.limit_growth_capacity_constraint_rpt = Set( + dimen=4, initialize=growth_capacity.limit_growth_capacity_indices + ) + m.limit_growth_capacity_constraint = Constraint( + m.limit_growth_capacity_constraint_rpt, + rule=growth_capacity.limit_growth_capacity_constraint_rule, + ) + m.limit_degrowth_capacity_constraint_rpt = Set( + dimen=4, initialize=growth_capacity.limit_degrowth_capacity_indices + ) + m.limit_degrowth_capacity_constraint = Constraint( + m.limit_degrowth_capacity_constraint_rpt, + rule=growth_capacity.limit_degrowth_capacity_constraint_rule, + ) + + m.limit_growth_new_capacity_constraint_rpt = Set( + dimen=4, initialize=growth_new_capacity.limit_growth_new_capacity_indices + ) + m.limit_growth_new_capacity_constraint = Constraint( + m.limit_growth_new_capacity_constraint_rpt, + rule=growth_new_capacity.limit_growth_new_capacity_constraint_rule, + ) + m.limit_degrowth_new_capacity_constraint_rpt = Set( + dimen=4, initialize=growth_new_capacity.limit_degrowth_new_capacity_indices + ) + m.limit_degrowth_new_capacity_constraint = Constraint( + m.limit_degrowth_new_capacity_constraint_rpt, + rule=growth_new_capacity.limit_degrowth_new_capacity_constraint_rule, + ) + + m.limit_growth_new_capacity_delta_constraint_rpt = Set( + dimen=4, initialize=growth_new_capacity_delta.limit_growth_new_capacity_delta_indices + ) + m.limit_growth_new_capacity_delta_constraint = Constraint( + m.limit_growth_new_capacity_delta_constraint_rpt, + rule=growth_new_capacity_delta.limit_growth_new_capacity_delta_constraint_rule, + ) + m.limit_degrowth_new_capacity_delta_constraint_rpt = Set( + dimen=4, initialize=growth_new_capacity_delta.limit_degrowth_new_capacity_delta_indices + ) + m.limit_degrowth_new_capacity_delta_constraint = Constraint( + m.limit_degrowth_new_capacity_delta_constraint_rpt, + rule=growth_new_capacity_delta.limit_degrowth_new_capacity_delta_constraint_rule, + ) diff --git a/temoa/extensions/growth_rates/data_manifest.py b/temoa/extensions/growth_rates/data_manifest.py new file mode 100644 index 000000000..d81b1d355 --- /dev/null +++ b/temoa/extensions/growth_rates/data_manifest.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from temoa.data_io.loader_manifest import LoadItem + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.growth_rates.core.model import GrowthRatesModel + + +def build_manifest_items(model: TemoaModel) -> list[LoadItem]: + """Return LoadItems for growth/degrowth constraints.""" + m = cast('GrowthRatesModel', model) + return [ + LoadItem( + component=m.limit_growth_capacity, + table='limit_growth_capacity', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + LoadItem( + component=m.limit_growth_new_capacity, + table='limit_growth_new_capacity', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + LoadItem( + component=m.limit_growth_new_capacity_delta, + table='limit_growth_new_capacity_delta', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + LoadItem( + component=m.limit_degrowth_capacity, + table='limit_degrowth_capacity', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + LoadItem( + component=m.limit_degrowth_new_capacity, + table='limit_degrowth_new_capacity', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + LoadItem( + component=m.limit_degrowth_new_capacity_delta, + table='limit_degrowth_new_capacity_delta', + columns=['region', 'tech_or_group', 'operator', 'rate', 'seed'], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_period_filtered=False, + is_table_required=False, + ), + ] diff --git a/temoa/extensions/growth_rates/extension.py b/temoa/extensions/growth_rates/extension.py new file mode 100644 index 000000000..5c148b612 --- /dev/null +++ b/temoa/extensions/growth_rates/extension.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path + +from temoa.extensions.framework import ExtensionSpec +from temoa.extensions.growth_rates.core.model import register_model_components +from temoa.extensions.growth_rates.data_manifest import build_manifest_items + + +GROWTH_RATES_EXTENSION = ExtensionSpec( + extension_id='growth_rates', + owned_tables=( + 'limit_growth_capacity', + 'limit_degrowth_capacity', + 'limit_growth_new_capacity', + 'limit_degrowth_new_capacity', + 'limit_growth_new_capacity_delta', + 'limit_degrowth_new_capacity_delta', + ), + regional_group_tables={ + 'limit_growth_capacity': 'region', + 'limit_degrowth_capacity': 'region', + 'limit_growth_new_capacity': 'region', + 'limit_degrowth_new_capacity': 'region', + 'limit_growth_new_capacity_delta': 'region', + 'limit_degrowth_new_capacity_delta': 'region', + }, + register_model_components=register_model_components, + build_manifest_items=build_manifest_items, + schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), + fail_if_tables_populated_when_disabled=True, +) diff --git a/temoa/extensions/growth_rates/tables.sql b/temoa/extensions/growth_rates/tables.sql new file mode 100644 index 000000000..e2fece833 --- /dev/null +++ b/temoa/extensions/growth_rates/tables.sql @@ -0,0 +1,72 @@ +CREATE TABLE IF NOT EXISTS limit_growth_capacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE IF NOT EXISTS limit_degrowth_capacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE IF NOT EXISTS limit_growth_new_capacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE IF NOT EXISTS limit_degrowth_new_capacity +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE IF NOT EXISTS limit_growth_new_capacity_delta +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); +CREATE TABLE IF NOT EXISTS limit_degrowth_new_capacity_delta +( + region TEXT, + tech_or_group TEXT, + operator TEXT NOT NULL DEFAULT "le" + REFERENCES operator (operator), + rate REAL NOT NULL DEFAULT 0, + seed REAL NOT NULL DEFAULT 0, + seed_units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group, operator) +); diff --git a/tests/testing_configs/config_myopic_capacities.toml b/tests/testing_configs/config_myopic_capacities.toml index 3ab763c44..db2d11539 100644 --- a/tests/testing_configs/config_myopic_capacities.toml +++ b/tests/testing_configs/config_myopic_capacities.toml @@ -1,5 +1,6 @@ scenario = "test myopic capacities" scenario_mode = "myopic" +extensions= ["growth_rates"] input_database = "tests/testing_outputs/myopic_capacities.sqlite" output_database = "tests/testing_outputs/myopic_capacities.sqlite" neos = false diff --git a/tests/testing_data/mediumville_sets.json b/tests/testing_data/mediumville_sets.json index c86baf5cc..acc5b16ea 100644 --- a/tests/testing_data/mediumville_sets.json +++ b/tests/testing_data/mediumville_sets.json @@ -39,13 +39,7 @@ "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_capacity_constraint_rpt": "118826dabd14af75ae09adbd5bdbba750528b3d59907cbfba3690223ffc7def7", "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_emission_constraint_rpe": "9bf30514ecc261370f67cd0c2e18194f45ff14c15764d274cc342a5a27eaf2a0", - "limit_growth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_share_constraint_rggv": "296e4148565f752973655692bae04ebf50877bd13fe9cec8f9563f347a61d885", "limit_resource_constraint_rt": "b9b44ae8bc9642da0a81202877bd69f0b35ce193e31590dd3325ac0828edb5cf", diff --git a/tests/testing_data/test_system_sets.json b/tests/testing_data/test_system_sets.json index 6d73af97a..f5a33a549 100644 --- a/tests/testing_data/test_system_sets.json +++ b/tests/testing_data/test_system_sets.json @@ -39,13 +39,7 @@ "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_emission_constraint_rpe": "6f11e4ee041970584903d5afba7ee1147824b233260422564fdf0c701e9fabde", - "limit_growth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_share_constraint_rggv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_resource_constraint_rt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", diff --git a/tests/testing_data/utopia_sets.json b/tests/testing_data/utopia_sets.json index 53d8b2a8d..6d9adc1a5 100644 --- a/tests/testing_data/utopia_sets.json +++ b/tests/testing_data/utopia_sets.json @@ -39,13 +39,7 @@ "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_capacity_constraint_rpt": "5a43de033187da68c612c5bcef6a76edb6ab9806464b6d865e46d289ccbbd815", "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_degrowth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_emission_constraint_rpe": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_growth_new_capacity_delta_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_new_capacity_share_constraint_rggv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "limit_resource_constraint_rt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", From 1fbc8c85c98c7f7d0fd7eeaaf786a6ec120db559 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 6 Jul 2026 13:52:29 -0400 Subject: [PATCH 04/23] Fix bugs for Bugs Signed-off-by: Davey Elder --- temoa/cli.py | 9 ++-- temoa/components/limits.py | 6 +-- temoa/components/utils.py | 10 ++-- temoa/data_io/hybrid_loader.py | 2 +- temoa/data_io/loader_manifest.py | 2 + .../components/growth_capacity.py | 21 ++++---- .../components/growth_new_capacity.py | 17 ++++--- .../components/growth_new_capacity_delta.py | 48 +++++++++++-------- .../stochastics/scenario_creator.py | 5 +- 9 files changed, 71 insertions(+), 49 deletions(-) diff --git a/temoa/cli.py b/temoa/cli.py index cbcc234c1..9feee14d4 100644 --- a/temoa/cli.py +++ b/temoa/cli.py @@ -564,6 +564,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None target_config: Path where configuration file should be copied target_database: Path where database file should be created """ + import contextlib import sqlite3 try: @@ -587,7 +588,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None schema_content = schema_resource.read_text(encoding='utf-8') sql_content = sql_resource.read_text(encoding='utf-8') - with sqlite3.connect(target_database) as conn: + with contextlib.closing(sqlite3.connect(target_database)) as conn: conn.executescript(schema_content) conn.execute('PRAGMA foreign_keys = OFF;') conn.executescript(sql_content) @@ -597,6 +598,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None raise sqlite3.IntegrityError( f'Foreign key check failed after tutorial data load: {fk_violations[:5]}' ) + conn.commit() # Copy Monte Carlo settings with mc_settings_resource.open('rb') as source: @@ -640,7 +642,7 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None # Generate database from canonical schema and tutorial data source sql_content = fallback_sql.read_text(encoding='utf-8') - with sqlite3.connect(target_database) as conn: + with contextlib.closing(sqlite3.connect(target_database)) as conn: conn.executescript(fallback_schema.read_text(encoding='utf-8')) conn.execute('PRAGMA foreign_keys = OFF;') conn.executescript(sql_content) @@ -649,7 +651,8 @@ def _copy_tutorial_resources(target_config: Path, target_database: Path) -> None if fk_violations: raise sqlite3.IntegrityError( f'Foreign key check failed after tutorial data load: {fk_violations[:5]}' - ) from e + ) from None + conn.commit() # Copy mc_settings from fallback shutil.copy2(fallback_mc, target_config.parent / 'mc_settings.csv') diff --git a/temoa/components/limits.py b/temoa/components/limits.py index 0145073ea..702adb171 100644 --- a/temoa/components/limits.py +++ b/temoa/components/limits.py @@ -444,10 +444,8 @@ def limit_annual_capacity_factor_constraint( regions = geography.gather_group_regions(model, r) techs = technology.gather_group_techs(model, t) - if TYPE_CHECKING: - activity_rptvo = cast('Expression', 0) - else: - activity_rptvo = 0 + activity_rptvo = 0 + for _t in techs: if _t not in model.tech_annual: activity_rptvo += quicksum( diff --git a/temoa/components/utils.py b/temoa/components/utils.py index daa2b421b..7a8c91fae 100644 --- a/temoa/components/utils.py +++ b/temoa/components/utils.py @@ -12,10 +12,10 @@ from logging import getLogger from typing import TYPE_CHECKING -from pyomo.environ import value +from pyomo.environ import Expression, value if TYPE_CHECKING: - from pyomo.core import Expression + from pyomo.core.expr.numeric_expr import NumericValue from temoa.core.model import TemoaModel from temoa.types import ( @@ -39,7 +39,11 @@ class Operator(StrEnum): GREATER_EQUAL = 'ge' -def operator_expression(lhs: Expression, operator: Operator, rhs: Expression) -> ExprLike: +def operator_expression( + lhs: Expression | NumericValue | float, + operator: Operator, + rhs: Expression | NumericValue | float, +) -> ExprLike: match operator: case Operator.EQUAL: return lhs == rhs diff --git a/temoa/data_io/hybrid_loader.py b/temoa/data_io/hybrid_loader.py index 8bb73ba20..6dad50308 100644 --- a/temoa/data_io/hybrid_loader.py +++ b/temoa/data_io/hybrid_loader.py @@ -246,7 +246,7 @@ def create_data_dict(self, myopic_index: MyopicIndex | None = None) -> dict[str, for item in self.manifest: # 1. Fetch data from the database raw_data = self._fetch_data(cur, item, myopic_index) - if item.index_length: + if item.index_length and len(item.columns) - item.index_length > 1: raw_data = [ (*row[0 : item.index_length], row[item.index_length :]) for row in raw_data ] diff --git a/temoa/data_io/loader_manifest.py b/temoa/data_io/loader_manifest.py index 5723d9e96..886aaa4e1 100644 --- a/temoa/data_io/loader_manifest.py +++ b/temoa/data_io/loader_manifest.py @@ -32,6 +32,8 @@ class LoadItem: table: The name of the source table in the SQLite database. columns: A list of column names to select from the table. The last column is assumed to be the value for a `Param`. + index_length: Optional. The number of columns that form the index for + a `Param`, for tables with multiple value columns. validator_name: Optional. The name of a `ViableSet` attribute on the `HybridLoader` instance, used for source-trace filtering. validation_map: A tuple indicating which column indices in the data diff --git a/temoa/extensions/growth_rates/components/growth_capacity.py b/temoa/extensions/growth_rates/components/growth_capacity.py index 14a15ca64..a091e2604 100644 --- a/temoa/extensions/growth_rates/components/growth_capacity.py +++ b/temoa/extensions/growth_rates/components/growth_capacity.py @@ -99,16 +99,19 @@ def limit_growth_capacity( capacity = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p) + capacity_prev = 0.0 + if p == model.time_optimize.first(): - p_prev = model.time_exist.last() - capacity_prev = sum( - get_adjusted_existing_capacity(model, _r, _t, _v) - * value(model.process_life_frac[_r, p_prev, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions - and _t in techs - and _v + value(model.lifetime_process[_r, _t, _v]) > p_prev - ) + if model.time_exist: + p_prev = model.time_exist.last() + capacity_prev = quicksum( + get_adjusted_existing_capacity(model, _r, _t, _v) + * value(model.process_life_frac[_r, p_prev, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions + and _t in techs + and _v + value(model.lifetime_process[_r, _t, _v]) > p_prev + ) else: p_prev = model.time_optimize.prev(p) capacity_prev = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p_prev) diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity.py b/temoa/extensions/growth_rates/components/growth_new_capacity.py index e0115be0b..51c279fd4 100644 --- a/temoa/extensions/growth_rates/components/growth_new_capacity.py +++ b/temoa/extensions/growth_rates/components/growth_new_capacity.py @@ -100,16 +100,19 @@ def limit_growth_new_capacity( new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + new_cap_prev = 0.0 + if p == model.time_optimize.first(): - p_prev = model.time_exist.last() - new_cap_prev = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev - ) + if model.time_exist: + p_prev = model.time_exist.last() + new_cap_prev = quicksum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev + ) else: p_prev = model.time_optimize.prev(p) - new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + new_cap_prev = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) if degrowth: expr = operator_expression(new_cap_prev, Operator(op), seed + new_cap * rate) diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py index c06b12901..d813d54c6 100644 --- a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py +++ b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from pyomo.environ import Constraint, value +from pyomo.environ import Constraint, quicksum, value import temoa.components.geography as geography import temoa.components.technology as technology @@ -106,34 +106,40 @@ def limit_growth_new_capacity_delta( if len(periods) == 0: return Constraint.Skip - new_cap = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + + new_cap_prev = 0.0 + new_cap_prev2 = 0.0 if p == model.time_optimize.first(): - p_prev = model.time_exist.last() - new_cap_prev = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev - ) - p_prev2 = model.time_exist.prev(p_prev) - new_cap_prev2 = sum( - value(model.existing_capacity[_r, _t, _v]) - for _r, _t, _v in model.existing_capacity.sparse_keys() - if _r in regions and _t in techs and _v == p_prev2 - ) - else: - p_prev = model.time_optimize.prev(p) - new_cap_prev = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) - if p == model.time_optimize.at(2): - p_prev2 = model.time_exist.last() - new_cap_prev2 = sum( + if model.time_exist: + p_prev = model.time_exist.last() + new_cap_prev = quicksum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev + ) + if len(model.time_exist) >= 2: + p_prev2 = model.time_exist.prev(p_prev) + new_cap_prev2 = quicksum( value(model.existing_capacity[_r, _t, _v]) for _r, _t, _v in model.existing_capacity.sparse_keys() if _r in regions and _t in techs and _v == p_prev2 ) + else: + p_prev = model.time_optimize.prev(p) + new_cap_prev = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + if p == model.time_optimize.at(2): + if model.time_exist: + p_prev2 = model.time_exist.last() + new_cap_prev2 = quicksum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs and _v == p_prev2 + ) else: p_prev2 = model.time_optimize.prev(p_prev) - new_cap_prev2 = sum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2) + new_cap_prev2 = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2) nc_delta_prev = new_cap_prev - new_cap_prev2 nc_delta = new_cap - new_cap_prev diff --git a/temoa/extensions/stochastics/scenario_creator.py b/temoa/extensions/stochastics/scenario_creator.py index 3dc0b6591..a1ffcccc3 100644 --- a/temoa/extensions/stochastics/scenario_creator.py +++ b/temoa/extensions/stochastics/scenario_creator.py @@ -45,7 +45,10 @@ def scenario_creator(scenario_name: str, **kwargs: Any) -> Any: table_index_map: dict[str, list[str]] = {} for item in cast('Iterable[LoadItem]', hybrid_loader.manifest): if item.table not in table_index_map and item.columns: - table_index_map[item.table] = list(item.columns[:-1]) + if item.index_length: + table_index_map[item.table] = list(item.columns[:item.index_length]) + else: + table_index_map[item.table] = list(item.columns[:-1]) except Exception as e: logger.exception('Failed to connect to database %s', temoa_config.input_database) raise RuntimeError(f'Failed to connect to database {temoa_config.input_database}') from e From 5ba582d8b1c1a2d9edc80297b1e192884bd218cf Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Tue, 7 Jul 2026 19:19:57 -0400 Subject: [PATCH 05/23] Add integer capacity extension Signed-off-by: Davey Elder --- docs/source/extensions.rst | 1 + docs/source/extensions/discrete_capacity.rst | 93 +++++++++++++++ .../extensions/discrete_capacity/__init__.py | 3 + .../discrete_capacity/components/__init__.py | 0 .../components/discrete_capacity.py | 108 ++++++++++++++++++ .../discrete_capacity/core/__init__.py | 0 .../discrete_capacity/core/model.py | 55 +++++++++ .../discrete_capacity/data_manifest.py | 33 ++++++ .../extensions/discrete_capacity/extension.py | 20 ++++ temoa/extensions/discrete_capacity/tables.sql | 16 +++ temoa/extensions/framework.py | 7 +- .../components/growth_capacity.py | 2 +- tests/test_extensions.py | 95 +++++++++++++++ 13 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 docs/source/extensions/discrete_capacity.rst create mode 100644 temoa/extensions/discrete_capacity/__init__.py create mode 100644 temoa/extensions/discrete_capacity/components/__init__.py create mode 100644 temoa/extensions/discrete_capacity/components/discrete_capacity.py create mode 100644 temoa/extensions/discrete_capacity/core/__init__.py create mode 100644 temoa/extensions/discrete_capacity/core/model.py create mode 100644 temoa/extensions/discrete_capacity/data_manifest.py create mode 100644 temoa/extensions/discrete_capacity/extension.py create mode 100644 temoa/extensions/discrete_capacity/tables.sql create mode 100644 tests/test_extensions.py diff --git a/docs/source/extensions.rst b/docs/source/extensions.rst index 32f72454e..06cc597c5 100644 --- a/docs/source/extensions.rst +++ b/docs/source/extensions.rst @@ -244,3 +244,4 @@ as new extensions are added. :maxdepth: 1 extensions/growth_rates + extensions/discrete_capacity diff --git a/docs/source/extensions/discrete_capacity.rst b/docs/source/extensions/discrete_capacity.rst new file mode 100644 index 000000000..aa3d988bc --- /dev/null +++ b/docs/source/extensions/discrete_capacity.rst @@ -0,0 +1,93 @@ +.. _extension-discrete-capacity: + +Discrete Capacity +================================ + +The **discrete_capacity** extension adds optional constraints that force the +capacity of a technology or technology group to be deployed in discrete, +indivisible units rather than as a continuous quantity. This is useful for +modeling lumpy investments or retirements such as a power plant, reactor, or +other facility that can only exist in whole units of a fixed size. + +Enabling the extension introduces integer decision variables, which turns the +model into a mixed-integer program (MIP). It is disabled by default and enabled +per run through configuration: + +.. code-block:: toml + + extensions = ["discrete_capacity"] + +Its parameters live in the extension-owned tables of the same name and are loaded +only when the extension is enabled. See :ref:`extensions` for how the extension +framework wires these components into the model. + +Parameters +---------- + +.. csv-table:: + :header: "Parameter", "Database Table", "Model Element", "Notes" + :widths: 15, 20, 25, 40 + + ":math:`\text{LDNC}_{r,t}`", ":code:`limit_discrete_new_capacity`", ":code:`limit_discrete_new_capacity`", "unit size for new capacity additions; new capacity is forced to an discrete multiple of this size; :code:`tech_or_group` column accepts a technology name or group name" + ":math:`\text{LDC}_{r,t}`", ":code:`limit_discrete_capacity`", ":code:`limit_discrete_capacity`", "unit size for total available (retirement-adjusted) capacity; net capacity is forced to an integer multiple of this size; :code:`tech_or_group` column accepts a technology name or group name" + + +limit_discrete_new_capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LDNC}_{r \in R, t \in T}` + +The :code:`limit_discrete_new_capacity` parameter specifies the unit size for new +capacity additions of a technology (or group). The new capacity built in each +vintage is forced to be an integer multiple of this unit size. The +:code:`tech_or_group` column accepts a technology name or group name, and the +:code:`capacity` column gives the size of a single unit. + + +limit_discrete_capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{LDC}_{r \in R, t \in T}` + +The :code:`limit_discrete_capacity` parameter specifies the unit size for the +total available (retirement-adjusted) capacity of a technology (or group). The +net capacity available in each period is forced to be an integer multiple of this +unit size. The :code:`tech_or_group` column accepts a technology name or group +name, and the :code:`capacity` column gives the size of a single unit. + + +Decision Variables +------------------ + +Enabling the extension adds two integer decision variables, one per constraint. +Each counts the number of discrete units, which is multiplied by the +corresponding unit size to recover the capacity quantity. + +v_discrete_new_capacity +~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`DNCAP_{r, t, v}` + +The number of discrete units of new capacity built for technology (or group) +:math:`t` in region :math:`r` and vintage :math:`v`. It is restricted to +non-negative integers, and the total new capacity equals this count multiplied by +the unit size :math:`LDNC_{r, t}`. + + +v_discrete_capacity +~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`DCAP_{r, p, t}` + +The number of discrete units of total available (retirement-adjusted) capacity +for technology (or group) :math:`t` in region :math:`r` and period :math:`p`. It +is restricted to non-negative integers, and the net available capacity equals this +count multiplied by the unit size :math:`LDC_{r, t}`. + + +Constraints +----------- + +.. autofunction:: temoa.extensions.discrete_capacity.components.discrete_capacity.limit_discrete_new_capacity_constraint_rule + +.. autofunction:: temoa.extensions.discrete_capacity.components.discrete_capacity.limit_discrete_capacity_constraint_rule diff --git a/temoa/extensions/discrete_capacity/__init__.py b/temoa/extensions/discrete_capacity/__init__.py new file mode 100644 index 000000000..c1a499cd1 --- /dev/null +++ b/temoa/extensions/discrete_capacity/__init__.py @@ -0,0 +1,3 @@ +from temoa.extensions.discrete_capacity.extension import DISCRETE_CAPACITY_EXTENSION + +__all__ = ['DISCRETE_CAPACITY_EXTENSION'] diff --git a/temoa/extensions/discrete_capacity/components/__init__.py b/temoa/extensions/discrete_capacity/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/discrete_capacity/components/discrete_capacity.py b/temoa/extensions/discrete_capacity/components/discrete_capacity.py new file mode 100644 index 000000000..6d0c6bbd9 --- /dev/null +++ b/temoa/extensions/discrete_capacity/components/discrete_capacity.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from temoa.extensions.discrete_capacity.core.model import DiscreteCapacityModel + from temoa.types import ExprLike, Period, Region, Technology, Vintage + + +def limit_discrete_new_capacity_indices( + model: DiscreteCapacityModel, +) -> set[tuple[Region, Technology, Vintage]]: + + indices = { + (r, t, v) + for r, t in model.limit_discrete_new_capacity.sparse_keys() + for _r in geography.gather_group_regions(model, r) + for _t in technology.gather_group_techs(model, t) + for v in model.vintage_optimize + if (_r, _t, v) in model.process_periods + } + return indices + + +def limit_discrete_new_capacity_constraint_rule( + model: DiscreteCapacityModel, r: Region, t: Technology, v: Vintage +) -> ExprLike: + r""" + The limit_discrete_new_capacity constraint requires the total new capacity + for a technology (or technology group) in a region to be discrete, equal + to some integer multiple of the specified capacity. + + .. math:: + :label: limit_discrete_new_capacity + + \textbf{NCAP}_{r, t, v} = LDNC_{r, t} \cdot \textbf{DNCAP}_{r, v, t} + + \forall \{r, t, v\} \in \Theta_{\text{limit\_discrete\_new\_capacity}} + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + unit_cap = value(model.limit_discrete_new_capacity[r, t]) + + new_capacity = quicksum( + model.v_new_capacity[_r, _t, v] + for _r in regions + for _t in techs + if (_r, _t, v) in model.process_periods + ) + expr = new_capacity == unit_cap * model.v_discrete_new_capacity[r, t, v] + if isinstance(expr, bool): + return Constraint.Skip + return expr + + +def limit_discrete_capacity_indices( + model: DiscreteCapacityModel, +) -> set[tuple[Region, Period, Technology]]: + indices = { + (r, p, t) + for r, t in model.limit_discrete_capacity.sparse_keys() + for _r in geography.gather_group_regions(model, r) + for _t in technology.gather_group_techs(model, t) + for p in model.time_optimize + if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + } + return indices + + +def limit_discrete_capacity_constraint_rule( + model: DiscreteCapacityModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + The limit_discrete_capacity constraint requires the total available capacity + for a technology (or technology group) in a region to be discrete, equal + to some integer multiple of the specified capacity. + + .. math:: + :label: limit_discrete_capacity + + \textbf{CAPAVL}_{r, p, t} = LDC_{r, t} \cdot \textbf{DCAP}_{r, p, t} + + \forall \{r, p, t\} \in \Theta_{\text{limit\_discrete\_net\_capacity}} + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + capacity = value(model.limit_discrete_capacity[r, t]) + + net_capacity = quicksum( + model.v_capacity_available_by_period_and_tech[_r, p, _t] + for _r in regions + for _t in techs + if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + ) + expr = net_capacity == capacity * model.v_discrete_capacity[r, p, t] + if isinstance(expr, bool): + return Constraint.Skip + return expr diff --git a/temoa/extensions/discrete_capacity/core/__init__.py b/temoa/extensions/discrete_capacity/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/discrete_capacity/core/model.py b/temoa/extensions/discrete_capacity/core/model.py new file mode 100644 index 000000000..6cc127b80 --- /dev/null +++ b/temoa/extensions/discrete_capacity/core/model.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pyomo.environ import Constraint, Integers, NonNegativeReals, Param, Set, Var + +from temoa.extensions.discrete_capacity.components import discrete_capacity + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + + class DiscreteCapacityModel(TemoaModel): + + limit_discrete_new_capacity: Param + limit_discrete_new_capacity_constraint_rtv: Set + limit_discrete_new_capacity_constraint: Constraint + v_discrete_new_capacity: Var + + limit_discrete_capacity: Param + limit_discrete_capacity_constraint_rpt: Set + limit_discrete_capacity_constraint: Constraint + v_discrete_capacity: Var + + +def register_model_components(model: TemoaModel) -> None: + m = cast('DiscreteCapacityModel', model) + + m.limit_discrete_new_capacity = Param( + m.regional_global_indices, m.tech_or_group, within=NonNegativeReals + ) + m.limit_discrete_new_capacity_constraint_rtv = Set( + dimen=3, initialize=discrete_capacity.limit_discrete_new_capacity_indices + ) + m.v_discrete_new_capacity = Var( + m.limit_discrete_new_capacity_constraint_rtv, within=Integers, bounds=(0, None) + ) + m.limit_discrete_new_capacity_constraint = Constraint( + m.limit_discrete_new_capacity_constraint_rtv, + rule=discrete_capacity.limit_discrete_new_capacity_constraint_rule, + ) + + + m.limit_discrete_capacity = Param( + m.regional_global_indices, m.tech_or_group, within=NonNegativeReals + ) + m.limit_discrete_capacity_constraint_rpt = Set( + dimen=3, initialize=discrete_capacity.limit_discrete_capacity_indices + ) + m.v_discrete_capacity = Var( + m.limit_discrete_capacity_constraint_rpt, within=Integers, bounds=(0, None) + ) + m.limit_discrete_capacity_constraint = Constraint( + m.limit_discrete_capacity_constraint_rpt, + rule=discrete_capacity.limit_discrete_capacity_constraint_rule, + ) diff --git a/temoa/extensions/discrete_capacity/data_manifest.py b/temoa/extensions/discrete_capacity/data_manifest.py new file mode 100644 index 000000000..35702e203 --- /dev/null +++ b/temoa/extensions/discrete_capacity/data_manifest.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from temoa.data_io.loader_manifest import LoadItem + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.discrete_capacity.core.model import DiscreteCapacityModel + + +def build_manifest_items(model: TemoaModel) -> list[LoadItem]: + m = cast('DiscreteCapacityModel', model) + return [ + LoadItem( + component=m.limit_discrete_new_capacity, + table='limit_discrete_new_capacity', + columns=['region', 'tech_or_group', 'capacity'], + validator_name='viable_rt', + validation_map=(0, 1), + is_table_required=False, + is_period_filtered=False, + ), + LoadItem( + component=m.limit_discrete_capacity, + table='limit_discrete_capacity', + columns=['region', 'tech_or_group', 'capacity'], + validator_name='viable_rt', + validation_map=(0, 1), + is_table_required=False, + is_period_filtered=False, + ), + ] diff --git a/temoa/extensions/discrete_capacity/extension.py b/temoa/extensions/discrete_capacity/extension.py new file mode 100644 index 000000000..2a0c141e0 --- /dev/null +++ b/temoa/extensions/discrete_capacity/extension.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path + +from temoa.extensions.discrete_capacity.core.model import register_model_components +from temoa.extensions.discrete_capacity.data_manifest import build_manifest_items +from temoa.extensions.framework import ExtensionSpec + +DISCRETE_CAPACITY_EXTENSION = ExtensionSpec( + extension_id='discrete_capacity', + owned_tables=('limit_discrete_new_capacity', 'limit_discrete_capacity'), + regional_group_tables={ + 'limit_discrete_new_capacity': 'region', + 'limit_discrete_capacity': 'region' + }, + register_model_components=register_model_components, + build_manifest_items=build_manifest_items, + schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), + fail_if_tables_populated_when_disabled=True, +) diff --git a/temoa/extensions/discrete_capacity/tables.sql b/temoa/extensions/discrete_capacity/tables.sql new file mode 100644 index 000000000..210308d98 --- /dev/null +++ b/temoa/extensions/discrete_capacity/tables.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS limit_discrete_new_capacity( + region TEXT NOT NULL, + tech_or_group TEXT NOT NULL, + capacity REAL NOT NULL DEFAULT 0, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group) +); +CREATE TABLE IF NOT EXISTS limit_discrete_capacity( + region TEXT NOT NULL, + tech_or_group TEXT NOT NULL, + capacity REAL NOT NULL DEFAULT 0, + units TEXT, + notes TEXT, + PRIMARY KEY (region, tech_or_group) +); diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py index 9a6551113..4a1abd3a3 100644 --- a/temoa/extensions/framework.py +++ b/temoa/extensions/framework.py @@ -53,12 +53,9 @@ def normalize_extension_ids(extension_ids: Sequence[str] | None) -> tuple[str, . def get_known_extension_specs() -> dict[str, ExtensionSpec]: """Return all extension specs known to this installation.""" from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION + from temoa.extensions.discrete_capacity.extension import DISCRETE_CAPACITY_EXTENSION - # TEMPLATE: To activate a new extension copied from ``temoa/extensions/template``, - # import its spec here and add it to ``specs`` below, e.g.: - # from temoa.extensions..extension import - # specs = [GROWTH_RATES_EXTENSION, ] - specs = [GROWTH_RATES_EXTENSION] + specs = [GROWTH_RATES_EXTENSION, DISCRETE_CAPACITY_EXTENSION] return {spec.extension_id: spec for spec in specs} diff --git a/temoa/extensions/growth_rates/components/growth_capacity.py b/temoa/extensions/growth_rates/components/growth_capacity.py index a091e2604..434ba90e1 100644 --- a/temoa/extensions/growth_rates/components/growth_capacity.py +++ b/temoa/extensions/growth_rates/components/growth_capacity.py @@ -92,7 +92,7 @@ def limit_growth_capacity( cap_rpt = model.v_capacity_available_by_period_and_tech cap_indices = {(_r, _p, _t) for _r, _p, _t in cap_rpt.keys() if _t in techs and _r in regions} - periods = sorted({_p for _r, _p, _t in cap_rpt}) + periods = sorted({_p for _r, _p, _t in cap_indices}) if len(periods) == 0: return Constraint.Skip diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 000000000..e283dd929 --- /dev/null +++ b/tests/test_extensions.py @@ -0,0 +1,95 @@ +"""Tests for the optional model extensions and their wiring into TemoaModel.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pyomo.environ import Constraint + +from temoa.core.model import TemoaModel +from temoa.extensions.framework import ( + get_known_extension_specs, + resolve_extension_specs, +) + +# Parametrize over every registered extension so new extensions are covered +# automatically without listing their components here. +EXTENSION_IDS = sorted(get_known_extension_specs()) + + +def _component_map(model: TemoaModel) -> dict[str, object]: + """Map of component name -> component object declared on a model.""" + return {component.name: component for component in model.component_objects()} + + +# Cache abstract models so each extension is only built once per test session. +_abstract_models: dict[str | None, TemoaModel] = {} + + +def _model_with(extension_id: str | None) -> TemoaModel: + if extension_id not in _abstract_models: + extensions = [extension_id] if extension_id else None + _abstract_models[extension_id] = TemoaModel(extensions=extensions) + return _abstract_models[extension_id] + + +def _added_components(extension_id: str) -> dict[str, object]: + """Components present with the extension enabled but absent without it.""" + base = _component_map(_model_with(None)) + enabled = _component_map(_model_with(extension_id)) + return {name: comp for name, comp in enabled.items() if name not in base} + + +@pytest.mark.parametrize('extension_id', EXTENSION_IDS) +def test_extension_registered(extension_id: str) -> None: + """The extension is discoverable by its id and resolves to its spec.""" + spec = get_known_extension_specs()[extension_id] + assert spec.extension_id == extension_id + assert resolve_extension_specs([extension_id]) == (spec,) + + +@pytest.mark.parametrize('extension_id', EXTENSION_IDS) +def test_extension_spec_fields(extension_id: str) -> None: + """The spec declares consistent region-group columns and a registration hook.""" + spec = get_known_extension_specs()[extension_id] + # Region-group tables must refer to declared owned tables via string columns. + assert set(spec.regional_group_tables) <= set(spec.owned_tables) + assert all(isinstance(column, str) and column for column in spec.regional_group_tables.values()) + # Every extension attaches model components; data loading is optional. + assert spec.register_model_components is not None + + +@pytest.mark.parametrize('extension_id', EXTENSION_IDS) +def test_extension_schema_matches_owned_tables(extension_id: str) -> None: + """If the extension ships a schema, it defines every owned table. + + Extensions that only add components feeding from existing tables own no + tables and need not provide a schema file. + """ + spec = get_known_extension_specs()[extension_id] + if spec.schema_sql_path is None: + # No schema means the extension introduces no tables of its own. + assert not spec.owned_tables + return + schema_path = Path(spec.schema_sql_path) + assert schema_path.is_file() + schema_sql = schema_path.read_text(encoding='utf-8') + for table in spec.owned_tables: + assert table in schema_sql + + +@pytest.mark.parametrize('extension_id', EXTENSION_IDS) +def test_extension_adds_components(extension_id: str) -> None: + """Enabling the extension attaches new model components, including constraints.""" + added = _added_components(extension_id) + assert added, f'{extension_id} should attach components to the model' + assert any(isinstance(component, Constraint) for component in added.values()), ( + f'{extension_id} should contribute at least one constraint' + ) + + +def test_unknown_extension_id_rejected() -> None: + """Resolving an unregistered extension id raises a descriptive error.""" + with pytest.raises(ValueError, match='Unknown extension id'): + resolve_extension_specs(['not_a_real_extension']) From 0d1f4cfaf7499977e5772f0e735d4a53b6a8b82c Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 09:56:59 -0400 Subject: [PATCH 06/23] Stop ignoring extensions in ruff Signed-off-by: Davey Elder --- pyproject.toml | 4 +- temoa/extensions/framework.py | 63 +++++++++------- .../components/growth_new_capacity_delta.py | 4 +- temoa/extensions/growth_rates/extension.py | 1 - temoa/extensions/method_of_morris/morris.py | 20 ++++-- .../method_of_morris/morris_sequencer.py | 9 +-- .../modeling_to_generate_alternatives/hull.py | 5 +- .../mga_sequencer.py | 19 +++-- .../example_builds/scenario_analyzer.py | 8 +-- .../example_builds/scenario_maker.py | 4 +- temoa/extensions/monte_carlo/mc_run.py | 15 ++-- temoa/extensions/monte_carlo/mc_sequencer.py | 17 +++-- temoa/extensions/myopic/evolution_updater.py | 16 +++-- .../myopic/myopic_progress_mapper.py | 8 +-- temoa/extensions/myopic/myopic_sequencer.py | 72 +++++++++---------- .../single_vector_mga/sv_mga_sequencer.py | 2 +- .../stochastics/scenario_creator.py | 8 ++- .../stochastics/stochastic_sequencer.py | 4 +- 18 files changed, 151 insertions(+), 128 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0e4b35570..602f9066b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,9 +86,7 @@ path = "temoa/__about__.py" [tool.ruff] line-length = 100 indent-width = 4 -exclude = ["stubs", - "temoa/extensions/" -] +exclude = ["stubs"] [tool.ruff.format] quote-style = "single" diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py index 4a1abd3a3..179ddf097 100644 --- a/temoa/extensions/framework.py +++ b/temoa/extensions/framework.py @@ -1,14 +1,14 @@ from __future__ import annotations -from pathlib import Path +from collections.abc import Callable from dataclasses import dataclass, field -from sqlite3 import Connection +from logging import getLogger +from pathlib import Path from typing import TYPE_CHECKING -from collections.abc import Callable - if TYPE_CHECKING: from collections.abc import Mapping, Sequence + from sqlite3 import Connection from temoa.core.model import TemoaModel from temoa.data_io.loader_manifest import LoadItem @@ -16,6 +16,8 @@ ModelHook = Callable[['TemoaModel'], None] ManifestHook = Callable[['TemoaModel'], list['LoadItem']] +logger = getLogger(__name__) + @dataclass(frozen=True) class ExtensionSpec: @@ -30,7 +32,7 @@ class ExtensionSpec: fail_if_tables_populated_when_disabled: bool = False -def normalize_extension_ids(extension_ids: Sequence[str] | None) -> tuple[str, ...]: +def normalize_extension_ids(extension_ids: Sequence[str | object] | None) -> tuple[str, ...]: """Normalize configured extension ids while preserving user-provided order.""" if not extension_ids: return () @@ -39,7 +41,9 @@ def normalize_extension_ids(extension_ids: Sequence[str] | None) -> tuple[str, . seen: set[str] = set() for ext_id in extension_ids: if not isinstance(ext_id, str): - raise ValueError(f'Extension ids must be strings. Received: {type(ext_id).__name__}') + msg = f'Extension ids must be strings. Received: {type(ext_id).__name__}' + logger.error(msg) + raise ValueError(msg) cleaned = ext_id.strip().lower() if not cleaned: continue @@ -52,8 +56,8 @@ def normalize_extension_ids(extension_ids: Sequence[str] | None) -> tuple[str, . def get_known_extension_specs() -> dict[str, ExtensionSpec]: """Return all extension specs known to this installation.""" - from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION from temoa.extensions.discrete_capacity.extension import DISCRETE_CAPACITY_EXTENSION + from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION specs = [GROWTH_RATES_EXTENSION, DISCRETE_CAPACITY_EXTENSION] return {spec.extension_id: spec for spec in specs} @@ -67,9 +71,9 @@ def resolve_extension_specs(extension_ids: Sequence[str] | None) -> tuple[Extens if unknown: known_ids = ', '.join(sorted(known)) unknown_ids = ', '.join(sorted(unknown)) - raise ValueError( - f'Unknown extension id(s): {unknown_ids}. Known extension ids: {known_ids}.' - ) + msg = f'Unknown extension id(s): {unknown_ids}. Known extension ids: {known_ids}.' + logger.error(msg) + raise ValueError(msg) return tuple(known[ext_id] for ext_id in normalized_ids) @@ -101,10 +105,12 @@ def merge_regional_group_tables( for table_name, field_name in spec.regional_group_tables.items(): existing = merged.get(table_name) if existing is not None and existing != field_name: - raise ValueError( + msg = ( f"Regional-group table '{table_name}' has conflicting field mappings: " f"'{existing}' vs '{field_name}' from extension '{spec.extension_id}'." ) + logger.error(msg) + raise ValueError(msg) merged[table_name] = field_name return merged @@ -127,10 +133,11 @@ def assert_disabled_extension_tables_are_empty( if populated: table_list = ', '.join(sorted(populated)) - raise RuntimeError( + msg = ( f"Extension '{spec.extension_id}' is not enabled, but extension-owned table(s) " f'contain data: {table_list}. Enable the extension or remove those rows.' ) + logger.warning(msg) def ensure_enabled_extension_tables_exist( @@ -148,36 +155,42 @@ def ensure_enabled_extension_tables_exist( missing_list = ', '.join(sorted(missing_tables)) if not spec.schema_sql_path: - raise RuntimeError( + msg = ( f"Extension '{spec.extension_id}' is enabled, but required table(s) are missing: " f'{missing_list}. No schema SQL path is registered for this extension.' ) + logger.error(msg) + raise RuntimeError(msg) should_apply = False if not silent: prompt = ( f"Extension '{spec.extension_id}' is enabled but missing table(s): {missing_list}. " - f"Append schema from '{spec.schema_sql_path}' to input database '{input_database}' now? " - '[y/N]: ' + f"Append schema from '{spec.schema_sql_path}' to input database '{input_database} " + 'now? [y/N]: ' ) response = input(prompt).strip().lower() should_apply = response in {'y', 'yes'} if not should_apply: - raise RuntimeError( + msg = ( f"Extension '{spec.extension_id}' is enabled, but required table(s) are missing: " - f"{missing_list}. Re-run and accept the prompt, or append schema manually from " + f'{missing_list}. Re-run and accept the prompt, or append schema manually from ' f"'{spec.schema_sql_path}' to '{input_database}'." ) + logger.error(msg) + raise RuntimeError(msg) _append_extension_schema(con, spec) still_missing = [table for table in spec.owned_tables if not _table_exists(con, table)] if still_missing: still_missing_list = ', '.join(sorted(still_missing)) - raise RuntimeError( - f"Schema append for extension '{spec.extension_id}' completed but table(s) are still " - f'missing: {still_missing_list}.' + msg = ( + f"Schema append for extension '{spec.extension_id}' completed " + f'but table(s) are still missing: {still_missing_list}.' ) + logger.error(msg) + raise RuntimeError(msg) def _table_has_rows(con: Connection, table_name: str) -> bool: @@ -202,13 +215,15 @@ def _table_exists(con: Connection, table_name: str) -> bool: def _append_extension_schema(con: Connection, spec: ExtensionSpec) -> None: if spec.schema_sql_path is None: - raise RuntimeError(f"Extension '{spec.extension_id}' has no schema SQL path configured.") + msg = f"Extension '{spec.extension_id}' has no schema SQL path configured." + logger.error(msg) + raise RuntimeError(msg) schema_path = Path(spec.schema_sql_path) if not schema_path.is_file(): - raise FileNotFoundError( - f"Schema SQL file for extension '{spec.extension_id}' not found: {schema_path}" - ) + msg = f"Schema SQL file for extension '{spec.extension_id}' not found: {schema_path}" + logger.error(msg) + raise FileNotFoundError(msg) sql = schema_path.read_text(encoding='utf-8') con.executescript(sql) diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py index d813d54c6..a158ce6b7 100644 --- a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py +++ b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py @@ -139,7 +139,9 @@ def limit_growth_new_capacity_delta( ) else: p_prev2 = model.time_optimize.prev(p_prev) - new_cap_prev2 = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2) + new_cap_prev2 = quicksum( + new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2 + ) nc_delta_prev = new_cap_prev - new_cap_prev2 nc_delta = new_cap - new_cap_prev diff --git a/temoa/extensions/growth_rates/extension.py b/temoa/extensions/growth_rates/extension.py index 5c148b612..19aec7008 100644 --- a/temoa/extensions/growth_rates/extension.py +++ b/temoa/extensions/growth_rates/extension.py @@ -6,7 +6,6 @@ from temoa.extensions.growth_rates.core.model import register_model_components from temoa.extensions.growth_rates.data_manifest import build_manifest_items - GROWTH_RATES_EXTENSION = ExtensionSpec( extension_id='growth_rates', owned_tables=( diff --git a/temoa/extensions/method_of_morris/morris.py b/temoa/extensions/method_of_morris/morris.py index 4bb14ddcb..dd981d79e 100644 --- a/temoa/extensions/method_of_morris/morris.py +++ b/temoa/extensions/method_of_morris/morris.py @@ -1,8 +1,8 @@ from __future__ import annotations +import contextlib import csv import sqlite3 -from importlib import resources from pathlib import Path from typing import Any @@ -21,8 +21,9 @@ seed = 42 -def evaluate(param_names: dict[int, list[Any]], param_values: Any, - data: dict[str, Any], k: int) -> list[Any]: +def evaluate( + param_names: dict[int, list[Any]], param_values: Any, data: dict[str, Any], k: int +) -> list[Any]: m = len(param_values) for j in range(0, m): names = param_names[j] @@ -47,6 +48,7 @@ def evaluate(param_names: dict[int, list[Any]], param_values: Any, table_writer.write_results(model=mdl) import contextlib + with contextlib.closing(sqlite3.connect(db_file)) as con: cur = con.cursor() cur.execute('SELECT * FROM output_objective') @@ -73,12 +75,11 @@ def evaluate(param_names: dict[int, list[Any]], param_values: Any, if not db_file.exists() or not config_path.exists(): raise FileNotFoundError( - "Example Morris data files not found in current directory. " - "Please see MM_README.md for instructions on how to generate " + 'Example Morris data files not found in current directory. ' + 'Please see MM_README.md for instructions on how to generate ' "or provide the required 'morris_utopia.sqlite' and 'morris_utopia.toml'." ) -import contextlib with contextlib.closing(sqlite3.connect(str(db_file))) as con: with open(param_file, 'w') as file: param_names = {} @@ -152,7 +153,12 @@ def evaluate(param_names: dict[int, list[Any]], param_values: Any, problem = read_param_file(str(param_file), delimiter=' ') param_values = sample( - problem, N=10, num_levels=8, optimal_trajectories=False, local_optimization=False, seed=seed + problem, + N=10, + num_levels=8, + optimal_trajectories=False, + local_optimization=False, + seed=seed, ) print(param_values) print(param_names) diff --git a/temoa/extensions/method_of_morris/morris_sequencer.py b/temoa/extensions/method_of_morris/morris_sequencer.py index 6ae6bdf35..6c3d1bc76 100644 --- a/temoa/extensions/method_of_morris/morris_sequencer.py +++ b/temoa/extensions/method_of_morris/morris_sequencer.py @@ -2,6 +2,7 @@ An event sequencer to control the flow of a Method of Morris calculation. This code uses multiprocessing via the joblib library """ + from __future__ import annotations import csv @@ -30,7 +31,6 @@ from temoa.core.config import TemoaConfig - logger = logging.getLogger(__name__) solver_options_file = ( @@ -87,7 +87,7 @@ def __init__(self, config: TemoaConfig): # the amount to perturb the marked params pert = morris_inputs.get('perturbation') if pert: - self.mm_perturbation = float(cast(str | float, pert)) + self.mm_perturbation = float(cast('str | float', pert)) logger.info('Morris perturbation: %0.2f', self.mm_perturbation) else: self.mm_perturbation = 0.10 @@ -201,8 +201,9 @@ def start(self) -> Any: # 7. Return the cost objective Mu_Star for testing purposes... return cost_mu_star - def process_results(self, problem: dict[str, Any], mm_samples: Any, - morris_results: list[Any]) -> Any: + def process_results( + self, problem: dict[str, Any], mm_samples: Any, morris_results: list[Any] + ) -> Any: """ Process the results of the runs on the mm_samples :param problem: the problem structure diff --git a/temoa/extensions/modeling_to_generate_alternatives/hull.py b/temoa/extensions/modeling_to_generate_alternatives/hull.py index d0f79c132..f03909ed0 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/hull.py +++ b/temoa/extensions/modeling_to_generate_alternatives/hull.py @@ -1,6 +1,7 @@ """ A thin wrapper on Scipy's ConvexHull to make it more manageable """ + from __future__ import annotations from logging import getLogger @@ -119,8 +120,8 @@ def update(self) -> None: def add_point(self, point: np.ndarray) -> None: if len(point) != self.dim: msg = ( - f'Tried adding a point to hull (dim: {self.dim}) with wrong dimensions {len(point)}. ' - f'Point: {point}' + f'Tried adding a point to hull (dim: {self.dim}) with ' + f'wrong dimensions {len(point)}. Point: {point}' ) logger.error(msg) raise ValueError(msg) diff --git a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py index 080d06974..a261ba458 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py +++ b/temoa/extensions/modeling_to_generate_alternatives/mga_sequencer.py @@ -1,6 +1,7 @@ """ Performs top-level control over an MGA model run """ + from __future__ import annotations import logging @@ -26,10 +27,6 @@ from temoa.extensions.modeling_to_generate_alternatives.vector_manager import VectorManager - - - - import pyomo.environ as pyo from pyomo.opt import check_optimal_termination @@ -124,13 +121,13 @@ def __init__(self, config: TemoaConfig): except KeyError: logger.warning('No/bad MGA Weighting specified. Using default: Hull Expansion') self.mga_weighting = MgaWeighting.HULL_EXPANSION - self.num_workers = int(cast(str | int, all_options.get('num_workers', 1))) + self.num_workers = int(cast('str | int', all_options.get('num_workers', 1))) logger.info('MGA workers are set to %s', self.num_workers) - self.iteration_limit = int(cast(str | int, mga_inputs.get('iteration_limit', 20))) + self.iteration_limit = int(cast('str | int', mga_inputs.get('iteration_limit', 20))) logger.info('Set MGA iteration limit to: %d', self.iteration_limit) - self.time_limit_hrs = float(cast(str | float, mga_inputs.get('time_limit_hrs', 12))) + self.time_limit_hrs = float(cast('str | float', mga_inputs.get('time_limit_hrs', 12))) logger.info('Set MGA time limit hours to: %0.1f', self.time_limit_hrs) - self.cost_epsilon = float(cast(str | float, mga_inputs.get('cost_epsilon', 0.05))) + self.cost_epsilon = float(cast('str | float', mga_inputs.get('cost_epsilon', 0.05))) logger.info('Set MGA cost (relaxation) epsilon to: %0.3f', self.cost_epsilon) # internal records @@ -358,8 +355,10 @@ def solve_instance(self, instance: TemoaModel) -> bool: elapsed.total_seconds(), status.name, ) - return status == pyo.TerminationCondition.optimal or \ - str(status) == 'convergenceCriteriaSatisfied' + return ( + status == pyo.TerminationCondition.optimal + or str(status) == 'convergenceCriteriaSatisfied' + ) def process_solve_results(self, instance: TemoaModel) -> None: """write the results as required""" diff --git a/temoa/extensions/monte_carlo/example_builds/scenario_analyzer.py b/temoa/extensions/monte_carlo/example_builds/scenario_analyzer.py index 08b688946..a025b1107 100644 --- a/temoa/extensions/monte_carlo/example_builds/scenario_analyzer.py +++ b/temoa/extensions/monte_carlo/example_builds/scenario_analyzer.py @@ -1,7 +1,7 @@ """ Simple analyzer--example only """ -from importlib import resources + from math import sqrt from pathlib import Path from sqlite3 import Connection @@ -25,15 +25,15 @@ cur = conn.cursor() # Check if results exist before attempting to plot obj_values = cur.execute( - "SELECT total_system_cost FROM output_objective WHERE scenario LIKE ?", - (f"{scenario_name}-%",) + 'SELECT total_system_cost FROM output_objective WHERE scenario LIKE ?', + (f'{scenario_name}-%',), ).fetchall() if len(obj_values) == 0: raise RuntimeError( f"No results found for scenario '{scenario_name}-*' in '{db_resource}'. " "Please run 'temoa run tutorial_config.toml' or run the tutorial model " - "to populate output_objective with results first." + 'to populate output_objective with results first.' ) obj_values_tuple = tuple(t[0] for t in obj_values) diff --git a/temoa/extensions/monte_carlo/example_builds/scenario_maker.py b/temoa/extensions/monte_carlo/example_builds/scenario_maker.py index 0e72dd483..8561f556a 100644 --- a/temoa/extensions/monte_carlo/example_builds/scenario_maker.py +++ b/temoa/extensions/monte_carlo/example_builds/scenario_maker.py @@ -21,10 +21,10 @@ """ from pathlib import Path +from typing import cast import matplotlib.pyplot as plt import numpy as np -from typing import cast # distro for the related cost vars @@ -34,7 +34,7 @@ num_runs = 1000 cov = np.array([[0.4, -0.1], [-0.1, 0.1]]) price_devs = np.random.multivariate_normal([0, 0], cov, size=num_runs) -corr_matrix = cast(np.typing.NDArray[np.float64], np.corrcoef(price_devs.T)) +corr_matrix = cast('np.typing.NDArray[np.float64]', np.corrcoef(price_devs.T)) print(f'correlation check: {corr_matrix[0, 1]}') # verify with a peek... diff --git a/temoa/extensions/monte_carlo/mc_run.py b/temoa/extensions/monte_carlo/mc_run.py index 7c2874a7a..cca3d8b53 100644 --- a/temoa/extensions/monte_carlo/mc_run.py +++ b/temoa/extensions/monte_carlo/mc_run.py @@ -1,6 +1,5 @@ -""" +""" """ -""" from __future__ import annotations from collections import defaultdict, namedtuple @@ -27,8 +26,6 @@ """a record of a data element change, for an element acted on by a Tweak""" - - class Tweak: """ objects of this class represent individual tweaks to single (or wildcard) @@ -235,9 +232,11 @@ def __init__(self, config: TemoaConfig, data_store: dict[str, Any]): "Monte Carlo mode requires 'run_settings' path in 'monte_carlo_inputs'." ) - self.settings_file = Path(cast(str, settings_path)) + self.settings_file = Path(cast('str', settings_path)) if not self.settings_file.exists(): - raise FileNotFoundError(f'Monte Carlo run settings file not found: {self.settings_file}') + raise FileNotFoundError( + f'Monte Carlo run settings file not found: {self.settings_file}' + ) def prescreen_input_file(self) -> bool: """ @@ -330,9 +329,7 @@ def element_locator( ) raw_indices = param_data.keys() matches = [ - k - for k in raw_indices - if all(k[idx] == target_index[idx] for idx in non_wildcard_locs) + k for k in raw_indices if all(k[idx] == target_index[idx] for idx in non_wildcard_locs) ] return matches diff --git a/temoa/extensions/monte_carlo/mc_sequencer.py b/temoa/extensions/monte_carlo/mc_sequencer.py index 0df6fd25e..eb99da89a 100644 --- a/temoa/extensions/monte_carlo/mc_sequencer.py +++ b/temoa/extensions/monte_carlo/mc_sequencer.py @@ -3,6 +3,7 @@ A sequencer for Monte Carlo Runs """ + from __future__ import annotations import logging @@ -31,12 +32,9 @@ from temoa.core.config import TemoaConfig - logger = getLogger(__name__) -solver_options_path = ( - resources.files('temoa.extensions.monte_carlo') / 'MC_solver_options.toml' -) +solver_options_path = resources.files('temoa.extensions.monte_carlo') / 'MC_solver_options.toml' class MCSequencer: @@ -117,7 +115,6 @@ def start(self) -> None: # 4. copy & modify the base data to make per-dataset runs # 5. farm out the runs to workers - # 0. Set up database for scenario self.writer.clear_scenario() self.writer.make_mc_tweaks_table() # add the output table for tweaks, if not exists @@ -125,6 +122,7 @@ def start(self) -> None: # 1. Load data import contextlib + with contextlib.closing(sqlite3.connect(self.config.input_database)) as con: hybrid_loader = HybridLoader(db_connection=con, config=self.config) data_store = hybrid_loader.create_data_dict(myopic_index=None) @@ -138,6 +136,7 @@ def start(self) -> None: # 4. Set up the workers import multiprocessing + ctx = multiprocessing.get_context('spawn') num_workers = self.num_workers @@ -151,10 +150,10 @@ def start(self) -> None: log_queue: Queue[logging.LogRecord] = ctx.Queue() # make workers workers: list[BaseProcess] = [] - kwargs = { - 'solver_name': self.config.solver_name, - 'solver_options': self.worker_solver_options, - } + # kwargs = { + # 'solver_name': self.config.solver_name, + # 'solver_options': self.worker_solver_options, + # } # construct path for the solver logs s_path = self.config.output_path / 'solver_logs' if not s_path.exists(): diff --git a/temoa/extensions/myopic/evolution_updater.py b/temoa/extensions/myopic/evolution_updater.py index 20808e79a..efeb9946d 100644 --- a/temoa/extensions/myopic/evolution_updater.py +++ b/temoa/extensions/myopic/evolution_updater.py @@ -1,15 +1,17 @@ -import sqlite3 import logging +import sqlite3 + from temoa.extensions.myopic.myopic_index import MyopicIndex logger = logging.getLogger(__name__) + def iterate( - idx: MyopicIndex | None = None, - prev_base_year: int | None = None, - last_instance_status: str | None = None, - db_con: sqlite3.Connection | None = None, - ) -> None: + idx: MyopicIndex | None = None, + prev_base_year: int | None = None, + last_instance_status: str | None = None, + db_con: sqlite3.Connection | None = None, +) -> None: """ This function is called at the end of each myopic iteration, after the results have been recorded to the myopic database. @@ -29,7 +31,7 @@ def iterate( """ assert idx is not None - logger.info(f"Running myopic iteration updater for base year {idx.base_year}") + logger.info('Running myopic iteration updater for base year %s', idx.base_year) # Update your myopic database here. diff --git a/temoa/extensions/myopic/myopic_progress_mapper.py b/temoa/extensions/myopic/myopic_progress_mapper.py index 3dc3c2955..e474702a5 100644 --- a/temoa/extensions/myopic/myopic_progress_mapper.py +++ b/temoa/extensions/myopic/myopic_progress_mapper.py @@ -3,7 +3,6 @@ """ from datetime import datetime, timedelta - from typing import Literal from temoa.extensions.myopic.myopic_index import MyopicIndex @@ -38,7 +37,7 @@ def draw_header(self) -> None: print(time_buffer, end='') print('*' * tot_length) print() - print(f"{'HH:MM:SS':10s}", end='') + print(f'{"HH:MM:SS":10s}', end='') print(' ', end='') for year in self.years: print(f'{self.leader}{year}{self.trailer} ', end='') @@ -47,8 +46,9 @@ def draw_header(self) -> None: def timestamp(self) -> str: delta = datetime.now() - self.hack return ( - f'Elapsed: {int(delta.total_seconds()//3600):02d}:' - f'{int(delta.total_seconds()%3600//60):02d}:{int(delta.total_seconds())%60:02d} ' + f'Elapsed: {int(delta.total_seconds() // 3600):02d}:' + f'{int(delta.total_seconds() % 3600 // 60):02d}:' + f'{int(delta.total_seconds()) % 60:02d} ' ) def report( diff --git a/temoa/extensions/myopic/myopic_sequencer.py b/temoa/extensions/myopic/myopic_sequencer.py index e671c3759..e32ed347f 100644 --- a/temoa/extensions/myopic/myopic_sequencer.py +++ b/temoa/extensions/myopic/myopic_sequencer.py @@ -9,7 +9,7 @@ from collections import deque from importlib import resources, util from pathlib import Path -from sqlite3 import Connection, Cursor +from sqlite3 import Connection from typing import Any, cast from temoa._internal import run_actions @@ -87,7 +87,7 @@ def __init__(self, config: TemoaConfig | None) -> None: default_cap_threshold = 1e-3 if config and config.myopic_inputs: self.capacity_epsilon = float( - cast(Any, config.myopic_inputs.get('capacity_threshold', default_cap_threshold)) + cast('Any', config.myopic_inputs.get('capacity_threshold', default_cap_threshold)) ) else: self.capacity_epsilon = default_cap_threshold @@ -110,10 +110,10 @@ def __init__(self, config: TemoaConfig | None) -> None: ) raise RuntimeError('No myopic options received. See log file.') else: - self.view_depth = int(cast(Any, myopic_options.get('view_depth'))) + self.view_depth = int(cast('Any', myopic_options.get('view_depth'))) if not isinstance(self.view_depth, int) or self.view_depth < 1: raise ValueError(f'view_depth is not a positive integer {self.view_depth}') - self.step_size = int(cast(Any, myopic_options.get('step_size'))) + self.step_size = int(cast('Any', myopic_options.get('step_size'))) if not isinstance(self.step_size, int) or self.step_size < 1: raise ValueError(f'step_size is not a positive integer {self.step_size}') if self.step_size > self.view_depth: @@ -224,8 +224,9 @@ def start(self) -> None: raise RuntimeError('Illegal state in myopic iteration.') logger.info('Processing Myopic Index: %s', idx) - # 4. If evolving, call the evolution script and pass it the myopic index and last instance status - if self.evolving and last_instance_status is not None: # don't evolve before first iteration (pointless) + # 4. If evolving, call the evolution script and pass it the myopic index + # and last instance status, unless is first iteration (pointless) + if self.evolving and last_instance_status is not None: self.run_evolution_script( idx=idx, last_base_year=last_base_year, @@ -298,7 +299,7 @@ def start(self) -> None: self.table_writer.write_dual_variables(results=results, iteration=idx.base_year) # prep next loop - last_base_year = idx.base_year if idx else last_base_year # update + last_base_year = idx.base_year if idx else last_base_year # update # delete anything in the output_objective table, it is nonsensical... assert self.output_con is not None @@ -307,13 +308,15 @@ def start(self) -> None: ) self.output_con.commit() - # Total system cost is, theoretically, sum of discounted costs from output_cost table - total_cost = self.get_current_total_cost(last_base_year if last_base_year is not None else 0) + total_cost = self.get_current_total_cost( + last_base_year if last_base_year is not None else 0 + ) assert self.output_con is not None self.output_con.execute( - f"INSERT INTO output_objective(scenario, objective_name, total_system_cost) VALUES('{self.config.scenario}', 'total_cost', {total_cost})" + 'INSERT INTO output_objective(scenario, objective_name, total_system_cost) ' + f"VALUES('{self.config.scenario}', 'total_cost', {total_cost})" ) self.output_con.commit() @@ -385,12 +388,12 @@ def initialize_myopic_efficiency_table(self) -> None: print(list(res)) def run_evolution_script( - self, - idx: MyopicIndex | None, - last_base_year: int | None, - last_instance_status: str | None, - con: sqlite3.Connection | None - ) -> None: + self, + idx: MyopicIndex | None, + last_base_year: int | None, + last_instance_status: str | None, + con: sqlite3.Connection | None, + ) -> None: """ Run the evolution script to update the myopic database before the next iteration. """ @@ -402,21 +405,21 @@ def run_evolution_script( # import the script as a module and call the iterate function script_path = Path(self.evolution_script).expanduser() if not script_path.is_file(): - msg = f"Myopic evolution script not found: {script_path}" + msg = f'Myopic evolution script not found: {script_path}' logger.error(msg) raise FileNotFoundError(msg) - spec = util.spec_from_file_location("evolution_script", script_path) + spec = util.spec_from_file_location('evolution_script', script_path) if spec is None or spec.loader is None: - msg = f"Could not load evolution script module spec from: {script_path}" + msg = f'Could not load evolution script module spec from: {script_path}' logger.error(msg) raise RuntimeError(msg) evolution_module = util.module_from_spec(spec) spec.loader.exec_module(evolution_module) - iterate = getattr(evolution_module, "iterate", None) + iterate = getattr(evolution_module, 'iterate', None) if not callable(iterate): - msg = f"Evolution script must define callable iterate(...): {script_path}" + msg = f'Evolution script must define callable iterate(...): {script_path}' logger.error(msg) raise AttributeError(msg) @@ -571,21 +574,22 @@ def characterize_run(self, future_periods: list[int] | None = None) -> None: raise RuntimeError('view_depth not initialized') if len(future_periods) < self.view_depth + 1: msg = ( - 'Not enough future periods for view depth. Need {} including end period. Got {}.' - ).format(self.view_depth+1, len(future_periods)) + 'Not enough future periods for view depth. ' + f'Need {self.view_depth + 1} including end period. Got {len(future_periods)}.' + ) logger.error(msg) raise RuntimeError(msg) self.optimization_periods = future_periods.copy() if self.step_size is None: raise RuntimeError('step_size not initialized') last_base_year = ((len(future_periods) - 2) // self.step_size) * self.step_size - base_years = list(range(0, last_base_year+1, self.step_size)) + base_years = list(range(0, last_base_year + 1, self.step_size)) if not self.evolving: # Remove redundant iterations near end of horizon if not evolving - catch_Pe = [i for i in base_years if i + self.view_depth >= len(future_periods) - 1] - if len(catch_Pe) > 1: + catch_pe = [i for i in base_years if i + self.view_depth >= len(future_periods) - 1] + if len(catch_pe) > 1: # keep only one iteration that captures the end of the horizon - base_years = base_years[:-len(catch_Pe) + 1] + base_years = base_years[: -len(catch_pe) + 1] for n, idx in enumerate(base_years): depth = min(self.view_depth, len(future_periods) - idx - 1) if idx == base_years[-1]: @@ -593,13 +597,13 @@ def characterize_run(self, future_periods: list[int] | None = None) -> None: step = depth else: # record to next base year - step = base_years[n+1] - idx + step = base_years[n + 1] - idx if depth < 1: msg = ( 'Calculated MyopicIndex with non-positive depth. ' 'This should never happen. Code error likely. ' - 'idx: {}, step: {}, depth: {}, future_periods: {}' - ).format(idx, step, depth, future_periods) + f'idx: {idx}, step: {step}, depth: {depth}, future_periods: {future_periods}' + ) logger.error(msg) raise RuntimeError(msg) myopic_idx = MyopicIndex( @@ -708,9 +712,7 @@ def clear_results_after(self, period: int) -> None: def report_total_demand(self, mi: MyopicIndex) -> None: assert self.output_con is not None assert self.cursor is not None - self.cursor.execute( - "SELECT SUM(demand) FROM output_demand WHERE scenario='original'" - ) + self.cursor.execute("SELECT SUM(demand) FROM output_demand WHERE scenario='original'") self.output_con.commit() def write_myopic_efficiency(self, mi: MyopicIndex, status: str) -> None: @@ -737,9 +739,7 @@ def write_myopic_efficiency(self, mi: MyopicIndex, status: str) -> None: def report_cumulative_capacity(self, mi: MyopicIndex) -> None: assert self.output_con is not None assert self.cursor is not None - self.cursor.execute( - "SELECT SUM(capacity) FROM output_capacity WHERE scenario='original'" - ) + self.cursor.execute("SELECT SUM(capacity) FROM output_capacity WHERE scenario='original'") self.output_con.commit() def __del__(self) -> None: diff --git a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py index 937aeb65c..543af6678 100644 --- a/temoa/extensions/single_vector_mga/sv_mga_sequencer.py +++ b/temoa/extensions/single_vector_mga/sv_mga_sequencer.py @@ -9,7 +9,7 @@ import sqlite3 import sys from collections.abc import Iterable -from typing import Any, cast, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from pyomo.core import Constraint, Expression, Objective, value from pyomo.opt import check_optimal_termination diff --git a/temoa/extensions/stochastics/scenario_creator.py b/temoa/extensions/stochastics/scenario_creator.py index a1ffcccc3..ac54bcb77 100644 --- a/temoa/extensions/stochastics/scenario_creator.py +++ b/temoa/extensions/stochastics/scenario_creator.py @@ -3,7 +3,6 @@ import logging import sqlite3 from typing import TYPE_CHECKING, Any, cast -from collections.abc import Iterable from mpisppy.utils.sputils import attach_root_node # type: ignore[import-untyped] @@ -12,6 +11,8 @@ from temoa.data_io.hybrid_loader import HybridLoader if TYPE_CHECKING: + from collections.abc import Iterable + from temoa.core.config import TemoaConfig from temoa.data_io.hybrid_loader import LoadItem from temoa.extensions.stochastics.stochastic_config import StochasticConfig @@ -36,6 +37,7 @@ def scenario_creator(scenario_name: str, **kwargs: Any) -> Any: # 1. Load base data try: import contextlib + with contextlib.closing(sqlite3.connect(temoa_config.input_database)) as con: hybrid_loader = HybridLoader(db_connection=con, config=temoa_config) data_dict = hybrid_loader.create_data_dict(myopic_index=None) @@ -46,7 +48,7 @@ def scenario_creator(scenario_name: str, **kwargs: Any) -> Any: for item in cast('Iterable[LoadItem]', hybrid_loader.manifest): if item.table not in table_index_map and item.columns: if item.index_length: - table_index_map[item.table] = list(item.columns[:item.index_length]) + table_index_map[item.table] = list(item.columns[: item.index_length]) else: table_index_map[item.table] = list(item.columns[:-1]) except Exception as e: @@ -58,7 +60,7 @@ def scenario_creator(scenario_name: str, **kwargs: Any) -> Any: if p.scenario != scenario_name: continue - target_param = cast(dict[Any, Any] | None, data_dict.get(p.table)) + target_param = cast('dict[Any, Any] | None', data_dict.get(p.table)) if target_param is None: logger.warning( 'Table %s not found in data_dict for scenario %s', p.table, scenario_name diff --git a/temoa/extensions/stochastics/stochastic_sequencer.py b/temoa/extensions/stochastics/stochastic_sequencer.py index 9950ee6cc..5c904ce30 100644 --- a/temoa/extensions/stochastics/stochastic_sequencer.py +++ b/temoa/extensions/stochastics/stochastic_sequencer.py @@ -34,7 +34,9 @@ def __init__(self, config: TemoaConfig) -> None: self.stoch_config = StochasticConfig.from_toml(sc_path) except Exception as e: logger.exception('Failed to load stochastic config from %s', sc_path) - raise ValueError(f'Error parsing stochastic config {sc_path}. Original error: {e}') from e + raise ValueError( + f'Error parsing stochastic config {sc_path}. Original error: {e}' + ) from e def start(self) -> None: """ From 45ff154099ede7207660d5356290bfac2f567681 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 09:57:58 -0400 Subject: [PATCH 07/23] Bypass duals in highs because they dont work and raise errors for MILP models Signed-off-by: Davey Elder --- temoa/_internal/run_actions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/temoa/_internal/run_actions.py b/temoa/_internal/run_actions.py index af2bc0ddd..6aecc7dcc 100644 --- a/temoa/_internal/run_actions.py +++ b/temoa/_internal/run_actions.py @@ -244,7 +244,10 @@ def solve_instance( with task_timer(f'Solving model {instance.name}', silent=silent): try: # currently, the highs solver call will puke if the suffixes are passed + # it also doesn't seem to support duals properly so disable those if solver_name == 'appsi_highs': + dual_suffix = instance.component('dual') + dual_suffix._direction = Suffix.LOCAL result = optimizer.solve(instance) else: result = optimizer.solve(instance, suffixes=solver_suffixes_list) From a0d0b93fe5a958b9dcb32baf2b5af81361b22b2c Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 09:59:25 -0400 Subject: [PATCH 08/23] Move index set loading to manifest Signed-off-by: Davey Elder --- temoa/data_io/component_manifest.py | 15 +++++++++++++ temoa/data_io/hybrid_loader.py | 35 +++++------------------------ temoa/data_io/loader_manifest.py | 4 ++++ 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/temoa/data_io/component_manifest.py b/temoa/data_io/component_manifest.py index c02513f90..7521b34c8 100644 --- a/temoa/data_io/component_manifest.py +++ b/temoa/data_io/component_manifest.py @@ -307,6 +307,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1, 2), is_period_filtered=False, is_table_required=False, + index_set=model.cost_invest_rtv, ), LoadItem( component=model.cost_fixed, @@ -327,6 +328,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None table='cost_emission', columns=['region', 'period', 'emis_comm', 'cost'], is_table_required=False, + index_set=model.cost_emission_rpe, ), LoadItem( component=model.loan_rate, @@ -391,6 +393,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None component=model.demand, table='demand', columns=['region', 'period', 'commodity', 'demand'], + index_set=model.demand_constraint_rpc, ), LoadItem( component=model.demand_specific_distribution, @@ -501,6 +504,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None columns=['region', 'period', 'tech_group', 'requirement'], custom_loader_name='_load_rps_requirement', is_table_required=False, + index_set=model.renewable_portfolio_standard_constraint_rpg, ), LoadItem( component=model.capacity_credit, @@ -550,6 +554,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 3), is_period_filtered=False, is_table_required=False, + index_set=model.limit_storage_fraction_param_rsdt, ), LoadItem( component=model.emission_activity, @@ -614,6 +619,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validator_name='viable_rpt', validation_map=(0, 1, 2), is_table_required=False, + index_set=model.limit_capacity_constraint_rpt, ), LoadItem( component=model.limit_new_capacity, @@ -623,6 +629,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1, 2), is_period_filtered=False, is_table_required=False, + index_set=model.limit_new_capacity_constraint_rtv, ), LoadItem( component=model.limit_capacity_share, @@ -631,6 +638,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validator_name='viable_rpt', validation_map=(0, 1, 2), is_table_required=False, + index_set=model.limit_capacity_share_constraint_rpgg, ), LoadItem( component=model.limit_new_capacity_share, @@ -640,6 +648,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1, 3), is_period_filtered=False, is_table_required=False, + index_set=model.limit_new_capacity_share_constraint_rggv, ), LoadItem( component=model.limit_activity, @@ -648,6 +657,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validator_name='viable_rpt', validation_map=(0, 1, 2), is_table_required=False, + index_set=model.limit_activity_constraint_rpt, ), LoadItem( component=model.limit_activity_share, @@ -656,6 +666,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validator_name='viable_rpt', validation_map=(0, 1, 2), is_table_required=False, + index_set=model.limit_activity_share_constraint_rpgg, ), LoadItem( component=model.limit_resource, @@ -665,6 +676,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1), is_period_filtered=False, is_table_required=False, + index_set=model.limit_resource_constraint_rt, ), LoadItem( component=model.limit_seasonal_capacity_factor, @@ -674,6 +686,7 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 2), is_period_filtered=False, is_table_required=False, + index_set=model.limit_seasonal_capacity_factor_constraint_rst, ), LoadItem( component=model.limit_annual_capacity_factor, @@ -683,12 +696,14 @@ def build_manifest(model: TemoaModel, extension_ids: Sequence[str] | None = None validation_map=(0, 1, 2, 3), is_period_filtered=False, is_table_required=False, + index_set=model.limit_annual_capacity_factor_constraint_rtvo, ), LoadItem( component=model.limit_emission, table='limit_emission', columns=['region', 'period', 'emis_comm', 'operator', 'value'], is_table_required=False, + index_set=model.limit_emission_constraint_rpe, ), LoadItem( component=model.limit_tech_input_split, diff --git a/temoa/data_io/hybrid_loader.py b/temoa/data_io/hybrid_loader.py index 6dad50308..42ad02e97 100644 --- a/temoa/data_io/hybrid_loader.py +++ b/temoa/data_io/hybrid_loader.py @@ -970,36 +970,11 @@ def load_param_idx_sets(self, data: dict[str, object]) -> dict[str, object]: :param data: The main data dictionary. :return: A dictionary of the new index sets to be added. """ - model = TemoaModel() - param_idx_sets = { - model.cost_invest.name: model.cost_invest_rtv.name, - model.cost_emission.name: model.cost_emission_rpe.name, - model.demand.name: model.demand_constraint_rpc.name, - model.limit_emission.name: model.limit_emission_constraint_rpe.name, - model.limit_activity.name: model.limit_activity_constraint_rpt.name, - model.limit_seasonal_capacity_factor.name: ( - model.limit_seasonal_capacity_factor_constraint_rst.name - ), - model.limit_activity_share.name: model.limit_activity_share_constraint_rpgg.name, - model.limit_annual_capacity_factor.name: ( - model.limit_annual_capacity_factor_constraint_rtvo.name - ), - model.limit_capacity.name: model.limit_capacity_constraint_rpt.name, - model.limit_capacity_share.name: model.limit_capacity_share_constraint_rpgg.name, - model.limit_new_capacity.name: model.limit_new_capacity_constraint_rtv.name, - model.limit_new_capacity_share.name: ( - model.limit_new_capacity_share_constraint_rggv.name - ), - model.limit_resource.name: model.limit_resource_constraint_rt.name, - model.limit_storage_fraction.name: model.limit_storage_fraction_param_rsdt.name, - model.renewable_portfolio_standard.name: ( - model.renewable_portfolio_standard_constraint_rpg.name - ), - } - res: dict[str, object] = {} - for p_name, s_name in param_idx_sets.items(): - param_data = data.get(p_name) + for item in self.manifest: + if item.index_set is None: + continue + param_data = data.get(item.component.name) if isinstance(param_data, dict): - res[s_name] = list(param_data.keys()) + res[item.index_set.name] = list(param_data.keys()) return res diff --git a/temoa/data_io/loader_manifest.py b/temoa/data_io/loader_manifest.py index 886aaa4e1..a43d44cb6 100644 --- a/temoa/data_io/loader_manifest.py +++ b/temoa/data_io/loader_manifest.py @@ -47,6 +47,9 @@ class LoadItem: `HybridLoader` to handle non-standard loading logic for this component. fallback_data: Optional. A list of default data tuples to use if the table is missing or returns no data. + index_set: Optional. The Pyomo `Set` that is indexed by the keys of + this `Param`. When set, the loader will automatically derive the + set's data from the param's loaded keys during finalization. """ component: ComponentType @@ -61,3 +64,4 @@ class LoadItem: custom_loader_name: str | None = None fallback_data: list[tuple[object, ...]] | None = None order_by: list[str] | None = None + index_set: ComponentType | None = None From dd9ba65b80f9b215e8622b837729971bdac3a6a1 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 13:57:54 -0400 Subject: [PATCH 09/23] Tidy up for Bugs Signed-off-by: Davey Elder --- temoa/extensions/discrete_capacity/extension.py | 4 ++-- temoa/extensions/growth_rates/extension.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/temoa/extensions/discrete_capacity/extension.py b/temoa/extensions/discrete_capacity/extension.py index 2a0c141e0..d9a062c89 100644 --- a/temoa/extensions/discrete_capacity/extension.py +++ b/temoa/extensions/discrete_capacity/extension.py @@ -11,10 +11,10 @@ owned_tables=('limit_discrete_new_capacity', 'limit_discrete_capacity'), regional_group_tables={ 'limit_discrete_new_capacity': 'region', - 'limit_discrete_capacity': 'region' + 'limit_discrete_capacity': 'region', }, register_model_components=register_model_components, build_manifest_items=build_manifest_items, - schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), + schema_sql_path=str(Path(__file__).parent / 'tables.sql'), fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/growth_rates/extension.py b/temoa/extensions/growth_rates/extension.py index 19aec7008..15847fc0c 100644 --- a/temoa/extensions/growth_rates/extension.py +++ b/temoa/extensions/growth_rates/extension.py @@ -26,6 +26,6 @@ }, register_model_components=register_model_components, build_manifest_items=build_manifest_items, - schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), + schema_sql_path=str(Path(__file__).parent / 'tables.sql'), fail_if_tables_populated_when_disabled=True, ) From a01531993d282d782a83e09b3a3d8863c5add37e Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 13:59:02 -0400 Subject: [PATCH 10/23] Build main extension components for economies of scale Signed-off-by: Davey Elder --- temoa/core/model.py | 6 + .../extensions/economies_of_scale/__init__.py | 3 + .../economies_of_scale/components/__init__.py | 0 .../components/cost_fixed_eos.py | 313 ++++++++++++ .../components/cost_invest_eos.py | 474 ++++++++++++++++++ .../components/cost_variable_eos.py | 334 ++++++++++++ .../economies_of_scale/core/__init__.py | 0 .../economies_of_scale/core/model.py | 208 ++++++++ .../economies_of_scale/data_manifest.py | 74 +++ .../economies_of_scale/extension.py | 21 + .../extensions/economies_of_scale/tables.sql | 53 ++ temoa/extensions/framework.py | 3 +- 12 files changed, 1488 insertions(+), 1 deletion(-) create mode 100644 temoa/extensions/economies_of_scale/__init__.py create mode 100644 temoa/extensions/economies_of_scale/components/__init__.py create mode 100644 temoa/extensions/economies_of_scale/components/cost_fixed_eos.py create mode 100644 temoa/extensions/economies_of_scale/components/cost_invest_eos.py create mode 100644 temoa/extensions/economies_of_scale/components/cost_variable_eos.py create mode 100644 temoa/extensions/economies_of_scale/core/__init__.py create mode 100644 temoa/extensions/economies_of_scale/core/model.py create mode 100644 temoa/extensions/economies_of_scale/data_manifest.py create mode 100644 temoa/extensions/economies_of_scale/extension.py create mode 100644 temoa/extensions/economies_of_scale/tables.sql diff --git a/temoa/core/model.py b/temoa/core/model.py index 10f0a77b5..24d4dbb08 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -39,6 +39,9 @@ technology, time, ) +from temoa.extensions.economies_of_scale.core.model import ( + register_early_eos_components, +) from temoa.extensions.framework import apply_model_extension_hooks, resolve_extension_specs from temoa.model_checking.validators import ( no_slash_or_pipe, @@ -563,6 +566,9 @@ def __init__( ) self.cost_invest = Param(self.cost_invest_rtv) + # Inject cost_invest_eos extension components prior to loan params, if active + register_early_eos_components(self) + self.default_loan_rate = Param(domain=NonNegativeReals) self.loan_rate = Param( self.cost_invest_rtv, domain=NonNegativeReals, default=costs.get_default_loan_rate diff --git a/temoa/extensions/economies_of_scale/__init__.py b/temoa/extensions/economies_of_scale/__init__.py new file mode 100644 index 000000000..42728b346 --- /dev/null +++ b/temoa/extensions/economies_of_scale/__init__.py @@ -0,0 +1,3 @@ +from temoa.extensions.economies_of_scale.extension import EOS_EXTENSION + +__all__ = ['EOS_EXTENSION'] diff --git a/temoa/extensions/economies_of_scale/components/__init__.py b/temoa/extensions/economies_of_scale/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py b/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py new file mode 100644 index 000000000..9a902eb43 --- /dev/null +++ b/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pyomo.environ import quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.costs import fixed_or_variable_cost + +if TYPE_CHECKING: + from pyomo.core import Expression + from pyomo.core.base.objective import ObjectiveData + from pyomo.core.base.var import VarData + + from temoa.extensions.economies_of_scale.core.model import EOSModel + from temoa.types import ExprLike, Period, Region, Technology + +logger = logging.getLogger(__name__) + + +def period_cost_indices(model: EOSModel) -> set[tuple[Region, Period, Technology]]: + return {(r, p, t) for r, p, t, _ in model.cost_fixed_eos_rptn} + + +def initialize_components(model: EOSModel) -> None: + """ + Organise some data and put up some guard rails + """ + + # Collect segment indices n for each (r,p,t) cluster; order doesn't matter + for r, p, t, n in model.cost_fixed_eos_rptn: + model.cost_fixed_eos_segments.setdefault((r, p, t), set()).add(n) + + # Check that costs and capacities are monotonically increasing + for (r, p, t), segs in model.cost_fixed_eos_segments.items(): + sorted_segs = sorted(segs) + for i, n in enumerate(sorted_segs): + cap_lower, cap_upper, cost_lower, cost_upper = model.cost_fixed_eos[r, p, t, n] + + # Check cost curve is nonnegative and monotonically increasing + # If someone wants to break this assumption they should have the + # skills to edit this code. + if not all( + ( + cap_lower >= 0, + cap_upper >= 0, + cost_lower >= 0, + cost_upper >= 0, + cap_upper > cap_lower, + cost_upper > cost_lower, + ) + ): + msg = ( + 'Negative values or non-increasing segment bounds found ' + f'in cost_fixed_eos table for {r, p, t, n}' + ) + logger.error(msg) + raise ValueError(msg) + + # Backcheck for monotonic growth + if i == 0: + continue + + _, prev_cap_upper, _, prev_cost_upper = model.cost_fixed_eos[ + r, p, t, sorted_segs[i - 1] + ] + + if not all( + ( + abs(cap_lower - prev_cap_upper) <= 0.001, + abs(cost_lower - prev_cost_upper) <= 0.001, + ) + ): + msg = ( + 'Segments in cost_fixed_eos table do not align on their bounds. This would ' + 'leave gaps in the cost curve and could lead to infeasibility. Check ' + f'({r, p, t, sorted_segs[i - 1]}) to ({r, p, t, n})' + ) + logger.error(msg) + raise ValueError(msg) + + +# --- Enforce the rules of cumulative capacity progression up the cost curve --- +def cost_fixed_eos_segment_binary_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Enforce exactly one active segment of the cost curve for each cluster + in each period. + + Each technology cluster :math:`(r, p, t)` has a piecewise-linear cost curve + split into :math:`N_{r,p,t}` segments. The binary variable + :math:`\textbf{CFEB}_{r,p,t,n}` selects which segment is currently active. + Exactly one segment must be active at all times: + + + .. math:: + :label: Cost Fixed EOS Segment Binary Constraint + + \sum_{n \in N_{r,p,t}} \textbf{CFEB}_{r,p,t,n} = 1 + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_fixed\_eos\_period\_rpt}} + + where :math:`\textbf{CFEB}_{r,p,t,n}` is + :code:`v_cost_fixed_eos_segment_binary[r, p, t, n]` (:math:`\in \{0, 1\}`) and + :math:`N_{r,p,t}` is the set of segment indices declared for cluster + :math:`(r, p, t)` in the :code:`cost_fixed_eos` table. + """ + count_active_segments = quicksum( + model.v_cost_fixed_eos_segment_binary[r, p, t, n] + for n in model.cost_fixed_eos_segments[r, p, t] + ) + return count_active_segments == 1 + + +def cost_fixed_eos_capacity_lower_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the lower bound of the active EOS cost-curve segment on the + capacity variable for that segment. + + When segment :math:`n` is inactive (:math:`\textbf{CFEB}_{r,p,t,n} = 0`), the + right-hand side collapses to zero so the constraint is non-binding. When + segment :math:`n` is active (:math:`\textbf{CFEB}_{r,p,t,n} = 1`), it + enforces that the segment capacity is at least the lower boundary + :math:`\underline{K}_{r,p,t,n}` of that segment: + + .. math:: + :label: EOS Capacity Lower Bound + + \textbf{CFECAP}_{r,p,t,n} + \ge \textbf{CFEB}_{r,p,t,n} \cdot \underline{K}_{r,p,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_fixed\_eos\_rptn}} + + where :math:`\textbf{CFECAP}_{r,p,t,n}` is + :code:`v_cost_fixed_eos_capacity[r, p, t, n]`, :math:`\textbf{CFEB}_{r,p,t,n}` + is :code:`v_cost_fixed_eos_segment_binary[r, p, t, n]`, and + :math:`\underline{K}_{r,p,t,n}` is :code:`cost_fixed_eos[r, p, t, n][0]` + (the :code:`capacity_lower` column). + """ + cap_lower = model.v_cost_fixed_eos_segment_binary[r, p, t, n] * value( + model.cost_fixed_eos[r, p, t, n][0] + ) + return model.v_cost_fixed_eos_capacity[r, p, t, n] >= cap_lower + + +def cost_fixed_eos_capacity_upper_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the upper bound of the active EOS cost-curve segment on the + capacity variable for that segment. + + This is the mirror of the lower bound. When segment :math:`n` is inactive + the right-hand side collapses to zero, driving + :math:`\textbf{CFECAP}_{r,p,t,n}` to zero. When it is active, + the segment capacity cannot exceed the upper boundary + :math:`\overline{K}_{r,p,t,n}` of that segment: + + .. math:: + :label: EOS Capacity Upper Bound + + \textbf{CFECAP}_{r,p,t,n} + \le \textbf{CFEB}_{r,p,t,n} \cdot \overline{K}_{r,p,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_fixed\_eos\_rptn}} + + where :math:`\overline{K}_{r,p,t,n}` is :code:`cost_fixed_eos[r, p, t, n][1]` + (the :code:`capacity_upper` column). + + Together the lower and upper bound constraints implement a "big-M"-style + activation: only the single active segment can carry non-zero capacity, + and that capacity is pinned within the segment's range. + """ + cap_upper = model.v_cost_fixed_eos_segment_binary[r, p, t, n] * value( + model.cost_fixed_eos[r, p, t, n][1] + ) + return model.v_cost_fixed_eos_capacity[r, p, t, n] <= cap_upper + + +def cost_fixed_eos_capacity_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Equate the EOS capacity expression to the actual capacity available + in the base model. + + The EOS formulation tracks capacity internally through + :math:`\textbf{CFECAP}_{r,p,t,n}`. This constraint ties those + internal variables to the real decision variables + :math:`\textbf{CAPAVL}_{r,p,t}` from the base Temoa model, + so the cost curve reflects actually-available capacity + rather than a free parameter: + + .. math:: + :label: Cost Fixed EOS Capacity + + \sum_{n \in N_{r,p,t}} \textbf{CFECAP}_{r,p,t,n} + = + \sum_{\substack{r' \in R(r),\, t' \in T(t)}} + \textbf{CAPAVL}_{r',p,t'} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_fixed\_eos\_period\_rpt}} + + where :math:`R(r)` and :math:`T(t)` expand any group labels to the + individual regions and technologies they contain, and + :math:`\textbf{CAPAVL}_{r',p,t'}` is + :code:`v_capacity_available_by_period_and_tech[r', p, t']`. + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + cap_eos = quicksum( + model.v_cost_fixed_eos_capacity[r, p, t, n] for n in model.cost_fixed_eos_segments[r, p, t] + ) + capacity = quicksum( + model.v_capacity_available_by_period_and_tech[_r, p, _t] + for _t in techs + for _r in regions + if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + ) + return capacity == cap_eos + + +# --- Calculating costs --- +def cost_fixed_eos_segment_cost( + model: EOSModel, + r: Region, + p: Period, + t: Technology, + n: int, + capacity: ExprLike, + binary: VarData | bool = True, +) -> ExprLike: + """Fixed cost within a segment, if capacity were in that segment""" + cap_lower, cap_upper, cost_lower, cost_upper = model.cost_fixed_eos[r, p, t, n] + m = (value(cost_upper) - value(cost_lower)) / (value(cap_upper) - value(cap_lower)) + return m * capacity + binary * (value(cost_lower) - m * value(cap_lower)) + + +def period_cost(model: EOSModel, r: Region, p: Period, t: Technology) -> Expression: + r""" + EOS clusters do not contribute to the base-model + :math:`\text{CostFixed}` sum. Instead, the extension adds a separate + discounted fixed-cost term to :code:`total_cost` via a + :code:`BuildAction` (``append_cost_fixed_eos_to_total_cost``). + + The *period cost* for cluster :math:`(r, p, t)` is evaluated from the + piecewise-linear cost curve at the capacity available in period :math:`p`: + + .. math:: + + \text{EOS\_PeriodCost}_{r,p,t} = + \sum_{n \in N_{r,p,t}} + \left[ + m_{r,p,t,n} \cdot \textbf{CFECAP}_{r,p,t,n} + + \textbf{CFEB}_{r,p,t,n} + \left(\underline{C}_{r,p,t,n} - m_{r,p,t,n} + \cdot \underline{K}_{r,p,t,n}\right) + \right] + + \text{with slope }m_{r,p,t,n} = \dfrac{\overline{C}_{r,p,t,n} - + \underline{C}_{r,p,t,n}}{\overline{K}_{r,p,t,n} - \underline{K}_{r,p,t,n}} + + Note the terms in this summation are only non-zero for the active segment. + + This cost is then discounted and amortised using the same + :func:`~temoa.components.costs.fixed_or_variable_cost` function as for + cost_fixed in the base model. The result is added to :code:`total_cost` + once per cluster-period combination in + :math:`\Theta_{\text{cost\_fixed\_eos\_period\_rpt}}`. + """ + return quicksum( + cost_fixed_eos_segment_cost( + model, + r, + p, + t, + n, + model.v_cost_fixed_eos_capacity[r, p, t, n], + model.v_cost_fixed_eos_segment_binary[r, p, t, n], + ) + for n in model.cost_fixed_eos_segments[r, p, t] + ) + + +def total_cost(model: EOSModel) -> None: + """ + Discounted fixed costs for all EOS clusters in the planning horizon + """ + + p_0 = min(model.time_optimize) + global_discount_rate = value(model.global_discount_rate) + + total_cost = quicksum( + fixed_or_variable_cost( + 1, + period_cost(model, r, p, t), + value(model.period_length[p]), + global_discount_rate, + p_0, + p=p, + ) + for r, p, t in model.cost_fixed_eos_period_rpt + ) + + # Append to total cost objective + model.total_cost.set_value(cast('ObjectiveData', model.total_cost).expr + total_cost) diff --git a/temoa/extensions/economies_of_scale/components/cost_invest_eos.py b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py new file mode 100644 index 000000000..a59c60e77 --- /dev/null +++ b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import logging +from itertools import product as cross_product +from typing import TYPE_CHECKING, cast + +from pyomo.environ import quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.costs import loan_cost, loan_cost_survival_curve + +if TYPE_CHECKING: + from pyomo.core import Expression + from pyomo.core.base.objective import ObjectiveData + from pyomo.core.base.var import VarData + + from temoa.extensions.economies_of_scale.core.model import EOSModel + from temoa.types import ExprLike, Period, Region, Technology + +logger = logging.getLogger(__name__) + + +# --- Initialise EOS invest model components --- +def cost_invest_eos_cumulative_capacity_indices( + model: EOSModel, +) -> set[tuple[Region, Period, Technology, int]]: + return { + (r, cast('Period', v), t, n) + for r, t, n in model.cost_invest_eos.sparse_keys() + for _r in geography.gather_group_regions(model, r) + for _t in technology.gather_group_techs(model, t) + for _p in model.time_optimize + for v in model.process_vintages.get((_r, _p, _t), set()) + if v == _p + } + + +def cost_invest_eos_period_cost_indices(model: EOSModel) -> set[tuple[Region, Period, Technology]]: + return {(r, p, t) for r, p, t, _ in model.cost_invest_eos_segment_rptn} + + +def append_cost_invest_eos_rtv(model: EOSModel) -> None: + """ + EOS invest processes most likely do not have a cost_invest parameter, as + that is handled by the EOS cost curve. As a result they would not have + loan parameters either. We add these rtv indices to the base + cost_invest param so EOS invest processes have loan parameters for + discounting in the objective function. + """ + for r, p, t in model.cost_invest_eos_period_rpt: + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + valid_rtv = { + (_r, _t, p) for _r in regions for _t in techs if (_r, _t, p) in model.process_periods + } + for rtv in valid_rtv: + if rtv not in model.cost_invest_rtv: + model.cost_invest_rtv.add(rtv) + + +def initialize_cost_invest_eos(model: EOSModel) -> None: + """ + Gather segment data and validate guard rails for EOS invest clusters. + """ + + # Collect segment indices n for each (r,t) cluster; order doesn't matter + for r, t, n in model.cost_invest_eos.sparse_keys(): + model.cost_invest_eos_segments.setdefault((r, t), set()).add(n) + + # Check that costs and capacities are monotonically increasing + for (r, t), segs in model.cost_invest_eos_segments.items(): + sorted_segs = sorted(segs) + for i, n in enumerate(sorted_segs): + cap_lower, cap_upper, cost_lower, cost_upper = model.cost_invest_eos[r, t, n] + + # Check cost curve is nonnegative and monotonically increasing. + # If someone wants to break this assumption they should have the + # skills to edit this code. + if not all( + ( + cap_lower >= 0, + cap_upper >= 0, + cost_lower >= 0, + cost_upper >= 0, + cap_upper > cap_lower, + cost_upper > cost_lower, + ) + ): + msg = ( + 'Negative values or non-increasing segment bounds found ' + f'in cost_invest_eos table for {r, t, n}' + ) + logger.error(msg) + raise ValueError(msg) + + # Backcheck for monotonic growth + if i == 0: + continue + + _, prev_cap_upper, _, prev_cost_upper = model.cost_invest_eos[r, t, sorted_segs[i - 1]] + + if not all( + ( + abs(cap_lower - prev_cap_upper) <= 0.001, + abs(cost_lower - prev_cost_upper) <= 0.001, + ) + ): + msg = ( + 'Segments in cost_invest_eos table do not align on their bounds. This would ' + 'leave gaps in the cost curve and could lead to infeasibility. Check ' + f'({r, t, sorted_segs[i - 1]}) to ({r, t, n})' + ) + logger.error(msg) + raise ValueError(msg) + + # Shortens some lines below in error checks + life = model.lifetime_process + loan_life = model.loan_lifetime_process + loan_rate = model.loan_rate + + # Get a viable built process for each EOS invest period, for discounting + # parameters. Check that all r, t combos in the cluster share the same + # lifetime and loan parameters. + for r, p, t in model.cost_invest_eos_period_rpt: + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + for _r, _t in cross_product(regions, techs): + if (_r, _t, p) in model.process_periods: + # Store first valid process as our reference for this period + if (r, p, t) not in model.cost_invest_eos_reference_process: + model.cost_invest_eos_reference_process[r, p, t] = _r, _t + + # Check that the assumptions hold + r0, t0 = model.cost_invest_eos_reference_process[r, p, t] + if any( + ( + abs(life[r0, t0, p] - life[_r, _t, p]) >= 0.001, + abs(loan_life[r0, t0, p] - loan_life[_r, _t, p]) >= 0.001, + abs(loan_rate[r0, t0, p] - loan_rate[_r, _t, p]) >= 0.001, + ) + ): + msg = ( + 'Processes assigned to the same cost_invest_eos cost curve must all have ' + 'the same process lifetime, loan lifetime, and loan rate. These two ' + 'processes do not match:\n ' + f'({r0}, {t0}, {p}) : lifetime = {life[r0, t0, p]}, ' + f'loan life = {loan_life[r0, t0, p]}, loan rate = {loan_rate[r0, t0, p]}\n' + f'({_r}, {_t}, {p}) : lifetime = {life[_r, _t, p]}, ' + f'loan life = {loan_life[_r, _t, p]}, loan rate = {loan_rate[_r, _t, p]}' + ) + logger.error(msg) + raise ValueError(msg) + + +# --- Enforce the rules of cumulative capacity progression up the cost curve --- +def cost_invest_eos_segment_binary_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Enforce exactly one active segment of the EOS invest cost curve for each + cluster in each period. + + Each technology cluster :math:`(r, t)` has a piecewise-linear investment + cost curve split into :math:`N_{r,t}` segments. The binary variable + :math:`\textbf{CIEB}_{r,p,t,n}` selects which segment is currently active. + Exactly one segment must be active at all times: + + .. math:: + :label: Cost Invest EOS Segment Binary Constraint + + \sum_{n \in N_{r,t}} \textbf{CIEB}_{r,p,t,n} = 1 + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_invest\_eos\_period\_rpt}} + + where :math:`\textbf{CIEB}_{r,p,t,n}` is + :code:`v_cost_invest_eos_segment_binary[r, p, t, n]` (:math:`\in \{0, 1\}`) and + :math:`N_{r,t}` is the set of segment indices declared for cluster + :math:`(r, t)` in the :code:`cost_invest_eos` table. + """ + count_active_segments = quicksum( + model.v_cost_invest_eos_segment_binary[r, p, t, n] + for n in model.cost_invest_eos_segments[r, t] + ) + return count_active_segments == 1 + + +def cost_invest_eos_capacity_lower_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the lower bound of the active EOS invest cost-curve segment on + cumulative capacity. + + When segment :math:`n` is inactive (:math:`\textbf{CIEB}_{r,p,t,n} = 0`), the + right-hand side collapses to zero so the constraint is non-binding. When + segment :math:`n` is active (:math:`\textbf{CIEB}_{r,p,t,n} = 1`), it + enforces that cumulative capacity has reached at least the lower boundary + :math:`\underline{K}_{r,t,n}` of that segment: + + .. math:: + :label: Cost Invest EOS Capacity Lower Bound + + \textbf{CIECAP}_{r,p,t,n} + \ge \textbf{CIEB}_{r,p,t,n} \cdot \underline{K}_{r,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_invest\_eos\_segment\_rptn}} + + where :math:`\textbf{CIECAP}_{r,p,t,n}` is + :code:`v_cost_invest_eos_cumulative_capacity[r, p, t, n]`, + :math:`\textbf{CIEB}_{r,p,t,n}` is + :code:`v_cost_invest_eos_segment_binary[r, p, t, n]`, and + :math:`\underline{K}_{r,t,n}` is :code:`cost_invest_eos[r, t, n][0]` + (the :code:`capacity_lower` column). + """ + cap_lower = model.v_cost_invest_eos_segment_binary[r, p, t, n] * value( + model.cost_invest_eos[r, t, n][0] + ) + return model.v_cost_invest_eos_cumulative_capacity[r, p, t, n] >= cap_lower + + +def cost_invest_eos_capacity_upper_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the upper bound of the active EOS invest cost-curve segment on + cumulative capacity. + + This is the mirror of the lower bound. When segment :math:`n` is inactive + the right-hand side collapses to zero, driving + :math:`\textbf{CIECAP}_{r,p,t,n}` to zero. When it is active, + cumulative capacity cannot exceed the upper boundary + :math:`\overline{K}_{r,t,n}` of that segment: + + .. math:: + :label: Cost Invest EOS Capacity Upper Bound + + \textbf{CIECAP}_{r,p,t,n} + \le \textbf{CIEB}_{r,p,t,n} \cdot \overline{K}_{r,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_invest\_eos\_segment\_rptn}} + + where :math:`\overline{K}_{r,t,n}` is :code:`cost_invest_eos[r, t, n][1]` + (the :code:`capacity_upper` column). + + Together the lower and upper bound constraints implement a "big-M"-style + activation: only the single active segment can carry non-zero capacity, + and that capacity is pinned within the segment's range. + """ + cap_upper = model.v_cost_invest_eos_segment_binary[r, p, t, n] * value( + model.cost_invest_eos[r, t, n][1] + ) + return model.v_cost_invest_eos_cumulative_capacity[r, p, t, n] <= cap_upper + + +def cost_invest_eos_cumulative_capacity_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Equate the EOS invest cumulative-capacity expression to the actual capacity + built in the base model. + + The EOS invest formulation tracks cumulative capacity internally through + :math:`\textbf{CIECAP}_{r,p,t,n}`. This constraint ties those internal + variables to the real decision variables :math:`\textbf{NCAP}_{r,t,v}` and + the parameter :math:`\textbf{ECAP}_{r,t,v}` from the base Temoa model, so + the cost curve reflects actually-built capacity rather than a free + parameter: + + .. math:: + :label: Cost Invest EOS Cumulative Capacity + + \sum_{n \in N_{r,t}} \textbf{CIECAP}_{r,p,t,n} + = + \sum_{\substack{r' \in R(r),\, t' \in T(t),\\ v \in V}} + \textbf{ECAP}_{r',t',v} + + + \sum_{\substack{r' \in R(r),\, t' \in T(t),\\ v \le p,\, v \in \mathcal{V}(r',t')}} + \textbf{NCAP}_{r',t',v} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_invest\_eos\_period\_rpt}} + + where :math:`R(r)` and :math:`T(t)` expand any group labels to the + individual regions and technologies they contain, and + :math:`\mathcal{V}(r', t')` is the set of all optimised vintages for that + process. + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + # What EOS invest thinks the cumulative capacity is for this cluster in this period + cap_eos = quicksum( + model.v_cost_invest_eos_cumulative_capacity[r, p, t, n] + for n in model.cost_invest_eos_segments[r, t] + ) + + # Sum all actually built capacity so far + cap_deployed = quicksum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity.sparse_keys() + if _r in regions and _t in techs + ) + cap_deployed += quicksum( + model.v_new_capacity[_r, _t, _v] + for _v in model.vintage_optimize + if _v <= p + for _r in regions + for _t in techs + if (_r, _t, _v) in model.v_new_capacity + ) + + return cap_deployed == cap_eos + + +# --- Calculating investment costs --- +def cost_invest_eos_segment_cost( + model: EOSModel, + r: Region, + t: Technology, + n: int, + cumulative_capacity: ExprLike, + binary: VarData | bool = True, +) -> ExprLike: + """Cumulative investment cost within an EOS invest segment, if capacity were in that segment""" + cap_lower, cap_upper, cost_lower, cost_upper = model.cost_invest_eos[r, t, n] + m = (value(cost_upper) - value(cost_lower)) / (value(cap_upper) - value(cap_lower)) + return m * cumulative_capacity + binary * (value(cost_lower) - m * value(cap_lower)) + + +def cost_invest_eos_cluster_cumulative_cost( + model: EOSModel, r: Region, p: Period, t: Technology +) -> Expression: + """ + Cumulative investment cost for an EOS invest cluster up to period p, + accounting for which segment of the cost curve is active. + """ + return quicksum( + cost_invest_eos_segment_cost( + model, + r, + t, + n, + model.v_cost_invest_eos_cumulative_capacity[r, p, t, n], + model.v_cost_invest_eos_segment_binary[r, p, t, n], + ) + for n in model.cost_invest_eos_segments[r, t] + ) + + +def period_cost(model: EOSModel, r: Region, p: Period, t: Technology) -> Expression: + r""" + EOS invest clusters do not contribute to the base-model + :math:`\text{CostInvest}` sum. Instead, the extension adds a separate + discounted-investment term to :code:`total_cost` via a + :code:`BuildAction` (``append_cost_invest_eos_to_total_cost``). + + The *period cost* for cluster :math:`(r, p, t)` is the *incremental* + cumulative cost: the cost of all capacity built up to period :math:`p` + minus the cost of all capacity built up to the preceding EOS invest period + (or the cost attributable to pre-existing capacity in the first period): + + .. math:: + + \text{EOS\_Invest\_PeriodCost}_{r,p,t} = + \mathcal{C}_{r,p,t} - \mathcal{C}_{r,p_{\text{prev}},t} + + where the cumulative cost for a cluster in a given period is: + + .. math:: + + \mathcal{C}_{r,p,t} = + \sum_{n \in N_{r,t}} + \left[ + m_{r,t,n} \cdot \textbf{CIECAP}_{r,p,t,n} + + \textbf{CIEB}_{r,p,t,n} + \left(\underline{C}_{r,t,n} - m_{r,t,n} \cdot \underline{K}_{r,t,n}\right) + \right] + + \text{with slope }m_{r,p,t,n} = \dfrac{\overline{C}_{r,p,t,n} - + \underline{C}_{r,p,t,n}}{\overline{K}_{r,p,t,n} - \underline{K}_{r,p,t,n}} + + Note the terms in this summation are only non-zero for the active segment. + + This incremental cost is then discounted and amortised using the same + :func:`~temoa.components.costs.loan_cost` function as for cost_invest in + the base model, using the reference process :math:`(r_0, t_0)` for loan + parameters. The result is added to :code:`total_cost` once per + cluster-period combination in + :math:`\Theta_{\text{cost\_invest\_eos\_period\_rpt}}`. + """ + + cumulative_cost = cost_invest_eos_cluster_cumulative_cost(model, r, p, t) + prev_cum_cost: ExprLike = 0.0 + + # Subtract previously accumulated investment costs to get the incremental for discounting + prev_periods = { + _p for _r, _p, _t in model.cost_invest_eos_period_rpt if _r == r and _t == t and _p < p + } + if len(prev_periods) > 0: + # Endogenous previous cumulative investment costs to subtract + p_prev = max(prev_periods) + prev_cum_cost = cost_invest_eos_cluster_cumulative_cost(model, r, p_prev, t) + else: + # Existing capacity costs to subtract (needed for myopic) + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + existing_capacity = quicksum( + value(model.existing_capacity[_r, _t, _v]) + for _r, _t, _v in model.existing_capacity + if _r in regions and _t in techs + ) + if existing_capacity: + for n in model.cost_invest_eos_segments[r, t]: + cap_lower, cap_upper, _, _ = model.cost_invest_eos[r, t, n] + if value(cap_lower) <= existing_capacity <= value(cap_upper): + prev_cum_cost = cost_invest_eos_segment_cost(model, r, t, n, existing_capacity) + continue # in case we're exactly on the boundary of two segments + if not prev_cum_cost: + msg = ( + 'Existing capacity for a cost_invest_eos cluster is outside the bounds of ' + 'the cost curve. Check the cost_invest_eos table and existing_capacity ' + f'for {r, t}: {existing_capacity}' + ) + logger.error(msg) + raise ValueError(msg) + + return cumulative_cost - prev_cum_cost + + +def total_cost(model: EOSModel) -> None: + """ + Discounted investment costs for all EOS invest clusters in the planning horizon + """ + + p_0 = min(model.time_optimize) + p_e = model.time_future.last() + global_discount_rate = value(model.global_discount_rate) + + total_cost = 0.0 + + for _r, _p, _t in model.cost_invest_eos_period_rpt: + _r0, _t0 = model.cost_invest_eos_reference_process[_r, _p, _t] + if model.is_survival_curve_process[_r0, _t0, _p]: + total_cost += loan_cost_survival_curve( + model, + _r0, + _t0, + _p, + 1, + period_cost(model, _r, _p, _t), + value(model.loan_annualize[_r0, _t0, _p]), + value(model.loan_lifetime_process[_r0, _t0, _p]), + p_0, + p_e, + global_discount_rate, + ) + else: + total_cost += loan_cost( + 1, + period_cost(model, _r, _p, _t), + value(model.loan_annualize[_r0, _t0, _p]), + value(model.loan_lifetime_process[_r0, _t0, _p]), + value(model.lifetime_process[_r0, _t0, _p]), + p_0, + p_e, + global_discount_rate, + vintage=_p, + ) + + # Append to total cost objective + model.total_cost.set_value(cast('ObjectiveData', model.total_cost).expr + total_cost) diff --git a/temoa/extensions/economies_of_scale/components/cost_variable_eos.py b/temoa/extensions/economies_of_scale/components/cost_variable_eos.py new file mode 100644 index 000000000..e65868cc6 --- /dev/null +++ b/temoa/extensions/economies_of_scale/components/cost_variable_eos.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pyomo.environ import quicksum, value + +import temoa.components.geography as geography +import temoa.components.technology as technology +from temoa.components.costs import fixed_or_variable_cost + +if TYPE_CHECKING: + from pyomo.core import Expression + from pyomo.core.base.objective import ObjectiveData + from pyomo.core.base.var import VarData + + from temoa.extensions.economies_of_scale.core.model import EOSModel + from temoa.types import ExprLike, Period, Region, Technology + +logger = logging.getLogger(__name__) + + +def period_cost_indices(model: EOSModel) -> set[tuple[Region, Period, Technology]]: + return {(r, p, t) for r, p, t, _ in model.cost_variable_eos_rptn} + + +def initialize_components(model: EOSModel) -> None: + """ + Organise some data and put up some guard rails + """ + + # Collect segment indices n for each (r,p,t) cluster; order doesn't matter + for r, p, t, n in model.cost_variable_eos_rptn: + model.cost_variable_eos_segments.setdefault((r, p, t), set()).add(n) + + # Check that costs and activities are monotonically increasing + for (r, p, t), segs in model.cost_variable_eos_segments.items(): + sorted_segs = sorted(segs) + for i, n in enumerate(sorted_segs): + act_lower, act_upper, cost_lower, cost_upper = model.cost_variable_eos[r, p, t, n] + + # Check cost curve is nonnegative and monotonically increasing + # If someone wants to break this assumption they should have the + # skills to edit this code. + if not all( + ( + act_lower >= 0, + act_upper >= 0, + cost_lower >= 0, + cost_upper >= 0, + act_upper > act_lower, + cost_upper > cost_lower, + ) + ): + msg = ( + 'Negative values or non-increasing segment bounds found ' + f'in cost_variable_eos table for {r, p, t, n}' + ) + logger.error(msg) + raise ValueError(msg) + + # Backcheck for monotonic growth + if i == 0: + continue + + _, prev_act_upper, _, prev_cost_upper = model.cost_variable_eos[ + r, p, t, sorted_segs[i - 1] + ] + + if not all( + ( + abs(act_lower - prev_act_upper) <= 0.001, + abs(cost_lower - prev_cost_upper) <= 0.001, + ) + ): + msg = ( + 'Segments in cost_variable_eos table do not align on their bounds. This would ' + 'leave gaps in the cost curve and could lead to infeasibility. Check ' + f'({r, p, t, sorted_segs[i - 1]}) to ({r, p, t, n})' + ) + logger.error(msg) + raise ValueError(msg) + + +# --- Enforce the rules of activity progression up the cost curve --- +def cost_variable_eos_segment_binary_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Enforce exactly one active segment of the cost curve for each cluster + in each period. + + Each technology cluster :math:`(r, p, t)` has a piecewise-linear cost curve + split into :math:`N_{r,p,t}` segments. The binary variable + :math:`\textbf{CVEB}_{r,p,t,n}` selects which segment is currently active. + Exactly one segment must be active at all times: + + .. math:: + :label: Cost Variable EOS Segment Binary Constraint + + \sum_{n \in N_{r,p,t}} \textbf{CVEB}_{r,p,t,n} = 1 + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_variable\_eos\_period\_rpt}} + + where :math:`\textbf{CVEB}_{r,p,t,n}` is + :code:`v_cost_variable_eos_segment_binary[r, p, t, n]` (:math:`\in \{0, 1\}`) and + :math:`N_{r,p,t}` is the set of segment indices declared for cluster + :math:`(r, p, t)` in the :code:`cost_variable_eos` table. + """ + count_active_segments = quicksum( + model.v_cost_variable_eos_segment_binary[r, p, t, n] + for n in model.cost_variable_eos_segments[r, p, t] + ) + return count_active_segments == 1 + + +def cost_variable_eos_activity_lower_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the lower bound of the active EOS cost-curve segment on the + activity variable for that segment. + + When segment :math:`n` is inactive (:math:`\textbf{CVEB}_{r,p,t,n} = 0`), the + right-hand side collapses to zero so the constraint is non-binding. When + segment :math:`n` is active (:math:`\textbf{CVEB}_{r,p,t,n} = 1`), it + enforces that the segment activity is at least the lower boundary + :math:`\underline{A}_{r,p,t,n}` of that segment: + + .. math:: + :label: Cost Variable EOS Activity Lower Bound + + \textbf{CVEACT}_{r,p,t,n} + \ge \textbf{CVEB}_{r,p,t,n} \cdot \underline{A}_{r,p,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_variable\_eos\_rptn}} + + where :math:`\textbf{CVEACT}_{r,p,t,n}` is + :code:`v_cost_variable_eos_activity[r, p, t, n]`, :math:`\textbf{CVEB}_{r,p,t,n}` + is :code:`v_cost_variable_eos_segment_binary[r, p, t, n]`, and + :math:`\underline{A}_{r,p,t,n}` is :code:`cost_variable_eos[r, p, t, n][0]` + (the :code:`activity_lower` column). + """ + act_lower = model.v_cost_variable_eos_segment_binary[r, p, t, n] * value( + model.cost_variable_eos[r, p, t, n][0] + ) + return model.v_cost_variable_eos_activity[r, p, t, n] >= act_lower + + +def cost_variable_eos_activity_upper_bound_constraint( + model: EOSModel, r: Region, p: Period, t: Technology, n: int +) -> ExprLike: + r""" + Enforce the upper bound of the active EOS cost-curve segment on the + activity variable for that segment. + + This is the mirror of the lower bound. When segment :math:`n` is inactive + the right-hand side collapses to zero, driving + :math:`\textbf{CVEACT}_{r,p,t,n}` to zero. When it is active, + the segment activity cannot exceed the upper boundary + :math:`\overline{A}_{r,p,t,n}` of that segment: + + .. math:: + :label: Cost Variable EOS Activity Upper Bound + + \textbf{CVEACT}_{r,p,t,n} + \le \textbf{CVEB}_{r,p,t,n} \cdot \overline{A}_{r,p,t,n} + + \qquad \forall \{r, p, t, n\} \in \Theta_{\text{cost\_variable\_eos\_rptn}} + + where :math:`\overline{A}_{r,p,t,n}` is :code:`cost_variable_eos[r, p, t, n][1]` + (the :code:`activity_upper` column). + + Together the lower and upper bound constraints implement a "big-M"-style + activation: only the single active segment can carry non-zero activity, + and that activity is pinned within the segment's range. + """ + act_upper = model.v_cost_variable_eos_segment_binary[r, p, t, n] * value( + model.cost_variable_eos[r, p, t, n][1] + ) + return model.v_cost_variable_eos_activity[r, p, t, n] <= act_upper + + +def cost_variable_eos_activity_constraint( + model: EOSModel, r: Region, p: Period, t: Technology +) -> ExprLike: + r""" + Equate the EOS activity expression to the actual total activity in the + base model. + + The EOS formulation tracks activity internally through + :math:`\textbf{CVEACT}_{r,p,t,n}`. This constraint ties those + internal variables to the real flow decision variables from the base + Temoa model, so the cost curve reflects actual activity rather than + a free parameter: + + .. math:: + :label: Cost Variable EOS Activity + + \sum_{n \in N_{r,p,t}} \textbf{CVEACT}_{r,p,t,n} + = + \sum_{\substack{r' \in R(r),\, t' \in T(t) \setminus T_a,\\ + v,\, s,\, d,\, i,\, o}} + \textbf{FO}_{r',p,s,d,i,t',v,o} + + + \sum_{\substack{r' \in R(r),\, t' \in T(t) \cap T_a,\\ + v,\, i,\, o}} + \textbf{FOA}_{r',p,i,t',v,o} + + \qquad \forall \{r, p, t\} \in \Theta_{\text{cost\_variable\_eos\_period\_rpt}} + + where :math:`R(r)` and :math:`T(t)` expand any group labels to the + individual regions and technologies they contain, :math:`T_a` is the set + of annual technologies (:code:`tech_annual`), + :math:`\textbf{FO}` is :code:`v_flow_out` summed over all vintages, + seasons :math:`s`, times of day :math:`d`, inputs :math:`i`, and outputs + :math:`o` active in period :math:`p`, and :math:`\textbf{FOA}` is + :code:`v_flow_out_annual` for annual technologies (no :math:`s,d` indices). + """ + + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) + + activity_eos = quicksum( + model.v_cost_variable_eos_activity[r, p, t, n] + for n in model.cost_variable_eos_segments[r, p, t] + ) + activity = quicksum( + model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] + for _t in techs + if _t not in model.tech_annual + for _r in regions + for S_v in model.process_vintages.get((_r, p, _t), []) + for S_i in model.process_inputs[_r, p, _t, S_v] + for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] + for s in model.time_season + for d in model.time_of_day + ) + activity += quicksum( + model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] + for _t in techs + if _t in model.tech_annual + for _r in regions + for S_v in model.process_vintages.get((_r, p, _t), []) + for S_i in model.process_inputs[_r, p, _t, S_v] + for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] + ) + return activity == activity_eos + + +# --- Calculating costs --- +def cost_variable_eos_segment_cost( + model: EOSModel, + r: Region, + p: Period, + t: Technology, + n: int, + activity: ExprLike, + binary: VarData | bool = True, +) -> ExprLike: + """Variable cost within a segment, if activity were in that segment""" + act_lower, act_upper, cost_lower, cost_upper = model.cost_variable_eos[r, p, t, n] + m = (value(cost_upper) - value(cost_lower)) / (value(act_upper) - value(act_lower)) + return m * activity + binary * (value(cost_lower) - m * value(act_lower)) + + +def period_cost(model: EOSModel, r: Region, p: Period, t: Technology) -> Expression: + r""" + EOS clusters do not contribute to the base-model + :math:`\text{CostVariable}` sum. Instead, the extension adds a separate + discounted variable-cost term to :code:`total_cost` via a + :code:`BuildAction` (``append_cost_variable_eos_to_total_cost``). + + The *period cost* for cluster :math:`(r, p, t)` is evaluated from the + piecewise-linear cost curve at the total activity in period :math:`p`: + + .. math:: + + \text{EOS\_PeriodCost}_{r,p,t} = + \sum_{n \in N_{r,p,t}} + \left[ + m_{r,p,t,n} \cdot \textbf{CVEACT}_{r,p,t,n} + + \textbf{CVEB}_{r,p,t,n} + \left(\underline{C}_{r,p,t,n} - m_{r,p,t,n} + \cdot \underline{A}_{r,p,t,n}\right) + \right] + + \text{with slope }m_{r,p,t,n} = \dfrac{\overline{C}_{r,p,t,n} - + \underline{C}_{r,p,t,n}}{\overline{A}_{r,p,t,n} - \underline{A}_{r,p,t,n}} + + Note the terms in this summation are only non-zero for the active segment. + + This cost is then discounted and amortised using the same + :func:`~temoa.components.costs.fixed_or_variable_cost` function as for + cost_variable in the base model. The result is added to :code:`total_cost` + once per cluster-period combination in + :math:`\Theta_{\text{cost\_variable\_eos\_period\_rpt}}`. + """ + return quicksum( + cost_variable_eos_segment_cost( + model, + r, + p, + t, + n, + model.v_cost_variable_eos_activity[r, p, t, n], + model.v_cost_variable_eos_segment_binary[r, p, t, n], + ) + for n in model.cost_variable_eos_segments[r, p, t] + ) + + +def total_cost(model: EOSModel) -> None: + """ + Discounted fixed costs for all EOS clusters in the planning horizon + """ + + p_0 = min(model.time_optimize) + global_discount_rate = value(model.global_discount_rate) + + total_cost = quicksum( + fixed_or_variable_cost( + 1, + period_cost(model, r, p, t), + value(model.period_length[p]), + global_discount_rate, + p_0, + p=p, + ) + for r, p, t in model.cost_variable_eos_period_rpt + ) + + # Append to total cost objective + model.total_cost.set_value(cast('ObjectiveData', model.total_cost).expr + total_cost) diff --git a/temoa/extensions/economies_of_scale/core/__init__.py b/temoa/extensions/economies_of_scale/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/economies_of_scale/core/model.py b/temoa/extensions/economies_of_scale/core/model.py new file mode 100644 index 000000000..82c0d0e27 --- /dev/null +++ b/temoa/extensions/economies_of_scale/core/model.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pyomo.environ import ( + Any, + Binary, + BuildAction, + Constraint, + Integers, + NonNegativeReals, + Param, + Set, + Var, +) + +import temoa.extensions.economies_of_scale.components.cost_fixed_eos as cost_fixed_eos +import temoa.extensions.economies_of_scale.components.cost_invest_eos as cost_invest_eos +import temoa.extensions.economies_of_scale.components.cost_variable_eos as cost_variable_eos + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.types.core_types import Period, Region, Technology + + class EOSModel(TemoaModel): + """Inherits the base TemoaModel class and declares EOS extension + components for type checking/hinting purposes""" + + # Param (data from database table) + cost_invest_eos: Param + cost_fixed_eos: Param + cost_variable_eos: Param + + # Primitive components (instantiation helpers) + cost_invest_eos_segments: dict[tuple[Region, Technology], set[int]] + cost_invest_eos_reference_process: dict[ + tuple[Region, Period, Technology], tuple[Region, Technology] + ] + cost_fixed_eos_segments: dict[tuple[Region, Period, Technology], set[int]] + cost_variable_eos_segments: dict[tuple[Region, Period, Technology], set[int]] + + # Sets (indexing sets for variables and constraints) + cost_invest_eos_rtn: Set + cost_invest_eos_segment_rptn: Set + cost_invest_eos_period_rpt: Set + cost_fixed_eos_rptn: Set + cost_variable_eos_rptn: Set + cost_fixed_eos_period_rpt: Set + cost_variable_eos_period_rpt: Set + + # Instantiation build actions + append_cost_invest_eos_to_cost_invest_rtv: BuildAction + initialize_cost_invest_eos: BuildAction + append_cost_invest_eos_to_total_cost: BuildAction + initialize_cost_fixed_eos: BuildAction + append_cost_fixed_eos_to_total_cost: BuildAction + initialize_cost_variable_eos: BuildAction + append_cost_variable_eos_to_total_cost: BuildAction + + # Decision variables + v_cost_invest_eos_cumulative_capacity: Var + v_cost_invest_eos_segment_binary: Var + v_cost_fixed_eos_capacity: Var + v_cost_fixed_eos_segment_binary: Var + v_cost_variable_eos_activity: Var + v_cost_variable_eos_segment_binary: Var + + # Constraints + cost_invest_eos_segment_binary_constraint: Constraint + cost_invest_eos_capacity_lower_bound_constraint: Constraint + cost_invest_eos_capacity_upper_bound_constraint: Constraint + cost_invest_eos_cumulative_capacity_constraint: Constraint + cost_fixed_eos_segment_binary_constraint: Constraint + cost_fixed_eos_capacity_lower_bound_constraint: Constraint + cost_fixed_eos_capacity_upper_bound_constraint: Constraint + cost_fixed_eos_capacity_constraint: Constraint + cost_variable_eos_segment_binary_constraint: Constraint + cost_variable_eos_activity_lower_bound_constraint: Constraint + cost_variable_eos_activity_upper_bound_constraint: Constraint + cost_variable_eos_activity_constraint: Constraint + + +def register_early_eos_components(model: TemoaModel) -> None: + """Build cost_invest_eos components that must be instantiated before loan parameters.""" + if 'eos' not in model.enabled_extensions: + return + m = cast('EOSModel', model) + + m.cost_invest_eos_rtn = Set( + within=model.regional_global_indices * model.tech_or_group * Integers + ) + m.cost_invest_eos = Param(m.cost_invest_eos_rtn, domain=Any) + + m.cost_invest_eos_segments = {} + m.cost_invest_eos_reference_process = {} + + m.cost_invest_eos_segment_rptn = Set( + dimen=4, initialize=cost_invest_eos.cost_invest_eos_cumulative_capacity_indices + ) + m.cost_invest_eos_period_rpt = Set( + dimen=3, initialize=cost_invest_eos.cost_invest_eos_period_cost_indices + ) + + m.append_cost_invest_eos_to_cost_invest_rtv = BuildAction( + rule=cost_invest_eos.append_cost_invest_eos_rtv + ) + + +def register_model_components(model: TemoaModel) -> None: + """Build remaining components that can be instantiated at the end of model instantiation""" + m = cast('EOSModel', model) + + # --- cost_invest_eos --- + m.initialize_cost_invest_eos = BuildAction(rule=cost_invest_eos.initialize_cost_invest_eos) + + m.v_cost_invest_eos_cumulative_capacity = Var( + m.cost_invest_eos_segment_rptn, domain=NonNegativeReals, initialize=0 + ) + m.v_cost_invest_eos_segment_binary = Var( + m.cost_invest_eos_segment_rptn, domain=Binary, initialize=0 + ) + + m.cost_invest_eos_segment_binary_constraint = Constraint( + m.cost_invest_eos_period_rpt, + rule=cost_invest_eos.cost_invest_eos_segment_binary_constraint, + ) + m.cost_invest_eos_cumulative_capacity_constraint = Constraint( + m.cost_invest_eos_period_rpt, + rule=cost_invest_eos.cost_invest_eos_cumulative_capacity_constraint, + ) + m.cost_invest_eos_capacity_lower_bound_constraint = Constraint( + m.cost_invest_eos_segment_rptn, + rule=cost_invest_eos.cost_invest_eos_capacity_lower_bound_constraint, + ) + m.cost_invest_eos_capacity_upper_bound_constraint = Constraint( + m.cost_invest_eos_segment_rptn, + rule=cost_invest_eos.cost_invest_eos_capacity_upper_bound_constraint, + ) + + m.append_cost_invest_eos_to_total_cost = BuildAction(rule=cost_invest_eos.total_cost) + + # --- cost_fixed_eos --- + m.cost_fixed_eos_rptn = Set( + within=model.regional_global_indices * model.time_optimize * model.tech_or_group * Integers + ) + m.cost_fixed_eos = Param(m.cost_fixed_eos_rptn, domain=Any) + + m.cost_fixed_eos_segments = {} + + m.cost_fixed_eos_period_rpt = Set(dimen=3, initialize=cost_fixed_eos.period_cost_indices) + + m.initialize_cost_fixed_eos = BuildAction(rule=cost_fixed_eos.initialize_components) + + m.v_cost_fixed_eos_capacity = Var(m.cost_fixed_eos_rptn, domain=NonNegativeReals, initialize=0) + m.v_cost_fixed_eos_segment_binary = Var(m.cost_fixed_eos_rptn, domain=Binary, initialize=0) + + m.cost_fixed_eos_segment_binary_constraint = Constraint( + m.cost_fixed_eos_period_rpt, rule=cost_fixed_eos.cost_fixed_eos_segment_binary_constraint + ) + m.cost_fixed_eos_capacity_constraint = Constraint( + m.cost_fixed_eos_period_rpt, rule=cost_fixed_eos.cost_fixed_eos_capacity_constraint + ) + m.cost_fixed_eos_capacity_lower_bound_constraint = Constraint( + m.cost_fixed_eos_rptn, + rule=cost_fixed_eos.cost_fixed_eos_capacity_lower_bound_constraint, + ) + m.cost_fixed_eos_capacity_upper_bound_constraint = Constraint( + m.cost_fixed_eos_rptn, + rule=cost_fixed_eos.cost_fixed_eos_capacity_upper_bound_constraint, + ) + + # -- cost_variable_eos --- + m.cost_variable_eos_segments = {} + + m.cost_variable_eos_rptn = Set( + within=model.regional_global_indices * model.time_optimize * model.tech_or_group * Integers + ) + m.cost_variable_eos = Param(m.cost_variable_eos_rptn, domain=Any) + + m.cost_variable_eos_period_rpt = Set(dimen=3, initialize=cost_variable_eos.period_cost_indices) + + m.initialize_cost_variable_eos = BuildAction(rule=cost_variable_eos.initialize_components) + + m.v_cost_variable_eos_activity = Var( + m.cost_variable_eos_rptn, domain=NonNegativeReals, initialize=0 + ) + m.v_cost_variable_eos_segment_binary = Var( + m.cost_variable_eos_rptn, domain=Binary, initialize=0 + ) + + m.cost_variable_eos_segment_binary_constraint = Constraint( + m.cost_variable_eos_period_rpt, + rule=cost_variable_eos.cost_variable_eos_segment_binary_constraint, + ) + m.cost_variable_eos_activity_constraint = Constraint( + m.cost_variable_eos_period_rpt, + rule=cost_variable_eos.cost_variable_eos_activity_constraint, + ) + m.cost_variable_eos_activity_lower_bound_constraint = Constraint( + m.cost_variable_eos_rptn, + rule=cost_variable_eos.cost_variable_eos_activity_lower_bound_constraint, + ) + m.cost_variable_eos_activity_upper_bound_constraint = Constraint( + m.cost_variable_eos_rptn, + rule=cost_variable_eos.cost_variable_eos_activity_upper_bound_constraint, + ) + + m.append_cost_variable_eos_to_total_cost = BuildAction(rule=cost_variable_eos.total_cost) diff --git a/temoa/extensions/economies_of_scale/data_manifest.py b/temoa/extensions/economies_of_scale/data_manifest.py new file mode 100644 index 000000000..a74bd11d3 --- /dev/null +++ b/temoa/extensions/economies_of_scale/data_manifest.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from temoa.data_io.loader_manifest import LoadItem + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.economies_of_scale.core.model import EOSModel + + +def build_manifest_items(model: TemoaModel) -> list[LoadItem]: + m = cast('EOSModel', model) + return [ + LoadItem( + component=m.cost_invest_eos, + table='cost_invest_eos', + columns=[ + 'region', + 'tech_or_group', + 'segment', + 'capacity_lower', + 'capacity_upper', + 'cost_lower', + 'cost_upper', + ], + index_length=3, + validator_name='viable_rt', + validation_map=(0, 1), + is_table_required=False, + is_period_filtered=False, + index_set=m.cost_invest_eos_rtn, + ), + LoadItem( + component=m.cost_fixed_eos, + table='cost_fixed_eos', + columns=[ + 'region', + 'period', + 'tech_or_group', + 'segment', + 'capacity_lower', + 'capacity_upper', + 'cost_lower', + 'cost_upper', + ], + index_length=4, + validator_name='viable_rpt', + validation_map=(0, 1, 2), + is_table_required=False, + is_period_filtered=True, + index_set=m.cost_fixed_eos_rptn, + ), + LoadItem( + component=m.cost_variable_eos, + table='cost_variable_eos', + columns=[ + 'region', + 'period', + 'tech_or_group', + 'segment', + 'activity_lower', + 'activity_upper', + 'cost_lower', + 'cost_upper', + ], + index_length=4, + validator_name='viable_rpt', + validation_map=(0, 1, 2), + is_table_required=False, + is_period_filtered=True, + index_set=m.cost_variable_eos_rptn, + ), + ] diff --git a/temoa/extensions/economies_of_scale/extension.py b/temoa/extensions/economies_of_scale/extension.py new file mode 100644 index 000000000..c0b5d8cf5 --- /dev/null +++ b/temoa/extensions/economies_of_scale/extension.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from pathlib import Path + +from temoa.extensions.economies_of_scale.core.model import register_model_components +from temoa.extensions.economies_of_scale.data_manifest import build_manifest_items +from temoa.extensions.framework import ExtensionSpec + +EOS_EXTENSION = ExtensionSpec( + extension_id='eos', + owned_tables=('cost_invest_eos', 'cost_fixed_eos', 'cost_variable_eos'), + regional_group_tables={ + 'cost_invest_eos': 'region', + 'cost_fixed_eos': 'region', + 'cost_variable_eos': 'region', + }, + register_model_components=register_model_components, + build_manifest_items=build_manifest_items, + schema_sql_path=str(Path(__file__).parent / 'tables.sql'), + fail_if_tables_populated_when_disabled=True, +) diff --git a/temoa/extensions/economies_of_scale/tables.sql b/temoa/extensions/economies_of_scale/tables.sql new file mode 100644 index 000000000..2efd7fa18 --- /dev/null +++ b/temoa/extensions/economies_of_scale/tables.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS cost_invest_eos +( + region TEXT NOT NULL, + tech_or_group TEXT NOT NULL, + segment INTEGER NOT NULL, + capacity_lower REAL NOT NULL, + capacity_upper REAL NOT NULL, + cost_lower REAL NOT NULL, + cost_upper REAL NOT NULL, + units TEXT, + notes TEXT, + CHECK (capacity_lower >= 0), + CHECK (capacity_upper >= 0), + CHECK (cost_lower >= 0), + CHECK (cost_upper >= 0), + PRIMARY KEY (region, tech_or_group, segment) +); +CREATE TABLE IF NOT EXISTS cost_fixed_eos +( + region TEXT NOT NULL, + period INTEGER NOT NULL, + tech_or_group TEXT NOT NULL, + segment INTEGER NOT NULL, + capacity_lower REAL NOT NULL, + capacity_upper REAL NOT NULL, + cost_lower REAL NOT NULL, + cost_upper REAL NOT NULL, + units TEXT, + notes TEXT, + CHECK (capacity_lower >= 0), + CHECK (capacity_upper >= 0), + CHECK (cost_lower >= 0), + CHECK (cost_upper >= 0), + PRIMARY KEY (region, period, tech_or_group, segment) +); +CREATE TABLE IF NOT EXISTS cost_variable_eos +( + region TEXT NOT NULL, + period INTEGER NOT NULL, + tech_or_group TEXT NOT NULL, + segment INTEGER NOT NULL, + activity_lower REAL NOT NULL, + activity_upper REAL NOT NULL, + cost_lower REAL NOT NULL, + cost_upper REAL NOT NULL, + units TEXT, + notes TEXT, + CHECK (activity_lower >= 0), + CHECK (activity_upper >= 0), + CHECK (cost_lower >= 0), + CHECK (cost_upper >= 0), + PRIMARY KEY (region, period, tech_or_group, segment) +); diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py index 179ddf097..7927e3bf3 100644 --- a/temoa/extensions/framework.py +++ b/temoa/extensions/framework.py @@ -57,9 +57,10 @@ def normalize_extension_ids(extension_ids: Sequence[str | object] | None) -> tup def get_known_extension_specs() -> dict[str, ExtensionSpec]: """Return all extension specs known to this installation.""" from temoa.extensions.discrete_capacity.extension import DISCRETE_CAPACITY_EXTENSION + from temoa.extensions.economies_of_scale.extension import EOS_EXTENSION from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION - specs = [GROWTH_RATES_EXTENSION, DISCRETE_CAPACITY_EXTENSION] + specs = [GROWTH_RATES_EXTENSION, DISCRETE_CAPACITY_EXTENSION, EOS_EXTENSION] return {spec.extension_id: spec for spec in specs} From feaa99e4cac249dea46cb4092e5e296b6150d8cd Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 14:44:44 -0400 Subject: [PATCH 11/23] Enable output cost results for economies of scale extension Signed-off-by: Davey Elder --- temoa/__init__.py | 4 +- temoa/_internal/exchange_tech_cost_ledger.py | 6 +- temoa/_internal/table_data_puller.py | 130 ++---------- temoa/components/costs.py | 105 +++++++++- temoa/db_schema/temoa_schema_v4.sql | 5 +- .../economies_of_scale/core/data_puller.py | 187 ++++++++++++++++++ tests/test_table_writer.py | 12 +- 7 files changed, 325 insertions(+), 124 deletions(-) create mode 100644 temoa/extensions/economies_of_scale/core/data_puller.py diff --git a/temoa/__init__.py b/temoa/__init__.py index 67ef283bd..75a8d2419 100644 --- a/temoa/__init__.py +++ b/temoa/__init__.py @@ -19,7 +19,6 @@ solve_instance, ) from temoa._internal.table_data_puller import ( - loan_costs, poll_capacity_results, poll_cost_results, poll_emissions, @@ -27,6 +26,7 @@ ) from temoa._internal.table_writer import TableWriter from temoa._internal.temoa_sequencer import TemoaSequencer +from temoa.components.costs import poll_loan_costs from temoa.core.config import TemoaConfig from temoa.core.model import TemoaModel from temoa.core.modes import TemoaMode @@ -48,7 +48,7 @@ 'solve_instance', 'handle_results', 'save_lp', - 'loan_costs', + 'poll_loan_costs', 'poll_capacity_results', 'poll_emissions', 'poll_flow_results', diff --git a/temoa/_internal/exchange_tech_cost_ledger.py b/temoa/_internal/exchange_tech_cost_ledger.py index 12b170f5f..361ad8dd1 100644 --- a/temoa/_internal/exchange_tech_cost_ledger.py +++ b/temoa/_internal/exchange_tech_cost_ledger.py @@ -57,7 +57,11 @@ def add_cost_record( if not r1 and r2: raise ValueError(f'problem splitting region-region: {link}') # add to the "seen" records for appropriate cost type - self.cost_records[cost_type][r1, r2, tech, vintage, period] = cost + key = (r1, r2, tech, vintage, period) + if key in self.cost_records[cost_type]: + self.cost_records[cost_type][key] += cost + else: + self.cost_records[cost_type][key] = cost def get_use_ratio( self, exporter: Region, importer: Region, period: Period, tech: Technology, vintage: Vintage diff --git a/temoa/_internal/table_data_puller.py b/temoa/_internal/table_data_puller.py index 832d3557c..3973832a7 100644 --- a/temoa/_internal/table_data_puller.py +++ b/temoa/_internal/table_data_puller.py @@ -20,6 +20,7 @@ from temoa._internal.exchange_tech_cost_ledger import CostType, ExchangeTechCostLedger from temoa.components import costs from temoa.components.utils import get_variable_efficiency +from temoa.extensions.economies_of_scale.core import data_puller as eos from temoa.types.model_types import EI, FI, SLI, CapData, FlowType if TYPE_CHECKING: @@ -340,30 +341,30 @@ def poll_cost_results( cap = value(model.v_new_capacity[r, t, v]) if abs(cap) < epsilon: continue + cost_invest = value(model.cost_invest[r, t, v]) loan_life = value(loan_lifetime_process[r, t, v]) - loan_rate = value(model.loan_rate[r, t, v]) + loan_annualize = value(model.loan_annualize[r, t, v]) if model.is_survival_curve_process[r, t, v]: - model_loan_cost, undiscounted_cost = loan_costs_survival_curve( + discounted_cost, undiscounted_cost = costs.poll_loan_costs_survival_curve( model=model, r=r, t=t, v=v, - loan_rate=loan_rate, loan_life=loan_life, + loan_annualize=loan_annualize, capacity=cap, - invest_cost=value(model.cost_invest[r, t, v]), + invest_cost=cost_invest, p_0=p_0_true, p_e=p_e, global_discount_rate=global_discount_rate, - vintage=v, ) else: - model_loan_cost, undiscounted_cost = loan_costs( - loan_rate=loan_rate, + discounted_cost, undiscounted_cost = costs.poll_loan_costs( loan_life=loan_life, + loan_annualize=loan_annualize, capacity=cap, - invest_cost=value(model.cost_invest[r, t, v]), + invest_cost=cost_invest, process_life=value(model.lifetime_process[r, t, v]), p_0=p_0_true, p_e=p_e, @@ -377,7 +378,7 @@ def poll_cost_results( period=v, tech=t, vintage=v, - cost=model_loan_cost, + cost=discounted_cost, cost_type=CostType.D_INVEST, ) exchange_costs.add_cost_record( @@ -392,7 +393,7 @@ def poll_cost_results( # The period `p` for an investment cost is its vintage `v`. key = (cast('Region', r), cast('Period', v), cast('Technology', t), cast('Vintage', v)) entries[key].update( - {CostType.D_INVEST: model_loan_cost, CostType.INVEST: undiscounted_cost} + {CostType.D_INVEST: discounted_cost, CostType.INVEST: undiscounted_cost} ) for r, p, t, v in model.cost_fixed.sparse_keys(): @@ -489,107 +490,18 @@ def poll_cost_results( CostType.VARIABLE: float(value(undiscounted_var_cost)), } ) - exchange_entries = exchange_costs.get_entries() - return entries, exchange_entries - -def loan_costs( - loan_rate: float, # this is referred to as loan_rate in parameters - loan_life: float, - capacity: float, - invest_cost: float, - process_life: int, - p_0: int, - p_e: int, - global_discount_rate: float, - vintage: int, - **kwargs: object, -) -> tuple[float, float]: - """ - Calculate Loan costs by calling the loan annualize and loan cost functions in temoa_rules - :return: tuple of [model-view discounted cost, un-discounted annuity] - """ - # dev note: this is a passthrough function. Sole intent is to use the EXACT formula the - # model uses for these costs - loan_ar = costs.pv_to_annuity(rate=loan_rate, periods=int(loan_life)) - model_ic = costs.loan_cost( - capacity, - invest_cost, - loan_annualize=float(value(loan_ar)), - lifetime_loan_process=loan_life, - lifetime_process=process_life, - p_0=p_0, - p_e=p_e, - global_discount_rate=global_discount_rate, - vintage=vintage, - ) - # Override the GDR to get the undiscounted value - global_discount_rate = 0 - undiscounted_cost = costs.loan_cost( - capacity, - invest_cost, - loan_annualize=float(value(loan_ar)), - lifetime_loan_process=loan_life, - lifetime_process=process_life, - p_0=p_0, - p_e=p_e, - global_discount_rate=global_discount_rate, - vintage=vintage, + # Get nonlinear costs from the EOS extension, if active + eos.poll_costs( + model=model, + exchange_costs=exchange_costs, + entries=entries, + p_0=p_0_true, + epsilon=epsilon, ) - return float(value(model_ic)), float(value(undiscounted_cost)) - - -def loan_costs_survival_curve( - model: TemoaModel, - r: Region, - t: Technology, - v: Vintage, - loan_rate: float, # this is referred to as loan_rate in parameters - loan_life: float, - capacity: float, - invest_cost: float, - p_0: Period, - p_e: Period, - global_discount_rate: float, - vintage: Vintage, - **kwargs: object, -) -> tuple[float, float]: - """ - Calculate Loan costs by calling the loan annualize and loan cost functions in temoa_rules - :return: tuple of [model-view discounted cost, un-discounted annuity] - """ - # dev note: this is a passthrough function. Sole intent is to use the EXACT formula the - # model uses for these costs - loan_ar = costs.pv_to_annuity(rate=loan_rate, periods=int(loan_life)) - model_ic = costs.loan_cost_survival_curve( - model, - r, - t, - v, - capacity, - invest_cost, - loan_annualize=float(value(loan_ar)), - lifetime_loan_process=loan_life, - p_0=p_0, - p_e=p_e, - global_discount_rate=global_discount_rate, - ) - # Override the GDR to get the undiscounted value - global_discount_rate = 0 - undiscounted_cost = costs.loan_cost_survival_curve( - model, - r, - t, - v, - capacity, - invest_cost, - loan_annualize=float(value(loan_ar)), - lifetime_loan_process=loan_life, - p_0=p_0, - p_e=p_e, - global_discount_rate=global_discount_rate, - ) - return float(value(model_ic)), float(value(undiscounted_cost)) + + exchange_entries = exchange_costs.get_entries() + return entries, exchange_entries def poll_emissions( diff --git a/temoa/components/costs.py b/temoa/components/costs.py index 7814c2c4e..9edaf1a23 100644 --- a/temoa/components/costs.py +++ b/temoa/components/costs.py @@ -124,7 +124,7 @@ def lifetime_loan_process_indices(model: TemoaModel) -> set[tuple[Region, Techno def loan_cost( capacity: float | Var | ComponentData, - invest_cost: float, + invest_cost: float | Expression, loan_annualize: float, lifetime_loan_process: float | int, lifetime_process: float, @@ -190,7 +190,7 @@ def loan_cost_survival_curve( t: Technology, v: Vintage, capacity: float | Var | ComponentData, - invest_cost: float, + invest_cost: float | Expression, loan_annualize: float, lifetime_loan_process: float | int, p_0: Period, @@ -266,7 +266,7 @@ def loan_cost_survival_curve( def fixed_or_variable_cost( cap_or_flow: float | Var | ComponentData, - cost_factor: float, + cost_factor: float | Expression, cost_years: float | ComponentData, global_discount_rate: float | None, p_0: float, @@ -715,3 +715,102 @@ def param_loan_annualize_rule( lln = value(model.loan_lifetime_process[r, t, v]) annualized_rate = pv_to_annuity(dr, lln) return annualized_rate + + +# ============================================================================ +# DATA PULLING UTILITIES +# ============================================================================ + + +def poll_loan_costs( + loan_life: float, + loan_annualize: float, + capacity: float, + invest_cost: float, + process_life: int, + p_0: int, + p_e: int, + global_discount_rate: float, + vintage: int, + **kwargs: object, +) -> tuple[float, float]: + """ + Calculate Loan costs by calling the loan annualize and loan cost functions in temoa_rules + :return: tuple of [model-view discounted cost, un-discounted annuity] + """ + # dev note: this is a passthrough function. Sole intent is to use the EXACT formula the + # model uses for these costs + discounted_cost = loan_cost( + capacity, + invest_cost, + loan_annualize=loan_annualize, + lifetime_loan_process=loan_life, + lifetime_process=process_life, + p_0=p_0, + p_e=p_e, + global_discount_rate=global_discount_rate, + vintage=vintage, + ) + # Override the GDR to get the undiscounted value + undiscounted_cost = loan_cost( + capacity, + invest_cost, + loan_annualize=loan_annualize, + lifetime_loan_process=loan_life, + lifetime_process=process_life, + p_0=p_0, + p_e=p_e, + global_discount_rate=0, + vintage=vintage, + ) + return float(value(discounted_cost)), float(value(undiscounted_cost)) + + +def poll_loan_costs_survival_curve( + model: TemoaModel, + r: Region, + t: Technology, + v: Vintage, + loan_life: float, + loan_annualize: float, + capacity: float, + invest_cost: float, + p_0: Period, + p_e: Period, + global_discount_rate: float, + **kwargs: object, +) -> tuple[float, float]: + """ + Calculate Loan costs by calling the loan annualize and loan cost functions in temoa_rules + :return: tuple of [model-view discounted cost, un-discounted annuity] + """ + # dev note: this is a passthrough function. Sole intent is to use the EXACT formula the + # model uses for these costs + discounted_cost = loan_cost_survival_curve( + model, + r, + t, + v, + capacity, + invest_cost, + loan_annualize, + lifetime_loan_process=loan_life, + p_0=p_0, + p_e=p_e, + global_discount_rate=global_discount_rate, + ) + # Override the GDR to get the undiscounted value + undiscounted_cost = loan_cost_survival_curve( + model, + r, + t, + v, + capacity, + invest_cost, + loan_annualize, + lifetime_loan_process=loan_life, + p_0=p_0, + p_e=p_e, + global_discount_rate=0, + ) + return float(value(discounted_cost)), float(value(undiscounted_cost)) diff --git a/temoa/db_schema/temoa_schema_v4.sql b/temoa/db_schema/temoa_schema_v4.sql index 69eef34a5..c97f741b6 100644 --- a/temoa/db_schema/temoa_schema_v4.sql +++ b/temoa/db_schema/temoa_schema_v4.sql @@ -922,7 +922,7 @@ CREATE TABLE IF NOT EXISTS output_cost region TEXT, sector TEXT REFERENCES sector_label (sector), period INTEGER REFERENCES time_period (period), - tech TEXT REFERENCES technology (tech), + tech TEXT, vintage INTEGER REFERENCES time_period (period), d_invest REAL, d_fixed REAL, @@ -934,8 +934,7 @@ CREATE TABLE IF NOT EXISTS output_cost emiss REAL, units TEXT, PRIMARY KEY (scenario, region, period, tech, vintage), - FOREIGN KEY (vintage) REFERENCES time_period (period), - FOREIGN KEY (tech) REFERENCES technology (tech) + FOREIGN KEY (vintage) REFERENCES time_period (period) ); CREATE TABLE IF NOT EXISTS time_season diff --git a/temoa/extensions/economies_of_scale/core/data_puller.py b/temoa/extensions/economies_of_scale/core/data_puller.py new file mode 100644 index 000000000..2713483e8 --- /dev/null +++ b/temoa/extensions/economies_of_scale/core/data_puller.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pyomo.environ import value + +from temoa._internal.exchange_tech_cost_ledger import CostType, ExchangeTechCostLedger +from temoa.components import costs +from temoa.extensions.economies_of_scale.components import ( + cost_fixed_eos, + cost_invest_eos, + cost_variable_eos, +) + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.economies_of_scale.core.model import EOSModel + from temoa.types import Period, Region, Technology, Vintage + +logger = logging.getLogger(__name__) + + +# --- Poll cost results --- +def poll_costs( + model: TemoaModel, + exchange_costs: ExchangeTechCostLedger, + entries: dict[tuple[Region, Period, Technology, Vintage], dict[CostType, float]], + p_0: Period, + epsilon: float, +) -> None: + """ + Poll the fixed and variable costs for all EOS clusters in the planning horizon + and add them to the cost entries for the model. + """ + if 'eos' not in model.enabled_extensions: + return + model = cast('EOSModel', model) + + global_discount_rate = value(model.global_discount_rate) + + for r, p, t in model.cost_invest_eos_period_rpt: + cost = value(cost_invest_eos.period_cost(model, r, p, t)) + if cost < epsilon: + continue + + # gather details... + r0, t0 = model.cost_invest_eos_reference_process[r, p, t] + loan_life = value(model.loan_lifetime_process[r0, t0, p]) + loan_annualize = value(model.loan_annualize[r0, t0, p]) + life = value(model.lifetime_process[r0, t0, p]) + + if model.is_survival_curve_process[r0, t0, p]: + discounted_cost, undiscounted_cost = costs.poll_loan_costs_survival_curve( + model=model, + r=r0, + t=t0, + v=p, + loan_life=loan_life, + loan_annualize=loan_annualize, + capacity=1, + invest_cost=cost, + p_0=p_0, + p_e=model.time_future.last(), + global_discount_rate=global_discount_rate, + ) + else: + discounted_cost, undiscounted_cost = costs.poll_loan_costs( + loan_life=loan_life, + loan_annualize=loan_annualize, + capacity=1, + invest_cost=cost, + process_life=life, + p_0=p_0, + p_e=model.time_future.last(), + global_discount_rate=global_discount_rate, + vintage=p, + ) + + # screen for linked region... + if '-' in r: + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=discounted_cost, + cost_type=CostType.D_INVEST, + ) + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=undiscounted_cost, + cost_type=CostType.INVEST, + ) + else: + # The period `p` for an investment cost is its vintage `v`. + key = (cast('Region', r), cast('Period', p), cast('Technology', t), cast('Vintage', p)) + if CostType.INVEST in entries.get(key, {}): + entries[key][CostType.D_INVEST] += discounted_cost + entries[key][CostType.INVEST] += undiscounted_cost + else: + entries[key].update( + {CostType.D_INVEST: discounted_cost, CostType.INVEST: undiscounted_cost} + ) + + for r, p, t in model.cost_fixed_eos_period_rpt: + fixed_cost = value(cost_fixed_eos.period_cost(model, r, p, t)) + if fixed_cost < epsilon: + continue + + undiscounted_fixed_cost = fixed_cost * value(model.period_length[p]) + discounted_fixed_cost = costs.fixed_or_variable_cost( + 1, + fixed_cost, + value(model.period_length[p]), + global_discount_rate=global_discount_rate, + p_0=p_0, + p=p, + ) + + if '-' in r: + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=float(value(discounted_fixed_cost)), + cost_type=CostType.D_FIXED, + ) + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=float(undiscounted_fixed_cost), + cost_type=CostType.FIXED, + ) + else: + entries[r, p, t, p].update( + { + CostType.D_FIXED: float(value(discounted_fixed_cost)), + CostType.FIXED: float(undiscounted_fixed_cost), + } + ) + + for r, p, t in model.cost_fixed_eos_period_rpt: + variable_cost = value(cost_variable_eos.period_cost(model, r, p, t)) + if variable_cost < epsilon: + continue + + undiscounted_variable_cost = variable_cost * value(model.period_length[p]) + discounted_variable_cost = costs.fixed_or_variable_cost( + 1, + variable_cost, + value(model.period_length[p]), + global_discount_rate=global_discount_rate, + p_0=p_0, + p=p, + ) + + if '-' in r: + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=float(value(discounted_variable_cost)), + cost_type=CostType.D_VARIABLE, + ) + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=p, + cost=float(undiscounted_variable_cost), + cost_type=CostType.VARIABLE, + ) + else: + entries[r, p, t, p].update( + { + CostType.D_VARIABLE: float(value(discounted_variable_cost)), + CostType.VARIABLE: float(undiscounted_variable_cost), + } + ) diff --git a/tests/test_table_writer.py b/tests/test_table_writer.py index 3f30d2e1f..646c0a47d 100644 --- a/tests/test_table_writer.py +++ b/tests/test_table_writer.py @@ -2,7 +2,7 @@ import pytest -from temoa._internal.table_data_puller import loan_costs +from temoa.components.costs import poll_loan_costs class LoanCostInput(TypedDict): @@ -31,7 +31,7 @@ class LoanCostTestCase(TypedDict): 'capacity': 100_000.0, # units 'invest_cost': 1.0, # $/unit of capacity 'loan_life': 40.0, - 'loan_rate': 0.10, + 'loan_annualize': 0.102259414, 'global_discount_rate': 0.000000000001, 'process_life': 40, 'p_0': 2020, # the "myopic base year" to which all prices are discounted @@ -47,7 +47,7 @@ class LoanCostTestCase(TypedDict): 'capacity': 100_000.0, 'invest_cost': 1.0, 'loan_life': 40.0, - 'loan_rate': 0.08, + 'loan_annualize': 0.083860162, 'global_discount_rate': 0.05, 'process_life': 50, 'p_0': 2020, @@ -65,7 +65,7 @@ class LoanCostTestCase(TypedDict): 'capacity': 100_000.0, # units 'invest_cost': 1.0, # $/unit of capacity 'loan_life': 40.0, - 'loan_rate': 0.10, + 'loan_annualize': 0.102259414, 'global_discount_rate': 0, 'process_life': 40, 'p_0': 2020, # the "myopic base year" to which all prices are discounted @@ -84,7 +84,7 @@ def test_loan_costs(test_case: LoanCostTestCase) -> None: Test the loan cost calculations """ # we will test with a 1% error to accommodate the approximation of GDR=0 - model_cost, undiscounted_cost = loan_costs(**test_case['input']) + model_cost, undiscounted_cost = poll_loan_costs(**test_case['input']) assert model_cost == pytest.approx(test_case['expected_model_cost'], rel=0.01) assert undiscounted_cost == pytest.approx(test_case['expected_undiscounted_cost'], rel=0.01) @@ -99,6 +99,6 @@ def test_loan_costs_with_zero_gdr(test_case: LoanCostTestCase) -> None: Test the formula with zero for GDR to make sure it is handled correctly. The formula risks division by zero if this is not correct. """ - model_cost, undiscounted_cost = loan_costs(**test_case['input']) + model_cost, undiscounted_cost = poll_loan_costs(**test_case['input']) assert model_cost == pytest.approx(test_case['expected_model_cost'], abs=0.01) assert undiscounted_cost == pytest.approx(test_case['expected_undiscounted_cost'], abs=0.01) From f9c02f65538d500d4e75f3cb37c44c07943b5ae1 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 14:44:57 -0400 Subject: [PATCH 12/23] Create documentation for economies of scale extension Signed-off-by: Davey Elder --- docs/source/extensions.rst | 1 + docs/source/extensions/eos.rst | 306 +++++++++++ docs/source/images/eos_cost_curve.png | Bin 0 -> 75291 bytes docs/source/images/eos_cost_curve.svg | 702 ++++++++++++++++++++++++++ docs/source/images/etl_cost_curve.png | Bin 0 -> 75182 bytes docs/source/images/etl_cost_curve.svg | 683 +++++++++++++++++++++++++ scripts/generate_etl_figure.py | 223 ++++++++ 7 files changed, 1915 insertions(+) create mode 100644 docs/source/extensions/eos.rst create mode 100644 docs/source/images/eos_cost_curve.png create mode 100644 docs/source/images/eos_cost_curve.svg create mode 100644 docs/source/images/etl_cost_curve.png create mode 100644 docs/source/images/etl_cost_curve.svg create mode 100644 scripts/generate_etl_figure.py diff --git a/docs/source/extensions.rst b/docs/source/extensions.rst index 06cc597c5..47c0fd7d8 100644 --- a/docs/source/extensions.rst +++ b/docs/source/extensions.rst @@ -245,3 +245,4 @@ as new extensions are added. extensions/growth_rates extensions/discrete_capacity + extensions/eos diff --git a/docs/source/extensions/eos.rst b/docs/source/extensions/eos.rst new file mode 100644 index 000000000..d71d34e59 --- /dev/null +++ b/docs/source/extensions/eos.rst @@ -0,0 +1,306 @@ +.. _extension-eos: + +Economies of Scale (EOS) +======================== + +The **eos** extension adds piecewise-linear cost curves to three cost +types: investment (:code:`cost_invest_eos`), fixed O&M +(:code:`cost_fixed_eos`), and variable O&M (:code:`cost_variable_eos`). +It is disabled by default and enabled per run through configuration: + +.. code-block:: toml + + extensions = ["eos"] + +All three features share the same binary-integer formulation: a set of +contiguous segments, exactly one of which is active per cluster per period. +They differ in what quantity drives the cost curve (cumulative capacity, +available capacity, or activity) and in how the resulting cost is +discounted and added to the objective. + +.. figure:: ../images/eos_cost_curve.png + :align: center + :width: 90% + :alt: EOS piecewise-linear cost curve + + **EOS piecewise-linear cost curve.** The curve is composed of contiguous + segments :math:`n=0,1,2` (three shown here), each with its own quantity + bounds :math:`[\underline{Q}_{n}, \overline{Q}_{n}]` and corresponding + total-cost bounds :math:`[\underline{C}_{n}, \overline{C}_{n}]`. The + slope :math:`m_n` is the marginal cost within segment :math:`n`. In this + period, segment :math:`n=1` is **active** (red shading): the binary + variable is 1, and the quantity variable :math:`\textbf{Q}_p` lies within + its bounds. + +Overview +-------- + +.. list-table:: + :header-rows: 1 + :widths: 22 20 20 38 + + * - Feature + - Table + - Cost type + - Curve driven by + * - :ref:`cost_invest_eos ` + - :code:`cost_invest_eos` + - Investment (:code:`cost_invest`) + - **Cumulative capacity** built up to period :math:`p` (learning curve) + * - :ref:`cost_fixed_eos ` + - :code:`cost_fixed_eos` + - Fixed O&M (:code:`cost_fixed`) + - **Available capacity** in period :math:`p` + * - :ref:`cost_variable_eos ` + - :code:`cost_variable_eos` + - Variable O&M (:code:`cost_variable`) + - **Total activity** (output flow) in period :math:`p` + +Technologies subject to EOS are specified as *clusters* identified by a +region/group label :math:`r` and a technology/group label :math:`t`. +Each cluster has a piecewise-linear cost curve defined by contiguous +segments :math:`n \in N_{r,t}` (for invest) or +:math:`n \in N_{r,p,t}` (for fixed/variable, which are +period-specific). + +.. note:: + + The :code:`tech_or_group` column accepts either an individual technology + name or a technology-group name. When a group is used, all member + technologies share the same cost curve and their contributions to the + driving quantity (capacity or activity) are summed. For + :code:`cost_invest_eos`, all technologies in a cluster must share the + same :code:`lifetime_process`, :code:`lifetime_survival_curve`, + :code:`loan_lifetime_process`, and :code:`loan_rate` in every period + they appear together. + +.. _eos-invest: + +cost_invest_eos — Investment Cost Curve +---------------------------------------- + +Concept +~~~~~~~ + +:code:`cost_invest_eos` replaces (or supplements) the flat per-unit +investment cost for a cluster with a piecewise-linear curve whose marginal +cost changes with *cumulative* installed capacity — a learning-curve +effect. The curve is global across time: each successive period inherits +the capacity built in all previous periods. The incremental investment cost +charged in period :math:`p` is the difference between the curve evaluated at +cumulative capacity up to :math:`p` and the curve evaluated at the end of +the previous period, so that each unit of new capacity is charged at the +marginal cost appropriate to the total deployment level at the time of +construction. + +.. note:: + + The cumulative capacity **includes pre-existing capacity** (from the + :code:`existing_capacity` table), but pre-existing capacity does not + incur an investment cost — it is subtracted out when computing the + incremental period cost. The first segment's lower bound can almost + always be set to 0 regardless of how much capacity already exists. + +.. note:: + + EOS invest processes can carry a flat investment cost via + :code:`cost_invest` in addition to the piecewise curve. + +Table: cost_invest_eos +~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{CIEOS}_{r \in R,\, t \in T,\, n \in \mathbb{Z}^+}` + +The segment index :math:`n` is an integer; segments must be numbered +consecutively and their capacity bounds must be contiguous. All four bound +columns must be non-negative and strictly increasing within a segment. + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 18, 60 + + ":code:`region`", ":math:`r`", "region or region-group label" + ":code:`tech_or_group`", ":math:`t`", "technology or technology-group label" + ":code:`segment`", ":math:`n`", "integer segment index (contiguous)" + ":code:`capacity_lower`", ":math:`\underline{K}_{r,t,n}`", "lower cumulative-capacity bound of the segment" + ":code:`capacity_upper`", ":math:`\overline{K}_{r,t,n}`", "upper cumulative-capacity bound of the segment" + ":code:`cost_lower`", ":math:`\underline{C}_{r,t,n}`", "investment cost at :math:`\underline{K}_{r,t,n}` (same units as :code:`cost_invest`)" + ":code:`cost_upper`", ":math:`\overline{C}_{r,t,n}`", "investment cost at :math:`\overline{K}_{r,t,n}`" + +Sets +~~~~ + +.. csv-table:: + :header: "Set", "Indices", "Description" + :widths: 38, 18, 44 + + ":code:`cost_invest_eos_rtn`", ":math:`(r, t, n)`", "valid cluster–segment combinations; populated directly from the table" + ":code:`cost_invest_eos_segment_rptn`", ":math:`(r, p, t, n)`", "valid cluster–period–segment combinations; derived from :code:`cost_invest_eos_rtn` and :code:`process_vintages`" + ":code:`cost_invest_eos_period_rpt`", ":math:`(r, p, t)`", "projection of :code:`cost_invest_eos_segment_rptn` onto :math:`(r, p, t)`" + +Variables +~~~~~~~~~ + +.. csv-table:: + :header: "Variable", "Domain", "Indices", "Description" + :widths: 36, 12, 24, 28 + + ":math:`\textbf{CIECAP}_{r,p,t,n}` (:code:`v_cost_invest_eos_cumulative_capacity`)", ":math:`\mathbb{R}_{\ge 0}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_invest\_eos\_segment\_rptn}}`", "cumulative capacity assigned to segment :math:`n` of cluster :math:`(r,t)` in period :math:`p`" + ":math:`\textbf{CIEB}_{r,p,t,n}` (:code:`v_cost_invest_eos_segment_binary`)", ":math:`\{0, 1\}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_invest\_eos\_segment\_rptn}}`", "1 if segment :math:`n` is the active segment for cluster :math:`(r,t)` in period :math:`p`" + +Constraints +~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_invest_eos.cost_invest_eos_segment_binary_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_invest_eos.cost_invest_eos_capacity_lower_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_invest_eos.cost_invest_eos_capacity_upper_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_invest_eos.cost_invest_eos_cumulative_capacity_constraint + +Objective Contribution +~~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_invest_eos.period_cost + + +.. _eos-fixed: + +cost_fixed_eos — Fixed O&M Cost Curve +-------------------------------------- + +Concept +~~~~~~~ + +:code:`cost_fixed_eos` replaces (or supplements) the flat per-unit fixed +O&M cost for a cluster with a piecewise-linear curve whose unit cost +changes with *available capacity* in each period independently. Unlike +:code:`cost_invest_eos`, the cost curve is period-specific: segments can +differ from one period to the next, allowing the modeller to represent +O&M cost trends over time separately from capacity-scale effects. + +Table: cost_fixed_eos +~~~~~~~~~~~~~~~~~~~~~ + +:math:`{CFEOS}_{r \in R,\, p \in P,\, t \in T,\, n \in \mathbb{Z}^+}` + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 18, 60 + + ":code:`region`", ":math:`r`", "region or region-group label" + ":code:`period`", ":math:`p`", "model period" + ":code:`tech_or_group`", ":math:`t`", "technology or technology-group label" + ":code:`segment`", ":math:`n`", "integer segment index (contiguous)" + ":code:`capacity_lower`", ":math:`\underline{K}_{r,p,t,n}`", "lower capacity bound of the segment" + ":code:`capacity_upper`", ":math:`\overline{K}_{r,p,t,n}`", "upper capacity bound of the segment" + ":code:`cost_lower`", ":math:`\underline{C}_{r,p,t,n}`", "fixed O&M cost at :math:`\underline{K}_{r,p,t,n}` (same units as :code:`cost_fixed`)" + ":code:`cost_upper`", ":math:`\overline{C}_{r,p,t,n}`", "fixed O&M cost at :math:`\overline{K}_{r,p,t,n}`" + +Sets +~~~~ + +.. csv-table:: + :header: "Set", "Indices", "Description" + :widths: 36, 20, 44 + + ":code:`cost_fixed_eos_rptn`", ":math:`(r, p, t, n)`", "valid cluster–period–segment combinations" + ":code:`cost_fixed_eos_period_rpt`", ":math:`(r, p, t)`", "projection of :code:`cost_fixed_eos_rptn` onto :math:`(r, p, t)`" + +Variables +~~~~~~~~~ + +.. csv-table:: + :header: "Variable", "Domain", "Indices", "Description" + :widths: 36, 12, 24, 28 + + ":math:`\textbf{CFECAP}_{r,p,t,n}` (:code:`v_cost_fixed_eos_capacity`)", ":math:`\mathbb{R}_{\ge 0}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_fixed\_eos\_rptn}}`", "capacity assigned to segment :math:`n` of cluster :math:`(r,p,t)`" + ":math:`\textbf{CFEB}_{r,p,t,n}` (:code:`v_cost_fixed_eos_segment_binary`)", ":math:`\{0, 1\}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_fixed\_eos\_rptn}}`", "1 if segment :math:`n` is active for cluster :math:`(r,p,t)`" + +Constraints +~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_fixed_eos.cost_fixed_eos_segment_binary_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_fixed_eos.cost_fixed_eos_capacity_lower_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_fixed_eos.cost_fixed_eos_capacity_upper_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_fixed_eos.cost_fixed_eos_capacity_constraint + +Objective Contribution +~~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_fixed_eos.period_cost + + +.. _eos-variable: + +cost_variable_eos — Variable O&M Cost Curve +-------------------------------------------- + +Concept +~~~~~~~ + +:code:`cost_variable_eos` replaces (or supplements) the flat per-unit +variable O&M cost for a cluster with a piecewise-linear curve whose unit +cost changes with *total activity* (output flow) in each period +independently. Like :code:`cost_fixed_eos`, the cost curve is +period-specific. This allows the modeller to represent dispatch-scale +economies (e.g. fuel-handling efficiency at high utilisation rates) that +vary over time. + +Table: cost_variable_eos +~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{CVEOS}_{r \in R,\, p \in P,\, t \in T,\, n \in \mathbb{Z}^+}` + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 18, 60 + + ":code:`region`", ":math:`r`", "region or region-group label" + ":code:`period`", ":math:`p`", "model period" + ":code:`tech_or_group`", ":math:`t`", "technology or technology-group label" + ":code:`segment`", ":math:`n`", "integer segment index (contiguous)" + ":code:`activity_lower`", ":math:`\underline{A}_{r,p,t,n}`", "lower activity bound of the segment" + ":code:`activity_upper`", ":math:`\overline{A}_{r,p,t,n}`", "upper activity bound of the segment" + ":code:`cost_lower`", ":math:`\underline{C}_{r,p,t,n}`", "variable O&M cost at :math:`\underline{A}_{r,p,t,n}` (same units as :code:`cost_variable`)" + ":code:`cost_upper`", ":math:`\overline{C}_{r,p,t,n}`", "variable O&M cost at :math:`\overline{A}_{r,p,t,n}`" + +Sets +~~~~ + +.. csv-table:: + :header: "Set", "Indices", "Description" + :widths: 36, 20, 44 + + ":code:`cost_variable_eos_rptn`", ":math:`(r, p, t, n)`", "valid cluster–period–segment combinations" + ":code:`cost_variable_eos_period_rpt`", ":math:`(r, p, t)`", "projection of :code:`cost_variable_eos_rptn` onto :math:`(r, p, t)`" + +Variables +~~~~~~~~~ + +.. csv-table:: + :header: "Variable", "Domain", "Indices", "Description" + :widths: 36, 12, 24, 28 + + ":math:`\textbf{CVEACT}_{r,p,t,n}` (:code:`v_cost_variable_eos_activity`)", ":math:`\mathbb{R}_{\ge 0}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_variable\_eos\_rptn}}`", "activity assigned to segment :math:`n` of cluster :math:`(r,p,t)`" + ":math:`\textbf{CVEB}_{r,p,t,n}` (:code:`v_cost_variable_eos_segment_binary`)", ":math:`\{0, 1\}`", ":math:`(r, p, t, n) \in \Theta_{\text{cost\_variable\_eos\_rptn}}`", "1 if segment :math:`n` is active for cluster :math:`(r,p,t)`" + +Constraints +~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_variable_eos.cost_variable_eos_segment_binary_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_variable_eos.cost_variable_eos_activity_lower_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_variable_eos.cost_variable_eos_activity_upper_bound_constraint + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_variable_eos.cost_variable_eos_activity_constraint + +Objective Contribution +~~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.economies_of_scale.components.cost_variable_eos.period_cost diff --git a/docs/source/images/eos_cost_curve.png b/docs/source/images/eos_cost_curve.png new file mode 100644 index 0000000000000000000000000000000000000000..9772ea4e97bf69ba4ecb5d13fe6b15c2ec5e9912 GIT binary patch literal 75291 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 + + + + + + + 2026-07-08T13:23:55.421448 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Q + n + = + 0 + = + 0 + + + + + + + + + + + + + + + + ̄ + = + Q + Q + n + n + = + 0 + = + 1 + + + + + + + + + + + + + + + + ̄ + = + Q + Q + n + n + = + 1 + = + 2 + + + + + + + + + + + + + + + + ̄ + Q + n + = + 2 + + + + + + + + + + + + + + + Q + p + + + + + + + + + Q + u + a + n + t + i + t + y +   +   +   +   + ( + c + a + p + a + c + i + t + y +   + o + r +   + a + c + t + i + v + i + t + y + ) + Q + + + + + + + + + + + + + + + + + + + C + n + = + 0 + = + 0 + + + + + + + + + + + + + + + + ̄ + = + C + C + n + n + = + 0 + = + 1 + + + + + + + + + + + + + + + + ̄ + = + C + C + n + n + = + 1 + = + 2 + + + + + + + + + + + + + + + + ̄ + C + n + = + 2 + + + + + + + + + T + o + t + a + l +   + c + o + s + t +   +   + C + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + m + n + C + Q + = +   +   + ( + s + l + o + p + e + ) + Δ + Δ + + + + + + + + + n + = + 0 + + + + + + + + n + = + 1 + + + + + + + + n + = + 2 + + + + + + + + active segment + + + + b + n + = + 1 + = + 1 + + + + + EOS piecewise-linear cost curve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A + c + t + i + v + e +   + s + e + g + m + e + n + t +   + i + n +   + p + e + r + i + o + d +   + p + + + + + + + + Active segment cost line + + + + + + Inactive segment cost line + + + + + + + + + Q + p +   + ( + c + u + r + r + e + n + t +   + p + e + r + i + o + d + ) + + + + + + + + + + + + diff --git a/docs/source/images/etl_cost_curve.png b/docs/source/images/etl_cost_curve.png new file mode 100644 index 0000000000000000000000000000000000000000..af24dd9142ab064933af49ca07afb1bb9e3ef9c7 GIT binary patch literal 75182 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 + + + + + + + 2026-07-07T18:07:34.827185 + image/svg+xml + + + Matplotlib v3.11.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + ̄ + = + K + K + t + t + , + 1 + , + 2 + + + + + + + + + + + + + + + + ̄ + = + K + K + t + t + , + 2 + , + 3 + + + + + + + + + + + + + + + + ̄ + K + t + , + 3 + + + + + + + + + + + + + + + E + T + L + C + A + P + p + + + + + + + + + C + u + m + u + l + a + t + i + v + e +   + i + n + s + t + a + l + l + e + d +   + c + a + p + a + c + i + t + y +   +   + K + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + C + t + , + 1 + + + + + + + + + + + + + + + + C + t + , + 2 + + + + + + + + + + + + + + + + C + t + , + 3 + + + + + + + + + + T + o + t + a + l +   + i + n + v + e + s + t + m + e + n + t +   + c + o + s + t +   +   + C + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + m + t + n + C + K + , + Δ + Δ + = +   +   + ( + s + l + o + p + e + ) + + + + + + + + + n + = + 1 + + + + + + + + n + = + 2 + + + + + + + + n + = + 3 + + + + + + + + active + segment + + + ETL piecewise-linear cost curve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A + c + t + i + v + e +   + s + e + g + m + e + n + t +   + i + n +   + p + e + r + i + o + d +   + p + + + + + + + + Active segment cost line + + + + + + Inactive segment cost line + + + + + + + + + E + T + L + C + A + P + p +   + ( + c + u + r + r + e + n + t +   + p + e + r + i + o + d + ) + + + + + + + + + + + + diff --git a/scripts/generate_etl_figure.py b/scripts/generate_etl_figure.py new file mode 100644 index 000000000..20079f58d --- /dev/null +++ b/scripts/generate_etl_figure.py @@ -0,0 +1,223 @@ +""" +Generates docs/source/images/eos_cost_curve.{png,svg} — a labelled diagram +of the piecewise-linear cost curve shared by all three features of the +economies of scale (EOS) extension: cost_invest_eos, cost_fixed_eos, and +cost_variable_eos. +""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt +import matplotlib.ticker as ticker + +matplotlib.rcParams.update( + { + 'font.family': 'sans-serif', + 'font.size': 10, + 'axes.spines.top': False, + 'axes.spines.right': False, + 'pdf.fonttype': 42, + 'svg.fonttype': 'none', + } +) + +OUT_DIR = Path(__file__).parent.parent / 'docs' / 'source' / 'images' + +# ——— Segment data (decreasing marginal cost — economies of scale) ————————————— +# Each entry: (q_lower, q_upper, cost_lower, cost_upper) +segments = [ + (0.0, 2.0, 0.0, 6.0), # n=1 slope = 3.0 + (2.0, 5.0, 6.0, 12.0), # n=2 slope = 2.0 + (5.0, 9.0, 12.0, 16.0), # n=3 slope = 1.0 +] + +PALETTE = { + 'curve': '#1f1f1f', + 'active': '#d44040', + 'inactive': '#aaaaaa', + 'fill': '#fde8e8', + 'grid': '#e8e8e8', + 'annot': '#555555', + 'tick': '#333333', +} + +# Active segment (0-indexed) — the one the optimizer has selected in this period +ACTIVE = 1 +# Quantity realised in this period (sits inside the active segment) +Q_P = 3.5 + +fig, ax = plt.subplots(figsize=(8.0, 5.0)) +fig.patch.set_facecolor('white') +ax.set_facecolor('white') + +# ——— Shade the active segment background —————————————————————————————————————— +ql_a, qu_a, cl_a, cu_a = segments[ACTIVE] +ax.axvspan(ql_a, qu_a, color=PALETTE['fill'], zorder=0, linewidth=0) + +# ——— Draw all segment lines ———————————————————————————————————————————————————— +for i, (ql, qu, cl, cu) in enumerate(segments): + color = PALETTE['active'] if i == ACTIVE else PALETTE['curve'] + lw = 2.2 if i == ACTIVE else 1.6 + ax.plot([ql, qu], [cl, cu], color=color, linewidth=lw, solid_capstyle='round', zorder=3) + dot_color = PALETTE['active'] if i == ACTIVE else PALETTE['curve'] + ax.plot(ql, cl, 'o', ms=5, color=dot_color, zorder=4) + ax.plot(qu, cu, 'o', ms=5, color=dot_color, zorder=4) + +# ——— Vertical line at current-period quantity ————————————————————————————————— +slope_a = (cu_a - cl_a) / (qu_a - ql_a) +cost_p = cl_a + slope_a * (Q_P - ql_a) + +# ——— Capacity/activity bound tick labels on x-axis ——————————————————————————— +boundaries = [] +for i, (ql, _qu, _cl, _cu) in enumerate(segments): + n = i # 0-based + if ql == 0: + boundaries.append((ql, r'$\underline{Q}_{n=0} = 0$')) + else: + boundaries.append((ql, rf'$\bar{{Q}}_{{n={n - 1}}} = \underline{{Q}}_{{n={n}}}$')) + +_, qu_last, _, _ = segments[-1] +boundaries.append((qu_last, rf'$\bar{{Q}}_{{n={len(segments) - 1}}}$')) + +x_ticks = [b[0] for b in boundaries] + [Q_P] +x_labels = [b[1] for b in boundaries] + [r'$\mathbf{Q}_p$'] + +ax.set_xticks(x_ticks) +ax.set_xticklabels(x_labels, fontsize=8.5, color=PALETTE['tick']) + +# ——— Cost bound tick labels on y-axis ———————————————————————————————————————— +y_boundaries = [] +for i, (_ql, _qu, cl, _cu) in enumerate(segments): + n = i # 0-based + if cl == 0: + y_boundaries.append((cl, r'$\underline{C}_{n=0} = 0$')) + else: + # shared upper bound of prev segment / lower bound of this segment + y_boundaries.append((cl, rf'$\bar{{C}}_{{n={n - 1}}} = \underline{{C}}_{{n={n}}}$')) + +_, _, _, cu_last = segments[-1] +y_boundaries.append((cu_last, rf'$\bar{{C}}_{{n={len(segments) - 1}}}$')) + +y_ticks = [b[0] for b in y_boundaries] +y_labels = [b[1] for b in y_boundaries] + +ax.set_yticks(y_ticks) +ax.set_yticklabels(y_labels, fontsize=8.5, color=PALETTE['tick']) + +# ——— Dashed reference lines to axes from segment endpoints ——————————————————— +dash_kw = {'color': PALETTE['inactive'], 'linewidth': 0.8, 'linestyle': ':', 'zorder': 1} +for ql, qu, cl, cu in segments: + ax.plot([ql, ql], [0, cl], **dash_kw) + ax.plot([qu, qu], [0, cu], **dash_kw) + ax.plot([0, ql], [cl, cl], **dash_kw) + ax.plot([0, qu], [cu, cu], **dash_kw) + +# dashed lines from Q_P up to cost curve, then across to y-axis +ax.plot([Q_P, Q_P], [0, cost_p], color=PALETTE['active'], linewidth=0.9, linestyle='--', zorder=2) +ax.plot( + [0, Q_P], [cost_p, cost_p], color=PALETTE['active'], linewidth=0.9, linestyle='--', zorder=2 +) +ax.plot(0, cost_p, '<', ms=5, color=PALETTE['active'], zorder=4, clip_on=False) +ax.plot(Q_P, 0, 'v', ms=5, color=PALETTE['active'], zorder=4, clip_on=False) + +# ——— Slope annotation on active segment —————————————————————————————————————— +mid_q = (ql_a + qu_a) / 2 +mid_c = cl_a + slope_a * (mid_q - ql_a) +dq, dc = 0.6, slope_a * 0.6 +ax.annotate( + '', + xy=(mid_q + dq, mid_c + dc), + xytext=(mid_q + dq, mid_c), + arrowprops={'arrowstyle': '-', 'color': PALETTE['annot'], 'lw': 0.9}, +) +ax.annotate( + '', + xy=(mid_q + dq, mid_c), + xytext=(mid_q, mid_c), + arrowprops={'arrowstyle': '-', 'color': PALETTE['annot'], 'lw': 0.9}, +) +ax.text( + mid_q + dq + 0.08, + mid_c + dc / 2, + r'$m_n = \frac{\Delta C}{\Delta Q}$ (slope)', + va='center', + ha='left', + fontsize=8.5, + color=PALETTE['annot'], +) + +# ——— Segment labels ——————————————————————————————————————————————————————————— +for i, (ql, qu, cl, cu) in enumerate(segments): + n = i # 0-based + mid_q = (ql + qu) / 2 + mid_c = (cl + cu) / 2 + color = PALETTE['active'] if i == ACTIVE else PALETTE['annot'] + weight = 'bold' if i == ACTIVE else 'normal' + ax.text( + mid_q, + mid_c + 0.7, + f'$n={n}$', + ha='center', + va='bottom', + fontsize=9, + color=color, + fontweight=weight, + ) + +# ——— Active-segment label ————————————————————————————————————————————————————— +ax.text( + (ql_a + qu_a) / 2, + 1.5, + 'active segment\n' r'$b_{n=1} = 1$', + ha='center', + va='bottom', + fontsize=8.5, + color=PALETTE['active'], + bbox={'boxstyle': 'round,pad=0.3', 'fc': PALETTE['fill'], 'ec': PALETTE['active'], 'lw': 0.8}, +) + +# ——— Axes formatting —————————————————————————————————————————————————————————— +ax.set_xlim(-0.3, 10.2) +ax.set_ylim(-0.3, 18.5) +ax.set_xlabel('Quantity $Q$ (capacity or activity)', labelpad=8) +ax.set_ylabel('Total cost $C$', labelpad=8) +ax.set_title('EOS piecewise-linear cost curve', pad=10, fontsize=11) + +ax.xaxis.set_minor_locator(ticker.NullLocator()) +ax.yaxis.set_minor_locator(ticker.NullLocator()) + +# ——— Legend ——————————————————————————————————————————————————————————————————— +handles = [ + mpatches.Patch( + facecolor=PALETTE['fill'], + edgecolor=PALETTE['active'], + lw=0.8, + label='Active segment in period $p$', + ), + plt.Line2D([0], [0], color=PALETTE['active'], lw=2.2, label='Active segment cost line'), + plt.Line2D([0], [0], color=PALETTE['curve'], lw=1.6, label='Inactive segment cost line'), + plt.Line2D( + [0], + [0], + color=PALETTE['active'], + lw=1.2, + linestyle='--', + label=r'$\mathbf{Q}_p$ (current period)', + ), +] +ax.legend( + handles=handles, fontsize=8, loc='upper left', frameon=True, framealpha=0.9, edgecolor='#cccccc' +) + +fig.tight_layout() + +for ext in ('png', 'svg'): + out = OUT_DIR / f'eos_cost_curve.{ext}' + fig.savefig(out, dpi=150, bbox_inches='tight', facecolor='white') + print(f'Saved {out}') + +plt.close(fig) From 82b73f45df47305a2c521dd65ce50298b39cd9ea Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 17:48:24 -0400 Subject: [PATCH 13/23] Cleanup for Bugs Signed-off-by: Davey Elder --- scripts/generate_etl_figure.py | 8 ++- temoa/_internal/run_actions.py | 3 +- .../economies_of_scale/core/data_puller.py | 50 ++++++++----------- .../economies_of_scale/core/model.py | 2 + temoa/extensions/method_of_morris/morris.py | 2 - tests/test_table_writer.py | 2 +- 6 files changed, 34 insertions(+), 33 deletions(-) diff --git a/scripts/generate_etl_figure.py b/scripts/generate_etl_figure.py index 20079f58d..4e42ee48c 100644 --- a/scripts/generate_etl_figure.py +++ b/scripts/generate_etl_figure.py @@ -8,6 +8,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any import matplotlib import matplotlib.patches as mpatches @@ -109,7 +110,12 @@ ax.set_yticklabels(y_labels, fontsize=8.5, color=PALETTE['tick']) # ——— Dashed reference lines to axes from segment endpoints ——————————————————— -dash_kw = {'color': PALETTE['inactive'], 'linewidth': 0.8, 'linestyle': ':', 'zorder': 1} +dash_kw: dict[str, Any] = { + 'color': PALETTE['inactive'], + 'linewidth': 0.8, + 'linestyle': ':', + 'zorder': 1, +} for ql, qu, cl, cu in segments: ax.plot([ql, ql], [0, cl], **dash_kw) ax.plot([qu, qu], [0, cu], **dash_kw) diff --git a/temoa/_internal/run_actions.py b/temoa/_internal/run_actions.py index 6aecc7dcc..7e7b5ec5b 100644 --- a/temoa/_internal/run_actions.py +++ b/temoa/_internal/run_actions.py @@ -247,7 +247,8 @@ def solve_instance( # it also doesn't seem to support duals properly so disable those if solver_name == 'appsi_highs': dual_suffix = instance.component('dual') - dual_suffix._direction = Suffix.LOCAL + if dual_suffix is not None: + dual_suffix.direction = Suffix.LOCAL result = optimizer.solve(instance) else: result = optimizer.solve(instance, suffixes=solver_suffixes_list) diff --git a/temoa/extensions/economies_of_scale/core/data_puller.py b/temoa/extensions/economies_of_scale/core/data_puller.py index 2713483e8..d34a6279c 100644 --- a/temoa/extensions/economies_of_scale/core/data_puller.py +++ b/temoa/extensions/economies_of_scale/core/data_puller.py @@ -98,21 +98,17 @@ def poll_costs( else: # The period `p` for an investment cost is its vintage `v`. key = (cast('Region', r), cast('Period', p), cast('Technology', t), cast('Vintage', p)) - if CostType.INVEST in entries.get(key, {}): - entries[key][CostType.D_INVEST] += discounted_cost - entries[key][CostType.INVEST] += undiscounted_cost - else: - entries[key].update( - {CostType.D_INVEST: discounted_cost, CostType.INVEST: undiscounted_cost} - ) + entry = entries[key] + entry[CostType.D_INVEST] = entry.get(CostType.D_INVEST, 0.0) + discounted_cost + entry[CostType.INVEST] = entry.get(CostType.INVEST, 0.0) + undiscounted_cost for r, p, t in model.cost_fixed_eos_period_rpt: fixed_cost = value(cost_fixed_eos.period_cost(model, r, p, t)) if fixed_cost < epsilon: continue - undiscounted_fixed_cost = fixed_cost * value(model.period_length[p]) - discounted_fixed_cost = costs.fixed_or_variable_cost( + ud_fixed = float(fixed_cost * value(model.period_length[p])) + d_fixed = costs.fixed_or_variable_cost( 1, fixed_cost, value(model.period_length[p]), @@ -120,6 +116,7 @@ def poll_costs( p_0=p_0, p=p, ) + d_fixed = float(value(d_fixed)) if '-' in r: exchange_costs.add_cost_record( @@ -127,7 +124,7 @@ def poll_costs( period=p, tech=t, vintage=p, - cost=float(value(discounted_fixed_cost)), + cost=d_fixed, cost_type=CostType.D_FIXED, ) exchange_costs.add_cost_record( @@ -135,24 +132,22 @@ def poll_costs( period=p, tech=t, vintage=p, - cost=float(undiscounted_fixed_cost), + cost=ud_fixed, cost_type=CostType.FIXED, ) else: - entries[r, p, t, p].update( - { - CostType.D_FIXED: float(value(discounted_fixed_cost)), - CostType.FIXED: float(undiscounted_fixed_cost), - } - ) + key = (cast('Region', r), cast('Period', p), cast('Technology', t), cast('Vintage', p)) + entry = entries[key] + entry[CostType.D_FIXED] = entry.get(CostType.D_FIXED, 0.0) + d_fixed + entry[CostType.FIXED] = entry.get(CostType.FIXED, 0.0) + ud_fixed - for r, p, t in model.cost_fixed_eos_period_rpt: + for r, p, t in model.cost_variable_eos_period_rpt: variable_cost = value(cost_variable_eos.period_cost(model, r, p, t)) if variable_cost < epsilon: continue - undiscounted_variable_cost = variable_cost * value(model.period_length[p]) - discounted_variable_cost = costs.fixed_or_variable_cost( + ud_variable = float(variable_cost * value(model.period_length[p])) + d_variable = costs.fixed_or_variable_cost( 1, variable_cost, value(model.period_length[p]), @@ -160,6 +155,7 @@ def poll_costs( p_0=p_0, p=p, ) + d_variable = float(value(d_variable)) if '-' in r: exchange_costs.add_cost_record( @@ -167,7 +163,7 @@ def poll_costs( period=p, tech=t, vintage=p, - cost=float(value(discounted_variable_cost)), + cost=d_variable, cost_type=CostType.D_VARIABLE, ) exchange_costs.add_cost_record( @@ -175,13 +171,11 @@ def poll_costs( period=p, tech=t, vintage=p, - cost=float(undiscounted_variable_cost), + cost=ud_variable, cost_type=CostType.VARIABLE, ) else: - entries[r, p, t, p].update( - { - CostType.D_VARIABLE: float(value(discounted_variable_cost)), - CostType.VARIABLE: float(undiscounted_variable_cost), - } - ) + key = (cast('Region', r), cast('Period', p), cast('Technology', t), cast('Vintage', p)) + entry = entries[key] + entry[CostType.D_VARIABLE] = entry.get(CostType.D_VARIABLE, 0.0) + d_variable + entry[CostType.VARIABLE] = entry.get(CostType.VARIABLE, 0.0) + ud_variable diff --git a/temoa/extensions/economies_of_scale/core/model.py b/temoa/extensions/economies_of_scale/core/model.py index 82c0d0e27..6b1298a41 100644 --- a/temoa/extensions/economies_of_scale/core/model.py +++ b/temoa/extensions/economies_of_scale/core/model.py @@ -169,6 +169,8 @@ def register_model_components(model: TemoaModel) -> None: rule=cost_fixed_eos.cost_fixed_eos_capacity_upper_bound_constraint, ) + m.append_cost_fixed_eos_to_total_cost = BuildAction(rule=cost_fixed_eos.total_cost) + # -- cost_variable_eos --- m.cost_variable_eos_segments = {} diff --git a/temoa/extensions/method_of_morris/morris.py b/temoa/extensions/method_of_morris/morris.py index dd981d79e..4ae1f308f 100644 --- a/temoa/extensions/method_of_morris/morris.py +++ b/temoa/extensions/method_of_morris/morris.py @@ -47,8 +47,6 @@ def evaluate( with TableWriter(config) as table_writer: table_writer.write_results(model=mdl) - import contextlib - with contextlib.closing(sqlite3.connect(db_file)) as con: cur = con.cursor() cur.execute('SELECT * FROM output_objective') diff --git a/tests/test_table_writer.py b/tests/test_table_writer.py index 646c0a47d..fdfe741fb 100644 --- a/tests/test_table_writer.py +++ b/tests/test_table_writer.py @@ -9,7 +9,7 @@ class LoanCostInput(TypedDict): capacity: float invest_cost: float loan_life: float - loan_rate: float + loan_annualize: float global_discount_rate: float process_life: int p_0: int From 0ce8cc00b235da281183368480cdc84ce9e35a77 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 8 Jul 2026 21:00:46 -0400 Subject: [PATCH 14/23] Add economies of scale and discrete capacities to myopic capacities test Signed-off-by: Davey Elder --- tests/test_full_runs.py | 31 +++++- .../config_myopic_capacities.toml | 2 +- tests/testing_data/myopic_capacities.sql | 95 +++++++++++++++++++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/tests/test_full_runs.py b/tests/test_full_runs.py index c71f9b4c8..9956449e5 100644 --- a/tests/test_full_runs.py +++ b/tests/test_full_runs.py @@ -171,18 +171,41 @@ def test_myopic_stress_tests( ) -> None: """ The idea of these is that they should be tightly constrained so that if anything - is wrong the model will fail to find a feasible solution. Use lots of equality constraints + is wrong the model will fail to find a feasible solution. Use lots of equality constraints. + + Two new paths test limit_discrete_capacity and limit_discrete_new_capacity with EoS costs + and survival curves. """ _, _, _, sequencer = system_test_run import contextlib with contextlib.closing(sqlite3.connect(sequencer.config.output_database)) as con: cur = con.cursor() + dc_fixed = cur.execute( + "SELECT SUM(fixed) FROM output_cost WHERE tech='tech_discrete_cap'" + ).fetchone()[0] + dc_variable = cur.execute( + "SELECT SUM(var) FROM output_cost WHERE tech='tech_discrete_cap'" + ).fetchone()[0] + dnc_invest = cur.execute( + "SELECT SUM(invest) FROM output_cost WHERE tech='tech_discrete_new_cap'" + ).fetchone()[0] + + assert dc_fixed == pytest.approx(37, rel=1e-5), ( + 'tech_discrete_cap fixed cost did not match expected' + ) + assert dc_variable == pytest.approx(24, rel=1e-5), ( + 'tech_discrete_cap variable cost did not match expected' + ) + assert dnc_invest == pytest.approx(2.5, rel=1e-5), ( + 'tech_discrete_new_cap invest cost did not match expected' + ) + + # This part is just a very rough check on the objective function. Constraints inside the + # model are extremely tight so other changes will likely lead to infeasibility res = cur.execute('SELECT SUM(total_system_cost) FROM main.output_objective').fetchone() obj = res[0] - # This part is just a very rough check on the objective function. Constraints inside the - # model are extremely tight so any other changes will lead to infeasibility - assert obj == pytest.approx(32, abs=1), ( + assert obj == pytest.approx(309, rel=0.01), ( 'objective function value did not match expected for myopic stress test' ) diff --git a/tests/testing_configs/config_myopic_capacities.toml b/tests/testing_configs/config_myopic_capacities.toml index db2d11539..4ff76c5b3 100644 --- a/tests/testing_configs/config_myopic_capacities.toml +++ b/tests/testing_configs/config_myopic_capacities.toml @@ -1,6 +1,6 @@ scenario = "test myopic capacities" scenario_mode = "myopic" -extensions= ["growth_rates"] +extensions= ["growth_rates", "discrete_capacity", "eos"] input_database = "tests/testing_outputs/myopic_capacities.sqlite" output_database = "tests/testing_outputs/myopic_capacities.sqlite" neos = false diff --git a/tests/testing_data/myopic_capacities.sql b/tests/testing_data/myopic_capacities.sql index f561e073f..ca11c4158 100644 --- a/tests/testing_data/myopic_capacities.sql +++ b/tests/testing_data/myopic_capacities.sql @@ -240,3 +240,98 @@ REPLACE INTO technology VALUES('tech_current','p','energy',NULL,NULL,0,0,0,0,1,0 REPLACE INTO technology VALUES('tech_future','p','energy',NULL,NULL,0,0,0,0,1,0,0,0,NULL); REPLACE INTO technology VALUES('tech_retire','p','energy',NULL,NULL,0,0,0,0,1,0,0,0,NULL); REPLACE INTO time_season VALUES(0,'s',1.0,NULL); +REPLACE INTO commodity VALUES('demand_dc','d',NULL,NULL); +REPLACE INTO commodity VALUES('demand_dnc','d',NULL,NULL); +REPLACE INTO technology VALUES('tech_discrete_cap','p','energy',NULL,NULL,0,0,0,0,1,0,0,0,NULL); +REPLACE INTO technology VALUES('tech_dc_dummy','p','energy',NULL,NULL,1,0,0,0,0,0,0,0,NULL); +REPLACE INTO technology VALUES('tech_discrete_new_cap','p','energy',NULL,NULL,0,0,0,0,1,0,0,0,NULL); +REPLACE INTO technology VALUES('tech_dnc_dummy','p','energy',NULL,NULL,1,0,0,0,0,0,0,0,NULL); +REPLACE INTO lifetime_tech VALUES('region','tech_discrete_cap',35.0,NULL,NULL); +REPLACE INTO lifetime_tech VALUES('region','tech_dc_dummy',35.0,NULL,NULL); +REPLACE INTO lifetime_tech VALUES('region','tech_discrete_new_cap',5.0,NULL,NULL); +REPLACE INTO lifetime_tech VALUES('region','tech_dnc_dummy',35.0,NULL,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2025,0.0,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2030,0.0,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2035,0.0,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2040,0.0,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2045,0.0,NULL); +REPLACE INTO loan_rate VALUES('region','tech_discrete_new_cap',2050,0.0,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_cap',2025,'demand_dc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_dc_dummy',2025,'demand_dc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2025,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2030,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2035,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2040,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2045,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_discrete_new_cap',2050,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO efficiency VALUES('region','source','tech_dnc_dummy',2025,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2025,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2030,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2035,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2040,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2045,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2050,'demand_dc',2.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2025,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2030,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2035,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2040,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2045,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO demand VALUES('region',2050,'demand_dnc',1.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2025,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2030,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2035,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2040,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2045,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2050,'tech_dc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2025,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2030,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2035,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2040,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2045,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO cost_variable VALUES('region',2050,'tech_dnc_dummy',2025,10.0,NULL,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2025,'tech_discrete_cap',2025,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2030,'tech_discrete_cap',2025,0.8,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2035,'tech_discrete_cap',2025,0.6,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2040,'tech_discrete_cap',2025,0.4,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2045,'tech_discrete_cap',2025,0.2,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2060,'tech_discrete_cap',2025,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2025,'tech_discrete_new_cap',2025,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2030,'tech_discrete_new_cap',2025,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2030,'tech_discrete_new_cap',2030,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2035,'tech_discrete_new_cap',2030,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2035,'tech_discrete_new_cap',2035,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2040,'tech_discrete_new_cap',2035,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2040,'tech_discrete_new_cap',2040,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2045,'tech_discrete_new_cap',2040,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2045,'tech_discrete_new_cap',2045,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2050,'tech_discrete_new_cap',2045,0.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2050,'tech_discrete_new_cap',2050,1.0,NULL); +REPLACE INTO lifetime_survival_curve VALUES('region',2055,'tech_discrete_new_cap',2050,0.0,NULL); +REPLACE INTO limit_discrete_capacity VALUES('region','tech_discrete_cap',0.2,NULL,NULL); +REPLACE INTO limit_discrete_new_capacity VALUES('region','tech_discrete_new_cap',0.2,NULL,NULL); +REPLACE INTO cost_invest_eos VALUES('region','tech_discrete_new_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_invest_eos VALUES('region','tech_discrete_new_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2025,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2025,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2030,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2030,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2035,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2035,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2040,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2040,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2045,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2045,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2050,'tech_discrete_cap',1,0.0,0.5,0.0,1.0,NULL,NULL); +REPLACE INTO cost_fixed_eos VALUES('region',2050,'tech_discrete_cap',2,0.5,2.0,1.0,2.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2025,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2025,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2030,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2030,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2035,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2035,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2040,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2040,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2045,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2045,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2050,'tech_discrete_cap',1,0.0,0.5,0.0,0.75,NULL,NULL); +REPLACE INTO cost_variable_eos VALUES('region',2050,'tech_discrete_cap',2,0.5,2.0,0.75,1.5,NULL,NULL); From b53804165800fc1de29a03e9e62a0d15514be143 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 13 Jul 2026 21:33:38 -0400 Subject: [PATCH 15/23] Optimise O(n2) group tech-region lookups Signed-off-by: Davey Elder --- temoa/components/capacity.py | 49 +++- temoa/components/limits.py | 218 ++++++------------ temoa/core/model.py | 3 + .../components/discrete_capacity.py | 36 +-- .../components/cost_fixed_eos.py | 14 +- .../components/cost_invest_eos.py | 68 +++--- .../components/cost_variable_eos.py | 12 +- .../components/growth_capacity.py | 22 +- .../components/growth_new_capacity.py | 29 +-- .../components/growth_new_capacity_delta.py | 30 +-- .../template/components/example_limit.py | 14 +- 11 files changed, 211 insertions(+), 284 deletions(-) diff --git a/temoa/components/capacity.py b/temoa/components/capacity.py index 82e553126..988a9c915 100644 --- a/temoa/components/capacity.py +++ b/temoa/components/capacity.py @@ -18,6 +18,8 @@ from deprecated import deprecated from pyomo.environ import value +from temoa.components import geography, technology + from .utils import get_adjusted_existing_capacity, get_capacity_factor if TYPE_CHECKING: @@ -456,15 +458,18 @@ def capacity_constraint( if t in model.tech_curtailment: # If technologies are present in the curtailment set, then enough # capacity must be available to cover both activity and curtailment. - return get_capacity_factor(model, r, s, d, t, v) * value( - model.capacity_to_activity[r, t] - ) * value(model.segment_fraction[s, d]) * model.v_capacity[ - r, p, t, v - ] == useful_activity + sum( + curtailment = sum( model.v_curtailment[r, p, s, d, S_i, t, v, S_o] for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) + return ( + get_capacity_factor(model, r, s, d, t, v) + * value(model.capacity_to_activity[r, t]) + * value(model.segment_fraction[s, d]) + * model.v_capacity[r, p, t, v] + == useful_activity + curtailment + ) else: return ( get_capacity_factor(model, r, s, d, t, v) @@ -661,3 +666,37 @@ def create_capacity_and_retirement_sets(model: TemoaModel) -> None: for r, p, t in model.active_capacity_available_rpt for v in model.process_vintages[r, p, t] } + + +def gather_group_active_processes( + model: TemoaModel, r: Region, p: Period, t: Technology +) -> set[tuple[Region, Technology]]: + """ + A utility to get the valid active processes for a group-region, tech-group pair in period p. + Caches the result if its a new call so we don't repeat these O(n2) lookups. + """ + if (r, p, t) not in model.group_active_processes: + model.group_active_processes[r, p, t] = { + (_r, _t) + for _r in geography.gather_group_regions(model, r) + for _t in technology.gather_group_techs(model, t) + if (_r, p, _t) in model.process_vintages + } + return model.group_active_processes[r, p, t] + + +def gather_group_built_processes( + model: TemoaModel, r: Region, t: Technology, v: Vintage +) -> set[tuple[Region, Technology]]: + """ + A utility to get the valid built processes for a group-region, tech-group, vintage index. + Caches the result if its a new call so we don't repeat these O(n2) lookups. + """ + if (r, t, v) not in model.group_built_processes: + model.group_built_processes[r, t, v] = { + (_r, _t) + for _r in geography.gather_group_regions(model, r) + for _t in technology.gather_group_techs(model, t) + if (_r, _t, v) in model.process_periods + } + return model.group_built_processes[r, t, v] diff --git a/temoa/components/limits.py b/temoa/components/limits.py index 702adb171..b2772e3e6 100644 --- a/temoa/components/limits.py +++ b/temoa/components/limits.py @@ -14,12 +14,12 @@ import sys from logging import getLogger -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from pyomo.environ import Constraint, quicksum, value import temoa.components.geography as geography -import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.utils import ( Operator, get_variable_efficiency, @@ -27,8 +27,6 @@ ) if TYPE_CHECKING: - from pyomo.core import Expression - from temoa.core.model import TemoaModel from temoa.types import ExprLike, Period, Region, Technology, Vintage from temoa.types.core_types import Commodity, Season, TimeOfDay @@ -151,9 +149,8 @@ def limit_annual_capacity_factor_indices( return { (r, p, t, v, o, op) for r, t, v, o, op in model.limit_annual_capacity_factor_constraint_rtvo - for _r in geography.gather_group_regions(model, r) - for _t in technology.gather_group_techs(model, t) for p in model.time_optimize + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if o in model.process_outputs.get((_r, p, _t, v), []) } @@ -221,27 +218,20 @@ def limit_resource_constraint(model: TemoaModel, r: Region, t: Technology, op: s # dev note: this would generally be applied to a "dummy import" technology to restrict # something like oil/mineral extraction across all model periods. Looks fine to me. - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - activity = quicksum( model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] - for _t in techs - if _t in model.tech_annual for p in model.time_optimize - for _r in regions - if (_r, p, _t) in model.process_vintages + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t in model.tech_annual for S_v in model.process_vintages[_r, p, _t] for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] ) activity += quicksum( model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] - for _t in techs - if _t not in model.tech_annual for p in model.time_optimize - for _r in regions - if (_r, p, _t) in model.process_vintages + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t not in model.tech_annual for S_v in model.process_vintages[_r, p, _t] for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] @@ -281,50 +271,42 @@ def limit_activity_share_constraint( \qquad \forall \{r, p, g_1, g_2\} \in \Theta_{\text{limit\_activity\_share}} """ - regions = geography.gather_group_regions(model, r) - - sub_group = technology.gather_group_techs(model, g1) sub_activity = quicksum( - model.v_flow_out[_r, p, s, d, S_i, S_t, S_v, S_o] - for S_t in sub_group + model.v_flow_out[S_r, p, s, d, S_i, S_t, S_v, S_o] + for S_r, S_t in capacity.gather_group_active_processes(model, r, p, g1) if S_t not in model.tech_annual - for _r in regions - for S_v in model.process_vintages.get((_r, p, S_t), []) - for S_i in model.process_inputs[_r, p, S_t, S_v] - for S_o in model.process_outputs_by_input[_r, p, S_t, S_v, S_i] + for S_v in model.process_vintages.get((S_r, p, S_t), []) + for S_i in model.process_inputs[S_r, p, S_t, S_v] + for S_o in model.process_outputs_by_input[S_r, p, S_t, S_v, S_i] for s in model.time_season for d in model.time_of_day ) sub_activity += quicksum( - model.v_flow_out_annual[_r, p, S_i, S_t, S_v, S_o] - for S_t in sub_group + model.v_flow_out_annual[S_r, p, S_i, S_t, S_v, S_o] + for S_r, S_t in capacity.gather_group_active_processes(model, r, p, g1) if S_t in model.tech_annual - for _r in regions - for S_v in model.process_vintages.get((_r, p, S_t), []) - for S_i in model.process_inputs[_r, p, S_t, S_v] - for S_o in model.process_outputs_by_input[_r, p, S_t, S_v, S_i] + for S_v in model.process_vintages.get((S_r, p, S_t), []) + for S_i in model.process_inputs[S_r, p, S_t, S_v] + for S_o in model.process_outputs_by_input[S_r, p, S_t, S_v, S_i] ) - super_group = technology.gather_group_techs(model, g2) super_activity = quicksum( - model.v_flow_out[_r, p, s, d, S_i, S_t, S_v, S_o] - for S_t in super_group + model.v_flow_out[S_r, p, s, d, S_i, S_t, S_v, S_o] + for S_r, S_t in capacity.gather_group_active_processes(model, r, p, g2) if S_t not in model.tech_annual - for _r in regions - for S_v in model.process_vintages.get((_r, p, S_t), []) - for S_i in model.process_inputs[_r, p, S_t, S_v] - for S_o in model.process_outputs_by_input[_r, p, S_t, S_v, S_i] + for S_v in model.process_vintages.get((S_r, p, S_t), []) + for S_i in model.process_inputs[S_r, p, S_t, S_v] + for S_o in model.process_outputs_by_input[S_r, p, S_t, S_v, S_i] for s in model.time_season for d in model.time_of_day ) super_activity += quicksum( - model.v_flow_out_annual[_r, p, S_i, S_t, S_v, S_o] - for S_t in super_group + model.v_flow_out_annual[S_r, p, S_i, S_t, S_v, S_o] + for S_r, S_t in capacity.gather_group_active_processes(model, r, p, g2) if S_t in model.tech_annual - for _r in regions - for S_v in model.process_vintages.get((_r, p, S_t), []) - for S_i in model.process_inputs[_r, p, S_t, S_v] - for S_o in model.process_outputs_by_input[_r, p, S_t, S_v, S_i] + for S_v in model.process_vintages.get((S_r, p, S_t), []) + for S_i in model.process_inputs[S_r, p, S_t, S_v] + for S_o in model.process_outputs_by_input[S_r, p, S_t, S_v, S_i] ) share_lim = value(model.limit_activity_share[r, p, g1, g2, op]) @@ -351,22 +333,13 @@ def limit_capacity_share_constraint( technology or technology group as a fraction of another technology or group. """ - regions = geography.gather_group_regions(model, r) - - sub_group = technology.gather_group_techs(model, g1) sub_capacity = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] - for _t in sub_group - for _r in regions - if (_r, p, _t) in model.process_vintages + for _r, _t in capacity.gather_group_active_processes(model, r, p, g1) ) - - super_group = technology.gather_group_techs(model, g2) super_capacity = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] - for _t in super_group - for _r in regions - if (_r, p, _t) in model.process_vintages + for _r, _t in capacity.gather_group_active_processes(model, r, p, g2) ) share_lim = value(model.limit_capacity_share[r, p, g1, g2, op]) @@ -384,22 +357,13 @@ def limit_new_capacity_share_constraint( of a given technology or group as a fraction of another technology or group.""" - regions = geography.gather_group_regions(model, r) - - sub_group = technology.gather_group_techs(model, g1) sub_new_cap = quicksum( model.v_new_capacity[_r, _t, v] - for _t in sub_group - for _r in regions - if (_r, _t, v) in model.process_periods + for _r, _t in capacity.gather_group_built_processes(model, r, g1, v) ) - - super_group = technology.gather_group_techs(model, g2) super_new_cap = quicksum( model.v_new_capacity[_r, _t, v] - for _t in super_group - for _r in regions - if (_r, _t, v) in model.process_periods + for _r, _t in capacity.gather_group_built_processes(model, r, g2, v) ) share_lim = value(model.limit_new_capacity_share[r, g1, g2, v, op]) @@ -441,31 +405,27 @@ def limit_annual_capacity_factor_constraint( # r can be an individual region (r='US'), or a combination of regions separated by plus # (r='Mexico+US+Canada'), or 'global'. # if r == 'global', the constraint is system-wide - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) activity_rptvo = 0 - for _t in techs: - if _t not in model.tech_annual: - activity_rptvo += quicksum( - model.v_flow_out[_r, p, s, d, S_i, _t, v, o] - for _r in regions - for S_i in model.process_inputs_by_output.get((_r, p, _t, v, o), []) - for s in model.time_season - for d in model.time_of_day - ) - else: - activity_rptvo += quicksum( - model.v_flow_out_annual[_r, p, S_i, _t, v, o] - for _r in regions - for S_i in model.process_inputs_by_output.get((_r, p, _t, v, o), []) - ) + activity_rptvo += quicksum( + model.v_flow_out[_r, p, s, d, S_i, _t, v, o] + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t not in model.tech_annual + for S_i in model.process_inputs_by_output.get((_r, p, _t, v, o), []) + for s in model.time_season + for d in model.time_of_day + ) + activity_rptvo += quicksum( + model.v_flow_out_annual[_r, p, S_i, _t, v, o] + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t in model.tech_annual + for S_i in model.process_inputs_by_output.get((_r, p, _t, v, o), []) + ) possible_activity_rptvo = quicksum( model.v_capacity[_r, p, _t, v] * value(model.capacity_to_activity[_r, _t]) - for _r in regions - for _t in techs + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if v in model.process_vintages.get((_r, p, _t), []) ) annual_cf = value(model.limit_annual_capacity_factor[r, t, v, o, op]) @@ -503,52 +463,31 @@ def limit_seasonal_capacity_factor_constraint( \forall \{r, p, s, t \in T^{a}\} \in \Theta_{\text{limit\_seasonal\_capacity\_factor}} """ - # r can be an individual region (r='US'), or a combination of regions separated by plus - # (r='Mexico+US+Canada'), or 'global'. - # if r == 'global', the constraint is system-wide - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - - # we need to screen here because it is possible that the restriction extends beyond the - # lifetime of any vintage of the tech... - if all( - (_r, p, _t) not in model.v_capacity_available_by_period_and_tech - for _r in regions - for _t in techs - ): - return Constraint.Skip - if TYPE_CHECKING: - activity_rpst = cast('Expression', 0) - else: - activity_rpst = 0 - for _t in techs: - if _t not in model.tech_annual: - activity_rpst += quicksum( - model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] - for _r in regions - for S_v in model.process_vintages.get((_r, p, _t), []) - for S_i in model.process_inputs.get((_r, p, _t, S_v), []) - for S_o in model.process_outputs_by_input.get((_r, p, _t, S_v, S_i), []) - for d in model.time_of_day - ) - else: - activity_rpst += quicksum( - model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] - * model.segment_fraction_per_season[s] - for _r in regions - for S_v in model.process_vintages.get((_r, p, _t), []) - for S_i in model.process_inputs.get((_r, p, _t, S_v), []) - for S_o in model.process_outputs_by_input.get((_r, p, _t, S_v, S_i), []) - ) + activity_rpst = 0 + activity_rpst += quicksum( + model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t not in model.tech_annual + for S_v in model.process_vintages.get((_r, p, _t), []) + for S_i in model.process_inputs.get((_r, p, _t, S_v), []) + for S_o in model.process_outputs_by_input.get((_r, p, _t, S_v, S_i), []) + for d in model.time_of_day + ) + activity_rpst += quicksum( + model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] * model.segment_fraction_per_season[s] + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + if _t in model.tech_annual + for S_v in model.process_vintages.get((_r, p, _t), []) + for S_i in model.process_inputs.get((_r, p, _t, S_v), []) + for S_o in model.process_outputs_by_input.get((_r, p, _t, S_v, S_i), []) + ) possible_activity_rpst = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] * value(model.capacity_to_activity[_r, _t]) * value(model.segment_fraction_per_season[s]) - for _r in regions - for _t in techs - if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) ) seasonal_cf = value(model.limit_seasonal_capacity_factor[r, s, t, op]) expr = operator_expression(activity_rpst, Operator(op), seasonal_cf * possible_activity_rpst) @@ -917,17 +856,11 @@ def limit_activity_constraint( \quad \le, \ge, \text{or} = \quad LA_{r, p, t} """ - # r can be an individual region (r='US'), or a combination of regions separated by - # a + (r='Mexico+US+Canada'), or 'global'. - # if r == 'global', the constraint is system-wide - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) activity = quicksum( model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] - for _t in techs + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if _t not in model.tech_annual - for _r in regions for S_v in model.process_vintages.get((_r, p, _t), []) for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] @@ -936,9 +869,8 @@ def limit_activity_constraint( ) activity += quicksum( model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] - for _t in techs + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if _t in model.tech_annual - for _r in regions for S_v in model.process_vintages.get((_r, p, _t), []) for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] @@ -964,14 +896,10 @@ def limit_new_capacity_constraint( \textbf{NCAP}_{r, t, v} \quad \le, \ge, \text{or} = \quad LNC_{r, t, v} """ - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) cap_lim = value(model.limit_new_capacity[r, t, v, op]) new_cap = quicksum( model.v_new_capacity[_r, _t, v] - for _t in techs - for _r in regions - if (_r, _t, v) in model.process_periods + for _r, _t in capacity.gather_group_built_processes(model, r, t, v) ) expr = operator_expression(new_cap, Operator(op), cap_lim) if isinstance(expr, bool): @@ -994,16 +922,12 @@ def limit_capacity_constraint( \textbf{CAPAVL}_{r, p, t} \quad \le, \ge, \text{or} = \quad LC_{r, p, t} \forall \{r, p, t\} \in \Theta_{\text{limit\_capacity}}""" - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) cap_lim = value(model.limit_capacity[r, p, t, op]) - capacity = quicksum( + cap = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] - for _t in techs - for _r in regions - if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) ) - expr = operator_expression(capacity, Operator(op), cap_lim) + expr = operator_expression(cap, Operator(op), cap_lim) if isinstance(expr, bool): return Constraint.Skip return expr diff --git a/temoa/core/model.py b/temoa/core/model.py index 24d4dbb08..62afbe489 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -168,6 +168,9 @@ def __init__( # {(r, t, v): set(p)} periods in which a process can economically or naturally retire self.retirement_periods: t.RetirementPeriodsDict = {} self.process_vintages: t.ProcessVintagesDict = {} + """current available (within lifespan) vintages {(r, p, t) : set(v)}""" + self.group_built_processes: t.GroupBuiltProcessesDict = {} + self.group_active_processes: t.GroupActiveProcessesDict = {} # {(r, t, v): set(p)} periods for which the process has a defined survival fraction self.survival_curve_periods: t.SurvivalCurvePeriodsDict = {} """current available (within lifespan) vintages {(r, p, t) : set(v)}""" diff --git a/temoa/extensions/discrete_capacity/components/discrete_capacity.py b/temoa/extensions/discrete_capacity/components/discrete_capacity.py index 6d0c6bbd9..cbeb53eaa 100644 --- a/temoa/extensions/discrete_capacity/components/discrete_capacity.py +++ b/temoa/extensions/discrete_capacity/components/discrete_capacity.py @@ -5,8 +5,7 @@ from pyomo.environ import Constraint, quicksum, value -import temoa.components.geography as geography -import temoa.components.technology as technology +from temoa.components import capacity logger = logging.getLogger(__name__) @@ -18,14 +17,11 @@ def limit_discrete_new_capacity_indices( model: DiscreteCapacityModel, ) -> set[tuple[Region, Technology, Vintage]]: - indices = { (r, t, v) for r, t in model.limit_discrete_new_capacity.sparse_keys() - for _r in geography.gather_group_regions(model, r) - for _t in technology.gather_group_techs(model, t) for v in model.vintage_optimize - if (_r, _t, v) in model.process_periods + if capacity.gather_group_built_processes(model, r, t, v) } return indices @@ -45,18 +41,12 @@ def limit_discrete_new_capacity_constraint_rule( \forall \{r, t, v\} \in \Theta_{\text{limit\_discrete\_new\_capacity}} """ - - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - unit_cap = value(model.limit_discrete_new_capacity[r, t]) - + discrete_cap = value(model.limit_discrete_new_capacity[r, t]) new_capacity = quicksum( model.v_new_capacity[_r, _t, v] - for _r in regions - for _t in techs - if (_r, _t, v) in model.process_periods + for _r, _t in capacity.gather_group_built_processes(model, r, t, v) ) - expr = new_capacity == unit_cap * model.v_discrete_new_capacity[r, t, v] + expr = new_capacity == discrete_cap * model.v_discrete_new_capacity[r, t, v] if isinstance(expr, bool): return Constraint.Skip return expr @@ -68,10 +58,8 @@ def limit_discrete_capacity_indices( indices = { (r, p, t) for r, t in model.limit_discrete_capacity.sparse_keys() - for _r in geography.gather_group_regions(model, r) - for _t in technology.gather_group_techs(model, t) for p in model.time_optimize - if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + if capacity.gather_group_active_processes(model, r, p, t) } return indices @@ -91,18 +79,12 @@ def limit_discrete_capacity_constraint_rule( \forall \{r, p, t\} \in \Theta_{\text{limit\_discrete\_net\_capacity}} """ - - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - capacity = value(model.limit_discrete_capacity[r, t]) - + discrete_cap = value(model.limit_discrete_capacity[r, t]) net_capacity = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] - for _r in regions - for _t in techs - if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) ) - expr = net_capacity == capacity * model.v_discrete_capacity[r, p, t] + expr = net_capacity == discrete_cap * model.v_discrete_capacity[r, p, t] if isinstance(expr, bool): return Constraint.Skip return expr diff --git a/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py b/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py index 9a902eb43..94d7ce477 100644 --- a/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py +++ b/temoa/extensions/economies_of_scale/components/cost_fixed_eos.py @@ -5,8 +5,7 @@ from pyomo.environ import quicksum, value -import temoa.components.geography as geography -import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.costs import fixed_or_variable_cost if TYPE_CHECKING: @@ -212,19 +211,14 @@ def cost_fixed_eos_capacity_constraint( :code:`v_capacity_available_by_period_and_tech[r', p, t']`. """ - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - cap_eos = quicksum( model.v_cost_fixed_eos_capacity[r, p, t, n] for n in model.cost_fixed_eos_segments[r, p, t] ) - capacity = quicksum( + cap = quicksum( model.v_capacity_available_by_period_and_tech[_r, p, _t] - for _t in techs - for _r in regions - if (_r, p, _t) in model.v_capacity_available_by_period_and_tech + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) ) - return capacity == cap_eos + return cap == cap_eos # --- Calculating costs --- diff --git a/temoa/extensions/economies_of_scale/components/cost_invest_eos.py b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py index a59c60e77..06a3c902c 100644 --- a/temoa/extensions/economies_of_scale/components/cost_invest_eos.py +++ b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py @@ -1,13 +1,13 @@ from __future__ import annotations import logging -from itertools import product as cross_product from typing import TYPE_CHECKING, cast from pyomo.environ import quicksum, value import temoa.components.geography as geography import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.costs import loan_cost, loan_cost_survival_curve if TYPE_CHECKING: @@ -26,13 +26,10 @@ def cost_invest_eos_cumulative_capacity_indices( model: EOSModel, ) -> set[tuple[Region, Period, Technology, int]]: return { - (r, cast('Period', v), t, n) + (r, _p, t, n) for r, t, n in model.cost_invest_eos.sparse_keys() - for _r in geography.gather_group_regions(model, r) - for _t in technology.gather_group_techs(model, t) for _p in model.time_optimize - for v in model.process_vintages.get((_r, _p, _t), set()) - if v == _p + for _r, _t in capacity.gather_group_built_processes(model, r, t, _p) } @@ -49,11 +46,8 @@ def append_cost_invest_eos_rtv(model: EOSModel) -> None: discounting in the objective function. """ for r, p, t in model.cost_invest_eos_period_rpt: - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - valid_rtv = { - (_r, _t, p) for _r in regions for _t in techs if (_r, _t, p) in model.process_periods + (_r, _t, p) for _r, _t in capacity.gather_group_built_processes(model, r, t, p) } for rtv in valid_rtv: if rtv not in model.cost_invest_rtv: @@ -124,35 +118,31 @@ def initialize_cost_invest_eos(model: EOSModel) -> None: # parameters. Check that all r, t combos in the cluster share the same # lifetime and loan parameters. for r, p, t in model.cost_invest_eos_period_rpt: - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - - for _r, _t in cross_product(regions, techs): - if (_r, _t, p) in model.process_periods: - # Store first valid process as our reference for this period - if (r, p, t) not in model.cost_invest_eos_reference_process: - model.cost_invest_eos_reference_process[r, p, t] = _r, _t - - # Check that the assumptions hold - r0, t0 = model.cost_invest_eos_reference_process[r, p, t] - if any( - ( - abs(life[r0, t0, p] - life[_r, _t, p]) >= 0.001, - abs(loan_life[r0, t0, p] - loan_life[_r, _t, p]) >= 0.001, - abs(loan_rate[r0, t0, p] - loan_rate[_r, _t, p]) >= 0.001, - ) - ): - msg = ( - 'Processes assigned to the same cost_invest_eos cost curve must all have ' - 'the same process lifetime, loan lifetime, and loan rate. These two ' - 'processes do not match:\n ' - f'({r0}, {t0}, {p}) : lifetime = {life[r0, t0, p]}, ' - f'loan life = {loan_life[r0, t0, p]}, loan rate = {loan_rate[r0, t0, p]}\n' - f'({_r}, {_t}, {p}) : lifetime = {life[_r, _t, p]}, ' - f'loan life = {loan_life[_r, _t, p]}, loan rate = {loan_rate[_r, _t, p]}' - ) - logger.error(msg) - raise ValueError(msg) + for _r, _t in capacity.gather_group_built_processes(model, r, t, p): + # Store first valid process as our reference for this period + if (r, p, t) not in model.cost_invest_eos_reference_process: + model.cost_invest_eos_reference_process[r, p, t] = _r, _t + + # Check that the assumptions hold + r0, t0 = model.cost_invest_eos_reference_process[r, p, t] + if any( + ( + abs(life[r0, t0, p] - life[_r, _t, p]) >= 0.001, + abs(loan_life[r0, t0, p] - loan_life[_r, _t, p]) >= 0.001, + abs(loan_rate[r0, t0, p] - loan_rate[_r, _t, p]) >= 0.001, + ) + ): + msg = ( + 'Processes assigned to the same cost_invest_eos cost curve must all have ' + 'the same process lifetime, loan lifetime, and loan rate. These two ' + 'processes do not match:\n ' + f'({r0}, {t0}, {p}) : lifetime = {life[r0, t0, p]}, ' + f'loan life = {loan_life[r0, t0, p]}, loan rate = {loan_rate[r0, t0, p]}\n' + f'({_r}, {_t}, {p}) : lifetime = {life[_r, _t, p]}, ' + f'loan life = {loan_life[_r, _t, p]}, loan rate = {loan_rate[_r, _t, p]}' + ) + logger.error(msg) + raise ValueError(msg) # --- Enforce the rules of cumulative capacity progression up the cost curve --- diff --git a/temoa/extensions/economies_of_scale/components/cost_variable_eos.py b/temoa/extensions/economies_of_scale/components/cost_variable_eos.py index e65868cc6..787f48459 100644 --- a/temoa/extensions/economies_of_scale/components/cost_variable_eos.py +++ b/temoa/extensions/economies_of_scale/components/cost_variable_eos.py @@ -5,8 +5,7 @@ from pyomo.environ import quicksum, value -import temoa.components.geography as geography -import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.costs import fixed_or_variable_cost if TYPE_CHECKING: @@ -218,18 +217,14 @@ def cost_variable_eos_activity_constraint( :code:`v_flow_out_annual` for annual technologies (no :math:`s,d` indices). """ - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - activity_eos = quicksum( model.v_cost_variable_eos_activity[r, p, t, n] for n in model.cost_variable_eos_segments[r, p, t] ) activity = quicksum( model.v_flow_out[_r, p, s, d, S_i, _t, S_v, S_o] - for _t in techs + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if _t not in model.tech_annual - for _r in regions for S_v in model.process_vintages.get((_r, p, _t), []) for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] @@ -238,9 +233,8 @@ def cost_variable_eos_activity_constraint( ) activity += quicksum( model.v_flow_out_annual[_r, p, S_i, _t, S_v, S_o] - for _t in techs + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) if _t in model.tech_annual - for _r in regions for S_v in model.process_vintages.get((_r, p, _t), []) for S_i in model.process_inputs[_r, p, _t, S_v] for S_o in model.process_outputs_by_input[_r, p, _t, S_v, S_i] diff --git a/temoa/extensions/growth_rates/components/growth_capacity.py b/temoa/extensions/growth_rates/components/growth_capacity.py index 434ba90e1..dd1c6f6aa 100644 --- a/temoa/extensions/growth_rates/components/growth_capacity.py +++ b/temoa/extensions/growth_rates/components/growth_capacity.py @@ -6,6 +6,7 @@ import temoa.components.geography as geography import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.utils import Operator, get_adjusted_existing_capacity, operator_expression if TYPE_CHECKING: @@ -89,15 +90,11 @@ def limit_growth_capacity( growth = model.limit_degrowth_capacity if degrowth else model.limit_growth_capacity rate = 1 + value(growth[r, t, op][0]) seed = value(growth[r, t, op][1]) - cap_rpt = model.v_capacity_available_by_period_and_tech - cap_indices = {(_r, _p, _t) for _r, _p, _t in cap_rpt.keys() if _t in techs and _r in regions} - periods = sorted({_p for _r, _p, _t in cap_indices}) - - if len(periods) == 0: - return Constraint.Skip - - capacity = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p) + cap = quicksum( + model.v_capacity_available_by_period_and_tech[_r, p, _t] + for _r, _t in capacity.gather_group_active_processes(model, r, p, t) + ) capacity_prev = 0.0 @@ -114,12 +111,15 @@ def limit_growth_capacity( ) else: p_prev = model.time_optimize.prev(p) - capacity_prev = quicksum(cap_rpt[_r, _p, _t] for _r, _p, _t in cap_indices if _p == p_prev) + capacity_prev = quicksum( + model.v_capacity_available_by_period_and_tech[_r, p_prev, _t] + for _r, _t in capacity.gather_group_active_processes(model, r, p_prev, t) + ) if degrowth: - expr = operator_expression(capacity_prev, Operator(op), seed + capacity * rate) + expr = operator_expression(capacity_prev, Operator(op), seed + cap * rate) else: - expr = operator_expression(capacity, Operator(op), seed + capacity_prev * rate) + expr = operator_expression(cap, Operator(op), seed + capacity_prev * rate) if isinstance(expr, bool): return Constraint.Skip diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity.py b/temoa/extensions/growth_rates/components/growth_new_capacity.py index 51c279fd4..4792a67aa 100644 --- a/temoa/extensions/growth_rates/components/growth_new_capacity.py +++ b/temoa/extensions/growth_rates/components/growth_new_capacity.py @@ -1,16 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from pyomo.environ import Constraint, quicksum, value import temoa.components.geography as geography import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.utils import Operator, operator_expression if TYPE_CHECKING: from temoa.extensions.growth_rates.core.model import GrowthRatesModel - from temoa.types import ExprLike, Period, Region, Technology + from temoa.types import ExprLike, Period, Region, Technology, Vintage def limit_growth_new_capacity_indices( @@ -84,26 +85,21 @@ def limit_growth_new_capacity( \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacity}} """ - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) - growth = model.limit_degrowth_new_capacity if degrowth else model.limit_growth_new_capacity rate = 1 + value(growth[r, t, op][0]) seed = value(growth[r, t, op][1]) - new_cap_rtv = model.v_new_capacity - - cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} - periods = sorted({_v for _r, _t, _v in cap_rtv}) - if len(periods) == 0: - return Constraint.Skip - - new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + new_cap = quicksum( + model.v_new_capacity[_r, _t, p] + for _r, _t in capacity.gather_group_built_processes(model, r, t, cast('Vintage', p)) + ) new_cap_prev = 0.0 if p == model.time_optimize.first(): if model.time_exist: + regions = geography.gather_group_regions(model, r) + techs = technology.gather_group_techs(model, t) p_prev = model.time_exist.last() new_cap_prev = quicksum( value(model.existing_capacity[_r, _t, _v]) @@ -112,7 +108,12 @@ def limit_growth_new_capacity( ) else: p_prev = model.time_optimize.prev(p) - new_cap_prev = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + new_cap_prev = quicksum( + model.v_new_capacity[_r, _t, p_prev] + for _r, _t in capacity.gather_group_built_processes( + model, r, t, cast('Vintage', p_prev) + ) + ) if degrowth: expr = operator_expression(new_cap_prev, Operator(op), seed + new_cap * rate) diff --git a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py index a158ce6b7..cadf59c3e 100644 --- a/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py +++ b/temoa/extensions/growth_rates/components/growth_new_capacity_delta.py @@ -1,16 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from pyomo.environ import Constraint, quicksum, value import temoa.components.geography as geography import temoa.components.technology as technology +from temoa.components import capacity from temoa.components.utils import Operator, operator_expression if TYPE_CHECKING: from temoa.extensions.growth_rates.core.model import GrowthRatesModel - from temoa.types import ExprLike, Period, Region, Technology + from temoa.types import ExprLike, Period, Region, Technology, Vintage def limit_growth_new_capacity_delta_indices( @@ -87,7 +88,6 @@ def limit_growth_new_capacity_delta( \qquad \forall \{r, p, t\} \in \Theta_{\text{limit\_degrowth\_capacityDelta}} """ - regions = geography.gather_group_regions(model, r) techs = technology.gather_group_techs(model, t) @@ -98,15 +98,11 @@ def limit_growth_new_capacity_delta( ) rate = 1 + value(growth[r, t, op][0]) seed = value(growth[r, t, op][1]) - new_cap_rtv = model.v_new_capacity - - cap_rtv = {(_r, _t, _v) for _r, _t, _v in new_cap_rtv.keys() if _t in techs and _r in regions} - periods = sorted({_v for _r, _t, _v in cap_rtv}) - - if len(periods) == 0: - return Constraint.Skip - new_cap = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p) + new_cap = quicksum( + model.v_new_capacity[_r, _t, p] + for _r, _t in capacity.gather_group_built_processes(model, r, t, cast('Vintage', p)) + ) new_cap_prev = 0.0 new_cap_prev2 = 0.0 @@ -128,7 +124,12 @@ def limit_growth_new_capacity_delta( ) else: p_prev = model.time_optimize.prev(p) - new_cap_prev = quicksum(new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev) + new_cap_prev = quicksum( + model.v_new_capacity[_r, _t, p_prev] + for _r, _t in capacity.gather_group_built_processes( + model, r, t, cast('Vintage', p_prev) + ) + ) if p == model.time_optimize.at(2): if model.time_exist: p_prev2 = model.time_exist.last() @@ -140,7 +141,10 @@ def limit_growth_new_capacity_delta( else: p_prev2 = model.time_optimize.prev(p_prev) new_cap_prev2 = quicksum( - new_cap_rtv[_r, _t, _v] for _r, _t, _v in cap_rtv if _v == p_prev2 + model.v_new_capacity[_r, _t, p_prev2] + for _r, _t in capacity.gather_group_built_processes( + model, r, t, cast('Vintage', p_prev2) + ) ) nc_delta_prev = new_cap_prev - new_cap_prev2 diff --git a/temoa/extensions/template/components/example_limit.py b/temoa/extensions/template/components/example_limit.py index a00570bce..aa10fccce 100644 --- a/temoa/extensions/template/components/example_limit.py +++ b/temoa/extensions/template/components/example_limit.py @@ -11,16 +11,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from pyomo.environ import Constraint, quicksum, value -import temoa.components.geography as geography -import temoa.components.technology as technology +from temoa.components import capacity if TYPE_CHECKING: from temoa.extensions.template.core.model import ExampleModel - from temoa.types import ExprLike, Period, Region, Technology + from temoa.types import ExprLike, Period, Region, Technology, Vintage def example_new_capacity_limit_indices( @@ -43,16 +42,13 @@ def example_new_capacity_limit_constraint_rule( model: ExampleModel, r: Region, p: Period, t: Technology ) -> ExprLike: """Cap total new capacity built in period ``p`` for region/group ``r``/``t``.""" - regions = geography.gather_group_regions(model, r) - techs = technology.gather_group_techs(model, t) limit = value(model.example_new_capacity_limit[r, t]) new_cap_rtv = model.v_new_capacity new_cap = quicksum( - new_cap_rtv[_r, _t, _v] - for _r, _t, _v in new_cap_rtv.keys() - if _r in regions and _t in techs and _v == p + new_cap_rtv[_r, _t, p] + for _r, _t in capacity.gather_group_built_processes(model, r, t, cast('Vintage', p)) ) if isinstance(new_cap, (int, float)): From b70f1e1611919034ae9b7e80cdc8a64b1a6c5451 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 13 Jul 2026 21:35:52 -0400 Subject: [PATCH 16/23] Remove season ramp constraints and refactor Signed-off-by: Davey Elder --- docs/source/computational_implementation.rst | 14 +- docs/source/mathematical_formulation.rst | 15 +- temoa/components/operations.py | 398 +++++-------------- temoa/components/time.py | 18 + temoa/core/model.py | 29 +- temoa/types/__init__.py | 4 + temoa/types/dict_types.py | 2 + tests/testing_data/mediumville_sets.json | 6 +- tests/testing_data/test_system_sets.json | 6 +- tests/testing_data/utopia_sets.json | 6 +- 10 files changed, 156 insertions(+), 342 deletions(-) diff --git a/docs/source/computational_implementation.rst b/docs/source/computational_implementation.rst index 943452766..5c9b0408c 100644 --- a/docs/source/computational_implementation.rst +++ b/docs/source/computational_implementation.rst @@ -268,7 +268,7 @@ which determines the valid indices from other model components. This second meth when the indices of a constraint do not exactly align with the indices of the database table. For example, the database table :code:`ramp_up_hourly` is indexed by :code:`region`, :code:`tech` in the database but one of its inheriting constraints, -:code:`ramp_up_day_constraint`, is indexed by :code:`region`, :code:`period`, +:code:`ramp_up_constraint`, is indexed by :code:`region`, :code:`period`, :code:`season`, :code:`time_of_day`, :code:`tech`, :code:`vintage` in the model. In this case, an index function is needed to determine the valid indices for the constraint. @@ -277,11 +277,11 @@ an index function is needed to determine the valid indices for the constraint. .. code-block:: python :linenos: - self.ramp_up_day_constraint_rpsdtv = Set( - dimen=6, initialize=operations.ramp_up_day_constraint_indices + self.ramp_up_constraint_rpsdtv = Set( + dimen=6, initialize=operations.ramp_up_constraint_indices ) - self.ramp_up_day_constraint = Constraint( - self.ramp_up_day_constraint_rpsdtv, rule=operations.ramp_up_day_constraint + self.ramp_up__constraint = Constraint( + self.ramp_up_constraint_rpsdtv, rule=operations.ramp_up_constraint ) .. topic:: in ``temoa/components/operations.py`` (return valid indices) @@ -289,7 +289,7 @@ an index function is needed to determine the valid indices for the constraint. .. code-block:: python :linenos: - def ramp_up_day_constraint_indices( + def ramp_up_constraint_indices( model: TemoaModel, ) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: indices = { @@ -302,7 +302,7 @@ an index function is needed to determine the valid indices for the constraint. return indices -With the sparse set (:code:`demand_constraint_rpc` or :code:`ramp_up_day_constraint_rpsdtv`) +With the sparse set (:code:`demand_constraint_rpc` or :code:`ramp_up_constraint_rpsdtv`) created, we can now can use it in place of the sets specified in the non-sparse implementation. Pyomo will now call the constraint implementation rule the minimum number of times. diff --git a/docs/source/mathematical_formulation.rst b/docs/source/mathematical_formulation.rst index 86bd4b4fc..8bbd7a21e 100644 --- a/docs/source/mathematical_formulation.rst +++ b/docs/source/mathematical_formulation.rst @@ -838,9 +838,7 @@ ramp_down_hourly The :code:`ramp_down_hourly` parameter specifies the fraction of installed capacity by which a technology can ramp output down per hour. This is used in the -:code:`ramp_down_day_constraint` (between time-of-day slices) and -:code:`ramp_down_season_constraint` (between the last time-of-day slice in one -season and the first in the next). +:code:`ramp_down_constraint`. ramp_up_hourly @@ -850,8 +848,7 @@ ramp_up_hourly The :code:`ramp_up_hourly` parameter specifies the fraction of installed capacity by which a technology can ramp output up per hour. This is used in the -:code:`ramp_up_day_constraint` (between time-of-day slices) and -:code:`ramp_up_season_constraint` (between seasons). +:code:`ramp_up_constraint`. reserve_capacity_derate @@ -1312,13 +1309,9 @@ various physical and operational real-world phenomena. .. autofunction:: temoa.components.storage.storage_throughput_constraint -.. autofunction:: temoa.components.operations.ramp_up_day_constraint +.. autofunction:: temoa.components.operations.ramp_up_constraint -.. autofunction:: temoa.components.operations.ramp_down_day_constraint - -.. autofunction:: temoa.components.operations.ramp_up_season_constraint - -.. autofunction:: temoa.components.operations.ramp_down_season_constraint +.. autofunction:: temoa.components.operations.ramp_down_constraint .. autofunction:: temoa.components.reserves.reserve_margin_static diff --git a/temoa/components/operations.py b/temoa/components/operations.py index ab49431c6..7fd9e72ab 100644 --- a/temoa/components/operations.py +++ b/temoa/components/operations.py @@ -15,7 +15,9 @@ from logging import getLogger from typing import TYPE_CHECKING -from pyomo.environ import Constraint, value +from pyomo.environ import Constraint, quicksum, value + +from temoa.components import time if TYPE_CHECKING: from temoa.core.model import TemoaModel @@ -41,7 +43,7 @@ def baseload_diurnal_constraint_indices( } -def ramp_up_day_constraint_indices( +def ramp_up_constraint_indices( model: TemoaModel, ) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: return { @@ -53,7 +55,7 @@ def ramp_up_day_constraint_indices( } -def ramp_down_day_constraint_indices( +def ramp_down_constraint_indices( model: TemoaModel, ) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: return { @@ -65,44 +67,6 @@ def ramp_down_day_constraint_indices( } -def ramp_up_season_constraint_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Season, Season, Technology, Vintage]]: - # Season-to-season ramp constraints require full inter-season ordering; - # skip for consecutive_days (no season links) and seasonal_timeslices (no TOD ordering). - if model.time_sequencing.first() in ('consecutive_days', 'seasonal_timeslices'): - return set() - - # s, s_next indexing ensures we dont build redundant constraints - return { - (r, p, s, s_next, t, v) - for r, p, t in model.ramp_up_vintages - for v in model.ramp_up_vintages[r, p, t] - for s_seq, s in model.ordered_season_sequential - for s_next in (model.sequential_to_season[model.time_next_sequential[s_seq]],) - if s_next != model.time_next[s, model.time_of_day.last()][0] - } - - -def ramp_down_season_constraint_indices( - model: TemoaModel, -) -> set[tuple[Region, Period, Season, Season, Technology, Vintage]]: - # Season-to-season ramp constraints require full inter-season ordering; - # skip for consecutive_days (no season links) and seasonal_timeslices (no TOD ordering). - if model.time_sequencing.first() in ('consecutive_days', 'seasonal_timeslices'): - return set() - - # s, s_next indexing ensures we dont build redundant constraints - return { - (r, p, s, s_next, t, v) - for r, p, t in model.ramp_down_vintages - for v in model.ramp_down_vintages[r, p, t] - for s_seq, s in model.ordered_season_sequential - for s_next in (model.sequential_to_season[model.time_next_sequential[s_seq]],) - if s_next != model.time_next[s, model.time_of_day.last()][0] - } - - # ============================================================================ # PYOMO CONSTRAINT RULES # ============================================================================ @@ -168,13 +132,13 @@ def baseload_diurnal_constraint( # So: (ActA / SegA) == (ActB / SegB) # computationally, however, multiplication is cheaper than division, so: # (ActA * SegB) == (ActB * SegA) - activity_sd = sum( + activity_sd = quicksum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) - activity_sd_0 = sum( + activity_sd_0 = quicksum( model.v_flow_out[r, p, s, d_0, S_i, t, v, S_o] for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] @@ -228,22 +192,85 @@ def create_operational_vintage_sets(model: TemoaModel) -> None: model.is_seasonal_storage[t] = t in model.tech_seasonal_storage -def ramp_up_day_constraint( +def _ramp_constraint( model: TemoaModel, + ramp_up: bool, r: Region, p: Period, s: Season, d: TimeOfDay, + s_next: Season, + d_next: TimeOfDay, t: Technology, v: Vintage, ) -> ExprLike: - r""" - One of two constraints built from the ramp_up_hourly table, along with the - ramp_up_season_constraint. ramp_up_day constrains ramp rates between time slices - within each season and ramp_up_season constrains ramp rates between sequential - seasons. If the :code:`time_sequencing` parameter is set to :code:`consecutive_days` - then the ramp_up_season constraint is skipped as seasons already connect together. + """ + Helper function to compute ramping constraints for both ramp-up and ramp-down. + + Args: + model (TemoaModel): The Temoa model instance. + ramp_up (bool): True for ramp-up constraints, False for ramp-down constraints. + r (Region): The region. + p (Period): The period. + s (Season): The season. + d (TimeOfDay): The time of day. + s_next (Season): The next season. + d_next (TimeOfDay): The next time of day. + t (Technology): The technology. + v (Vintage): The vintage. + + Returns: + Expression | Constraint.Skip: A tuple containing the activity increase + and rampable activity expressions or a constraint skip. + """ + ramping_param = model.ramp_up_hourly if ramp_up else model.ramp_down_hourly + hourly_activity_sd = quicksum( + model.v_flow_out[r, p, s, d, S_i, t, v, S_o] + for S_i in model.process_inputs[r, p, t, v] + for S_o in model.process_outputs_by_input[r, p, t, v, S_i] + ) / time.hours_in_time_slice(model, s, d) + hourly_activity_sd_next = quicksum( + model.v_flow_out[r, p, s_next, d_next, S_i, t, v, S_o] + for S_i in model.process_inputs[r, p, t, v] + for S_o in model.process_outputs_by_input[r, p, t, v, S_i] + ) / time.hours_in_time_slice(model, s_next, d_next) + + # Elapsed hours from middle of this time slice to middle of next time slice + hours_elapsed = time.tod_elapsed_hours(model, d, d_next) + ramp_fraction = hours_elapsed * value(ramping_param[r, t]) + if ramp_fraction >= 1: + msg = ( + 'Warning: Rate for {}[{}, {}] is too large to be constraining from ' + '({}, {}, {}) to ({}, {}, {}). ' + f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' + ) + logger.warning(msg.format(ramping_param.name, r, t, p, s, d, p, s_next, d_next)) + return Constraint.Skip + + activity_increase = hourly_activity_sd_next - hourly_activity_sd + rampable_activity = ( + ramp_fraction + * model.v_capacity[r, p, t, v] + * value(model.capacity_to_activity[r, t]) + / (24 * value(model.days_per_period)) + ) + if ramp_up: + return activity_increase <= rampable_activity + else: + return -activity_increase <= rampable_activity + + +def ramp_up_constraint( + model: TemoaModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" The ramp rate constraint is utilized to limit the rate of electricity generation increase and decrease between two adjacent time slices in order to account for physical limits associated with thermal power plants. This constraint is only @@ -254,10 +281,11 @@ def ramp_up_day_constraint( In a representative periods or seasonal time slices model, the next time slice, :math:`(s_{next},d_{next})`, from the end of each season, :math:`(s,d_{last})` - is the beginning of the same season, :math:`(s,d_{first})` + is the beginning of the same season, :math:`(s,d_{first})`. For these time + sequencing modes, ramp rates do not apply between seasons. .. math:: - :label: ramp_up_day + :label: ramp_up \frac{ \sum_{I,O} \mathbf{FO}_{r,p,s_{next},d_{next},i,t,v,o} @@ -273,7 +301,7 @@ def ramp_up_day_constraint( \leq RUH_{r,t} \cdot \Delta H \cdot \frac{CAP_{r,p,t,v} \cdot C2A_{r,t}}{24 \cdot DPP} \\ - \forall \{r, p, s, d, t, v\} \in \Theta_{\text{ramp\_up\_day}} + \forall \{r, p, s, d, t, v\} \in \Theta_{\text{ramp\_up}} \\ \text{where: } \Delta H = \frac{H_d + H_{d_{next}}}{2} @@ -288,57 +316,21 @@ def ramp_up_day_constraint( """ s_next, d_next = model.time_next[s, d] - - # How many hours does this time slice represent - hours_adjust = value(model.segment_fraction[s, d]) * value(model.days_per_period) * 24 - hours_adjust_next = ( - value(model.segment_fraction[s_next, d_next]) * value(model.days_per_period) * 24 - ) - - hourly_activity_sd = ( - sum( - model.v_flow_out[r, p, s, d, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust - ) - - hourly_activity_sd_next = ( - sum( - model.v_flow_out[r, p, s_next, d_next, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust_next - ) - - # elapsed hours from middle of this time slice to middle of next time slice - hours_elapsed = (model.time_of_day_hours[d] + model.time_of_day_hours[d_next]) / 2 - ramp_fraction = hours_elapsed * value(model.ramp_up_hourly[r, t]) - - if ramp_fraction >= 1: - msg = ( - 'Warning: Hourly ramp up rate ({}, {}) is too large to be constraining from ' - '({}, {}, {}) to ({}, {}, {}). ' - f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' - ) - logger.warning(msg.format(r, t, p, s, d, p, s_next, d_next)) - return Constraint.Skip - - activity_increase = hourly_activity_sd_next - hourly_activity_sd # opposite sign from rampdown - rampable_activity = ( - ramp_fraction - * model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - / (24 * value(model.days_per_period)) # adjust capacity to hourly basis + return _ramp_constraint( + model=model, + ramp_up=True, + r=r, + p=p, + s=s, + d=d, + s_next=s_next, + d_next=d_next, + t=t, + v=v, ) - expr = activity_increase <= rampable_activity - - return expr -def ramp_down_day_constraint( +def ramp_down_constraint( model: TemoaModel, r: Region, p: Period, @@ -349,11 +341,11 @@ def ramp_down_day_constraint( ) -> ExprLike: r""" - Similar to the :code`ramp_up_day` constraint, we use the :code:`ramp_down_day` + Similar to the :code`ramp_up` constraint, we use the :code:`ramp_down` constraint to limit ramp down rates between any two adjacent time slices. .. math:: - :label: ramp_down_day + :label: ramp_down \frac{ \sum_{I,O} \mathbf{FO}_{r,p,s,d,i,t,v,o} @@ -369,197 +361,21 @@ def ramp_down_day_constraint( \leq RDH_{r,t} \cdot \Delta H \cdot \frac{CAP_{r,p,t,v} \cdot C2A_{r,t}}{24 \cdot DPP} \\ - \forall \{r, p, s, d, t, v\} \in \Theta_{\text{ramp\_down\_day}} + \forall \{r, p, s, d, t, v\} \in \Theta_{\text{ramp\_down}} \\ \text{where: } \Delta H = \frac{H_d + H_{d_{next}}}{2} """ s_next, d_next = model.time_next[s, d] - - # How many hours does this time slice represent - hours_adjust = value(model.segment_fraction[s, d]) * value(model.days_per_period) * 24 - hours_adjust_next = ( - value(model.segment_fraction[s_next, d_next]) * value(model.days_per_period) * 24 - ) - - hourly_activity_sd = ( - sum( - model.v_flow_out[r, p, s, d, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust - ) - - hourly_activity_sd_next = ( - sum( - model.v_flow_out[r, p, s_next, d_next, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust_next - ) - - # elapsed hours from middle of this time slice to middle of next time slice - hours_elapsed = (model.time_of_day_hours[d] + model.time_of_day_hours[d_next]) / 2 - ramp_fraction = hours_elapsed * value(model.ramp_down_hourly[r, t]) - - if ramp_fraction >= 1: - msg = ( - 'Warning: Hourly ramp down rate ({}, {}) is too large to be constraining from ' - '({}, {}, {}) to ({}, {}, {}). ' - f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' - ) - logger.warning(msg.format(r, t, p, s, d, p, s_next, d_next)) - return Constraint.Skip - - activity_decrease = hourly_activity_sd - hourly_activity_sd_next # opposite sign from rampup - rampable_activity = ( - ramp_fraction - * model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - / (24 * value(model.days_per_period)) # adjust capacity to hourly basis + return _ramp_constraint( + model=model, + ramp_up=False, + r=r, + p=p, + s=s, + d=d, + s_next=s_next, + d_next=d_next, + t=t, + v=v, ) - expr = activity_decrease <= rampable_activity - - return expr - - -def ramp_up_season_constraint( - model: TemoaModel, - r: Region, - p: Period, - s: Season, - s_next: Season, - t: Technology, - v: Vintage, -) -> ExprLike: - r""" - Constrains the ramp up rate of activity between time slices at the boundary - of sequential seasons. Same as ramp_up_day but only applies to the boundary - between sequential seasons, i.e., :math:`(s^{seq},d_{last})` to - :math:`(s^{seq}_{next},d_{first})` - and :math:`s^{seq}_{next}` is based on the TimeSequential table rather than the - time_season table. - """ - - d = model.time_of_day.last() - d_next = model.time_of_day.first() - - # How many hours does this time slice represent - hours_adjust = value(model.segment_fraction[s, d]) * value(model.days_per_period) * 24 - hours_adjust_next = ( - value(model.segment_fraction[s_next, d_next]) * value(model.days_per_period) * 24 - ) - - hourly_activity_sd = ( - sum( - model.v_flow_out[r, p, s, d, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust - ) - - hourly_activity_sd_next = ( - sum( - model.v_flow_out[r, p, s_next, d_next, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust_next - ) - - # elapsed hours from middle of this time slice to middle of next time slice - hours_elapsed = (model.time_of_day_hours[d] + model.time_of_day_hours[d_next]) / 2 - ramp_fraction = hours_elapsed * value(model.ramp_up_hourly[r, t]) - - if ramp_fraction >= 1: - msg = ( - 'Warning: Hourly ramp up rate ({}, {}) is too large to be constraining from ' - '({}, {}, {}) to ({}, {}, {}). ' - f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' - ) - logger.warning(msg.format(r, t, p, s, d, p, s_next, d_next)) - return Constraint.Skip - - activity_increase = hourly_activity_sd_next - hourly_activity_sd # opposite sign from rampdown - rampable_activity = ( - ramp_fraction - * model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - / (24 * value(model.days_per_period)) # adjust capacity to hourly basis - ) - expr = activity_increase <= rampable_activity - - return expr - - -def ramp_down_season_constraint( - model: TemoaModel, - r: Region, - p: Period, - s: Season, - s_next: Season, - t: Technology, - v: Vintage, -) -> ExprLike: - r""" - Constrains the ramp down rate of activity between time slices at the boundary - of sequential seasons. Same as ramp_down_day but only applies to the boundary - between sequential seasons, i.e., :math:`(s^{seq},d_{last})` to - :math:`(s^{seq}_{next},d_{first})` - and :math:`s^{seq}_{next}` is based on the TimeSequential table rather than the - time_season table. - """ - - d = model.time_of_day.last() - d_next = model.time_of_day.first() - - # How many hours does this time slice represent - hours_adjust = value(model.segment_fraction[s, d]) * value(model.days_per_period) * 24 - hours_adjust_next = ( - value(model.segment_fraction[s_next, d_next]) * value(model.days_per_period) * 24 - ) - - hourly_activity_sd = ( - sum( - model.v_flow_out[r, p, s, d, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust - ) - - hourly_activity_sd_next = ( - sum( - model.v_flow_out[r, p, s_next, d_next, S_i, t, v, S_o] - for S_i in model.process_inputs[r, p, t, v] - for S_o in model.process_outputs_by_input[r, p, t, v, S_i] - ) - / hours_adjust_next - ) - - # elapsed hours from middle of this time slice to middle of next time slice - hours_elapsed = (model.time_of_day_hours[d] + model.time_of_day_hours[d_next]) / 2 - ramp_fraction = hours_elapsed * value(model.ramp_down_hourly[r, t]) - - if ramp_fraction >= 1: - msg = ( - 'Warning: Hourly ramp down rate ({}, {}) is too large to be constraining from ' - '({}, {}, {}) to ({}, {}, {}). ' - f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' - ) - logger.warning(msg.format(r, t, p, s, d, p, s_next, d_next)) - return Constraint.Skip - - activity_decrease = hourly_activity_sd - hourly_activity_sd_next # opposite sign from rampup - rampable_activity = ( - ramp_fraction - * model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - / (24 * value(model.days_per_period)) # adjust capacity to hourly basis - ) - expr = activity_decrease <= rampable_activity - - return expr diff --git a/temoa/components/time.py b/temoa/components/time.py index cdbb05db4..0422041ca 100644 --- a/temoa/components/time.py +++ b/temoa/components/time.py @@ -400,3 +400,21 @@ def create_time_season_to_sequential(model: TemoaModel) -> None: f', time_season_sequential: {(s, season_frac_seq)}' ) logger.warning(msg) + + +def tod_elapsed_hours( + model: TemoaModel, + d: TimeOfDay, + d_next: TimeOfDay, +) -> float: + """Helper function to get elapsed hours from one time slice to another. + Note this uses unadjusted time; i.e., we ignore the segment_fraction_per_season + adjustment where one season could be representing multiple days, enlarging + the segment fraction of each time slice.""" + return (model.time_of_day_hours[d] + model.time_of_day_hours[d_next]) / 2 + + +def hours_in_time_slice(model: TemoaModel, s: Season, d: TimeOfDay) -> float: + """Helper function to get the number of hours represented by a time slice. + Useful for, e.g., adjusting activity to activity per hour.""" + return value(model.segment_fraction[s, d]) * value(model.days_per_period) * 24 diff --git a/temoa/core/model.py b/temoa/core/model.py index 62afbe489..c468d2b93 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -952,30 +952,17 @@ def __init__( rule=storage.limit_storage_fraction_constraint, ) - self.ramp_up_day_constraint_rpsdtv = Set( - dimen=6, initialize=operations.ramp_up_day_constraint_indices + self.ramp_up_constraint_rpsdtv = Set( + dimen=6, initialize=operations.ramp_up_constraint_indices ) - self.ramp_up_day_constraint = Constraint( - self.ramp_up_day_constraint_rpsdtv, rule=operations.ramp_up_day_constraint + self.ramp_down_constraint_rpsdtv = Set( + dimen=6, initialize=operations.ramp_down_constraint_indices ) - self.ramp_down_day_constraint_rpsdtv = Set( - dimen=6, initialize=operations.ramp_down_day_constraint_indices + self.ramp_down_constraint = Constraint( + self.ramp_down_constraint_rpsdtv, rule=operations.ramp_down_constraint ) - self.ramp_down_day_constraint = Constraint( - self.ramp_down_day_constraint_rpsdtv, rule=operations.ramp_down_day_constraint - ) - - self.ramp_up_season_constraint_rpsstv = Set( - dimen=6, initialize=operations.ramp_up_season_constraint_indices - ) - self.ramp_up_season_constraint = Constraint( - self.ramp_up_season_constraint_rpsstv, rule=operations.ramp_up_season_constraint - ) - self.ramp_down_season_constraint_rpsstv = Set( - dimen=6, initialize=operations.ramp_down_season_constraint_indices - ) - self.ramp_down_season_constraint = Constraint( - self.ramp_down_season_constraint_rpsstv, rule=operations.ramp_down_season_constraint + self.ramp_up_constraint = Constraint( + self.ramp_up_constraint_rpsdtv, rule=operations.ramp_up_constraint ) self.reserve_margin_rpsd = Set(dimen=4, initialize=reserves.reserve_margin_indices) diff --git a/temoa/types/__init__.py b/temoa/types/__init__.py index 022a245d9..78d4a98ac 100644 --- a/temoa/types/__init__.py +++ b/temoa/types/__init__.py @@ -40,6 +40,8 @@ 'ProcessReservePeriodsDict', 'ProcessTechsDict', 'ProcessVintagesDict', + 'GroupBuiltProcessesDict', + 'GroupActiveProcessesDict', 'RampDownVintagesDict', 'RampUpVintagesDict', 'RetirementPeriodsDict', @@ -102,6 +104,8 @@ CurtailmentVintagesDict, EfficiencyVariableDict, ExportRegionsDict, + GroupActiveProcessesDict, + GroupBuiltProcessesDict, ImportRegionsDict, InputSplitAnnualVintagesDict, InputSplitVintagesDict, diff --git a/temoa/types/dict_types.py b/temoa/types/dict_types.py index e0023277b..380e4f772 100644 --- a/temoa/types/dict_types.py +++ b/temoa/types/dict_types.py @@ -22,6 +22,8 @@ ProcessPeriodsDict = dict[tuple[Region, Technology, Vintage], set[Period]] RetirementPeriodsDict = dict[tuple[Region, Technology, Vintage], set[Period]] ProcessVintagesDict = dict[tuple[Region, Period, Technology], set[Vintage]] +GroupBuiltProcessesDict = dict[tuple[Region, Technology, Vintage], set[tuple[Region, Technology]]] +GroupActiveProcessesDict = dict[tuple[Region, Period, Technology], set[tuple[Region, Technology]]] SurvivalCurvePeriodsDict = dict[tuple[Region, Technology, Vintage], set[Period]] CapacityConsumptionTechsDict = dict[tuple[Region, Period, Commodity], set[Technology]] RetirementProductionProcessesDict = dict[ diff --git a/tests/testing_data/mediumville_sets.json b/tests/testing_data/mediumville_sets.json index acc5b16ea..97cf8566a 100644 --- a/tests/testing_data/mediumville_sets.json +++ b/tests/testing_data/mediumville_sets.json @@ -59,10 +59,8 @@ "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", "ordered_season_sequential": "53bc15b6510e5bad389153a661165e5a434fea3f17ba2c8b3919568edf124f72", "process_life_frac_rptv": "fdb177f119430de12c8b1083c9275e3a256f45f859d6b6819a84bff5c8e92184", - "ramp_down_day_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", - "ramp_down_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_day_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", - "ramp_up_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_down_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", + "ramp_up_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", "regional_exchange_capacity_constraint_rrptv": "cf641ca7d7b664fda48e76f03b37c3b81aaa130ce821f919e6a8ac4860dad100", "regional_global_indices": "a4bd2969735cb437072971a2fa02a2d8fbb20a707a1bdfa695b92922daeb5d10", "regional_indices": "6d8dc3bc6dc8cd485bcf2a59d752df20974b0fd78cc623e3a51e705a01edeea4", diff --git a/tests/testing_data/test_system_sets.json b/tests/testing_data/test_system_sets.json index f5a33a549..8a7d66f84 100644 --- a/tests/testing_data/test_system_sets.json +++ b/tests/testing_data/test_system_sets.json @@ -59,10 +59,8 @@ "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", "ordered_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "process_life_frac_rptv": "d1b75ede9f90899b1c1cbc56489bf9d12c52abc6c0466eb5fe4dccaf6f3be800", - "ramp_down_day_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_down_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_day_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_down_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_up_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "regional_exchange_capacity_constraint_rrptv": "ae0671daa1263140faff22e3d2610c1fbab942d4e814892a50471adfa1bcc740", "regional_global_indices": "92fa6c5d5745d765d6e16ad1bca7e1fc72f4377273be7cfbfde626ca1967d81b", "regional_indices": "f74187f92c4fdb3c12d5610304c7ac9696001433150bdaa9ff20793fb6365b32", diff --git a/tests/testing_data/utopia_sets.json b/tests/testing_data/utopia_sets.json index 6d9adc1a5..75530fa95 100644 --- a/tests/testing_data/utopia_sets.json +++ b/tests/testing_data/utopia_sets.json @@ -59,10 +59,8 @@ "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", "ordered_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "process_life_frac_rptv": "63d17957ee2bebf664ffa6a39cea5e16ec65ad07db1df24dcea6b15bb9ebf589", - "ramp_down_day_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_down_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_day_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_season_constraint_rpsstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_down_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_up_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "regional_exchange_capacity_constraint_rrptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "regional_global_indices": "ce00905893c23bc59c15dcf61d7e261c2e97ab1fc298ed0e0ccb1344f1cace37", "regional_indices": "ce00905893c23bc59c15dcf61d7e261c2e97ab1fc298ed0e0ccb1344f1cace37", From 5e448e777758ad0c50621fbff84e964d099e34ae Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Mon, 13 Jul 2026 21:38:17 -0400 Subject: [PATCH 17/23] Some miscellaneous refactoring/tidy up Signed-off-by: Davey Elder --- docs/source/database_schema.mmd | 54 ------------------- temoa/_internal/table_data_puller.py | 13 ++--- temoa/core/model.py | 18 ++++--- .../economies_of_scale/core/data_puller.py | 2 - .../economies_of_scale/core/model.py | 2 - 5 files changed, 15 insertions(+), 74 deletions(-) diff --git a/docs/source/database_schema.mmd b/docs/source/database_schema.mmd index 0bc047252..84f580141 100644 --- a/docs/source/database_schema.mmd +++ b/docs/source/database_schema.mmd @@ -275,33 +275,6 @@ limit_capacity_share { TEXT notes REAL share } -limit_degrowth_capacity { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} -limit_degrowth_new_capacity { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} -limit_degrowth_new_capacity_delta { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} limit_emission { TEXT emis_comm PK TEXT operator PK @@ -311,33 +284,6 @@ limit_emission { TEXT units REAL value } -limit_growth_capacity { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} -limit_growth_new_capacity { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} -limit_growth_new_capacity_delta { - TEXT operator PK - TEXT region PK - TEXT tech_or_group PK - TEXT notes - REAL rate - REAL seed - TEXT seed_units -} limit_new_capacity { TEXT operator PK INTEGER period PK diff --git a/temoa/_internal/table_data_puller.py b/temoa/_internal/table_data_puller.py index 3973832a7..9ef239e7c 100644 --- a/temoa/_internal/table_data_puller.py +++ b/temoa/_internal/table_data_puller.py @@ -20,7 +20,6 @@ from temoa._internal.exchange_tech_cost_ledger import CostType, ExchangeTechCostLedger from temoa.components import costs from temoa.components.utils import get_variable_efficiency -from temoa.extensions.economies_of_scale.core import data_puller as eos from temoa.types.model_types import EI, FI, SLI, CapData, FlowType if TYPE_CHECKING: @@ -491,14 +490,10 @@ def poll_cost_results( } ) - # Get nonlinear costs from the EOS extension, if active - eos.poll_costs( - model=model, - exchange_costs=exchange_costs, - entries=entries, - p_0=p_0_true, - epsilon=epsilon, - ) + if 'eos' in model.enabled_extensions: + import temoa.extensions.economies_of_scale.core.data_puller as eos + + eos.poll_costs(model, exchange_costs, entries, p_0_true, epsilon) exchange_entries = exchange_costs.get_entries() return entries, exchange_entries diff --git a/temoa/core/model.py b/temoa/core/model.py index c468d2b93..c919a70d7 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -39,9 +39,6 @@ technology, time, ) -from temoa.extensions.economies_of_scale.core.model import ( - register_early_eos_components, -) from temoa.extensions.framework import apply_model_extension_hooks, resolve_extension_specs from temoa.model_checking.validators import ( no_slash_or_pipe, @@ -570,7 +567,10 @@ def __init__( self.cost_invest = Param(self.cost_invest_rtv) # Inject cost_invest_eos extension components prior to loan params, if active - register_early_eos_components(self) + if 'eos' in self.enabled_extensions: + import temoa.extensions.economies_of_scale.core.model as eos + + eos.register_early_eos_components(self) self.default_loan_rate = Param(domain=NonNegativeReals) self.loan_rate = Param( @@ -687,9 +687,13 @@ def __init__( self.seasonal_storage_constraints_rpsdtv = Set( dimen=6, initialize=storage.seasonal_storage_constraint_indices ) - self.limit_storage_fraction_param_rsdt = ( - Set() - ) # populated by hybrid_loader with (r, s, d, t, op) keys + self.limit_storage_fraction_param_rsdt = Set( + within=self.regional_global_indices + * (self.time_season | self.time_season_sequential) + * self.time_of_day + * self.tech_storage + * self.operator + ) self.limit_storage_fraction = Param( self.limit_storage_fraction_param_rsdt, validate=validate_0to1 ) diff --git a/temoa/extensions/economies_of_scale/core/data_puller.py b/temoa/extensions/economies_of_scale/core/data_puller.py index d34a6279c..59ecf12a8 100644 --- a/temoa/extensions/economies_of_scale/core/data_puller.py +++ b/temoa/extensions/economies_of_scale/core/data_puller.py @@ -33,8 +33,6 @@ def poll_costs( Poll the fixed and variable costs for all EOS clusters in the planning horizon and add them to the cost entries for the model. """ - if 'eos' not in model.enabled_extensions: - return model = cast('EOSModel', model) global_discount_rate = value(model.global_discount_rate) diff --git a/temoa/extensions/economies_of_scale/core/model.py b/temoa/extensions/economies_of_scale/core/model.py index 6b1298a41..42b4656a1 100644 --- a/temoa/extensions/economies_of_scale/core/model.py +++ b/temoa/extensions/economies_of_scale/core/model.py @@ -82,8 +82,6 @@ class EOSModel(TemoaModel): def register_early_eos_components(model: TemoaModel) -> None: """Build cost_invest_eos components that must be instantiated before loan parameters.""" - if 'eos' not in model.enabled_extensions: - return m = cast('EOSModel', model) m.cost_invest_eos_rtn = Set( From c797b1dd56babbe12ae4d1b8289d9081f7d49f84 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 22 Jul 2026 11:15:05 -0400 Subject: [PATCH 18/23] Refactor existing components in preparation for unit_commitment connections Signed-off-by: Davey Elder --- temoa/components/capacity.py | 18 +- temoa/components/operations.py | 77 +++++---- temoa/components/reserves.py | 292 +++++++++++++++++---------------- temoa/components/storage.py | 51 ++---- temoa/components/utils.py | 55 ++++++- temoa/core/model.py | 2 + 6 files changed, 262 insertions(+), 233 deletions(-) diff --git a/temoa/components/capacity.py b/temoa/components/capacity.py index 988a9c915..78b681346 100644 --- a/temoa/components/capacity.py +++ b/temoa/components/capacity.py @@ -20,7 +20,7 @@ from temoa.components import geography, technology -from .utils import get_adjusted_existing_capacity, get_capacity_factor +from .utils import get_adjusted_existing_capacity, get_available_output if TYPE_CHECKING: from temoa.core.model import TemoaModel @@ -463,21 +463,9 @@ def capacity_constraint( for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) - return ( - get_capacity_factor(model, r, s, d, t, v) - * value(model.capacity_to_activity[r, t]) - * value(model.segment_fraction[s, d]) - * model.v_capacity[r, p, t, v] - == useful_activity + curtailment - ) + return get_available_output(model, r, p, s, d, t, v) == useful_activity + curtailment else: - return ( - get_capacity_factor(model, r, s, d, t, v) - * value(model.capacity_to_activity[r, t]) - * value(model.segment_fraction[s, d]) - * model.v_capacity[r, p, t, v] - >= useful_activity - ) + return get_available_output(model, r, p, s, d, t, v) >= useful_activity def adjusted_capacity_constraint( diff --git a/temoa/components/operations.py b/temoa/components/operations.py index 7fd9e72ab..df60d38c9 100644 --- a/temoa/components/operations.py +++ b/temoa/components/operations.py @@ -20,6 +20,8 @@ from temoa.components import time if TYPE_CHECKING: + from pyomo.core.base import Expression + from temoa.core.model import TemoaModel from temoa.types import ExprLike from temoa.types.core_types import Period, Region, Season, Technology, TimeOfDay, Vintage @@ -192,7 +194,7 @@ def create_operational_vintage_sets(model: TemoaModel) -> None: model.is_seasonal_storage[t] = t in model.tech_seasonal_storage -def _ramp_constraint( +def _ramp_activity_increase( model: TemoaModel, ramp_up: bool, r: Region, @@ -203,25 +205,12 @@ def _ramp_constraint( d_next: TimeOfDay, t: Technology, v: Vintage, -) -> ExprLike: - """ - Helper function to compute ramping constraints for both ramp-up and ramp-down. - - Args: - model (TemoaModel): The Temoa model instance. - ramp_up (bool): True for ramp-up constraints, False for ramp-down constraints. - r (Region): The region. - p (Period): The period. - s (Season): The season. - d (TimeOfDay): The time of day. - s_next (Season): The next season. - d_next (TimeOfDay): The next time of day. - t (Technology): The technology. - v (Vintage): The vintage. - - Returns: - Expression | Constraint.Skip: A tuple containing the activity increase - and rampable activity expressions or a constraint skip. +) -> tuple[Expression, float] | None: + """Compute the hourly activity increase between adjacent time slices and the + ramp fraction for the given direction. + + Returns ``(activity_increase, ramp_fraction)`` or ``None`` when the ramp + rate is so large that the constraint would never bind (skip). """ ramping_param = model.ramp_up_hourly if ramp_up else model.ramp_down_hourly @@ -246,19 +235,51 @@ def _ramp_constraint( f'Should be less than {1 / hours_elapsed:.4f}. Constraint skipped.' ) logger.warning(msg.format(ramping_param.name, r, t, p, s, d, p, s_next, d_next)) - return Constraint.Skip + return None activity_increase = hourly_activity_sd_next - hourly_activity_sd - rampable_activity = ( + return activity_increase, ramp_fraction + + +def _rampable_activity( + model: TemoaModel, + r: Region, + p: Period, + t: Technology, + v: Vintage, + ramp_fraction: float, +) -> ExprLike: + """Rampable activity for the core model: fraction of total installed capacity.""" + return ( ramp_fraction * model.v_capacity[r, p, t, v] * value(model.capacity_to_activity[r, t]) / (24 * value(model.days_per_period)) ) + + +def _ramp_constraint( + model: TemoaModel, + ramp_up: bool, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + """Helper that composes :func:`_ramp_activity_increase` and + :func:`_rampable_activity` into the core-model ramp constraint.""" + s_next, d_next = model.time_next[s, d] + result = _ramp_activity_increase(model, ramp_up, r, p, s, d, s_next, d_next, t, v) + if result is None: + return Constraint.Skip + activity_increase, ramp_fraction = result + rampable = _rampable_activity(model, r, p, t, v, ramp_fraction) if ramp_up: - return activity_increase <= rampable_activity + return activity_increase <= rampable else: - return -activity_increase <= rampable_activity + return -activity_increase <= rampable def ramp_up_constraint( @@ -314,8 +335,6 @@ def ramp_up_constraint( i.e. :math:`(H_d + H_{d_{next}}) / 2` - :math:`CAP \cdot C2A / (24 \cdot DPP)` gives the maximum hourly capacity """ - - s_next, d_next = model.time_next[s, d] return _ramp_constraint( model=model, ramp_up=True, @@ -323,8 +342,6 @@ def ramp_up_constraint( p=p, s=s, d=d, - s_next=s_next, - d_next=d_next, t=t, v=v, ) @@ -365,8 +382,6 @@ def ramp_down_constraint( \\ \text{where: } \Delta H = \frac{H_d + H_{d_{next}}}{2} """ - - s_next, d_next = model.time_next[s, d] return _ramp_constraint( model=model, ramp_up=False, @@ -374,8 +389,6 @@ def ramp_down_constraint( p=p, s=s, d=d, - s_next=s_next, - d_next=d_next, t=t, v=v, ) diff --git a/temoa/components/reserves.py b/temoa/components/reserves.py index 390ffc718..9b519d89a 100644 --- a/temoa/components/reserves.py +++ b/temoa/components/reserves.py @@ -13,9 +13,9 @@ from logging import getLogger from typing import TYPE_CHECKING -from pyomo.environ import Constraint, value +from pyomo.environ import Constraint, Expression, value -from .utils import get_capacity_factor, get_variable_efficiency +from .utils import get_available_output, get_variable_efficiency if TYPE_CHECKING: from temoa.core.model import TemoaModel @@ -45,66 +45,15 @@ def reserve_margin_indices(model: TemoaModel) -> set[tuple[Region, Period, Seaso # ============================================================================ -def reserve_margin_dynamic( +def _available_activity_dynamic( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay -) -> ExprLike: - r""" - A dynamic alternative to the traditional, static reserve margin constraint. Capacity values - are calculated from availability of generation in each hour—like an operating reserve margin—\ - accounting for a capacity derate factor subtracting, for example, forced outage due to icing. - - .. math:: - :label: reserve_margin_dynamic - - &\sum_{t \in T^{res} \setminus T^{x} \setminus T^s,\ V} CFP_{r,s^*,d^*,t,v}\ - \cdot RCD_{r,s^*,t,v}\ - \cdot \mathbf{CAP}_{r,p,t,v} \cdot SEG_{s^*,d^*}\ - \cdot C2A_{r,t} \\ - &+ \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r_i - r, s^*, d^*, t, v}\ - \cdot RCD_{r_i - r, s^*, t, v}\ - \cdot \mathbf{CAP}_{r_i - r,p,t,v} \cdot SEG_{s^*,d^*}\ - \cdot C2A_{r_i - r, t} \\ - &- \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r - r_i, s^*, d^*, t, v}\ - \cdot RCD_{r - r_i, s^*, t, v}\ - \cdot \mathbf{CAP}_{r - r_i,p,t,v}\ - \cdot SEG_{s^*,d^*} \cdot C2A_{r - r_i, t} \\ - &+ \sum_{t \in (T^s \cap T^{res}), V, I, O} \ - \left(\ - \mathbf{FO}_{r,p,s,d,i,t,v,o} - \mathbf{FI}_{r,p,s,d,i,t,v,o}\ - \right)\ - \cdot RCD_{r,s,t,v} \\ - &\geq\ - \left[\ - \sum_{t \in T^{res} \setminus T^{x} \setminus T^a, V, I, O}\ - \mathbf{FO}_{r, p, s, d, i, t, v, o}\ - \right. \\ - &+ \sum_{t \in T^{res} \cap T^a, V, I, O} - \begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\ - SEG_{s,d} & \text{otherwise} \end{cases} - \cdot \mathbf{FOA}_{r, p, i, t, v, o} \\ - &+ \sum_{t \in T^{res} \cap T^{x}, V, I, O} \ - \mathbf{FO}_{r_i - r, p, s, d, i, t, v, o} \\ - &- \sum_{t \in T^{res} \cap T^{x}, V, I, O} \ - \mathbf{FI}_{r - r_i, p, s, d, i, t, v, o} \\ - &- \left. \sum_{t \in T^{res} \cap T^{s}, V, I, O} \ - \mathbf{FI}_{r, p, s, d, i, t, v, o} \right] \cdot (1 + PRM_r) \\ - \\ - &\qquad \qquad \forall \{r, p, s, d\} \in \ - \Theta_{\text{ReserveMargin}} \text{ and } \forall r_i \in R - """ - if (not model.tech_reserve) or ( - (r, p) not in model.process_reserve_periods - ): # If reserve set empty or if r,p not in M.processReservePeriod, skip the constraint - return Constraint.Skip +) -> Expression: # Everything but storage and exchange techs # Derated available generation available = sum( - model.v_capacity[r, p, t, v] + get_available_output(model, r, p, s, d, t, v) * value(model.reserve_capacity_derate[r, s, t, v]) - * get_capacity_factor(model, r, s, d, t, v) - * value(model.capacity_to_activity[r, t]) - * value(model.segment_fraction[s, d]) for (t, v) in model.process_reserve_periods[r, p] if t not in model.tech_uncap and t not in model.tech_storage ) @@ -144,85 +93,28 @@ def reserve_margin_dynamic( continue r1, r2 = r1r2.split('-') + output = sum( + get_available_output(model, r1r2, p, s, d, t, v) + * value(model.reserve_capacity_derate[r1r2, s, t, v]) + for (t, v) in model.process_reserve_periods[r1r2, p] + ) + # Only consider exchange technologies connecting to this region if r2 == r: # Add the firm capacity commitment TO this region # (this region was guaranteed an import of power) - available += sum( - model.v_capacity[r1r2, p, t, v] - * value(model.reserve_capacity_derate[r1r2, s, t, v]) - * get_capacity_factor(model, r1r2, s, d, t, v) - * value(model.capacity_to_activity[r1r2, t]) - * value(model.segment_fraction[s, d]) - for (t, v) in model.process_reserve_periods[r1r2, p] - ) + available += output elif r1 == r: # Subtract the firm capacity commitment FROM this region # (this region guaranteed an export of power) - available -= sum( - model.v_capacity[r1r2, p, t, v] - * value(model.reserve_capacity_derate[r1r2, s, t, v]) - * get_capacity_factor(model, r1r2, s, d, t, v) - * value(model.capacity_to_activity[r1r2, t]) - * value(model.segment_fraction[s, d]) - for (t, v) in model.process_reserve_periods[r1r2, p] - ) + available -= output return available -def reserve_margin_static( +def _available_activity_static( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay -) -> ExprLike: - r""" - - During each period :math:`p`, the sum of capacity values of all reserve - technologies :math:`\sum_{t \in T^{res}} \textbf{CAP}_{r,p,t,v}`, which are - defined in the set :math:`\textbf{T}^{res}`, should exceed the peak load by - :math:`PRM`, the regional reserve margin. Note that the reserve - margin is expressed in percentage of the peak load. Generally speaking, in - a database we may not know the peak demand before running the model, therefore, - we write this equation for all the time-slices defined in the database in each region. - Each generator is allowed to contribute its available capacity times a pre-defined - capacity credit, :math:`CC_{r,p,t,v}`. - - For exchange technologies (i.e., inter-regional transmission), reserve contributions - are added to the downstream region but *subtracted* from the upstream region. This is - because, since they are not generating any power, their summed contribution across - regions should be zero. - - .. math:: - :label: reserve_margin_static - - &\sum_{t \in T^{res} \setminus T^{x}, V} {CC_{r,p,t,v} - \cdot \textbf{CAP}_{r,p,t,v} \cdot - SEG_{s^*,d^*} \cdot C2A_{r,t} }\\ - &+ \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r_i-r,p,t,v} - \cdot \textbf{CAP}_{r_i-r,p,t,v} \cdot - SEG_{s^*,d^*} \cdot C2A_{r_i-r,t} }\\ - &- \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r-r_i,p,t,v} - \cdot \textbf{CAP}_{r-r_i,p,t,v} \cdot - SEG_{s^*,d^*} \cdot C2A_{r-r_i,t} }\\ - &\geq \left [ \sum_{ t \in T^{res} \setminus T^{x} \setminus T^a,V,I,O } - \textbf{FO}_{r, p, s, d, i, t, v, o}\right.\\ - &+ \sum_{ t \in T^{res} \cap T^a,V,I,O } - \begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\ - SEG_{s,d} & \text{otherwise} \end{cases} - \cdot \textbf{FOA}_{r, p, i, t, v, o}\\ - &+ \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FO}_{r_i-r, p, s, d, i, t, v, o}\\ - &- \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FI}_{r-r_i, p, s, d, i, t, v, o}\\ - &- \left.\sum_{ t \in T^{res} \cap T^{s},V,I,O } \textbf{FI}_{r, p, s, d, i, t, v, o} - \right] - \cdot (1 + PRM_r)\\ - - \\ - &\qquad\qquad\forall \{r, p, s, d\} \in \Theta_{\text{ReserveMargin}} \text{and} \forall - r_i \in R - """ - if (not model.tech_reserve) or ( - (r, p) not in model.process_reserve_periods - ): # If reserve set empty or if r,p not in M.processReservePeriod, skip the constraint - return Constraint.Skip +) -> Expression: available = sum( value(model.capacity_credit[r, p, t, v]) @@ -276,27 +168,9 @@ def reserve_margin_static( return available -# ============================================================================ -# PYOMO CONSTRAINT RULE -# ============================================================================ - - -def reserve_margin_constraint( +def _required_available_activity( model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay ) -> ExprLike: - # Get available generation in this time slice depending on method specified in config file - match model.reserve_margin_method.first(): - case 'static': - available = reserve_margin_static(model, r, p, s, d) - case 'dynamic': - available = reserve_margin_dynamic(model, r, p, s, d) - case _: - msg = ( - f"Invalid reserve margin parameter '{model.reserve_margin_method.first()}'. " - 'Check the config file.' - ) - logger.error(msg) - raise ValueError(msg) # In most Temoa input databases, demand is endogenous, so we use electricity # generation instead as a proxy for electricity demand. @@ -367,4 +241,136 @@ def reserve_margin_constraint( ) requirement = total_generation * (1 + value(model.planning_reserve_margin[r])) - return available >= requirement + return requirement + + +# ============================================================================ +# PYOMO CONSTRAINT RULE +# ============================================================================ + + +def reserve_margin_dynamic( + model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay +) -> Constraint: + r""" + A dynamic alternative to the traditional, static reserve margin constraint. Capacity values + are calculated from availability of generation in each hour—like an operating reserve margin—\ + accounting for a capacity derate factor subtracting, for example, forced outage due to icing. + + .. math:: + :label: reserve_margin_dynamic + + &\sum_{t \in T^{res} \setminus T^{x} \setminus T^s,\ V} CFP_{r,s^*,d^*,t,v}\ + \cdot RCD_{r,s^*,t,v}\ + \cdot \mathbf{CAP}_{r,p,t,v} \cdot SEG_{s^*,d^*}\ + \cdot C2A_{r,t} \\ + &+ \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r_i - r, s^*, d^*, t, v}\ + \cdot RCD_{r_i - r, s^*, t, v}\ + \cdot \mathbf{CAP}_{r_i - r,p,t,v} \cdot SEG_{s^*,d^*}\ + \cdot C2A_{r_i - r, t} \\ + &- \sum_{t \in T^{res} \cap T^{x} \setminus T^s,\ V} CFP_{r - r_i, s^*, d^*, t, v}\ + \cdot RCD_{r - r_i, s^*, t, v}\ + \cdot \mathbf{CAP}_{r - r_i,p,t,v}\ + \cdot SEG_{s^*,d^*} \cdot C2A_{r - r_i, t} \\ + &+ \sum_{t \in (T^s \cap T^{res}), V, I, O} \ + \left(\ + \mathbf{FO}_{r,p,s,d,i,t,v,o} - \mathbf{FI}_{r,p,s,d,i,t,v,o}\ + \right)\ + \cdot RCD_{r,s,t,v} \\ + &\geq\ + \left[\ + \sum_{t \in T^{res} \setminus T^{x} \setminus T^a, V, I, O}\ + \mathbf{FO}_{r, p, s, d, i, t, v, o}\ + \right. \\ + &+ \sum_{t \in T^{res} \cap T^a, V, I, O} + \begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\ + SEG_{s,d} & \text{otherwise} \end{cases} + \cdot \mathbf{FOA}_{r, p, i, t, v, o} \\ + &+ \sum_{t \in T^{res} \cap T^{x}, V, I, O} \ + \mathbf{FO}_{r_i - r, p, s, d, i, t, v, o} \\ + &- \sum_{t \in T^{res} \cap T^{x}, V, I, O} \ + \mathbf{FI}_{r - r_i, p, s, d, i, t, v, o} \\ + &- \left. \sum_{t \in T^{res} \cap T^{s}, V, I, O} \ + \mathbf{FI}_{r, p, s, d, i, t, v, o} \right] \cdot (1 + PRM_r) \\ + \\ + &\qquad \qquad \forall \{r, p, s, d\} \in \ + \Theta_{\text{ReserveMargin}} \text{ and } \forall r_i \in R + """ + return _available_activity_dynamic(model, r, p, s, d) >= _required_available_activity( + model, r, p, s, d + ) + + +def reserve_margin_static( + model: TemoaModel, r: Region, p: Period, s: Season, d: TimeOfDay +) -> Constraint: + r""" + During each period :math:`p`, the sum of capacity values of all reserve + technologies :math:`\sum_{t \in T^{res}} \textbf{CAP}_{r,p,t,v}`, which are + defined in the set :math:`\textbf{T}^{res}`, should exceed the peak load by + :math:`PRM`, the regional reserve margin. Note that the reserve + margin is expressed in percentage of the peak load. Generally speaking, in + a database we may not know the peak demand before running the model, therefore, + we write this equation for all the time-slices defined in the database in each region. + Each generator is allowed to contribute its available capacity times a pre-defined + capacity credit, :math:`CC_{r,p,t,v}`. + + For exchange technologies (i.e., inter-regional transmission), reserve contributions + are added to the downstream region but *subtracted* from the upstream region. This is + because, since they are not generating any power, their summed contribution across + regions should be zero. + + .. math:: + :label: reserve_margin_static + + &\sum_{t \in T^{res} \setminus T^{x}, V} {CC_{r,p,t,v} + \cdot \textbf{CAP}_{r,p,t,v} \cdot + SEG_{s^*,d^*} \cdot C2A_{r,t} }\\ + &+ \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r_i-r,p,t,v} + \cdot \textbf{CAP}_{r_i-r,p,t,v} \cdot + SEG_{s^*,d^*} \cdot C2A_{r_i-r,t} }\\ + &- \sum_{t \in T^{res} \cap T^{x}, V} {CC_{r-r_i,p,t,v} + \cdot \textbf{CAP}_{r-r_i,p,t,v} \cdot + SEG_{s^*,d^*} \cdot C2A_{r-r_i,t} }\\ + &\geq \left [ \sum_{ t \in T^{res} \setminus T^{x} \setminus T^a,V,I,O } + \textbf{FO}_{r, p, s, d, i, t, v, o}\right.\\ + &+ \sum_{ t \in T^{res} \cap T^a,V,I,O } + \begin{cases} DSD_{r,s,d,o} & \text{if } o \in C^d \\ + SEG_{s,d} & \text{otherwise} \end{cases} + \cdot \textbf{FOA}_{r, p, i, t, v, o}\\ + &+ \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FO}_{r_i-r, p, s, d, i, t, v, o}\\ + &- \sum_{ t \in T^{res} \cap T^{x},V,I,O } \textbf{FI}_{r-r_i, p, s, d, i, t, v, o}\\ + &- \left.\sum_{ t \in T^{res} \cap T^{s},V,I,O } \textbf{FI}_{r, p, s, d, i, t, v, o} + \right] + \cdot (1 + PRM_r)\\ + + \\ + &\qquad\qquad\forall \{r, p, s, d\} \in \Theta_{\text{ReserveMargin}} \text{and} \forall + r_i \in R + """ + return _available_activity_static(model, r, p, s, d) >= _required_available_activity( + model, r, p, s, d + ) + + +def reserve_margin_constraint( + model: TemoaModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, +) -> ExprLike: + """Returns the appropriate reserve margin constraint rule.""" + if (not model.tech_reserve) or ( + (r, p) not in model.process_reserve_periods + ): # If reserve set empty or if r,p not in M.processReservePeriod, skip the constraint + return Constraint.Skip + mode = model.reserve_margin_method.first() + if mode == 'dynamic': + return reserve_margin_dynamic(model, r, p, s, d) + elif mode == 'static': + return reserve_margin_static(model, r, p, s, d) + else: + raise ValueError( + f"Invalid reserve margin method: {mode}. Must be either 'dynamic' or 'static'." + ) diff --git a/temoa/components/storage.py b/temoa/components/storage.py index 2cce3ce57..c7415c96e 100644 --- a/temoa/components/storage.py +++ b/temoa/components/storage.py @@ -16,9 +16,9 @@ from logging import getLogger from typing import TYPE_CHECKING -from pyomo.environ import Constraint, value +from pyomo.environ import Constraint, quicksum, value -from .utils import Operator, get_capacity_factor, get_variable_efficiency, operator_expression +from .utils import Operator, get_available_output, get_variable_efficiency, operator_expression if TYPE_CHECKING: from temoa.core.model import TemoaModel @@ -152,14 +152,14 @@ def storage_energy_constraint( if model.is_seasonal_storage[t] and d == model.time_of_day.last(): return Constraint.Skip - charge = sum( + charge = quicksum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) - discharge = sum( + discharge = quicksum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] @@ -244,7 +244,7 @@ def seasonal_storage_energy_constraint( # This is the sum of all input=i sent TO storage tech t of vintage v with # output=o in p,s - charge = sum( + charge = quicksum( model.v_flow_in[r, p, s, model.time_of_day.last(), S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, model.time_of_day.last(), S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] @@ -253,7 +253,7 @@ def seasonal_storage_energy_constraint( # This is the sum of all output=o withdrawn FROM storage tech t of vintage v # with input=i in p,s - discharge = sum( + discharge = quicksum( model.v_flow_out[r, p, s, model.time_of_day.last(), S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] @@ -441,24 +441,15 @@ def storage_charge_rate_constraint( """ # Calculate energy charge in each time slice - slice_charge = sum( + slice_charge = quicksum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] for S_o in model.process_outputs_by_input[r, p, t, v, S_i] ) - # Maximum energy charge in each time slice - max_charge = ( - model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - * value(model.segment_fraction[s, d]) - ) - # Energy charge cannot exceed the power capacity of the storage unit - expr = slice_charge <= max_charge - - return expr + return slice_charge <= get_available_output(model, r, p, s, d, t, v) def storage_discharge_rate_constraint( @@ -480,23 +471,14 @@ def storage_discharge_rate_constraint( \forall \{r,p, s, d, t, v\} \in \Theta_{\text{StorageDischargeRate}} """ # Calculate energy discharge in each time slice - slice_discharge = sum( + slice_discharge = quicksum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) - # Maximum energy discharge in each time slice - max_discharge = ( - model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - * value(model.segment_fraction[s, d]) - ) - # Energy discharge cannot exceed the capacity of the storage unit - expr = slice_discharge <= max_discharge - - return expr + return slice_discharge <= get_available_output(model, r, p, s, d, t, v) def storage_throughput_constraint( @@ -520,13 +502,13 @@ def storage_throughput_constraint( \\ \forall \{r, p, s, d, t, v\} \in \Theta_{\text{StorageThroughput}} """ - discharge = sum( + discharge = quicksum( model.v_flow_out[r, p, s, d, S_i, t, v, S_o] for S_o in model.process_outputs[r, p, t, v] for S_i in model.process_inputs_by_output[r, p, t, v, S_o] ) - charge = sum( + charge = quicksum( model.v_flow_in[r, p, s, d, S_i, t, v, S_o] * get_variable_efficiency(model, r, p, s, d, S_i, t, v, S_o) for S_i in model.process_inputs[r, p, t, v] @@ -534,14 +516,7 @@ def storage_throughput_constraint( ) throughput = charge + discharge - max_throughput = ( - model.v_capacity[r, p, t, v] - * value(model.capacity_to_activity[r, t]) - * get_capacity_factor(model, r, s, d, t, v) - * value(model.segment_fraction[s, d]) - ) - expr = throughput <= max_throughput - return expr + return throughput <= get_available_output(model, r, p, s, d, t, v) # A limit but more cohesive here than in limits.py diff --git a/temoa/components/utils.py b/temoa/components/utils.py index 7a8c91fae..ee18b11c0 100644 --- a/temoa/components/utils.py +++ b/temoa/components/utils.py @@ -15,6 +15,8 @@ from pyomo.environ import Expression, value if TYPE_CHECKING: + from collections.abc import Callable + from pyomo.core.expr.numeric_expr import NumericValue from temoa.core.model import TemoaModel @@ -85,12 +87,55 @@ def get_variable_efficiency( return value(model.efficiency[r, i, t, v, o]) -def get_capacity_factor( - model: TemoaModel, r: Region, s: Season, d: TimeOfDay, t: Technology, v: Vintage -) -> float: +def available_output_base( + model: TemoaModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Maximum available output for a process in a specific time slice. + + .. math:: + + \textit{available} = + \mathbf{CAP}_{r,p,t,v} + \cdot CF_{r,s,d,t,v} + \cdot C2A_{r,t} + \cdot SEG_{s,d} + + where :math:`CF_{r,s,d,t,v}` is taken from ``capacity_factor_process`` if a + process-specific value exists, otherwise from ``capacity_factor_tech``. + """ + base = ( + model.v_capacity[r, p, t, v] + * value(model.capacity_to_activity[r, t]) + * value(model.segment_fraction[s, d]) + ) if model.is_capacity_factor_process[r, t, v]: - return value(model.capacity_factor_process[r, s, d, t, v]) - return value(model.capacity_factor_tech[r, s, d, t]) + return base * value(model.capacity_factor_process[r, s, d, t, v]) + else: + return base * value(model.capacity_factor_tech[r, s, d, t]) + + +# May be replaced by extensions (e.g. unit_commitment) during model initialization. +available_output_function: Callable[..., ExprLike] = available_output_base + + +def get_available_output( + model: TemoaModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + """The maximum available output for a process in a specific time slice.""" + return available_output_function(model, r, p, s, d, t, v) def get_adjusted_existing_capacity( diff --git a/temoa/core/model.py b/temoa/core/model.py index c919a70d7..145bf5728 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -38,6 +38,7 @@ storage, technology, time, + utils, ) from temoa.extensions.framework import apply_model_extension_hooks, resolve_extension_specs from temoa.model_checking.validators import ( @@ -110,6 +111,7 @@ def __init__( AbstractModel.__init__(self, *args, **kwargs) self.enabled_extensions = tuple(extensions or ()) self.extension_specs = resolve_extension_specs(self.enabled_extensions) + utils.available_output_function = utils.available_output_base ################################################ # Internally used Data Containers # From 1a0da45b6707dae49bb1a5be1f18204d11fff4b1 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 22 Jul 2026 11:17:45 -0400 Subject: [PATCH 19/23] Add unit commitment extension Signed-off-by: Davey Elder --- docs/source/extensions.rst | 1 + docs/source/extensions/unit_commitment.rst | 289 +++++++++ temoa/_internal/table_data_puller.py | 14 + temoa/_internal/table_writer.py | 10 + temoa/components/commodities.py | 10 + temoa/components/limits.py | 7 + temoa/core/model.py | 18 +- temoa/extensions/framework.py | 10 +- temoa/extensions/unit_commitment/__init__.py | 3 + .../unit_commitment/components/__init__.py | 0 .../unit_commitment/components/commitment.py | 593 ++++++++++++++++++ .../unit_commitment/components/startup.py | 158 +++++ .../unit_commitment/core/__init__.py | 0 .../unit_commitment/core/data_puller.py | 204 ++++++ .../extensions/unit_commitment/core/model.py | 173 +++++ .../unit_commitment/data_manifest.py | 79 +++ temoa/extensions/unit_commitment/extension.py | 23 + temoa/extensions/unit_commitment/tables.sql | 68 ++ 18 files changed, 1652 insertions(+), 8 deletions(-) create mode 100644 docs/source/extensions/unit_commitment.rst create mode 100644 temoa/extensions/unit_commitment/__init__.py create mode 100644 temoa/extensions/unit_commitment/components/__init__.py create mode 100644 temoa/extensions/unit_commitment/components/commitment.py create mode 100644 temoa/extensions/unit_commitment/components/startup.py create mode 100644 temoa/extensions/unit_commitment/core/__init__.py create mode 100644 temoa/extensions/unit_commitment/core/data_puller.py create mode 100644 temoa/extensions/unit_commitment/core/model.py create mode 100644 temoa/extensions/unit_commitment/data_manifest.py create mode 100644 temoa/extensions/unit_commitment/extension.py create mode 100644 temoa/extensions/unit_commitment/tables.sql diff --git a/docs/source/extensions.rst b/docs/source/extensions.rst index 47c0fd7d8..8c844658e 100644 --- a/docs/source/extensions.rst +++ b/docs/source/extensions.rst @@ -246,3 +246,4 @@ as new extensions are added. extensions/growth_rates extensions/discrete_capacity extensions/eos + extensions/unit_commitment diff --git a/docs/source/extensions/unit_commitment.rst b/docs/source/extensions/unit_commitment.rst new file mode 100644 index 000000000..833f8e913 --- /dev/null +++ b/docs/source/extensions/unit_commitment.rst @@ -0,0 +1,289 @@ +.. _extension-unit-commitment: + +Unit Commitment +=============== + +The **unit_commitment** extension adds commitment-level operational constraints +to selected technologies: online/started/stopped unit accounting, minimum +output floors, maximum output ceilings, and minimum up-time and down-time windows. +It also supports discounted startup costs and startup emissions and input flows. + +It is disabled by default and enabled per run through configuration: + +.. code-block:: toml + + extensions = ["unit_commitment"] + +Conceptual Model +---------------- + +The core idea is that the continuous capacity variable :math:`\textbf{CAP}_{r,p,t,v}` is +*notionally divided* into discrete units, each of size :math:`UC_{r,t}`. The number of +units available is therefore :math:`\textbf{CAP}_{r,p,t,v} / UC_{r,t}`. Crucially, +:math:`\textbf{CAP}` itself remains a standard linear (continuous) Pyomo variable — the +extension does not change its domain. In practice, because the UC constraints tie output +directly to the integer count of online units, the optimiser will typically land on a +solution where :math:`\textbf{CAP}` is an integer multiple of :math:`UC_{r,t}`, but this +is a consequence of the optimisation rather than an explicit constraint. + +The three UC variables — :math:`\textbf{UCN}` (online), :math:`\textbf{UCST}` (started), +and :math:`\textbf{UCSP}` (stopped) — count whole units and are promoted to non-negative +integers by default (MIP). In **linearized mode** (:code:`linearized = 1`), these +variables are kept continuous. This is equivalent to imagining that :math:`UC_{r,t}` is +infinitesimally small: the commitment variables become fractional occupancy factors rather +than discrete unit counts, and the min-output bound acts as a simple lower capacity-factor +constraint without any integer requirements. Linearized mode is useful for reducing solve +time. + +Integration with Core Constraints +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For some existing core constraints, the extension overrides a utility function — +:func:`~temoa.components.utils.get_available_output` — that computes +the maximum available output for a process in a given time slice. For UC technologies, +this function returns a commitment-aware expression based on online units rather than total +installed capacity. Because this function is used throughout the core model, the following +constraints automatically become UC-aware for registered technologies with no further changes: + +- :code:`capacity_constraint` — output bounded by online capacity +- :code:`storage_charge_rate_constraint`, :code:`storage_discharge_rate_constraint`, + :code:`storage_throughput_constraint` — storage rates scale with online units +- :code:`reserve_margin_constraint` (dynamic mode only) — reserve contribution from online units only + +Ramp constraints are the only exception: they require explicit knowledge of +started/stopped units to compute the ramp envelope and are therefore fully replaced by +UC-aware versions for technologies that appear in both :code:`unit_commitment` and the +ramp tables. + +.. autofunction:: temoa.components.utils.available_output_base + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_available_output + +.. warning:: + + **Annual technologies are incompatible with unit commitment.** Technologies + in the :code:`tech_annual` set have no time-slice flexibility and cannot be + committed; the model will raise an error if any annual technology appears in + the :code:`unit_commitment` table. + +.. warning:: + + **Integer vs. linear relaxation.** By default all three UC variables + (:math:`\textbf{UCN}`, :math:`\textbf{UCST}`, :math:`\textbf{UCSP}`) are + promoted to non-negative integers, turning the problem into a MIP. Set + :code:`linearized = 1` in the :code:`unit_commitment` table for a + particular technology to keep those variables continuous (LP relaxation). + +Overview +-------- + +.. list-table:: + :header-rows: 1 + :widths: 28 38 34 + + * - Table + - Purpose + - Key interaction + * - :code:`unit_commitment` + - Core UC parameters (unit size, output fractions, up/down times). + - Enables UC constraints; overrides available output calculation for UC techs. + Commitment results are written to :code:`output_unit_commitment`. + * - :code:`unit_commitment_startup_cost` + - Direct startup cost per unit of capacity started. + - Added to variable costs. + * - :code:`unit_commitment_startup_emissions` + - Emissions produced at startup per unit of capacity started. + - Added to :code:`limit_emission_constraint` totals, emission + costs and :code:`output_emission` table. + * - :code:`unit_commitment_startup_input` + - Input commodity consumed at startup per unit of capacity started. + - Added to consumption in :code:`commodity_balance_constraint` and + results in :code:`output_flow_in`. + +Parameters +---------- + +unit_commitment +~~~~~~~~~~~~~~~ + +:math:`{UC}_{r \in R,\, t \in T}` + +The primary UC table. Each row registers a technology (or technology group) +as subject to unit-commitment constraints and specifies all operational +parameters. Exactly one row per :math:`(r, t)` pair is required. + +.. csv-table:: + :header: "Column", "Symbol", "Default", "Description" + :widths: 24, 22, 10, 44 + + ":code:`region`", ":math:`r`", "—", "region label" + ":code:`tech`", ":math:`t`", "—", "technology name" + ":code:`unit_capacity`", ":math:`UC_{r,t}`", "—", "nameplate capacity per discrete unit (same units as :code:`v_capacity`); **required**" + ":code:`min_output_fraction`", ":math:`\underline{f}_{r,t}`", "0", "minimum output as a fraction of available nameplate output per online unit; :math:`[0, 1]`" + ":code:`max_output_fraction`", ":math:`\overline{f}_{r,t}`", "1", "maximum output as a fraction of available nameplate output per online unit; :math:`(0, 1]`" + ":code:`min_up_time_hours`", ":math:`T^{up}_{r,t}`", "0", "minimum number of consecutive hours a unit must remain online after starting" + ":code:`min_down_time_hours`", ":math:`T^{dn}_{r,t}`", "0", "minimum number of consecutive hours a unit must remain offline after stopping" + ":code:`linearized`", "—", "0", "if 1, UC variables are kept continuous (LP relaxation); if 0, they are promoted to non-negative integers (MIP)" + +unit_commitment_startup_cost +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{UCSC}_{r \in R,\, t \in T}` + +Optional. Specifies a cost incurred each time a unit is started, expressed +per unit of capacity. The total startup cost in a timeslice :math:`(s, d)` is: + +.. math:: + + \text{startup cost} = \textbf{UCST}_{r,p,s,d,t,v} \cdot UC_{r,t} \cdot UCSC_{r,t} + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 20, 58 + + ":code:`region`", ":math:`r`", "region label" + ":code:`tech`", ":math:`t`", "technology name" + ":code:`cost_per_cap`", ":math:`UCSC_{r,t}`", "startup cost per unit of capacity (same currency units as :code:`cost_invest`)" + +unit_commitment_startup_emissions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{UCSE}_{r \in R,\, e \in C^e,\, t \in T}` + +Optional. Specifies an emission produced at startup per unit of capacity +started. Startup emissions are summed into the :code:`limit_emission_constraint` +alongside normal activity-based emissions. + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 20, 58 + + ":code:`region`", ":math:`r`", "region label" + ":code:`emis_comm`", ":math:`e`", "emission commodity" + ":code:`tech`", ":math:`t`", "technology name" + ":code:`emis_per_cap`", ":math:`UCSE_{r,e,t}`", "emission per unit of capacity started (same units as :code:`emission_activity`)" + +unit_commitment_startup_input +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:math:`{UCSI}_{r \in R,\, i \in C^p,\, t \in T}` + +Optional. Specifies a physical commodity consumed at startup per unit of +capacity started. Startup inputs are summed into the +:code:`commodity_balance_constraint` alongside normal flow-based consumption. + +.. csv-table:: + :header: "Column", "Symbol", "Description" + :widths: 22, 20, 58 + + ":code:`region`", ":math:`r`", "region label" + ":code:`input_comm`", ":math:`i`", "input commodity" + ":code:`tech`", ":math:`t`", "technology name" + ":code:`input_per_cap`", ":math:`UCSI_{r,i,t}`", "input commodity consumed per unit of capacity started" + +.. note:: + + These inputs are **not** considered in network checking. If no other process produces or consumes the + input commodity in this region and period, it may be removed from the model and lead to errors. + +Decision Variables +------------------ + +Three UC decision variables are added per :math:`(r, p, s, d, t, v)` index. +By default they are non-negative integers (MIP); set :code:`linearized = 1` +to relax them to continuous non-negative reals. + +.. csv-table:: + :header: "Variable", "Domain", "Description" + :widths: 36, 16, 48 + + ":math:`\textbf{UCN}_{r,p,s,d,t,v}` (:code:`v_uc_online`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units online at the start of timeslice :math:`(s,d)`" + ":math:`\textbf{UCST}_{r,p,s,d,t,v}` (:code:`v_uc_started`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units that start up during timeslice :math:`(s,d)`" + ":math:`\textbf{UCSP}_{r,p,s,d,t,v}` (:code:`v_uc_stopped`)", ":math:`\mathbb{Z}_{\ge 0}`", "number of units that shut down during timeslice :math:`(s,d)`" + +Constraints +----------- + +Unit Count Bounds +~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_online_upper_constraint + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_started_upper_tightening_constraint + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_stopped_upper_tightening_constraint + +Commitment Transition +~~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_transition_constraint + +Output Bounds +~~~~~~~~~~~~~ + +The minimum output constraint enforces a floor on generation per online unit. +The maximum output (capacity) constraint is handled by the standard core-model +:code:`capacity_constraint` — the extension overrides +:func:`~temoa.components.utils.get_available_output` so that available output +is based on online units rather than total installed capacity, requiring no +separate replacement constraint. + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_output_constraint + +Minimum Up/Down Time +~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_up_time_constraint + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_min_down_time_constraint + +Ramp Constraints +~~~~~~~~~~~~~~~~ + +When a technology appears in both the :code:`unit_commitment` table and the +:code:`ramp_up_hourly` / :code:`ramp_down_hourly` tables, the standard core-model +ramp constraints (:code:`ramp_up_constraint`, :code:`ramp_down_constraint`) are +**replaced** by UC-aware versions for that technology. Non-UC technologies retain +the original core-model ramp constraints unchanged. + +The core ramp constraint bounds the change in hourly activity against a fraction of +total installed capacity. The UC version replaces this with a commitment-aware +expression: only online units contribute to the ramp envelope, and the operating +point assumed for units that start or stop during the transition adds or removes +headroom according to the effective minimum output fraction. Units are assumed to +start and stop at their minimum viable output level, taken as the larger of their +:code:`min_output_fraction` and :code:`ramp_up_fraction` / :code:`ramp_down_fraction`. + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_ramp_up_constraint + +.. autofunction:: temoa.extensions.unit_commitment.components.commitment.uc_ramp_down_constraint + +Objective Contributions +----------------------- + +.. autofunction:: temoa.extensions.unit_commitment.components.startup.append_startup_costs + +Output Table +------------ + +Results are written to the :code:`output_unit_commitment` table with one row per +:math:`(scenario, region, period, season, tod, tech, vintage)`: + +.. csv-table:: + :header: "Column", "Description" + :widths: 22, 78 + + ":code:`online_cap`", "capacity online at the start of the timeslice (:math:`\textbf{UCN} \cdot UC_{r,t}`)" + ":code:`start_cap`", "capacity started during the timeslice (:math:`\textbf{UCST} \cdot UC_{r,t}`)" + ":code:`stop_cap`", "capacity stopped during the timeslice (:math:`\textbf{UCSP} \cdot UC_{r,t}`)" + +.. note:: + + **Starts and stops take effect in the following time slice.** The transition + constraint links :math:`\textbf{UCST}_{s,d}` and :math:`\textbf{UCSP}_{s,d}` to the + *change* in online count between :math:`(s,d)` and its successor + :math:`(s_{next}, d_{next})`. A unit started during slice :math:`(s,d)` therefore + first appears as online — and is first able to produce output — in + :math:`(s_{next}, d_{next})`. Equivalently, a unit stopped during :math:`(s,d)` is + still counted as online for that slice and goes offline from + :math:`(s_{next}, d_{next})` onward. diff --git a/temoa/_internal/table_data_puller.py b/temoa/_internal/table_data_puller.py index 9ef239e7c..6eb71b308 100644 --- a/temoa/_internal/table_data_puller.py +++ b/temoa/_internal/table_data_puller.py @@ -246,6 +246,11 @@ def poll_flow_results(model: TemoaModel, epsilon: float = 1e-5) -> dict[FI, dict continue res[fi][FlowType.OUT] = flow + if 'unit_commitment' in model.enabled_extensions: + import temoa.extensions.unit_commitment.core.data_puller as uc + + uc.poll_startup_input_results(model, res, epsilon) + return res @@ -494,6 +499,10 @@ def poll_cost_results( import temoa.extensions.economies_of_scale.core.data_puller as eos eos.poll_costs(model, exchange_costs, entries, p_0_true, epsilon) + if 'unit_commitment' in model.enabled_extensions: + import temoa.extensions.unit_commitment.core.data_puller as uc + + uc.poll_startup_cost_results(model, exchange_costs, entries, p_0_true, epsilon) exchange_entries = exchange_costs.get_entries() return entries, exchange_entries @@ -558,6 +567,11 @@ def poll_emissions( * model.emission_activity[r, e, i, t, v, o] ) + if 'unit_commitment' in model.enabled_extensions: + from temoa.extensions.unit_commitment.core import data_puller as uc + + uc.poll_emission_results(model, flows) + # gather costs ud_costs: dict[tuple[Region, Period, Technology, Vintage], float] = defaultdict(float) d_costs: dict[tuple[Region, Period, Technology, Vintage], float] = defaultdict(float) diff --git a/temoa/_internal/table_writer.py b/temoa/_internal/table_writer.py index 221c23e8f..d28d3f0fd 100644 --- a/temoa/_internal/table_writer.py +++ b/temoa/_internal/table_writer.py @@ -483,6 +483,16 @@ def write_capacity_tables(self, model: TemoaModel, iteration: int | None = None) cap_data = poll_capacity_results(model=model, epsilon=self.output_threshold_capacity) self._insert_capacity_results(cap_data=cap_data, iteration=iteration) + if 'unit_commitment' in model.enabled_extensions: + import temoa.extensions.unit_commitment.core.data_puller as uc_puller + + uc_puller.write_uc_results( + model=model, + writer=self, + iteration=iteration, + epsilon=self.output_threshold_capacity, + ) + def _insert_capacity_results(self, cap_data: CapData, iteration: int | None) -> None: if self.tech_sectors is None: raise RuntimeError('tech sectors not available... code error') diff --git a/temoa/components/commodities.py b/temoa/components/commodities.py index 1328d65a8..fe2f1ebf2 100644 --- a/temoa/components/commodities.py +++ b/temoa/components/commodities.py @@ -425,6 +425,11 @@ def commodity_balance_constraint( for s_o in model.process_outputs_by_input[r, p, s_t, s_v, c] ) + if 'unit_commitment' in model.enabled_extensions: + from temoa.extensions.unit_commitment.components import startup + + consumed += startup.uc_startup_input_rpsdc(model, r, p, s, d, c) + if (r, p, c) in model.capacity_consumption_techs: # Consumed by building capacity # Assume evenly distributed over a year @@ -573,6 +578,11 @@ def annual_commodity_balance_constraint( for s_o in model.process_outputs_by_input[r, p, s_t, s_v, c] ) + if 'unit_commitment' in model.enabled_extensions: + from temoa.extensions.unit_commitment.components import startup + + consumed += startup.uc_startup_input_rpc(model, r, p, c) + if (r, p, c) in model.capacity_consumption_techs: # Consumed by building capacity # Assume evenly distributed over a year diff --git a/temoa/components/limits.py b/temoa/components/limits.py index b2772e3e6..35eacb054 100644 --- a/temoa/components/limits.py +++ b/temoa/components/limits.py @@ -795,6 +795,13 @@ def limit_emission_constraint( if (reg, p, S_t, S_v) in model.process_inputs ) + if 'unit_commitment' in model.enabled_extensions: + from temoa.extensions.unit_commitment.components import startup + + process_emissions += quicksum( + startup.uc_startup_emissions_rpe(model, reg, p, e) for reg in regions + ) + embodied_emissions = quicksum( model.v_new_capacity[reg, t, v] * value(model.emission_embodied[reg, e, t, v]) diff --git a/temoa/core/model.py b/temoa/core/model.py index 145bf5728..645d15c45 100755 --- a/temoa/core/model.py +++ b/temoa/core/model.py @@ -557,6 +557,11 @@ def __init__( self.initialize_CapacityFactors = BuildAction(rule=capacity.check_capacity_factor_process) self.initialize_efficiency_variable = BuildAction(rule=technology.check_efficiency_variable) + if 'unit_commitment' in self.enabled_extensions: + import temoa.extensions.unit_commitment.core.model as uc + + uc.register_early_components(self) + # Define technology cost parameters self.cost_fixed_rptv = Set(dimen=4, initialize=costs.cost_fixed_indices) self.cost_fixed = Param(self.cost_fixed_rptv) @@ -964,12 +969,13 @@ def __init__( self.ramp_down_constraint_rpsdtv = Set( dimen=6, initialize=operations.ramp_down_constraint_indices ) - self.ramp_down_constraint = Constraint( - self.ramp_down_constraint_rpsdtv, rule=operations.ramp_down_constraint - ) - self.ramp_up_constraint = Constraint( - self.ramp_up_constraint_rpsdtv, rule=operations.ramp_up_constraint - ) + if 'unit_commitment' not in self.enabled_extensions: + self.ramp_down_constraint = Constraint( + self.ramp_down_constraint_rpsdtv, rule=operations.ramp_down_constraint + ) + self.ramp_up_constraint = Constraint( + self.ramp_up_constraint_rpsdtv, rule=operations.ramp_up_constraint + ) self.reserve_margin_rpsd = Set(dimen=4, initialize=reserves.reserve_margin_indices) self.validate_reserve_margin = BuildAction(rule=validate_reserve_margin) diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py index 7927e3bf3..a7116f504 100644 --- a/temoa/extensions/framework.py +++ b/temoa/extensions/framework.py @@ -59,8 +59,14 @@ def get_known_extension_specs() -> dict[str, ExtensionSpec]: from temoa.extensions.discrete_capacity.extension import DISCRETE_CAPACITY_EXTENSION from temoa.extensions.economies_of_scale.extension import EOS_EXTENSION from temoa.extensions.growth_rates.extension import GROWTH_RATES_EXTENSION - - specs = [GROWTH_RATES_EXTENSION, DISCRETE_CAPACITY_EXTENSION, EOS_EXTENSION] + from temoa.extensions.unit_commitment.extension import UNIT_COMMITMENT_EXTENSION + + specs = [ + GROWTH_RATES_EXTENSION, + DISCRETE_CAPACITY_EXTENSION, + EOS_EXTENSION, + UNIT_COMMITMENT_EXTENSION, + ] return {spec.extension_id: spec for spec in specs} diff --git a/temoa/extensions/unit_commitment/__init__.py b/temoa/extensions/unit_commitment/__init__.py new file mode 100644 index 000000000..ea4da100e --- /dev/null +++ b/temoa/extensions/unit_commitment/__init__.py @@ -0,0 +1,3 @@ +from temoa.extensions.unit_commitment.extension import UNIT_COMMITMENT_EXTENSION + +__all__ = ['UNIT_COMMITMENT_EXTENSION'] diff --git a/temoa/extensions/unit_commitment/components/__init__.py b/temoa/extensions/unit_commitment/components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/unit_commitment/components/commitment.py b/temoa/extensions/unit_commitment/components/commitment.py new file mode 100644 index 000000000..5b4966b33 --- /dev/null +++ b/temoa/extensions/unit_commitment/components/commitment.py @@ -0,0 +1,593 @@ +"""Unit-commitment constraints for the unit_commitment extension. + +Implements: + - cyclic previous-time map (uc_time_prev) built from model.time_next + - integer-domain promotion for UC variables + - online/started/stopped upper bounds + tightening constraints + - commitment transition + - min/max output from online units + - min up-time and min down-time via cyclic windows + - ramp-up and ramp-down constraints that account for online units and started/stopped units + - dynamic reserve margin constraint that accounts for online units and started/stopped units +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pyomo.environ import Constraint, NonNegativeIntegers, quicksum, value + +import temoa.components.utils as utils +from temoa.components.operations import _ramp_activity_increase +from temoa.components.time import tod_elapsed_hours +from temoa.components.utils import available_output_base + +if TYPE_CHECKING: + from pyomo.environ import Expression + + from temoa.extensions.unit_commitment.core.model import UnitCommitmentModel + from temoa.types import ExprLike + from temoa.types.core_types import Period, Region, Season, Technology, TimeOfDay, Vintage + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Build helpers +# --------------------------------------------------------------------------- + + +def apply_integer_domains(model: UnitCommitmentModel) -> None: + """Set integer domain on UC variable indices where integer_flag == 1.""" + for r, p, s, d, t, v in model.uc_indices_rpsdtv: + if not value(model.uc_linearized[r, t]): + model.v_uc_online[r, p, s, d, t, v].domain = NonNegativeIntegers + model.v_uc_started[r, p, s, d, t, v].domain = NonNegativeIntegers + model.v_uc_stopped[r, p, s, d, t, v].domain = NonNegativeIntegers + + +# --------------------------------------------------------------------------- +# Index set +# --------------------------------------------------------------------------- + + +def initialize_unit_commitment(model: UnitCommitmentModel) -> None: + """Initialize the unit commitment index set.""" + + # Override get_available_output with the UC-aware version for this run. + utils.available_output_function = uc_available_output + + # Annual techs cant use unit commitment as their flows are not flexible + bad_techs = {t for _, t in model.uc_unit_capacity.sparse_keys() if t in model.tech_annual} + if bad_techs: + msg = ( + 'Unit commitment constraints cannot be applied to annual technologies as they ' + 'have no flexibility in operations: {}' + ).format(', '.join(sorted(bad_techs))) + logger.error(msg) + raise ValueError(msg) + + # Make sure any startup parameters have a valid unit commitment process + startup_rt = ( + set(model.uc_startup_cost.sparse_keys()) + | {(r, t) for r, e, t in model.uc_startup_emissions.sparse_keys()} + | {(r, t) for r, i, t in model.uc_startup_input.sparse_keys()} + ) + bad_rt = set(startup_rt) - set(model.uc_unit_capacity.sparse_keys()) + if bad_rt: + msg = ( + 'Startup parameters provided for (region, tech) pairs not in unit_commitment: {}' + ).format(', '.join(f'({r}, {t})' for r, t in sorted(bad_rt))) + logger.error(msg) + raise ValueError(msg) + + # Initialise the time slice back-check lists for min up/down time constraints + # Tells us which previous time slices' startups/shutdowns are relevant + epsilon = 1e-6 # To buffer float/integer interactions + + # We only care about how many hours back we're looking, so why redo work? + hours_back = { + int(value(model.uc_min_down_time_hours[r, t])) + for (r, t) in model.uc_min_down_time_hours.sparse_keys() + } + hours_back |= { + int(value(model.uc_min_up_time_hours[r, t])) + for (r, t) in model.uc_min_up_time_hours.sparse_keys() + } + + # Invert the time next sequence to start... + time_prev = {model.time_next[s, d]: (s, d) for s, d in model.time_next} + # Then back down the chain until the next timeslice back is irrelevant + for hb in hours_back: + for s, d in time_prev: + model.uc_backslices[s, d, hb] = set() + _s, _d = s, d + _hours = float(hb) + i = 0 # Some loop safety + while i + 1 < len(model.time_next): + s_prev, d_prev = time_prev[_s, _d] + if s_prev == _s and d_prev == _d: + msg = ( + 'When finding relevant past time slices for min up/downtime ' + 'constraints, looped back to the same time slice. This would ' + 'likely cause infeasibility and is not supported. If the ' + 'min up/downtime is longer than a season, try instead ' + 'flagging the technology as production baseload "pb" and ' + 'setting min up/down times to zero to skip the constraints. ' + ) + logger.error(msg) + raise ValueError(msg) + elapsed = tod_elapsed_hours(model, d_prev, _d) + _hours -= elapsed + if _hours < -epsilon: + break # too far back (with some rounding buffer) so ignore + i += 1 + model.uc_backslices[s, d, hb].add((s_prev, d_prev)) + _s, _d = s_prev, d_prev + + +def uc_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + """All (r, p, s, d, t, v) indices for unit commitment constraints.""" + return { + (r, p, s, d, t, v) + for r, t in model.uc_unit_capacity.sparse_keys() + for p in model.time_optimize + for v in model.process_vintages.get((r, p, t), []) + for s in model.time_season + for d in model.time_of_day + } + + +def capacity_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + # Non uc processes back to default capacity constraint + return set(model.capacity_constraint_rpsdtv - model.uc_indices_rpsdtv) + + +def ramp_up_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + # Non uc processes back to default ramp up constraint + return set(model.ramp_up_constraint_rpsdtv - model.uc_indices_rpsdtv) + + +def ramp_down_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + # Non uc processes back to default ramp down constraint + return set(model.ramp_down_constraint_rpsdtv - model.uc_indices_rpsdtv) + + +def uc_ramp_up_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + # UC processes ramp up constraint + return set(model.ramp_up_constraint_rpsdtv & model.uc_indices_rpsdtv) + + +def uc_ramp_down_constraint_indices( + model: UnitCommitmentModel, +) -> set[tuple[Region, Period, Season, TimeOfDay, Technology, Vintage]]: + # UC processes ramp down constraint + return set(model.ramp_down_constraint_rpsdtv & model.uc_indices_rpsdtv) + + +# --------------------------------------------------------------------------- +# Helper expressions +# --------------------------------------------------------------------------- + + +def _total_units( + model: UnitCommitmentModel, r: Region, p: Period, t: Technology, v: Vintage +) -> ExprLike: + return model.v_capacity[r, p, t, v] / value(model.uc_unit_capacity[r, t]) + + +def _flow_out_sum( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> Expression: + """Total output energy for process (r,p,s,d,t,v): sum over inputs/outputs of v_flow_out.""" + return quicksum( + model.v_flow_out[r, p, s, d, _i, t, v, _o] + for _i in model.process_inputs[r, p, t, v] + for _o in model.process_outputs_by_input[r, p, t, v, _i] + ) + + +def uc_available_output( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Commitment-aware maximum available output for a process in a specific time slice. + + For non-UC technologies, delegates to + :func:`~temoa.components.utils.available_output_base`. For UC technologies, + online units replace installed capacity: + + .. math:: + + \textit{available} = + \mathbf{UCN}_{r,p,s,d,t,v} + \cdot UC_{r,t} + \cdot \overline{f}_{r,t} + \cdot CF_{r,s,d,t,v} + \cdot C2A_{r,t} + \cdot SEG_{s,d} + + where :math:`\overline{f}_{r,t}` is ``max_output_fraction`` and + :math:`CF_{r,s,d,t,v}` is taken from ``capacity_factor_process`` or + ``capacity_factor_tech`` as appropriate. Offline units contribute zero. + + This function is registered as :data:`~temoa.components.utils.available_output_function` + during model initialization when the ``unit_commitment`` extension is active, + so it is called transparently by :func:`~temoa.components.utils.get_available_output` + throughout the core model. + """ + if (r, t) not in model.uc_unit_capacity: + return available_output_base(model, r, p, s, d, t, v) + base = ( + model.capacity_to_activity[r, t] + * model.segment_fraction[s, d] + * value(model.uc_max_output_fraction[r, t]) + * value(model.uc_unit_capacity[r, t]) + * model.v_uc_online[r, p, s, d, t, v] + ) + if model.is_capacity_factor_process[r, t, v]: + return base * value(model.capacity_factor_process[r, s, d, t, v]) + else: + return base * value(model.capacity_factor_tech[r, s, d, t]) + + +# --------------------------------------------------------------------------- +# Constraints +# --------------------------------------------------------------------------- + + +def uc_online_upper_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Online units cannot exceed the number of available (physical) units: + + .. math:: + + \textbf{UCN}_{r,p,s,d,t,v} \le \frac{\textbf{CAP}_{r,p,t,v}}{UC_{r,t}} + """ + return model.v_uc_online[r, p, s, d, t, v] <= _total_units(model, r, p, t, v) + + +def uc_started_upper_tightening_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Tightening: cannot start more units than are currently offline. + Redundant but makes the integer formulation a bit more efficient. + + .. math:: + + \textbf{UCST}_{r,p,s,d,t,v} + \le \frac{\textbf{CAP}_{r,p,t,v}}{UC_{r,t}} + - \textbf{UCN}_{r,p,s,d,t,v} + """ + offline = _total_units(model, r, p, t, v) - model.v_uc_online[r, p, s, d, t, v] + return model.v_uc_started[r, p, s, d, t, v] <= offline + + +def uc_stopped_upper_tightening_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Tightening: cannot shutdown more units than are currently online. + Redundant but makes the integer formulation a bit more efficient. + + .. math:: + + \textbf{UCSP}_{r,p,s,d,t,v} + \le \textbf{UCN}_{r,p,s,d,t,v} + """ + return model.v_uc_stopped[r, p, s, d, t, v] <= model.v_uc_online[r, p, s, d, t, v] + + +def uc_transition_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Commitment transition: change in online units equals started minus stopped. + The constraint is indexed by the current slice :math:`(s,d)` and links to its + successor :math:`(s_{next}, d_{next})` via :code:`model.time_next`. + + .. math:: + + \textbf{UCN}_{r,p,s_{next},d_{next},t,v} - \textbf{UCN}_{r,p,s,d,t,v} + = \textbf{UCST}_{r,p,s,d,t,v} - \textbf{UCSP}_{r,p,s,d,t,v} + """ + s_next, d_next = model.time_next[s, d] + return ( + model.v_uc_online[r, p, s_next, d_next, t, v] - model.v_uc_online[r, p, s, d, t, v] + == model.v_uc_started[r, p, s, d, t, v] - model.v_uc_stopped[r, p, s, d, t, v] + ) + + +def uc_min_output_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Output must be at least min_output_fraction * unit_capacity per online unit, + converted to energy units via capacity_to_activity and segment_fraction. + + .. math:: + + \sum_{i,o} \textbf{FO}_{r,p,s,d,i,t,v,o} + \ge \text{minFrac} \cdot UC_{r,t} \cdot C2A_{r,t} \cdot SEG_{s,d} + \cdot \textbf{UCN}_{r,p,s,d,t,v} + """ + min_frac = value(model.uc_min_output_fraction[r, t]) + if min_frac <= 0.0: + return Constraint.Skip + + output = _flow_out_sum(model, r, p, s, d, t, v) + # We can't use the regular available output here because it includes the max_output_fraction + available = ( + model.capacity_to_activity[r, t] + * model.segment_fraction[s, d] + * value(model.uc_unit_capacity[r, t]) + * model.v_uc_online[r, p, s, d, t, v] + ) + return output >= min_frac * available + + +def uc_min_up_time_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Sum of starts over a cyclic window of length min_up_time_steps ending at (s,d) + must not exceed online units at (s,d). + + .. math:: + + \sum_{\tau \in W^{\text{up}}_{s,d}} \textbf{UCST}_{r,p,\tau,t,v} + \le \textbf{UCN}_{r,p,s,d,t,v} + """ + hours_back = int(value(model.uc_min_up_time_hours[r, t])) + started_sum = quicksum( + model.v_uc_started[r, p, _s, _d, t, v] for _s, _d in model.uc_backslices[s, d, hours_back] + ) + return model.v_uc_online[r, p, s, d, t, v] >= started_sum + + +def uc_min_down_time_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + Sum of stops over a cyclic window of length min_down_time_steps ending at (s,d) + must not exceed offline units at (s,d). + + .. math:: + + \sum_{\tau \in W^{\text{dn}}_{s,d}} \textbf{UCSP}_{r,p,\tau,t,v} + \le \frac{\textbf{CAP}_{r,p,t,v}}{UC_{r,t}} - \textbf{UCN}_{r,p,s,d,t,v} + + .. warning:: + + **Floating-point precision of time-slice hours.** The + :code:`min_x_time_hours` values are integers, but the hour length of + each time-of-day segment is stored as a float. When walking + backwards through the time-slice sequence to build the look-back window + :math:`W^{\text{dn}}_{s,d}`, the cumulative elapsed hours are compared + against the integer target with a tolerance of :math:`10^{-6}` hours. + This buffer is intentionally tiny + and will not mask genuine misalignment, and means that the look-back cyclic + window is only correct when the segment hours are near-exact fractions + of the day. If your :code:`time_of_day`: :code:`hours` or :code:`time_season`: + :code:`segment_fraction` data are rounded imprecisely, the window boundary may be + mis-classified by one time slice. + """ + hours_back = int(value(model.uc_min_down_time_hours[r, t])) + stopped_sum = quicksum( + model.v_uc_stopped[r, p, _s, _d, t, v] for _s, _d in model.uc_backslices[s, d, hours_back] + ) + offline = _total_units(model, r, p, t, v) - model.v_uc_online[r, p, s, d, t, v] + return offline >= stopped_sum + + +def _rampable_activity( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, + ramp_up: bool, + ramp_fraction: float, +) -> ExprLike: + + unit_cap = value(model.uc_unit_capacity[r, t]) + online = ramp_fraction * model.v_uc_online[r, p, s, d, t, v] * unit_cap + + min_fraction = max(value(model.uc_min_output_fraction[r, t]), ramp_fraction) + startup = min_fraction * model.v_uc_started[r, p, s, d, t, v] * unit_cap + shutdown = min_fraction * model.v_uc_stopped[r, p, s, d, t, v] * unit_cap + + capacity_ramp = (online + startup - shutdown) if ramp_up else (online + shutdown - startup) + return ( + capacity_ramp + * value(model.capacity_to_activity[r, t]) + / (24 * value(model.days_per_period)) + ) + + +def _ramp_constraint( + model: UnitCommitmentModel, + ramp_up: bool, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + s_next, d_next = model.time_next[s, d] + result = _ramp_activity_increase(model, ramp_up, r, p, s, d, s_next, d_next, t, v) + if result is None: + return Constraint.Skip + activity_increase, ramp_fraction = result + rampable = _rampable_activity(model, r, p, s, d, t, v, ramp_up, ramp_fraction) + if ramp_up: + return activity_increase <= rampable + else: + return -activity_increase <= rampable + + +def uc_ramp_up_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + UC-aware ramp-up constraint. Replaces the core + :code:`ramp_up_constraint` for technologies that appear in both the + :code:`unit_commitment` and :code:`ramp_up_hourly` tables. + + The ramp envelope is driven by *online units* rather than total installed + capacity. Units that start during slice :math:`(s,d)` arrive part-way to + their maximum output, providing additional headroom; units that stop reduce + it: + + .. math:: + :label: uc_ramp_up + + \frac{\sum_{I,O}\textbf{FO}_{r,p,s_{next},d_{next},i,t,v,o}}{H_{s_{next},d_{next}}} + - + \frac{\sum_{I,O}\textbf{FO}_{r,p,s,d,i,t,v,o}}{H_{s,d}} + \;\le\; + \left[ + RH_{r,t} \cdot \Delta H \cdot \textbf{UCN}_{r,p,s,d,t,v} + + f^{\min}_{\text{eff}} \cdot \textbf{UCST}_{r,p,s,d,t,v} + - f^{\min}_{\text{eff}} \cdot \textbf{UCSP}_{r,p,s,d,t,v} + \right] + \cdot \frac{UC_{r,t} \cdot C2A_{r,t}}{24 \cdot DPP} + + where :math:`f^{\min}_{\text{eff}} = \max(\underline{f}_{r,t},\, RH_{r,t} \cdot \Delta H)` + is the effective minimum output fraction for started/stopped units, and + :math:`\Delta H = (H_{s,d} + H_{s_{next},d_{next}}) / 2`. + """ + return _ramp_constraint( + model=model, + ramp_up=True, + r=r, + p=p, + s=s, + d=d, + t=t, + v=v, + ) + + +def uc_ramp_down_constraint( + model: UnitCommitmentModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + t: Technology, + v: Vintage, +) -> ExprLike: + r""" + UC-aware ramp-down constraint. Replaces the core + :code:`ramp_down_constraint` for technologies that appear in both the + :code:`unit_commitment` and :code:`ramp_down_hourly` tables. + + Symmetric to :func:`uc_ramp_up_constraint` with signs on + :math:`\textbf{UCST}` and :math:`\textbf{UCSP}` reversed: a unit that stops + during :math:`(s,d)` adds headroom (output falls), while a starting unit + reduces it: + + .. math:: + :label: uc_ramp_down + + \frac{\sum_{I,O}\textbf{FO}_{r,p,s,d,i,t,v,o}}{H_{s,d}} + - + \frac{\sum_{I,O}\textbf{FO}_{r,p,s_{next},d_{next},i,t,v,o}}{H_{s_{next},d_{next}}} + \;\le\; + \left[ + RD_{r,t} \cdot \Delta H \cdot \textbf{UCN}_{r,p,s,d,t,v} + + f^{\min}_{\text{eff}} \cdot \textbf{UCSP}_{r,p,s,d,t,v} + - f^{\min}_{\text{eff}} \cdot \textbf{UCST}_{r,p,s,d,t,v} + \right] + \cdot \frac{UC_{r,t} \cdot C2A_{r,t}}{24 \cdot DPP} + + where :math:`f^{\min}_{\text{eff}} = \max(\underline{f}_{r,t},\, RD_{r,t} \cdot \Delta H)`. + """ + return _ramp_constraint( + model=model, + ramp_up=False, + r=r, + p=p, + s=s, + d=d, + t=t, + v=v, + ) diff --git a/temoa/extensions/unit_commitment/components/startup.py b/temoa/extensions/unit_commitment/components/startup.py new file mode 100644 index 000000000..ba7a37cae --- /dev/null +++ b/temoa/extensions/unit_commitment/components/startup.py @@ -0,0 +1,158 @@ +"""Startup cost and emissions wiring for the unit_commitment extension. + +Responsibilities: + - uc_startup_rte_indices: sparse (r, t, e) set for startup emissions/cost data. + - uc_startup_emission_expr_rule: per-(r, p, e) Expression of startup emissions + (used in limit_emission_constraint). + - append_startup_cost_to_total_cost: BuildAction that appends discounted startup + cost to the objective. + - append_startup_emission_cost_to_total_cost: BuildAction that appends discounted + startup emission cost (started_units * unit_cap * emissions * cost_emission) to + the objective. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, cast + +from pyomo.environ import Expression, quicksum, value + +from temoa.components.costs import fixed_or_variable_cost + +if TYPE_CHECKING: + from pyomo.core.base.objective import ObjectiveData + + from temoa.core.model import TemoaModel + from temoa.extensions.unit_commitment.core.model import UnitCommitmentModel + from temoa.types.core_types import ( + Commodity, + Period, + Region, + Season, + Technology, + TimeOfDay, + Vintage, + ) + +logger = logging.getLogger(__name__) + + +def uc_startup_emissions_rpe(model: TemoaModel, r: Region, p: Period, e: Commodity) -> Expression: + """ + Startup emissions to add to limit_emission_constraint. + """ + model = cast('UnitCommitmentModel', model) + return quicksum( + value(model.uc_startup_emissions[_r, _e, t]) + * value(model.uc_unit_capacity[_r, t]) + * model.v_uc_started[_r, p, s, d, t, v] + for _r, _e, t in model.uc_startup_emissions.sparse_keys() + if _r == r and _e == e + for v in model.process_vintages.get((_r, p, t), []) + for s in model.time_season + for d in model.time_of_day + ) + + +def uc_startup_input_rpc( + model: TemoaModel, + r: Region, + p: Period, + c: Commodity, +) -> Expression: + """ + Startup inputs to add to annual_commodity_balance_constraint. + """ + model = cast('UnitCommitmentModel', model) + return quicksum( + uc_startup_input_rpsdc(model, r, p, s, d, c) + for s in model.time_season + for d in model.time_of_day + ) + + +def uc_startup_input_rpsdc( + model: TemoaModel, + r: Region, + p: Period, + s: Season, + d: TimeOfDay, + c: Commodity, +) -> Expression: + """ + Startup inputs to add to commodity_balance_constraint. + """ + model = cast('UnitCommitmentModel', model) + return quicksum( + value(model.uc_startup_input[r, i, t]) + * value(model.uc_unit_capacity[r, t]) + * model.v_uc_started[r, p, s, d, t, v] + for _r, i, t in model.uc_startup_input.sparse_keys() + if _r == r and i == c + for v in model.process_vintages.get((_r, p, t), []) + ) + + +def uc_startup_cost_rptv( + model: TemoaModel, + r: Region, + p: Period, + t: Technology, + v: Vintage, +) -> Expression: + model = cast('UnitCommitmentModel', model) + unit_cap = value(model.uc_unit_capacity[r, t]) + startup_cost_per_cap = value(model.uc_startup_cost[r, t]) + return quicksum( + unit_cap * model.v_uc_started[r, p, s, d, t, v] * startup_cost_per_cap + for s in model.time_season + for d in model.time_of_day + ) + + +def append_startup_costs(model: UnitCommitmentModel) -> None: + r""" + Append discounted startup costs and startup emission costs to the total-cost objective. + + Two cost terms are added: + + 1. **Direct startup cost**: for each :math:`(r, t)` in :code:`uc_startup_cost`, + the cost per timeslice is + :math:`started\_units \cdot UC_{r,t} \cdot startup\_cost\_per\_capacity`, + discounted and summed over all periods and vintages. + + 2. **Startup emission cost**: for each :math:`(r, p, e)` in :code:`cost_emission`, + the startup emissions (from :code:`uc_startup_emissions`) are multiplied by the + emission cost rate, then discounted and added to the objective. + """ + + p_0 = min(model.time_optimize) + gdr = value(model.global_discount_rate) + + startup_costs = quicksum( + fixed_or_variable_cost( + cap_or_flow=1.0, + cost_factor=uc_startup_cost_rptv(model, r, p, t, v), + cost_years=value(model.period_length[p]), + global_discount_rate=gdr, + p_0=p_0, + p=p, + ) + for (r, t) in model.uc_startup_cost.sparse_keys() + for p in model.time_optimize + for v in model.process_vintages.get((r, p, t), []) + ) + startup_costs += quicksum( + fixed_or_variable_cost( + cap_or_flow=1.0, + cost_factor=(model.cost_emission[r, p, e] * uc_startup_emissions_rpe(model, r, p, e)), + cost_years=value(model.period_length[p]), + global_discount_rate=gdr, + p_0=p_0, + p=p, + ) + for (r, p, e) in model.cost_emission + ) + + model.total_cost.set_value(cast('ObjectiveData', model.total_cost).expr + startup_costs) diff --git a/temoa/extensions/unit_commitment/core/__init__.py b/temoa/extensions/unit_commitment/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/temoa/extensions/unit_commitment/core/data_puller.py b/temoa/extensions/unit_commitment/core/data_puller.py new file mode 100644 index 000000000..1f16d24a6 --- /dev/null +++ b/temoa/extensions/unit_commitment/core/data_puller.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import logging +import sqlite3 +from typing import TYPE_CHECKING, NamedTuple, cast + +from pyomo.environ import quicksum, value + +from temoa._internal.exchange_tech_cost_ledger import CostType, ExchangeTechCostLedger +from temoa.components import costs +from temoa.extensions.unit_commitment.components import startup +from temoa.types.model_types import EI, FI, FlowType + +if TYPE_CHECKING: + from temoa._internal.table_writer import TableWriter + from temoa.core.model import TemoaModel + from temoa.extensions.unit_commitment.core.model import UnitCommitmentModel + from temoa.types import Commodity, Period, Region, Season, Technology, TimeOfDay, Vintage + +logger = logging.getLogger(__name__) + + +class UCI(NamedTuple): + """Unit Commitment Index""" + + r: Region + p: Period + s: Season + d: TimeOfDay + t: Technology + v: Vintage + + +def poll_commitment_results(model: TemoaModel) -> dict[UCI, tuple[float, float, float]]: + """ + Poll the start, stop, and online capacity for all unit commitment process, + periods, and time slices, and add them to the output_unit_commitment table. + """ + model = cast('UnitCommitmentModel', model) + + results: dict[UCI, tuple[float, float, float]] = {} + for r, p, s, d, t, v in model.uc_indices_rpsdtv: + unit_cap = value(model.uc_unit_capacity[r, t]) + + online = unit_cap * value(model.v_uc_online[r, p, s, d, t, v]) + started = unit_cap * value(model.v_uc_started[r, p, s, d, t, v]) + stopped = unit_cap * value(model.v_uc_stopped[r, p, s, d, t, v]) + + if online > 0 or started > 0 or stopped > 0: + results[UCI(r, p, s, d, t, v)] = (online, started, stopped) + return results + + +def write_uc_results( + model: TemoaModel, writer: TableWriter, iteration: int | None, epsilon: float = 1e-5 +) -> None: + if writer.tech_sectors is None: + raise RuntimeError('Missing tech_sectors') + + results = poll_commitment_results(model) + scenario = writer._get_scenario_name(iteration) + unit_prop = writer.unit_propagator + records = [] + + for uci, (online, started, stopped) in results.items(): + if all(abs(v) < epsilon for v in (online, started, stopped)): + continue + row = { + 'scenario': scenario, + 'region': uci.r, + 'sector': writer.tech_sectors.get(uci.t), + 'period': uci.p, + 'season': uci.s, + 'tod': uci.d, + 'tech': uci.t, + 'vintage': uci.v, + 'online_cap': online, + 'start_cap': started, + 'stop_cap': stopped, + '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 + + writer._bulk_insert('output_unit_commitment', records) + writer.connection.commit() + + +def poll_startup_input_results( + model: TemoaModel, res: dict[FI, dict[FlowType, float]], epsilon: float = 1e-5 +) -> None: + """ + Poll the emissions for all unit commitment processes in the planning horizon + and add them to the cost entries for the model. + """ + model = cast('UnitCommitmentModel', model) + + keys = { + FI(r, p, s, d, i, t, v, cast('Commodity', None)) + for r, i, t in model.uc_startup_input.sparse_keys() + for p in model.time_optimize + for v in model.process_vintages.get((r, p, t), []) + for s in model.time_season + for d in model.time_of_day + } + for fi in keys: + flow = ( + value(model.uc_startup_input[fi.r, fi.i, fi.t]) + * value(model.uc_unit_capacity[fi.r, fi.t]) + * value(model.v_uc_started[fi.r, fi.p, fi.s, fi.d, fi.t, fi.v]) + ) + if abs(flow) < epsilon: + continue + res[fi][FlowType.IN] = flow + + +def poll_emission_results(model: TemoaModel, flows: dict[EI, float]) -> None: + """ + Poll the emissions for all unit commitment processes in the planning horizon + and add them to the cost entries for the model. + """ + model = cast('UnitCommitmentModel', model) + + keys = { + EI(r, p, t, v, e) + for r, e, t in model.uc_startup_emissions.sparse_keys() + for p in model.time_optimize + for v in model.process_vintages.get((r, p, t), []) + } + for ei in keys: + unit_cap = value(model.uc_unit_capacity[ei.r, ei.t]) + emis_per_cap = value(model.uc_startup_emissions[ei.r, ei.e, ei.t]) + emis = quicksum( + value(model.v_uc_started[ei.r, ei.p, s, d, ei.t, ei.v]) * unit_cap * emis_per_cap + for s in model.time_season + for d in model.time_of_day + ) + flows[ei] += float(emis) + + +# --- Poll unit commitment results --- +def poll_startup_cost_results( + model: TemoaModel, + exchange_costs: ExchangeTechCostLedger, + entries: dict[tuple[Region, Period, Technology, Vintage], dict[CostType, float]], + p_0: Period, + epsilon: float, +) -> None: + """ + Poll the startup costs for all unit commitment processes in the planning horizon + and add them to the cost entries for the model. + """ + model = cast('UnitCommitmentModel', model) + + global_discount_rate = value(model.global_discount_rate) + + keys = { + (r, p, t, v) + for r, t in model.uc_startup_cost.sparse_keys() + for p in model.time_optimize + for v in model.process_vintages.get((r, p, t), []) + } + for r, p, t, v in keys: + cost = float(value(startup.uc_startup_cost_rptv(model, r, p, t, v))) + if abs(cost) < epsilon: + continue + + ud_variable = float(cost * value(model.period_length[p])) + d_variable = costs.fixed_or_variable_cost( + cap_or_flow=1.0, + cost_factor=cost, + cost_years=value(model.period_length[p]), + global_discount_rate=global_discount_rate, + p_0=float(p_0), + p=p, + ) + d_variable = float(value(d_variable)) + + if '-' in r: + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=v, + cost=d_variable, + cost_type=CostType.D_VARIABLE, + ) + exchange_costs.add_cost_record( + r, + period=p, + tech=t, + vintage=v, + cost=ud_variable, + cost_type=CostType.VARIABLE, + ) + else: + entry = entries[r, p, t, v] + entry[CostType.D_VARIABLE] = entry.get(CostType.D_VARIABLE, 0.0) + d_variable + entry[CostType.VARIABLE] = entry.get(CostType.VARIABLE, 0.0) + ud_variable diff --git a/temoa/extensions/unit_commitment/core/model.py b/temoa/extensions/unit_commitment/core/model.py new file mode 100644 index 000000000..0fe4c5bcf --- /dev/null +++ b/temoa/extensions/unit_commitment/core/model.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pyomo.environ import ( + Binary, + BuildAction, + Constraint, + Integers, + NonNegativeReals, + Param, + PositiveReals, + Set, + Var, +) + +from temoa.components import operations +from temoa.extensions.unit_commitment.components import commitment, startup + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.types.core_types import Season, TimeOfDay + + class UnitCommitmentModel(TemoaModel): + """TemoaModel extended with unit-commitment components for type hinting/checking.""" + + # --- Instantiation helpers --- + uc_backslices: dict[tuple[Season, TimeOfDay, int], set[tuple[Season, TimeOfDay]]] + + # --- UC process parameters (indexed by region, tech) --- + uc_unit_capacity: Param + uc_min_output_fraction: Param + uc_max_output_fraction: Param + uc_min_up_time_hours: Param + uc_min_down_time_hours: Param + uc_linearized: Param + + # --- Startup parameters (indexed by region, tech, emission_commodity) --- + uc_startup_cost: Param + uc_startup_emissions: Param + uc_startup_input: Param + + # --- Index sets --- + uc_indices_rpsdtv: Set # all (r,p,s,d,t,v) subject to UC + default_capacity_constraint_rpsdtv: Set + default_ramp_up_constraint_rpsdtv: Set + default_ramp_down_constraint_rpsdtv: Set + uc_ramp_up_constraint_rpsdtv: Set + uc_ramp_down_constraint_rpsdtv: Set + + # --- Build actions --- + uc_initialise: BuildAction + uc_apply_integer_domains: BuildAction + uc_append_startup_costs: BuildAction + + # --- Decision variables --- + v_uc_online: Var + v_uc_started: Var + v_uc_stopped: Var + + # --- Constraints --- + uc_online_upper_constraint: Constraint + uc_min_output_constraint: Constraint + uc_max_output_constraint: Constraint + uc_started_upper_tightening_constraint: Constraint + uc_stopped_upper_tightening_constraint: Constraint + uc_transition_constraint: Constraint + uc_min_up_time_constraint: Constraint + uc_min_down_time_constraint: Constraint + uc_ramp_up_constraint: Constraint + uc_ramp_down_constraint: Constraint + + +def register_early_components(model: TemoaModel) -> None: + """Attach unit-commitment components to the core Temoa model.""" + m = cast('UnitCommitmentModel', model) + + # Instantiation helpers + m.uc_backslices = {} + + # Params + m.uc_unit_capacity = Param(m.regions, m.tech_with_capacity, domain=PositiveReals) + m.uc_min_output_fraction = Param( + m.regions, m.tech_with_capacity, domain=NonNegativeReals, default=0.0 + ) + m.uc_max_output_fraction = Param( + m.regions, m.tech_with_capacity, domain=NonNegativeReals, default=1.0 + ) + m.uc_min_up_time_hours = Param(m.regions, m.tech_with_capacity, domain=Integers, default=0) + m.uc_min_down_time_hours = Param(m.regions, m.tech_with_capacity, domain=Integers, default=0) + m.uc_linearized = Param(m.regions, m.tech_with_capacity, domain=Binary, default=0) + + # Startup params + m.uc_startup_cost = Param(m.regions, m.tech_with_capacity, domain=PositiveReals) + m.uc_startup_emissions = Param( + m.regions, m.commodity_emissions, m.tech_with_capacity, domain=PositiveReals + ) + m.uc_startup_input = Param( + m.regions, m.commodity_physical, m.tech_with_capacity, domain=PositiveReals + ) + + # Index sets + m.uc_indices_rpsdtv = Set(dimen=6, initialize=commitment.uc_constraint_indices) + + # BuildAction to initialise + m.uc_initialise = BuildAction(rule=commitment.initialize_unit_commitment) + + # Decision variables + m.v_uc_online = Var(m.uc_indices_rpsdtv, domain=NonNegativeReals, initialize=0) + m.v_uc_started = Var(m.uc_indices_rpsdtv, domain=NonNegativeReals, initialize=0) + m.v_uc_stopped = Var(m.uc_indices_rpsdtv, domain=NonNegativeReals, initialize=0) + + # BuildAction to upgrade domains to integers where flag is set + m.uc_apply_integer_domains = BuildAction(rule=commitment.apply_integer_domains) + + +def register_model_components(model: TemoaModel) -> None: + """Attach unit-commitment components to the core Temoa model.""" + m = cast('UnitCommitmentModel', model) + + # We intercepted these earlier. Split them by unit commitment vs default + m.default_ramp_up_constraint_rpsdtv = Set( + dimen=6, initialize=commitment.ramp_up_constraint_indices + ) + m.default_ramp_down_constraint_rpsdtv = Set( + dimen=6, initialize=commitment.ramp_down_constraint_indices + ) + m.uc_ramp_up_constraint_rpsdtv = Set( + dimen=6, initialize=commitment.uc_ramp_up_constraint_indices + ) + m.uc_ramp_down_constraint_rpsdtv = Set( + dimen=6, initialize=commitment.uc_ramp_down_constraint_indices + ) + + # Rebuild default constraints we intercepted + m.ramp_up_constraint = Constraint( + m.default_ramp_up_constraint_rpsdtv, rule=operations.ramp_up_constraint + ) + m.ramp_down_constraint = Constraint( + m.default_ramp_down_constraint_rpsdtv, rule=operations.ramp_down_constraint + ) + + # Constraints + m.uc_online_upper_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_online_upper_constraint + ) + m.uc_started_upper_tightening_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_started_upper_tightening_constraint + ) + m.uc_stopped_upper_tightening_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_stopped_upper_tightening_constraint + ) + m.uc_transition_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_transition_constraint + ) + m.uc_min_output_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_min_output_constraint + ) + m.uc_min_up_time_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_min_up_time_constraint + ) + m.uc_min_down_time_constraint = Constraint( + m.uc_indices_rpsdtv, rule=commitment.uc_min_down_time_constraint + ) + m.uc_ramp_up_constraint = Constraint( + m.uc_ramp_up_constraint_rpsdtv, rule=commitment.uc_ramp_up_constraint + ) + m.uc_ramp_down_constraint = Constraint( + m.uc_ramp_down_constraint_rpsdtv, rule=commitment.uc_ramp_down_constraint + ) + + # Startup costs to objective function + m.uc_append_startup_costs = BuildAction(rule=startup.append_startup_costs) diff --git a/temoa/extensions/unit_commitment/data_manifest.py b/temoa/extensions/unit_commitment/data_manifest.py new file mode 100644 index 000000000..e4aafb624 --- /dev/null +++ b/temoa/extensions/unit_commitment/data_manifest.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from temoa.data_io.loader_manifest import LoadItem + +if TYPE_CHECKING: + from temoa.core.model import TemoaModel + from temoa.extensions.unit_commitment.core.model import UnitCommitmentModel + + +def build_manifest_items(model: TemoaModel) -> list[LoadItem]: + m = cast('UnitCommitmentModel', model) + uc: dict[str, Any] = { + 'table': 'unit_commitment', + 'validator_name': 'viable_rt', + 'validation_map': (0, 1), + 'is_table_required': False, + 'is_period_filtered': False, + } + return [ + LoadItem( + component=m.uc_unit_capacity, + columns=['region', 'tech', 'unit_capacity'], + **uc, + ), + LoadItem( + component=m.uc_min_output_fraction, + columns=['region', 'tech', 'min_output_fraction'], + **uc, + ), + LoadItem( + component=m.uc_max_output_fraction, + columns=['region', 'tech', 'max_output_fraction'], + **uc, + ), + LoadItem( + component=m.uc_min_up_time_hours, + columns=['region', 'tech', 'min_up_time_hours'], + **uc, + ), + LoadItem( + component=m.uc_min_down_time_hours, + columns=['region', 'tech', 'min_down_time_hours'], + **uc, + ), + LoadItem( + component=m.uc_linearized, + columns=['region', 'tech', 'linearized'], + **uc, + ), + LoadItem( + component=m.uc_startup_cost, + table='unit_commitment_startup_cost', + columns=['region', 'tech', 'cost_per_cap'], + is_period_filtered=False, + is_table_required=False, + validator_name='viable_rt', + validation_map=(0, 1), + ), + LoadItem( + component=m.uc_startup_emissions, + table='unit_commitment_startup_emissions', + columns=['region', 'emis_comm', 'tech', 'emis_per_cap'], + is_period_filtered=False, + is_table_required=False, + validator_name='viable_rt', + validation_map=(0, 2), + ), + LoadItem( + component=m.uc_startup_input, + table='unit_commitment_startup_input', + columns=['region', 'input_comm', 'tech', 'input_per_cap'], + is_period_filtered=False, + is_table_required=False, + validator_name='viable_rt', + validation_map=(0, 2), + ), + ] diff --git a/temoa/extensions/unit_commitment/extension.py b/temoa/extensions/unit_commitment/extension.py new file mode 100644 index 000000000..5fbb92f2c --- /dev/null +++ b/temoa/extensions/unit_commitment/extension.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from temoa.extensions.framework import ExtensionSpec +from temoa.extensions.unit_commitment.core.model import register_model_components +from temoa.extensions.unit_commitment.data_manifest import build_manifest_items + +UNIT_COMMITMENT_EXTENSION = ExtensionSpec( + extension_id='unit_commitment', + owned_tables=( + 'unit_commitment', + 'unit_commitment_startup_cost', + 'unit_commitment_startup_emissions', + 'unit_commitment_startup_input', + 'output_unit_commitment', + ), + regional_group_tables={}, + register_model_components=register_model_components, + build_manifest_items=build_manifest_items, + schema_sql_path=str(Path(__file__).parent / 'tables.sql'), + fail_if_tables_populated_when_disabled=True, +) diff --git a/temoa/extensions/unit_commitment/tables.sql b/temoa/extensions/unit_commitment/tables.sql new file mode 100644 index 000000000..41ca48662 --- /dev/null +++ b/temoa/extensions/unit_commitment/tables.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS unit_commitment +( + region TEXT NOT NULL, + tech TEXT NOT NULL, + unit_capacity REAL NOT NULL, + min_output_fraction REAL NOT NULL DEFAULT 0.0, + max_output_fraction REAL NOT NULL DEFAULT 1.0, + min_up_time_hours INTEGER NOT NULL DEFAULT 0, + min_down_time_hours INTEGER NOT NULL DEFAULT 0, + linearized INTEGER NOT NULL DEFAULT 0, + units TEXT, + notes TEXT, + CHECK (unit_capacity > 0), + CHECK (0 <= min_output_fraction AND min_output_fraction <= 1), + CHECK (0 < max_output_fraction AND max_output_fraction <= 1), + CHECK (min_up_time_hours >= 0), + CHECK (min_down_time_hours >= 0), + CHECK (linearized IN (0, 1)), + PRIMARY KEY (region, tech) +); +CREATE TABLE IF NOT EXISTS unit_commitment_startup_cost +( + region TEXT NOT NULL, + tech TEXT NOT NULL, + cost_per_cap REAL NOT NULL DEFAULT 0.0, + units TEXT, + notes TEXT, + CHECK (cost_per_cap > 0), + PRIMARY KEY (region, tech) +); +CREATE TABLE IF NOT EXISTS unit_commitment_startup_emissions +( + region TEXT NOT NULL, + emis_comm TEXT NOT NULL DEFAULT '', + tech TEXT NOT NULL, + emis_per_cap REAL NOT NULL DEFAULT 0.0, + units TEXT, + notes TEXT, + CHECK (emis_per_cap > 0), + PRIMARY KEY (region, emis_comm, tech) +); +CREATE TABLE IF NOT EXISTS unit_commitment_startup_input +( + region TEXT NOT NULL, + input_comm TEXT NOT NULL DEFAULT '', + tech TEXT NOT NULL, + input_per_cap REAL NOT NULL DEFAULT 0.0, + units TEXT, + notes TEXT, + CHECK (input_per_cap > 0), + PRIMARY KEY (region, input_comm, tech) +); +CREATE TABLE IF NOT EXISTS output_unit_commitment +( + scenario TEXT NOT NULL, + region TEXT NOT NULL, + sector TEXT NOT NULL, + period INTEGER NOT NULL, + season TEXT NOT NULL, + tod TEXT NOT NULL, + tech TEXT NOT NULL, + vintage INTEGER NOT NULL, + online_cap REAL, + start_cap REAL, + stop_cap REAL, + units TEXT, + PRIMARY KEY (scenario, region, sector, period, season, tod, tech, vintage) +); From 0d9ab4af3d4ef4aa1df0f5c23467c53db584edf3 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 22 Jul 2026 11:18:28 -0400 Subject: [PATCH 20/23] Add a test for unit_commitment Signed-off-by: Davey Elder --- tests/conftest.py | 6 + tests/test_full_runs.py | 45 ++++++++ tests/testing_configs/config_test_week.toml | 23 ++++ tests/testing_data/test_week.sql | 119 ++++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 tests/testing_configs/config_test_week.toml create mode 100644 tests/testing_data/test_week.sql diff --git a/tests/conftest.py b/tests/conftest.py index 5714aa84e..199c7813c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -103,6 +103,7 @@ def refresh_databases() -> None: ('materials.sql', 'materials.sqlite'), ('simple_linked_tech.sql', 'simple_linked_tech.sqlite'), ('storageville.sql', 'storageville.sqlite'), + ('test_week.sql', 'test_week.sqlite'), (None, 'utopia_stochastic.sqlite'), ] @@ -221,6 +222,11 @@ def system_test_run( if myopic_overrides is not None: config.myopic_inputs = {**(config.myopic_inputs or {}), **myopic_overrides} + # Allow parametrized tests to override myopic settings per case. + time_sequencing_override = request.param.get('time_sequencing') + if time_sequencing_override is not None: + config.time_sequencing = time_sequencing_override + sequencer = TemoaSequencer(config=config) sequencer.start() diff --git a/tests/test_full_runs.py b/tests/test_full_runs.py index 9956449e5..349a46abd 100644 --- a/tests/test_full_runs.py +++ b/tests/test_full_runs.py @@ -49,6 +49,12 @@ class MyopicStressCase(TypedDict): myopic: MyopicSettings +class TimeSequenceCase(TypedDict): + name: str + filename: str + time_sequencing: str + + myopic_stress_tests: list[MyopicStressCase] = [ { 'name': ( @@ -68,6 +74,16 @@ class MyopicStressCase(TypedDict): ] +time_sequence_tests: list[TimeSequenceCase] = [ + { + 'name': (f'test_week | {time_sequencing}'), + 'filename': 'config_test_week.toml', + 'time_sequencing': time_sequencing, + } + for time_sequencing in ['consecutive_days', 'representative_periods', 'seasonal_timeslices'] +] + + @pytest.mark.parametrize( 'system_test_run', argvalues=legacy_config_files, @@ -210,6 +226,35 @@ def test_myopic_stress_tests( ) +@pytest.mark.parametrize( + 'system_test_run', + argvalues=time_sequence_tests, + indirect=True, + ids=[d['name'] for d in time_sequence_tests], +) +def test_time_sequence_tests( + system_test_run: tuple[str, SolverResults | None, TemoaModel | None, TemoaSequencer], +) -> None: + _, _, _, sequencer = system_test_run + import contextlib + + objectives = { + 'consecutive_days': 1445.875735, + 'representative_periods': 1467.912766, + 'seasonal_timeslices': 1467.912766, + } + time_sequencing = str(sequencer.config.time_sequencing) + expected_obj = objectives[time_sequencing] + + with contextlib.closing(sqlite3.connect(sequencer.config.output_database)) as con: + cur = con.cursor() + res = cur.execute('SELECT SUM(total_system_cost) FROM main.output_objective').fetchone() + obj = res[0] + assert obj == pytest.approx(expected_obj, abs=0.00001), ( + 'objective function value did not match expected for time sequencing test' + ) + + @pytest.mark.parametrize( 'system_test_run', argvalues=stochastic_files, diff --git a/tests/testing_configs/config_test_week.toml b/tests/testing_configs/config_test_week.toml new file mode 100644 index 000000000..484272eae --- /dev/null +++ b/tests/testing_configs/config_test_week.toml @@ -0,0 +1,23 @@ +scenario = "test_week" +scenario_mode = "perfect_foresight" +extensions= ["unit_commitment"] +input_database = "tests/testing_outputs/test_week.sqlite" +output_database = "tests/testing_outputs/test_week.sqlite" +neos = false +solver_name = "appsi_highs" +save_excel = false +save_duals = false +save_lp_file = false +days_per_period = 7 +reserve_margin = "static" + +[MGA] +slack = 0.1 +iterations = 4 +weight = "integer" + +[myopic] +view_depth = 1 +step_size = 1 +evolving = false +evolution_script = '' diff --git a/tests/testing_data/test_week.sql b/tests/testing_data/test_week.sql new file mode 100644 index 000000000..a0384dc26 --- /dev/null +++ b/tests/testing_data/test_week.sql @@ -0,0 +1,119 @@ +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.05000000000000000277,'Discount Rate for future costs'); +REPLACE INTO metadata_real VALUES('default_loan_rate',0.05000000000000000277,'Default Loan Rate if not specified in LoanRate table'); +REPLACE INTO sector_label VALUES('energy',NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d01','h01','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d02','h12','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d03','h20','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d04','h24','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d05','h01','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d06','h22','uc_cheap',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d05','h16','uc_expensive',0.0,NULL); +REPLACE INTO capacity_factor_tech VALUES('region','d01','h16','uc_expensive',0.0,NULL); +REPLACE INTO capacity_to_activity VALUES('region','uc_cheap',168.0,NULL,NULL); +REPLACE INTO capacity_to_activity VALUES('region','uc_expensive',168.0,NULL,NULL); +REPLACE INTO commodity VALUES('source','s',NULL,NULL); +REPLACE INTO commodity VALUES('demand','d',NULL,NULL); +REPLACE INTO commodity VALUES('co2','e',NULL,NULL); +REPLACE INTO commodity VALUES('dummy','p',NULL,NULL); +REPLACE INTO commodity_type VALUES('s','source commodity'); +REPLACE INTO commodity_type VALUES('a','annual commodity'); +REPLACE INTO commodity_type VALUES('p','physical commodity'); +REPLACE INTO commodity_type VALUES('d','demand commodity'); +REPLACE INTO commodity_type VALUES('e','emissions commodity'); +REPLACE INTO commodity_type VALUES('w','waste commodity'); +REPLACE INTO commodity_type VALUES('wa','waste annual commodity'); +REPLACE INTO commodity_type VALUES('wp','waste physical commodity'); +REPLACE INTO cost_emission VALUES('region',0,'co2',1.0,'$/kt',NULL); +REPLACE INTO cost_fixed VALUES('region',0,'uc_cheap',0,8.0,'$/MWy',NULL); +REPLACE INTO cost_fixed VALUES('region',0,'uc_expensive',0,10.0,'$/MWy',NULL); +REPLACE INTO cost_invest VALUES('region','uc_cheap',0,8.0,'$/MW',NULL); +REPLACE INTO cost_invest VALUES('region','uc_expensive',0,10.0,'$/MW',NULL); +REPLACE INTO cost_invest VALUES('region','daily_storage',0,5.0,'$/MW',NULL); +REPLACE INTO cost_invest VALUES('region','seasonal_storage',0,5.0,'$/MW',NULL); +REPLACE INTO cost_variable VALUES('region',0,'uc_cheap',0,8.0,'$/MWh',NULL); +REPLACE INTO cost_variable VALUES('region',0,'uc_expensive',0,10.0,'$/MWh',NULL); +REPLACE INTO demand VALUES('region',0,'demand',168.0,'MWh',NULL); +REPLACE INTO efficiency VALUES('region','source','uc_cheap',0,'dummy',1.0,'MW/MW',NULL); +REPLACE INTO efficiency VALUES('region','source','uc_expensive',0,'dummy',1.0,'MW/MW',NULL); +REPLACE INTO efficiency VALUES('region','dummy','demand_sink',0,'demand',1.0,'MW/MW',NULL); +REPLACE INTO efficiency VALUES('region','dummy','daily_storage',0,'dummy',1.0,'MW/MW',NULL); +REPLACE INTO efficiency VALUES('region','dummy','seasonal_storage',0,'dummy',1.0,'MW/MW',NULL); +REPLACE INTO lifetime_tech VALUES('region','uc_cheap',1.0,NULL,NULL); +REPLACE INTO lifetime_tech VALUES('region','uc_expensive',1.0,NULL,NULL); +REPLACE INTO operator VALUES('e','equal to'); +REPLACE INTO operator VALUES('le','less than or equal to'); +REPLACE INTO operator VALUES('ge','greater than or equal to'); +REPLACE INTO limit_activity VALUES('region',0,'uc_expensive','ge',10.0,NULL,NULL); +REPLACE INTO limit_activity VALUES('region',0,'uc_cheap','ge',10.0,NULL,NULL); +REPLACE INTO limit_activity VALUES('region',0,'daily_storage','ge',10.0,NULL,NULL); +REPLACE INTO limit_activity VALUES('region',0,'seasonal_storage','ge',10.0,NULL,NULL); +REPLACE INTO limit_capacity VALUES('region',0,'uc_cheap','le',1.0,NULL,NULL); +REPLACE INTO limit_capacity VALUES('region',0,'uc_expensive','le',2.0,NULL,NULL); +REPLACE INTO limit_emission VALUES('global',0,'co2','le',100.0,NULL,NULL); +REPLACE INTO ramp_down_hourly VALUES('region','uc_expensive',0.5,NULL); +REPLACE INTO ramp_down_hourly VALUES('region','uc_cheap',0.5,NULL); +REPLACE INTO ramp_up_hourly VALUES('region','uc_expensive',0.5,NULL); +REPLACE INTO ramp_up_hourly VALUES('region','uc_cheap',0.5,NULL); +REPLACE INTO ramp_up_hourly VALUES('region','seasonal_storage',0.5,NULL); +REPLACE INTO ramp_up_hourly VALUES('region','daily_storage',0.5,NULL); +REPLACE INTO region VALUES('region',NULL);REPLACE INTO storage_duration VALUES('region','daily_storage',1.0,NULL); +REPLACE INTO storage_duration VALUES('region','seasonal_storage',1.0,NULL); +REPLACE INTO technology_type VALUES('p','production technology'); +REPLACE INTO technology_type VALUES('pb','baseload production technology'); +REPLACE INTO technology_type VALUES('ps','storage production technology'); +REPLACE INTO time_of_day VALUES(1,'h01',1.0,NULL); +REPLACE INTO time_of_day VALUES(2,'h02',1.0,NULL); +REPLACE INTO time_of_day VALUES(3,'h03',1.0,NULL); +REPLACE INTO time_of_day VALUES(4,'h04',1.0,NULL); +REPLACE INTO time_of_day VALUES(5,'h05',1.0,NULL); +REPLACE INTO time_of_day VALUES(6,'h06',1.0,NULL); +REPLACE INTO time_of_day VALUES(7,'h07',1.0,NULL); +REPLACE INTO time_of_day VALUES(8,'h08',1.0,NULL); +REPLACE INTO time_of_day VALUES(9,'h09',1.0,NULL); +REPLACE INTO time_of_day VALUES(10,'h10',1.0,NULL); +REPLACE INTO time_of_day VALUES(11,'h11',1.0,NULL); +REPLACE INTO time_of_day VALUES(12,'h12',1.0,NULL); +REPLACE INTO time_of_day VALUES(13,'h13',1.0,NULL); +REPLACE INTO time_of_day VALUES(14,'h14',1.0,NULL); +REPLACE INTO time_of_day VALUES(15,'h15',1.0,NULL); +REPLACE INTO time_of_day VALUES(16,'h16',1.0,NULL); +REPLACE INTO time_of_day VALUES(17,'h17',1.0,NULL); +REPLACE INTO time_of_day VALUES(18,'h18',1.0,NULL); +REPLACE INTO time_of_day VALUES(19,'h19',1.0,NULL); +REPLACE INTO time_of_day VALUES(20,'h20',1.0,NULL); +REPLACE INTO time_of_day VALUES(21,'h21',1.0,NULL); +REPLACE INTO time_of_day VALUES(22,'h22',1.0,NULL); +REPLACE INTO time_of_day VALUES(23,'h23',1.0,NULL); +REPLACE INTO time_of_day VALUES(24,'h24',1.0,NULL); +REPLACE INTO time_period VALUES(0,0,'f'); +REPLACE INTO time_period VALUES(1,1,'f'); +REPLACE INTO time_period_type VALUES('e','existing vintages'); +REPLACE INTO time_period_type VALUES('f','future');REPLACE INTO technology VALUES('uc_cheap','p','energy',NULL,NULL,0,0,0,0,0,0,0,0,NULL); +REPLACE INTO technology VALUES('uc_expensive','p','energy',NULL,NULL,0,0,0,0,0,0,0,0,NULL); +REPLACE INTO technology VALUES('demand_sink','p','energy',NULL,NULL,1,0,0,0,0,0,0,0,NULL); +REPLACE INTO technology VALUES('daily_storage','ps','energy',NULL,NULL,0,0,0,0,0,0,0,0,NULL); +REPLACE INTO technology VALUES('seasonal_storage','ps','energy',NULL,NULL,0,0,0,0,0,0,0,1,NULL); +REPLACE INTO time_season VALUES(1,'d01',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(2,'d02',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(3,'d03',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(4,'d04',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(5,'d05',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(6,'d06',0.1428571428571428493,'1/7'); +REPLACE INTO time_season VALUES(7,'d07',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(1,'d01','d03',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(2,'d02','d05',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(3,'d03','d01',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(4,'d04','d07',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(5,'d05','d04',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(6,'d06','d06',0.1428571428571428493,'1/7'); +REPLACE INTO time_season_sequential VALUES(7,'d07','d02',0.1428571428571428493,'1/7'); +REPLACE INTO unit_commitment VALUES('region','uc_cheap',0.2000000000000000111,0.5,1.0,3,5,0,NULL,NULL); +REPLACE INTO unit_commitment VALUES('region','uc_expensive',0.2000000000000000111,0.5,1.0,2,1,1,NULL,NULL); +REPLACE INTO unit_commitment_startup_cost VALUES('region','uc_expensive',5.0,NULL,NULL); +REPLACE INTO unit_commitment_startup_cost VALUES('region','uc_cheap',4.0,NULL,NULL); +REPLACE INTO unit_commitment_startup_emissions VALUES('region','co2','uc_expensive',5.0,NULL,NULL); +REPLACE INTO unit_commitment_startup_emissions VALUES('region','co2','uc_cheap',4.0,NULL,NULL); +REPLACE INTO unit_commitment_startup_input VALUES('region','source','uc_expensive',5.0,NULL,NULL); +REPLACE INTO unit_commitment_startup_input VALUES('region','source','uc_cheap',4.0,NULL,NULL); From b7418e90e4b32ebb8f99d32a83b8c03666eb25a3 Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 22 Jul 2026 12:50:44 -0400 Subject: [PATCH 21/23] Miscellaneous tidying for the rabbit Signed-off-by: Davey Elder --- temoa/components/time.py | 14 ++------- temoa/data_io/hybrid_loader.py | 31 +++++++++---------- .../extensions/discrete_capacity/extension.py | 1 - .../components/cost_invest_eos.py | 31 ++++++++++--------- .../economies_of_scale/core/model.py | 8 +++-- .../economies_of_scale/extension.py | 1 - temoa/extensions/framework.py | 14 +++------ temoa/extensions/growth_rates/extension.py | 1 - temoa/extensions/template/extension.py | 3 -- temoa/extensions/unit_commitment/extension.py | 1 - temoa/extensions/unit_commitment/tables.sql | 10 +++--- tests/conftest.py | 2 +- 12 files changed, 49 insertions(+), 68 deletions(-) diff --git a/temoa/components/time.py b/temoa/components/time.py index 0422041ca..769d8825e 100644 --- a/temoa/components/time.py +++ b/temoa/components/time.py @@ -308,13 +308,7 @@ def create_time_sequence(model: TemoaModel) -> None: def create_time_season_to_sequential(model: TemoaModel) -> None: - if all( - ( - not model.tech_seasonal_storage, - not model.ramp_up_hourly, - not model.ramp_down_hourly, - ) - ): + if not model.tech_seasonal_storage: # Don't need it anyway return @@ -334,11 +328,9 @@ def create_time_season_to_sequential(model: TemoaModel) -> None: else: msg = ( f'No data in time_season_sequential but time_sequencing parameter set to ' - f'{model.time_sequencing.first()} and inter-season features used. ' + f'{model.time_sequencing.first()} and seasonal storage used. ' 'time_season_sequential must be filled for this type of time sequencing if ' - 'seasonal storage or inter-season constraints like ramp_up/ramp_down are used. ' - 'Check ' - 'the config file.' + 'seasonal storage is used. Check the config file.' ) logger.error(msg) raise ValueError(msg) diff --git a/temoa/data_io/hybrid_loader.py b/temoa/data_io/hybrid_loader.py index 42ad02e97..6f7e26b1c 100644 --- a/temoa/data_io/hybrid_loader.py +++ b/temoa/data_io/hybrid_loader.py @@ -564,7 +564,7 @@ def _load_tech_group_members( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads members into the indexed set `tech_group_members`.""" - model = TemoaModel() + model = self.model validator = self.viable_techs.members if self.viable_techs else None for group_name, tech in filtered_data: if validator is None or tech in validator: @@ -582,7 +582,7 @@ def _load_time_season( """ Loads time_season as a flat ordered set of season names. """ - model = TemoaModel() + model = self.model if not filtered_data: logger.warning('No time_season table found. Loading a single filler season "S".') seasons_to_load: list[tuple[object, ...]] = [('S',)] @@ -599,7 +599,7 @@ def _load_time_season_sequential( """ Composite loader for time_season_sequential and its associated index sets. """ - model = TemoaModel() + model = self.model if filtered_data: seg_frac_data = [ (row[0], row[2]) for row in filtered_data @@ -623,7 +623,7 @@ def _load_existing_capacity( Handles different queries for myopic vs. standard runs and also populates the `tech_exist` set. """ - model = TemoaModel() + model = self.model cur = self.con.cursor() mi = self.myopic_index @@ -652,7 +652,6 @@ def _load_existing_capacity( self._load_component_data(data, model.vintage_exist, vintage_exist_data) # Collect existing capacity data indices - self.viable_existing_techs = {row[1] for row in rows_to_load} self.viable_existing_rt = {(row[0], row[1]) for row in rows_to_load} self.viable_existing_rtv = {(row[0], row[1], row[2]) for row in rows_to_load} @@ -672,7 +671,7 @@ def _load_retired_existing_capacity( ) return - model = TemoaModel() + model = self.model cur = self.con.cursor() mi = self.myopic_index @@ -701,12 +700,12 @@ def _load_lifetime_tech( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the lifetime_tech component.""" - model = TemoaModel() + model = self.model cur = self.con.cursor() rows_to_load = cur.execute('SELECT region, tech, lifetime FROM lifetime_tech').fetchall() rt_getter = itemgetter(0, 1) if self.viable_rt: - valid_rt = self.viable_rt.members | self.viable_existing_rt + valid_rt = self.viable_rt.member_tuples | self.viable_existing_rt rows_to_load = [item for item in rows_to_load if rt_getter(item) in valid_rt] self._load_component_data(data, model.lifetime_tech, rows_to_load) @@ -717,7 +716,7 @@ def _load_lifetime_process( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the lifetime_process component.""" - model = TemoaModel() + model = self.model cur = self.con.cursor() mi = self.myopic_index @@ -743,7 +742,7 @@ def _load_lifetime_survival_curve( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the lifetime_survival_curve component.""" - model = TemoaModel() + model = self.model cur = self.con.cursor() mi = self.myopic_index @@ -771,7 +770,7 @@ def _load_global_discount_rate( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the required singleton global_discount_rate.""" - model = TemoaModel() + model = self.model if filtered_data: data[model.global_discount_rate.name] = {None: cast('float', filtered_data[0][0])} else: @@ -787,7 +786,7 @@ def _load_default_loan_rate( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the optional singleton default_loan_rate.""" - model = TemoaModel() + model = self.model if filtered_data: data[model.default_loan_rate.name] = {None: cast('float', filtered_data[0][0])} @@ -799,7 +798,7 @@ def _load_efficiency( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Loads the main efficiency parameter, which is pre-calculated.""" - model = TemoaModel() + model = self.model self._load_component_data(data, model.efficiency, self.efficiency_values) def _load_linked_techs( @@ -832,7 +831,7 @@ def _load_ramping_down( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Composite loader for ramp_down_hourly and its index set `tech_downramping`.""" - model = TemoaModel() + model = self.model self._load_component_data(data, model.ramp_down_hourly, filtered_data) if filtered_data: tech_data = sorted({(row[1],) for row in filtered_data}) @@ -848,7 +847,7 @@ def _load_ramping_up( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Composite loader for ramp_up_hourly and its index set `tech_upramping`.""" - model = TemoaModel() + model = self.model self._load_component_data(data, model.ramp_up_hourly, filtered_data) if filtered_data: tech_data = sorted({(row[1],) for row in filtered_data}) @@ -864,7 +863,7 @@ def _load_rps_requirement( filtered_data: Sequence[tuple[object, ...]], ) -> None: """Handles deprecation warning for renewable_portfolio_standard.""" - model = TemoaModel() + model = self.model self._load_component_data(data, model.renewable_portfolio_standard, filtered_data) if filtered_data: logger.warning( diff --git a/temoa/extensions/discrete_capacity/extension.py b/temoa/extensions/discrete_capacity/extension.py index d9a062c89..fb58c8cdd 100644 --- a/temoa/extensions/discrete_capacity/extension.py +++ b/temoa/extensions/discrete_capacity/extension.py @@ -16,5 +16,4 @@ register_model_components=register_model_components, build_manifest_items=build_manifest_items, schema_sql_path=str(Path(__file__).parent / 'tables.sql'), - fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/economies_of_scale/components/cost_invest_eos.py b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py index 06a3c902c..732034e7f 100644 --- a/temoa/extensions/economies_of_scale/components/cost_invest_eos.py +++ b/temoa/extensions/economies_of_scale/components/cost_invest_eos.py @@ -397,25 +397,26 @@ def period_cost(model: EOSModel, r: Region, p: Period, t: Technology) -> Express # Existing capacity costs to subtract (needed for myopic) regions = geography.gather_group_regions(model, r) techs = technology.gather_group_techs(model, t) - existing_capacity = quicksum( + existing_capacity = sum( value(model.existing_capacity[_r, _t, _v]) for _r, _t, _v in model.existing_capacity if _r in regions and _t in techs ) - if existing_capacity: - for n in model.cost_invest_eos_segments[r, t]: - cap_lower, cap_upper, _, _ = model.cost_invest_eos[r, t, n] - if value(cap_lower) <= existing_capacity <= value(cap_upper): - prev_cum_cost = cost_invest_eos_segment_cost(model, r, t, n, existing_capacity) - continue # in case we're exactly on the boundary of two segments - if not prev_cum_cost: - msg = ( - 'Existing capacity for a cost_invest_eos cluster is outside the bounds of ' - 'the cost curve. Check the cost_invest_eos table and existing_capacity ' - f'for {r, t}: {existing_capacity}' - ) - logger.error(msg) - raise ValueError(msg) + in_bounds = False + for n in model.cost_invest_eos_segments[r, t]: + cap_lower, cap_upper, _, _ = model.cost_invest_eos[r, t, n] + if value(cap_lower) <= existing_capacity <= value(cap_upper): + prev_cum_cost = cost_invest_eos_segment_cost(model, r, t, n, existing_capacity) + in_bounds = True + break + if not in_bounds: + msg = ( + 'Existing capacity for a cost_invest_eos cluster is outside the bounds of ' + 'the cost curve. Check the cost_invest_eos table and existing_capacity ' + f'for {r, t}: {existing_capacity}' + ) + logger.error(msg) + raise ValueError(msg) return cumulative_cost - prev_cum_cost diff --git a/temoa/extensions/economies_of_scale/core/model.py b/temoa/extensions/economies_of_scale/core/model.py index 42b4656a1..eca233a7a 100644 --- a/temoa/extensions/economies_of_scale/core/model.py +++ b/temoa/extensions/economies_of_scale/core/model.py @@ -14,9 +14,11 @@ Var, ) -import temoa.extensions.economies_of_scale.components.cost_fixed_eos as cost_fixed_eos -import temoa.extensions.economies_of_scale.components.cost_invest_eos as cost_invest_eos -import temoa.extensions.economies_of_scale.components.cost_variable_eos as cost_variable_eos +from temoa.extensions.economies_of_scale.components import ( + cost_fixed_eos, + cost_invest_eos, + cost_variable_eos, +) if TYPE_CHECKING: from temoa.core.model import TemoaModel diff --git a/temoa/extensions/economies_of_scale/extension.py b/temoa/extensions/economies_of_scale/extension.py index c0b5d8cf5..ecfb7911c 100644 --- a/temoa/extensions/economies_of_scale/extension.py +++ b/temoa/extensions/economies_of_scale/extension.py @@ -17,5 +17,4 @@ register_model_components=register_model_components, build_manifest_items=build_manifest_items, schema_sql_path=str(Path(__file__).parent / 'tables.sql'), - fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/framework.py b/temoa/extensions/framework.py index a7116f504..9c4638ede 100644 --- a/temoa/extensions/framework.py +++ b/temoa/extensions/framework.py @@ -29,7 +29,6 @@ class ExtensionSpec: register_model_components: ModelHook | None = None build_manifest_items: ManifestHook | None = None schema_sql_path: str | None = None - fail_if_tables_populated_when_disabled: bool = False def normalize_extension_ids(extension_ids: Sequence[str | object] | None) -> tuple[str, ...]: @@ -43,7 +42,7 @@ def normalize_extension_ids(extension_ids: Sequence[str | object] | None) -> tup if not isinstance(ext_id, str): msg = f'Extension ids must be strings. Received: {type(ext_id).__name__}' logger.error(msg) - raise ValueError(msg) + raise TypeError(msg) cleaned = ext_id.strip().lower() if not cleaned: continue @@ -125,24 +124,19 @@ def merge_regional_group_tables( def assert_disabled_extension_tables_are_empty( con: Connection, enabled_specs: Sequence[ExtensionSpec] ) -> None: - """Fail if disabled extensions with strict guards own tables populated with data.""" + """Warn if disabled extension has table populated with data.""" enabled_ids = {spec.extension_id for spec in enabled_specs} for spec in get_known_extension_specs().values(): if spec.extension_id in enabled_ids: continue - if not spec.fail_if_tables_populated_when_disabled: - continue - populated: list[str] = [] - for table in spec.owned_tables: - if _table_has_rows(con, table): - populated.append(table) + populated = [table for table in spec.owned_tables if _table_has_rows(con, table)] if populated: table_list = ', '.join(sorted(populated)) msg = ( f"Extension '{spec.extension_id}' is not enabled, but extension-owned table(s) " - f'contain data: {table_list}. Enable the extension or remove those rows.' + f'contain data: {table_list}.' ) logger.warning(msg) diff --git a/temoa/extensions/growth_rates/extension.py b/temoa/extensions/growth_rates/extension.py index 15847fc0c..7adbeb9f4 100644 --- a/temoa/extensions/growth_rates/extension.py +++ b/temoa/extensions/growth_rates/extension.py @@ -27,5 +27,4 @@ register_model_components=register_model_components, build_manifest_items=build_manifest_items, schema_sql_path=str(Path(__file__).parent / 'tables.sql'), - fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/template/extension.py b/temoa/extensions/template/extension.py index 9c8509d85..14fc64887 100644 --- a/temoa/extensions/template/extension.py +++ b/temoa/extensions/template/extension.py @@ -28,7 +28,4 @@ build_manifest_items=build_manifest_items, # Schema applied (with consent) when enabled but tables are missing. schema_sql_path=str(Path(__file__).parents[0] / 'tables.sql'), - # If True, loading fails when this extension is DISABLED but its tables hold - # data, preventing silently-ignored inputs. - fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/unit_commitment/extension.py b/temoa/extensions/unit_commitment/extension.py index 5fbb92f2c..27e2c7e7b 100644 --- a/temoa/extensions/unit_commitment/extension.py +++ b/temoa/extensions/unit_commitment/extension.py @@ -19,5 +19,4 @@ register_model_components=register_model_components, build_manifest_items=build_manifest_items, schema_sql_path=str(Path(__file__).parent / 'tables.sql'), - fail_if_tables_populated_when_disabled=True, ) diff --git a/temoa/extensions/unit_commitment/tables.sql b/temoa/extensions/unit_commitment/tables.sql index 41ca48662..282929adb 100644 --- a/temoa/extensions/unit_commitment/tables.sql +++ b/temoa/extensions/unit_commitment/tables.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS unit_commitment_startup_cost ( region TEXT NOT NULL, tech TEXT NOT NULL, - cost_per_cap REAL NOT NULL DEFAULT 0.0, + cost_per_cap REAL NOT NULL, units TEXT, notes TEXT, CHECK (cost_per_cap > 0), @@ -31,9 +31,9 @@ CREATE TABLE IF NOT EXISTS unit_commitment_startup_cost CREATE TABLE IF NOT EXISTS unit_commitment_startup_emissions ( region TEXT NOT NULL, - emis_comm TEXT NOT NULL DEFAULT '', + emis_comm TEXT NOT NULL, tech TEXT NOT NULL, - emis_per_cap REAL NOT NULL DEFAULT 0.0, + emis_per_cap REAL NOT NULL, units TEXT, notes TEXT, CHECK (emis_per_cap > 0), @@ -42,9 +42,9 @@ CREATE TABLE IF NOT EXISTS unit_commitment_startup_emissions CREATE TABLE IF NOT EXISTS unit_commitment_startup_input ( region TEXT NOT NULL, - input_comm TEXT NOT NULL DEFAULT '', + input_comm TEXT NOT NULL, tech TEXT NOT NULL, - input_per_cap REAL NOT NULL DEFAULT 0.0, + input_per_cap REAL NOT NULL, units TEXT, notes TEXT, CHECK (input_per_cap > 0), diff --git a/tests/conftest.py b/tests/conftest.py index 199c7813c..444aebd50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -222,7 +222,7 @@ def system_test_run( if myopic_overrides is not None: config.myopic_inputs = {**(config.myopic_inputs or {}), **myopic_overrides} - # Allow parametrized tests to override myopic settings per case. + # Allow parametrized tests to override time sequencing mode per case. time_sequencing_override = request.param.get('time_sequencing') if time_sequencing_override is not None: config.time_sequencing = time_sequencing_override From d79e278e0647ec75a55b571410d4ac1b5ce3b87d Mon Sep 17 00:00:00 2001 From: Davey Elder Date: Wed, 22 Jul 2026 12:50:59 -0400 Subject: [PATCH 22/23] Update mediumville set and make empty sets more obvious for diagnosis Signed-off-by: Davey Elder --- tests/test_set_consistency.py | 4 +- tests/testing_data/mediumville_sets.json | 58 +++++----- tests/testing_data/test_system_sets.json | 84 +++++++-------- tests/testing_data/utopia_sets.json | 100 +++++++++--------- .../utilities/capture_set_values_for_cache.py | 4 +- tests/utilities/hash_utils.py | 14 +++ 6 files changed, 139 insertions(+), 125 deletions(-) diff --git a/tests/test_set_consistency.py b/tests/test_set_consistency.py index 7f18054cb..74108b765 100644 --- a/tests/test_set_consistency.py +++ b/tests/test_set_consistency.py @@ -12,7 +12,7 @@ from temoa._internal.temoa_sequencer import TemoaSequencer from temoa.core.config import TemoaConfig from temoa.core.modes import TemoaMode -from tests.utilities.hash_utils import hash_set +from tests.utilities.hash_utils import decode_hashes, hash_set TESTING_CONFIGS_DIR = pathlib.Path(__file__).parent / 'testing_configs' @@ -48,7 +48,7 @@ def test_set_consistency( # retrieve the cache which now stores hashes cache_file = pathlib.Path(__file__).parent / 'testing_data' / set_file with open(cache_file) as src: - cached_sets = json.load(src) + cached_sets = decode_hashes(json.load(src)) # compare hashes where they exist in the model. mismatched_sets = {} diff --git a/tests/testing_data/mediumville_sets.json b/tests/testing_data/mediumville_sets.json index 97cf8566a..3aca6a682 100644 --- a/tests/testing_data/mediumville_sets.json +++ b/tests/testing_data/mediumville_sets.json @@ -1,6 +1,6 @@ { - "annual_commodity_balance_constraint_rpc": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "annual_retirement_var_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "annual_commodity_balance_constraint_rpc": "(empty)", + "annual_retirement_var_rptv": "(empty)", "baseload_diurnal_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", "capacity_annual_constraint_rptv": "6bd365e2e2f3267c7b838774484507a0812b25077f3006bcbfe135eceda11a28", "capacity_available_var_rpt": "34f71aae4b6b3a07130b57fa7910379bc1ecc3e12a6b4930fca5717bf54f8f56", @@ -8,7 +8,7 @@ "capacity_factor_rsdt": "596087531ddbb1b27bc63b78e85b3d4fbb56c621ed2dbe35ef4f9f0ffcc49f68", "capacity_var_rptv": "fdb177f119430de12c8b1083c9275e3a256f45f859d6b6819a84bff5c8e92184", "commodity_all": "1967c4b68c028414d99b55bfe9e03efd2fff370d15540a95282b721875d41460", - "commodity_annual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_annual": "(empty)", "commodity_balance_constraint_rpsdc": "a1d06a815047ba07b42ced6dc65df3511301af2f30791b3094fb7f25ccdd4217", "commodity_carrier": "1b95135b3de272d1ecab3761bdb976f1e48c044dcb2b5b6d320bc19dae0038cd", "commodity_demand": "f0ecafabcdc5015dab09f4b23f3c742a27b536f1c5634057a902a9b9c4a741b8", @@ -17,7 +17,7 @@ "commodity_physical": "7dd03855822916eb73171efbb05042535e0f89c26614139ed357a002b8a80fd1", "commodity_sink": "f0ecafabcdc5015dab09f4b23f3c742a27b536f1c5634057a902a9b9c4a741b8", "commodity_source": "ab3705f3ba0b69baa453c70d1bdfde3faa4eaeabc007204278258b4aacd861f3", - "commodity_waste": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_waste": "(empty)", "cost_emission_rpe": "14c414e00f949816cbdbbfc01ab7df2fea18cd7f0ab4e84a33571e41667bd057", "cost_fixed_rptv": "fdb177f119430de12c8b1083c9275e3a256f45f859d6b6819a84bff5c8e92184", "cost_invest_rtv": "cc795cbbeaaae0c046247752c104269e877cd4c402391b63ce6fe4d6ed4cf46b", @@ -26,7 +26,7 @@ "demand_activity_constraint_rpsdtv_dem": "91570a9fbe4b071c37f1985c1671de2f33d9dfa7847f791a2933b8b3f262f8e8", "demand_constraint_rpc": "65996df03f7f3d85067ae18d36bdffe8596881473ae847052dc5df735cd01262", "emission_activity_reitvo": "1292a5003c98c9ae6b7476807aca4bcc528b3dc28aec79827a47aeaaaaab74e4", - "flex_var_annual_rpitvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "flex_var_annual_rpitvo": "(empty)", "flex_var_rpsditvo": "e4a8d7519d3e9deda0a32a471bdfa825eb893f10ba5781b895c9e3a3ef5faaa3", "flow_in_storage_rpsditvo": "ff680754a218ee843941e77bd07cbc5e5afd74e2b7b29a76ea86621624e8c0fd", "flow_var_annual_rpitvo": "6f5c569787fbf1e0a4076011f75fcd50a840bac85bb57b026f4fc5682739f888", @@ -34,30 +34,30 @@ "lifetime_tech_rt": "6786f2e9acdccce907ebad50d2ea70481bff72a749253e78168d29b480b07f65", "lifetime_process_rtv": "212639322f29aa7687eb9962fb9d74ef60961d720a2a753e2af53473b9e1648f", "limit_activity_constraint_rpt": "90461349933d0b32167abdca242233004b66212b57ed57213dce6f6818203963", - "limit_activity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_activity_share_constraint_rpgg": "(empty)", + "limit_annual_capacity_factor_constraint_rptvo": "(empty)", + "limit_annual_capacity_factor_constraint_rtvo": "(empty)", "limit_capacity_constraint_rpt": "118826dabd14af75ae09adbd5bdbba750528b3d59907cbfba3690223ffc7def7", - "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_capacity_share_constraint_rpgg": "(empty)", "limit_emission_constraint_rpe": "9bf30514ecc261370f67cd0c2e18194f45ff14c15764d274cc342a5a27eaf2a0", - "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_new_capacity_constraint_rtv": "(empty)", "limit_new_capacity_share_constraint_rggv": "296e4148565f752973655692bae04ebf50877bd13fe9cec8f9563f347a61d885", "limit_resource_constraint_rt": "b9b44ae8bc9642da0a81202877bd69f0b35ce193e31590dd3325ac0828edb5cf", - "limit_seasonal_capacity_factor_constraint_rpst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_seasonal_capacity_factor_constraint_rst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_storage_fraction_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_storage_fraction_param_rsdt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_annual_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_average_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_seasonal_capacity_factor_constraint_rpst": "(empty)", + "limit_seasonal_capacity_factor_constraint_rst": "(empty)", + "limit_storage_fraction_constraint_rpsdtv": "(empty)", + "limit_storage_fraction_param_rsdt": "(empty)", + "limit_tech_input_split_annual_constraint_rpitv": "(empty)", + "limit_tech_input_split_average_constraint_rpitv": "(empty)", "limit_tech_input_split_constraint_rpsditv": "7a0e9b6c8271bcc30ccd6588da984c4aa8afe5d768ce5035fe6d8e62b31f8b3e", - "limit_tech_output_split_annual_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_output_split_average_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_tech_output_split_annual_constraint_rptvo": "(empty)", + "limit_tech_output_split_average_constraint_rptvo": "(empty)", "limit_tech_output_split_constraint_rpsdtvo": "7cf9f5c111231f4e5c3f0ee8acfcddf52f5b1af3474d4cb949b969997602c8d7", "linked_emissions_tech_constraint_rpsdtve": "f5accc0ee9eaaf16e584566a50e576405f8b5e1eb7d13815b87cb2f8b33a19b8", "loan_lifetime_process_rtv": "212639322f29aa7687eb9962fb9d74ef60961d720a2a753e2af53473b9e1648f", "new_capacity_var_rtv": "212639322f29aa7687eb9962fb9d74ef60961d720a2a753e2af53473b9e1648f", "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", - "ordered_season_sequential": "53bc15b6510e5bad389153a661165e5a434fea3f17ba2c8b3919568edf124f72", + "ordered_season_sequential": "(empty)", "process_life_frac_rptv": "fdb177f119430de12c8b1083c9275e3a256f45f859d6b6819a84bff5c8e92184", "ramp_down_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", "ramp_up_constraint_rpsdtv": "d766f05fa377b69768214641fda5d15617b697a43e64c863fe53503f11db4a89", @@ -68,9 +68,9 @@ "renewable_portfolio_standard_constraint_rpg": "ab39415b5df1f906b11f06034cbfca390f7332b6a17adae60a71a74d7b421f67", "reserve_margin_method": "7869283c0d14273f720716309207a8f0c24606d03c679d6b68e656ed8d86241d", "reserve_margin_rpsd": "9bd91053e57055456e04648c00d8e581addc9aa503cf31cf44c7a439c6f4fe0f", - "retired_capacity_var_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_constraints_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_level_rpstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "retired_capacity_var_rptv": "(empty)", + "seasonal_storage_constraints_rpsdtv": "(empty)", + "seasonal_storage_level_rpstv": "(empty)", "storage_constraints_rpsdtv": "e1fe21679519e4410954d233e22d9490b182f21fda460b31a8d02538856fb85c", "storage_init_rpstv": "90db44be71af3d9d75369791755570cf888e63e3544be22800077a4a2871175c", "storage_level_rpsdtv": "e1fe21679519e4410954d233e22d9490b182f21fda460b31a8d02538856fb85c", @@ -81,7 +81,7 @@ "tech_demand": "b9e4a17c6dbf50597b7fd89aa581889bc19b262bd61629465295a52d91208096", "tech_downramping": "ddcf6ff7665c2a8acc4dff1b43655fae1b5a265135cbee18cec638df4e954346", "tech_exchange": "4ad32a8fc9b95e840aa771b2dabc6fb35fae4904c089d4552659a4a9267883a7", - "tech_exist": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_exist": "(empty)", "tech_flex": "658ce9e7b9c3d7bcc751d8d49b9ece9c57623b3e25470c4f849af44b61ea5e20", "tech_group_members": "6b923047f11d1c00a828b0f02aed79e853d81fd35ffd4667fbdc828ce356e515", "tech_group_names": "6b923047f11d1c00a828b0f02aed79e853d81fd35ffd4667fbdc828ce356e515", @@ -89,20 +89,20 @@ "tech_production": "24f75b6f705a36033456d02638e7c50667908c45c474151fb8490666d928c63f", "tech_reserve": "b28d0fd7ec11abdd0e645c2a9d83b05c08ecadcfb944d4a7bbd844ff4b83bfbf", "tech_retirement": "ddcf6ff7665c2a8acc4dff1b43655fae1b5a265135cbee18cec638df4e954346", - "tech_seasonal_storage": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_seasonal_storage": "(empty)", "tech_storage": "e4e91128cadba8c633bf98029cf6666adc3959c63967c6f396028b78e2e9f0cf", - "tech_uncap": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_uncap": "(empty)", "tech_upramping": "ddcf6ff7665c2a8acc4dff1b43655fae1b5a265135cbee18cec638df4e954346", "tech_with_capacity": "24f75b6f705a36033456d02638e7c50667908c45c474151fb8490666d928c63f", - "time_exist": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_exist": "(empty)", "time_future": "6b150df8e12ac4dd15396b52f304c7935a41d2cc4da498186552819973171389", - "time_manual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_manual": "(empty)", "time_of_day": "f9bbfe9130c510cba59a13e8b385b4d0206196abbdfe8032b995502bb7215f76", "time_optimize": "9c9380fb50cd4f4f9e2032bd9a18645ae4fc1d30335672c62c897bcb9e099ba5", "time_season": "1e413891065e24bbf66543d4747ff12b1c65bf23c4ff9857b0bf0520eccd7f21", - "time_season_sequential": "1e413891065e24bbf66543d4747ff12b1c65bf23c4ff9857b0bf0520eccd7f21", + "time_season_sequential": "(empty)", "time_sequencing": "91f69c8abab9959c1f8c90f5aaa56db29bccc67e37d12673ab41c54e4179d7ca", "vintage_all": "9c9380fb50cd4f4f9e2032bd9a18645ae4fc1d30335672c62c897bcb9e099ba5", - "vintage_exist": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "vintage_exist": "(empty)", "vintage_optimize": "9c9380fb50cd4f4f9e2032bd9a18645ae4fc1d30335672c62c897bcb9e099ba5" } diff --git a/tests/testing_data/test_system_sets.json b/tests/testing_data/test_system_sets.json index 8a7d66f84..40e5b7ab4 100644 --- a/tests/testing_data/test_system_sets.json +++ b/tests/testing_data/test_system_sets.json @@ -1,24 +1,24 @@ { - "annual_commodity_balance_constraint_rpc": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "annual_commodity_balance_constraint_rpc": "(empty)", "annual_retirement_var_rptv": "ee162bcd5c52c6e713033f89c7ffd0d244e4a34721217cfaf699959b131adcda", "baseload_diurnal_constraint_rpsdtv": "20070e9c3c7443e237d3779a8ef33a25ae3a29284f5e1158a257f1a1d80c1421", - "capacity_annual_constraint_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "capacity_annual_constraint_rptv": "(empty)", "capacity_available_var_rpt": "8f305de45d0d42708ef17b16602d770ef731810252b0d0b3a89dc396b54b8e8a", "capacity_constraint_rpsdtv": "cd98cf3db577dec19c292199f5b7b8af2dfe336174af315ce2186bbcbd8e81cf", "capacity_factor_rsdt": "53e46681758c146b82380d26b0fc8eb4f6e95a91c3c2766221e33f895863e1aa", "capacity_var_rptv": "d839477ae7f5d8fc897e2df4c3c519d742f946b2ce74e1d92fff91e50285296f", "commodity_all": "01fbdcd2e272aa7c36fbf1400e575188f4501e1957bba1dc2b29cd11ec6a070c", - "commodity_annual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_annual": "(empty)", "commodity_balance_constraint_rpsdc": "4a46a0de6ec903639858a35c63fc46223f82ab1e057b20f4a1624eacb6652229", "commodity_carrier": "3508a787012b20265e9c97acebe2c39d6648b972e7bb4a6d42083e7e167b5c92", "commodity_demand": "6b657255c2baf8b6d4fc96f21cb21263bae7378eb11a9b9cf25a0d8aa83c6aca", "commodity_emissions": "6aa406c61418fb1a0cc0d9f505fafcbf94e65a66cb5c5f1dc0afa8117b762979", - "commodity_flex": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_flex": "(empty)", "commodity_physical": "555367a723a957f166d9ec6c580ea46e958bd3a3f684aceee050e26ba2d423c6", "commodity_sink": "6b657255c2baf8b6d4fc96f21cb21263bae7378eb11a9b9cf25a0d8aa83c6aca", "commodity_source": "44567fa34febd7556a3797bba4949e8d57aa1416fc240258417955809226d723", - "commodity_waste": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "cost_emission_rpe": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_waste": "(empty)", + "cost_emission_rpe": "(empty)", "cost_fixed_rptv": "d839477ae7f5d8fc897e2df4c3c519d742f946b2ce74e1d92fff91e50285296f", "cost_invest_rtv": "46d311691b8fc8d384d75ae29216ecbbd4ecb16253e4d1836e868c5b2f8a6bc5", "cost_variable_rptv": "5501652d0145dbf7c2e8c006aabd3dbb85ca64ed5caf1d8f45a1957274af81ca", @@ -26,81 +26,81 @@ "demand_activity_constraint_rpsdtv_dem": "5e98cba1dc8ee89799becaa6a4bb7824d41badf80940f18ddb11724ec883e93d", "demand_constraint_rpc": "eae7c4920f895c8e036bc95461418eafb9dc9eb1f836755ad16c78b945019623", "emission_activity_reitvo": "a161b5d9619833e599c9201d4da6e0f45df261cfb93b185b5840daee36880443", - "flex_var_annual_rpitvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "flex_var_rpsditvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "flex_var_annual_rpitvo": "(empty)", + "flex_var_rpsditvo": "(empty)", "flow_in_storage_rpsditvo": "8c97fbeacbca1a772ba04f63d0a82f7703500f83c0dc602114b17c64ad4d2e28", "flow_var_annual_rpitvo": "f98f5f41c5cba8ce2a894734abbc5e90310b695bee3c24c52acd8b4800c5ab85", "flow_var_rpsditvo": "784d98e451a8dcf0122f475c2e229162d99e403bdea85f7c608a3d86e850db44", "lifetime_tech_rt": "984cc175a7aa58e77bb626e3237ca9bf625f5d9b15250870bef1cddc5f57f642", "lifetime_process_rtv": "5033502364848a3a3f295f1b3d051531ecd5c1e5f8bbaecd61afcd18181225ab", "limit_activity_constraint_rpt": "2561103e7e6dd3dee3ba5832290ab5e933f4af08661dff4f2e661b6f4ffefc86", - "limit_activity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_capacity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_activity_share_constraint_rpgg": "(empty)", + "limit_annual_capacity_factor_constraint_rptvo": "(empty)", + "limit_annual_capacity_factor_constraint_rtvo": "(empty)", + "limit_capacity_constraint_rpt": "(empty)", + "limit_capacity_share_constraint_rpgg": "(empty)", "limit_emission_constraint_rpe": "6f11e4ee041970584903d5afba7ee1147824b233260422564fdf0c701e9fabde", - "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_new_capacity_share_constraint_rggv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_resource_constraint_rt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_seasonal_capacity_factor_constraint_rpst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_seasonal_capacity_factor_constraint_rst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_new_capacity_constraint_rtv": "(empty)", + "limit_new_capacity_share_constraint_rggv": "(empty)", + "limit_resource_constraint_rt": "(empty)", + "limit_seasonal_capacity_factor_constraint_rpst": "(empty)", + "limit_seasonal_capacity_factor_constraint_rst": "(empty)", "limit_storage_fraction_constraint_rpsdtv": "13bd0438fa93073d0820deb25c91729fc14bde24f8053cbc42f9412d10f25cd3", "limit_storage_fraction_param_rsdt": "426fc3cc85aeb1d61ea1e862f82915520e380ee9dbdf81c17423dc881a29791b", - "limit_tech_input_split_annual_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_average_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_tech_input_split_annual_constraint_rpitv": "(empty)", + "limit_tech_input_split_average_constraint_rpitv": "(empty)", "limit_tech_input_split_constraint_rpsditv": "ccadc066c091a1c0326154aff6b60229c6f1885a8ace32b7957aff39af910682", - "limit_tech_output_split_annual_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_output_split_average_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_tech_output_split_annual_constraint_rptvo": "(empty)", + "limit_tech_output_split_average_constraint_rptvo": "(empty)", "limit_tech_output_split_constraint_rpsdtvo": "115dc2061e98232f0b3b4884fedd68d5bb347dc04db7debf615655ba1e511d35", - "linked_emissions_tech_constraint_rpsdtve": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "linked_emissions_tech_constraint_rpsdtve": "(empty)", "loan_lifetime_process_rtv": "5033502364848a3a3f295f1b3d051531ecd5c1e5f8bbaecd61afcd18181225ab", "new_capacity_var_rtv": "bc0ec9e7f812410cb2af924cbc99a31c6e99cd95fc2de26193daad38f33cc132", "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", - "ordered_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ordered_season_sequential": "(empty)", "process_life_frac_rptv": "d1b75ede9f90899b1c1cbc56489bf9d12c52abc6c0466eb5fe4dccaf6f3be800", - "ramp_down_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_down_constraint_rpsdtv": "(empty)", + "ramp_up_constraint_rpsdtv": "(empty)", "regional_exchange_capacity_constraint_rrptv": "ae0671daa1263140faff22e3d2610c1fbab942d4e814892a50471adfa1bcc740", "regional_global_indices": "92fa6c5d5745d765d6e16ad1bca7e1fc72f4377273be7cfbfde626ca1967d81b", "regional_indices": "f74187f92c4fdb3c12d5610304c7ac9696001433150bdaa9ff20793fb6365b32", "regions": "0ddd05d695b255ac719dfa85de1e900a3036d547ffc7261c9c9ca2c81bfda029", - "renewable_portfolio_standard_constraint_rpg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "renewable_portfolio_standard_constraint_rpg": "(empty)", "reserve_margin_method": "7869283c0d14273f720716309207a8f0c24606d03c679d6b68e656ed8d86241d", - "reserve_margin_rpsd": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "retired_capacity_var_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_constraints_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_level_rpstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "reserve_margin_rpsd": "(empty)", + "retired_capacity_var_rptv": "(empty)", + "seasonal_storage_constraints_rpsdtv": "(empty)", + "seasonal_storage_level_rpstv": "(empty)", "storage_constraints_rpsdtv": "a724916a2cae70bf0de3b0af98d53d017c515cec75692363b541580ead74dc7c", "storage_init_rpstv": "60214760f620f09278b3aa4b6b510411c1e2c177df5e80838ab97917ca84445d", "storage_level_rpsdtv": "a724916a2cae70bf0de3b0af98d53d017c515cec75692363b541580ead74dc7c", "tech_all": "85a3645929dbeaf6b7eb17e8085c8923ef86949eaa3fb4fd81724dcdcf38fd30", - "tech_annual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_annual": "(empty)", "tech_baseload": "050aff703818154bf3439ed7d4a2cfef892be61da0c477526001c8c38b41a935", "tech_curtailment": "81683c0099a4eb22c8cca4b8eaf65a5bbf77c24b559cd1219823e6d6eb6cf915", "tech_demand": "cf8d1075bb0667935fc3834c5266e775d1c746692179ef253809eaaa7abab6f8", - "tech_downramping": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_downramping": "(empty)", "tech_exchange": "6610eb4a0360be009669b5d50a2c3e1447bcf39eb820a0e63fd3718c27fae768", "tech_exist": "eefd900ce35331470ba9f3312de6f03883efb5f402d321ff714cfe22d9ecdf94", - "tech_flex": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_group_members": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_group_names": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_flex": "(empty)", + "tech_group_members": "(empty)", + "tech_group_names": "(empty)", "tech_or_group": "85a3645929dbeaf6b7eb17e8085c8923ef86949eaa3fb4fd81724dcdcf38fd30", "tech_production": "85a3645929dbeaf6b7eb17e8085c8923ef86949eaa3fb4fd81724dcdcf38fd30", - "tech_reserve": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_retirement": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_seasonal_storage": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_reserve": "(empty)", + "tech_retirement": "(empty)", + "tech_seasonal_storage": "(empty)", "tech_storage": "7109b89425e6707adc8a5e571bf70fc64475d2d76471e5afe93110fd86bbcec8", "tech_uncap": "50b9da0bce0fcc3b318930929a2191c6c6c5e535e9ee986ca2ee740c7ff789bc", - "tech_upramping": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_upramping": "(empty)", "tech_with_capacity": "bf3db0293ae9b3ec605ba8260a111eb71cbf3a786077a8172142a4e90ca1141b", "time_exist": "5206b4814992da4c7a4f643fdf04124735accc989ec0047874eb80e8541766c9", "time_future": "2739be4c64d75aa5fb1552392c1f9d5587f15dba7a728f1768e415c0792b044e", - "time_manual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_manual": "(empty)", "time_of_day": "9f58a9fbc74271e641f4b7624b22daca061195a3d85b58d270fb8590937007b1", "time_optimize": "376da8132a26c5ebebc6d27395c2c883261928ac8d91004c167843c1e21d6141", "time_season": "6d8e2fd49929c1f5216c0313307108c4993e58b00207210d3b6390d3f64e2896", - "time_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_season_sequential": "(empty)", "time_sequencing": "91f69c8abab9959c1f8c90f5aaa56db29bccc67e37d12673ab41c54e4179d7ca", "vintage_all": "a19045d7d5b67af65f00af8d62f398968fea2c2d418f182b5d3ad0a56415eddf", "vintage_exist": "5206b4814992da4c7a4f643fdf04124735accc989ec0047874eb80e8541766c9", diff --git a/tests/testing_data/utopia_sets.json b/tests/testing_data/utopia_sets.json index 75530fa95..dded63032 100644 --- a/tests/testing_data/utopia_sets.json +++ b/tests/testing_data/utopia_sets.json @@ -1,106 +1,106 @@ { - "annual_commodity_balance_constraint_rpc": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "annual_commodity_balance_constraint_rpc": "(empty)", "annual_retirement_var_rptv": "e2487c314e1404726bdfbc8ba061d507f18882c658e81ea27c4369f0d4a892fd", "baseload_diurnal_constraint_rpsdtv": "da0dafcd9aef41d231282c85362d767636d8ba3815ea809e0b7eeb326233fbf9", - "capacity_annual_constraint_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "capacity_annual_constraint_rptv": "(empty)", "capacity_available_var_rpt": "96e94bf0459e843802e1ea858c807be22b09354409fbd93a6b1a91d4438a7727", "capacity_constraint_rpsdtv": "67324b45087c277b42b4664f23c93bd75820eb6a0ed7298afe6b185165b97a25", "capacity_factor_rsdt": "1e5fae1e3999dbea84cc6d39e3ad91e95a2064c96f3569ea2dfef041d6011c5a", "capacity_var_rptv": "972a1a82e59135297f51ef6ca2ce5760a8e0fcb67e064b270b14cbf5e4661831", "commodity_all": "25fb43b483a85d6e4b4d8251a272a4c78fb9d02a65af63a9c044d64211661563", - "commodity_annual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_annual": "(empty)", "commodity_balance_constraint_rpsdc": "79a5589e5d9b834ff2132fd4ce354a16e3a17529415245da9b9d8b13814ad382", "commodity_carrier": "23deb826164f35ff8adf73b9c64a4edb3efeeb51b683e14ea40f0771eba42eb8", "commodity_demand": "8d35b293b5cef0a6d0c6552029082eaec587db0bc5813783f24ab1aafe7491c8", "commodity_emissions": "cc2dd294fd87cfc0324fcd1d62eb647ff83bd89d7b5abd5dd5374debea96d91b", - "commodity_flex": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_flex": "(empty)", "commodity_physical": "3aab3aa4622d1545fe0f3cb8bd1a01fb9918632cff7186cfd49fa8575b73b2f3", "commodity_sink": "8d35b293b5cef0a6d0c6552029082eaec587db0bc5813783f24ab1aafe7491c8", "commodity_source": "9679c6d5ef1c2949d7c4b6685ba2fd4195282539b3f24650247f40be62eecf3a", - "commodity_waste": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "cost_emission_rpe": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "commodity_waste": "(empty)", + "cost_emission_rpe": "(empty)", "cost_fixed_rptv": "972a1a82e59135297f51ef6ca2ce5760a8e0fcb67e064b270b14cbf5e4661831", "cost_invest_rtv": "651a10edbb8297f51b380318385c9f1ae824a73216318e1c9aea78fc9f371a14", "cost_variable_rptv": "37ad263f78986f4d38c63278a0d1a5a85b3eb8b833e8bfabbbea254eaf3a3efb", - "curtailment_var_rpsditvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "curtailment_var_rpsditvo": "(empty)", "demand_activity_constraint_rpsdtv_dem": "264109f8bf88d194fdfe03d2e18654ffe0ba11bdf8430c50b39c03bdc43a53e1", "demand_constraint_rpc": "c1654f787e15af2031bcea2dcebde3830df52ea0f798e854ba784e6f1223b301", "emission_activity_reitvo": "8b4c8f2cf9b46db814898d140f48dc75e50c34bcf71a55adee31afa67cea1eba", - "flex_var_annual_rpitvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "flex_var_rpsditvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "flex_var_annual_rpitvo": "(empty)", + "flex_var_rpsditvo": "(empty)", "flow_in_storage_rpsditvo": "99eb864a26adcb6633c95462649bca3ef7096f67682702915d7769b54b5de386", "flow_var_annual_rpitvo": "4053866a304af0a6b9a1d293ddaf358dfee5b170dcb01d41e6bc52437908a98a", "flow_var_rpsditvo": "350739a59b14969da094cf1c95524134b9c9acd0989710456a8dcc6596d0a401", "lifetime_tech_rt": "7b3703e1f021594993a54c55427a599ed03fc512cf34826b7a7670eb93edfb9a", "lifetime_process_rtv": "2cfc288b15f25957dfc70f6396d97ad655ecbed91c5a11582329749f1fb3dbd7", - "limit_activity_constraint_rpt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_activity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_annual_capacity_factor_constraint_rtvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_activity_constraint_rpt": "(empty)", + "limit_activity_share_constraint_rpgg": "(empty)", + "limit_annual_capacity_factor_constraint_rptvo": "(empty)", + "limit_annual_capacity_factor_constraint_rtvo": "(empty)", "limit_capacity_constraint_rpt": "5a43de033187da68c612c5bcef6a76edb6ab9806464b6d865e46d289ccbbd815", - "limit_capacity_share_constraint_rpgg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_emission_constraint_rpe": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_new_capacity_constraint_rtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_new_capacity_share_constraint_rggv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_resource_constraint_rt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_seasonal_capacity_factor_constraint_rpst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_seasonal_capacity_factor_constraint_rst": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_storage_fraction_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_storage_fraction_param_rsdt": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_annual_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_average_constraint_rpitv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_input_split_constraint_rpsditv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_output_split_annual_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "limit_tech_output_split_average_constraint_rptvo": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "limit_capacity_share_constraint_rpgg": "(empty)", + "limit_emission_constraint_rpe": "(empty)", + "limit_new_capacity_constraint_rtv": "(empty)", + "limit_new_capacity_share_constraint_rggv": "(empty)", + "limit_resource_constraint_rt": "(empty)", + "limit_seasonal_capacity_factor_constraint_rpst": "(empty)", + "limit_seasonal_capacity_factor_constraint_rst": "(empty)", + "limit_storage_fraction_constraint_rpsdtv": "(empty)", + "limit_storage_fraction_param_rsdt": "(empty)", + "limit_tech_input_split_annual_constraint_rpitv": "(empty)", + "limit_tech_input_split_average_constraint_rpitv": "(empty)", + "limit_tech_input_split_constraint_rpsditv": "(empty)", + "limit_tech_output_split_annual_constraint_rptvo": "(empty)", + "limit_tech_output_split_average_constraint_rptvo": "(empty)", "limit_tech_output_split_constraint_rpsdtvo": "f8e7d420020c7bc28dc29fb313fae2027f40143e43bc8c39347060d4e63a087d", - "linked_emissions_tech_constraint_rpsdtve": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "linked_emissions_tech_constraint_rpsdtve": "(empty)", "loan_lifetime_process_rtv": "2cfc288b15f25957dfc70f6396d97ad655ecbed91c5a11582329749f1fb3dbd7", "new_capacity_var_rtv": "d4f1cc8b432075001befddab648d54d1f82a29099236948845393a193b6add5b", "operator": "74d830836f1399fb336a0432dde7d7bd36cffa3ff76b1c42d7945350cfb9bf91", - "ordered_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ordered_season_sequential": "(empty)", "process_life_frac_rptv": "63d17957ee2bebf664ffa6a39cea5e16ec65ad07db1df24dcea6b15bb9ebf589", - "ramp_down_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "ramp_up_constraint_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "regional_exchange_capacity_constraint_rrptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "ramp_down_constraint_rpsdtv": "(empty)", + "ramp_up_constraint_rpsdtv": "(empty)", + "regional_exchange_capacity_constraint_rrptv": "(empty)", "regional_global_indices": "ce00905893c23bc59c15dcf61d7e261c2e97ab1fc298ed0e0ccb1344f1cace37", "regional_indices": "ce00905893c23bc59c15dcf61d7e261c2e97ab1fc298ed0e0ccb1344f1cace37", "regions": "ce00905893c23bc59c15dcf61d7e261c2e97ab1fc298ed0e0ccb1344f1cace37", - "renewable_portfolio_standard_constraint_rpg": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "renewable_portfolio_standard_constraint_rpg": "(empty)", "reserve_margin_method": "7869283c0d14273f720716309207a8f0c24606d03c679d6b68e656ed8d86241d", - "reserve_margin_rpsd": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "retired_capacity_var_rptv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_constraints_rpsdtv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "seasonal_storage_level_rpstv": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "reserve_margin_rpsd": "(empty)", + "retired_capacity_var_rptv": "(empty)", + "seasonal_storage_constraints_rpsdtv": "(empty)", + "seasonal_storage_level_rpstv": "(empty)", "storage_constraints_rpsdtv": "fe4c04240f67fbe31572a29c9c1d3bef9dfb10c1090d311797530deeae8393e9", "storage_init_rpstv": "86f4e3cf182540836f499c369cbac966e81c807d55a4f70d9b7a1473276c07b4", "storage_level_rpsdtv": "fe4c04240f67fbe31572a29c9c1d3bef9dfb10c1090d311797530deeae8393e9", "tech_all": "5c321f60e5d16e60c5063b83d59ac9e184ab78e7fc469e41b21fd8aea83f600c", - "tech_annual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_annual": "(empty)", "tech_baseload": "e7c3d5843adeb51ccb0774118e8e5085f4a01c787a32b5ed34cba220efd9121a", - "tech_curtailment": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_curtailment": "(empty)", "tech_demand": "481b9363bcd61f44255efcc7bb7cc4112bef3c969435024dd5e2659e3c8dbfdb", - "tech_downramping": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_exchange": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_downramping": "(empty)", + "tech_exchange": "(empty)", "tech_exist": "b7d598371119e425be7beedbdde0525c88b7aa0018887e81d2cccc6bb0385ffa", - "tech_flex": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_group_members": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_group_names": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_flex": "(empty)", + "tech_group_members": "(empty)", + "tech_group_names": "(empty)", "tech_or_group": "5c321f60e5d16e60c5063b83d59ac9e184ab78e7fc469e41b21fd8aea83f600c", "tech_production": "5c321f60e5d16e60c5063b83d59ac9e184ab78e7fc469e41b21fd8aea83f600c", - "tech_reserve": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_retirement": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "tech_seasonal_storage": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_reserve": "(empty)", + "tech_retirement": "(empty)", + "tech_seasonal_storage": "(empty)", "tech_storage": "245737c06f3e838e63da08a47a7d2a8605340ea5cad86397e1bb66461a574358", "tech_uncap": "b2c8c3f94a51bb7ab55ad7b534cb7aba64a506678c109abb4141f4d2e84bf431", - "tech_upramping": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "tech_upramping": "(empty)", "tech_with_capacity": "459b04924b80d93e0061564b6c1f1b8d39543d697b81150413d7bc86c3003e33", "time_exist": "c9529ee6b8874a867c196738619704c725ba6fbf61a03b9739090422b8904de2", "time_future": "8c9cb7410e2199a84d1b5607c46429f0c6bc67a2b9e322a48434d83a2e81e007", - "time_manual": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_manual": "(empty)", "time_of_day": "9f58a9fbc74271e641f4b7624b22daca061195a3d85b58d270fb8590937007b1", "time_optimize": "b36c1cbd59292ec15b8ff1504ec03c5657665e2b3350e224dfd31364cb393ffa", "time_season": "df37d485468915bce6cd7fa627702773c5d6c3dee47cffdc6360d31dd1dd28eb", - "time_season_sequential": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "time_season_sequential": "(empty)", "time_sequencing": "91f69c8abab9959c1f8c90f5aaa56db29bccc67e37d12673ab41c54e4179d7ca", "vintage_all": "b96a0eef5579c688203bc71c6700b5a74d6d9306ee443980e67beef17ac4e8f0", "vintage_exist": "c9529ee6b8874a867c196738619704c725ba6fbf61a03b9739090422b8904de2", diff --git a/tests/utilities/capture_set_values_for_cache.py b/tests/utilities/capture_set_values_for_cache.py index f0ca2d218..aed5b18f9 100644 --- a/tests/utilities/capture_set_values_for_cache.py +++ b/tests/utilities/capture_set_values_for_cache.py @@ -13,7 +13,7 @@ from temoa.core.config import TemoaConfig from temoa.core.modes import TemoaMode from tests.conftest import refresh_databases -from tests.utilities.hash_utils import hash_set +from tests.utilities.hash_utils import encode_hashes, hash_set output_path = Path(__file__).parent.parent / 'testing_log' # capture the log here output_path.mkdir(parents=True, exist_ok=True) @@ -50,4 +50,4 @@ # stash the result in a json file... with open(scenario['output_file'], 'w') as f_out: - json.dump(sets_dict, f_out, indent=2, sort_keys=True) + json.dump(encode_hashes(sets_dict), f_out, indent=2, sort_keys=True) diff --git a/tests/utilities/hash_utils.py b/tests/utilities/hash_utils.py index 008aa7378..03480bf04 100644 --- a/tests/utilities/hash_utils.py +++ b/tests/utilities/hash_utils.py @@ -14,3 +14,17 @@ def hash_set(s: Any) -> str: sorted_elements = sorted([(type(e).__name__, str(e)) for e in s]) s_bytes = json.dumps(sorted_elements, ensure_ascii=False).encode('utf-8') return hashlib.sha256(s_bytes).hexdigest() + + +EMPTY_SET_HASH = hash_set([]) +EMPTY_SET_SENTINEL = '(empty)' + + +def encode_hashes(sets_dict: dict[str, str]) -> dict[str, str]: + """Replace the empty-set hash with a readable sentinel for storage.""" + return {k: EMPTY_SET_SENTINEL if v == EMPTY_SET_HASH else v for k, v in sets_dict.items()} + + +def decode_hashes(sets_dict: dict[str, str]) -> dict[str, str]: + """Resolve the empty-set sentinel back to its hash for comparison.""" + return {k: EMPTY_SET_HASH if v == EMPTY_SET_SENTINEL else v for k, v in sets_dict.items()} From 2ec18c5df8c086322f900eaedd96592cf478c9dd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:51:26 +0000 Subject: [PATCH 23/23] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- temoa/extensions/discrete_capacity/core/model.py | 2 -- temoa/extensions/get_comm_tech.py | 4 +--- .../extensions/method_of_morris/morris_evaluate.py | 14 +++++++++++--- .../manager_factory.py | 2 -- .../tech_activity_vector_manager.py | 9 ++++++--- .../vector_manager.py | 1 + .../modeling_to_generate_alternatives/worker.py | 11 +++++++---- temoa/extensions/monte_carlo/mc_worker.py | 3 ++- .../extensions/single_vector_mga/output_summary.py | 1 + temoa/extensions/stochastics/stochastic_config.py | 1 - temoa/extensions/template/core/model.py | 4 +--- 11 files changed, 30 insertions(+), 22 deletions(-) diff --git a/temoa/extensions/discrete_capacity/core/model.py b/temoa/extensions/discrete_capacity/core/model.py index 6cc127b80..d968a2430 100644 --- a/temoa/extensions/discrete_capacity/core/model.py +++ b/temoa/extensions/discrete_capacity/core/model.py @@ -10,7 +10,6 @@ from temoa.core.model import TemoaModel class DiscreteCapacityModel(TemoaModel): - limit_discrete_new_capacity: Param limit_discrete_new_capacity_constraint_rtv: Set limit_discrete_new_capacity_constraint: Constraint @@ -39,7 +38,6 @@ def register_model_components(model: TemoaModel) -> None: rule=discrete_capacity.limit_discrete_new_capacity_constraint_rule, ) - m.limit_discrete_capacity = Param( m.regional_global_indices, m.tech_or_group, within=NonNegativeReals ) diff --git a/temoa/extensions/get_comm_tech.py b/temoa/extensions/get_comm_tech.py index 8c7167c28..717ee9e56 100644 --- a/temoa/extensions/get_comm_tech.py +++ b/temoa/extensions/get_comm_tech.py @@ -31,9 +31,7 @@ def get_tperiods(inp_f: str) -> dict[str, list[int]]: for row in cur: x.append(row[0]) for y in x: - cur.execute( - 'SELECT DISTINCT period FROM output_flow_out WHERE scenario = ?', (y,) - ) + cur.execute('SELECT DISTINCT period FROM output_flow_out WHERE scenario = ?', (y,)) periods_list[y] = [] for per in cur: z = per[0] diff --git a/temoa/extensions/method_of_morris/morris_evaluate.py b/temoa/extensions/method_of_morris/morris_evaluate.py index 1460b7757..9695b1406 100644 --- a/temoa/extensions/method_of_morris/morris_evaluate.py +++ b/temoa/extensions/method_of_morris/morris_evaluate.py @@ -2,6 +2,7 @@ This module contains the core "evaluation" function for Method Of Morris. It needs to be isolated (outside of class) to enable parallelization. """ + from __future__ import annotations import logging @@ -19,7 +20,6 @@ from temoa.core.config import TemoaConfig - def configure_worker_logger(log_queue: Any, log_level: int) -> logging.Logger: """configure the logger""" worker_logger = logging.getLogger('MM evaluate') @@ -35,8 +35,15 @@ def configure_worker_logger(log_queue: Any, log_level: int) -> logging.Logger: return worker_logger -def evaluate(param_info: dict[int, list[Any]], mm_sample: Any, data: dict[str, Any], - i: int, config: TemoaConfig, log_queue: Any, log_level: int) -> list[float]: +def evaluate( + param_info: dict[int, list[Any]], + mm_sample: Any, + data: dict[str, Any], + i: int, + config: TemoaConfig, + log_queue: Any, + log_level: int, +) -> list[float]: """ Run model for params provided and return objective value and emission value Note: This function needs to be a static instance to enable the parallel @@ -81,6 +88,7 @@ def evaluate(param_info: dict[int, list[Any]], mm_sample: Any, data: dict[str, A with TableWriter(config) as table_writer: table_writer.write_mm_results(model=mdl, iteration=i) import contextlib + with contextlib.closing(sqlite3.connect(config.input_database)) as con: cur = con.cursor() scenario_name = config.scenario + f'-{i}' diff --git a/temoa/extensions/modeling_to_generate_alternatives/manager_factory.py b/temoa/extensions/modeling_to_generate_alternatives/manager_factory.py index 3970c4e87..16b41cbcb 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/manager_factory.py +++ b/temoa/extensions/modeling_to_generate_alternatives/manager_factory.py @@ -14,8 +14,6 @@ from temoa.extensions.modeling_to_generate_alternatives.vector_manager import VectorManager - - def get_manager( axis: MgaAxis, weighting: MgaWeighting, diff --git a/temoa/extensions/modeling_to_generate_alternatives/tech_activity_vector_manager.py b/temoa/extensions/modeling_to_generate_alternatives/tech_activity_vector_manager.py index 62f9a99a4..5f6c19460 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/tech_activity_vector_manager.py +++ b/temoa/extensions/modeling_to_generate_alternatives/tech_activity_vector_manager.py @@ -137,7 +137,9 @@ def initialize(self) -> None: for idx_annual in self.base_model.active_flow_rpitvo or set(): tech = idx_annual[3] self.technology_size[tech] += 1 - self.variable_index_mapping[tech][self.base_model.v_flow_out_annual.name].append(idx_annual) + self.variable_index_mapping[tech][self.base_model.v_flow_out_annual.name].append( + idx_annual + ) logger.debug('Catalogued %d Technology Variables', sum(self.technology_size.values())) @property @@ -364,8 +366,9 @@ def tracker(self) -> None: A little function to track the size of the hull, after it is built initially Note: This hull is a "throw away" and only used for volume calc, but it is pretty quick """ - if self.hull is not None and \ - self.hull_points is not None: # don't try until after first hull is built + if ( + self.hull is not None and self.hull_points is not None + ): # don't try until after first hull is built hull = Hull(self.hull_points) volume = hull.volume logger.info('Tracking hull at %0.2f', volume) diff --git a/temoa/extensions/modeling_to_generate_alternatives/vector_manager.py b/temoa/extensions/modeling_to_generate_alternatives/vector_manager.py index 3b0d1ea27..e3649e768 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/vector_manager.py +++ b/temoa/extensions/modeling_to_generate_alternatives/vector_manager.py @@ -1,6 +1,7 @@ """ An ABC to serve as a framework for future Vector Managers """ + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/temoa/extensions/modeling_to_generate_alternatives/worker.py b/temoa/extensions/modeling_to_generate_alternatives/worker.py index efb1c3ff6..d42dc95a0 100644 --- a/temoa/extensions/modeling_to_generate_alternatives/worker.py +++ b/temoa/extensions/modeling_to_generate_alternatives/worker.py @@ -1,6 +1,7 @@ """ Class to contain Workers that execute solves in separate processes """ + from __future__ import annotations import logging.handlers @@ -52,15 +53,16 @@ def __init__( self.solver_name = solver_name self.solver_options = solver_options or {} self.solver_log_path = solver_log_path - self.opt = None # Initialize in run() + self.opt = None # Initialize in run() self.log_root_name = log_root_name self.log_level = log_level self.solve_count = 0 def run(self) -> None: - logger: logging.Logger = getLogger('.'.join( - (self.log_root_name, 'worker', str(self.worker_number)))) + logger: logging.Logger = getLogger( + '.'.join((self.log_root_name, 'worker', str(self.worker_number))) + ) logger.propagate = False # prevent duplicate logs # add a handler that pushes to the queue handler = logging.handlers.QueueHandler(self.log_queue) @@ -133,7 +135,8 @@ def run(self) -> None: status = solve_res['Solver'].termination_condition logger.info( 'Worker %d did not solve. Results status: %s', - self.worker_number, status + self.worker_number, + status, ) except AttributeError: pass diff --git a/temoa/extensions/monte_carlo/mc_worker.py b/temoa/extensions/monte_carlo/mc_worker.py index 7bf987ea9..5ab992fa7 100644 --- a/temoa/extensions/monte_carlo/mc_worker.py +++ b/temoa/extensions/monte_carlo/mc_worker.py @@ -9,6 +9,7 @@ new obj functions """ + from __future__ import annotations import logging.handlers @@ -60,7 +61,7 @@ def __init__( self.results_queue = results_queue self.solver_name = solver_name self.solver_options = solver_options - self.opt = None # Initialize in run() + self.opt = None # Initialize in run() self.log_queue = log_queue self.log_level = log_level self.root_logger_name = log_root_name diff --git a/temoa/extensions/single_vector_mga/output_summary.py b/temoa/extensions/single_vector_mga/output_summary.py index 54e387b15..80369ae7b 100644 --- a/temoa/extensions/single_vector_mga/output_summary.py +++ b/temoa/extensions/single_vector_mga/output_summary.py @@ -1,6 +1,7 @@ """ A tabular summation of the results from an SVMGA run """ + from __future__ import annotations import sqlite3 diff --git a/temoa/extensions/stochastics/stochastic_config.py b/temoa/extensions/stochastics/stochastic_config.py index a46df1e32..3164be085 100644 --- a/temoa/extensions/stochastics/stochastic_config.py +++ b/temoa/extensions/stochastics/stochastic_config.py @@ -33,7 +33,6 @@ class StochasticConfig: perturbations: list[Perturbation] = field(default_factory=list) solver_options: dict[str, Any] = field(default_factory=dict) - @classmethod def from_toml(cls, path: Path) -> Self: with open(path, 'rb') as f: diff --git a/temoa/extensions/template/core/model.py b/temoa/extensions/template/core/model.py index e66a92916..2e3e5a121 100644 --- a/temoa/extensions/template/core/model.py +++ b/temoa/extensions/template/core/model.py @@ -55,9 +55,7 @@ def register_model_components(model: TemoaModel) -> None: m = cast('ExampleModel', model) # Param: a per-(region, tech-or-group) cap on cumulative new capacity. - m.example_new_capacity_limit = Param( - m.regional_global_indices, m.tech_or_group, within=Any - ) + m.example_new_capacity_limit = Param(m.regional_global_indices, m.tech_or_group, within=Any) # Sparse index set for the constraint, built from the param's populated keys. m.example_new_capacity_limit_constraint_rpt = Set(