From 3f21aee7a5faab88b5481cb645e278ad09a9be3d Mon Sep 17 00:00:00 2001 From: Jim O'Donnell Date: Fri, 22 Nov 2024 15:04:56 +0000 Subject: [PATCH] feat: persist model time intervals in the backend - New Django model, `TimeInterval`, representing time intervals for secondary parameters. - Update the combined model serialiser so that it can save `model.time_intervals`. - Update the OpenAPI schema in the backend and frontend. - Update the React app to use the new OpenAPI models. --- frontend-v2/src/App.tsx | 10 +-- frontend-v2/src/app/backendApi.ts | 33 +++++++ frontend-v2/src/contexts/SimulationContext.ts | 6 +- frontend-v2/src/features/model/Model.tsx | 1 + .../model/secondary/TimeIntervalsTable.tsx | 89 ++++++++++--------- .../src/features/projects/Projects.tsx | 1 + .../src/features/results/ResultsTab.tsx | 18 ++-- .../src/features/results/ResultsTable.tsx | 10 ++- frontend-v2/src/features/results/columns.tsx | 13 +-- .../src/features/results/useParameters.tsx | 14 ++- .../src/features/results/useTableRows.ts | 12 ++- frontend-v2/src/features/results/utils.ts | 26 +++--- .../src/hooks/useModelTimeIntervals.ts | 49 ++++++++++ pkpdapp/pkpdapp/api/serializers/models.py | 26 +++++- .../migrations/0016_auto_20241122_1523.py | 70 +++++++++++++++ pkpdapp/pkpdapp/models/__init__.py | 3 +- pkpdapp/pkpdapp/models/combined_model.py | 23 +++++ .../tests/test_views/test_combined_model.py | 1 + pkpdapp/schema.yml | 43 +++++++++ 19 files changed, 355 insertions(+), 93 deletions(-) create mode 100644 frontend-v2/src/hooks/useModelTimeIntervals.ts create mode 100644 pkpdapp/pkpdapp/migrations/0016_auto_20241122_1523.py diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index e839e70cb..2ac9667a8 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -17,25 +17,19 @@ import "react-toastify/dist/ReactToastify.css"; import { SimulationContext } from "./contexts/SimulationContext"; import { SimulateResponse } from "./app/backendApi"; import { CollapsibleSidebarProvider } from "./shared/contexts/CollapsibleSidebarContext"; +import { useModelTimeIntervals } from "./hooks/useModelTimeIntervals"; -export type TimeInterval = { - start: number; - end: number; - unit: { [key: string]: string }; -}; type Threshold = { lower: number; upper: number }; export type Thresholds = { [key: string]: Threshold }; -const TIME_INTERVALS: TimeInterval[] = []; - const THRESHOLDS: Thresholds = {}; function App() { const dispatch = useAppDispatch(); const isAuth = useSelector(isAuthenticated); const error = useSelector((state: RootState) => state.login.error); + const [intervals, setIntervals] = useModelTimeIntervals(); const [simulations, setSimulations] = useState([]); - const [intervals, setIntervals] = useState(TIME_INTERVALS); const [thresholds, setThresholds] = useState(THRESHOLDS); const simulationContext = { simulations, diff --git a/frontend-v2/src/app/backendApi.ts b/frontend-v2/src/app/backendApi.ts index 870ea0e80..84e80b14f 100644 --- a/frontend-v2/src/app/backendApi.ts +++ b/frontend-v2/src/app/backendApi.ts @@ -1959,10 +1959,40 @@ export type DerivedVariableRead = { /** base variable in PK part of model */ pk_variable: number; }; +export type TimeInterval = { + /** true if object has been stored */ + read_only?: boolean; + /** datetime the object was stored. */ + datetime?: string | null; + /** start time of interval */ + start_time: number; + /** end time of interval */ + end_time: number; + /** PKPD model that this time interval is for */ + pkpd_model: number; + /** unit of interval */ + unit: number; +}; +export type TimeIntervalRead = { + id: number; + /** true if object has been stored */ + read_only?: boolean; + /** datetime the object was stored. */ + datetime?: string | null; + /** start time of interval */ + start_time: number; + /** end time of interval */ + end_time: number; + /** PKPD model that this time interval is for */ + pkpd_model: number; + /** unit of interval */ + unit: number; +}; export type CombinedModelSpeciesEnum = "H" | "R" | "N" | "M"; export type CombinedModel = { mappings: PkpdMapping[]; derived_variables: DerivedVariable[]; + time_intervals: TimeInterval[]; /** true if object has been stored */ read_only?: boolean; /** datetime the object was stored. */ @@ -2001,6 +2031,7 @@ export type CombinedModelRead = { id: number; mappings: PkpdMappingRead[]; derived_variables: DerivedVariableRead[]; + time_intervals: TimeIntervalRead[]; components: string; variables: number[]; mmt: string; @@ -2043,6 +2074,7 @@ export type CombinedModelRead = { export type PatchedCombinedModel = { mappings?: PkpdMapping[]; derived_variables?: DerivedVariable[]; + time_intervals?: TimeInterval[]; /** true if object has been stored */ read_only?: boolean; /** datetime the object was stored. */ @@ -2081,6 +2113,7 @@ export type PatchedCombinedModelRead = { id?: number; mappings?: PkpdMappingRead[]; derived_variables?: DerivedVariableRead[]; + time_intervals?: TimeIntervalRead[]; components?: string; variables?: number[]; mmt?: string; diff --git a/frontend-v2/src/contexts/SimulationContext.ts b/frontend-v2/src/contexts/SimulationContext.ts index 53003cb9d..8c0985286 100644 --- a/frontend-v2/src/contexts/SimulationContext.ts +++ b/frontend-v2/src/contexts/SimulationContext.ts @@ -1,12 +1,10 @@ import { createContext } from "react"; -import { SimulateResponse } from "../app/backendApi"; -import { TimeInterval, Thresholds } from "../App"; +import { SimulateResponse, TimeIntervalRead } from "../app/backendApi"; +import { Thresholds } from "../App"; export const SimulationContext = createContext({ simulations: [] as SimulateResponse[], setSimulations: (simulations: SimulateResponse[]) => {}, - intervals: [] as TimeInterval[], - setIntervals: (intervals: TimeInterval[]) => {}, thresholds: {} as Thresholds, setThresholds: (thresholds: Thresholds) => {}, }); diff --git a/frontend-v2/src/features/model/Model.tsx b/frontend-v2/src/features/model/Model.tsx index 843b7c004..1ef365c4c 100644 --- a/frontend-v2/src/features/model/Model.tsx +++ b/frontend-v2/src/features/model/Model.tsx @@ -86,6 +86,7 @@ const Model: FC = () => { project: projectIdOrZero, mappings: [], derived_variables: [], + time_intervals: [], components: "", variables: [], mmt: "", diff --git a/frontend-v2/src/features/model/secondary/TimeIntervalsTable.tsx b/frontend-v2/src/features/model/secondary/TimeIntervalsTable.tsx index 2d062e4f0..d9caadb1a 100644 --- a/frontend-v2/src/features/model/secondary/TimeIntervalsTable.tsx +++ b/frontend-v2/src/features/model/secondary/TimeIntervalsTable.tsx @@ -1,4 +1,4 @@ -import { FC, useContext } from "react"; +import { FC, useState } from "react"; import { Button, FormControl, @@ -18,16 +18,16 @@ import Delete from "@mui/icons-material/Delete"; import { useSelector } from "react-redux"; import { + TimeIntervalRead, useProjectRetrieveQuery, useUnitListQuery, } from "../../../app/backendApi"; import { RootState } from "../../../app/store"; -import { SimulationContext } from "../../../contexts/SimulationContext"; -import { TimeInterval } from "../../../App"; +import { useModelTimeIntervals } from "../../../hooks/useModelTimeIntervals"; type TimeUnitSelectProps = { - interval: TimeInterval; - onChange: (interval: TimeInterval) => void; + interval: TimeIntervalRead; + onChange: (interval: TimeIntervalRead) => void; }; function useTimeUnits() { @@ -47,25 +47,25 @@ function useTimeUnits() { } function TimeUnitSelect({ interval, onChange }: TimeUnitSelectProps) { + const defaultTimeUnit = 9; // set hours by default + const [selectedUnit, setSelectedUnit] = useState( + interval.unit || defaultTimeUnit, + ); const timeUnits = useTimeUnits(); const timeUnitOptions = - timeUnits?.map((unit) => ({ value: unit.symbol, label: unit.symbol })) || - []; - const defaultTimeUnit = "h"; + timeUnits?.map((unit) => ({ value: unit.id, label: unit.symbol })) || []; function onChangeUnit(event: SelectChangeEvent) { - const unit = timeUnits?.find((unit) => unit.symbol === event.target.value); + const unit = timeUnits?.find((unit) => unit.id === event.target.value); if (unit) { - onChange({ ...interval, unit }); + setSelectedUnit(+unit.id); + onChange({ ...interval, unit: +unit.id }); } } return ( - {timeUnitOptions.map((option) => ( {option.label} @@ -77,30 +77,32 @@ function TimeUnitSelect({ interval, onChange }: TimeUnitSelectProps) { } type IntervalRowProps = { - interval: TimeInterval; + interval: TimeIntervalRead; onDelete: () => void; - onUpdate: (interval: TimeInterval) => void; + onUpdate: (interval: TimeIntervalRead) => void; }; function IntervalRow({ interval, onDelete, onUpdate }: IntervalRowProps) { + const [start, setStart] = useState(interval.start_time); + const [end, setEnd] = useState(interval.end_time); function onChangeStart(event: React.ChangeEvent) { - onUpdate({ ...interval, start: parseFloat(event.target.value) }); + const newStartTime = parseFloat(event.target.value); + setStart(newStartTime); + onUpdate({ ...interval, start_time: newStartTime }); } function onChangeEnd(event: React.ChangeEvent) { - onUpdate({ ...interval, end: parseFloat(event.target.value) }); + const newEndTime = parseFloat(event.target.value); + setEnd(newEndTime); + onUpdate({ ...interval, end_time: newEndTime }); } return ( - + - + @@ -115,36 +117,39 @@ function IntervalRow({ interval, onDelete, onUpdate }: IntervalRowProps) { } const TimeIntervalsTable: FC = (props) => { - const { intervals, setIntervals } = useContext(SimulationContext); + const [intervals, setIntervals] = useModelTimeIntervals(); const timeUnits = useTimeUnits(); const hourUnit = timeUnits?.find((unit) => unit.symbol === "h"); function addInterval() { const lastInterval = intervals[intervals.length - 1]; if (hourUnit) { - let newInterval: TimeInterval = { start: 0, end: 0, unit: hourUnit }; + let newInterval = { + start_time: 0, + end_time: 0, + unit: +hourUnit.id, + }; if (lastInterval) { - const duration = lastInterval.end - lastInterval.start; + const duration = lastInterval.end_time - lastInterval.start_time; newInterval = { - start: lastInterval.end, - end: lastInterval.end + duration, + start_time: lastInterval.end_time, + end_time: lastInterval.end_time + duration, unit: lastInterval.unit, }; } setIntervals([...intervals, newInterval]); } } - function removeInterval(index: number) { - setIntervals(intervals.filter((_, i) => i !== index)); + function removeInterval(id: number) { + const newIntervals = intervals.filter((i) => i.id !== id); + setIntervals(newIntervals); } - function updateInterval(index: number, interval: TimeInterval) { - setIntervals( - intervals.map((i, iIndex) => (iIndex === index ? interval : i)), - ); + function updateInterval(id: number, interval: TimeIntervalRead) { + setIntervals(intervals.map((i) => (i.id === id ? interval : i))); } - const onDelete = (index: number) => () => removeInterval(index); - const onUpdate = (index: number) => (interval: TimeInterval) => - updateInterval(index, interval); + const onDelete = (id: number) => () => removeInterval(id); + const onUpdate = (id: number) => (interval: TimeIntervalRead) => + updateInterval(id, interval); return ( <> @@ -156,12 +161,12 @@ const TimeIntervalsTable: FC = (props) => { Remove - {intervals.map((interval, index) => ( + {intervals.map((interval) => ( ))} diff --git a/frontend-v2/src/features/projects/Projects.tsx b/frontend-v2/src/features/projects/Projects.tsx index bcd51312a..4cc1b5b19 100644 --- a/frontend-v2/src/features/projects/Projects.tsx +++ b/frontend-v2/src/features/projects/Projects.tsx @@ -175,6 +175,7 @@ const ProjectTable: FC = () => { project: newProject.data.id, mappings: [], derived_variables: [], + time_intervals: [], }, }).then((combinedModel) => { if (combinedModel?.data) { diff --git a/frontend-v2/src/features/results/ResultsTab.tsx b/frontend-v2/src/features/results/ResultsTab.tsx index 76889d6fb..2f76694be 100644 --- a/frontend-v2/src/features/results/ResultsTab.tsx +++ b/frontend-v2/src/features/results/ResultsTab.tsx @@ -1,4 +1,4 @@ -import { FC, useContext, useState } from "react"; +import { FC, useState } from "react"; import { FormControl, InputLabel, @@ -8,14 +8,13 @@ import { Typography, } from "@mui/material"; -import { TimeInterval } from "../../App"; -import { VariableRead } from "../../app/backendApi"; +import { TimeIntervalRead, VariableRead } from "../../app/backendApi"; import useSubjectGroups from "../../hooks/useSubjectGroups"; -import { SimulationContext } from "../../contexts/SimulationContext"; import { useConcentrationVariables } from "./useConcentrationVariables"; import { useParameters, Parameter } from "./useParameters"; import { ResultsTable } from "./ResultsTable"; +import { useModelTimeIntervals } from "../../hooks/useModelTimeIntervals"; const options = [ { name: "Parameters", value: "parameters" }, @@ -28,13 +27,13 @@ export type FilterIndex = number | "rows" | "columns"; export type RowData = | { name: string }[] | Parameter[] - | TimeInterval[] + | TimeIntervalRead[] | VariableRead[]; type Item = | { name: string } | Parameter - | (TimeInterval & { name: string }) + | (TimeIntervalRead & { name: string }) | VariableRead; type RowFilter = { filter: (event: SelectChangeEvent) => void; @@ -45,7 +44,7 @@ type RowFilter = { const ResultsTab: FC = () => { const { groups = [] } = useSubjectGroups(); - const { intervals } = useContext(SimulationContext); + const [intervals] = useModelTimeIntervals(); const concentrationVariables = useConcentrationVariables(); const parameters = useParameters(); @@ -126,7 +125,10 @@ const ResultsTab: FC = () => { const intervalSelect = { filter: handleIntervalChange, value: interval, - items: intervals.map((i) => ({ name: `${i.start} - ${i.end}`, ...i })), + items: intervals.map((i) => ({ + name: `${i.start_time} - ${i.end_time}`, + ...i, + })), label: "Interval", }; const variableSelect = { diff --git a/frontend-v2/src/features/results/ResultsTable.tsx b/frontend-v2/src/features/results/ResultsTable.tsx index 046d95734..a5a5f5a9f 100644 --- a/frontend-v2/src/features/results/ResultsTable.tsx +++ b/frontend-v2/src/features/results/ResultsTable.tsx @@ -6,9 +6,8 @@ import { TableHead, TableRow, } from "@mui/material"; -import { FC, useContext } from "react"; +import { FC } from "react"; -import { SimulationContext } from "../../contexts/SimulationContext"; import useSubjectGroups from "../../hooks/useSubjectGroups"; import { useParameters } from "./useParameters"; @@ -17,6 +16,7 @@ import { useConcentrationVariables } from "./useConcentrationVariables"; import { FilterIndex, RowData } from "./ResultsTab"; import { useTableRows } from "./useTableRows"; import { getTableHeight } from "../../shared/calculateTableHeights"; +import { useModelTimeIntervals } from "../../hooks/useModelTimeIntervals"; const RESULTS_TABLE_HEIGHTS = [ { @@ -101,7 +101,7 @@ export const ResultsTable: FC = ({ const { groups } = useSubjectGroups(); const parameters = useParameters(); const concentrationVariables = useConcentrationVariables(); - const { intervals } = useContext(SimulationContext); + const [intervals] = useModelTimeIntervals(); const tableRows = useTableRows({ rows, groupIndex, @@ -121,7 +121,9 @@ export const ResultsTable: FC = ({ : variableIndex === "columns" ? concentrationVariables.map((variable) => variable.name) : intervalIndex === "columns" - ? intervals.map((interval) => `${interval.start} – ${interval.end}`) + ? intervals.map( + (interval) => `${interval.start_time} – ${interval.end_time}`, + ) : groupIndex === "columns" ? groups ? [{ name: "Project" }, ...groups].map((group) => group.name) diff --git a/frontend-v2/src/features/results/columns.tsx b/frontend-v2/src/features/results/columns.tsx index 95e24a84f..fe7313ad0 100644 --- a/frontend-v2/src/features/results/columns.tsx +++ b/frontend-v2/src/features/results/columns.tsx @@ -1,5 +1,8 @@ -import { SimulateResponse, VariableRead } from "../../app/backendApi"; -import { TimeInterval } from "../../App"; +import { + SimulateResponse, + TimeIntervalRead, + VariableRead, +} from "../../app/backendApi"; import { Parameter } from "./useParameters"; @@ -11,8 +14,8 @@ interface ParametersProps { simulations: SimulateResponse[]; parameter?: Parameter; parameters: Parameter[]; - interval?: TimeInterval; - intervals: TimeInterval[]; + interval?: TimeIntervalRead; + intervals: TimeIntervalRead[]; } export function columns({ @@ -37,7 +40,7 @@ export function columns({ if (parameter && !interval) { return intervals.map((interval) => { return { - header: `${interval.start} – ${interval.end}`, + header: `${interval.start_time} – ${interval.end_time}`, value: parameter.value, }; }); diff --git a/frontend-v2/src/features/results/useParameters.tsx b/frontend-v2/src/features/results/useParameters.tsx index 5d45aa724..cd07fd4ee 100644 --- a/frontend-v2/src/features/results/useParameters.tsx +++ b/frontend-v2/src/features/results/useParameters.tsx @@ -1,6 +1,10 @@ import { useContext } from "react"; -import { SimulateResponse, VariableRead } from "../../app/backendApi"; +import { + SimulateResponse, + TimeIntervalRead, + VariableRead, +} from "../../app/backendApi"; import { formattedNumber, timeOverThreshold, @@ -9,7 +13,8 @@ import { } from "./utils"; import { SimulationContext } from "../../contexts/SimulationContext"; import { useVariables } from "./useVariables"; -import { Thresholds, TimeInterval } from "../../App"; +import { Thresholds } from "../../App"; +import { useModelTimeIntervals } from "../../hooks/useModelTimeIntervals"; export type Parameter = { name: string | JSX.Element; @@ -22,7 +27,7 @@ export type Parameter = { }; const variablePerInterval = ( - intervals: TimeInterval[], + intervals: TimeIntervalRead[], variable: VariableRead, simulation: SimulateResponse, intervalIndex: number, @@ -67,7 +72,8 @@ const timeOverUpperThresholdPerInterval = ( }; export function useParameters() { - const { intervals, thresholds } = useContext(SimulationContext); + const { thresholds } = useContext(SimulationContext); + const [intervals] = useModelTimeIntervals(); const variables = useVariables(); return [ { diff --git a/frontend-v2/src/features/results/useTableRows.ts b/frontend-v2/src/features/results/useTableRows.ts index e1e90c9e7..0e1b23196 100644 --- a/frontend-v2/src/features/results/useTableRows.ts +++ b/frontend-v2/src/features/results/useTableRows.ts @@ -7,6 +7,8 @@ import { useConcentrationVariables } from "./useConcentrationVariables"; import { useParameters } from "./useParameters"; import { useVariables } from "./useVariables"; import { tableRow } from "./utils"; +import { useModelTimeIntervals } from "../../hooks/useModelTimeIntervals"; +import { useUnits } from "./useUnits"; interface TableRowsProps { rows: RowData; @@ -22,15 +24,19 @@ export function useTableRows({ variableIndex, parameterIndex, }: TableRowsProps) { + const units = useUnits(); const variables = useVariables(); const parameters = useParameters(); const concentrationVariables = useConcentrationVariables(); - const { intervals, simulations } = useContext(SimulationContext); + const { simulations } = useContext(SimulationContext); + const [intervals] = useModelTimeIntervals(); return rows.map((row, index) => { + const rowUnit = + "unit" in row ? units?.find((unit) => unit.id === row.unit) : undefined; const header = - "start" in row && "end" in row && "unit" in row - ? `${row.start} – ${row.end} [${row.unit.symbol}]` + "start_time" in row && "end_time" in row + ? `${row.start_time} – ${row.end_time} [${rowUnit?.symbol}]` : "name" in row ? row.name : ""; diff --git a/frontend-v2/src/features/results/utils.ts b/frontend-v2/src/features/results/utils.ts index dba6e365d..6e15fcbbb 100644 --- a/frontend-v2/src/features/results/utils.ts +++ b/frontend-v2/src/features/results/utils.ts @@ -1,9 +1,9 @@ import { SimulateResponse, + TimeIntervalRead, VariableListApiResponse, VariableRead, } from "../../app/backendApi"; -import { TimeInterval } from "../../App"; import { Parameter } from "./useParameters"; import { columns } from "./columns"; @@ -27,7 +27,7 @@ function interpolate(x: [number, number], y: [number, number], x0: number) { * @returns interpolated values per interval */ export function valuesPerInterval( - timeIntervals: TimeInterval[], + timeIntervals: TimeIntervalRead[], variable?: VariableRead, simulation?: SimulateResponse, ) { @@ -37,14 +37,14 @@ export function valuesPerInterval( if (values.length === 0) { return []; } - const startIndex = times.findIndex((t) => t >= interval.start); - const endIndex = times.findIndex((t) => t >= interval.end); + const startIndex = times.findIndex((t) => t >= interval.start_time); + const endIndex = times.findIndex((t) => t >= interval.end_time); const start = startIndex > 0 ? interpolate( [times[startIndex - 1], times[startIndex]], [values[startIndex - 1], values[startIndex]], - interval.start, + interval.start_time, ) : values[0]; const end = @@ -52,7 +52,7 @@ export function valuesPerInterval( ? interpolate( [times[endIndex - 1], times[endIndex]], [values[endIndex - 1], values[endIndex]], - interval.end, + interval.end_time, ) : values[values.length - 1]; const intervalValues = [ @@ -72,15 +72,15 @@ export function valuesPerInterval( */ export function timesPerInterval( times: number[], - timeIntervals: TimeInterval[], + timeIntervals: TimeIntervalRead[], ) { return timeIntervals.map((interval) => { - const startIndex = times.findIndex((t) => t >= interval.start); - const endIndex = times.findIndex((t) => t >= interval.end); + const startIndex = times.findIndex((t) => t >= interval.start_time); + const endIndex = times.findIndex((t) => t >= interval.end_time); return [ - interval.start, + interval.start_time, ...times.slice(startIndex + 1, endIndex - 1), - interval.end, + interval.end_time, ]; }); } @@ -163,8 +163,8 @@ export function formattedNumber(value: number, threshold: number = 1e4) { interface TableRowProps { header: string | JSX.Element; - interval?: TimeInterval; - intervals: TimeInterval[]; + interval?: TimeIntervalRead; + intervals: TimeIntervalRead[]; variables: VariableListApiResponse | undefined; simulation?: SimulateResponse; simulations: SimulateResponse[]; diff --git a/frontend-v2/src/hooks/useModelTimeIntervals.ts b/frontend-v2/src/hooks/useModelTimeIntervals.ts new file mode 100644 index 000000000..9d3235bf1 --- /dev/null +++ b/frontend-v2/src/hooks/useModelTimeIntervals.ts @@ -0,0 +1,49 @@ +import { useSelector } from "react-redux"; + +import { RootState } from "../app/store"; +import { + TimeIntervalRead, + useCombinedModelListQuery, + useCombinedModelUpdateMutation, +} from "../app/backendApi"; + +export type TimeIntervalUpdate = { + start_time: number; + end_time: number; + unit: number; +}; + +export function useModelTimeIntervals() { + const projectId = useSelector( + (state: RootState) => state.main.selectedProject, + ); + const projectIdOrZero = projectId || 0; + const { data: models } = useCombinedModelListQuery( + { projectId: projectIdOrZero }, + { skip: !projectId }, + ); + const model = models?.[0] || null; + const [updateModel] = useCombinedModelUpdateMutation(); + const updateModelTimeIntervals = ( + timeIntervals: TimeIntervalRead[] | TimeIntervalUpdate[], + ) => { + if (model) { + updateModel({ + id: model.id, + combinedModel: { + ...model, + time_intervals: timeIntervals.map((timeInterval) => ({ + start_time: timeInterval.start_time, + end_time: timeInterval.end_time, + pkpd_model: model.id, + unit: timeInterval.unit, + })), + }, + }); + } + }; + return [model?.time_intervals || [], updateModelTimeIntervals] as [ + TimeIntervalRead[], + (timeIntervals: TimeIntervalRead[] | TimeIntervalUpdate[]) => void, + ]; +} diff --git a/pkpdapp/pkpdapp/api/serializers/models.py b/pkpdapp/pkpdapp/api/serializers/models.py index 66558668a..4c0bda631 100644 --- a/pkpdapp/pkpdapp/api/serializers/models.py +++ b/pkpdapp/pkpdapp/api/serializers/models.py @@ -12,6 +12,7 @@ MyokitModelMixin, DerivedVariable, Variable, + TimeInterval, ) from pkpdapp.api.serializers import ValidSbml, ValidMmt @@ -31,6 +32,12 @@ class Meta: fields = "__all__" +class TimeIntervalSerializer(serializers.ModelSerializer): + class Meta: + model = TimeInterval + fields = "__all__" + + class BaseDosedPharmacokineticSerializer(serializers.ModelSerializer): class Meta: model = CombinedModel @@ -40,6 +47,7 @@ class Meta: class CombinedModelSerializer(serializers.ModelSerializer): mappings = PkpdMappingSerializer(many=True) derived_variables = DerivedVariableSerializer(many=True) + time_intervals = TimeIntervalSerializer(many=True) components = serializers.SerializerMethodField("get_components") variables = serializers.PrimaryKeyRelatedField(many=True, read_only=True) mmt = serializers.SerializerMethodField("get_mmt", read_only=True) @@ -70,10 +78,12 @@ def get_components(self, m): def create(self, validated_data): mappings_data = validated_data.pop("mappings", []) derived_variables_data = validated_data.pop("derived_variables", []) + time_intervals_data = validated_data.pop("time_intervals", []) new_pkpd_model = BaseDosedPharmacokineticSerializer().create(validated_data) for field_datas, Serializer in [ (mappings_data, PkpdMappingSerializer), (derived_variables_data, DerivedVariableSerializer), + (time_intervals_data, TimeIntervalSerializer), ]: for field_data in field_datas: serializer = Serializer() @@ -85,8 +95,10 @@ def create(self, validated_data): def update(self, instance, validated_data): mappings_data = validated_data.pop("mappings", []) derived_var_data = validated_data.pop("derived_variables", []) + time_interval_data = validated_data.pop("time_intervals", []) old_mappings = list((instance.mappings).all()) old_derived_vars = list((instance.derived_variables).all()) + old_time_intervals = list((instance.time_intervals).all()) pk_model_changed = False if "pk_model" in validated_data: @@ -124,6 +136,16 @@ def update(self, instance, validated_data): derived_var["pkpd_model"] = new_pkpd_model new_model = serializer.create(derived_var) + for time_interval in time_interval_data: + serializer = TimeIntervalSerializer() + try: + old_model = old_time_intervals.pop(0) + new_model = serializer.update(old_model, time_interval) + new_model.save() + except IndexError: + time_interval["pkpd_model"] = new_pkpd_model + new_model = serializer.create(time_interval) + for mapping in mappings_data: serializer = PkpdMappingSerializer() try: @@ -134,11 +156,13 @@ def update(self, instance, validated_data): mapping["pkpd_model"] = new_pkpd_model new_model = serializer.create(mapping) - # delete any remaining old mappings and derived variables + # delete any remaining old mappings, derived variables and time intervals for old_model in old_mappings: old_model.delete() for old_model in old_derived_vars: old_model.delete() + for old_model in old_time_intervals: + old_model.delete() # update model since mappings might have changed new_pkpd_model.update_model() diff --git a/pkpdapp/pkpdapp/migrations/0016_auto_20241122_1523.py b/pkpdapp/pkpdapp/migrations/0016_auto_20241122_1523.py new file mode 100644 index 000000000..4ad5b111a --- /dev/null +++ b/pkpdapp/pkpdapp/migrations/0016_auto_20241122_1523.py @@ -0,0 +1,70 @@ +# +# This file is part of PKPDApp (https://github.com/pkpdapp-team/pkpdapp) which +# is released under the BSD 3-clause license. See accompanying LICENSE.md for +# copyright notice and full license details. +# +# Generated by Django 3.2.25 on 2024-11-22 15:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('pkpdapp', '0015_subjectgroup_project'), + ] + + operations = [ + migrations.AlterField( + model_name='derivedvariable', + name='type', + field=models.CharField( + choices=[ + ('AUC', 'area under curve'), + ('RO', 'receptor occupancy'), + ('FUP', 'faction unbound plasma'), + ('BPR', 'blood plasma ratio'), + ('TLG', 'dosing lag time') + ], + help_text='type of derived variable', + max_length=3 + ), + ), + migrations.CreateModel( + name='TimeInterval', + fields=[ + ('id', models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID' + )), + ('read_only', models.BooleanField( + default=False, + help_text='true if object has been stored' + )), + ('datetime', models.DateTimeField( + blank=True, + help_text='datetime the object was stored.', + null=True + )), + ('start_time', models.FloatField(help_text='start time of interval')), + ('end_time', models.FloatField(help_text='end time of interval')), + ('pkpd_model', models.ForeignKey( + help_text='PKPD model that this time interval is for', + on_delete=django.db.models.deletion.CASCADE, + related_name='time_intervals', + to='pkpdapp.combinedmodel' + )), + ('unit', models.ForeignKey( + help_text='unit of interval', + on_delete=django.db.models.deletion.PROTECT, + to='pkpdapp.unit' + )), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/pkpdapp/pkpdapp/models/__init__.py b/pkpdapp/pkpdapp/models/__init__.py index 2db4d94e0..c5ad3c9a0 100644 --- a/pkpdapp/pkpdapp/models/__init__.py +++ b/pkpdapp/pkpdapp/models/__init__.py @@ -28,7 +28,8 @@ from .combined_model import ( CombinedModel, PkpdMapping, - DerivedVariable + DerivedVariable, + TimeInterval ) from .dataset import Dataset from .variable import Variable diff --git a/pkpdapp/pkpdapp/models/combined_model.py b/pkpdapp/pkpdapp/models/combined_model.py index 7eaeb5783..91d9ec7ed 100644 --- a/pkpdapp/pkpdapp/models/combined_model.py +++ b/pkpdapp/pkpdapp/models/combined_model.py @@ -741,3 +741,26 @@ def copy(self, new_pkpd_model, new_variables): } stored_mapping = DerivedVariable.objects.create(**stored_kwargs) return stored_mapping + + +class TimeInterval(StoredModel): + pkpd_model = models.ForeignKey( + CombinedModel, + on_delete=models.CASCADE, + related_name="time_intervals", + help_text="PKPD model that this time interval is for", + ) + start_time = models.FloatField( + help_text="start time of interval" + ) + end_time = models.FloatField( + help_text="end time of interval" + ) + unit = models.ForeignKey( + Unit, + on_delete=models.PROTECT, + help_text="unit of interval", + ) + + def __str__(self): + return f"{self.start_time} - {self.end_time} [{self.unit}]" diff --git a/pkpdapp/pkpdapp/tests/test_views/test_combined_model.py b/pkpdapp/pkpdapp/tests/test_views/test_combined_model.py index aa37dc443..13e23f4e6 100644 --- a/pkpdapp/pkpdapp/tests/test_views/test_combined_model.py +++ b/pkpdapp/pkpdapp/tests/test_views/test_combined_model.py @@ -62,6 +62,7 @@ def create_combined_model(self, name, pd=None): "pk_model": pk.id, "mappings": [], "derived_variables": [], + "time_intervals": [], } if pd is not None: data["pd_model"] = pd.id diff --git a/pkpdapp/schema.yml b/pkpdapp/schema.yml index 0d38b96bf..845bb8685 100644 --- a/pkpdapp/schema.yml +++ b/pkpdapp/schema.yml @@ -3376,6 +3376,10 @@ components: type: array items: $ref: '#/components/schemas/DerivedVariable' + time_intervals: + type: array + items: + $ref: '#/components/schemas/TimeInterval' components: type: string readOnly: true @@ -3459,6 +3463,7 @@ components: - mappings - mmt - name + - time_intervals - time_unit - variables CombinedModelSpeciesEnum: @@ -4105,6 +4110,10 @@ components: type: array items: $ref: '#/components/schemas/DerivedVariable' + time_intervals: + type: array + items: + $ref: '#/components/schemas/TimeInterval' components: type: string readOnly: true @@ -5479,6 +5488,40 @@ components: - name - protocols - subjects + TimeInterval: + type: object + properties: + id: + type: integer + readOnly: true + read_only: + type: boolean + description: true if object has been stored + datetime: + type: string + format: date-time + nullable: true + description: datetime the object was stored. + start_time: + type: number + format: double + description: start time of interval + end_time: + type: number + format: double + description: end time of interval + pkpd_model: + type: integer + description: PKPD model that this time interval is for + unit: + type: integer + description: unit of interval + required: + - end_time + - id + - pkpd_model + - start_time + - unit TypeEnum: enum: - AUC