Add validations and logging for puck beamtime assignment.

Introduced checks to prevent reassigning beamtime if puck samples have recorded events. Updated logging in beamline-related methods to provide more insight. Simplified data structure updates for dewars, pucks, and samples, ensuring consistency with beamtime assignments.
This commit is contained in:
GotthardG 2025-05-09 13:51:01 +02:00
parent 6a0953c913
commit 707c98c5ce
9 changed files with 303 additions and 114 deletions

View File

@ -709,9 +709,18 @@ dewar_to_beamtime = {
for dewar in dewars # Or use actual beamtime ids
}
# Update dewars and their pucks with consistent beamtime
for dewar in dewars:
dewar.beamtime_id = dewar_to_beamtime[dewar.id]
assigned_beamtime_obj = next(
b for b in beamtimes if b.id == dewar_to_beamtime[dewar.id]
)
dewar.beamtimes = [assigned_beamtime_obj]
for puck in pucks:
assigned_beamtime_obj = next(
b for b in beamtimes if b.id == dewar_to_beamtime[puck.dewar_id]
)
puck.beamtimes = [assigned_beamtime_obj]
for puck in pucks:
dewar_id = puck.dewar_id # Assuming puck has dewar_id

View File

@ -51,6 +51,8 @@ from app.crud import (
)
from app.routers.auth import get_current_user
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
dewar_router = APIRouter()
@ -599,6 +601,19 @@ async def assign_beamtime_to_dewar(
if not dewar:
raise HTTPException(status_code=404, detail="Dewar not found")
# Check if any sample (in any puck on this dewar) has sample events
for puck in dewar.pucks:
for sample in puck.samples:
sample_event_exists = (
db.query(SampleEvent).filter(SampleEvent.sample_id == sample.id).first()
)
if sample_event_exists:
raise HTTPException(
status_code=400,
detail="Cannot change beamtime:"
"at least one sample has events recorded.",
)
# Find the Beamtime instance, if not unassigning
beamtime = (
db.query(BeamtimeModel).filter(BeamtimeModel.id == beamtime_id).first()
@ -609,9 +624,7 @@ async def assign_beamtime_to_dewar(
if beamtime_id == 0:
dewar.beamtimes = []
else:
dewar.beamtimes = [
beamtime
] # assign one; append if you want to support multiple
dewar.beamtimes = [beamtime]
db.commit()
db.refresh(dewar)
@ -621,11 +634,7 @@ async def assign_beamtime_to_dewar(
else:
puck.beamtimes = [beamtime]
for sample in puck.samples:
has_sample_event = (
db.query(SampleEvent).filter(SampleEvent.sample_id == sample.id).count()
> 0
)
if not has_sample_event:
# Can assume all have no events because of previous check
if beamtime_id == 0:
sample.beamtimes = []
else:
@ -746,6 +755,7 @@ async def get_single_shipment(id: int, db: Session = Depends(get_db)):
operation_id="get_dewars_by_beamtime",
)
async def get_dewars_by_beamtime(beamtime_id: int, db: Session = Depends(get_db)):
logger.info(f"get_dewars_by_beamtime called with beamtime_id={beamtime_id}")
beamtime = (
db.query(BeamtimeModel)
.options(joinedload(BeamtimeModel.dewars))
@ -753,5 +763,9 @@ async def get_dewars_by_beamtime(beamtime_id: int, db: Session = Depends(get_db)
.first()
)
if not beamtime:
logger.warning(f"Beamtime {beamtime_id} not found")
raise HTTPException(status_code=404, detail="Beamtime not found")
logger.info(
f"Returning {len(beamtime.dewars)} dewars: {[d.id for d in beamtime.dewars]}"
)
return beamtime.dewars

View File

@ -665,13 +665,25 @@ async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db))
@router.patch("/puck/{puck_id}/assign-beamtime", operation_id="assignPuckToBeamtime")
async def assign_beamtime_to_puck(
puck_id: int,
beamtime_id: int, # expects ?beamtime_id=123 in the query
beamtime_id: int,
db: Session = Depends(get_db),
):
puck = db.query(PuckModel).filter(PuckModel.id == puck_id).first()
if not puck:
raise HTTPException(status_code=404, detail="Puck not found")
# Check if any sample in this puck has sample events
for sample in puck.samples:
sample_event_exists = (
db.query(SampleEvent).filter(SampleEvent.sample_id == sample.id).first()
)
if sample_event_exists:
raise HTTPException(
status_code=400,
detail="Cannot change beamtime:"
"at least one sample has events recorded.",
)
beamtime = (
db.query(BeamtimeModel).filter(BeamtimeModel.id == beamtime_id).first()
if beamtime_id
@ -681,18 +693,11 @@ async def assign_beamtime_to_puck(
if beamtime_id == 0:
puck.beamtimes = []
else:
puck.beamtimes = [
beamtime
] # or use .append(beamtime) if you want to support multiple
puck.beamtimes = [beamtime]
db.commit()
db.refresh(puck)
# Update samples as well
for sample in puck.samples:
has_sample_event = (
db.query(SampleEvent).filter(SampleEvent.sample_id == sample.id).count() > 0
)
if not has_sample_event:
if beamtime_id == 0:
sample.beamtimes = []
else:
@ -707,6 +712,7 @@ async def assign_beamtime_to_puck(
operation_id="get_pucks_by_beamtime",
)
async def get_pucks_by_beamtime(beamtime_id: int, db: Session = Depends(get_db)):
logger.info(f"get_pucks_by_beamtime called with beamtime_id={beamtime_id}")
beamtime = (
db.query(BeamtimeModel)
.options(joinedload(BeamtimeModel.pucks)) # eager load pucks
@ -714,5 +720,9 @@ async def get_pucks_by_beamtime(beamtime_id: int, db: Session = Depends(get_db))
.first()
)
if not beamtime:
logger.warning(f"Beamtime {beamtime_id} not found")
raise HTTPException(status_code=404, detail="Beamtime not found")
logger.info(
f"Returning {len(beamtime.pucks)} pucks: {[p.id for p in beamtime.pucks]}"
)
return beamtime.pucks

View File

@ -168,8 +168,8 @@ async def lifespan(app: FastAPI):
load_slots_data(db)
else: # dev or test environments
print(f"{environment.capitalize()} environment: Regenerating database.")
# Base.metadata.drop_all(bind=engine)
# Base.metadata.create_all(bind=engine)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
# from sqlalchemy.engine import reflection
# from app.models import ExperimentParameters # adjust the import as needed
# inspector = reflection.Inspector.from_engine(engine)

View File

@ -84,8 +84,31 @@ const App: React.FC = () => {
<Routes>
<Route path="/login" element={<LoginView />} />
<Route path="/" element={<ProtectedRoute element={<HomePage />} />} />
<Route path="/shipments" element={<ProtectedRoute element={<ShipmentView pgroups={pgroups} activePgroup={activePgroup} />} />} />
<Route path="/planning" element={<ProtectedRoute element={<PlanningView />} />} />
<Route path="/shipments"
element={
<ProtectedRoute
element={
<ShipmentView
pgroups={pgroups}
activePgroup={activePgroup}
/>
}
/>
}
/>
<Route path="/planning"
element={
<ProtectedRoute
element={
<PlanningView
pgroups={pgroups}
activePgroup={activePgroup}
onPgroupChange={handlePgroupChange}
/>
}
/>
}
/>
<Route
path="/results/:beamtimeId"
element={

View File

@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import '../styles/Calendar.css';
import { BeamtimesService, DewarsService, PucksService } from '../../openapi';
import Chip from '@mui/material/Chip'
const beamlineColors: { [key: string]: string } = {
X06SA: '#FF5733',
@ -18,8 +19,15 @@ interface CustomEvent extends EventInput {
beamtime_shift: string;
beamtime_id?: number;
isSubmitted?: boolean;
activePgroup?: string;
pgroups?: string;
}
interface CalendarProps {
activePgroup: string;
}
const experimentModes = ['SDU-Scheduled', 'SDU-queued', 'Remote', 'In-person'];
const darkenColor = (color: string, percent: number): string => {
@ -34,7 +42,7 @@ const darkenColor = (color: string, percent: number): string => {
return `#${newColor}`;
};
const Calendar: React.FC = () => {
const Calendar = ({ activePgroup }: CalendarProps) => {
const [events, setEvents] = useState<CustomEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
@ -59,6 +67,7 @@ const Calendar: React.FC = () => {
setFetchError(null);
try {
const beamtimes = await BeamtimesService.getMyBeamtimesProtectedBeamtimesMyBeamtimesGet();
console.log('Loaded beamtimes:', beamtimes);
const grouped: { [key: string]: any[] } = {};
beamtimes.forEach((beamtime: any) => {
const key = `${beamtime.start_date}|${beamtime.beamline}|${beamtime.pgroups}`;
@ -70,8 +79,9 @@ const Calendar: React.FC = () => {
const shifts = group.map((bt: any) => bt.shift).join(" + ");
const ids = group.map((bt: any) => bt.id);
const first = group[0];
console.log(`[DEBUG] pgroups: ${first.pgroups}`); // Ensure the value of pgroups here is correct
return {
id: `${first.beamline}-${first.start_date}-${first.pgroups}`, // ensure uniqueness
id: `${first.beamline}-${first.start_date}-${first.pgroups}`,
title: `${first.beamline}: ${shifts}`,
start: first.start_date,
end: first.end_date,
@ -82,6 +92,9 @@ const Calendar: React.FC = () => {
borderColor: '#000',
textColor: '#fff',
beamtimes: group,
extendedProps: {
pgroups: first.pgroups, // Check that this is a valid, comma-separated string
},
};
});
setEvents(formattedEvents);
@ -89,6 +102,7 @@ const Calendar: React.FC = () => {
// Fetch associations for all
const assoc: { [id: string]: { dewars: string[]; pucks: string[] } } = {};
console.log('Fetched associations after loading events:', assoc);
await Promise.all(
Object.values(grouped).map(async (group) => {
@ -103,6 +117,8 @@ const Calendar: React.FC = () => {
DewarsService.getDewarsByBeamtime(beamtimeId),
PucksService.getPucksByBeamtime(beamtimeId),
]);
console.log(`Dewars for beamtime ${beamtimeId}:`, dewars);
console.log(`Pucks for beamtime ${beamtimeId}:`, pucks);
dewars.forEach((d: any) => dewarsSet.add(d.id));
pucks.forEach((p: any) => pucksSet.add(p.id));
})
@ -115,8 +131,10 @@ const Calendar: React.FC = () => {
};
})
);
console.log("Final eventAssociations:", assoc);
setEventAssociations(assoc);
} catch (error) {
setFetchError('Failed to load beamtime data. Please try again later.');
setEvents([]);
@ -259,7 +277,6 @@ const Calendar: React.FC = () => {
: isSelected
? '#FFD700'
: (beamlineColors[beamline] || beamlineColors.Unknown);
return (
<div
style={{
@ -311,13 +328,32 @@ const Calendar: React.FC = () => {
textAlign: 'center'
}}>{assoc.pucks.length}</span>
</span>
{eventInfo.event.extendedProps?.pgroups && eventInfo.event.extendedProps.pgroups.split(',')
.map((pgroup: string) => (
<Chip
key={pgroup.trim()}
label={pgroup.trim()}
size="small"
sx={{
marginLeft: 0.5,
marginRight: 0.5,
backgroundColor: pgroup.trim() === activePgroup ? '#19d238' : '#b0b0b0',
color: pgroup.trim() === activePgroup ? 'white' : 'black',
fontWeight: 'bold',
borderRadius: '8px',
height: '20px',
fontSize: '12px',
boxShadow: '0px 1px 3px rgba(0, 0, 0, 0.2)',
mr: 1,
mb: 1,
}}
/>
))
}
</span>
</div>
);
};
// Used in Dewar/Puck assign status reporting
function getAssignedEventForDewar(dewarId: string) {
return Object.entries(eventAssociations).find(([eid, assoc]) =>
assoc.dewars.includes(dewarId)

View File

@ -39,13 +39,13 @@ interface ProcessingResults {
resolution: number;
unit_cell: string;
spacegroup: string;
rmerge: number;
rmeas: number;
isig: number;
rmerge: CCPoint[];
rmeas: CCPoint[];
isig: CCPoint[];
cc: CCPoint[];
cchalf: CCPoint[];
completeness: number;
multiplicity: number;
completeness: CCPoint[];
multiplicity: CCPoint[];
nobs: number;
total_refl: number;
unique_refl: number;
@ -81,13 +81,13 @@ const RunDetails: React.FC<RunDetailsProps> = ({ run, onHeightChange, basePath,
resolution: res.result?.resolution ?? 0,
unit_cell: res.result?.unit_cell || 'N/A',
spacegroup: res.result?.spacegroup || 'N/A',
rmerge: res.result?.rmerge ?? 0,
rmeas: res.result?.rmeas ?? 0,
isig: res.result?.isig ?? 0,
rmerge: res.result?.rmerge || [],
rmeas: res.result?.rmeas || [],
isig: res.result?.isig || [],
cc: res.result?.cc || [],
cchalf: res.result?.cchalf || [],
completeness: res.result?.completeness ?? 0,
multiplicity: res.result?.multiplicity ?? 0,
completeness: res.result?.completeness || [],
multiplicity: res.result?.multiplicity || [],
nobs: res.result?.nobs ?? 0,
total_refl: res.result?.total_refl ?? 0,
unique_refl: res.result?.unique_refl ?? 0,
@ -111,15 +111,45 @@ const RunDetails: React.FC<RunDetailsProps> = ({ run, onHeightChange, basePath,
{ field: 'resolution', headerName: 'Resolution (Å)', flex: 1 },
{ field: 'unit_cell', headerName: 'Unit Cell (Å)', flex: 1.5 },
{ field: 'spacegroup', headerName: 'Spacegroup', flex: 1 },
{ field: 'rmerge', headerName: 'Rmerge', flex: 1 },
{ field: 'rmeas', headerName: 'Rmeas', flex: 1 },
{ field: 'isig', headerName: 'I/sig(I)', flex: 1 },
{
field: 'rmerge',
headerName: 'Rmerge',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
params.row?.rmerge
? Array.isArray(params.row.rmerge)
? params.row.rmerge.map((value: CCPoint) => `${value.value.toFixed(2)}@${value.resolution.toFixed(2)}`).join(', ')
: params.row.rmerge.toFixed(2)
: 'N/A',
},
{
field: 'rmeas',
headerName: 'Rmeas',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
params.row?.rmeas
? Array.isArray(params.row.rmeas)
? params.row.rmeas.map((value: CCPoint) => `${value.value.toFixed(2)}@${value.resolution.toFixed(2)}`).join(', ')
: params.row.rmeas.toFixed(2)
: 'N/A',
},
{
field: 'isig',
headerName: 'I/sig(I)',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
params.row?.isig
? Array.isArray(params.row.isig)
? params.row.isig.map((value: CCPoint) => `${value.value.toFixed(2)}@${value.resolution.toFixed(2)}`).join(', ')
: params.row.isig.toFixed(2)
: 'N/A',
},
{
field: 'cc',
headerName: 'CC',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
Array.isArray(params.row?.cc)
params.row?.cc && Array.isArray(params.row.cc)
? params.row.cc.map((point: CCPoint) => `${point.value.toFixed(2)}@${point.resolution.toFixed(2)}`).join(', ')
: '',
},
@ -128,18 +158,39 @@ const RunDetails: React.FC<RunDetailsProps> = ({ run, onHeightChange, basePath,
headerName: 'CC(1/2)',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
Array.isArray(params.row?.cchalf)
params.row?.cchalf && Array.isArray(params.row.cchalf)
? params.row.cchalf.map((point: CCPoint) => `${point.value.toFixed(2)}@${point.resolution.toFixed(2)}`).join(', ')
: '',
},
{ field: 'completeness', headerName: 'Completeness (%)', flex: 1 },
{ field: 'multiplicity', headerName: 'Multiplicity', flex: 1 },
{
field: 'completeness',
headerName: 'Completeness (%)',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
params.row?.completeness
? Array.isArray(params.row.completeness)
? params.row.completeness.map((value: CCPoint) => `${value.value.toFixed(2)}@${value.resolution.toFixed(2)}`).join(', ')
: params.row.completeness.toFixed(2)
: 'N/A',
},
{
field: 'multiplicity',
headerName: 'Multiplicity',
flex: 1,
valueGetter: (params: GridValueGetterParams<ProcessingResults, string>) =>
params.row?.multiplicity
? Array.isArray(params.row.multiplicity)
? params.row.multiplicity.map((value: CCPoint) => `${value.value.toFixed(2)}@${value.resolution.toFixed(2)}`).join(', ')
: params.row.multiplicity.toFixed(2)
: 'N/A',
},
{ field: 'nobs', headerName: 'N obs.', flex: 1 },
{ field: 'total_refl', headerName: 'Total Reflections', flex: 1 },
{ field: 'unique_refl', headerName: 'Unique Reflections', flex: 1 },
{ field: 'comments', headerName: 'Comments', flex: 2 },
];
const updateHeight = () => {
if (containerRef.current) {
const newHeight = containerRef.current.offsetHeight;
@ -312,30 +363,70 @@ const RunDetails: React.FC<RunDetailsProps> = ({ run, onHeightChange, basePath,
{processingResult && processingResult.length > 0 && (
<div style={{width: 400, marginTop: '16px'}}>
<Typography variant="h6" gutterBottom>CC and CC(1/2) vs Resolution</Typography>
<Typography variant="h6" gutterBottom>Processing Metrics vs Resolution</Typography>
<LineChart
xAxis={[
{
data: processingResult[0].cc
.map((point) => point.resolution) // Grab the resolution values
.reverse(), // Reverse the data for resolution
.map((point) => point.resolution) // Use resolution values for the x-axis
.reverse(), // Reverse the resolution values to go from high-res to low-res
label: 'Resolution (Å)',
reverse: true, // This ensures the visual flip on the chart, low-res to right and high-res to left
reverse: true, // Flip visually so low-res is to the right
},
]}
series={[
{
data: processingResult[0].cc
.map((point) => point.value)
.reverse(), // Reverse the CC values to match the reversed resolution
.map((point) => point.value) // Map CC values
.reverse(), // Reverse order for visual consistency
label: 'CC',
},
{
data: processingResult[0].cchalf
.map((point) => point.value)
.reverse(), // Reverse the CC(1/2) values to match the reversed resolution
.map((point) => point.value) // Map CC(1/2) values
.reverse(),
label: 'CC(1/2)',
},
{
data: Array.isArray(processingResult[0].rmerge)
? processingResult[0].rmerge
.map((point: CCPoint) => point.value) // Map Rmerge values
.reverse()
: [], // Handle edge case where Rmerge isn't an array
label: 'Rmerge',
},
{
data: Array.isArray(processingResult[0].rmeas)
? processingResult[0].rmeas
.map((point: CCPoint) => point.value) // Map Rmeas values
.reverse()
: [],
label: 'Rmeas',
},
{
data: Array.isArray(processingResult[0].isig)
? processingResult[0].isig
.map((point: CCPoint) => point.value) // Map I/sig(I) values
.reverse()
: [],
label: 'I/sig(I)',
},
{
data: Array.isArray(processingResult[0].completeness)
? processingResult[0].completeness
.map((point: CCPoint) => point.value) // Map Completeness values
.reverse()
: [],
label: 'Completeness (%)',
},
{
data: Array.isArray(processingResult[0].multiplicity)
? processingResult[0].multiplicity
.map((point: CCPoint) => point.value) // Map Multiplicity values
.reverse()
: [],
label: 'Multiplicity',
},
]}
height={300}
/>

View File

@ -1,10 +1,16 @@
// Planning.tsx
import React from 'react';
import CustomCalendar from '../components/Calendar.tsx';
const PlanningView: React.FC = () => {
return <CustomCalendar />;
//return <div>Welcome to the Planning Page</div>;
interface PlanningViewProps {
onPgroupChange?: (pgroup: string) => void;
activePgroup: string;
}
const PlanningView: React.FC<PlanningViewProps> = ({ onPgroupChange, activePgroup }) => {
return <CustomCalendar
activePgroup={activePgroup}
onPgroupChange={onPgroupChange}
/>;
};
export default PlanningView;

View File

@ -446,21 +446,21 @@
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-05-08T13:31:36.929465Z",
"start_time": "2025-05-08T13:31:36.925054Z"
"end_time": "2025-05-08T15:17:09.428353Z",
"start_time": "2025-05-08T15:17:09.424769Z"
}
},
"cell_type": "code",
"source": "sample_id = 44",
"source": "sample_id = 106",
"id": "54d4d46ca558e7b9",
"outputs": [],
"execution_count": 28
"execution_count": 36
},
{
"metadata": {
"ExecuteTime": {
"end_time": "2025-05-08T13:31:40.023546Z",
"start_time": "2025-05-08T13:31:39.978510Z"
"end_time": "2025-05-08T15:17:16.752451Z",
"start_time": "2025-05-08T15:17:16.719047Z"
}
},
"cell_type": "code",
@ -478,7 +478,7 @@
" sample_event_create = SampleEventCreate(\n",
" sample_id=sample_id,\n",
" #event_type=\"Centering\" # Valid event type\n",
" event_type=\"Collecting\" # Valid event type\n",
" event_type=\"Mounting\" # Valid event type\n",
" )\n",
"\n",
" # Debug the payload before sending\n",
@ -512,7 +512,7 @@
"DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): localhost:8000\n",
"/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/site-packages/urllib3/connectionpool.py:1103: InsecureRequestWarning: Unverified HTTPS request is being made to host 'localhost'. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#tls-warnings\n",
" warnings.warn(\n",
"DEBUG:urllib3.connectionpool:https://localhost:8000 \"POST /samples/samples/44/events HTTP/1.1\" 200 884\n"
"DEBUG:urllib3.connectionpool:https://localhost:8000 \"POST /samples/samples/106/events HTTP/1.1\" 200 718\n"
]
},
{
@ -520,25 +520,25 @@
"output_type": "stream",
"text": [
"Payload being sent to API:\n",
"{\"event_type\":\"Collecting\"}\n",
"{\"event_type\":\"Mounting\"}\n",
"API response:\n",
"('id', 44)\n",
"('sample_name', 'Sample044')\n",
"('position', 7)\n",
"('puck_id', 7)\n",
"('id', 106)\n",
"('sample_name', 'Sample106')\n",
"('position', 13)\n",
"('puck_id', 11)\n",
"('crystalname', None)\n",
"('proteinname', None)\n",
"('positioninpuck', None)\n",
"('priority', None)\n",
"('comments', None)\n",
"('data_collection_parameters', None)\n",
"('events', [SampleEventResponse(event_type='Mounting', id=87, sample_id=44, timestamp=datetime.datetime(2025, 5, 7, 10, 16)), SampleEventResponse(event_type='Unmounting', id=88, sample_id=44, timestamp=datetime.datetime(2025, 5, 7, 10, 16, 50)), SampleEventResponse(event_type='Collecting', id=507, sample_id=44, timestamp=datetime.datetime(2025, 5, 8, 13, 31, 40, 6059))])\n",
"('events', [SampleEventResponse(event_type='Mounting', id=452, sample_id=106, timestamp=datetime.datetime(2025, 5, 8, 15, 17, 16, 743011))])\n",
"('mount_count', 0)\n",
"('unmount_count', 0)\n"
]
}
],
"execution_count": 29
"execution_count": 37
},
{
"metadata": {