From 408b4ecb226e0c04438f03764c2b4158bba16ff0 Mon Sep 17 00:00:00 2001 From: huojiarong Date: Thu, 20 Aug 2026 05:59:04 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=BA=E6=A2=B0=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=8A=9B=E6=9B=B2=E7=BA=BF=E5=B9=B6=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=B0=94=E5=8A=A8=E5=AD=94=E5=8F=A3=E7=AB=AF=E5=8F=A3=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/amesim/flow/orifices.py | 10 +- docs/update-log/更新日志-2026-08-20.md | 6 + frontend/src/App.tsx | 8 +- frontend/src/SimulationResultsView.tsx | 101 +++++++--- frontend/src/pnvoPortDisplayMigration.ts | 39 ++++ frontend/src/resultEventSeries.ts | 190 ++++++++++++++++++ frontend/tests/e2e/component-symbols.spec.ts | 16 +- .../e2e/pnvo-port-display-migration.spec.ts | 54 +++++ .../tests/e2e/result-event-series.spec.ts | 131 ++++++++++++ .../tests/e2e/results-chart-windows.spec.ts | 171 ++++++++++++++++ tests/test_component_catalog.py | 14 ++ 11 files changed, 697 insertions(+), 43 deletions(-) create mode 100644 frontend/src/pnvoPortDisplayMigration.ts create mode 100644 frontend/src/resultEventSeries.ts create mode 100644 frontend/tests/e2e/pnvo-port-display-migration.spec.ts create mode 100644 frontend/tests/e2e/result-event-series.spec.ts diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index f23493a..f7af1eb 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -574,8 +574,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent): category_id="flow", symbol="amesim_pnvo001_fixed", ports=( - PortDisplaySpec("port_2", "left", order=10), - PortDisplaySpec("port_3", "right", order=20), + # AMESim default geometry places port 2 right and port 3 left. + PortDisplaySpec("port_2", "right", order=10), + PortDisplaySpec("port_3", "left", order=20), ), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), @@ -990,8 +991,9 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening): symbol="amesim_pnvo001", ports=( PortDisplaySpec("res", "left", order=5), - PortDisplaySpec("port_2", "left", order=10), - PortDisplaySpec("port_3", "right", order=20), + # AMESim default geometry places port 2 right and port 3 left. + PortDisplaySpec("port_2", "right", order=10), + PortDisplaySpec("port_3", "left", order=20), ), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), diff --git a/docs/update-log/更新日志-2026-08-20.md b/docs/update-log/更新日志-2026-08-20.md index b3c51e9..9f6ed0b 100644 --- a/docs/update-log/更新日志-2026-08-20.md +++ b/docs/update-log/更新日志-2026-08-20.md @@ -8,3 +8,9 @@ - 纳管慢区归因、真实 worker/API/浏览器复验及未启用候选的历史报告;最终默认方案保持 `legacy` 机械容差和 SciPy Jacobian。本地证据中 production worker `0.2 s / 0.001 s` 相对旧批准基线缩短约 `7.50%`,AMESim 物理门保持通过,改变轨迹或收益不足的接触感知容差与半解析 Jacobian 候选未启用。 - 合并后的完整后端 900 项测试通过,其中 3 项按条件跳过;前端两套 TypeScript 检查、Vite 生产构建及 12 项活动看门狗/超时回归通过,差异格式检查无异常。 - 仍存在的问题:跨 `maxStep` 的自动分层比较器尚未完成;当前权威 `5 s` 聚合报告和 `10 s / 0.001 s` 基线尚未生成;真正无活动调用的硬终止、客户端断连恢复、并发资源门控及 SciPy 内部有限差分 Jacobian 的实时分类仍待实现。 + +## 13:55 + +- 对齐固定开度与信号控制 PNVO001 的 AMESim 默认端口显示:`port_2` 位于右侧、`port_3` 位于左侧,同时保留目录中的端口编号顺序;旧工程仅在检测到历史端口方位快照时幂等迁移一次镜像状态,不修改连接端口名、压力流量方程或结果键。 +- 修复机械状态事件对结果曲线的展示干扰:结果页仅隐藏事件时刻相对两侧连续样本异常放大的孤立力尖峰,并连续绘制其两侧正常样本;规则同时覆盖机械端口力和 LSTP00A、LMECHN1 等机械组件的派生力,不处理外部力信号、持续接触力及非力状态。原始 `series`、`.simresult` 与 CSV 导出保持不变。 +- PNVO 目录/组件定向后端测试 20 项、端口符号/旧工程迁移/事件力纯函数与真实浏览器回归 4 项均通过;前端 TypeScript 检查与 Vite 生产构建通过,差异格式检查无异常。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f45d3ec..244a4a2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -142,6 +142,7 @@ import { isParameterExpressionValue, isScientificNotationValue, } from "./parameterExpression"; +import { migratedPnvo001Mirrored } from "./pnvoPortDisplayMigration"; import { WorkspaceViewTabs, type WorkspaceView, @@ -9473,7 +9474,12 @@ function normalizeLoadedNode( parameterUnits, parameterScientificNotation, rotation: normalizeNodeRotation(node.data.rotation), - mirrored: Boolean(node.data.mirrored), + mirrored: migratedPnvo001Mirrored( + definition?.modelType ?? node.data.modelType, + node.data.ports, + definition?.ports, + Boolean(node.data.mirrored), + ), }, }; } diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index 8698c75..71961ca 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -50,6 +50,11 @@ import { orthogonalEdgePoints, type EdgeRouteData, } from "./edgeRouting"; +import { + continuousMechanicalForceChartValues, + resultStateTransitionSampleIndices, + segmentedChartPath, +} from "./resultEventSeries"; type ResultPortDefinition = { name: string; @@ -136,6 +141,11 @@ type ResultPayload = { final: Record; series: Record; diagnostics: { + integration?: { + segments?: Array<{ + stateTransitionTimes?: number[]; + }>; + }; pressureFlow: { solveCount: number; maxScaledResidual: number; @@ -383,6 +393,14 @@ export function SimulationResultsView({ }: SimulationResultsViewProps) { const reactFlow = useReactFlow(); const resultStatus = simulationResultStatus(snapshot.result); + const stateTransitionSampleIndices = useMemo( + () => + resultStateTransitionSampleIndices( + snapshot.result.series.time ?? [], + snapshot.result.diagnostics, + ), + [snapshot.result.diagnostics, snapshot.result.series.time], + ); const [selectedNodeId, setSelectedNodeId] = useState( snapshot.project.nodes[0]?.id ?? null, ); @@ -1613,9 +1631,16 @@ export function SimulationResultsView({ variable, chartWindow.unit, ); - const displayValues = ( - snapshot.result.series[chartWindow.variableKey] ?? [] - ).map(selectedUnit.fromBase); + const chartTime = snapshot.result.series.time ?? []; + const primaryChartValues = continuousMechanicalForceChartValues( + variable, + component, + snapshot.result.series[chartWindow.variableKey] ?? [], + stateTransitionSampleIndices, + ); + const displayValues = primaryChartValues.values.map( + selectedUnit.fromBase, + ); const combinedVariables = windowVariables.filter( (item) => isMixed || item.unit === variable.unit, ); @@ -1632,6 +1657,13 @@ export function SimulationResultsView({ chartWindow.variableUnits[item.key] ?? item.unit, ) : selectedUnit; + const itemChartValues = + continuousMechanicalForceChartValues( + item, + itemComponent, + snapshot.result.series[item.key] ?? [], + stateTransitionSampleIndices, + ); return { key: item.key, label: resultVariableTitle( @@ -1640,12 +1672,18 @@ export function SimulationResultsView({ ), color: colorForVariable(item.key), unit: itemUnit.label, - values: (snapshot.result.series[item.key] ?? []).map( - itemUnit.fromBase, - ), + values: itemChartValues.values.map(itemUnit.fromBase), + separatedEventSampleCount: + itemChartValues.separatedEventSampleCount, }; }); - const chartTime = snapshot.result.series.time ?? []; + const separatedEventSampleCount = isCombined + ? multiSeries.reduce( + (count, item) => + count + item.separatedEventSampleCount, + 0, + ) + : primaryChartValues.separatedEventSampleCount; const visibilitySeries = isCombined ? multiSeries : [ @@ -1671,6 +1709,7 @@ export function SimulationResultsView({ }`} data-chart-kind={chartWindow.kind} data-pending="false" + data-separated-event-samples={separatedEventSampleCount} key={chartWindow.id} onDragLeave={(event) => { if ( @@ -1727,6 +1766,9 @@ export function SimulationResultsView({ {isCombined ? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}` : componentName} + {separatedEventSampleCount > 0 + ? ` · 已隐藏 ${separatedEventSampleCount} 个孤立事件力尖峰` + : ""}
@@ -2026,7 +2068,7 @@ export function SimulationResultsView({ } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} series={multiSeries} - time={snapshot.result.series.time ?? []} + time={chartTime} viewport={chartWindow.viewport} zoomEnabled={chartWindow.zoomEnabled} /> @@ -2038,7 +2080,7 @@ export function SimulationResultsView({ } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} series={multiSeries} - time={snapshot.result.series.time ?? []} + time={chartTime} title="多曲线对比" unit={selectedUnit.label} viewport={chartWindow.viewport} @@ -2052,7 +2094,7 @@ export function SimulationResultsView({ updateChartWindowViewport(chartWindow.id, viewport) } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} - time={snapshot.result.series.time ?? []} + time={chartTime} title={chartTitle} unit={selectedUnit.label} values={displayValues} @@ -2268,6 +2310,7 @@ type MultiCurveSeries = { color: string; unit: string; values: number[]; + separatedEventSampleCount: number; }; type ChartVisibilitySeries = { @@ -3598,12 +3641,12 @@ function MultiCurvePlot({ /> {plottedSeries.map((curve) => { - const path = curve.samples - .map( - (sample, index) => - `${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`, - ) - .join(" "); + const path = segmentedChartPath( + time, + curve.values, + xPosition, + yPosition, + ); return ( {plotBands.map((band) => { const isLast = band.index === plotBands.length - 1; - const path = band.curve.samples - .map( - (sample, sampleIndex) => - `${sampleIndex === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${band.yPosition(sample.y).toFixed(2)}`, - ) - .join(" "); + const path = segmentedChartPath( + time, + band.curve.values, + xPosition, + band.yPosition, + ); return ( {band.index > 0 ? ( @@ -4614,14 +4657,12 @@ function CurvePlot({ margin.top + (1 - (value - yMin) / yRange) * plotHeight; const xTicks = chartTicks(xMin, xMax, 5); const yTicks = chartTicks(yMin, yMax, 5); - const path = samples - .map( - (sample, index) => - `${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition( - sample.y, - ).toFixed(2)}`, - ) - .join(" "); + const path = segmentedChartPath( + time, + values, + xPosition, + yPosition, + ); const requestedCursorIndex = cursorIndex === null ? Math.floor(samples.length / 2) diff --git a/frontend/src/pnvoPortDisplayMigration.ts b/frontend/src/pnvoPortDisplayMigration.ts new file mode 100644 index 0000000..5ad6c83 --- /dev/null +++ b/frontend/src/pnvoPortDisplayMigration.ts @@ -0,0 +1,39 @@ +type PortSideSnapshot = { + name: string; + side: string; +}; + +const PNVO001_MODEL_TYPES = new Set([ + "amesim_pnvo001_fixed", + "amesim_pnvo001", +]); + +function portUsesSide( + ports: readonly PortSideSnapshot[], + portName: string, + side: string, +) { + return ports.some((port) => port.name === portName && port.side === side); +} + +export function migratedPnvo001Mirrored( + modelType: string, + savedPorts: readonly PortSideSnapshot[], + currentPorts: readonly PortSideSnapshot[] | undefined, + mirrored: boolean, +) { + if (!PNVO001_MODEL_TYPES.has(modelType) || !currentPorts) { + return mirrored; + } + + const savedUsesLegacySides = + portUsesSide(savedPorts, "port_2", "left") && + portUsesSide(savedPorts, "port_3", "right"); + const catalogUsesAmesimSides = + portUsesSide(currentPorts, "port_2", "right") && + portUsesSide(currentPorts, "port_3", "left"); + + return savedUsesLegacySides && catalogUsesAmesimSides + ? !mirrored + : mirrored; +} diff --git a/frontend/src/resultEventSeries.ts b/frontend/src/resultEventSeries.ts new file mode 100644 index 0000000..2e30cfe --- /dev/null +++ b/frontend/src/resultEventSeries.ts @@ -0,0 +1,190 @@ +export type StateTransitionDiagnostics = { + integration?: { + segments?: Array<{ + stateTransitionTimes?: number[]; + }>; + }; +}; + +type EventSeriesVariable = { + scope: "component" | "port"; + portName: string | null; + quantity: string; + category: string; +}; + +type EventSeriesComponent = { + data: { + ports: Array<{ + name: string; + domain: string; + }>; + }; +}; + +const ISOLATED_EVENT_FORCE_RATIO = 100; + +export function resultStateTransitionSampleIndices( + time: number[], + diagnostics: StateTransitionDiagnostics, +) { + const indices = new Set(); + for (const segment of diagnostics.integration?.segments ?? []) { + for (const eventTime of segment.stateTransitionTimes ?? []) { + if (!Number.isFinite(eventTime)) { + continue; + } + let index = time.indexOf(eventTime); + if (index < 0) { + const tolerance = 1e-12 * Math.max(Math.abs(eventTime), 1); + index = time.findIndex( + (sampleTime) => Math.abs(sampleTime - eventTime) <= tolerance, + ); + } + if (index >= 0) { + indices.add(index); + } + } + } + return indices; +} + +export function continuousMechanicalForceChartValues( + variable: EventSeriesVariable, + component: EventSeriesComponent | undefined, + values: number[], + stateTransitionSampleIndices: ReadonlySet, +) { + const componentHasMechanicalPort = Boolean( + component?.data.ports.some((port) => port.domain === "mechanical"), + ); + const forceBelongsToMechanicalDomain = + variable.scope === "component" + ? variable.category === "derived" && componentHasMechanicalPort + : variable.portName !== null && + Boolean( + component?.data.ports.some( + (port) => + port.name === variable.portName && + port.domain === "mechanical", + ), + ); + if (variable.quantity !== "force" || !forceBelongsToMechanicalDomain) { + return { values, separatedEventSampleCount: 0 }; + } + + const isolatedEventIndices: number[] = []; + for (const index of stateTransitionSampleIndices) { + if ( + index < 0 || + index >= values.length || + !isolatedEventForceSample( + values, + index, + stateTransitionSampleIndices, + ) + ) { + continue; + } + isolatedEventIndices.push(index); + } + if (isolatedEventIndices.length === 0) { + return { values, separatedEventSampleCount: 0 }; + } + + const displayValues = values.slice(); + for (const index of isolatedEventIndices) { + displayValues[index] = Number.NaN; + } + return { + values: displayValues, + separatedEventSampleCount: isolatedEventIndices.length, + }; +} + +function isolatedEventForceSample( + values: number[], + index: number, + stateTransitionSampleIndices: ReadonlySet, +) { + const value = Number(values[index]); + if (!Number.isFinite(value)) { + return false; + } + const previous = finiteNonEventNeighbor( + values, + index - 1, + -1, + stateTransitionSampleIndices, + ); + const next = finiteNonEventNeighbor( + values, + index + 1, + 1, + stateTransitionSampleIndices, + ); + if (previous === null || next === null) { + return false; + } + + const localScale = Math.max( + Math.abs(previous), + Math.abs(next), + Math.abs(next - previous), + 1e-9, + ); + const localEstimate = (previous + next) / 2; + return ( + Math.abs(value) > ISOLATED_EVENT_FORCE_RATIO * localScale && + Math.abs(value - localEstimate) > + ISOLATED_EVENT_FORCE_RATIO * localScale + ); +} + +function finiteNonEventNeighbor( + values: number[], + startIndex: number, + direction: -1 | 1, + stateTransitionSampleIndices: ReadonlySet, +) { + for ( + let index = startIndex; + index >= 0 && index < values.length; + index += direction + ) { + if (stateTransitionSampleIndices.has(index)) { + continue; + } + const value = Number(values[index]); + if (Number.isFinite(value)) { + return value; + } + } + return null; +} + +export function segmentedChartPath( + time: number[], + values: number[], + xPosition: (value: number) => number, + yPosition: (value: number) => number, +) { + const commands: string[] = []; + let continuesSegment = false; + for (let index = 0; index < time.length; index += 1) { + const x = Number(time[index]); + const y = Number(values[index]); + if (!Number.isFinite(x)) { + continuesSegment = false; + continue; + } + if (!Number.isFinite(y)) { + continue; + } + commands.push( + `${continuesSegment ? "L" : "M"} ${xPosition(x).toFixed(2)} ${yPosition(y).toFixed(2)}`, + ); + continuesSegment = true; + } + return commands.join(" "); +} diff --git a/frontend/tests/e2e/component-symbols.spec.ts b/frontend/tests/e2e/component-symbols.spec.ts index d449f74..04dc03e 100644 --- a/frontend/tests/e2e/component-symbols.spec.ts +++ b/frontend/tests/e2e/component-symbols.spec.ts @@ -107,15 +107,15 @@ function portsForSymbol(modelType: string) { } if (modelType === "amesim_pnvo001_fixed") { return [ - physicalPort("port_2", "left", 10), - physicalPort("port_3", "right", 20), + physicalPort("port_2", "right", 10), + physicalPort("port_3", "left", 20), ]; } if (modelType === "amesim_pnvo001") { return [ signalPort("res", "left", "input", 5), - physicalPort("port_2", "left", 10), - physicalPort("port_3", "right", 20), + physicalPort("port_2", "right", 10), + physicalPort("port_3", "left", 20), ]; } if ( @@ -784,8 +784,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async }, { anchors: [ - { portName: "port_2", x: 12.8, y: 24 }, - { portName: "port_3", x: 51.2, y: 24 }, + { portName: "port_3", x: 12.8, y: 24 }, + { portName: "port_2", x: 51.2, y: 24 }, ], control: "fixed", modelType: "amesim_pnvo001_fixed", @@ -794,8 +794,8 @@ test("PNOR001 与两种 PNVO001 使用统一孔板风格和 small 画布", async { anchors: [ { portName: "res", x: 27.35, y: 43.2 }, - { portName: "port_2", x: 12.8, y: 24 }, - { portName: "port_3", x: 51.2, y: 24 }, + { portName: "port_3", x: 12.8, y: 24 }, + { portName: "port_2", x: 51.2, y: 24 }, ], control: "signal", modelType: "amesim_pnvo001", diff --git a/frontend/tests/e2e/pnvo-port-display-migration.spec.ts b/frontend/tests/e2e/pnvo-port-display-migration.spec.ts new file mode 100644 index 0000000..dac7cec --- /dev/null +++ b/frontend/tests/e2e/pnvo-port-display-migration.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +import { migratedPnvo001Mirrored } from "../../src/pnvoPortDisplayMigration"; + +const legacyPorts = [ + { name: "res", side: "left" }, + { name: "port_2", side: "left" }, + { name: "port_3", side: "right" }, +]; +const amesimPorts = [ + { name: "res", side: "left" }, + { name: "port_2", side: "right" }, + { name: "port_3", side: "left" }, +]; + +test("旧 PNVO001 端口快照只迁移一次镜像方向", () => { + for (const modelType of [ + "amesim_pnvo001_fixed", + "amesim_pnvo001", + ]) { + const migrated = migratedPnvo001Mirrored( + modelType, + legacyPorts, + amesimPorts, + true, + ); + expect(migrated).toBe(false); + expect( + migratedPnvo001Mirrored( + modelType, + amesimPorts, + amesimPorts, + migrated, + ), + ).toBe(false); + expect( + migratedPnvo001Mirrored( + modelType, + legacyPorts, + amesimPorts, + false, + ), + ).toBe(true); + } + + expect( + migratedPnvo001Mirrored( + "amesim_pnor001", + legacyPorts, + amesimPorts, + true, + ), + ).toBe(true); +}); diff --git a/frontend/tests/e2e/result-event-series.spec.ts b/frontend/tests/e2e/result-event-series.spec.ts new file mode 100644 index 0000000..df828e3 --- /dev/null +++ b/frontend/tests/e2e/result-event-series.spec.ts @@ -0,0 +1,131 @@ +import { expect, test } from "@playwright/test"; + +import { + continuousMechanicalForceChartValues, + resultStateTransitionSampleIndices, + segmentedChartPath, +} from "../../src/resultEventSeries"; + +const mechanicalComponent = { + data: { + ports: [{ name: "port_2", domain: "mechanical" }], + }, +}; + +const mechanicalPortForce = { + scope: "port" as const, + portName: "port_2", + quantity: "force", + category: "flow", +}; + +test("只分离机械事件中的孤立力尖峰,同时保留连续曲线和原始值", () => { + const time = [0, 0.5, 0.75, 1, 1.5]; + const eventIndices = resultStateTransitionSampleIndices(time, { + integration: { + segments: [ + { stateTransitionTimes: [0.75] }, + { stateTransitionTimes: [0.7500000000000001] }, + ], + }, + }); + expect([...eventIndices]).toEqual([2]); + + const rawForce = [1, 2, 1e12, 3, 4]; + const displayedForce = continuousMechanicalForceChartValues( + mechanicalPortForce, + mechanicalComponent, + rawForce, + eventIndices, + ); + expect(displayedForce.separatedEventSampleCount).toBe(1); + expect(Number.isNaN(displayedForce.values[2])).toBe(true); + expect(Math.max(...displayedForce.values.filter(Number.isFinite))).toBe(4); + expect(rawForce[2]).toBe(1e12); + + const path = segmentedChartPath( + time, + displayedForce.values, + (value) => value, + (value) => value, + ); + expect(path.match(/\bM /g)).toHaveLength(1); + + const smoothForce = [1, 2, 2.5, 3, 4]; + const displayedSmoothForce = continuousMechanicalForceChartValues( + mechanicalPortForce, + mechanicalComponent, + smoothForce, + eventIndices, + ); + expect(displayedSmoothForce.values).toBe(smoothForce); + expect(displayedSmoothForce.separatedEventSampleCount).toBe(0); + + const componentForce = continuousMechanicalForceChartValues( + { + scope: "component", + portName: null, + quantity: "force", + category: "derived", + }, + mechanicalComponent, + [1, 2, -1e12, 3, 4], + eventIndices, + ); + expect(componentForce.separatedEventSampleCount).toBe(1); + expect(Number.isNaN(componentForce.values[2])).toBe(true); + + const signalForce = continuousMechanicalForceChartValues( + { + scope: "component", + portName: null, + quantity: "force", + category: "signal", + }, + mechanicalComponent, + [1, 2, -1e12, 3, 4], + eventIndices, + ); + expect(signalForce.values[2]).toBe(-1e12); + expect(signalForce.separatedEventSampleCount).toBe(0); + + const stateValues = [0, 0.5, 1e9, 1, 1.5]; + const displayedState = continuousMechanicalForceChartValues( + { ...mechanicalPortForce, quantity: "velocity" }, + mechanicalComponent, + stateValues, + eventIndices, + ); + expect(displayedState.values).toBe(stateValues); + expect(displayedState.values[2]).toBe(1e9); + + const pneumaticForce = continuousMechanicalForceChartValues( + mechanicalPortForce, + { + data: { + ports: [{ name: "port_2", domain: "pneumatic" }], + }, + }, + rawForce, + eventIndices, + ); + expect(pneumaticForce.values).toBe(rawForce); + + const persistentContactForce = [0, 0, 1e6, 1e6, 1e6]; + const displayedPersistentContactForce = + continuousMechanicalForceChartValues( + mechanicalPortForce, + mechanicalComponent, + persistentContactForce, + eventIndices, + ); + expect(displayedPersistentContactForce.values).toBe( + persistentContactForce, + ); + expect( + displayedPersistentContactForce.separatedEventSampleCount, + ).toBe(0); + expect( + resultStateTransitionSampleIndices(time, {}), + ).toEqual(new Set()); +}); diff --git a/frontend/tests/e2e/results-chart-windows.spec.ts b/frontend/tests/e2e/results-chart-windows.spec.ts index efdca1e..c09cd83 100644 --- a/frontend/tests/e2e/results-chart-windows.spec.ts +++ b/frontend/tests/e2e/results-chart-windows.spec.ts @@ -34,6 +34,88 @@ const chartWindowSnapshot = { }, }; +const eventForceSnapshot = { + ...chartWindowSnapshot, + id: "e2e-mechanical-event-force-chart", + project: { + ...chartWindowSnapshot.project, + nodes: chartWindowSnapshot.project.nodes.map((node, nodeIndex) => + nodeIndex === 0 + ? { + ...node, + data: { + ...node.data, + ports: node.data.ports.map((port, portIndex) => + portIndex === 0 + ? { ...port, domain: "mechanical" } + : port, + ), + }, + } + : node, + ), + simulation: { + ...chartWindowSnapshot.project.simulation, + t_stop: 2, + step: 0.5, + }, + }, + result: { + ...chartWindowSnapshot.result, + simulatedUntil: 2, + requestedStopTime: 2, + variables: [ + ...chartWindowSnapshot.result.variables, + { + key: "generic_sensor_1.port_a.f", + componentId: "generic_sensor_1", + componentType: "generic_sensor", + scope: "port", + portName: "port_a", + name: "f", + label: "力", + quantity: "force", + unit: "N", + category: "flow", + order: 30, + }, + { + key: "generic_sensor_1.force", + componentId: "generic_sensor_1", + componentType: "generic_sensor", + scope: "component", + portName: null, + name: "force", + label: "接触力", + quantity: "force", + unit: "N", + category: "derived", + order: 31, + }, + ], + final: { + ...chartWindowSnapshot.result.final, + "generic_sensor_1.value": 2, + "generic_sensor_1.port_a.f": 5, + "generic_sensor_1.force": 5, + }, + series: { + ...chartWindowSnapshot.result.series, + time: [0, 0.5, 0.75, 1, 1.5, 2], + "generic_sensor_1.value": [0, 0.5, 1e9, 1, 1.5, 2], + "generic_sensor_1.port_a.f": [1, 2, 1e12, 3, 4, 5], + "generic_sensor_1.force": [1, 2, -1e12, 3, 4, 5], + }, + diagnostics: { + ...chartWindowSnapshot.result.diagnostics, + integration: { + segments: [{ stateTransitionTimes: [0.75] }], + }, + sampleCount: 6, + }, + }, +}; + async function openResults(page: Page) { await page.getByRole("tab", { name: "结果", exact: true }).click(); await expect(page.locator(".results-chart-workspace")).toBeVisible(); @@ -129,6 +211,95 @@ test.beforeEach(async ({ page }) => { }, chartWindowSnapshot); }); +test("机械状态事件力点不参与连续曲线纵轴,但原始数据与非力状态保持不变", async ({ + page, +}) => { + await page.addInitScript((snapshot) => { + window.sessionStorage.setItem( + "system-simulation-flow:latest-result", + JSON.stringify(snapshot), + ); + }, eventForceSnapshot); + await page.goto("/"); + await openResults(page); + + await page + .locator(".results-variable-list button") + .filter({ hasText: "port_a · 力" }) + .first() + .click(); + + const forceWindow = page + .locator('.result-chart-window[data-chart-kind="single"]') + .filter({ hasText: "port_a.f" }); + await expect(forceWindow).toHaveAttribute( + "data-separated-event-samples", + "1", + ); + await expect(forceWindow).toContainText("已隐藏 1 个孤立事件力尖峰"); + + const forceChart = forceWindow.locator('svg[data-result-chart="true"]'); + const forceDomain = await readChartDomain(forceChart); + expect(forceDomain.yMax).toBeLessThan(10); + const forcePath = await forceChart + .locator('path[data-chart-clipped-series="true"]') + .getAttribute("d"); + expect(forcePath?.match(/\bM /g)).toHaveLength(1); + + const storedEventForce = await page.evaluate(() => { + const raw = sessionStorage.getItem( + "system-simulation-flow:latest-result", + ); + return raw + ? JSON.parse(raw).result.series["generic_sensor_1.port_a.f"][2] + : null; + }); + expect(storedEventForce).toBe(1e12); + + await page + .locator(".results-variable-list button") + .filter({ hasText: "接触力" }) + .click(); + const componentForceWindow = page + .locator('.result-chart-window[data-chart-kind="single"]') + .filter({ hasText: "generic_sensor_1.force" }); + await expect(componentForceWindow).toHaveAttribute( + "data-separated-event-samples", + "1", + ); + const componentForceDomain = await readChartDomain( + componentForceWindow.locator('svg[data-result-chart="true"]'), + ); + expect(componentForceDomain.yMin).toBeGreaterThan(-10); + + const storedComponentForce = await page.evaluate(() => { + const raw = sessionStorage.getItem( + "system-simulation-flow:latest-result", + ); + return raw + ? JSON.parse(raw).result.series["generic_sensor_1.force"][2] + : null; + }); + expect(storedComponentForce).toBe(-1e12); + + await page + .locator(".results-variable-list button") + .filter({ hasText: "数值" }) + .first() + .click(); + const stateWindow = page + .locator('.result-chart-window[data-chart-kind="single"]') + .filter({ hasText: "数值" }); + await expect(stateWindow).toHaveAttribute( + "data-separated-event-samples", + "0", + ); + const stateDomain = await readChartDomain( + stateWindow.locator('svg[data-result-chart="true"]'), + ); + expect(stateDomain.yMax).toBeGreaterThan(1e8); +}); + test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({ page, }) => { diff --git a/tests/test_component_catalog.py b/tests/test_component_catalog.py index 4c32910..e129e96 100644 --- a/tests/test_component_catalog.py +++ b/tests/test_component_catalog.py @@ -141,6 +141,13 @@ class ComponentCatalogTests(unittest.TestCase): self.assertEqual(components["amesim_pnvo001_fixed"]["category"]["id"], "flow") self.assertEqual(pnvo_parameters["opening"]["maximum"], 1.0) self.assertEqual([port["name"] for port in components["amesim_pnvo001_fixed"]["ports"]], ["port_2", "port_3"]) + self.assertEqual( + { + port["name"]: port["side"] + for port in components["amesim_pnvo001_fixed"]["ports"] + }, + {"port_2": "right", "port_3": "left"}, + ) self.assertEqual(components["amesim_step0"]["category"]["id"], "signals") self.assertEqual([port["name"] for port in components["amesim_step0"]["ports"]], ["out"]) @@ -383,6 +390,13 @@ class ComponentCatalogTests(unittest.TestCase): ) self.assertEqual(components["amesim_pnvo001"]["category"]["id"], "flow") self.assertEqual([port["name"] for port in components["amesim_pnvo001"]["ports"]], ["res", "port_2", "port_3"]) + self.assertEqual( + { + port["name"]: port["side"] + for port in components["amesim_pnvo001"]["ports"] + }, + {"res": "left", "port_2": "right", "port_3": "left"}, + ) pnl_parameters = { parameter["name"]: parameter