From 01ed15a68e2dd0748c4d078f2896b56653c93d65 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sat, 5 Mar 2022 13:49:16 -0500 Subject: [PATCH 01/39] ScenarioSeq --- dse_do_utils/scenariodbmanager2.py | 1616 ++++++++++++++++++++++++++++ 1 file changed, 1616 insertions(+) create mode 100644 dse_do_utils/scenariodbmanager2.py diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py new file mode 100644 index 0000000..e5a508a --- /dev/null +++ b/dse_do_utils/scenariodbmanager2.py @@ -0,0 +1,1616 @@ +# Copyright IBM All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +# ----------------------------------------------------------------------------------- +# ----------------------------------------------------------------------------------- +# ScenarioDbManager +# ----------------------------------------------------------------------------------- +# ----------------------------------------------------------------------------------- +# Change notes: +# VT 2021-12-27: +# - FK checks in SQLite. Avoids the hack in a separate Jupyter cell. +# - Transactions +# - Removed `ScenarioDbTable.camel_case_to_snake_case(db_table_name)` from ScenarioDbTable constructor +# - Cleanup and documentation +# VT 2021-12-22: +# - Cached read of scenarios table +# VT 2021-12-01: +# - Cleanup, small documentation and typing hints +# - Make 'multi_scenario' the default option +# ----------------------------------------------------------------------------------- +import pathlib +import zipfile +from abc import ABC +from multiprocessing.pool import ThreadPool + +import sqlalchemy +import pandas as pd +from typing import Dict, List, NamedTuple, Any, Optional +from collections import OrderedDict +import re +from sqlalchemy import exc, MetaData +from sqlalchemy import Table, Column, String, Integer, Float, ForeignKey, ForeignKeyConstraint + +# Typing aliases +from dse_do_utils import ScenarioManager + +Inputs = Dict[str, pd.DataFrame] +Outputs = Dict[str, pd.DataFrame] + + +class ScenarioDbTable(ABC): + """Abstract class. Subclass to be able to define table schema definition, i.e. column name, data types, primary and foreign keys. + Only columns that are specified and included in the DB insert. + """ + + def __init__(self, db_table_name: str, columns_metadata: List[sqlalchemy.Column] = [], constraints_metadata=[]): + """ + Warning: Do not use mixed case names for the db_table_name. + Supplying a mixed-case is not working well and is causing DB FK errors. + Therefore, for now, ensure db_table_name is all lower-case. + Currently, the code does NOT prevent from using mixed-case. It just generates a warning. + + Also, will check db_table_name against some reserved words, i.e. ['order'] + + :param db_table_name: Name of table in DB. Do NOT use MixedCase! Will cause odd DB errors. Lower-case works fine. + :param columns_metadata: + :param constraints_metadata: + """ + self.db_table_name = db_table_name + # ScenarioDbTable.camel_case_to_snake_case(db_table_name) # To make sure it is a proper DB table name. Also allows us to use the scenario table name. + self.columns_metadata = columns_metadata + self.constraints_metadata = constraints_metadata + self.dtype = None + if not db_table_name.islower() and not db_table_name.isupper(): ## I.e. is mixed_case + print(f"Warning: using mixed case in the db_table_name {db_table_name} may cause unexpected DB errors. Use lower-case only.") + + reserved_table_names = ['order', 'parameter'] # TODO: add more reserved words for table names + if db_table_name in reserved_table_names: + print(f"Warning: the db_table_name '{db_table_name}' is a reserved word. Do not use as table name.") + self._sa_column_by_name = None # Dict[str, sqlalchemy.Column] Will be generated dynamically first time it is needed. + + def get_db_table_name(self) -> str: + return self.db_table_name + + def get_df_column_names(self) -> List[str]: + """TODO: what to do if the column names in the DB are different from the column names in the DF?""" + column_names = [] + for c in self.columns_metadata: + if isinstance(c, sqlalchemy.Column): + column_names.append(c.name) + return column_names + + def get_sa_table(self) -> Optional[sqlalchemy.Table]: + """Returns the SQLAlchemy Table. Can be None if table is a AutoScenarioDbTable and not defined in Python code.""" + return self.table_metadata + + def get_sa_column(self, db_column_name) -> Optional[sqlalchemy.Column]: + """Returns the SQLAlchemy.Column with the specified name. + Uses the self.table_metadata (i.e. the sqlalchemy.Table), so works both for pre-defined tables and self-reflected tables like AutoScenarioDbTable + """ + # Grab column directly from sqlalchemy.Table, see https://docs.sqlalchemy.org/en/14/core/metadata.html#accessing-tables-and-columns + if (self.table_metadata is not None) and (db_column_name in self.table_metadata.c): + return self.table_metadata.c[db_column_name] + else: + return None + + def create_table_metadata(self, metadata, engine, schema, multi_scenario: bool = False) -> sqlalchemy.Table: + """If multi_scenario, then add a primary key 'scenario_seq'. + + engine, schema is used only for AutoScenarioDbTable to get the Table (metadata) by reflection + """ + columns_metadata = self.columns_metadata + constraints_metadata = self.constraints_metadata + + if multi_scenario and (self.db_table_name != 'scenario'): + columns_metadata.insert(0, Column('scenario_seq', String(256), ForeignKey("scenario.scenario_seq"), + primary_key=True, index=True)) + constraints_metadata = [ScenarioDbTable.add_scenario_seq_to_fk_constraint(fkc) for fkc in + constraints_metadata] + + return Table(self.db_table_name, metadata, *(c for c in (columns_metadata + constraints_metadata))) + + @staticmethod + def add_scenario_name_to_fk_constraint(fkc: ForeignKeyConstraint): + """DEPRECATED + Creates a new ForeignKeyConstraint by adding the `scenario_name`.""" + columns = fkc.column_keys + refcolumns = [fk.target_fullname for fk in fkc.elements] + table_name = refcolumns[0].split(".")[0] + # Create a new ForeignKeyConstraint by adding the `scenario_name` + columns.insert(0, 'scenario_name') + refcolumns.insert(0, f"{table_name}.scenario_name") + # TODO: `deferrable=True` doesn't seem to have an effect. Also, deferrable is illegal in DB2!? + return ForeignKeyConstraint(columns, refcolumns) #, deferrable=True + + @staticmethod + def add_scenario_seq_to_fk_constraint(fkc: ForeignKeyConstraint): + """Creates a new ForeignKeyConstraint by adding the `scenario_seq`.""" + columns = fkc.column_keys + refcolumns = [fk.target_fullname for fk in fkc.elements] + table_name = refcolumns[0].split(".")[0] + # Create a new ForeignKeyConstraint by adding the `scenario_seq` + columns.insert(0, 'scenario_seq') + refcolumns.insert(0, f"{table_name}.scenario_seq") + # TODO: `deferrable=True` doesn't seem to have an effect. Also, deferrable is illegal in DB2!? + return ForeignKeyConstraint(columns, refcolumns) #, deferrable=True + + @staticmethod + def camel_case_to_snake_case(name: str) -> str: + return re.sub(r'(? pd.DataFrame: + """"Change all columns names from camelCase to snake_case.""" + df.columns = [ScenarioDbTable.camel_case_to_snake_case(x) for x in df.columns] + return df + + def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None): + """Insert a DataFrame in the DB using 'bulk' insert, i.e. with one SQL insert. + (Instead of row-by-row.) + Args: + df (pd.DataFrame) + mgr (ScenarioDbManager) + connection: if not None, being run within a transaction + """ + table_name = self.get_db_table_name() + columns = self.get_df_column_names() + try: + if connection is None: + df[columns].to_sql(table_name, schema=mgr.schema, con=mgr.engine, if_exists='append', dtype=None, + index=False) + else: + df[columns].to_sql(table_name, schema=mgr.schema, con=connection, if_exists='append', dtype=None, + index=False) + except exc.IntegrityError as e: + print("++++++++++++Integrity Error+++++++++++++") + print(f"DataFrame insert/append of table '{table_name}'") + print(e) + + def _delete_scenario_table_from_db(self, scenario_name, connection): + """Delete all rows associated with the scenario in the DB table. + Beware: make sure this is done in the right 'inverse cascading' order to avoid FK violations. + """ + # sql = f"DELETE FROM {db_table.db_table_name} WHERE scenario_name = '{scenario_name}'" # Old + t = self.get_sa_table() # A Table() + sql = t.delete().where(t.c.scenario_name == scenario_name) + connection.execute(sql) + + @staticmethod + def sqlcol(df: pd.DataFrame) -> Dict: + dtypedict = {} + for i, j in zip(df.columns, df.dtypes): + if "object" in str(j): + dtypedict.update({i: sqlalchemy.types.NVARCHAR(length=255)}) + + if "datetime" in str(j): + dtypedict.update({i: sqlalchemy.types.DateTime()}) + + if "float" in str(j): + dtypedict.update({i: sqlalchemy.types.Float()}) # precision=10, asdecimal=True + + if "int" in str(j): + dtypedict.update({i: sqlalchemy.types.INT()}) + return dtypedict + + +######################################################################### +# AutoScenarioDbTable +######################################################################### +class AutoScenarioDbTable(ScenarioDbTable): + """Designed to automatically generate the table definition based on the DataFrame. + + Main difference with the 'regular' ScenarioDbTable definition: + * At 'create_schema`, the table will NOT be created. Instead, + * At 'insert_table_in_db_bulk' SQLAlchemy will automatically create a TABLE based on the DataFrame. + + Advantages: + - No need to define a custom ScenarioDbTable class per table + - Automatically all columns are inserted + Disadvantages: + - No primary and foreign key relations. Thus no checks. + - Missing relationships means Cognos cannot automatically extract a data model + + TODO: find out what will happen if the DataFrame structure changes and we're doing a new insert + """ + def __init__(self, db_table_name: str): + """Need to provide a name for the DB table. + """ + super().__init__(db_table_name) + # metadata and engine are set during initialization + self.metadata = None + self.engine = None + + def create_table_metadata(self, metadata, engine, schema, multi_scenario: bool = False): + """Use the engine to reflect the Table metadata. + Called during initialization.""" + # Store metadata and engine so we can do a dynamic reflect later + self.metadata = metadata + self.engine = engine + self.schema = schema + + # TODO: From the reflected Table, also extract the columns_metadata. + # We need that for any DB edits + + # print(f"create_table_metadata: ") + if engine is not None: + return self._reflect_db_table(metadata, engine, schema) + + return None + + def insert_table_in_db_bulk(self, df, mgr, connection=None): + """ + Args: + df (pd.DataFrame) + mgr (ScenarioDbManager) + connection: if not None, being run within a transaction + """ + table_name = self.get_db_table_name() + if self.dtype is None: + dtype = ScenarioDbTable.sqlcol(df) + else: + dtype = self.dtype + try: + # Note that this can use the 'replace', so the table will be dropped automatically and the defintion auto created + # So no need to drop the table explicitly (?) + if connection is None: + df.to_sql(table_name, schema=mgr.schema, con=mgr.engine, if_exists='replace', dtype=dtype, index=False) + else: + df.to_sql(table_name, schema=mgr.schema, con=connection, if_exists='replace', dtype=dtype, index=False) + except exc.IntegrityError as e: + print("++++++++++++Integrity Error+++++++++++++") + print(f"DataFrame insert/append of table '{table_name}'") + print(e) + + def get_sa_table(self) -> Optional[sqlalchemy.Table]: + """Returns the SQLAlchemy Table. Can be None if table is a AutoScenarioDbTable and not defined in Python code. + TODO: automatically reflect if None. Is NOT working yet! + """ + # Get table_metadata from reflection if it doesn't exist + # Disabled because reflection doesn't find the table + if self.table_metadata is None: + self.table_metadata = self._reflect_db_table(self.metadata, self.engine, self.schema) + + return self.table_metadata + + def _reflect_db_table(self, metadata_obj, engine, schema) -> Optional[sqlalchemy.Table]: + """Get the Table metadata from reflection. + Does NOT WORK with SQLAlchemy 1.4 and ibm_db_sa 0.3.7 + You do need to specify the schema. + For reflection, not sure if we should reuse the existing metadata_obj, or create a new one. + + """ + try: + table = Table(self.db_table_name, metadata_obj, autoload_with=engine) + # table = Table(self.db_table_name, MetaData(schema=schema), autoload_with=engine) + print(f"AutoScenarioDbTable._reflect_db_table: Found table '{self.db_table_name}'.") + except sqlalchemy.exc.NoSuchTableError: + table = None + print(f"AutoScenarioDbTable._reflect_db_table: Table '{self.db_table_name}' doesn't exist in DB.") + return table + + +class DbCellUpdate(NamedTuple): + scenario_name: str + table_name: str + row_index: List[Dict[str, Any]] # e.g. [{'column': 'col1', 'value': 1}, {'column': 'col2', 'value': 'pear'}] + column_name: str + current_value: Any + previous_value: Any # Not used for DB operation + row_idx: int # Not used for DB operation + + +######################################################################### +# ScenarioDbManager +######################################################################### +class ScenarioDbManager(): + """ + TODO: documentation! + """ + + def __init__(self, input_db_tables: Dict[str, ScenarioDbTable], output_db_tables: Dict[str, ScenarioDbTable], + credentials=None, schema: str = None, echo: bool = False, multi_scenario: bool = True, + enable_transactions: bool = True, enable_sqlite_fk: bool = True): + """Create a ScenarioDbManager. + + :param input_db_tables: OrderedDict[str, ScenarioDbTable] of name and sub-class of ScenarioDbTable. Need to be in correct order. + :param output_db_tables: OrderedDict[str, ScenarioDbTable] of name and sub-class of ScenarioDbTable. Need to be in correct order. + :param credentials: DB credentials + :param schema: schema name + :param echo: if True, SQLAlchemy will produce a lot of debugging output + :param multi_scenario: If true, adds SCENARIO table and PK + :param enable_transactions: If true, uses transactions + :param enable_sqlite_fk: If True, enables FK constraint checks in SQLite + """ + # WARNING: do NOT use 'OrderedDict[str, ScenarioDbTable]' as type. OrderedDict is not subscriptable. Will cause a syntax error. + self.schema = self._check_schema_name(schema) + self.multi_scenario = multi_scenario # If true, will add a primary key 'scenario_name' to each table + self.enable_transactions = enable_transactions + self.enable_sqlite_fk = enable_sqlite_fk + self.echo = echo + self.input_db_tables = self._add_scenario_db_table(input_db_tables) + self.output_db_tables = output_db_tables + self.db_tables = OrderedDict(list(input_db_tables.items()) + list(output_db_tables.items())) # {**input_db_tables, **output_db_tables} # For compatibility reasons + + self.engine = self._create_database_engine(credentials, schema, echo) + self.metadata = sqlalchemy.MetaData(schema=schema) # VT_20210120: Added schema=schema just for reflection? Not sure what are the implications + self._initialize_db_tables_metadata() # Needs to be done after self.metadata, self.multi_scenario has been set + self.read_scenario_table_from_db_callback = None # For Flask caching + self.read_scenarios_table_from_db_callback = None # For Flask caching + + ############################################################################################ + # Initialization. Called from constructor. + ############################################################################################ + def _check_schema_name(self, schema: Optional[str]): + """Checks if schema name is not mixed-case, as this is known to cause issues. Upper-case works well. + This is just a warning. It does not change the schema name.""" + if schema is not None and not schema.islower() and not schema.isupper(): ## I.e. is mixed_case + print(f"Warning: using mixed case in the schema name {schema} may cause unexpected DB errors. Use upper-case only.") + return schema + + def _add_scenario_db_table(self, input_db_tables: Dict[str, ScenarioDbTable]) -> Dict[str, ScenarioDbTable]: + """Adds a Scenario table as the first in the OrderedDict (if it doesn't already exist). + Called from constructor.""" + # WARNING: do NOT use 'OrderedDict[str, ScenarioDbTable]' as type. OrderedDict is not subscriptable. Will cause a syntax error. + if self.multi_scenario: + if 'Scenario' not in input_db_tables.keys(): + input_db_tables.update({'Scenario': ScenarioTable()}) + input_db_tables.move_to_end('Scenario', last=False) + else: + if list(input_db_tables.keys()).index('Scenario') > 0: + print("Warning: the `Scenario` table should be the first in the input tables") + return input_db_tables + + def get_scenario_db_table(self) -> ScenarioDbTable: + """Scenario table must be the first in self.input_db_tables""" + db_table: ScenarioTable = list(self.input_db_tables.values())[0] + return db_table + + def _create_database_engine(self, credentials=None, schema: str = None, echo: bool = False): + """Creates a SQLAlchemy engine at initialization. + If no credentials, creates an in-memory SQLite DB. Which can be used for schema validation of the data. + """ + if credentials is not None: + engine = self._create_db2_engine(credentials, schema, echo) + else: + engine = self._create_sqllite_engine(echo) + return engine + + def _create_sqllite_engine(self, echo: bool): + if self.enable_sqlite_fk: + ScenarioDbManager._enable_sqlite_foreign_key_checks() + return sqlalchemy.create_engine('sqlite:///:memory:', echo=echo) + + @staticmethod + def _enable_sqlite_foreign_key_checks(): + """Enables the FK constraint validation in SQLite.""" + print("Enable SQLite FK checks") + from sqlalchemy import event + from sqlalchemy.engine import Engine + from sqlite3 import Connection as SQLite3Connection + + @event.listens_for(Engine, "connect") + def _set_sqlite_pragma(dbapi_connection, connection_record): + if isinstance(dbapi_connection, SQLite3Connection): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON;") + cursor.close() + + # def get_db2_connection_string(self, credentials, schema: str): + # """Create a DB2 connection string. + # Needs a work-around for DB2 on cloud.ibm.com. + # The option 'ssl=True' doesn't work. Instead use 'Security=ssl'. + # See https://stackoverflow.com/questions/58952002/using-credentials-from-db2-warehouse-on-cloud-to-initialize-flask-sqlalchemy. + + # TODO: + # * Not sure the check for the port 50001 is necessary, or if this applies to any `ssl=True` + # * The schema doesn't work properly in db2 on cloud.ibm.com. Instead it automatically creates a schema based on the username. + # * Also tried to use 'schema={schema}', but it didn't work properly. + # * In case ssl=False, do NOT add the option `ssl=False`: doesn't gie an error, but select rows will always return zero rows! + # * TODO: what do we do in case ssl=True, but the port is not 50001?! + # """ + # if str(credentials['ssl']).upper() == 'TRUE' and str(credentials['port']) == '50001': + # ssl = '?Security=ssl' # Instead of 'ssl=True' + # else: + # # ssl = f"ssl={credentials['ssl']}" # I.e. 'ssl=True' or 'ssl=False' + # ssl = '' # For some weird reason, the string `ssl=False` blocks selection from return any rows!!!! + # connection_string = 'db2+ibm_db://{username}:{password}@{host}:{port}/{database}{ssl};currentSchema={schema}'.format( + # username=credentials['username'], + # password=credentials['password'], + # host=credentials['host'], + # port=credentials['port'], + # database=credentials['database'], + # ssl=ssl, + # schema=schema + # ) + # return connection_string + + def _get_db2_connection_string(self, credentials, schema: str): + """Create a DB2 connection string. + + Needs a work-around for DB2 on cloud.ibm.com. + Workaround: Pass in your credentials like this: + + DB2_credentials = { + 'username': "user1", + 'password': "password1", + 'host': "hostname.databases.appdomain.cloud", + 'port': "30080", + 'database': "bludb", + 'schema': "my_schema", #<- SCHEMA IN DATABASE + 'ssl': "SSL" #<- NOTE: SPECIFY SSL HERE IF TRUE FOR DB2 + } + + The option 'ssl=True' doesn't work. Instead use 'Security=ssl'. + See https://stackoverflow.com/questions/58952002/using-credentials-from-db2-warehouse-on-cloud-to-initialize-flask-sqlalchemy. + + Notes: + * The schema doesn't work properly in DB2 Lite version on cloud.ibm.com. Schema names work properly + with paid versions where you can have multiple schemas, i.e. the 'Standard' version. + * SQLAlchemy expects a certain way the SSL is encoded in the connection string. + This method adapts based on different ways the SSL is defined in the credentials + """ + + if 'ssl' in credentials: + # SAVE FOR FUTURE LOGGER MESSAGES... + #print("SSL Flag detected.") + + #SET THIS IF WE NEED TO SEE IF WE ARE CONNECTING TO A CLOUD DATABASE VS ON PREM (FUTURE?) + #WE ARE CONNECTING TO A DB2 DATABASE AAS, SO WE NEED TO SET (CHECK) THE SSL FLAG CORRECTLY + #if("DATABASES.APPDOMAIN.CLOUD" in str(credentials['host']).upper()): + # SAVE FOR FUTURE LOGGER MESSAGES... + #print("DB2 database in the cloud detected based off hostname...") + + #SSL=True IS NOT THE PROPER SYNTAX FOR SQLALCHEMY AND DB2 CLOUD. IT NEEDS TO BE 'ssl=SSL' so we will correct it. + + if(str(credentials['ssl']).upper() == 'TRUE'): + #print("WARNING! 'SSL':'TRUE' Detected, but it needs to be 'ssl':'SSL' for SQL ALCHEMY. Correcting...") + credentials['ssl'] = 'SSL' + elif(str(credentials['ssl']).upper() == 'SSL'): + # SAVE FOR FUTURE LOGGER MESSAGES... + #print("SSL Specified correctly for DB2aaS cloud connection.") + credentials['ssl'] = 'SSL' + else: + # print("WARNING! SSL was specified as a none standard value: SSL was not set to True or SSL.") + pass + + connection_string = 'db2+ibm_db://{username}:{password}@{host}:{port}/{database};currentSchema={schema};SECURITY={ssl}'.format( + username=credentials['username'], + password=credentials['password'], + host=credentials['host'], + port=credentials['port'], + database=credentials['database'], + ssl=credentials['ssl'], + schema=schema + ) + else: + # print(" WARNING! SSL was not specified! Creating connection string without it!") + connection_string = 'db2+ibm_db://{username}:{password}@{host}:{port}/{database};currentSchema={schema}'.format( + username=credentials['username'], + password=credentials['password'], + host=credentials['host'], + port=credentials['port'], + database=credentials['database'], + schema=schema + ) + # SAVE FOR FUTURE LOGGER MESSAGES... + #print("Connection String : " + connection_string) + return connection_string + + def _create_db2_engine(self, credentials, schema: str, echo: bool = False): + """Create a DB2 engine instance. + Connection string logic in `get_db2_connection_string` + """ + connection_string = self._get_db2_connection_string(credentials, schema) + return sqlalchemy.create_engine(connection_string, echo=echo) + + def _initialize_db_tables_metadata(self): + """To be called from constructor, after engine is 'created'/connected, after self.metadata, self.multi_scenario have been set. + This will add the `scenario_name` to the db_table configurations. + This also allows non-bulk inserts into an existing DB (i.e. without running 'create_schema') + + TODO: also reflect the columns_metadata. That is required for any table edits + """ + for scenario_table_name, db_table in self.db_tables.items(): + db_table.table_metadata = db_table.create_table_metadata(self.metadata, + self.engine, + self.schema, + self.multi_scenario) # Stores the table schema in the self.metadata + + ############################################################################################ + # Create schema + ############################################################################################ + def create_schema(self): + """Drops all tables and re-creates the schema in the DB.""" + if self.enable_transactions: + print("Create schema within a transaction") + with self.engine.begin() as connection: + self._create_schema_transaction(connection=connection) + else: + self._create_schema_transaction(self.engine) + + def _create_schema_transaction(self, connection): + """(Re)creates a schema, optionally using a transaction + Drops all tables and re-creates the schema in the DB.""" + # if self.schema is None: + # self.drop_all_tables_transaction(connection=connection) + # else: + # self.drop_schema_transaction(self.schema) + # DROP SCHEMA isn't working properly, so back to dropping all tables + self._drop_all_tables_transaction(connection=connection) + self.metadata.create_all(connection, checkfirst=True) + + def drop_all_tables(self): + """Drops all tables in the current schema.""" + if self.enable_transactions: + with self.engine.begin() as connection: + self._drop_all_tables_transaction(connection=connection) + else: + self._drop_all_tables_transaction(self.engine) + + def _drop_all_tables_transaction(self, connection): + """Drops all tables as defined in db_tables (if exists) + TODO: loop over tables as they exist in the DB. + This will make sure that however the schema definition has changed, all tables will be cleared. + Problem. The following code will loop over all existing tables: + + inspector = sqlalchemy.inspect(self.engine) + for db_table_name in inspector.get_table_names(schema=self.schema): + + However, the order is alphabetically, which causes FK constraint violation + Weirdly, this happens in SQLite, not in DB2! With or without transactions + + TODO: + 1. Use SQLAlchemy to drop table, avoid text SQL + 2. Drop all tables without having to loop and know all tables + See: https://stackoverflow.com/questions/35918605/how-to-delete-a-table-in-sqlalchemy) + See https://docs.sqlalchemy.org/en/14/core/metadata.html#sqlalchemy.schema.MetaData.drop_all + """ + for scenario_table_name, db_table in reversed(self.db_tables.items()): + db_table_name = db_table.db_table_name + sql = f"DROP TABLE IF EXISTS {db_table_name}" + # print(f"Dropping table {db_table_name}") + connection.execute(sql) + + def _drop_schema_transaction(self, schema: str, connection=None): + """NOT USED. Not working in DB2 Cloud. + Drops schema, and all the objects defined within that schema. + See: https://www.ibm.com/docs/en/db2/11.5?topic=procedure-admin-drop-schema-drop-schema + However, this doesn't work on DB2 cloud. + TODO: find out if and how we can get this to work. + See https://docs.sqlalchemy.org/en/14/core/metadata.html#sqlalchemy.schema.MetaData.drop_all + """ + # sql = f"DROP SCHEMA {schema} CASCADE" # Not allowed in DB2! + sql = f"CALL SYSPROC.ADMIN_DROP_SCHEMA('{schema}', NULL, 'ERRORSCHEMA', 'ERRORTABLE')" + # sql = f"CALL SYSPROC.ADMIN_DROP_SCHEMA('{schema}', NULL, NULL, NULL)" + if connection is None: + r = self.engine.execute(sql) + else: + r = connection.execute(sql) + + ##################################################################################### + # DEPRECATED(?): `insert_scenarios_in_db` and `insert_scenarios_in_db_transaction` + ##################################################################################### + def insert_scenarios_in_db(self, inputs={}, outputs={}, bulk: bool = True): + """DEPRECATED. If we need it back, requires re-evaluation and bulk support.""" + if self.enable_transactions: + print("Inserting all tables within a transaction") + with self.engine.begin() as connection: + self._insert_scenarios_in_db_transaction(inputs=inputs, outputs=outputs, bulk=bulk, connection=connection) + else: + self._insert_scenarios_in_db_transaction(inputs=inputs, outputs=outputs, bulk=bulk) + + def _insert_scenarios_in_db_transaction(self, inputs={}, outputs={}, bulk: bool = True, connection=None): + """DEPRECATED(?) + """ + num_caught_exceptions=0 + for table_name, df in inputs.items(): + num_caught_exceptions += self._insert_table_in_db_by_row(table_name, df, connection=connection) + for table_name, df in outputs.items(): + num_caught_exceptions += self._insert_table_in_db_by_row(table_name, df, connection=connection) + # Throw exception if any exceptions caught in 'non-bulk' mode + # This will cause a rollback when using a transaction + if num_caught_exceptions > 0: + raise RuntimeError(f"Multiple ({num_caught_exceptions}) Integrity and/or Statement errors caught. See log. Raising exception to allow for rollback.") + + ############################################################################################ + # Insert/replace scenario + ############################################################################################ + def replace_scenario_in_db(self, scenario_name: str, inputs: Inputs = {}, outputs: Outputs = {}, bulk=True): + """Insert or replace a scenario. Main API to insert/update a scenario. + If the scenario exists, will delete rows first. + Inserts scenario data in all tables. + Inserts tables in order specified in OrderedDict. Inputs first, outputs second. + + :param scenario_name: + :param inputs: + :param outputs: + :param bulk: + :return: + """ + if self.enable_transactions: + print("Replacing scenario within transaction") + with self.engine.begin() as connection: + self._replace_scenario_in_db_transaction(connection, scenario_name=scenario_name, inputs=inputs, outputs=outputs, bulk=bulk) + else: + self._replace_scenario_in_db_transaction(self.engine, scenario_name=scenario_name, inputs=inputs, outputs=outputs, bulk=bulk) + + def _replace_scenario_in_db_transaction(self, connection, scenario_name: str, inputs: Inputs = {}, outputs: Outputs = {}, + bulk: bool = True): + """Replace a single full scenario in the DB. If doesn't exist, will insert. + Only inserts tables with an entry defined in self.db_tables (i.e. no `auto_insert`). + Will first delete all rows associated with a scenario_name. + Will set/overwrite the scenario_name in all dfs, so no need to add in advance. + Assumes schema has been created. + Note: there is no difference between dfs in inputs or outputs, i.e. they are inserted the same way. + + TODO: break-out in a delete and an insert. Then we can re-use the insert for the duplicate API + """ + # Step 1: delete scenario if exists + self._delete_scenario_from_db(scenario_name, connection=connection) + # Step 2: add scenario_name to all dfs + inputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, inputs) + outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) + # Step 3: insert scenario_name in scenario table + # sql = f"INSERT INTO SCENARIO (scenario_name) VALUES ('{scenario_name}')" + sa_scenario_table = self.get_scenario_db_table().get_sa_table() + sql_insert = sa_scenario_table.insert().values(scenario_name = scenario_name) + connection.execute(sql_insert) + # Step 4: (bulk) insert scenario + num_caught_exceptions = self._insert_single_scenario_tables_in_db(inputs=inputs, outputs=outputs, bulk=bulk, connection=connection) + # Throw exception if any exceptions caught in 'non-bulk' mode + # This will cause a rollback when using a transaction + if num_caught_exceptions > 0: + raise RuntimeError(f"Multiple ({num_caught_exceptions}) Integrity and/or Statement errors caught. See log. Raising exception to allow for rollback.") + + + def _insert_single_scenario_tables_in_db(self, inputs: Inputs = {}, outputs: Outputs = {}, + bulk: bool = True, connection=None) -> int: + """Specifically for single scenario replace/insert. + Does NOT insert into the `scenario` table. + No `auto_insert`, i.e. only df matching db_tables. TODO: verify if doesn't work with AutoScenarioDbTable + """ + num_caught_exceptions = 0 + dfs = {**inputs, **outputs} # Combine all dfs in one dict + for scenario_table_name, db_table in self.db_tables.items(): + if scenario_table_name != 'Scenario': + if scenario_table_name in dfs: + df = dfs[scenario_table_name] + print(f"Inserting {df.shape[0]} rows and {df.shape[1]} columns in {scenario_table_name}") + # display(df.head(3)) + if bulk: + db_table.insert_table_in_db_bulk(df=df, mgr=self, connection=connection) + else: # Row by row for data checking + num_caught_exceptions += self._insert_table_in_db_by_row(db_table, df, connection=connection) + else: + print(f"No table named {scenario_table_name} in inputs or outputs") + return num_caught_exceptions + + def _insert_table_in_db_by_row(self, db_table: ScenarioDbTable, df: pd.DataFrame, connection=None) -> int: + """Inserts a table in the DB row-by-row. + For debugging FK/PK data issues. + Uses a single SQL insert statement for each row in the DataFrame so that if there is a FK/PK issue, + the error message will be about only this row. Is a lot easier to debug than using bulk. + In addition, it catches the exception and keeps on inserting, so that we get to see multiple errors. + This allows us to debug multiple data issues within one run. + To avoid too many exceptions, the number of exceptions per table is limited to 10. + After the limit, the insert will be terminated. And the next table will be inserted. + Note that as a result of terminating a table insert, it is very likely it will cause FK issues in subsequent tables. + """ + num_exceptions = 0 + max_num_exceptions = 10 + columns = db_table.get_df_column_names() + # print(columns) + # df[columns] ensures that the order of columns in the DF matches that of the SQL table definition. If not, the insert will fail + for row in df[columns].itertuples(index=False): + # print(row) + stmt = ( + sqlalchemy.insert(db_table.table_metadata). + values(row) + ) + try: + if connection is None: + self.engine.execute(stmt) + else: + connection.execute(stmt) + except exc.IntegrityError as e: + print("++++++++++++Integrity Error+++++++++++++") + print(e) + num_exceptions = num_exceptions + 1 + except exc.StatementError as e: + print("++++++++++++Statement Error+++++++++++++") + print(e) + num_exceptions = num_exceptions + 1 + finally: + if num_exceptions > max_num_exceptions: + print( + f"Max number of exceptions {max_num_exceptions} for this table exceeded. Stopped inserting more data.") + break + return num_exceptions + + def insert_tables_in_db(self, inputs: Inputs = {}, outputs: Outputs = {}, + bulk: bool = True, auto_insert: bool = False, connection=None) -> int: + """DEPRECATED. + Was attempt to automatically insert a scenario without any schema definition. + Currently, one would need to use the AutoScenarioDbTable in the constructor. + If you want to automatically create such schema based on the inputs/outputs, then do that in the constructor. Not here. + Note: the non-bulk ONLY works if the schema was created! I.e. only when using with self.create_schema. + """ + dfs = {**inputs, **outputs} # Combine all dfs in one dict + completed_dfs = [] + num_caught_exceptions=0 + for scenario_table_name, db_table in self.db_tables.items(): + if scenario_table_name in dfs: + completed_dfs.append(scenario_table_name) + if bulk: + # self.insert_table_in_db_bulk(db_table, dfs[scenario_table_name]) + db_table.insert_table_in_db_bulk(dfs[scenario_table_name], self, connection=connection) + else: # Row by row for data checking + num_caught_exceptions += self._insert_table_in_db_by_row(db_table, dfs[scenario_table_name], connection=connection) + else: + print(f"No table named {scenario_table_name} in inputs or outputs") + # Insert any tables not defined in the schema: + if auto_insert: + for scenario_table_name, df in dfs.items(): + if scenario_table_name not in completed_dfs: + print(f"Table {scenario_table_name} auto inserted") + db_table = AutoScenarioDbTable(scenario_table_name) + db_table.insert_table_in_db_bulk(df, self, connection=connection) + return num_caught_exceptions + + ############################################################################################ + # Read scenario + ############################################################################################ + def get_scenarios_df(self) -> pd.DataFrame: + """Return all scenarios in df. Result is indexed by `scenario_name`. + Main API to get all scenarios. + The API called by a cached procedure in the dse_do_dashboard.DoDashApp. + """ + # sql = f"SELECT * FROM SCENARIO" + sa_scenario_table = list(self.input_db_tables.values())[0].table_metadata + sql = sa_scenario_table.select() + if self.enable_transactions: + with self.engine.begin() as connection: + df = pd.read_sql(sql, con=connection).set_index(['scenario_name']) + else: + df = pd.read_sql(sql, con=self.engine).set_index(['scenario_name']) + return df + + def read_scenario_table_from_db(self, scenario_name: str, scenario_table_name: str) -> pd.DataFrame: + """Read a single table from the DB. + Main API to read a single table. + The API called by a cached procedure in the dse_do_dashboard.DoDashApp. + + :param scenario_name: Name of scenario + :param scenario_table_name: Name of scenario table (not the DB table name) + :return: + """ + # print(f"read table {scenario_table_name}") + if scenario_table_name in self.db_tables: + db_table = self.db_tables[scenario_table_name] + else: + # error! + raise ValueError(f"Scenario table name '{scenario_table_name}' unknown. Cannot load data from DB.") + + if self.enable_transactions: + with self.engine.begin() as connection: + df = self._read_scenario_db_table_from_db(scenario_name, db_table, connection) + else: + df = self._read_scenario_db_table_from_db(scenario_name, db_table, self.engine) + + return df + + # def read_scenario_from_db(self, scenario_name: str) -> (Inputs, Outputs): + # """Single scenario load. + # Main API to read a complete scenario. + # Reads all tables for a single scenario. + # Returns all tables in one dict""" + # inputs = {} + # for scenario_table_name, db_table in self.input_db_tables.items(): + # inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table) + # + # outputs = {} + # for scenario_table_name, db_table in self.output_db_tables.items(): + # outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table) + # + # return inputs, outputs + + + # def _read_scenario_from_db(self, scenario_name: str, connection) -> (Inputs, Outputs): + # """Single scenario load. + # Main API to read a complete scenario. + # Reads all tables for a single scenario. + # Returns all tables in one dict + # """ + # inputs = {} + # for scenario_table_name, db_table in self.input_db_tables.items(): + # # print(f"scenario_table_name = {scenario_table_name}") + # if scenario_table_name != 'Scenario': # Skip the Scenario table as an input + # inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + # + # outputs = {} + # for scenario_table_name, db_table in self.output_db_tables.items(): + # outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + # # if scenario_table_name == 'kpis': + # # # print(f"kpis table columns = {outputs[scenario_table_name].columns}") + # # outputs[scenario_table_name] = outputs[scenario_table_name].rename(columns={'name': 'NAME'}) #HACK!!!!! + # return inputs, outputs + def read_scenario_from_db(self, scenario_name: str, multi_threaded: bool = False) -> (Inputs, Outputs): + """Single scenario load. + Main API to read a complete scenario. + Reads all tables for a single scenario. + Returns all tables in one dict + + Note: multi_threaded doesn't seem to lead to performance improvement. + Fixed: omit reading scenario table as an input. + """ + # print(f"read_scenario_from_db.multi_threaded = {multi_threaded}") + if multi_threaded: + inputs, outputs = self._read_scenario_from_db_multi_threaded(scenario_name) + else: + if self.enable_transactions: + with self.engine.begin() as connection: + inputs, outputs = self._read_scenario_from_db(scenario_name, connection) + else: + inputs, outputs = self._read_scenario_from_db(scenario_name, self.engine) + return inputs, outputs + + def _read_scenario_from_db(self, scenario_name: str, connection) -> (Inputs, Outputs): + """Single scenario load. + Main API to read a complete scenario. + Reads all tables for a single scenario. + Returns all tables in one dict + """ + inputs = {} + for scenario_table_name, db_table in self.input_db_tables.items(): + # print(f"scenario_table_name = {scenario_table_name}") + if scenario_table_name != 'Scenario': # Skip the Scenario table as an input + inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + + outputs = {} + for scenario_table_name, db_table in self.output_db_tables.items(): + outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + + return inputs, outputs + + def _read_scenario_from_db_multi_threaded(self, scenario_name) -> (Inputs, Outputs): + """Reads all tables from a scenario using multi-threading. + Does NOT seem to result in performance improvement!""" + class ReadTableFunction(object): + def __init__(self, dbm): + self.dbm = dbm + def __call__(self, scenario_table_name, db_table): + return self._read_scenario_db_table_from_db_thread(scenario_table_name, db_table) + def _read_scenario_db_table_from_db_thread(self, scenario_table_name, db_table): + with self.dbm.engine.begin() as connection: + df = self.dbm._read_scenario_db_table_from_db(scenario_name, db_table, connection) + dict = {scenario_table_name: df} + return dict + + thread_number = 8 + pool = ThreadPool(thread_number) + thread_worker = ReadTableFunction(self) + # print("ThreadPool created") + all_tables = [(scenario_table_name, db_table) for scenario_table_name, db_table in self.db_tables.items() if scenario_table_name != 'Scenario'] + # print(all_tables) + all_results = pool.starmap(thread_worker, all_tables) + inputs = {k:v for element in all_results for k,v in element.items() if k in self.input_db_tables.keys()} + outputs = {k:v for element in all_results for k,v in element.items() if k in self.output_db_tables.keys()} + # print("All tables loaded") + + return inputs, outputs + + def read_scenario_input_tables_from_db(self, scenario_name: str) -> Inputs: + """Convenience method to load all input tables. + Typically used at start if optimization model. + :returns The inputs and outputs. (The outputs are always empty.) + """ + inputs, outputs = self.read_scenario_tables_from_db(scenario_name, input_table_names=['*']) + return inputs + + def read_scenario_tables_from_db(self, scenario_name: str, + input_table_names: Optional[List[str]] = None, + output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): + """Read selected set input and output tables from scenario. + If input_table_names/output_table_names contains a '*', then all input/output tables will be read. + If empty list or None, then no tables will be read. + """ + if self.enable_transactions: + with self.engine.begin() as connection: + inputs, outputs = self._read_scenario_tables_from_db(connection, scenario_name, input_table_names, output_table_names) + else: + inputs, outputs = self._read_scenario_tables_from_db(self.engine, scenario_name, input_table_names, output_table_names) + return inputs, outputs + + def _read_scenario_tables_from_db(self, connection, scenario_name: str, + input_table_names: List[str] = None, + output_table_names: List[str] = None) -> (Inputs, Outputs): + """Loads data for selected input and output tables. + If either list is names is ['*'], will load all tables as defined in db_tables configuration. + """ + if input_table_names is None: # load no tables by default + input_table_names = [] + elif '*' in input_table_names: + input_table_names = list(self.input_db_tables.keys()) + if 'Scenario' in input_table_names: input_table_names.remove('Scenario') # Remove the scenario table + + if output_table_names is None: # load no tables by default + output_table_names = [] + elif '*' in output_table_names: + output_table_names = self.output_db_tables.keys() + + inputs = {} + for scenario_table_name, db_table in self.input_db_tables.items(): + if scenario_table_name in input_table_names: + inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + outputs = {} + for scenario_table_name, db_table in self.output_db_tables.items(): + if scenario_table_name in output_table_names: + outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) + return inputs, outputs + + # def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: ScenarioDbTable) -> pd.DataFrame: + # """Read one table from the DB. + # Removes the `scenario_name` column.""" + # db_table_name = db_table.db_table_name + # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" + # df = pd.read_sql(sql, con=self.engine) + # if db_table_name != 'scenario': + # df = df.drop(columns=['scenario_name']) + # + # return df + def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: ScenarioDbTable, connection) -> pd.DataFrame: + """Read one table from the DB. + Removes the `scenario_name` column. + + Modification: based on SQLAlchemy syntax. If doing the plain text SQL, then some column names not properly extracted + """ + db_table_name = db_table.db_table_name + # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" # Old + # db_table.table_metadata is a Table() + t: sqlalchemy.Table = db_table.get_sa_table() #table_metadata + sql = t.select().where(t.c.scenario_name == scenario_name) # This is NOT a simple string! + df = pd.read_sql(sql, con=connection) + if db_table_name != 'scenario': + df = df.drop(columns=['scenario_name']) + + return df + + ############################################################################################ + # Read multi scenario + ############################################################################################ + def read_multi_scenario_tables_from_db(self, scenario_names: List[str], + input_table_names: Optional[List[str]] = None, + output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): + """Read selected set input and output tables from multiple scenarios. + If input_table_names/output_table_names contains a '*', then all input/output tables will be read. + If empty list or None, then no tables will be read. + """ + if self.enable_transactions: + with self.engine.begin() as connection: + inputs, outputs = self._read_multi_scenario_tables_from_db(connection, scenario_names, input_table_names, output_table_names) + else: + inputs, outputs = self._read_multi_scenario_tables_from_db(self.engine, scenario_names, input_table_names, output_table_names) + return inputs, outputs + + def _read_multi_scenario_tables_from_db(self, connection, scenario_names: List[str], + input_table_names: List[str] = None, + output_table_names: List[str] = None) -> (Inputs, Outputs): + """Loads data for selected input and output tables from multiple scenarios. + If either list is names is ['*'], will load all tables as defined in db_tables configuration. + """ + if input_table_names is None: # load no tables by default + input_table_names = [] + elif '*' in input_table_names: + input_table_names = list(self.input_db_tables.keys()) + + # Add the scenario table + if 'Scenario' not in input_table_names: + input_table_names.append('Scenario') + + if output_table_names is None: # load no tables by default + output_table_names = [] + elif '*' in output_table_names: + output_table_names = self.output_db_tables.keys() + + inputs = {} + for scenario_table_name, db_table in self.input_db_tables.items(): + if scenario_table_name in input_table_names: + inputs[scenario_table_name] = self._read_multi_scenario_db_table_from_db(scenario_names, db_table, connection=connection) + outputs = {} + for scenario_table_name, db_table in self.output_db_tables.items(): + if scenario_table_name in output_table_names: + outputs[scenario_table_name] = self._read_multi_scenario_db_table_from_db(scenario_names, db_table, connection=connection) + return inputs, outputs + + def _read_multi_scenario_db_table_from_db(self, scenario_names: List[str], db_table: ScenarioDbTable, connection) -> pd.DataFrame: + """Read one table from the DB for multiple scenarios. + Does NOT remove the `scenario_name` column. + """ + t: sqlalchemy.Table = db_table.get_sa_table() #table_metadata + sql = t.select().where(t.c.scenario_name.in_(scenario_names)) # This is NOT a simple string! + df = pd.read_sql(sql, con=connection) + + return df + + ############################################################################################ + # Update scenario + ############################################################################################ + def update_cell_changes_in_db(self, db_cell_updates: List[DbCellUpdate]): + """Update a set of cellz in the DB. + + :param db_cell_updates: + :return: + """ + if self.enable_transactions: + print("Update cellz with transaction") + with self.engine.begin() as connection: + self._update_cell_changes_in_db(db_cell_updates, connection=connection) + else: + self._update_cell_changes_in_db(db_cell_updates) + + def _update_cell_changes_in_db(self, db_cell_updates: List[DbCellUpdate], connection=None): + """Update an ordered list of single value changes (cell) in the DB.""" + for db_cell_change in db_cell_updates: + self._update_cell_change_in_db(db_cell_change, connection) + + def _update_cell_change_in_db(self, db_cell_update: DbCellUpdate, connection): + """Update a single value (cell) change in the DB.""" + # db_table_name = self.db_tables[db_cell_update.table_name].db_table_name + # column_change = f"{db_cell_update.column_name} = '{db_cell_update.current_value}'" + # scenario_condition = f"scenario_name = '{db_cell_update.scenario_name}'" + # pk_conditions = ' AND '.join([f"{pk['column']} = '{pk['value']}'" for pk in db_cell_update.row_index]) + # old_sql = f"UPDATE {db_table_name} SET {column_change} WHERE {pk_conditions} AND {scenario_condition};" + + db_table: ScenarioDbTable = self.db_tables[db_cell_update.table_name] + t: sqlalchemy.Table = db_table.get_sa_table() + pk_conditions = [(db_table.get_sa_column(pk['column']) == pk['value']) for pk in db_cell_update.row_index] + target_col: sqlalchemy.Column = db_table.get_sa_column(db_cell_update.column_name) + print(f"_update_cell_change_in_db - target_col = {target_col} for db_cell_update.column_name={db_cell_update.column_name}, pk_conditions={pk_conditions}") + sql = t.update().where(sqlalchemy.and_((t.c.scenario_name == db_cell_update.scenario_name), *pk_conditions)).values({target_col:db_cell_update.current_value}) + # print(f"_update_cell_change_in_db = {sql}") + + connection.execute(sql) + + ############################################################################################ + # Update/Replace tables in scenario + ############################################################################################ + def update_scenario_output_tables_in_db(self, scenario_name, outputs: Outputs): + """Main API to update output from a DO solve in the scenario. + Deletes ALL output tables. Then inserts the given set of tables. + Since this only touches the output tables, more efficient than replacing the whole scenario.""" + if self.enable_transactions: + with self.engine.begin() as connection: + self._update_scenario_output_tables_in_db(scenario_name, outputs, connection) + else: + self._update_scenario_output_tables_in_db(scenario_name, outputs, self.engine) + + def _update_scenario_output_tables_in_db(self, scenario_name, outputs: Outputs, connection): + """Deletes ALL output tables. Then inserts the given set of tables. + Note that if a defined output table is not included in the outputs, it will still be deleted from the scenario data.""" + # 1. Add scenario name to dfs: + outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) + # 2. Delete all output tables + for scenario_table_name, db_table in reversed(self.output_db_tables.items()): # Note this INCLUDES the SCENARIO table! + if (scenario_table_name != 'Scenario'): + db_table._delete_scenario_table_from_db(scenario_name, connection) + # 3. Insert new data + for scenario_table_name, db_table in self.output_db_tables.items(): # Note this INCLUDES the SCENARIO table! + if (scenario_table_name != 'Scenario') and scenario_table_name in outputs.keys(): # If in given set of tables to replace + df = outputs[scenario_table_name] + print(f"Inserting {df.shape[0]} rows and {df.shape[1]} columns in {scenario_table_name}") + db_table.insert_table_in_db_bulk(df=df, mgr=self, connection=connection) # The scenario_name is a column in the df + + def replace_scenario_tables_in_db(self, scenario_name, inputs={}, outputs={}): + """Untested""" + if self.enable_transactions: + with self.engine.begin() as connection: + self._replace_scenario_tables_in_db(connection, scenario_name, inputs, outputs) + else: + self._replace_scenario_tables_in_db(self.engine, scenario_name, inputs, outputs) + + def _replace_scenario_tables_in_db(self, connection, scenario_name, inputs={}, outputs={}): + """Untested + Replace only the tables listed in the inputs and outputs. But leave all other tables untouched. + Will first delete all given tables (in reverse cascading order), then insert the new ones (in cascading order)""" + + # Add scenario name to dfs: + inputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, inputs) + outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) + dfs = {**inputs, **outputs} + # 1. Delete tables + for scenario_table_name, db_table in reversed(self.db_tables.items()): # Note this INCLUDES the SCENARIO table! + if (scenario_table_name != 'Scenario') and db_table.db_table_name in dfs.keys(): # If in given set of tables to replace + db_table._delete_scenario_table_from_db() + # 2. Insert new data + for scenario_table_name, db_table in self.db_tables.items(): # Note this INCLUDES the SCENARIO table! + if (scenario_table_name != 'Scenario') and db_table.db_table_name in dfs.keys(): # If in given set of tables to replace + df = dfs[scenario_table_name] + db_table.insert_table_in_db_bulk(df=df, mgr=self, connection=connection) # The scenario_name is a column in the df + + ############################################################################################ + # CRUD operations on scenarios in DB: + # - Delete scenario + # - Duplicate scenario + # - Rename scenario + ############################################################################################ + def delete_scenario_from_db(self, scenario_name: str): + """Delete a scenario. Uses a transaction (when enabled).""" + if self.enable_transactions: + print("Delete scenario within a transaction") + with self.engine.begin() as connection: + self._delete_scenario_from_db(scenario_name=scenario_name, connection=connection) + else: + self._delete_scenario_from_db(scenario_name=scenario_name, connection=self.engine) + + ########################################################## + def duplicate_scenario_in_db(self, source_scenario_name: str, target_scenario_name: str): + """Duplicate a scenario. Uses a transaction (when enabled).""" + if self.enable_transactions: + print("Duplicate scenario within a transaction") + with self.engine.begin() as connection: + self._duplicate_scenario_in_db(connection, source_scenario_name, target_scenario_name) + else: + self._duplicate_scenario_in_db(self.engine, source_scenario_name, target_scenario_name) + + def _duplicate_scenario_in_db(self, connection, source_scenario_name: str, target_scenario_name: str = None): + """Is fully done in DB using SQL in one SQL execute statement + :param source_scenario_name: + :param target_scenario_name: + :param connection: + :return: + """ + if target_scenario_name is None: + new_scenario_name = self._find_free_duplicate_scenario_name(source_scenario_name) + elif self._check_free_scenario_name(target_scenario_name): + new_scenario_name = target_scenario_name + else: + raise ValueError(f"Target name for duplicate scenario '{target_scenario_name}' already exists.") + + # inputs, outputs = self.read_scenario_from_db(source_scenario_name) + # self._replace_scenario_in_db_transaction(scenario_name=new_scenario_name, inputs=inputs, outputs=outputs, + # bulk=True, connection=connection) + self._duplicate_scenario_in_db_sql(connection, source_scenario_name, new_scenario_name) + + def _duplicate_scenario_in_db_sql(self, connection, source_scenario_name: str, target_scenario_name: str = None): + """ + :param source_scenario_name: + :param target_scenario_name: + :param connection: + :return: + + See https://stackoverflow.com/questions/9879830/select-modify-and-insert-into-the-same-table + + Problem: the table Parameter/parameters has a column 'value' (lower-case). + Almost all of the column names in the DFs are lower-case, as are the column names in the ScenarioDbTable. + Typically, the DB schema converts that the upper-case column names in the DB. + But probably because 'VALUE' is a reserved word, it does NOT do this for 'value'. But that means in order to refer to this column in SQL, + one needs to put "value" between double quotes. + Problem is that you CANNOT do that for other columns, since these are in upper-case in the DB. + Note that the kpis table uses upper case 'VALUE' and that seems to work fine + + Resolution: use SQLAlchemy to construct the SQL. Do NOT create SQL expressions by text manipulation. + SQLAlchemy has the smarts to properly deal with these complex names. + """ + if target_scenario_name is None: + new_scenario_name = self._find_free_duplicate_scenario_name(source_scenario_name) + elif self._check_free_scenario_name(target_scenario_name): + new_scenario_name = target_scenario_name + else: + raise ValueError(f"Target name for duplicate scenario '{target_scenario_name}' already exists.") + + batch_sql=False # BEWARE: batch = True does NOT work! + sql_statements = [] + + # 1. Insert scenario in scenario table + # sql_insert = f"INSERT INTO SCENARIO (scenario_name) VALUES ('{new_scenario_name}')" # Old SQL + # sa_scenario_table = list(self.input_db_tables.values())[0].get_sa_table() # Scenario table must be the first + sa_scenario_table = self.get_scenario_db_table().get_sa_table() + sql_insert = sa_scenario_table.insert().values(scenario_name = new_scenario_name) + # print(f"_duplicate_scenario_in_db_sql - Insert SQL = {sql_insert}") + if batch_sql: + sql_statements.append(sql_insert) + else: + connection.execute(sql_insert) + + # 2. Do 'insert into select' to duplicate rows in each table + for scenario_table_name, db_table in self.db_tables.items(): + if scenario_table_name == 'Scenario': + continue + + t: sqlalchemy.table = db_table.table_metadata # The table at hand + s: sqlalchemy.table = sa_scenario_table # The scenario table + # print("+++++++++++SQLAlchemy insert-select") + select_columns = [s.c.scenario_name if c.name == 'scenario_name' else c for c in t.columns] # Replace the t.c.scenario_name with s.c.scenario_name, so we get the new value + # print(f"select columns = {select_columns}") + select_sql = (sqlalchemy.select(select_columns) + .where(sqlalchemy.and_(t.c.scenario_name == source_scenario_name, s.c.scenario_name == target_scenario_name))) + target_columns = [c for c in t.columns] + sql_insert = t.insert().from_select(target_columns, select_sql) + # print(f"sql_insert = {sql_insert}") + + # sql_insert = f"INSERT INTO {db_table.db_table_name} ({target_columns_txt}) SELECT '{target_scenario_name}',{other_source_columns_txt} FROM {db_table.db_table_name} WHERE scenario_name = '{source_scenario_name}'" + if batch_sql: + sql_statements.append(sql_insert) + else: + connection.execute(sql_insert) + if batch_sql: + batch_sql = ";\n".join(sql_statements) + print(batch_sql) + connection.execute(batch_sql) + + def _find_free_duplicate_scenario_name(self, scenario_name: str, scenarios_df=None) -> Optional[str]: + """Finds next free scenario name based on pattern '{scenario_name}_copy_n'. + Will try at maximum 20 attempts. + """ + max_num_attempts = 20 + for i in range(1, max_num_attempts + 1): + new_name = f"{scenario_name}({i})" + free = self._check_free_scenario_name(new_name, scenarios_df) + if free: + return new_name + raise ValueError(f"Cannot find free name for duplicate scenario. Tried {max_num_attempts}. Last attempt = {new_name}. Rename scenarios.") + return None + + def _check_free_scenario_name(self, scenario_name, scenarios_df=None) -> bool: + if scenarios_df is None: + scenarios_df = self.get_scenarios_df() + free = (False if scenario_name in scenarios_df.index else True) + return free + + ############################################## + def rename_scenario_in_db(self, source_scenario_name: str, target_scenario_name: str): + """Rename a scenario. Uses a transaction (when enabled).""" + if self.enable_transactions: + print("Rename scenario within a transaction") + with self.engine.begin() as connection: + # self._rename_scenario_in_db(source_scenario_name, target_scenario_name, connection=connection) + self._rename_scenario_in_db_sql(connection, source_scenario_name, target_scenario_name) + else: + # self._rename_scenario_in_db(source_scenario_name, target_scenario_name) + self._rename_scenario_in_db_sql(self.engine, source_scenario_name, target_scenario_name) + + def _rename_scenario_in_db_sql(self, connection, source_scenario_name: str, target_scenario_name: str = None): + """Rename scenario. + Uses 2 steps: + 1. Duplicate scenario + 2. Delete source scenario. + + Problem is that we use scenario_name as a primary key. You should not change the value of primary keys in a DB. + Instead, first copy the data using a new scenario_name, i.e. duplicate a scenario. Next, delete the original scenario. + + Long-term solution: use a scenario_seq sequence key as the PK. With scenario_name as a ordinary column in the scenario table. + + Use of 'insert into select': https://stackoverflow.com/questions/9879830/select-modify-and-insert-into-the-same-table + """ + # 1. Duplicate scenario + self._duplicate_scenario_in_db_sql(connection, source_scenario_name, target_scenario_name) + # 2. Delete scenario + self._delete_scenario_from_db(source_scenario_name, connection=connection) + + def _delete_scenario_from_db(self, scenario_name: str, connection): + """Deletes all rows associated with a given scenario. + Note that it only deletes rows from tables defined in the self.db_tables, i.e. will NOT delete rows in 'auto-inserted' tables! + Must do a 'cascading' delete to ensure not violating FK constraints. In reverse order of how they are inserted. + Also deletes entry in scenario table + Uses SQLAlchemy syntax to generate SQL + TODO: check with 'auto-inserted' tables + TODO: batch all sql statements in single execute. Faster? And will that do the defer integrity checks? + """ + # batch_sql=False # Batch=True does NOT work! + insp = sqlalchemy.inspect(connection) + tables_in_db = insp.get_table_names(schema=self.schema) + # sql_statements = [] + for scenario_table_name, db_table in reversed(self.db_tables.items()): # Note this INCLUDES the SCENARIO table! + if db_table.db_table_name in tables_in_db: + # # sql = f"DELETE FROM {db_table.db_table_name} WHERE scenario_name = '{scenario_name}'" # Old + # t = db_table.table_metadata # A Table() + # sql = t.delete().where(t.c.scenario_name == scenario_name) + # connection.execute(sql) + db_table._delete_scenario_table_from_db(scenario_name, connection) + + ############################################################################################ + # Import from zip + ############################################################################################ + def insert_scenarios_from_zip(self, filepath: str): + """Insert (or replace) a set of scenarios from a .zip file into the DB. + Zip is assumed to contain one or more .xlsx files. Others will be skipped. + Name of .xlsx file will be used as the scenario name. + + :param filepath: filepath of a zip file + :return: + """ + with zipfile.ZipFile(filepath, 'r') as zip_file: + for info in zip_file.infolist(): + scenario_name = pathlib.Path(info.filename).stem + file_extension = pathlib.Path(info.filename).suffix + if file_extension == '.xlsx': + # print(f"file in zip : {info.filename}") + xl = pd.ExcelFile(zip_file.read(info)) + inputs, outputs = ScenarioManager.load_data_from_excel_s(xl) + print("Input tables: {}".format(", ".join(inputs.keys()))) + print("Output tables: {}".format(", ".join(outputs.keys()))) + self.replace_scenario_in_db(scenario_name=scenario_name, inputs=inputs, outputs=outputs) # + print(f"Uploaded scenario: '{scenario_name}' from '{info.filename}'") + else: + print(f"File '{info.filename}' in zip is not a .xlsx. Skipped.") + + ############################################################################################ + # Old Read scenario APIs + ############################################################################################ + # def read_scenario_table_from_db(self, scenario_name: str, scenario_table_name: str) -> pd.DataFrame: + # """Read a single table from the DB. + # The API called by a cached procedure in the dse_do_dashboard.DoDashApp. + # + # :param scenario_name: Name of scenario + # :param scenario_table_name: Name of scenario table (not the DB table name) + # :return: + # """ + # # print(f"read table {scenario_table_name}") + # if scenario_table_name in self.input_db_tables: + # db_table = self.input_db_tables[scenario_table_name] + # elif scenario_table_name in self.output_db_tables: + # db_table = self.output_db_tables[scenario_table_name] + # else: + # # error! + # raise ValueError(f"Scenario table name '{scenario_table_name}' unknown. Cannot load data from DB.") + # + # db_table_name = db_table.db_table_name + # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" + # df = pd.read_sql(sql, con=self.engine) + # if db_table_name != 'scenario': + # df = df.drop(columns=['scenario_name']) + # + # return df + + # def read_scenario_from_db(self, scenario_name: str) -> (Inputs, Outputs): + # """Single scenario load. + # Reads all tables for a single scenario. + # Returns all tables in one dict""" + # inputs = {} + # for scenario_table_name, db_table in self.input_db_tables.items(): + # db_table_name = db_table.db_table_name + # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" + # df = pd.read_sql(sql, con=self.engine) + # # print(db_table_name) + # inputs[scenario_table_name] = df + # + # outputs = {} + # for scenario_table_name, db_table in self.output_db_tables.items(): + # db_table_name = db_table.db_table_name + # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" + # df = pd.read_sql(sql, con=self.engine) + # # print(db_table_name) + # outputs[scenario_table_name] = df + # + # inputs, outputs = ScenarioDbManager.delete_scenario_name_column(inputs, outputs) + # return inputs, outputs + + ####################################################################################################### + # Caching + # How it works: + # Setup: + # 1. DoDashApp defines a procedure `read_xxxx_proc` + # 2. DoDashApp applies Flask caching to procedure + # 3. DoDashApp registers the procedure as a callback in the ScenarioDbManager.read_xxx_callback using `dbm.set_xxx_callback(read_xxx_callback)` + # Operationally (where dbm is a ScenarioDbManager): + # 1. In the DoDashApp, call to `dbm.read_xxxx_cached()` + # 2. In the `ScenarioDbManager.read_xxxx_cached()` calls the cached callback procedure defined in the DoDashApp (i.e. `read_xxxx_proc`) + # 3. The cached procedure calls `dbm.read_xxxx()` + # + # TODO: why can't the DoDashApp call the `read_xxxx_proc` directly. This would avoid all this registration of callbacks + # TODO: migrate all of this caching and callbacks (if applicable) to the DoDashApp to reduce complexity and dependency + ####################################################################################################### + # ScenarioTable + def set_scenarios_table_read_callback(self, scenarios_table_read_callback=None): + """DEPRECATED - now in DoDashApp + Sets a callback function to read the scenario table from the DB + """ + self.read_scenarios_table_from_db_callback = scenarios_table_read_callback + + def read_scenarios_table_from_db_cached(self) -> pd.DataFrame: + """DEPRECATED - now in DoDashApp + For use with Flask caching. Default implementation. + To be called from (typically) a Dash app to use the cached version. + In case no caching has been configured. Simply calls the regular method `get_scenarios_df`. + + For caching: + 1. Specify a callback procedure in `read_scenarios_table_from_db_callback` that uses a hard-coded version of a ScenarioDbManager, + which in turn calls the regular method `get_scenarios_df` + """ + if self.read_scenarios_table_from_db_callback is not None: + df = self.read_scenarios_table_from_db_callback() # NOT a method! + else: + df = self.get_scenarios_df() + return df + + # Tables + def set_table_read_callback(self, table_read_callback=None): + """DEPRECATED - now in DoDashApp + Sets a callback function to read a table from a scenario + """ + # print(f"Set callback to {table_read_callback}") + self.read_scenario_table_from_db_callback = table_read_callback + + def read_scenario_table_from_db_cached(self, scenario_name: str, scenario_table_name: str) -> pd.DataFrame: + """DEPRECATED - now in DoDashApp + For use with Flask caching. Default implementation. + In case no caching has been configured. Simply calls the regular method `read_scenario_table_from_db`. + + For caching: + 1. Specify a callback procedure in `read_scenario_table_from_db_callback` that uses a hard-coded version of a ScenarioDbManager, + which in turn calls the regular method `read_scenario_table_from_db` + """ + # 1. Override this method and call a procedure that uses a hard-coded version of a ScenarioDbManager, + # which in turn calls the regular method `read_scenario_table_from_db` + + # return self.read_scenario_table_from_db(scenario_name, scenario_table_name) + if self.read_scenario_table_from_db_callback is not None: + df = self.read_scenario_table_from_db_callback(scenario_name, scenario_table_name) # NOT a method! + else: + df = self.read_scenario_table_from_db(scenario_name, scenario_table_name) + return df + + def read_scenario_tables_from_db_cached(self, scenario_name: str, + input_table_names: List[str] = None, + output_table_names: List[str] = None) -> (Inputs, Outputs): + """DEPRECATED - now in DoDashApp + For use with Flask caching. Loads data for selected input and output tables. + Same as `read_scenario_tables_from_db`, but calls `read_scenario_table_from_db_cached`. + Is called from dse_do_dashboard.DoDashApp to create the PlotlyManager.""" + + if input_table_names is None: # load all tables by default + input_table_names = list(self.input_db_tables.keys()) + if 'Scenario' in input_table_names: input_table_names.remove('Scenario') # Remove the scenario table + if output_table_names is None: # load all tables by default + output_table_names = self.output_db_tables.keys() + + inputs = {} + for scenario_table_name in input_table_names: + # print(f"read input table {scenario_table_name}") + inputs[scenario_table_name] = self.read_scenario_table_from_db_cached(scenario_name, scenario_table_name) + + outputs = {} + for scenario_table_name in output_table_names: + # print(f"read output table {scenario_table_name}") + outputs[scenario_table_name] = self.read_scenario_table_from_db_cached(scenario_name, scenario_table_name) + return inputs, outputs + + ####################################################################################################### + # Review + ####################################################################################################### + + + def read_scenarios_from_db(self, scenario_names: List[str] = []) -> (Inputs, Outputs): + """DEPRECATED. Multi scenario load. + Reads all tables from set of scenarios + TODO: avoid use of text SQL. Use SQLAlchemy sql generation. + """ + where_scenarios = ','.join([f"'{n}'" for n in scenario_names]) + + inputs = {} + for scenario_table_name, db_table in self.input_db_tables.items(): + db_table_name = db_table.db_table_name + sql = f"SELECT * FROM {db_table_name} WHERE scenario_name in ({where_scenarios})" + # print(sql) + df = pd.read_sql(sql, con=self.engine) + # print(db_table_name) + inputs[scenario_table_name] = df + print(f"Read {df.shape[0]} rows and {df.shape[1]} columns into {scenario_table_name}") + + outputs = {} + for scenario_table_name, db_table in self.output_db_tables.items(): + db_table_name = db_table.db_table_name + sql = f"SELECT * FROM {db_table_name} WHERE scenario_name in ({where_scenarios})" + # print(sql) + df = pd.read_sql(sql, con=self.engine) + # print(db_table_name) + outputs[scenario_table_name] = df + print(f"Read {df.shape[0]} rows and {df.shape[1]} columns into {scenario_table_name}") + + return inputs, outputs + + ####################################################################################################### + # Utils + ####################################################################################################### + @staticmethod + def add_scenario_name_to_dfs(scenario_name: str, inputs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: + """DEPRECATED + Adds a `scenario_name` column to each df. + Or overwrites all values of that column already exists. + This avoids to need for the MultiScenarioManager when loading and storing single scenarios.""" + outputs = {} + for scenario_table_name, df in inputs.items(): + df['scenario_name'] = scenario_name + outputs[scenario_table_name] = df + return outputs + + @staticmethod + def add_scenario_seq_to_dfs(scenario_seq: int, inputs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: + """Adds a `scenario_seq` column to each df. + Or overwrites all values of that column already exists. + This avoids to need for the MultiScenarioManager when loading and storing single scenarios.""" + outputs = {} + for scenario_table_name, df in inputs.items(): + df['scenario_seq'] = scenario_seq + outputs[scenario_table_name] = df + return outputs + + @staticmethod + def delete_scenario_name_column(inputs: Inputs, outputs: Outputs) -> (Inputs, Outputs): + """DEPRECATED + Drops the column `scenario_name` from any df in either inputs or outputs. + This is used to create a inputs/outputs combination similar to loading a single scenario from the DO Experiment. + """ + new_inputs = {} + new_outputs = {} + for scenario_table_name, df in inputs.items(): + if 'scenario_name' in df.columns: + df = df.drop(columns=['scenario_name']) + new_inputs[scenario_table_name] = df + for scenario_table_name, df in outputs.items(): + if 'scenario_name' in df.columns: + df = df.drop(columns=['scenario_name']) + new_outputs[scenario_table_name] = df + return new_inputs, new_outputs + + @staticmethod + def delete_scenario_seq_column(inputs: Inputs, outputs: Outputs) -> (Inputs, Outputs): + """Drops the column `scenario_seq` from any df in either inputs or outputs. + This is used to create a inputs/outputs combination similar to loading a single scenario from the DO Experiment. + """ + new_inputs = {} + new_outputs = {} + for scenario_table_name, df in inputs.items(): + if 'scenario_seq' in df.columns: + df = df.drop(columns=['scenario_seq']) + new_inputs[scenario_table_name] = df + for scenario_table_name, df in outputs.items(): + if 'scenario_seq' in df.columns: + df = df.drop(columns=['scenario_seq']) + new_outputs[scenario_table_name] = df + return new_inputs, new_outputs + + +####################################################################################################### +# Input Tables +####################################################################################################### +class ScenarioTable(ScenarioDbTable): + def __init__(self, db_table_name: str = 'scenario'): + columns_metadata = [ + Column('scenario_seq', Integer(), autoincrement=True, primary_key=True), + Column('scenario_name', String(256), primary_key=False), + ] + super().__init__(db_table_name, columns_metadata) + + +class ParameterTable(ScenarioDbTable): + def __init__(self, db_table_name: str = 'parameters', extended_columns_metadata: List[Column] = []): + columns_metadata = [ + Column('param', String(256), primary_key=True), + Column('value', String(256), primary_key=False), + ] + columns_metadata.extend(extended_columns_metadata) + super().__init__(db_table_name, columns_metadata) + + +####################################################################################################### +# Output Tables +####################################################################################################### +class KpiTable(ScenarioDbTable): + def __init__(self, db_table_name: str = 'kpis'): + columns_metadata = [ + Column('NAME', String(256), primary_key=True), + Column('VALUE', Float(), primary_key=False), + ] + super().__init__(db_table_name, columns_metadata) + + +class BusinessKpiTable(ScenarioDbTable): + def __init__(self, db_table_name: str = 'business_kpi', extended_columns_metadata: List[Column] = []): + columns_metadata = [ + Column('kpi', String(256), primary_key=True), + Column('value', Float(), primary_key=False), + ] + columns_metadata.extend(extended_columns_metadata) + super().__init__(db_table_name, columns_metadata) \ No newline at end of file From 85d4e5f76373d0500dcb120c1634c78e993df56e Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sun, 6 Mar 2022 12:33:38 -0500 Subject: [PATCH 02/39] ScenarioSeq --- dse_do_utils/scenariodbmanager2.py | 76 ++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py index e5a508a..746a41c 100644 --- a/dse_do_utils/scenariodbmanager2.py +++ b/dse_do_utils/scenariodbmanager2.py @@ -103,7 +103,7 @@ def create_table_metadata(self, metadata, engine, schema, multi_scenario: bool = constraints_metadata = self.constraints_metadata if multi_scenario and (self.db_table_name != 'scenario'): - columns_metadata.insert(0, Column('scenario_seq', String(256), ForeignKey("scenario.scenario_seq"), + columns_metadata.insert(0, Column('scenario_seq', Integer(), ForeignKey("scenario.scenario_seq"), primary_key=True, index=True)) constraints_metadata = [ScenarioDbTable.add_scenario_seq_to_fk_constraint(fkc) for fkc in constraints_metadata] @@ -167,14 +167,21 @@ def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None): print(f"DataFrame insert/append of table '{table_name}'") print(e) - def _delete_scenario_table_from_db(self, scenario_name, connection): + def _delete_scenario_table_from_db(self, scenario_name: str, connection, scenario_sa_table: Table): """Delete all rows associated with the scenario in the DB table. Beware: make sure this is done in the right 'inverse cascading' order to avoid FK violations. + Note that if there happen to be multiple entries in the scenario table with the same name + (which shouldn't happen), all will be deleted. """ - # sql = f"DELETE FROM {db_table.db_table_name} WHERE scenario_name = '{scenario_name}'" # Old + s = scenario_sa_table + # scenario_seqs = [seq for seq in connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name == scenario_name))] + scenario_seqs = [r.scenario_seq for r in connection.execute(s.select().where(s.c.scenario_name == scenario_name))] + t = self.get_sa_table() # A Table() - sql = t.delete().where(t.c.scenario_name == scenario_name) - connection.execute(sql) + if t is not None: + # Do a join with the scenario table to delete all entries based on the scenario_name + sql = t.delete().where(t.c.scenario_seq.in_(scenario_seqs)) + connection.execute(sql) @staticmethod def sqlcol(df: pd.DataFrame) -> Dict: @@ -366,6 +373,10 @@ def get_scenario_db_table(self) -> ScenarioDbTable: db_table: ScenarioTable = list(self.input_db_tables.values())[0] return db_table + def get_scenario_sa_table(self) -> sqlalchemy.Table: + """Returns the SQLAlchemy 'scenario' table. """ + return self.get_scenario_db_table().get_sa_table() + def _create_database_engine(self, credentials=None, schema: str = None, echo: bool = False): """Creates a SQLAlchemy engine at initialization. If no credentials, creates an in-memory SQLite DB. Which can be used for schema validation of the data. @@ -648,14 +659,18 @@ def _replace_scenario_in_db_transaction(self, connection, scenario_name: str, in """ # Step 1: delete scenario if exists self._delete_scenario_from_db(scenario_name, connection=connection) - # Step 2: add scenario_name to all dfs - inputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, inputs) - outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) - # Step 3: insert scenario_name in scenario table - # sql = f"INSERT INTO SCENARIO (scenario_name) VALUES ('{scenario_name}')" - sa_scenario_table = self.get_scenario_db_table().get_sa_table() - sql_insert = sa_scenario_table.insert().values(scenario_name = scenario_name) - connection.execute(sql_insert) + # Step 2: insert scenario_name in scenario table and get scenario_seq + scenario_seq = self._get_or_create_scenario_in_scenario_table(scenario_name, connection) + + # Step 3: add scenario_name to all dfs + inputs = ScenarioDbManager.add_scenario_seq_to_dfs(scenario_seq, inputs) + outputs = ScenarioDbManager.add_scenario_seq_to_dfs(scenario_seq, outputs) + + # # Step 3: insert scenario_name in scenario table + # sa_scenario_table = self.get_scenario_db_table().get_sa_table() + # sql_insert = sa_scenario_table.insert().values(scenario_name = scenario_name) + # connection.execute(sql_insert) + # Step 4: (bulk) insert scenario num_caught_exceptions = self._insert_single_scenario_tables_in_db(inputs=inputs, outputs=outputs, bulk=bulk, connection=connection) # Throw exception if any exceptions caught in 'non-bulk' mode @@ -968,11 +983,12 @@ def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: Scenario db_table_name = db_table.db_table_name # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" # Old # db_table.table_metadata is a Table() + s: sqlalchemy.Table = self.get_scenario_sa_table() t: sqlalchemy.Table = db_table.get_sa_table() #table_metadata - sql = t.select().where(t.c.scenario_name == scenario_name) # This is NOT a simple string! + sql = t.select().where(t.c.scenario_seq == s.c.scenario_seq).where(s.c.scenario_name == scenario_name) # This is NOT a simple string! df = pd.read_sql(sql, con=connection) if db_table_name != 'scenario': - df = df.drop(columns=['scenario_name']) + df = df.drop(columns=['scenario_seq']) return df @@ -1091,9 +1107,10 @@ def _update_scenario_output_tables_in_db(self, scenario_name, outputs: Outputs, # 1. Add scenario name to dfs: outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) # 2. Delete all output tables + scenario_sa_table = self.get_scenario_sa_table() for scenario_table_name, db_table in reversed(self.output_db_tables.items()): # Note this INCLUDES the SCENARIO table! if (scenario_table_name != 'Scenario'): - db_table._delete_scenario_table_from_db(scenario_name, connection) + db_table._delete_scenario_table_from_db(scenario_name, connection, scenario_sa_table) # 3. Insert new data for scenario_table_name, db_table in self.output_db_tables.items(): # Note this INCLUDES the SCENARIO table! if (scenario_table_name != 'Scenario') and scenario_table_name in outputs.keys(): # If in given set of tables to replace @@ -1119,9 +1136,10 @@ def _replace_scenario_tables_in_db(self, connection, scenario_name, inputs={}, o outputs = ScenarioDbManager.add_scenario_name_to_dfs(scenario_name, outputs) dfs = {**inputs, **outputs} # 1. Delete tables + scenario_sa_table = self.get_scenario_sa_table() for scenario_table_name, db_table in reversed(self.db_tables.items()): # Note this INCLUDES the SCENARIO table! if (scenario_table_name != 'Scenario') and db_table.db_table_name in dfs.keys(): # If in given set of tables to replace - db_table._delete_scenario_table_from_db() + db_table._delete_scenario_table_from_db() #VT 2022-03-05: this cannot work. Incomplete arguments! # 2. Insert new data for scenario_table_name, db_table in self.db_tables.items(): # Note this INCLUDES the SCENARIO table! if (scenario_table_name != 'Scenario') and db_table.db_table_name in dfs.keys(): # If in given set of tables to replace @@ -1297,17 +1315,27 @@ def _delete_scenario_from_db(self, scenario_name: str, connection): TODO: check with 'auto-inserted' tables TODO: batch all sql statements in single execute. Faster? And will that do the defer integrity checks? """ - # batch_sql=False # Batch=True does NOT work! insp = sqlalchemy.inspect(connection) tables_in_db = insp.get_table_names(schema=self.schema) - # sql_statements = [] + scenario_sa_table = self.get_scenario_sa_table() for scenario_table_name, db_table in reversed(self.db_tables.items()): # Note this INCLUDES the SCENARIO table! if db_table.db_table_name in tables_in_db: - # # sql = f"DELETE FROM {db_table.db_table_name} WHERE scenario_name = '{scenario_name}'" # Old - # t = db_table.table_metadata # A Table() - # sql = t.delete().where(t.c.scenario_name == scenario_name) - # connection.execute(sql) - db_table._delete_scenario_table_from_db(scenario_name, connection) + db_table._delete_scenario_table_from_db(scenario_name, connection, scenario_sa_table) + + def _get_or_create_scenario_in_scenario_table(self, scenario_name: str, connection) -> int: + """Returns the scenario_seq of (the first) entry matching the scenario_name. + If it doesn't exist, will insert a new entry. + """ + s = self.get_scenario_sa_table() + r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name == scenario_name)) + if (r is not None) and ((first := r.first()) is not None): # Walrus operator! + seq = first[0] + else: + connection.execute(s.insert().values(scenario_name=scenario_name)) + r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name==scenario_name)) + seq = r.first()[0] + return seq + ############################################################################################ # Import from zip From daa7d155fee1985766c8b7aa6822d1f04723e261 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sun, 6 Mar 2022 22:13:14 -0500 Subject: [PATCH 03/39] more ScenarioSeq --- dse_do_utils/scenariodbmanager2.py | 415 +++-------------------------- 1 file changed, 32 insertions(+), 383 deletions(-) diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py index 746a41c..278de3d 100644 --- a/dse_do_utils/scenariodbmanager2.py +++ b/dse_do_utils/scenariodbmanager2.py @@ -407,35 +407,6 @@ def _set_sqlite_pragma(dbapi_connection, connection_record): cursor.execute("PRAGMA foreign_keys=ON;") cursor.close() - # def get_db2_connection_string(self, credentials, schema: str): - # """Create a DB2 connection string. - # Needs a work-around for DB2 on cloud.ibm.com. - # The option 'ssl=True' doesn't work. Instead use 'Security=ssl'. - # See https://stackoverflow.com/questions/58952002/using-credentials-from-db2-warehouse-on-cloud-to-initialize-flask-sqlalchemy. - - # TODO: - # * Not sure the check for the port 50001 is necessary, or if this applies to any `ssl=True` - # * The schema doesn't work properly in db2 on cloud.ibm.com. Instead it automatically creates a schema based on the username. - # * Also tried to use 'schema={schema}', but it didn't work properly. - # * In case ssl=False, do NOT add the option `ssl=False`: doesn't gie an error, but select rows will always return zero rows! - # * TODO: what do we do in case ssl=True, but the port is not 50001?! - # """ - # if str(credentials['ssl']).upper() == 'TRUE' and str(credentials['port']) == '50001': - # ssl = '?Security=ssl' # Instead of 'ssl=True' - # else: - # # ssl = f"ssl={credentials['ssl']}" # I.e. 'ssl=True' or 'ssl=False' - # ssl = '' # For some weird reason, the string `ssl=False` blocks selection from return any rows!!!! - # connection_string = 'db2+ibm_db://{username}:{password}@{host}:{port}/{database}{ssl};currentSchema={schema}'.format( - # username=credentials['username'], - # password=credentials['password'], - # host=credentials['host'], - # port=credentials['port'], - # database=credentials['database'], - # ssl=ssl, - # schema=schema - # ) - # return connection_string - def _get_db2_connection_string(self, credentials, schema: str): """Create a DB2 connection string. @@ -599,31 +570,6 @@ def _drop_schema_transaction(self, schema: str, connection=None): else: r = connection.execute(sql) - ##################################################################################### - # DEPRECATED(?): `insert_scenarios_in_db` and `insert_scenarios_in_db_transaction` - ##################################################################################### - def insert_scenarios_in_db(self, inputs={}, outputs={}, bulk: bool = True): - """DEPRECATED. If we need it back, requires re-evaluation and bulk support.""" - if self.enable_transactions: - print("Inserting all tables within a transaction") - with self.engine.begin() as connection: - self._insert_scenarios_in_db_transaction(inputs=inputs, outputs=outputs, bulk=bulk, connection=connection) - else: - self._insert_scenarios_in_db_transaction(inputs=inputs, outputs=outputs, bulk=bulk) - - def _insert_scenarios_in_db_transaction(self, inputs={}, outputs={}, bulk: bool = True, connection=None): - """DEPRECATED(?) - """ - num_caught_exceptions=0 - for table_name, df in inputs.items(): - num_caught_exceptions += self._insert_table_in_db_by_row(table_name, df, connection=connection) - for table_name, df in outputs.items(): - num_caught_exceptions += self._insert_table_in_db_by_row(table_name, df, connection=connection) - # Throw exception if any exceptions caught in 'non-bulk' mode - # This will cause a rollback when using a transaction - if num_caught_exceptions > 0: - raise RuntimeError(f"Multiple ({num_caught_exceptions}) Integrity and/or Statement errors caught. See log. Raising exception to allow for rollback.") - ############################################################################################ # Insert/replace scenario ############################################################################################ @@ -654,23 +600,14 @@ def _replace_scenario_in_db_transaction(self, connection, scenario_name: str, in Will set/overwrite the scenario_name in all dfs, so no need to add in advance. Assumes schema has been created. Note: there is no difference between dfs in inputs or outputs, i.e. they are inserted the same way. - - TODO: break-out in a delete and an insert. Then we can re-use the insert for the duplicate API """ # Step 1: delete scenario if exists self._delete_scenario_from_db(scenario_name, connection=connection) # Step 2: insert scenario_name in scenario table and get scenario_seq scenario_seq = self._get_or_create_scenario_in_scenario_table(scenario_name, connection) - # Step 3: add scenario_name to all dfs inputs = ScenarioDbManager.add_scenario_seq_to_dfs(scenario_seq, inputs) outputs = ScenarioDbManager.add_scenario_seq_to_dfs(scenario_seq, outputs) - - # # Step 3: insert scenario_name in scenario table - # sa_scenario_table = self.get_scenario_db_table().get_sa_table() - # sql_insert = sa_scenario_table.insert().values(scenario_name = scenario_name) - # connection.execute(sql_insert) - # Step 4: (bulk) insert scenario num_caught_exceptions = self._insert_single_scenario_tables_in_db(inputs=inputs, outputs=outputs, bulk=bulk, connection=connection) # Throw exception if any exceptions caught in 'non-bulk' mode @@ -743,36 +680,6 @@ def _insert_table_in_db_by_row(self, db_table: ScenarioDbTable, df: pd.DataFrame break return num_exceptions - def insert_tables_in_db(self, inputs: Inputs = {}, outputs: Outputs = {}, - bulk: bool = True, auto_insert: bool = False, connection=None) -> int: - """DEPRECATED. - Was attempt to automatically insert a scenario without any schema definition. - Currently, one would need to use the AutoScenarioDbTable in the constructor. - If you want to automatically create such schema based on the inputs/outputs, then do that in the constructor. Not here. - Note: the non-bulk ONLY works if the schema was created! I.e. only when using with self.create_schema. - """ - dfs = {**inputs, **outputs} # Combine all dfs in one dict - completed_dfs = [] - num_caught_exceptions=0 - for scenario_table_name, db_table in self.db_tables.items(): - if scenario_table_name in dfs: - completed_dfs.append(scenario_table_name) - if bulk: - # self.insert_table_in_db_bulk(db_table, dfs[scenario_table_name]) - db_table.insert_table_in_db_bulk(dfs[scenario_table_name], self, connection=connection) - else: # Row by row for data checking - num_caught_exceptions += self._insert_table_in_db_by_row(db_table, dfs[scenario_table_name], connection=connection) - else: - print(f"No table named {scenario_table_name} in inputs or outputs") - # Insert any tables not defined in the schema: - if auto_insert: - for scenario_table_name, df in dfs.items(): - if scenario_table_name not in completed_dfs: - print(f"Table {scenario_table_name} auto inserted") - db_table = AutoScenarioDbTable(scenario_table_name) - db_table.insert_table_in_db_bulk(df, self, connection=connection) - return num_caught_exceptions - ############################################################################################ # Read scenario ############################################################################################ @@ -782,10 +689,13 @@ def get_scenarios_df(self) -> pd.DataFrame: The API called by a cached procedure in the dse_do_dashboard.DoDashApp. """ # sql = f"SELECT * FROM SCENARIO" - sa_scenario_table = list(self.input_db_tables.values())[0].table_metadata + # sa_scenario_table = list(self.input_db_tables.values())[0].table_metadata + sa_scenario_table = self.get_scenario_sa_table() sql = sa_scenario_table.select() if self.enable_transactions: with self.engine.begin() as connection: + # TODO: Still index by scenario_name, or by scenario_seq? By name keeps it backward compatible. + # But there is a theoretical risk of duplicates df = pd.read_sql(sql, con=connection).set_index(['scenario_name']) else: df = pd.read_sql(sql, con=self.engine).set_index(['scenario_name']) @@ -815,41 +725,6 @@ def read_scenario_table_from_db(self, scenario_name: str, scenario_table_name: s return df - # def read_scenario_from_db(self, scenario_name: str) -> (Inputs, Outputs): - # """Single scenario load. - # Main API to read a complete scenario. - # Reads all tables for a single scenario. - # Returns all tables in one dict""" - # inputs = {} - # for scenario_table_name, db_table in self.input_db_tables.items(): - # inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table) - # - # outputs = {} - # for scenario_table_name, db_table in self.output_db_tables.items(): - # outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table) - # - # return inputs, outputs - - - # def _read_scenario_from_db(self, scenario_name: str, connection) -> (Inputs, Outputs): - # """Single scenario load. - # Main API to read a complete scenario. - # Reads all tables for a single scenario. - # Returns all tables in one dict - # """ - # inputs = {} - # for scenario_table_name, db_table in self.input_db_tables.items(): - # # print(f"scenario_table_name = {scenario_table_name}") - # if scenario_table_name != 'Scenario': # Skip the Scenario table as an input - # inputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) - # - # outputs = {} - # for scenario_table_name, db_table in self.output_db_tables.items(): - # outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) - # # if scenario_table_name == 'kpis': - # # # print(f"kpis table columns = {outputs[scenario_table_name].columns}") - # # outputs[scenario_table_name] = outputs[scenario_table_name].rename(columns={'name': 'NAME'}) #HACK!!!!! - # return inputs, outputs def read_scenario_from_db(self, scenario_name: str, multi_threaded: bool = False) -> (Inputs, Outputs): """Single scenario load. Main API to read a complete scenario. @@ -964,28 +839,16 @@ def _read_scenario_tables_from_db(self, connection, scenario_name: str, outputs[scenario_table_name] = self._read_scenario_db_table_from_db(scenario_name, db_table, connection=connection) return inputs, outputs - # def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: ScenarioDbTable) -> pd.DataFrame: - # """Read one table from the DB. - # Removes the `scenario_name` column.""" - # db_table_name = db_table.db_table_name - # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" - # df = pd.read_sql(sql, con=self.engine) - # if db_table_name != 'scenario': - # df = df.drop(columns=['scenario_name']) - # - # return df def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: ScenarioDbTable, connection) -> pd.DataFrame: """Read one table from the DB. - Removes the `scenario_name` column. - - Modification: based on SQLAlchemy syntax. If doing the plain text SQL, then some column names not properly extracted + Removes the `scenario_seq` column. """ db_table_name = db_table.db_table_name # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" # Old # db_table.table_metadata is a Table() s: sqlalchemy.Table = self.get_scenario_sa_table() - t: sqlalchemy.Table = db_table.get_sa_table() #table_metadata - sql = t.select().where(t.c.scenario_seq == s.c.scenario_seq).where(s.c.scenario_name == scenario_name) # This is NOT a simple string! + t: sqlalchemy.Table = db_table.get_sa_table() + sql = t.select().where(t.c.scenario_seq == s.c.scenario_seq).where(s.c.scenario_name == scenario_name) df = pd.read_sql(sql, con=connection) if db_table_name != 'scenario': df = df.drop(columns=['scenario_seq']) @@ -1044,7 +907,17 @@ def _read_multi_scenario_db_table_from_db(self, scenario_names: List[str], db_ta Does NOT remove the `scenario_name` column. """ t: sqlalchemy.Table = db_table.get_sa_table() #table_metadata - sql = t.select().where(t.c.scenario_name.in_(scenario_names)) # This is NOT a simple string! + # sql = t.select().where(t.c.scenario_name.in_(scenario_names)) # This is NOT a simple string! + + s = self.get_scenario_sa_table() + # TODO: Test of we can do below query in one select (option 1), joining the scenario table, instead of separate selects (option 2) + # Option 1: do in one query: + sql = t.select().where(t.c.scenario_seq == s.c.scenario_seq).where(s.c.scenario_name.in_(scenario_names)) + + # Option2: If not, we can do in 2 selects + # scenario_seqs = [r.scenario_seq for r in connection.execute(s.select().where(s.c.scenario_name.in_(scenario_names)))] + # sql = t.select().where(t.c.scenario_seq.in_(scenario_seqs)) + df = pd.read_sql(sql, con=connection) return df @@ -1072,18 +945,13 @@ def _update_cell_changes_in_db(self, db_cell_updates: List[DbCellUpdate], connec def _update_cell_change_in_db(self, db_cell_update: DbCellUpdate, connection): """Update a single value (cell) change in the DB.""" - # db_table_name = self.db_tables[db_cell_update.table_name].db_table_name - # column_change = f"{db_cell_update.column_name} = '{db_cell_update.current_value}'" - # scenario_condition = f"scenario_name = '{db_cell_update.scenario_name}'" - # pk_conditions = ' AND '.join([f"{pk['column']} = '{pk['value']}'" for pk in db_cell_update.row_index]) - # old_sql = f"UPDATE {db_table_name} SET {column_change} WHERE {pk_conditions} AND {scenario_condition};" - db_table: ScenarioDbTable = self.db_tables[db_cell_update.table_name] t: sqlalchemy.Table = db_table.get_sa_table() pk_conditions = [(db_table.get_sa_column(pk['column']) == pk['value']) for pk in db_cell_update.row_index] target_col: sqlalchemy.Column = db_table.get_sa_column(db_cell_update.column_name) print(f"_update_cell_change_in_db - target_col = {target_col} for db_cell_update.column_name={db_cell_update.column_name}, pk_conditions={pk_conditions}") - sql = t.update().where(sqlalchemy.and_((t.c.scenario_name == db_cell_update.scenario_name), *pk_conditions)).values({target_col:db_cell_update.current_value}) + # sql = t.update().where(sqlalchemy.and_((t.c.scenario_name == db_cell_update.scenario_name), *pk_conditions)).values({target_col:db_cell_update.current_value}) + sql = t.update().where(sqlalchemy.and_((t.c.scenario_seq == db_cell_update.scenario_seq), *pk_conditions)).values({target_col:db_cell_update.current_value}) # print(f"_update_cell_change_in_db = {sql}") connection.execute(sql) @@ -1185,9 +1053,6 @@ def _duplicate_scenario_in_db(self, connection, source_scenario_name: str, targe else: raise ValueError(f"Target name for duplicate scenario '{target_scenario_name}' already exists.") - # inputs, outputs = self.read_scenario_from_db(source_scenario_name) - # self._replace_scenario_in_db_transaction(scenario_name=new_scenario_name, inputs=inputs, outputs=outputs, - # bulk=True, connection=connection) self._duplicate_scenario_in_db_sql(connection, source_scenario_name, new_scenario_name) def _duplicate_scenario_in_db_sql(self, connection, source_scenario_name: str, target_scenario_name: str = None): @@ -1217,45 +1082,30 @@ def _duplicate_scenario_in_db_sql(self, connection, source_scenario_name: str, t else: raise ValueError(f"Target name for duplicate scenario '{target_scenario_name}' already exists.") - batch_sql=False # BEWARE: batch = True does NOT work! - sql_statements = [] + # TODO: TEST # 1. Insert scenario in scenario table - # sql_insert = f"INSERT INTO SCENARIO (scenario_name) VALUES ('{new_scenario_name}')" # Old SQL - # sa_scenario_table = list(self.input_db_tables.values())[0].get_sa_table() # Scenario table must be the first - sa_scenario_table = self.get_scenario_db_table().get_sa_table() - sql_insert = sa_scenario_table.insert().values(scenario_name = new_scenario_name) - # print(f"_duplicate_scenario_in_db_sql - Insert SQL = {sql_insert}") - if batch_sql: - sql_statements.append(sql_insert) - else: - connection.execute(sql_insert) + source_scenario_seq = self._get_or_create_scenario_in_scenario_table(source_scenario_name, connection) + new_scenario_seq = self._get_or_create_scenario_in_scenario_table(new_scenario_name, connection) # 2. Do 'insert into select' to duplicate rows in each table + s: sqlalchemy.table = self.get_scenario_sa_table() for scenario_table_name, db_table in self.db_tables.items(): if scenario_table_name == 'Scenario': continue t: sqlalchemy.table = db_table.table_metadata # The table at hand - s: sqlalchemy.table = sa_scenario_table # The scenario table # print("+++++++++++SQLAlchemy insert-select") - select_columns = [s.c.scenario_name if c.name == 'scenario_name' else c for c in t.columns] # Replace the t.c.scenario_name with s.c.scenario_name, so we get the new value + select_columns = [s.c.scenario_seq if c.name == 'scenario_seq' else c for c in t.columns] # Replace the t.c.scenario_name with s.c.scenario_name, so we get the new value # print(f"select columns = {select_columns}") select_sql = (sqlalchemy.select(select_columns) - .where(sqlalchemy.and_(t.c.scenario_name == source_scenario_name, s.c.scenario_name == target_scenario_name))) + .where(sqlalchemy.and_(t.c.scenario_seq == source_scenario_seq, s.c.scenario_seq == new_scenario_seq))) target_columns = [c for c in t.columns] sql_insert = t.insert().from_select(target_columns, select_sql) # print(f"sql_insert = {sql_insert}") # sql_insert = f"INSERT INTO {db_table.db_table_name} ({target_columns_txt}) SELECT '{target_scenario_name}',{other_source_columns_txt} FROM {db_table.db_table_name} WHERE scenario_name = '{source_scenario_name}'" - if batch_sql: - sql_statements.append(sql_insert) - else: - connection.execute(sql_insert) - if batch_sql: - batch_sql = ";\n".join(sql_statements) - print(batch_sql) - connection.execute(batch_sql) + connection.execute(sql_insert) def _find_free_duplicate_scenario_name(self, scenario_name: str, scenarios_df=None) -> Optional[str]: """Finds next free scenario name based on pattern '{scenario_name}_copy_n'. @@ -1301,6 +1151,10 @@ def _rename_scenario_in_db_sql(self, connection, source_scenario_name: str, targ Use of 'insert into select': https://stackoverflow.com/questions/9879830/select-modify-and-insert-into-the-same-table """ + # TODO: just update the scenario_name: + # 1. Get the scenario_seq + # 2. Update the name + # 1. Duplicate scenario self._duplicate_scenario_in_db_sql(connection, source_scenario_name, target_scenario_name) # 2. Delete scenario @@ -1363,196 +1217,9 @@ def insert_scenarios_from_zip(self, filepath: str): else: print(f"File '{info.filename}' in zip is not a .xlsx. Skipped.") - ############################################################################################ - # Old Read scenario APIs - ############################################################################################ - # def read_scenario_table_from_db(self, scenario_name: str, scenario_table_name: str) -> pd.DataFrame: - # """Read a single table from the DB. - # The API called by a cached procedure in the dse_do_dashboard.DoDashApp. - # - # :param scenario_name: Name of scenario - # :param scenario_table_name: Name of scenario table (not the DB table name) - # :return: - # """ - # # print(f"read table {scenario_table_name}") - # if scenario_table_name in self.input_db_tables: - # db_table = self.input_db_tables[scenario_table_name] - # elif scenario_table_name in self.output_db_tables: - # db_table = self.output_db_tables[scenario_table_name] - # else: - # # error! - # raise ValueError(f"Scenario table name '{scenario_table_name}' unknown. Cannot load data from DB.") - # - # db_table_name = db_table.db_table_name - # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" - # df = pd.read_sql(sql, con=self.engine) - # if db_table_name != 'scenario': - # df = df.drop(columns=['scenario_name']) - # - # return df - - # def read_scenario_from_db(self, scenario_name: str) -> (Inputs, Outputs): - # """Single scenario load. - # Reads all tables for a single scenario. - # Returns all tables in one dict""" - # inputs = {} - # for scenario_table_name, db_table in self.input_db_tables.items(): - # db_table_name = db_table.db_table_name - # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" - # df = pd.read_sql(sql, con=self.engine) - # # print(db_table_name) - # inputs[scenario_table_name] = df - # - # outputs = {} - # for scenario_table_name, db_table in self.output_db_tables.items(): - # db_table_name = db_table.db_table_name - # sql = f"SELECT * FROM {db_table_name} WHERE scenario_name = '{scenario_name}'" - # df = pd.read_sql(sql, con=self.engine) - # # print(db_table_name) - # outputs[scenario_table_name] = df - # - # inputs, outputs = ScenarioDbManager.delete_scenario_name_column(inputs, outputs) - # return inputs, outputs - - ####################################################################################################### - # Caching - # How it works: - # Setup: - # 1. DoDashApp defines a procedure `read_xxxx_proc` - # 2. DoDashApp applies Flask caching to procedure - # 3. DoDashApp registers the procedure as a callback in the ScenarioDbManager.read_xxx_callback using `dbm.set_xxx_callback(read_xxx_callback)` - # Operationally (where dbm is a ScenarioDbManager): - # 1. In the DoDashApp, call to `dbm.read_xxxx_cached()` - # 2. In the `ScenarioDbManager.read_xxxx_cached()` calls the cached callback procedure defined in the DoDashApp (i.e. `read_xxxx_proc`) - # 3. The cached procedure calls `dbm.read_xxxx()` - # - # TODO: why can't the DoDashApp call the `read_xxxx_proc` directly. This would avoid all this registration of callbacks - # TODO: migrate all of this caching and callbacks (if applicable) to the DoDashApp to reduce complexity and dependency - ####################################################################################################### - # ScenarioTable - def set_scenarios_table_read_callback(self, scenarios_table_read_callback=None): - """DEPRECATED - now in DoDashApp - Sets a callback function to read the scenario table from the DB - """ - self.read_scenarios_table_from_db_callback = scenarios_table_read_callback - - def read_scenarios_table_from_db_cached(self) -> pd.DataFrame: - """DEPRECATED - now in DoDashApp - For use with Flask caching. Default implementation. - To be called from (typically) a Dash app to use the cached version. - In case no caching has been configured. Simply calls the regular method `get_scenarios_df`. - - For caching: - 1. Specify a callback procedure in `read_scenarios_table_from_db_callback` that uses a hard-coded version of a ScenarioDbManager, - which in turn calls the regular method `get_scenarios_df` - """ - if self.read_scenarios_table_from_db_callback is not None: - df = self.read_scenarios_table_from_db_callback() # NOT a method! - else: - df = self.get_scenarios_df() - return df - - # Tables - def set_table_read_callback(self, table_read_callback=None): - """DEPRECATED - now in DoDashApp - Sets a callback function to read a table from a scenario - """ - # print(f"Set callback to {table_read_callback}") - self.read_scenario_table_from_db_callback = table_read_callback - - def read_scenario_table_from_db_cached(self, scenario_name: str, scenario_table_name: str) -> pd.DataFrame: - """DEPRECATED - now in DoDashApp - For use with Flask caching. Default implementation. - In case no caching has been configured. Simply calls the regular method `read_scenario_table_from_db`. - - For caching: - 1. Specify a callback procedure in `read_scenario_table_from_db_callback` that uses a hard-coded version of a ScenarioDbManager, - which in turn calls the regular method `read_scenario_table_from_db` - """ - # 1. Override this method and call a procedure that uses a hard-coded version of a ScenarioDbManager, - # which in turn calls the regular method `read_scenario_table_from_db` - - # return self.read_scenario_table_from_db(scenario_name, scenario_table_name) - if self.read_scenario_table_from_db_callback is not None: - df = self.read_scenario_table_from_db_callback(scenario_name, scenario_table_name) # NOT a method! - else: - df = self.read_scenario_table_from_db(scenario_name, scenario_table_name) - return df - - def read_scenario_tables_from_db_cached(self, scenario_name: str, - input_table_names: List[str] = None, - output_table_names: List[str] = None) -> (Inputs, Outputs): - """DEPRECATED - now in DoDashApp - For use with Flask caching. Loads data for selected input and output tables. - Same as `read_scenario_tables_from_db`, but calls `read_scenario_table_from_db_cached`. - Is called from dse_do_dashboard.DoDashApp to create the PlotlyManager.""" - - if input_table_names is None: # load all tables by default - input_table_names = list(self.input_db_tables.keys()) - if 'Scenario' in input_table_names: input_table_names.remove('Scenario') # Remove the scenario table - if output_table_names is None: # load all tables by default - output_table_names = self.output_db_tables.keys() - - inputs = {} - for scenario_table_name in input_table_names: - # print(f"read input table {scenario_table_name}") - inputs[scenario_table_name] = self.read_scenario_table_from_db_cached(scenario_name, scenario_table_name) - - outputs = {} - for scenario_table_name in output_table_names: - # print(f"read output table {scenario_table_name}") - outputs[scenario_table_name] = self.read_scenario_table_from_db_cached(scenario_name, scenario_table_name) - return inputs, outputs - - ####################################################################################################### - # Review - ####################################################################################################### - - - def read_scenarios_from_db(self, scenario_names: List[str] = []) -> (Inputs, Outputs): - """DEPRECATED. Multi scenario load. - Reads all tables from set of scenarios - TODO: avoid use of text SQL. Use SQLAlchemy sql generation. - """ - where_scenarios = ','.join([f"'{n}'" for n in scenario_names]) - - inputs = {} - for scenario_table_name, db_table in self.input_db_tables.items(): - db_table_name = db_table.db_table_name - sql = f"SELECT * FROM {db_table_name} WHERE scenario_name in ({where_scenarios})" - # print(sql) - df = pd.read_sql(sql, con=self.engine) - # print(db_table_name) - inputs[scenario_table_name] = df - print(f"Read {df.shape[0]} rows and {df.shape[1]} columns into {scenario_table_name}") - - outputs = {} - for scenario_table_name, db_table in self.output_db_tables.items(): - db_table_name = db_table.db_table_name - sql = f"SELECT * FROM {db_table_name} WHERE scenario_name in ({where_scenarios})" - # print(sql) - df = pd.read_sql(sql, con=self.engine) - # print(db_table_name) - outputs[scenario_table_name] = df - print(f"Read {df.shape[0]} rows and {df.shape[1]} columns into {scenario_table_name}") - - return inputs, outputs - ####################################################################################################### # Utils ####################################################################################################### - @staticmethod - def add_scenario_name_to_dfs(scenario_name: str, inputs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: - """DEPRECATED - Adds a `scenario_name` column to each df. - Or overwrites all values of that column already exists. - This avoids to need for the MultiScenarioManager when loading and storing single scenarios.""" - outputs = {} - for scenario_table_name, df in inputs.items(): - df['scenario_name'] = scenario_name - outputs[scenario_table_name] = df - return outputs - @staticmethod def add_scenario_seq_to_dfs(scenario_seq: int, inputs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]: """Adds a `scenario_seq` column to each df. @@ -1564,24 +1231,6 @@ def add_scenario_seq_to_dfs(scenario_seq: int, inputs: Dict[str, pd.DataFrame]) outputs[scenario_table_name] = df return outputs - @staticmethod - def delete_scenario_name_column(inputs: Inputs, outputs: Outputs) -> (Inputs, Outputs): - """DEPRECATED - Drops the column `scenario_name` from any df in either inputs or outputs. - This is used to create a inputs/outputs combination similar to loading a single scenario from the DO Experiment. - """ - new_inputs = {} - new_outputs = {} - for scenario_table_name, df in inputs.items(): - if 'scenario_name' in df.columns: - df = df.drop(columns=['scenario_name']) - new_inputs[scenario_table_name] = df - for scenario_table_name, df in outputs.items(): - if 'scenario_name' in df.columns: - df = df.drop(columns=['scenario_name']) - new_outputs[scenario_table_name] = df - return new_inputs, new_outputs - @staticmethod def delete_scenario_seq_column(inputs: Inputs, outputs: Outputs) -> (Inputs, Outputs): """Drops the column `scenario_seq` from any df in either inputs or outputs. @@ -1607,7 +1256,7 @@ class ScenarioTable(ScenarioDbTable): def __init__(self, db_table_name: str = 'scenario'): columns_metadata = [ Column('scenario_seq', Integer(), autoincrement=True, primary_key=True), - Column('scenario_name', String(256), primary_key=False), + Column('scenario_name', String(256), primary_key=False, nullable=False), # TODO: should we add a 'unique' constraint on the name? ] super().__init__(db_table_name, columns_metadata) From aa1370bdaa40f912d2cf6bf0c77f931a64978eb5 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Mon, 7 Mar 2022 23:27:20 -0500 Subject: [PATCH 04/39] Scenario rename --- dse_do_utils/scenariodbmanager2.py | 57 ++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py index 278de3d..1f487e9 100644 --- a/dse_do_utils/scenariodbmanager2.py +++ b/dse_do_utils/scenariodbmanager2.py @@ -28,7 +28,7 @@ from typing import Dict, List, NamedTuple, Any, Optional from collections import OrderedDict import re -from sqlalchemy import exc, MetaData +from sqlalchemy import exc, MetaData, select from sqlalchemy import Table, Column, String, Integer, Float, ForeignKey, ForeignKeyConstraint # Typing aliases @@ -946,15 +946,21 @@ def _update_cell_changes_in_db(self, db_cell_updates: List[DbCellUpdate], connec def _update_cell_change_in_db(self, db_cell_update: DbCellUpdate, connection): """Update a single value (cell) change in the DB.""" db_table: ScenarioDbTable = self.db_tables[db_cell_update.table_name] + s = self.get_scenario_sa_table() t: sqlalchemy.Table = db_table.get_sa_table() pk_conditions = [(db_table.get_sa_column(pk['column']) == pk['value']) for pk in db_cell_update.row_index] target_col: sqlalchemy.Column = db_table.get_sa_column(db_cell_update.column_name) print(f"_update_cell_change_in_db - target_col = {target_col} for db_cell_update.column_name={db_cell_update.column_name}, pk_conditions={pk_conditions}") - # sql = t.update().where(sqlalchemy.and_((t.c.scenario_name == db_cell_update.scenario_name), *pk_conditions)).values({target_col:db_cell_update.current_value}) - sql = t.update().where(sqlalchemy.and_((t.c.scenario_seq == db_cell_update.scenario_seq), *pk_conditions)).values({target_col:db_cell_update.current_value}) + + if scenario_seq := self._get_scenario_seq(db_cell_update.scenario_name, connection) is not None: + sql = t.update().where(sqlalchemy.and_((t.c.scenario_seq == scenario_seq), *pk_conditions)).values({target_col:db_cell_update.current_value}) + connection.execute(sql) + else: + # Error? + pass # print(f"_update_cell_change_in_db = {sql}") - connection.execute(sql) + ############################################################################################ # Update/Replace tables in scenario @@ -1151,14 +1157,20 @@ def _rename_scenario_in_db_sql(self, connection, source_scenario_name: str, targ Use of 'insert into select': https://stackoverflow.com/questions/9879830/select-modify-and-insert-into-the-same-table """ - # TODO: just update the scenario_name: + # Just update the scenario_name: # 1. Get the scenario_seq # 2. Update the name + s = self.get_scenario_sa_table() + scenario_seq = self._get_scenario_seq(source_scenario_name, connection) + if scenario_seq is not None: + print(f"Rename scenario name = {source_scenario_name}, seq = {scenario_seq}") + sql = s.update().where(s.c.scenario_seq == scenario_seq).values({s.c.scenario_name: target_scenario_name}) + connection.execute(sql) - # 1. Duplicate scenario - self._duplicate_scenario_in_db_sql(connection, source_scenario_name, target_scenario_name) - # 2. Delete scenario - self._delete_scenario_from_db(source_scenario_name, connection=connection) + # # 1. Duplicate scenario + # self._duplicate_scenario_in_db_sql(connection, source_scenario_name, target_scenario_name) + # # 2. Delete scenario + # self._delete_scenario_from_db(source_scenario_name, connection=connection) def _delete_scenario_from_db(self, scenario_name: str, connection): """Deletes all rows associated with a given scenario. @@ -1180,16 +1192,33 @@ def _get_or_create_scenario_in_scenario_table(self, scenario_name: str, connecti """Returns the scenario_seq of (the first) entry matching the scenario_name. If it doesn't exist, will insert a new entry. """ - s = self.get_scenario_sa_table() - r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name == scenario_name)) - if (r is not None) and ((first := r.first()) is not None): # Walrus operator! - seq = first[0] - else: + # s = self.get_scenario_sa_table() + # r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name == scenario_name)) + # if (r is not None) and ((first := r.first()) is not None): # Walrus operator! + # seq = first[0] + # else: + + seq = self._get_scenario_seq(scenario_name, connection) + if seq is None: + s = self.get_scenario_sa_table() connection.execute(s.insert().values(scenario_name=scenario_name)) r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name==scenario_name)) seq = r.first()[0] return seq + def _get_scenario_seq(self, scenario_name: str, connection) -> Optional[int]: + """Returns the scenario_seq of (the first) entry matching the scenario_name. + """ + s = self.get_scenario_sa_table() + # r = connection.execute(s.select(s.c.scenario_seq).where(s.c.scenario_name == scenario_name)) + r = connection.execute(s.select().where(s.c.scenario_name == scenario_name)) + if (r is not None) and ((first := r.first()) is not None): # Walrus operator! + # print(f"_get_scenario_seq: r={first}") + seq = first[0] # Tuple with values. First (0) is the scenario_seq. TODO: do more structured so we can be sure it is the scenario_seq! + else: + seq = None + return seq + ############################################################################################ # Import from zip From a2bb3191563538ebee5c376f6de92a1b1a773273 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Tue, 8 Mar 2022 21:09:31 -0500 Subject: [PATCH 05/39] Added warning for reserved word --- dse_do_utils/scenariodbmanager2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py index 1f487e9..a0831dc 100644 --- a/dse_do_utils/scenariodbmanager2.py +++ b/dse_do_utils/scenariodbmanager2.py @@ -64,7 +64,7 @@ def __init__(self, db_table_name: str, columns_metadata: List[sqlalchemy.Column] if not db_table_name.islower() and not db_table_name.isupper(): ## I.e. is mixed_case print(f"Warning: using mixed case in the db_table_name {db_table_name} may cause unexpected DB errors. Use lower-case only.") - reserved_table_names = ['order', 'parameter'] # TODO: add more reserved words for table names + reserved_table_names = ['order', 'parameter', 'group'] # TODO: add more reserved words for table names if db_table_name in reserved_table_names: print(f"Warning: the db_table_name '{db_table_name}' is a reserved word. Do not use as table name.") self._sa_column_by_name = None # Dict[str, sqlalchemy.Column] Will be generated dynamically first time it is needed. From ebc22972aa8306a1c1ed6e44b14d8b134e809bb8 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Tue, 8 Mar 2022 21:10:32 -0500 Subject: [PATCH 06/39] Added warning for reserved word --- dse_do_utils/scenariodbmanager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dse_do_utils/scenariodbmanager.py b/dse_do_utils/scenariodbmanager.py index a1778c0..12b91d4 100644 --- a/dse_do_utils/scenariodbmanager.py +++ b/dse_do_utils/scenariodbmanager.py @@ -64,7 +64,7 @@ def __init__(self, db_table_name: str, columns_metadata: List[sqlalchemy.Column] if not db_table_name.islower() and not db_table_name.isupper(): ## I.e. is mixed_case print(f"Warning: using mixed case in the db_table_name {db_table_name} may cause unexpected DB errors. Use lower-case only.") - reserved_table_names = ['order', 'parameter'] # TODO: add more reserved words for table names + reserved_table_names = ['order', 'parameter', 'group'] # TODO: add more reserved words for table names if db_table_name in reserved_table_names: print(f"Warning: the db_table_name '{db_table_name}' is a reserved word. Do not use as table name.") self._sa_column_by_name = None # Dict[str, sqlalchemy.Column] Will be generated dynamically first time it is needed. From 9089194280f661b5f2ac68487880ddcf5a075314 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Wed, 9 Mar 2022 23:02:15 -0500 Subject: [PATCH 07/39] drop tables experiments --- dse_do_utils/scenariodbmanager.py | 7 +++++++ dse_do_utils/scenariodbmanager2.py | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/dse_do_utils/scenariodbmanager.py b/dse_do_utils/scenariodbmanager.py index 12b91d4..0e7a99a 100644 --- a/dse_do_utils/scenariodbmanager.py +++ b/dse_do_utils/scenariodbmanager.py @@ -559,6 +559,13 @@ def _drop_all_tables_transaction(self, connection): See: https://stackoverflow.com/questions/35918605/how-to-delete-a-table-in-sqlalchemy) See https://docs.sqlalchemy.org/en/14/core/metadata.html#sqlalchemy.schema.MetaData.drop_all """ + # # Would this work?: + # print("+++++++++++++++++Reflect+++++++++++++++++") + # NOte: the reflect seems to mess things up: when doing the next drop_all we're getting weird errors + # self.metadata.reflect(bind=connection) # To reflect any tables in the DB, but not in the current schema + # print("+++++++++++++++++Drop all tables+++++++++++++++++") + # self.metadata.drop_all(bind=connection) + for scenario_table_name, db_table in reversed(self.db_tables.items()): db_table_name = db_table.db_table_name sql = f"DROP TABLE IF EXISTS {db_table_name}" diff --git a/dse_do_utils/scenariodbmanager2.py b/dse_do_utils/scenariodbmanager2.py index a0831dc..e359637 100644 --- a/dse_do_utils/scenariodbmanager2.py +++ b/dse_do_utils/scenariodbmanager2.py @@ -317,7 +317,7 @@ class ScenarioDbManager(): def __init__(self, input_db_tables: Dict[str, ScenarioDbTable], output_db_tables: Dict[str, ScenarioDbTable], credentials=None, schema: str = None, echo: bool = False, multi_scenario: bool = True, - enable_transactions: bool = True, enable_sqlite_fk: bool = True): + enable_transactions: bool = True, enable_sqlite_fk: bool = True, enable_scenario_seq: bool = False): """Create a ScenarioDbManager. :param input_db_tables: OrderedDict[str, ScenarioDbTable] of name and sub-class of ScenarioDbTable. Need to be in correct order. @@ -334,6 +334,7 @@ def __init__(self, input_db_tables: Dict[str, ScenarioDbTable], output_db_tables self.multi_scenario = multi_scenario # If true, will add a primary key 'scenario_name' to each table self.enable_transactions = enable_transactions self.enable_sqlite_fk = enable_sqlite_fk + self.enable_scenario_seq = enable_scenario_seq self.echo = echo self.input_db_tables = self._add_scenario_db_table(input_db_tables) self.output_db_tables = output_db_tables @@ -548,6 +549,10 @@ def _drop_all_tables_transaction(self, connection): See: https://stackoverflow.com/questions/35918605/how-to-delete-a-table-in-sqlalchemy) See https://docs.sqlalchemy.org/en/14/core/metadata.html#sqlalchemy.schema.MetaData.drop_all """ + # # Would this work?: + # self.metadata.reflect(bind=connection) # To reflect any tables in the DB, but not in the current schema + # self.metadata.drop_all(bind=connection) + for scenario_table_name, db_table in reversed(self.db_tables.items()): db_table_name = db_table.db_table_name sql = f"DROP TABLE IF EXISTS {db_table_name}" From e0067584f66612b75804ad0a22b78c51f6bac682 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Thu, 28 Apr 2022 18:47:36 -0400 Subject: [PATCH 08/39] Create 0.5.4.5b --- dse_do_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dse_do_utils/version.py b/dse_do_utils/version.py index 7a9311b..154c272 100644 --- a/dse_do_utils/version.py +++ b/dse_do_utils/version.py @@ -9,4 +9,4 @@ See https://stackoverflow.com/questions/458550/standard-way-to-embed-version-into-python-package """ -__version__ = "0.5.4.4" +__version__ = "0.5.4.5b" From a1875a198c4a6a6c79fa5c66637ce85a9136baba Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Mon, 29 Aug 2022 21:20:20 -0400 Subject: [PATCH 09/39] ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. --- CHANGELOG.md | 1 + dse_do_utils/scenariodbmanager.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9457a4a..530de70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased]## [0.5.4.5b0] ### Changed +- ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. ## [0.5.4.4] - 2022-04-28 ### Added diff --git a/dse_do_utils/scenariodbmanager.py b/dse_do_utils/scenariodbmanager.py index 6f69e66..77b8eee 100644 --- a/dse_do_utils/scenariodbmanager.py +++ b/dse_do_utils/scenariodbmanager.py @@ -152,6 +152,8 @@ def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None mgr (ScenarioDbManager) connection: if not None, being run within a transaction enable_astype: if True, apply df.column.astype based on datatypes extracted from columns_metadata (i.e. sqlachemy.Column) + + TODO VT_20220814: allow for optional columns not present in df: only insert intersection of columns """ if connection is None: connection = mgr.engine @@ -169,6 +171,10 @@ def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None df = self._set_df_column_types(df) try: + # TODO: try Jihyoung: replace NaN with + # df.replace({float('NaN'): None}) + # df = df[columns].replace({float('NaN'): None}) + # df.to_sql..... df[columns].to_sql(table_name, schema=mgr.schema, con=connection, if_exists='append', dtype=None, index=False) except exc.IntegrityError as e: @@ -758,6 +764,8 @@ def _insert_table_in_db_by_row(self, db_table: ScenarioDbTable, df: pd.DataFrame To avoid too many exceptions, the number of exceptions per table is limited to 10. After the limit, the insert will be terminated. And the next table will be inserted. Note that as a result of terminating a table insert, it is very likely it will cause FK issues in subsequent tables. + + TODO VT_20220814: Allow for optional columns in DataFrame: do not try to insert all columns. Select intersection of columns. Let DB deal with nullable constraint """ num_exceptions = 0 max_num_exceptions = 10 From 1a3f4d91724ff5889695c98c2490b463f757af1a Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Fri, 2 Sep 2022 16:54:04 -0400 Subject: [PATCH 10/39] - ScenarioDbManager.resolve_metadata_column_conflicts: resolve conflicts where a ScenarioDbTable subclass redefines a column. - ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. --- CHANGELOG.md | 1 + dse_do_utils/scenariodbmanager.py | 73 ++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530de70..9198d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased]## [0.5.4.5b0] ### Changed +- ScenarioDbManager.resolve_metadata_column_conflicts: resolve conflicts where a ScenarioDbTable subclass redefines a column. - ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. ## [0.5.4.4] - 2022-04-28 diff --git a/dse_do_utils/scenariodbmanager.py b/dse_do_utils/scenariodbmanager.py index 77b8eee..92cfa19 100644 --- a/dse_do_utils/scenariodbmanager.py +++ b/dse_do_utils/scenariodbmanager.py @@ -57,8 +57,8 @@ def __init__(self, db_table_name: str, columns_metadata: List[sqlalchemy.Column] :param constraints_metadata: """ self.db_table_name = db_table_name - # ScenarioDbTable.camel_case_to_snake_case(db_table_name) # To make sure it is a proper DB table name. Also allows us to use the scenario table name. - self.columns_metadata = columns_metadata + # ScenarioDbTable.camel_case_to_snake_case(db_table_name) # To make sure it is a proper DB table name. Also allows us to use the scenario table name. + self.columns_metadata = self.resolve_metadata_column_conflicts(columns_metadata) self.constraints_metadata = constraints_metadata self.dtype = None if not db_table_name.islower() and not db_table_name.isupper(): ## I.e. is mixed_case @@ -69,9 +69,51 @@ def __init__(self, db_table_name: str, columns_metadata: List[sqlalchemy.Column] print(f"Warning: the db_table_name '{db_table_name}' is a reserved word. Do not use as table name.") self._sa_column_by_name = None # Dict[str, sqlalchemy.Column] Will be generated dynamically first time it is needed. + def resolve_metadata_column_conflicts(self, columns_metadata: List[sqlalchemy.Column]) -> List[sqlalchemy.Column]: + columns_dict = {} + for column in reversed(columns_metadata): + if isinstance(column, sqlalchemy.Column): + if column.name in columns_dict: + print(f"Warning: Conflicts in column definition for column {column.name} in table {self.__class__}. Retained override.") + else: + columns_dict[column.name] = column + else: + print(f"Warning: Column metadata contains non-sqlalchemy in table {self.__class__}. Retained override.") + return list(reversed(columns_dict.values())) + def get_db_table_name(self) -> str: return self.db_table_name + def get_df_column_names_2(self, df: pd.DataFrame) -> (List[str], pd.DataFrame): + """Get all column names that are defined in the DB schema. + If not present in the DataFrame df, adds the missing column with all None values. + + Note 1 (VT 20220829): + Note that the `sqlalchemy.insert(db_table.table_metadata).values(row)` does NOT properly handle columns that are missing in the row. + It seems to simply truncate the columns if the row length is less than the number of columns. + It does NOT match the column names! + Thus the need to add columns, so we end up with proper None values in the row for the insert, specifying all columns in the table. + + Note 2 (VT 20220829): + Reducing the list of sqlalchemy.Column does NOT work in `sqlalchemy.insert(db_table.table_metadata).values(row)` + The db_table.table_metadata is an object, not a List[sqlalchemy.Column] + + :param df: + :return: + """ + column_names = [] + # columns_metadata = [] + for c in self.columns_metadata: + if isinstance(c, sqlalchemy.Column): + if c.name in df.columns: + column_names.append(c.name) + # columns_metadata.append(c) + else: + column_names.append(c.name) + df[c.name] = None + + return column_names, df + def get_df_column_names(self, df: pd.DataFrame) -> List[str]: """Get all column names that are both defined in the DB schema and present in the DataFrame df. @@ -152,8 +194,6 @@ def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None mgr (ScenarioDbManager) connection: if not None, being run within a transaction enable_astype: if True, apply df.column.astype based on datatypes extracted from columns_metadata (i.e. sqlachemy.Column) - - TODO VT_20220814: allow for optional columns not present in df: only insert intersection of columns """ if connection is None: connection = mgr.engine @@ -171,12 +211,8 @@ def insert_table_in_db_bulk(self, df: pd.DataFrame, mgr, connection=None df = self._set_df_column_types(df) try: - # TODO: try Jihyoung: replace NaN with - # df.replace({float('NaN'): None}) - # df = df[columns].replace({float('NaN'): None}) - # df.to_sql..... df[columns].to_sql(table_name, schema=mgr.schema, con=connection, if_exists='append', dtype=None, - index=False) + index=False) except exc.IntegrityError as e: print("++++++++++++Integrity Error+++++++++++++") print(f"DataFrame insert/append of table '{table_name}'") @@ -764,19 +800,16 @@ def _insert_table_in_db_by_row(self, db_table: ScenarioDbTable, df: pd.DataFrame To avoid too many exceptions, the number of exceptions per table is limited to 10. After the limit, the insert will be terminated. And the next table will be inserted. Note that as a result of terminating a table insert, it is very likely it will cause FK issues in subsequent tables. - - TODO VT_20220814: Allow for optional columns in DataFrame: do not try to insert all columns. Select intersection of columns. Let DB deal with nullable constraint """ num_exceptions = 0 max_num_exceptions = 10 - columns = db_table.get_df_column_names(df=df) + columns, df2 = db_table.get_df_column_names_2(df=df) # Adds missing columns with None values # print(columns) # df[columns] ensures that the order of columns in the DF matches that of the SQL table definition. If not, the insert will fail - for row in df[columns].itertuples(index=False): - # print(row) + for row in df2[columns].itertuples(index=False): + # print(row) stmt = ( - sqlalchemy.insert(db_table.table_metadata). - values(row) + sqlalchemy.insert(db_table.table_metadata).values(row) ) try: if connection is None: @@ -1050,8 +1083,8 @@ def _read_scenario_db_table_from_db(self, scenario_name: str, db_table: Scenario # Read multi scenario ############################################################################################ def read_multi_scenario_tables_from_db(self, scenario_names: List[str], - input_table_names: Optional[List[str]] = None, - output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): + input_table_names: Optional[List[str]] = None, + output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): """Read selected set input and output tables from multiple scenarios. If input_table_names/output_table_names contains a '*', then all input/output tables will be read. If empty list or None, then no tables will be read. @@ -1064,8 +1097,8 @@ def read_multi_scenario_tables_from_db(self, scenario_names: List[str], return inputs, outputs def _read_multi_scenario_tables_from_db(self, connection, scenario_names: List[str], - input_table_names: List[str] = None, - output_table_names: List[str] = None) -> (Inputs, Outputs): + input_table_names: List[str] = None, + output_table_names: List[str] = None) -> (Inputs, Outputs): """Loads data for selected input and output tables from multiple scenarios. If either list is names is ['*'], will load all tables as defined in db_tables configuration. """ From 528a8220eab039802f098eeb744af81aa22af7a6 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Fri, 2 Sep 2022 22:29:29 -0400 Subject: [PATCH 11/39] Improved column metadata warning message --- dse_do_utils/scenariodbmanager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dse_do_utils/scenariodbmanager.py b/dse_do_utils/scenariodbmanager.py index 92cfa19..f3498e0 100644 --- a/dse_do_utils/scenariodbmanager.py +++ b/dse_do_utils/scenariodbmanager.py @@ -74,11 +74,11 @@ def resolve_metadata_column_conflicts(self, columns_metadata: List[sqlalchemy.Co for column in reversed(columns_metadata): if isinstance(column, sqlalchemy.Column): if column.name in columns_dict: - print(f"Warning: Conflicts in column definition for column {column.name} in table {self.__class__}. Retained override.") + print(f"Warning: Conflicts in column definition for column {column.name} in table {self.__class__.__name__}. Retained override.") else: columns_dict[column.name] = column else: - print(f"Warning: Column metadata contains non-sqlalchemy in table {self.__class__}. Retained override.") + print(f"Warning: Column metadata contains non-sqlalchemy in table {self.__class__.__name__}. Retained override.") return list(reversed(columns_dict.values())) def get_db_table_name(self) -> str: From 4460cf130eccdf8fc3e0d3753da675249cbf8fe7 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Thu, 22 Sep 2022 15:08:44 -0400 Subject: [PATCH 12/39] ScenarioRunner --- CHANGELOG.md | 6 +- dse_do_utils/mapmanager.py | 3 + dse_do_utils/scenariomanager.py | 2 +- dse_do_utils/scenariorunner.py | 313 ++++++++++++++++++++++++++++++++ dse_do_utils/version.py | 2 +- 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 dse_do_utils/scenariorunner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9198d1f..b820de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased]## [0.5.4.5b0] +## [Unreleased]## [0.5.4.5b1] +### Added +- ScenarioRunner + +## [Unreleased]## [0.5.4.5b0] - 2022-09-06 ### Changed - ScenarioDbManager.resolve_metadata_column_conflicts: resolve conflicts where a ScenarioDbTable subclass redefines a column. - ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. diff --git a/dse_do_utils/mapmanager.py b/dse_do_utils/mapmanager.py index 3a8dfb0..6ceaeb9 100644 --- a/dse_do_utils/mapmanager.py +++ b/dse_do_utils/mapmanager.py @@ -276,3 +276,6 @@ def get_tooltip_table(rows: List[Tuple[str,str]]) -> str: :returns (str): text for a tooltip in table format """ return MapManager.get_html_table(rows) + + + diff --git a/dse_do_utils/scenariomanager.py b/dse_do_utils/scenariomanager.py index 6ff0e1f..8031add 100644 --- a/dse_do_utils/scenariomanager.py +++ b/dse_do_utils/scenariomanager.py @@ -565,7 +565,7 @@ def load_data_from_excel(self, excel_file_name: str) -> InputsOutputs: # self.inputs, self.outputs = ScenarioManager.load_data_from_excel_s(xl) # return self.inputs, self.outputs - def write_data_to_excel(self, excel_file_name: str = None, copy_to_csv: bool = False) -> None: + def write_data_to_excel(self, excel_file_name: str = None, copy_to_csv: bool = False) -> str: """Write inputs and/or outputs to an Excel file in datasets. The inputs and outputs as in the attributes `self.inputs` and `self.outputs` of the ScenarioManager diff --git a/dse_do_utils/scenariorunner.py b/dse_do_utils/scenariorunner.py new file mode 100644 index 0000000..1ebfab0 --- /dev/null +++ b/dse_do_utils/scenariorunner.py @@ -0,0 +1,313 @@ +# Copyright IBM All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from abc import ABC, abstractmethod +from copy import deepcopy +from dse_do_utils import ScenarioManager, OptimizationEngine +from dse_do_utils.datamanager import Inputs, Outputs, DataManager +from dse_do_utils.scenariodbmanager import ScenarioDbManager +from logging import Logger, getLogger +from typing import Any, Dict, Optional, Tuple, NamedTuple + + +class ScenarioConfig(NamedTuple): + scenario_name: str = 'Scenario_x' + + +class RunConfig(NamedTuple): + insert_inputs_in_db: bool = False + insert_outputs_in_db: bool = False + new_schema: bool = False + insert_in_do: bool = False + write_output_to_excel: bool = False + enable_data_check: bool = False + enable_data_check_outputs: bool = False + data_check_bulk_insert: bool = False # False implies row-by-row + log_level: str = 'DEBUG' # 'DEBUG' + export_lp: bool = False + export_lp_path: str = '' + do_model_name: str = None + template_scenario_name: Optional[str] = None # 'TemplateScenario' + + +class ScenarioGenerator(ABC): + + def __init__(self, + inputs: Inputs, + scenario_config: ScenarioConfig) -> None: + self._logger: Logger = getLogger(self.__class__.__name__) + self.inputs: Inputs = inputs + self.scenario_config: ScenarioConfig = scenario_config + + @abstractmethod + def generate_scenario(self): + new_inputs = self.inputs.copy() + return new_inputs + + +class ScenarioRunner: + """ + TODO: remove local_root, local_platform, replace by data_directory + """ + def __init__(self, + scenario_db_manager: ScenarioDbManager, + optimization_engine_class, + data_manager_class, + scenario_db_manager_class, # For the SQLite data check + scenario_generator_class = None, + do_model_name: str = 'my_model', + schema: Optional[str] = None, + use_scenario_db: bool = True, + local_root: Optional[str] = None, + local_platform: Optional[int] = None, + data_directory: Optional[str] = None) -> None: + + self.scenario_db_manager: ScenarioDbManager = scenario_db_manager + self.optimization_engine_class: Any = optimization_engine_class + self.data_manager_class: Any = data_manager_class + self.scenario_db_manager_class: Any = scenario_db_manager_class + self.scenario_generator_class = scenario_generator_class + + self.optimization_engine: Any = None # To be set in run. + self.data_manager: Any = None # To be set in run. + self.sqlite_scenario_db_manager: Any = None # To be set in run. + + self._logger: Logger = getLogger(self.__class__.__name__) + self.schema: Optional[str] = schema + self.do_model_name: str = do_model_name + self.use_scenario_db: bool = use_scenario_db # TODO: VT20220906: remove, doesn't seem to be used? + self.local_root: Optional[str] = local_root + self.local_platform: Optional[int] = local_platform + self.data_directory: Optional[str] = data_directory + + + def run_once(self, + scenario_config: ScenarioConfig, + run_config: RunConfig, + base_inputs: Optional[Inputs] = None, + excel_file_name: Optional[str] = None): + ''' + :param scenario_config: + :param run_config: + :param base_inputs: + :param excel_filepath + :return: + ''' + + scenario_name = scenario_config.scenario_name + db_insert_input_flag = run_config.insert_inputs_in_db + db_insert_output_flag = run_config.insert_outputs_in_db + ''' + Read data from the excel file. Either use `ScenarioInputsOutputs` class + or `ScenarioManager.load_data_from_excel`. + ''' + + # Load base inputs + if excel_file_name and not base_inputs: + self._logger.info('Loading data from the excel file') + inputs = self.load_input_data_from_excel(excel_file_name) + elif not excel_file_name and base_inputs: + inputs = base_inputs + else: + raise ValueError( + 'Either base_inputs or excel_file_name should be provided.') + + # Generate scenario + self._logger.info(f'Generating scenario {scenario_name}') + inputs = self.generate_scenario(inputs, scenario_config) + + # Data check + if run_config.enable_data_check: + inputs = self.data_check_inputs(inputs, scenario_name = scenario_config.scenario_name, bulk = run_config.data_check_bulk_insert) + + # Pass inputs through scenario DB + if run_config.insert_inputs_in_db: + inputs = self.insert_inputs_in_db(inputs, run_config, scenario_config.scenario_name) + + # Run DO engine. + self._logger.info(f'Solving {scenario_name}') + # inputs = inputs_from_db if db_insert_input_flag else inputs + outputs = self.run_model(inputs, run_config) + + if run_config.enable_data_check_outputs: + inputs, outputs = self.data_check_outputs(inputs=inputs, outputs=outputs, scenario_name=scenario_config.scenario_name, bulk=run_config.data_check_bulk_insert) + + if run_config.insert_outputs_in_db: + self.insert_outputs_in_db(inputs, outputs, run_config, scenario_config.scenario_name) + + if run_config.insert_in_do: + self.insert_in_do(inputs, outputs, scenario_config, self.model_name) + + if run_config.write_output_to_excel: + self.write_output_data_to_excel(inputs, outputs, scenario_name) + + self._logger.info(f'Done with {scenario_config.scenario_name}') + + return outputs + + def load_input_data_from_excel(self, excel_file_name) -> Inputs: + sm = ScenarioManager(local_root=self.local_root, local_relative_data_path = '', + data_directory=self.data_directory, + # model_name=self.do_model_name, + # scenario_name=scenario_name, + # template_scenario_name='TemplateScenario', + platform=self.local_platform) + inputs, _ = sm.load_data_from_excel(excel_file_name) + return inputs + + def write_output_data_to_excel(self, inputs: Inputs, outputs: Outputs, scenario_name: str): + sm = ScenarioManager(local_root=self.local_root, local_relative_data_path = '', + data_directory=self.data_directory, + inputs=inputs, outputs=outputs, + model_name=self.do_model_name, + scenario_name=scenario_name, + platform=self.local_platform) + filepath = sm.write_data_to_excel() + self._logger.info(f'Wrote output to {filepath}') + + def generate_scenario(self, base_inputs: Inputs, + scenario_config: ScenarioConfig): + """ + Generate a derived scenario from a baseline scenario on the + specifications in the scenario_config. + :param base_inputs: + :param scenario_config: + :return: + """ + if self.scenario_generator_class is not None: + self._logger.info('Generate Scenario') + sg: ScenarioGenerator = self.scenario_generator_class(base_inputs, scenario_config) + inputs = sg.generate_scenario() + else: + inputs = base_inputs + return inputs + + def data_check_inputs(self, inputs: Inputs, scenario_name: str = 'data_check', bulk: bool = False) -> Inputs: + """Use SQLite to validate data. Read data back and do a dm.prepare_data_frames. + Does a deepcopy of the inputs to ensure the DB operations do not alter the inputs. + Bulk can be set to True once the basic data issues have been resolved and performance needs to be improved. + Set bulk to False to get more granular DB insert errors, i.e. per record. + TODO: add a data_check() on the DataManager for additional checks.""" + self._logger.info('Checking input data via SQLite and DataManager') + self.sqlite_scenario_db_manager: ScenarioDbManager = self.scenario_db_manager_class() + self.sqlite_scenario_db_manager.create_schema() + self.sqlite_scenario_db_manager.replace_scenario_in_db(scenario_name, deepcopy(inputs), {}, bulk=bulk) + + inputs_v2, outputs_v2 = self.sqlite_scenario_db_manager.read_scenario_from_db(scenario_name) + dm: DataManager = self.data_manager_class(inputs_v2, outputs_v2) + dm.prepare_data_frames() + return inputs_v2 + + def data_check_outputs(self, inputs: Inputs, outputs: Outputs, scenario_name: str = 'data_check', bulk: bool = False) -> Tuple[Inputs, Outputs]: + """Use SQLite to validate data. Read data back and do a dm.prepare_data_frames. + Does a deepcopy of the inputs to ensure the DB operations do not alter the inputs. + Bulk can be set to True once the basic data issues have been resolved and performance needs to be improved. + Set bulk to False to get more granular DB insert errors, i.e. per record. + TODO: add a data_check() on the DataManager for additional checks.""" + self._logger.info('Checking output data via SQLite and DataManager') + if self.sqlite_scenario_db_manager is None: + self.sqlite_scenario_db_manager: ScenarioDbManager = self.scenario_db_manager_class() + self.sqlite_scenario_db_manager.create_schema() + self.sqlite_scenario_db_manager.replace_scenario_in_db(scenario_name, deepcopy(inputs), deepcopy(outputs), bulk=bulk) + else: + self.sqlite_scenario_db_manager.update_scenario_output_tables_in_db(scenario_name, outputs) # TODO: add bulk=False option + + inputs_v2, outputs_v2 = self.sqlite_scenario_db_manager.read_scenario_from_db(scenario_name) + dm: DataManager = self.data_manager_class(inputs_v2, outputs_v2) + dm.prepare_data_frames() + return inputs_v2, outputs_v2 + + def insert_inputs_in_db(self, inputs: Inputs, run_config: RunConfig, scenario_name: str) -> Inputs: + + # 1. Create new schema + if run_config.new_schema: + self._logger.info(f'Creating a new schema: {self.schema}') + self.scenario_db_manager.create_schema() + # 2. Insert inputs in DB + self.scenario_db_manager.replace_scenario_in_db(scenario_name, inputs, {}, bulk=True) + # 3. Read inputs from DB + inputs_v2 = self.scenario_db_manager.read_scenario_input_tables_from_db(scenario_name) + return inputs_v2 + + + def run_model(self, inputs: Inputs, run_config: RunConfig): + ''' + Main method to run the optimization model. + ''' + + self.data_manager = self.data_manager_class( + inputs=inputs, log_level=run_config.log_level) # 'DEBUG' + self.optimization_engine: OptimizationEngine = self.optimization_engine_class( + data_manager=self.data_manager, + name=(run_config.do_model_name if run_config.do_model_name is not None else self.do_model_name), + export_lp=run_config.export_lp, + export_lp_path=run_config.export_lp_path, + ) + + return self.optimization_engine.run() + + def insert_outputs_in_db(self, inputs: Inputs, outputs: Outputs, run_config: RunConfig, scenario_name: str): + self._logger.info('Inserting outputs into the database') + if run_config.insert_inputs_in_db: + self.scenario_db_manager.update_scenario_output_tables_in_db(scenario_name, outputs) + else: + self.scenario_db_manager.replace_scenario_in_db(scenario_name, inputs, outputs) + + def insert_in_do(self, inputs, outputs, scenario_config: ScenarioConfig, + run_config: RunConfig): + + print(f"DO insert for {scenario_config.scenario_name}") + sm = ScenarioManager(model_name=run_config.do_model_name, + scenario_name=scenario_config.scenario_name) + # sm.inputs = inputs + # sm.outputs = outputs + # self._logger.info(f'Scenario: {scenario_config.scenario_name}') + # sm.print_table_names() + # sm.write_data_into_scenario() + # self._logger.info('Start create') + sm.write_data_into_scenario_s( + run_config.do_model_name, + scenario_config.scenario_name, + inputs, + outputs, + template_scenario_name=run_config.template_scenario_name) + # sm.write_data_to_excel() + + # @staticmethod + # def schema_exists(scdb: ScenarioDbManager, db_credentials: dict, + # schema: str) -> bool: + # """ + # TODO: scdb already has a engine. No need to create a new one + # :param scdb: + # :param db_credentials: + # :param schema: + # :return: + # """ + # + # connection_string_list = scdb._get_db2_connection_string( + # db_credentials, schema).split(';') + # connection_string = (connection_string_list[0] + ';' + + # connection_string_list[-1]) + # engine = create_engine(connection_string, echo=True) + # + # with engine.connect() as connection: + # result = connection.execute( + # text(f'SELECT schemaname FROM syscat.schemata;')) + # + # for row in result: + # if row[0] == schema: + # return True + # + # return False + # + # def create_new_schema(self, db_credentials: Dict[str, str], + # schema: str) -> None: + # ''' + # Create a new schema if it does not exist. + # ''' + # + # scdb = ScenarioDbManager(echo=False, + # credentials=db_credentials, + # schema=schema) + # + # if not ScenarioRunner.schema_exists(scdb, db_credentials, schema): + # scdb.create_schema() diff --git a/dse_do_utils/version.py b/dse_do_utils/version.py index 154c272..24dd133 100644 --- a/dse_do_utils/version.py +++ b/dse_do_utils/version.py @@ -9,4 +9,4 @@ See https://stackoverflow.com/questions/458550/standard-way-to-embed-version-into-python-package """ -__version__ = "0.5.4.5b" +__version__ = "0.5.4.5b1" From 82a0ef1c672d4ae413bddc2945a4efea271f4d06 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Thu, 22 Sep 2022 15:37:23 -0400 Subject: [PATCH 13/39] Releasing 0.5.4.5b1 --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b820de8..eaae810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased]## [0.5.4.5b1] +## [Unreleased]## [0.5.4.5b2] + +## [0.5.4.5b1] - 2022-09-22 ### Added - ScenarioRunner -## [Unreleased]## [0.5.4.5b0] - 2022-09-06 +## [0.5.4.5b0] - 2022-09-06 ### Changed - ScenarioDbManager.resolve_metadata_column_conflicts: resolve conflicts where a ScenarioDbTable subclass redefines a column. - ScenarioDbManager._insert_table_in_db_by_row automatically inserts None for columns in defined schema, but missing in DataFrame. From d12318b02a410b101de4b2e39f57bcf354d3e967 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Thu, 22 Sep 2022 15:38:23 -0400 Subject: [PATCH 14/39] Starting 0.5.4.5b2 --- dse_do_utils/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dse_do_utils/version.py b/dse_do_utils/version.py index 24dd133..4af7642 100644 --- a/dse_do_utils/version.py +++ b/dse_do_utils/version.py @@ -9,4 +9,4 @@ See https://stackoverflow.com/questions/458550/standard-way-to-embed-version-into-python-package """ -__version__ = "0.5.4.5b1" +__version__ = "0.5.4.5b2" From e60e148547f69dfcc577c6440320c2bc6501ca5c Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sat, 1 Oct 2022 17:51:20 -0400 Subject: [PATCH 15/39] ScenarioGenerator and ScenarioRunner refactoring --- CHANGELOG.md | 2 +- dse_do_utils/scenariorunner.py | 231 +++++++++++++++++++++++++++------ 2 files changed, 190 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaae810..17e6beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.5.4.5b1] - 2022-09-22 ### Added -- ScenarioRunner +- ScenarioRunner and ScenarioGenerator ## [0.5.4.5b0] - 2022-09-06 ### Changed diff --git a/dse_do_utils/scenariorunner.py b/dse_do_utils/scenariorunner.py index 1ebfab0..57bf81c 100644 --- a/dse_do_utils/scenariorunner.py +++ b/dse_do_utils/scenariorunner.py @@ -2,18 +2,24 @@ # SPDX-License-Identifier: Apache-2.0 from abc import ABC, abstractmethod from copy import deepcopy +from dataclasses import dataclass + +import pandas as pd + from dse_do_utils import ScenarioManager, OptimizationEngine from dse_do_utils.datamanager import Inputs, Outputs, DataManager from dse_do_utils.scenariodbmanager import ScenarioDbManager from logging import Logger, getLogger -from typing import Any, Dict, Optional, Tuple, NamedTuple +from typing import Any, Dict, Optional, Tuple, NamedTuple, Type, List -class ScenarioConfig(NamedTuple): +@dataclass # (frozen=True) +class ScenarioConfig: scenario_name: str = 'Scenario_x' + parameters: Dict = None # Dict of parameters to override. Uses same names as in Parameters data table. - -class RunConfig(NamedTuple): +@dataclass # (frozen=True) +class RunConfig: insert_inputs_in_db: bool = False insert_outputs_in_db: bool = False new_schema: bool = False @@ -29,62 +35,120 @@ class RunConfig(NamedTuple): template_scenario_name: Optional[str] = None # 'TemplateScenario' -class ScenarioGenerator(ABC): +class ScenarioGenerator(): + """Generates a variation of a scenario, i.e. `inputs` dataset, driven by a ScenarioConfig. + To be subclassed. + This base class implements overrides of the Parameter table. + The ScenarioGenerator is typically used in the context of a ScenarioRunner. + + Usage:: + + class MyScenarioGenerator(ScenarioGenerator): + def generate_scenario(self): + new_inputs = super().generate_scenario() + new_inputs['MyTable1'] = self.generate_my_table1().reset_index() + new_inputs['MyTable2'] = self.generate_my_table2().reset_index() + return new_inputs + """ def __init__(self, inputs: Inputs, scenario_config: ScenarioConfig) -> None: self._logger: Logger = getLogger(self.__class__.__name__) - self.inputs: Inputs = inputs + self.inputs: Inputs = inputs.copy() # Only copy of dict self.scenario_config: ScenarioConfig = scenario_config - @abstractmethod def generate_scenario(self): - new_inputs = self.inputs.copy() + """Generate a variation of the base_inputs. To be overridden. + This default implementation changes the Parameter table based on the overrides in the ScenarioConfig.parameters. + + Usage:: + + def generate_scenario(self): + new_inputs = super().generate_scenario() + new_inputs['MyTable'] = self.generate_my_table().reset_index() + return new_inputs + + """ + new_inputs = self.inputs + new_inputs['Parameter'] = self.get_parameters().reset_index() return new_inputs + def get_parameters(self) -> pd.DataFrame: + """Applies overrides to the Parameter table based on the ScenarioConfig.parameters. + """ + if self.scenario_config.parameters is None: + df = self.inputs['Parameter'] + else: + df = self.inputs['Parameter'].copy().set_index(['param']) + for param, value in self.scenario_config.parameters.items(): + df.at[param, 'value'] = value + return df + class ScenarioRunner: """ - TODO: remove local_root, local_platform, replace by data_directory + TODO: remove local_root, local_platform, replace by data_directory? (It seems to be working fine though) """ def __init__(self, scenario_db_manager: ScenarioDbManager, - optimization_engine_class, - data_manager_class, - scenario_db_manager_class, # For the SQLite data check - scenario_generator_class = None, + optimization_engine_class: Type[OptimizationEngine], + data_manager_class: Type[DataManager], + scenario_db_manager_class: Type[ScenarioDbManager], # For the SQLite data check + scenario_generator_class: Optional[Type[ScenarioGenerator]] = None, do_model_name: str = 'my_model', schema: Optional[str] = None, - use_scenario_db: bool = True, + # use_scenario_db: bool = True, local_root: Optional[str] = None, local_platform: Optional[int] = None, data_directory: Optional[str] = None) -> None: self.scenario_db_manager: ScenarioDbManager = scenario_db_manager - self.optimization_engine_class: Any = optimization_engine_class - self.data_manager_class: Any = data_manager_class - self.scenario_db_manager_class: Any = scenario_db_manager_class + self.optimization_engine_class = optimization_engine_class + self.data_manager_class = data_manager_class + self.scenario_db_manager_class = scenario_db_manager_class self.scenario_generator_class = scenario_generator_class - self.optimization_engine: Any = None # To be set in run. - self.data_manager: Any = None # To be set in run. - self.sqlite_scenario_db_manager: Any = None # To be set in run. + self.optimization_engine: OptimizationEngine = None # To be set in run. + self.data_manager: DataManager = None # To be set in run. + self.sqlite_scenario_db_manager: ScenarioDbManager = None # To be set in run. self._logger: Logger = getLogger(self.__class__.__name__) self.schema: Optional[str] = schema self.do_model_name: str = do_model_name - self.use_scenario_db: bool = use_scenario_db # TODO: VT20220906: remove, doesn't seem to be used? + # self.use_scenario_db: bool = use_scenario_db # TODO: VT20220906: remove, doesn't seem to be used? self.local_root: Optional[str] = local_root self.local_platform: Optional[int] = local_platform self.data_directory: Optional[str] = data_directory - def run_once(self, scenario_config: ScenarioConfig, run_config: RunConfig, base_inputs: Optional[Inputs] = None, excel_file_name: Optional[str] = None): + if run_config.new_schema: + self.create_new_db_schema() + base_inputs = self._load_base_inputs(excel_file_name=excel_file_name, base_inputs=base_inputs) + outputs = self._run_once(scenario_config, run_config, base_inputs) + return outputs + + def run_multiple(self, + scenario_configs: List[ScenarioConfig], + run_config: RunConfig, + base_inputs: Optional[Inputs] = None, + excel_file_name: Optional[str] = None) -> None: + """Only once create schema and/or load data from Excel. + Then it will run all scenario_configs, each time applying the ScenarioGenerator on the base inputs.""" + if run_config.new_schema: + self.create_new_db_schema() + base_inputs = self._load_base_inputs(excel_file_name=excel_file_name, base_inputs=base_inputs) + for scenario_config in scenario_configs: + self._run_once(scenario_config, run_config, base_inputs) + + def _run_once(self, + scenario_config: ScenarioConfig, + run_config: RunConfig, + base_inputs: Inputs = None) -> Outputs: ''' :param scenario_config: :param run_config: @@ -94,26 +158,20 @@ def run_once(self, ''' scenario_name = scenario_config.scenario_name - db_insert_input_flag = run_config.insert_inputs_in_db - db_insert_output_flag = run_config.insert_outputs_in_db - ''' - Read data from the excel file. Either use `ScenarioInputsOutputs` class - or `ScenarioManager.load_data_from_excel`. - ''' - # Load base inputs - if excel_file_name and not base_inputs: - self._logger.info('Loading data from the excel file') - inputs = self.load_input_data_from_excel(excel_file_name) - elif not excel_file_name and base_inputs: - inputs = base_inputs - else: - raise ValueError( - 'Either base_inputs or excel_file_name should be provided.') + # # Load base inputs + # if excel_file_name and not base_inputs: + # self._logger.info('Loading data from the excel file') + # inputs = self.load_input_data_from_excel(excel_file_name) + # elif not excel_file_name and base_inputs: + # inputs = base_inputs + # else: + # raise ValueError( + # 'Either base_inputs or excel_file_name should be provided.') # Generate scenario self._logger.info(f'Generating scenario {scenario_name}') - inputs = self.generate_scenario(inputs, scenario_config) + inputs = self.generate_scenario(base_inputs, scenario_config) # Data check if run_config.enable_data_check: @@ -144,6 +202,86 @@ def run_once(self, return outputs + def create_new_db_schema(self): + self._logger.info(f'Creating a new schema: {self.schema}') + self.scenario_db_manager.create_schema() + + def _load_base_inputs(self, excel_file_name, base_inputs): + # Load base inputs + if excel_file_name and not base_inputs: + self._logger.info('Loading data from the excel file') + inputs = self.load_input_data_from_excel(excel_file_name) + elif not excel_file_name and base_inputs: + inputs = base_inputs + else: + raise ValueError( + 'Either base_inputs or excel_file_name should be provided.') + return inputs + + # def run_once(self, + # scenario_config: ScenarioConfig, + # run_config: RunConfig, + # base_inputs: Optional[Inputs] = None, + # excel_file_name: Optional[str] = None) -> Outputs: + # ''' + # :param scenario_config: + # :param run_config: + # :param base_inputs: + # :param excel_filepath + # :return: + # ''' + # + # scenario_name = scenario_config.scenario_name + # db_insert_input_flag = run_config.insert_inputs_in_db + # db_insert_output_flag = run_config.insert_outputs_in_db + # ''' + # Read data from the excel file. Either use `ScenarioInputsOutputs` class + # or `ScenarioManager.load_data_from_excel`. + # ''' + # + # # Load base inputs + # if excel_file_name and not base_inputs: + # self._logger.info('Loading data from the excel file') + # inputs = self.load_input_data_from_excel(excel_file_name) + # elif not excel_file_name and base_inputs: + # inputs = base_inputs + # else: + # raise ValueError( + # 'Either base_inputs or excel_file_name should be provided.') + # + # # Generate scenario + # self._logger.info(f'Generating scenario {scenario_name}') + # inputs = self.generate_scenario(inputs, scenario_config) + # + # # Data check + # if run_config.enable_data_check: + # inputs = self.data_check_inputs(inputs, scenario_name = scenario_config.scenario_name, bulk = run_config.data_check_bulk_insert) + # + # # Pass inputs through scenario DB + # if run_config.insert_inputs_in_db: + # inputs = self.insert_inputs_in_db(inputs, run_config, scenario_config.scenario_name) + # + # # Run DO engine. + # self._logger.info(f'Solving {scenario_name}') + # # inputs = inputs_from_db if db_insert_input_flag else inputs + # outputs = self.run_model(inputs, run_config) + # + # if run_config.enable_data_check_outputs: + # inputs, outputs = self.data_check_outputs(inputs=inputs, outputs=outputs, scenario_name=scenario_config.scenario_name, bulk=run_config.data_check_bulk_insert) + # + # if run_config.insert_outputs_in_db: + # self.insert_outputs_in_db(inputs, outputs, run_config, scenario_config.scenario_name) + # + # if run_config.insert_in_do: + # self.insert_in_do(inputs, outputs, scenario_config, self.model_name) + # + # if run_config.write_output_to_excel: + # self.write_output_data_to_excel(inputs, outputs, scenario_name) + # + # self._logger.info(f'Done with {scenario_config.scenario_name}') + # + # return outputs + def load_input_data_from_excel(self, excel_file_name) -> Inputs: sm = ScenarioManager(local_root=self.local_root, local_relative_data_path = '', data_directory=self.data_directory, @@ -218,16 +356,25 @@ def data_check_outputs(self, inputs: Inputs, outputs: Outputs, scenario_name: st def insert_inputs_in_db(self, inputs: Inputs, run_config: RunConfig, scenario_name: str) -> Inputs: - # 1. Create new schema - if run_config.new_schema: - self._logger.info(f'Creating a new schema: {self.schema}') - self.scenario_db_manager.create_schema() + # 1. Create new schema: Is NOT done here! Is now done earlier and only once per call # 2. Insert inputs in DB self.scenario_db_manager.replace_scenario_in_db(scenario_name, inputs, {}, bulk=True) # 3. Read inputs from DB inputs_v2 = self.scenario_db_manager.read_scenario_input_tables_from_db(scenario_name) return inputs_v2 + # def insert_inputs_in_db(self, inputs: Inputs, run_config: RunConfig, scenario_name: str) -> Inputs: + # + # # 1. Create new schema + # if run_config.new_schema: + # self._logger.info(f'Creating a new schema: {self.schema}') + # self.scenario_db_manager.create_schema() + # # 2. Insert inputs in DB + # self.scenario_db_manager.replace_scenario_in_db(scenario_name, inputs, {}, bulk=True) + # # 3. Read inputs from DB + # inputs_v2 = self.scenario_db_manager.read_scenario_input_tables_from_db(scenario_name) + # return inputs_v2 + def run_model(self, inputs: Inputs, run_config: RunConfig): ''' From 2db8a349e986a896ca56f565003257d821257073 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sat, 1 Oct 2022 22:49:34 -0400 Subject: [PATCH 16/39] ScenarioGenerator and ScenarioRunner refactoring --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e6beb..415cc33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased]## [0.5.4.5b2] +### Changed +- ScenarioRunner and ScenarioGenerator refactoring ## [0.5.4.5b1] - 2022-09-22 ### Added From d267965621190a80110d1d44a4e97b8dc0d41ae4 Mon Sep 17 00:00:00 2001 From: vjterpstra Date: Sun, 2 Oct 2022 18:12:29 -0400 Subject: [PATCH 17/39] Releasing v0.5.4.5b2 --- CHANGELOG.md | 4 +- docs/doc_build/doctrees/dse_do_utils.doctree | Bin 1117836 -> 1252889 bytes docs/doc_build/doctrees/environment.pickle | Bin 427874 -> 461724 bytes docs/doc_build/html/.buildinfo | 2 +- .../doc_build/html/_modules/dse_do_utils.html | 6 +- .../_modules/dse_do_utils/cpd25utilities.html | 6 +- .../_modules/dse_do_utils/datamanager.html | 6 +- .../dse_do_utils/deployeddomodel.html | 6 +- .../dse_do_utils/deployeddomodelcpd21.html | 6 +- .../dse_do_utils/domodeldeployer.html | 6 +- .../dse_do_utils/domodelexporter.html | 6 +- .../_modules/dse_do_utils/mapmanager.html | 9 +- .../dse_do_utils/multiscenariomanager.html | 6 +- .../dse_do_utils/optimizationengine.html | 6 +- .../_modules/dse_do_utils/plotlymanager.html | 6 +- .../dse_do_utils/scenariodbmanager.html | 71 +++-- .../dse_do_utils/scenariomanager.html | 8 +- .../_modules/dse_do_utils/scenariopicker.html | 6 +- .../html/_modules/dse_do_utils/utilities.html | 6 +- docs/doc_build/html/_modules/index.html | 7 +- .../html/_sources/dse_do_utils.rst.txt | 8 + docs/doc_build/html/_static/bizstyle.js | 2 +- .../html/_static/documentation_options.js | 2 +- docs/doc_build/html/dse_do_utils.html | 253 +++++++++++++++++- docs/doc_build/html/genindex.html | 125 +++++++-- docs/doc_build/html/index.html | 6 +- docs/doc_build/html/modules.html | 7 +- docs/doc_build/html/objects.inv | Bin 2541 -> 2822 bytes docs/doc_build/html/py-modindex.html | 11 +- docs/doc_build/html/readme_link.html | 6 +- docs/doc_build/html/search.html | 6 +- docs/doc_build/html/searchindex.js | 2 +- docs/source/dse_do_utils.rst | 8 + 33 files changed, 505 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 415cc33..f09cc1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased]## [0.5.4.5b2] +## [Unreleased]## [0.5.4.5b3] + +## [0.5.4.5b2] - 2022-10-02 ### Changed - ScenarioRunner and ScenarioGenerator refactoring diff --git a/docs/doc_build/doctrees/dse_do_utils.doctree b/docs/doc_build/doctrees/dse_do_utils.doctree index 29b44f8a6379d8666e0509f3af467f0c338d9bbb..f024c995c3731be937730131c98a4da1ac9a0471 100644 GIT binary patch delta 111720 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≫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 diff --git a/docs/doc_build/doctrees/environment.pickle b/docs/doc_build/doctrees/environment.pickle index 012c29052c4932492fe09b1fa3c15aa07a969481..876bec9649764f11e51e24dfab7723d19381bdf5 100644 GIT binary patch delta 54834 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 delta 29940 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 E2S6{0?*IS* diff --git a/docs/doc_build/html/.buildinfo b/docs/doc_build/html/.buildinfo index f454eb1..fe3bcce 100644 --- a/docs/doc_build/html/.buildinfo +++ b/docs/doc_build/html/.buildinfo @@ -1,4 +1,4 @@ # Sphinx build info version 1 # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. -config: f8f9929cad760595d068a901248ae903 +config: 2b7be20a10eb195de1f495ac2b2525a4 tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/doc_build/html/_modules/dse_do_utils.html b/docs/doc_build/html/_modules/dse_do_utils.html index 5e77e88..7366f63 100644 --- a/docs/doc_build/html/_modules/dse_do_utils.html +++ b/docs/doc_build/html/_modules/dse_do_utils.html @@ -6,7 +6,7 @@ - dse_do_utils — DSE DO Utils 0.5.4.4 documentation + dse_do_utils — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

