diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5928484..743243c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,40 @@
All notable changes to DualSPHysics Studio are documented in this file.
+## [1.0.2] - 2026-07-29
+
+### Added
+
+- Added conservative preflight resource estimates from `dp`, definition
+ bounds, `TimeMax`, and `TimeOut`, followed by GenCase particle-count
+ correction and measured solver-memory parsing.
+- Added a run center with session history, stage status, determinate particle
+ frame progress, dynamic ETA, resource evidence, output access, and record
+ export actions.
+- Added an explicitly confirmed one-click workflow that always executes
+ GenCase, the user-selected CPU or GPU solver, and PartVTK in order.
+- Added case-local `.dss-run` records with atomic `run.json` updates, the exact
+ XML snapshot, stage logs, tool and configuration hashes, relative manifest
+ references, interrupted-run recovery, and checksum-bearing diagnostic ZIPs.
+- Added “create a new run from record”; it repeats preflight into a new output
+ directory and never replays an old command over existing outputs.
+
+### Changed
+
+- Manual GenCase, CPU/GPU, and PartVTK actions now join or create the same
+ reproducible output session used by the one-click workflow.
+- Diagnostic exports include the XML snapshot by default. Users with case
+ privacy requirements can explicitly exclude it in the export dialog.
+
+### Security
+
+- Diagnostic exports redact local absolute paths from records, manifests, and
+ logs while continuing to exclude credentials and arbitrary environment
+ variables.
+- Heuristic capacity estimates never silently select a solver, overwrite an
+ output, or claim guaranteed capacity; hard execution failures still come
+ from validated inputs, trusted tools, and observed output conflicts.
+
## [1.0.1] - 2026-07-29
### Added
diff --git a/README.md b/README.md
index d127ac9..34eb77c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# DualSPHysics Studio 1.0.1
+# DualSPHysics Studio 1.0.2
面向 DualSPHysics v5.4 的 Windows XML 可视化编辑器与本地计算流程前端。
@@ -11,6 +11,8 @@ Copyright © 2026 Fang Li。项目采用 [MIT License](LICENSE) 发布。
- 加载外部 3D 文件时仅支持 `.vtk` 和 `.stl`;读取前执行文件、点和单元预算预检,大型模型需确认,超过硬上限则阻止加载。
- “算例医生”汇总并筛选 XML 结构、计算域、marker、参数生效状态和时间配置问题;双击问题可在独立详情页查看完整描述、XML 路径、行号和源码上下文,双击位置仍可直接跳转到对应源码行。
- 直接调用已有 `GenCase_win64.exe`、`DualSPHysics5.4CPU_win64.exe`、`DualSPHysics5.4_win64.exe` 和 `PartVTK_win64.exe`,依次生成初始模型、CPU/GPU 求解数据和粒子 VTK。
+- “一键完整运行”在强确认后依次执行 GenCase、用户明确选择的 CPU/GPU 求解和 PartVTK;运行中心持续显示阶段状态、粒子与资源区间、进度和动态 ETA。
+- 每个正式阶段都在输出目录的 `.dss-run` 中维护原子会话记录、XML 快照、脱敏日志、工具哈希和阶段清单;诊断 ZIP 默认包含 XML,也可因隐私需要显式排除。
- “拓展功能”菜单按需发现便携目录 `DLC` 中由用户单独放入的独立 EXE,只有用户点击工具后才直接启动。
- 可选 OpenAI Responses API 中文解读;离线功能不需要密钥或网络。
@@ -35,12 +37,12 @@ python -m venv .venv
若当前环境已安装 `requirements-lock.txt` 中的固定版本,可添加 `-SkipInstall`。构建脚本使用 PyInstaller `onedir`,只生成目录,不额外创建 ZIP:
-- `outputs\DualSPHysics Studio-v1.0.1 (windows x64)\`
+- `outputs\DualSPHysics Studio-v1.0.2 (windows x64)\`
便携目录包含中文快速说明和合并后的第三方许可证,不包含 Python `*.dist-info` 安装元数据、DualSPHysics 官方二进制或任何案例文件。其基本结构为:
```text
-DualSPHysics Studio-v1.0.1 (windows x64)\
+DualSPHysics Studio-v1.0.2 (windows x64)\
DualSPHysicsStudio.exe
_internal\
快速说明.md
@@ -63,6 +65,8 @@ DualSPHysics Studio-v1.0.1 (windows x64)\
- GenCase、CPU/GPU 求解器和 PartVTK 仅从已保存设置、`DUALSPHYSICS_ROOT` 或用户明确选择的目录解析。
- `DLC` 工具仅在用户点击后以原 EXE 直接启动,不经过 shell/BAT,也不自动附加参数。
- 已存在求解数据或目标 VTK 时阻止运行,不自动删除或覆盖旧结果。
+- 资源估算明确区分定义域网格上限、GenCase 实测粒子数和求解器实测内存;数据不足时显示未知,不把启发式结果作为容量保证。
+- 诊断导出始终脱敏本机绝对路径;XML 默认随可复现记录导出,用户可在导出时勾选排除。
- 保存前自动创建源 XML 备份。
- AI 默认关闭;启用后会在发送前展示载荷,建议不会自动应用或运行。
diff --git a/ROADMAP.md b/ROADMAP.md
index c614b8a..30be9bb 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -27,13 +27,19 @@ scope. Version labels are planning buckets, not delivery-date commitments.
bridge to the GenCase quick check.
3. Completed in v1.0.1: expose an initial searchable parameter workbench for
existing scalar values while retaining XML as the only source of truth.
-4. Collect reproducible bug reports from the public releases.
-5. Add an opt-in diagnostic export containing version, stage logs, and
- non-sensitive environment facts while excluding case content, credentials,
- and personal paths by default.
-6. Exercise the portable build in CI or a controlled release workflow and add
+4. Completed in v1.0.2: add conservative resource estimates that distinguish
+ definition-domain upper bounds, GenCase particle measurements, solver
+ memory reports, history-calibrated duration, and unavailable data.
+5. Completed in v1.0.2: add a run center and an explicitly confirmed complete
+ GenCase -> CPU/GPU -> PartVTK workflow while preserving direct stage actions.
+6. Completed in v1.0.2: add case-local atomic run records, XML snapshots,
+ stage logs, tool hashes, interrupted-run recovery, and privacy-aware
+ diagnostic exports. XML is included by default and can be explicitly
+ excluded for private cases; local paths are always redacted from exports.
+7. Collect reproducible bug reports from the public releases.
+8. Exercise the portable build in CI or a controlled release workflow and add
Authenticode signing when a publisher certificate is available.
-7. Keep Python and pinned dependencies current through reviewed, audited patch
+9. Keep Python and pinned dependencies current through reviewed, audited patch
releases.
Exit evidence: reproduced issues have regression tests; the complete Windows
@@ -53,20 +59,20 @@ remain clean.
Exit evidence: every supported edit round-trips losslessly outside the intended
change and has a focused fixture-based regression test.
-## v1.2 — simulation workflow
+## v1.2 — run insight and recovery
-1. Make GenCase, CPU/GPU solver, and PartVTK stages easier to inspect through
- structured command previews, progress, duration, and result summaries.
-2. Add a case-local run history that records hashes and stage outcomes without
- storing private paths in the repository or release package.
-3. Improve recovery guidance for interrupted, failed, or conflicting stages
- while preserving the no-delete/no-overwrite rule.
-4. Add explicit capability and version diagnostics for configured
- DualSPHysics installations.
+The base simulation workflow moved forward into v1.0.2.
-Exit evidence: a generic sample case can complete the configured stage sequence
-with reviewable manifests, deterministic conflict behavior, and tested failure
-recovery.
+1. Compare estimates and actual measurements across multiple selected runs.
+2. Add richer filtering and recovery guidance for interrupted or failed
+ sessions while preserving the no-delete/no-overwrite rule.
+3. Add explicit capability diagnostics for configured DualSPHysics
+ installations and solver options.
+4. Validate history calibration on documented representative CPU and GPU
+ cases before narrowing any resource range.
+
+Exit evidence: comparisons remain source-hash and backend aware, recovery always
+creates a new safe run, and representative cases document estimate error bands.
## v1.3 — results visualization
@@ -100,7 +106,6 @@ without expanding their execution authority.
## Recommended next development ticket
-After validating the v1.0.1 portable package, implement the opt-in,
-privacy-filtered diagnostic export. It gives public bug reports a reproducible
-source of truth without collecting case XML, credentials, or personal paths by
-default.
+After validating the v1.0.2 portable package, add run-to-run comparison using
+the case-local session records. Keep comparisons source-hash and backend aware,
+and do not narrow resource ranges until representative evidence supports it.
diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt
index 573aa57..ded0c33 100644
--- a/THIRD_PARTY_NOTICES.txt
+++ b/THIRD_PARTY_NOTICES.txt
@@ -1,5 +1,5 @@
THIRD-PARTY SOFTWARE NOTICES
-DualSPHysics Studio 1.0.1
+DualSPHysics Studio 1.0.2
Copyright (c) 2026 Fang Li. The original DualSPHysics Studio source code is
licensed under the MIT License in the separate `LICENSE` file.
diff --git a/packaging/DualSPHysicsStudio.spec b/packaging/DualSPHysicsStudio.spec
index 528fb0e..448ce99 100644
--- a/packaging/DualSPHysicsStudio.spec
+++ b/packaging/DualSPHysicsStudio.spec
@@ -1,5 +1,5 @@
# -*- mode: python ; coding: utf-8 -*-
-"""PyInstaller onedir specification for DualSPHysics Studio 1.0.1.
+"""PyInstaller onedir specification for DualSPHysics Studio 1.0.2.
Run this specification through ``packaging/build_portable.ps1``. The spec
deliberately packages only the Studio application and its Python runtime; it
diff --git a/packaging/build_portable.ps1 b/packaging/build_portable.ps1
index 1f50864..6fa2103 100644
--- a/packaging/build_portable.ps1
+++ b/packaging/build_portable.ps1
@@ -21,11 +21,11 @@ $QtThirdPartyNotices = Join-Path $LicenseSource "Qt-6.11.1-THIRD-PARTY-NOTICES.t
$VtkThirdPartyNotices = Join-Path $LicenseSource "VTK-9.6.2-THIRD-PARTY-NOTICES.txt"
$RustThirdPartyNotices = Join-Path $LicenseSource "Rust-Native-THIRD-PARTY-NOTICES.txt"
$NoticesSource = Join-Path $ProjectRoot "THIRD_PARTY_NOTICES.txt"
-$WorkRoot = Join-Path ([System.IO.Path]::GetTempPath()) "DualSPHysicsStudio-1.0.1-pyinstaller"
+$WorkRoot = Join-Path ([System.IO.Path]::GetTempPath()) "DualSPHysicsStudio-1.0.2-pyinstaller"
$BuildRoot = Join-Path $WorkRoot "build"
$DistRoot = Join-Path $WorkRoot "dist"
$OutputsRoot = Join-Path $ProjectRoot "outputs"
-$ArtifactName = "DualSPHysics Studio-v1.0.1 (windows x64)"
+$ArtifactName = "DualSPHysics Studio-v1.0.2 (windows x64)"
$PyInstallerDirectoryName = "DualSPHysicsStudio"
$BuiltDirectory = Join-Path $DistRoot $PyInstallerDirectoryName
$PortableDirectory = Join-Path $OutputsRoot $ArtifactName
diff --git a/packaging/version_info.txt b/packaging/version_info.txt
index 13c802b..d2cdce4 100644
--- a/packaging/version_info.txt
+++ b/packaging/version_info.txt
@@ -1,8 +1,8 @@
# UTF-8
VSVersionInfo(
ffi=FixedFileInfo(
- filevers=(1, 0, 1, 0),
- prodvers=(1, 0, 1, 0),
+ filevers=(1, 0, 2, 0),
+ prodvers=(1, 0, 2, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -16,12 +16,12 @@ VSVersionInfo(
u'080404b0',
[
StringStruct(u'FileDescription', u'DualSPHysics v5.4 XML visual editor and local pipeline frontend'),
- StringStruct(u'FileVersion', u'1.0.1.0'),
+ StringStruct(u'FileVersion', u'1.0.2.0'),
StringStruct(u'InternalName', u'DualSPHysicsStudio'),
StringStruct(u'LegalCopyright', u'Copyright (c) 2026 Fang Li'),
StringStruct(u'OriginalFilename', u'DualSPHysicsStudio.exe'),
StringStruct(u'ProductName', u'DualSPHysics Studio'),
- StringStruct(u'ProductVersion', u'1.0.1')
+ StringStruct(u'ProductVersion', u'1.0.2')
]
)
]),
diff --git a/pyproject.toml b/pyproject.toml
index c9b6947..e530e8f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dualsphysics-studio"
-version = "1.0.1"
+version = "1.0.2"
description = "DualSPHysics v5.4 XML visual editor and local pipeline frontend"
readme = "README.md"
license = "MIT"
diff --git a/src/dualsphysics_studio/__main__.py b/src/dualsphysics_studio/__main__.py
index 843d49c..f53c17b 100644
--- a/src/dualsphysics_studio/__main__.py
+++ b/src/dualsphysics_studio/__main__.py
@@ -24,6 +24,8 @@ def _smoke_test() -> int:
DualSPHysicsGPUAdapter,
GenCaseAdapter,
PartVTKAdapter,
+ RunSession,
+ estimate_case,
)
from .resources import resource_path
from openai import OpenAI
@@ -44,6 +46,21 @@ def _smoke_test() -> int:
PartVTKAdapter()
with tempfile.TemporaryDirectory(prefix="dss-smoke-") as temporary:
temporary_path = Path(temporary)
+ source_path = temporary_path / "Case_Def.xml"
+ source_path.write_text(document.text, encoding="utf-8")
+ session = RunSession.create(
+ source_xml=source_path,
+ output_root=temporary_path / "Case_out",
+ backend="gpu",
+ workflow=("gencase", "gpu", "partvtk"),
+ )
+ session.add_estimate(estimate_case(document))
+ diagnostic_path = session.export_diagnostic(
+ temporary_path / "diagnostic.zip"
+ )
+ if not session.record_path.is_file() or not diagnostic_path.is_file():
+ raise RuntimeError("run-session smoke test failed")
+
stl_path = temporary_path / "triangle.stl"
stl_path.write_text(
"solid test\n"
diff --git a/src/dualsphysics_studio/app.py b/src/dualsphysics_studio/app.py
index 859d18f..528988e 100644
--- a/src/dualsphysics_studio/app.py
+++ b/src/dualsphysics_studio/app.py
@@ -1,4 +1,4 @@
-"""Application controller for DualSPHysics Studio 1.0.1.
+"""Application controller for DualSPHysics Studio.
Qt widgets live in :mod:`dualsphysics_studio.ui`; this module connects them to
the lossless document, local DualSPHysics pipeline, persistent settings, and
@@ -13,12 +13,15 @@
from datetime import datetime
from pathlib import Path
from threading import Event
+import time
from typing import Any
-from PySide6.QtCore import QEvent, QObject, QThread, QTimer, Signal, Slot
-from PySide6.QtGui import QIcon
+from PySide6.QtCore import QEvent, QObject, QThread, QTimer, QUrl, Signal, Slot
+from PySide6.QtGui import QDesktopServices, QIcon
from PySide6.QtWidgets import (
QApplication,
+ QCheckBox,
+ QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
@@ -26,6 +29,7 @@
QLabel,
QLineEdit,
QMessageBox,
+ QPlainTextEdit,
QPushButton,
QVBoxLayout,
)
@@ -39,7 +43,13 @@
GenCaseAdapter,
PartVTKAdapter,
PipelineAdapter,
+ ResourceEstimate,
RunRequest,
+ RunSession,
+ estimate_case,
+ find_case_sessions,
+ live_stage_progress,
+ session_history_values,
)
from .pipeline.models import file_sha256
from .resources import resource_path
@@ -191,6 +201,111 @@ def __init__(
layout.addWidget(buttons)
+class _FullRunDialog(QDialog):
+ """Explicit confirmation for the uninterrupted full pipeline."""
+
+ def __init__(
+ self,
+ parent: MainWindow,
+ *,
+ default_backend: str,
+ estimate: ResourceEstimate,
+ ) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("确认一键完整运行")
+ self.setModal(True)
+ self.resize(620, 460)
+ layout = QVBoxLayout(self)
+ warning = QLabel(
+ "确认后将直接完成剩余全部步骤:
"
+ "GenCase → 选择的 CPU/GPU 求解 → PartVTK。
"
+ "流程中不会再次逐阶段询问。开始前请确认 XML 参数、几何、边界条件,"
+ "以及用于比对或校准的数据均合理。运行可能持续很久并产生大量文件。",
+ self,
+ )
+ warning.setWordWrap(True)
+ layout.addWidget(warning)
+
+ form = QFormLayout()
+ self.backend_combo = QComboBox(self)
+ self.backend_combo.addItem("GPU 求解", "gpu")
+ self.backend_combo.addItem("CPU 求解", "cpu")
+ index = self.backend_combo.findData(default_backend.casefold())
+ self.backend_combo.setCurrentIndex(max(0, index))
+ form.addRow("求解后端", self.backend_combo)
+ layout.addLayout(form)
+
+ estimate_view = QPlainTextEdit(self)
+ estimate_view.setReadOnly(True)
+ estimate_view.setMaximumHeight(190)
+ estimate_view.setPlainText(
+ "\n".join(
+ f"{label}:{value} [{confidence}]\n {basis}"
+ for label, value, confidence, basis in estimate.metric_rows()
+ )
+ )
+ layout.addWidget(estimate_view)
+
+ self.confirm_check = QCheckBox(
+ "我已检查 XML 和比对数据,并确认立即执行完整流程(包含 PartVTK)",
+ self,
+ )
+ layout.addWidget(self.confirm_check)
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok
+ | QDialogButtonBox.StandardButton.Cancel,
+ parent=self,
+ )
+ self.start_button = buttons.button(QDialogButtonBox.StandardButton.Ok)
+ self.start_button.setText("开始完整运行")
+ self.start_button.setEnabled(False)
+ buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
+ self.confirm_check.toggled.connect(self.start_button.setEnabled)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ @property
+ def backend(self) -> str:
+ return str(self.backend_combo.currentData())
+
+
+class _DiagnosticExportDialog(QDialog):
+ """Collect the explicit privacy exception for diagnostic export."""
+
+ def __init__(self, parent: MainWindow) -> None:
+ super().__init__(parent)
+ self.setWindowTitle("导出可复现记录")
+ self.setModal(True)
+ layout = QVBoxLayout(self)
+ note = QLabel(
+ "默认导出 XML 快照、脱敏日志、阶段清单、工具哈希和校验文件。"
+ "绝对路径会自动替换为占位符。",
+ self,
+ )
+ note.setWordWrap(True)
+ layout.addWidget(note)
+ self.exclude_xml_check = QCheckBox(
+ "因案例隐私需要,不在导出包中包含 XML",
+ self,
+ )
+ self.exclude_xml_check.setChecked(False)
+ layout.addWidget(self.exclude_xml_check)
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Save
+ | QDialogButtonBox.StandardButton.Cancel,
+ parent=self,
+ )
+ buttons.button(QDialogButtonBox.StandardButton.Save).setText("选择导出位置")
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+ layout.addWidget(buttons)
+
+ @property
+ def include_xml(self) -> bool:
+ return not self.exclude_xml_check.isChecked()
+
+
class StudioController(QObject):
"""Own application-level state and asynchronous integrations."""
@@ -227,14 +342,29 @@ def __init__(
self._last_gencase_run: Any = None
self._case_output_prefix: Path | None = None
self._case_output_source: Path | None = None
+ self._run_session: RunSession | None = None
+ self._selected_session: RunSession | None = None
+ self._full_run_active = False
+ self._manual_session_active = False
+ self._full_run_backend: str | None = None
+ self._full_run_adapters: dict[str, tuple[PipelineAdapter, Any]] = {}
+ self._pending_full_stage: str | None = None
+ self._run_estimate: ResourceEstimate | None = None
+ self._gencase_summary: dict[str, Any] = {}
+ self._active_stage_started_monotonic: float | None = None
self.window.documentChanged.connect(self._document_changed)
+ self.window.fullRunRequested.connect(self._start_full_run)
self.window.quickCheckRequested.connect(lambda: self._start_run("quick"))
self.window.generateRequested.connect(lambda: self._start_run("full"))
self.window.cpuRequested.connect(self._start_cpu)
self.window.gpuRequested.connect(self._start_gpu)
self.window.partVTKRequested.connect(self._start_partvtk)
self.window.cancelRequested.connect(self.cancel_run)
+ self.window.openRunOutputRequested.connect(self._open_run_output)
+ self.window.exportRunRequested.connect(self._export_run_record)
+ self.window.rerunFromRecordRequested.connect(self._rerun_from_record)
+ self.window.runSessionSelected.connect(self._select_run_session)
self.window.configureDualSPHysicsRequested.connect(
self.configure_dualsphysics
)
@@ -315,6 +445,337 @@ def _document_changed(self, document: Any) -> None:
self._last_gencase_run = None
self._case_output_prefix = None
self._case_output_source = None
+ self._run_session = None
+ self._selected_session = None
+ self._full_run_active = False
+ self._manual_session_active = False
+ self._pending_full_stage = None
+ self._gencase_summary = {}
+ if path:
+ source = Path(path).resolve()
+ sessions = self._refresh_run_history(source)
+ try:
+ estimate = estimate_case(
+ document,
+ output_directory=GenCaseAdapter.default_output_prefix(source).parent,
+ source_sha256=file_sha256(source),
+ backend=self.settings.last_solver_backend,
+ history=session_history_values(sessions),
+ )
+ except OSError:
+ estimate = estimate_case(document)
+ self._run_estimate = estimate
+ self.window.update_resource_estimate(estimate)
+ else:
+ self.window.set_run_history(())
+ self.window.set_run_record_available(False)
+
+ def _refresh_run_history(self, source: Path) -> tuple[RunSession, ...]:
+ sessions = find_case_sessions(source)
+ for session in sessions:
+ session.mark_interrupted()
+ self.window.set_run_history(sessions)
+ if sessions:
+ self._display_session(sessions[0])
+ else:
+ self.window.set_run_record_available(False)
+ return sessions
+
+ @Slot(str)
+ def _select_run_session(self, record_path: str) -> None:
+ try:
+ session = RunSession.load(record_path)
+ except (OSError, ValueError, TypeError) as error:
+ QMessageBox.warning(
+ self.window,
+ "无法读取运行记录",
+ str(error),
+ )
+ return
+ if session.mark_interrupted():
+ self.window.append_log(
+ f"检测到未正常结束的会话,已标记为中断:{session.session_id}"
+ )
+ self._display_session(session)
+
+ def _display_session(self, session: RunSession) -> None:
+ self._selected_session = session
+ self.window.begin_run_session(session.session_id, session.backend)
+ for stage, entry in session.stages.items():
+ detail = ""
+ errors = entry.get("errors") or ()
+ if errors:
+ detail = str(errors[0])
+ self.window.update_run_stage(
+ stage,
+ str(entry.get("status", "unknown")),
+ detail,
+ )
+ if session.estimates:
+ try:
+ estimate = ResourceEstimate.from_dict(session.estimates[-1])
+ except (TypeError, ValueError):
+ estimate = None
+ if estimate is not None:
+ self.window.update_resource_estimate(estimate)
+ self.window.set_run_record_available(True)
+
+ @Slot()
+ def _start_full_run(self, backend_override: str | None = None) -> None:
+ if self._run_thread is not None:
+ return
+ source = self._prepare_source()
+ if source is None:
+ return
+ output_prefix = GenCaseAdapter.suggest_output_prefix(source)
+ sessions = find_case_sessions(source)
+ backend = (
+ backend_override
+ if backend_override in {"cpu", "gpu"}
+ else self.settings.last_solver_backend
+ if self.settings.last_solver_backend in {"cpu", "gpu"}
+ else "gpu"
+ )
+ source_hash = file_sha256(source)
+ estimate = estimate_case(
+ self.window.document,
+ output_directory=output_prefix.parent,
+ source_sha256=source_hash,
+ backend=backend,
+ history=session_history_values(sessions),
+ )
+ dialog = _FullRunDialog(
+ self.window,
+ default_backend=backend,
+ estimate=estimate,
+ )
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ self.window.set_run_state(False, "已取消完整运行")
+ return
+ backend = dialog.backend
+ if backend != self.settings.last_solver_backend:
+ self.settings.last_solver_backend = backend
+ try:
+ self.settings.save()
+ except OSError:
+ pass
+
+ located = self._locate_full_run_tools(source, backend)
+ if located is None:
+ self.window.set_run_state(False, "完整运行所需工具未通过核验")
+ return
+ # Backend selection can change in the confirmation dialog; refresh the
+ # history-calibrated duration using the final explicit choice.
+ estimate = estimate_case(
+ self.window.document,
+ output_directory=output_prefix.parent,
+ source_sha256=source_hash,
+ backend=backend,
+ history=session_history_values(sessions),
+ )
+ workflow = ("gencase", backend, "partvtk")
+ self.window.set_run_state(True, "正在创建记录并计算工具 SHA-256…")
+ try:
+ session = RunSession.create(
+ source_xml=source,
+ output_root=output_prefix.parent,
+ backend=backend,
+ workflow=workflow,
+ tools={stage: info for stage, (_adapter, info) in located.items()},
+ )
+ except (OSError, ValueError) as error:
+ QMessageBox.critical(
+ self.window,
+ "无法创建运行记录",
+ f"完整流程尚未启动。\n\n{error}",
+ )
+ self.window.set_run_state(False, "运行记录创建失败")
+ return
+
+ session.add_estimate(estimate)
+ self._run_session = session
+ self._selected_session = session
+ self._full_run_active = True
+ self._manual_session_active = False
+ self._full_run_backend = backend
+ self._full_run_adapters = located
+ self._pending_full_stage = None
+ self._run_estimate = estimate
+ self._gencase_summary = {}
+ self._case_output_prefix = output_prefix.resolve()
+ self._case_output_source = source.resolve()
+ self.window.log_view.clear()
+ self.window.begin_run_session(session.session_id, backend)
+ self.window.update_resource_estimate(estimate)
+ self.window.set_run_record_available(True)
+ self.window.set_run_state(True, "完整运行:准备 GenCase…")
+ self._refresh_run_history(source)
+ self._display_session(session)
+ self._start_full_stage("gencase")
+
+ def _locate_full_run_tools(
+ self,
+ source: Path,
+ backend: str,
+ ) -> dict[str, tuple[PipelineAdapter, Any]] | None:
+ adapter_types: tuple[type[PipelineAdapter], ...] = (
+ GenCaseAdapter,
+ DualSPHysicsGPUAdapter if backend == "gpu" else DualSPHysicsCPUAdapter,
+ PartVTKAdapter,
+ )
+
+ def locate() -> dict[str, tuple[PipelineAdapter, Any]]:
+ found: dict[str, tuple[PipelineAdapter, Any]] = {}
+ for adapter_type in adapter_types:
+ adapter = adapter_type(self.settings.gencase_root or None)
+ info = adapter.probe(source)
+ found[adapter.stage] = (adapter, info)
+ return found
+
+ found = locate()
+ if all(info.ready for _adapter, info in found.values()):
+ return found
+ details = "\n".join(
+ message
+ for _adapter, info in found.values()
+ for message in info.diagnostics
+ )
+ QMessageBox.information(
+ self.window,
+ "完整运行需要核验本地工具",
+ (details or "GenCase、求解器或 PartVTK 尚未配置")
+ + "\n\n请选择并核验 DualSPHysics v5.4 根目录。",
+ )
+ if not self.configure_dualsphysics(source=source):
+ return None
+ found = locate()
+ if not all(info.ready for _adapter, info in found.values()):
+ QMessageBox.critical(
+ self.window,
+ "完整运行工具不可用",
+ "\n".join(
+ message
+ for _adapter, info in found.values()
+ for message in info.diagnostics
+ )
+ or "至少一个完整流程工具不可用。",
+ )
+ return None
+ return found
+
+ def _start_full_stage(self, stage: str) -> None:
+ session = self._run_session
+ source = self._source_path()
+ case_prefix = self._case_output_prefix
+ located = self._full_run_adapters.get(stage)
+ if (
+ not self._full_run_active
+ or session is None
+ or source is None
+ or case_prefix is None
+ or located is None
+ ):
+ return
+ adapter, tool_info = located
+ if stage == "gencase":
+ request = RunRequest(
+ source_xml=source,
+ output_prefix=case_prefix,
+ mode="full",
+ create_dirs=True,
+ use_timestamp_on_conflict=False,
+ )
+ message = "完整运行:正在生成初始模型…"
+ elif stage in {"cpu", "gpu"}:
+ request = RunRequest(
+ source_xml=source,
+ output_prefix=case_prefix,
+ mode=stage,
+ create_dirs=False,
+ use_timestamp_on_conflict=False,
+ )
+ message = f"完整运行:正在执行 {stage.upper()} 求解…"
+ else:
+ request = RunRequest(
+ source_xml=source,
+ output_prefix=case_prefix.parent / "vtk" / "PartAll",
+ mode="partvtk",
+ create_dirs=True,
+ use_timestamp_on_conflict=False,
+ )
+ message = "完整运行:正在生成 PartVTK…"
+ session.start_stage(stage)
+ self.window.update_run_stage(stage, "running")
+ self._launch_pipeline(
+ adapter,
+ request,
+ tool_info,
+ message,
+ preserve_log=True,
+ )
+
+ def _prepare_manual_session(
+ self,
+ *,
+ source: Path,
+ output_prefix: Path,
+ stage: str,
+ tool_info: Any,
+ ) -> bool:
+ record_path = output_prefix.parent / ".dss-run" / "run.json"
+ self.window.set_run_state(True, "正在准备可复现记录…")
+ try:
+ if record_path.is_file():
+ session = RunSession.load(record_path)
+ if session.source_sha256 != file_sha256(source):
+ raise ValueError(
+ "现有输出目录的运行记录属于不同版本的 XML;"
+ "为保护可复现性,已阻止混用"
+ )
+ if session.status == "running":
+ session.mark_interrupted()
+ session.add_tool(stage, tool_info)
+ if stage in {"cpu", "gpu"}:
+ session.backend = stage
+ session.write()
+ else:
+ backend = stage if stage in {"cpu", "gpu"} else "manual"
+ session = RunSession.create(
+ source_xml=source,
+ output_root=output_prefix.parent,
+ backend=backend,
+ workflow=(stage,),
+ tools={stage: tool_info},
+ )
+ estimate = estimate_case(
+ self.window.document,
+ output_directory=output_prefix.parent,
+ source_sha256=session.source_sha256,
+ backend=session.backend,
+ history=session_history_values(find_case_sessions(source)),
+ )
+ session.add_estimate(estimate)
+ except (OSError, ValueError) as error:
+ QMessageBox.critical(
+ self.window,
+ "无法准备可复现记录",
+ f"阶段尚未启动。\n\n{error}",
+ )
+ self.window.set_run_state(False, "运行记录准备失败")
+ return False
+ self._run_session = session
+ self._selected_session = session
+ self._manual_session_active = True
+ self._full_run_active = False
+ self._run_estimate = estimate
+ if stage == "gencase":
+ self._gencase_summary = {}
+ self.window.begin_run_session(session.session_id, session.backend)
+ self.window.update_resource_estimate(estimate)
+ self.window.set_run_record_available(True)
+ session.start_stage(stage)
+ self.window.update_run_stage(stage, "running")
+ return True
def _source_path(self) -> Path | None:
document = self.window.document
@@ -473,8 +934,11 @@ def _launch_pipeline(
request: RunRequest,
tool_info: Any,
message: str,
+ *,
+ preserve_log: bool = False,
) -> None:
- self.window.log_view.clear()
+ if not preserve_log:
+ self.window.log_view.clear()
self.window.set_run_state(True, message)
output = request.output_prefix or "自动选择"
self.window.append_log(
@@ -486,6 +950,7 @@ def _launch_pipeline(
self._active_adapter = adapter
self._active_stage = adapter.stage
+ self._active_stage_started_monotonic = time.monotonic()
self._cancel_event = Event()
thread = QThread(self)
worker = _PipelineWorker(adapter, request, self._cancel_event)
@@ -520,6 +985,8 @@ def _start_run(self, mode: str) -> None:
digest = file_sha256(source)[:16]
stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
output_prefix = cache_dir() / "quick" / digest / stamp / "Case"
+ else:
+ output_prefix = GenCaseAdapter.suggest_output_prefix(source)
request = RunRequest(
source_xml=source,
output_prefix=output_prefix,
@@ -527,6 +994,15 @@ def _start_run(self, mode: str) -> None:
create_dirs=True,
use_timestamp_on_conflict=True,
)
+ if mode == "full":
+ assert output_prefix is not None
+ if not self._prepare_manual_session(
+ source=source,
+ output_prefix=output_prefix,
+ stage="gencase",
+ tool_info=tool_info,
+ ):
+ return
self._launch_pipeline(
adapter,
@@ -578,6 +1054,13 @@ def _start_solver(
)
self._case_output_prefix = case_prefix
self._case_output_source = source.resolve()
+ if not self._prepare_manual_session(
+ source=source,
+ output_prefix=case_prefix,
+ stage=mode,
+ tool_info=tool_info,
+ ):
+ return
self._launch_pipeline(
adapter,
request,
@@ -624,11 +1107,54 @@ def _start_partvtk(self) -> None:
)
self._case_output_prefix = case_prefix
self._case_output_source = source.resolve()
+ if not self._prepare_manual_session(
+ source=source,
+ output_prefix=case_prefix,
+ stage="partvtk",
+ tool_info=tool_info,
+ ):
+ return
self._launch_pipeline(adapter, request, tool_info, "正在转换 PartVTK…")
@Slot(str, str)
def _append_run_log(self, stream: str, line: str) -> None:
self.window.append_log(line, stream=stream)
+ session = self._run_session
+ stage = self._active_stage
+ if session is not None and stage is not None:
+ try:
+ session.append_log(stage, stream, line)
+ except OSError as error:
+ self.window.append_log(
+ f"警告:无法写入会话日志:{error}",
+ stream="stderr",
+ )
+ expected = (
+ self._run_estimate.output_frames
+ if self._run_estimate is not None
+ else None
+ )
+ progress = live_stage_progress(
+ stage,
+ line,
+ expected_frames=expected,
+ )
+ if progress is not None:
+ completed, total = progress
+ eta_seconds = None
+ if (
+ completed > 0
+ and self._active_stage_started_monotonic is not None
+ and completed < total
+ ):
+ elapsed = time.monotonic() - self._active_stage_started_monotonic
+ eta_seconds = elapsed * (total - completed) / completed
+ self.window.update_stage_progress(
+ stage,
+ completed,
+ total,
+ eta_seconds=eta_seconds,
+ )
@Slot(object)
def _run_finished(self, result: Any) -> None:
@@ -644,11 +1170,92 @@ def _run_finished(self, result: Any) -> None:
self._last_gencase_run = result
self._case_output_prefix = Path(result.output_prefix).resolve()
self._case_output_source = Path(manifest.source_path).resolve()
+
+ workflow_continues = False
+ session = self._run_session
+ if (
+ self._full_run_active
+ and session is not None
+ and stage in session.workflow
+ ):
+ session.finish_stage(str(stage), result)
+ self.window.update_run_stage(
+ str(stage),
+ str(getattr(result, "status", "unknown")),
+ (
+ f"退出码 {result.return_code}"
+ if result.return_code is not None
+ else ""
+ ),
+ )
+ if getattr(result, "success", False):
+ if stage == "gencase":
+ self._gencase_summary = dict(getattr(result, "summary", {}) or {})
+ self._refresh_session_estimate(self._gencase_summary)
+ self._pending_full_stage = self._full_run_backend
+ elif stage in {"cpu", "gpu"}:
+ combined = dict(self._gencase_summary)
+ combined.update(dict(getattr(result, "summary", {}) or {}))
+ self._refresh_session_estimate(combined)
+ self._pending_full_stage = "partvtk"
+ else:
+ session.finalize("succeeded")
+ self._selected_session = session
+ self._full_run_active = False
+ self._pending_full_stage = None
+ workflow_continues = self._pending_full_stage is not None
+ else:
+ status = str(getattr(result, "status", "failed"))
+ session.skip_remaining(
+ after=str(stage),
+ reason=f"{stage} 未成功,后续阶段未执行",
+ )
+ session.finalize(status)
+ for remaining in session.workflow:
+ entry = session.stages.get(remaining, {})
+ if entry.get("status") == "skipped":
+ self.window.update_run_stage(
+ remaining,
+ "skipped",
+ f"因 {stage} 未成功而跳过",
+ )
+ self._selected_session = session
+ self._full_run_active = False
+ self._pending_full_stage = None
+ elif (
+ self._manual_session_active
+ and session is not None
+ and stage is not None
+ ):
+ session.finish_stage(str(stage), result)
+ if stage == "gencase" and getattr(result, "success", False):
+ self._gencase_summary = dict(getattr(result, "summary", {}) or {})
+ self._refresh_session_estimate(self._gencase_summary)
+ elif stage in {"cpu", "gpu"}:
+ combined = dict(self._gencase_summary)
+ combined.update(dict(getattr(result, "summary", {}) or {}))
+ self._refresh_session_estimate(combined)
+ session.finalize(str(getattr(result, "status", "failed")))
+ self._selected_session = session
+ self.window.update_run_stage(
+ str(stage),
+ str(getattr(result, "status", "unknown")),
+ (
+ f"退出码 {result.return_code}"
+ if result.return_code is not None
+ else ""
+ ),
+ )
+ self._manual_session_active = False
ui_result = copy(result)
# Output has already streamed live; avoid appending it a second time.
ui_result.stdout = ""
ui_result.stderr = ""
- self.window.finish_run(ui_result, stage=stage)
+ self.window.finish_run(
+ ui_result,
+ stage=stage,
+ workflow_continues=workflow_continues,
+ )
for warning in getattr(result, "warnings", ()):
self.window.append_log(f"警告:{warning}")
for error in getattr(result, "errors", ()):
@@ -657,11 +1264,38 @@ def _run_finished(self, result: Any) -> None:
if manifest_path:
self.window.append_log(f"运行清单:{manifest_path}")
self.window.append_log(f"输出前缀:{getattr(result, 'output_prefix', '')}")
+ if session is not None:
+ self.window.append_log(f"会话记录:{session.record_path}")
+ self.window.set_run_record_available(True)
@Slot(str)
def _run_failed(self, message: str) -> None:
self.window.append_log(message, stream="stderr")
- stage = _STAGE_LABELS.get(self._active_stage or "", "当前任务")
+ stage_key = self._active_stage or "unknown"
+ stage = _STAGE_LABELS.get(stage_key, "当前任务")
+ session = self._run_session
+ if self._full_run_active and session is not None:
+ session.fail_stage(stage_key, message)
+ session.skip_remaining(
+ after=stage_key,
+ reason=f"{stage}异常,后续阶段未执行",
+ )
+ session.finalize("failed")
+ self._selected_session = session
+ self._full_run_active = False
+ self._pending_full_stage = None
+ self.window.update_run_stage(stage_key, "failed", message)
+ for remaining in session.workflow:
+ if session.stages.get(remaining, {}).get("status") == "skipped":
+ self.window.update_run_stage(remaining, "skipped", "前序阶段异常")
+ self.window.set_run_record_available(True)
+ elif self._manual_session_active and session is not None:
+ session.fail_stage(stage_key, message)
+ session.finalize("failed")
+ self._selected_session = session
+ self._manual_session_active = False
+ self.window.update_run_stage(stage_key, "failed", message)
+ self.window.set_run_record_available(True)
self.window.set_run_state(False, f"{stage}失败")
QMessageBox.critical(self.window, f"{stage}失败", message)
@@ -674,6 +1308,35 @@ def _run_thread_finished(self) -> None:
self._active_adapter = None
self._active_stage = None
self._cancel_event = None
+ self._active_stage_started_monotonic = None
+ next_stage = self._pending_full_stage
+ self._pending_full_stage = None
+ if next_stage is not None and self._full_run_active:
+ QTimer.singleShot(0, lambda stage=next_stage: self._start_full_stage(stage))
+ return
+ if not self._full_run_active:
+ self._run_session = None
+ self._full_run_backend = None
+ self._full_run_adapters = {}
+ self._manual_session_active = False
+
+ def _refresh_session_estimate(self, summary: dict[str, Any]) -> None:
+ session = self._run_session
+ document = self.window.document
+ if session is None or document is None:
+ return
+ source = Path(self.window.document_path).resolve()
+ estimate = estimate_case(
+ document,
+ output_directory=session.output_root,
+ particle_summary=summary,
+ source_sha256=session.source_sha256,
+ backend=session.backend,
+ history=session_history_values(find_case_sessions(source)),
+ )
+ session.add_estimate(estimate)
+ self._run_estimate = estimate
+ self.window.update_resource_estimate(estimate)
@Slot()
def cancel_run(self) -> None:
@@ -684,13 +1347,111 @@ def cancel_run(self) -> None:
stage = _STAGE_LABELS.get(self._active_stage or "", "当前任务")
self.window.status_message.setText(f"正在取消{stage}…")
+ @Slot()
+ def _open_run_output(self) -> None:
+ session = self._selected_session or self._run_session
+ if session is None or not session.output_root.is_dir():
+ QMessageBox.warning(self.window, "输出目录不可用", "当前没有可打开的运行输出目录。")
+ return
+ if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(session.output_root))):
+ QMessageBox.warning(
+ self.window,
+ "无法打开输出目录",
+ str(session.output_root),
+ )
+
+ @Slot()
+ def _export_run_record(self) -> None:
+ session = self._selected_session or self._run_session
+ if session is None:
+ QMessageBox.warning(self.window, "没有运行记录", "请先选择一个运行会话。")
+ return
+ privacy = _DiagnosticExportDialog(self.window)
+ if privacy.exec() != QDialog.DialogCode.Accepted:
+ return
+ source_stem = Path(session.source_name).stem
+ default_name = (
+ session.output_root
+ / f"{source_stem}-{session.session_id}-diagnostic.zip"
+ )
+ selected, _filter = QFileDialog.getSaveFileName(
+ self.window,
+ "导出可复现记录",
+ str(default_name),
+ "ZIP 压缩包 (*.zip)",
+ )
+ if not selected:
+ return
+ target = Path(selected)
+ if target.suffix.casefold() != ".zip":
+ target = target.with_suffix(".zip")
+ source_path = (
+ Path(self.window.document_path).resolve()
+ if self.window.document_path is not None
+ else session.source_snapshot
+ )
+ try:
+ exported = session.export_diagnostic(
+ target,
+ include_xml=privacy.include_xml,
+ redaction_paths=(
+ source_path.parent,
+ session.output_root,
+ self.settings.gencase_root,
+ ),
+ )
+ except OSError as error:
+ QMessageBox.critical(self.window, "导出失败", str(error))
+ return
+ QMessageBox.information(
+ self.window,
+ "导出完成",
+ f"记录已导出:\n{exported}\n\n"
+ f"XML:{'已包含' if privacy.include_xml else '已按隐私选项排除'}",
+ )
+
+ @Slot()
+ def _rerun_from_record(self) -> None:
+ session = self._selected_session
+ if session is None:
+ QMessageBox.warning(self.window, "没有运行记录", "请先选择一个运行会话。")
+ return
+ source = self._source_path()
+ if source is None:
+ return
+ if file_sha256(source) != session.source_sha256:
+ answer = QMessageBox.warning(
+ self.window,
+ "当前 XML 与记录不同",
+ "当前 XML 的 SHA-256 与所选记录不一致。新运行会使用当前 XML,"
+ "不会覆盖旧结果。是否继续?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
+ QMessageBox.StandardButton.Cancel,
+ )
+ if answer != QMessageBox.StandardButton.Yes:
+ return
+ self._start_full_run(session.backend)
+
@Slot()
def _mark_stale(self) -> None:
manifest = getattr(self._last_gencase_run or self._last_run, "manifest", None)
document = self.window.document
- if manifest is None or document is None:
+ if document is None:
return
current = getattr(document, "source_sha256", "")
+ if self.window.document_path is not None:
+ source = Path(self.window.document_path).resolve()
+ estimate = estimate_case(
+ document,
+ output_directory=GenCaseAdapter.default_output_prefix(source).parent,
+ source_sha256=current or None,
+ backend=self.settings.last_solver_backend,
+ history=session_history_values(find_case_sessions(source)),
+ )
+ self._run_estimate = estimate
+ self.window.update_resource_estimate(estimate)
+ if manifest is None:
+ return
if current and current != manifest.source_sha256:
self.window.run_state_label.setText("当前初始模型结果已过期")
diff --git a/src/dualsphysics_studio/core/__init__.py b/src/dualsphysics_studio/core/__init__.py
index 1f63fca..1999b67 100644
--- a/src/dualsphysics_studio/core/__init__.py
+++ b/src/dualsphysics_studio/core/__init__.py
@@ -1,4 +1,4 @@
-"""Public core API for DualSPHysics Studio 1.0.1."""
+"""Public core API for DualSPHysics Studio."""
from .document import CaseDocument
from .errors import (
diff --git a/src/dualsphysics_studio/pipeline/__init__.py b/src/dualsphysics_studio/pipeline/__init__.py
index 02828cc..c5f4b82 100644
--- a/src/dualsphysics_studio/pipeline/__init__.py
+++ b/src/dualsphysics_studio/pipeline/__init__.py
@@ -1,4 +1,4 @@
-"""Executable pipeline API for DualSPHysics Studio 1.0.1."""
+"""Executable pipeline API for DualSPHysics Studio."""
from .base import (
IncompatibleToolError,
@@ -33,6 +33,19 @@
file_sha256,
manifest_is_stale,
)
+from .estimate import (
+ ResourceEstimate,
+ estimate_case,
+ format_bytes,
+ live_stage_progress,
+)
+from .session import (
+ SESSION_DIRECTORY,
+ SESSION_SCHEMA_VERSION,
+ RunSession,
+ find_case_sessions,
+ session_history_values,
+)
__all__ = [
"Artifact",
@@ -51,13 +64,22 @@
"PartVTKAdapter",
"RunRequest",
"RunResult",
+ "RunSession",
+ "ResourceEstimate",
+ "SESSION_DIRECTORY",
+ "SESSION_SCHEMA_VERSION",
"SUPPORTED_VERSION",
"ToolInfo",
"ToolNotFoundError",
"DualSPHysicsCPUAdapter",
"DualSPHysicsGPUAdapter",
"file_sha256",
+ "find_case_sessions",
+ "estimate_case",
+ "format_bytes",
+ "live_stage_progress",
"manifest_is_stale",
"parse_case_out",
"parse_gencase_output",
+ "session_history_values",
]
diff --git a/src/dualsphysics_studio/pipeline/dualsphysics.py b/src/dualsphysics_studio/pipeline/dualsphysics.py
index dd80d38..be74ab7 100644
--- a/src/dualsphysics_studio/pipeline/dualsphysics.py
+++ b/src/dualsphysics_studio/pipeline/dualsphysics.py
@@ -113,6 +113,34 @@ def _parse_gpu_report(text: str) -> tuple[dict[str, object], list[str]]:
device_match = re.match(r"^Device default:\s*(.+)$", line)
if device_match is not None:
summary["device"] = device_match.group(1).strip()
+ particle_match = re.match(r"^CaseNp\s*=\s*([\d,]+)", line, re.IGNORECASE)
+ if particle_match is not None:
+ summary["particles"] = int(particle_match.group(1).replace(",", ""))
+ requested_memory = re.search(
+ r"Requested\s+gpu\s+memory\s+for\s+[\d,]+\s+particles:\s*"
+ r"([0-9.,]+)\s*(B|KB|MB|GB)\b",
+ line,
+ re.IGNORECASE,
+ )
+ if requested_memory is not None:
+ summary["requested_gpu_memory_bytes"] = _memory_bytes(
+ requested_memory.group(1), requested_memory.group(2)
+ )
+ allocated_memory = re.match(
+ r"^Allocated\s+memory\s+in\s+(CPU|GPU):\s*([\d,]+)"
+ r"(?:\s*\(\s*([0-9.,]+)\s*(B|KB|MB|GB)\s*\))?",
+ line,
+ re.IGNORECASE,
+ )
+ if allocated_memory is not None:
+ raw_bytes = int(allocated_memory.group(2).replace(",", ""))
+ value = (
+ _memory_bytes(allocated_memory.group(3), allocated_memory.group(4))
+ if allocated_memory.group(3) and allocated_memory.group(4)
+ else raw_bytes
+ )
+ key = f"allocated_{allocated_memory.group(1).casefold()}_memory_bytes"
+ summary[key] = value
for key, pattern in patterns:
match = re.match(pattern, line, re.IGNORECASE)
if match is None:
@@ -123,6 +151,17 @@ def _parse_gpu_report(text: str) -> tuple[dict[str, object], list[str]]:
return summary, warnings
+def _memory_bytes(value: str, unit: str) -> int:
+ number = float(value.replace(",", ""))
+ factor = {
+ "b": 1,
+ "kb": 1024,
+ "mb": 1024 * 1024,
+ "gb": 1024 * 1024 * 1024,
+ }[unit.casefold()]
+ return int(round(number * factor))
+
+
class _DualSPHysicsExecutableAdapter(PipelineAdapter):
"""Shared shell-free runner for one official v5.4 executable."""
diff --git a/src/dualsphysics_studio/pipeline/estimate.py b/src/dualsphysics_studio/pipeline/estimate.py
new file mode 100644
index 0000000..db33d8a
--- /dev/null
+++ b/src/dualsphysics_studio/pipeline/estimate.py
@@ -0,0 +1,493 @@
+"""Conservative resource estimates for one DualSPHysics case.
+
+The estimator intentionally exposes ranges and evidence instead of presenting
+heuristics as exact requirements. It accepts the existing document model and
+GenCase summaries, so XML remains the only editable source of truth.
+"""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+import ctypes
+import math
+from pathlib import Path
+import shutil
+from typing import Any, Iterable, Mapping
+
+
+MIB = 1024 * 1024
+GIB = 1024 * MIB
+
+# Deliberately broad Studio safety envelopes. Actual DualSPHysics allocation
+# depends on solver options and is replaced by measured log values when known.
+RAM_BASE_BYTES = 256 * MIB
+RAM_BYTES_PER_PARTICLE = (256, 1024)
+VRAM_BASE_BYTES = 128 * MIB
+VRAM_BYTES_PER_PARTICLE = (160, 640)
+DISK_BYTES_PER_PARTICLE_FRAME = (48, 256)
+
+
+@dataclass(frozen=True, slots=True)
+class ResourceEstimate:
+ """A serialisable resource estimate with explicit confidence and evidence."""
+
+ phase: str
+ confidence: str
+ dp: float | None = None
+ dimensions: int | None = None
+ domain_grid_particles: int | None = None
+ actual_particles: int | None = None
+ output_frames: int | None = None
+ ram_low_bytes: int | None = None
+ ram_high_bytes: int | None = None
+ vram_low_bytes: int | None = None
+ vram_high_bytes: int | None = None
+ disk_low_bytes: int | None = None
+ disk_high_bytes: int | None = None
+ runtime_low_seconds: float | None = None
+ runtime_high_seconds: float | None = None
+ available_ram_bytes: int | None = None
+ available_disk_bytes: int | None = None
+ assumptions: tuple[str, ...] = ()
+ warnings: tuple[str, ...] = ()
+ evidence: tuple[str, ...] = ()
+ measured: dict[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, value: Mapping[str, Any]) -> "ResourceEstimate":
+ data = dict(value)
+ for key in ("assumptions", "warnings", "evidence"):
+ data[key] = tuple(str(item) for item in data.get(key, ()))
+ data["measured"] = dict(data.get("measured") or {})
+ allowed = cls.__dataclass_fields__
+ return cls(**{key: item for key, item in data.items() if key in allowed})
+
+ @property
+ def particle_basis(self) -> int | None:
+ return self.actual_particles or self.domain_grid_particles
+
+ def metric_rows(self) -> tuple[tuple[str, str, str, str], ...]:
+ """Return UI-ready label, value, confidence, and evidence rows."""
+
+ rows: list[tuple[str, str, str, str]] = []
+ if self.actual_particles is not None:
+ rows.append(
+ ("实际粒子数", f"{self.actual_particles:,}", "实测", "GenCase Case.out")
+ )
+ elif self.domain_grid_particles is not None:
+ rows.append(
+ (
+ "定义域网格上限",
+ f"{self.domain_grid_particles:,}",
+ "低",
+ "definition 范围和 dp;不等于真实粒子数",
+ )
+ )
+ if self.output_frames is not None:
+ rows.append(
+ (
+ "预计输出帧",
+ f"{self.output_frames:,}",
+ "中",
+ "TimeMax / TimeOut,包含初始帧",
+ )
+ )
+ _append_range_row(
+ rows,
+ "系统内存需求",
+ self.ram_low_bytes,
+ self.ram_high_bytes,
+ _confidence_label(self.confidence),
+ "粒子数安全区间;求解日志实测值优先",
+ )
+ _append_range_row(
+ rows,
+ "GPU 显存需求",
+ self.vram_low_bytes,
+ self.vram_high_bytes,
+ _confidence_label(self.confidence),
+ "与求解选项有关;首次运行可能偏宽",
+ )
+ _append_range_row(
+ rows,
+ "输出磁盘",
+ self.disk_low_bytes,
+ self.disk_high_bytes,
+ _confidence_label(self.confidence),
+ "粒子数、输出帧和历史文件大小",
+ )
+ if self.available_ram_bytes is not None:
+ rows.append(
+ (
+ "当前可用物理内存",
+ format_bytes(self.available_ram_bytes),
+ "实测",
+ "运行预检时的 Windows 系统状态",
+ )
+ )
+ if self.available_disk_bytes is not None:
+ rows.append(
+ (
+ "目标磁盘可用空间",
+ format_bytes(self.available_disk_bytes),
+ "实测",
+ "运行预检时的目标卷状态",
+ )
+ )
+ if self.runtime_low_seconds is not None and self.runtime_high_seconds is not None:
+ rows.append(
+ (
+ "预计运行时间",
+ f"{_duration(self.runtime_low_seconds)} – "
+ f"{_duration(self.runtime_high_seconds)}",
+ "历史校准",
+ "相同 XML、后端和已完成本机运行",
+ )
+ )
+ else:
+ rows.append(
+ ("预计运行时间", "数据不足", "未知", "求解开始后根据实际进度更新")
+ )
+ for warning in self.warnings:
+ rows.append(("资源警告", warning, "需确认", "预检比较结果"))
+ return tuple(rows)
+
+
+def estimate_case(
+ document: Any,
+ *,
+ output_directory: str | Path | None = None,
+ particle_summary: Mapping[str, Any] | None = None,
+ source_sha256: str | None = None,
+ backend: str | None = None,
+ history: Iterable[Mapping[str, Any]] = (),
+) -> ResourceEstimate:
+ """Estimate resources from the current document and optional measured data."""
+
+ facts = _value(document, "facts")
+ dp = _finite_positive(_value(facts, "dp"))
+ bounds = _value(facts, "definition_bounds", "bounds")
+ spans = _bounds_spans(bounds)
+ dimensions: int | None = None
+ domain_particles: int | None = None
+ assumptions: list[str] = []
+ warnings: list[str] = []
+ evidence: list[str] = []
+
+ if dp is not None and spans is not None:
+ active_spans = [span for span in spans if span > max(dp * 0.5, 1e-12)]
+ dimensions = 2 if len(active_spans) <= 2 else 3
+ counts = [
+ max(1, math.floor(span / dp + 1e-9) + 1)
+ for span in spans
+ ]
+ domain_particles = math.prod(counts)
+ evidence.append("由 definition 范围和 dp 计算定义域网格上限")
+ assumptions.append("定义域网格上限不是几何实际填充粒子数")
+ else:
+ warnings.append("缺少可计算的 dp 或 definition 范围,无法估算粒子上限")
+
+ parameters = _parameter_values(document)
+ time_max = _finite_positive(parameters.get("timemax"))
+ time_out = _finite_positive(parameters.get("timeout"))
+ frames = None
+ if time_max is not None and time_out is not None:
+ frames = max(1, math.floor(time_max / time_out + 1e-9) + 1)
+ evidence.append("输出帧按 TimeMax / TimeOut 并包含初始帧估算")
+ else:
+ warnings.append("TimeMax 或 TimeOut 不可用,无法估算输出帧")
+
+ actual_particles = _actual_particle_count(particle_summary)
+ phase = "post-gencase" if actual_particles is not None else "preflight"
+ confidence = "medium" if actual_particles is not None else "low"
+ particle_basis = actual_particles or domain_particles
+ if actual_particles is not None:
+ evidence.append("粒子数来自 GenCase Case.out")
+
+ ram_low = ram_high = vram_low = vram_high = None
+ disk_low = disk_high = None
+ if particle_basis is not None:
+ ram_low = RAM_BASE_BYTES + particle_basis * RAM_BYTES_PER_PARTICLE[0]
+ ram_high = RAM_BASE_BYTES + particle_basis * RAM_BYTES_PER_PARTICLE[1]
+ vram_low = VRAM_BASE_BYTES + particle_basis * VRAM_BYTES_PER_PARTICLE[0]
+ vram_high = VRAM_BASE_BYTES + particle_basis * VRAM_BYTES_PER_PARTICLE[1]
+ if frames is not None:
+ disk_low = particle_basis * frames * DISK_BYTES_PER_PARTICLE_FRAME[0]
+ disk_high = particle_basis * frames * DISK_BYTES_PER_PARTICLE_FRAME[1]
+ assumptions.append("内存和磁盘为 Studio 保守区间,不是官方容量保证")
+
+ available_ram = available_physical_memory()
+ available_disk = available_disk_space(output_directory)
+ if ram_high is not None and available_ram is not None and ram_high > available_ram:
+ warnings.append("内存需求上界超过当前可用物理内存")
+ if disk_high is not None and available_disk is not None and disk_high > available_disk:
+ warnings.append("磁盘需求上界超过目标磁盘当前可用空间")
+
+ runtime_low, runtime_high = _history_runtime(
+ history,
+ source_sha256=source_sha256,
+ backend=backend,
+ )
+ if runtime_low is not None:
+ evidence.append("运行时间来自相同 XML 和后端的本机历史记录")
+
+ measured = _measured_values(particle_summary)
+ if measured:
+ confidence = "measured"
+ ram_value = measured.get("allocated_cpu_memory_bytes")
+ vram_value = measured.get("allocated_gpu_memory_bytes") or measured.get(
+ "requested_gpu_memory_bytes"
+ )
+ if isinstance(ram_value, int):
+ ram_low = ram_high = ram_value
+ if isinstance(vram_value, int):
+ vram_low = vram_high = vram_value
+
+ return ResourceEstimate(
+ phase=phase,
+ confidence=confidence,
+ dp=dp,
+ dimensions=dimensions,
+ domain_grid_particles=domain_particles,
+ actual_particles=actual_particles,
+ output_frames=frames,
+ ram_low_bytes=ram_low,
+ ram_high_bytes=ram_high,
+ vram_low_bytes=vram_low,
+ vram_high_bytes=vram_high,
+ disk_low_bytes=disk_low,
+ disk_high_bytes=disk_high,
+ runtime_low_seconds=runtime_low,
+ runtime_high_seconds=runtime_high,
+ available_ram_bytes=available_ram,
+ available_disk_bytes=available_disk,
+ assumptions=tuple(assumptions),
+ warnings=tuple(warnings),
+ evidence=tuple(evidence),
+ measured=measured,
+ )
+
+
+def live_stage_progress(
+ stage: str,
+ line: str,
+ *,
+ expected_frames: int | None = None,
+) -> tuple[int, int] | None:
+ """Best-effort determinate progress from official stage output lines."""
+
+ import re
+
+ if expected_frames is None or expected_frames <= 0:
+ return None
+ if stage in {"cpu", "gpu"}:
+ match = re.search(r"\bPart[_ ]?(\d{4,})\b", line, re.IGNORECASE)
+ elif stage == "partvtk":
+ match = re.search(r"\bPartAll[_ ]?(\d{4,})\b", line, re.IGNORECASE)
+ else:
+ return None
+ if match is None:
+ return None
+ completed = min(expected_frames, int(match.group(1)) + 1)
+ return completed, expected_frames
+
+
+def available_physical_memory() -> int | None:
+ """Return available physical RAM on Windows without adding a dependency."""
+
+ if not hasattr(ctypes, "windll"):
+ return None
+
+ class _MemoryStatus(ctypes.Structure):
+ _fields_ = [
+ ("dwLength", ctypes.c_ulong),
+ ("dwMemoryLoad", ctypes.c_ulong),
+ ("ullTotalPhys", ctypes.c_ulonglong),
+ ("ullAvailPhys", ctypes.c_ulonglong),
+ ("ullTotalPageFile", ctypes.c_ulonglong),
+ ("ullAvailPageFile", ctypes.c_ulonglong),
+ ("ullTotalVirtual", ctypes.c_ulonglong),
+ ("ullAvailVirtual", ctypes.c_ulonglong),
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
+ ]
+
+ status = _MemoryStatus()
+ status.dwLength = ctypes.sizeof(_MemoryStatus)
+ try:
+ if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
+ return None
+ except (AttributeError, OSError):
+ return None
+ return int(status.ullAvailPhys)
+
+
+def available_disk_space(path: str | Path | None) -> int | None:
+ if path is None:
+ return None
+ candidate = Path(path).expanduser()
+ while not candidate.exists() and candidate != candidate.parent:
+ candidate = candidate.parent
+ try:
+ return int(shutil.disk_usage(candidate).free)
+ except OSError:
+ return None
+
+
+def format_bytes(value: int | None) -> str:
+ if value is None:
+ return "未知"
+ if value >= GIB:
+ return f"{value / GIB:.2f} GiB"
+ if value >= MIB:
+ return f"{value / MIB:.1f} MiB"
+ if value >= 1024:
+ return f"{value / 1024:.1f} KiB"
+ return f"{value} B"
+
+
+def _append_range_row(
+ rows: list[tuple[str, str, str, str]],
+ label: str,
+ low: int | None,
+ high: int | None,
+ confidence: str,
+ evidence: str,
+) -> None:
+ if low is None or high is None:
+ rows.append((label, "数据不足", "未知", evidence))
+ return
+ value = format_bytes(low) if low == high else f"{format_bytes(low)} – {format_bytes(high)}"
+ rows.append((label, value, confidence, evidence))
+
+
+def _value(value: Any, *names: str) -> Any:
+ for name in names:
+ if value is None:
+ return None
+ if isinstance(value, Mapping) and name in value:
+ return value[name]
+ if hasattr(value, name):
+ result = getattr(value, name)
+ return result() if callable(result) else result
+ return None
+
+
+def _finite_positive(value: Any) -> float | None:
+ try:
+ number = float(value)
+ except (TypeError, ValueError):
+ return None
+ return number if math.isfinite(number) and number > 0 else None
+
+
+def _bounds_spans(bounds: Any) -> tuple[float, float, float] | None:
+ minimum = _value(bounds, "minimum")
+ maximum = _value(bounds, "maximum")
+ if minimum is None or maximum is None:
+ return None
+ try:
+ spans = tuple(
+ float(_value(maximum, axis)) - float(_value(minimum, axis))
+ for axis in ("x", "y", "z")
+ )
+ except (TypeError, ValueError):
+ return None
+ if any(not math.isfinite(span) or span < 0 for span in spans):
+ return None
+ return spans # type: ignore[return-value]
+
+
+def _parameter_values(document: Any) -> dict[str, str]:
+ fields = _value(document, "parameter_fields") or ()
+ result: dict[str, str] = {}
+ for field in fields:
+ if str(_value(field, "category") or "").casefold() != "execution":
+ continue
+ key = str(_value(field, "key") or "").strip().casefold()
+ value = str(_value(field, "value") or "").strip()
+ if key:
+ result[key] = value
+ return result
+
+
+def _actual_particle_count(summary: Mapping[str, Any] | None) -> int | None:
+ if not summary:
+ return None
+ direct = summary.get("particles") or summary.get("particle_count")
+ if isinstance(direct, (int, float)) and direct > 0:
+ return int(direct)
+ counts = summary.get("particle_counts")
+ if not isinstance(counts, Mapping):
+ return None
+ boundary = counts.get("boundary")
+ fluid = counts.get("fluid")
+ if isinstance(boundary, (int, float)) and isinstance(fluid, (int, float)):
+ return int(boundary + fluid)
+ values = [
+ counts.get(key)
+ for key in ("fixed", "moving", "floating", "fluid")
+ if isinstance(counts.get(key), (int, float))
+ ]
+ return int(sum(values)) if values else None
+
+
+def _measured_values(summary: Mapping[str, Any] | None) -> dict[str, Any]:
+ if not summary:
+ return {}
+ keys = (
+ "allocated_cpu_memory_bytes",
+ "allocated_gpu_memory_bytes",
+ "requested_gpu_memory_bytes",
+ "total_runtime_seconds",
+ "simulation_runtime_seconds",
+ "device",
+ )
+ return {key: summary[key] for key in keys if key in summary}
+
+
+def _history_runtime(
+ history: Iterable[Mapping[str, Any]],
+ *,
+ source_sha256: str | None,
+ backend: str | None,
+) -> tuple[float | None, float | None]:
+ if not source_sha256 or not backend:
+ return None, None
+ samples: list[float] = []
+ for record in history:
+ source = record.get("source") or {}
+ if source.get("sha256") != source_sha256:
+ continue
+ if str(record.get("backend", "")).casefold() != backend.casefold():
+ continue
+ actual = record.get("actual") or {}
+ value = actual.get("total_runtime_seconds")
+ if isinstance(value, (int, float)) and value > 0:
+ samples.append(float(value))
+ if not samples:
+ return None, None
+ low = min(samples) * 0.8
+ high = max(samples) * 1.25
+ return low, high
+
+
+def _duration(seconds: float) -> str:
+ total = max(0, int(round(seconds)))
+ hours, remainder = divmod(total, 3600)
+ minutes, secs = divmod(remainder, 60)
+ if hours:
+ return f"{hours} 小时 {minutes} 分"
+ if minutes:
+ return f"{minutes} 分 {secs} 秒"
+ return f"{secs} 秒"
+
+
+def _confidence_label(value: str) -> str:
+ return {
+ "low": "低",
+ "medium": "中",
+ "measured": "含实测",
+ }.get(value, value)
diff --git a/src/dualsphysics_studio/pipeline/session.py b/src/dualsphysics_studio/pipeline/session.py
new file mode 100644
index 0000000..cd0a16b
--- /dev/null
+++ b/src/dualsphysics_studio/pipeline/session.py
@@ -0,0 +1,508 @@
+"""Case-local, privacy-aware records for multi-stage run sessions."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from hashlib import sha256
+import json
+import os
+from pathlib import Path
+import platform
+import re
+import shutil
+from typing import Any, Iterable, Mapping
+from uuid import uuid4
+from zipfile import ZIP_DEFLATED, ZipFile
+
+from ..version import APP_VERSION
+from .estimate import ResourceEstimate, available_physical_memory
+from .models import RunResult, ToolInfo, file_sha256
+
+
+SESSION_SCHEMA_VERSION = 1
+SESSION_DIRECTORY = ".dss-run"
+
+
+def _utc_now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
+
+
+@dataclass(slots=True)
+class RunSession:
+ """Mutable in-memory representation persisted atomically after each event."""
+
+ record_path: Path
+ session_id: str
+ source_name: str
+ source_sha256: str
+ backend: str
+ workflow: tuple[str, ...]
+ status: str = "created"
+ created_at: str = field(default_factory=_utc_now)
+ started_at: str | None = None
+ finished_at: str | None = None
+ app_version: str = APP_VERSION
+ stages: dict[str, dict[str, Any]] = field(default_factory=dict)
+ estimates: list[dict[str, Any]] = field(default_factory=list)
+ tools: dict[str, dict[str, Any]] = field(default_factory=dict)
+ actual: dict[str, Any] = field(default_factory=dict)
+ hardware: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def output_root(self) -> Path:
+ return self.record_path.parent.parent
+
+ @property
+ def run_directory(self) -> Path:
+ return self.record_path.parent
+
+ @property
+ def source_snapshot(self) -> Path:
+ return self.run_directory / "source" / self.source_name
+
+ @classmethod
+ def create(
+ cls,
+ *,
+ source_xml: str | os.PathLike[str],
+ output_root: str | os.PathLike[str],
+ backend: str,
+ workflow: Iterable[str],
+ tools: Mapping[str, ToolInfo] | None = None,
+ ) -> "RunSession":
+ source = Path(source_xml).resolve()
+ root = Path(output_root).resolve()
+ run_directory = root / SESSION_DIRECTORY
+ record_path = run_directory / "run.json"
+ if record_path.exists():
+ raise FileExistsError(f"运行记录已存在: {record_path}")
+ (run_directory / "source").mkdir(parents=True, exist_ok=True)
+ (run_directory / "logs").mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source, run_directory / "source" / source.name)
+ now = datetime.now(timezone.utc)
+ session = cls(
+ record_path=record_path,
+ session_id=f"{now.strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}",
+ source_name=source.name,
+ source_sha256=file_sha256(source),
+ backend=backend,
+ workflow=tuple(workflow),
+ stages={
+ stage: {
+ "status": "pending",
+ "started_at": None,
+ "finished_at": None,
+ "return_code": None,
+ "manifest": None,
+ "summary": {},
+ "warnings": [],
+ "errors": [],
+ }
+ for stage in workflow
+ },
+ tools=_tool_records(tools or {}),
+ hardware=_hardware_facts(),
+ )
+ session.write()
+ return session
+
+ @classmethod
+ def load(cls, path: str | os.PathLike[str]) -> "RunSession":
+ record_path = Path(path).resolve()
+ value = json.loads(record_path.read_text(encoding="utf-8"))
+ source = value.get("source") or {}
+ return cls(
+ record_path=record_path,
+ session_id=str(value.get("session_id", "")),
+ source_name=str(source.get("snapshot", "Case_Def.xml")),
+ source_sha256=str(source.get("sha256", "")),
+ backend=str(value.get("backend", "")),
+ workflow=tuple(str(item) for item in value.get("workflow", ())),
+ status=str(value.get("status", "unknown")),
+ created_at=str(value.get("created_at", "")),
+ started_at=value.get("started_at"),
+ finished_at=value.get("finished_at"),
+ app_version=str(value.get("application", {}).get("version", "")),
+ stages={
+ str(key): dict(item)
+ for key, item in (value.get("stages") or {}).items()
+ },
+ estimates=[dict(item) for item in value.get("estimates", ())],
+ tools={
+ str(key): dict(item)
+ for key, item in (value.get("tools") or {}).items()
+ },
+ actual=dict(value.get("actual") or {}),
+ hardware=dict(value.get("hardware") or {}),
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema_version": SESSION_SCHEMA_VERSION,
+ "session_id": self.session_id,
+ "application": {
+ "name": "DualSPHysics Studio",
+ "version": self.app_version,
+ },
+ "source": {
+ "snapshot": self.source_name,
+ "sha256": self.source_sha256,
+ },
+ "backend": self.backend,
+ "workflow": list(self.workflow),
+ "status": self.status,
+ "created_at": self.created_at,
+ "started_at": self.started_at,
+ "finished_at": self.finished_at,
+ "hardware": self.hardware,
+ "tools": self.tools,
+ "estimates": self.estimates,
+ "stages": self.stages,
+ "actual": self.actual,
+ "privacy": {
+ "local_record_contains_xml": True,
+ "absolute_paths_in_session_record": False,
+ },
+ }
+
+ def write(self) -> Path:
+ self.record_path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = self.record_path.with_name(
+ f".{self.record_path.name}.{os.getpid()}.tmp"
+ )
+ temporary.write_text(
+ json.dumps(self.to_dict(), ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ os.replace(temporary, self.record_path)
+ return self.record_path
+
+ def start_stage(self, stage: str) -> None:
+ if stage not in self.stages:
+ self.workflow = (*self.workflow, stage)
+ self.stages[stage] = {
+ "status": "pending",
+ "started_at": None,
+ "finished_at": None,
+ "return_code": None,
+ "manifest": None,
+ "summary": {},
+ "warnings": [],
+ "errors": [],
+ }
+ now = _utc_now()
+ if self.started_at is None:
+ self.started_at = now
+ self.status = "running"
+ self.stages[stage]["status"] = "running"
+ self.stages[stage]["started_at"] = now
+ self.write()
+
+ def append_log(self, stage: str, stream: str, line: str) -> Path:
+ safe_stream = "stderr" if stream.casefold() == "stderr" else "stdout"
+ target = self.run_directory / "logs" / f"{stage}.{safe_stream}.log"
+ with target.open("a", encoding="utf-8", newline="\n") as handle:
+ handle.write(line)
+ if not line.endswith("\n"):
+ handle.write("\n")
+ return target
+
+ def add_estimate(self, estimate: ResourceEstimate) -> None:
+ item = estimate.to_dict()
+ item["recorded_at"] = _utc_now()
+ self.estimates.append(item)
+ self.write()
+
+ def add_tool(self, stage: str, info: ToolInfo) -> None:
+ self.tools.update(_tool_records({stage: info}))
+ self.write()
+
+ def finish_stage(self, stage: str, result: RunResult) -> None:
+ entry = self.stages.setdefault(stage, {})
+ entry.update(
+ {
+ "status": result.status,
+ "finished_at": result.finished_at,
+ "return_code": result.return_code,
+ "summary": dict(result.summary),
+ "warnings": list(result.warnings),
+ "errors": list(result.errors),
+ }
+ )
+ if result.manifest_path is not None:
+ entry["manifest"] = _relative_or_name(
+ Path(result.manifest_path), self.output_root
+ )
+ self._update_actual(stage, result)
+ self.write()
+
+ def fail_stage(self, stage: str, message: str) -> None:
+ entry = self.stages.setdefault(stage, {})
+ entry.update(
+ {
+ "status": "failed",
+ "finished_at": _utc_now(),
+ "errors": [message],
+ }
+ )
+ self.write()
+
+ def skip_remaining(self, *, after: str | None, reason: str) -> None:
+ seen = after is None
+ for stage in self.workflow:
+ if stage == after:
+ seen = True
+ continue
+ if seen and self.stages.get(stage, {}).get("status") == "pending":
+ self.stages[stage]["status"] = "skipped"
+ self.stages[stage]["errors"] = [reason]
+ self.write()
+
+ def finalize(self, status: str) -> None:
+ self.status = status
+ self.finished_at = _utc_now()
+ self.write()
+
+ def mark_interrupted(self) -> bool:
+ if self.status != "running":
+ return False
+ self.status = "interrupted"
+ self.finished_at = _utc_now()
+ for entry in self.stages.values():
+ if entry.get("status") == "running":
+ entry["status"] = "interrupted"
+ entry["finished_at"] = self.finished_at
+ self.write()
+ return True
+
+ def export_diagnostic(
+ self,
+ destination: str | os.PathLike[str],
+ *,
+ include_xml: bool = True,
+ redaction_paths: Iterable[str | os.PathLike[str]] = (),
+ ) -> Path:
+ """Export a reviewable ZIP; XML is included unless the user opts out."""
+
+ target = Path(destination)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ redactor = _Redactor(redaction_paths)
+ payloads: dict[str, bytes] = {}
+ run_json = json.dumps(
+ _redact_value(self.to_dict(), redactor),
+ ensure_ascii=False,
+ indent=2,
+ ).encode("utf-8")
+ payloads["run.json"] = run_json
+ payloads["summary.md"] = redactor.text(self.summary_markdown()).encode("utf-8")
+
+ if include_xml and self.source_snapshot.is_file():
+ payloads[f"source/{self.source_name}"] = self.source_snapshot.read_bytes()
+
+ logs_directory = self.run_directory / "logs"
+ if logs_directory.is_dir():
+ for path in sorted(logs_directory.glob("*.log")):
+ payloads[f"logs/{path.name}"] = redactor.text(
+ path.read_text(encoding="utf-8", errors="replace")
+ ).encode("utf-8")
+
+ for stage, entry in self.stages.items():
+ manifest = entry.get("manifest")
+ if not manifest:
+ continue
+ path = (self.output_root / str(manifest)).resolve()
+ if not path.is_file():
+ continue
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ continue
+ payloads[f"manifests/{stage}.json"] = json.dumps(
+ _redact_value(value, redactor),
+ ensure_ascii=False,
+ indent=2,
+ ).encode("utf-8")
+
+ checksums = [
+ f"{sha256(content).hexdigest()} {name}"
+ for name, content in sorted(payloads.items())
+ ]
+ payloads["checksums.sha256"] = ("\n".join(checksums) + "\n").encode("ascii")
+ with ZipFile(target, "w", compression=ZIP_DEFLATED) as archive:
+ for name, content in payloads.items():
+ archive.writestr(name, content)
+ return target
+
+ def summary_markdown(self) -> str:
+ lines = [
+ "# DualSPHysics Studio 运行记录",
+ "",
+ f"- 会话:`{self.session_id}`",
+ f"- Studio:`{self.app_version}`",
+ f"- 后端:`{self.backend.upper()}`",
+ f"- 状态:`{self.status}`",
+ f"- XML SHA-256:`{self.source_sha256}`",
+ "",
+ "## 阶段",
+ "",
+ ]
+ for stage in self.workflow:
+ entry = self.stages.get(stage, {})
+ lines.append(f"- {stage}: {entry.get('status', 'unknown')}")
+ if self.actual:
+ lines.extend(["", "## 实际结果", ""])
+ for key, value in self.actual.items():
+ lines.append(f"- {key}: {value}")
+ return "\n".join(lines) + "\n"
+
+ def _update_actual(self, stage: str, result: RunResult) -> None:
+ summary = result.summary
+ if stage == "gencase":
+ counts = summary.get("particle_counts")
+ if isinstance(counts, Mapping):
+ self.actual["particle_counts"] = dict(counts)
+ if stage in {"cpu", "gpu"}:
+ for key in (
+ "total_runtime_seconds",
+ "simulation_runtime_seconds",
+ "allocated_cpu_memory_bytes",
+ "allocated_gpu_memory_bytes",
+ "requested_gpu_memory_bytes",
+ "device",
+ "steps",
+ "reported_part_files",
+ ):
+ if key in summary:
+ self.actual[key] = summary[key]
+ if result.duration_seconds is not None:
+ self.actual[f"{stage}_wall_seconds"] = result.duration_seconds
+
+
+def find_case_sessions(source_xml: str | os.PathLike[str]) -> tuple[RunSession, ...]:
+ source = Path(source_xml).resolve()
+ stem = source.stem[:-4] if source.stem.casefold().endswith("_def") else source.stem
+ sessions: list[RunSession] = []
+ output_name = f"{stem}_out"
+ try:
+ directories = tuple(source.parent.iterdir())
+ except OSError:
+ directories = ()
+ for directory in directories:
+ if not directory.is_dir() or not (
+ directory.name == output_name
+ or directory.name.startswith(f"{output_name}_")
+ ):
+ continue
+ record = directory / SESSION_DIRECTORY / "run.json"
+ if not record.is_file():
+ continue
+ try:
+ session = RunSession.load(record)
+ except (OSError, ValueError, TypeError):
+ continue
+ if session.source_name.casefold() != source.name.casefold():
+ continue
+ sessions.append(session)
+ return tuple(sorted(sessions, key=lambda item: item.created_at, reverse=True))
+
+
+def session_history_values(
+ sessions: Iterable[RunSession],
+) -> tuple[dict[str, Any], ...]:
+ return tuple(session.to_dict() for session in sessions)
+
+
+def _tool_records(tools: Mapping[str, ToolInfo]) -> dict[str, dict[str, Any]]:
+ records: dict[str, dict[str, Any]] = {}
+ for stage, info in tools.items():
+ executable = info.executable
+ config = info.config_path
+ records[stage] = {
+ "name": info.name,
+ "version": info.version,
+ "executable": executable.name if executable is not None else None,
+ "executable_sha256": _safe_hash(executable),
+ "config": config.name if config is not None else None,
+ "config_sha256": _safe_hash(config),
+ }
+ return records
+
+
+def _safe_hash(path: Path | None) -> str | None:
+ try:
+ return file_sha256(path) if path is not None and path.is_file() else None
+ except OSError:
+ return None
+
+
+def _hardware_facts() -> dict[str, Any]:
+ total_memory = None
+ if hasattr(__import__("ctypes"), "windll"):
+ # Available memory is useful preflight evidence; total memory is not
+ # required for identity and no hostname or serial number is collected.
+ total_memory = available_physical_memory()
+ return {
+ "os": platform.system(),
+ "os_release": platform.release(),
+ "architecture": platform.machine(),
+ "cpu": platform.processor() or None,
+ "logical_cpu_count": os.cpu_count(),
+ "available_memory_at_start_bytes": total_memory,
+ }
+
+
+def _relative_or_name(path: Path, root: Path) -> str:
+ resolved = path.resolve()
+ try:
+ return resolved.relative_to(root.resolve()).as_posix()
+ except ValueError:
+ return resolved.name
+
+
+class _Redactor:
+ def __init__(self, paths: Iterable[str | os.PathLike[str]]) -> None:
+ replacements: list[tuple[str, int]] = []
+ seen: set[str] = set()
+ for index, item in enumerate(paths, start=1):
+ raw = str(Path(item))
+ aliases = [raw]
+ try:
+ aliases.append(str(Path(item).resolve()))
+ except OSError:
+ pass
+ for alias in aliases:
+ if alias in seen:
+ continue
+ seen.add(alias)
+ replacements.append((alias, index))
+ self.replacements = tuple(
+ sorted(replacements, key=lambda item: len(item[0]), reverse=True)
+ )
+
+ def text(self, value: str) -> str:
+ result = value
+ for path, index in self.replacements:
+ result = result.replace(path, f"")
+ result = result.replace(path.replace("\\", "/"), f"")
+ result = re.sub(
+ r"(?i)\b[A-Z]:[\\/]+Users[\\/]+[^\\/\s\"']+",
+ "",
+ result,
+ )
+ return result
+
+
+def _redact_value(value: Any, redactor: _Redactor) -> Any:
+ if isinstance(value, str):
+ return redactor.text(value)
+ if isinstance(value, list):
+ return [_redact_value(item, redactor) for item in value]
+ if isinstance(value, tuple):
+ return [_redact_value(item, redactor) for item in value]
+ if isinstance(value, Mapping):
+ return {
+ str(key): _redact_value(item, redactor)
+ for key, item in value.items()
+ }
+ return value
diff --git a/src/dualsphysics_studio/settings.py b/src/dualsphysics_studio/settings.py
index f961571..4f004e2 100644
--- a/src/dualsphysics_studio/settings.py
+++ b/src/dualsphysics_studio/settings.py
@@ -22,6 +22,7 @@ def cache_dir() -> Path:
@dataclass(slots=True)
class AppSettings:
gencase_root: str = ""
+ last_solver_backend: str = "gpu"
openai_model: str = "gpt-5.6-terra"
recent_files: list[str] = field(default_factory=list)
orthographic_view: bool = False
@@ -55,4 +56,3 @@ def remember_file(self, path: str, limit: int = 10) -> None:
self.recent_files = [item for item in self.recent_files if item != canonical]
self.recent_files.insert(0, canonical)
del self.recent_files[limit:]
-
diff --git a/src/dualsphysics_studio/ui/main_window.py b/src/dualsphysics_studio/ui/main_window.py
index da826e5..c3bbe5b 100644
--- a/src/dualsphysics_studio/ui/main_window.py
+++ b/src/dualsphysics_studio/ui/main_window.py
@@ -1,4 +1,4 @@
-"""Main PySide6 window for DualSPHysics Studio 1.0.1."""
+"""Main PySide6 window for DualSPHysics Studio."""
from __future__ import annotations
@@ -109,7 +109,12 @@ class MainWindow(QMainWindow):
cpuRequested = Signal()
gpuRequested = Signal()
partVTKRequested = Signal()
+ fullRunRequested = Signal()
cancelRequested = Signal()
+ openRunOutputRequested = Signal()
+ exportRunRequested = Signal()
+ rerunFromRecordRequested = Signal()
+ runSessionSelected = Signal(str)
configureDualSPHysicsRequested = Signal()
aiPayloadPreviewRequested = Signal(str, str)
aiExplainRequested = Signal(str, str)
@@ -283,6 +288,14 @@ def _create_actions(self) -> None:
self.action_generate.setObjectName("action_generate")
self.action_generate.setToolTip("运行 GenCase 并生成 Case.xml、Case.bi4 和初始 VTK")
+ self.action_full_run = QAction(
+ style.standardIcon(QStyle.StandardPixmap.SP_MediaPlay), "一键完整运行", self
+ )
+ self.action_full_run.setObjectName("action_full_run")
+ self.action_full_run.setToolTip(
+ "依次执行 GenCase、明确选择的 CPU/GPU 求解和 PartVTK 转换"
+ )
+
self.action_cancel = QAction(
style.standardIcon(QStyle.StandardPixmap.SP_MediaStop), "取消运行", self
)
@@ -298,6 +311,15 @@ def _create_actions(self) -> None:
self.action_partvtk = QAction("PartVTK 转换", self)
self.action_partvtk.setObjectName("action_partvtk")
self.action_partvtk.setToolTip("将 data 中的粒子 BI4 数据转换为 PartAll VTK")
+ self.action_open_run_output = QAction("打开运行输出目录", self)
+ self.action_open_run_output.setObjectName("action_open_run_output")
+ self.action_open_run_output.setEnabled(False)
+ self.action_export_run = QAction("导出可复现记录…", self)
+ self.action_export_run.setObjectName("action_export_run")
+ self.action_export_run.setEnabled(False)
+ self.action_rerun_record = QAction("从记录创建新运行", self)
+ self.action_rerun_record.setObjectName("action_rerun_record")
+ self.action_rerun_record.setEnabled(False)
self.action_configure_dualsphysics = QAction("配置 DualSPHysics 路径…", self)
self.action_configure_dualsphysics.setObjectName(
"action_configure_dualsphysics"
@@ -349,6 +371,7 @@ def _create_menu_bar(self) -> None:
run_menu = self.menuBar().addMenu("运行(&R)")
run_menu.addAction(self.action_configure_dualsphysics)
run_menu.addSeparator()
+ run_menu.addAction(self.action_full_run)
run_menu.addAction(self.action_quick_check)
run_menu.addAction(self.action_generate)
run_menu.addAction(self.action_cancel)
@@ -356,6 +379,10 @@ def _create_menu_bar(self) -> None:
run_menu.addAction(self.action_cpu)
run_menu.addAction(self.action_gpu)
run_menu.addAction(self.action_partvtk)
+ run_menu.addSeparator()
+ run_menu.addAction(self.action_open_run_output)
+ run_menu.addAction(self.action_export_run)
+ run_menu.addAction(self.action_rerun_record)
self.extensions_menu = self.menuBar().addMenu("拓展功能(&X)")
self.extensions_menu.setObjectName("extensions_menu")
@@ -378,6 +405,7 @@ def _create_toolbars(self) -> None:
run_toolbar = QToolBar("运行", self)
run_toolbar.setObjectName("run_toolbar")
run_toolbar.setMovable(False)
+ run_toolbar.addAction(self.action_full_run)
run_toolbar.addAction(self.action_quick_check)
run_toolbar.addAction(self.action_generate)
run_toolbar.addAction(self.action_cpu)
@@ -469,7 +497,7 @@ def _create_center_panel(self) -> None:
self._create_source_tab()
self._create_log_tab()
self.bottom_tabs.addTab(self.source_tab, "XML 源码")
- self.bottom_tabs.addTab(self.log_tab, "运行日志")
+ self.bottom_tabs.addTab(self.log_tab, "运行中心")
self.center_splitter.addWidget(viewport_group)
self.center_splitter.addWidget(self.bottom_tabs)
@@ -519,13 +547,103 @@ def _create_log_tab(self) -> None:
run_bar.addWidget(self.run_progress)
layout.addLayout(run_bar)
- self.log_view = QPlainTextEdit(self.log_tab)
+ self.run_center_tabs = QTabWidget(self.log_tab)
+ self.run_center_tabs.setObjectName("run_center_tabs")
+
+ self.run_overview_page = QWidget(self.run_center_tabs)
+ overview_layout = QVBoxLayout(self.run_overview_page)
+ overview_layout.setContentsMargins(4, 4, 4, 4)
+ session_row = QHBoxLayout()
+ self.run_session_label = QLabel("会话:尚未创建", self.run_overview_page)
+ self.run_session_label.setObjectName("run_session_label")
+ session_row.addWidget(self.run_session_label)
+ session_row.addStretch(1)
+ session_row.addWidget(QLabel("历史记录", self.run_overview_page))
+ self.run_history_combo = QComboBox(self.run_overview_page)
+ self.run_history_combo.setObjectName("run_history_combo")
+ self.run_history_combo.setMinimumWidth(260)
+ self.run_history_combo.addItem("暂无记录", "")
+ session_row.addWidget(self.run_history_combo)
+ overview_layout.addLayout(session_row)
+
+ self.run_stage_table = QTableWidget(4, 3, self.run_overview_page)
+ self.run_stage_table.setObjectName("run_stage_table")
+ self.run_stage_table.setHorizontalHeaderLabels(["阶段", "状态", "详情"])
+ self.run_stage_table.verticalHeader().setVisible(False)
+ self.run_stage_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.run_stage_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
+ self.run_stage_table.horizontalHeader().setSectionResizeMode(
+ 0, QHeaderView.ResizeMode.ResizeToContents
+ )
+ self.run_stage_table.horizontalHeader().setSectionResizeMode(
+ 1, QHeaderView.ResizeMode.ResizeToContents
+ )
+ self.run_stage_table.horizontalHeader().setStretchLastSection(True)
+ self.run_stage_table.setMaximumHeight(150)
+ for row, (stage, label) in enumerate(
+ (
+ ("preflight", "资源预检"),
+ ("gencase", "GenCase"),
+ ("solver", "CPU/GPU 求解"),
+ ("partvtk", "PartVTK"),
+ )
+ ):
+ item = QTableWidgetItem(label)
+ item.setData(Qt.ItemDataRole.UserRole, stage)
+ self.run_stage_table.setItem(row, 0, item)
+ self.run_stage_table.setItem(row, 1, QTableWidgetItem("等待"))
+ self.run_stage_table.setItem(row, 2, QTableWidgetItem(""))
+ overview_layout.addWidget(self.run_stage_table)
+
+ self.resource_table = QTableWidget(0, 4, self.run_overview_page)
+ self.resource_table.setObjectName("resource_estimate_table")
+ self.resource_table.setHorizontalHeaderLabels(
+ ["资源项目", "估算/实测", "置信度", "依据"]
+ )
+ self.resource_table.verticalHeader().setVisible(False)
+ self.resource_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.resource_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
+ self.resource_table.horizontalHeader().setSectionResizeMode(
+ 0, QHeaderView.ResizeMode.ResizeToContents
+ )
+ self.resource_table.horizontalHeader().setSectionResizeMode(
+ 1, QHeaderView.ResizeMode.ResizeToContents
+ )
+ self.resource_table.horizontalHeader().setSectionResizeMode(
+ 2, QHeaderView.ResizeMode.ResizeToContents
+ )
+ self.resource_table.horizontalHeader().setStretchLastSection(True)
+ overview_layout.addWidget(self.resource_table, 1)
+
+ record_buttons = QHBoxLayout()
+ self.open_run_output_button = QPushButton("打开输出目录", self.run_overview_page)
+ self.open_run_output_button.setEnabled(False)
+ record_buttons.addWidget(self.open_run_output_button)
+ self.export_run_button = QPushButton("导出记录…", self.run_overview_page)
+ self.export_run_button.setEnabled(False)
+ record_buttons.addWidget(self.export_run_button)
+ self.rerun_record_button = QPushButton(
+ "从记录创建新运行", self.run_overview_page
+ )
+ self.rerun_record_button.setEnabled(False)
+ record_buttons.addWidget(self.rerun_record_button)
+ record_buttons.addStretch(1)
+ overview_layout.addLayout(record_buttons)
+
+ self.run_log_page = QWidget(self.run_center_tabs)
+ log_layout = QVBoxLayout(self.run_log_page)
+ log_layout.setContentsMargins(4, 4, 4, 4)
+ self.log_view = QPlainTextEdit(self.run_log_page)
self.log_view.setObjectName("log_view")
self.log_view.setReadOnly(True)
self.log_view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
self.log_view.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.log_view.setPlaceholderText("运行工具的标准输出、错误和产物摘要将在这里显示。")
- layout.addWidget(self.log_view, 1)
+ log_layout.addWidget(self.log_view, 1)
+
+ self.run_center_tabs.addTab(self.run_overview_page, "会话概览")
+ self.run_center_tabs.addTab(self.run_log_page, "实时日志")
+ layout.addWidget(self.run_center_tabs, 1)
def _create_inspector_panel(self) -> None:
self.inspector_tabs = QTabWidget(self)
@@ -772,11 +890,27 @@ def _connect_signals(self) -> None:
lambda _checked=False: self.configureDualSPHysicsRequested.emit()
)
self.action_quick_check.triggered.connect(self._request_quick_check)
+ self.action_full_run.triggered.connect(self._request_full_run)
self.action_generate.triggered.connect(self._request_generate)
self.action_cpu.triggered.connect(self._request_cpu)
self.action_gpu.triggered.connect(self._request_gpu)
self.action_partvtk.triggered.connect(self._request_partvtk)
self.action_cancel.triggered.connect(lambda _checked=False: self.cancelRequested.emit())
+ self.action_open_run_output.triggered.connect(
+ lambda _checked=False: self.openRunOutputRequested.emit()
+ )
+ self.action_export_run.triggered.connect(
+ lambda _checked=False: self.exportRunRequested.emit()
+ )
+ self.action_rerun_record.triggered.connect(
+ lambda _checked=False: self.rerunFromRecordRequested.emit()
+ )
+ self.open_run_output_button.clicked.connect(self.openRunOutputRequested.emit)
+ self.export_run_button.clicked.connect(self.exportRunRequested.emit)
+ self.rerun_record_button.clicked.connect(self.rerunFromRecordRequested.emit)
+ self.run_history_combo.currentIndexChanged.connect(
+ self._run_history_selection_changed
+ )
self.action_about.triggered.connect(self._show_about)
self.model_tree.currentItemChanged.connect(self._tree_selection_changed)
@@ -2109,6 +2243,14 @@ def _call_history_method(self, method_name: str) -> bool:
return False
# --------------------------------------------------------------- pipeline UI
+ def _request_full_run(self) -> None:
+ if self._document is None:
+ self.status_message.setText("请先打开 XML")
+ return
+ if not self._flush_source_edit(strict=True):
+ return
+ self.fullRunRequested.emit()
+
def _request_quick_check(self) -> None:
if self._document is None:
self.status_message.setText("请先打开 XML")
@@ -2154,16 +2296,118 @@ def _request_partvtk(self) -> None:
self.set_run_state(True, "正在执行 PartVTK 转换…")
self.partVTKRequested.emit()
+ def begin_run_session(self, session_id: str, backend: str) -> None:
+ self.run_session_label.setText(
+ f"会话:{session_id} 后端:{backend.upper()}"
+ )
+ for row in range(self.run_stage_table.rowCount()):
+ self.run_stage_table.item(row, 1).setText("等待")
+ self.run_stage_table.item(row, 2).setText("")
+ self.update_run_stage("preflight", "succeeded", "资源估算已完成")
+ self.run_center_tabs.setCurrentWidget(self.run_overview_page)
+
+ def update_run_stage(self, stage: str, status: str, detail: str = "") -> None:
+ stage_key = "solver" if stage in {"cpu", "gpu"} else stage
+ labels = {
+ "pending": "等待",
+ "running": "运行中",
+ "succeeded": "成功",
+ "failed": "失败",
+ "cancelled": "已取消",
+ "skipped": "已跳过",
+ "interrupted": "已中断",
+ }
+ for row in range(self.run_stage_table.rowCount()):
+ item = self.run_stage_table.item(row, 0)
+ if item.data(Qt.ItemDataRole.UserRole) != stage_key:
+ continue
+ self.run_stage_table.item(row, 1).setText(
+ labels.get(status, status)
+ )
+ self.run_stage_table.item(row, 2).setText(detail)
+ break
+
+ def update_resource_estimate(self, estimate: Any) -> None:
+ rows = getattr(estimate, "metric_rows", lambda: ())()
+ self.resource_table.setRowCount(len(rows))
+ for row, values in enumerate(rows):
+ for column, value in enumerate(values):
+ self.resource_table.setItem(
+ row, column, QTableWidgetItem(str(value))
+ )
+
+ def update_stage_progress(
+ self,
+ stage: str,
+ completed: int,
+ total: int,
+ *,
+ eta_seconds: float | None = None,
+ ) -> None:
+ if total <= 0:
+ return
+ percent = max(0, min(100, round(completed * 100 / total)))
+ self.run_progress.setRange(0, 100)
+ self.run_progress.setValue(percent)
+ detail = f"{completed}/{total}({percent}%)"
+ if eta_seconds is not None:
+ eta = max(0, int(round(eta_seconds)))
+ hours, remainder = divmod(eta, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ if hours:
+ eta_text = f"{hours} 小时 {minutes} 分"
+ elif minutes:
+ eta_text = f"{minutes} 分 {seconds} 秒"
+ else:
+ eta_text = f"{seconds} 秒"
+ detail += f" 预计剩余 {eta_text}"
+ self.update_run_stage(stage, "running", detail)
+
+ def set_run_history(self, sessions: Any) -> None:
+ blocker = QSignalBlocker(self.run_history_combo)
+ self.run_history_combo.clear()
+ for session in sessions:
+ status = str(getattr(session, "status", "unknown"))
+ backend = str(getattr(session, "backend", "")).upper()
+ created = str(getattr(session, "created_at", ""))
+ session_id = str(getattr(session, "session_id", ""))
+ record_path = str(getattr(session, "record_path", ""))
+ self.run_history_combo.addItem(
+ f"{created[:19]} {backend} {status} {session_id[-8:]}",
+ record_path,
+ )
+ if self.run_history_combo.count() == 0:
+ self.run_history_combo.addItem("暂无记录", "")
+ del blocker
+
+ def set_run_record_available(self, available: bool) -> None:
+ self.action_open_run_output.setEnabled(available)
+ self.action_export_run.setEnabled(available)
+ self.action_rerun_record.setEnabled(available and not self._run_active)
+ self.open_run_output_button.setEnabled(available)
+ self.export_run_button.setEnabled(available)
+ self.rerun_record_button.setEnabled(available and not self._run_active)
+
+ def _run_history_selection_changed(self, index: int) -> None:
+ path = str(self.run_history_combo.itemData(index) or "")
+ if path:
+ self.runSessionSelected.emit(path)
+
def set_run_state(self, running: bool, message: str | None = None) -> None:
self._run_active = bool(running)
run_enabled = not running and self._document is not None
self.action_configure_dualsphysics.setEnabled(not running)
+ self.action_full_run.setEnabled(run_enabled)
self.action_quick_check.setEnabled(run_enabled)
self.action_generate.setEnabled(run_enabled)
self.action_cpu.setEnabled(run_enabled)
self.action_gpu.setEnabled(run_enabled)
self.action_partvtk.setEnabled(run_enabled)
self.action_cancel.setEnabled(running)
+ self.run_history_combo.setEnabled(not running)
+ record_available = self.action_export_run.isEnabled()
+ self.action_rerun_record.setEnabled(record_available and not running)
+ self.rerun_record_button.setEnabled(record_available and not running)
self.action_new_box.setEnabled(not running and self._document is not None)
self.action_new_prism.setEnabled(not running and self._document is not None)
self._set_geometry_actions_enabled(
@@ -2176,6 +2420,7 @@ def set_run_state(self, running: bool, message: str | None = None) -> None:
if running:
self.run_progress.setRange(0, 0)
self.bottom_tabs.setCurrentWidget(self.log_tab)
+ self.run_center_tabs.setCurrentWidget(self.run_log_page)
else:
self.run_progress.setRange(0, 100)
self.run_progress.setValue(100 if message and "成功" in message else 0)
@@ -2195,7 +2440,13 @@ def append_log(self, text: str, *, stream: str = "stdout") -> None:
self.log_view.setTextCursor(cursor)
self.log_view.ensureCursorVisible()
- def finish_run(self, result: Any, *, stage: str | None = None) -> None:
+ def finish_run(
+ self,
+ result: Any,
+ *,
+ stage: str | None = None,
+ workflow_continues: bool = False,
+ ) -> None:
success = bool(value_of(result, "success", default=False))
status = value_of(result, "status", default="")
if str(status).lower() in {"success", "succeeded", "complete", "completed"}:
@@ -2212,7 +2463,13 @@ def finish_run(self, result: Any, *, stage: str | None = None) -> None:
message = str(value_of(result, "message", default="")).strip()
if not message and str(status).lower() == "cancelled":
message = f"{stage_label}已取消"
- self.set_run_state(False, message or f"{stage_label}{'成功完成' if success else '运行失败'}")
+ if workflow_continues and success:
+ self.set_run_state(True, message or f"{stage_label}成功,准备下一阶段…")
+ else:
+ self.set_run_state(
+ False,
+ message or f"{stage_label}{'成功完成' if success else '运行失败'}",
+ )
log = value_of(result, "stdout", default="")
if log:
self.append_log(str(log))
@@ -2496,6 +2753,7 @@ def _show_welcome_state(self) -> None:
)
self.action_save.setEnabled(False)
self.action_save_as.setEnabled(False)
+ self.action_full_run.setEnabled(False)
self.action_quick_check.setEnabled(False)
self.action_generate.setEnabled(False)
self.action_cpu.setEnabled(False)
@@ -2515,6 +2773,7 @@ def _update_title(self) -> None:
enabled and self._selected_entry is not None and self._selected_entry.editable
)
if not self._run_active:
+ self.action_full_run.setEnabled(enabled)
self.action_quick_check.setEnabled(enabled)
self.action_generate.setEnabled(enabled)
self.action_cpu.setEnabled(enabled)
diff --git a/src/dualsphysics_studio/version.py b/src/dualsphysics_studio/version.py
index eee8cca..3afd83c 100644
--- a/src/dualsphysics_studio/version.py
+++ b/src/dualsphysics_studio/version.py
@@ -1,3 +1,3 @@
APP_NAME = "DualSPHysics Studio"
-APP_VERSION = "1.0.1"
+APP_VERSION = "1.0.2"
APP_ID = "DualSPHysicsStudio"
diff --git a/tests/test_app_helpers.py b/tests/test_app_helpers.py
index 315ce33..6d4f4dc 100644
--- a/tests/test_app_helpers.py
+++ b/tests/test_app_helpers.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
+from types import SimpleNamespace
import dualsphysics_studio.app as app_module
from dualsphysics_studio.ai.models import (
@@ -10,7 +11,7 @@
RiskItem,
)
from dualsphysics_studio.app import StudioController, format_ai_result
-from dualsphysics_studio.pipeline import ToolInfo
+from dualsphysics_studio.pipeline import RunResult, RunSession, ToolInfo
from dualsphysics_studio.resources import resource_path
@@ -194,3 +195,111 @@ def test_configure_dualsphysics_rejects_incomplete_or_wrong_version_root(
assert controller.settings.gencase_root == ""
assert controller.settings.save_count == 0
assert "[失败] PartVTK" in errors[-1]
+
+
+class _FakeRunWindow:
+ def __init__(self, source: Path) -> None:
+ self.document_path = source
+ self.document = object()
+ self.finished: list[tuple[str | None, bool]] = []
+ self.stages: list[tuple[str, str, str]] = []
+ self.logs: list[tuple[str, str]] = []
+ self.record_available = False
+
+ def finish_run(
+ self,
+ _result,
+ *,
+ stage=None,
+ workflow_continues=False,
+ ) -> None:
+ self.finished.append((stage, workflow_continues))
+
+ def update_run_stage(self, stage: str, status: str, detail: str = "") -> None:
+ self.stages.append((stage, status, detail))
+
+ def update_resource_estimate(self, _estimate) -> None:
+ pass
+
+ def append_log(self, text: str, *, stream: str = "stdout") -> None:
+ self.logs.append((stream, text))
+
+ def set_run_record_available(self, available: bool) -> None:
+ self.record_available = available
+
+
+def _run_result(
+ source: Path,
+ output_prefix: Path,
+ stage: str,
+ *,
+ status: str = "succeeded",
+) -> RunResult:
+ result = RunResult(
+ status=status,
+ return_code=0 if status == "succeeded" else 1,
+ command=(stage,),
+ working_directory=source.parent,
+ output_prefix=output_prefix,
+ started_at="2026-07-29T00:00:00+00:00",
+ finished_at="2026-07-29T00:00:01+00:00",
+ summary={},
+ cancelled=status == "cancelled",
+ )
+ result.manifest = SimpleNamespace(
+ stage=stage,
+ parameters={"mode": "full" if stage == "gencase" else stage},
+ source_path=source,
+ )
+ return result
+
+
+def test_full_run_state_machine_always_reaches_partvtk(tmp_path) -> None:
+ source = tmp_path / "Case_Def.xml"
+ source.write_text("", encoding="utf-8")
+ output_prefix = tmp_path / "Case_out" / "Case"
+ session = RunSession.create(
+ source_xml=source,
+ output_root=output_prefix.parent,
+ backend="gpu",
+ workflow=("gencase", "gpu", "partvtk"),
+ )
+ controller = StudioController.__new__(StudioController)
+ controller.window = _FakeRunWindow(source)
+ controller._run_session = session
+ controller._selected_session = session
+ controller._full_run_active = True
+ controller._full_run_backend = "gpu"
+ controller._pending_full_stage = None
+ controller._active_stage = "gencase"
+ controller._last_run = None
+ controller._last_gencase_run = None
+ controller._case_output_prefix = output_prefix
+ controller._case_output_source = source
+ controller._gencase_summary = {}
+ estimates: list[dict] = []
+ controller._refresh_session_estimate = lambda summary: estimates.append(summary)
+
+ controller._run_finished(_run_result(source, output_prefix, "gencase"))
+ assert controller._pending_full_stage == "gpu"
+ assert controller.window.finished[-1] == ("gencase", True)
+
+ controller._active_stage = "gpu"
+ controller._pending_full_stage = None
+ controller._run_finished(_run_result(source, output_prefix, "gpu"))
+ assert controller._pending_full_stage == "partvtk"
+ assert controller.window.finished[-1] == ("gpu", True)
+
+ controller._active_stage = "partvtk"
+ controller._pending_full_stage = None
+ controller._run_finished(_run_result(source, output_prefix, "partvtk"))
+ assert not controller._full_run_active
+ assert controller._pending_full_stage is None
+ assert controller.window.finished[-1] == ("partvtk", False)
+ assert session.status == "succeeded"
+ assert [session.stages[stage]["status"] for stage in session.workflow] == [
+ "succeeded",
+ "succeeded",
+ "succeeded",
+ ]
+ assert len(estimates) == 2
diff --git a/tests/test_catalog_settings.py b/tests/test_catalog_settings.py
index d0c7098..fc86f29 100644
--- a/tests/test_catalog_settings.py
+++ b/tests/test_catalog_settings.py
@@ -6,7 +6,7 @@
def test_product_version_is_1_0() -> None:
- assert APP_VERSION == "1.0.1"
+ assert APP_VERSION == "1.0.2"
def test_catalog_resolves_parameter_key() -> None:
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index c54e81c..bb1583d 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -222,6 +222,10 @@ def __init__(
"DualSPHysics5 v5.4.355\n"
f"{device}"
"*** WARNING: Test warning.\n"
+ "CaseNp=1,000\n"
+ "**Requested gpu memory for 1,000 particles: 64.0 MB.\n"
+ "Allocated memory in CPU: 33554432 (32.0 MB)\n"
+ "Allocated memory in GPU: 67108864 (64.0 MB)\n"
"Total Runtime....................: 2.5 sec.\n"
"Simulation Runtime...............: 2.0 sec.\n"
"Steps of simulation..............: 1,234\n"
@@ -695,6 +699,10 @@ def popen(args, **kwargs):
self.assertEqual(result.summary["device"], '0 "Test GPU"')
self.assertEqual(result.summary["steps"], 1_234)
self.assertEqual(result.summary["total_runtime_seconds"], 2.5)
+ self.assertEqual(result.summary["particles"], 1_000)
+ self.assertEqual(result.summary["allocated_cpu_memory_bytes"], 32 * 1024 * 1024)
+ self.assertEqual(result.summary["allocated_gpu_memory_bytes"], 64 * 1024 * 1024)
+ self.assertEqual(result.summary["requested_gpu_memory_bytes"], 64 * 1024 * 1024)
self.assertIn("Test warning.", result.warnings)
self.assertEqual(result.manifest.stage, "gpu")
self.assertTrue(result.manifest_path.name.endswith(".gpu.manifest.json"))
diff --git a/tests/test_run_session.py b/tests/test_run_session.py
new file mode 100644
index 0000000..689610c
--- /dev/null
+++ b/tests/test_run_session.py
@@ -0,0 +1,269 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+import unittest
+from zipfile import ZipFile
+
+from dualsphysics_studio.pipeline import (
+ ResourceEstimate,
+ RunResult,
+ RunSession,
+ ToolInfo,
+ estimate_case,
+ find_case_sessions,
+ live_stage_progress,
+)
+
+
+@dataclass
+class _Point:
+ x: float
+ y: float
+ z: float
+
+
+@dataclass
+class _Bounds:
+ minimum: _Point
+ maximum: _Point
+
+
+@dataclass
+class _Facts:
+ dp: float
+ definition_bounds: _Bounds
+
+
+@dataclass
+class _Field:
+ key: str
+ category: str
+ value: str
+
+
+@dataclass
+class _Document:
+ facts: _Facts
+ parameter_fields: tuple[_Field, ...]
+
+
+def _document() -> _Document:
+ return _Document(
+ _Facts(0.1, _Bounds(_Point(0, 0, 0), _Point(1, 0.5, 0.2))),
+ (
+ _Field("TimeMax", "execution", "2.0"),
+ _Field("TimeOut", "execution", "0.5"),
+ ),
+ )
+
+
+class ResourceEstimateTests(unittest.TestCase):
+ def test_preflight_reports_domain_upper_bound_and_frames(self) -> None:
+ estimate = estimate_case(_document())
+
+ self.assertEqual(estimate.phase, "preflight")
+ self.assertEqual(estimate.domain_grid_particles, 11 * 6 * 3)
+ self.assertEqual(estimate.output_frames, 5)
+ self.assertIsNone(estimate.actual_particles)
+ self.assertEqual(estimate.confidence, "low")
+ self.assertGreater(estimate.ram_high_bytes or 0, estimate.ram_low_bytes or 0)
+
+ def test_gencase_counts_replace_the_domain_upper_bound_as_basis(self) -> None:
+ estimate = estimate_case(
+ _document(),
+ particle_summary={
+ "particle_counts": {
+ "boundary": 200,
+ "fixed": 180,
+ "moving": 10,
+ "floating": 10,
+ "fluid": 800,
+ }
+ },
+ )
+
+ self.assertEqual(estimate.phase, "post-gencase")
+ self.assertEqual(estimate.actual_particles, 1000)
+ self.assertEqual(estimate.particle_basis, 1000)
+ self.assertEqual(estimate.confidence, "medium")
+
+ def test_matching_history_provides_a_bounded_runtime(self) -> None:
+ estimate = estimate_case(
+ _document(),
+ source_sha256="abc",
+ backend="gpu",
+ history=(
+ {
+ "source": {"sha256": "abc"},
+ "backend": "gpu",
+ "actual": {"total_runtime_seconds": 100.0},
+ },
+ {
+ "source": {"sha256": "other"},
+ "backend": "gpu",
+ "actual": {"total_runtime_seconds": 1.0},
+ },
+ ),
+ )
+
+ self.assertEqual(estimate.runtime_low_seconds, 80.0)
+ self.assertEqual(estimate.runtime_high_seconds, 125.0)
+
+ def test_live_progress_is_best_effort_and_bounded(self) -> None:
+ self.assertEqual(
+ live_stage_progress("gpu", "Part_0003 stored", expected_frames=10),
+ (4, 10),
+ )
+ self.assertEqual(
+ live_stage_progress("partvtk", "saved PartAll_9999.vtk", expected_frames=10),
+ (10, 10),
+ )
+ self.assertIsNone(live_stage_progress("gencase", "working", expected_frames=10))
+
+
+class RunSessionTests(unittest.TestCase):
+ def test_session_is_case_local_relative_and_atomically_updated(self) -> None:
+ with TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "Case_Def.xml"
+ source.write_text("", encoding="utf-8")
+ executable = root / "GenCase_win64.exe"
+ executable.write_bytes(b"tool")
+ output = root / "Case_out"
+ session = RunSession.create(
+ source_xml=source,
+ output_root=output,
+ backend="gpu",
+ workflow=("gencase", "gpu", "partvtk"),
+ tools={
+ "gencase": ToolInfo(
+ name="GenCase",
+ executable=executable,
+ version="5.4",
+ available=True,
+ compatible=True,
+ )
+ },
+ )
+
+ self.assertEqual(
+ session.record_path,
+ output.resolve() / ".dss-run" / "run.json",
+ )
+ self.assertEqual(session.source_snapshot.read_text(encoding="utf-8"), "")
+ payload = json.loads(session.record_path.read_text(encoding="utf-8"))
+ self.assertNotIn(str(root), json.dumps(payload))
+ self.assertEqual(
+ payload["tools"]["gencase"]["executable_sha256"],
+ "7c9bbe5ec9b3fb774e8fa0f54247e93c34ddf8e5d16fe3073420de0ae81a262d",
+ )
+
+ session.add_estimate(ResourceEstimate("preflight", "low"))
+ session.start_stage("gencase")
+ manifest_path = output / "Case.manifest.json"
+ manifest_path.write_text("{}", encoding="utf-8")
+ result = RunResult(
+ status="succeeded",
+ return_code=0,
+ command=("GenCase",),
+ working_directory=root,
+ output_prefix=output / "Case",
+ started_at="2026-07-29T00:00:00+00:00",
+ finished_at="2026-07-29T00:00:01+00:00",
+ summary={"particle_counts": {"boundary": 10, "fluid": 90}},
+ manifest_path=manifest_path,
+ )
+ session.finish_stage("gencase", result)
+ session.skip_remaining(after="gencase", reason="test")
+ session.finalize("succeeded")
+
+ loaded = RunSession.load(session.record_path)
+ self.assertEqual(loaded.status, "succeeded")
+ self.assertEqual(
+ loaded.stages["gencase"]["manifest"], "Case.manifest.json"
+ )
+ self.assertEqual(loaded.stages["gpu"]["status"], "skipped")
+ self.assertEqual(loaded.actual["particle_counts"]["fluid"], 90)
+
+ def test_diagnostic_export_includes_xml_by_default_and_can_exclude_it(self) -> None:
+ with TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "Case_Def.xml"
+ source.write_text("", encoding="utf-8")
+ session = RunSession.create(
+ source_xml=source,
+ output_root=root / "Case_out",
+ backend="cpu",
+ workflow=("gencase",),
+ )
+ session.append_log("gencase", "stdout", f"source={source}")
+
+ included = session.export_diagnostic(
+ root / "included.zip",
+ redaction_paths=(root,),
+ )
+ with ZipFile(included) as archive:
+ self.assertIn("source/Case_Def.xml", archive.namelist())
+ log = archive.read("logs/gencase.stdout.log").decode("utf-8")
+ self.assertNotIn(str(root), log)
+ self.assertIn("", log)
+
+ excluded = session.export_diagnostic(
+ root / "excluded.zip",
+ include_xml=False,
+ redaction_paths=(root,),
+ )
+ with ZipFile(excluded) as archive:
+ self.assertNotIn("source/Case_Def.xml", archive.namelist())
+
+ def test_case_history_discovers_newest_records(self) -> None:
+ with TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "Case_Def.xml"
+ source.write_text("", encoding="utf-8")
+ first = RunSession.create(
+ source_xml=source,
+ output_root=root / "Case_out",
+ backend="cpu",
+ workflow=("gencase",),
+ )
+ second = RunSession.create(
+ source_xml=source,
+ output_root=root / "Case_out_20260729-120000",
+ backend="gpu",
+ workflow=("gencase",),
+ )
+ first.created_at = "2026-07-28T00:00:00+00:00"
+ first.write()
+ second.created_at = "2026-07-29T00:00:00+00:00"
+ second.write()
+
+ sessions = find_case_sessions(source)
+
+ self.assertEqual([item.backend for item in sessions], ["gpu", "cpu"])
+
+ def test_running_session_is_recovered_as_interrupted(self) -> None:
+ with TemporaryDirectory() as temporary:
+ root = Path(temporary)
+ source = root / "Case_Def.xml"
+ source.write_text("", encoding="utf-8")
+ session = RunSession.create(
+ source_xml=source,
+ output_root=root / "Case_out",
+ backend="gpu",
+ workflow=("gencase", "gpu", "partvtk"),
+ )
+ session.start_stage("gencase")
+
+ recovered = RunSession.load(session.record_path)
+ self.assertTrue(recovered.mark_interrupted())
+ self.assertEqual(recovered.status, "interrupted")
+ self.assertEqual(recovered.stages["gencase"]["status"], "interrupted")
+ self.assertFalse(recovered.mark_interrupted())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_viewport.py b/tests/test_viewport.py
index 786d695..a9d2e00 100644
--- a/tests/test_viewport.py
+++ b/tests/test_viewport.py
@@ -17,7 +17,9 @@
import dualsphysics_studio.ui.dialogs as dialogs_module
import dualsphysics_studio.ui.main_window as main_window_module
import dualsphysics_studio.ui.viewport as viewport_module
+import dualsphysics_studio.app as app_module
from dualsphysics_studio.core import CaseDocument
+from dualsphysics_studio.pipeline import ResourceEstimate
from dualsphysics_studio.ui.viewport import VTK_AVAILABLE, VTKViewport
if VTK_AVAILABLE:
@@ -1002,6 +1004,92 @@ def test_run_menu_exposes_dualsphysics_path_configuration(
qapp.processEvents()
+def test_run_center_exposes_full_flow_estimates_and_record_actions(
+ qapp: QApplication, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ fixture = Path(__file__).parent / "fixtures" / "sample_case.xml"
+ document = CaseDocument.from_file(fixture)
+ window = _main_window_without_vtk(monkeypatch)
+ window.set_document(document, fixture)
+
+ emitted: list[bool] = []
+ window.fullRunRequested.connect(lambda: emitted.append(True))
+ window.action_full_run.trigger()
+ qapp.processEvents()
+ assert emitted == [True]
+
+ estimate = ResourceEstimate(
+ phase="preflight",
+ confidence="low",
+ domain_grid_particles=1234,
+ output_frames=31,
+ )
+ window.begin_run_session("session-1234", "gpu")
+ window.update_resource_estimate(estimate)
+ window.update_run_stage("gpu", "running", "3/31")
+ assert "session-1234" in window.run_session_label.text()
+ assert window.resource_table.rowCount() >= 2
+ solver_row = next(
+ row
+ for row in range(window.run_stage_table.rowCount())
+ if window.run_stage_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
+ == "solver"
+ )
+ assert window.run_stage_table.item(solver_row, 1).text() == "运行中"
+ assert window.run_stage_table.item(solver_row, 2).text() == "3/31"
+
+ session = SimpleNamespace(
+ status="succeeded",
+ backend="gpu",
+ created_at="2026-07-29T12:00:00+00:00",
+ session_id="session-1234",
+ record_path=fixture.parent / "run.json",
+ )
+ window.set_run_history((session,))
+ assert window.run_history_combo.count() == 1
+ window.set_run_record_available(True)
+ assert window.action_export_run.isEnabled()
+ assert window.export_run_button.isEnabled()
+
+ window.set_run_state(True, "运行中")
+ assert not window.action_full_run.isEnabled()
+ assert not window.run_history_combo.isEnabled()
+ window.set_run_state(False, "成功完成")
+ assert window.action_full_run.isEnabled()
+ assert window.run_history_combo.isEnabled()
+
+ delete(window)
+ qapp.processEvents()
+
+
+def test_full_run_dialog_requires_explicit_acknowledgement_and_keeps_partvtk(
+ qapp: QApplication,
+) -> None:
+ parent = main_window_module.MainWindow()
+ estimate = ResourceEstimate("preflight", "low")
+ dialog = app_module._FullRunDialog(
+ parent,
+ default_backend="cpu",
+ estimate=estimate,
+ )
+
+ assert dialog.backend == "cpu"
+ assert not dialog.start_button.isEnabled()
+ assert "PartVTK" in dialog.confirm_check.text()
+ dialog.confirm_check.setChecked(True)
+ assert dialog.start_button.isEnabled()
+
+ privacy = app_module._DiagnosticExportDialog(parent)
+ assert privacy.include_xml
+ privacy.exclude_xml_check.setChecked(True)
+ assert not privacy.include_xml
+
+ delete(privacy)
+ delete(dialog)
+ delete(parent)
+ qapp.processEvents()
+
+
def test_extension_menu_refresh_discovers_only_root_and_one_level_tools(
qapp: QApplication, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git "a/\345\277\253\351\200\237\350\257\264\346\230\216.md" "b/\345\277\253\351\200\237\350\257\264\346\230\216.md"
index 90fabb6..05bbaab 100644
--- "a/\345\277\253\351\200\237\350\257\264\346\230\216.md"
+++ "b/\345\277\253\351\200\237\350\257\264\346\230\216.md"
@@ -1,4 +1,4 @@
-# DualSPHysics Studio 1.0.1 快速说明
+# DualSPHysics Studio 1.0.2 快速说明
DualSPHysics Studio 是面向 DualSPHysics v5.4 的中文 XML 可视化编辑器和本地计算流程前端。程序以 Windows x64 便携目录发布,不需要安装 Python,也不会附带或修改 DualSPHysics 官方程序。
@@ -8,12 +8,12 @@ DualSPHysics Studio 是面向 DualSPHysics v5.4 的中文 XML 可视化编辑器
- 已安装或解压 DualSPHysics v5.4,目录中应包含 `GenCase_win64.exe`、`DualSPHysics5.4CPU_win64.exe`、`DualSPHysics5.4_win64.exe`、`PartVTK_win64.exe` 与相邻的 `DsphConfig.xml`。
- 若只查看和编辑 XML,不需要网络,也不需要 OpenAI 密钥。
-打开 `DualSPHysics Studio-v1.0.1 (windows x64)` 便携目录后,请保持目录结构完整,双击 `DualSPHysicsStudio.exe`。不要只把 EXE 单独移动到其他目录。
+打开 `DualSPHysics Studio-v1.0.2 (windows x64)` 便携目录后,请保持目录结构完整,双击 `DualSPHysicsStudio.exe`。不要只把 EXE 单独移动到其他目录。
便携目录中的 `DLC` 文件夹是默认拓展功能目录,可以直接放置单个 EXE,也可以为带 DLL、插件和许可文件的便携工具保留一级子目录:
```text
-DualSPHysics Studio-v1.0.1 (windows x64)\
+DualSPHysics Studio-v1.0.2 (windows x64)\
DualSPHysicsStudio.exe
_internal\
快速说明.md
@@ -28,14 +28,14 @@ DualSPHysics Studio-v1.0.1 (windows x64)\
1. 选择“文件 → 打开 XML”,打开 `Case_Def.xml`。
2. 左侧模型树列出流体、固定边界和浮动刚体;选择对象后,3D 视图、属性表和离线解读会同步更新。右侧“参数”页可搜索几何、物理常量和执行参数;“算例医生”按错误、警告和建议汇总问题。双击“级别”或“问题”会打开完整详情页,显示诊断代码、完整描述、XML 路径、行号和源码上下文;双击“位置”或点击“跳转到 XML”仍会直接定位到对应源码行。
3. 3D 视图中,普通左键用于选择模型,不会旋转视角;选中可编辑对象后,其全部顶点显示为蓝色球。再次单击一个顶点会将它标为黄色,并在鼠标右侧显示三维坐标。左键拖动该点只预览位置,按 Enter 提交,按 Esc 取消;短按右键可打开精确坐标窗口,右键拖动仍用于缩放。按住 Ctrl 再按住左键拖动可围绕当前观察中心自由旋转,且旋转优先于顶点操作。按住中键可在当前视平面内平移,滚轮也可缩放。工具栏的“前、后、左、右、顶、底”按钮会切换到对应正交视角;自由旋转不会自动改变当前正交或透视模式。
-4. 下方“XML 源码”保留中文注释、空白、属性顺序和未知节点;“运行日志”用于查看 GenCase、CPU/GPU 求解和 PartVTK 输出。
+4. 下方“XML 源码”保留中文注释、空白、属性顺序和未知节点;“运行中心”用于查看资源估算、阶段状态、历史记录以及 GenCase、CPU/GPU 求解和 PartVTK 日志。
5. “加载 3D 文件”仅接受 `.vtk` 和 `.stl`。STL 由 VTK 直接读取和显示,保留文件中的原坐标与尺度;外部 VTK/STL 都只作为查看层,不会转换或回写到当前 XML。读取前会检查文件头、文件大小以及可预读的点/单元数量:超过 512 MiB、500 万点/单元或约 2 GiB 保守内存估算时必须由用户单次确认;超过 4 GiB 或 2500 万点/单元时阻止加载。阈值是 Studio 的桌面渲染保护,不代表 STL/VTK 格式本身的限制。
实时 3D 表面只是轻量近似。GenCase 实际生成的粒子 VTK 才是后续计算的权威结果。
## 3. 编辑与保存
-- 1.0.1 的图形化回写范围是 `drawbox`、`drawprism` 及其 marker、`shapeout` 和无歧义的 floating 关联。
+- 1.0.2 的图形化回写范围是 `drawbox`、`drawprism` 及其 marker、`shapeout` 和无歧义的 floating 关联。
- “参数”页列出现有 `dp`、物理常量和 ``。值列通过当前无损源码补丁器修改,并进入统一撤销/重做历史;搜索和分类不会改变 XML。未知参数仍会显示和保留,但程序不会猜测其意义。源码存在未修正的语法错误或外部任务运行时,参数值暂时只读。
- “算例医生”检查 XML 结构、计算域方向、marker 范围与关联、重复或无效执行参数、`TimeMax/TimeOut` 关系、失效参数及 v5.4 弃用项。详情页和跳转功能只提供诊断、上下文与定位,不会自动修改源码;正式求解前仍建议执行一次 GenCase 快速检查。
- `drawbox` 无法原生保存“只移动一个角、其余七点不动”的形状。首次提交这种顶点修改时,Studio 会在原位置写入等价的 8 点 `drawprism`,并把原 `drawbox` 作为 XML 注释保留以便回溯;该转换与坐标修改属于同一次撤销操作。若对象关联 floating,`center` 会按变形前后的体积质心差同步更新。
@@ -61,6 +61,14 @@ DualSPHysics Studio-v1.0.1 (windows x64)\
## 5. 运行计算流程
+“一键完整运行”会先显示资源粗估和强确认窗口。用户必须明确选择 CPU 或 GPU,并勾选“已检查 XML 和比对数据”;确认后程序不再逐阶段询问,固定依次执行:
+
+```text
+GenCase → CPU 或 GPU → PartVTK
+```
+
+因此一键流程始终生成最终粒子 VTK。任何前序阶段失败或被取消时,后续阶段会标记为“已跳过”,不会继续启动,也不会静默切换 CPU/GPU。
+
“运行”菜单提供五个手动阶段:
- **快速检查**:在本地缓存中运行轻量 GenCase,优先加载 `MkCells` VTK,适合检查 marker、定义域和几何关系。
@@ -81,13 +89,27 @@ Case_Def.xml
→ PartVTK + vtk/PartAll_*.vtk
```
-GenCase 默认输出为 `Case_Def.xml → Case_out/Case`。若初始模型目标已有内容,程序建议使用带时间戳的新目录;在本次打开期间继续运行时,CPU、GPU 和 PartVTK 会沿用该实际目录。重新打开既有案例后,程序从默认 `Case_out/Case` 继续。若发现旧求解数据或目标 VTK,会阻止运行并要求用户自行确认和处理,不会自动清理或覆盖。运行期间可查看标准输出/错误并取消。
+GenCase 默认输出为 `Case_Def.xml → Case_out/Case`。若初始模型目标已有内容,程序建议使用带时间戳的新目录;在本次打开期间继续运行时,CPU、GPU 和 PartVTK 会沿用该实际目录。重新打开既有案例后,程序从默认 `Case_out/Case` 继续。若发现旧求解数据或目标 VTK,会阻止运行并要求用户自行确认和处理,不会自动清理或覆盖。
+
+下方“运行中心”显示预检、GenCase、CPU/GPU 和 PartVTK 的阶段状态。运行前根据 `dp`、计算域和 `TimeMax/TimeOut` 显示定义域网格上限、预计帧数和宽范围资源估算;GenCase 完成后以实际粒子数修正,求解日志提供内存数据后再显示实测值。首次运行没有可靠历史时,运行时间会显示“数据不足”;求解产生可识别帧进度后才计算动态 ETA。
成功需要同时满足 GenCase 退出码为 0,并存在 `Case.bi4` 和 `Case.xml`。某个 `shapeout` 为空或对应 VTK 缺失只会显示提示,不会单独判定整次运行失败。
CPU 或 GPU 成功除退出码为 0 外,还要求运行日志给出正常结束标记,并存在 `data/Part_Head.ibi4` 和至少一个 `Part_????.bi4`。PartVTK 成功要求退出码为 0,且每个输入粒子帧编号都对应一个非空 `PartAll_????.vtk`。
-每个阶段都会记录 XML 的 SHA-256、工具版本、参数、时间、退出码和产物清单;源 XML 后续发生变化时,旧结果会标记为“已过期”。
+每个正式阶段都会记录 XML 的 SHA-256、工具版本、参数、时间、退出码和产物清单;源 XML 后续发生变化时,旧结果会标记为“已过期”。无论使用一键流程还是手动阶段,实际输出目录都会包含:
+
+```text
+.dss-run\
+ run.json
+ source\Case_Def.xml
+ logs\*.stdout.log
+ logs\*.stderr.log
+```
+
+`run.json` 只使用相对路径,并引用同目录中的阶段 manifest。程序启动阶段前即原子写入记录,异常退出后再次载入会标记为“已中断”。运行中心可打开输出目录、选择案例历史记录、从记录创建使用新输出目录的运行,或导出带 SHA-256 校验清单的诊断 ZIP。
+
+诊断 ZIP 默认包含 XML 快照。若案例内容有隐私要求,在导出窗口勾选“不包含案例 XML”;本机绝对路径无论是否包含 XML 都会替换为占位符。
## 6. 拓展功能
@@ -119,11 +141,11 @@ API 密钥通过 Windows Credential Manager 保存,不写入配置文件、运
### 提示版本不兼容
-DualSPHysics Studio 1.0.1 只保证 v5.4。其他版本可只读打开,但外部工具执行会被阻止或显示兼容性警告。
+DualSPHysics Studio 1.0.2 只保证 v5.4。其他版本可只读打开,但外部工具执行会被阻止或显示兼容性警告。
### 中文或带空格路径运行失败
-程序本身支持中文和空格路径。仍失败时,请确认案例目录可写、路径不过长,并检查“运行日志”中的原始信息。
+程序本身支持中文和空格路径。仍失败时,请确认案例目录可写、路径不过长,并检查“运行中心 → 实时日志”中的原始信息。
### 目标目录无权限或已有结果
@@ -139,7 +161,7 @@ GenCase 预览请查看产物清单中是否有 `MkCells`、`All`、`Bound` 或
## 9. 版本范围
-DualSPHysics Studio 1.0.1 执行 GenCase 初始模型生成、DualSPHysics CPU/GPU 求解和 PartVTK 转换。CPU 与 GPU 均调用用户已有的 v5.4 官方可执行文件,便携包不包含这些二进制。
+DualSPHysics Studio 1.0.2 执行 GenCase 初始模型生成、DualSPHysics CPU/GPU 求解和 PartVTK 转换。CPU 与 GPU 均调用用户已有的 v5.4 官方可执行文件,便携包不包含这些二进制。
DualSPHysics Studio 是独立前端,不是 DualSPHysics 官方组件。第三方软件说明与完整许可原文集中在便携目录根部的 `THIRD_PARTY_LICENSES.txt`;`_internal` 不保留 Python 安装元数据。