# OT AIops — Energy edition [Health: Active]

**Category:** 🏭 Industrial & IoT  
**Repository:** https://github.com/industrial-aiops/industrial-aiops-energy  
**GitHub Stars:** 1  
**Views:** 1  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/ot-aiops-energy-edition

## Description
Industrial-AIOps energy edition: IEC-104/DNP3/IEC-61850 read-only OT connectors on iaiops.core.

## Tools
Capabilities this server exposes over MCP:

- **protocols_supported** — [READ][risk=low] Capability map — protocols, status, tools, connection params.

    Call this to discover what iaiops can do before choosing a protocol/tool.
    Lists implemented protocols (OPC-UA incl. HDA, Modbus, S7comm, Mitsubishi MC,
    MTConnect, MQTT/Sparkplug B full-decode, EtherNet/IP Logix) and the EtherCAT
    roadmap stub, plus cross-protocol analytics (OEE/downtime, asset inventory,
    CoV), each with its read/write tools and the endpoint params it needs.

    Also reports whether this server runs under the no-egress gate, so a model is
    TOLD the posture instead of having to infer it from tools it cannot see.
    Read/write authorisation is NOT a server posture here — it is the caller's
    decision; every call (read or write, MCP or CLI) is audited.

    Returns dict: {tool, posture, implemented_protocols:[...], roadmap_stubs:[...],
        protocols:[{protocol, status, library, transport, auth, read_tools,
        write_tools, params}], diagnostics:[...], analytics:[...], tool_counts,
        safety, write_note, no_egress_mode, no_egress_note}.

    Example: protocols_supported().
    
- **site_readiness** — [READ][risk=low] Which scenarios THIS site can run today, and what each gap needs.

    The companion to `protocols_supported`, one altitude down. That one says what
    the product can do; this says what this installation can do — and calling the
    first without the second is how an agent plans a scenario the site has no
    inputs for.

    Contacts nothing: no device, no network, no historian. It is derived from
    `config.yaml` and the local store, which is what makes it runnable against a
    site nobody has authorised you to probe — the site that most needs it.

    Three states, and the middle one carries the value: `ready`, `degraded` (it
    RUNS, on less than full evidence — root cause without a historian still ranks
    causes, it just cannot see the two hours before the stoppage) and `blocked`.

    `blocked_on` is the actionable half: one missing input usually unlocks several
    scenarios, ranked by how many.

    It never fills a gap in for you. Which tag is the production counter is
    process knowledge, and a wrong guess yields plausible-looking OEE numbers —
    considerably worse than an error (D16). Where a prerequisite cannot be
    supplied at all yet, the row says `not_yet_expressible` rather than implying
    somebody forgot to configure it.

    `db` overrides the local store path (.db/.sqlite, under $IAIOPS_HOME, the
    config dir or an IAIOPS_AGENT_READ_DIRS dir); empty means the iaiops store.
    
- **onboarding_status** — [READ][risk=low] Where this site is on the path from a network to an answer.

    One altitude below `site_readiness`. That one lists every scenario and what
    each is waiting for; this one answers the smaller, earlier question a caller
    actually has first — **which journey this site is on, and which single command
    comes next** on it. There are two, with different first steps:

        devices — no UNS yet: scan → endpoints → point list → what each point
                  MEANS → collect → ask the question
        uns     — data already in an MQTT/UNS broker: connect → audit the
                  namespace → choose points → MEANS → collect → ask

    `track` is `auto` (derived from config.yaml), `devices` or `uns`. On `uns` a
    stored namespace audit forks it into B1 (audited clean) and B2 (governance is
    the work); until an audit is stored the result says B1/B2 is unknown rather
    than assuming either. When nothing is configured, or both kinds of endpoint
    are, the single step is a question with two `choices` — relay both to the
    caller and pick neither; the files on disk cannot say which kind of site this
    is, and neither can you.

    Derived from the local store and `config.yaml` every time; there is no
    onboarding state file, so a site that edits config.yaml by hand or restores a
    backup still gets a true answer rather than a remembered one.

    Every step carries its own `command`, including the ones still waiting. Show
    the caller the one under `next_command`; the rest are context, not a to-do
    list, and handing someone five commands is the same as handing them none.

    A step that is genuinely done stays `done` even when it sits behind the
    cursor — a site that hand-wrote config.yaml before ever scanning has real
    endpoints, and reporting otherwise to keep the sequence tidy would be a lie
    in the direction that makes this tool look more necessary.

    Contacts nothing. `db` overrides the local store path (.db/.sqlite, under
    $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir); empty means the
    iaiops store.
    
- **onboarding_config_draft** — [READ][risk=low] The config.yaml endpoints a stored scan can justify.

    Closes the gap where a site scanned forty devices and then retyped all forty
    by hand. Reads a stored scan; writes nothing. `config.yaml` is edited by a
    person, exactly as with `iaiops tags apply`.

    **Every drafted field says whether the scan established it.** A field with
    `value: null` was NOT established, and its `caution` says what it is waiting
    for — do not fill one in and do not drop it. Dropping it lets the protocol
    default apply in silence, which is how a Modbus gateway gets read at unit 1
    and shows a confident number for the wrong machine. Carry the cautions to
    whoever merges this; they are the content, not decoration.

    **Only CONFIRMED protocols become endpoints.** An open port means something
    is listening, not that it speaks the protocol; those hosts appear under
    `skipped` with the reason. `limits` states what this draft structurally
    cannot contain — a protocol's absence here is not evidence of its absence at
    the site.

    `tags` is always empty and this tool will not fill it. A scan finds devices;
    which point means run state or good count is process knowledge, and a wrong
    production counter yields a plausible OEE, which is worse than an error
    (D16). That confirmation is a person's, via `iaiops tags export` / `apply`.

    `scan_id` empty means the newest stored scan. `db` overrides the store
    (.db/.sqlite, under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir).
    
- **health_summary** — [DEPRECATED → opcua_health_summary][READ][risk=low] Classify OPC-UA tags.

    Classifies tag node-ids against warn/alarm thresholds. Returns
    ok/warn/alarm/unknown counts plus the offending tags. Thresholds
    come from config tags, or per-ref overrides in ``thresholds``.

    Args:
        endpoint: Endpoint name from config.
        node_ids: Tag node ids to evaluate; omit to use configured tags.
        thresholds: Optional {ref: {warn_high, alarm_high, warn_low, alarm_low}}.
    
- **anomaly_scan** — [DEPRECATED → opcua_anomaly_scan][READ][risk=low] Statistical outlier scan.

    Samples a node over a bounded window and flags statistical outliers.
    Computes mean/stddev/min/max and flags samples outside mean ± sigma*stddev.
    Simple statistics only — no ML, no persisted model.

    Args:
        node_id: The OPC-UA node id to scan.
        endpoint: Endpoint name from config.
        samples: Max samples (capped server-side).
        interval_ms: Delay between samples in milliseconds.
        sigma: Outlier band width in standard deviations.
    
- **diagnose_dataflow** — [READ][risk=low] Localize a 'no data' break across an endpoint's reachable hops.

    Probes connect → read(ref) → freshness → variance and returns a verdict with
    per-hop detail and a recommended action. The #1 OT triage: distinguishes
    "cannot connect" (network/PLC down) from "comms OK but value stale"
    (upstream/field/source) from "good status but flatline" (sensor stuck).

    Args:
        endpoint: Endpoint name from config (any protocol).
        ref: Tag/node/address/device to read (OPC-UA node id, Modbus address,
            S7 address string, MELSEC device). Omit to test connectivity only.
        freshness_threshold_s: Max value-age (seconds) before 'stale' (default 60).
        series: Optional injected samples (scalars or {value,timestamp}) for
            flatline/variance reasoning when a live historian is out of reach.
        flatline_eps: Spread at/below which a series counts as flatline.

    Returns dict: {verdict ('cannot_connect'|'comms_ok_value_unreadable'|
        'comms_ok_description_mismatch'|'comms_ok_bad_quality'|
        'comms_ok_value_stale'|'comms_ok_flatline'|'no_per_ref_read_in_this_build'|
        'no_connect_probe_in_this_build'|'healthy'), diagnosis,
        recommended_action, hops:[{hop, ok, detail}]}.
        'comms_ok_description_mismatch' means the sensor profile does not fit the
        device (wrong sensor fitted, other process-data layout, no profile): fix
        the configuration, not the field.

    Example: diagnose_dataflow(endpoint="line1", ref="ns=2;i=5", freshness_threshold_s=30).
    
- **historian_health** — [READ][risk=low] Bad-tag / flatline / gap detection over a provided series.

    Pure analysis over an injected sample series — no live historian needed.

    Args:
        series: Samples — scalars or {value, timestamp (ISO-8601), quality|good}.
        gap_threshold_s: Time gap (seconds) between consecutive samples that counts
            as a data gap (default 60).
        flatline_eps: Spread at/below which the series counts as flatline.

    Returns dict: {samples, numeric_samples, bad_quality_count, flatline (bool),
        gap_count, gaps:[{after, gap_seconds}], stdev,
        verdict ('ok'|'degraded'|'gappy'|'flatline'|'bad_tag')}.

    Example: historian_health(series=[{"value":10,"timestamp":"2026-06-28T10:00:00Z"}, ...]).
    
- **alarm_bad_actors** — [READ][risk=low] ISA-18.2 alarm-flood analysis over a list of alarm events.

    Args:
        events: Alarm/condition events — {source, timestamp (ISO-8601), priority?,
            state? (ACTIVE/RTN/ACK)}.
        window_minutes: Analysis window; omitted → inferred from event timestamps.
        chatter_window_s: A source with >=3 transitions inside this window chatters.
        standing_s: An alarm active longer than this is 'standing/stale' (default 24h).
        top_n: How many top offenders to return.

    Returns dict: {event_count, window_minutes, alarms_per_hour,
        isa_18_2:{ok_max:6, manageable_max:12, flood_min:30},
        flood_verdict ('ok'|'manageable'|'over_target'|'flood'),
        priority_distribution, pareto_sources_for_80pct, top_offenders:[{source,
        count, share_pct, chattering, standing}], chattering:[...], standing:[...]}.

    Example: alarm_bad_actors(events=[{"source":"FIC101","timestamp":"...",
        "priority":"high"}, ...]).
    
- **tag_health** — [READ][risk=low] Rank tag offenders by bad-quality / flatline / range / anomaly.

    Args:
        tags: Per-tag dicts — {ref, label?, samples:[scalars or {value, good|quality}],
            warn_high?, alarm_high?, warn_low?, alarm_low?}.
        thresholds: Optional {ref: {warn_high, alarm_high, warn_low, alarm_low}} override.

    Returns dict: {evaluated, overall ('ok'|'warn'|'alarm'), offender_count,
        offenders:[{ref, label, samples, latest, flags:[...], anomaly_count,
        severity (0..3)}], results:[...]}. Flags include bad_quality, flatline,
        out_of_range_warn/alarm, statistical_anomaly.

    Example: tag_health(tags=[{"ref":"ns=2;i=5","samples":[70,71,70,99]}]).
    
