Skip to content

PerfStudio v0.5.0

Choose a tag to compare

@github-actions github-actions released this 14 Aug 15:44
· 6 commits to main since this release

Added

  • A part can be given a value (double-click it, or F2). This was the one field on
    the document no human could reach: every part the window placed was created with
    value="" and nothing could change it afterwards, while an agent on the MCP server has
    been able to pass one to place_component since that server existed. guide._bom
    groups on exactly this field, so the tool's own build guide printed "Resistor × 4"
    where it meant "10k × 4".

    • The dialog carries only what component.update carries — reference, value, lock.
      Rotation is a command of its own, and putting it here would make one press of OK into
      two entries on the undo stack for what the user experienced as one edit.
    • The Parts panel has a value box as well, applied to each part as it is placed. A
      board is populated in runs — five 10k resistors, then three 100nF — and naming each
      one afterwards through its own dialog is the same work done once per part instead of
      once per run.
  • Right-click menus on the board, the Nets panel and the findings panel. There were
    none anywhere: everything a part can be told to do lived in the menu bar at the top of a
    window a metre wide, while the part itself was under the pointer in the middle of the
    board. Every menu is built from the same QAction objects the menu bar holds, so an
    action greyed out in one place is greyed out in the other. Right-click already means
    "finish" inside a board mode, and the guards for that are described in
    BoardView.contextMenuEvent.

  • The window remembers its layout — geometry, dock sizes and positions, board colour,
    the ratsnest, ruler and hatch toggles, and the preferred connection style. All of it
    reset on every launch, and the cost was paid by whoever used the tool most. The 3D panel
    deliberately still starts closed: restoring it open would build VTK's whole pipeline
    during startup to show a board nobody has looked at yet.

  • The build guide, in the window (Ctrl+4). The soldering order is the thing this
    application is for, and the only way to see one was to export four files and go and
    find them — so the order the tool had worked out was invisible while the board was being
    designed, which is when it is worth knowing. Picking a step selects its parts on the 2D
    board, brings its holes into view, and moves the 3D assembly slider with it. Closed by
    default and rebuilt only while open, because building a guide runs DRC and LVS.

  • The export offers to open what it wrote, rather than ending at a line in the status
    bar naming a file in a directory the user then has to go and find.

  • A language menu (View ▸ Language). The Turkish catalogue has existed the whole time
    and could be selected only by an environment variable or a command-line flag. Applied at
    the next start, and it says so: every label is translated once as the window is built,
    and the widgets a live rebuild missed would be exactly the ones nobody would notice had
    stayed English.

  • Files can be dropped on the window — a .perf to open it (through the same
    unsaved-work guard the Open menu item uses) or a .net to import it. Nothing happened
    before, which reads as the application refusing that kind of file rather than refusing
    drops.

  • The findings panel has a filter box, like the parts and nets panels. A board
    mid-layout carries a hundred proximity warnings, and "show me the errors" is how anybody
    reads a list that long.

  • Copy, paste and duplicate a block of board (Ctrl+C / Ctrl+V / Ctrl+D). A
    perfboard project repeats itself in a way a PCB does not — eight identical channels,
    the same RC pair at every op-amp — and until now the only way to build the second one
    was to place every part again by hand.

    • A block is parts AND the copper between them, placed by one new command,
      block.place. Two commands would put a state on the undo stack nobody chose: the
      parts down with their wiring gone, one Ctrl+Z from a board that looks finished and
      is not. The copper is validated against a document the new parts have already
      joined, which is what lets a pasted lead bend name the part it is a leg of.
    • JSON on the system clipboard, not a variable on the window, so a block crosses
      documents and crosses two running copies of the application — which is the "channel 1
      into that other board" case. It is readable, so a block can be pasted into a bug
      report, for the same reason the project file is diffable.
    • A copy of R1 is not R1. Pasted parts get fresh references counted from the board,
      and pasted copper carries no net claim: copper that kept net_id would tell LVS
      the new block is wired to a schematic that has never heard of it. Unclaimed copper is
      also the one kind rip-up and the stale-conductor cleanup both promise never to touch.
    • Three things are deliberately left behind, and each is reported rather than silent: a
      lead bend whose part was not in the selection (it would be a leg of nothing), the
      lock (a pasted part is one you are still positioning), and copper that the offset
      would have pushed off the board.
    • Duplicate does not touch the system clipboard. Duplicating a part is a board
      operation; it has no business throwing away what somebody copied elsewhere.
  • Stripboard is a board type this application can actually design on, rather than a
    string the data model accepted and nothing understood. board.type and strip_axis
    have been in model.py, persist.py and the .perf format since the first commit, a
    cut.add command has existed the whole time, and nothing read any of it: the
    connectivity engine did not know a strip joins the holes along it, so a stripboard
    loaded as a pad-per-hole board with a decorative field set.

    • A cut destroys the copper AT a hole (stripboard.py). That is how a track is
      actually broken — a spot-face cutter or a drill bit turned by hand in the hole, which
      takes the pad with it. The alternative model, a cut between two holes, describes a
      knife scored across the track: a real technique, much harder to do reliably at 2.54 mm
      and much harder to inspect, and one that would leave a cut without an address in an
      application where every message is addressed.
    • Connectivity gained a fourth rule beside the three it has always had: on
      stripboard the BOARD joins holes, and nobody soldered those connections. Only holes
      something is soldered into take part — a strip physically joins all thirty holes in
      its row, and registering the twenty-six nobody used would put every empty pad on the
      board into a net, which is exactly what the module's existing note says not to do.
      Gated on the board type, which is why fifteen golden fixtures still reproduce byte
      for byte.
    • The autorouter for a stripboard subtracts before it adds (striproute.py). Two
      pins of different nets on one strip are shorted by the board itself, so the first
      pass is cuts and the second is links; the links go over the COMPONENT side, because
      the solder side is one sheet of parallel copper and a wire laid across it there shorts
      every strip it crosses. Pins it cannot separate — adjacent, with no hole between them
      to drill — are reported by name rather than routed around, because the fix is to move
      a part and that is the user's decision.
    • The cuts and the links commit as one command (stripboard.apply). Separately,
      one Ctrl+Z leaves a board cut apart with nothing linking it, or linked with nothing
      cut — which is a short across two nets, and a state nobody designed.
    • DRC reports a pin standing in a cut hole (cut-track-conflict), an error for the
      same reason mounting-hole-conflict is: the pad is gone, so the board cannot work
      rather than probably will not.
    • Both renderers draw the strips, and the 2D view marks each cut. A stripboard drawn
      as a grid of separate pads is a picture of a different board, on a view whose whole
      job is to be checked against the real one.
    • The build guide cuts first and measures each one. The cuts are made from the
      copper side with a drill, and once a part is over a hole there is no way back to it —
      so they are phase 0, and each gets a blocking isolation probe, because a cut that did
      not go all the way through looks exactly like one that did.
    • Board Setup chooses the type and which way the strips run, and Draw ▸ Cut Track
      (X) makes and un-makes cuts by clicking. Not done: the placer does not yet reward
      putting a net's pins on one strip, which on stripboard is most of the design — said
      here rather than left to be discovered.
  • Something now checks what the render looks like (PLAN.md §10 asks for visual
    regression). The headless run has always produced PNGs and CI has always kept them as
    artefacts, and the only thing asserted about one was that it began with the PNG magic
    bytes — so a render that lost every pad, drew the board inside out or came out blank
    passed the build, and somebody had to open the artefact and look.

    • Not a pixel diff. Antialiasing and Qt's own version move individual pixels across
      platforms and this suite runs on three; a per-pixel golden would fail on macOS for
      reasons that have nothing to do with the board, and a test that fails for the wrong
      reason gets switched off. tests/test_render_golden.py compares the mean colour of
      each cell of a 6 × 6 grid
      against a checked-in signature: re-rendering the board
      moves it by 0.0 of 255, rendering the other face by 27.5, and rendering it with every
      part and conductor removed by 22.6 — against a tolerance of 3.
    • The first attempt measured how much of each cell was covered in ink and was nearly
      useless: a perfboard is mostly board, so losing every part moved a cell by 2.6 points
      against a tolerance of 2. That is recorded in the file, because it looked reasonable.
  • The window notices when the file changes underneath it (PLAN.md §9.3). The project
    file is diffable and agent-friendly precisely so that a session which only writes
    files
    still works — and the window was the one participant that did not notice: the
    board on screen went quietly stale and the next save overwrote everything the agent had
    done.

    • A window with no unsaved edits reloads itself. One with unsaved edits does not,
      and says so instead: the file and the window have both moved, and only the person in
      front of it can say which is right. Losing somebody's work to a background event is
      the one outcome that must not happen, so File ▸ Reload from Disk (F5) is the
      manual way to take the file's version.
    • The viewport is left where it was on a reload — somebody watching an agent work is
      looking at a particular corner of the board — and a save does not trigger one, which
      would have thrown away the window's own undo history for nothing.
  • Five MCP tools for the board itself: set_board, add_mounting_hole,
    add_edge_connector, cut_track and remove_board_feature. The asymmetry they fix is
    the argument for them: get_board_info reported mounting holes and edge connectors that
    nothing could add, and the only route to a different board size, material or type was
    new_document, which throws the work away. One delete covers all three kinds of feature
    because they differ only in which list the id is in. Forty-four tools now, against
    PLAN.md §2's "~25, deliberately narrow" — the cap is a decision somebody has to make on
    purpose, and tests/test_mcp.py still makes them make it.

  • Measure the distance between two holes (View ▸ Measure Distance, Ctrl+M). It
    reports three numbers because they answer three different questions: holes across
    is what a footprint and the build guide are written in, mm is what a lead-bending
    jig and a pair of pliers are set to, and steps is how much solder trace it would
    take — a diagonal is two steps of copper, not 1.4, because solder crosses the 0.6 mm
    orthogonal gap and not the 1.7 mm diagonal one. The answer follows the pointer, because
    the question is usually "how far to about there". The one tool in the window that
    changes nothing.

  • Go to Part (View ▸ Go to Part…, Ctrl+G) — filters on reference, value and
    footprint together and centres the view on what it finds. Which of the three somebody
    remembers depends on why they are looking: R37 from a DRC message, 10k from the
    schematic, TO-220 from the pile of parts on the bench. On a dense board there was no
    way to answer "where is R37" except to read the screen until it turned up.

