mirror of
https://github.com/wassname/optuna-dashboard.git
synced 2026-09-09 11:28:14 +08:00
Merge pull request #451 from c-bata/optional-text-widget
Allow `TextInputWidget` to accept strings for user attribute forms.
This commit is contained in:
@@ -46,7 +46,12 @@ if TYPE_CHECKING:
|
||||
)
|
||||
TextInputWidgetJSON = TypedDict(
|
||||
"TextInputWidgetJSON",
|
||||
{"type": Literal["text"], "description": Optional[str], "user_attr_key": Optional[str]},
|
||||
{
|
||||
"type": Literal["text"],
|
||||
"description": Optional[str],
|
||||
"user_attr_key": Optional[str],
|
||||
"optional": bool,
|
||||
},
|
||||
)
|
||||
UserAttrRefJSON = TypedDict("UserAttrRefJSON", {"type": Literal["user_attr"], "key": str})
|
||||
FormWidgetJSON = TypedDict(
|
||||
@@ -185,6 +190,7 @@ class TextInputWidget:
|
||||
description: A description of the text input field.
|
||||
user_attr_key: The key used by `register_user_attr_form_widgets`.
|
||||
Form output is saved as `trial.user_attrs[user_attr_key]`. Defaults to None.
|
||||
optional: If True, an empty string is acceptable.
|
||||
|
||||
Example:
|
||||
.. code-block:: python
|
||||
@@ -197,6 +203,7 @@ class TextInputWidget:
|
||||
|
||||
description: Optional[str] = None
|
||||
user_attr_key: Optional[str] = None
|
||||
optional: bool = False
|
||||
|
||||
def to_dict(self) -> TextInputWidgetJSON:
|
||||
"""
|
||||
@@ -209,6 +216,7 @@ class TextInputWidget:
|
||||
"type": "text",
|
||||
"description": self.description,
|
||||
"user_attr_key": self.user_attr_key,
|
||||
"optional": self.optional,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -217,6 +225,7 @@ class TextInputWidget:
|
||||
return cls(
|
||||
description=d.get("description"),
|
||||
user_attr_key=d.get("user_attr_key"),
|
||||
optional=d.get("optional", False),
|
||||
)
|
||||
|
||||
|
||||
@@ -347,6 +356,8 @@ def register_objective_form_widgets(
|
||||
not isinstance(w, ObjectiveUserAttrRef) and w.user_attr_key is not None for w in widgets
|
||||
):
|
||||
warnings.warn("`user_attr_key` specified, but it will not be used.")
|
||||
if any(isinstance(w, TextInputWidget) and w.optional for w in widgets):
|
||||
raise ValueError("TextInputWidget.optional must be False.")
|
||||
form_widgets: FormWidgetJSON = {
|
||||
"output_type": "objective",
|
||||
"widgets": [w.to_dict() for w in widgets],
|
||||
|
||||
@@ -91,6 +91,8 @@ def serialize_attrs(attrs: dict[str, Any]) -> list[Attribute]:
|
||||
value: str
|
||||
if isinstance(v, bytes):
|
||||
value = "<binary object>"
|
||||
elif isinstance(v, str):
|
||||
value = v
|
||||
else:
|
||||
value = json.dumps(v)
|
||||
value = value[:MAX_ATTR_LENGTH] if len(value) > MAX_ATTR_LENGTH else value
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isFileUploading,
|
||||
artifactIsAvailable,
|
||||
reloadIntervalState,
|
||||
trialsUpdatingState,
|
||||
} from "./state"
|
||||
import { getDominatedTrials } from "./dominatedTrials"
|
||||
|
||||
@@ -45,6 +46,7 @@ export const actionCreator = () => {
|
||||
const [paramImportance, setParamImportance] =
|
||||
useRecoilState<StudyParamImportance>(paramImportanceState)
|
||||
const setUploading = useSetRecoilState<boolean>(isFileUploading)
|
||||
const setTrialsUpdating = useSetRecoilState(trialsUpdatingState)
|
||||
const setArtifactIsAvailable = useSetRecoilState<boolean>(artifactIsAvailable)
|
||||
|
||||
const setStudyDetailState = (studyId: number, study: StudyDetail) => {
|
||||
@@ -62,6 +64,13 @@ export const actionCreator = () => {
|
||||
newStudy.trials = newTrials
|
||||
setStudyDetailState(studyId, newStudy)
|
||||
}
|
||||
const setTrialUpdating = (trialId: number, updating: boolean) => {
|
||||
setTrialsUpdating((prev) => {
|
||||
const newVal = Object.assign({}, prev)
|
||||
newVal[trialId] = updating
|
||||
return newVal
|
||||
})
|
||||
}
|
||||
|
||||
const setTrialNote = (studyId: number, index: number, note: Note) => {
|
||||
const newTrial: Trial = Object.assign(
|
||||
@@ -157,7 +166,7 @@ export const actionCreator = () => {
|
||||
const setTrialUserAttrs = (
|
||||
studyId: number,
|
||||
index: number,
|
||||
user_attrs: { [key: string]: number }
|
||||
user_attrs: { [key: string]: number | string }
|
||||
) => {
|
||||
const newTrial: Trial = Object.assign(
|
||||
{},
|
||||
@@ -469,6 +478,7 @@ export const actionCreator = () => {
|
||||
|
||||
const makeTrialFail = (studyId: number, trialId: number): void => {
|
||||
const message = `id=${trialId}, state=Fail`
|
||||
setTrialUpdating(trialId, true)
|
||||
tellTrialAPI(trialId, "Fail")
|
||||
.then(() => {
|
||||
const index = studyDetails[studyId].trials.findIndex(
|
||||
@@ -486,6 +496,7 @@ export const actionCreator = () => {
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
setTrialUpdating(trialId, false)
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(
|
||||
`Failed to update trial (${message}). Reason: ${reason}`,
|
||||
@@ -503,6 +514,7 @@ export const actionCreator = () => {
|
||||
values: number[]
|
||||
): void => {
|
||||
const message = `id=${trialId}, state=Complete, values=${values}`
|
||||
setTrialUpdating(trialId, true)
|
||||
tellTrialAPI(trialId, "Complete", values)
|
||||
.then(() => {
|
||||
const index = studyDetails[studyId].trials.findIndex(
|
||||
@@ -517,6 +529,7 @@ export const actionCreator = () => {
|
||||
setTrialStateValues(studyId, index, "Complete", values)
|
||||
})
|
||||
.catch((err) => {
|
||||
setTrialUpdating(trialId, false)
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(
|
||||
`Failed to update trial (${message}). Reason: ${reason}`,
|
||||
@@ -531,10 +544,10 @@ export const actionCreator = () => {
|
||||
const saveTrialUserAttrs = (
|
||||
studyId: number,
|
||||
trialId: number,
|
||||
user_attrs: { [key: string]: number }
|
||||
user_attrs: { [key: string]: string | number }
|
||||
): void => {
|
||||
console.log("user_attrs", user_attrs)
|
||||
const message = `id=${trialId}, user_attrs=${JSON.stringify(user_attrs)}`
|
||||
setTrialUpdating(trialId, true)
|
||||
saveTrialUserAttrsAPI(trialId, user_attrs)
|
||||
.then(() => {
|
||||
const index = studyDetails[studyId].trials.findIndex(
|
||||
@@ -552,6 +565,7 @@ export const actionCreator = () => {
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
setTrialUpdating(trialId, false)
|
||||
const reason = err.response?.data.reason
|
||||
enqueueSnackbar(
|
||||
`Failed to update trial (${message}). Reason: ${reason}`,
|
||||
|
||||
@@ -283,7 +283,7 @@ export const tellTrialAPI = (
|
||||
|
||||
export const saveTrialUserAttrsAPI = (
|
||||
trialId: number,
|
||||
user_attrs: { [key: string]: number }
|
||||
user_attrs: { [key: string]: number | string }
|
||||
): Promise<void> => {
|
||||
const req = { user_attrs: user_attrs }
|
||||
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
import React, { FC, useMemo, useState } from "react"
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
useTheme,
|
||||
Card,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Button,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Slider,
|
||||
TextField,
|
||||
} from "@mui/material"
|
||||
import { DebouncedInputTextField } from "./Debounce"
|
||||
import { actionCreator } from "../action"
|
||||
|
||||
export const ObjectiveForm: FC<{
|
||||
trial: Trial
|
||||
directions: StudyDirection[]
|
||||
names: string[]
|
||||
formWidgets: FormWidgets
|
||||
}> = ({ trial, directions, names, formWidgets }) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
const [values, setValues] = useState<(number | null)[]>(
|
||||
formWidgets.widgets.map((widget) => {
|
||||
if (widget === undefined) {
|
||||
return null
|
||||
} else if (widget.type === "text") {
|
||||
return null
|
||||
} else if (widget.type === "choice") {
|
||||
return widget.values.at(0) || null
|
||||
} else if (widget.type === "slider") {
|
||||
return widget.min
|
||||
} else if (widget.type === "user_attr") {
|
||||
const attr = trial.user_attrs.find((attr) => attr.key == widget.key)
|
||||
if (attr === undefined) {
|
||||
return null
|
||||
} else {
|
||||
const n = Number(attr.value)
|
||||
return isNaN(n) ? null : n
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const setValue = (objectiveId: number, value: number | null) => {
|
||||
const newValues = [...values]
|
||||
if (newValues.length <= objectiveId) {
|
||||
return
|
||||
}
|
||||
newValues[objectiveId] = value
|
||||
setValues(newValues)
|
||||
}
|
||||
|
||||
const disableSubmit = useMemo<boolean>(
|
||||
() => values.findIndex((v) => v === null) >= 0,
|
||||
[values]
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault()
|
||||
if (formWidgets.output_type == "objective") {
|
||||
const filtered = values.filter<number>((v): v is number => v !== null)
|
||||
if (filtered.length !== directions.length) {
|
||||
return
|
||||
}
|
||||
action.makeTrialComplete(trial.study_id, trial.trial_id, filtered)
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
const user_attrs = Object.fromEntries(
|
||||
formWidgets.widgets.map((widget, i) => [
|
||||
widget.user_attr_key,
|
||||
values[i],
|
||||
])
|
||||
)
|
||||
action.saveTrialUserAttrs(trial.study_id, trial.trial_id, user_attrs)
|
||||
}
|
||||
}
|
||||
|
||||
const getMetricName = (i: number): string => {
|
||||
if (formWidgets.output_type == "objective") {
|
||||
const n = names.at(i)
|
||||
if (n !== undefined) {
|
||||
return n
|
||||
}
|
||||
if (directions.length == 1) {
|
||||
return `Objective`
|
||||
} else {
|
||||
return `Objective ${i}`
|
||||
}
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
return formWidgets.widgets[i].user_attr_key as string
|
||||
}
|
||||
return "Unkown metric name"
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: theme.typography.fontWeightBold }}
|
||||
>
|
||||
{directions.length > 1 ? "Set Objective Values" : "Set Objective Value"}
|
||||
</Typography>
|
||||
<Box sx={{ p: theme.spacing(1, 0) }}>
|
||||
<Card
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBottom: theme.spacing(2),
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
p: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
{formWidgets.widgets.map((widget, i) => {
|
||||
const value = values.at(i)
|
||||
const key = `objective-${i}`
|
||||
if (widget.type === "text") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<DebouncedInputTextField
|
||||
onChange={(s, valid) => {
|
||||
const n = Number(s)
|
||||
if (s.length > 0 && valid && !isNaN(n)) {
|
||||
setValue(i, n)
|
||||
return
|
||||
} else if (values.at(i) !== null) {
|
||||
setValue(i, null)
|
||||
}
|
||||
}}
|
||||
delay={500}
|
||||
textFieldProps={{
|
||||
required: true,
|
||||
autoFocus: true,
|
||||
fullWidth: true,
|
||||
helperText:
|
||||
value === null || value === undefined
|
||||
? `Please input the float number.`
|
||||
: "",
|
||||
type: "text",
|
||||
inputProps: {
|
||||
pattern: "[-+]?[0-9]*.?[0-9]+([eE][-+]?[0-9]+)?",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "choice") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<RadioGroup row defaultValue={widget.values.at(0)}>
|
||||
{widget.choices.map((c, j) => (
|
||||
<FormControlLabel
|
||||
key={c}
|
||||
control={
|
||||
<Radio
|
||||
checked={value === widget.values.at(j)}
|
||||
onChange={(e) => {
|
||||
const selected = widget.values.at(j)
|
||||
if (e.target.checked) {
|
||||
setValue(
|
||||
i,
|
||||
selected === undefined ? null : selected
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={c}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "slider") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<Box sx={{ padding: theme.spacing(0, 2) }}>
|
||||
<Slider
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue(i, e.target.value as number)
|
||||
}}
|
||||
defaultValue={widget.min}
|
||||
min={widget.min}
|
||||
max={widget.max}
|
||||
step={widget.step}
|
||||
marks={
|
||||
widget.labels === null || widget.labels.length == 0
|
||||
? true
|
||||
: widget.labels
|
||||
}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "user_attr") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>{getMetricName(i)}</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={value || undefined}
|
||||
error={value === null}
|
||||
helperText={
|
||||
value === null || value === undefined
|
||||
? `This objective value is referred from trial.user_attrs[${widget.key}].`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
margin: theme.spacing(1, 2),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ marginRight: theme.spacing(1) }}
|
||||
disabled={disableSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
action.makeTrialFail(trial.study_id, trial.trial_id)
|
||||
}}
|
||||
>
|
||||
Fail Trial
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReadonlyObjectiveForm: FC<{
|
||||
trial: Trial
|
||||
directions: StudyDirection[]
|
||||
names: string[]
|
||||
formWidgets: FormWidgets
|
||||
}> = ({ trial, directions, names, formWidgets }) => {
|
||||
const theme = useTheme()
|
||||
const getMetricName = (i: number): string => {
|
||||
if (formWidgets.output_type == "objective") {
|
||||
const n = names.at(i)
|
||||
if (n !== undefined) {
|
||||
return n
|
||||
}
|
||||
if (directions.length == 1) {
|
||||
return `Objective`
|
||||
} else {
|
||||
return `Objective ${i}`
|
||||
}
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
return formWidgets.widgets[i].user_attr_key as string
|
||||
}
|
||||
return "Unkown metric name"
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: theme.typography.fontWeightBold }}
|
||||
>
|
||||
{directions.length > 1 ? "Set Objective Values" : "Set Objective Value"}
|
||||
</Typography>
|
||||
<Box sx={{ p: theme.spacing(1, 0) }}>
|
||||
<Card
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBottom: theme.spacing(2),
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
p: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
{formWidgets.widgets.map((widget, i) => {
|
||||
const key = `objective-${i}`
|
||||
if (widget.type === "text") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={trial.values?.at(i)}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "choice") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<RadioGroup row defaultValue={trial.values?.at(i)}>
|
||||
{widget.choices.map((c, j) => (
|
||||
<FormControlLabel
|
||||
key={c}
|
||||
control={
|
||||
<Radio
|
||||
checked={
|
||||
trial.values?.at(i) === widget.values.at(j)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={c}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "slider") {
|
||||
const value = trial.values?.at(i)
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{getMetricName(i)} - {widget.description}
|
||||
</FormLabel>
|
||||
<Box sx={{ padding: theme.spacing(0, 2) }}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
value === "inf" || value === "-inf" ? undefined : value
|
||||
}
|
||||
min={widget.min}
|
||||
max={widget.max}
|
||||
step={widget.step}
|
||||
marks={
|
||||
widget.labels === null || widget.labels.length == 0
|
||||
? true
|
||||
: widget.labels
|
||||
}
|
||||
valueLabelDisplay="auto"
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "user_attr") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>{getMetricName(i)}</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={trial.values?.at(i)}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Card>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import React, { FC, ReactNode, useMemo, useState } from "react"
|
||||
import {
|
||||
Typography,
|
||||
Box,
|
||||
useTheme,
|
||||
Card,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Button,
|
||||
RadioGroup,
|
||||
Radio,
|
||||
Slider,
|
||||
TextField,
|
||||
} from "@mui/material"
|
||||
import { DebouncedInputTextField } from "./Debounce"
|
||||
import { actionCreator } from "../action"
|
||||
import { useTrialUpdatingValue } from "../state"
|
||||
|
||||
type WidgetState = {
|
||||
isValid: boolean
|
||||
value: number | string
|
||||
render: () => ReactNode
|
||||
}
|
||||
|
||||
export const TrialFormWidgets: FC<{
|
||||
trial: Trial
|
||||
objectiveNames: string[]
|
||||
directions: StudyDirection[]
|
||||
formWidgets?: FormWidgets
|
||||
}> = ({ trial, objectiveNames, directions, formWidgets }) => {
|
||||
if (
|
||||
formWidgets === undefined ||
|
||||
trial.state === "Pruned" ||
|
||||
trial.state === "Fail"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const theme = useTheme()
|
||||
const trialNowUpdating = useTrialUpdatingValue(trial.trial_id)
|
||||
const headerText =
|
||||
formWidgets.output_type === "user_attr"
|
||||
? "Set User Attributes Form"
|
||||
: directions.length > 1
|
||||
? "Set Objective Values Form"
|
||||
: "Set Objective Value Form"
|
||||
const widgetNames = formWidgets.widgets.map((widget, i) => {
|
||||
if (formWidgets.output_type == "objective") {
|
||||
if (objectiveNames.at(i) !== undefined) {
|
||||
return objectiveNames[i]
|
||||
}
|
||||
return directions.length == 1 ? "Objective" : `Objective ${i}`
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
if (widget.type !== "user_attr" && widget.user_attr_key !== undefined) {
|
||||
return widget.user_attr_key
|
||||
}
|
||||
}
|
||||
console.error("Must not reach here")
|
||||
return "Unknown"
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{ fontWeight: theme.typography.fontWeightBold }}
|
||||
>
|
||||
{headerText}
|
||||
</Typography>
|
||||
{trial.state === "Running" && !trialNowUpdating ? (
|
||||
<UpdatableFormWidgets
|
||||
trial={trial}
|
||||
widgetNames={widgetNames}
|
||||
formWidgets={formWidgets}
|
||||
/>
|
||||
) : (
|
||||
<ReadonlyFormWidgets
|
||||
trial={trial}
|
||||
widgetNames={widgetNames}
|
||||
formWidgets={formWidgets}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const UpdatableFormWidgets: FC<{
|
||||
trial: Trial
|
||||
widgetNames: string[]
|
||||
formWidgets: FormWidgets
|
||||
}> = ({ trial, widgetNames, formWidgets }) => {
|
||||
const theme = useTheme()
|
||||
const action = actionCreator()
|
||||
|
||||
const widgetStates = formWidgets.widgets
|
||||
.map((w, i) => {
|
||||
const key = `${formWidgets.output_type}-${i}`
|
||||
const outputType = formWidgets.output_type
|
||||
if (w.type === "text") {
|
||||
return useTextInputWidget(key, outputType, w, widgetNames[i])
|
||||
} else if (w.type === "choice") {
|
||||
return useChoiceWidget(key, outputType, w, widgetNames[i])
|
||||
} else if (w.type === "slider") {
|
||||
return useSliderWidget(key, outputType, w, widgetNames[i])
|
||||
} else if (w.type === "user_attr") {
|
||||
return useUserAttrRefWidget(key, w, widgetNames[i], trial)
|
||||
}
|
||||
console.error("Must not reach here")
|
||||
return undefined
|
||||
})
|
||||
.filter((w): w is WidgetState => w !== undefined)
|
||||
|
||||
const disableSubmit = useMemo<boolean>(
|
||||
() => !widgetStates.every((ws) => ws.isValid),
|
||||
[widgetStates]
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault()
|
||||
const values = widgetStates.map((ws) => ws.value)
|
||||
if (formWidgets.output_type == "objective") {
|
||||
const filtered = values.filter<number>((v): v is number => v !== null)
|
||||
if (filtered.length !== formWidgets.widgets.length) {
|
||||
return
|
||||
}
|
||||
action.makeTrialComplete(trial.study_id, trial.trial_id, filtered)
|
||||
} else if (formWidgets.output_type == "user_attr") {
|
||||
const user_attrs = Object.fromEntries(
|
||||
formWidgets.widgets.map((widget, i) => [
|
||||
widget.user_attr_key,
|
||||
values[i] !== null ? values[i] : "",
|
||||
])
|
||||
)
|
||||
action.saveTrialUserAttrs(trial.study_id, trial.trial_id, user_attrs)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: theme.spacing(1, 0) }}>
|
||||
<Card
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBottom: theme.spacing(2),
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
p: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
{widgetStates.map((ws) => ws.render())}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
margin: theme.spacing(1, 2),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
type="submit"
|
||||
sx={{ marginRight: theme.spacing(1) }}
|
||||
disabled={disableSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
action.makeTrialFail(trial.study_id, trial.trial_id)
|
||||
}}
|
||||
>
|
||||
Fail Trial
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTextInputWidget = (
|
||||
key: string,
|
||||
widgetType: "user_attr" | "objective",
|
||||
widget: ObjectiveTextInputWidget,
|
||||
metricName: string
|
||||
): WidgetState => {
|
||||
const theme = useTheme()
|
||||
const [value, setValue] = useState<number | string>("")
|
||||
const isValid = useMemo(
|
||||
() =>
|
||||
widgetType === "user_attr"
|
||||
? value !== "" || widget.optional
|
||||
: value !== "" && !isNaN(Number(value)),
|
||||
[widget, value]
|
||||
)
|
||||
|
||||
const inputProps =
|
||||
widgetType === "objective"
|
||||
? {
|
||||
pattern: "[-+]?[0-9]*.?[0-9]+([eE][-+]?[0-9]+)?",
|
||||
}
|
||||
: undefined
|
||||
const helperText =
|
||||
!widget.optional && value === "" ? `Please input the float number.` : ""
|
||||
const render = () => (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{metricName} - {widget.description}
|
||||
</FormLabel>
|
||||
<DebouncedInputTextField
|
||||
onChange={(s, valid) => {
|
||||
if (widgetType === "user_attr") {
|
||||
setValue(s)
|
||||
return
|
||||
}
|
||||
|
||||
const n = Number(s)
|
||||
if (s.length > 0 && valid && !isNaN(n)) {
|
||||
setValue(n)
|
||||
} else {
|
||||
setValue("")
|
||||
}
|
||||
}}
|
||||
delay={500}
|
||||
textFieldProps={{
|
||||
type: "text",
|
||||
autoFocus: true,
|
||||
fullWidth: true,
|
||||
required: !widget.optional,
|
||||
helperText,
|
||||
inputProps,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
return { isValid, value, render }
|
||||
}
|
||||
|
||||
export const useChoiceWidget = (
|
||||
key: string,
|
||||
widgetType: "user_attr" | "objective",
|
||||
widget: ObjectiveChoiceWidget,
|
||||
metricName: string
|
||||
): WidgetState => {
|
||||
const theme = useTheme()
|
||||
const [value, setValue] = useState<number>(widget.values[0])
|
||||
const render = () => (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{metricName} - {widget.description}
|
||||
</FormLabel>
|
||||
<RadioGroup row defaultValue={widget.values.at(0)}>
|
||||
{widget.choices.map((c, j) => (
|
||||
<FormControlLabel
|
||||
key={c}
|
||||
control={
|
||||
<Radio
|
||||
checked={value === widget.values.at(j)}
|
||||
onChange={(e) => {
|
||||
const selected = widget.values.at(j)
|
||||
if (selected === undefined) {
|
||||
console.error("Must not reach here.")
|
||||
return
|
||||
}
|
||||
if (e.target.checked) {
|
||||
setValue(selected)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={c}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)
|
||||
return { isValid: true, value, render }
|
||||
}
|
||||
|
||||
export const useSliderWidget = (
|
||||
key: string,
|
||||
widgetType: "user_attr" | "objective",
|
||||
widget: ObjectiveSliderWidget,
|
||||
metricName: string
|
||||
): WidgetState => {
|
||||
const theme = useTheme()
|
||||
const [value, setValue] = useState<number>(widget.min)
|
||||
const render = () => (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{metricName} - {widget.description}
|
||||
</FormLabel>
|
||||
<Box sx={{ padding: theme.spacing(0, 2) }}>
|
||||
<Slider
|
||||
onChange={(e) => {
|
||||
// @ts-ignore
|
||||
setValue(e.target.value as number)
|
||||
}}
|
||||
defaultValue={widget.min}
|
||||
min={widget.min}
|
||||
max={widget.max}
|
||||
step={widget.step}
|
||||
marks={
|
||||
widget.labels === null || widget.labels.length == 0
|
||||
? true
|
||||
: widget.labels
|
||||
}
|
||||
valueLabelDisplay="auto"
|
||||
/>
|
||||
</Box>
|
||||
</FormControl>
|
||||
)
|
||||
return { isValid: true, value, render }
|
||||
}
|
||||
|
||||
export const useUserAttrRefWidget = (
|
||||
key: string,
|
||||
widget: ObjectiveUserAttrRef,
|
||||
metricName: string,
|
||||
trial: Trial
|
||||
): WidgetState => {
|
||||
const theme = useTheme()
|
||||
const value = useMemo(() => {
|
||||
const attr = trial.user_attrs.find((attr) => attr.key === widget.key)
|
||||
if (attr === undefined) {
|
||||
return null
|
||||
}
|
||||
const n = Number(attr.value)
|
||||
if (isNaN(n)) {
|
||||
return null
|
||||
}
|
||||
return n
|
||||
}, [trial.user_attrs])
|
||||
const render = () => (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>{metricName}</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={value || ""}
|
||||
error={value === null}
|
||||
helperText={
|
||||
value === null
|
||||
? `This objective value is referred from trial.user_attrs[${widget.key}].`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
return {
|
||||
isValid: value !== null,
|
||||
value: value !== null ? value : "",
|
||||
render,
|
||||
}
|
||||
}
|
||||
|
||||
const ReadonlyFormWidgets: FC<{
|
||||
trial: Trial
|
||||
widgetNames: string[]
|
||||
formWidgets: FormWidgets
|
||||
}> = ({ trial, widgetNames, formWidgets }) => {
|
||||
const theme = useTheme()
|
||||
const getValue = (i: number): string | TrialValueNumber => {
|
||||
if (formWidgets.output_type === "user_attr") {
|
||||
const widget = formWidgets.widgets[i] as UserAttrFormWidget
|
||||
return (
|
||||
trial.user_attrs.find((attr) => attr.key === widget.user_attr_key)
|
||||
?.value || ""
|
||||
)
|
||||
}
|
||||
const value = trial.values?.at(i)
|
||||
if (value === undefined) {
|
||||
console.error("Must not reach here.")
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: theme.spacing(1, 0) }}>
|
||||
<Card
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginBottom: theme.spacing(2),
|
||||
margin: theme.spacing(0, 1, 1, 0),
|
||||
p: theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
{formWidgets.widgets.map((widget, i) => {
|
||||
const key = `objective-${i}`
|
||||
const widgetName = widgetNames[i]
|
||||
if (widget.type === "text") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{widgetName} - {widget.description}
|
||||
</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={getValue(i)}
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "choice") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{widgetName} - {widget.description}
|
||||
</FormLabel>
|
||||
<RadioGroup row defaultValue={trial.values?.at(i)}>
|
||||
{widget.choices.map((c, j) => (
|
||||
<FormControlLabel
|
||||
key={c}
|
||||
control={
|
||||
<Radio
|
||||
checked={trial.values?.at(i) === widget.values.at(j)}
|
||||
/>
|
||||
}
|
||||
label={c}
|
||||
disabled
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "slider") {
|
||||
const value = trial.values?.at(i)
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>
|
||||
{widgetName} - {widget.description}
|
||||
</FormLabel>
|
||||
<Box sx={{ padding: theme.spacing(0, 2) }}>
|
||||
<Slider
|
||||
defaultValue={
|
||||
value === "inf" || value === "-inf" ? undefined : value
|
||||
}
|
||||
min={widget.min}
|
||||
max={widget.max}
|
||||
step={widget.step}
|
||||
marks={
|
||||
widget.labels === null || widget.labels.length == 0
|
||||
? true
|
||||
: widget.labels
|
||||
}
|
||||
valueLabelDisplay="auto"
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
</FormControl>
|
||||
)
|
||||
} else if (widget.type === "user_attr") {
|
||||
return (
|
||||
<FormControl key={key} sx={{ margin: theme.spacing(1, 2) }}>
|
||||
<FormLabel>{widgetName}</FormLabel>
|
||||
<TextField
|
||||
inputProps={{ readOnly: true }}
|
||||
value={trial.values?.at(i)}
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import { useRecoilValue } from "recoil"
|
||||
import { artifactIsAvailable } from "../state"
|
||||
import { actionCreator } from "../action"
|
||||
import { useDeleteArtifactDialog } from "./DeleteArtifactDialog"
|
||||
import { ObjectiveForm, ReadonlyObjectiveForm } from "./ObjectiveForm"
|
||||
import { TrialFormWidgets } from "./TrialFormWidgets"
|
||||
|
||||
const states: TrialState[] = [
|
||||
"Complete",
|
||||
@@ -274,26 +274,12 @@ const TrialListDetail: FC<{
|
||||
latestNote={trial.note}
|
||||
cardSx={{ marginBottom: theme.spacing(2) }}
|
||||
/>
|
||||
{trial.state === "Running" &&
|
||||
directions.length > 0 &&
|
||||
formWidgets !== undefined && (
|
||||
<ObjectiveForm
|
||||
trial={trial}
|
||||
directions={directions}
|
||||
names={objectiveNames}
|
||||
formWidgets={formWidgets}
|
||||
/>
|
||||
)}
|
||||
{trial.state === "Complete" &&
|
||||
directions.length > 0 &&
|
||||
formWidgets !== undefined && (
|
||||
<ReadonlyObjectiveForm
|
||||
trial={trial}
|
||||
directions={directions}
|
||||
names={objectiveNames}
|
||||
formWidgets={formWidgets}
|
||||
/>
|
||||
)}
|
||||
<TrialFormWidgets
|
||||
trial={trial}
|
||||
directions={directions}
|
||||
objectiveNames={objectiveNames}
|
||||
formWidgets={formWidgets}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
marginBottom: theme.spacing(2),
|
||||
|
||||
@@ -10,6 +10,13 @@ export const studyDetailsState = atom<StudyDetails>({
|
||||
default: {},
|
||||
})
|
||||
|
||||
export const trialsUpdatingState = atom<{
|
||||
[trialId: string]: boolean
|
||||
}>({
|
||||
key: "trialsUpdating",
|
||||
default: {},
|
||||
})
|
||||
|
||||
export const paramImportanceState = atom<StudyParamImportance>({
|
||||
key: "paramImportance",
|
||||
default: {},
|
||||
@@ -59,6 +66,11 @@ export const useStudySummaryValue = (studyId: number): StudySummary | null => {
|
||||
return studySummaries.find((s) => s.study_id == studyId) || null
|
||||
}
|
||||
|
||||
export const useTrialUpdatingValue = (trialId: number): boolean => {
|
||||
const updating = useRecoilValue(trialsUpdatingState)
|
||||
return updating[trialId] || false
|
||||
}
|
||||
|
||||
export const useParamImportanceValue = (
|
||||
studyId: number
|
||||
): ParamImportance[][] | null => {
|
||||
|
||||
Vendored
+1
@@ -151,6 +151,7 @@ type ObjectiveSliderWidget = {
|
||||
type ObjectiveTextInputWidget = {
|
||||
type: "text"
|
||||
description: string
|
||||
optional: boolean
|
||||
user_attr_key?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,6 @@ class SerializeAttrsTestCase(TestCase):
|
||||
serialized = serialize_attrs({"bytes": b"This is a bytes object."})
|
||||
self.assertEqual(serialized[0]["value"], "<binary object>")
|
||||
|
||||
def test_serialize_string(self) -> None:
|
||||
for length in [1000, 1024, 1100]:
|
||||
with self.subTest(f"length: {length}"):
|
||||
value = "a" * length
|
||||
serialized = serialize_attrs(
|
||||
{
|
||||
"key": value,
|
||||
}
|
||||
)
|
||||
self.assertLessEqual(len(serialized[0]["value"]), 1024)
|
||||
|
||||
def test_serialize_dict(self) -> None:
|
||||
serialized = serialize_attrs(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user