Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
129 changes: 62 additions & 67 deletions api/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from fastapi import APIRouter, Query, Response
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import select, and_
from sqlalchemy import select
from starlette import status

from api.pagination import CustomPage
Expand All @@ -28,8 +28,9 @@
)
from db import Observation, Sensor, Deployment, Thing
from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor
from services.crud_helper import model_patcher, model_deleter, model_adder
from services.exceptions_helper import PydanticStyleException
from services.crud_helper import model_deleter, model_adder, model_patcher

# from services.exceptions_helper import PydanticStyleException
from services.query_helper import order_sort_filter, simple_get_by_id

router = APIRouter(prefix="/sensor", tags=["sensor"])
Expand All @@ -50,6 +51,9 @@ async def add_sensor(
# ====== PATCH =================================================================


# TODO: datetime_installed and datetime_removed have been moved from the Sensor model to the Deployment model. Do we need to keep the validation for datetime_installed and datetime_removed?


@router.patch("/{sensor_id}", status_code=status.HTTP_200_OK)
async def update_sensor(
sensor_id: int,
Expand All @@ -60,50 +64,50 @@ async def update_sensor(
"""
Update a sensor in the system.
"""
if (
sensor_data.datetime_installed is not None
and sensor_data.datetime_removed is None
):
sensor = simple_get_by_id(session, Sensor, sensor_id)
existing_datetime_removed = sensor.datetime_removed
if (
existing_datetime_removed is not None
and sensor_data.datetime_installed >= existing_datetime_removed
):
detail = {
"loc": ["body", "datetime_installed"],
"msg": f"new datetime installed must be before existing datetime removed of {existing_datetime_removed.isoformat().replace('+00:00', 'Z')}",
"type": "value_error",
"input": {
"datetime_installed": sensor_data.datetime_installed.isoformat().replace(
"+00:00", "Z"
)
},
}
raise PydanticStyleException(
status_code=status.HTTP_409_CONFLICT, detail=[detail]
)
elif (
sensor_data.datetime_installed is None
and sensor_data.datetime_removed is not None
):
sensor = simple_get_by_id(session, Sensor, sensor_id)
existing_datetime_installed = sensor.datetime_installed
if sensor_data.datetime_removed <= existing_datetime_installed:
detail = {
"loc": ["body", "datetime_removed"],
"msg": f"new datetime removed must be after existing datetime installed of {existing_datetime_installed.isoformat().replace('+00:00', 'Z')}",
"type": "value_error",
"input": {
"datetime_removed": sensor_data.datetime_removed.isoformat().replace(
"+00:00", "Z"
)
},
}
raise PydanticStyleException(
status_code=status.HTTP_409_CONFLICT, detail=[detail]
)

# if (
# sensor_data.datetime_installed is not None
# and sensor_data.datetime_removed is None
# ):
# sensor = simple_get_by_id(session, Sensor, sensor_id)
# existing_datetime_removed = sensor.datetime_removed
# if (
# existing_datetime_removed is not None
# and sensor_data.datetime_installed >= existing_datetime_removed
# ):
# detail = {
# "loc": ["body", "datetime_installed"],
# "msg": f"new datetime installed must be before existing datetime removed of {existing_datetime_removed.isoformat().replace('+00:00', 'Z')}",
# "type": "value_error",
# "input": {
# "datetime_installed": sensor_data.datetime_installed.isoformat().replace(
# "+00:00", "Z"
# )
# },
# }
# raise PydanticStyleException(
# status_code=status.HTTP_409_CONFLICT, detail=[detail]
# )
# elif (
# sensor_data.datetime_installed is None
# and sensor_data.datetime_removed is not None
# ):
# sensor = simple_get_by_id(session, Sensor, sensor_id)
# existing_datetime_installed = sensor.datetime_installed
# if sensor_data.datetime_removed <= existing_datetime_installed:
# detail = {
# "loc": ["body", "datetime_removed"],
# "msg": f"new datetime removed must be after existing datetime installed of {existing_datetime_installed.isoformat().replace('+00:00', 'Z')}",
# "type": "value_error",
# "input": {
# "datetime_removed": sensor_data.datetime_removed.isoformat().replace(
# "+00:00", "Z"
# )
# },
# }
# raise PydanticStyleException(
# status_code=status.HTTP_409_CONFLICT, detail=[detail]
# )
#
return model_patcher(session, Sensor, sensor_id, sensor_data, user=user)


Expand All @@ -127,8 +131,8 @@ async def delete_sensor(
async def get_sensors(
session: session_dependency,
user: viewer_dependency,
thing_id: int = None, # Optional filter for thing_id
observed_property: str = None, # Optional filter for observed_property
thing_id: int = None, # Optional filter for thing_id. Filter by the Thing where equipment is deployed
parameter_id: int = None, # Filter by the parameter the sensor/equipment measures
Comment on lines +134 to +135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these query parameters are now present in this endpoint we should make tests to ensure they work as expected and to safeguard future development.

sort: str | None = None,
order: str | None = None,
filter_: str = Query(alias="filter", default=None),
Expand All @@ -138,24 +142,15 @@ async def get_sensors(
This endpoint is a placeholder and should be implemented with actual logic.
"""
sql = select(Sensor)
if thing_id is not None or observed_property is not None:
conditions = []
joins = []
if observed_property is not None:
joins.append(Observation)
conditions.append(Observation.observed_property == observed_property)

if thing_id is not None:
joins.append(Deployment)
joins.append(Thing)
conditions.append(Thing.id == thing_id)

if joins:
for j in joins:
sql = sql.join(j)

if conditions:
sql = sql.where(and_(*conditions))
# --- Logic to filter by Thing ---
# The path is now: Sensor <-> Deployment <-> Thing
if thing_id is not None:
sql = sql.join(Deployment).join(Thing).where(Thing.id == thing_id)

# --- Logic to filter by Parameter ---
# The path is now: Sensor <-> Observation <-> Parameter
if parameter_id is not None:
sql = sql.join(Observation).where(Observation.parameter_id == parameter_id)

sql = order_sort_filter(sql, Sensor, sort=sort, order=order, filter_=filter_)
return paginate(conn=session, query=sql)
Expand Down
31 changes: 24 additions & 7 deletions core/lexicon.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
{"name": "coordinate_method", "description": null},
{"name": "country", "description": null},
{"name": "county", "description": null},
{"name": "well_purpose", "description": null},
{"name": "data_quality", "description": null},
{"name": "data_source", "description": null},
{"name": "depth_completion_source", "description": null},
Expand Down Expand Up @@ -37,12 +36,16 @@
{"name": "sample_matrix", "description": null},
{"name": "sample_method", "description": null},
{"name": "sample_type", "description": null},
{"name": "sensor_type", "description": null},
{"name": "sensor_status", "description": null},
{"name": "spring_type", "description": null},
{"name": "state", "description": null},
{"name": "status", "description": null},
{"name": "thing_type", "description": null},
{"name": "unit", "description": null},
{"name": "vertical_datum", "description": null}
{"name": "vertical_datum", "description": null},
{"name": "well_purpose", "description": null},
{"name": "well_status", "description": null}
],
"terms": [
{"categories": ["qc_type"], "term": "Normal", "definition": "The primary environmental sample collected from the well, spring, or soil boring."},
Expand Down Expand Up @@ -97,6 +100,8 @@
{"categories": ["well_purpose"], "term": "Institutional", "definition": "Institutional"},
{"categories": ["well_purpose"], "term": "Unused", "definition": "Unused"},
{"categories": ["well_purpose"], "term": "Exploration", "definition": "Exploration well"},
{"categories": ["well_purpose"], "term": "Monitoring", "definition": "Monitoring"},
{"categories": ["well_purpose"], "term": "Production", "definition": "Production"},
{"categories": ["well_purpose"], "term": "Injection", "definition": "Injection"},
{"categories": ["data_quality"], "term": "Water level accurate to within two hundreths of a foot", "definition": "Good"},
{"categories": ["data_quality"], "term": "Water level accurate to within one foot", "definition": "Fair"},
Expand Down Expand Up @@ -312,10 +317,10 @@
{"categories": ["groundwater_level_reason"], "term": "Water level affected by stage in nearby surface-water site", "definition": "Water level affected by stage in nearby surface-water site"},
{"categories": ["groundwater_level_reason"], "term": "Other conditions exist that would affect the level (remarks)", "definition": "Other conditions exist that would affect the level (remarks)"},
{"categories": ["groundwater_level_reason"], "term": "Water level not affected", "definition": "Water level not affected"},
{"categories": ["status"], "term": "Abandoned", "definition": "Abandoned"},
{"categories": ["status"], "term": "Active, pumping well", "definition": "Active, pumping well"},
{"categories": ["status"], "term": "Destroyed, exists but not usable", "definition": "Destroyed, exists but not usable"},
{"categories": ["status"], "term": "Inactive, exists but not used", "definition": "Inactive, exists but not used"},
{"categories": ["well_status"], "term": "Abandoned", "definition": "Abandoned"},
{"categories": ["well_status"], "term": "Active, pumping well", "definition": "Active, pumping well"},
{"categories": ["well_status"], "term": "Destroyed, exists but not usable", "definition": "Destroyed, exists but not usable"},
{"categories": ["well_status"], "term": "Inactive, exists but not used", "definition": "Inactive, exists but not used"},
{"categories": ["sample_method"], "term": "Airline measurement", "definition": "Airline measurement"},
{"categories": ["sample_method"], "term": "Analog or graphic recorder", "definition": "Analog or graphic recorder"},
{"categories": ["sample_method"], "term": "Calibrated airline measurement", "definition": "Calibrated airline measurement"},
Expand Down Expand Up @@ -508,6 +513,18 @@
{"categories": ["parameter_type"], "term": "Radionuclide", "definition": "Radionuclide"},
{"categories": ["parameter_type"], "term": "Major Element", "definition": "Major Element"},
{"categories": ["parameter_type"], "term": "Minor Element", "definition": "Minor Element"},
{"categories": ["parameter_type"], "term": "Physical property", "definition": "Physical property"}
{"categories": ["parameter_type"], "term": "Physical property", "definition": "Physical property"},
{"categories": ["sensor_type"], "term": "Pressure Transducer", "definition": "Pressure Transducer"},
{"categories": ["sensor_type"], "term": "Data Logger", "definition": "Data Logger"},
{"categories": ["sensor_type"], "term": "Barometer", "definition": "Barometer"},
{"categories": ["sensor_type"], "term": "Acoustic Sounder", "definition": "Acoustic Sounder"},
{"categories": ["sensor_type"], "term": "Precip Collector", "definition": "Precip Collector"},
{"categories": ["sensor_type"], "term": "Camera", "definition": "Camera"},
{"categories": ["sensor_type"], "term": "Soil Moisture Sensor", "definition": "Soil Moisture Sensor"},
{"categories": ["sensor_type"], "term": "Tipping Bucket", "definition": "Tipping Bucket"},
{"categories": ["sensor_status"], "term": "In Service", "definition": "In Service"},
{"categories": ["sensor_status"], "term": "In Repair", "definition": "In Repair"},
{"categories": ["sensor_status"], "term": "Retired", "definition": "Retired"},
{"categories": ["sensor_status"], "term": "Lost", "definition": "Lost"}
]
}
6 changes: 5 additions & 1 deletion db/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class Deployment(Base, AutoBaseMixin, ReleaseMixin):
removal_date: Mapped[Date] = mapped_column(Date, nullable=True)
recording_interval: Mapped[int] = mapped_column(Integer, nullable=True)
recording_interval_units: Mapped[str] = lexicon_term(nullable=True)
hanging_cable_length: Mapped[float] = mapped_column(Numeric, nullable=True)
hanging_cable_length: Mapped[float] = mapped_column(
Numeric,
nullable=True,
comment="Length of cable from sensor to hanging point, in ft",
)
hanging_point_height: Mapped[float] = mapped_column(Numeric, nullable=True)
hanging_point_description: Mapped[str] = mapped_column(Text, nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=True)
Expand Down
60 changes: 47 additions & 13 deletions db/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ===============================================================================
from datetime import datetime
"""
SQLAlchemy model for the Sensor table.
"""

from sqlalchemy import String, Integer, DateTime
from sqlalchemy import String, Text
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
from sqlalchemy.orm import relationship, mapped_column, Mapped

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

from typing import List, TYPE_CHECKING

Expand All @@ -31,22 +33,54 @@

class Sensor(Base, AutoBaseMixin, ReleaseMixin):
"""
Base class for all sensor types.
The `Sensor` table serves as the central asset inventory for all physical hardware used for data collection.
Its purpose is to track each unique piece of equipment as a distinct asset.

This table is distinct from the `AnalysisMethod` table, as it deals exclusively with tangible, physical objects.

This class can be extended to create specific sensor types.
"""

# Define common attributes for sensors here
# --- Columns ---
nma_pk_equipment: Mapped[str] = mapped_column(
String(36),
nullable=True,
comment="Preserves the primary key (GlobalID) of the Equipment table from NMAquifer.",
)
# TODO: Is a name field necessary? If it is, we should consider standardizing naming conventions.
name: Mapped[str] = mapped_column(String(255), nullable=False)
model: Mapped[str] = mapped_column(String(50), nullable=True)
serial_no: Mapped[str] = mapped_column(String(50), nullable=True)
datetime_installed: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
sensor_type: Mapped[str] = lexicon_term(
nullable=False,
comment="A controlled vocabulary field to categorize the equipment (e.g., 'Pressure Transducer', 'Barometer', 'Data Logger', etc).",
)
model: Mapped[str] = mapped_column(
String(50), nullable=True, comment="The specific model of the equipment."
)
serial_no: Mapped[str] = mapped_column(
String(50),
nullable=True,
unique=True,
comment="The serial number of the equipment.",
)
# TODO: What data type should `pcn_number` be? Should it be a string or integer?
pcn_number: Mapped[str] = mapped_column(
String(50),
nullable=True,
unique=True,
comment="The Property Control Number (PCN) assigned to equipment for inventory tracking. This is only available for equipment owned by the NMBGMR.",
)
owner_agency: Mapped[str] = lexicon_term(
nullable=True, comment="The agency or organization that owns the equipment."
)
sensor_status: Mapped[str] = lexicon_term(
nullable=True,
comment="A controlled vocabulary field to indicate the current status of the equipment (e.g., 'In Service', 'In Repair', 'Retired', 'Lost', etc).",
Comment on lines +75 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that goes in the StatusHistory table?

)
datetime_removed: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=True
notes: Mapped[str] = mapped_column(
Text,
nullable=True,
comment="A field for general notes or comments about the equipment.",
)
recording_interval: Mapped[int] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(String(50), nullable=True)

# --- Relationships ---
# One-To-Many: A piece of Equipment can generate many Observations.
Expand Down
Loading
Loading