Folds three previously-separate pieces into one preliminary-example repo for the HAHACS thesis: - thesis/ (submodule) → gitea Thesis.git — the PhD proposal - fret-pipeline/ — FRET requirements to AIGER controller (was ~/Documents/fret_processing/; prior single-commit history abandoned per user decision) - plant-model/ — 10-state PKE + lumped T/H PWR model (was ~/Documents/PKE_Playground/; never version-controlled before) - presentations/2026DICE/ (submodule) → gitea 2026DICE.git - reachability/, hardware/ — empty placeholders for Thrust 3 and HIL - docs/architecture.md — how the discrete and continuous layers compose - claude_memory/ — session notes and scratch knowledge pattern Plant model refactored to thesis naming (x, plant, u, ref); pke_th_rhs now takes u as an explicit arg instead of reading rho_ext from the params struct. First two controllers built to the contract u = ctrl_<mode>(t, x, plant, ref): ctrl_null (baseline) and ctrl_operation (stabilizing, proportional on T_avg). Validated under a 100% -> 80% Q_sg step: ctrl_operation reduces steady-state T_avg drift ~47% vs. the unforced plant. Root CLAUDE.md emphasizes that CLAUDE.md files are living documents and that any knowledge not captured before a session ends is lost forever; claude_memory/ holds the session-level notes that haven't stabilized enough to graduate into a CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
9.0 KiB
Python
259 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert FRET JSON export to ltlsynt synthesis config.
|
|
|
|
Automatically detects:
|
|
- Mode variables: any variable matching `control_* = q_*` in the LTL.
|
|
Each control group (e.g. control_mode) gets its own set of boolean
|
|
outputs (e.g. in_mode_shutdown, in_mode_heatup) with a mutual
|
|
exclusion constraint.
|
|
- Environment inputs: variables that appear only in conditions
|
|
(pre_condition / regular_condition) and are not mode variables.
|
|
- System outputs: the boolean mode propositions derived from control_*.
|
|
|
|
Naming convention in FRET:
|
|
- Mode variables MUST start with "control_" (e.g. control_mode, control_pump)
|
|
- Mode values MUST start with "q_" (e.g. q_shutdown, q_heatup)
|
|
- All other variables are classified by their FRET role.
|
|
|
|
Usage: python3 scripts/fret_to_synth.py <fret_export.json> [output.json]
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
|
|
def discover_modes(requirements):
|
|
"""Scan all LTL formulas for (control_X = q_Y) patterns.
|
|
|
|
Returns a dict: { "control_X": ["q_Y", "q_Z", ...] }
|
|
Each key is a mode group, values are the discovered mode values.
|
|
"""
|
|
# Match patterns like (control_mode = q_shutdown), (control_pump = q_off)
|
|
pattern = re.compile(r'\((\s*control_\w+)\s*=\s*(q_\w+)\s*\)')
|
|
|
|
mode_groups = defaultdict(set)
|
|
for req in requirements:
|
|
sem = req["semantics"]
|
|
for field in ("ftInfAUExpanded", "ft", "ftExpanded"):
|
|
ltl = sem.get(field, "")
|
|
if ltl:
|
|
for match in pattern.finditer(ltl):
|
|
ctrl_var = match.group(1).strip()
|
|
mode_val = match.group(2).strip()
|
|
mode_groups[ctrl_var].add(mode_val)
|
|
|
|
# Sort mode values for deterministic output
|
|
return {k: sorted(v) for k, v in sorted(mode_groups.items())}
|
|
|
|
|
|
def build_mode_map(mode_groups):
|
|
"""Build the string replacement map and output variable list.
|
|
|
|
For a single control group like control_mode with values
|
|
[q_heatup, q_operation, q_scram, q_shutdown], produces:
|
|
- replacements: {"(control_mode = q_heatup)": "in_mode_heatup", ...}
|
|
- outputs: ["in_mode_heatup", "in_mode_operation", ...]
|
|
|
|
For multiple groups, the boolean name includes the group suffix:
|
|
control_mode -> in_mode_X
|
|
control_pump -> in_pump_X
|
|
"""
|
|
replacements = {}
|
|
outputs_by_group = {}
|
|
|
|
for ctrl_var, mode_vals in mode_groups.items():
|
|
# Derive group suffix: control_mode -> mode, control_pump -> pump
|
|
suffix = ctrl_var.replace("control_", "", 1)
|
|
group_outputs = []
|
|
|
|
for qval in mode_vals:
|
|
# q_shutdown -> shutdown
|
|
val_name = qval.replace("q_", "", 1)
|
|
bool_name = f"in_{suffix}_{val_name}"
|
|
replacements[f"({ctrl_var} = {qval})"] = bool_name
|
|
group_outputs.append(bool_name)
|
|
|
|
outputs_by_group[ctrl_var] = group_outputs
|
|
|
|
return replacements, outputs_by_group
|
|
|
|
|
|
def classify_variables(requirements, mode_groups):
|
|
"""Classify non-mode variables as environment inputs.
|
|
|
|
A variable is an environment input if:
|
|
- It is NOT a control_* or q_* variable (those are handled by mode encoding)
|
|
- It appears in any requirement's semantics.variables
|
|
|
|
All mode propositions are system outputs (handled separately).
|
|
"""
|
|
# Collect all known mode-related variable names to exclude
|
|
mode_var_names = set()
|
|
for ctrl_var, mode_vals in mode_groups.items():
|
|
mode_var_names.add(ctrl_var)
|
|
mode_var_names.update(mode_vals)
|
|
|
|
# Collect all variables referenced across requirements
|
|
all_vars = set()
|
|
for req in requirements:
|
|
sem = req["semantics"]
|
|
for v in sem.get("variables", []):
|
|
all_vars.add(v)
|
|
|
|
# Everything that's not a mode variable is an environment input
|
|
env_inputs = sorted(all_vars - mode_var_names)
|
|
return env_inputs
|
|
|
|
|
|
def encode_ltl(formula, replacements):
|
|
"""Replace all (control_X = q_Y) with boolean mode propositions."""
|
|
result = formula
|
|
for pattern, replacement in replacements.items():
|
|
result = result.replace(pattern, replacement)
|
|
|
|
# Sanity check: no control_ or q_ references should remain
|
|
leftover = re.findall(r'control_\w+|q_\w+', result)
|
|
if leftover:
|
|
raise ValueError(
|
|
f"Unhandled mode references in LTL: {leftover}\n"
|
|
f" Formula: {result}\n"
|
|
f" Known replacements: {list(replacements.keys())}"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def mutual_exclusion_ltl(group_outputs):
|
|
"""Generate LTL for exactly-one-active constraint over a mode group.
|
|
|
|
Produces: G( (a & !b & !c & !d) | (!a & b & !c & !d) | ... )
|
|
"""
|
|
clauses = []
|
|
for i, m in enumerate(group_outputs):
|
|
parts = []
|
|
for j, n in enumerate(group_outputs):
|
|
if i == j:
|
|
parts.append(n)
|
|
else:
|
|
parts.append(f"(! {n})")
|
|
clauses.append(f"({' & '.join(parts)})")
|
|
exactly_one = " | ".join(clauses)
|
|
return f"G ({exactly_one})"
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print(f"Usage: {sys.argv[0]} <fret_export.json> [output.json]", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
input_path = Path(sys.argv[1])
|
|
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("specs/synthesis_config_v3.json")
|
|
|
|
with open(input_path) as f:
|
|
fret_data = json.load(f)
|
|
|
|
requirements = fret_data["requirements"]
|
|
project = requirements[0]["project"]
|
|
component = requirements[0]["semantics"]["component_name"]
|
|
|
|
# --- Auto-discover everything ---
|
|
mode_groups = discover_modes(requirements)
|
|
replacements, outputs_by_group = build_mode_map(mode_groups)
|
|
env_inputs = classify_variables(requirements, mode_groups)
|
|
all_outputs = [out for group in outputs_by_group.values() for out in group]
|
|
|
|
print(f"Discovered {len(mode_groups)} mode group(s):")
|
|
for ctrl_var, modes in mode_groups.items():
|
|
print(f" {ctrl_var}: {modes}")
|
|
print(f" -> booleans: {outputs_by_group[ctrl_var]}")
|
|
print(f"Environment inputs: {env_inputs}")
|
|
print(f"System outputs: {all_outputs}")
|
|
print()
|
|
|
|
# --- Encode requirements ---
|
|
encoded_reqs = []
|
|
for req in requirements:
|
|
reqid = req["reqid"]
|
|
fulltext = req["fulltext"]
|
|
sem = req["semantics"]
|
|
|
|
# Prefer ftInfAUExpanded (cleanest infinite-horizon LTL)
|
|
raw_ltl = sem.get("ftInfAUExpanded") or sem.get("ft")
|
|
if not raw_ltl:
|
|
print(f"WARNING: No LTL for {reqid}, skipping", file=sys.stderr)
|
|
continue
|
|
|
|
encoded = encode_ltl(raw_ltl, replacements)
|
|
|
|
encoded_reqs.append({
|
|
"req_id": reqid,
|
|
"fulltext": fulltext,
|
|
"project": project,
|
|
"component": component,
|
|
"ltl": encoded,
|
|
"ltl_original": raw_ltl,
|
|
"condition_type": sem.get("condition", "unknown"),
|
|
})
|
|
|
|
# --- Build structural constraints (one mutex per mode group) ---
|
|
structural = []
|
|
for ctrl_var, group_outs in outputs_by_group.items():
|
|
mutex = mutual_exclusion_ltl(group_outs)
|
|
structural.append({
|
|
"name": f"mutex_{ctrl_var}",
|
|
"description": f"Exactly one {ctrl_var} value active at all times",
|
|
"ltl": mutex,
|
|
})
|
|
|
|
# --- Conjoin all formulas ---
|
|
all_formulas = [r["ltl"] for r in encoded_reqs]
|
|
all_formulas += [s["ltl"] for s in structural]
|
|
conjoined = " & ".join(f"({f})" for f in all_formulas)
|
|
|
|
# --- Write config ---
|
|
config = {
|
|
"_comment": (
|
|
f"Generated from {input_path.name} by fret_to_synth.py. "
|
|
f"Boolean mode encoding with auto-discovered mode groups. "
|
|
f"Mutual exclusion constraints are synthesis artifacts — they enforce "
|
|
f"the single-valued semantics of control_* variables after decomposition "
|
|
f"into independent booleans. They are NOT needed in FRET itself, where "
|
|
f"control_mode = q_X is inherently single-valued."
|
|
),
|
|
"spec_name": f"{project}_{component}",
|
|
"source_file": str(input_path),
|
|
"mode_groups": {
|
|
ctrl_var: {
|
|
"values": modes,
|
|
"booleans": outputs_by_group[ctrl_var],
|
|
}
|
|
for ctrl_var, modes in mode_groups.items()
|
|
},
|
|
"inputs": env_inputs,
|
|
"outputs": all_outputs,
|
|
"requirements": encoded_reqs,
|
|
"structural_constraints": structural,
|
|
"conjoined_ltl": conjoined,
|
|
}
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(output_path, "w") as f:
|
|
json.dump(config, f, indent=2)
|
|
|
|
print(f"Synthesis config written to: {output_path}")
|
|
print(f" Requirements: {len(encoded_reqs)}")
|
|
print(f" Structural constraints: {len(structural)}")
|
|
print(f" Conjoined formula length: {len(conjoined)} chars")
|
|
|
|
print("\nEncoded requirements:")
|
|
for r in encoded_reqs:
|
|
print(f" {r['req_id']} [{r['condition_type']}]:")
|
|
print(f" {r['ltl'][:120]}{'...' if len(r['ltl']) > 120 else ''}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|