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 安装元数据。