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.
@@ -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,9 @@
1
+ """Validation: semantic and profile checks over a canonical IR."""
2
+
3
+ from model_parser.validation.validators import (
4
+ Diagnostic,
5
+ ValidationReport,
6
+ validate_ir,
7
+ )
8
+
9
+ __all__ = ["Diagnostic", "ValidationReport", "validate_ir"]
@@ -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
+ )