npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@openpowershift/time-current-grading-language

v0.1.1

Published

Render protection-relay time-current grading curves (TCC) from a compact text language to SVG, PNG, or PDF.

Readme

tc-curves — Time-Current Grading Language

tc-curves is a small text language and renderer for protection-relay time-current grading studies. You describe the relays, the fault levels, and the grading intent; the tool computes the operate times, checks the margins, and draws a publication-quality log-log TCC — as SVG, PNG, or PDF.

Try it in the browser: openpowershift.github.io/prot-time-current-curves — a live playground: type .ptc on the left, see the curves and the margin report on the right.

Published as @openpowershift/time-current-grading-language. See Use as a library for programmatic use.

grade {
    primary  = R_FDR_1:51;      // 11 kV feeder — trips first
    backup   = R_TRF_INC:51;    // 33 kV incomer — backs it up
    fault    = "F_FDR1_max";
    margin_target = 0.30 s;

    solve { strategy = "tight"; }
}

That block is the whole point: the tool works out the backup’s time multiplier, reports the margin actually achieved, and flags it if the target cannot be met.

Why

A grading study is arithmetic that engineers redo by hand every time a setting moves — evaluating IEC/IEEE curve formulas at a fault current, subtracting two operate times, checking the result clears the CTI, then redrawing the chart. tc-curves treats the study as the source of truth: the curves, the margins, and the chart are all derived from one text file, so they are diffable, reviewable, and regenerated whenever a setting changes.

Goals & concepts

  • Declarative. You state pick-ups, curves, and the grading intent. Operate times, margins, axis ranges, and the plot are all derived.
  • Standards-backed. Curve constants are part of the processor, not the source file, transcribed from IEC 60255-151:2009 Annex A, IEC 60255-3:1989 (legacy LTI/STI), and IEEE C37.112-1996, plus vendor families from SEL, Siemens, GE, ABB, and Schneider. A study renders identically wherever it is processed.
  • Voltage levels, not transformers. Name the levels and let the processor derive turns ratios; relays and faults reference a level by name. No transformer block to declare and keep in sync.
  • Two kinds of intent, kept distinct. CTI_min_s is a constraint — at least this margin at every current in the fault’s range, reported pass/fail. margin_s is a target at one declared fault current, which a solve block turns into a computed setting.
  • Everything is checked. Around forty validation rules cover unknown curve ids (with "did you mean"), time-multiplier ranges, mixed element forms, unresolved references, combine cycles, implausible pick-ups, and more.

Use as a library

npm install @openpowershift/time-current-grading-language
import {
  process,
  parseAndRender,
  formatGradeReports,
} from "@openpowershift/time-current-grading-language";

const result = process(source);

// Margin reports — one per `grade` block, with any solver-computed settings
console.log(formatGradeReports(result.reports));

// …or go straight to a chart
const { svg } = parseAndRender(source);

process() runs the whole pipeline — parse, resolve, validate, grade — and returns the study, the diagnostics, and the reports. SVG rendering is isomorphic (Node + browser); exportPng() and exportPdf() produce bytes in either.

Full API and the options tables are in the API Reference.

Command line

npx tc-curves render study.ptc              # → study.svg
npx tc-curves render study.ptc --png -o out.png
npx tc-curves render study.ptc --pdf --size A3 --landscape
npx tc-curves report study.ptc              # margin report only
npx tc-curves check  study.ptc              # diagnostics only
npx tc-curves check  study.ptc --json       # machine-readable, for CI

Exit status: 0 clean, 1 an error-severity finding, 2 usage or I/O failure, 3 a valid study whose grading fails.

3 is separate from 1 deliberately. A study with an error could not be evaluated; a study that fails its margins was evaluated and did not coordinate. Those are different jobs for whoever reads the status -- "fix the file" against "change the settings" -- and a gate that collapses them into non-zero loses the distinction exactly where it matters. check is therefore a CI gate on coordination, not merely on syntax.

--json emits the same findings and the grading result as a versioned document, so a settings database, a dashboard or a PR annotation does not have to scrape the text report.

Playground / development

Requires Node 20+ (the dev playground uses Node 24 — see .nvmrc).

npm install
npm run dev        # live playground at the printed URL
npm run build      # type-check + production build of the playground to dist/
npm run build:lib  # build the publishable library to lib/
npm test           # run the test suite (vitest)
npm run typecheck  # tsc --noEmit
npm run lint       # eslint

See developing.adoc for the project layout, what to touch when the language grows, and how a release is cut; CONTRIBUTING.adoc for the rules a change must follow.

Documentation

Four documents, deliberately separate:

  • docs/tutorial.adoc
    The tutorial. A walkthrough that starts with a single curve and adds one idea at a time -- a second relay, a fault to grade against, secondary amps, multi-stage elements, earth faults, fuses, annotations, required times, the drawing itself, several sheets -- and finishes by introducing scenarios, which is where a study stops being one number per fault. Every step is a file you can paste into the playground. Read this first if you have not written a .ptc file before.
  • docs/guide.adoc
    The user guide. Example-led, about writing studies, and a single self-contained file -- so it can be read straight through, or handed whole to a language model asked to produce a .ptc study. It is held to a size budget for that reason: it stays a digest of the spec rather than a second copy of it.
  • docs/advanced.adoc
    The advanced guide. What changes when a study stops being one sheet -- naming (handle, caption, prose), what appears on which sheet, protection chains, declaring a mark inside a view, and the rendered sheet as a document. Everything in it is drawn from examples/18-bess-two-chains.ptc, a two-chain study with four sheets. Read it when the guide’s answers start being "it depends which sheet".

The playground’s Guide button opens all three, converted by Asciidoctor at build time (scripts/guide-plugin.js), with a contents list and a filter; a Tutorial / Reference / Advanced switch in the header picks between them, and the tutorial is what opens first.

  • spec/spec.adoc
    The normative specification, assembled from spec/sections/. Grammar productions, curve constant tables, conformance requirements. It answers what the language is, which is the wrong thing to put in front of someone trying to author a file. Where the two differ, the spec wins.

The code blocks in both are checked against the real parser by tests/unit/guide.spec.ts, so an example cannot teach a mistake. A further test requires every key and value the editor offers to appear in the guide, in the spec, and in at least one worked example, so the four cannot drift apart again. Conversion runs in the build, not the browser -- Asciidoctor itself is never shipped, only the HTML it produced, in a chunk fetched on first open.

