devagent-ignition 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.
- devagent_hmi.py +471 -0
- devagent_ignition-0.1.0.dist-info/METADATA +204 -0
- devagent_ignition-0.1.0.dist-info/RECORD +133 -0
- devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
- devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
- devagent_spatial/__init__.py +1 -0
- devagent_spatial/_resources/README.md +187 -0
- devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
- devagent_spatial/_resources/SHA256SUMS.txt +270 -0
- devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
- devagent_spatial/_resources/assets/agent.js +102 -0
- devagent_spatial/_resources/assets/creator.css +247 -0
- devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
- devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
- devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
- devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
- devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
- devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
- devagent_spatial/_resources/assets/customer_documents.js +110 -0
- devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
- devagent_spatial/_resources/assets/facility2d.css +24 -0
- devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
- devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
- devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
- devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
- devagent_spatial/_resources/assets/fallback_scene.js +17 -0
- devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
- devagent_spatial/_resources/assets/harness.js +78 -0
- devagent_spatial/_resources/assets/live_design_review.js +129 -0
- devagent_spatial/_resources/assets/operations_ui.js +305 -0
- devagent_spatial/_resources/assets/operator_viewer.html +64 -0
- devagent_spatial/_resources/assets/project_package.js +52 -0
- devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
- devagent_spatial/_resources/assets/runtime_feed.js +30 -0
- devagent_spatial/_resources/assets/studio.html +139 -0
- devagent_spatial/_resources/assets/style.css +191 -0
- devagent_spatial/_resources/assets/test_workspace.html +195 -0
- devagent_spatial/_resources/assets/three_scene.js +442 -0
- devagent_spatial/_resources/assets/unified_engineer.js +101 -0
- devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
- devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
- devagent_spatial/_resources/assets/viewer.html +45 -0
- devagent_spatial/_resources/assets/viewer.js +112 -0
- devagent_spatial/_resources/design-system.json +130 -0
- devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
- devagent_spatial/_resources/examples/cv101_guided.json +272 -0
- devagent_spatial/_resources/examples/make_examples.py +36 -0
- devagent_spatial/_resources/examples/single_floor.json +444 -0
- devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
- devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
- devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
- devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
- devagent_spatial/_resources/examples/warehouse.json +708 -0
- devagent_spatial/_resources/examples/water.json +288 -0
- devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
- devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
- devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
- devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
- devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
- devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
- devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
- devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
- devagent_spatial/_resources/reference/v8-original/README.md +36 -0
- devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
- devagent_spatial/_resources/reference/v8-original/model.py +124 -0
- devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
- devagent_spatial/_resources/reference/v8-original/server.py +100 -0
- devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
- devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
- devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
- devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
- devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
- devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
- devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
- devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
- devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
- devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
- devagent_spatial/_resources/templates/water_connections.csv +7 -0
- devagent_spatial/_resources/templates/water_equipment.csv +10 -0
- devagent_spatial/_resources/templates/water_signals.csv +12 -0
- devagent_spatial/agent_harness.py +299 -0
- devagent_spatial/agent_runtime.py +314 -0
- devagent_spatial/contract_opc_fat.py +216 -0
- devagent_spatial/contract_opc_source.py +265 -0
- devagent_spatial/control_intents.py +38 -0
- devagent_spatial/customer_intake.py +235 -0
- devagent_spatial/engineering_expertise.py +114 -0
- devagent_spatial/generator.py +201 -0
- devagent_spatial/ignition_native.py +234 -0
- devagent_spatial/ignition_project.py +144 -0
- devagent_spatial/live_lab_binding.py +128 -0
- devagent_spatial/model.py +297 -0
- devagent_spatial/native_reference.py +69 -0
- devagent_spatial/opc_snapshot.py +87 -0
- devagent_spatial/operations.py +284 -0
- devagent_spatial/operations_capture.py +47 -0
- devagent_spatial/operations_lab.py +64 -0
- devagent_spatial/original_r9.py +169 -0
- devagent_spatial/original_v8.py +81 -0
- devagent_spatial/preflight.py +140 -0
- devagent_spatial/preview_simulation.py +189 -0
- devagent_spatial/project_package.py +101 -0
- devagent_spatial/qualification.py +152 -0
- devagent_spatial/strict_json.py +29 -0
- devagent_spatial/studio.py +894 -0
- devagent_spatial/studio_workflow.py +277 -0
- devagent_spatial/test_reporting.py +87 -0
- devagent_spatial/v076_opc_fat.py +180 -0
- devagent_spatial/v078_commands.py +277 -0
- devagent_spatial/v078_native_commands.py +161 -0
- devagent_spatial/v07_fat.py +110 -0
- devagent_spatial/v07_native.py +318 -0
- devagent_spatial/v083_native.py +349 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Pre-deployment OFFLINE integrity/cross-project qualification. No Gateway access.
|
|
2
|
+
|
|
3
|
+
This never claims Ignition runtime compatibility, live mapping, or FAT success.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import copy
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from .generator import build, BuildHold, ROOT
|
|
12
|
+
from .qualification import audit
|
|
13
|
+
from .model import qualify
|
|
14
|
+
from .ignition_native import compile_candidate, verify_candidate, find_views, NativeHold
|
|
15
|
+
from .ignition_project import compile_project, verify_project, CONFIRM
|
|
16
|
+
|
|
17
|
+
EXAMPLES = ('single_floor', 'warehouse', 'water')
|
|
18
|
+
SCHEMA = 'devagent.spatial.offline_preflight.v0.4'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _hash(path: Path) -> str:
|
|
22
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def preflight(*, reference_zip=None, reference_sha256=None, template_view=None):
|
|
26
|
+
results = []
|
|
27
|
+
def result(name, passed, detail):
|
|
28
|
+
results.append({'gate':name,'status':'PASS' if passed else 'HOLD','detail':detail})
|
|
29
|
+
|
|
30
|
+
with tempfile.TemporaryDirectory(prefix='devagent-preflight-') as base:
|
|
31
|
+
tmp = Path(base)
|
|
32
|
+
checked_models = {}
|
|
33
|
+
for name in EXAMPLES:
|
|
34
|
+
try:
|
|
35
|
+
model_path = ROOT/'examples'/f'{name}.json'
|
|
36
|
+
original_sha = _hash(model_path)
|
|
37
|
+
model = json.loads(model_path.read_text(encoding='utf-8'))
|
|
38
|
+
report = qualify(model)
|
|
39
|
+
if report['status']!='PASS_STATIC_CONTRACT_ONLY':
|
|
40
|
+
raise ValueError('STATIC_CONTRACT_HOLD')
|
|
41
|
+
expected_floors = len({e.get('floor',1) for e in model['equipment']})
|
|
42
|
+
if name=='single_floor' and expected_floors != 1:
|
|
43
|
+
raise ValueError('SINGLE_FLOOR_NOT_SINGLE')
|
|
44
|
+
out = tmp/name
|
|
45
|
+
res = build(model,out)
|
|
46
|
+
audited = audit(out,purpose='preview')
|
|
47
|
+
if res['status']!='PASS_STATIC_BUILD_ONLY' or audited['status']!='PASS_CONCEPT_PREVIEW_ONLY':
|
|
48
|
+
raise ValueError('PREVIEW_AUDIT_HOLD')
|
|
49
|
+
if audited['equipment_count']!=len(model['equipment']) or audited['signals_proposed']!=len(model['signals']):
|
|
50
|
+
raise ValueError('MODEL_COUNTS_DIFFER')
|
|
51
|
+
if original_sha!=_hash(model_path):
|
|
52
|
+
raise ValueError('SOURCE_FILE_MUTATED')
|
|
53
|
+
checked_models[name]=model
|
|
54
|
+
result('PROJECT_'+name.upper(),True,{'assets':len(model['equipment']),
|
|
55
|
+
'signals':len(model['signals']),'floors':expected_floors,
|
|
56
|
+
'FAT':'NOT_EXECUTED','preview':'PASS_CONCEPT_ONLY'})
|
|
57
|
+
except (OSError,ValueError,BuildHold,KeyError,TypeError,RecursionError) as exc:
|
|
58
|
+
result('PROJECT_'+name.upper(),False,type(exc).__name__+': '+str(exc)[:100])
|
|
59
|
+
|
|
60
|
+
# Deliberately corrupt an already-built artifact and check the independent
|
|
61
|
+
# auditor fails closed. Re-hashing a changed file must not bypass semantic checks.
|
|
62
|
+
if (tmp/'single_floor'/'BUILD.json').exists():
|
|
63
|
+
try:
|
|
64
|
+
builddoc = tmp/'single_floor'/'BUILD.json'
|
|
65
|
+
payload = json.loads(builddoc.read_text())
|
|
66
|
+
payload['ignition_hmi'] = 'VERIFIED'
|
|
67
|
+
builddoc.write_text(json.dumps(payload,indent=2)+'\n')
|
|
68
|
+
checksum = tmp/'single_floor'/'SHA256SUMS.txt'
|
|
69
|
+
updated=[]
|
|
70
|
+
for line in checksum.read_text().splitlines():
|
|
71
|
+
digest,relative=line.split(' ',1)
|
|
72
|
+
if relative=='BUILD.json':digest=_hash(builddoc)
|
|
73
|
+
updated.append(digest+' '+relative)
|
|
74
|
+
checksum.write_text('\n'.join(updated)+'\n')
|
|
75
|
+
report=audit(tmp/'single_floor',purpose='preview')
|
|
76
|
+
result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',
|
|
77
|
+
report['status']=='HOLD' and 'UNSUPPORTED_RUNTIME_CERTIFICATION' in report['issues'],
|
|
78
|
+
report['issues'])
|
|
79
|
+
except (ValueError,OSError,KeyError) as exc:
|
|
80
|
+
result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',False,type(exc).__name__)
|
|
81
|
+
else:
|
|
82
|
+
result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',False,'NO_PREVIEW_BUILT')
|
|
83
|
+
|
|
84
|
+
for mutation in ('duplicate_asset','invalid_role','write_authority'):
|
|
85
|
+
try:
|
|
86
|
+
model=copy.deepcopy(checked_models['single_floor'])
|
|
87
|
+
if mutation=='duplicate_asset':model['equipment'].append(copy.deepcopy(model['equipment'][0]))
|
|
88
|
+
elif mutation=='invalid_role':model['signals'][0]['role']=[]
|
|
89
|
+
else:model['signals'][0]['write_authorized']=True
|
|
90
|
+
q=qualify(model)
|
|
91
|
+
result('REJECT_'+mutation.upper(),q['status']=='HOLD',
|
|
92
|
+
[x['code'] for x in q['issues'] if x['severity']=='HOLD'])
|
|
93
|
+
except (ValueError,KeyError,TypeError) as exc:
|
|
94
|
+
result('REJECT_'+mutation.upper(),False,'EXCEPTION_'+type(exc).__name__)
|
|
95
|
+
if reference_zip is not None:
|
|
96
|
+
try:
|
|
97
|
+
if not reference_sha256 or not template_view:raise NativeHold('SHA_AND_TEMPLATE_REQUIRED')
|
|
98
|
+
reference_zip=Path(reference_zip)
|
|
99
|
+
if _hash(reference_zip)!=reference_sha256:raise NativeHold('SOURCE_SHA256_MISMATCH')
|
|
100
|
+
index=find_views(reference_zip)
|
|
101
|
+
if template_view not in index['views']:raise NativeHold('REFERENCE_VIEW_UNAVAILABLE')
|
|
102
|
+
dst=tmp/'native_candidate.zip'
|
|
103
|
+
status=compile_candidate(checked_models['single_floor'],reference_zip,
|
|
104
|
+
template_view,reference_sha256,dst)
|
|
105
|
+
verification=verify_candidate(checked_models['single_floor'],reference_zip,
|
|
106
|
+
template_view,reference_sha256,dst)
|
|
107
|
+
# Require a standalone, conservative project candidate for
|
|
108
|
+
# THREE materially different customer systems. Generated only
|
|
109
|
+
# in the transient test directory; never contacts a Gateway.
|
|
110
|
+
project_results=[]
|
|
111
|
+
for scenario,customer_model in checked_models.items():
|
|
112
|
+
project_out=tmp/f'{scenario}-project.zip'
|
|
113
|
+
project_name='DevAgent_'+scenario.upper()+'_LAB'
|
|
114
|
+
pr=compile_project(customer_model,reference_zip,template_view,
|
|
115
|
+
reference_sha256,project_name,project_out,CONFIRM)
|
|
116
|
+
pv=verify_project(customer_model,reference_zip,template_view,
|
|
117
|
+
reference_sha256,project_name,project_out)
|
|
118
|
+
project_results.append({'scenario':scenario,'status':'PASS' if
|
|
119
|
+
pr['status']==pv['status']=='PASS_OFFLINE_ISOLATED_PROJECT_CANDIDATE_ONLY'
|
|
120
|
+
else 'HOLD','members':pv['members_verified']})
|
|
121
|
+
result('REFERENCE_NATIVE_OFFLINE',
|
|
122
|
+
status['status']==verification['status']=='PASS_OFFLINE_NATIVE_VIEW_CANDIDATE_ONLY'
|
|
123
|
+
and len(project_results)==len(EXAMPLES)
|
|
124
|
+
and all(x['status']=='PASS' for x in project_results),
|
|
125
|
+
{'source_sha256':reference_sha256,'native_import':'NOT_TESTED',
|
|
126
|
+
'generated_project_candidates':project_results})
|
|
127
|
+
except (OSError,ValueError,KeyError,NativeHold,TypeError) as exc:
|
|
128
|
+
result('REFERENCE_NATIVE_OFFLINE',False,str(exc)[:160])
|
|
129
|
+
else:
|
|
130
|
+
result('REFERENCE_NATIVE_OFFLINE',False,'APPROVED_R9_NATIVE_EXPORT_REQUIRED')
|
|
131
|
+
return {
|
|
132
|
+
'schema':SCHEMA,
|
|
133
|
+
'status':'PASS_OFFLINE_PRETEST_ONLY' if all(r['status']=='PASS' for r in results if r['gate']!='REFERENCE_NATIVE_OFFLINE') else 'HOLD',
|
|
134
|
+
'production_release':'HOLD',
|
|
135
|
+
'ignition_project_import':'NOT_TESTED',
|
|
136
|
+
'native_reference':next(x['status'] for x in results if x['gate']=='REFERENCE_NATIVE_OFFLINE'),
|
|
137
|
+
'real_opc_ua':'NOT_TESTED','hmi_runtime':'NOT_TESTED','FAT':'NOT_EXECUTED',
|
|
138
|
+
'R9_design_parity':'NOT_VERIFIED',
|
|
139
|
+
'gates':results
|
|
140
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Project-scoped, in-memory SYNTHETIC preview demonstration.
|
|
2
|
+
|
|
3
|
+
This module NEVER talks to OPC UA, Ignition or PLCs, changes tags or asserts FAT
|
|
4
|
+
qualification. It models conceptual material flow using only declared graph edges.
|
|
5
|
+
Separate from real Studio R2's guarded tag readback workflow.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import deque
|
|
12
|
+
from .operations import evaluate, mapping_digest
|
|
13
|
+
|
|
14
|
+
MAX_SECONDS = 900
|
|
15
|
+
PREVIEW_PACES = {'slow':180.0, 'normal':120.0, 'fast':75.0}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PreviewSimulationHold(ValueError): pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main_path(model):
|
|
22
|
+
"""Longest declared acyclic handling path (deterministic branch selection)."""
|
|
23
|
+
equipment = {e['id']: e for e in model['equipment']}
|
|
24
|
+
handling = {k for k,e in equipment.items() if e['kind'] in ('conveyor','sorter','spiral','junction')}
|
|
25
|
+
if not handling:raise PreviewSimulationHold('NO_MATERIAL_HANDLING_ASSETS')
|
|
26
|
+
children = {k: [] for k in handling}
|
|
27
|
+
incoming = {k: 0 for k in handling}
|
|
28
|
+
for edge in model['connections']:
|
|
29
|
+
a,b = edge['from'],edge['to']
|
|
30
|
+
if a in handling and b in handling:
|
|
31
|
+
children[a].append(b);incoming[b]+=1
|
|
32
|
+
if not any(children.values()):raise PreviewSimulationHold('NO_DECLARED_MATERIAL_FLOW')
|
|
33
|
+
for paths in children.values():paths.sort()
|
|
34
|
+
# Never silently invent a path through missing or cyclic edges.
|
|
35
|
+
def visit(k, seen):
|
|
36
|
+
if k in seen:raise PreviewSimulationHold('CYCLIC_MATERIAL_FLOW_NEEDS_REVIEW')
|
|
37
|
+
options = [visit(c,seen|{k}) for c in children[k]]
|
|
38
|
+
return [k] + (max(options,key=lambda p:(len(p),tuple(p))) if options else [])
|
|
39
|
+
roots = sorted(k for k,n in incoming.items() if n == 0)
|
|
40
|
+
if not roots:raise PreviewSimulationHold('CYCLIC_MATERIAL_FLOW_OR_NO_ROOT')
|
|
41
|
+
return max((visit(r,set()) for r in roots),key=lambda p:(len(p),tuple(p)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PreviewSimulator:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self._lock = threading.RLock()
|
|
47
|
+
self._runs = {}
|
|
48
|
+
|
|
49
|
+
def _get(self,pid,model):
|
|
50
|
+
run=self._runs.get(pid)
|
|
51
|
+
if run and run['digest']!=mapping_digest(model):
|
|
52
|
+
self._runs.pop(pid,None)
|
|
53
|
+
raise PreviewSimulationHold('PROJECT_MAPPING_CHANGED_SIMULATION_STOPPED')
|
|
54
|
+
if run and (time.monotonic()-run['created'])>MAX_SECONDS:
|
|
55
|
+
self._runs.pop(pid,None)
|
|
56
|
+
raise PreviewSimulationHold('SIMULATION_EXPIRED_RESTART_REQUIRED')
|
|
57
|
+
return run
|
|
58
|
+
|
|
59
|
+
def active(self,pid):
|
|
60
|
+
with self._lock:return pid in self._runs
|
|
61
|
+
|
|
62
|
+
def action(self,pid,model,action,*,fault_asset=None,pace=None):
|
|
63
|
+
with self._lock:
|
|
64
|
+
if action=='start':
|
|
65
|
+
if pace not in (None,*PREVIEW_PACES):raise PreviewSimulationHold('UNKNOWN_PREVIEW_PACE')
|
|
66
|
+
path=main_path(model)
|
|
67
|
+
self._runs[pid]={'digest':mapping_digest(model),'path':path,'created':time.monotonic(),
|
|
68
|
+
'tick':time.monotonic(),'elapsed':0.,'duration':PREVIEW_PACES[pace or 'normal'],'paused':False,'fault_asset':None,
|
|
69
|
+
'history':deque(maxlen=40),'scanned':0,'generation':0,'acknowledged':False}
|
|
70
|
+
elif action=='stop':
|
|
71
|
+
self._runs.pop(pid,None)
|
|
72
|
+
return {'status':'SYNTHETIC_PREVIEW_STOPPED','production':'HOLD','opc_writes':False}
|
|
73
|
+
else:
|
|
74
|
+
run=self._get(pid,model)
|
|
75
|
+
if not run:raise PreviewSimulationHold('START_SIMULATION_FIRST')
|
|
76
|
+
self._advance(run)
|
|
77
|
+
if action=='pause':run['paused']=not run['paused']
|
|
78
|
+
elif action=='operator-stop':run['paused']=True
|
|
79
|
+
elif action=='operator-start':
|
|
80
|
+
if run['fault_asset'] is not None:raise PreviewSimulationHold('ACTIVE_FAULT_RESET_REQUIRED_BEFORE_START')
|
|
81
|
+
run['paused']=False
|
|
82
|
+
elif action=='operator-reset':
|
|
83
|
+
if not run['paused']:raise PreviewSimulationHold('STOP_BEFORE_SIMULATED_RESET')
|
|
84
|
+
run['fault_asset']=None;run['acknowledged']=False
|
|
85
|
+
run['elapsed']=0.0;run['history'].clear();run['tick']=time.monotonic()
|
|
86
|
+
elif action=='operator-ack':
|
|
87
|
+
if run['fault_asset'] is None:raise PreviewSimulationHold('NO_ACTIVE_SIMULATED_FAULT_TO_ACK')
|
|
88
|
+
if run['acknowledged']:raise PreviewSimulationHold('ALARM_ALREADY_ACKNOWLEDGED_IN_SIMULATION')
|
|
89
|
+
run['acknowledged']=True
|
|
90
|
+
elif action=='fault':
|
|
91
|
+
ids={s['asset_id'] for s in model['signals'] if s['role']=='fault' and s['datatype']=='Boolean'}
|
|
92
|
+
if fault_asset not in ids:raise PreviewSimulationHold('UNKNOWN_FAULT_ASSET')
|
|
93
|
+
run['fault_asset']=None if run['fault_asset']==fault_asset else fault_asset
|
|
94
|
+
run['acknowledged']=False
|
|
95
|
+
else:raise PreviewSimulationHold('UNKNOWN_SIMULATION_ACTION')
|
|
96
|
+
run=self._runs[pid]
|
|
97
|
+
return {'status':'SYNTHETIC_PREVIEW_ACTIVE','paused':run['paused'],
|
|
98
|
+
'fault_asset':run['fault_asset'],'acknowledged':run['acknowledged'],'path':run['path'],'duration_seconds':run['duration'],'source':'IN_MEMORY_SYNTHETIC_NOT_OPC',
|
|
99
|
+
'production':'HOLD','opc_writes':False,'ignition':'NOT_VERIFIED'}
|
|
100
|
+
|
|
101
|
+
def _advance(self,run):
|
|
102
|
+
now=time.monotonic(); delta=max(0.,min(now-run['tick'],10.))
|
|
103
|
+
if not run['paused'] and run['fault_asset'] is None:run['elapsed']+=delta
|
|
104
|
+
run['tick']=now
|
|
105
|
+
|
|
106
|
+
def status(self,pid,model):
|
|
107
|
+
with self._lock:
|
|
108
|
+
run=self._get(pid,model)
|
|
109
|
+
if not run:return {'status':'SIMULATION_OFF','source':'NONE','opc_writes':False}
|
|
110
|
+
return {'status':'SYNTHETIC_PREVIEW_ACTIVE','paused':run['paused'],
|
|
111
|
+
'fault_asset':run['fault_asset'],'acknowledged':run['acknowledged'],'path':run['path'],'duration_seconds':run['duration'],
|
|
112
|
+
'source':'IN_MEMORY_SYNTHETIC_NOT_OPC','production':'HOLD','opc_writes':False}
|
|
113
|
+
|
|
114
|
+
def reading(self,pid,model):
|
|
115
|
+
with self._lock:
|
|
116
|
+
run=self._get(pid,model)
|
|
117
|
+
if not run:return {'status':'SIMULATION_OFF'}
|
|
118
|
+
self._advance(run)
|
|
119
|
+
duration=run['duration'] # slow, explicitly labeled conceptual traversal (no PLC speed claim)
|
|
120
|
+
parcels=[]
|
|
121
|
+
for n in range(3):
|
|
122
|
+
seconds=run['elapsed']-n*(duration/4)
|
|
123
|
+
if seconds < 0:continue
|
|
124
|
+
# A completed parcel exits; a NEW parcel enters on the next
|
|
125
|
+
# cycle. Reusing the same identity across a 1->0 progress wrap
|
|
126
|
+
# would teleport a parcel from the chute back to the infeed.
|
|
127
|
+
cycle=int(seconds//duration)
|
|
128
|
+
phase=(seconds%duration)/duration
|
|
129
|
+
parcels.append({'id':f'LAB-{n+1:02d}-C{cycle:04d}',
|
|
130
|
+
'progress':round(phase,6),'path':run['path']})
|
|
131
|
+
# Conceptual machine-specific virtual port sensors. These are NEVER
|
|
132
|
+
# customer OPC UA photoeye signals, even if the names resemble PE IN/OUT.
|
|
133
|
+
# Use a long enough conceptual gate for 1-second UI polls to observe it.
|
|
134
|
+
gates={asset:{'in':False,'out':False} for asset in run['path']}
|
|
135
|
+
length=len(run['path'])
|
|
136
|
+
for parcel in parcels:
|
|
137
|
+
scaled=parcel['progress']*length
|
|
138
|
+
index=min(length-1,int(scaled))
|
|
139
|
+
local=scaled-index
|
|
140
|
+
asset=run['path'][index]
|
|
141
|
+
gates[asset]['in'] |= local <= .23
|
|
142
|
+
gates[asset]['out'] |= local >= .77
|
|
143
|
+
virtual_pe=[{'asset_id':asset,'pe_in':v['in'],'pe_out':v['out']}
|
|
144
|
+
for asset,v in gates.items()]
|
|
145
|
+
# Project example photoeyes have no machine IN/OUT binding. Synthetic
|
|
146
|
+
# visualization MAY associate them by nearest declared same-floor route
|
|
147
|
+
# station; this is explicitly NOT a physical installation mapping.
|
|
148
|
+
equipment={e['id']:e for e in model['equipment']}
|
|
149
|
+
virtual_sensor_owners={}
|
|
150
|
+
for sensor in model['equipment']:
|
|
151
|
+
if sensor['kind']!='photoeye':continue
|
|
152
|
+
candidates=[( (sensor['position']['x']-equipment[k]['position']['x'])**2+
|
|
153
|
+
(sensor['position']['z']-equipment[k]['position']['z'])**2,
|
|
154
|
+
k) for k in run['path'] if equipment[k]['floor']==sensor['floor']]
|
|
155
|
+
if candidates:virtual_sensor_owners[sensor['id']]=min(candidates)[1]
|
|
156
|
+
at=dt.datetime.now(dt.timezone.utc)
|
|
157
|
+
# monotonic wall-clock for the operations engine, even on rapid requests
|
|
158
|
+
if run['history']:
|
|
159
|
+
prev=dt.datetime.fromisoformat(run['history'][-1]['at'])
|
|
160
|
+
if at<=prev:at=prev+dt.timedelta(microseconds=1)
|
|
161
|
+
timestamp=at.isoformat()
|
|
162
|
+
values=[]
|
|
163
|
+
signal_roles={s['id']:s for s in model['signals']}
|
|
164
|
+
for signal in model['signals']:
|
|
165
|
+
role=signal['role'];dtype=signal['datatype'];asset=signal['asset_id']
|
|
166
|
+
if dtype=='Boolean':
|
|
167
|
+
on=(role=='run_feedback' and not run['paused'] and run['fault_asset'] is None)
|
|
168
|
+
if role=='fault':on=(asset==run['fault_asset'])
|
|
169
|
+
if role=='photoeye_blocked':
|
|
170
|
+
owner=virtual_sensor_owners.get(asset)
|
|
171
|
+
on=bool(owner and (gates[owner]['in'] or gates[owner]['out']))
|
|
172
|
+
value=not on if signal.get('polarity','') in ('active_low','ACTIVE_LOW') and role in ('fault','photoeye_blocked') else on
|
|
173
|
+
elif dtype=='Int32':
|
|
174
|
+
value=int(run['elapsed']//duration) if role in ('scan_count','delivered_count') else 0
|
|
175
|
+
elif dtype=='Double':value=38.0 if not run['paused'] and run['fault_asset'] is None else 0.0
|
|
176
|
+
elif dtype=='String':value='GOOD_READ' if role=='scanner_result' else 'LAB-DEMO'
|
|
177
|
+
else:raise PreviewSimulationHold('UNSUPPORTED_SIGNAL_TYPE')
|
|
178
|
+
values.append({'signal_id':signal['id'],'namespace_uri':signal['source']['namespace_uri'],
|
|
179
|
+
'node_id':signal['source']['node_id'],'datatype':dtype,'quality':'GOOD',
|
|
180
|
+
'source_timestamp':timestamp,'value':value})
|
|
181
|
+
run['history'].append({'at':timestamp,'observations':values})
|
|
182
|
+
receipt=evaluate(model,list(run['history']),origin='SYNTHETIC_OFFLINE_FIXTURE')
|
|
183
|
+
return {'status':'PASS_SYNTHETIC_PREVIEW_ONLY','source':'IN_MEMORY_SYNTHETIC_NOT_OPC',
|
|
184
|
+
'authority':'PROJECT_SCOPED_DEMO_ONLY','receipt':receipt,'parcels':parcels,
|
|
185
|
+
'virtual_pe':virtual_pe,'virtual_pe_source':'CONCEPTUAL_SYNTHETIC_PORTS_NOT_MAPPED',
|
|
186
|
+
'acknowledged':run['acknowledged'],
|
|
187
|
+
'paused':run['paused'],'fault_asset':run['fault_asset'],
|
|
188
|
+
'ignition':'NOT_VERIFIED','opc_verified':False,'production':'HOLD','opc_writes':False,
|
|
189
|
+
'hmi_fat':'NOT_VERIFIED'}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Read-only customer project ZIP intake: reviewable Creator fields, never executable artifacts.
|
|
2
|
+
|
|
3
|
+
No archive member is extracted or executed. Preview and FAT evidence files are ignored.
|
|
4
|
+
AI review, PLC mapping and production approval remain independent.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import re
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
MAX_PACKAGE_BYTES = 8_000_000
|
|
14
|
+
MAX_MEMBERS = 180
|
|
15
|
+
MAX_EXPANDED_BYTES = 20_000_000
|
|
16
|
+
MAX_INPUT_BYTES = 120_000
|
|
17
|
+
MAX_PROJECTS = 4
|
|
18
|
+
FILES = {
|
|
19
|
+
'equipment_csv': ('02_Equipment_IMPORT.csv','02_Equipment.csv','equipment.csv'),
|
|
20
|
+
'connections_csv': ('03_Connections_IMPORT.csv','03_Connections.csv','connections.csv'),
|
|
21
|
+
'signals_csv': ('04_Training_OPC_Signals_IMPORT.csv','04_Signals_DEMO_ONLY.csv','04_Training_OPC_Signals.csv','signals.csv'),
|
|
22
|
+
'requirements': ('06_Requirements_PASTE.txt','05_Requirements_15_Lines.txt','requirements.txt'),
|
|
23
|
+
'description': ('01_Project_Description_PASTE.txt','01_Project_Brief.txt','project.txt'),
|
|
24
|
+
}
|
|
25
|
+
class PackageHold(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def inspect_package(name: str, raw: bytes) -> dict:
|
|
29
|
+
if not isinstance(name,str) or not name.lower().endswith('.zip') or not isinstance(raw,bytes):
|
|
30
|
+
raise PackageHold('PROJECT_PACKAGE_ZIP_REQUIRED')
|
|
31
|
+
if not 20 <= len(raw) <= MAX_PACKAGE_BYTES:
|
|
32
|
+
raise PackageHold('PROJECT_PACKAGE_SIZE_HOLD')
|
|
33
|
+
try:
|
|
34
|
+
with zipfile.ZipFile(io.BytesIO(raw)) as z:
|
|
35
|
+
members=z.infolist()
|
|
36
|
+
if not members or len(members)>MAX_MEMBERS or sum(m.file_size for m in members)>MAX_EXPANDED_BYTES:
|
|
37
|
+
raise PackageHold('PROJECT_PACKAGE_EXPANSION_LIMIT')
|
|
38
|
+
by_dir={}
|
|
39
|
+
seen=set()
|
|
40
|
+
for item in members:
|
|
41
|
+
filename=item.filename
|
|
42
|
+
path=PurePosixPath(filename)
|
|
43
|
+
if item.flag_bits & 1: raise PackageHold('PROJECT_PACKAGE_ENCRYPTED')
|
|
44
|
+
if item.create_system==3 and ((item.external_attr>>16)&0o170000)==0o120000:
|
|
45
|
+
raise PackageHold('PROJECT_PACKAGE_SYMLINK_REJECTED')
|
|
46
|
+
if item.is_dir():continue
|
|
47
|
+
if (not filename or filename.startswith('/') or '\\' in filename or
|
|
48
|
+
'..' in path.parts or any(part.startswith('.') for part in path.parts) or
|
|
49
|
+
':' in filename or item.file_size>MAX_EXPANDED_BYTES):
|
|
50
|
+
raise PackageHold('PROJECT_PACKAGE_UNSAFE_PATH')
|
|
51
|
+
if filename in seen:raise PackageHold('PROJECT_PACKAGE_DUPLICATE_PATH')
|
|
52
|
+
seen.add(filename)
|
|
53
|
+
# Scan archive metadata only. HMI previews, executables, reports and reference models are ignored.
|
|
54
|
+
if path.name not in {v for names in FILES.values() for v in names}:continue
|
|
55
|
+
directory=path.parent.as_posix()
|
|
56
|
+
by_dir.setdefault(directory,{})[path.name]=item
|
|
57
|
+
options=[]
|
|
58
|
+
from .studio import from_wizard
|
|
59
|
+
from .model import qualify
|
|
60
|
+
for directory,available in sorted(by_dir.items()):
|
|
61
|
+
data={}
|
|
62
|
+
for field,names in FILES.items():
|
|
63
|
+
chosen=next((n for n in names if n in available),None)
|
|
64
|
+
if chosen:
|
|
65
|
+
member=available[chosen]
|
|
66
|
+
if member.file_size>MAX_INPUT_BYTES:raise PackageHold('PROJECT_INPUT_FILE_TOO_LARGE')
|
|
67
|
+
try:data[field]=z.read(member).decode('utf-8-sig')
|
|
68
|
+
except (UnicodeError,RuntimeError):raise PackageHold('PROJECT_INPUT_ENCODING_INVALID') from None
|
|
69
|
+
if not data.get('equipment_csv'):continue
|
|
70
|
+
label=PurePosixPath(directory).name or 'Customer project'
|
|
71
|
+
# Project name is a UI convenience, not an inferred equipment or identity.
|
|
72
|
+
name_text=('Imported '+label.replace('_',' ').replace('-',' '))[:90]
|
|
73
|
+
wizard={'name':name_text,'industry':'Unspecified Industry',
|
|
74
|
+
'layout':'spatial_overview','palette':'r9_cyan',
|
|
75
|
+
'description':data.get('description',''),
|
|
76
|
+
'equipment_csv':data['equipment_csv'],
|
|
77
|
+
'connections_csv':data.get('connections_csv','from,to\n'),
|
|
78
|
+
'signals_csv':data.get('signals_csv','id,asset_id,role,datatype,namespace_uri,node_id,unit,polarity\n'),
|
|
79
|
+
'requirements':data.get('requirements','')}
|
|
80
|
+
try:
|
|
81
|
+
model=from_wizard(wizard)
|
|
82
|
+
state=qualify(model)
|
|
83
|
+
except (ValueError,TypeError,KeyError):
|
|
84
|
+
raise PackageHold('PROJECT_INPUT_SCHEMA_HOLD') from None
|
|
85
|
+
if state.get('status')!='PASS_STATIC_CONTRACT_ONLY':
|
|
86
|
+
raise PackageHold('PROJECT_INPUT_VALIDATION_HOLD')
|
|
87
|
+
options.append({'id':str(len(options)),'label':label,
|
|
88
|
+
'equipment':len(model['equipment']),
|
|
89
|
+
'links':len(model['connections']),
|
|
90
|
+
'signals':len(model['signals']),
|
|
91
|
+
'requirements':len(model['requirements']),
|
|
92
|
+
'training':any('TRAINING_ONLY' in s or 'training:' in s.lower() for s in [wizard['signals_csv']]),
|
|
93
|
+
'wizard':wizard,
|
|
94
|
+
'stage':'UNVERIFIED_CUSTOMER_INPUTS_ONLY'})
|
|
95
|
+
if len(options)>MAX_PROJECTS:raise PackageHold('TOO_MANY_PROJECTS_IN_PACKAGE')
|
|
96
|
+
if not options:raise PackageHold('NO_SUPPORTED_CUSTOMER_INPUT_BUNDLE')
|
|
97
|
+
return {'status':'REVIEW_REQUIRED','package_sha256':hashlib.sha256(raw).hexdigest(),
|
|
98
|
+
'options':options,'production':'HOLD','ai_review':'NOT_RUN',
|
|
99
|
+
'note':'Only customer input tables were inspected; generated HMI and FAT evidence in the ZIP were ignored.'}
|
|
100
|
+
except (zipfile.BadZipFile,zipfile.LargeZipFile,EOFError,RuntimeError) as ex:
|
|
101
|
+
raise PackageHold('PROJECT_PACKAGE_INVALID_ZIP') from ex
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Independent deterministic build audit. Never promotes a concept to live HMI qualification."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from .generator import enrich, fat_inventory, ROOT
|
|
8
|
+
from .model import qualify, json_embed
|
|
9
|
+
from .engineering_expertise import review as expert_review, LIBRARY_VERSION
|
|
10
|
+
from .control_intents import control_plan
|
|
11
|
+
|
|
12
|
+
ALLOWED = {
|
|
13
|
+
'BUILD.json','SHA256SUMS.txt',
|
|
14
|
+
'contracts/project.json','contracts/compiled-scene.json','contracts/design-system.json','contracts/operations-policy.json',
|
|
15
|
+
'reports/static-validation.json','reports/fat-inventory.json','reports/engineering-review.json','reports/control-readiness.json',
|
|
16
|
+
'viewer/index.html','viewer/equipment_controls.mjs','viewer/facility2d_core.mjs','viewer/facility2d_app.mjs','viewer/facility2d_simulation.mjs','viewer/facility2d.css','viewer/style.css','viewer/viewer.js','viewer/three_scene.js','viewer/flow_geometry.mjs','viewer/r9_transport.mjs','viewer/fallback_scene.js','viewer/operations_ui.js','viewer/runtime_feed.js',
|
|
17
|
+
}
|
|
18
|
+
VENDOR={'viewer/vendor/three.module.js','viewer/vendor/three.core.js','viewer/vendor/LICENSE-three.txt'}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_json(path):
|
|
22
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def audit(project_dir:Path, *, purpose:str='preview') -> dict:
|
|
26
|
+
"""Validate artifact identity, content and traceability; never invent live evidence."""
|
|
27
|
+
if purpose not in ('preview','production'):
|
|
28
|
+
raise ValueError('INVALID_QUALIFICATION_PURPOSE')
|
|
29
|
+
root=Path(project_dir).expanduser().resolve()
|
|
30
|
+
issues=[]
|
|
31
|
+
def hold(code):issues.append(code)
|
|
32
|
+
if not root.is_dir():
|
|
33
|
+
return {'status':'HOLD','issues':['PROJECT_DIRECTORY_NOT_FOUND'],'purpose':purpose}
|
|
34
|
+
# No symlinks, unexpected files or unreadable paths. Files only from fixed allowlist.
|
|
35
|
+
present=set()
|
|
36
|
+
for f in root.rglob('*'):
|
|
37
|
+
if f.is_symlink():hold('SYMLINK_IN_ARTIFACT');continue
|
|
38
|
+
if f.is_file():present.add(f.relative_to(root).as_posix())
|
|
39
|
+
elif not f.is_dir():hold('UNEXPECTED_FILE_KIND')
|
|
40
|
+
if not ALLOWED.issubset(present):hold('REQUIRED_ARTIFACT_MISSING')
|
|
41
|
+
if not present.issubset(ALLOWED|VENDOR):hold('UNEXPECTED_ARTIFACT_FILE')
|
|
42
|
+
if present&VENDOR and present&VENDOR!=VENDOR:hold('INCOMPLETE_THREE_VENDOR')
|
|
43
|
+
if issues:
|
|
44
|
+
return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':0}
|
|
45
|
+
checksum_lines=(root/'SHA256SUMS.txt').read_text(encoding='utf-8').splitlines()
|
|
46
|
+
expected={}
|
|
47
|
+
for line in checksum_lines:
|
|
48
|
+
m=re.fullmatch(r'([0-9a-f]{64}) (.+)',line)
|
|
49
|
+
if not m:hold('INVALID_SHA256_MANIFEST');continue
|
|
50
|
+
h,relative=m.groups()
|
|
51
|
+
p=PurePosixPath(relative)
|
|
52
|
+
if p.is_absolute() or '..' in p.parts or relative in expected or '\\' in relative:
|
|
53
|
+
hold('UNSAFE_OR_DUPLICATE_MANIFEST_PATH');continue
|
|
54
|
+
expected[relative]=h
|
|
55
|
+
if set(expected)!=present-{'SHA256SUMS.txt'}:hold('SHA256_MANIFEST_COVERAGE_FAILURE')
|
|
56
|
+
for name,h in expected.items():
|
|
57
|
+
p=root/name
|
|
58
|
+
if p.is_file() and hashlib.sha256(p.read_bytes()).hexdigest()!=h:
|
|
59
|
+
hold('ARTIFACT_HASH_MISMATCH')
|
|
60
|
+
if issues:
|
|
61
|
+
return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':len(expected)}
|
|
62
|
+
# All issued preview resources are compiler-owned, not customer-authored JS.
|
|
63
|
+
# Even a rehashed output must not silently replace the approved R9 renderer.
|
|
64
|
+
for name in ('viewer.js','three_scene.js','r9_transport.mjs','flow_geometry.mjs','fallback_scene.js','operations_ui.js','runtime_feed.js','style.css','facility2d_core.mjs','facility2d_app.mjs','facility2d_simulation.mjs','facility2d.css','equipment_controls.mjs'):
|
|
65
|
+
if (root/'viewer'/name).read_bytes()!=(ROOT/'assets'/name).read_bytes():
|
|
66
|
+
hold('RENDERER_SOURCE_NOT_EQUAL_TO_ISSUED_COMPILER')
|
|
67
|
+
if issues:
|
|
68
|
+
return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':len(expected)}
|
|
69
|
+
try:
|
|
70
|
+
source=_parse_json(root/'contracts/project.json')
|
|
71
|
+
compiled=_parse_json(root/'contracts/compiled-scene.json')
|
|
72
|
+
validation=_parse_json(root/'reports/static-validation.json')
|
|
73
|
+
inventory=_parse_json(root/'reports/fat-inventory.json')
|
|
74
|
+
build=_parse_json(root/'BUILD.json')
|
|
75
|
+
design=_parse_json(root/'contracts/design-system.json')
|
|
76
|
+
operations_policy=_parse_json(root/'contracts/operations-policy.json')
|
|
77
|
+
engineering_review=_parse_json(root/'reports/engineering-review.json')
|
|
78
|
+
commands=_parse_json(root/'reports/control-readiness.json')
|
|
79
|
+
except (UnicodeError,ValueError,OSError) as e:
|
|
80
|
+
return {'status':'HOLD','purpose':purpose,'issues':['INVALID_ARTIFACT_JSON'],'detail':type(e).__name__}
|
|
81
|
+
fresh=qualify(source)
|
|
82
|
+
if fresh['status']!='PASS_STATIC_CONTRACT_ONLY' or validation!=fresh:hold('STATIC_VALIDATION_NOT_REPRODUCIBLE')
|
|
83
|
+
if compiled!=enrich(source):hold('COMPILED_SCENE_NOT_REPRODUCIBLE')
|
|
84
|
+
if commands!=control_plan(compiled):hold('COMMAND_INTENTS_NOT_REPRODUCIBLE')
|
|
85
|
+
if commands.get('runtime_command_authority')!='NONE' or commands.get('plc_opcua_writes')!='DISABLED' or any(x.get('enabled') is not False for x in commands.get('devices',[])):hold('UNAUTHORIZED_DEVICE_COMMAND_CLAIM')
|
|
86
|
+
if engineering_review!=expert_review(source):hold('ENGINEERING_REVIEW_NOT_REPRODUCIBLE')
|
|
87
|
+
if engineering_review.get('production')!='HOLD' or engineering_review.get('native_ignition_bindings')!='NOT_VERIFIED':
|
|
88
|
+
hold('ENGINEERING_REVIEW_UNSAFE_CLAIM')
|
|
89
|
+
if engineering_review.get('role_asset_mismatches'):hold('ENGINEERING_ROLE_ASSET_KIND_MISMATCH')
|
|
90
|
+
if build.get('industrial_knowledge_library')!=LIBRARY_VERSION or build.get('engineering_review')!='STATIC_INSTRUMENTATION_REVIEW_ONLY':
|
|
91
|
+
hold('INDUSTRIAL_KNOWLEDGE_LIBRARY_PROVENANCE_HOLD')
|
|
92
|
+
from .operations import policy as compute_operations_policy
|
|
93
|
+
try:
|
|
94
|
+
if operations_policy != compute_operations_policy(source):hold('OPERATIONS_POLICY_NOT_REPRODUCIBLE')
|
|
95
|
+
except (ValueError,KeyError,TypeError):hold('OPERATIONS_POLICY_INVALID')
|
|
96
|
+
if inventory.get('cases')!=fat_inventory(compiled) or inventory.get('execution')!='NOT_RUN':
|
|
97
|
+
hold('FAT_INVENTORY_NOT_REPRODUCIBLE')
|
|
98
|
+
if any(c.get('result')!='HOLD_NOT_EXECUTED' for c in inventory.get('cases',[])):
|
|
99
|
+
hold('UNSUPPORTED_FAT_PASS_CLAIM')
|
|
100
|
+
if design!=json.loads((ROOT/'design-system.json').read_text(encoding='utf8')):
|
|
101
|
+
hold('INVALID_DESIGN_SYSTEM')
|
|
102
|
+
if design.get('id')!='devagent_spatial_v1':hold('DESIGN_IDENTITY_MISMATCH')
|
|
103
|
+
if build.get('studio_to_hmi_runtime')!='READ_ONLY_AFTER_EXACT_MANIFEST_AND_PROTOCOL_PROBE' or build.get('hmi_runtime_source')!='ISOLATED_STUDIO_LAB_ONLY_NOT_IGNITION':
|
|
104
|
+
hold('STUDIO_HMI_SOURCE_AUTHORITY_CONTRACT_CHANGED')
|
|
105
|
+
if build.get('operations_mapping_digest')!=operations_policy.get('mapping_digest') or build.get('operations_intelligence')!='SOURCE_QUALIFIED_LAB_REPLAY_ONLY':
|
|
106
|
+
hold('OPERATIONS_POLICY_BINDING_UNSAFE')
|
|
107
|
+
if build.get('status')!='PASS_STATIC_BUILD_ONLY' or build.get('lab_controls')!='DISABLED' or build.get('device_control_intents')!='PRESENT_LOCKED_UNTIL_VERIFIED':
|
|
108
|
+
hold('BUILD_CAPABILITY_CLAIM_UNSAFE')
|
|
109
|
+
if build.get('ignition_project_export')!='NOT_IMPLEMENTED' or build.get('live_opcua')!='NOT_VERIFIED' or build.get('ignition_hmi')!='NOT_VERIFIED':
|
|
110
|
+
hold('UNSUPPORTED_RUNTIME_CERTIFICATION')
|
|
111
|
+
embedded=(root/'viewer/index.html').read_text(encoding='utf-8')
|
|
112
|
+
match=re.search(r'<script\s+type="application/json"\s+id="project-data">(.*?)</script>',embedded,re.S)
|
|
113
|
+
try:
|
|
114
|
+
if match is None or json.loads(match.group(1))!=compiled:
|
|
115
|
+
hold('PREVIEW_MODEL_MISMATCH')
|
|
116
|
+
except ValueError:
|
|
117
|
+
hold('PREVIEW_MODEL_INVALID')
|
|
118
|
+
embedded_ops=re.search(r'<script\s+type="application/json"\s+id="operations-policy">(.*?)</script>', embedded, re.S)
|
|
119
|
+
try:
|
|
120
|
+
if embedded_ops is None or json.loads(embedded_ops.group(1))!=operations_policy:
|
|
121
|
+
hold('OPERATIONS_POLICY_PREVIEW_MISMATCH')
|
|
122
|
+
except ValueError:hold('OPERATIONS_POLICY_PREVIEW_INVALID')
|
|
123
|
+
embedded_controls=re.search(r'<script\s+type="application/json"\s+id="control-plan">(.*?)</script>',embedded,re.S)
|
|
124
|
+
try:
|
|
125
|
+
if embedded_controls is None or json.loads(embedded_controls.group(1))!=commands:hold('DEVICE_CONTROL_PLAN_PREVIEW_MISMATCH')
|
|
126
|
+
except ValueError:hold('DEVICE_CONTROL_PLAN_INVALID')
|
|
127
|
+
expected_html=(ROOT/'assets'/('facility2d_viewer.html' if source['layout']=='standard_2d_flow' else 'operator_viewer.html')).read_bytes().replace(
|
|
128
|
+
b'__EMBEDDED_PROJECT__', json_embed(compiled).encode('utf-8')).replace(
|
|
129
|
+
b'__EMBEDDED_OPERATIONS_POLICY__', json_embed(operations_policy).encode('utf-8')).replace(
|
|
130
|
+
b'__EMBEDDED_CONTROL_PLAN__', json_embed(commands).encode('utf-8'))
|
|
131
|
+
if (root/'viewer/index.html').read_bytes()!=expected_html:
|
|
132
|
+
hold('COMPILED_VIEW_HTML_NOT_EQUAL_TO_ISSUED_COMPILER')
|
|
133
|
+
if build.get('design_variant',source['layout'])!=source['layout']:hold('DESIGN_VARIANT_MISMATCH')
|
|
134
|
+
if (present&VENDOR==VENDOR)!=bool(build.get('three_vendor_bundled')):
|
|
135
|
+
hold('RENDERER_MANIFEST_MISMATCH')
|
|
136
|
+
if purpose=='production':
|
|
137
|
+
hold('NATIVE_IGNITION_COMPILER_NOT_IMPLEMENTED')
|
|
138
|
+
hold('LIVE_OPCUA_HMI_FAT_NOT_VERIFIED')
|
|
139
|
+
hold('CUSTOMER_ACCEPTANCE_NOT_VERIFIED')
|
|
140
|
+
return {
|
|
141
|
+
'schema':'devagent.spatial.artifact_qualification.v2',
|
|
142
|
+
'status':'HOLD' if issues else 'PASS_CONCEPT_PREVIEW_ONLY',
|
|
143
|
+
'purpose':purpose,
|
|
144
|
+
'issues':sorted(set(issues)),
|
|
145
|
+
'files_checked':len(expected),
|
|
146
|
+
'equipment_count':len(compiled.get('equipment',[])),
|
|
147
|
+
'signals_proposed':len(compiled.get('signals',[])),
|
|
148
|
+
'fat_unexecuted':len(inventory.get('cases',[])),
|
|
149
|
+
'ignition_native':'NOT_VERIFIED',
|
|
150
|
+
'physical_mapping':'NOT_VERIFIED',
|
|
151
|
+
'runtime_controls':'DISABLED',
|
|
152
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Reject ambiguous/unserializable JSON before it enters engineering models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
class ContractJSONError(ValueError):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def loads(raw: str | bytes):
|
|
11
|
+
def pairs(items):
|
|
12
|
+
obj = {}
|
|
13
|
+
for k, v in items:
|
|
14
|
+
if k in obj:
|
|
15
|
+
raise ContractJSONError('DUPLICATE_JSON_KEY: '+k[:80])
|
|
16
|
+
obj[k] = v
|
|
17
|
+
return obj
|
|
18
|
+
|
|
19
|
+
def finite(value):
|
|
20
|
+
n = float(value)
|
|
21
|
+
if not math.isfinite(n):
|
|
22
|
+
raise ContractJSONError('NONFINITE_JSON_NUMBER')
|
|
23
|
+
return n
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(raw, object_pairs_hook=pairs, parse_float=finite,
|
|
27
|
+
parse_constant=lambda val: (_ for _ in ()).throw(ContractJSONError('NONFINITE_JSON_CONSTANT')))
|
|
28
|
+
except (RecursionError, OverflowError) as exc:
|
|
29
|
+
raise ContractJSONError('JSON_COMPLEXITY_LIMIT') from exc
|