228 lines
7.6 KiB
Python
228 lines
7.6 KiB
Python
"""
|
|
ControlPlan v0.1: Declarative HIL control specification.
|
|
|
|
This model defines a JSON-serializable spec that describes HIL behavior
|
|
using high-level tasks (loops, playback profiles) and actions (set, add, if).
|
|
The spec is compiled deterministically into Python HIL logic.
|
|
|
|
Design goals:
|
|
- LLM-friendly: structured output, no free-form Python
|
|
- Safe: expressions are parsed with AST, only safe operations allowed
|
|
- Expressive: loops, conditionals, profiles (Gaussian, ramp, etc.)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, List, Literal, Optional, Tuple, Union
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
|
|
# =============================================================================
|
|
# Action types
|
|
# =============================================================================
|
|
|
|
class SetAction(BaseModel):
|
|
"""Set a variable to an expression result: var = expr"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
set: Tuple[str, str] = Field(
|
|
description="[variable_name, expression_string]"
|
|
)
|
|
|
|
|
|
class AddAction(BaseModel):
|
|
"""Add expression result to a variable: var += expr"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
add: Tuple[str, str] = Field(
|
|
description="[variable_name, expression_string]"
|
|
)
|
|
|
|
|
|
class IfAction(BaseModel):
|
|
"""
|
|
Conditional action: if condition then actions [else actions].
|
|
|
|
The condition is an expression string that evaluates to a boolean.
|
|
"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
# Using "if_" to avoid Python keyword conflict, aliased to "if" in JSON
|
|
if_: str = Field(alias="if", description="Condition expression string")
|
|
then: List["Action"] = Field(description="Actions to execute if condition is true")
|
|
else_: Optional[List["Action"]] = Field(
|
|
default=None,
|
|
alias="else",
|
|
description="Actions to execute if condition is false"
|
|
)
|
|
|
|
|
|
# Union of all action types
|
|
Action = Union[SetAction, AddAction, IfAction]
|
|
|
|
# Enable forward reference resolution
|
|
IfAction.model_rebuild()
|
|
|
|
|
|
# =============================================================================
|
|
# Profile types (for playback tasks)
|
|
# =============================================================================
|
|
|
|
class GaussianProfile(BaseModel):
|
|
"""Gaussian noise profile for playback tasks."""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
kind: Literal["gaussian"] = "gaussian"
|
|
height: float = Field(description="Base/center value")
|
|
mean: float = Field(default=0.0, description="Mean of Gaussian noise")
|
|
std: float = Field(gt=0, description="Standard deviation of Gaussian noise")
|
|
entries: int = Field(gt=0, description="Number of entries in one cycle")
|
|
|
|
|
|
class RampProfile(BaseModel):
|
|
"""Linear ramp profile for playback tasks."""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
kind: Literal["ramp"] = "ramp"
|
|
start: float = Field(description="Start value")
|
|
end: float = Field(description="End value")
|
|
entries: int = Field(gt=0, description="Number of entries in one cycle")
|
|
|
|
|
|
class StepProfile(BaseModel):
|
|
"""Step function profile for playback tasks."""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
kind: Literal["step"] = "step"
|
|
values: List[float] = Field(min_length=1, description="Values to cycle through")
|
|
|
|
|
|
Profile = Union[GaussianProfile, RampProfile, StepProfile]
|
|
|
|
|
|
# =============================================================================
|
|
# Task types
|
|
# =============================================================================
|
|
|
|
class LoopTask(BaseModel):
|
|
"""
|
|
A loop task that executes actions repeatedly at a fixed interval.
|
|
|
|
This is the main mechanism for implementing control logic:
|
|
- Read inputs (from physical_values)
|
|
- Compute outputs (via expressions)
|
|
- Write outputs (via set/add actions)
|
|
"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
type: Literal["loop"] = "loop"
|
|
name: str = Field(description="Task name for debugging/logging")
|
|
dt_s: float = Field(gt=0, description="Loop interval in seconds")
|
|
actions: List[Action] = Field(description="Actions to execute each iteration")
|
|
|
|
|
|
class PlaybackTask(BaseModel):
|
|
"""
|
|
A playback task that outputs a profile (Gaussian, ramp, step) over time.
|
|
|
|
Use for generating test signals, disturbances, or time-varying setpoints.
|
|
"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
type: Literal["playback"] = "playback"
|
|
name: str = Field(description="Task name for debugging/logging")
|
|
dt_s: float = Field(gt=0, description="Interval between profile entries")
|
|
target: str = Field(description="Variable name to write profile values to")
|
|
profile: Profile = Field(description="Profile definition")
|
|
repeat: bool = Field(default=True, description="Whether to repeat the profile")
|
|
|
|
|
|
Task = Union[LoopTask, PlaybackTask]
|
|
|
|
|
|
# =============================================================================
|
|
# Top-level structures
|
|
# =============================================================================
|
|
|
|
class ControlPlanHIL(BaseModel):
|
|
"""
|
|
HIL control plan: defines initialization and tasks for one HIL.
|
|
|
|
Structure:
|
|
- name: must match hils[].name in configuration.json
|
|
- warmup_s: optional delay before starting tasks
|
|
- init: initial values for state variables
|
|
- params: optional constants for use in expressions
|
|
- tasks: list of loop/playback tasks (run in separate threads if >1)
|
|
"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
name: str = Field(description="HIL name (must match config)")
|
|
warmup_s: Optional[float] = Field(
|
|
default=None,
|
|
ge=0,
|
|
description="Warmup delay in seconds before starting tasks"
|
|
)
|
|
init: Dict[str, Union[float, int, bool]] = Field(
|
|
description="Initial values for physical_values keys"
|
|
)
|
|
params: Optional[Dict[str, Union[float, int, bool]]] = Field(
|
|
default=None,
|
|
description="Constants available in expressions"
|
|
)
|
|
tasks: List[Task] = Field(
|
|
min_length=1,
|
|
description="Tasks to run (loop or playback)"
|
|
)
|
|
|
|
@field_validator("init")
|
|
@classmethod
|
|
def validate_init_keys(cls, v: Dict[str, Union[float, int, bool]]) -> Dict[str, Union[float, int, bool]]:
|
|
"""Ensure init keys are valid Python identifiers."""
|
|
for key in v.keys():
|
|
if not key.isidentifier():
|
|
raise ValueError(f"Invalid init key (not a valid identifier): {key}")
|
|
return v
|
|
|
|
@field_validator("params")
|
|
@classmethod
|
|
def validate_params_keys(cls, v: Optional[Dict[str, Union[float, int, bool]]]) -> Optional[Dict[str, Union[float, int, bool]]]:
|
|
"""Ensure params keys are valid Python identifiers."""
|
|
if v is None:
|
|
return v
|
|
for key in v.keys():
|
|
if not key.isidentifier():
|
|
raise ValueError(f"Invalid params key (not a valid identifier): {key}")
|
|
return v
|
|
|
|
|
|
class ControlPlan(BaseModel):
|
|
"""
|
|
Top-level ControlPlan specification.
|
|
|
|
Usage:
|
|
control_plan.json -> compile_control_plan.py -> hil_*.py
|
|
|
|
The generated HIL logic follows the ICS-SimLab contract:
|
|
def logic(physical_values):
|
|
# init phase
|
|
# warmup sleep
|
|
# while True: run tasks
|
|
"""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
version: Literal["v0.1"] = Field(
|
|
default="v0.1",
|
|
description="Schema version"
|
|
)
|
|
hils: List[ControlPlanHIL] = Field(
|
|
min_length=1,
|
|
description="HIL control specifications"
|
|
)
|
|
|
|
|
|
def get_control_plan_json_schema() -> dict:
|
|
"""Return JSON Schema for ControlPlan, suitable for LLM structured output."""
|
|
return ControlPlan.model_json_schema()
|