- **subscription_health** — [READ][risk=low] Health of a sequenced subscription feed (OPC-UA or Sparkplug B).

    Detects dropped notifications (sequence gaps), duplicates / out-of-order, a high
    republish-rejection rate, and overloaded channels — the classic Kepware
    "too many tags on one channel → republish/queue-flush dropouts" fault.

    Args:
        sequence: Sequence numbers actually received, in arrival order.
        republish_requested: How many republish requests were made.
        republish_rejected: How many were rejected (server couldn't keep up).
        tags_per_channel: {channel/endpoint: tag_count} — flags channels over the max.
        max_tags_per_channel: Density above which a channel is flagged (default 5000).
        wrap_at: Modulus for rolling counters (e.g. 256 for Sparkplug B seq); omit
            for monotonic OPC-UA counters.

    Returns dict: {received, missed_count, duplicate_count, out_of_order_count,
        republish_requested, republish_rejected, republish_reject_rate,
        overloaded_channels:[{channel, tags}], max_tags_per_channel,
        verdict ('ok'|'reordered'|'lossy'|'overloaded'), recommendation}.

    Example: subscription_health(sequence=[1,2,4,5], tags_per_channel={"ch1":7000}).
    
- **downtime_root_cause** — [READ][risk=low] AI downtime root-cause copilot — cited verdict, ADVISORY only.

    Correlates whatever evidence you supply around a downtime/incident window —
    alarm events, tag samples, a diagnose_dataflow verdict, a machine-state series —
    ranks candidate root causes, and cites the REAL signals behind each. Read-first:
    it proposes a human-approved, undoable (MOC-gated) action but executes nothing.
    Anti-hallucination: only signals present in the input are cited; thin evidence
    downgrades to 'insufficient_evidence' with a 'recommended_next_data' list rather
    than a confident guess. Confidence combines independent, time-correlated evidence
    (signals BEFORE onset outweigh signals during it).

    Args:
        window: {start (ISO-8601), end? (ISO-8601), asset?, category?}. If 'end' is
            omitted but state_series is given, the first running→stopped span bounds it.
        alarms: Alarm/condition events — {source, timestamp, message?, priority?, state?}.
        tags: Per-tag samples — {ref, samples:[scalars or {value, good|quality}],
            warn_high?, alarm_high?, ...} (scored via tag_health).
        dataflow: A diagnose_dataflow result dict (its 'verdict' localizes comms vs field).
        state_series: {timestamp, state} samples to bound the window if 'end' is absent.
        lead_window_s: How far before onset a signal may sit and still count as a cause
            (default 300s); signals after onset are treated as consequences.
        cause_weights: Optional per-site {cause: multiplier} override (e.g. from
            learn_cause_weights) — scales each cause's evidence (1.0 = neutral
            default) before the noisy-OR. Unknown causes / non-numeric weights are
            rejected; values are clamped. Omit for the shipped default weighting.
        include_graph: When true, also return a 'graph' block — the SAME verdict
            re-projected as a causal graph {nodes, edges, mermaid, meta} (signal →
            cause → downtime) for a frontend/Grafana. Pure re-shape: signal→cause
            edge weights are the evidence contribution scores, cause→symptom edge
            weights are the hypothesis confidences — no new reasoning. Omit for the
            flat verdict only (default).

    When a per-site 'historian:' block is configured (~/.iaiops/config.yaml, A7),
    the 2h pre-incident window is additionally pulled from that reader and scored
    as historian trend evidence — cited with its source ('historian:<name>'),
    window, and sample count. Without the config, behaviour is unchanged.

    Returns dict: {window, verdict ('root_cause_identified'|'multiple_candidates'|
        'insufficient_evidence'), primary_cause, hypotheses:[{cause, confidence (0..1),
        confidence_band, evidence:[{signal, ref, at?, lead_time_s?, detail, weight}],
        recommended_action}], evidence_summary, recommended_next_data?,
        anti_hallucination, graph? (when include_graph): {nodes:[{id, kind
        (signal|cause|symptom), label, score, ...}], edges:[{from, to, weight,
        relation (supports|attributed_to)}], mermaid, meta}}.

    Example: downtime_root_cause(window={"start":"2026-06-28T10:00:00Z","asset":"line1"},
        alarms=[{"source":"M1_DRIVE","timestamp":"2026-06-28T09:59:50Z",
                 "message":"motor overload trip"}], dataflow={"verdict":"healthy"}).
    
- **downtime_root_cause_live** — [READ][risk=low] AI downtime RCA copilot that GATHERS its own live evidence.

    Same advisory, read-only, evidence-cited contract as downtime_root_cause — but
    instead of hand-injecting evidence you give an endpoint + incident window and it
    pulls the evidence itself: a cross-protocol diagnose_dataflow probe, a short
    sampled series per ref (so flatline/bad-quality/anomaly surface via tag_health),
    and active OPC-UA conditions. Light read load; non-destructive; nothing executed.
    The gathered bundle is echoed under 'collected_evidence' (no hidden inputs).

    Args:
        endpoint: Endpoint name from config (any protocol). Omit for the default.
        window: {start (ISO-8601), end?, asset?, category?, freshness_threshold_s?}.
        refs: Tags/nodes/addresses to sample for this incident (first is also the
            diagnose_dataflow target). Capped at 20.
        sample_count: Reads per ref to build its series (1..60, default 8).
        interval_ms: Delay between reads (>=50ms, default 200).
        include_alarms: Surface active OPC-UA conditions as alarm evidence (OPC-UA only).
        lead_window_s: Causal lead window before onset (default 300s).
        include_graph: When true, also return the 'graph' block (same {nodes, edges,
            mermaid, meta} causal-graph re-projection as downtime_root_cause). Pure
            re-shape of the verdict; no new reasoning. Omit for the flat verdict.

    Returns dict: same shape as downtime_root_cause plus 'collected_evidence'
        {endpoint, protocol, refs_sampled, alarms_found, dataflow_verdict}.

    Example: downtime_root_cause_live(endpoint="line1",
        window={"start":"2026-06-28T10:00:00Z","asset":"line1"},
        refs=["ns=2;i=5","ns=2;i=6"]).
    
- **learn_cause_weights** — [READ][risk=low] Learn a per-site RCA {cause: weight} profile from history.

    Derives a per-site cause-weight profile from a corpus of CONFIRMED past
    incidents so downtime_root_cause adapts to what THIS site's evidence actually
    predicts. Pure + explainable: each weight is the smoothed signal→cause
    precision relative to chance (>1 = evidence for that cause is reliable here,
    <1 = often misleading) — no black box. Anti-overfit: Laplace smoothing + a
    per-cause min-sample guard, and a fall-back to the shipped defaults when the
    corpus is too thin. Feed the returned 'cause_weights' to downtime_root_cause's
    cause_weights argument. Advisory: it tunes ranking, never executes anything.

    Args:
        history: Confirmed incidents — [{cause, signals:[...]}] where 'cause' is the
            known root cause and 'signals' are the cause labels the evidence pointed
            at (both from the copilot taxonomy: mechanical_fault, comms_loss,
            sensor_fault, material_starvation, quality_reject, changeover, utility_fault).
        min_samples: Minimum confirmed incidents before adapting at all (default 8);
            below it the defaults are kept.
        smoothing: Laplace pseudo-count pulling each estimate toward chance (default 1.0).

    Returns dict: {cause_weights:{cause: multiplier}, n_incidents, per_cause:{cause:
        {support, hits, precision, weight, note}}, rationale}.

    Example: learn_cause_weights(history=[{"cause":"mechanical_fault",
        "signals":["mechanical_fault"]}, {"cause":"comms_loss","signals":["comms_loss"]}]).
    
- **rca_corpus_from_maintenance** — [READ][risk=low] Turn a CMMS/work-order export into the RCA incident corpus.

    Auto-builds the labeled history learn_cause_weights needs from closed maintenance
    records: an explicit taxonomy cause column wins; else a built-in EN/中文 CMMS
    synonym table (extendable via 'synonyms'); else UNAMBIGUOUS keyword inference
    over the row's free text using the copilot's own cause keywords. Rows it cannot
    map land in 'unmapped' with the reason — never silently guessed. 'signals' come
    from an explicit column or the symptom/alarm text (may stay empty — no fabricated
    evidence). Pure + advisory; with learn=true the learned weights are included.

    Args:
        rows: Work-order records, one dict each. Recognized cause columns:
            cause / root_cause / failure_class / category / problem_code; free-text
            columns: description / problem / notes / comment / text / 故障描述;
            signal text: symptom(s) / alarm(s) / 现象.
        synonyms: Extra site vocabulary, e.g. {"spindle crash": "mechanical_fault"};
            values must be taxonomy causes.
        learn: Also run learn_cause_weights on the mapped corpus (default true).
        min_samples: Passed to learn_cause_weights (default 8).
        smoothing: Passed to learn_cause_weights (default 1.0).

    Returns dict: {corpus:[{cause, signals}], n_rows, n_mapped, unmapped:[{row,
        reason, excerpt}], mapped_via, weights?, next_step}.

    Example: rca_corpus_from_maintenance(rows=[{"category":"轴承损坏",
        "symptom":"drive overload alarm"}], synonyms={"spindle crash":"mechanical_fault"}).
    
- **data_quality_scorecard** — [READ][risk=low] Fleet data-TRUST scorecard across endpoints' tag feeds.

    Scores each tag 0-100 on whether its data can be BELIEVED — staleness, dead
    heartbeat, bad-quality, flatline, gaps, anomaly — then rolls up per endpoint
    and across the fleet. NOT process health (it does not score whether a value is
    alarming, only whether it is trustworthy). Pure analysis over provided feeds.

    Args:
        feeds: Per-endpoint feeds — {endpoint, tags:[{ref, label?, samples:[scalars
            or {value, good|quality, timestamp?}], expected_update_s?, heartbeat?}]}.
        default_staleness_s: Max sample-age before 'stale' when a tag sets no
            expected_update_s (default 300).
        now: ISO-8601 reference time for staleness (deterministic); omit for now-UTC.

    Returns dict: {evaluated_endpoints, evaluated_tags, fleet_score (0-100),
        fleet_status, issue_breakdown{}, worst_endpoints[], worst_tags[],
        endpoints:[{endpoint, score, status, status_counts, worst_tag}]}.

    Example: data_quality_scorecard(feeds=[{"endpoint":"line1","tags":[{"ref":"hb",
        "heartbeat":true,"samples":[5,5,5,5]}]}]).
    
- **data_quality_fleet_rollup** — [READ][risk=low] Cross-endpoint fleet rollup of data-TRUST: worst tags + bad quality.

    Builds on data_quality_scorecard to give a fleet-wide view: endpoints ranked by
    their single worst tag, bad-quality tag counts aggregated across every endpoint,
    and a first-class liveness rollup (dead-heartbeat / flatline). Staleness and gap
    budgets are configurable per tag (staleness_s / gap_threshold_s) and per feed,
    so a slow daily counter is not judged like a 1Hz sensor. Pure analysis.

    Args:
        feeds: Per-endpoint feeds — {endpoint, staleness_s?, tags:[{ref, label?,
            samples:[scalars or {value, good|quality, timestamp?}], expected_update_s?,
            staleness_s?, gap_threshold_s?, flatline_after_s?, heartbeat?}]}.
        default_staleness_s: Fallback max sample-age (seconds) before 'stale' when a
            tag/feed sets no staleness_s/expected_update_s (default 300).
        now: ISO-8601 reference time for staleness (deterministic); omit for now-UTC.
        top_n: How many endpoints / bad-quality rows to return (default 10).

    Returns dict: {evaluated_endpoints, evaluated_tags, fleet_score (0-100),
        fleet_status, endpoints_ranked_by_worst_tag:[...], bad_quality_rollup:
        {total_bad_quality_tags, endpoints_affected, by_endpoint:[{endpoint,
        bad_quality_tags, fully_bad, partial_bad}]}, liveness_rollup:
        {dead_heartbeat_count, flatline_count, dead_heartbeats[], flatlines[]},
        issue_breakdown{}}.

    Example: data_quality_fleet_rollup(feeds=[{"endpoint":"line1","tags":[{"ref":"t",
        "samples":[{"value":None,"good":false}]}]}]).
    
- **heartbeat_health** — [READ][risk=low] Is a heartbeat/watchdog tag still alive? (liveness check).

    A heartbeat must keep CHANGING; a flatlined one means the upstream is dead even
    when comms/quality look fine. With timestamped samples + max_interval_s, also
    flags the longest stall.

    Args:
        series: Heartbeat samples — scalars or {value, timestamp?} (a counter/toggle).
        max_interval_s: Max allowed gap between changes; exceeding it = not alive.

    Returns dict: {alive (bool), samples, distinct_transitions, spread,
        longest_stall_s, reason}.

    Example: heartbeat_health(series=[1,2,3,4,5], max_interval_s=10).
    
- **downtime_attribution** — [READ][risk=low] Which stoppage started it, and which ones were downstream of it.

    RCA weights evidence by TIME alone — a signal before onset counts, one after
    counts less. That is the honest half of the axis. Run `downtime_root_cause`
    per asset after one upstream stop and every downstream machine comes back
    with its own confident local root cause: each internally consistent, each
    citing real signals, and all but one about a machine that stopped because it
    was starved. The distinguishing fact is not in the evidence — it is the
    line's topology.

    Two rules decide an attribution and both must hold: the candidate must be
    **declared upstream** of the asset, and it must have **stopped first**. An
    upstream asset that stopped later cannot have caused an earlier stop, however
    upstream it is.

    Topology is declared, never inferred (D25). Co-occurrence on a production
    line is guaranteed — everything stops together — so mining it for edges would
    manufacture the causality this exists to remove. With no relations declared
    every row comes back `not_evaluable` and the reason names the command that
    fixes it. Assets the topology does not connect are left `unattributed` rather
    than folded into the origin's column.

    This ranks the stoppages; it does not diagnose the origin. Run
    `downtime_root_cause` on the origin asset for that.

    Args:
        stoppages: [{asset, start, end?}] for one incident window (ISO-8601).
        site: Which declared line topology to use (default "default").
        max_lead_s: A downstream stop is attributed only if it began within this
            many seconds of the upstream one (default 900).

    Returns dict: {site, stoppages_evaluated, relations_declared, max_lead_s,
        verdict ('origin'|'multiple_origins'|'unattributed'|'not_evaluable'),
        origins, consequence_count, attributions:[{asset, start, status,
        origin_asset?, hops_upstream?, lead_s?, explains?, detail}], advisory}.

    Example: downtime_attribution(stoppages=[{"asset":"filler","start":"2026-01-05T06:00:00Z"},
        {"asset":"capper","start":"2026-01-05T06:01:00Z"}]).
    
