Compare commits

...
Author SHA1 Message Date
jiang e67f9542cd feat(history): add aligned online simulation action
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 59s
Reuse the existing simulation endpoint and align backfill to the hydraulic timestep. Block backfill when event metadata fails so the fallback query window cannot be calculated as event history.
2026-11-03 13:21:59 +08:00
4 changed files with 349 additions and 11 deletions
@@ -4,6 +4,8 @@ import dayjs from "dayjs";
import ReactECharts from "echarts-for-react";
import { apiFetch } from "@/lib/apiFetch";
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
jest.mock("@/lib/apiFetch", () => ({
@@ -29,6 +31,11 @@ jest.mock("@mui/x-date-pickers", () => ({
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock("echarts-for-react", () => jest.fn(() => null));
jest.mock("./useTimelineTimeConfig", () => ({
useTimelineTimeConfig: () => ({
durationMinutes: 1440, stepMinutes: 15, properties: { "HYDRAULIC TIMESTEP": "0:15" },
}),
}));
const jsonResponse = (payload: unknown, status = 200) =>
({
@@ -177,6 +184,8 @@ describe("HistoryDataPanel requests", () => {
jest.clearAllMocks();
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
useAccessStore.setState({ permissions: ["simulation.run"], loading: false });
useProjectStore.getState().setCurrentProject("project-1", "network-1");
});
afterEach(() => jest.clearAllMocks());
@@ -302,6 +311,7 @@ describe("HistoryDataPanel requests", () => {
render(React.createElement(HistoryDataPanel, props));
await screen.findByText(/无法读取事件方案/);
expect(apiFetch).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
@@ -341,4 +351,58 @@ describe("HistoryDataPanel requests", () => {
]);
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
});
it("snaps off-step event context without changing the actual event marker", async () => {
jest.mocked(getAnalysisScheme).mockResolvedValue(eventRun("run-1", "2026-07-30T05:38:00Z"));
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body))).toMatchObject({
start_time: "2026-07-30T04:30:00.000Z", end_time: "2026-07-30T07:45:00.000Z",
});
expect(screen.getByText(/事件方案时段:2026-07-30 13:38/)).toBeInTheDocument();
});
it("runs online simulation once, shows progress, then refreshes both history sources", async () => {
let finishSimulation!: (value: Response) => void;
jest.mocked(apiFetch).mockImplementation(async (url) => {
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
if (String(url).endsWith("/simulation-runs")) return new Promise((resolve) => { finishSimulation = resolve; });
return jsonResponse({ series: [] });
});
render(React.createElement(HistoryDataPanel, props));
const button = screen.getByRole("button", { name: "在线模拟" });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
await screen.findByText("正在计算在线模拟数据,完成后将自动刷新图表。");
expect(screen.getByRole("button", { name: "模拟中…" })).toBeDisabled();
await waitFor(() => expect(finishSimulation).toBeDefined());
fireEvent.click(button);
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
await act(async () => { finishSimulation(jsonResponse({ status: "success" })); });
await waitFor(() => expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(4));
expect(screen.getByRole("button", { name: "在线模拟" })).toBeEnabled();
});
it("disables the write action for a viewer while allowing history reads", async () => {
useAccessStore.setState({ permissions: ["simulation.view"] });
render(React.createElement(HistoryDataPanel, props));
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
expect(screen.getByRole("button", { name: "刷新" })).toBeEnabled();
});
it("shows a simulation failure without retrying or claiming data was refreshed", async () => {
jest.mocked(apiFetch).mockImplementation(async (url) => {
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
if (String(url).endsWith("/simulation-runs")) return jsonResponse({ detail: "计算服务不可用" }, 503);
return jsonResponse({ series: [] });
});
render(React.createElement(HistoryDataPanel, props));
const button = screen.getByRole("button", { name: "在线模拟" });
await waitFor(() => expect(button).toBeEnabled());
fireEvent.click(button);
expect(await screen.findByRole("alert")).toHaveTextContent("计算服务不可用");
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(2);
});
});
@@ -22,7 +22,7 @@ import {
Tooltip,
Typography,
} from "@mui/material";
import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { zhCN } from "@mui/x-data-grid/locales";
import ReactECharts from "echarts-for-react";
@@ -49,6 +49,15 @@ import {
getAnalysisHistoryContext,
getAnalysisScheme,
} from "@/lib/analysisRuns";
import {
alignSimulationRange,
parseHydraulicStepSeconds,
runOnlineSimulation,
} from "@/lib/onlineSimulation";
import { permissionCodes } from "@/lib/permissions";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
import {
FLOW_DISPLAY_UNIT,
PRESSURE_DISPLAY_UNIT,
@@ -225,6 +234,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
runId,
onClose,
}) => {
const timeConfig = useTimelineTimeConfig();
const stepSeconds = parseHydraulicStepSeconds(timeConfig.properties["HYDRAULIC TIMESTEP"])
?? timeConfig.stepMinutes * 60;
const canRunSimulation = useAccessStore((state) =>
state.permissions.includes(permissionCodes.simulationRun),
);
const projectId = useProjectStore((state) => state.currentProjectId);
const [schemeState, setSchemeState] = useState<{
runId: string;
context?: AnalysisHistoryContext;
@@ -246,7 +262,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const now = dayjs();
const start = start_time ? dayjs(start_time) : null;
const end = end_time ? dayjs(end_time) : null;
return {
const range = {
from: start?.isValid()
? start
: eventRange
@@ -258,7 +274,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
? dayjs(eventRange.end).add(1, "hour")
: now,
};
}, [start_time, end_time, eventRange]);
const aligned = alignSimulationRange({ from: range.from.toDate(), to: range.to.toDate() }, stepSeconds);
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
}, [start_time, end_time, eventRange, stepSeconds]);
const [selectedRange, setSelectedRange] = useState<{
key: string;
from: Dayjs;
@@ -273,10 +291,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const to = rangeForContext && (
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
) ? rangeForContext.to : defaultRange.to;
const setFrom = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
const setTo = (value: Dayjs) =>
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
const setFrom = (value: Dayjs) => {
const range = alignSimulationRange({ from: value.toDate(), to: to.toDate() }, stepSeconds);
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
};
const setTo = (value: Dayjs) => {
const range = alignSimulationRange({ from: from.toDate(), to: value.toDate() }, stepSeconds);
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
};
useEffect(() => {
if (!rangeForContext || (
@@ -289,6 +311,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const simulationControllerRef = useRef<AbortController | null>(null);
const draggableRef = useRef<HTMLDivElement>(null);
const requestControllerRef = useRef<AbortController | null>(null);
@@ -316,7 +341,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
};
}, [type, runId, schemeReload]);
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
const normalizedRange = useMemo(() => {
const ordered = ensureValidRange(from, to);
const aligned = alignSimulationRange({ from: ordered.from.toDate(), to: ordered.to.toDate() }, stepSeconds);
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
}, [from, to, stepSeconds]);
const featureInfosKey = JSON.stringify(featureInfos);
const requestedFeatureInfos = useMemo(
@@ -325,6 +354,42 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const hasDevices = requestedFeatureInfos.length > 0;
const hasData = timeSeries.length > 0;
const simulationContext = JSON.stringify([
projectId, type, runId, featureInfosKey, start_time, end_time,
]);
const simulationContextRef = useRef(simulationContext);
useEffect(() => {
simulationContextRef.current = simulationContext;
setSimulationError(null);
}, [simulationContext]);
const handleOnlineSimulation = async () => {
if (simulationControllerRef.current || !canRunSimulation || !projectId || !hasDevices || !schemeReady || schemeError) return;
const controller = new AbortController();
simulationControllerRef.current = controller;
setIsSimulating(true);
setSimulationError(null);
try {
const result = await runOnlineSimulation({
from: normalizedRange.from.toDate(), to: normalizedRange.to.toDate(),
}, controller.signal);
if (controller.signal.aborted || simulationContextRef.current !== simulationContext) return;
// 更新查询范围会触发历史数据重查,读取刚保存的在线结果。
setSelectedRange({
key: rangeKey, from: dayjs(result.range.from), to: dayjs(result.range.to),
startTime: start_time, endTime: end_time,
});
} catch (err) {
if (!controller.signal.aborted && simulationContextRef.current === simulationContext) {
setSimulationError(err instanceof Error ? err.message : "在线模拟失败,请刷新确认已生成的数据");
}
} finally {
if (simulationControllerRef.current === controller) {
simulationControllerRef.current = null;
setIsSimulating(false);
}
}
};
const dataset = useMemo(
() => buildDataset(timeSeries, historySeries, fractionDigits),
@@ -392,6 +457,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
useEffect(
() => () => {
requestControllerRef.current?.abort();
simulationControllerRef.current?.abort();
simulationControllerRef.current = null;
},
[],
);
@@ -454,6 +521,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
);
const renderEmpty = () => {
if (isSimulating) {
return <Box sx={{ display: "grid", placeItems: "center", height: "100%" }}>
<CircularProgress size={32} />
</Box>;
}
if (!hasDevices) {
return (
<PanelEmptyState
@@ -583,8 +655,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
boundaryGap: false,
min: normalizedRange.from.valueOf(),
max: normalizedRange.to.valueOf(),
minInterval: stepSeconds * 1000,
axisLabel: {
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
hideOverlap: true,
},
},
yAxis: [
@@ -801,6 +875,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="开始时间"
value={from}
disabled={isSimulating}
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setFrom(value);
@@ -814,6 +889,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="结束时间"
value={to}
disabled={isSimulating}
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setTo(value);
@@ -826,7 +902,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Stack>
<Stack
direction="row"
direction={{ xs: "column", sm: "row" }}
spacing={1}
alignItems="center"
justifyContent="space-between"
@@ -850,6 +926,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
/>
</Tabs>
<Stack direction="row" spacing={1}>
{type !== "none" && (
<Tooltip title={canRunSimulation
? "按管网水力步长计算当前查询时段的在线模拟数据"
: "需要在线模拟运行权限"}>
<span>
<Button
variant="contained"
size="small"
startIcon={isSimulating ? <CircularProgress size={16} color="inherit" /> : <PlayArrow fontSize="small" />}
disabled={!canRunSimulation || !projectId || !hasDevices || !schemeReady || Boolean(schemeError) || isSimulating || loadingState === "loading"}
onClick={() => { void handleOnlineSimulation(); }}
>
{isSimulating ? "模拟中…" : "在线模拟"}
</Button>
</span>
</Tooltip>
)}
<Tooltip title="刷新数据">
<span>
<Button
@@ -858,7 +951,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
color="primary"
startIcon={<Refresh fontSize="small" />}
disabled={
!hasDevices || !schemeReady || loadingState === "loading"
!hasDevices || !schemeReady || isSimulating || loadingState === "loading"
}
onClick={() => {
if (schemeError) {
@@ -883,6 +976,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
</Typography>
)}
{isSimulating && (
<Typography role="status" variant="caption" sx={{ mt: 1, display: "block" }}>
正在计算在线模拟数据,完成后将自动刷新图表。
</Typography>
)}
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
</Typography>
)}
{simulationError && (
<Typography role="alert" variant="caption" color="error" sx={{ mt: 1, display: "block" }}>
在线模拟失败:{simulationError}
</Typography>
)}
{(schemeError || error) && (
<Typography
variant="caption"
+86
View File
@@ -0,0 +1,86 @@
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
import { alignSimulationRange, parseHydraulicStepSeconds, runOnlineSimulation } from "./onlineSimulation";
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
const response = (body: unknown, ok = true) => ({
ok, json: async () => body,
}) as Response;
describe("online simulation time alignment", () => {
it.each([["0:15", 900], ["00:05:00", 300], ["1:00", 3600], ["0:00:30", 30]])(
"reads the configured step %s", (clock, seconds) => {
expect(parseHydraulicStepSeconds(clock)).toBe(seconds);
},
);
it.each(["0:00", "0:60", "0:00:60", "15", "invalid", undefined])(
"does not invent a step from invalid config %s", (clock) => {
expect(parseHydraulicStepSeconds(clock)).toBeUndefined();
},
);
it("aligns the screenshot's range to 15-minute clock boundaries", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T12:38:42+08:00"),
to: new Date("2026-07-30T15:38:42+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T04:30:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
});
it("keeps aligned endpoints and handles ranges crossing midnight", () => {
const range = alignSimulationRange({
from: new Date("2026-07-30T23:50:00+08:00"),
to: new Date("2026-07-31T00:10:00+08:00"),
}, 900);
expect(range.from.toISOString()).toBe("2026-07-30T15:45:00.000Z");
expect(range.to.toISOString()).toBe("2026-07-30T16:15:00.000Z");
expect(alignSimulationRange(range, 900)).toEqual(range);
});
});
describe("online simulation request", () => {
const range = {
from: new Date("2026-07-30T12:38:00+08:00"),
to: new Date("2026-07-30T15:38:00+08:00"),
};
beforeEach(() => {
jest.mocked(apiFetch).mockReset();
useProjectStore.getState().setCurrentProject("project-1", "network-1");
});
it("runs the full snapped range including the right endpoint, without network fields", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ times: { "HYDRAULIC TIMESTEP": "0:15" } }))
.mockResolvedValueOnce(response({ status: "success" }));
const result = await runOnlineSimulation(range);
expect(result.range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toEqual({
start_time: "2026-07-30T04:30:00.000Z", duration: 210,
});
expect(String(jest.mocked(apiFetch).mock.calls[1][0])).toContain("/api/v1/simulation-runs");
});
it("refuses to calculate with a missing hydraulic step", async () => {
jest.mocked(apiFetch).mockResolvedValue(response({}));
await expect(runOnlineSimulation(range)).rejects.toThrow("水力时间步长无效");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not submit a write if the project changed during the config read", async () => {
jest.mocked(apiFetch).mockImplementationOnce(async () => {
useProjectStore.getState().setCurrentProject("project-2", "network-2");
return response({ "HYDRAULIC TIMESTEP": "0:15" });
});
await expect(runOnlineSimulation(range)).rejects.toThrow("项目已切换");
expect(apiFetch).toHaveBeenCalledTimes(1);
});
it("does not retry or treat a queued acknowledgment as completion", async () => {
jest.mocked(apiFetch).mockResolvedValueOnce(response({ "HYDRAULIC TIMESTEP": "0:15" }))
.mockResolvedValueOnce(response({ status: "queued" }));
await expect(runOnlineSimulation(range)).rejects.toThrow("未确认完成");
expect(apiFetch).toHaveBeenCalledTimes(2);
});
});
+79
View File
@@ -0,0 +1,79 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
import config from "@/config/config";
import { apiFetch } from "@/lib/apiFetch";
import { useProjectStore } from "@/store/projectStore";
dayjs.extend(utc);
dayjs.extend(timezone);
export type SimulationRange = { from: Date; to: Date };
export const parseHydraulicStepSeconds = (value: unknown): number | undefined => {
if (typeof value !== "string" || !/^\d+:\d{1,2}(:\d{1,2})?$/.test(value.trim())) {
return undefined;
}
const [hours, minutes, seconds = 0] = value.trim().split(":").map(Number);
const step = hours * 3600 + minutes * 60 + seconds;
return minutes < 60 && seconds < 60 && step > 0 ? step : undefined;
};
/** 与地图时间轴一致,以北京时间零点为基准向外取整,跨日仍保持同一步长。 */
export const alignSimulationRange = (
range: SimulationRange,
stepSeconds: number,
): SimulationRange => {
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0 ||
!Number.isFinite(range.from.getTime()) || !Number.isFinite(range.to.getTime())) {
throw new Error("查询时间或水力时间步长无效");
}
const start = Math.min(range.from.getTime(), range.to.getTime());
const end = Math.max(range.from.getTime(), range.to.getTime());
const origin = dayjs(start).tz("Asia/Shanghai").startOf("day").valueOf();
const step = stepSeconds * 1000;
const from = origin + Math.floor((start - origin) / step) * step;
const to = Math.max(from + step, origin + Math.ceil((end - origin) / step) * step);
return { from: new Date(from), to: new Date(to) };
};
/** 复用同步在线模拟接口,成功响应表示计算和保存已完成;不自动重试写请求。 */
export const runOnlineSimulation = async (
range: SimulationRange,
signal?: AbortSignal,
): Promise<{ range: SimulationRange; stepSeconds: number }> => {
const projectId = useProjectStore.getState().currentProjectId;
if (!projectId) throw new Error("请先选择项目,未启动在线模拟");
const projectHeaders = { "X-Project-Id": projectId };
const timeResponse = await apiFetch(
`${config.BACKEND_URL}/api/v1/network-options/time`, {
signal, headers: projectHeaders, projectHeaderMode: "omit",
},
);
if (!timeResponse.ok) throw new Error("无法读取管网时间步长,未启动在线模拟");
const payload = await timeResponse.json();
const stepSeconds = parseHydraulicStepSeconds(
(payload?.times ?? payload)?.["HYDRAULIC TIMESTEP"],
);
if (!stepSeconds) throw new Error("管网水力时间步长无效,未启动在线模拟");
if (signal?.aborted || useProjectStore.getState().currentProjectId !== projectId) {
throw new Error("项目已切换或面板已关闭,未启动在线模拟");
}
const alignedRange = alignSimulationRange(range, stepSeconds);
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/simulation-runs`, {
method: "POST",
headers: { ...projectHeaders, "Content-Type": "application/json" },
projectHeaderMode: "omit",
// 后端循环的结束时间不包含在计算内,多算一个步长以覆盖图表右端点。
body: JSON.stringify({
start_time: alignedRange.from.toISOString(),
duration: Math.ceil((alignedRange.to.getTime() - alignedRange.from.getTime() + stepSeconds * 1000) / 60000),
}),
signal,
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.status !== "success") {
throw new Error(typeof result?.detail === "string" ? result.detail : "在线模拟未确认完成,请刷新查看已生成的数据");
}
return { range: alignedRange, stepSeconds };
};