otko/docs/adr/ADR-0001-gidopensees-schema-import.md
smillmorel 612936a00b
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
feat: initial otko import
2026-09-08 02:12:15 -04:00

331 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.