A taste of the language

system {
    voltages {
        "HV" { V  = 33.0 kV; }
        "LV" { V  = 11.0 kV; }
    }
}

faults {
    "F_FDR1_max" { I   = 6.40 kA; voltage = "LV"; }
}

relay R_TRF_INC {
    voltage  = "HV";
    ct_ratio = 600/5;

    element 51 { function = "phase_oc"; curve = iec.si; I_pickup = 720 A; tms = 0.30; }
}

relay R_FDR_1 {
    voltage  = "LV";
    ct_ratio = 400/5;

    element 50 { curve = definite; I_pickup = 3200 A; t_delay = 0.05 s; }
    element 51 { curve = iec.vi;   I_pickup =  480 A; tms = 0.25; }
}

grade {
    primary   = R_FDR_1:51;
    backup    = R_TRF_INC:51;
    fault     = "F_FDR1_max";
    margin    = 0.30 s;
}

...which reports:

grade R_FDR_1:51 / R_TRF_INC:51:
    fault           = F_FDR1_max (I_f = 6400 A on LV)
    CTI_min         = 0.300 s
    seen by primary = 6400 A    seen by backup = 2133 A
    t_primary       = 0.274 s        (M = 13.33)
    t_backup        = 1.912 s        (M = 2.96)
    achieved margin = 1.639 s        -- pass

The language specification is the normative reference for the grammar, the curve tables, and the grading semantics. Implementation status and the resolved spec ambiguities are tracked in IMPLEMENTATION.adoc.

Gallery

Five sheets, each drawn by the tool from the source beside it. Every one is a file in examples/ -- the same files the test suite validates, so nothing here is a mock-up -- and every one is loaded in the playground under Gallery 1..Gallery 5, so you can change a setting and watch the margin move.

The smallest study that says anything

Two relays and one fault. This is the whole language in thirty lines: declare the levels, declare the relays, say which must clear first and by how much.

Two graded curves and the fault they are checked at

💡 TIP
Open it in the playground: Gallery 1 — try it. It is also in the picker, listed as Gallery 1.

# Sample 00 — The smallest study that says anything
#
# Two relays, one fault, one question: does the feeder clear before the
# incomer? Everything else in this library is an elaboration of this.

system {
  voltages {
    MV {
      V = 11 kV;
    }
  }
}

faults {
  BOARD_MAX {
    name = "Board max";
    I = 6.2 kA;
    type = three_phase;
    voltage = MV;
  }
}

relay R_FDR {
  voltage = MV;
  ct_ratio = 400/5;
  element 51 {
    function = "phase_oc";
    curve = iec.si;
    I_pickup = 480 A;
    tms = 0.10;
  }
}

relay R_INC {
  voltage = MV;
  ct_ratio = 1200/5;
  element 51 {
    function = "phase_oc";
    curve = iec.si;
    I_pickup = 960 A;
    tms = 0.25;
  }
}

grade {
  primary = R_FDR:51;
  backup = R_INC:51;
  fault = BOARD_MAX;
  margin = 0.30 s;
}

A four-level cascade

An incomer, a tie, a feeder, a sub-feeder and a spur fuse, each graded against the one behind it. The margin arrows are drawn at the fault they were computed at, and the fuse is a shaded band rather than a line -- it may start to melt at the lower curve and is guaranteed clear by the upper.

Six curves cascading

💡 TIP
Open it in the playground: Gallery 2 — try it. It is also in the picker, listed as Gallery 2.

# Sample 10 — Four-level substation cascade
#
# A realistic grading chain from an 33 kV incomer down to a spur fuse,
# six characteristics on one sheet:
#
#   R_INC:51      33 kV incomer, IEC SI          (slowest, last resort)
#   R_TIE:51      11 kV bus tie, IEC SI
#   R_FDR:51/50   11 kV feeder, IDMT + high-set  (composite)
#   R_SUB:51      11 kV sub-feeder, IEC VI
#   spur_fuse_65T 65T expulsion fuse             (fastest, at the end)
#
# Each adjacent pair is graded, and each margin is annotated at the
# fault that governs it — so the sheet carries the whole coordination
# argument, not just the curves.
#
# In the playground, press ? (or Ctrl-Space) for completions: on a blank
# line it lists what the enclosing block accepts, and after an `=` it
# lists that field's values -- curve names, quantities, declared
# conditions and voltage levels included.

meta {
  project = "Northgate 33/11 kV";
  study = "Phase overcurrent cascade";
  engineer = "B. Singh";
  date = "2026-07-28";
  revision = "B";
  standard  = ["IEC_60255_151"];
  margin = 0.30 s;
}

system {
  voltages {
    HV {
      V = 33.0 kV;
      description = "33 kV incomer";
    }
    MV {
      V = 11.0 kV;
      description = "11 kV switchboard";
    }
  }
  base_S = 25.0 MVA;
}

faults {
  BUS_MAX {
    name = "Bus max";
    I = 8.20 kA;
    voltage = MV;
  }
  FEEDER_MAX {
    name = "Feeder max";
    I = 5.60 kA;
    voltage = MV;
  }
  SUB_MAX {
    name = "Sub max";
    I = 3.10 kA;
    voltage = MV;
  }
  SPUR_MAX {
    name = "Spur max";
    I = 1.25 kA;
    voltage = MV;
  }
  SUB_MIN {
    name = "Sub min";
    I = 0.95 kA;
    voltage = MV;
  }
}

device spur_fuse_65T {
  kind = "fuse";
  maker = "Mersen";
  model = "65T";
  rating_I = 65 A;
  min_melt    = [(130 A, 60 s), (400 A, 0.42 s), (1500 A, 0.021 s)];
  total_clear = [(130 A, 100 s), (400 A, 0.70 s), (1500 A, 0.036 s)];
}

relay R_INC {
  voltage = HV;
  maker = "ABB";
  model = "REF615";
  ct_ratio = 600/5;
  element 51 {
    function = "phase_oc";
    curve = iec.si;
    I_pickup = 700 A;
    tms = 0.40;
  }
}

relay R_TIE {
  voltage = MV;
  maker = "ABB";
  model = "REF615";
  ct_ratio = 800/5;
  element 51 {
    function = "phase_oc";
    curve = iec.si;
    I_pickup = 900 A;
    tms = 0.30;
  }
}

