scl_skeleton: Anlagen-JSON als Eingabe (Stufe 4, Namen)

--json erkennt <csv>_anlage.json am Schema. scl_context.HwSource setzt
Namen aus bindings (nur "zugeordnet") und conveyors ein: Separator-,
Lagerlinien-, Weichen- und Vario-Antriebsnamen, #fbCarousel<NNNN> am
Kreisel, Foerderernummer im DB_JamArea-Platzhalter (<k> bleibt offen).
Eingesetzte Namen werden gegen <projekt>_hw.json geprueft (--hw,
--strict bricht ab). --eplan bleibt fuer die TRO-JSON; Templates und
Golden-Files unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 10:53:52 +02:00
parent 7ec2b88b79
commit 39b445a7c4
6 changed files with 588 additions and 96 deletions
+6 -2
View File
@@ -227,8 +227,12 @@ byte — rewrite them (`SKEL_UPDATE_GOLDEN=1`) only for a reviewed, intended cha
TRO-ID + slot + role, `conveyors[]`). Reads no CSV: the TRO-JSON carries `parts` TRO-ID + slot + role, `conveyors[]`). Reads no CSV: the TRO-JSON carries `parts`
(positions/bounding boxes, written by `tro_flow --json`). Conveyors are matched by (positions/bounding boxes, written by `tro_flow --json`). Conveyors are matched by
majority vote of the devices naming them; corrections in `cfg/hw_overrides.ini`, majority vote of the devices naming them; corrections in `cfg/hw_overrides.ini`,
numbers in `cfg/parameter.ini [merge_hw_layout]`. `scl_skeleton` does not read the plant JSON numbers in `cfg/parameter.ini [merge_hw_layout]`. See `doc/Python_Scripts.md` §5e.
yet (stage 4). See `doc/Python_Scripts.md` §5e. 8. **`scl_skeleton.py --json <csv>_anlage.json`** (stage 4, names) — reads the plant JSON
(schema detected) and fills sensors, valves, drives and conveyor numbers from `bindings`/
`conveyors` via `scl_context.HwSource`; open/contradicting bindings stay `TODO(E-Planung)`;
inserted names are checked against `<projekt>_hw.json` (`--strict` aborts). Templates are
unchanged. See `doc/Python_Scripts.md` §5b.
What is **not** built out yet, relative to the original two-tool design: What is **not** built out yet, relative to the original two-tool design:
+7
View File
@@ -188,6 +188,13 @@ Weichenventile (`doc/E-Planung_Bruecke.md`, "Offen").
### 4. `scl_skeleton.py` / `scl_context.py` (Stufe 4) ### 4. `scl_skeleton.py` / `scl_context.py` (Stufe 4)
**Stand 2026-10-16, umgesetzt (Namen):** `scl_skeleton --json <csv>_anlage.json`
(Schema erkannt), Namen über `scl_context.HwSource` aus `bindings` (nur
`zugeordnet`) und `conveyors` (`#fbCarousel<NNNN>`, `DB_JamArea<NNNN>` mit
offenem `<k>`), Gegenprüfung gegen `<projekt>_hw.json` (`--strict` bricht ab).
`--eplan` bleibt für die TRO-JSON, bis `e_planning.py` entfällt. Offen: neue
Bausteine (unten), Stausensoren der Ausgänge, Scanner.
- Liest **eine** Eingabe: `<csv>_anlage.json`; ohne Hardwareteil wie heute - Liest **eine** Eingabe: `<csv>_anlage.json`; ohne Hardwareteil wie heute
`<csv>_tro.json`. `<csv>_tro.json`.
- `--eplan` und die Zusammenführung in `scl_context._separator` entfallen; der - `--eplan` und die Zusammenführung in `scl_context._separator` entfallen; der
+39 -10
View File
@@ -46,11 +46,13 @@ tro_flow.py ──► <name>_tro_flow.dot / .svg, _tro_doc.md, _tro_error
│ (assigns BG/MB of the E-planning drawing to the layout │ (assigns BG/MB of the E-planning drawing to the layout
│ parts by position + numbering rule; see §5c) │ parts by position + numbering rule; see §5c)
│ │
│ NEW PATH (stages 2/3, scl_skeleton does not read it yet): │ NEW PATH (stages 2-4):
│ <name>_tro.json ──┐ │ <name>_tro.json ──┐
│ ├─► merge_hw_layout.py ──► <name>_anlage.json + .md (§5e) │ ├─► merge_hw_layout.py ──► <name>_anlage.json + .md (§5e)
│ hw_import.py ─────┘ (TRO slots <- BMK, conveyors K0102 -> Kreisel3) │ hw_import.py ─────┘ (TRO slots <- BMK, conveyors K0102 -> Kreisel3)
│ ▲ *_TIA.xlsx + *_positionsconv.json (§5d) │ ▲ *_TIA.xlsx + *_positionsconv.json (§5d)
│ <name>_anlage.json ──► scl_skeleton.py --json ──► <name>_FB_Main.scl
│ (names from the bindings; replaces --eplan)
│ │
└── same derivation ──► tro_annotate.py ──► <name>_annotated.dxf (VIEWER ONLY) └── same derivation ──► tro_annotate.py ──► <name>_annotated.dxf (VIEWER ONLY)
(also needs the BricsCAD DXF; uses dxf_registration.py for the CSV↔DXF (also needs the BricsCAD DXF; uses dxf_registration.py for the CSV↔DXF
@@ -114,6 +116,7 @@ bin\tro_flow.bat --file mubea.csv --tosvg --use-cords --doc --json
bin\scl_skeleton.bat --json mubea_tro.json bin\scl_skeleton.bat --json mubea_tro.json
bin\e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json bin\e_planning.bat --file mubea.csv --eplan Mubea_positionsdraw.json
bin\scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json bin\scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json
bin\scl_skeleton.bat --json mubea_anlage.json --templates
bin\tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb --legend bin\tro_annotate.bat --file mubea.csv --dxf 500573_60_1.dxf --flow --fb --legend
``` ```
@@ -532,8 +535,10 @@ exits, how they divide them is a planning decision, so it stays a gap rather
than a guess. than a guess.
Switches: `--json NAME` (default `export_tro.json`, looked up in Switches: `--json NAME` (default `export_tro.json`, looked up in
`%SKEL_RESULTS%`; written by `tro_flow.py --json`), `--out NAME` (default `%SKEL_RESULTS%`; either the TRO-JSON from `tro_flow.py --json` or the plant
`<source>_FB_Main.scl`), `--eplan NAME` (see below), `--start TRO`. The last one matters while the plant has JSON from `merge_hw_layout.py`, told apart by its `schema`), `--out NAME`
(default `<source>_FB_Main.scl`, `_tro`/`_anlage` dropped from the source
name), `--eplan NAME` and `--hw NAME` (see below), `--start TRO`. The last one matters while the plant has
no loading/unloading station: the flow is then a closed loop with no entry, so no loading/unloading station: the flow is then a closed loop with no entry, so
the order has to be broken somewhere arbitrary. The chosen start and the reason the order has to be broken somewhere arbitrary. The chosen start and the reason
are recorded in the file header. are recorded in the file header.
@@ -551,8 +556,9 @@ shows up as a destination in the per-switch lists.
(`build_context` → `SclFile` / `SclTro`: flow order, separator/switch/scanner (`build_context` → `SclFile` / `SclTro`: flow order, separator/switch/scanner
blocks, jam entries/exits, destination lists, names), and `scl_skeleton.py` only blocks, jam entries/exits, destination lists, names), and `scl_skeleton.py` only
turns that context into text (`render_context`). Every name or address is an turns that context into text (`render_context`). Every name or address is an
`SclName` (value + `todo` flag); today all of them are placeholders with `SclName` (value + `todo` flag); without hardware planning all of them are
`todo=True`. This split prepares the move to parameterised templates and placeholders with `todo=True`. Real names come from `scl_context.HwSource`
(plant JSON or, phasing out, the eplan assignment). This split prepares the move to parameterised templates and
per-project naming rules described in [`SCL_Templates.md`](SCL_Templates.md). per-project naming rules described in [`SCL_Templates.md`](SCL_Templates.md).
**Templates (opt-in, `--templates [DIR]`).** With this switch every TRO whose FB **Templates (opt-in, `--templates [DIR]`).** With this switch every TRO whose FB
@@ -567,7 +573,30 @@ by a PLC engineer (`doc/SCL_Templates.md` §6). `2Sep1Swi`, `EmptyCarrBuffer`,
`LoadingBoom` and `2Sep2Swi` have no call site yet and stay generic. `--strict` stops (exit `1`, no SCL) if any FB type has no `LoadingBoom` and `2Sep2Swi` have no call site yet and stay generic. `--strict` stops (exit `1`, no SCL) if any FB type has no
template. Without `--templates` the output is unchanged. template. Without `--templates` the output is unchanged.
**Names from the E-planning (opt-in, `--eplan NAME`).** Reads the assignment **Names from the plant JSON (stage 4, `--json <csv>_anlage.json`).** The
layout is `anlage.layout`; names come from `bindings[]` by TRO-ID + slot + role
(`AnlageDocument.binding`), and only status `zugeordnet` counts — `offen` and
`widerspruch` stay `TODO(E-Planung)`. Sensors are written as the TIA constant
(`Binding.constant`, e.g. `cInBG0642`), outputs as the BMK (`MB0643`).
| Slot / role | SCL |
|---|---|
| `separator<n>` `sensor` / `stopper` / `partSensor` | `stSenInSep`, `xOutStopper<n>`, `stSenPart` |
| `line<n>` `sensor` / `stopper` / `jamSensor` | PinStore storage lines |
| `switch<n>` `valve` | `xOutSw<n>ExTo3` |
| `drive` `motor` / `motorProtection` / `sen*` | Vario motor, `xInMotorProtection`, `stSenCarrInPos` … (motor/protection fall back to the conveyor of the TRO's host) |
| `conveyors[]` on the TRO's host | `xInCarouselRun` = `#fbCarousel<NNNN>.xOutConveyorFullSpeed` (Kreisel only); jam DB `"DB_JamArea<NNNN>".stJam<NNNN>_____` — still `TODO`, the running number `<k>` is not fixed yet |
Not filled yet: jam sensors at the exits (`stInSenJam<n>`, the `Ausschleus.stau`
bindings have no slot), scanners, separator/switch/MainTro numbers. The
templates are unchanged — a known name simply drops its marker. Afterwards
every inserted name is checked against the hardware planning (`hwFile` of the
plant JSON in `%SKEL_RESULTS%`, or `--hw NAME`): names missing there are listed
as a warning, with `--strict` the run stops (exit `1`, no SCL). `--eplan`
together with a plant JSON is an error. Mubea: 46 names inserted, 47 fewer
`TODO`s (482 → 435), 0 unknown names. Tests: `tests/test_scl_anlage.py`.
**Names from the E-planning (phasing out, `--eplan NAME`, TRO-JSON only).** Reads the assignment
`<csv>_eplan.json` written by `e_planning.py` (§5c; bare name looked up in `<csv>_eplan.json` written by `e_planning.py` (§5c; bare name looked up in
`%SKEL_RESULTS%`). For every separator it assigns, the separator sensor `%SKEL_RESULTS%`). For every separator it assigns, the separator sensor
(`stSenInSep := "DB_Inputs".Sensors["cInBG0642"]`) and the stopper valve (`stSenInSep := "DB_Inputs".Sensors["cInBG0642"]`) and the stopper valve
@@ -790,9 +819,9 @@ K0105 = Kreisel1 (2/3)**, each with motor and motor protection. The other
conveyors (Aufgabestation #100/#101, Vario #106–#109) are outside the CSV or conveyors (Aufgabestation #100/#101, Vario #106–#109) are outside the CSV or
have a single vote and stay open. have a single vote and stay open.
**Not yet:** `scl_skeleton` still reads `<csv>_eplan.json` (`--eplan`); **Stage 4:** `scl_skeleton --json <csv>_anlage.json` reads the plant JSON
switching it to `<csv>_anlage.json` is stage 4 of the concept, and only then directly (§5b). `--eplan` still works for a TRO-JSON and is phased out together
`e_planning.py` becomes the thin wrapper and is removed. with `e_planning.py`.
Tests: `tests/test_merge_hw_layout.py` (sensor spatial / stopper by number, Tests: `tests/test_merge_hw_layout.py` (sensor spatial / stopper by number,
contradiction, open roles, part without slot, conveyor majority with contradiction, open roles, part without slot, conveyor majority with
@@ -855,7 +884,7 @@ tro_flow.py (writes) · scl_skeleton.py (reads) · tro_annotate.py (draws)
| `LayoutPart` | A layout part with position (`layoutId`, `kind`, `name`, `position`, `bbox`) — the target of the spatial assignment in `merge_hw_layout.py`, so that it never reads the CSV. `distance_to_box()`. Optional in v4; a file without `parts` still reads. | | `LayoutPart` | A layout part with position (`layoutId`, `kind`, `name`, `position`, `bbox`) — the target of the spatial assignment in `merge_hw_layout.py`, so that it never reads the CSV. `distance_to_box()`. Optional in v4; a file without `parts` still reads. |
| `Binding` | One assignment hardware → slot role: `tro`, `slot`, `role`, `layoutId`, `bmk`, `constant`, `address`, `source`, `rule` (`raeumlich`/`nummer`/`mehrheit`/`hand`), `distance`, `runnerUp`, `status` (`zugeordnet`/`offen`/`widerspruch`/`ohne_slot`), `note`. | | `Binding` | One assignment hardware → slot role: `tro`, `slot`, `role`, `layoutId`, `bmk`, `constant`, `address`, `source`, `rule` (`raeumlich`/`nummer`/`mehrheit`/`hand`), `distance`, `runnerUp`, `status` (`zugeordnet`/`offen`/`widerspruch`/`ohne_slot`), `note`. |
| `ConveyorBinding` | A conveyor of the E-planning (`K0102`) at a layout conveyor: `eplanName`, `layoutId`, `layoutName`, `votes`/`total`, `motor`, `protection`, `encoder`, `rule`, `status`, `note`. | | `ConveyorBinding` | A conveyor of the E-planning (`K0102`) at a layout conveyor: `eplanName`, `layoutId`, `layoutName`, `votes`/`total`, `motor`, `protection`, `encoder`, `rule`, `status`, `note`. |
| `AnlageDocument` | The plant JSON `<csv>_anlage.json` (`sps_skel-anlage-v1`): `layout` (the TRO-JSON unchanged), `registration`, `bindings`, `conveyors`, `unassigned`, `warnings`. `binding(tro, slot, role)`, `conveyor_for(layout_id)`. Written by `merge_hw_layout.py`; the future single input of `scl_skeleton`. | | `AnlageDocument` | The plant JSON `<csv>_anlage.json` (`sps_skel-anlage-v1`): `layout` (the TRO-JSON unchanged), `registration`, `bindings`, `conveyors`, `unassigned`, `warnings`. `binding(tro, slot, role)`, `conveyor_for(layout_id)`. Written by `merge_hw_layout.py`; input of `scl_skeleton` (stage 4). |
`TroBase` and `TroCandidate` share the `position` handling (JSON `{x,y,z}`, `TroBase` and `TroCandidate` share the `position` handling (JSON `{x,y,z}`,
`x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`. `x`/`y`/`z`, `describe_position()`) through the private mixin `_Positioned`.
+221 -61
View File
@@ -16,11 +16,19 @@ Schichten:
Namen (Separator-, Weichen-, MainTro-Nummer, Adressen ...) sind ein Namen (Separator-, Weichen-, MainTro-Nummer, Adressen ...) sind ein
:class:`SclName`: Wert plus Merker, ob er noch aus der E-Planung kommen muss. :class:`SclName`: Wert plus Merker, ob er noch aus der E-Planung kommen muss.
Ohne E-Planung sind alle Platzhalter ``todo=True``. Mit einer Zuordnung aus Ohne Hardwareplanung sind alle Platzhalter ``todo=True``. Quelle der echten
lib/e_planning.py (``eplan``) werden Separator-Sensor und Stopperventil der Namen ist :class:`HwSource`:
zugeordneten Separatoren eingesetzt und ``todo`` geloescht; alles andere
bleibt offen. Die Namensregeln aus Schritt 4 (cfg/scl_naming.ini) arbeiten - die Anlagen-JSON aus merge_hw_layout (``anlage``, Stufe 4 in
spaeter genauso: Wert ersetzen, Merker loeschen. doc/Konzept_Layout_Hardware_SCL.md): Zuordnungen je TRO-ID + Slot + Rolle
(Separator, Lagerlinie, Weiche, Antrieb) und die Foerderernummer je
Layout-Foerderer (Kreisel-Freigabe, Staubereichs-DB);
- oder, auslaufend, die Zuordnung aus lib/e_planning.py (``eplan``):
nur Separator-Sensor und Stopperventil, je Layout-ID.
Ein eingesetzter Name loescht ``todo``; alles andere bleibt offen. Die
Namensregeln aus Schritt 4 (cfg/scl_naming.ini) arbeiten spaeter genauso:
Wert ersetzen, Merker loeschen.
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,7 +40,7 @@ from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from model import JamArea, TroBase, TroDocument from model import AnlageDocument, ConveyorBinding, JamArea, TroBase, TroDocument
from tro_catalog import TroItem from tro_catalog import TroItem
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -52,6 +60,7 @@ __all__ = [
"SclStore", "SclStore",
"SclTro", "SclTro",
"SclFile", "SclFile",
"HwSource",
"build_context", "build_context",
"flow_order", "flow_order",
"reachable_via", "reachable_via",
@@ -81,10 +90,91 @@ def _bekannt(wert: str) -> SclName:
# Rolle in e_planning.ini -> wie der Name in der SCL geschrieben wird # Rolle in e_planning.ini -> wie der Name in der SCL geschrieben wird
# (Sensor als Konstante der DB_Inputs-Sensorliste, Ventil als Variable) # (Sensor als Konstante der DB_Inputs-Sensorliste, Ventil als Variable).
# Die Anlagen-JSON bringt die Konstante aus dem TIA-Import mit; die Vorlage
# gilt nur, wenn sie fehlt (alte eplan-Zuordnung).
_SENSOR = "cIn{bmk}" _SENSOR = "cIn{bmk}"
_VENTIL = "{bmk}" _VENTIL = "{bmk}"
# Antriebs-Slot (model._DRIVE_ROLES) -> Parameter des Vario-Aufrufs
_VARIO_SENSOR_ROLES = {
"stSenCarrInPos": "senCarrInPos",
"stSenFinger": "senFinger",
"stSenLastPos": "senLastPos",
"stSenJam": "senJam",
}
class HwSource:
"""Namen aus der Hardwareplanung - eine Abfrage fuer beide Quellen.
``anlage`` (merge_hw_layout): Schluessel TRO-ID + Slot + Rolle, nur
Zuordnungen mit Status ``zugeordnet`` zaehlen; ``offen``/``widerspruch``
bleiben ``TODO(E-Planung)``. ``eplan`` (e_planning.py, auslaufend):
Schluessel Layout-ID, kennt nur Separator-Sensor und -Stopper. Ohne beide
liefert jede Abfrage None.
"""
def __init__(self, anlage: AnlageDocument | None = None,
eplan: EplanDocument | None = None) -> None:
self.anlage = anlage
self.eplan = eplan
self._kinds = ({p.layout_id: p.kind for p in anlage.layout.parts}
if anlage is not None else {})
def device(self, tro_id: str, slot: str, role: str,
layout: str = "") -> tuple[str, str] | None:
"""(BMK, Konstante) einer Slot-Rolle; Konstante "" wenn unbekannt."""
if self.anlage is not None:
b = self.anlage.binding(tro_id, slot, role)
if b is None or b.status != "zugeordnet" or not b.bmk:
return None
return b.bmk, b.constant
if self.eplan is not None and layout and layout != "?":
a = self.eplan.for_layout(layout).get(role)
return (a.bmk, "") if a is not None else None
return None
def sensor(self, tro_id: str, slot: str, role: str, layout: str = "",
offen: str = "cInBG____") -> SclName:
"""Sensor als Konstante der DB_Inputs-Sensorliste (cInBG0532)."""
geraet = self.device(tro_id, slot, role, layout)
if geraet is None:
return _offen(offen)
bmk, konstante = geraet
return _bekannt(konstante or _SENSOR.format(bmk=bmk))
def output(self, tro_id: str, slot: str, role: str, layout: str = "",
offen: str = "MB____", fmt: str = _VENTIL) -> SclName:
"""Ausgang/Variable ueber den BMK (MB0533, "FC0202" ...)."""
geraet = self.device(tro_id, slot, role, layout)
return _bekannt(fmt.format(bmk=geraet[0])) if geraet else _offen(offen)
def conveyor(self, host: str) -> ConveyorBinding | None:
"""Foerderer der Hardwareplanung (K0102) am Layout-Foerderer ("0010-R")."""
if self.anlage is None or not host:
return None
return self.anlage.conveyor_for(host)
def is_carousel(self, host: str) -> bool:
return self._kinds.get(host.split("-", 1)[0]) == "Kreisel"
@property
def source(self) -> str:
"""Fuer den Dateikopf; "" ohne Hardwareplanung."""
if self.anlage is not None:
return (f"{self.anlage.hw_file or '-'} + {self.anlage.layout_file or '-'} "
f"(Anlagen-JSON vom {self.anlage.created})")
if self.eplan is not None:
return f"{self.eplan.eplan_file} (Zuordnung vom {self.eplan.created})"
return ""
def _foerderer_nr(conveyor: ConveyorBinding) -> str:
"""K0102 / #106 -> "0102" bzw. "0106" (Schreibweise der Referenz-DBs)."""
ziffern = re.sub(r"\D", "", conveyor.eplan_name)
return ziffern.zfill(4) if ziffern else ""
class SclSeparator(BaseModel): class SclSeparator(BaseModel):
"""Ein stInSeparator<n>-Block.""" """Ein stInSeparator<n>-Block."""
@@ -318,6 +408,8 @@ class SclFile(BaseModel):
tros: list[SclTro] = Field(default_factory=list) tros: list[SclTro] = Field(default_factory=list)
# Zuordnung zur E-Planung, aus der Namen eingesetzt wurden ("" = keine) # Zuordnung zur E-Planung, aus der Namen eingesetzt wurden ("" = keine)
eplan_source: str = "" eplan_source: str = ""
# Anlagen-JSON (merge_hw_layout), aus der Namen eingesetzt wurden ("" = keine)
hw_source: str = ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -432,27 +524,32 @@ def _jam(n: int, neighbour: str, jam_db: str, sensor: SclName | None = None,
) )
def _store(tro: TroBase, eplan: EplanDocument | None = None) -> SclStore: def _store(tro: TroBase, hw: HwSource) -> SclStore:
"""PinStore_Auto: eine Linie je Speicherbahn (Host) des Layouts. """PinStore_Auto: eine Linie je Speicherbahn (Host) des Layouts.
tro_flow legt die Separatoren als [steuernde ..., Linien ...] ab; die tro_flow legt die Separatoren als [steuernde ..., Linien ...] ab; die
Linien sind die letzten len(hosts). Fehlen Hosts (fremde JSON), zaehlt Linien sind die letzten len(hosts). Fehlen Hosts (fremde JSON), zaehlt
jeder Separator als Linie. Sensor und Stopper jeder Linie (und des jeder Separator als Linie. Sensor und Stopper jeder Linie (Slot
Pin-Separators, falls tro_flow einen gefunden hat) kommen aus der ``line<n>``) und des Pin-Separators (Slot ``separator1``, falls tro_flow
E-Planung wie bei den Separatorbloecken. einen steuernden Separator gefunden hat) kommen aus der Hardwareplanung
wie bei den Separatorbloecken.
""" """
hosts = list(getattr(tro, "hosts", []) or []) hosts = list(getattr(tro, "hosts", []) or [])
seps = list(tro.separators) seps = list(tro.separators)
anzahl = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps) anzahl = len(hosts) if 0 < len(hosts) <= len(seps) else len(seps)
steuer, linien = seps[:len(seps) - anzahl], seps[len(seps) - anzahl:] steuer, linien = seps[:len(seps) - anzahl], seps[len(seps) - anzahl:]
pin_sensor, pin_stopper = _sep_namen(steuer[0], eplan) if steuer else ( if steuer:
_offen("cInBG____"), _offen("MB____")) pin_sensor, pin_stopper = _sep_namen(tro.tro_id, "separator1", steuer[0], hw)
else:
pin_sensor, pin_stopper = _offen("cInBG____"), _offen("MB____")
zeilen = [] zeilen = []
for n, sep in enumerate(linien, start=1): for n, sep in enumerate(linien, start=1):
sensor, stopper = _sep_namen(sep, eplan) slot = f"line{n}"
sensor, stopper = _sep_namen(tro.tro_id, slot, sep, hw)
zeilen.append(SclStoreLine(n=n, layout=sep, zeilen.append(SclStoreLine(n=n, layout=sep,
jam_length=_offen('"cStorage____ExitJam"'), jam_length=_offen('"cStorage____ExitJam"'),
sensor=sensor, jam_sensor=_offen("cInBG____"), sensor=sensor,
jam_sensor=hw.sensor(tro.tro_id, slot, "jamSensor"),
stopper=stopper)) stopper=stopper))
return SclStore( return SclStore(
encoder=_offen("#fbEncoder____"), encoder=_offen("#fbEncoder____"),
@@ -477,35 +574,51 @@ def _store(tro: TroBase, eplan: EplanDocument | None = None) -> SclStore:
) )
def _sep_namen(layout: str, eplan: EplanDocument | None) -> tuple[SclName, SclName]: def _sep_namen(tro_id: str, slot: str, layout: str,
"""Sensor und Stopperventil eines Layout-Separators aus der E-Planung. hw: HwSource) -> tuple[SclName, SclName]:
"""Sensor und Stopperventil eines Separators aus der Hardwareplanung.
Gilt fuer die Separatorbloecke (stInSeparator<n>) und genauso fuer die Gilt fuer die Separatorbloecke (stInSeparator<n>, Slot ``separator<n>``)
Linien eines PinStore_Auto - beide sind Separatoren im Layout. Ohne und genauso fuer die Linien eines PinStore_Auto (Slot ``line<n>``) - beide
Zuordnung bleiben beide offen. sind Separatoren im Layout. Ohne Zuordnung bleiben beide offen.
""" """
rollen = eplan.for_layout(layout) if eplan is not None and layout != "?" else {} return (hw.sensor(tro_id, slot, "sensor", layout),
sensor = rollen.get("sensor") hw.output(tro_id, slot, "stopper", layout))
stopper = rollen.get("stopper")
return (_bekannt(_SENSOR.format(bmk=sensor.bmk)) if sensor else _offen("cInBG____"),
_bekannt(_VENTIL.format(bmk=stopper.bmk)) if stopper else _offen("MB____"))
def _separator(i: int, layout: str, eplan: EplanDocument | None) -> SclSeparator: def _separator(i: int, tro_id: str, layout: str, hw: HwSource) -> SclSeparator:
sensor, stopper = _sep_namen(layout, eplan) slot = f"separator{i}"
sensor, stopper = _sep_namen(tro_id, slot, layout, hw)
return SclSeparator( return SclSeparator(
n=i, n=i,
layout=layout, layout=layout,
no=_offen("cSep_____"), no=_offen("cSep_____"),
sensor=sensor, sensor=sensor,
stopper=stopper, stopper=stopper,
# Teilesensor kennt die E-Planung-Bruecke noch nicht - offen # Teilesensor: nur die Anlagen-JSON kennt ihn (Rolle partSensor)
part_sensor=_offen("cInBG____"), part_sensor=hw.sensor(tro_id, slot, "partSensor"),
) )
_JAM_DB = '"DB_JamArea____".stJam____'
def _jam_db(area: JamArea | None, hw: HwSource) -> str:
"""Platzhalter des Staubereichs-DB, mit Foerderernummer soweit bekannt.
Schema der Referenzen: "DB_JamArea<NNNN>".stJam<NNNN>_<k>. <NNNN> ist der
Foerderer, auf dem der Staubereich liegt (Anlagen-JSON, conveyors[]);
<k> - die laufende Nummer entlang des Flusses auf diesem Foerderer - ist
noch nicht festgelegt (doc/Konzept_Layout_Hardware_SCL.md "Staubereiche").
Der Name bleibt deshalb immer offen.
"""
conveyor = hw.conveyor(area.host) if area is not None else None
nr = _foerderer_nr(conveyor) if conveyor is not None else ""
return f'"DB_JamArea{nr}".stJam{nr}_____' if nr else _JAM_DB
def _entries(tro: TroBase, eigene: list[JamArea], def _entries(tro: TroBase, eigene: list[JamArea],
alle: dict[str, TroBase], jam_db: str) -> list[SclJam]: alle: dict[str, TroBase], hw: HwSource) -> list[SclJam]:
"""Ein JamEntr je eigenem Staubereich - nicht je Vorgaenger: mehrere """Ein JamEntr je eigenem Staubereich - nicht je Vorgaenger: mehrere
Vorgaenger fahren in denselben Staubereich ein (Referenzen UH01-UH05).""" Vorgaenger fahren in denselben Staubereich ein (Referenzen UH01-UH05)."""
eingaenge = [] eingaenge = []
@@ -513,65 +626,106 @@ def _entries(tro: TroBase, eigene: list[JamArea],
von = ", ".join(_nachbar(v, alle) for v in area.predecessors) or "-" von = ", ".join(_nachbar(v, alle) for v in area.predecessors) or "-"
if area.note: if area.note:
von = f"{von} ({area.note})" von = f"{von} ({area.note})"
eingaenge.append(_jam(area.entry, von, jam_db, jam_id=area.jam_id)) eingaenge.append(_jam(area.entry, von, _jam_db(area, hw), jam_id=area.jam_id))
return eingaenge return eingaenge
def _exit_area(tro_id: str, nach: str, def _exit_area(tro_id: str, nach: str,
bereiche: dict[str, list[JamArea]]) -> str: bereiche: dict[str, list[JamArea]]) -> JamArea | None:
"""Kennung des Staubereichs, in den tro_id beim Nachfolger einfaehrt.""" """Staubereich, in den tro_id beim Nachfolger einfaehrt."""
ziel = bereiche.get(nach, []) ziel = bereiche.get(nach, [])
passend = [a for a in ziel if tro_id in a.predecessors] or ziel passend = [a for a in ziel if tro_id in a.predecessors] or ziel
return passend[0].jam_id if passend else "" return passend[0] if passend else None
def _drive(tro: TroBase, hw: HwSource) -> tuple[SclName, SclName]:
"""Motor und Motorschutz eines Vario-Antriebs.
Erst der Antriebs-Slot (Rollen motor/motorProtection); fehlt er, der
Foerderer der Hardwareplanung am Host des TRO - der Vario-Antrieb ist
dessen Motor (MA/FC-Paar aus hw_import).
"""
motor = hw.output(tro.tro_id, "drive", "motor", offen="MA____")
schutz = hw.output(tro.tro_id, "drive", "motorProtection",
offen='"FC____"', fmt='"{bmk}"')
hosts = list(getattr(tro, "hosts", []) or [])
conveyor = hw.conveyor(hosts[0]) if hosts else None
if conveyor is not None:
if motor.todo and conveyor.motor:
motor = _bekannt(conveyor.motor)
if schutz.todo and conveyor.protection:
schutz = _bekannt(f'"{conveyor.protection}"')
return motor, schutz
def _carousel_run(tro: TroBase, hw: HwSource) -> SclName:
"""xInCarouselRun1: Freigabe des Kreisels, auf dem der TRO sitzt.
Bekannt nur, wenn der Host ein Kreisel ist und die Anlagen-JSON dessen
Foerderernummer kennt (Referenz: #fbCarousel0102.xOutConveyorFullSpeed).
"""
hosts = list(getattr(tro, "hosts", []) or [])
host = hosts[0] if hosts else ""
conveyor = hw.conveyor(host) if hw.is_carousel(host) else None
nr = _foerderer_nr(conveyor) if conveyor is not None else ""
if not nr:
return _offen("#fbCarousel____.xOutConveyorFullSpeed")
return _bekannt(f"#fbCarousel{nr}.xOutConveyorFullSpeed")
def _tro_context(tro: TroBase, alle: dict[str, TroBase], def _tro_context(tro: TroBase, alle: dict[str, TroBase],
eplan: EplanDocument | None = None, hw: HwSource | None = None,
bereiche: dict[str, list[JamArea]] | None = None) -> SclTro: bereiche: dict[str, list[JamArea]] | None = None) -> SclTro:
items = tro.items items = tro.items
n_sep = items.get(TroItem.SEPARATOR, 1) n_sep = items.get(TroItem.SEPARATOR, 1)
n_swi = items.get(TroItem.SWITCH, 0) n_swi = items.get(TroItem.SWITCH, 0)
n_scan = items.get(TroItem.SCANNER, 0) n_scan = items.get(TroItem.SCANNER, 0)
tro_id = tro.tro_id tro_id = tro.tro_id
jam_db = '"DB_JamArea____".stJam____' hw = hw or HwSource()
bereiche = bereiche or {} bereiche = bereiche or {}
separators = [ separators = [
_separator(i, tro.separators[i - 1] if i <= len(tro.separators) else "?", eplan) _separator(i, tro_id,
tro.separators[i - 1] if i <= len(tro.separators) else "?", hw)
for i in range(1, n_sep + 1) for i in range(1, n_sep + 1)
] ]
switches = [ switches = [
SclSwitch( SclSwitch(
n=j, n=j,
no=_offen("cSwi_____"), no=_offen("cSwi_____"),
valve=_offen("MB____"), valve=hw.output(tro_id, f"switch{j}", "valve"),
# Eine Weiche bedient alle Abgaenge - direkt ableitbar. # Eine Weiche bedient alle Abgaenge - direkt ableitbar.
to1_destinations=len(tro.successors) if n_swi == 1 else None, to1_destinations=len(tro.successors) if n_swi == 1 else None,
direction_target=(f"#{tro_id}.nInDirection" if n_swi == 1 direction_target=(f"#{tro_id}.nInDirection" if n_swi == 1
else f"#{tro_id}.nInDirection{j}"), else f"#{tro_id}.nInDirection{j}"),
jam_db=_offen(jam_db), jam_db=_offen(_JAM_DB),
) )
for j in range(1, n_swi + 1) for j in range(1, n_swi + 1)
] ]
has_vario = bool(items.get(TroItem.VARIO_DRIVE)) has_vario = bool(items.get(TroItem.VARIO_DRIVE))
vario_sensors = { vario_sensors = {
name: _offen("cInBG____") name: hw.sensor(tro_id, "drive", rolle)
for name in ("stSenCarrInPos", "stSenFinger", "stSenLastPos", "stSenJam") for name, rolle in _VARIO_SENSOR_ROLES.items()
} if has_vario else {} } if has_vario else {}
# Nur ein Vario hat einen eigenen Antrieb; sonst bleiben die (ungenutzten)
# Platzhalter offen, statt den Motor des Kreisels zu tragen.
vario_motor, motor_protection = _drive(tro, hw) if has_vario else (
_offen("MA____"), _offen('"FC____"'))
switch_exits = [ switch_exits = [
SclSwitchExit(via=abgang, targets=reachable_via(tro_id, abgang, alle)) SclSwitchExit(via=abgang, targets=reachable_via(tro_id, abgang, alle))
for abgang in tro.successors for abgang in tro.successors
] if n_swi else [] ] if n_swi else []
# Staubereiche aus der TRO-JSON (jamAreas): Eingaenge sind die eigenen # Staubereiche aus der TRO-JSON (jamAreas): Eingaenge sind die eigenen
# (ein JamEntr je eigenem Staubereich, nicht je Vorgaenger), Ausgaenge die # (ein JamEntr je eigenem Staubereich, nicht je Vorgaenger), Ausgaenge die
# Eingaenge der Nachfolger. Der DB-Name braucht die Foerderernummer aus der # Eingaenge der Nachfolger. Der DB-Name braucht Foerderernummer (aus der
# Hardwareplanung und bleibt offen. # Anlagen-JSON) und laufende Nummer (nicht festgelegt) und bleibt offen.
jam_entries = _entries(tro, bereiche.get(tro_id, []), alle, jam_db) jam_entries = _entries(tro, bereiche.get(tro_id, []), alle, hw)
jam_exits = [ jam_exits = []
_jam(n, _nachbar(nach, alle), jam_db, sensor=_offen("cInBG____"), for n, nach in enumerate(tro.successors, start=2):
jam_id=_exit_area(tro_id, nach, bereiche)) area = _exit_area(tro_id, nach, bereiche)
for n, nach in enumerate(tro.successors, start=2) jam_exits.append(_jam(n, _nachbar(nach, alle), _jam_db(area, hw),
] sensor=_offen("cInBG____"),
jam_id=area.jam_id if area is not None else ""))
return SclTro( return SclTro(
id=tro_id, id=tro_id,
@@ -599,18 +753,18 @@ def _tro_context(tro: TroBase, alle: dict[str, TroBase],
main_tro=_offen("cMainTro____"), main_tro=_offen("cMainTro____"),
safety=_offen('"DB_InterfaceSafety".stFromSafety.EStopMain.Q'), safety=_offen('"DB_InterfaceSafety".stFromSafety.EStopMain.Q'),
release=f'"DB_TRO_TEST".{tro_id}', release=f'"DB_TRO_TEST".{tro_id}',
motor_protection=_offen('"FC____"'), motor_protection=motor_protection,
vario_motor=_offen("MA____"), vario_motor=vario_motor,
# Schaltschrank ist anlagenspezifisch ("...OF1" in der Referenz) # Schaltschrank ist anlagenspezifisch ("...OF1" in der Referenz)
machine_state=_offen("#stInOutControlUnitCabinet"), machine_state=_offen("#stInOutControlUnitCabinet"),
priority_udt=_offen("udt_____"), priority_udt=_offen("udt_____"),
# Vorschlag aus der Referenz TRO107, Prioritaet bleibt Planungsentscheidung # Vorschlag aus der Referenz TRO107, Prioritaet bleibt Planungsentscheidung
prio_dir2=_offen("TRUE"), prio_dir2=_offen("TRUE"),
carousel_run=_offen("#fbCarousel____.xOutConveyorFullSpeed"), carousel_run=_carousel_run(tro, hw),
# dasselbe Objekt wie der erste Ein-/Ausgang: ein Name, den die # dasselbe Objekt wie der erste Ein-/Ausgang: ein Name, den die
# Namensregeln dort setzen, gilt damit auch hier # Namensregeln dort setzen, gilt damit auch hier
jam_in=jam_entries[0] if jam_entries else _jam(1, "-", jam_db), jam_in=jam_entries[0] if jam_entries else _jam(1, "-", _JAM_DB),
jam_out=jam_exits[0] if jam_exits else _jam(2, "-", jam_db), jam_out=jam_exits[0] if jam_exits else _jam(2, "-", _JAM_DB),
error_quit=_offen('"SF____"'), error_quit=_offen('"SF____"'),
automatic=_offen("#t_automatik"), automatic=_offen("#t_automatik"),
is_border=_offen("TRUE"), is_border=_offen("TRUE"),
@@ -622,18 +776,24 @@ def _tro_context(tro: TroBase, alle: dict[str, TroBase],
station_release=_offen('#"rTrig_Workstation ____".CLK'), station_release=_offen('#"rTrig_Workstation ____".CLK'),
loaded_error_sensor=_offen("cInBG____")) loaded_error_sensor=_offen("cInBG____"))
if items.get(TroItem.WORK_STATION) else None), if items.get(TroItem.WORK_STATION) else None),
store=_store(tro, eplan) if items.get(TroItem.STORAGE_LINE) else None, store=_store(tro, hw) if items.get(TroItem.STORAGE_LINE) else None,
) )
def build_context(document: TroDocument, source: str, start_id: str = "", def build_context(document: TroDocument, source: str, start_id: str = "",
created: str | None = None, created: str | None = None,
eplan: EplanDocument | None = None) -> SclFile: eplan: EplanDocument | None = None,
anlage: AnlageDocument | None = None) -> SclFile:
"""TroDocument -> SclFile (TROs in Fliessrichtung). """TroDocument -> SclFile (TROs in Fliessrichtung).
eplan: Zuordnung Layout -> E-Planung (e_planning.py); ohne sie bleiben anlage: Anlagen-JSON (merge_hw_layout) - ihr Layout ist ``document``,
alle Namen offen und die Ausgabe ist die bisherige. ihre Zuordnungen liefern die Namen. eplan: alte Zuordnung Layout ->
E-Planung (e_planning.py), nur ohne ``anlage``. Ohne beide bleiben alle
Namen offen und die Ausgabe ist die bisherige.
""" """
if anlage is not None and eplan is not None:
raise ValueError("anlage und eplan schliessen sich aus")
hw = HwSource(anlage=anlage, eplan=eplan)
alle = {t.tro_id: t for t in document.tros} alle = {t.tro_id: t for t in document.tros}
bereiche: dict[str, list[JamArea]] = {} bereiche: dict[str, list[JamArea]] = {}
for area in document.jam_areas_or_derived(): for area in document.jam_areas_or_derived():
@@ -643,7 +803,7 @@ def build_context(document: TroDocument, source: str, start_id: str = "",
source=source, source=source,
created=created or datetime.now().strftime("%Y-%m-%d %H:%M"), created=created or datetime.now().strftime("%Y-%m-%d %H:%M"),
start_reason=startgrund, start_reason=startgrund,
tros=[_tro_context(t, alle, eplan, bereiche) for t in geordnet], tros=[_tro_context(t, alle, hw, bereiche) for t in geordnet],
eplan_source=(f"{eplan.eplan_file} (Zuordnung vom {eplan.created})" eplan_source=hw.source if eplan is not None else "",
if eplan is not None else ""), hw_source=hw.source if anlage is not None else "",
) )
+132 -23
View File
@@ -6,14 +6,19 @@ scl_skeleton.py - FB_Main-Geruest als SCL aus der TRO-Liste erzeugen.
Letzter Schritt der Kette: Letzter Schritt der Kette:
material_flow.py Objekte -> Materialfluss material_flow.py Objekte -> Materialfluss
tro_flow.py Fluss -> TROs -> <name>_tro.json (--json) tro_flow.py Fluss -> TROs -> <name>_tro.json (--json)
scl_skeleton.py JSON -> FB_Main.scl <- hier merge_hw_layout.py TRO-JSON + Hardwareplanung -> <name>_anlage.json
scl_skeleton.py JSON -> FB_Main.scl <- hier
Die TRO-JSON wird ueber das gemeinsame Modell lib/model.py (TroDocument) Eingabe ist die TRO-JSON (nur Layout) oder die Anlagen-JSON (Layout plus
eingelesen und dabei geprueft: ein falscher oder umbenannter Schluessel bricht Zuordnungen der Hardwareplanung); erkannt am Schema. Beide werden ueber das
mit einer Fehlermeldung ab, statt still einen Standardwert zu nehmen. Bauteile gemeinsame Modell lib/model.py (TroDocument / AnlageDocument) eingelesen und
(items) und FB-Typ kommen aus tro_catalog, ueber den TRO-Typ. dabei geprueft: ein falscher oder umbenannter Schluessel bricht mit einer
Fehlermeldung ab, statt still einen Standardwert zu nehmen. Bauteile (items)
und FB-Typ kommen aus tro_catalog, ueber den TRO-Typ. Aus der Anlagen-JSON
werden zugeordnete Sensoren, Ventile, Antriebe und Foerderernummern
eingesetzt (scl_context.HwSource); offene Zuordnungen bleiben TODO.
Erzeugt wird ein **Geruest**, kein fertiges Programm: je TRO eine REGION mit Erzeugt wird ein **Geruest**, kein fertiges Programm: je TRO eine REGION mit
Instanzaufruf und allen Parameterzeilen in der richtigen Reihenfolge, so wie es Instanzaufruf und allen Parameterzeilen in der richtigen Reihenfolge, so wie es
@@ -49,11 +54,12 @@ from jinja2 import (
TemplateError, TemplateError,
TemplateNotFound, TemplateNotFound,
) )
from pydantic import ValidationError from pydantic import BaseModel, ValidationError
from e_planning import EPLAN_SCHEMA, EplanDocument from e_planning import EPLAN_SCHEMA, EplanDocument
from hw_import import HardwareDocument
from material_flow import env_dir from material_flow import env_dir
from model import SCHEMA, TroDocument from model import ANLAGE_SCHEMA, SCHEMA, AnlageDocument, TroDocument
from scl_context import TODO, SclFile, SclName, SclTro, build_context from scl_context import TODO, SclFile, SclName, SclTro, build_context
# Parametrisierte Aufrufstellen je FB (doc/SCL_Templates.md); Dateiname = fbType. # Parametrisierte Aufrufstellen je FB (doc/SCL_Templates.md); Dateiname = fbType.
@@ -336,6 +342,13 @@ def render_context(context: SclFile, templates: SclTemplates | None = None) -> s
add("// Separator-Sensoren und Stopperventile aus der Zuordnung sind") add("// Separator-Sensoren und Stopperventile aus der Zuordnung sind")
add("// eingesetzt (ohne Marker); was die Zuordnung nicht sicher fand,") add("// eingesetzt (ohne Marker); was die Zuordnung nicht sicher fand,")
add(f"// bleibt '{TODO}'. Details: <csv>_eplan.md (lib/e_planning.py).") add(f"// bleibt '{TODO}'. Details: <csv>_eplan.md (lib/e_planning.py).")
if context.hw_source:
add("//")
add(f"// Hardwareplanung: {context.hw_source}")
add("// Zugeordnete Sensoren, Ventile, Antriebe und Foerderernummern")
add("// sind eingesetzt (ohne Marker); offene Zuordnungen und")
add(f"// Widersprueche bleiben '{TODO}'. Details: <csv>_anlage.md")
add("// (lib/merge_hw_layout.py).")
if templates is not None: if templates is not None:
mit, ohne = template_usage(context, templates) mit, ohne = template_usage(context, templates)
add("//") add("//")
@@ -386,13 +399,23 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr) file=sys.stderr)
return 1 return 1
schema = _schema_of(json_file)
anlage = None
try: try:
document = load_document(json_file) if schema == ANLAGE_SCHEMA:
anlage = AnlageDocument.from_json(json_file.read_text(encoding="utf-8"))
document = anlage.layout
else:
document = load_document(json_file)
except OSError as exc: except OSError as exc:
print(f"FEHLER: {json_file} nicht lesbar: {exc}", file=sys.stderr) print(f"FEHLER: {json_file} nicht lesbar: {exc}", file=sys.stderr)
return 1 return 1
except ValidationError as exc: except ValidationError as exc:
schema = _schema_of(json_file) if schema == ANLAGE_SCHEMA:
print(f"FEHLER: {json_file} passt nicht zum Anlagen-Modell (lib/model.py):",
file=sys.stderr)
print(exc, file=sys.stderr)
return 1
if schema and schema != SCHEMA: if schema and schema != SCHEMA:
# Anderes Format (z. B. die alte tro_extract-JSON) - die einzelnen # Anderes Format (z. B. die alte tro_extract-JSON) - die einzelnen
# Feldfehler helfen dann nicht weiter. # Feldfehler helfen dann nicht weiter.
@@ -417,6 +440,11 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
templates = SclTemplates(ordner) templates = SclTemplates(ordner)
if anlage is not None and args.eplan:
print("FEHLER: --eplan geht nicht zusammen mit einer Anlagen-JSON - die "
"Zuordnungen stehen schon darin (merge_hw_layout).", file=sys.stderr)
return 1
eplan = None eplan = None
if args.eplan: if args.eplan:
eplan_file = _in_results(args.eplan, results) eplan_file = _in_results(args.eplan, results)
@@ -438,12 +466,39 @@ def main(argv: list[str] | None = None) -> int:
f"TRO-JSON zu '{document.source}'.", file=sys.stderr) f"TRO-JSON zu '{document.source}'.", file=sys.stderr)
return 1 return 1
context = build_context(document, json_file.name, args.start, eplan=eplan) context = build_context(document, json_file.name, args.start, eplan=eplan,
anlage=anlage)
mit, ohne = template_usage(context, templates) mit, ohne = template_usage(context, templates)
if args.strict and ohne: if args.strict and ohne:
print(f"FEHLER: kein Template fuer {', '.join(ohne)} (--strict) - " print(f"FEHLER: kein Template fuer {', '.join(ohne)} (--strict) - "
f"keine SCL geschrieben.", file=sys.stderr) f"keine SCL geschrieben.", file=sys.stderr)
return 1 return 1
# Gegenpruefung: jeder eingesetzte Name muss in der Hardwareplanung stehen
# (doc/Konzept_Layout_Hardware_SCL.md, Stufe 4). Die Namen kommen selbst aus
# ihr - ein Fehler hier heisst: Handkorrektur (hw_overrides.ini) mit
# Tippfehler oder Anlagen-JSON zu einem anderen Stand der Hardwareplanung.
hw_file = None
unbekannt: list[str] = []
if anlage is not None and (args.hw or anlage.hw_file):
hw_file = _in_results(args.hw or Path(anlage.hw_file).name, results)
try:
hw = HardwareDocument.from_json(hw_file.read_text(encoding="utf-8"))
except (OSError, ValidationError) as exc:
if args.hw:
print(f"FEHLER: Hardwareplanung nicht lesbar: {exc}", file=sys.stderr)
return 1
print(f"Hinweis: {hw_file} nicht lesbar - Namen nicht gegengeprueft.")
hw_file = None
else:
unbekannt = unknown_names(context, hw)
if unbekannt:
stufe = "FEHLER" if args.strict else "WARNUNG"
print(f"{stufe}: {len(unbekannt)} eingesetzte Namen fehlen in {hw_file.name}: "
f"{', '.join(unbekannt)}", file=sys.stderr)
if args.strict:
print(" (--strict) - keine SCL geschrieben.", file=sys.stderr)
return 1
try: try:
text = render_context(context, templates) text = render_context(context, templates)
except TemplateError as exc: except TemplateError as exc:
@@ -451,7 +506,8 @@ def main(argv: list[str] | None = None) -> int:
# als eine mit stillen Luecken. # als eine mit stillen Luecken.
print(f"FEHLER im Template: {exc}", file=sys.stderr) print(f"FEHLER im Template: {exc}", file=sys.stderr)
return 1 return 1
out_file = results / (args.out or f"{json_file.stem.replace('_tro', '')}_FB_Main.scl") stamm = json_file.stem.replace("_anlage", "").replace("_tro", "")
out_file = results / (args.out or f"{stamm}_FB_Main.scl")
out_file.write_text(text, encoding="utf-8") out_file.write_text(text, encoding="utf-8")
offen = text.count(TODO) offen = text.count(TODO)
@@ -469,6 +525,11 @@ def main(argv: list[str] | None = None) -> int:
if eplan is not None: if eplan is not None:
print(f"E-Planung = {eplan_file}") print(f"E-Planung = {eplan_file}")
print(f"Eingesetzt = {_eingesetzt(context)} Namen aus der E-Planung") print(f"Eingesetzt = {_eingesetzt(context)} Namen aus der E-Planung")
if anlage is not None:
print(f"Hardwareplanung = {anlage.hw_file or '-'}")
print(f"Eingesetzt = {len(known_names(context))} Namen aus der Anlagen-JSON")
if hw_file is not None:
print(f"Gegenpruefung = {len(unbekannt)} unbekannte Namen ({hw_file.name})")
print(f"Offene Stellen = {offen} (Marker '{TODO}')") print(f"Offene Stellen = {offen} (Marker '{TODO}')")
print(f"SCL = {out_file}") print(f"SCL = {out_file}")
print("================================================================") print("================================================================")
@@ -498,6 +559,41 @@ def _eingesetzt(context: SclFile) -> int:
return sum(not name.todo for name in namen) return sum(not name.todo for name in namen)
def known_names(context: SclFile) -> list[str]:
"""Alle eingesetzten (nicht offenen) Namen des Kontexts, ohne Doppelte."""
namen: list[str] = []
def walk(obj) -> None:
if isinstance(obj, SclName):
if not obj.todo and obj.value not in namen:
namen.append(obj.value)
elif isinstance(obj, BaseModel):
for feld in type(obj).model_fields:
walk(getattr(obj, feld))
elif isinstance(obj, (list, tuple)):
for x in obj:
walk(x)
elif isinstance(obj, dict):
for x in obj.values():
walk(x)
walk(context)
return namen
def unknown_names(context: SclFile, hw: HardwareDocument) -> list[str]:
"""Eingesetzte Namen, die die Hardwareplanung nicht kennt.
Geprueft werden Symbole und Konstanten (cInBG0532, MB0533, "FC0202");
Instanzzugriffe wie #fbCarousel0102.xOut... sind keine globalen Namen.
"""
bekannt = {n for d in hw.devices for n in (d.bmk, d.constant) if n}
bekannt |= {c.name for c in hw.constants}
bekannt |= {n for d in hw.drives for n in (d.motor, d.protection, d.encoder) if n}
return [name for name in known_names(context)
if not name.startswith("#") and name.strip('"') not in bekannt]
def _schema_of(path: Path) -> str: def _schema_of(path: Path) -> str:
"""Schema-Kennung einer JSON-Datei, nur fuer die Fehlermeldung ("" wenn unlesbar).""" """Schema-Kennung einer JSON-Datei, nur fuer die Fehlermeldung ("" wenn unlesbar)."""
try: try:
@@ -509,26 +605,30 @@ def _schema_of(path: Path) -> str:
def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="scl_skeleton", prog="scl_skeleton",
description="Erzeugt aus der TRO-JSON (tro_flow --json) ein FB_Main-Geruest " description="Erzeugt aus der TRO-JSON (tro_flow --json) oder der "
"als SCL zum Import in TIA Portal.", "Anlagen-JSON (merge_hw_layout) ein FB_Main-Geruest als SCL "
"zum Import in TIA Portal.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Beispiele:\n" epilog="Beispiele:\n"
" scl_skeleton.bat\n" " scl_skeleton.bat\n"
" scl_skeleton.bat --json export_tro.json --out FB_Main.scl\n" " scl_skeleton.bat --json export_tro.json --out FB_Main.scl\n"
" scl_skeleton.bat --json mubea_tro.json --templates\n" " scl_skeleton.bat --json mubea_tro.json --templates\n"
" scl_skeleton.bat --json mubea_anlage.json --templates\n"
" scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json\n" " scl_skeleton.bat --json mubea_tro.json --eplan mubea_eplan.json\n"
"\n" "\n"
"Exit-Codes:\n" "Exit-Codes:\n"
" 0 ok (auch wenn TROs ohne Template generisch erzeugt wurden)\n" " 0 ok (auch wenn TROs ohne Template generisch erzeugt wurden)\n"
" 1 Eingabe-, Aufruf- oder Templatefehler; mit --strict auch\n" " 1 Eingabe-, Aufruf- oder Templatefehler; mit --strict auch\n"
" ein FB-Typ ohne Template\n", " ein FB-Typ ohne Template oder ein eingesetzter Name, der\n"
" in der Hardwareplanung fehlt\n",
) )
parser.add_argument( parser.add_argument(
"--json", "--json",
default="export_tro.json", default="export_tro.json",
metavar="NAME", metavar="NAME",
help="TRO-JSON aus tro_flow.py --json in %%SKEL_RESULTS%% " help="TRO-JSON aus tro_flow.py --json oder Anlagen-JSON aus "
"(oder ein vollstaendiger Pfad). Standard: %(default)s", "merge_hw_layout.py in %%SKEL_RESULTS%% (oder ein vollstaendiger "
"Pfad); erkannt am Schema. Standard: %(default)s",
) )
parser.add_argument( parser.add_argument(
"--out", "--out",
@@ -555,15 +655,24 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--eplan", "--eplan",
metavar="NAME", metavar="NAME",
help="Zuordnung zur E-Planung aus e_planning.py (<csv>_eplan.json in " help="Auslaufend, nur mit einer TRO-JSON: Zuordnung zur E-Planung aus "
"%%SKEL_RESULTS%% oder ein Pfad): Separator-Sensoren und " "e_planning.py (<csv>_eplan.json in %%SKEL_RESULTS%% oder ein "
"Stopperventile werden eingesetzt statt als TODO markiert.", "Pfad): Separator-Sensoren und Stopperventile werden eingesetzt "
"statt als TODO markiert. Mit der Anlagen-JSON nicht noetig.",
)
parser.add_argument(
"--hw",
metavar="NAME",
help="Nur mit einer Anlagen-JSON: Hardwareplanung (<projekt>_hw.json) "
"fuer die Gegenpruefung der eingesetzten Namen. Standard: hwFile "
"aus der Anlagen-JSON, in %%SKEL_RESULTS%%.",
) )
parser.add_argument( parser.add_argument(
"--strict", "--strict",
action="store_true", action="store_true",
help="Nur mit --templates: abbrechen (Exit 1), wenn ein FB-Typ der " help="Abbrechen (Exit 1), wenn ein FB-Typ der Anlage kein Template "
"Anlage kein Template hat.", "hat (mit --templates) oder ein eingesetzter Name in der "
"Hardwareplanung fehlt (mit Anlagen-JSON).",
) )
return parser.parse_args(argv) return parser.parse_args(argv)
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""Tests fuer scl_skeleton mit der Anlagen-JSON (Stufe 4).
Die Anlagen-JSON (merge_hw_layout) traegt das Layout und die Zuordnungen der
Hardwareplanung je TRO-ID + Slot + Rolle. scl_context.HwSource setzt daraus
die Namen ein; was offen oder widerspruechlich ist, bleibt TODO(E-Planung).
Die Zuordnungen sind hier von Hand gebaut - wie merge_hw_layout sie findet,
pruefen die Tests in test_merge_hw_layout.py.
"""
import scl_skeleton
from hw_import import HardwareDocument, HwDevice
from model import (
AnlageDocument,
Analysis,
Binding,
ConveyorBinding,
DerivedTro,
LayoutPart,
TroDocument,
)
from scl_context import TODO, build_context
def _tro(tro_id, type_name, seps, hosts, **extra):
return DerivedTro(tro_id=tro_id, type_name=type_name, label=tro_id,
hosts=list(hosts), separators=list(seps), scanners=[],
reason="Test", **extra)
def _part(layout_id, kind, name):
return LayoutPart(layout_id=layout_id, kind=kind, name=name,
position=(0.0, 0.0, 0.0))
def _bind(tro, slot, role, bmk="", constant="", status="zugeordnet"):
return Binding(tro=tro, slot=slot, role=role, bmk=bmk, constant=constant,
status=status)
def anlage() -> AnlageDocument:
"""SYS1 -> TRO01 (1Sep1Swi, Kreisel3) -> TRO02 (1Sep, Kreisel3) / TRO03 (Vario, VF_1)."""
tros = [
_tro("TRO01", "1Sep1Swi", ["0042"], ["0010-R"], predecessors=["SYS1"],
successors=["TRO02", "TRO03"]),
_tro("TRO02", "1Sep", ["0043"], ["0010-L"], predecessors=["TRO01"],
successors=["SYS2"]),
_tro("TRO03", "Vario", ["0062"], ["0015"], predecessors=["TRO01"]),
]
parts = [_part("0010", "Kreisel", "Kreisel3"), _part("0015", "Strecke", "VF_1")]
layout = TroDocument.from_analysis(Analysis(tros=tros), source="anlage.csv",
parts=parts)
return AnlageDocument(
layout_file="anlage_tro.json",
hw_file="Anlage_hw.json",
layout=layout,
bindings=[
_bind("TRO01", "separator1", "sensor", "BG0532", "cInBG0532"),
_bind("TRO01", "separator1", "stopper", "MB0533", "cOutMB0533"),
_bind("TRO01", "switch1", "valve", "MB0540", "cOutMB0540"),
# Widerspruch: Name ist da, aber nicht verlaesslich -> bleibt offen
_bind("TRO02", "separator1", "sensor", "BG0600", "cInBG0600",
status="widerspruch"),
_bind("TRO02", "separator1", "stopper", status="offen"),
_bind("TRO03", "drive", "senJam", "BG0900", "cInBG0900"),
],
conveyors=[
ConveyorBinding(eplan_name="K0102", layout_id="0010", layout_name="Kreisel3",
motor="MA0202", protection="FC0202"),
ConveyorBinding(eplan_name="K0106", layout_id="0015", layout_name="VF_1",
motor="MA0210", protection="FC0210"),
],
)
def _by_id(context, tro_id):
return next(t for t in context.tros if t.id == tro_id)
def _kontext(doc: AnlageDocument):
return build_context(doc.layout, "anlage.json", created="2026-01-01 00:00",
anlage=doc)
def test_separator_und_weiche_aus_den_zuordnungen():
tro = _by_id(_kontext(anlage()), "TRO01")
sep = tro.separators[0]
assert (sep.sensor.value, sep.sensor.todo) == ("cInBG0532", False)
assert (sep.stopper.value, sep.stopper.todo) == ("MB0533", False)
assert (tro.switches[0].valve.value, tro.switches[0].valve.todo) == ("MB0540", False)
# Rolle ohne Zuordnung bleibt offen
assert sep.part_sensor.todo
def test_offen_und_widerspruch_bleiben_todo():
sep = _by_id(_kontext(anlage()), "TRO02").separators[0]
assert (sep.sensor.value, sep.sensor.todo) == ("cInBG____", True)
assert (sep.stopper.value, sep.stopper.todo) == ("MB____", True)
def test_kreisel_freigabe_und_staubereich_aus_der_foerderernummer():
ctx = _kontext(anlage())
tro01 = _by_id(ctx, "TRO01")
assert tro01.carousel_run.value == "#fbCarousel0102.xOutConveyorFullSpeed"
assert not tro01.carousel_run.todo
# Staubereich: Foerderer bekannt, laufende Nummer <k> nicht -> weiter offen
assert tro01.jam_entries[0].db.value == '"DB_JamArea0102".stJam0102_____'
assert tro01.jam_entries[0].db.todo
# Ausgang zu TRO03 liegt auf VF_1 (K0106)
assert tro01.jam_exits[1].db.value == '"DB_JamArea0106".stJam0106_____'
# TRO03 sitzt auf einer Strecke, nicht am Kreisel -> Freigabe bleibt offen
assert _by_id(ctx, "TRO03").carousel_run.todo
def test_vario_antrieb_ueber_den_foerderer():
tro = _by_id(_kontext(anlage()), "TRO03")
assert (tro.vario_motor.value, tro.vario_motor.todo) == ("MA0210", False)
assert (tro.motor_protection.value, tro.motor_protection.todo) == ('"FC0210"', False)
assert tro.vario_sensors["stSenJam"].value == "cInBG0900"
assert tro.vario_sensors["stSenFinger"].todo
def test_ohne_hardwareplanung_alles_offen():
doc = anlage()
ctx = build_context(doc.layout, "anlage_tro.json")
tro = _by_id(ctx, "TRO01")
assert tro.separators[0].sensor.todo and tro.carousel_run.todo
assert ctx.hw_source == ""
def _hw(*ohne: str) -> HardwareDocument:
namen = [("BG0532", "cInBG0532"), ("MB0533", "cOutMB0533"),
("MB0540", "cOutMB0540"), ("BG0900", "cInBG0900"),
("MA0210", ""), ("FC0210", "")]
return HardwareDocument(devices=[HwDevice(bmk=b, constant=c)
for b, c in namen if b not in ohne])
def _lauf(tmp_path, monkeypatch, *weitere: str, hw: HardwareDocument | None = None):
(tmp_path / "anlage_anlage.json").write_text(anlage().to_json(), encoding="utf-8")
(tmp_path / "Anlage_hw.json").write_text((hw or _hw()).to_json(), encoding="utf-8")
monkeypatch.setenv("SKEL_RESULTS", str(tmp_path))
rc = scl_skeleton.main(["--json", "anlage_anlage.json", *weitere])
return rc, tmp_path / "anlage_FB_Main.scl"
def test_scl_skeleton_liest_die_anlagen_json(tmp_path, monkeypatch, capsys):
rc, scl = _lauf(tmp_path, monkeypatch, "--templates")
assert rc == 0
text = scl.read_text(encoding="utf-8")
assert "// Hardwareplanung: Anlage_hw.json + anlage_tro.json" in text
assert '#TRO01.stInSeparator1.stSenInSep := "DB_Inputs".Sensors["cInBG0532"];\n' in text
assert 'xOutStopper1 => "MB0533",\n' in text
assert "xInCarouselRun1 := #fbCarousel0102.xOutConveyorFullSpeed," in text
assert "0 unbekannte Namen" in capsys.readouterr().out
def test_weniger_offene_stellen_als_mit_der_tro_json(tmp_path, monkeypatch):
rc, scl = _lauf(tmp_path, monkeypatch, "--templates")
mit = scl.read_text(encoding="utf-8").count(TODO)
(tmp_path / "anlage_tro.json").write_text(anlage().layout.to_json(), encoding="utf-8")
assert scl_skeleton.main(["--json", "anlage_tro.json", "--templates",
"--out", "ohne.scl"]) == 0
ohne = (tmp_path / "ohne.scl").read_text(encoding="utf-8").count(TODO)
assert rc == 0 and mit < ohne
def test_eplan_und_anlagen_json_schliessen_sich_aus(tmp_path, monkeypatch):
rc, scl = _lauf(tmp_path, monkeypatch, "--eplan", "x_eplan.json")
assert rc == 1
assert not scl.exists()
def test_unbekannter_name_warnt_und_bricht_mit_strict_ab(tmp_path, monkeypatch, capsys):
rc, scl = _lauf(tmp_path, monkeypatch, hw=_hw("MB0540"))
assert rc == 0 and scl.exists()
assert "WARNUNG: 1 eingesetzte Namen fehlen in Anlage_hw.json: MB0540" in \
capsys.readouterr().err
scl.unlink()
rc, scl = _lauf(tmp_path, monkeypatch, "--strict", hw=_hw("MB0540"))
assert rc == 1
assert not scl.exists()