Source code for rubin_sim.maf.utils.opsim_utils

__all__ = (
    "get_sim_data",
    "get_visit_data",
    "save_visits_as_parquet",
    "scale_benchmarks",
    "calc_coadded_depth",
)

import os
import sqlite3
import urllib
from contextlib import closing
from pathlib import Path
from typing import Any, Union

import numpy as np
import pandas as pd
from sqlalchemy import create_engine, inspect
from sqlalchemy.engine import make_url


def _local_get_sim_data(
    db_con,
    sqlconstraint=None,
    dbcols=None,
    stackers=None,
    table_name=None,
    full_sql_query=None,
    *,
    return_class=np.recarray,
):
    if sqlconstraint is None:
        sqlconstraint = ""

    need_sql: bool = full_sql_query is not None or len(sqlconstraint) > 0

    # Check that file exists
    if isinstance(db_con, str):
        if os.path.isfile(db_con) is False:
            raise FileNotFoundError("No file %s" % db_con)

    # Check what type of source was provided, defaulting
    # to an sqlite3 file if it is a string without an extinsion
    # that identifies it as a parquet or hdf5 file name.
    source_type: str = "sqlite3"
    if isinstance(db_con, str) and db_con.lower().endswith((".h5", ".hdf5")):
        source_type = "hdf5"
    elif isinstance(db_con, str) and db_con.lower().endswith((".parquet", ".pq", ".parq")):
        source_type = "parquet"
    elif isinstance(db_con, sqlite3.Connection):
        source_type = "connection"

    # Check if table is "observations" or "SummaryAllProps"
    if (table_name is None) & (full_sql_query is None) & (isinstance(db_con, str)):
        if source_type == "hdf5":
            # For HDF5, detect table names by reading the store keys
            with pd.HDFStore(db_con, mode="r") as store:
                table_keys = [key.lstrip("/") for key in store.keys()]
            if "observations" in table_keys:
                table_name = "observations"
            elif "SummaryAllProps" in table_keys:
                table_name = "SummaryAllProps"
            elif "summary" in table_keys:
                table_name = "summary"
            else:
                raise ValueError("Could not guess table_name, set with table_name or full_sql_query kwargs")
        elif source_type == "parquet":
            table_name = "observations"
        else:
            url = make_url("sqlite:///" + db_con)
            eng = create_engine(url)
            inspector = inspect(eng)
            tables = [inspector.get_table_names(schema=schema) for schema in inspector.get_schema_names()]
            if "observations" in tables[0]:
                table_name = "observations"
            elif "SummaryAllProps" in tables[0]:
                table_name = "SummaryAllProps"
            elif "summary" in tables[0]:
                table_name = "summary"
            else:
                raise ValueError("Could not guess table_name, set with table_name or full_sql_query kwargs")
    elif (table_name is None) & (full_sql_query is None):
        # If someone passes in a connection object with an old table_name
        # things will fail
        # that's probably fine, keep people from getting fancy with old sims
        table_name = "observations"

    if full_sql_query is None:
        col_str = "*" if dbcols is None else ", ".join(dbcols)

        query = "SELECT %s FROM %s" % (col_str, table_name)
        if len(sqlconstraint) > 0:
            query += " WHERE %s" % (sqlconstraint)
        query += ";"
    else:
        query = full_sql_query

    sim_data: np.recarray | pd.DataFrame | None = None

    match source_type:
        case "connection":
            sim_data = pd.read_sql(query, db_con)
        case "sqlite3":
            with closing(sqlite3.connect(db_con)) as con:
                sim_data = pd.read_sql(query, con)
        case "hdf5":
            if need_sql:
                with closing(sqlite3.connect(":memory:")) as con:
                    with pd.HDFStore(db_con, mode="r") as store:
                        for key in store.keys():
                            table_name = key.lstrip("/")
                            table_values = pd.read_hdf(db_con, key=key)
                            table_values.to_sql(table_name, con, index=False)
                    sim_data = pd.read_sql(query, con)
            else:
                sim_data = pd.read_hdf(db_con, key=table_name)
        case "parquet":
            if need_sql:
                with closing(sqlite3.connect(":memory:")) as con:
                    raw_observations = pd.read_parquet(db_con)
                    raw_observations.to_sql("observations", con, index=False)
                    sim_data = pd.read_sql(query, con)
            else:
                sim_data = pd.read_parquet(db_con)
        case _:
            raise RuntimeError(f"Cannot find {db_con}.")

    if len(sim_data) == 0:
        raise UserWarning("No data found matching sqlconstraint %s" % (sqlconstraint))

    # Now add the stacker columns.
    # This fails for pandas.DataFrames, so convert to recarray
    # if necessary.
    if stackers is not None:
        if not isinstance(sim_data, np.recarray):
            assert isinstance(sim_data, pd.DataFrame)
            sim_data = sim_data.to_records(index=False)
        for s in stackers:
            sim_data = s.run(sim_data).view(np.recarray)

    if return_class is np.recarray:
        if isinstance(sim_data, pd.DataFrame):
            sim_data = sim_data.to_records(index=False)
    if return_class is pd.DataFrame:
        if isinstance(sim_data, np.recarray):
            sim_data = pd.DataFrame(sim_data)

    assert isinstance(sim_data, return_class)

    return sim_data