relay R_FDR {
  voltage = MV;
  maker = "SEL";
  model = "SEL-751";
  ct_ratio = 600/5;
  element 51 {
    function = "phase_oc";
    stages {
      stage main {
        curve = iec.si;
        I_pickup = 550 A;
        tms = 0.20;
      }
      stage inst {
        curve = definite;
        I_pickup = 7000 A;
        t_delay = 0.06 s;
      }
    }
  }
}

relay R_SUB {
  voltage = MV;
  maker = "SEL";
  model = "SEL-751";
  ct_ratio = 300/5;
  element 51 {
    function = "phase_oc";
    curve = iec.vi;
    I_pickup = 280 A;
    tms = 0.15;
  }
}

# ---- the coordination chain, checked at each level -------------------
grade {
  primary = R_TIE:51;
  backup = R_INC:51;
  fault = BUS_MAX;
  margin = 0.30 s;
  upstream = true;
}

grade {
  primary = R_FDR:51;
  backup = R_TIE:51;
  fault = FEEDER_MAX;
  margin = 0.30 s;
  upstream = true;
}

# The sweep runs by default, and its ceiling is the largest fault
# declared at the primary's own voltage — here the 8.2 kA bus fault. A
# sub-feeder can never see that: its own maximum is 3.1 kA. Unbounded,
# the sweep runs into the feeder's 7 kA high-set and reports a margin
# inversion for a current this pair cannot experience.
#
# Voltage level is all the tool knows. Where the device sits on the
# network is what `upstream_to` tells it.
grade {
  primary = R_SUB:51;
  backup = R_FDR:51;
  fault = SUB_MAX;
  margin = 0.30 s;
  upstream_to = 3.1 kA;
}

grade {
  primary = spur_fuse_65T;
  backup = R_SUB:51;
  fault = SPUR_MAX;
  margin = 0.15 s; # fuse-relay pairs run tighter

  # The one pair in this study that is not swept above its fault, and
  # the reason is topology rather than protection.
  #
  # The sweep normally walks from the graded fault up to the largest
  # fault declared at that voltage, on the reasoning that the study
  # said that current is available. Here it is not available *to this
  # pair*: the fuse is at the far end of a spur, and the 8.2 kA bus
  # fault flows through R_SUB and every relay above it without ever
  # passing through the fuse. The most this fuse can carry is a fault
  # immediately downstream of it, which is "Spur max" — so there is
  # nothing above it to check.
  #
  # The language has no way to know that: nothing here says which
  # device is downstream of which. Until it does, a pair whose ceiling
  # is set by where it sits rather than by the fault levels says so.
  upstream = false;
}

# ---- the upstream backup, as one curve -------------------------------
#
# "Worst case, how long before something above the 11 kV bus clears?"
# is the SLOWEST of the incomer and the tie — an `envelope_max`.
#
# The two sit on opposite sides of the transformer, so this combine
# must say which bus it is READ at. A combined curve is evaluated at
# one current, and across a transformer one current is two: at 8.2 kA
# on the 11 kV bus the 33 kV incomer carries 2.73 kA, and that is the
# current it has to be asked about.
#
# The difference is not cosmetic. Handed the 11 kV figure instead, the
# incomer answers for a fault three times the one that happened:
#
#   at 8.2 kA (bus max)  1.11 s claimed   vs   2.03 s actual
#   at 2.0 kA            2.64 s claimed   vs   does not operate
#
# — 83% optimistic at the current this study grades at, and at 2 kA a
# clearance time for a path that never clears, because the incomer sees
# 667 A against a 700 A pickup. That is why the curve below begins at
# 2.1 kA (700 A referred) and not at 900 A.
#
# Omitted, this is an error (COMBINE_LEVEL_AMBIGUOUS) rather than a
# guess. A combine whose sources all sit on one level needs no
# `voltage` — sample 14's four take it from their sources.

combine {
  name = "Upstream backup";
  sources = [R_INC:51, R_TIE:51];
  as = envelope_max;
  voltage = MV;
  label = "Slowest upstream backup, read at 11 kV";
  style = dashed;
  color = "#6c3483";
}

# ---- each margin, on the sheet ---------------------------------------
annotate {
  primary = R_TIE:51;
  backup = R_INC:51;
  fault = BUS_MAX;
  label = "CTI";
}
annotate {
  primary = R_FDR:51;
  backup = R_TIE:51;
  fault = FEEDER_MAX;
  label = "CTI";
}
annotate {
  primary = R_SUB:51;
  backup = R_FDR:51;
  fault = SUB_MAX;
  label = "CTI";
}
annotate {
  primary = spur_fuse_65T;
  backup = R_SUB:51;
  fault = SPUR_MAX;
  label = "CTI";
}

annotate {
  on_curve = R_INC:51;
  at_I = 8.2 kA;
  label = "Incomer at bus fault";
  style = "leader";
  coords = true;
}

page {
  # A five-device cascade wants the extra width.
  size = "Legal";
  orientation = "landscape";
  theme = "light";
  border = true;
  title = {
    text = "Northgate 33/11 kV — phase overcurrent cascade";
    subtitle = "Incomer / tie / feeder / sub-feeder / spur fuse";
  };
  legend {
    title = "Devices";
  }
  axes {
    mirror = true;
  } # time scale repeated down the right-hand edge
  faults {
    width_px = 1.5;
  } # a touch heavier; each gets its own dash
}

view {
  voltage = MV;
  current_max = 60 kA;
  time_pad_high = 0.2;

  # A second current scale across the top, in the 33 kV incomer's own
  # amps. Ampere-turns referral is a UNIFORM map -- every current on
  # this sheet scales by the same 11/33 -- so the second scale is these
  # same pixels relabelled: exact, and not one curve moves.
  #
  # It is how a cross-level sheet is meant to be read. R_INC's 700 A
  # pickup is 2.1 kA down here; with the top scale you read 700 A off
  # the sheet instead of doing the division. Each fault rule carries
  # both figures for the same reason.
  #
  # `page { axes { mirror } }` would otherwise repeat the bottom scale
  # up there; the second axis takes that strip, since two rows of
  # numbers a few pixels apart -- one of them a copy -- is how a reader
  # takes the wrong one.
  second_axis = HV;
}

Clearance times