Changed

  • The Turkish translation is complete. 216 strings were wrapped in t() and roughly
    67 were not — including every tooltip and all the tree headers, so --lang tr gave
    Turkish menu items with English explanations under them, which is the half a user stops
    to read. tests/test_i18n.py now checks the direction it could not see before: a
    user-facing string never wrapped in t() is not a missing translation, it is not in the
    system at all, so it moved no number and nothing reported it.

    • The catalogue scanner understands adjacent string literals as one key, because a
      tooltip lives in the source as three quoted fragments on three lines. Without that,
      wrapping a tooltip both failed the coverage check and reported its own catalogue key
      as stale — which is why the tooltips stayed English.
  • The findings panel keeps your place across an edit. It rebuilt from clear() on
    every command, throwing away the expanded groups and the selected row — so working
    through a rule meant re-expanding it after every attempt to fix what the rule was
    complaining about. Groups are restored by name, since a rule that gained a violation
    moves down the tree and one that lost its last disappears. Severity now colours the row
    too, in the colours the status bar already uses for the same counts.

  • The Nets panel's "Left" column is now "To route". One English key cannot carry two
    meanings in a catalogue whose keys are the English strings, and Board Features already
    has an edge called Left. Saying what the number counts is better English anyway.

  • Autorouting a big board is a third faster, and the interesting part is which third.
    A 100 × 60 board with 60 parts took 6.8 s; it takes 4.5 s. Every golden route
    reproduces byte for byte, which is the only reason to believe the change was safe.

    • The A* open list was not the problem, which was worth measuring before trading
      away the differential proof to fix it. router.py's docstring has always said a
      binary heap would change tie-breaking among equal-f nodes and could silently pick a
      different equal-cost path; a profile put the search loop at under a tenth of the time.
      It stays a linear scan, now with the measurement written beside the reason.
    • The time was in R5'. Pricing bridging risk into the search — the thing this
      project is organised around — asked _has_foreign_neighbour a million times for about
      two thousand distinct questions per route, rebuilding a set of "our" nets on every one
      of them for a value that cannot change while a search runs. Both are memoised on the
      route context now.
    • And in building "37,12" strings: 15.8 million calls to geometry.hole_key, more
      than the search itself cost. router.py keys its own sets on (col, row) tuples.
      hole_key stays the one encoding for everything that crosses a module boundary —
      occupancy, connectivity, DRC — all of which have golden output that must not move.
    • Two tests pin the properties rather than a stopwatch: a timing assertion is a flaky
      test wearing a useful hat.
  • The headless run is its own module (ui/headless.py). It is a program in its own
    right, it shares nothing with the window but the scene it renders, and being importable
    on its own is what lets a test call it without standing up a MainWindow. main.py
    loses 228 lines, and starting the GUI no longer imports the CLI.

  • Importing a netlist places its parts in one undo step, which is what the code doing
    it has claimed in its own docstring since it was written. It dispatched one
    component.place per part, so taking back a thirty-part import took thirty presses of
    Ctrl+Z and every one of them left a half-imported board. It is one block.place now.

  • The build guide is a third of the size it was. dense.perf produced a 6378 KB
    guide.html; the same board now produces 2070 KB, and nothing was dropped from it.

    • The step images are JPEG rather than PNG. They are photographs of a lit 3D scene
      — smooth shading, no flat colour, no sharp text — which is the exact content PNG
      stores worst: 135.6 KB per image against 47.0 KB at quality 82, measured on the 33
      steps of dense.perf. Below about quality 70 the compression starts ringing around
      the thin leader lines in the exploded shots, so 82 is where it stopped rather than at
      the smallest number that still looked fine in a thumbnail.
    • JPEG and not WebP, which would have been smaller again: vtkJPEGWriter is linked
      into VTK, while Qt's WebP writer is an image-format plugin that has to be collected
      into a PyInstaller bundle — and a missing plugin is a failure on the user's machine,
      not on ours. The guide's whole promise is that it opens anywhere, later.
    • guide_export reads the media type off the bytes instead of naming PNG in the
      data URI. It is inlined into a file with no network behind it, so a picture announced
      as the wrong type is a broken image in the one place nobody can re-fetch it — and the
      renderer's format has now changed once, which is the argument against agreeing on it
      in two places.
  • The printed guide is legible when the browser is in dark mode. @media print reset
    the body colours but not the palette tokens, and browsers drop background colours when
    they print — so on a dark-mode machine every .meta line printed pale grey on white
    paper. The print block now redefines the whole palette. This guide is meant to be taped
    next to the board.

  • Ruff is a gate. It reported 466 findings and did not block, which is the worst of
    both worlds: a permanently red tick is one nobody reads, so it protects nothing while
    looking like it does.

    • 389 of the 466 were two rules that are wrong for this codebase, and they are off
      in pyproject.toml with the argument written at the switch. E501 fired 235 times at
      a median of 105 characters, almost all of it prose in a comment or a message string —
      which a formatter cannot split either, and ruff's own guidance is to leave line length
      to the formatter. RUF001/2/3 fired 154 times and 137 were the dotless ı in
      the Turkish catalogue: not a suspicious lookalike of i, but a different letter of the
      language the interface speaks. The rest were ρ for resistivity and × in "5 × 7 cm".
    • The other 77 were fixed rather than configured away, including a zip that now
      says strict=False where it means it, two nested ifs that read better as one, and
      an int(round(...)) that was rounding an int.
    • Four were fixed by hand because the automatic fix made the code worse. RUF005
      rewrites [a] + b as [*a, *b], and on a multi-line constructor call it does that by
      inlining the whole thing onto one 200-character line — which only looked acceptable
      because E501 had just been switched off. The same edit made by hand is an
      improvement; made by the tool it was vandalism with a green tick.
    • UP040 would have broken the application silently, and the suite caught it.
      Rewriting X: TypeAlias = Literal[...] as type X = Literal[...] is correct for 32
      of the 33 aliases and wrong for the four that are read at RUN time: get_args returns
      an empty tuple for a PEP 695 alias, so BoardMaterial, BodyArchetype and
      ConductorKind stopped listing anything and three completeness tests started
      asserting that an empty set equals an empty set. RoutingStyle was worse — MCP
      validates an agent's requested style against get_args(RoutingStyle), so every style
      would have been refused, by a check that raises nothing. All four keep the old
      spelling with the reason written above them.
    • The linter is pinned to a minor version. An unbounded dependency took this project
      apart twice this morning; a linter is the same hazard in a smaller way, since rules
      arrive with releases and a gate that fails on a tree nobody touched is a gate people
      switch off.
    • ruff format still is not adopted, and making the linter a gate nearly adopted it
      by accident.
      The same job also ran ruff format --check, which had been failing
      quietly under continue-on-error for as long as it existed; removing that flag turned
      "41 files would be reformatted" into a failed build, deciding the exact question the
      comment beside it says is not being decided. It reports now and does not block. The
      number is worth watching; it is not worth watching from behind a red tick that means
      something else.
  • CI runs all three platforms on every push, which is what ci.yml's own condition
    said to do on going public: standard runners are free on public repositories, so the
    metered-minutes trade-off it encoded stopped applying. The first full matrix that ran
    under the old rule is the argument for not restricting it again — it found a VTK abort
    on Windows and two footprint goldens off by a ULP on macOS arm64, neither visible on
    Linux, both an ordinary edit away from coming back. "Seen before a release" turns out
    to be a weak property when a release is where they were seen.

