Source code for src.database_utils

"""SQLite storage for processed CFS forecast data.

Wraps a single SQLite database/table behind :class:`CFSDatabase`, providing
schema creation, row- and DataFrame-level inserts, point lookups, and helpers
for figuring out which forecast run to download next. The schema keys each
value by ``(cfs_run, year, month, lake, surface_type, component)``.

The forecast notebooks use this module to persist processed CFS output and to
resume incremental downloads where the previous run left off.
"""

import sqlite3
import os
import pandas as pd
import sys
import time
import sqlite3
from datetime import datetime, timedelta, timezone
from dateutil.relativedelta import relativedelta

[docs] class CFSDatabase: """Manage a SQLite database of processed CFS forecast values. Each instance is bound to one database file and one table. The table stores one value per ``(cfs_run, year, month, lake, surface_type, component)`` key. Methods cover schema creation, inserting individual records or whole DataFrames, point lookups, and determining the next CFS run to download. Parameters ---------- database : str Path to the SQLite database file (created if it does not exist). table : str Name of the table to read from and write to. """
[docs] def __init__(self, database, table): """ Initialize database connection and ensure table exists. """ self.database = database self.table = table self._initialize_database()
def _initialize_database(self): """ Ensure database file exists. """ conn = sqlite3.connect(self.database) conn.close()
[docs] def create_cfs_table(self): """ Create the standard CFS table schema if it does not exist. """ with sqlite3.connect(self.database) as conn: conn.execute(f''' CREATE TABLE IF NOT EXISTS {self.table} ( cfs_run TEXT, year INTEGER, month INTEGER, lake TEXT, surface_type TEXT, component TEXT, value REAL, PRIMARY KEY (cfs_run, year, month, lake, surface_type, component) ) ''')
[docs] def load(self): """ Load the entire table into a DataFrame. Returns ------- pandas.DataFrame All rows of the configured table. """ # Create a connection to the SQLite database conn = sqlite3.connect(self.database) # Use an f-string to insert the table name properly query = f'SELECT * FROM {self.table}' # Execute the query and fetch the data into a DataFrame data = pd.read_sql(query, conn) # Close the connection once done conn.close() return data
[docs] def pull(self, cfs_run, year, month, lake, surface_type, component): """ Look up a single stored value by its full primary key. Detects whether the table uses a ``value`` or ``value [mm]`` column and queries accordingly. Parameters ---------- cfs_run : str CFS run identifier (YYYYMMDDHH). year : int Forecast year. month : int Forecast month (1-12). lake : str Lake name. surface_type : str Surface type ('lake' or 'land'). component : str NBS component ('precipitation', 'evaporation', 'runoff', 'nbs'). Returns ------- float or None The stored value, or None if no matching row exists or a database error occurs. """ try: conn = sqlite3.connect(self.database) cursor = conn.cursor() # --- Detect which "value" column exists --- cursor.execute(f"PRAGMA table_info({self.table})") columns = [row[1] for row in cursor.fetchall()] if "value [mm]" in columns: value_col = '"value [mm]"' elif "value" in columns: value_col = "value" else: raise RuntimeError( f"Neither 'value' nor 'value [mm]' column found in table '{self.table}'." ) # --- Build query using detected column name --- query = f''' SELECT {value_col} FROM {self.table} WHERE cfs_run = ? AND year = ? AND month = ? AND lake = ? AND surface_type = ? AND component = ? ''' cursor.execute(query, (cfs_run, year, month, lake, surface_type, component)) result = cursor.fetchone() conn.close() if result: return result[0] else: print(f"No data found for {locals()}") return None except sqlite3.Error as e: print(f"Database error: {e}") return None except Exception as e: print(f"Error: {e}") return None
[docs] def add(self, cfs_run, year, month, lake, surface_type, component, value): """ Safely adds or replaces a record in the database table using SQLite. Parameters: cfs_run (str): The CFS run identifier (YYYYMMDDHH). year (int): Forecast year (e.g., 2024, 2025). month (int): Forecast month (1–12). lake (str): Lake name. surface_type (str): Surface type ('lake' or 'land'). component (str): NBS Component ('precipitation', 'evaporation', 'runoff', 'cnbs'). value (float): Data value. Raises: ValueError: For invalid parameter types or ranges. sqlite3.DatabaseError: For database interaction errors. """ # Ensure the table exists before attempting to add data self.create_cfs_table() # --- Input validation --- if not isinstance(year, int): raise ValueError("ERROR: Year must be an integer.") if not (1 <= month <= 12): raise ValueError("ERROR: Month must be between 1 and 12.") if not all(isinstance(v, str) for v in [cfs_run, lake, surface_type, component]): raise ValueError("ERROR: cfs_run, lake, surface_type, and component must be strings.") if not isinstance(value, (float, int)): raise ValueError("ERROR: Value must be numeric.") max_retries = 5 retry_delay = 3 # seconds for attempt in range(1, max_retries + 1): try: # Use context manager for safe connection handling with sqlite3.connect(self.database, timeout=30) as conn: conn.execute("PRAGMA journal_mode=WAL;") # allows concurrent reads cursor = conn.cursor() # Get table columns to find correct value column cursor.execute(f"PRAGMA table_info({self.table})") columns = [row[1].strip() for row in cursor.fetchall()] if "value [mm]" in columns: value_col = '"value [mm]"' elif "value" in columns: value_col = "value" else: raise RuntimeError( f"Neither 'value' nor 'value [mm]' column found in table '{self.table}'" ) # Insert or replace record query = f""" INSERT OR REPLACE INTO {self.table} ( cfs_run, year, month, lake, surface_type, component, {value_col} ) VALUES (?, ?, ?, ?, ?, ?, ?) """ cursor.execute(query, (cfs_run, year, month, lake, surface_type, component, value)) conn.commit() return # ✅ success — exit early except sqlite3.OperationalError as e: # Handle transient locking or “readonly” issues if "locked" in str(e).lower() or "readonly" in str(e).lower(): print(f"Database busy or locked (attempt {attempt}/{max_retries}). Retrying in {retry_delay}s...") time.sleep(retry_delay) continue else: raise sqlite3.DatabaseError(f"Database error occurred: {e}") except sqlite3.DatabaseError as e: raise sqlite3.DatabaseError(f"Database error occurred: {e}") print(f"Failed to write record after {max_retries} attempts — skipping forecast.")
[docs] def add_df(self, df, if_exists="append"): """ Add an entire pandas DataFrame to the database table. Parameters ---------- df : pandas.DataFrame The DataFrame to insert. Must contain the columns ``['cfs_run', 'forecast_month', 'model', 'lake', 'precipitation', 'evaporation', 'runoff', 'nbs']``. if_exists : str, default "append" How to behave if the table already exists. One of ``'fail'``, ``'replace'``, or ``'append'``. """ required_cols = ['cfs_run', 'forecast_month', 'model', 'lake', 'precipitation', 'evaporation', 'runoff', 'nbs'] missing_cols = [col for col in required_cols if col not in df.columns] if missing_cols: raise ValueError(f"Missing required columns in DataFrame: {missing_cols}") try: conn = sqlite3.connect(self.database) # Insert DataFrame into the database df.to_sql(self.table, conn, if_exists=if_exists, index=False) conn.close() except sqlite3.DatabaseError as e: raise sqlite3.DatabaseError(f"Database error occurred while inserting DataFrame: {e}")
[docs] def get_next_run(self): """ Determine the next CFS run date to download. Reads the most recent ``cfs_run`` in the table and returns the date from which downloading should resume: the same date at midnight if the last run was the 00/06/12 cycle, or the following day if it was the 18 cycle. If the table is missing or empty, falls back to the first of the month nine months ago. Returns ------- datetime.datetime The next run date, with the time component zeroed. """ try: conn = sqlite3.connect(self.database) cursor = conn.cursor() # Check if the table exists cursor.execute(f''' SELECT name FROM sqlite_master WHERE type='table' AND name='{self.table}' ''') if not cursor.fetchone(): raise sqlite3.OperationalError(f"Table '{self.table}' does not exist. Resorting to fallback date.") # Get the most recent run cursor.execute(f''' SELECT cfs_run FROM {self.table} ORDER BY cfs_run DESC LIMIT 1 ''') result = cursor.fetchone() conn.close() if result and result[0]: raw_value = str(result[0]).strip() # Try parsing with common formats for fmt in ("%Y%m%d%H", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H"): try: last_run = datetime.strptime(raw_value, fmt) break except ValueError: continue else: raise ValueError(f"Unrecognized datetime format: {raw_value}") hour = last_run.hour # Decide what to return based on the hour if hour in (0, 6, 12): # Same date at midnight next_run = last_run.replace(hour=0, minute=0, second=0, microsecond=0) print(f"WARNING: Last date {next_run.strftime('%m-%d-%Y')} did not download all runs. Redownloading beginning from that date.") elif hour == 18: # Next day at midnight next_run = (last_run + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) else: # Unexpected hour - fallback behavior, treat as same date at midnight with warning next_run = last_run.replace(hour=0, minute=0, second=0, microsecond=0) print(f"WARNING: Unexpected hour {hour} in last run date. Defaulting to {next_run.strftime('%m-%d-%Y')} at midnight.") return next_run # datetime object with time zeroed else: raise ValueError("Table exists but no previous run was found. Resorting to fallback date.") except (sqlite3.Error, ValueError) as e: print(f"WARNING: {e}") # Fallback: first of the month, 9 months ago at midnight UTC now_utc = datetime.utcnow().replace(tzinfo=None) nine_months_ago = now_utc.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - relativedelta(months=9) return nine_months_ago # datetime object at midnight
[docs] def get_date_range(self, auto, start_date, end_date): """ Determine the start and end dates for CFS CSV downloads, and return the date range. Parameters ---------- auto : str, Whether to automatically fetch the next run date from the database ('yes' or 'no'). start_date : str, Manual start date in format 'MM-DD-YYYY'. Required if auto='no'. end_date : str, Manual end date in format 'MM-DD-YYYY'. Required if auto='no'. Returns ------- tuple (start_date: datetime, end_date: datetime, date_array: pd.DatetimeIndex) """ if auto.lower() == 'yes': start_date = self.get_next_run() end_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) elif auto.lower() == 'no': start_date = datetime.strptime(start_date, "%m-%d-%Y") end_date = datetime.strptime(end_date, "%m-%d-%Y") else: raise ValueError("Invalid value for 'auto'. Please enter 'yes' or 'no'.") # Validate dates if start_date == end_date: print("The CSV files are up-to-date. Script will exit now.") sys.exit(0) elif start_date > end_date: raise ValueError("End date cannot be older than start date. Try again.") else: print(f"Starting from: {start_date.strftime('%m-%d-%Y')} and continuing through: {end_date.strftime('%m-%d-%Y')}") date_array = pd.date_range(start=start_date, end=end_date, freq='1d') return start_date, end_date, date_array
[docs] def print_columns(self): """ Print the name and declared type of each column in the table. Intended as an interactive/debugging helper; prints a message if the table does not exist or has no columns. """ try: conn = sqlite3.connect(self.database) cursor = conn.cursor() cursor.execute(f'PRAGMA table_info({self.table})') columns = cursor.fetchall() conn.close() if columns: print(f"Columns in table '{self.table}':") for col in columns: print(f"- {col[1]} ({col[2]})") else: print(f"Table '{self.table}' does not exist or has no columns.") except sqlite3.Error as e: print(f"Database error: {e}")