A study is judged up and down as well as left to right. An arc-flash boundary, a grid-code disconnection time and a transformer withstand are drawn as horizontal rules, so the requirement sits beside the characteristic that has to meet it. Each curve also stops at the largest fault its bus can deliver, rather than running to the frame.

Curves against arc-flash

💡 TIP
Open it in the playground: Gallery 3 — try it. It is also in the picker, listed as Gallery 3.

# Sample 13 — Clearance times: grading against the other axis
#
# A coordination study is usually read left to right: at this current,
# does the downstream device clear before the upstream one? But a study
# is also judged UP AND DOWN. An arc-flash boundary, a grid-code
# disconnection time, a transformer through-fault withstand — each is a
# time the protection must beat, at a current someone has already fixed.
#
# `times { ... }` puts those on the sheet as horizontal rules, so the
# requirement sits beside the characteristic that has to meet it instead
# of in the reader's head.
#
# In the playground, press ? (or Ctrl-Space) for completions: on a blank
# line it lists what the enclosing block accepts, and after an `=` it
# lists that field's values — and only the ones legal where you are.

meta {
  project = "Kingsford 33/11 kV";
  study = "Clearance times against the incomer";
  engineer = "R. Adeyemi";
  date = "2026-08-01";
  revision = "C";
  margin = 0.30 s;
}

system {
  voltages {
    HV {
      V = 33.0 kV;
      description = "33 kV incoming";
    }
    MV {
      V = 11.0 kV;
      description = "11 kV switchboard";
    }
  }

  # What is between the two levels, off the nameplate.
  #
  # The turns ratio alone refers a *balanced* fault correctly and
  # nothing else. A delta-star transition rotates the positive- and
  # negative-sequence components in opposite directions, so they
  # recombine differently on the far side: the phase-phase fault below
  # comes out 2:1:1 on the 33 kV lines, the largest being 2/sqrt(3) --
  # 15.5% more than the ratio gives. That current is what the incomer
  # actually sees, and it operates faster on it.
  #
  # The group also settles zero sequence: a delta blocks it, so the
  # earth fault below is not carried across at all.
  transformer HV to MV {
    vector_group = "Dyn11";
  }
}

faults {
  BOARD_MAX {
    name = "Board max";
    I = 9.4 kA;
    type = three_phase;
    voltage = MV;
  }
  BOARD_MIN {
    name = "Board min";
    I = 2.1 kA;
    type = two_phase;
    voltage = MV;
  }
  EARTH_MAX {
    name = "Earth max";
    I = 1.6 kA;
    type = single_phase_earth;
    voltage = MV;
  }
}

# ---- The requirements, as horizontal rules. --------------------------
#      A time needs no voltage level: a second is a second on every
#      winding, which is the one way this is simpler than a fault.
#
#      `at_I` says where along the rule to write its name. The rule
#      spans the whole plot, so its caption has no natural anchor and
#      goes at the left-hand end unless told otherwise — put it beside
#      the curve the requirement actually bites on.
times {
  ARC_FLASH_BOUNDARY {
    name = "Arc flash boundary";
    t = 500 ms;
    at_I = 6 kA;
    description = "Above this, PPE category rises a level";
  }
  GRID_CODE_CLEARANCE {
    name = "Grid code clearance";
    t = 800 ms;
    at_I = 3 kA;
    description = "DNO connection agreement, 11 kV";
  }
  TRANSFORMER_WITHSTAND {
    name = "Transformer withstand";
    t = 4 s;
    at_I = 1.2 kA;
    description = "ONAN 10 MVA, category II through-fault";
  }
}

# ---- A rating, as a vertical rule. ------------------------------------
#      `currents` is `times`'s answer in the other direction: a plant or
#      equipment limit rather than a required time, and never usable in
#      a grade or scenario -- it names no condition, only a figure from
#      a nameplate. `at_t` places its caption, mirroring `at_I` above.
currents {
  BUSBAR_RATING {
    name = "11 kV busbar rating";
    I = 12.5 kA;
    at_t = 300 ms;
    description = "Switchgear short-time withstand, 1 s";
  }
}

# ---- The board feeder. -----------------------------------------------
relay R_FDR {
  name = "11 kV feeder";
  maker = "SEL";
  model = "751";
  reference = "Panel 3B, relay 2";
  voltage = MV;
  ct_ratio = 600/5;

  element 51 {
    name = "Phase TOC";
    function = "phase_oc";
    curve = iec.vi;
    I_pickup = 720 A;
    tms = 0.14;

    # The board cannot deliver more than 9.4 kA, so the curve stops
    # there. Drawn to the frame it would invite a margin to be read
    # at a fault this network cannot produce.
    I_cutoff = 9.4 kA;
  }

  element 51G {
    name = "Ground TOC";
    function = "earth_fault";
    curve = iec.si;
    I_pickup = 120 A;
    tms = 0.10;
    I_cutoff = 1.6 kA;
  }
}

# ---- The incomer it grades against. ----------------------------------
relay R_INC {
  name = "33/11 kV incomer";
  maker = "ABB";
  model = "REF615";
  voltage = HV;
  ct_ratio = 300/5;

  element 51 {
    name = "Phase TOC";
    function = "phase_oc";
    curve = iec.vi;
    I_pickup = 260 A;
    tms = 0.40;
    I_cutoff = 3.2 kA;
  }
}

grade {
  primary = R_FDR:51;
  backup = R_INC:51;
  fault = BOARD_MAX;
  margin = 0.30 s;
  comment = "the case the arc-flash boundary is written for";
}

grade {
  primary = R_FDR:51;
  backup = R_INC:51;
  fault = BOARD_MIN;
  margin = 0.30 s;
}

# ---- Marking where a requirement is met. -----------------------------
#      `style = tag` is bare text beside the point; `pin` is a dot on
#      the curve with the label alongside; `leader` (the default) draws
#      an elbowed line when the label has to move to find room.
annotate {
  on_curve = R_FDR:51;
  at_I = 9.4 kA;
  label = "Feeder clears the board fault";
  style = tag;
  coords = true;
}

annotate {
  on_curve = R_INC:51;
  at_I = 3.2 kA;
  label = "Incomer backup";
  style = leader; # `pin` draws a marker only, and would lose this label
}

# ---- The margin the study exists to prove. ---------------------------
annotate {
  primary = R_FDR:51;
  backup = R_INC:51;
  fault = BOARD_MAX;
  label = "CTI";
}