Navigation

  • modules |
  • - + @@ -169,7 +169,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/cpd25utilities.html b/docs/doc_build/html/_modules/dse_do_utils/cpd25utilities.html index 2926153..94625d3 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/cpd25utilities.html +++ b/docs/doc_build/html/_modules/dse_do_utils/cpd25utilities.html @@ -6,7 +6,7 @@ - dse_do_utils.cpd25utilities — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.cpd25utilities — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -211,7 +211,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/datamanager.html b/docs/doc_build/html/_modules/dse_do_utils/datamanager.html index 7255597..d680b8e 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/datamanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/datamanager.html @@ -6,7 +6,7 @@ - dse_do_utils.datamanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.datamanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -456,7 +456,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/deployeddomodel.html b/docs/doc_build/html/_modules/dse_do_utils/deployeddomodel.html index 2fd123c..c632c08 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/deployeddomodel.html +++ b/docs/doc_build/html/_modules/dse_do_utils/deployeddomodel.html @@ -6,7 +6,7 @@ - dse_do_utils.deployeddomodel — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.deployeddomodel — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -325,7 +325,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/deployeddomodelcpd21.html b/docs/doc_build/html/_modules/dse_do_utils/deployeddomodelcpd21.html index 79c19b4..ffad653 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/deployeddomodelcpd21.html +++ b/docs/doc_build/html/_modules/dse_do_utils/deployeddomodelcpd21.html @@ -6,7 +6,7 @@ - dse_do_utils.deployeddomodelcpd21 — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.deployeddomodelcpd21 — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -737,7 +737,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/domodeldeployer.html b/docs/doc_build/html/_modules/dse_do_utils/domodeldeployer.html index cb09f97..7b686ef 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/domodeldeployer.html +++ b/docs/doc_build/html/_modules/dse_do_utils/domodeldeployer.html @@ -6,7 +6,7 @@ - dse_do_utils.domodeldeployer — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.domodeldeployer — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -452,7 +452,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/domodelexporter.html b/docs/doc_build/html/_modules/dse_do_utils/domodelexporter.html index 273af97..4c05a54 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/domodelexporter.html +++ b/docs/doc_build/html/_modules/dse_do_utils/domodelexporter.html @@ -6,7 +6,7 @@ - dse_do_utils.domodelexporter — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.domodelexporter — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -342,7 +342,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/mapmanager.html b/docs/doc_build/html/_modules/dse_do_utils/mapmanager.html index 742604e..1e79293 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/mapmanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/mapmanager.html @@ -6,7 +6,7 @@ - dse_do_utils.mapmanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.mapmanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -322,6 +322,9 @@

    Source code for dse_do_utils.mapmanager

             :returns (str): text for a tooltip in table format
             """
             return MapManager.get_html_table(rows)
    + + +
    @@ -353,7 +356,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/multiscenariomanager.html b/docs/doc_build/html/_modules/dse_do_utils/multiscenariomanager.html index 7c3070e..da29f89 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/multiscenariomanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/multiscenariomanager.html @@ -6,7 +6,7 @@ - dse_do_utils.multiscenariomanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.multiscenariomanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -331,7 +331,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/optimizationengine.html b/docs/doc_build/html/_modules/dse_do_utils/optimizationengine.html index c4163b1..8bd998e 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/optimizationengine.html +++ b/docs/doc_build/html/_modules/dse_do_utils/optimizationengine.html @@ -6,7 +6,7 @@ - dse_do_utils.optimizationengine — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.optimizationengine — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -350,7 +350,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/plotlymanager.html b/docs/doc_build/html/_modules/dse_do_utils/plotlymanager.html index 1076f6a..50e8698 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/plotlymanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/plotlymanager.html @@ -6,7 +6,7 @@ - dse_do_utils.plotlymanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.plotlymanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -118,7 +118,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/scenariodbmanager.html b/docs/doc_build/html/_modules/dse_do_utils/scenariodbmanager.html index da33eae..618a8cf 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/scenariodbmanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/scenariodbmanager.html @@ -6,7 +6,7 @@ - dse_do_utils.scenariodbmanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.scenariodbmanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -103,8 +103,8 @@

    Source code for dse_do_utils.scenariodbmanager

    :param constraints_metadata: """ self.db_table_name = db_table_name - # ScenarioDbTable.camel_case_to_snake_case(db_table_name) # To make sure it is a proper DB table name. Also allows us to use the scenario table name. - self.columns_metadata = columns_metadata + # ScenarioDbTable.camel_case_to_snake_case(db_table_name) # To make sure it is a proper DB table name. Also allows us to use the scenario table name. + self.columns_metadata = self.resolve_metadata_column_conflicts(columns_metadata) self.constraints_metadata = constraints_metadata self.dtype = None if not db_table_name.islower() and not db_table_name.isupper(): ## I.e. is mixed_case @@ -115,9 +115,51 @@

    Source code for dse_do_utils.scenariodbmanager

    print(f"Warning: the db_table_name '{db_table_name}' is a reserved word. Do not use as table name.") self._sa_column_by_name = None # Dict[str, sqlalchemy.Column] Will be generated dynamically first time it is needed. +

    [docs] def resolve_metadata_column_conflicts(self, columns_metadata: List[sqlalchemy.Column]) -> List[sqlalchemy.Column]: + columns_dict = {} + for column in reversed(columns_metadata): + if isinstance(column, sqlalchemy.Column): + if column.name in columns_dict: + print(f"Warning: Conflicts in column definition for column {column.name} in table {self.__class__.__name__}. Retained override.") + else: + columns_dict[column.name] = column + else: + print(f"Warning: Column metadata contains non-sqlalchemy in table {self.__class__.__name__}. Retained override.") + return list(reversed(columns_dict.values()))
    +
    [docs] def get_db_table_name(self) -> str: return self.db_table_name
    +
    [docs] def get_df_column_names_2(self, df: pd.DataFrame) -> (List[str], pd.DataFrame): + """Get all column names that are defined in the DB schema. + If not present in the DataFrame df, adds the missing column with all None values. + + Note 1 (VT 20220829): + Note that the `sqlalchemy.insert(db_table.table_metadata).values(row)` does NOT properly handle columns that are missing in the row. + It seems to simply truncate the columns if the row length is less than the number of columns. + It does NOT match the column names! + Thus the need to add columns, so we end up with proper None values in the row for the insert, specifying all columns in the table. + + Note 2 (VT 20220829): + Reducing the list of sqlalchemy.Column does NOT work in `sqlalchemy.insert(db_table.table_metadata).values(row)` + The db_table.table_metadata is an object, not a List[sqlalchemy.Column] + + :param df: + :return: + """ + column_names = [] + # columns_metadata = [] + for c in self.columns_metadata: + if isinstance(c, sqlalchemy.Column): + if c.name in df.columns: + column_names.append(c.name) + # columns_metadata.append(c) + else: + column_names.append(c.name) + df[c.name] = None + + return column_names, df
    +
    [docs] def get_df_column_names(self, df: pd.DataFrame) -> List[str]: """Get all column names that are both defined in the DB schema and present in the DataFrame df. @@ -216,7 +258,7 @@

    Source code for dse_do_utils.scenariodbmanager

    try: df[columns].to_sql(table_name, schema=mgr.schema, con=connection, if_exists='append', dtype=None, - index=False) + index=False) except exc.IntegrityError as e: print("++++++++++++Integrity Error+++++++++++++") print(f"DataFrame insert/append of table '{table_name}'") @@ -807,14 +849,13 @@

    Source code for dse_do_utils.scenariodbmanager

    """ num_exceptions = 0 max_num_exceptions = 10 - columns = db_table.get_df_column_names(df=df) + columns, df2 = db_table.get_df_column_names_2(df=df) # Adds missing columns with None values # print(columns) # df[columns] ensures that the order of columns in the DF matches that of the SQL table definition. If not, the insert will fail - for row in df[columns].itertuples(index=False): - # print(row) + for row in df2[columns].itertuples(index=False): + # print(row) stmt = ( - sqlalchemy.insert(db_table.table_metadata). - values(row) + sqlalchemy.insert(db_table.table_metadata).values(row) ) try: if connection is None: @@ -1088,8 +1129,8 @@

    Source code for dse_do_utils.scenariodbmanager

    # Read multi scenario ############################################################################################

    [docs] def read_multi_scenario_tables_from_db(self, scenario_names: List[str], - input_table_names: Optional[List[str]] = None, - output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): + input_table_names: Optional[List[str]] = None, + output_table_names: Optional[List[str]] = None) -> (Inputs, Outputs): """Read selected set input and output tables from multiple scenarios. If input_table_names/output_table_names contains a '*', then all input/output tables will be read. If empty list or None, then no tables will be read. @@ -1102,8 +1143,8 @@

    Source code for dse_do_utils.scenariodbmanager

    return inputs, outputs

    def _read_multi_scenario_tables_from_db(self, connection, scenario_names: List[str], - input_table_names: List[str] = None, - output_table_names: List[str] = None) -> (Inputs, Outputs): + input_table_names: List[str] = None, + output_table_names: List[str] = None) -> (Inputs, Outputs): """Loads data for selected input and output tables from multiple scenarios. If either list is names is ['*'], will load all tables as defined in db_tables configuration. """ @@ -1722,7 +1763,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/scenariomanager.html b/docs/doc_build/html/_modules/dse_do_utils/scenariomanager.html index da2857b..1d0c3d0 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/scenariomanager.html +++ b/docs/doc_build/html/_modules/dse_do_utils/scenariomanager.html @@ -6,7 +6,7 @@ - dse_do_utils.scenariomanager — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.scenariomanager — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -611,7 +611,7 @@

    Source code for dse_do_utils.scenariomanager

    #     self.inputs, self.outputs = ScenarioManager.load_data_from_excel_s(xl)
         #     return self.inputs, self.outputs
     
    -
    [docs] def write_data_to_excel(self, excel_file_name: str = None, copy_to_csv: bool = False) -> None: +
    [docs] def write_data_to_excel(self, excel_file_name: str = None, copy_to_csv: bool = False) -> str: """Write inputs and/or outputs to an Excel file in datasets. The inputs and outputs as in the attributes `self.inputs` and `self.outputs` of the ScenarioManager @@ -1275,7 +1275,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/scenariopicker.html b/docs/doc_build/html/_modules/dse_do_utils/scenariopicker.html index 92db4d7..bd41cbf 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/scenariopicker.html +++ b/docs/doc_build/html/_modules/dse_do_utils/scenariopicker.html @@ -6,7 +6,7 @@ - dse_do_utils.scenariopicker — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.scenariopicker — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -257,7 +257,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/dse_do_utils/utilities.html b/docs/doc_build/html/_modules/dse_do_utils/utilities.html index 5cda33b..30ac158 100644 --- a/docs/doc_build/html/_modules/dse_do_utils/utilities.html +++ b/docs/doc_build/html/_modules/dse_do_utils/utilities.html @@ -6,7 +6,7 @@ - dse_do_utils.utilities — DSE DO Utils 0.5.4.4 documentation + dse_do_utils.utilities — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - + @@ -162,7 +162,7 @@

    Navigation

  • modules |
  • - + diff --git a/docs/doc_build/html/_modules/index.html b/docs/doc_build/html/_modules/index.html index 875e6a9..f9ff916 100644 --- a/docs/doc_build/html/_modules/index.html +++ b/docs/doc_build/html/_modules/index.html @@ -6,7 +6,7 @@ - Overview: module code — DSE DO Utils 0.5.4.4 documentation + Overview: module code — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - +
    @@ -56,6 +56,7 @@

    All modules for which code is available

  • dse_do_utils.scenariodbmanager
  • dse_do_utils.scenariomanager
  • dse_do_utils.scenariopicker
  • +
  • dse_do_utils.scenariorunner
  • dse_do_utils.utilities
  • @@ -88,7 +89,7 @@

    Navigation

  • modules |
  • - +
    diff --git a/docs/doc_build/html/_sources/dse_do_utils.rst.txt b/docs/doc_build/html/_sources/dse_do_utils.rst.txt index 4e59039..ef21836 100644 --- a/docs/doc_build/html/_sources/dse_do_utils.rst.txt +++ b/docs/doc_build/html/_sources/dse_do_utils.rst.txt @@ -116,6 +116,14 @@ dse\_do\_utils.scenariopicker module :undoc-members: :show-inheritance: +dse\_do\_utils.scenariorunner module +------------------------------------ + +.. automodule:: dse_do_utils.scenariorunner + :members: + :undoc-members: + :show-inheritance: + dse\_do\_utils.utilities module ------------------------------- diff --git a/docs/doc_build/html/_static/bizstyle.js b/docs/doc_build/html/_static/bizstyle.js index acf1a61..2334a87 100644 --- a/docs/doc_build/html/_static/bizstyle.js +++ b/docs/doc_build/html/_static/bizstyle.js @@ -36,6 +36,6 @@ $(window).resize(function(){ $("li.nav-item-0 a").text("Top"); } else { - $("li.nav-item-0 a").text("DSE DO Utils 0.5.4.4 documentation"); + $("li.nav-item-0 a").text("DSE DO Utils 0.5.4.5b2 documentation"); } }); \ No newline at end of file diff --git a/docs/doc_build/html/_static/documentation_options.js b/docs/doc_build/html/_static/documentation_options.js index 405b9f4..c4134ff 100644 --- a/docs/doc_build/html/_static/documentation_options.js +++ b/docs/doc_build/html/_static/documentation_options.js @@ -1,6 +1,6 @@ var DOCUMENTATION_OPTIONS = { URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), - VERSION: '0.5.4.4', + VERSION: '0.5.4.5b2', LANGUAGE: 'None', COLLAPSE_INDEX: false, BUILDER: 'html', diff --git a/docs/doc_build/html/dse_do_utils.html b/docs/doc_build/html/dse_do_utils.html index 9e44a70..7d1a1e4 100644 --- a/docs/doc_build/html/dse_do_utils.html +++ b/docs/doc_build/html/dse_do_utils.html @@ -6,7 +6,7 @@ - dse_do_utils package — DSE DO Utils 0.5.4.4 documentation + dse_do_utils package — DSE DO Utils 0.5.4.5b2 documentation @@ -35,7 +35,7 @@

    Navigation

  • previous |
  • - + @@ -2124,6 +2124,31 @@

    Submodules +
    +get_df_column_names_2(df: pandas.core.frame.DataFrame) -> (typing.List[str], <class 'pandas.core.frame.DataFrame'>)[source]
    +
    +
    Get all column names that are defined in the DB schema.

    If not present in the DataFrame df, adds the missing column with all None values.

    +

    Note 1 (VT 20220829): +Note that the sqlalchemy.insert(db_table.table_metadata).values(row) does NOT properly handle columns that are missing in the row. +It seems to simply truncate the columns if the row length is less than the number of columns. +It does NOT match the column names! +Thus the need to add columns, so we end up with proper None values in the row for the insert, specifying all columns in the table.

    +

    Note 2 (VT 20220829): +Reducing the list of sqlalchemy.Column does NOT work in sqlalchemy.insert(db_table.table_metadata).values(row) +The db_table.table_metadata is an object, not a List[sqlalchemy.Column]

    +
    +
    +
    +
    Parameters
    +

    df

    +
    +
    Returns
    +

    +
    +
    +
    +
    get_sa_column(db_column_name) Optional[sqlalchemy.sql.schema.Column][source]
    @@ -2150,6 +2175,11 @@

    Submodules +
    +resolve_metadata_column_conflicts(columns_metadata: List[sqlalchemy.sql.schema.Column]) List[sqlalchemy.sql.schema.Column][source]
    +

    +
    static sqlcol(df: pandas.core.frame.DataFrame) Dict[source]
    @@ -2802,7 +2832,7 @@

    Submodules
    -write_data_to_excel(excel_file_name: Optional[str] = None, copy_to_csv: bool = False) None[source]
    +write_data_to_excel(excel_file_name: Optional[str] = None, copy_to_csv: bool = False) str[source]

    Write inputs and/or outputs to an Excel file in datasets. The inputs and outputs as in the attributes self.inputs and self.outputs of the ScenarioManager

    The copy_to_csv is a work-around for the WS limitation of not being able to download a file from datasets that is not a csv file. @@ -2978,6 +3008,220 @@

    Submodules +

    dse_do_utils.scenariorunner module

    +
    +
    +class dse_do_utils.scenariorunner.RunConfig(insert_inputs_in_db: bool = False, insert_outputs_in_db: bool = False, new_schema: bool = False, insert_in_do: bool = False, write_output_to_excel: bool = False, enable_data_check: bool = False, enable_data_check_outputs: bool = False, data_check_bulk_insert: bool = False, log_level: str = 'DEBUG', export_lp: bool = False, export_lp_path: str = '', do_model_name: str = None, template_scenario_name: Optional[str] = None)[source]
    +

    Bases: object

    +
    +
    +data_check_bulk_insert: bool = False
    +
    + +
    +
    +do_model_name: str = None
    +
    + +
    +
    +enable_data_check: bool = False
    +
    + +
    +
    +enable_data_check_outputs: bool = False
    +
    + +
    +
    +export_lp: bool = False
    +
    + +
    +
    +export_lp_path: str = ''
    +
    + +
    +
    +insert_in_do: bool = False
    +
    + +
    +
    +insert_inputs_in_db: bool = False
    +
    + +
    +
    +insert_outputs_in_db: bool = False
    +
    + +
    +
    +log_level: str = 'DEBUG'
    +
    + +
    +
    +new_schema: bool = False
    +
    + +
    +
    +template_scenario_name: Optional[str] = None
    +
    + +
    +
    +write_output_to_excel: bool = False
    +
    + +
    + +
    +
    +class dse_do_utils.scenariorunner.ScenarioConfig(scenario_name: str = 'Scenario_x', parameters: Dict = None)[source]
    +

    Bases: object

    +
    +
    +parameters: Dict = None
    +
    + +
    +
    +scenario_name: str = 'Scenario_x'
    +
    + +
    + +
    +
    +class dse_do_utils.scenariorunner.ScenarioGenerator(inputs: Dict[str, pandas.core.frame.DataFrame], scenario_config: dse_do_utils.scenariorunner.ScenarioConfig)[source]
    +

    Bases: object

    +

    Generates a variation of a scenario, i.e. inputs dataset, driven by a ScenarioConfig. +To be subclassed. +This base class implements overrides of the Parameter table. +The ScenarioGenerator is typically used in the context of a ScenarioRunner.

    +

    Usage:

    +
    class MyScenarioGenerator(ScenarioGenerator):
    +    def generate_scenario(self):
    +        new_inputs = super().generate_scenario()
    +        new_inputs['MyTable1'] = self.generate_my_table1().reset_index()
    +        new_inputs['MyTable2'] = self.generate_my_table2().reset_index()
    +        return new_inputs
    +
    +
    +
    +
    +generate_scenario()[source]
    +

    Generate a variation of the base_inputs. To be overridden. +This default implementation changes the Parameter table based on the overrides in the ScenarioConfig.parameters.

    +

    Usage:

    +
    def generate_scenario(self):
    +    new_inputs = super().generate_scenario()
    +    new_inputs['MyTable'] = self.generate_my_table().reset_index()
    +    return new_inputs
    +
    +
    +
    + +
    +
    +get_parameters() pandas.core.frame.DataFrame[source]
    +

    Applies overrides to the Parameter table based on the ScenarioConfig.parameters.

    +
    + +
    + +
    +
    +class dse_do_utils.scenariorunner.ScenarioRunner(scenario_db_manager: dse_do_utils.scenariodbmanager.ScenarioDbManager, optimization_engine_class: Type[dse_do_utils.optimizationengine.OptimizationEngine], data_manager_class: Type[dse_do_utils.datamanager.DataManager], scenario_db_manager_class: Type[dse_do_utils.scenariodbmanager.ScenarioDbManager], scenario_generator_class: Optional[Type[dse_do_utils.scenariorunner.ScenarioGenerator]] = None, do_model_name: str = 'my_model', schema: Optional[str] = None, local_root: Optional[str] = None, local_platform: Optional[int] = None, data_directory: Optional[str] = None)[source]
    +

    Bases: object

    +

    TODO: remove local_root, local_platform, replace by data_directory? (It seems to be working fine though)

    +
    +
    +create_new_db_schema()[source]
    +
    + +
    +
    +data_check_inputs(inputs: Dict[str, pandas.core.frame.DataFrame], scenario_name: str = 'data_check', bulk: bool = False) Dict[str, pandas.core.frame.DataFrame][source]
    +

    Use SQLite to validate data. Read data back and do a dm.prepare_data_frames. +Does a deepcopy of the inputs to ensure the DB operations do not alter the inputs. +Bulk can be set to True once the basic data issues have been resolved and performance needs to be improved. +Set bulk to False to get more granular DB insert errors, i.e. per record. +TODO: add a data_check() on the DataManager for additional checks.

    +
    + +
    +
    +data_check_outputs(inputs: Dict[str, pandas.core.frame.DataFrame], outputs: Dict[str, pandas.core.frame.DataFrame], scenario_name: str = 'data_check', bulk: bool = False) Tuple[Dict[str, pandas.core.frame.DataFrame], Dict[str, pandas.core.frame.DataFrame]][source]
    +

    Use SQLite to validate data. Read data back and do a dm.prepare_data_frames. +Does a deepcopy of the inputs to ensure the DB operations do not alter the inputs. +Bulk can be set to True once the basic data issues have been resolved and performance needs to be improved. +Set bulk to False to get more granular DB insert errors, i.e. per record. +TODO: add a data_check() on the DataManager for additional checks.

    +
    + +
    +
    +generate_scenario(base_inputs: Dict[str, pandas.core.frame.DataFrame], scenario_config: dse_do_utils.scenariorunner.ScenarioConfig)[source]
    +

    Generate a derived scenario from a baseline scenario on the +specifications in the scenario_config. +:param base_inputs: +:param scenario_config: +:return:

    +
    + +
    +
    +insert_in_do(inputs, outputs, scenario_config: dse_do_utils.scenariorunner.ScenarioConfig, run_config: dse_do_utils.scenariorunner.RunConfig)[source]
    +
    + +
    +
    +insert_inputs_in_db(inputs: Dict[str, pandas.core.frame.DataFrame], run_config: dse_do_utils.scenariorunner.RunConfig, scenario_name: str) Dict[str, pandas.core.frame.DataFrame][source]
    +
    + +
    +
    +insert_outputs_in_db(inputs: Dict[str, pandas.core.frame.DataFrame], outputs: Dict[str, pandas.core.frame.DataFrame], run_config: dse_do_utils.scenariorunner.RunConfig, scenario_name: str)[source]
    +
    + +
    +
    +load_input_data_from_excel(excel_file_name) Dict[str, pandas.core.frame.DataFrame][source]
    +
    + +
    +
    +run_model(inputs: Dict[str, pandas.core.frame.DataFrame], run_config: dse_do_utils.scenariorunner.RunConfig)[source]
    +

    Main method to run the optimization model.

    +
    + +
    +
    +run_multiple(scenario_configs: List[dse_do_utils.scenariorunner.ScenarioConfig], run_config: dse_do_utils.scenariorunner.RunConfig, base_inputs: Optional[Dict[str, pandas.core.frame.DataFrame]] = None, excel_file_name: Optional[str] = None) None[source]
    +

    Only once create schema and/or load data from Excel. +Then it will run all scenario_configs, each time applying the ScenarioGenerator on the base inputs.

    +
    + +
    +
    +run_once(scenario_config: dse_do_utils.scenariorunner.ScenarioConfig, run_config: dse_do_utils.scenariorunner.RunConfig, base_inputs: Optional[Dict[str, pandas.core.frame.DataFrame]] = None, excel_file_name: Optional[str] = None)[source]
    +
    + +
    +
    +write_output_data_to_excel(inputs: Dict[str, pandas.core.frame.DataFrame], outputs: Dict[str, pandas.core.frame.DataFrame], scenario_name: str)[source]
    +
    + +
    +

    dse_do_utils.utilities module

    @@ -3116,6 +3360,7 @@

    Table of Contents

  • dse_do_utils.scenariodbmanager module
  • dse_do_utils.scenariomanager module
  • dse_do_utils.scenariopicker module
  • +
  • dse_do_utils.scenariorunner module
  • dse_do_utils.utilities module
  • dse_do_utils.version module
  • Module contents
  • @@ -3159,7 +3404,7 @@

    Navigation

  • previous |
  • - + diff --git a/docs/doc_build/html/genindex.html b/docs/doc_build/html/genindex.html index 6942b5e..e895fe4 100644 --- a/docs/doc_build/html/genindex.html +++ b/docs/doc_build/html/genindex.html @@ -6,7 +6,7 @@ - Index — DSE DO Utils 0.5.4.4 documentation + Index — DSE DO Utils 0.5.4.5b2 documentation @@ -31,7 +31,7 @@

    Navigation

  • modules |
  • - +
    @@ -159,6 +159,8 @@

    C

  • create_model_archive() (dse_do_utils.domodeldeployer.DOModelDeployer method)
  • create_model_directory() (dse_do_utils.domodeldeployer.DOModelDeployer method) +
  • +
  • create_new_db_schema() (dse_do_utils.scenariorunner.ScenarioRunner method)
  • create_new_scenario() (dse_do_utils.scenariomanager.ScenarioManager static method)
  • @@ -184,6 +186,12 @@

    C

    D

    + -
    +
    • dse_do_utils.deployeddomodelcpd21 @@ -253,8 +265,6 @@

      D

    • module
    -