Add SetTellPositionRequest schema and minor cleanup.

Added a new `SetTellPositionRequest` schema in `schemas.py` to support bulk updates of TELL positions. Commented out redundant metadata operations in `main.py` and cleaned up unused content in the test notebook for better readability.
This commit is contained in:
GotthardG
2025-02-04 14:43:59 +01:00
parent fef9b1c618
commit 780ba1959f
4 changed files with 553 additions and 380 deletions

View File

@ -11,14 +11,13 @@ from app.schemas import (
PuckUpdate,
PuckWithTellPosition,
Sample,
SetTellPosition,
SetTellPositionRequest,
DataCollectionParameters,
)
from app.models import (
Puck as PuckModel,
PuckEvent as PuckEventModel,
Sample as SampleModel,
Slot as SlotModel,
LogisticsEvent as LogisticsEventModel,
Dewar as DewarModel,
)
@ -30,6 +29,15 @@ router = APIRouter()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
VALID_TELL_OPTIONS = {"X06SA", "X06DA", "X10SA"}
def validate_tell(tell: str):
if tell not in VALID_TELL_OPTIONS:
raise ValueError(
f"Invalid tell: {tell}. Must be one of {', '.join(VALID_TELL_OPTIONS)}"
)
def normalize_puck_name(name: str) -> str:
"""
@ -39,6 +47,154 @@ def normalize_puck_name(name: str) -> str:
return name
def resolve_slot_id(slot_identifier: str) -> int:
"""
Convert a slot identifier (either numeric or alias) to a numeric slot ID.
Args:
slot_identifier (str): The slot identifier to resolve (e.g., "PXI",
"PXII", "48").
Returns:
int: The numeric slot ID corresponding to the identifier.
Raises:
HTTPException: If the slot identifier is invalid or unrecognized.
"""
# Map slot identifier keywords to numeric slot IDs
slot_aliases = {
"PXI": 47,
"PXII": 48,
"PXIII": 49,
"X06SA": 47,
"X10SA": 48,
"X06DA": 49,
}
# Try to resolve the identifier
try:
return int(slot_identifier) # If it's a numeric slot ID, return it directly
except ValueError:
# Convert alias to slot ID using the mapping
slot_id = slot_aliases.get(slot_identifier.upper())
if slot_id:
return slot_id
# Log error and raise an exception for invalid identifiers
logger.error(f"Invalid slot identifier: {slot_identifier}")
raise HTTPException(
status_code=400, detail=f"Invalid slot identifier: {slot_identifier}"
)
def get_pucks_at_beamline(slot_id: int, db: Session) -> List[PuckWithTellPosition]:
"""
Fetch all pucks currently located at the beamline for a given slot ID.
"""
# Subquery: Latest logistic event for each dewar
latest_event_subquery = (
db.query(
LogisticsEventModel.dewar_id.label("dewar_id"),
func.max(LogisticsEventModel.timestamp).label("latest_event_time"),
)
.group_by(LogisticsEventModel.dewar_id)
.subquery(name="latest_event_subquery")
)
# Query dewars in the slot with the latest event "beamline"
dewars = (
db.query(DewarModel)
.join(LogisticsEventModel, DewarModel.id == LogisticsEventModel.dewar_id)
.join(
latest_event_subquery,
(LogisticsEventModel.dewar_id == latest_event_subquery.c.dewar_id)
& (
LogisticsEventModel.timestamp
== latest_event_subquery.c.latest_event_time
),
)
.filter(
LogisticsEventModel.slot_id == slot_id,
LogisticsEventModel.event_type == "beamline",
)
.all()
)
if not dewars:
logger.warning(f"No dewars found for slot ID: {slot_id}")
return []
# Map dewars to their details
dewar_ids = [dewar.id for dewar in dewars]
dewar_map = {dewar.id: dewar.dewar_name for dewar in dewars}
dewar_pgroups = {dewar.id: dewar.pgroups for dewar in dewars}
# Subquery: Latest event for each puck
latest_puck_event_subquery = (
db.query(
PuckEventModel.puck_id.label("puck_id"),
func.max(PuckEventModel.timestamp).label("latest_event_time"),
)
.group_by(PuckEventModel.puck_id)
.subquery(name="latest_event_subquery")
)
# Query pucks for the selected dewars
pucks_with_latest_events = (
db.query(
PuckModel,
PuckEventModel.event_type,
PuckEventModel.tell_position,
DewarModel,
)
.join(
latest_puck_event_subquery,
PuckModel.id == latest_puck_event_subquery.c.puck_id,
isouter=True,
)
.join(
PuckEventModel,
(PuckEventModel.puck_id == latest_puck_event_subquery.c.puck_id)
& (
PuckEventModel.timestamp
== latest_puck_event_subquery.c.latest_event_time
),
isouter=True,
)
.join(DewarModel, PuckModel.dewar_id == DewarModel.id, isouter=True)
.filter(PuckModel.dewar_id.in_(dewar_ids))
.all()
)
# Prepare the results
results = []
for puck, event_type, tell_position, dewar in pucks_with_latest_events:
dewar_name = dewar_map.get(puck.dewar_id, "Unknown")
pgroup = dewar_pgroups.get(puck.dewar_id)
# For pucks with no events or whose latest event is "puck_removed", set
# tell_position to None
if event_type is None or event_type == "puck_removed":
tell_position = None
results.append(
PuckWithTellPosition(
id=puck.id,
pgroup=pgroup,
puck_name=puck.puck_name,
puck_type=puck.puck_type,
puck_location_in_dewar=int(puck.puck_location_in_dewar)
if puck.puck_location_in_dewar
else None,
dewar_id=puck.dewar_id,
dewar_name=dewar_name,
tell_position=tell_position,
)
)
return results
@router.get("/", response_model=List[PuckSchema])
async def get_pucks(db: Session = Depends(get_db)):
return db.query(PuckModel).all()
@ -46,146 +202,235 @@ async def get_pucks(db: Session = Depends(get_db)):
@router.put("/set-tell-positions", status_code=status.HTTP_200_OK)
async def set_tell_positions(
pucks: List[SetTellPosition], db: Session = Depends(get_db)
payload: SetTellPositionRequest, # Accept the wrapped request as a single payload
db: Session = Depends(get_db),
):
results = []
# Extract the tell position (slot identifier) and the list of pucks
tell = payload.tell
pucks = payload.pucks
# Retrieve all pucks in the database with their most recent
# `tell_position_set` event
all_pucks_with_last_event = (
db.query(PuckModel, PuckEventModel)
.outerjoin(PuckEventModel, PuckEventModel.puck_id == PuckModel.id)
.order_by(PuckEventModel.puck_id, PuckEventModel.timestamp.desc())
.all()
)
try:
# Resolve slot ID from the provided identifier
slot_id = resolve_slot_id(tell)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid tell or slot identifier: {tell}." f" Error: {str(e)}",
)
# Dictionary mapping each puck's ID to its latest event
last_events = {}
for puck, last_event in all_pucks_with_last_event:
if puck.id not in last_events: # Only store the latest event for each puck
last_events[puck.id] = last_event
# Fetch existing pucks at the beamline slot
pucks_at_beamline = get_pucks_at_beamline(slot_id, db)
beamline_puck_map = {
normalize_puck_name(puck.puck_name): puck for puck in pucks_at_beamline
}
# Track processed puck IDs to avoid double-processing
processed_pucks = set()
# Check if the payload has any pucks
if not pucks: # Empty payload case
results = []
# Process pucks provided in the payload
for puck_data in pucks:
try:
# Extract data from input
puck_name = puck_data.puck_name
new_position = (
puck_data.tell_position
) # Combined from segment + puck_in_segment
normalized_name = normalize_puck_name(puck_name)
# Deduplicate pucks based on ID
unique_pucks_at_beamline = {
puck.id: puck for puck in pucks_at_beamline
}.values()
# Find puck in the database
puck = (
db.query(PuckModel)
for puck in unique_pucks_at_beamline:
# Fetch the most recent event for the puck
last_event = (
db.query(PuckEventModel)
.filter(
func.replace(func.upper(PuckModel.puck_name), "-", "")
== normalized_name
PuckEventModel.puck_id == puck.id,
)
.order_by(
PuckEventModel.id.desc()
) # Order by timestamp ensures we get the latest event
.first()
)
if not puck:
raise ValueError(f"Puck with name '{puck_name}' not found.")
# Log the last event for the puck
if last_event:
logger.info(
f"Processing puck: {puck.puck_name}, "
f"Last Event -> Type: {last_event.event_type}, "
f"Tell Position: {last_event.tell_position}, "
f"Timestamp: {last_event.timestamp}"
)
else:
logger.info(
f"Processing puck: {puck.puck_name}, No events found for this puck"
)
# Mark this puck as processed
processed_pucks.add(puck.id)
# Remove all pucks, including those without events or with None
# tell_position
if last_event.tell_position is not None:
try:
# Add a puck_removed event
remove_event = PuckEventModel(
puck_id=puck.id,
tell=None, # Nullify the `tell` for removal
tell_position=None,
event_type="puck_removed",
timestamp=datetime.utcnow(),
)
db.add(remove_event)
# Query the last event for this puck
last_event = last_events.get(puck.id)
# Record this removal in the response
results.append(
{
"puck_name": puck.puck_name,
"tell": tell,
"removed_position": last_event.tell_position
if last_event
else None,
"status": "removed",
"message": "Puck removed due to empty payload.",
}
)
except Exception as e:
# Handle and log the error for this particular puck
results.append(
{
"puck_name": puck.puck_name,
"error": str(e),
}
)
# Rule 1: Skip if the last event's `tell_position` matches the new position
if last_event and last_event.tell_position == new_position:
# Commit all removal events and return the results
db.commit()
return results
# If the payload contains pucks, continue with the regular logic
results = []
processed_pucks = (
set()
) # To track pucks that have been processed (unchanged or updated)
# Existing pucks' most recent events
last_events_map = {
puck.id: db.query(PuckEventModel)
.filter(
PuckEventModel.puck_id == puck.id,
PuckEventModel.event_type == "tell_position_set",
)
.order_by(PuckEventModel.timestamp.desc())
.first()
for puck in pucks_at_beamline
}
# Step 1: Process each puck in the payload
for puck_data in pucks:
try:
puck_name = puck_data.puck_name
normalized_name = normalize_puck_name(puck_name)
new_position = puck_data.tell_position
existing_puck = beamline_puck_map.get(normalized_name)
if not existing_puck:
# If the puck is not found, it's a potential error
results.append(
{
"puck_name": puck.puck_name,
"current_position": new_position,
"status": "unchanged",
"message": "No change in tell_position. No event created.",
"puck_name": puck_name,
"error": f"Puck '{puck_name}' not found at the beamline.",
}
)
continue
# Rule 2: Add a "puck_removed" event if the last tell_position is not None
processed_pucks.add(existing_puck.id) # Mark this puck as processed
# Check if the tell position is unchanged
last_event = last_events_map.get(existing_puck.id)
if last_event and last_event.tell_position == new_position:
results.append(
{
"puck_name": puck_name,
"tell": tell,
"current_position": new_position,
"status": "unchanged",
"message": "No change in tell_position.",
}
)
continue
# Add a "puck_removed" event if the position is being changed (old
# position removed)
if last_event and last_event.tell_position is not None:
remove_event = PuckEventModel(
puck_id=puck.id,
puck_id=existing_puck.id,
tell=None,
tell_position=None,
event_type="puck_removed", # Event type set to "puck_removed"
timestamp=datetime.now(),
event_type="puck_removed",
timestamp=datetime.utcnow(),
)
db.add(remove_event)
# Add a new "tell_position_set" event
if new_position:
new_event = PuckEventModel(
puck_id=puck.id,
tell_position=new_position,
event_type="tell_position_set",
timestamp=datetime.utcnow(),
)
db.add(new_event)
results.append(
{
"puck_name": puck.puck_name,
"new_position": new_position,
"previous_position": last_event.tell_position
if last_event
else None,
"status": "updated",
"message": "The tell_position was updated successfully.",
}
)
db.commit()
except Exception as e:
# Handle individual puck errors
results.append({"puck_name": puck_data.puck_name, "error": str(e)})
# Process pucks not included in the payload but present in the database
for puck_id, last_event in last_events.items():
# Skip pucks already processed in the previous loop
if puck_id in processed_pucks:
continue
puck = db.query(PuckModel).filter(PuckModel.id == puck_id).first()
if not puck:
continue
# Skip if the last event's tell_position is already null
if not last_event or last_event.tell_position is None:
continue
try:
# Add a "puck_removed" event
remove_event = PuckEventModel(
puck_id=puck.id,
tell_position=None,
event_type="puck_removed", # Event type set to "puck_removed"
# Add a new "tell_position_set" event (new position)
new_event = PuckEventModel(
puck_id=existing_puck.id,
tell=tell,
tell_position=new_position,
event_type="tell_position_set",
timestamp=datetime.utcnow(),
)
db.add(remove_event)
db.add(new_event)
results.append(
{
"puck_name": puck.puck_name,
"removed_position": last_event.tell_position,
"status": "removed",
"message": "Puck is not in payload and"
" has been marked as removed from tell_position.",
"puck_name": puck_name,
"tell": tell,
"new_position": new_position,
"previous_position": (
last_event.tell_position if last_event else None
),
"status": "updated",
"message": "Tell position updated successfully.",
}
)
db.commit()
except Exception as e:
# Handle errors for individual puck removal
results.append({"puck_name": puck.puck_name, "error": str(e)})
results.append(
{
"puck_name": puck_name,
"error": str(e),
}
)
# Step 2: Handle "absent" pucks for removal
for puck in pucks_at_beamline:
if puck.id not in processed_pucks: # This puck was not in the payload
last_event = last_events_map.get(
puck.id
) # Fetch the last event for the puck
# Only remove pucks that have a valid last event with a non-null
# tell_position
if last_event and last_event.tell_position is not None:
try:
remove_event = PuckEventModel(
puck_id=puck.id,
tell=None,
tell_position=None,
event_type="puck_removed",
timestamp=datetime.utcnow(),
)
db.add(remove_event)
results.append(
{
"puck_name": puck.puck_name,
"tell": tell,
"removed_position": last_event.tell_position,
"status": "removed",
"message": "Puck removed from tell_position.",
}
)
except Exception as e:
results.append(
{
"puck_name": puck.puck_name,
"error": str(e),
}
)
# Step 3: Commit all changes to the DB
db.commit()
return results
@ -193,13 +438,12 @@ async def set_tell_positions(
@router.get("/with-tell-position", response_model=List[PuckWithTellPosition])
async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
"""
Retrieve all pucks with a `tell_position` set (not null),
Retrieve all pucks with a valid `tell_position` set (non-null),
their associated samples, and the latest `tell_position` value (if any).
Only include pucks when their latest event has a `tell_position`
set and matches "tell_position_set".
set and an `event_type` matching "tell_position_set".
"""
# Step 1: Prepare a subquery to fetch the latest event timestamp for each
# puck with a non-null tell_position
# Step 1: Prepare a subquery to fetch the latest event timestamp for each puck.
latest_event_subquery = (
db.query(
PuckEventModel.puck_id,
@ -209,7 +453,7 @@ async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
.subquery()
)
# Step 2: Query the pucks and their latest `tell_position` by joining the subquery
# Step 2: Main query - fetch pucks with latest `tell_position` event details
pucks_with_events = (
db.query(PuckModel, PuckEventModel, DewarModel)
.join(PuckEventModel, PuckModel.id == PuckEventModel.puck_id)
@ -220,21 +464,23 @@ async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
)
.outerjoin(
DewarModel, PuckModel.dewar_id == DewarModel.id
) # Outer join with DewarModel
) # Optional, include related dewar info
.filter(
PuckEventModel.tell_position.isnot(None)
) # Only include non-null `tell_position`
.filter(
PuckEventModel.event_type == "tell_position_set"
) # Only include relevant event types
.all()
)
# Return an empty list if no relevant pucks are found
if not pucks_with_events:
return []
# Step 3: Construct the response with pucks and their latest tell_position
# Step 3: Construct the response with pucks and their valid tell_position
results = []
# Debug output for verification
print(f"Pucks with Events and Dewars: {pucks_with_events}")
for puck, event, dewar in pucks_with_events:
print(f"Puck: {puck}, Event: {event}, Dewar: {dewar}")
if event.tell_position is None:
continue
# Fetch associated samples for this puck
samples = db.query(SampleModel).filter(SampleModel.puck_id == puck.id).all()
@ -252,8 +498,8 @@ async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
if dewar and dewar.dewar_name
else None,
pgroup=str(dewar.pgroups)
if dewar.pgroups
else None, # will be replaced later by puck pgroup
if dewar and dewar.pgroups
else None, # Replace later by puck pgroup if needed
samples=[
Sample(
id=sample.id,
@ -271,7 +517,7 @@ async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
)
for sample in samples
],
tell_position=str(event.tell_position) if event else None,
tell_position=str(event.tell_position),
)
)
@ -361,163 +607,38 @@ async def get_last_tell_position(puck_id: str, db: Session = Depends(get_db)):
async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db)):
"""
Retrieve all pucks in a slot, reporting their latest event and
`tell_position` value.
`tell_position` value.
Args:
slot_identifier (str): The slot identifier (e.g., "PXI", "48").
db (Session): Database session dependency.
Returns:
List[PuckWithTellPosition]: List of pucks in the specified slot.
"""
# Map keywords to slot IDs
slot_aliases = {
"PXI": 47,
"PXII": 48,
"PXIII": 49,
"X06SA": 47,
"X10SA": 48,
"X06DA": 49,
}
# Resolve slot ID or alias
# Resolve the slot identifier to a numeric slot ID using the function
try:
slot_id = int(slot_identifier)
except ValueError:
slot_id = slot_aliases.get(slot_identifier.upper())
if not slot_id:
logger.error(f"Invalid slot alias: {slot_identifier}")
raise HTTPException(
status_code=400, detail=f"Invalid slot identifier: {slot_identifier}"
)
slot_id = resolve_slot_id(slot_identifier)
except HTTPException as e:
logger.error(
f"Failed to resolve slot identifier: {slot_identifier}. Error: {e.detail}"
)
raise e
logger.info(f"Resolved slot identifier: {slot_identifier} to Slot ID: {slot_id}")
logger.info(f"Resolved slot identifier '{slot_identifier}' to Slot ID: {slot_id}")
# Verify slot existence
slot = db.query(SlotModel).filter(SlotModel.id == slot_id).first()
if not slot:
logger.error(f"Slot not found: {slot_identifier}")
# Fetch the pucks at the beamline for the resolved slot ID
pucks = get_pucks_at_beamline(slot_id, db)
if not pucks:
logger.warning(
f"No pucks found for the slot '{slot_identifier}' (ID: {slot_id})"
)
raise HTTPException(
status_code=404, detail=f"Slot not found for identifier {slot_identifier}"
)
# Subquery to find the latest event for each dewar
latest_event_subquery = (
db.query(
LogisticsEventModel.dewar_id.label("dewar_id"),
func.max(LogisticsEventModel.timestamp).label("latest_event_time"),
)
.group_by(LogisticsEventModel.dewar_id)
.subquery(name="latest_event_subquery")
)
# Main query to fetch dewars where the latest event is "beamline"
dewars = (
db.query(DewarModel)
.join(LogisticsEventModel, DewarModel.id == LogisticsEventModel.dewar_id)
.join(
latest_event_subquery,
(LogisticsEventModel.dewar_id == latest_event_subquery.c.dewar_id)
& (
LogisticsEventModel.timestamp
== latest_event_subquery.c.latest_event_time
), # Match latest event
)
.filter(
LogisticsEventModel.slot_id == slot_id,
LogisticsEventModel.event_type
== "beamline", # Ensure latest event is "beamline"
)
.all()
)
if not dewars:
logger.warning(f"No dewars found for slot: {slot_identifier}")
raise HTTPException(
status_code=404, detail=f"No dewars found for slot {slot_identifier}"
status_code=404, detail=f"No pucks found for slot '{slot_identifier}'"
)
logger.info(
f"Found dewars for slot {slot_identifier}: {[dewar.id for dewar in dewars]}"
f"Found {len(pucks)} pucks for slot '{slot_identifier}' (ID: {slot_id})"
)
dewar_ids = [dewar.id for dewar in dewars]
dewar_map = {dewar.id: dewar.dewar_name for dewar in dewars}
dewar_pgroups = {dewar.id: dewar.pgroups for dewar in dewars}
# Subquery to fetch the latest event for each puck (any type of event)
latest_event_subquery = (
db.query(
PuckEventModel.puck_id.label("puck_id"),
func.max(PuckEventModel.timestamp).label("latest_event_time"),
)
.group_by(PuckEventModel.puck_id)
.subquery(name="latest_event_subquery") # Explicitly name the subquery
)
# Main query to fetch pucks and their latest events
pucks_with_latest_events = (
db.query(
PuckModel,
PuckEventModel.event_type,
PuckEventModel.tell_position,
DewarModel, # Include DewarModel
)
.join( # Join pucks with the latest event
latest_event_subquery,
PuckModel.id == latest_event_subquery.c.puck_id,
isouter=True,
)
.join( # Fetch event details from the latest event timestamp
PuckEventModel,
(PuckEventModel.puck_id == latest_event_subquery.c.puck_id)
& (PuckEventModel.timestamp == latest_event_subquery.c.latest_event_time),
isouter=True,
)
.join( # Join with DewarModel to get dewar details
DewarModel,
PuckModel.dewar_id == DewarModel.id,
isouter=True,
)
.filter(PuckModel.dewar_id.in_(dewar_ids)) # Restrict pucks to relevant dewars
.all()
)
# Log the results of the query
logger.debug(f"Results from query (latest events): {pucks_with_latest_events}")
if not pucks_with_latest_events:
logger.warning(f"No pucks found for slot: {slot_identifier}")
raise HTTPException(
status_code=404, detail=f"No pucks found for slot {slot_identifier}"
)
# Prepare the final response
results = []
for puck, event_type, tell_position, dewar in pucks_with_latest_events:
logger.debug(
f"Puck ID: {puck.id}, Name: {puck.puck_name}, Event Type: {event_type}, "
f"Tell Position: {tell_position}"
)
dewar_name = dewar_map.get(puck.dewar_id, "Unknown")
pgroup = dewar_pgroups.get(
puck.dewar_id
) # will be replaced later by puck pgroup
# For pucks with no events or whose latest event is `puck_removed`, set
# `tell_position` to None
if event_type is None or event_type == "puck_removed":
tell_position = None
# Construct the response model
results.append(
PuckWithTellPosition(
id=puck.id,
pgroup=pgroup,
puck_name=puck.puck_name,
puck_type=puck.puck_type,
puck_location_in_dewar=int(puck.puck_location_in_dewar)
if puck.puck_location_in_dewar
else None,
dewar_id=puck.dewar_id,
dewar_name=dewar_name,
tell_position=tell_position,
)
)
logger.info(f"Final response for slot {slot_identifier}: {results}")
return results
return pucks