# ---- A dimension between two figures, with no curve at either end. ---
#      Both margin forms measure between two *characteristics*. A
#      required band does not have a curve at either end — it is two
#      numbers an authority gave you — so `from` and `to` draw the
#      dimension directly.
#
#      The unit decides which way it runs: two times draw a vertical
#      span and need a current to stand at; two currents draw a
#      horizontal one and need a time to sit at.
annotate {
  from = 500 ms; # arc flash boundary
  to = 800 ms; # grid code clearance
  at_I = 6 kA;
  label = "band available";
  color = "#7a5c00";
}

#      The same idea across the sheet: the window the incomer's pickup
#      has to fall inside, read at its own operating time. Quoted as
#      the larger over the smaller, as every current gap on this sheet
#      is, so one dimension has one figure whichever way it is written.
annotate {
  from = 1.2 kA; # above maximum load
  to = 3.2 kA; # below the minimum fault it must see
  at_t = 4 s;
  label = "pickup window";
  color = "#7a5c00";
}

# ---- A marked point taken from the fault study. ----------------------
point WITHSTAND_CORNER {
  name = "Withstand corner";
  I = 1.2 kA;
  t = 4 s;
  label = "Transformer withstand corner";
  shape = diamond;
  color = "#8e44ad";
}

point Inrush {
  I = 2.4 kA;
  t = 100 ms;
  label = "Transformer inrush, 12x for 100 ms";
  shape = circle;
}

page {
  size = "A3";
  orientation = "landscape";
  theme = "light";
  border = true;

  # Rules are styled independently, so it is never a question which
  # axis a given rule belongs to.
  faults = {
    style = "dashed";
    labels = true;
  };
  times  = {
    style = "solid";
    color = "#1d7a4c";
    width_px = 1.6;
    labels = true;
  };

  scale  = {
    tick_density = "sparse";
  };

  title  = {
    text = "Kingsford 33/11 kV — clearance times";
    subtitle = "Feeder and incomer against arc-flash, grid-code and withstand limits";
  };
}

view {
  voltage = MV;
  quantity = any;
  current_min = 50 A;
  current_max = 30 kA;
  time_min = 20 ms;
  time_max = 20 s;
}

Devices from published tables

Not everything on a sheet is a relay setting. A fuse, a cable, a transformer’s damage limit, a motor start and a breaker’s own clearing time are all published as tables of (current, time) pairs. combine then builds synthetic curves from what is already declared -- the fastest of a group, the slowest, their sum.

A device library: fuse band

💡 TIP
Open it in the playground: Gallery 4 — try it. It is also in the picker, listed as Gallery 4.

# Sample 14 — Devices from published tables, and synthetic curves
#
# Not everything on a coordination sheet is a relay setting. A fuse, a
# cable, a transformer's damage limit, a motor start, a breaker's own
# operating time — each is a curve someone else published, given as a
# TABLE of (current, time) pairs rather than as constants in a formula.
#
#   flex_points = [(240 A, 50.0 s), (600 A, 5.0 s), ...]
#
# `device` carries those. `kind` says what sort of thing it is, which
# decides how it is drawn and which way round it should be graded: a
# relay must be FASTER than a cable's damage curve and SLOWER than a
# downstream fuse's total-clear.
#
# `combine` then builds a synthetic curve from curves already declared —
# the envelope of a group, their sum, or the first that operates.
#
# Press ? in the playground for completions; after `kind =` it lists
# exactly the six kinds, and after `as =` the four combining rules.

meta {
  project = "Halgarth pumping station";
  study = "Device library and protected envelope";
  engineer = "T. Okafor";
  date = "2026-08-01";
  revision = "A";
  margin = 0.30 s;
}

system {
  voltages {
    MV {
      V = 11.0 kV;
      description = "11 kV incoming";
    }
    LV {
      V = 0.4 kV;
      description = "400 V motor board";
    }
  }
  I_base = 1000 A;
}

faults {
  LV_BOARD_MAX {
    name = "LV board max";
    I = 21 kA;
    type = three_phase;
    voltage = LV;
  }
  LV_BOARD_MIN {
    name = "LV board min";
    I = 8.4 kA;
    type = two_phase;
    voltage = LV;
  }
}

# ---- The published curves. -------------------------------------------
#      Each is a table read off a manufacturer's sheet. `reference`
#      records where, because a number nobody can trace is a number
#      nobody can check.

device spur_fuse_100T {
  kind = fuse;
  voltage = LV;
  maker = "Mersen";
  model = "100T";
  rating_I = 100 A;
  rating_V = 0.6 kV;
  reference = "Mersen T-link data sheet, table 2";
  description = "Spur fuse to the sump pump";

  # A fuse is a band, not a line: it may start to melt at the first
  # curve and is guaranteed clear by the second. The renderer shades
  # between them, so no `combine` is needed.
  min_melt    = [(200 A, 10.0 s), (400 A, 1.20 s), (800 A, 0.14 s), (1.6 kA, 0.022 s)];
  total_clear = [(200 A, 16.0 s), (400 A, 2.00 s), (800 A, 0.26 s), (1.6 kA, 0.045 s)];
}

device sub_main_cable {
  kind = cable;
  voltage = LV;
  rating_I = 400 A;
  reference = "IEC 60949, 185 mm2 Cu XLPE, k = 143";
  description = "Board to motor control centre, 185 mm2";

  # Adiabatic damage: t = (k*S/I)^2. Tabulated here rather than
  # derived, so the sheet shows the same numbers the cable schedule does.
  flex_points = [(4 kA, 43.0 s), (8 kA, 10.8 s), (16 kA, 2.70 s), (32 kA, 0.67 s)];
}

device tx_damage_1250 {
  kind = transformer_damage;
  voltage = LV;
  maker = "Wilson";
  model = "1250 kVA ONAN";
  rating_S = 1.25 MVA;
  rating_V = 11 kV;
  reference = "IEEE C57.109 category II";
  description = "Through-fault withstand, delta-star";

  flex_points = [(3.6 kA, 300 s), (7.2 kA, 75 s), (14.4 kA, 18.8 s), (28.8 kA, 4.7 s)];
}

device sump_motor_start {
  kind = motor_startup;
  voltage = LV;
  rating_I = 180 A;
  reference = "Motor data sheet, DOL, 6.2x FLC";
  description = "132 kW sump pump, direct on line";

  # The start is a load, not a fault: nothing may trip during it.
  flex_points = [(1.12 kA, 9.0 s), (1.12 kA, 0.05 s)];
}

