tagaudit 0.1.1__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.
- tagaudit/__init__.py +3 -0
- tagaudit/__main__.py +3 -0
- tagaudit/audit.py +347 -0
- tagaudit/cli.py +149 -0
- tagaudit/infer.py +91 -0
- tagaudit/opcua.py +316 -0
- tagaudit/report.py +162 -0
- tagaudit/series.py +447 -0
- tagaudit-0.1.1.dist-info/METADATA +183 -0
- tagaudit-0.1.1.dist-info/RECORD +14 -0
- tagaudit-0.1.1.dist-info/WHEEL +5 -0
- tagaudit-0.1.1.dist-info/entry_points.txt +2 -0
- tagaudit-0.1.1.dist-info/licenses/LICENSE +201 -0
- tagaudit-0.1.1.dist-info/top_level.txt +1 -0
tagaudit/__init__.py
ADDED
tagaudit/__main__.py
ADDED
tagaudit/audit.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Offline checks for industrial tag data. Standard library only; no network or actuation.
|
|
2
|
+
|
|
3
|
+
This file also runs on its own (`python audit.py --profile ... --csv ... --output ...`)
|
|
4
|
+
for air-gapped machines. OPC UA timestamps and quality codes are interpreted under
|
|
5
|
+
an explicit profile.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import csv
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections import Counter, defaultdict
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from decimal import Decimal, ROUND_FLOOR
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
SCHEMA = "tagaudit.profile.v1"
|
|
22
|
+
LEGACY_SCHEMAS = frozenset({"forge.telemetry.audit.v1"})
|
|
23
|
+
REPORT_SCHEMA = "tagaudit.report.v1"
|
|
24
|
+
QUALITY_ENCODINGS = ("opcua_status_code", "opc_da_quality", "quality_words")
|
|
25
|
+
CSV_FIELDS = ("capture_id", "asset_id", "sequence", "tag", "value", "unit", "status_code",
|
|
26
|
+
"source_timestamp", "server_timestamp", "received_timestamp", "unit_origin")
|
|
27
|
+
MAX_ROWS = 100_000
|
|
28
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
29
|
+
NANOSECONDS = 1_000_000_000
|
|
30
|
+
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuditError(ValueError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _number(value, name):
|
|
38
|
+
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
|
39
|
+
raise AuditError(f"{name}: finite number required")
|
|
40
|
+
try:
|
|
41
|
+
result = float(value)
|
|
42
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
43
|
+
raise AuditError(f"{name}: finite number required") from exc
|
|
44
|
+
if not math.isfinite(result):
|
|
45
|
+
raise AuditError(f"{name}: finite number required")
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _integer(value, name):
|
|
50
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)) or not re.fullmatch(r"[0-9]+", str(value)):
|
|
51
|
+
raise AuditError(f"{name}: nonnegative integer required")
|
|
52
|
+
result = int(value)
|
|
53
|
+
if result > 2**63-1:
|
|
54
|
+
raise AuditError(f"{name}: integer too large")
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _text(value, name):
|
|
59
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 256:
|
|
60
|
+
raise AuditError(f"{name}: nonempty text required")
|
|
61
|
+
return value.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def timestamp(value):
|
|
65
|
+
if not isinstance(value, str) or not re.fullmatch(
|
|
66
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})", value
|
|
67
|
+
):
|
|
68
|
+
raise AuditError("timestamp: timezone-aware ISO 8601 required")
|
|
69
|
+
# Python 3.10 parses only 3- or 6-digit fractions; nanoseconds are kept separately.
|
|
70
|
+
text = re.sub(r"\.(\d{1,9})", lambda m: "." + m.group(1)[:6].ljust(6, "0"), value.replace("Z", "+00:00"))
|
|
71
|
+
try:
|
|
72
|
+
return datetime.fromisoformat(text).astimezone(timezone.utc)
|
|
73
|
+
except (ValueError, OverflowError) as exc:
|
|
74
|
+
raise AuditError("timestamp: invalid date/time") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _timestamp_nanoseconds(value):
|
|
78
|
+
"""Exact supported UTC instant; datetime remains the date/offset validator."""
|
|
79
|
+
parsed = timestamp(value)
|
|
80
|
+
fraction = re.search(r"\.(\d{1,9})(?:Z|[+-]\d{2}:\d{2})$", value)
|
|
81
|
+
nanoseconds = int(fraction.group(1).ljust(9, "0")) if fraction else 0
|
|
82
|
+
whole = parsed.replace(microsecond=0) - EPOCH
|
|
83
|
+
return (whole.days * 86400 + whole.seconds) * NANOSECONDS + nanoseconds
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _limit_nanoseconds(seconds):
|
|
87
|
+
return int((Decimal(str(seconds)) * NANOSECONDS).to_integral_value(rounding=ROUND_FLOOR))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _canonical_receiver_time(value):
|
|
91
|
+
instant = _timestamp_nanoseconds(value)
|
|
92
|
+
whole = timestamp(value).replace(microsecond=0).isoformat(timespec="seconds")
|
|
93
|
+
return whole.removesuffix("+00:00") + f".{instant % NANOSECONDS:09d}+00:00"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_profile(profile):
|
|
97
|
+
allowed = {"schema", "asset_id", "freshness_basis", "quality_encoding", "max_age_seconds",
|
|
98
|
+
"max_future_seconds", "max_snapshot_skew_seconds", "tags"}
|
|
99
|
+
if (not isinstance(profile, dict) or set(profile) - allowed
|
|
100
|
+
or (profile.get("schema") != SCHEMA and profile.get("schema") not in LEGACY_SCHEMAS)):
|
|
101
|
+
raise AuditError("unsupported profile schema or fields")
|
|
102
|
+
result = dict(profile)
|
|
103
|
+
result["asset_id"] = _text(profile.get("asset_id"), "asset_id")
|
|
104
|
+
if profile.get("freshness_basis") not in {"source", "server"}:
|
|
105
|
+
raise AuditError("freshness_basis must explicitly be source or server")
|
|
106
|
+
if profile.get("quality_encoding") not in QUALITY_ENCODINGS:
|
|
107
|
+
raise AuditError("quality_encoding must explicitly be one of " + ", ".join(QUALITY_ENCODINGS))
|
|
108
|
+
for key in ("max_age_seconds", "max_future_seconds", "max_snapshot_skew_seconds"):
|
|
109
|
+
result[key] = _number(profile.get(key), key)
|
|
110
|
+
if result[key] < 0 or (key != "max_future_seconds" and result[key] == 0):
|
|
111
|
+
raise AuditError(f"{key}: invalid limit")
|
|
112
|
+
tags = profile.get("tags")
|
|
113
|
+
if not isinstance(tags, list) or not 1 <= len(tags) <= 64:
|
|
114
|
+
raise AuditError("tags must contain 1 to 64 numeric channels")
|
|
115
|
+
seen, normalized = set(), []
|
|
116
|
+
for tag in tags:
|
|
117
|
+
if not isinstance(tag, dict) or set(tag) - {"name", "unit", "minimum", "maximum", "source_node", "units_node"}:
|
|
118
|
+
raise AuditError("invalid tag fields")
|
|
119
|
+
item = dict(tag)
|
|
120
|
+
item["name"] = _text(tag.get("name"), "tag.name")
|
|
121
|
+
item["unit"] = _text(tag.get("unit"), "tag.unit")
|
|
122
|
+
if item["name"] in seen:
|
|
123
|
+
raise AuditError("duplicate tag name")
|
|
124
|
+
seen.add(item["name"])
|
|
125
|
+
for key in ("minimum", "maximum"):
|
|
126
|
+
item[key] = None if tag.get(key) is None else _number(tag[key], key)
|
|
127
|
+
if item["minimum"] is not None and item["maximum"] is not None and item["minimum"] >= item["maximum"]:
|
|
128
|
+
raise AuditError("tag minimum must be below maximum")
|
|
129
|
+
for key in ("source_node", "units_node"):
|
|
130
|
+
if key in tag:
|
|
131
|
+
item[key] = _text(tag[key], key)
|
|
132
|
+
normalized.append(item)
|
|
133
|
+
result["tags"] = normalized
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _da_quality_checks(value):
|
|
138
|
+
"""OPC Classic (DA) quality: the low byte is QQSSSSLL, so 192 (0xC0) is good and 0 is bad."""
|
|
139
|
+
text = str(value).strip()
|
|
140
|
+
if isinstance(value, bool) or not re.fullmatch(r"(?:0[xX][0-9a-fA-F]{1,4}|[0-9]{1,5})", text):
|
|
141
|
+
return ["invalid_quality"], [], None
|
|
142
|
+
code = int(text, 16 if text.lower().startswith("0x") else 10)
|
|
143
|
+
if code > 0xFFFF or code & 0xC0 == 0x80:
|
|
144
|
+
return ["invalid_quality"], [], None
|
|
145
|
+
quality = code & 0xC0
|
|
146
|
+
reasons = [] if quality == 0xC0 else ["quality_uncertain" if quality == 0x40 else "quality_bad"]
|
|
147
|
+
warnings = ["local_override"] if code & 0xFC == 0xD8 else []
|
|
148
|
+
if code & 0x03:
|
|
149
|
+
warnings.append(("limit_low", "limit_high", "limit_constant")[(code & 0x03) - 1])
|
|
150
|
+
return reasons, warnings, code
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def status_checks(value, encoding):
|
|
154
|
+
warnings = []
|
|
155
|
+
if encoding == "opc_da_quality":
|
|
156
|
+
return _da_quality_checks(value)
|
|
157
|
+
if encoding == "quality_words":
|
|
158
|
+
quality = str(value).strip().lower()
|
|
159
|
+
if quality not in {"good", "uncertain", "bad"}:
|
|
160
|
+
return ["invalid_quality"], [], None
|
|
161
|
+
warnings.append("status_detail_unavailable")
|
|
162
|
+
return ([] if quality == "good" else ["quality_"+quality]), warnings, None
|
|
163
|
+
if isinstance(value, bool):
|
|
164
|
+
return ["invalid_status_code"], [], None
|
|
165
|
+
text = str(value).strip()
|
|
166
|
+
try:
|
|
167
|
+
if not re.fullmatch(r"(?:0[xX][0-9a-fA-F]{1,8}|[0-9]+)", text):
|
|
168
|
+
raise ValueError
|
|
169
|
+
code = int(text, 16 if text.lower().startswith("0x") else 10)
|
|
170
|
+
if not 0 <= code <= 0xFFFFFFFF:
|
|
171
|
+
raise ValueError
|
|
172
|
+
except ValueError:
|
|
173
|
+
return ["invalid_status_code"], [], None
|
|
174
|
+
severity = code >> 30
|
|
175
|
+
reasons = [] if severity == 0 else ["quality_uncertain" if severity == 1 else "quality_bad"]
|
|
176
|
+
if code & 0xC000:
|
|
177
|
+
reasons.append("metadata_changed_requires_review")
|
|
178
|
+
if code & 0x30000000:
|
|
179
|
+
reasons.append("reserved_status_bits")
|
|
180
|
+
if code & 0x480 == 0x480:
|
|
181
|
+
reasons.append("queue_overflow")
|
|
182
|
+
if code and not reasons:
|
|
183
|
+
warnings.append("nonzero_good_status_review")
|
|
184
|
+
return reasons, warnings, code
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_csv(content, max_rows=MAX_ROWS, max_bytes=MAX_BYTES):
|
|
188
|
+
if not isinstance(content, str) or len(content.encode("utf-8")) > max_bytes:
|
|
189
|
+
raise AuditError("CSV exceeds the input size limit")
|
|
190
|
+
try:
|
|
191
|
+
reader = csv.DictReader(io.StringIO(content.lstrip("\ufeff")), strict=True)
|
|
192
|
+
if not reader.fieldnames or len(set(reader.fieldnames)) != len(reader.fieldnames):
|
|
193
|
+
raise AuditError("CSV header is missing or duplicated")
|
|
194
|
+
if set(reader.fieldnames) != set(CSV_FIELDS):
|
|
195
|
+
raise AuditError("CSV header must match the documented long-format fields")
|
|
196
|
+
rows = []
|
|
197
|
+
for row in reader:
|
|
198
|
+
if None in row or any(value is None for value in row.values()):
|
|
199
|
+
raise AuditError("CSV row has the wrong number of fields")
|
|
200
|
+
rows.append(row)
|
|
201
|
+
if len(rows) > max_rows:
|
|
202
|
+
raise AuditError("CSV exceeds the row limit")
|
|
203
|
+
return rows
|
|
204
|
+
except csv.Error as exc:
|
|
205
|
+
raise AuditError("malformed CSV") from exc
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def audit_rows(profile, rows, max_rows=MAX_ROWS):
|
|
209
|
+
profile = validate_profile(profile)
|
|
210
|
+
tags = {tag["name"]: tag for tag in profile["tags"]}
|
|
211
|
+
max_age_ns = _limit_nanoseconds(profile["max_age_seconds"])
|
|
212
|
+
max_future_ns = _limit_nanoseconds(profile["max_future_seconds"])
|
|
213
|
+
max_skew_ns = _limit_nanoseconds(profile["max_snapshot_skew_seconds"])
|
|
214
|
+
results, snapshots = [], defaultdict(list)
|
|
215
|
+
previous = {}
|
|
216
|
+
for index, row in enumerate(rows, start=1):
|
|
217
|
+
if index > max_rows:
|
|
218
|
+
raise AuditError("row limit exceeded")
|
|
219
|
+
if not isinstance(row, dict):
|
|
220
|
+
raise AuditError("each row must be an object")
|
|
221
|
+
reasons, warnings = [], []
|
|
222
|
+
detail = {"row": index, "tag": str(row.get("tag", "")), "reasons": reasons, "warnings": warnings}
|
|
223
|
+
try:
|
|
224
|
+
capture = _text(row.get("capture_id"), "capture_id")
|
|
225
|
+
sequence = _integer(row.get("sequence"), "sequence")
|
|
226
|
+
asset = _text(row.get("asset_id"), "asset_id")
|
|
227
|
+
tag_name = _text(row.get("tag"), "tag")
|
|
228
|
+
if asset != profile["asset_id"]:
|
|
229
|
+
reasons.append("asset_mismatch")
|
|
230
|
+
tag = tags.get(tag_name)
|
|
231
|
+
if tag is None:
|
|
232
|
+
reasons.append("unknown_tag")
|
|
233
|
+
elif str(row.get("unit", "")).strip() != tag["unit"]:
|
|
234
|
+
reasons.append("unit_mismatch")
|
|
235
|
+
try:
|
|
236
|
+
value = _number(row.get("value"), "value")
|
|
237
|
+
detail["value"] = value
|
|
238
|
+
if tag and ((tag["minimum"] is not None and value < tag["minimum"])
|
|
239
|
+
or (tag["maximum"] is not None and value > tag["maximum"])):
|
|
240
|
+
reasons.append("outside_engineering_range")
|
|
241
|
+
except AuditError:
|
|
242
|
+
reasons.append("nonfinite_or_nonnumeric_value")
|
|
243
|
+
quality_reasons, quality_warnings, code = status_checks(row.get("status_code"), profile["quality_encoding"])
|
|
244
|
+
reasons.extend(quality_reasons)
|
|
245
|
+
warnings.extend(quality_warnings)
|
|
246
|
+
detail["status_code"] = code
|
|
247
|
+
times = {}
|
|
248
|
+
for key in ("source_timestamp", "server_timestamp", "received_timestamp"):
|
|
249
|
+
try:
|
|
250
|
+
times[key] = _timestamp_nanoseconds(row.get(key))
|
|
251
|
+
except AuditError:
|
|
252
|
+
reasons.append("invalid_"+key)
|
|
253
|
+
if "received_timestamp" in times:
|
|
254
|
+
received = times["received_timestamp"]
|
|
255
|
+
detail["received_timestamp"] = _canonical_receiver_time(row["received_timestamp"])
|
|
256
|
+
for kind in ("source", "server"):
|
|
257
|
+
ts = times.get(kind+"_timestamp")
|
|
258
|
+
if ts is not None:
|
|
259
|
+
age_ns = received-ts
|
|
260
|
+
age = age_ns / NANOSECONDS
|
|
261
|
+
detail[kind+"_age_seconds"] = age
|
|
262
|
+
detail[kind+"_age_nanoseconds"] = age_ns
|
|
263
|
+
if age_ns < -max_future_ns:
|
|
264
|
+
reasons.append(kind+"_timestamp_in_future")
|
|
265
|
+
if kind == profile["freshness_basis"] and age_ns > max_age_ns:
|
|
266
|
+
reasons.append("stale_"+kind+"_timestamp")
|
|
267
|
+
if profile["freshness_basis"] == "server" and detail.get("source_age_nanoseconds", 0) > max_age_ns:
|
|
268
|
+
warnings.append("source_unchanged_or_old_check_server_provenance")
|
|
269
|
+
key = (capture, asset, tag_name)
|
|
270
|
+
old = previous.get(key)
|
|
271
|
+
if old and sequence <= old[0]:
|
|
272
|
+
reasons.append("duplicate_or_reordered_sequence")
|
|
273
|
+
if old and received < old[1]:
|
|
274
|
+
reasons.append("receiver_time_went_backwards")
|
|
275
|
+
previous[key] = (max(sequence, old[0] if old else sequence), max(received, old[1] if old else received))
|
|
276
|
+
origin = str(row.get("unit_origin", "")).strip()
|
|
277
|
+
detail["unit_origin"] = origin
|
|
278
|
+
if origin not in {"server_property", "export_field"}:
|
|
279
|
+
reasons.append("unit_provenance_missing")
|
|
280
|
+
if origin == "export_field":
|
|
281
|
+
warnings.append("unit_is_export_declaration")
|
|
282
|
+
detail.update({"capture_id": capture, "sequence": sequence, "asset_id": asset, "tag": tag_name})
|
|
283
|
+
snapshots[(capture, sequence)].append(detail)
|
|
284
|
+
except AuditError:
|
|
285
|
+
reasons.append("malformed_record_identity")
|
|
286
|
+
detail["passed"] = not reasons
|
|
287
|
+
results.append(detail)
|
|
288
|
+
snapshot_results = []
|
|
289
|
+
for (capture, sequence), entries in snapshots.items():
|
|
290
|
+
counts = Counter(entry["tag"] for entry in entries)
|
|
291
|
+
missing = sorted(set(tags)-set(counts))
|
|
292
|
+
duplicates = sorted(tag for tag, count in counts.items() if count > 1)
|
|
293
|
+
received = [_timestamp_nanoseconds(entry["received_timestamp"]) for entry in entries if "received_timestamp" in entry]
|
|
294
|
+
skew_ns = max(received)-min(received) if received else None
|
|
295
|
+
skew = skew_ns / NANOSECONDS if skew_ns is not None else None
|
|
296
|
+
coherent = skew_ns is not None and skew_ns <= max_skew_ns
|
|
297
|
+
snapshot_results.append({"capture_id": capture, "sequence": sequence, "missing_tags": missing,
|
|
298
|
+
"duplicate_tags": duplicates, "received_skew_seconds": skew,
|
|
299
|
+
"received_skew_nanoseconds": skew_ns, "skew_within_limit": coherent,
|
|
300
|
+
"passed": not missing and not duplicates and coherent and all(entry["passed"] for entry in entries)})
|
|
301
|
+
passed = sum(row["passed"] for row in results)
|
|
302
|
+
unbounded = [tag["name"] for tag in profile["tags"] if tag["minimum"] is None and tag["maximum"] is None]
|
|
303
|
+
not_evaluated = ([{"check": "engineering_range", "tags": unbounded, "reason": "no minimum or maximum declared"}]
|
|
304
|
+
if unbounded else [])
|
|
305
|
+
return {"schema": REPORT_SCHEMA, "asset_id": profile["asset_id"], "not_evaluated": not_evaluated,
|
|
306
|
+
"profile_sha256": hashlib.sha256(json.dumps(profile, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest(),
|
|
307
|
+
"time_basis": "recorded receiver timestamps; not a claim about current live freshness",
|
|
308
|
+
"freshness_basis": profile["freshness_basis"], "rows": results, "snapshots": snapshot_results,
|
|
309
|
+
"summary": {"rows": len(results), "passed_rows": passed, "rejected_rows": len(results)-passed,
|
|
310
|
+
"snapshots": len(snapshot_results), "passed_snapshots": sum(x["passed"] for x in snapshot_results),
|
|
311
|
+
"reason_counts": dict(Counter(reason for row in results for reason in row["reasons"])),
|
|
312
|
+
"checks_passed": bool(results) and passed == len(results) and bool(snapshot_results) and all(x["passed"] for x in snapshot_results)},
|
|
313
|
+
"scope": "metadata/profile contract checks; no sensor-correctness, authenticated-source, model-calibration or actuation claim"}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def audit_csv(profile, content, max_rows=MAX_ROWS, max_bytes=MAX_BYTES):
|
|
317
|
+
if not isinstance(content, str):
|
|
318
|
+
raise AuditError("CSV must be decoded text")
|
|
319
|
+
content = content.lstrip("\ufeff")
|
|
320
|
+
report = audit_rows(profile, parse_csv(content, max_rows, max_bytes), max_rows)
|
|
321
|
+
report["csv_sha256"] = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
322
|
+
report["hash_basis"] = "UTF-8 decoded CSV text with leading BOM removed, original line endings; canonical normalized profile JSON"
|
|
323
|
+
return report
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main(argv=None):
|
|
327
|
+
parser = argparse.ArgumentParser(description="Audit engineering-unit telemetry locally; no network connection.")
|
|
328
|
+
parser.add_argument("--profile", type=Path, required=True)
|
|
329
|
+
parser.add_argument("--csv", type=Path, required=True)
|
|
330
|
+
parser.add_argument("--output", type=Path, required=True)
|
|
331
|
+
args = parser.parse_args(argv)
|
|
332
|
+
try:
|
|
333
|
+
if args.csv.stat().st_size > MAX_BYTES or args.profile.stat().st_size > 256_000:
|
|
334
|
+
raise AuditError("input file exceeds the size limit")
|
|
335
|
+
profile = json.loads(args.profile.read_text(encoding="utf-8"))
|
|
336
|
+
raw = args.csv.read_bytes()
|
|
337
|
+
report = audit_csv(profile, raw.decode("utf-8-sig"))
|
|
338
|
+
report["input_file_sha256"] = hashlib.sha256(raw).hexdigest()
|
|
339
|
+
args.output.write_text(json.dumps(report, indent=2, allow_nan=False), encoding="utf-8")
|
|
340
|
+
print(json.dumps(report["summary"]))
|
|
341
|
+
return 0 if report["summary"]["checks_passed"] else 2
|
|
342
|
+
except (AuditError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
343
|
+
parser.exit(1, f"Audit failed: {exc}\n")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
raise SystemExit(main())
|
tagaudit/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""tagaudit check | init | collect
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 all checks passed, 2 checks ran and found problems, 1 the input or
|
|
4
|
+
profile could not be read.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import csv
|
|
10
|
+
import hashlib
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .audit import CSV_FIELDS, MAX_BYTES, MAX_ROWS, QUALITY_ENCODINGS, AuditError, audit_csv
|
|
18
|
+
from .infer import infer_profile
|
|
19
|
+
from .report import finalize, render_text
|
|
20
|
+
from .series import DEFAULT_EXAMPLES, audit_series
|
|
21
|
+
|
|
22
|
+
NO_PROFILE_NOTE = ("No profile given: units were compared within the file only; ranges, quality, gaps and "
|
|
23
|
+
"stuck values were not checked. Write an editable profile with "
|
|
24
|
+
"`tagaudit init FILE -o profile.json`, then run `tagaudit check FILE --profile profile.json`.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Parser(argparse.ArgumentParser):
|
|
28
|
+
def error(self, message):
|
|
29
|
+
self.print_usage(sys.stderr)
|
|
30
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parser():
|
|
34
|
+
parser = _Parser(prog="tagaudit", description="Check industrial tag data before you trust it.")
|
|
35
|
+
parser.add_argument("--version", action="version", version=f"tagaudit {__version__}")
|
|
36
|
+
commands = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
|
|
37
|
+
|
|
38
|
+
check = commands.add_parser("check", help="audit a CSV export or capture")
|
|
39
|
+
check.add_argument("file", type=Path, help="CSV file: historian export (long or wide) or native capture")
|
|
40
|
+
check.add_argument("--profile", type=Path, help="profile JSON; without one, tagaudit infers a minimal one")
|
|
41
|
+
check.add_argument("--output", "-o", type=Path, help="write the JSON report to this file")
|
|
42
|
+
check.add_argument("--json", action="store_true", help="print the JSON report instead of text")
|
|
43
|
+
check.add_argument("--full", action="store_true", help="keep every row in the JSON report")
|
|
44
|
+
check.add_argument("--examples", type=int, default=DEFAULT_EXAMPLES, help="examples kept per problem")
|
|
45
|
+
check.add_argument("--max-rows", type=int, help=f"stop after this many rows (native default {MAX_ROWS:,})")
|
|
46
|
+
check.add_argument("--max-mb", type=int, default=MAX_BYTES // 2**20, help="native capture size limit in MB")
|
|
47
|
+
|
|
48
|
+
init = commands.add_parser("init", help="write a starter profile from an export")
|
|
49
|
+
init.add_argument("file", type=Path)
|
|
50
|
+
init.add_argument("--output", "-o", type=Path, help="profile path (default: print to stdout)")
|
|
51
|
+
init.add_argument("--asset-id")
|
|
52
|
+
init.add_argument("--quality-encoding", choices=QUALITY_ENCODINGS)
|
|
53
|
+
init.add_argument("--timezone", help="zone for timestamps without a UTC offset, e.g. UTC or Europe/Berlin")
|
|
54
|
+
|
|
55
|
+
commands.add_parser("collect", help="read OPC UA values into a capture and audit it (needs tagaudit[opcua])",
|
|
56
|
+
add_help=False)
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _open(path):
|
|
61
|
+
return open(path, encoding="utf-8-sig", newline="")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _sha256(path):
|
|
65
|
+
digest = hashlib.sha256()
|
|
66
|
+
with open(path, "rb") as handle:
|
|
67
|
+
for block in iter(lambda: handle.read(1 << 20), b""):
|
|
68
|
+
digest.update(block)
|
|
69
|
+
return digest.hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _profile(path):
|
|
73
|
+
try:
|
|
74
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise AuditError(f"profile is not valid JSON: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _native_header(path):
|
|
80
|
+
with _open(path) as stream:
|
|
81
|
+
header = next(csv.reader(stream), [])
|
|
82
|
+
return {name.strip() for name in header} == set(CSV_FIELDS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _check(args):
|
|
86
|
+
notes = []
|
|
87
|
+
if args.profile:
|
|
88
|
+
profile = _profile(args.profile)
|
|
89
|
+
else:
|
|
90
|
+
with _open(args.file) as stream:
|
|
91
|
+
profile, _ = infer_profile(stream)
|
|
92
|
+
notes.append(NO_PROFILE_NOTE)
|
|
93
|
+
detail = "full" if args.full else "examples"
|
|
94
|
+
if isinstance(profile, dict) and "input" in profile:
|
|
95
|
+
with _open(args.file) as stream:
|
|
96
|
+
report = audit_series(profile, stream, examples=args.examples, detail=detail, max_rows=args.max_rows)
|
|
97
|
+
else:
|
|
98
|
+
if not _native_header(args.file):
|
|
99
|
+
raise AuditError("this profile has no input section, so the file must be a native capture. "
|
|
100
|
+
"For a historian export, write a profile with `tagaudit init FILE -o profile.json`.")
|
|
101
|
+
content = args.file.read_bytes().decode("utf-8-sig")
|
|
102
|
+
report = audit_csv(profile, content, max_rows=args.max_rows or MAX_ROWS, max_bytes=args.max_mb * 2**20)
|
|
103
|
+
report = finalize(report, examples=args.examples, full=args.full, source=args.file.name,
|
|
104
|
+
input_sha256=_sha256(args.file))
|
|
105
|
+
if args.output:
|
|
106
|
+
args.output.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
|
107
|
+
print(json.dumps(report, indent=2, allow_nan=False) if args.json
|
|
108
|
+
else render_text(report, notes=notes, output=args.output))
|
|
109
|
+
return 0 if report["summary"]["checks_passed"] else 2
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _init(args):
|
|
113
|
+
with _open(args.file) as stream:
|
|
114
|
+
profile, notes = infer_profile(stream, asset_id=args.asset_id, quality_encoding=args.quality_encoding,
|
|
115
|
+
timezone=args.timezone)
|
|
116
|
+
text = json.dumps(profile, indent=2) + "\n"
|
|
117
|
+
if args.output:
|
|
118
|
+
args.output.write_text(text, encoding="utf-8")
|
|
119
|
+
notes.append(f"Profile written to {args.output}.")
|
|
120
|
+
else:
|
|
121
|
+
sys.stdout.write(text)
|
|
122
|
+
sys.stderr.write("\n".join(notes) + "\n")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _collect(argv):
|
|
127
|
+
if importlib.util.find_spec("asyncua") is None:
|
|
128
|
+
raise AuditError("the OPC UA collector needs the optional extra: pip install 'tagaudit[opcua]'")
|
|
129
|
+
from . import opcua
|
|
130
|
+
try:
|
|
131
|
+
return opcua.main(argv, prog="tagaudit collect")
|
|
132
|
+
except SystemExit as exc:
|
|
133
|
+
# The collector's argparse exits 2 on usage errors; here 2 means "checks failed".
|
|
134
|
+
return 1 if exc.code == 2 or not isinstance(exc.code, int) else exc.code
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv=None):
|
|
138
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
139
|
+
try:
|
|
140
|
+
if argv[:1] == ["collect"]:
|
|
141
|
+
return _collect(argv[1:])
|
|
142
|
+
try:
|
|
143
|
+
args = _parser().parse_args(argv)
|
|
144
|
+
except SystemExit as exc:
|
|
145
|
+
return exc.code if isinstance(exc.code, int) else 1
|
|
146
|
+
return _check(args) if args.command == "check" else _init(args)
|
|
147
|
+
except (AuditError, OSError, UnicodeError) as exc:
|
|
148
|
+
print(f"tagaudit: error: {exc}", file=sys.stderr)
|
|
149
|
+
return 1
|
tagaudit/infer.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Write a starter profile from an export.
|
|
2
|
+
|
|
3
|
+
It records the layout, tag names and the units seen in the file. Ranges, the quality
|
|
4
|
+
encoding and the time zone are left for a person to declare, because guessing them
|
|
5
|
+
would make those checks prove nothing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
from collections import Counter
|
|
11
|
+
|
|
12
|
+
from .audit import SCHEMA, AuditError
|
|
13
|
+
from .series import MAX_TAGS, detect_format, parse_time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _timestamp_format(sample):
|
|
17
|
+
if sample is None or parse_time(sample, "iso", None)[0] is not None:
|
|
18
|
+
return "iso", None
|
|
19
|
+
try:
|
|
20
|
+
number = float(sample)
|
|
21
|
+
except ValueError:
|
|
22
|
+
return "iso", ("Timestamps aren't ISO 8601: set input.timestamp_format to a strptime pattern "
|
|
23
|
+
"such as %d/%m/%Y %H:%M:%S.")
|
|
24
|
+
return ("epoch_ms" if abs(number) > 1e11 else "epoch_s"), None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def infer_profile(stream, *, asset_id=None, quality_encoding=None, timezone=None):
|
|
28
|
+
"""Return (profile, notes). Notes tell the user what to declare next."""
|
|
29
|
+
reader = csv.reader(stream, strict=True)
|
|
30
|
+
try:
|
|
31
|
+
header = [name.strip() for name in next(reader)]
|
|
32
|
+
except StopIteration:
|
|
33
|
+
raise AuditError("the export is empty") from None
|
|
34
|
+
except csv.Error as exc:
|
|
35
|
+
raise AuditError(f"malformed CSV header: {exc}") from exc
|
|
36
|
+
layout = detect_format(header)
|
|
37
|
+
if layout["format"] == "native":
|
|
38
|
+
raise AuditError("this is a native capture; check it with the profile it was collected with (--profile PROFILE)")
|
|
39
|
+
at = {name: index for index, name in enumerate(header)}
|
|
40
|
+
sample, order, units = None, [], {}
|
|
41
|
+
try:
|
|
42
|
+
for cells in reader:
|
|
43
|
+
if len(cells) != len(header) or not any(cell.strip() for cell in cells):
|
|
44
|
+
continue
|
|
45
|
+
sample = sample or cells[at[layout["timestamp"]]].strip()
|
|
46
|
+
if layout["format"] == "wide":
|
|
47
|
+
break
|
|
48
|
+
name = cells[at[layout["tag"]]].strip()
|
|
49
|
+
if name and name not in units:
|
|
50
|
+
if len(order) == MAX_TAGS:
|
|
51
|
+
raise AuditError(f"more than {MAX_TAGS} tags; split the export")
|
|
52
|
+
order.append(name)
|
|
53
|
+
units[name] = Counter()
|
|
54
|
+
if name and "unit" in layout and cells[at[layout["unit"]]].strip():
|
|
55
|
+
units[name][cells[at[layout["unit"]]].strip()] += 1
|
|
56
|
+
except csv.Error as exc:
|
|
57
|
+
raise AuditError(f"malformed CSV near line {reader.line_num}: {exc}") from exc
|
|
58
|
+
if layout["format"] == "wide":
|
|
59
|
+
order = list(layout["columns"])
|
|
60
|
+
if not order:
|
|
61
|
+
raise AuditError("no tag values found in the export")
|
|
62
|
+
|
|
63
|
+
fmt, format_note = _timestamp_format(sample)
|
|
64
|
+
tags, notes = [], []
|
|
65
|
+
for name in order:
|
|
66
|
+
tag = {"name": name}
|
|
67
|
+
if units.get(name):
|
|
68
|
+
tag["unit"] = units[name].most_common(1)[0][0]
|
|
69
|
+
if len(units[name]) > 1:
|
|
70
|
+
notes.append(f"{name} uses more than one unit in the export ({', '.join(sorted(units[name]))}); "
|
|
71
|
+
f"wrote the most common one.")
|
|
72
|
+
tags.append(tag)
|
|
73
|
+
spec = {key: value for key, value in layout.items() if key != "columns"}
|
|
74
|
+
spec.update(timezone=timezone, timestamp_format=fmt)
|
|
75
|
+
profile = {"schema": SCHEMA}
|
|
76
|
+
if asset_id:
|
|
77
|
+
profile["asset_id"] = asset_id
|
|
78
|
+
profile.update(quality_encoding=quality_encoding, input=spec, tags=tags)
|
|
79
|
+
|
|
80
|
+
notes.insert(0, f"Found {len(tags)} tags in a {layout['format']} export.")
|
|
81
|
+
notes.append("Add minimum and maximum to each tag to check engineering ranges.")
|
|
82
|
+
if "quality" in layout and quality_encoding is None:
|
|
83
|
+
notes.append(f"Quality column '{layout['quality']}' found: set quality_encoding to opcua_status_code, "
|
|
84
|
+
"opc_da_quality (192 = good) or quality_words. tagaudit won't guess it.")
|
|
85
|
+
if format_note:
|
|
86
|
+
notes.append(format_note)
|
|
87
|
+
elif sample and timezone is None and fmt == "iso" and parse_time(sample, "iso", None)[2]:
|
|
88
|
+
notes.append("Timestamps have no UTC offset: set input.timezone (UTC, +04:00 or a zone such as "
|
|
89
|
+
"Europe/Berlin) to catch daylight-saving problems.")
|
|
90
|
+
notes.append("Optional: set max_gap_seconds and max_repeats to find gaps and stuck values.")
|
|
91
|
+
return profile, notes
|