ics-simlab-config-gen-claude/tools/build_config.py

247 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
Build and validate ICS-SimLab configuration.
This is the config pipeline entrypoint that:
1. Loads raw JSON
2. Validates/normalizes with Pydantic v2 (type coercion)
3. Writes configuration_normalized.json
4. Enriches with monitors/controllers (calls existing enrich_config)
5. Re-validates enriched config
6. Runs semantic validation
7. Writes configuration_enriched.json (source of truth)
Usage:
python3 -m tools.build_config \\
--config examples/water_tank/configuration.json \\
--out-dir outputs/test_config \\
--overwrite
# Strict mode (no type coercion, fail on type mismatch):
python3 -m tools.build_config \\
--config examples/water_tank/configuration.json \\
--out-dir outputs/test_config \\
--strict
"""
import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict
from models.ics_simlab_config_v2 import Config, set_strict_mode
from tools.enrich_config import enrich_plc_connections, enrich_hmi_connections
from tools.semantic_validation import validate_hmi_semantics, SemanticError
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(levelname)s: %(message)s"
)
logger = logging.getLogger(__name__)
def load_and_normalize(raw_path: Path) -> Config:
"""
Load JSON and validate with Pydantic, normalizing types.
Args:
raw_path: Path to configuration.json
Returns:
Validated Config object
Raises:
SystemExit: On validation failure
"""
raw_text = raw_path.read_text(encoding="utf-8")
try:
raw_data = json.loads(raw_text)
except json.JSONDecodeError as e:
raise SystemExit(f"ERROR: Invalid JSON in {raw_path}: {e}")
try:
return Config.model_validate(raw_data)
except Exception as e:
raise SystemExit(f"ERROR: Pydantic validation failed:\n{e}")
def config_to_dict(cfg: Config) -> Dict[str, Any]:
"""Convert Pydantic model to dict for JSON serialization."""
return cfg.model_dump(mode="json", exclude_none=False)
def main() -> None:
parser = argparse.ArgumentParser(
description="Build and validate ICS-SimLab configuration"
)
parser.add_argument(
"--config",
required=True,
help="Input configuration.json path"
)
parser.add_argument(
"--out-dir",
required=True,
help="Output directory for normalized and enriched configs"
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing output files"
)
parser.add_argument(
"--strict",
action="store_true",
help="Strict mode: disable type coercion, fail on type mismatch"
)
parser.add_argument(
"--skip-semantic",
action="store_true",
help="Skip semantic validation (for debugging)"
)
parser.add_argument(
"--json-errors",
action="store_true",
help="Output semantic errors as JSON to stdout (for programmatic use)"
)
args = parser.parse_args()
config_path = Path(args.config)
out_dir = Path(args.out_dir)
if not config_path.exists():
raise SystemExit(f"ERROR: Config file not found: {config_path}")
# Enable strict mode if requested
if args.strict:
set_strict_mode(True)
# Prepare output path (single file: configuration.json = enriched version)
output_path = out_dir / "configuration.json"
if output_path.exists() and not args.overwrite:
raise SystemExit(f"ERROR: Output file exists: {output_path} (use --overwrite)")
# Ensure output directory exists
out_dir.mkdir(parents=True, exist_ok=True)
# =========================================================================
# Step 1: Load and normalize with Pydantic
# =========================================================================
print("=" * 60)
print("Step 1: Loading and normalizing configuration")
print("=" * 60)
config = load_and_normalize(config_path)
print(f" Source: {config_path}")
print(f" PLCs: {len(config.plcs)}")
print(f" HILs: {len(config.hils)}")
print(f" Sensors: {len(config.sensors)}")
print(f" Actuators: {len(config.actuators)}")
print(f" HMIs: {len(config.hmis)}")
print(" Pydantic validation: OK")
# =========================================================================
# Step 2: Enrich configuration
# =========================================================================
print()
print("=" * 60)
print("Step 2: Enriching configuration")
print("=" * 60)
# Work with dict for enrichment (existing enrich_config expects dict)
config_dict = config_to_dict(config)
enriched_dict = enrich_plc_connections(dict(config_dict))
enriched_dict = enrich_hmi_connections(enriched_dict)
# Re-validate enriched config with Pydantic
print()
print(" Re-validating enriched config...")
try:
enriched_config = Config.model_validate(enriched_dict)
print(" Enriched config validation: OK")
except Exception as e:
raise SystemExit(f"ERROR: Enriched config failed Pydantic validation:\n{e}")
# =========================================================================
# Step 3: Semantic validation
# =========================================================================
if not args.skip_semantic:
print()
print("=" * 60)
print("Step 3: Semantic validation")
print("=" * 60)
errors = validate_hmi_semantics(enriched_config)
if errors:
if args.json_errors:
# Output errors as JSON for programmatic consumption
error_list = [{"entity": err.entity, "message": err.message} for err in errors]
print(json.dumps({"semantic_errors": error_list}, indent=2))
sys.exit(2) # Exit code 2 = semantic validation failure
else:
print()
print("SEMANTIC VALIDATION ERRORS:")
for err in errors:
print(f" - {err}")
print()
raise SystemExit(
f"ERROR: Semantic validation failed with {len(errors)} error(s). "
f"Fix the configuration and retry."
)
else:
print(" HMI monitors/controllers: OK")
else:
print()
print("=" * 60)
print("Step 3: Semantic validation (SKIPPED)")
print("=" * 60)
# =========================================================================
# Step 4: Write final configuration
# =========================================================================
print()
print("=" * 60)
print("Step 4: Writing configuration.json")
print("=" * 60)
final_dict = config_to_dict(enriched_config)
output_path.write_text(
json.dumps(final_dict, indent=2, ensure_ascii=False),
encoding="utf-8"
)
print(f" Written: {output_path}")
# =========================================================================
# Summary
# =========================================================================
print()
print("#" * 60)
print("# SUCCESS: Configuration built and validated")
print("#" * 60)
print()
print(f"Output: {output_path}")
print()
# Summarize enrichment
for plc in enriched_config.plcs:
n_conn = len(plc.outbound_connections)
n_mon = len(plc.monitors)
n_ctrl = len(plc.controllers)
print(f" {plc.name}: {n_conn} connections, {n_mon} monitors, {n_ctrl} controllers")
for hmi in enriched_config.hmis:
n_mon = len(hmi.monitors)
n_ctrl = len(hmi.controllers)
print(f" {hmi.name}: {n_mon} monitors, {n_ctrl} controllers")
if __name__ == "__main__":
main()