device acb_operating {
  kind = breaker;
  voltage = LV;
  maker = "Schneider";
  model = "NW20";
  rating_I = 2000 A;
  reference = "Masterpact NW catalogue, opening + arcing";
  description = "Air circuit breaker own clearing time";
  t_delay = 50 ms;
}

device recloser_R1 {
  kind = recloser;
  voltage = MV;
  maker = "Schneider";
  model = "N-Series";
  rating_I = 630 A;
  reference = "N-Series fast curve, 11 kV pole-top";
  description = "Pole-top recloser, fast operation";

  flex_points = [(700 A, 0.60 s), (2 kA, 0.10 s), (6 kA, 0.045 s)];
}

# ---- The relays being graded against them. ---------------------------
relay R_MCC {
  name = "MCC incomer";
  maker = "SEL";
  model = "751";
  reference = "MCC-1 panel";
  voltage = LV;
  ct_ratio = 2000/5;

  element 51 {
    name = "Phase TOC";
    function = "phase_oc";
    curve = iec.vi;
    I_pickup = 1.6 kA;
    tms = 0.10;
    I_cutoff = 21 kA;
  }
}

relay R_BOARD {
  name = "LV board incomer";
  voltage = LV;
  ct_ratio = 3000/5;

  element 51 {
    name = "Phase TOC";
    function = "phase_oc";
    curve = iec.vi;
    I_pickup = 2.4 kA;
    tms = 0.25;
    I_cutoff = 21 kA;
  }

  # An element can carry a table too, when the device it models is
  # not one of the built-in kinds.
  element 49 {
    name = "Thermal overload";
    function = "thermal";
    flex_points = [(2.6 kA, 900 s), (4 kA, 220 s), (8 kA, 40 s)];
  }
}

# ---- Synthetic curves. -----------------------------------------------
#      `combine` draws a curve that is not any one device's:
#
#        envelope_min   the fastest of the group at every current
#                       — what actually clears, whichever acts first
#        envelope_max   the slowest — the worst case to grade against
#        sum            times added, for series operations
#        select_first   the first source that operates at all
#
#      They are curves like any other, so they can be graded and
#      annotated.

combine {
  name = "Fastest LV protection";
  sources = [R_MCC:51, R_BOARD:51];
  as = envelope_min;
  label = "Fastest LV protection (envelope)";
  color = "#7d3c98";
}

combine {
  name = "Slowest LV protection";
  sources = [R_MCC:51, R_BOARD:51];
  as = envelope_max;
  label = "Worst case to grade against";
  color = "#b7950b";
}

combine {
  name = "Board clear incl. breaker";
  sources = [R_BOARD:51, acb_operating];
  as = sum;
  label = "Relay + ACB total clearing time";
  color = "#1a5276";
}

combine {
  name = "First to operate";
  sources = [R_MCC:51, R_BOARD:49];
  as = select_first;
  label = "Whichever element picks up first";
  color = "#117a65";
}

# ---- Grading. --------------------------------------------------------
grade {
  primary = R_MCC:51;
  backup = R_BOARD:51;
  fault = LV_BOARD_MAX;
  margin = 0.30 s;
  comment = "MCC ahead of the board incomer";
}

annotate {
  primary = R_MCC:51;
  backup = R_BOARD:51;
  fault = LV_BOARD_MAX;
  label = "CTI";
}

annotate {
  on_curve = R_BOARD:51;
  at_I = 8.4 kA;
  label = "Board incomer at minimum fault";
  style = leader;
}

point MOTOR_START_CORNER {
  name = "Motor start corner";
  I = 1.12 kA;
  t = 9 s;
  label = "DOL start, 6.2x FLC for 9 s";
  shape = triangle;
}

page {
  size = "A3";
  orientation = "landscape";
  theme = "light";
  border = true;
  legend      = {
    title = "Devices and curves";
    position = "right";
    swatch = "line";
  };
  curves      = {
    palette = "okabe_ito";
    line_width_px = 1.8;
  };
  points      = {
    shape = "triangle";
    size_px = 8;
    outline = true;
  };
  title       = {
    text = "Halgarth pumping station — device library";
    subtitle = "Published tables, synthetic envelopes, and the relays graded against them";
  };
}

view {
  voltage = LV;
  quantity = any;
  current_min = 100 A;
  current_max = 60 kA;
  time_min = 10 ms;
  time_max = 1 ks;
}

Sequence currents

A relay does not measure "the fault current". At one unbalanced fault a 51 sees phase current, a 51G sees the residual 3*I0 and a 46 sees negative sequence -- three different numbers. Name the condition a sheet depicts and every curve is placed on the chosen axis using that condition’s own declared figures, with the conversion factor stated.

The same study drawn against negative-sequence current

💡 TIP
Open it in the playground: Gallery 5 — try it. It is also in the picker, listed as Gallery 5.

# Sample 17 — One study, four sheets: phase, I2, 3I2 and 3I0
#
# A relay does not measure "the fault current". At one unbalanced fault
# there are several different currents, and each element is evaluated
# against the one it actually sees. Which of them the sheet's x-axis is
# drawn in is a choice — and a sheet that does not say is a sheet two
# engineers will read two different ways.
#
#   quantity = phase   ordinary phase current
#   quantity = I2      negative sequence, as a 46 element is often set
#   quantity = 3I2     three times it, as other IEDs are scaled
#   quantity = 3I0     residual, what a 51G measures
#   quantity = any     everything on one axis, whatever it measures
#
# The factor of three between `I2` and `3I2` decides whether an element
# operates at all, so `measures` has no default and must be stated.
#
# `annotate` can name the component it is placed at — `at_I` for phase,
# `at_I1`, `at_I2`, `at_I0`, `at_residual` (or `at_3I0`, the same figure
# spelled the way `quantity` names it) — instead of leaving the reader
# to work out which current a bare number meant.

meta {
  project = "Cardross 33 kV";
  study = "Sequence sheets for an unbalanced fault";
  engineer = "P. Nowak";
  date = "2026-08-01";
  revision = "A";
  margin = 0.30 s;
}

system {
  voltages {
    HV {
      V = 33.0 kV;
      description = "33 kV ring";
    }
  }
}

