feat: named case-result load combinations with full GUI support
Snapshots the current development tree, headlined by proper load combinations (user request): a reusable LoadCombination entity of weighted completed static-case results (e.g. 1.2xDead + 1.6xLive). - core: LoadCombination/LoadCombinationItem entities, Project integration (lookup, unique ids, reference validation) - services: combinations.py (linear superposition + envelope), exported via services __init__ - commands: undoable Add/Delete/Update for combinations - GUI: Load Combinations manager dialog, Run-dialog evaluation, envelope display in Results panel, Combinations tab in Table dock - tests: unit coverage (validation, math, error paths) + integration superposition check vs a single factored run
This commit is contained in:
commit
f361fee969
560 changed files with 178701 additions and 0 deletions
331
docs/adr/ADR-0001-gidopensees-schema-import.md
Normal file
331
docs/adr/ADR-0001-gidopensees-schema-import.md
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
# ADR-0001 — Import gidopensees Schemas into OTKO
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-05-22 |
|
||||
| **Author** | ogunc |
|
||||
| **Deciders** | Core maintainers |
|
||||
| **Source project** | [gidopensees](https://github.com/rclab-auth/gidopensees) — AUTh Lab of R/C and Masonry Structures |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### Why gidopensees?
|
||||
|
||||
gidopensees is the most complete published schema inventory for the OpenSees
|
||||
material/element/condition set. It covers 60+ material types, 30+ element
|
||||
types, and 31 boundary-condition and load types — all expressed as GiD
|
||||
preprocessor `.mat` / `.cnd` BOOK definitions, with DEPENDENCIES (field
|
||||
visibility rules), `#UNITS#` annotations, and TKWIDGET hooks for
|
||||
auto-fill presets and Wiki links.
|
||||
|
||||
OTKO currently supports ~34 of these objects (see
|
||||
`docs/gap-analysis-gidopensees.md`). Importing gidopensees schemas would
|
||||
close 23 P1 gaps (Phase 8 targets) and 21 P2 gaps without requiring us to
|
||||
reverse-engineer OpenSeesPy docs for each type.
|
||||
|
||||
### What we are NOT doing
|
||||
|
||||
This ADR covers **schema definitions only** — Pydantic model fields, field
|
||||
metadata, and default values. It does not cover:
|
||||
|
||||
- The `bas/` Python code-generation templates (separate ADR).
|
||||
- The `tcl/` TKWIDGET Tcl implementations (separate ADR).
|
||||
- Any runtime OpenSeesPy command emission — that lives in
|
||||
`services/opensees_runner.py` and follows naturally once a schema
|
||||
is accepted.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision
|
||||
|
||||
### 2.1 New package: `core/catalog/`
|
||||
|
||||
A new package `src/otko/core/catalog/` will hold
|
||||
gidopensees-derived schema definitions alongside curated additions.
|
||||
|
||||
```
|
||||
src/otko/core/catalog/
|
||||
├── __init__.py # re-exports curated + generated public symbols
|
||||
├── generated/ # output of the codegen tool — DO NOT edit by hand
|
||||
│ ├── _header.py # shared attribution header (inserted by codegen)
|
||||
│ ├── steel.py
|
||||
│ ├── concrete.py
|
||||
│ ├── other_uniaxial.py
|
||||
│ ├── nd_materials.py
|
||||
│ ├── sections.py
|
||||
│ └── elements.py
|
||||
└── curated/ # human-reviewed, hand-edited overrides and additions
|
||||
├── README.md # explains the curated/ contract
|
||||
└── …
|
||||
```
|
||||
|
||||
**Existing modules are untouched.** The objects in `core/materials/`,
|
||||
`core/sections/`, `core/geometry/elements.py` remain authoritative for
|
||||
every type already supported. Catalog objects enter the UI only after
|
||||
equivalence is established (see §2.7).
|
||||
|
||||
### 2.2 Codegen tool: `tools/gidopensees_import/`
|
||||
|
||||
A build-time parser + code-generator lives in
|
||||
`tools/gidopensees_import/`, outside `src/`:
|
||||
|
||||
```
|
||||
tools/gidopensees_import/
|
||||
├── README.md
|
||||
├── parse_mat.py # parses OpenSees.mat into an intermediate IR
|
||||
├── parse_cnd.py # parses OpenSees.cnd into an intermediate IR
|
||||
├── codegen.py # renders IR → Pydantic v2 model source files
|
||||
├── ir.py # intermediate representation dataclasses
|
||||
└── tests/ # unit tests for the parser and codegen
|
||||
```
|
||||
|
||||
The tool runs once per gidopensees update. Its output (`generated/`) is
|
||||
committed so CI never requires gidopensees to be present. The tool is
|
||||
invoked manually by a maintainer:
|
||||
|
||||
```bash
|
||||
python tools/gidopensees_import/codegen.py \
|
||||
--mat path/to/OpenSees.mat \
|
||||
--cnd path/to/OpenSees.cnd \
|
||||
--out src/otko/core/catalog/generated/
|
||||
```
|
||||
|
||||
**Rationale:** keeping the parser outside `src/` prevents it from being
|
||||
imported at runtime, avoids adding GiD file parsing as a dependency of
|
||||
the installable package, and makes the "run once, commit output" contract
|
||||
explicit to contributors.
|
||||
|
||||
### 2.3 Attribution
|
||||
|
||||
Every file under `core/catalog/generated/` carries this header comment
|
||||
(inserted by `codegen.py`):
|
||||
|
||||
```python
|
||||
# This file is derived from gidopensees.
|
||||
# Source: https://github.com/rclab-auth/gidopensees
|
||||
# Authors: AUTh Lab of R/C and Masonry Structures
|
||||
# (https://rclab.civil.auth.gr/)
|
||||
# Modifications: generated by tools/gidopensees_import/codegen.py
|
||||
```
|
||||
|
||||
Files under `curated/` carry a similar header when they derive from
|
||||
gidopensees content.
|
||||
|
||||
### 2.4 `.osmodel` format backward-compatibility
|
||||
|
||||
The `.osmodel` JSON format uses Pydantic discriminated unions keyed on a
|
||||
`"type"` field. New material types from `core/catalog/` appear as new
|
||||
discriminator values. The union in `core/materials/__init__.py` is
|
||||
extended only when a catalog type is promoted to stable (see §2.7).
|
||||
|
||||
Old project files that do not contain the new discriminator values load
|
||||
cleanly: Pydantic ignores unknown items in lists when
|
||||
`model_config = ConfigDict(extra="ignore")`, and the `Project` validator
|
||||
will log (not raise) on unknown type strings if we add a graceful
|
||||
fallback.
|
||||
|
||||
**Migration path:**
|
||||
1. Catalog type is added with a temporary discriminator value
|
||||
(e.g. `"catalog.Concrete04"`).
|
||||
2. After verification (§2.7) it is promoted to a stable value
|
||||
(e.g. `"Concrete04"`) and the temporary value is kept as an alias for
|
||||
one minor version.
|
||||
3. A `migrate_osmodel.py` script in `tools/` handles the rename if needed.
|
||||
|
||||
No existing `.osmodel` file ever breaks on open.
|
||||
|
||||
### 2.5 Field metadata for TKWIDGET hooks
|
||||
|
||||
gidopensees BOOK definitions contain TKWIDGET directives that fire
|
||||
auto-fill presets (e.g. `SteelUniaxMaterial::GenerateValues`), Wiki links
|
||||
(`TK_MaterialWikiInfo`), and the Material Tester dialog
|
||||
(`TK_MaterialTester`). These are deferred from this ADR.
|
||||
|
||||
Each field or model where a TKWIDGET hook is relevant carries a
|
||||
`json_schema_extra` annotation recording the hook name:
|
||||
|
||||
```python
|
||||
class Concrete04(CatalogEntity):
|
||||
...
|
||||
class model_config(ConfigDict):
|
||||
json_schema_extra = {
|
||||
"tkwidget_hooks": [
|
||||
"ConcreteUniaxMaterial::GenerateValues",
|
||||
"TK_MaterialWikiInfo",
|
||||
"TK_MaterialTester",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This metadata is visible to future UI layers without coupling `core/` to
|
||||
Qt. Implementation of the actual preset dialogs and Wiki-link buttons
|
||||
comes in a later UI phase.
|
||||
|
||||
### 2.6 DEPENDENCIES → `dependent_schemas` metadata (not validators)
|
||||
|
||||
gidopensees BOOK DEPENDENCIES express field-visibility rules:
|
||||
|
||||
```
|
||||
(1, RESTORE, Gap_length, #CURRENT#), (0, HIDE, Gap_length, #CURRENT#)
|
||||
```
|
||||
|
||||
These are **viewmodel / UI concerns**, not data-validity rules, and
|
||||
therefore must not become Pydantic validators in `core/`.
|
||||
|
||||
Each Pydantic model that has visibility dependencies stores them as
|
||||
`json_schema_extra["dependencies"]` — a list of dicts describing the
|
||||
trigger field, trigger value, and affected fields. The viewmodel layer
|
||||
reads these at dialog-construction time to wire up the show/hide logic.
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
class ViscousDamper(CatalogEntity):
|
||||
activate_gap: Literal[0, 1] = 0
|
||||
gap_length: float | None = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"dependencies": [
|
||||
{"trigger": "activate_gap", "value": 1, "restore": ["gap_length"]},
|
||||
{"trigger": "activate_gap", "value": 0, "hide": ["gap_length"]},
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2.7 Unit-annotated fields (`#UNITS#`)
|
||||
|
||||
gidopensees marks numeric fields with `#UNITS#` where the value's
|
||||
interpretation is unit-system–dependent (forces in kN, lengths in m, etc.).
|
||||
|
||||
OTKO already has `core/units.py` with `UnitSystem` and
|
||||
`UnitLabels`. It does **not** currently attach unit metadata to individual
|
||||
model fields — the unit system is a project-level property and all numeric
|
||||
values are stored in the project's native unit system, with `UnitLabels`
|
||||
used only for display.
|
||||
|
||||
**Intended design:** Generated catalog models will mark unit-annotated fields
|
||||
using a `Field` `metadata` entry (Pydantic v2 `Annotated` style):
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
from pydantic import Field
|
||||
|
||||
class UnitTag:
|
||||
"""Marker for fields whose display label depends on UnitSystem."""
|
||||
def __init__(self, quantity: str):
|
||||
self.quantity = quantity # e.g. "force", "length", "stress"
|
||||
|
||||
ForceMagnitude = Annotated[float, UnitTag("force")]
|
||||
LengthValue = Annotated[float, UnitTag("length")]
|
||||
StressValue = Annotated[float, UnitTag("stress")]
|
||||
```
|
||||
|
||||
These type aliases would live in `core/catalog/_units.py`. No conversion logic
|
||||
is added to `core/`; the viewmodel layer reads `UnitTag.quantity` to
|
||||
select the right `UnitLabels` field for axis labels and input hints.
|
||||
|
||||
**Implementation status (2026-05-22) — DEFERRED:** The codegen tool does not
|
||||
yet emit `UnitTag` annotations. Fields corresponding to `#UNITS#` entries in
|
||||
the gidopensees source are currently emitted as `str` with a
|
||||
`# TODO: unit-aware type` comment preserving the gidopensees default string
|
||||
(e.g. `yield_stress_fy: str = '500 MPa' # TODO: unit-aware type`). This is
|
||||
a conscious deferral: the `str` placeholder keeps the field present and
|
||||
round-trippable without binding the codebase to a unit-system convention that
|
||||
is not yet finalised. A dedicated unit-system layer — covering `UnitTag`,
|
||||
`_units.py`, and viewmodel wiring — is tracked as future work and will be
|
||||
addressed in a follow-up ADR before any `#UNITS#` field is promoted to stable.
|
||||
|
||||
**If this convention is inadequate** (e.g. if we need per-field unit
|
||||
conversion in the future), a follow-up ADR should address it before
|
||||
the convention is applied beyond `catalog/`.
|
||||
|
||||
### 2.8 Verification plan
|
||||
|
||||
A catalog type is promoted from generated → stable only when **all three**
|
||||
of the following are satisfied:
|
||||
|
||||
1. **Schema equivalence test** (`tests/unit/catalog/test_<name>_schema.py`):
|
||||
Constructs a model instance with the same arguments as the
|
||||
corresponding Tcl example from the OpenSees Wiki and asserts that
|
||||
`model.model_dump()` produces the expected dict. No OpenSeesPy import.
|
||||
|
||||
2. **Round-trip test** (`tests/unit/catalog/test_<name>_roundtrip.py`):
|
||||
Serialises the model to JSON (`.osmodel` fragment), deserialises it
|
||||
back, and asserts equality. Confirms the discriminator and all field
|
||||
aliases survive the round-trip.
|
||||
|
||||
3. **Integration smoke test** (`tests/integration/catalog/test_<name>.py`):
|
||||
Builds a minimal project using the new type, runs it through
|
||||
`OpenSeesRunner`, and checks that the runner does not raise and that at
|
||||
least one result quantity (reaction, displacement, or force) is finite.
|
||||
Tagged `@pytest.mark.slow` and skipped if `openseespy` is not installed.
|
||||
|
||||
Manual review checklist (for the PR that promotes a type):
|
||||
|
||||
- [ ] Attribution header present in the generated file.
|
||||
- [ ] `json_schema_extra["tkwidget_hooks"]` populated where applicable.
|
||||
- [ ] `json_schema_extra["dependencies"]` populated for every DEPENDENCY
|
||||
in the source BOOK.
|
||||
- [ ] `#UNITS#` fields use the correct `UnitTag` quantity string.
|
||||
- [ ] The type discriminator value does not collide with any existing type
|
||||
in `core/materials/__init__.py`, `core/sections/__init__.py`, or
|
||||
`core/geometry/elements.py`.
|
||||
- [ ] The integration smoke test result has been spot-checked against the
|
||||
gidopensees wiki reference or an independent OpenSees Tcl run.
|
||||
|
||||
---
|
||||
|
||||
## 3. Alternatives considered
|
||||
|
||||
### 3A: Extend existing `core/materials/__init__.py` directly
|
||||
|
||||
Rejected. The existing module is small and well-tested; adding 60+
|
||||
unverified types creates noise and makes equivalence tracking harder.
|
||||
A separate `catalog/` namespace keeps the boundary clear.
|
||||
|
||||
### 3B: Use gidopensees at runtime (import `.mat` on startup)
|
||||
|
||||
Rejected. The GiD BOOK format is proprietary and requires the GiD parser.
|
||||
Depending on gidopensees at runtime adds a third-party dependency to the
|
||||
installed package and makes offline / air-gapped installs harder. The
|
||||
codegen + committed-output approach keeps the package dependency-clean.
|
||||
|
||||
### 3C: Hand-write every new type without the codegen tool
|
||||
|
||||
Would work but loses the systematic relationship between gidopensees
|
||||
DEPENDENCIES / TKWIDGET metadata and the Pydantic model. The codegen
|
||||
pipeline preserves that metadata structurally so UI implementors can
|
||||
reference it rather than re-reading `.mat` files.
|
||||
|
||||
---
|
||||
|
||||
## 4. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Namespace collision between `catalog/` and existing `core/` types | Medium | Medium | Verify discriminator values before promotion; CI check added to the merge checklist |
|
||||
| gidopensees schema drift (upstream changes `.mat`) | Low | Medium | `generated/` is committed; only re-run codegen intentionally; diff the output and add a CHANGELOG entry |
|
||||
| Testing surface explosion (60+ new types × 3 test tiers) | High | Low | Only promoted types get full test coverage; generated-but-not-yet-promoted types have schema + round-trip tests only |
|
||||
| Attribution omission | Low | High | Codegen always inserts the header; a pre-commit hook (`grep -r "rclab-auth/gidopensees" core/catalog/generated/` must pass) enforces it |
|
||||
| `#UNITS#` convention inadequacy | Medium | Medium | Convention is isolated to `core/catalog/_units.py`; a follow-up ADR can replace it without touching existing `core/` code |
|
||||
|
||||
---
|
||||
|
||||
## 5. Out of scope for this ADR
|
||||
|
||||
- `bas/` Python template parsing and code generation (separate ADR).
|
||||
- `tcl/` TKWIDGET Tcl implementations (separate ADR).
|
||||
- Seismic isolator element schemas (`elastomericBearing*`,
|
||||
`frictionPendulumBearing`, etc.) — those are new elements, not directly
|
||||
in the gidopensees BOOK format; they get their own ADR.
|
||||
- IDA batch runner (analysis feature, not schema).
|
||||
- Fiber-section visual editor UI polish (UI feature, not schema).
|
||||
- The OpenSeesPy command emission side — `services/opensees_runner.py`
|
||||
will need updates for each promoted type, but those changes follow
|
||||
naturally from the schema and are reviewed in the same PR as the
|
||||
integration smoke test.
|
||||
91
docs/adr/ADR-0002-headless-gui-dep-split.md
Normal file
91
docs/adr/ADR-0002-headless-gui-dep-split.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# ADR-0002 — Split pyproject dependencies into headless base and `gui` optional extra
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-06-15 |
|
||||
| **Author** | ogunc |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
`otko.core` is pure Pydantic v2 (no Qt, no OpenSeesPy imports — stated
|
||||
explicitly in `core/__init__.py`). `otko.services` adds NumPy, h5py, and
|
||||
OpenSeesPy for headless computation. Together, these two packages can be used from
|
||||
scripts, Jupyter notebooks, and web backends **without any Qt or 3D-rendering stack**.
|
||||
|
||||
Before this ADR, every `pip install otko` pulled in PySide6, pyvista,
|
||||
pyvistaqt, vtk, pyqtgraph, and imageio — roughly 800 MB of GUI/visualization
|
||||
packages — even when only the headless computation layer was needed. Web backends and
|
||||
CI machines without a display had to work around this with `--no-deps`, which is
|
||||
fragile and skips genuine compute-layer deps (numpy, h5py) too.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Split `[project.dependencies]` into two tiers in `pyproject.toml`:
|
||||
|
||||
### Headless base (`pip install -e .`)
|
||||
|
||||
Packages imported by `core/` and the non-Qt parts of `services/`:
|
||||
|
||||
| Package | Where used |
|
||||
|---------|-----------|
|
||||
| `pydantic>=2.5` | All `core/` modules, `material_tester.py` |
|
||||
| `numpy>=1.26` | `services/` computation modules (7 files) |
|
||||
| `h5py>=3.10` | `opensees_runner._run_transient()`, `TransientResults` accessors |
|
||||
| `openseespy==3.8.0.0` | Lazy import in `OpenSeesRunner.__init__` |
|
||||
| `openseespywin==3.8.0.0 ; sys_platform=='win32'` | Windows DLL companion |
|
||||
|
||||
### GUI extra (`pip install -e ".[gui]"`)
|
||||
|
||||
Packages only needed by `views/`, `viewmodels/`, `commands/`, `qt_workers.py`,
|
||||
and `animation_export.py` (which drives a live PyVista plotter):
|
||||
|
||||
`PySide6`, `pyvista`, `pyvistaqt`, `vtk`, `pyqtgraph`, `imageio[ffmpeg]`,
|
||||
`scipy` (forward-compat, currently a phantom dep), `pandas` (same).
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
- **Desktop developers** install with `pip install -e ".[gui,dev]"`. No change to
|
||||
what gets installed; only the install command changes from `.[dev]` → `.[gui,dev]`.
|
||||
- **Web backends / scripts / notebooks** install with `pip install -e .` (or
|
||||
`pip install otko`) and get a lean ~50 MB environment.
|
||||
- **otko-web** can drop the `--no-deps` workaround and install the
|
||||
package normally. The web backend's `requirements.txt` no longer needs to list
|
||||
pydantic/numpy/h5py separately — they come from the base install.
|
||||
- **scipy and pandas** are listed under `[gui]` as phantom deps (currently never
|
||||
imported anywhere in the codebase). They are kept to avoid surprise breakage if a
|
||||
future feature adds them; audited and flagged on 2026-06-15.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
After installing only the base set:
|
||||
|
||||
```python
|
||||
import sys
|
||||
from otko.core import Project
|
||||
from otko.services.opensees_runner import OpenSeesRunner
|
||||
from otko.services.material_tester import test_uniaxial_material
|
||||
|
||||
# Run a modal analysis on the bundled two-storey shear frame example
|
||||
import json
|
||||
from pathlib import Path
|
||||
from otko.core import ModalCase
|
||||
|
||||
data = json.loads(Path("examples/eigen_two_storey_shear_frame.osmodel").read_text())
|
||||
project = Project.model_validate(data)
|
||||
modal_case = next(c for c in project.analyses if isinstance(c, ModalCase))
|
||||
runner = OpenSeesRunner(project)
|
||||
runner.build()
|
||||
results = runner._run_modal(modal_case)
|
||||
|
||||
assert len(results.eigenvalues) == 2
|
||||
assert all(ev > 0 for ev in results.eigenvalues)
|
||||
|
||||
gui_packages = {"PySide6", "pyvista", "pyvistaqt", "vtk", "pyqtgraph"}
|
||||
assert not gui_packages.intersection(sys.modules), \
|
||||
f"GUI package imported: {gui_packages & sys.modules.keys()}"
|
||||
|
||||
print("PASS — headless modal analysis complete, no GUI packages imported")
|
||||
```
|
||||
Loading…
Reference in a new issue