[docs] def save_visits_as_parquet( visits: Union[pd.DataFrame, Any], parquet_file_path: Union[str, Path], ) -> None: """Save visit data to a Parquet file. Parameters ---------- visits : `pandas.DataFrame` or Any Visit-like tabular data. If not already a `pandas.DataFrame`, the input must be convertible via ``pandas.DataFrame(visits)``. For example, a recarray. parquet_file_path : `str` or `pathlib.Path` Destination path for the output Parquet file. Parent directories are created automatically if they do not exist. Notes ----- This function works around a few issues with pandas auto-detection of column types for columns with NaN or None values. """ # Normalize path and ensure parent directory exists. parquet_path = Path(parquet_file_path) parquet_path.parent.mkdir(parents=True, exist_ok=True) # Convert input to DataFrame, and ensure our adjustments # do not alter the originally passed data. if isinstance(visits, pd.DataFrame): cleaned_visits = visits.copy(deep=True) else: try: cleaned_visits = pd.DataFrame(visits).copy(deep=True) except Exception as exc: raise TypeError("visits must be a pandas.DataFrame or a DataFrame-compatible object.") from exc if "visit_id" in cleaned_visits.columns: cleaned_visits = cleaned_visits.set_index("visit_id", drop=False).rename_axis(index=None) if "index" in cleaned_visits.columns: cleaned_visits = cleaned_visits.drop(columns=["index"]) # Convert object columns that are entirely NaN to float for parquet. obj_cols = cleaned_visits.select_dtypes(include=["object"]).columns all_nan_obj_cols = [c for c in obj_cols if cleaned_visits[c].isna().all()] if all_nan_obj_cols: cleaned_visits[all_nan_obj_cols] = cleaned_visits[all_nan_obj_cols].astype("float64") # For object columns that contain only strings (ignoring nulls), # replace NaN with "". string_columns = [] for col in cleaned_visits.select_dtypes(include=["object"]).columns: non_null = cleaned_visits[col].dropna() if non_null.empty or non_null.map(lambda v: isinstance(v, str)).all(): string_columns.append(col) if string_columns: cleaned_visits[string_columns] = cleaned_visits[string_columns].fillna("") cleaned_visits.to_parquet(parquet_path, index=True)
[docs] def get_sim_data( db_con, sqlconstraint=None, dbcols=None, stackers=None, table_name=None, full_sql_query=None, *, return_class=np.recarray, ): """Query an opsim database for the needed data columns and run any required stackers. Parameters ---------- db_con : `str` or SQLAlchemy connectable, or sqlite3 connection Filename or lsst.resources path to an hdf5 or sqlite3 file, or a connection object that can be used by pandas.read_sql sqlconstraint : `str` or None SQL constraint to apply to query for observations. Ignored if full_sql_query is set. dbcols : `list` [`str`] Columns required from the database. Ignored if full_sql_query is set. stackers : `list` [`rubin_sim.maf.stackers`], optional Stackers to be used to generate additional columns. Default None. table_name : `str` (None) Name of the table to query. Default None will try "observations". Ignored if full_sql_query is set. full_sql_query : `str` The full SQL query to use. Overrides sqlconstraint, dbcols, tablename. Returns ------- sim_data : `np.ndarray` A numpy structured array with columns resulting from dbcols + stackers, for observations matching the SQLconstraint. """ if (not isinstance(db_con, str)) or urllib.parse.urlparse(db_con).scheme == "": # Already have a local copy sim_data = _local_get_sim_data( db_con, sqlconstraint, dbcols, stackers, table_name, full_sql_query, return_class=return_class ) else: try: from lsst.resources import ResourcePath with ResourcePath(db_con).as_local() as local_db_path: sim_data = _local_get_sim_data( local_db_path, sqlconstraint, dbcols, stackers, table_name, full_sql_query, return_class=return_class, ) except ModuleNotFoundError: raise RuntimeError( f"Cannot read visits from {db_con}." "Maybe it does not exist, or maybe you need to install lsst.resources." ) return sim_data
# This almost-alias to get_sim_data is named to be less misleading, # in that the same get_*_data function can be used for real visits # from consdb as well. Return a DF instead of a recarray by default # because much of the code designed for reading consdb output # wants that.
[docs] def get_visit_data( db_con, sqlconstraint=None, dbcols=None, stackers=None, table_name=None, full_sql_query=None, *, return_class=pd.DataFrame, ): """Query an opsim database, returning a `pandas.DataFrame` by default. Thin wrapper around `get_sim_data` with ``return_class`` defaulting to `pandas.DataFrame` instead of `numpy.recarray`. All parameters are forwarded unchanged; see `get_sim_data` for full documentation. Parameters ---------- db_con : `str` or SQLAlchemy connectable, or sqlite3 connection Filename or lsst.resources path to an hdf5 or sqlite3 file, or a connection object that can be used by pandas.read_sql sqlconstraint : `str` or None SQL constraint to apply to query for observations. Ignored if full_sql_query is set. dbcols : `list` [`str`] Columns required from the database. Ignored if full_sql_query is set. stackers : `list` [`rubin_sim.maf.stackers`], optional Stackers to be used to generate additional columns. Default None. table_name : `str` (None) Name of the table to query. Default None will try "observations". Ignored if full_sql_query is set. full_sql_query : `str` The full SQL query to use. Overrides sqlconstraint, dbcols, tablename. return_class : `type`, optional Class of the returned data. Default `pandas.DataFrame`. Returns ------- sim_data : `pandas.DataFrame` A `pandas.DataFrame` with columns resulting from dbcols + stackers, for observations matching the sqlconstraint. See Also -------- get_sim_data : Equivalent function with ``return_class`` defaulting to `numpy.recarray`. """ return get_sim_data( db_con, sqlconstraint=sqlconstraint, dbcols=dbcols, stackers=stackers, table_name=table_name, full_sql_query=full_sql_query, return_class=return_class, )
[docs] def scale_benchmarks(run_length, benchmark="design"): """Set design and stretch values of the number of visits or area of the footprint or seeing/Fwhmeff/skybrightness and single visit depth (based on SRD values). Scales number of visits for the length of the run, relative to 10 years. Parameters ---------- run_length : `float` The length (in years) of the run. benchmark : `str` design or stretch - which version of the SRD values to return. Returns ------- benchmarks: `dict` of floats A dictionary containing the number of visits, area of footprint, seeing and FWHMeff values, skybrightness and single visit depth for either the design or stretch SRD values. """ # Set baseline (default) numbers for the baseline survey length (10 years). baseline = 10.0 design = {} stretch = {} design["nvisitsTotal"] = 825 stretch["nvisitsTotal"] = 1000 design["Area"] = 18000 stretch["Area"] = 20000 design["nvisits"] = {"u": 56, "g": 80, "r": 184, "i": 184, "z": 160, "y": 160} stretch["nvisits"] = {"u": 70, "g": 100, "r": 230, "i": 230, "z": 200, "y": 200} # mag/sq arcsec design["skybrightness"] = { "u": 21.8, "g": 22.0, "r": 21.3, "i": 20.0, "z": 19.1, "y": 17.5, } stretch["skybrightness"] = { "u": 21.8, "g": 22.0, "r": 21.3, "i": 20.0, "z": 19.1, "y": 17.5, } # arcsec - old seeing values design["seeing"] = {"u": 0.77, "g": 0.73, "r": 0.7, "i": 0.67, "z": 0.65, "y": 0.63} stretch["seeing"] = { "u": 0.77, "g": 0.73, "r": 0.7, "i": 0.67, "z": 0.65, "y": 0.63, } # arcsec - new FWHMeff values (scaled from old seeing) design["FWHMeff"] = { "u": 0.92, "g": 0.87, "r": 0.83, "i": 0.80, "z": 0.78, "y": 0.76, } stretch["FWHMeff"] = { "u": 0.92, "g": 0.87, "r": 0.83, "i": 0.80, "z": 0.78, "y": 0.76, } design["singleVisitDepth"] = { "u": 23.9, "g": 25.0, "r": 24.7, "i": 24.0, "z": 23.3, "y": 22.1, } stretch["singleVisitDepth"] = { "u": 24.0, "g": 25.1, "r": 24.8, "i": 24.1, "z": 23.4, "y": 22.2, } # Scale the number of visits. if run_length != baseline: scalefactor = float(run_length) / float(baseline) # Calculate scaled value for design and stretch values of nvisits, # per filter. for f in design["nvisits"]: design["nvisits"][f] = int(np.floor(design["nvisits"][f] * scalefactor)) stretch["nvisits"][f] = int(np.floor(stretch["nvisits"][f] * scalefactor)) if benchmark == "design": return design elif benchmark == "stretch": return stretch else: raise ValueError("Benchmark value %s not understood: use 'design' or 'stretch'" % (benchmark))
[docs] def calc_coadded_depth(nvisits, single_visit_depth): """Calculate the coadded depth expected for a given number of visits and single visit depth. Parameters ---------- nvisits : `dict` of `int` or `float` Dictionary (per filter) of number of visits single_visit_depth : `dict` of `float` Dictionary (per filter) of the single visit depth Returns ------- coadded_depth : `dict` of `float` Dictionary of coadded depths per filter. """ coadded_depth = {} for f in nvisits: if f not in single_visit_depth: raise ValueError("Filter keys in nvisits and single_visit_depth must match") coadded_depth[f] = float(1.25 * np.log10(nvisits[f] * 10 ** (0.8 * single_visit_depth[f]))) if not np.isfinite(coadded_depth[f]): coadded_depth[f] = single_visit_depth[f] return coadded_depth