# ---- Two conditions, both unbalanced. --------------------------------
scenario PHASE_EARTH_AT_THE_TEE {
  name = "Phase-earth at the tee";
  type = single_phase_earth;
  description = "Solid A-earth fault at the tee point";

  # I1 = I2 = I0 = If/3 for a solid phase-earth fault; the residual
  # 3*I0 is the whole fault current.
  #
  # Writing both `I0` and `residual` states one number twice, and the
  # tool checks they agree (SEQUENCE_RESIDUAL_CONFLICT). It has to:
  # `I0` is read from `I0` and `3I0` from `residual`, each preferring
  # the figure written for it, so a contradictory pair would grade a
  # 3I0 relay and an I0 relay at the same earth fault against currents
  # in a ratio the definition forbids. Either figure alone is enough —
  # both are written here because the fault study quotes both.
  level HV {
    I = 2.4 kA;
    I1 = 800 A;
    I2 = 800 A;
    I0 = 800 A;
    residual = 2.4 kA;
  }
}

scenario PHASE_PHASE_AT_THE_TEE {
  name = "Phase-phase at the tee";
  type = two_phase;
  description = "B-C fault, no earth path";

  # A phase-phase fault carries no zero sequence at all, so the
  # residual sheet has nothing to draw for it — and says so.
  level HV {
    I = 2.1 kA;
    I1 = 1.21 kA;
    I2 = 1.21 kA;
    I0 = 0 A;
    residual = 0 A;
  }
}

faults {
  RING_MAX {
    name = "Ring max";
    I = 3.6 kA;
    type = three_phase;
    voltage = HV;
  }
}

# ---- The tee relay: three elements, three different currents. --------
relay R_TEE {
  name = "Tee point (SEL-751)";
  maker = "SEL";
  model = "751";
  reference = "Cardross tee kiosk";
  voltage = HV;
  ct_ratio = 400/5;

  element 51 {
    name = "Phase TOC";
    function = "phase_oc"; # -> phase current
    curve = iec.si;
    I_pickup = 480 A;
    tms = 0.12;
  }

  element 51G {
    name = "Ground TOC";
    function = "earth_fault"; # -> residual 3*I0
    curve = iec.si;
    I_pickup = 240 A;
    tms = 0.10;
  }

  element 46 {
    name = "Negative sequence";
    function = "neg_seq";
    measures = I2; # this IED is scaled in I2
    curve = definite;
    I_pickup = 200 A;
    t_delay = 0.60 s;
  }
}

# ---- The ring relay backing it up, scaled in 3*I2. -------------------
#      Same physics, different convention: state which and the tool
#      places both on whichever sheet is asked for.
relay R_RING {
  name = "Ring incomer (ABB REF615)";
  maker = "ABB";
  model = "REF615";
  voltage = HV;
  ct_ratio = 600/5;

  element 46 {
    name = "Negative sequence backup";
    function = "neg_seq";
    measures = 3I2; # scaled in three times I2
    curve = definite;
    I_pickup = 900 A;
    t_delay = 1.20 s;
  }

  element 51G {
    name = "Ground backup";
    function = "earth_fault";
    measures = 3I0; # residual, stated rather than assumed
    curve = iec.si;
    I_pickup = 400 A;
    tms = 0.22;
  }

  # Positive sequence. Unlike phase current it is unaffected by the
  # unbalance itself, so it makes a backup that reads the same figure
  # whichever phase the fault is on.
  # A curve can be kept to the sheets it belongs on, the same way a
  # mark can. Positive sequence has nothing to say on the residual
  # sheet, and drawing it there invites a comparison against curves it
  # shares no axis with. `view` takes one name, `views` a list; leave
  # both off and the curve is drawn on every sheet.
  element 51P {
    name = "Positive sequence backup";
    function = "phase_oc";
    measures = I1;
    curve = iec.si;
    I_pickup = 500 A;
    tms = 0.30;
    views = [Phase, NEGATIVE_SEQUENCE];
  }
}

grade {
  primary = R_TEE:46;
  backup = R_RING:46;
  scenario = PHASE_EARTH_AT_THE_TEE;
  margin = 0.30 s;
  comment = "both measure negative sequence, one scaled by three";

  solve {
    strategy = safety_factor;
    tolerance_pct = 10;
    comment = "what a 1.5x safety factor on the target would need";
  }
}

grade {
  primary = R_TEE:51G;
  backup = R_RING:51G;
  scenario = PHASE_EARTH_AT_THE_TEE;
  margin = 0.30 s;
}

# ---- Annotations that name their component. --------------------------
#      A bare `at_I` is a phase current. On a sequence sheet that is
#      rarely what is meant, so each component has its own spelling.
annotate {
  on_curve = R_TEE:46;
  at_I2 = 800 A;
  label = "46 at the phase-earth fault";
  style = tag;
}

# An earth-fault mark has nowhere to go on a sheet drawn for a fault
# with no earth in it. The "Three times I2" sheet depicts the
# phase-phase condition, where I0 and the residual are both zero — so
# there is no factor from 3I0 onto that axis, and these three marks are
# scoped to the sheets where zero sequence actually exists. Left
# unscoped the sheet still draws, and says in its notes which marks it
# could not place.
annotate {
  on_curve = R_TEE:51G;
  at_residual = 2.4 kA;
  label = "51G sees the whole residual";
  style = leader;
  views = [Phase, NEGATIVE_SEQUENCE, Residual];
}

# The component written has to be one the element measures. This relay
# is scaled in 3I2, so the figure is declared as I2 and the factor of
# three is applied for you -- writing `at_I1` here would be refused,
# because positive sequence is not negative sequence and the tool will
# not quietly substitute one for the other.
annotate {
  on_curve = R_RING:46;
  at_I2 = 800 A;
  label = "declared as I2, read on a 3I2 element";
  style = tag;
}

annotate {
  on_curve = R_RING:51P;
  at_I1 = 800 A;
  label = "positive-sequence backup";
  style = tag;
}

annotate {
  on_curve = R_RING:51G;
  at_I0 = 800 A;
  label = "I0 rather than 3I0";
  style = tag;
  views = [Phase, NEGATIVE_SEQUENCE, Residual];
}

# `at_t` places the mark by time instead of current — where a curve is
# read the other way round, "what current gives me 300 ms?".
#
# `style` decides what is drawn: `leader` an elbowed line to the text,
# `tag` the text alone, `pin` a bare marker. A `pin` draws no label, so
# writing one beside it is refused rather than dropped.
annotate {
  on_curve = R_TEE:51;
  at_t = 300 ms;
  label = "phase element at 300 ms";
  style = leader;
}

