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 ADDED
@@ -0,0 +1,3 @@
1
+ """Check industrial tag data before you trust it."""
2
+
3
+ __version__ = "0.1.1"
tagaudit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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