apc-model-parser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apc_model_parser-0.1.0.dist-info/METADATA +111 -0
- apc_model_parser-0.1.0.dist-info/RECORD +18 -0
- apc_model_parser-0.1.0.dist-info/WHEEL +4 -0
- apc_model_parser-0.1.0.dist-info/entry_points.txt +2 -0
- model_parser/__init__.py +18 -0
- model_parser/backends/__init__.py +5 -0
- model_parser/backends/julia_mtk.py +159 -0
- model_parser/cli.py +195 -0
- model_parser/frontends/__init__.py +9 -0
- model_parser/frontends/expr_parser.py +183 -0
- model_parser/frontends/exprtk_ini.py +204 -0
- model_parser/io.py +48 -0
- model_parser/ir/__init__.py +55 -0
- model_parser/ir/expr.py +87 -0
- model_parser/ir/model.py +133 -0
- model_parser/schema.py +29 -0
- model_parser/validation/__init__.py +9 -0
- model_parser/validation/validators.py +195 -0
model_parser/ir/model.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Canonical IR data model.
|
|
2
|
+
|
|
3
|
+
The IR describes a model **scaffold** only: structure, declarations, and
|
|
4
|
+
equations. Concrete numeric parameter values are carried as *defaults* for
|
|
5
|
+
bootstrap convenience, but fitted parameter sets and execution scenarios
|
|
6
|
+
(initial values, input trajectories, horizons) are deliberately *out of scope*
|
|
7
|
+
here — they are sibling contracts (see ADR 0006 and the org contracts page).
|
|
8
|
+
|
|
9
|
+
The model is a Pydantic v2 model so that the JSON Schema and structural
|
|
10
|
+
validation come for free; semantic checks live in :mod:`model_parser.validation`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from model_parser.ir.expr import Expr
|
|
18
|
+
|
|
19
|
+
IR_VERSION = "0.1.0"
|
|
20
|
+
"""SemVer of the IR schema. Major bumps require migration tooling and an ADR."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Parameter(BaseModel):
|
|
24
|
+
"""A time-independent model parameter declaration."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
default: float | None = None
|
|
30
|
+
unit: str | None = None
|
|
31
|
+
description: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Variable(BaseModel):
|
|
35
|
+
"""A declared state, input, or output variable.
|
|
36
|
+
|
|
37
|
+
``roles`` carries optional tags such as ``measured``, ``manipulated``,
|
|
38
|
+
``disturbance``, or ``estimated``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
unit: str | None = None
|
|
45
|
+
description: str | None = None
|
|
46
|
+
roles: list[str] = Field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Local(BaseModel):
|
|
50
|
+
"""A named intermediate expression (an algebraic/observed equation)."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(extra="forbid")
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
expr: Expr
|
|
56
|
+
unit: str | None = None
|
|
57
|
+
description: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DiffEq(BaseModel):
|
|
61
|
+
"""A differential equation ``d(state)/dt = rhs``."""
|
|
62
|
+
|
|
63
|
+
model_config = ConfigDict(extra="forbid")
|
|
64
|
+
|
|
65
|
+
state: str
|
|
66
|
+
rhs: Expr
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OutputEq(BaseModel):
|
|
70
|
+
"""An output equation ``output = rhs``."""
|
|
71
|
+
|
|
72
|
+
model_config = ConfigDict(extra="forbid")
|
|
73
|
+
|
|
74
|
+
output: str
|
|
75
|
+
rhs: Expr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Equations(BaseModel):
|
|
79
|
+
"""The differential and output equations of the scaffold."""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="forbid")
|
|
82
|
+
|
|
83
|
+
differential: list[DiffEq] = Field(default_factory=list)
|
|
84
|
+
outputs: list[OutputEq] = Field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ModelInfo(BaseModel):
|
|
88
|
+
"""Identity and free-form metadata for the model."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(extra="forbid")
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
description: str | None = None
|
|
94
|
+
source_version: str | None = None
|
|
95
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Provenance(BaseModel):
|
|
99
|
+
"""Where this IR came from and which tool produced it."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(extra="forbid")
|
|
102
|
+
|
|
103
|
+
tool: str
|
|
104
|
+
created_at: str
|
|
105
|
+
source_format: str | None = None
|
|
106
|
+
source_file: str | None = None
|
|
107
|
+
content_hash: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class IRModel(BaseModel):
|
|
111
|
+
"""The canonical intermediate representation of a process-model scaffold."""
|
|
112
|
+
|
|
113
|
+
model_config = ConfigDict(extra="forbid")
|
|
114
|
+
|
|
115
|
+
ir_version: str = IR_VERSION
|
|
116
|
+
model: ModelInfo
|
|
117
|
+
independent_variable: str = "t"
|
|
118
|
+
parameters: list[Parameter] = Field(default_factory=list)
|
|
119
|
+
states: list[Variable] = Field(default_factory=list)
|
|
120
|
+
inputs: list[Variable] = Field(default_factory=list)
|
|
121
|
+
outputs: list[Variable] = Field(default_factory=list)
|
|
122
|
+
locals: list[Local] = Field(default_factory=list)
|
|
123
|
+
equations: Equations = Field(default_factory=Equations)
|
|
124
|
+
profiles: list[str] = Field(default_factory=lambda: ["julia-analysis"])
|
|
125
|
+
provenance: Provenance | None = None
|
|
126
|
+
|
|
127
|
+
def symbol_names(self) -> set[str]:
|
|
128
|
+
"""Return all declared symbol names (states, inputs, outputs, params, locals)."""
|
|
129
|
+
names: set[str] = set()
|
|
130
|
+
for group in (self.states, self.inputs, self.outputs, self.parameters):
|
|
131
|
+
names |= {item.name for item in group}
|
|
132
|
+
names |= {local.name for local in self.locals}
|
|
133
|
+
return names
|
model_parser/schema.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""JSON Schema export for the canonical IR.
|
|
2
|
+
|
|
3
|
+
The schema is generated from the Pydantic model so there is a single source of
|
|
4
|
+
truth for the IR shape. ``schemas/canonical-ir.schema.json`` is the committed,
|
|
5
|
+
versioned artifact that other repositories and languages validate against.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from model_parser.ir import IR_VERSION, IRModel
|
|
13
|
+
|
|
14
|
+
SCHEMA_ID = "https://advanced-process-control.github.io/model-parser/canonical-ir.schema.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ir_json_schema() -> dict:
|
|
18
|
+
"""Return the JSON Schema for the canonical IR as a dict."""
|
|
19
|
+
schema = IRModel.model_json_schema()
|
|
20
|
+
schema["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
|
21
|
+
schema["$id"] = SCHEMA_ID
|
|
22
|
+
schema["title"] = "Canonical Process-Model IR"
|
|
23
|
+
schema["x-ir-version"] = IR_VERSION
|
|
24
|
+
return schema
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dumps_schema() -> str:
|
|
28
|
+
"""Return the IR JSON Schema as a pretty JSON string."""
|
|
29
|
+
return json.dumps(ir_json_schema(), indent=2) + "\n"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Core and profile validators for the canonical IR.
|
|
2
|
+
|
|
3
|
+
Structural validation (types, required fields) is handled by Pydantic at load
|
|
4
|
+
time. This module adds *semantic* checks (do referenced symbols exist? are
|
|
5
|
+
dimensions consistent?) and *profile* checks (is this IR within a backend's
|
|
6
|
+
supported subset?). Validation answers "is this model acceptable?"; it is
|
|
7
|
+
distinct from the conformance suite, which answers "do backends agree?".
|
|
8
|
+
|
|
9
|
+
Profiles currently understood:
|
|
10
|
+
|
|
11
|
+
- ``julia-analysis`` — permissive; the full IR is allowed.
|
|
12
|
+
- ``realtime-cpp`` — restricted: only the deterministic operator/function subset
|
|
13
|
+
and no unsupported constructs (a first-cut placeholder for the PLC target).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from model_parser.ir import (
|
|
21
|
+
ALLOWED_OPS,
|
|
22
|
+
Call,
|
|
23
|
+
Expr,
|
|
24
|
+
IRModel,
|
|
25
|
+
free_symbols,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Operators/functions a real-time C++ target is willing to emit deterministically.
|
|
29
|
+
_REALTIME_CPP_OPS: frozenset[str] = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"+",
|
|
32
|
+
"-",
|
|
33
|
+
"*",
|
|
34
|
+
"/",
|
|
35
|
+
"^",
|
|
36
|
+
"neg",
|
|
37
|
+
"<",
|
|
38
|
+
">",
|
|
39
|
+
"<=",
|
|
40
|
+
">=",
|
|
41
|
+
"==",
|
|
42
|
+
"!=",
|
|
43
|
+
"max",
|
|
44
|
+
"min",
|
|
45
|
+
"sqrt",
|
|
46
|
+
"exp",
|
|
47
|
+
"log",
|
|
48
|
+
"abs",
|
|
49
|
+
"ifelse",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_KNOWN_PROFILES: frozenset[str] = frozenset({"julia-analysis", "realtime-cpp"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Diagnostic:
|
|
58
|
+
"""A single validation finding."""
|
|
59
|
+
|
|
60
|
+
level: str # "ERROR" | "WARN"
|
|
61
|
+
code: str
|
|
62
|
+
message: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ValidationReport:
|
|
67
|
+
"""The result of validating an IR: a list of diagnostics."""
|
|
68
|
+
|
|
69
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def errors(self) -> list[Diagnostic]:
|
|
73
|
+
return [d for d in self.diagnostics if d.level == "ERROR"]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def warnings(self) -> list[Diagnostic]:
|
|
77
|
+
return [d for d in self.diagnostics if d.level == "WARN"]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ok(self) -> bool:
|
|
81
|
+
return not self.errors
|
|
82
|
+
|
|
83
|
+
def _add(self, level: str, code: str, message: str) -> None:
|
|
84
|
+
self.diagnostics.append(Diagnostic(level=level, code=code, message=message))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _iter_ops(expr: Expr) -> list[str]:
|
|
88
|
+
if isinstance(expr, Call):
|
|
89
|
+
ops = [expr.op]
|
|
90
|
+
for arg in expr.args:
|
|
91
|
+
ops.extend(_iter_ops(arg))
|
|
92
|
+
return ops
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _all_expressions(ir: IRModel) -> list[tuple[str, Expr]]:
|
|
97
|
+
items: list[tuple[str, Expr]] = []
|
|
98
|
+
for local in ir.locals:
|
|
99
|
+
items.append((f"local {local.name}", local.expr))
|
|
100
|
+
for diff in ir.equations.differential:
|
|
101
|
+
items.append((f"d({diff.state})/dt", diff.rhs))
|
|
102
|
+
for out in ir.equations.outputs:
|
|
103
|
+
items.append((f"output {out.output}", out.rhs))
|
|
104
|
+
return items
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_ir(ir: IRModel, *, profile: str | None = None) -> ValidationReport:
|
|
108
|
+
"""Validate ``ir`` semantically and, optionally, against a backend ``profile``."""
|
|
109
|
+
report = ValidationReport()
|
|
110
|
+
declared = ir.symbol_names()
|
|
111
|
+
|
|
112
|
+
# Duplicate declarations.
|
|
113
|
+
seen: set[str] = set()
|
|
114
|
+
for group in (ir.states, ir.inputs, ir.outputs, ir.parameters, ir.locals):
|
|
115
|
+
for item in group:
|
|
116
|
+
if item.name in seen:
|
|
117
|
+
report._add(
|
|
118
|
+
"ERROR", "duplicate-symbol", f"symbol {item.name!r} is declared more than once"
|
|
119
|
+
)
|
|
120
|
+
seen.add(item.name)
|
|
121
|
+
|
|
122
|
+
# Referenced-but-undeclared symbols.
|
|
123
|
+
for label, expr in _all_expressions(ir):
|
|
124
|
+
for ref in sorted(free_symbols(expr)):
|
|
125
|
+
if ref not in declared:
|
|
126
|
+
report._add(
|
|
127
|
+
"ERROR", "undeclared-symbol", f"{label} references undeclared symbol {ref!r}"
|
|
128
|
+
)
|
|
129
|
+
for op in _iter_ops(expr):
|
|
130
|
+
if op not in ALLOWED_OPS:
|
|
131
|
+
report._add("ERROR", "unknown-op", f"{label} uses unknown operator/function {op!r}")
|
|
132
|
+
|
|
133
|
+
# Every state needs a differential equation; outputs need output equations.
|
|
134
|
+
diff_states = {d.state for d in ir.equations.differential}
|
|
135
|
+
for state in ir.states:
|
|
136
|
+
if state.name not in diff_states:
|
|
137
|
+
report._add(
|
|
138
|
+
"WARN",
|
|
139
|
+
"missing-state-equation",
|
|
140
|
+
f"state {state.name!r} has no differential equation",
|
|
141
|
+
)
|
|
142
|
+
out_defs = {o.output for o in ir.equations.outputs}
|
|
143
|
+
for out in ir.outputs:
|
|
144
|
+
if out.name not in out_defs:
|
|
145
|
+
report._add(
|
|
146
|
+
"WARN", "missing-output-equation", f"output {out.name!r} has no output equation"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Local ordering / cyclic dependency check (locals must be resolvable in order).
|
|
150
|
+
_check_local_ordering(ir, report)
|
|
151
|
+
|
|
152
|
+
if profile is not None:
|
|
153
|
+
_validate_profile(ir, profile, report)
|
|
154
|
+
|
|
155
|
+
return report
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_local_ordering(ir: IRModel, report: ValidationReport) -> None:
|
|
159
|
+
base = (
|
|
160
|
+
{v.name for v in ir.states} | {v.name for v in ir.inputs} | {p.name for p in ir.parameters}
|
|
161
|
+
)
|
|
162
|
+
available = set(base)
|
|
163
|
+
pending = {local.name for local in ir.locals}
|
|
164
|
+
for local in ir.locals:
|
|
165
|
+
deps = free_symbols(local.expr) & pending
|
|
166
|
+
unresolved = {d for d in deps if d not in available and d != local.name}
|
|
167
|
+
if unresolved:
|
|
168
|
+
report._add(
|
|
169
|
+
"WARN",
|
|
170
|
+
"local-ordering",
|
|
171
|
+
f"local {local.name!r} references later/cyclic locals "
|
|
172
|
+
f"{sorted(unresolved)}; backends may require topological ordering",
|
|
173
|
+
)
|
|
174
|
+
available.add(local.name)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _validate_profile(ir: IRModel, profile: str, report: ValidationReport) -> None:
|
|
178
|
+
if profile not in _KNOWN_PROFILES:
|
|
179
|
+
report._add(
|
|
180
|
+
"WARN",
|
|
181
|
+
"unknown-profile",
|
|
182
|
+
f"profile {profile!r} is not recognized; skipping profile checks",
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
if profile == "julia-analysis":
|
|
186
|
+
return # permissive
|
|
187
|
+
if profile == "realtime-cpp":
|
|
188
|
+
for label, expr in _all_expressions(ir):
|
|
189
|
+
for op in _iter_ops(expr):
|
|
190
|
+
if op not in _REALTIME_CPP_OPS:
|
|
191
|
+
report._add(
|
|
192
|
+
"ERROR",
|
|
193
|
+
"profile-realtime-cpp",
|
|
194
|
+
f"{label} uses {op!r}, not in the realtime-cpp subset",
|
|
195
|
+
)
|