annotate {
  on_curve = R_TEE:51G;
  at_3I0 = 1.2 kA;                # same figure as at_residual
  style = pin;                    # a marker only — no label to lose
  views = [Phase, NEGATIVE_SEQUENCE, Residual];
}

# ---- A point drawn for both conditions at once. ----------------------
#      `scenarios` takes a list; the marker is drawn once per condition
#      named, at each one's own figure for the level being shown.
# ---- A mark that belongs to one sheet only. --------------------------
#      `view` (or `views`, for a list) keeps a mark to the sheets it
#      means something on. A phase-current inrush figure is meaningless
#      on the I2 axis; drawn there it invites a reader to compare it
#      against a curve it has nothing to do with.
point PHASE_INRUSH {
  name = "Phase inrush";
  I = 1.8 kA;
  t = 100 ms;
  view = Phase;
  label = "Inrush, phase amps";
  shape = cross;
}

times {
  # `at_I` says where along the rule to write its name. A bare `at_I`
  # is a *phase* current, which on these sheets is a figure from
  # another axis — so the component is named, exactly as a `point` or
  # an `annotate` names one. Written the wrong way the caption still
  # appears, at the left-hand end, and the sheet says why.
  EARTH_FAULT_CLEARANCE {
    name = "Earth-fault clearance";
    t = 800 ms;
    at_3I0 = 1.2 kA;    # same figure as at_residual, spelled to match `quantity = 3I0`
    views = [Residual];
  }

  NEGATIVE_SEQUENCE_CLEARANCE {

    name = "Negative-sequence clearance";
    t = 900 ms;
    at_I2 = 600 A;
    views = [NEGATIVE_SEQUENCE, THREE_TIMES_I2];
  }
}

point TEE_I2 {
  name = "Tee I2";
  scenarios = [PHASE_EARTH_AT_THE_TEE, PHASE_PHASE_AT_THE_TEE];
  voltage = HV;
  t = 0.60 s;
  label = "46 operates";
  shape = square;
}

page {
  size = "A4";
  orientation = "landscape";
  theme = "light";
  legend      = {
    title = "Elements";
    position = "top_right";
  };
  curves      = {
    palette = "ieee";
  };

  # A footer along the foot of the sheet. This study declares no
  # `border`, so there is no title block and the footer sits under the
  # plot; on a framed sheet the same three slots are drawn along the
  # foot of the title block instead. One declaration, placed to suit
  # the sheet — sample 16 shows the framed form.
  #
  # `[meta.*]` reads any field of the `meta` block, so the strings stay
  # correct when the study is revised rather than being a second copy
  # to keep in step.
  #
  # `[page]` and `[of]` are also available and deliberately not used
  # here: one SVG is one sheet and has no pagination to report, so they
  # resolve to "?" until the study is exported to PDF, where the
  # exporter counts the views. Put them in a footer you print, not in
  # one you read on screen.
  footer      = {
    left = "[meta.project] · [meta.study]";
    center = "[meta.engineer] · rev [meta.revision]";
    right = "[meta.date]";
  };
}

# ---- Four sheets of the same study. ----------------------------------
#      `condition` lets curves measuring one quantity be drawn on an
#      axis in another, using that condition's own declared figures.
view Phase {
  default = true;
  voltage = HV;
  quantity = phase;
  condition = PHASE_EARTH_AT_THE_TEE;
  title = "Phase sheet";
  subtitle = "Everything converted onto phase current";
  current_min = 50 A;
  current_max = 20 kA;

  # Room beyond the fitted range, as a factor, rather than fixed
  # bounds — so the sheet still frames itself if a setting changes.
  time_pad = 1.5;
  time_pad_low = 2;
}

view NEGATIVE_SEQUENCE {
  name = "Negative sequence";
  voltage = HV;
  quantity = I2;
  condition = PHASE_EARTH_AT_THE_TEE;
  title = "I2 sheet";
  subtitle = "As the tee relay is scaled";
  current_min = 50 A;
  current_max = 20 kA;

  current_pad_low = 1.5;
  current_pad_high = 1.2;
}

view THREE_TIMES_I2 {
  name = "Three times I2";
  voltage = HV;
  quantity = 3I2;
  condition = PHASE_PHASE_AT_THE_TEE;
  title = "3I2 sheet";
  subtitle = "As the ring relay is scaled — the same physics, x3";
  current_min = 50 A;
  current_max = 20 kA;
}

view Residual {
  voltage = HV;
  quantity = 3I0;
  condition = PHASE_EARTH_AT_THE_TEE;
  title = "Residual sheet";
  subtitle = "3I0 — what an earth-fault element measures";
  current_min = 50 A;
  current_max = 20 kA;
}

Project layout

| Path | What | | --- | --- | | src/index.ts | Public library API (process, parseAndRender, renderStudy, re-exports) | | src/cli.ts | Node CLI entry (tc-curves) | | src/parser/ | Tokeniser, recursive-descent parser, and AST for .ptc source | | src/constants/ | Curve constants tables and the device catalog | | src/semantics/ | Study model, curve evaluators, stages, combines, cross-voltage, solver, grading reports, validation | | src/renderer/ | Hand-rolled log-log SVG composer, scales, ticks, palettes, themes | | src/export/ | Self-contained SVG, PNG, and PDF export | | src/editor/, src/highlight/, src/help/ | CodeMirror autocompletion, hover help, snippets, and syntax highlighting | | src/components/ | Lit web components — the playground app, editor, and viewer | | spec/ | AsciiDoc language specification (spec/spec.adoc) | | examples/ | Sample .ptc studies, used by the playground and the tests | | tests/ | Vitest unit tests, including the spec’s normative worked examples |

Status

Under active development, tracking spec v0.1.0-draft. The language, the semantics (curves, stages, combines, cross-voltage grading, the solver), validation, rendering, export, the CLI, and the playground are implemented and tested against the spec’s normative worked examples.

Deferred to v0.2 by the spec: distance zones (21), transformer differential (87), voltage-restrained overcurrent (51V), auto-reclosing (79), per-element G_D override, safety_factor solving, multi-variable solving on multi-stage elements, and interactive curve dragging.

License

MIT © 2026 Daniel Mulholland