Fixed

  • Exporting a build guide on a machine with no OpenGL killed the application, and the
    code that was supposed to prevent it could not run. Both export paths wrap the render in
    except Exception and fall back to a picture-less guide — the promise that a guide
    without illustrations is still a complete guide — but VTK does not raise when there is
    no context behind an offscreen window. It ends the process. On a virtual machine, a
    remote desktop session or an old driver, File ▸ Export Build Guide took the window
    down with every unsaved edit in it, and the handler for exactly that case never
    executed. 0.4.0 shipped with this; the release notes named it as unfixed.

    • A crash cannot be caught where it happens, so it is spent where it costs nothing.
      view3d.offscreen_gl_available() opens a 16 × 16 offscreen window in a child
      process
      and reports by exit status. One spawn per run, cached — the answer cannot
      change while the application is open — and about 0.9 s.
    • A frozen build has no separate Python to spawn, so it probes by running itself.
      sys.executable is the application, and --probe-offscreen-gl is answered before Qt
      is touched. That is not a documented option and is not meant to be used by hand; it
      exists because the installed build is exactly where this has to keep working, and the
      Windows release job now runs it that way on a runner with no GPU.
    • Three consumers, one answer: the MCP generate_guide, the GUI's export, and
      --headless, which now prints "3D SKIPPED: no offscreen GL context on this machine"
      and writes everything else rather than stopping at the first stage that needs a
      graphics driver. The suite asks the same function, so a test skips exactly where the
      application would have declined to render — rather than the two disagreeing about what
      the machine can do.
    • Both CI workflows are held to the exit status again. The Windows steps had been
      allowed to fail while this was outstanding, which meant the one runner most likely to
      show a real Windows fault was the one whose result was being ignored.
  • A machine-wide installer was putting its shortcuts in one person's profile. Found
    by installing v0.4.0 on a real desktop, which is a thing that had never been done: CI
    unpacks the bundle and asks the binary its version, and the installer around it had only
    ever been built. It installs into $PROGRAMFILES64 under RequestExecutionLevel admin, and $SMPROGRAMS / $DESKTOP default to the current user — which under
    elevation is whoever answered the UAC prompt. Install for a standard user from an
    administrator account and every shortcut lands in the administrator's profile and none
    in the profile of the person who will use the program. SetShellVarContext all, in the
    install and uninstall sections both.

    • And its uninstall entry was in the 32-bit registry. makensis produces a 32-bit
      installer, so HKLM\Software is redirected through WOW64: a 64-bit application in
      Program Files was registering itself where 32-bit applications live. Add/Remove
      Programs reads both views so it still appeared, which is why nothing looked wrong —
      but every inventory tool and script that reads the 64-bit view saw nothing.
      SetRegView 64.
    • Both fixes have to look backwards. Every v0.4.0 install in the world recorded
      itself in the 32-bit view with per-user shortcuts, so the next installer searches both
      registry views for a previous version and clears both shortcut contexts. Without that
      it would find nothing to remove and unpack itself over the bundle it is replacing —
      the exact failure that block exists to prevent.
    • What did work, end to end and on the first try: silent install in 21 s, 522 MB across
      1008 files, the .perf association (double-clicking a board opened it in the installed
      build), the Turkish Start-menu entries the machine's locale selected, the full headless
      pipeline out of Program Files including 29 rendered step images, and a silent
      uninstall that left nothing behind — no files, no registry keys, no shortcuts.
  • The release ritual's own last step failed the tests that enforce it.
    docs/RELEASING.md step 4 opens the next cycle by putting the
    .devN suffix back on and leaving an empty ## [Unreleased] heading — and
    test_development_builds_have_an_open_unreleased_section then failed the whole suite,
    because it also demanded that section have entries in it. Nothing has accumulated
    towards a version opened a minute ago; that is what opening one means. The
    contradiction stood through three versions because reaching it requires finishing a
    release, and 0.4.0 is the first release this project has actually completed. The test
    now checks the half that cannot be satisfied by forgetting to write anything down —
    that a development version is not describing a version which already shipped — and the
    release side still refuses a closed section with no entries, and a released build whose
    section is not the newest.


Windows - PerfStudio_..._Setup.exe. It is unsigned, so SmartScreen will
warn on first run: More info -> Run anyway. A signed build is planned.

Linux - perfstudio-...-x86_64.AppImage. chmod +x and run it; it needs
glibc 2.35 or newer. If it will not start, the machine has no FUSE 2: install
libfuse2, or run the file with --appimage-extract-and-run, which unpacks it
instead of mounting it.

macOS - perfstudio-...-arm64.dmg, Apple silicon. Drag it to Applications,
then run this before opening it:

xattr -dr com.apple.quarantine /Applications/PerfStudio.app

It is signed ad-hoc but not notarized, so Gatekeeper blocks the first launch.
On macOS 15 and later the dialog it raises defaults to Move to Trash, which
deletes the install. Clearing the flag first avoids the dialog entirely. On an
Intel Mac, install from source.

Anywhere with Python 3.12+: pip install -e . from a clone, then
perfstudio.

This is pre-alpha software. Nobody has yet built a real board by following a
generated guide - check every measurement against the part in your hand.