Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
09dbfc0
feat: create new `Parameter` model and add it to the `__init__.py` file.
ksmuczynski Sep 26, 2025
6ce3e6f
feat: create new `regulatory_limit` table.
ksmuczynski Sep 26, 2025
14461fb
refactor: remove TODO from `regulatory_limit` table.
ksmuczynski Sep 26, 2025
fb67f57
refactor: modify the TODO in the `parameter` table.
ksmuczynski Sep 26, 2025
a5e8d37
refactor: modify comment for `limit_source` field.
ksmuczynski Sep 26, 2025
a871a2c
Merge branch 'staging' into parameter_model
ksmuczynski Sep 26, 2025
4e2c683
feat: add `parameter` relationship and foreign key to`Observation` mo…
ksmuczynski Sep 26, 2025
f0ef0a8
Formatting changes
ksmuczynski Sep 26, 2025
22f2626
refactor: update relationship name from `reg_limit` to `regulatory_li…
ksmuczynski Sep 27, 2025
ba29abd
Merge remote-tracking branch 'origin/parameter_model' into parameter_…
ksmuczynski Sep 29, 2025
39ce40e
refactor: fix typo in `cas_number` comment
ksmuczynski Sep 29, 2025
808f8ca
feat: add 'matrix' field and unique constraint to `Parameter` table.
ksmuczynski Sep 30, 2025
3e7bd0e
feat: add `RegulatoryLimit` model to the `db/__init__.py` file so it …
ksmuczynski Sep 30, 2025
ac2b0d0
refactor: updated the `lexicon.json` file to use the new normalized s…
ksmuczynski Oct 1, 2025
938ee3a
Merge branch 'staging' into parameter_model
jirhiker Oct 1, 2025
6e3b5e9
refactor: fix pytest failures
ksmuczynski Oct 6, 2025
cfdf8ea
fix: don't restrict lexicon strings
jacob-a-brown Oct 6, 2025
404b0b0
fix: eagerly load parameters
jacob-a-brown Oct 6, 2025
0710483
fix: use function scopes for fixtures to prevent queue pool limit
jacob-a-brown Oct 6, 2025
8879457
fix: coalesce contatinated contact search to prevent null from breaki…
jacob-a-brown Oct 6, 2025
77a541f
reversion: add pytest back to pre commit hooks
jacob-a-brown Oct 6, 2025
5e10768
refactor: Merge staging
ksmuczynski Oct 7, 2025
da5170e
refactor: initialize parameters
jacob-a-brown Oct 7, 2025
a74f404
Merge branch 'parameter_model' of https://github.com/DataIntegrationG…
jacob-a-brown Oct 7, 2025
d39b3db
Merge branch 'staging' into parameter_model
jirhiker Oct 8, 2025
ed5971d
Update test_observation.py
jirhiker Oct 8, 2025
1dad9c0
refactor: update test_observation.py to remove redundant assertions a…
jirhiker Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions api/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# limitations under the License.
# ===============================================================================
from fastapi import APIRouter
from sqlalchemy import select
from sqlalchemy import select, func, text
from sqlalchemy.orm import Session
from api.pagination import CustomPage
from fastapi_pagination import paginate
Expand All @@ -39,14 +39,14 @@

def _get_contact_results(session: Session, q: str, limit: int) -> list[dict]:
vector = (
Contact.search_vector
| Email.search_vector
| Phone.search_vector
| Address.search_vector
func.coalesce(Contact.search_vector, text("''::tsvector"))
.op("||")(func.coalesce(Email.search_vector, text("''::tsvector")))
.op("||")(func.coalesce(Phone.search_vector, text("''::tsvector")))
.op("||")(func.coalesce(Address.search_vector, text("''::tsvector")))
)

query = search(
select(Contact).join(Email).join(Phone).join(Address),
select(Contact).outerjoin(Email).outerjoin(Phone).outerjoin(Address),
q,
vector=vector,
limit=limit,
Expand All @@ -66,7 +66,6 @@ def _get_contact_results(session: Session, q: str, limit: int) -> list[dict]:
}
for c in contacts
]

return results


Expand Down
3 changes: 2 additions & 1 deletion core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)
from fastapi.openapi.utils import get_openapi

from .initializers import init_db, init_lexicon
from .initializers import init_db, init_lexicon, init_parameter
from .settings import settings


Expand All @@ -36,6 +36,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if settings.get_enum("MODE") == "development":
init_db()
init_lexicon()
init_parameter()
yield


Expand Down
31 changes: 31 additions & 0 deletions core/initializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from db import Base
from db.engine import engine, session_ctx
from db.parameter import Parameter
from services.lexicon_helper import add_lexicon_term, add_lexicon_category


Expand Down Expand Up @@ -50,6 +51,36 @@ def init_hypertables():
# session.close()


def init_parameter(path: str = None) -> None:
"""
Populate the parameter table to allow their use in creating and editing
observations
"""
if path is None:
path = Path(__file__).parent / "parameter.json"

with open(path) as f:
import json

default_parameter = json.load(f)

with session_ctx() as session:
for param in default_parameter:
try:
parameter_obj = Parameter(
parameter_name=param["parameter_name"],
matrix=param["matrix"],
parameter_type=param["parameter_type"],
cas_number=param["cas_number"],
default_unit=param["default_unit"],
)
session.add(parameter_obj)
session.commit()
except DatabaseError as e:
print(f"Failed to add parameter {param['parameter_name']}: error: {e}")
session.rollback()


def init_lexicon(path: str = None) -> None:
if path is None:
path = Path(__file__).parent / "lexicon.json"
Expand Down
255 changes: 136 additions & 119 deletions core/lexicon.json

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions core/parameter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[
{
"parameter_name": "groundwater level",
"matrix": "groundwater",
"parameter_type": "Field Parameter",
"cas_number": null,
"default_unit": "ft"
},
{
"parameter_name": "pH",
"matrix": "groundwater",
"parameter_type": "Field Parameter",
"cas_number": null,
"default_unit": "dimensionless"
}
]
2 changes: 2 additions & 0 deletions db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
from db.lexicon import *
from db.location import *
from db.observation import *
from db.parameter import *
from db.permission import *
from db.publication import *
from db.regulatory_limit import *
from db.sample import *
from db.sensor import *
from db.status_history import *
Expand Down
6 changes: 3 additions & 3 deletions db/lexicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# limitations under the License.
# ===============================================================================
from sqlalchemy import String, ForeignKey, Integer
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm import mapped_column, relationship, Mapped
from sqlalchemy.ext.associationproxy import association_proxy, AssociationProxy

from db.base import AutoBaseMixin, Base, lexicon_term
Expand All @@ -27,8 +27,8 @@ class LexiconTerm(Base, AutoBaseMixin):
"""

__tablename__ = "lexicon_term"
term = mapped_column(String(100), unique=True, nullable=False)
definition = mapped_column(String(255), nullable=False)
term: Mapped[str] = mapped_column(unique=True, nullable=False)
definition: Mapped[str] = mapped_column(nullable=False)

category_associations = relationship(
"LexiconTermCategoryAssociation",
Expand Down
11 changes: 10 additions & 1 deletion db/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from db.sample import Sample
from db.sensor import Sensor
from db.analysis_method import AnalysisMethod
from db.parameter import Parameter


class Observation(Base, AutoBaseMixin, ReleaseMixin):
Expand All @@ -50,11 +51,14 @@ class Observation(Base, AutoBaseMixin, ReleaseMixin):
Integer, ForeignKey("analysis_method.id"), nullable=True
)

parameter_id: Mapped[int] = mapped_column(
Integer, ForeignKey("parameter.id"), nullable=False
)

# --- Columns ---
observation_datetime: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, doc="Timestamp of the observation"
)
observed_property: Mapped[str] = lexicon_term(nullable=False)
value: Mapped[float] = mapped_column(
nullable=True,
)
Expand Down Expand Up @@ -88,5 +92,10 @@ class Observation(Base, AutoBaseMixin, ReleaseMixin):
"AnalysisMethod", back_populates="observations"
)

# Many-To-One: An Observation measures one Parameter.
parameter: Mapped["Parameter"] = relationship(
"Parameter", back_populates="observations", lazy="joined"
)


# ============= EOF =============================================
67 changes: 67 additions & 0 deletions db/parameter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
This table is a controlled vocabulary for all analytes, properties, and
characteristics that can be measured or observed.
"""

from typing import List, TYPE_CHECKING

from sqlalchemy.orm import relationship, Mapped, mapped_column

from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term

if TYPE_CHECKING:
from db.observation import Observation
from db.regulatory_limit import RegulatoryLimit


class Parameter(Base, AutoBaseMixin, ReleaseMixin):
"""

Represents an analyte or property that can be measured (e.g., Chloride).
"""

__versioned__ = {}

# --- Columns ---
# TODO: Parameter names are currently associated with the 'observed_property' category in the lexicon. Should we update the lexicon category name to 'parameter_name'?
Comment thread
ksmuczynski marked this conversation as resolved.
parameter_name: Mapped[str] = lexicon_term(
nullable=False,
comment="The official, full name of the parameter (e.g., 'Arsenic, Dissolved').",
)
matrix: Mapped[str] = lexicon_term(
nullable=False,
comment="A controlled vocabulary field defining the physical medium the analyte is measured in (e.g., 'Water', 'Soil', 'Air').",
)
Comment thread
ksmuczynski marked this conversation as resolved.
parameter_type: Mapped[str] = lexicon_term(
nullable=True,
comment="A controlled vocabulary field defining the category of the parameter (e.g., 'Metals', 'Nutrients', 'Field Parameter'). Used for grouping and filtering.",
)
cas_number: Mapped[str] = mapped_column(
nullable=True,
comment="The Chemical Abstracts Service (CAS) registry number, a globally unique identifier for a chemical substance.",
)
default_unit: Mapped[str] = lexicon_term(
nullable=False,
comment="The standard, preferred unit for reporting this parameter (e.g., 'ug/L', 'mg/L', 'pH units').",
Comment thread
ksmuczynski marked this conversation as resolved.
)

# --- Relationships ---
# One-To-Many: A Parameter can have many Observations.
observations: Mapped[List["Observation"]] = relationship(
"Observation", back_populates="parameter"
)

# One-To-Many: A Parameter can have many associated RegulatoryLimits.
# If a Parameter is deleted, all its associated limits are deleted as well.
regulatory_limits: Mapped[List["RegulatoryLimit"]] = relationship(
"RegulatoryLimit", back_populates="parameter", cascade="all, delete-orphan"
)

# --- Table Arguments ---
# An analyte is defined by its name and matrix. This constraint
# ensures a single, specific analyte can only be defined once.
from sqlalchemy import UniqueConstraint

__table_args__ = (
UniqueConstraint("parameter_name", "matrix", name="uq_parameter_name_matrix"),
)
50 changes: 50 additions & 0 deletions db/regulatory_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
This table stores the various regulatory or health-based limits for a given
parameter, sourced from different agencies or standards.
Comment thread
ksmuczynski marked this conversation as resolved.

The purpose of this table is to solve the real-world problem where a single
chemical (`Parameter`) can have multiple different limits set by various agencies
(e.g., a federal EPA limit and a state-level NMED limit).
"""

from typing import TYPE_CHECKING

from sqlalchemy import Integer, Numeric, ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column

from db.base import Base, AutoBaseMixin, ReleaseMixin, lexicon_term

if TYPE_CHECKING:
from db.parameter import Parameter


class RegulatoryLimit(Base, AutoBaseMixin, ReleaseMixin):
"""
Represents a single, citable regulatory or health-based limit for a
specific Parameter.
"""

__versioned__ = {}

# --- Foreign Keys ---
parameter_id: Mapped[int] = mapped_column(
Integer, ForeignKey("parameter.id"), nullable=False
)

# --- Columns ---
limit_source: Mapped[str] = lexicon_term(
nullable=False,
comment="The official source of the limit (e.g., 'EPA', 'NMED', 'EPA').",
)
limit_value: Mapped[float] = mapped_column(Numeric, nullable=False)
limit_unit: Mapped[str] = lexicon_term(nullable=False)
limit_type: Mapped[str] = lexicon_term(
nullable=True,
comment="A controlled vocabulary field to categorize the limit (e.g., 'MCL', 'PQL', 'MDL', etc.).",
)

# --- Relationships ---
# Many-To-One: A RegulatoryLimit is for one Parameter.
parameter: Mapped["Parameter"] = relationship(
"Parameter", back_populates="regulatory_limits"
)
10 changes: 6 additions & 4 deletions schemas/observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from typing_extensions import Self

from schemas import BaseCreateModel, BaseUpdateModel, BaseResponseModel
from schemas.parameter import ParameterResponse


# class GeothermalMixin:
Expand All @@ -36,7 +37,7 @@


class ValidateObservation(BaseModel):
observed_property: str
parameter_id: int
observation_datetime: AwareDatetime

@field_validator("observation_datetime", check_fields=False)
Expand All @@ -60,7 +61,7 @@ class CreateBaseObservation(BaseCreateModel, ValidateObservation):
observation_datetime: Annotated[AwareDatetime, PastDatetime()]
sample_id: int
sensor_id: int
observed_property: str
parameter_id: int
release_status: str
value: float | None
unit: str | None
Expand All @@ -82,7 +83,7 @@ class UpdateBaseObservation(BaseUpdateModel, ValidateObservation):
observation_datetime: Annotated[AwareDatetime, PastDatetime()] | None = None
sample_id: int | None = None
sensor_id: int | None = None
observed_property: str | None = None
parameter_id: int | None = None
release_status: str | None = None
value: float | None | None = None
unit: str | None = None
Expand All @@ -98,11 +99,12 @@ class UpdateWaterChemistryObservation(UpdateBaseObservation):


# -------- RESPONSE ----------
# TODO: Return full sample and sensor objects
class BaseObservationResponse(BaseResponseModel):
sample_id: int
sensor_id: int
observation_datetime: AwareDatetime
observed_property: str
parameter: ParameterResponse
release_status: str
value: float | None
unit: str
Expand Down
15 changes: 15 additions & 0 deletions schemas/parameter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from schemas import BaseResponseModel


# -------- RESPONSE -------
class ParameterResponse(BaseResponseModel):
"""
Pydantic model for the response of a parameter.
This model can be extended to include additional fields as needed.
"""

parameter_name: str
matrix: str
parameter_type: str | None
cas_number: str | None
default_unit: str
Loading
Loading