- **alarm_flood_analysis** — [READ][risk=low] ISA-18.2 deep alarm-flood analysis: episodes + chattering + stale + advice.

    Deepens alarm_bad_actors: detects flood *episodes* (start/end/count/peak rate/
    top contributors + each episode's first-out annunciation, per ISA-18.2's >=10
    alarms per 10 min per operator), alarms chattering ACTIVE↔CLEARED, standing/
    stale alarms, and percent-time-in-flood vs the ISA-18.2 targets (~1-2 alarms/
    10 min steady state, <1% time in flood). Also returns an ISA-18.2 'load_profile'
    (per-bucket rate band + peak period + trend) and per-source 'suppression_advice'
    (deadband/on-off-delay for chatter, time-limited shelve for standing alarms).
    The suppression advice is ADVISORY ONLY — starting values for a human to review
    and approve via your ISA-18.2 / management-of-change process; this tool never
    applies suppression, shelving, deadband, or delay changes. Pass 'events' for
    pure analysis, or an endpoint to collect live via the same OPC-UA active-
    condition scan the RCA copilot uses (polled over duration_s; other protocols
    contribute no alarms). Output is bounded; 'truncated' flags say when caps bit.

    Args:
        endpoint: Endpoint name from config (used only when events is omitted).
        duration_s: Live collection window in seconds (1..300, default 60).
        window_s: Flood analysis window in seconds (ISA-18.2 default 600).
        threshold: Annunciations per window that start a flood (default 10).
        events: Injected alarm events — {source, timestamp (ISO-8601), state?
            (ACTIVE/RTN/CLEARED)}; skips live collection entirely.
        stale_after_s: Continuously-active age that marks a standing alarm (default 24h).
        max_episodes: Cap on returned flood episodes (default 20).
        max_rows: Cap on chattering / stale / suppression-advice / worksheet rows (default 50).
        load_bucket_s: Load-profile bucket width in seconds (ISA-18.2 default 600 = 10 min).

    Returns dict: {event_count, summary:{insufficient_data, percent_time_in_flood,
        avg_alarms_per_10min, peak_alarms_per_10min, isa_18_2_targets, ...},
        load_profile:{overall_band, peak_bucket, band_distribution, trend,
        busiest_buckets:[...], ...}, flood_episodes:[{start, end, ..., top_contributors,
        first_out:{source, ts}}], chattering:[{source, cycles, cycles_per_hour, ...}],
        stale_standing:[{source, active_since, active_for_s}], suppression_advice:[{source,
        kind, technique, suggested_on_delay_s, suggested_off_delay_s, suggested_shelve_max_s,
        basis, advisory}], worksheet_preview:[...], advisory_note, truncated:{...}, collected?}.

    Example: alarm_flood_analysis(events=[{"source":"FIC101",
        "timestamp":"2026-06-28T10:00:00Z","state":"ACTIVE"}, ...]).
    
- **alarm_cascade** — [READ][risk=low] Collapse an alarm flood into cascades + each cascade's first-out root.

    Answers "which alarm to look at first" when 100+ alarms hit in minutes: groups annunciations
    into cascades (a new cascade starts after a quiet gap > window_s) and reports the FIRST-OUT
    alarm (earliest in the burst) as the likely root, plus downstream members and any chattering
    sources. First-out is a transparent heuristic cited by timestamp — NOT causal (use
    downtime_root_cause for causality). Pass 'events' for pure analysis, or an endpoint to collect
    live via the OPC-UA active-condition scan. Read-only; bounded.

    Args:
        endpoint: Endpoint name from config (used only when events is omitted).
        duration_s: Live collection window in seconds (1..300, default 60).
        window_s: Quiet gap (seconds) that separates one cascade from the next (default 60).
        min_cascade: Minimum annunciations for a group to count as a cascade (default 2).
        events: Injected alarm events — {source, timestamp (ISO-8601), state?}; skips live collect.

    Returns dict: {cascade_count, total_activations, cascades:[{root:{source, ts}, size,
        distinct_sources, span_s, members[], chattering[]}], collected?}.

    Example: alarm_cascade(events=[{"source": "PT101", "timestamp": "2026-06-28T10:00:00Z"}, ...]).
    
- **alarm_rationalization_worksheet** — [READ][risk=low] ISA-18.2 alarm-rationalization worksheet (CSV or inline rows).

    One row per alarm source, count-descending: count, % of total annunciations,
    chattering?, flood contributor?, and a recommendation stub — the starting
    document for an ISA-18.2 rationalization review. Pass 'events' for pure
    analysis, or an endpoint to collect live via the same OPC-UA active-condition
    scan the RCA copilot uses. With out_path the full worksheet is written as CSV
    and the path returned; otherwise bounded inline rows (truncation noted).

    Args:
        endpoint: Endpoint name from config (used only when events is omitted).
        duration_s: Live collection window in seconds (1..300, default 60).
        events: Injected alarm events — {source, timestamp (ISO-8601), state?}.
        window_s: Flood analysis window in seconds (ISA-18.2 default 600).
        threshold: Annunciations per window that start a flood (default 10).
        out_path: Optional CSV destination; parent directory must exist.

    Returns dict: {row_count, columns:[alarm_id, count, pct_of_total, chattering,
        in_flood, recommendation], csv_path? , rows?:[...], truncated (bool)}.

    Example: alarm_rationalization_worksheet(events=[...], out_path="worksheet.csv").
    
- **alarm_event_clusters** — [READ][risk=low] Collapse ten phrasings of one fault into one row.

    `alarm_bad_actors` ranks by SOURCE, which answers "which instrument is
    noisiest" and not "which fault is noisiest". A plant that words one condition
    ten ways — `PT-101 HIGH`, `PT-102 HIGH`, `PT-103 high alarm` — gets ten bad
    actors and no sign that they are one problem, so a rationalization meeting
    works the list top-down and fixes the same thing three times. This groups the
    same events by what they SAY instead of by who said it.

    Clustering is **exact equality of a normalized string, not similarity**: case,
    punctuation and embedded numbers are removed, and what remains must match
    exactly. That is deliberately dumber than it could be, and it is why the
    result needs no model and can be checked — two messages land together only
    when they are literally the same sentence with the identifiers taken out.
    Every cluster carries the distinct wordings and sources it merged, so you can
    see what was combined. It does **not** claim two differently-worded alarms
    mean the same thing; a person decides that.

    Events carrying no message text are counted separately and excluded from the
    shares, rather than being lumped together as one type.

    Args:
        events: [{source?, message|description|text|condition|type, ...}].
        top_n: Clusters returned, largest first (default 20, capped at 100).
        min_count: Only report clusters with at least this many events (default 1 —
            a one-off is still reported, not tidied away).

    Returns dict: {events_supplied, events_clustered, events_without_text,
        cluster_count, collapsed_count, clusters:[{signature, count, share_pct,
        distinct_wordings, distinct_sources, variants:[{text, count}],
        sources:[{source, count}]}], note}.

    Example: alarm_event_clusters(events=[{"source":"PT-101","message":"pressure HIGH"},
        {"source":"PT-102","message":"Pressure high!"}]).
    
- **asset_inventory** — [READ][risk=low] Actively fingerprint endpoints into an asset register.

    Connects to each target with our own protocol client and reads its identity
    call (S7 CPU info, EtherNet/IP controller info, OPC-UA server build info,
    Modbus device identification FC43, Mitsubishi CPU type, MTConnect device
    model), aggregating vendor/model/firmware/serial per device.

    Honest scope: ACTIVE fingerprinting (we connect to each device), NOT passive
    SPAN/tap discovery. Only finds devices we are configured to reach.

    Args:
        endpoints: Endpoint names to fingerprint; omit to fingerprint ALL
            configured endpoints.

    Returns dict: {asset_count, reachable_count, unreachable_count, method:
        'active_fingerprint', assets:[{endpoint, protocol, address, vendor, model,
        firmware, serial, reachable, last_seen, error}]}.

    Example: asset_inventory(endpoints=["press1","cell5"]).
    
- **device_advisory_check** — [READ][risk=low] Which scanned devices fall inside a mounted advisory's stated range.

    `iaiops scan` already reads vendor / model / firmware / serial and then does
    nothing with them. This closes that loop — and deliberately stops short of
    where a vulnerability scanner would go.

    **It reports that a device falls inside an advisory's stated range. Nothing
    more.** Not "vulnerable", not "exploitable", no severity score. Whether a
    published issue is reachable on a particular machine depends on
    configuration, network position and compensating controls that a read-only
    scan cannot see — and in OT most advisories against a protocol stack are not
    findings at all, because the stack is not reachable from anywhere that
    matters. A report full of red text that ignores that gets switched off by the
    site, and then the real one is missed too.

    **No database ships with this.** A bundled CVE feed is a maintenance
    commitment this repo has not made, and a stale one that looks current is
    worse than none — so the library is a file the site controls, which also
    makes it work air-gapped. Every entry must carry a source, and one bad entry
    refuses the whole file rather than half-mounting it.

    Four verdicts, and the middle two are the point: `in_affected_range`,
    `version_unknown` (model matches, no firmware read — neither a hit nor a
    pass), `version_unparsed` (a firmware string it will not invent an ordering
    for), `not_affected`. A device no advisory mentions is **absent** from the
    findings, not reported clean: "nothing known" is not "nothing there".

    Args:
        devices: [{ip?, vendor, model, firmware?}] — e.g. the `hosts` of a scan.
        library_path: Path to the advisory file (.yaml/.yml/.json) this site
            mounted, under $IAIOPS_HOME, the config directory or a directory
            the operator lists in IAIOPS_AGENT_READ_DIRS (other paths are refused unread);
            entries are {id, vendor, model, source, affected_below|affected_from|
            affected_versions, title?}.

    Returns dict: {devices_checked, advisories_mounted, devices_with_findings,
        summary:{in_affected_range, version_unparsed, version_unknown,
        not_affected}, findings:[{ip, vendor, model, firmware, advisory_id, title,
        source, status, detail}], truncated, advisory_note, note}.

    Example: device_advisory_check(devices=[{"ip":"10.0.0.5","vendor":"Siemens",
        "model":"S7-1500","firmware":"2.8.1"}], library_path="~/advisories.yaml").
    
- **cross_protocol_asset_model** — [READ][risk=low] Fuse per-protocol tag feeds into ONE unified asset model.

    Unifies the two per-protocol tag models (OPC-UA address-space discovery +
    Modbus register templates) into one cross-protocol asset/tag/alias model. Tags
    are re-classified with the SAME semantic classifier the OPC-UA layer uses,
    grouped into assets ACROSS protocols (a ``Line1`` OPC-UA folder + a ``Line1``
    Modbus block become one asset), and each gets a canonical alias
    ``<site>.<asset>.<class_or_name>``. Advisory only — aliases are SUGGESTIONS,
    never a server-side rename (OT-dangerous).

    Args:
        feeds: List of per-protocol feeds, each
            ``{protocol, source, asset?, tags:[...]}``. ``tags`` may be OPC-UA
            discovery descriptors (from opcua_discover_tags), Modbus template tags
            (from modbus_apply_template), or already-normalized tags. A feed-level
            ``asset`` is applied to its tags that don't carry their own.
        site: Site prefix for canonical aliases (default 'site').

    Returns dict: {site, protocols, tag_count, asset_count, assets:[{asset,
        protocols, tag_count, classes, tags:[{protocol, source, name, ref, asset,
        unit, klass, canonical_alias, suggested_alias}]}], naming_quality:
        {alias_collisions, cross_protocol_overlaps, cryptic_names, verdict}}.

    Example: cross_protocol_asset_model(feeds=[
        {"protocol":"opcua","source":"line1","tags":[...]},
        {"protocol":"modbus","source":"meter1","asset":"Line1","tags":[...]}],
        site="plant").
    
- **adopt_alias_map** — [READ][risk=low][PERSIST] Adopt + persist the canonical alias map for a site.

    Writes a local owner-only advisory JSON file (NOT an OT-device write — hence
    risk=low); see the persistence note below.

    Runs the cross-protocol asset model over ``feeds``, extracts the adopted map
    ``{canonical_alias: {ref, protocol, asset, name, class}}``, and persists it as
    the site's baseline (owner-only JSON under the iaiops home). Re-running
    overwrites the baseline. Advisory — the map is a SUGGESTION, never a
    server-side rename (OT-dangerous).

    Args:
        feeds: Per-protocol tag feeds ``[{protocol, source, asset?, tags:[...]}]``,
            the SAME shape ``cross_protocol_asset_model`` takes.
        site: Site label (a safe file leaf: alphanumeric/_/-). Default 'site'.

    Returns dict: {site, path, tag_count, adopted:{alias: {...}}}.

    Example: adopt_alias_map(feeds=[{"protocol":"opcua","source":"l1","tags":[...]}],
        site="plant").
    
- **diff_alias_map** — [READ][risk=low] Diff a fresh discovery run against the adopted baseline.

    Loads the site's previously adopted alias map, re-runs the cross-protocol
    asset model over ``feeds``, and reports how the address space moved: tags
    added / removed / renamed (same ref, new alias) / reclassified (same ref+alias,
    new semantic class), plus a stable|changed verdict. Adopt a baseline first
    with ``adopt_alias_map``.

    Args:
        feeds: Fresh per-protocol tag feeds (same shape as adopt_alias_map).
        site: Site label whose baseline to diff against. Default 'site'.

    Returns dict: {site, verdict, counts:{added,removed,renamed,reclassified},
        added:[...], removed:[...], renamed:[...], reclassified:[...]}.

    Example: diff_alias_map(feeds=[{"protocol":"opcua","source":"l1","tags":[...]}],
        site="plant").
    
