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