- **oee_compute** — [READ][risk=low] OEE = Availability × Performance × Quality (+ loss/energy depth).

    Args:
        planned_time_s: Planned production time (seconds).
        run_time_s: Actual running time (seconds) — planned minus downtime.
        ideal_cycle_time_s: Ideal/nameplate cycle time per part (seconds).
        total_count: Total parts produced.
        good_count: Good (non-reject) parts produced.
        breakdown_time_s: Optional — unplanned-stop seconds (splits availability loss).
        setup_time_s: Optional — changeover/setup seconds (splits availability loss).
        minor_stop_time_s: Optional — minor-stop seconds (splits performance loss;
            the remainder is speed loss).
        startup_reject_count: Optional — startup/warm-up rejects (splits quality
            loss; the remainder is production rejects).
        actual_kwh: Optional — measured energy for this run; enables the energy block.
        baseline_kwh: Optional — expected/baseline energy for the actual-vs-baseline
            deviation verdict.
        emission_factor_kg_per_kwh: Optional — carbon factor (kg CO2e/kWh). Default is
            a flagged placeholder (see the tool's carbon note); pass the grid's value.
        energy_tolerance: ± band (fraction) for the over/under/on-target verdict.

    Returns dict: OEE factors + oee/oee_pct + inputs + losses, plus
        ``six_big_losses`` (breakdown/setup/minor-stops/speed/startup/production-reject
        time-ladder that sums with OEE to 100%) and, when ``actual_kwh`` is given,
        ``energy`` (kwh_per_unit, carbon, and baseline deviation).

    Example: oee_compute(planned_time_s=28800, run_time_s=25200,
        ideal_cycle_time_s=2.0, total_count=12000, good_count=11800,
        setup_time_s=1800, actual_kwh=940, baseline_kwh=880).
    
- **downtime_events** — [READ][risk=low] Detect running→stopped transitions and categorize stoppages.

    Args:
        series: Timestamped samples — {timestamp (ISO-8601), state} where state is
            a string (RUNNING/IDLE/FAULT…), a bool, or a number.
        category_map: Optional {state_label: category} override (else keyword
            heuristics map to changeover/material/mechanical/quality/break/unknown).
        min_duration_s: Ignore stoppages shorter than this (seconds).

    Returns dict: {samples, event_count, total_downtime_s, by_category:{cat:
        {count, downtime_s}}, events:[{start, end, duration_s, state, category}]}.

    Example: downtime_events(series=[{"timestamp":"2026-06-28T08:00:00Z","state":"RUNNING"},
        {"timestamp":"2026-06-28T08:05:00Z","state":"FAULT"}, ...]).
    
- **oee_multidim** — [READ][risk=low] Aggregate OEE (+ optional energy) across dimensions.

    Args:
        records: Labelled records — {<dimension labels>, planned_time_s, run_time_s,
            ideal_cycle_time_s, total_count, good_count} plus optional actual_kwh /
            baseline_kwh to enable the energy rollup.
        dimensions: Dimension keys to group by (default ['machine','part','shift']);
            use ['shift'] for the classic by-shift energy comparison.
        emission_factor_kg_per_kwh: Optional carbon factor (kg CO2e/kWh); default is a
            flagged placeholder — pass the grid's published value.
        energy_tolerance: ± band (fraction) for the actual-vs-baseline verdict.

    Returns dict: {dimensions, group_count, mean_oee, worst_performers:[...],
        matrix:[{dimensions, oee, oee_pct, availability, performance, quality,
        energy?}]}. When any record carries energy, adds an ``energy_baseline`` block
        that flags cross-group deviation anomalies (tolerance + robust-outlier rules).

    Example: oee_multidim(records=[{"shift":"day","planned_time_s":28800,
        "run_time_s":25000,"ideal_cycle_time_s":2,"total_count":12000,
        "good_count":11800,"actual_kwh":940,"baseline_kwh":880}], dimensions=["shift"]).
    
- **monitor_changes** — [READ][risk=low] Capture only the value CHANGES of a point over a bounded window.

    Polls ``ref`` and returns only the changes (with timestamps), not every
    sample — the OT deadband-report pattern. Works across OPC-UA / Modbus / S7 /
    Mitsubishi MC / EtherNet/IP. Hard-capped by duration_s and max_changes (never
    an infinite loop).

    Args:
        ref: Point to watch — OPC-UA node id, Modbus address, S7 address string,
            MELSEC device, or Logix tag (per the endpoint's protocol).
        endpoint: Endpoint name from config.
        duration_s: Wall-clock window in seconds (1..120, capped server-side).
        interval_ms: Poll interval in milliseconds (>=50).
        deadband: Numeric change must exceed this to count (0 = any change).
        max_changes: Stop after this many changes (1..500, capped server-side).

    Returns dict: {endpoint, ref, duration_s, interval_ms, deadband, samples_polled,
        change_count, changes:[{value, previous, source_timestamp, wall_clock}]}.

    Example: monitor_changes(ref="ns=2;i=5", endpoint="line1", duration_s=20, deadband=0.5).
    
- **compliance_mapping** — [READ][risk=low] 《工控系统网络安全防护指南》 ↔ iaiops governance mapping.

    An honest onboarding/sales self-assessment across the pillars 分区隔离 / 可审计 /
    双向认证 / 最小权限 / 数据保护 / 自主可控. Each control names how iaiops addresses
    it, an honest status (addressed / partial / 待核实), and the remaining gap.

    Returns dict: {framework, frameworks[], pillars[], control_count, status_summary
        {addressed, partial, 待核实}, controls:[{pillar, requirement, iaiops, status,
        gap, crosswalk{dengbao, iec62443}}]}. See compliance_frameworks for the full
        cross-framework 对照.

    Example: compliance_mapping().
    
- **compliance_frameworks** — [READ][risk=low] 跨框架对照: 防护指南 ↔ 等保 2.0 ↔ IEC 62443 ↔ GxP ↔ NIS2 ↔ CRA.

    Omit ``framework`` for one row per governance pillar: the matching 《防护指南》
    requirement, 等保 2.0 control class, IEC 62443 FR, GxP clause, the NIS2 / CRA item
    refs that cite the pillar, and the iaiops status. Pass framework="nis2" (Directive
    (EU) 2022/2555 Art. 21(2)(a)-(j)) or "cra" (Regulation (EU) 2024/2847 Annex I) for
    that text item by item, each with an honest coverage: product_evidence (a cited,
    code-anchored mechanism produces relevant evidence — never "the measure is met"),
    gap, or organisational (product provides no evidence). NIS2 obliges entities, the
    CRA manufacturers — a product self-assessment, NOT a certification.

    Args:
        framework: None for the per-pillar crosswalk, or 'nis2' / 'cra'.

    Returns dict: per-pillar {frameworks[], framework_count, pillar_count,
        crosswalk:[{pillar, gjzn, dengbao, iec62443, gxp, nis2, nis2_refs, cra,
        cra_refs, iaiops_status}], note} — or for nis2/cra {framework, applies, scope,
        item_count, coverage_summary, items:[{id, ref, measure, coverage, iaiops, gap,
        mechanisms, mechanism_detail, pillars}], disclaimer, source}.

    Example: compliance_frameworks(framework="nis2").
    
- **compliance_dengbao_levels** — [READ][risk=low] 等保 2.0 二级 vs 三级 per-pillar deltas + honest iaiops posture.

    等保 2.0 (GB/T 22239) is graded — the same control tightens as the level rises.
    Per governance pillar this shows the 二级 baseline, what 三级 additionally requires,
    and how far iaiops moves you toward it (with the honest per-control status/gap).
    An onboarding/self-assessment aid, NOT a certification.

    Args:
        level: Focus on one level — 'l2'/'l3', '二级'/'三级', or '2'/'3'. Omit for both.

    Returns dict: {framework, levels:[{id,name,note}], selected_level, pillar_count,
        deltas:[{pillar, l2_requires?, l3_adds?, iaiops, iaiops_status, gap}], note}.

    Example: compliance_dengbao_levels(level="三级").
    
- **compliance_report** — [READ][risk=low] Render the 等保 2.0 / IEC 62443 or NIS2 / CRA report (Markdown).

    Turns the compliance crosswalk into a deliverable document a CISO can read:
    title-page metadata (site / date / iaiops version), per-pillar 等保 L2/L3 status
    table, IEC 62443 FR1–6 crosswalk, honest gap list, and a governance-controls
    appendix (audit hash chain / approval tokens / dry-run+undo / mTLS). With
    framework='nis2' / 'cra' / 'eu' it renders instead the English NIS2 Art. 21 /
    CRA Annex I self-assessment (product evidence and gaps, organisational measures
    separated, cited mechanisms with code anchors). An onboarding/self-assessment
    aid, NOT a certification.

    Args:
        level: 等保 2.0 target level — 'l2'/'l3', '二级'/'三级', '2'/'3'. Omit for both.
            Refused together with an EU framework.
        site: Site / plant name stamped on the title page.
        out_path: Optional file to write the markdown to (.md). Required when the
            report exceeds the inline bound (~400 lines): without it the inline
            markdown is truncated with a note.
        framework: Omit for the 等保 report; 'nis2', 'cra' or 'eu' (both).

    Returns dict: {format, framework, level, line_count, path?} plus either the full inline
        {markdown} (when within bounds and no out_path) or {markdown (truncated),
        truncated: true} with a hint to pass out_path.

    Example: compliance_report(level="三级", site="示例水厂",
        out_path="/tmp/compliance-report.md").
    
- **compliance_evidence_bundle** — [READ][risk=low] Export the audit-evidence bundle (zip) for an auditor.

    Packages the governance evidence trail into one deterministic zip:
    audit_rows.jsonl (secrets already redacted upstream), chain_verification.json
    (SHA-256 hash-chain walk result), rules.yaml (if present), doctor_summary.json
    (non-probing config/secret-store facts), and manifest.json. Path is validated
    (no '..' traversal; parent created 0700).

    Args:
        out_path: Destination zip path (must end in .zip).
        since: Optional ISO-8601 floor on the audit row timestamp (inclusive).
        until: Optional ISO-8601 ceiling on the audit row timestamp (inclusive).

    Returns dict: {path, row_count, chain{ok, checked, unhashed, ...}, files[],
        since, until}.

    Example: compliance_evidence_bundle(out_path="/tmp/evidence.zip",
        since="2026-06-01T00:00:00+00:00").
    
- **historian_push** — [WRITE][risk=low][→historian] Push collected telemetry to a national TSDB.

    Writes already-collected points to a domestic historian (信创) — TDengine or
    IoTDB — instead of binding InfluxDB. Data egress to the operator's OWN database,
    NOT a control-system write. Non-numeric points are skipped (numeric value column).

    Args:
        points: Collected points — {ref|metric, value|present_value, timestamp?, ...}
            (e.g. the output of interrogate / integrity_poll / read_points / monitor).
        sink: 'tdengine' or 'iotdb'.
        host/port/user/password: TSDB connection params (sensible defaults per sink
            when blank/0).
        database: Target database (TDengine db / IoTDB storage group, e.g. 'root.iaiops');
            for 'sqlite' a .db file under $IAIOPS_HOME, the config directory or an
            IAIOPS_AGENT_READ_DIRS directory (empty = the iaiops store).

    Returns dict: {sink, received, written, skipped_non_numeric, database}.

    Example: historian_push(points=[{"ref":"line1.temp","value":21.5}], sink="tdengine",
        host="10.0.0.20", database="iaiops").
    
- **export_data** — [READ][risk=low] Export collected samples from the LOCAL SQLite sink to a file.

    Source is ~/.iaiops/data.db — the local queryable store written by
    historian_push(sink="sqlite") — NOT a live device read. Writes csv (Excel),
    sqlite (SQL browser / Power BI) or parquet (pandas/Spark; needs
    pip install 'iaiops[export]'), and returns the file path + row count with a
    bounded inline preview (first 200 rows max) so the response never floods.

    Args:
        fmt: 'csv' | 'sqlite' | 'parquet'.
        since/until: Optional ISO-8601 time bounds (inclusive).
        endpoint: Only samples from this endpoint label.
        tag: Only samples for this tag.
        limit: Max rows exported (1..100000; default 10000).
        out_path: Output file; default ~/.iaiops/exports/iaiops-export-<ts>.<ext>.

    Returns dict: {format, path, rows, preview_rows:[{ts, endpoint, protocol, tag,
        value, quality, unit}] (≤200), preview_truncated}.

    Example: export_data(fmt="csv", tag="line1.temp", since="2026-07-01T00:00:00").
    
- **baseline_learn** — [READ][risk=low] Learn a conservative per-tag normal band from local history.

    Source is ~/.iaiops/data.db — the local store written by
    historian_push(sink="sqlite") — NOT a live device read. Learns robust
    percentiles (p1/p99 + median/MAD, no ML) from the tag's own samples,
    segmented at the latest change recorded via baseline_record_change (the band
    reflects only the post-change regime). REFUSES with an explicit
    insufficient_data verdict (listing exactly what is missing) below 100 usable
    samples or under 24h of span — it never invents a band from thin data. On
    success the band is persisted to ~/.iaiops/baselines.json (owner-only local
    metadata, not an OT write).

    Args:
        tag: Tag name to learn, e.g. 'line1.temp'.
        endpoint: Only samples from this endpoint label.
        since: Only samples at/after this ISO-8601 time.

    Returns dict: {status: 'ok'|'insufficient_data', tag, band:{p1,p99,median,mad},
        n_samples, window:{from_ts,to_ts,span_s}, segment, missing?:[...], note}.

    Example: baseline_learn(tag="line1.temp", since="2026-06-01T00:00:00").
    
- **baseline_check** — [READ][risk=low] Check recent local samples against the learned baseline.

    Reads the last window_s seconds from ~/.iaiops/data.db (no device I/O) and
    judges them against the stored band. Conservative by design: a violation is
    reported ONLY when values are beyond p1/p99 by more than 3×MAD AND sustained
    for >=3 consecutive samples — a single spike is never flagged. Every
    violation cites the baseline window (from/to ts, n samples), the band
    values, and the offending samples' timestamps/values. No stored baseline →
    an explicit no_baseline answer (never a guess). Bounded output (<=10
    violations, <=20 cited samples each).

    Args:
        tag: Tag name to check, e.g. 'line1.temp'.
        endpoint: Only samples from this endpoint label.
        window_s: Recent window to check, seconds (60..604800; default 3600).

    Returns dict: {status: 'ok'|'violation'|'no_baseline', tag, checked_samples,
        thresholds, baseline_citation, violations:[{direction, from_ts, to_ts,
        consecutive_samples, samples:[{ts,value}], baseline}], note}.

    Example: baseline_check(tag="line1.temp", window_s=7200).
    
- **baseline_record_change** — [READ][risk=low] Record an operator change-log entry for a tag (local only).

    Writes ONLY local metadata (~/.iaiops/baselines.json, owner-only) — never an
    OT device write, hence risk=low. A recorded change (setpoint moved, valve
    replaced, probe swapped) marks a regime boundary: the next baseline_learn
    uses only samples AFTER the latest change, so the band never mixes
    pre-change and post-change behavior. This operator change log — not a
    black-box score — is what makes the baseline trustworthy.

    Args:
        tag: Tag whose process changed, e.g. 'line1.temp'.
        note: What changed (required), e.g. 'setpoint 60→70C'.

    Returns dict: {tag, change:{ts, note}, changes_recorded}.

    Example: baseline_record_change(tag="line1.temp", note="setpoint 60→70C").
    
- **baseline_status** — [READ][risk=low] Baseline status for one tag, or a bounded listing of all.

    Read from the local store only (no history scan, no device I/O) and never
    guesses: 'no_baseline' (nothing learned, no refused attempt), 'learning'
    (last learn refused — still accumulating history), 'ok' (band learned, last
    check clean), 'violation' (last check flagged a sustained excursion). With
    no tag, lists every tracked tag (bounded to 100 entries).

    Args:
        tag: Optional tag name; omit to list all tracked tags.

    Returns dict: {tag, status, band?, baseline_window?, changes_recorded?, ...}
        for one tag, or {tracked_tags, listed, truncated, tags:[...]} for all.

    Example: baseline_status(tag="line1.temp").
    
- **baseline_learn_contextual** — [READ][risk=low] Learn one conservative band per declared context, not one per tag.

    One band per tag is wrong the moment a tag has more than one normal. A dryer
    running recipe A at 180 °C and recipe B at 240 °C gets a band spanning both,
    after which neither regime can go wrong — the band is too wide to catch a
    real excursion and too mixed to mean anything. OT normal ranges move with
    shift, product/recipe, and start-up versus steady state.

    **The context is declared, never inferred** (D16). Each sample carries a
    label under `context_key`; nothing here guesses which shift a timestamp falls
    in or clusters values into regimes it then treats as real. Each context is
    handed to the same learner as a global baseline, so it refuses on the same
    terms — a thin context is left without a band rather than borrowing another
    context's samples. Samples with no label are counted and named, not pooled
    into a default bucket, because a default bucket is that same fallback.

    Pass samples in (as with `spc_check` / `tag_health`). The local store's
    `samples` table has no context column, so there is deliberately no
    `iaiops baseline learn --context` yet; wiring one is a schema change and is
    not done.

    Args:
        samples: [{ts, value, quality?, tag?, <context_key>}] rows.
        tag: The tag being learned.
        context_key: Field that declares the context (default "context").
        min_samples: Per-context minimum before a band is learned (default 100).
        min_span_s: Per-context minimum history span in seconds (default 86400).

    Returns dict: {tag, context_key, contexts:{label: learn_baseline result},
        learned_contexts, refused_contexts, uncontexted_samples, note}.

    Example: baseline_learn_contextual(samples=[{"ts":"...","value":181.0,
        "context":"recipe-A"}], tag="dryer.temp").
    
- **baseline_check_in_context** — [READ][risk=low] Check readings against the band for ONE declared context.

    A reading whose context was never learned comes back `unknown_context` and
    stops there. It is **not** compared against the global band or the nearest
    one, and that refusal is the entire point of the tool: borrowing a band turns
    "we have never seen this regime" into "this regime is normal" — a silent
    pass, in the direction nobody reports. The response lists the contexts that
    do have bands so the gap is actionable.

    Otherwise the usual conservative rules apply: a violation needs values beyond
    the band by more than `margin_mad` × MAD AND sustained over `sustain_n`
    consecutive samples, and every flag cites the baseline it was judged against.

    Args:
        samples: [{ts, value, ...}] readings to check.
        contextual: A `baseline_learn_contextual` result.
        context: Which declared context these readings belong to.
        margin_mad: MAD margin beyond the band before flagging (default 3.0).
        sustain_n: Consecutive samples required (default 3 — no single-spike flags).

    Returns dict (known context): the `baseline_check` shape plus {context,
        context_key}. (unknown): {status:"unknown_context", tag, context,
        known_contexts, checked_samples, reason, note}.

    Example: baseline_check_in_context(samples=[...], contextual={...}, context="recipe-B").
    
- **historian_query** — [READ][risk=low] Query a tag's historical samples from a historian.

    Reads history back OUT of the store the sinks write — the local SQLite
    store (~/.iaiops/data.db), TDengine, or IoTDB — so the RCA copilot / an
    agent can see real pre-incident windows instead of only short live samples.
    Read-only over the operator's OWN historian; no device I/O. Bounded: rows
    are capped and a truncation flag is set when more history exists.

    Args:
        tag: Tag/metric name as stored by historian_push (e.g. 'line1.temp').
        since/until: Optional ISO-8601 time bounds (inclusive).
        endpoint: Only samples from this endpoint label (sqlite reader only —
            the TSDB layout stores no endpoint label).
        reader: 'sqlite' | 'tdengine' | 'iotdb'. Omit to use the per-site
            'historian:' block in ~/.iaiops/config.yaml, else the local sqlite
            store. TSDB readers need their extra: pip install iaiops[tdengine|iotdb].
        limit: Max rows returned (1..10000; default 1000).

    Returns dict: {reader, source, tag, since, until, rows,
        samples:[{ts, endpoint, protocol, tag, value, quality, unit}], truncated}
        plus the standard return envelope (items_returned, items_total,
        items_total_is_exact, is_truncated, truncation_note). Trust
        `is_truncated`: an empty `samples` with is_truncated=false means the
        history really is empty, NOT that the result was cut short.

    Example: historian_query(tag="line1.temp", since="2026-07-02T06:00:00Z",
        until="2026-07-02T08:00:00Z").
    
- **historian_coverage** — [READ][risk=low] Per-tag history coverage — what history do we actually have.

    Answers the question every RCA starts with: which tags have stored history,
    how many rows, and over what time span — per tag {rows, first_ts, last_ts}
    from the same store historian_push writes. Read-only, bounded (tag list is
    capped with a truncation flag); no device I/O.

    Args:
        reader: 'sqlite' | 'tdengine' | 'iotdb'. Omit to use the per-site
            'historian:' block in ~/.iaiops/config.yaml, else the local sqlite
            store. TSDB readers need their extra: pip install iaiops[tdengine|iotdb].
        limit: Max tags returned (1..2000; default 500).

    Returns dict: {reader, source, tag_count, tags:[{tag, rows, first_ts,
        last_ts}], truncated} plus the standard return envelope
        (items_returned, items_total, items_total_is_exact, is_truncated,
        truncation_note).

    Example: historian_coverage().
    
- **stream_publish** — [READ][risk=low] Publish already-read normalized points to a message bus (NATS).

    Egress of data the agent already READ — NOT a control write. Each numeric point becomes a JSON
    message on ``<subject_prefix>.tag.<metric>``; non-numeric points are skipped (use a historian
    sink for text/state). Needs the extra: pip install iaiops[nats].

    Args:
        points: Collected point dicts (e.g. from *_read_many): {ref/metric, value, timestamp, ...}.
        subject_prefix: NATS subject root (default 'iaiops').
        servers: Comma-separated NATS server URLs (default nats://localhost:4222).
        token: Optional NATS auth token.
        tls: Use TLS to the broker.
        publisher: Bus kind (currently 'nats').

    Returns dict: {publisher, subject_prefix, received, published, skipped_non_numeric}.

    Example: stream_publish(points=[{"ref": "line1.temp", "value": 21.5}], subject_prefix="plant").
    
- **uns_publish** — [READ][risk=low] Publish already-read normalized points to an MQTT broker / UNS.

    Egress of data the agent already READ — NOT a control write. Each numeric point becomes a JSON
    message on ``<topic_prefix>/<metric>``, with a dotted metric nested into topic levels
    (``line1.temp`` -> ``plant/line1/temp``) so a Unified Namespace stays browsable. Non-numeric
    points are skipped (use a historian sink for text/state). The topic is always derived from
    ``topic_prefix`` — it is never taken verbatim, so this cannot address a command topic. Needs the
    extra: pip install iaiops[mqtt].

    Args:
        points: Collected point dicts (e.g. from *_read_many): {ref/metric, value, timestamp, ...}.
        topic_prefix: Root of the topic tree (default 'iaiops'); wildcards are stripped.
        host: Broker hostname or IP (default localhost).
        port: Broker port; 0 picks 8883 with TLS else 1883.
        username: Optional broker username.
        password: Optional broker password.
        use_tls: Use TLS to the broker.
        qos: MQTT QoS 0/1/2 (default 0, fire-and-forget); values outside 0-2 are clamped.
        retain: Ask the broker to retain the last value per topic (useful for a UNS).

    Returns dict: {publisher, topic_prefix, broker, received, published, skipped_non_numeric}.

    Example: uns_publish(points=[{"ref": "line1.temp", "value": 21.5}], topic_prefix="plant",
        host="10.0.0.5").
    
- **stream_publish_event** — [READ][risk=low] Publish one computed event (RCA verdict / alarm) to a message bus (NATS).

    Egress of a finding the brain already COMPUTED — e.g. an RCA verdict or an alarm episode — to
    ``<subject_prefix>.<subject>`` as JSON. NOT a control write. Needs: pip install iaiops[nats].

    Args:
        subject: Event subject suffix (e.g. 'rca.verdict', 'alarm.flood').
        event: The event payload dict (published as JSON).
        servers/token/tls/subject_prefix/publisher: bus connection (see stream_publish).

    Returns dict: {publisher, subject, published}.

    Example: stream_publish_event(subject="rca.verdict", event={"primary_cause": "seal"}).
    
- **rca_narrate** — [READ][risk=low] Narrate a cited RCA verdict in plain language via an on-box LLM.

    Air-gapped: hands the already-computed, already-cited verdict to a LOCAL model (Ollama) that
    ONLY rephrases it — it never adds a cause, number, or citation (strict prompt; see docs/RCA.md).
    Read-only; no device I/O. Needs the extra + a running local model: pip install iaiops[ollama].

    Args:
        verdict: An RCA verdict dict (e.g. the output of downtime_root_cause).
        base_url: Ollama server URL (default http://localhost:11434).
        model: Local model name (default 'llama3.1').
        provider: LLM provider (currently 'ollama').

    Returns dict: {provider, model, narration}.

    Example: rca_narrate(verdict=<downtime_root_cause output>, model="llama3.1").
    
- **fleet_status** — [READ][risk=low] Roll up per-site status reports into one fleet health view.

    The tier above data_quality_fleet_rollup (per-endpoint within one site): this aggregates across
    many edge SITES for central management. A site is 'offline' if its last_seen is older than
    stale_after_s; fleet_status is the worst site status present. Read-only, pure; no device I/O.

    Args:
        sites: Per-site reports, each
            [{site, location?, profile?, status?, score?, issues?, last_seen?}]; status ∈
            ok|degraded|critical|offline (else derived from score); score 0..1.
        stale_after_s: A site with no report newer than this is 'offline' (default 300).
        now: Optional ISO-8601 'now' for deterministic staleness (default: current UTC).

    Returns dict: {site_count, fleet_status, fleet_score, by_status, worst_sites[], sites[]}.

    Example: fleet_status(sites=[{"site":"sh","score":0.9},{"site":"bj","status":"critical"}]).
    
- **fleet_incidents** — [READ][risk=low] Roll up active RCA incidents across sites → fleet-wide top causes.

    Aggregates the incidents each site reports into a fleet picture: how many incidents, which sites
    are affected, and the most common root causes across the whole fleet. Read-only; no device I/O.

    Args:
        sites: Per-site reports carrying incidents: [{site, incidents:[{cause|primary_cause,
            confidence?}]}].

    Returns dict: {total_incidents, sites_with_incidents, affected_sites[], top_causes[]}.

    Example: fleet_incidents(sites=[{"site":"plant-sh","incidents":[{"cause":"network"}]}]).
    
- **pdm_forecast** — [READ][risk=low] Forecast a value's trend + time until it crosses a warn/alarm limit.

    The predictive step above baseline_check (which flags a violation that already happened): fits a
    robust Theil-Sen trend to the recent history and, if it continues, estimates the ETA to the
    nearest limit in the direction of travel — the early warning that makes maintenance predictive
    (inverter/turbine degradation, bearing drift, filter clogging). Refuses thin history; read-only,
    pure over the provided series; no device I/O.

    Beyond the trend, the result deepens into three explainable, stdlib-only views: a degradation
    'pattern' (gradual vs sudden vs cyclic), a remaining-useful-life 'rul' block when degrading
    (linear + exponential extrapolation to the limit, a confidence band from the slope spread, and a
    fit R^2), and optional time-domain 'waveform' features (RMS/kurtosis/crest/... for
    vibration-type signals). Each states its own uncertainty rather than guessing.

    Args:
        series: Time-ordered samples: [{value, timestamp?}] (timestamp ISO-8601; if all present the
            ETA is in seconds, otherwise in samples). >= 30 numeric samples required.
        warn_high/alarm_high/warn_low/alarm_low: Optional limits; the forecast targets the nearest
            one in the trend's direction (rising → highs, falling → lows).
        imminent_within_s: ETA (seconds) at/under which status is 'imminent' (default 86400 = 24h).
        include_waveform: Add the time-domain 'waveform' feature block (default True). Set False for
            slow trend-only signals where vibration features do not apply.

    Returns dict: {status (insufficient_data|stable|degrading|imminent), samples, direction,
        slope_per_unit, unit (s|samples), current, limit:{name,value}, eta_to_limit,
        degradation:{pattern,confidence,rationale,metrics},
        waveform:{rms,crest_factor,kurtosis,...} (when include_waveform),
        rul:{linear,exponential,eta_band,recommended_model,confidence,...} (when degrading)}.

    Example: pdm_forecast(series=[{"value": 62.1, "timestamp": "2026-07-12T00:00:00Z"}, ...],
        warn_high=75, alarm_high=85).
    
- **downtime_triage** — [READ][risk=low] One-call downtime triage: first-look alarm + RCA cause + precursors.

    Answers the operator's three simultaneous questions on a stopped line — which
    alarm to look at first, the likely cause, and whether anything warned us —
    then cross-checks whether the first-out alarm agrees with the RCA verdict.
    Composes alarm_cascade + downtime_root_cause + pdm_forecast over ONE incident;
    every field traces to a sub-report echoed under 'cascade'/'rca'/
    'precursor_forecasts'. Read-first and advisory: it proposes but executes
    nothing. Thin evidence downgrades honestly rather than guessing.

    Args:
        window: {start (ISO-8601), end?, asset?, category?}. If 'end' is omitted
            but state_series is given, the first running→stopped span bounds it.
        alarms: Alarm/condition events — {source, timestamp, message?, priority?,
            state?}. Feeds BOTH the first-out cascade and the RCA.
        tags: Per-tag samples — {ref, samples:[...], warn_high?, ...} (via tag_health).
        dataflow: A diagnose_dataflow result dict (localizes comms vs field).
        state_series: {timestamp, state} samples to bound the window if 'end' is absent.
        precursors: Signals to check for a pre-incident trend — [{signal, series:
            [scalars or {value, timestamp}], warn_high?, alarm_high?, warn_low?,
            alarm_low?}]; each is run through pdm_forecast and kept only when it was
            degrading/imminent before the trip.
        cascade_window_s: Quiet gap (s) separating alarm cascades (default 60).
        lead_window_s: Causal lead window before onset (default 300s).
        cause_weights: Optional per-site {cause: multiplier} RCA override.
        imminent_within_s: ETA horizon that marks a precursor 'imminent' (default 24h).
        include_graph: When true, the echoed 'rca' sub-report also carries a 'graph'
            block — the SAME verdict re-projected as a causal graph {nodes, edges,
            mermaid, meta} (signal → cause → downtime) for a frontend. Pure re-shape;
            no new reasoning. Omit to keep the flat rca summary (default).

    Returns dict: {window, triage:{first_look:{source, ts, cascade_size, basis},
        likely_cause:{cause, verdict, confidence, confidence_band,
        recommended_action}, cross_check:{status ('corroborated'|'diverging'|
        'no_alarm_root'|'no_rca_primary'), detail}, precursors_missed:[{signal,
        status, direction, eta_to_limit, unit, limit}], recommended_next_data},
        cascade:{...}, rca:{verdict, primary_cause, top_hypotheses, graph?},
        precursor_forecasts:[...], anti_hallucination}.

    Example: downtime_triage(window={"start":"2026-06-28T10:00:00Z","asset":"line1"},
        alarms=[{"source":"M1_DRIVE","timestamp":"2026-06-28T09:59:50Z",
                 "message":"motor overload trip"}],
        precursors=[{"signal":"M1_temp","series":[...],"warn_high":80}]).
    
- **plc_program_outline** — [READ][risk=low] Structural outline of an EXPORTED PLC program file.

    Parses one exported text file (Siemens SCL/ST .scl/.st, AWL/STL .awl,
    Rockwell Studio 5000 .L5X — .txt is content-sniffed) and returns blocks
    (FB/FC/OB/DB/routines/AOIs) with VAR sections, IF/CASE branch inventory,
    timers/counters, and the call graph. Never uploads from a live PLC; reads
    exactly the named file (≤5 MB). Every element cites source_file + line
    (rung number for L5X ladder) — quote those citations when explaining.
    Malformed sections degrade to entries in parse_errors, never a crash.

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.

    Returns dict: {source_file, format, stats:{blocks, variables, call_edges,
        branches, timers_counters, comments, lines, parse_errors},
        blocks:[{name, kind, language, line, end_line, variables (≤100,
        variables_truncated), calls, branches, timers_counters, networks,
        comment}] (≤50, blocks_truncated), call_graph:[{caller, callee,
        source_file, line}], parse_errors, citation_note}.

    Example: plc_program_outline(path="~/.iaiops/plc/Line3_Conveyor.scl").
    
- **plc_program_xref** — [READ][risk=low] Cross-reference one symbol in an exported PLC program.

    Finds every read/write/call/declare site of a symbol or absolute address
    (e.g. Motor_Run, "FB_Conveyor", DB10.DBX0.1, M0.0, Tank[2].Level) in one
    exported file, quoting the surrounding source line verbatim so the agent
    cites real code. Access classification is heuristic (op/regex based, not
    data-flow analysis): SCL ':='→write, '('→call; AWL T/=/S/R→write,
    L/A/O…→read, CALL→call; L5X OTE/OTL/OTU/RES and MOV-dest→write. For L5X,
    line is the rung number.

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.
        symbol: Symbol / tag / absolute address to trace (word-bounded match).

    Returns dict: {source_file, format, symbol, hit_count,
        hits:[{symbol, access, block, source_file, line, source_line}] (≤200),
        hits_truncated, by_access:{read, write, call, declare, reference}}.

    Example: plc_program_xref(path="~/.iaiops/plc/OB1.awl", symbol="M10.0").
    
- **plc_program_section** — [READ][risk=low] Source text of ONE named block from an exported program.

    Returns the exact source of a single block (FB/FC/OB/DB name for SCL/AWL;
    Program.Routine or routine name for L5X — rungs are rendered as
    '[rung N] ...'), capped at 200 lines with an explicit truncated flag, so
    the agent reads exactly the section it is explaining instead of guessing.
    Unknown block names fail with the list of available blocks.

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.
        block: Block/routine name (case-insensitive; quotes optional).

    Returns dict: {source_file, format, block, kind, start_line, end_line,
        lines_returned, truncated, source, parse_errors}.

    Example: plc_program_section(path="~/.iaiops/plc/Line3.scl", block="FB_Conveyor").
    
- **plc_program_visibility** — [READ][risk=low] Maintainability / operational-risk profile of a legacy PLC program.

    The "what am I inheriting?" view over one EXPORTED program (SCL/ST, AWL/STL,
    Rockwell L5X): folds the structural outline into documentation coverage, the
    least-commented blocks, blocks nothing references (possible dead code), the
    complexity hotspots, risky constructs (unconditional JMPs, retentive RTO
    timers, loops), and a TRANSPARENT additive risk score whose every point cites
    its reason. Structural only — it anchors an engineer's review of a line
    somebody else left behind, not a semantic understanding. Reads exactly the
    named file (≤5 MB); never a live PLC upload. Every finding cites source_file +
    line (rung number for L5X ladder).

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.

    Returns dict: {source_file, fmt, stats:{blocks, call_edges, line_count,
        comment_count, comment_ratio, variables, branches, timers_counters},
        documentation:{comment_ratio, band ('well_commented'|'sparse'|
        'undocumented'), uncommented_block_count, uncommented_blocks},
        entry_points:[{name, kind}], unreferenced_blocks:[{name, kind,
        source_file, line}], complexity_hotspots:[{block, kind, score, branches,
        calls, timers_counters, source_file, line}], risky_constructs:{
        unconditional_jumps, unconditional_jump_count, loops, loop_count,
        retentive_timers, retentive_timer_count}, risk:{score (0..100), band
        ('low'|'medium'|'high'), reasons[]}, parse_errors, note}.

    Example: plc_program_visibility(path="~/.iaiops/plc/Line3_Conveyor.scl").
    
- **plc_program_snapshot** — [READ][risk=low] Record an exported program's structure as a change baseline.

    A control program is a controlled document, and the usual way an undocumented
    change to one gets noticed is that somebody remembers. This gives the
    comparison a number: the file's SHA-256, plus a per-block structural
    fingerprint (name/kind/language, declared variables, calls, branch conditions,
    timers) that deliberately excludes line numbers, comments and block order — so
    adding a comment at the top of a file does not report the whole program as
    changed. Stored locally under the iaiops home as block names, hashes and
    counts — never a declaration, a source line or a comment — so the store is not
    a second copy of the program. Reads the named EXPORTED file only; never a live
    PLC upload.

    Re-snapshotting a byte-identical file records nothing and says so — a history
    padded with identical rows hides the rows that are not.

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.
        name: Program identity across exports. Defaults to the file's stem, and
            the result says which was used — the export path changes every time
            somebody opens the engineering station, the program does not.
        label: Short label, e.g. "approved v3.2 / MOC-118".
        note: Free note recorded with the snapshot.

    Returns dict: {status ('recorded'|'unchanged'), program, name_source,
        snapshot:{snapshot_id, taken_at, source_file, content_sha256, label, note},
        block_count, snapshot_count, previous_snapshot}.

    Example: plc_program_snapshot(path="~/.iaiops/plc/Line3.scl", label="approved v3.2").
    
- **plc_program_drift** — [READ][risk=low] Has this program changed since its approved snapshot, and what moved?

    Three verdicts, and the wording of each is load-bearing. **identical** means
    the same SHA-256 and nothing else earns the word. **logic_changed** means the
    extracted structure differs — reported per block, naming which categories
    (variables / calls / branches / timers_counters) moved. **changed_outside_
    extracted_structure** means the bytes differ while every block fingerprint
    matched: usually comments or formatting, but these parsers extract structure
    rather than parse a grammar, so a real change inside a construct they do not
    model looks identical from here. Calling that "documentation only" would be
    the comfortable reading of evidence that does not support it, so it is not
    called that, and it is not a clearance — line and comment counts are reported
    beside it so a reviewer can see which way it leans.

    Nothing here decides whether a change was authorised; that is change control's
    job. No device is touched — this reads a file a person exported.

    Args:
        path: The current exported program file to check (same roots as above).
        name: Tracked program name (defaults to the file's stem).
        against: Snapshot id to compare with; default is the latest.

    Returns dict: {program, name_source, baseline:{snapshot_id, taken_at, label,
        source_file}, current:{source_file}, verdict, content_changed,
        structure_changed, content_sha256:{before, after}, blocks:{added[],
        removed[], changed:[{block, kind, line, previous_line, changed[]}],
        unchanged}, totals:{block_count, line_count, comment_count}, parse_errors,
        note, advisory}.

    Example: plc_program_drift(path="~/.iaiops/plc/Line3_today.scl", name="Line3").
    
- **plc_program_history** — [READ][risk=low] Tracked programs, or one program's snapshot history.

    Local read of the program-baseline store — no file is parsed and no device is
    touched. Nothing is ever pruned automatically: a change-control history that
    quietly drops last quarter's baseline is worse than one that grows, and a
    stored row is block names, hashes and counts rather than source. Removing history is a
    deliberate act and is CLI-only (`iaiops program forget`) — deleting
    change-control evidence should not be one tool call away.

    Args:
        name: Tracked program name. Omit for the list of every tracked program.

    Returns dict (listing): {store, program_count, programs:[{program,
        snapshot_count, latest, latest_taken_at}]}; (one program): {store,
        program, snapshot_count, snapshots:[{snapshot_id, taken_at, source_file,
        content_sha256, label, note}]}.

    Example: plc_program_history(name="Line3").
    
- **plc_program_xref_config** — [READ][risk=low] Join an exported program's addresses with one endpoint's configured points.

    Answers "which program symbol is that point on the line?" by **exact address
    match only** — never by name. A configured label that reads like a program
    symbol, or a ref one character from a tag, is NOT a match (semantics are given
    by a person, never guessed). Comparable pairs: Siemens AWL / .scl × an ``s7``
    endpoint (area, DB, byte, bit and width must all agree) and Rockwell L5X × a
    Logix ``ethernetip`` endpoint (the tag path is the CIP address). Every other
    pair returns ``comparable: false`` with the reason and NO lists — e.g. Modbus
    registers are assigned in the device's Modbus map, which no export contains.

    Args:
        path: Exported program file (.st/.scl/.awl/.l5x/.txt; must exist, ≤5 MB),
            under $IAIOPS_HOME, the config dir or an IAIOPS_AGENT_READ_DIRS dir.
        endpoint: Configured endpoint name whose ``tags`` are joined.
        live: Also read the MATCHED points now (read-only). A failed read is a
            gap with no value; each value carries ``read_at``.

    Returns dict: {program:{source_file, format, content_sha256}, endpoint:{name,
        protocol, configured_tag_count}, comparable, address_space,
        unmatchable:{program_format, protocol, reason}|null, refused (why an
        unreadable export was not answered)|null, parse_errors, program.controller
        (L5X: {name, processor_type}),
        matched:[{address, program_symbol, configured_ref, label, role, match
        ('exact_address'|'member_of_tag'), sites_scope (L5X), sites:[{access, block, line,
        source_line}], site_count}]|null, in_export_not_configured|null,
        configured_not_in_export|null, configured_not_comparable, overlaps,
        symbolic_only, indirect_access_sites, counts, notes,
        live:{points:[{configured_ref, status, ...}], read_count, gap_count}
        (only when live=True): a 'read' point has value, read_at, freshness; a 'gap'
        has error and NO value (read_at only when an empty answer arrived)}.

    Example: plc_program_xref_config(path="~/.iaiops/plc/OB1.awl", endpoint="line3").
    
- **investigation_readiness** — [READ][risk=low] How far into an investigation this site could get, and what each gap needs.

    `readiness` answers "which scenarios can this site run"; this answers the
    next question down — if something stopped tomorrow, how many of the eight
    evidence steps could actually be walked, and for each one that could not,
    what is missing.

    Contacts nothing: no device, no network, no historian. It is derived from
    the config and the local store, which is what makes it usable on a site
    nobody has been authorised to probe yet.

    Each gap says whether it is *unmet* (you have not supplied it — the fix names
    the command) or *not yet expressible* (this product offers no way to supply
    it at all). Those two send a person to very different places.
    
- **investigation_open** — [READ][risk=low] Open an investigation over one past window and walk what can be walked.

    Contacts no device — the window is already past, and its evidence is whatever
    was collected at the time. Each of the eight steps records its own outcome:
    `done` (it ran, here is what it found), `refused` (it could not run HERE —
    no samples, no alarm source; a site fact) or `not_possible` (this product
    cannot do it at all).

    The investigation is persisted, so it can be re-read and advanced later.
    
- **investigation_show** — [READ][risk=low] Re-read a saved investigation — the state it was left in.
- **investigation_list** — [READ][risk=low] List saved investigations, newest first.
- **line_relation_declare** — [READ][risk=low] Record that one asset feeds another — the second RCA axis.

    `[READ]` follows this repo's convention, where the tag is about PLANT state:
    it touches no device, exactly like `baseline_record_change` and
    `adopt_alias_map`, which are the same shape. It does write — a declaration
    about the line, into the site knowledge base.

    With time alone, an upstream stoppage produces a string of equally-confident
    downstream false causes, because on a line downstream co-occurrence is
    guaranteed whatever the cause. That guarantee is exactly why this is a
    declaration and not something inferred (D25): a person stating the line
    order needs no inference at all.

    ``by`` is required — a person is the evidence, and an edge with no author is
    indistinguishable from a guess a year later. Self-loops and cycles are
    refused here, where somebody can still fix them.
    
- **line_relations_list** — [READ][risk=low] The declared line order for a site, and what each asset feeds.
- **mechanism_library_check** — [READ][risk=low] What a mounted fault-mechanism library says about one candidate cause.

    Three answers, and the difference between the first two is the whole point:

    * ``nothing_known`` — the library has never heard of this cause. **Not** "no
      objection": a knowledge base that knows nothing about something has not
      cleared it.
    * ``known``, not excluded — mechanisms for it apply here, with what would
      confirm each.
    * ``known``, excluded — every mechanism for it is inapplicable to this
      equipment, so the candidate can be ruled out. That is the strong move a
      ranker cannot make.

    It never confirms. Raising a candidate to `confirmed` comes from outside the
    ranking — a measurement, a reproduction, or a person (D29).
    
- **mechanism_library_list** — [READ][risk=low] The fault mechanisms mounted for a site, and where each came from.
- **verify_determinism** — [READ][risk=low] Prove this engine's analysis is reproducible without a model.

    Runs a named suite of analyses (availability, production counts, the Six Big
    Losses, ISA-18.2 alarm load, control charts, the conservative baseline, the
    RCA copilot) over a pinned reference dataset, canonically encodes each result
    and digests it — twice in this process and, with ``subprocesses``, once more
    in each of two fresh interpreters started at different PYTHONHASHSEED values.
    That last arm is the one that catches a set or dict iteration order reaching a
    result; a single run never can. The socket API raises throughout, so a
    computation that reached for a device or a hostname fails here rather than
    quietly succeeding on a machine that happens to be online. ``sys.modules`` is
    checked afterwards for any model library — empty is the guarantee.

    Use it to answer "how do I know your AI didn't make this number up": the
    answer is that no model is in the path, and here is the SHA-256 that says so,
    reproducible on the customer's own box. Read-only; nothing is written unless
    the CLI (`iaiops verify determinism --out record.json`) is used to save the
    signable record for a validation file.

    Args:
        subprocesses: Also re-run in two fresh interpreters at fixed, different
            hash seeds (default True; adds roughly a second).

    Returns dict: {check, result:{verdict ('reproducible'|'not_reproducible'),
        suite_digest, dataset:{name, revision, digest}, checks:[{name, covers,
        digest}], arms:[{arm, suite_digest, matches_first_arm}], arms_disagreeing,
        model_modules_loaded, network}, context:{iaiops_version, python, platform,
        generated_at, ...}, note}. ``result`` is identical between runs; ``context``
        is not — it records when and where this run happened.

    Example: verify_determinism().
    
- **iec104_connection_info** — [READ][risk=low] Connect and report IEC-104 link status + discovered stations.

    Args:
        endpoint: Endpoint name from config (protocol 'iec104'); omit for default.

    Returns dict: {endpoint, host, port, connected, configured_common_address,
        station_count, common_addresses[]}.

    Example: iec104_connection_info(endpoint="rtu1").
    
- **iec104_interrogate** — [READ][risk=low] General interrogation: all monitored points of a station (ASDU CA).

    Args:
        common_address: ASDU common address; omit for the configured/first station.
        endpoint: Endpoint name from config (protocol 'iec104').

    Returns dict: {endpoint, common_address, point_count, points:[{io_address, type,
        value, quality, recorded_at}]}.

    Example: iec104_interrogate(common_address=1, endpoint="rtu1").
    
- **iec104_read_point** — [READ][risk=low] Read one monitored point by information-object address (IOA).

    Args:
        io_address: The point's information-object address (IOA).
        common_address: ASDU common address; omit for the configured/first station.
        endpoint: Endpoint name from config (protocol 'iec104').

    Returns dict: {endpoint, common_address, found, io_address, type, value, quality,
        recorded_at}.

    Example: iec104_read_point(io_address=1001, common_address=1, endpoint="rtu1").
    
- **dnp3_link_status** — [READ][risk=low] Bring the DNP3 master online and report link/outstation status.

    Args:
        endpoint: Endpoint name from config (protocol 'dnp3'); omit for default.

    Returns dict: {endpoint, host, port, outstation_address, master_address, online}.

    Example: dnp3_link_status(endpoint="rtu2").
    
- **dnp3_integrity_poll** — [READ][risk=low] Class 0/1/2/3 integrity poll → the outstation's database.

    Returns all static points grouped by measurement type (binary_input,
    analog_input, counter, …).

    Args:
        endpoint: Endpoint name from config (protocol 'dnp3').

    Returns dict: {endpoint, outstation_address, point_count, by_type{},
        points:[{type, group, index, value, quality, timestamp}]}.

    Example: dnp3_integrity_poll(endpoint="rtu2").
    
- **iec61850_device_directory** — [READ][risk=low] List the IED's logical devices (optionally their children).

    Args:
        include_children: Also browse each logical device's immediate model children.
        endpoint: Endpoint name from config (protocol 'iec61850'); omit for default.

    Returns dict: {endpoint, logical_device_count, logical_devices:[{logical_device,
        children[]?, child_count?}]}.

    Example: iec61850_device_directory(include_children=True, endpoint="ied1").
    
- **iec61850_browse** — [READ][risk=low] Browse immediate model children under a reference (LD/LN/DO).

    Args:
        reference: Model reference, e.g. 'IED1LD0' or 'IED1LD0/LLN0'.
        endpoint: Endpoint name from config (protocol 'iec61850').

    Returns dict: {endpoint, reference, child_count, children[]}.

    Example: iec61850_browse(reference="IED1LD0/MMXU1", endpoint="ied1").
    
- **iec61850_read** — [READ][risk=low] Read one data attribute by object-reference + functional constraint.

    Args:
        reference: Data-attribute object reference, e.g. 'IED1MMXU1.TotW.mag.f'.
        fc: Functional constraint — MX (measurands), ST (status), CF (config), …
        endpoint: Endpoint name from config (protocol 'iec61850').

    Returns dict: {endpoint, reference, fc, value, error}.

    Example: iec61850_read(reference="IED1MMXU1.TotW.mag.f", fc="MX", endpoint="ied1").
    
- **substation_event_analysis** — [READ][risk=low] Analyse a substation Sequence-of-Events for protection selectivity.

    Pure structural analysis over INJECTED events (no live protocol I/O, no
    endpoint): given relay pickups/trips, breaker open/close, lockouts and bus
    undervoltage with ISO-8601 timestamps, decide what tripped and whether
    protection coordinated — a selective trip (one zone contained), a
    non-selective backup operation (wider outage), or a breaker failure.
    Monitor-only, advisory, cite-first (every claim ties to a timestamped event).

    Args:
        events: SOE list of {ref, timestamp (ISO-8601), type, label?}; ``type``
            is one of protection_pickup / protection_trip / breaker_open /
            breaker_close / lockout / bus_undervoltage (a free-text ``label`` is
            keyword-matched when ``type`` is absent or unknown). ``ref`` is the
            point name / IOA.
        breaker_fail_window_s: Breaker-failure timer — the tripped breaker's own
            open must be seen within this many seconds (default 0.25).
        backup_margin_s: Backup coordination margin — a breaker opening later
            than this past the first protection event is treated as backup
            operation (default 0.5).

    Returns dict: {events_analyzed, ignored, verdict, first_protection,
        first_breaker_open, breakers_opened, breaker_open_count, affected_refs,
        timeline, coordination{status, detail}, note}. ``verdict`` is one of
        selective_trip / backup_operation / breaker_failure / insufficient.

    Example: substation_event_analysis(events=[
        {"ref": "R1", "type": "protection_trip", "timestamp": "2026-07-12T10:00:00Z"},
        {"ref": "BK1", "type": "breaker_open", "timestamp": "2026-07-12T10:00:00.08Z"}]).
    

## Claude Desktop Quick Installation
Install path detected from listing signals. Uses `uvx` (confidence: high):

```json
"mcpServers": {
  "ot-aiops-energy-edition": {
    "command": "uvx",
    "args": ["--from","iaiops-energy","iaiops-energy-mcp"]
  }
}
```

## Documentation & README

<!-- mcp-name: io.github.industrial-aiops/iaiops-energy -->

# industrial-aiops-energy — Energy Edition (Substation / Utility)

**English** · [中文](https://github.com/industrial-aiops/industrial-aiops-energy/blob/HEAD/README.zh-CN.md)

The **energy edition** of [Industrial-AIOps](https://github.com/industrial-aiops/industrial-aiops),
split out into its own repo: read-only OT connectors for **substation / utility
telecontrol** protocols, built **on top of `iaiops.core`**.

- **IEC 60870-5-104** (`c104`) — RTU / substation telemetry
- **DNP3 / IEEE 1815** (`pydnp3`) — outstation monitoring
- **IEC 61850 MMS** (`pyiec61850`, linux-only wheel) — substation IED reads

It reuses the base package's shared **governance** (audit / budget / risk-tier /
undo), **cross-protocol brain** (data-flow / alarm / OEE / downtime RCA on the
normalized ISA-95/18.2 model), and MCP server infrastructure — this repo only adds
the three energy connectors + their session builders + MCP tools. Read-first: no
control-direction writes are exposed.

Current release: **0.2.0** (requires `iaiops>=0.29.0,<1.0`; tested against `iaiops` 0.31.0). On the wire: **80 tools, 75 read-only, 0 destructive** (the energy connectors plus the base brain tools mounted on this server). New in 0.2.0 — every MCP call on
this server is audited with **which front-end and which client made it** (`origin=mcp`, the
client's self-reported name, one id per connection); before this, calls here reached the trail
as origin *unknown*. **Upgrade note:** `iaiops` 0.29 hashes those fields into the audit chain,
and an older base verifying a trail 0.29 has written to reports `row_hash mismatch` — so
upgrade every process sharing one `IAIOPS_HOME` (this server, `iaiops-mcp`, the CLI,
enterprise) together. The base's new HTTP bearer-token requirement does not apply here: this
server speaks stdio only. See `CHANGELOG.md`. Previously in 0.1.12 — the package
reports **its own** version: `__version__` had been hard-coded at 0.1.3 for eight releases, and
the MCP handshake reported the SDK's version as this server's; both now come from the installed
package metadata. Previously in 0.1.11 — **all three
monitor paths are CI-gated for the first time**, and a skipped live test now fails the build
instead of passing it. DNP3 was the holdout: `pip install pydnp3` fails on any current Linux,
so this repo inherited the ecosystem's "unbuildable on hosted runners" and
`tests/test_dnp3_live.py` skipped on every build. It is not unbuildable — opendnp3 compiles
clean, and the 2019 binding layer needed three mechanical fixes, now scripted in
`scripts/build_pydnp3.sh` and running on real GitHub runners. Also inherits two governance
fixes from `iaiops` 0.20.3 (pin raised): a call that **failed** is no longer audited as a
success (which also mis-informed the pattern circuit breaker on every failure), and the
runaway guard can now see a caller retrying a denial forever. Previously in 0.1.10 — a **security
fix inherited from the base package**: three egress tools this edition mirrors
(`stream_publish`, `stream_publish_event`, `historian_push`) wrote their credential — a NATS
auth token, a TSDB password — into the audit log in the clear, and `audit_forward` shipped
that row to the configured SIEM. They are defined in `iaiops`, so this edition could not fix
it alone; the pin moves to `iaiops>=0.20.2`, and a contract test now fails the build if the
whole registered surface ever again carries an undeclared credential parameter. **If you have
passed a token or historian password to these tools, rotate it** and check existing audit
rows. The energy connectors themselves take no credential parameters. Previously in 0.1.9 — every tool
ships the MCP `ToolAnnotations` hints (`readOnlyHint` / `destructiveHint` / `openWorldHint`),
**derived** from the `@governed_tool` harness rather than hand-written, so a client can tell a
monitor read from a tool that acts without parsing the `[READ]`/`[WRITE]` docstring tag. On the
wire that was (in 0.1.9) 59 tools, 55 read-only and **0 destructive** — this edition exposes no control
direction, and that is now enforced by a test rather than only documented. They are hints, not a
gate: the MCP spec forbids relying on annotations for security decisions, and enforcement stays in
`@governed_tool`. The base pin moved to `iaiops>=0.20.1` so the hint derivation is imported from
`mcp_server.hints` instead of duplicated here.

Previously in 0.1.8 — the base
`IAIOPS_READ_ONLY` gate was **removed** in iaiops 0.19.0 (read/write authorisation is the
caller's decision — agent judgement / account management — not the tap's; every tool is
governed and audited via the base `@governed_tool` harness), so this edition drops it too.
It keeps the **`IAIOPS_NO_EGRESS=1`** gate — a data-exfiltration / airgap axis that withholds
data-shipping tools from `list_tools()` at registration time. This edition runs its own
`FastMCP` instance, so the gate is wired into its own `main()`; without it `IAIOPS_NO_EGRESS=1`
would still expose `historian_push`, `rca_narrate` and the `stream_publish*` pair mirrored in
from the base brain. The energy connectors themselves are monitor-only and survive the gate
intact. See `CHANGELOG.md`.

Previously in 0.1.6 — an
**audit-hardening pass** over the three read-only connectors: DNP3 no longer reports an
offline outstation as online or returns a partial integrity-poll database; IEC-61850 gained
a bounded connect/request timeout and stopped fabricating `0.0`/empty-success on failure; the
substation analyzer no longer calls a lone breaker-open a "selective trip"; tests isolate
`IAIOPS_HOME`; and the base pin was raised to `iaiops>=0.14` so the governance endpoint-scoping
fix applies. See `CHANGELOG.md` §0.1.6. (0.1.5 verified the IEC-104 monitor path — a real `c104`
client↔server round-trip in a Linux container, `tests/test_iec104_live.py`.)
**Physical RTU / IED remains unverified.** Since 0.1.3 the server has
its **own MCP identity** — `iaiops-energy-mcp` runs a dedicated `FastMCP("iaiops-energy")`
instance with energy-specific instructions (IEC-104 / DNP3 / IEC-61850, read-first,
no control/operate), with the base cross-protocol brain tools mirrored onto it — plus
an **edition skill** (`skills/iaiops-energy/SKILL.md`, anti-drift-tested against the
registered tool surface) and **protocol-consistency contract tests** (every tool must
carry the governance marker, a `[READ]`-style risk tag, an `Args:` section, and the
canonical `{error, hint}` error shape; the server refuses to start if any registered
tool lacks the governance marker).

## 🧪 Beta testing & co-creation

**Live substation RTU / IED / IEC-104 field testing is what this package needs most.**
The IEC-104, DNP3 and IEC-61850 monitor paths are library-loopback-verified, and **all three
now run on every CI build** — a skip fails the build rather than passing it. (Since
2026-08-01: DNP3's evidence used to be a single manual run on 2026-07-02, because `pydnp3`
was believed unbuildable on hosted runners. That belief was wrong — see
`scripts/build_pydnp3.sh`.) **Real RTU / IED hardware remains unverified.** If you can run
`iaiops doctor` against real substation gear in an authorised test environment, we would
very much like to hear the result — verified devices are credited by name in the support
matrix. Report results (protocol + device model + `iaiops doctor` output) via the base
repo's pinned issue:
👉 [industrial-aiops#28 — Call for field-testing partners (v0.10.0)](https://github.com/industrial-aiops/industrial-aiops/issues/28)

## Why a separate repo

Energy targets a distinct buyer (utilities / substations), has heavier
platform-specific deps (`pyiec61850` is a linux-only SWIG wheel; `pydnp3` builds a
native ext), and its own compliance surface (China's *Security Protection of Power
Monitoring Systems* regime). Splitting keeps
the base install light. See the base repo's `docs/ENERGY-SPINOUT.md` for the plan.

## Install

```bash
pip install iaiops-energy[energy]      # all three energy protocols
pip install iaiops-energy[iec104]      # just IEC-104
```

`iaiops-energy` pulls in `iaiops` (the shared core) automatically.

## Use (MCP)

```bash
iaiops-energy-mcp                       # brain + energy tools over stdio
```

Point a target at your substation gear in `~/.iaiops/config.yaml`
(`protocol: iec104|dnp3|iec61850`, `host`, `port`, `common_address` / `unit_id`).

## Edge deployment & ecosystem (edge-native / Margo)

Like the base package, the energy edition rides on a hardened, centrally-managed **edge host** as a
portable, governed **edge application** — mapping onto the [Margo](https://margo.org/)
edge-interoperability roles (immutable host · compliant orchestrator · **iaiops-energy = the
OT-domain app**), deployable as an OCI **Managed Container** (outbound-only to substation RTUs/IEDs,
no inbound). A container + `margo.org/v1-alpha1` application-description skeleton is in
[`deploy/margo/`](https://github.com/industrial-aiops/industrial-aiops-energy/blob/HEAD/deploy/margo/); the full alignment + honest gap analysis lives in the base repo's
[`docs/MARGO-ALIGNMENT.md`](https://github.com/industrial-aiops/industrial-aiops/blob/main/docs/MARGO-ALIGNMENT.md).
The descriptor is validated against Margo's published `margo.org/v1-alpha1` LinkML schema on every
PR (CI job `margo-descriptor`) and passes clean — structural validity only, see
[`deploy/margo/schema/PROVENANCE.md`](https://github.com/industrial-aiops/industrial-aiops-energy/blob/HEAD/deploy/margo/schema/PROVENANCE.md).

> **Honest status:** a natural Margo edge application, but **NOT Margo-compliant yet** — image build,
> hosted+signed package, and a published conformance result are roadmap `⏳`. No claim of compliance
> until that result exists, and the schema pass above is **not** a step toward it: the compliance
> test suite cannot be run today because it does not exist yet (no conformance repo in the `margo`
> org; a first PR1 vertical slice was still being scoped as of 2026-01-15).

## Validation status (honest)

The same honesty ladder as the base repo. Driver **codec / API surface** is verified
against the real libraries; the mock/monkeypatched **unit tests run in CI** without
hardware. See the base repo's `docs/PREVIEW-VERIFICATION.md` runbook for how a
protocol is promoted.

| Protocol | Status | CI coverage | Evidence |
| --- | --- | --- | --- |
| **DNP3 / IEEE 1815** | **verified (monitor path)** | ✅ runs every push ¹ | Real master↔outstation round-trip against a live **opendnp3** outstation (`pydnp3`): `is_online()` reflects the real channel `OnStateChange`, and `integrity_poll()` (Class 0/1/2/3) returns the seeded binary/analog/counter database grouped by type. See `tests/test_dnp3_live.py` (`@pytest.mark.integration`). No physical RTU. |
| **IEC 60870-5-104** | **verified (monitor path)** | ✅ runs every push | Real client↔server round-trip against an in-process **`c104`** server (`tests/test_iec104_live.py` + `tests/iec104_server_harness.py`, `@pytest.mark.integration`, passes in a Linux container): `iec104_connection_info` discovers the seeded station, `iec104_interrogate` (general interrogation / C_IC) returns the seeded `M_ME_NC_1` + `M_SP_NA_1` points with quality, `iec104_read_point` reads the measurand, a bad IOA yields `found=False` with **no fabricated value**, and server-side ASDU capture proves **no control ASDU** (C_SC / C_DC / C_SE) is ever issued. `c104` ships no macOS wheel so the test skips on macOS (runs in CI / Linux). No physical RTU. Monitor/read only. |
| **IEC 61850 (MMS)** | **verified (monitor path)** | ✅ runs every push | Real client↔server MMS round-trip against an in-process **libiec61850** MMS server built with `pyiec61850`'s server API: `iec61850_device_directory` lists the logical device (and browses its logical nodes / data objects), and `iec61850_read` returns a seeded measurand (`TotW.mag.f`, FC `MX`) over real ISO-on-TCP; a bad reference surfaces an MMS data-access error instead of a fabricated value. See `tests/test_iec61850_live.py` (`@pytest.mark.integration`, skips when `pyiec61850` / its server API is absent). No physical IED. Read/monitor only — control / GOOSE / SV out of scope. |

**¹ All three monitor paths are now CI-gated**, and a skip fails the build rather than
passing it (`no live protocol test may skip`). This used to read: *"only IEC-104 is
CI-gated; DNP3 and IEC-61850 rest on out-of-CI runs, not on the green badge."* That was
true and is no longer.

DNP3 was the last one, and the reason is worth recording because the old note here — and
the CI job, and this README — all repeated the same wrong conclusion for months:
`pip install pydnp3` fails on any current Linux, so it was taken as **unbuildable** on
hosted runners. It is not. opendnp3 itself compiles clean; what had rotted was the 2019
binding layer. Three mechanical fixes, no patches to opendnp3:

1. Python headers must be present (`python3-dev`) — without them the build dies at
   `Python.h: No such file or directory`, which is what "unbuildable" looked like;
2. 214 vendored headers `#include <python2.7/Python.h>`, rewritten to `<Python.h>`;
3. the vendored pybind11 predates CPython 3.11 (it reads `PyFrameObject` internals that
   3.11 made opaque) and GCC 13 (`std::uint16_t` without `<cstdint>`) — replaced with
   pybind11 v2.13.6.

`scripts/build_pydnp3.sh` applies all three and verifies the import. ~3 min cold on two
cores. The lesson generalises: *"the ecosystem says it cannot be built"* is a claim to
test, not to inherit.

DNP3 notes: read-only / monitor direction only (no control). Its
`DNP3Manager.Shutdown()` can block in a long-lived interpreter, so the connector bounds
teardown (`_Pydnp3MasterAdapter.shutdown`) and the test drives the round-trip in a
short-lived child process.

## License

MIT — © wei. Part of the vendor-neutral, governed Industrial-AIOps line.

