Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to decode a container; the emulator survives as a development-only oracle that the codec is differentially tested against. Verified: - 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0 files rejected by both implementations (tools/difftest.py --self-test) - Default.BPJ: 22 components, 14 nets, no dangling terminal - 22 of 22 parts resolve to library symbols; one series inferred from a sibling, two DC resistance deviations reported - ERC clean and the netlist KiCad extracts from the generated sheet is an identical partition to the one derived from the Boxsim grid - container build byte-identical to the host build - 32 tests passing, ruff clean Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses) -> 1.6 s (pure Python). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb81ca61cb
commit
4737a5bae8
28 files changed
+4454
-117
No files matched your search
@@ -1,5 +1,8 @@
|
|||||||
*.zip
|
*.zip
|
||||||
boxsim_dl/
|
boxsim_dl/
|
||||||
|
stage/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
brute
|
brute
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `boxsim` command line tool with `decode`, `extract`, `netlist` and `build` subcommands.
|
||||||
|
- Pure Python implementation of the A2F codec, so decoding no longer needs `Boxsim.exe` or a CPU emulator. Verified byte-identical to Boxsim's own decoder across all 134 containers in the distribution.
|
||||||
|
- Project model covering `#General`, `#Weiche<n>`, `#Chassis<n>`, `#Einbau<n>` and `#Schallwand<n>`, with the crossover grid decoded to an electrical netlist.
|
||||||
|
- Symbol resolver driven by Visaton's `Visatonteile.txt` catalogue, matching values numerically against the KiCad library. Infers an omitted series from a sibling part, reports DC resistance deviations, and refuses to substitute an unstocked value.
|
||||||
|
- KiCad 10 schematic generation that preserves the Boxsim grid layout, including wires, junctions, flattened derived symbols and deterministic UUIDs.
|
||||||
|
- Verification gate comparing the netlist KiCad extracts from the generated sheet against the netlist derived from the Boxsim grid, plus ERC.
|
||||||
|
- Docker image bundling the tool, the Visaton library and the parts catalogue, with a build-time check that the bundled catalogue is complete.
|
||||||
|
- Development-only oracle that runs Boxsim's own `ExpandA2F` under Unicorn, and `tools/difftest.py` to differentially test the codec against it.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Emulator hooks are bound to their trap addresses instead of every instruction, cutting a decode from 69 s to 14.5 s with identical output. The pure Python codec then brought it to about 1.6 s.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# boxsim: decode Boxsim projects and generate KiCad projects from them.
|
||||||
|
#
|
||||||
|
# The image deliberately contains no KiCad and no Boxsim.exe:
|
||||||
|
# - KiCad 10 is a hard dependency of the *verification* gate only, and stays
|
||||||
|
# on the host. decode/extract/netlist/build all work without it.
|
||||||
|
# - The A2F codec is pure Python, so Boxsim's proprietary binary is not needed.
|
||||||
|
#
|
||||||
|
# Build via tools/build-image.sh, which stages the Visaton library and the
|
||||||
|
# Boxsim parts catalogue into the build context.
|
||||||
|
|
||||||
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="boxsim" \
|
||||||
|
org.opencontainers.image.description="Boxsim project decoder and KiCad project generator" \
|
||||||
|
org.opencontainers.image.source="https://src.bundespruefstelle.ch/boxdev/re-boxsim" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
WORKDIR /opt/boxsim
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY boxsim ./boxsim
|
||||||
|
|
||||||
|
# Staged by tools/build-image.sh: the upgraded Visaton symbol library and the
|
||||||
|
# Visaton parts catalogue shipped with Boxsim.
|
||||||
|
COPY stage/Visaton.kicad_sym ./boxsim/data/Visaton.kicad_sym
|
||||||
|
COPY stage/Visatonteile.txt ./boxsim/data/Visatonteile.txt
|
||||||
|
COPY stage/footprints ./boxsim/data/footprints
|
||||||
|
|
||||||
|
COPY tools/selfcheck.py ./tools/selfcheck.py
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
ENV BOXSIM_CATALOG=/opt/boxsim/boxsim/data/Visatonteile.txt \
|
||||||
|
BOXSIM_KICAD_LIB=/opt/boxsim/boxsim/data/Visaton.kicad_sym
|
||||||
|
|
||||||
|
# Refuse to produce an image whose bundled library does not cover the catalogue.
|
||||||
|
RUN python tools/selfcheck.py
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
ENTRYPOINT ["boxsim"]
|
||||||
|
CMD ["--help"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Plan
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
v0.1.0 is delivered. The `boxsim` package decodes Boxsim containers in pure Python and generates KiCad 10 projects from their crossovers; see `README.md` for the format and the tool, and `CHANGELOG.md` for what changed.
|
||||||
|
|
||||||
|
Environment as of 2026-09-29: KiCad 10.0.6 installed natively, Python 3.13.15, Visaton library at `/home/magdev/workspaces/other/kicad-visaton-lib` (remote `pidock.local:3022`), upgraded to the KiCad 10 format as part of this work.
|
||||||
|
|
||||||
|
Reference project for all work: `boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Projekte/Default.BPJ`.
|
||||||
|
|
||||||
|
## v0.1.0 - `boxsim` CLI and KiCad project generation
|
||||||
|
|
||||||
|
Delivered 2026-09-29. All seven tickets landed; what follows records the outcome, including where it differed from the design.
|
||||||
|
|
||||||
|
### What shipped
|
||||||
|
|
||||||
|
- `boxsim` with `decode`, `extract`, `netlist` and `build`, installable and packaged as a `python:3.13-slim` image bundling the Visaton library and catalogue.
|
||||||
|
- A pure Python A2F codec. `Boxsim.exe` and Unicorn are gone from the shipped tool and the image.
|
||||||
|
- Grid to netlist extraction, catalogue-driven symbol resolution, KiCad 10 schematic generation preserving the Boxsim layout, and a netlist-equality verification gate.
|
||||||
|
|
||||||
|
### Measured results
|
||||||
|
|
||||||
|
| Gate | Result |
|
||||||
|
| ---- | ------ |
|
||||||
|
| Codec vs Boxsim's own decoder | 134 containers byte-identical, 270 pre-2.0 files rejected by both |
|
||||||
|
| Decode time for `Default.BPJ` | 69 s to 14.5 s (hook binding) to 1.6 s (pure Python) |
|
||||||
|
| `Default.BPJ` netlist | 22 components, 14 nets, no dangling terminal |
|
||||||
|
| Symbol resolution | 22 of 22, one series inferred, two DC resistance deviations reported |
|
||||||
|
| ERC | clean, 0 errors |
|
||||||
|
| KiCad netlist vs Boxsim netlist | identical partition, 14 nets |
|
||||||
|
| Container vs host build | `.kicad_sch` and `.kicad_pro` byte-identical |
|
||||||
|
| Tests / lint | 32 passing, ruff clean |
|
||||||
|
|
||||||
|
### Where it differed from the design
|
||||||
|
|
||||||
|
- **The library needed changing after all.** The plan said no library change was required. That was wrong: the KiCad 6 era syntax (`(id N)` properties, bare `hide` tokens) cannot be embedded into a current schematic's `lib_symbols` cache. `kicad-visaton-lib` was upgraded with `kicad-cli sym upgrade` and `fp upgrade`, and the tool now refuses a legacy library with an error naming the fix. A pre-existing broken footprint reference on the `MKT 22μF` symbol (`Visaton:MKT-Kondensator`, which never existed) was repointed at `MKT 22mF`; all 48 footprint references now resolve.
|
||||||
|
- **Derived symbols had to be flattened.** 19 of the 98 symbols use `(extends ...)`, and KiCad never writes `extends` into a schematic cache. Pin geometry follows the chain to the base symbol, and the base's graphics are inlined under the derived name.
|
||||||
|
- **The decoder needed a padding allowance.** Boxsim reads past the end of its staging buffer for the final symbol's 16 bit lookup window. Five files failed until the reader tolerated a bounded number of zero padding bytes; the bound keeps genuine truncation loud.
|
||||||
|
- **Rotation could not be assumed.** A symbol's origin is not its pin midpoint (`Amplifier` has both pins at px=3.81), and a symbol's pin axis does not follow its slot. Rotation is chosen per symbol from its own pin axis, the origin is derived so the transformed pin midpoint lands on the slot midpoint, and the four transform matrices were measured against KiCad's demo schematics rather than guessed.
|
||||||
|
- **The image self-check was decorative at first.** `cross_check` only proves the catalogue is a subset of the library, so a truncated catalogue passed it and would have shipped an image rejecting valid parts. `catalog.check_shape` now asserts the catalogue's full shape, and the failure was reproduced deliberately before the fix was accepted.
|
||||||
|
- **Grid pitch is 25.4 mm, not 20 mm.** The widest symbol spans 22.86 mm between pins, so 20 mm would overlap neighbouring cells. `Default.BPJ` therefore lands on A3 rather than A4.
|
||||||
|
|
||||||
|
### Ticket outcomes
|
||||||
|
|
||||||
|
- **BOX-0** emulator bound to trap addresses, moved to `boxsim/_oracle.py`, reachable as `boxsim decode --oracle`.
|
||||||
|
- **BOX-1** `boxsim/a2f.py`, differentially tested by `tools/difftest.py`, self-test proves the comparison detects a corrupt digest.
|
||||||
|
- **BOX-2** `boxsim/model.py`, slot partition covered by tests, netlist cross-checked against crossover physics (600 Hz and 5.7 kHz corners).
|
||||||
|
- **BOX-3** `boxsim/catalog.py` and `boxsim/symbols.py`.
|
||||||
|
- **BOX-4** `boxsim/schematic.py`.
|
||||||
|
- **BOX-5** `boxsim/cli.py` and `boxsim/project.py`.
|
||||||
|
- **BOX-6** `boxsim/verify.py`, with `test_gate_has_teeth` mis-wiring a component to prove the gate fails when it should.
|
||||||
|
- **BOX-7** `Dockerfile`, `tools/build-image.sh`, `tools/selfcheck.py`.
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
- Multi-channel projects (`nAmps` up to 6), probably one hierarchical sheet per channel.
|
||||||
|
- Active filter sections (`#AktFilter<n>`, `#Aktfilter<n>`), which Boxsim models as DSP biquads with no passive equivalent.
|
||||||
|
- Per-driver symbols instead of the generic `Speaker`, if the library grows them. The 400 `.BCH` files provide the model names and TSP data.
|
||||||
|
- Round trip: patch component values in a `.bpj` from an edited schematic. Needs the A2F compressor, of which only the decompressor is currently exercised.
|
||||||
@@ -1,63 +1,160 @@
|
|||||||
# Boxsim .bpj / .bch file format (reverse engineered)
|
# boxsim
|
||||||
|
|
||||||
Boxsim (Visaton, Delphi, codec and container "MyIniFile" by UG 2004) stores projects (`.bpj`) and driver files (`.bch`) in a three-layer container. Reverse engineered 2026-09-28 from Boxsim 2.1 DE (`Boxsim.exe`, no prior public documentation existed).
|
Read Boxsim loudspeaker projects and turn their crossovers into KiCad projects.
|
||||||
|
|
||||||
## Container layout
|
[Boxsim](https://www.visaton.de/) is Visaton's free loudspeaker simulation program. Its project files (`.bpj`) and driver files (`.bch`) use an undocumented container, reverse engineered here. `boxsim` decodes them and generates a KiCad 10 project whose schematic mirrors the crossover you drew in Boxsim, with every part bound to a symbol and footprint from [kicad-visaton-lib](https://src.bundespruefstelle.ch/boxdev/kicad-visaton-lib).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is required at runtime beyond Python 3.11+. KiCad 10 is a hard dependency of the verification gate (`boxsim build --verify`) only, and lives on the host.
|
||||||
|
|
||||||
|
Point the tool at the Visaton library and the Boxsim parts catalogue:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BOXSIM_KICAD_LIB=/path/to/kicad-visaton-lib
|
||||||
|
export BOXSIM_CATALOG="/path/to/BoxSim 2.1 DE/Weichenbauteile/Visatonteile.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
The symbol library must be in the current KiCad format. Convert an older one with `kicad-cli sym upgrade --force symbols/Visaton.kicad_sym`.
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
boxsim decode project.bpj -o project.ini # raw INI payload
|
||||||
|
boxsim extract project.bpj -o project.json # structured model, symbols resolved
|
||||||
|
boxsim netlist project.bpj # crossover netlist
|
||||||
|
boxsim build project.bpj out/ --verify # a KiCad 10 project
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` writes `<name>.kicad_sch`, `<name>.kicad_pro` and project-local `sym-lib-table` / `fp-lib-table`. No board is generated: use KiCad's *Update PCB from Schematic*, which works because every Visaton symbol carries a `Footprint` property.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/build-image.sh
|
||||||
|
docker run --rm -v "$PWD:/work" boxsim:0.1.0 build project.bpj out/
|
||||||
|
```
|
||||||
|
|
||||||
|
The image bundles the tool, the Visaton library and the catalogue. It contains no KiCad and no `Boxsim.exe`, so `decode`, `extract`, `netlist` and `build` work inside it while `--verify` does not. Run the gate on the host.
|
||||||
|
|
||||||
|
## The file format
|
||||||
|
|
||||||
| Layer | Content |
|
| Layer | Content |
|
||||||
| ----- | ------- |
|
| ----- | ------- |
|
||||||
| 1 | 8-byte plaintext magic `MyIni2CK` |
|
| 1 | 8-byte plaintext magic `MyIni2CK` |
|
||||||
| 2 | Everything after the magic is XOR-encrypted with a position-based PRNG keystream, seeds (123, 456), identical for every file |
|
| 2 | XOR against a keystream from a table-shuffled three-LCG generator, seeded (123, 456) for every file ever written |
|
||||||
| 3 | The decrypted body is a custom LZ + canonical-Huffman stream ("A2F" codec, `CompressA2F`/`ExpandA2F` at VA `0xa1013c`/`0xa11880`) |
|
| 3 | A custom LZ plus canonical-Huffman codec ("A2F") |
|
||||||
| 4 | The decompressed payload is plain INI-style text: `key=<TAB>value` lines, CRLF, `#Section` headers, German decimal commas, arrays as `;`-separated values |
|
| 4 | INI-style text: `#Section` headers, `key<TAB>value`, CRLF, German decimal commas, `;`-separated arrays |
|
||||||
|
|
||||||
## Layer 2: the Krypt PRNG
|
Files written by Boxsim 1.2 use an older container without the magic and are rejected.
|
||||||
|
|
||||||
Table-shuffled combination of three LCGs (found at VA `0xa12be4` init, `0xa12c9c` next byte). All arithmetic mod 2^32:
|
### The Krypt keystream
|
||||||
|
|
||||||
|
Three linear congruential generators feeding a 256-entry shuffle table (Boxsim 2.1: init at VA `0xa12be4`, next byte at `0xa12c9c`). All arithmetic mod 2^32:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
init(s1=123, s2=456):
|
init(s1=123, s2=456):
|
||||||
s3 = (s1 mod 0xFFFE) * (s2 mod 0x7F23)
|
s3 = (s1 mod 0xFFFE) * (s2 mod 0x7F23)
|
||||||
repeat 256 times: step(); table[i] = s1 + s2
|
256 times: step(); table[i] = s1 + s2
|
||||||
|
|
||||||
step():
|
step(): s1 = s1*0x10B95 + 1
|
||||||
s1 = s1 * 0x10B95 + 1
|
|
||||||
s2 = s2*0x10BA9 + 1 (reset to 0 if >= 0xFFFFFFFF)
|
s2 = s2*0x10BA9 + 1 (reset to 0 if >= 0xFFFFFFFF)
|
||||||
s3 = s3*0x1155D + 1
|
s3 = s3*0x1155D + 1
|
||||||
|
|
||||||
next_byte():
|
next(): step(); i = s3 and 0xFF
|
||||||
step()
|
out = table[i]; table[i] = s1 + s2
|
||||||
idx = s3 and 0xFF
|
|
||||||
out = table[idx]; table[idx] = s1 + s2
|
|
||||||
return out mod 256
|
return out mod 256
|
||||||
```
|
```
|
||||||
|
|
||||||
`plaintext[i] = filebyte[8+i] XOR next_byte()`.
|
### The A2F codec
|
||||||
|
|
||||||
## Layer 3: the A2F codec
|
A bit stream read MSB first through a 31-bit accumulator, with symbols from a canonical Huffman code over 356 symbols decoded via a 65536-entry direct lookup table. All code lengths start at 9.
|
||||||
|
|
||||||
Sliding-window LZ (1 MB window, flush keeps the last 0x3FFF8 bytes) with a 356-symbol canonical Huffman alphabet: 0..255 literals, 0x100 end-of-stream, 0x101 rebuild code-length tables (adaptive), 0x102 special, rest match lengths/distances. Decoding uses a 65536-entry direct lookup table; initial state gives every symbol a 9-bit code. Not re-implemented: `bpj2ini.py` runs the original `ExpandA2F` machine code under Unicorn instead, which is exact by construction.
|
| Symbol | Meaning |
|
||||||
|
| ------ | ------- |
|
||||||
|
| `0x000`-`0x0FF` | literal byte |
|
||||||
|
| `0x100` | end of stream |
|
||||||
|
| `0x101` | new code lengths follow, rebuild the tables |
|
||||||
|
| `0x102` | define N entries of the 64-entry digram dictionary |
|
||||||
|
| `0x103`-`0x10A` | match, length 3, distance in k+3 bits |
|
||||||
|
| `0x10B`-`0x114` | match, length 4, distance in k+3 bits |
|
||||||
|
| `0x115`-`0x123` | match, unary-coded length class, distance in k+3 bits |
|
||||||
|
| `0x124`-`0x163` | emit the two bytes of digram dictionary entry k |
|
||||||
|
|
||||||
## Crossover encoding (`#Weiche<n>` section)
|
Distances take `k+3` raw bits biased by `2**(k+3) - 7`, so the classes tile 1.. with no gaps. Code lengths are coded as two zero bits meaning "same as previous", otherwise a nibble plus one, where the value 5 escapes to a second nibble.
|
||||||
|
|
||||||
The crossover editor grid serializes as `_Part<N>_*` keys. Only non-empty slots are written:
|
Boxsim decodes from a staging buffer and reads past the end of the input for the final symbol's lookup window, so the decoder tolerates a bounded number of zero padding bytes. Beyond that a truncated file fails loudly.
|
||||||
|
|
||||||
|
### The crossover grid
|
||||||
|
|
||||||
|
The crossover editor is an 11 x 18 node grid. A slot number encodes position and orientation:
|
||||||
|
|
||||||
|
| Range | Meaning |
|
||||||
|
| ----- | ------- |
|
||||||
|
| 1-180 | horizontal, 10 per row, joins node(r,c) to node(r,c+1) |
|
||||||
|
| 181-367 | vertical, 11 per row, joins node(r,c) to node(r+1,c) |
|
||||||
|
| 368-375 | free link, endpoints given by `_nod1` and `_nod2` |
|
||||||
|
|
||||||
|
Nodes are numbered row-major and zero based: `node = row * 11 + col`.
|
||||||
|
|
||||||
|
Keys in a `#Weiche<n>` section:
|
||||||
|
|
||||||
| Key | Meaning |
|
| Key | Meaning |
|
||||||
| --- | ------- |
|
| --- | ------- |
|
||||||
| `_Part<N>_typ` | 0 empty, 1 wire, 2 resistor, 3 capacitor, 4 inductor, 5 driver, 6 amplifier input |
|
| `_Part<N>_typ` | 0 empty, 1 wire, 2 resistor, 3 capacitor, 4 inductor, 5 driver, 6 amplifier input |
|
||||||
| `_Part<N>_value` | Component value in SI base units (Ohm, Farad, Henry) |
|
| `_Part<N>_value` | SI units: Ohm, Farad, Henry |
|
||||||
| `_Part<N>_Rvalue` | Series resistance of an inductor in Ohm |
|
| `_Part<N>_Rvalue` | inductor DC resistance in Ohm |
|
||||||
| `_Part<N>_bem` | Part label, e.g. `MKT-A`, `SP 0,6mm`, `Keramik 10W` |
|
| `_Part<N>_bem` | Visaton series name, matching `Visatonteile.txt` exactly |
|
||||||
| `_Part<N>_chassisnr` | Driver index (0-based) for typ 5 |
|
| `_Part<N>_chassisnr` | zero-based driver index |
|
||||||
| `_Part<N>_verpolt` | Polarity flag (-1 / 1) for typ 5 and 6 |
|
| `_Part<N>_verpolt` | polarity, -1 meaning inverted |
|
||||||
| `_Part368..375_nod1/nod2` | Extra node-to-node connections |
|
|
||||||
|
|
||||||
Other sections: `#General` (nAmps, nChassis), `#AktFilter<n>` (active filters), `#Chassis<n>` (TSP set plus measured frequency/impedance/phase arrays), `#Einbau<n>` (enclosure: Typ 1 closed with `Vb` in m³, etc.), `#Schallwand<n>` (baffle geometry in m), `#GemGeh` (shared enclosure).
|
Other sections: `#General`, `#AktFilter<n>` and `#Aktfilter<n>` (active filters, note the differing case), `#Chassis<n>` (name plus Thiele-Small parameters and measured response arrays), `#Einbau<n>` (enclosure, `Vb` in m³), `#Schallwand<n>` (baffle geometry in m), `#GemGeh` (shared enclosure).
|
||||||
|
|
||||||
## Tool
|
## How the parts are resolved
|
||||||
|
|
||||||
|
`Weichenbauteile/Visatonteile.txt` is the authoritative parts list: 95 parts across 11 series, with inductor DC resistance in the fourth column. Its series strings are exactly the `_bem` values Boxsim writes, so resolution is a lookup rather than a guess.
|
||||||
|
|
||||||
|
Values are matched numerically against the library rather than formatted into symbol names, because the library's own strings are irregular (`MKP 1.0μF` beside `MKP 10μF`, `SP-Spule 1.0mH (0.6)` beside `LR-Spule 10mH`).
|
||||||
|
|
||||||
|
- An empty `_bem` is inferred from a sibling part of the same type and value in the same crossover, else from the widest series for that type. Every inference is logged.
|
||||||
|
- A `_Rvalue` that differs from the catalogue is reported, never corrected: it is a legitimate edit in Boxsim.
|
||||||
|
- A value Visaton does not stock is a hard error naming the nearest stocked values. Nothing is substituted silently.
|
||||||
|
|
||||||
|
The library contains one symbol the catalogue does not list, `MKT 22μF`. It is a real Visaton part (radial film capacitor, 250 V) that Boxsim does not offer in its picker, so no project can reference it.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
ERC only proves a schematic is well formed; it would happily pass one wired to the wrong cells. So `boxsim build --verify` compares the netlist KiCad extracts from the generated sheet against the netlist derived independently from the Boxsim grid, and both derivations must agree.
|
||||||
|
|
||||||
|
The codec is held to the same standard. `tools/difftest.py` decodes every container in the Boxsim distribution twice, once with the pure Python codec and once by emulating Boxsim's own `ExpandA2F` under Unicorn, and requires byte-identical output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python bpj2ini.py <input.bpj|.bch> [output.ini]
|
.venv/bin/python tools/difftest.py --self-test
|
||||||
|
# 134 byte-identical, 270 rejected by both as pre-2.0 containers
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs `unicorn` and `pefile` (see `.venv/`) and `Boxsim.exe` (path via `BOXSIM_EXE` env, default: the bundled download under `boxsim_dl/`). Works for `.bpj` and `.bch` alike.
|
Both gates are checked against a deliberately broken input so they are known to fail when they should: `--self-test` corrupts a digest, and `test_gate_has_teeth` mis-wires a component.
|
||||||
|
|
||||||
|
The oracle is development-only. It needs `unicorn`, `pefile` and a copy of `Boxsim.exe`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e '.[oracle,dev]'
|
||||||
|
export BOXSIM_EXE="/path/to/Boxsim.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest # fast, no Boxsim.exe needed
|
||||||
|
ruff check boxsim tools tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests needing KiCad or the Boxsim distribution skip themselves when those are absent.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT.
|
||||||
Whitespace-only changes.
@@ -1,79 +1,56 @@
|
|||||||
#!/usr/bin/env python3
|
"""Development-only reference decoder: runs Boxsim's own machine code.
|
||||||
"""Decode Boxsim .bpj / .bch (MyIni2CK) files to plain INI text.
|
|
||||||
|
|
||||||
Pipeline (reverse engineered from Boxsim 2.1, TMyIniFile by UG):
|
This module emulates `ExpandA2F` (VA 0xa11880) out of an unmodified Boxsim.exe
|
||||||
1. 8-byte magic "MyIni2CK" (plaintext)
|
under Unicorn, with faked Delphi TStream objects. It is exact by construction
|
||||||
2. body XOR-encrypted with a table-shuffled 3-LCG PRNG, seeds (123, 456)
|
and exists solely as the oracle that the pure Python decoder in `boxsim.a2f`
|
||||||
3. result is a custom canonical-Huffman + LZ stream ("A2F" codec)
|
is differentially tested against.
|
||||||
4. decompressed: tab-separated INI text, CRLF line ends, German decimals
|
|
||||||
|
|
||||||
Step 3 is executed by emulating Boxsim's own ExpandA2F function (VA 0xa11880)
|
It is NOT part of the shipped tool: it needs `unicorn`, `pefile` and an 8 MB
|
||||||
with Unicorn, so Boxsim.exe must be available (BOXSIM_EXE env or default path).
|
proprietary Windows binary. Point BOXSIM_EXE at a Boxsim.exe to use it.
|
||||||
|
|
||||||
Usage: bpj2ini.py input.bpj [output.ini]
|
python -m boxsim.cli decode --oracle file.bpj
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import sys
|
|
||||||
|
|
||||||
import pefile
|
|
||||||
from unicorn import *
|
|
||||||
from unicorn.x86_const import *
|
|
||||||
|
|
||||||
EXE = os.environ.get(
|
|
||||||
"BOXSIM_EXE",
|
|
||||||
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
||||||
"boxsim_dl", "BoxSim 2.1 DE (Stand 12.2025)", "Boxsim.exe"))
|
|
||||||
|
|
||||||
MAGIC = b"MyIni2CK"
|
MAGIC = b"MyIni2CK"
|
||||||
ENTRY = 0xA11880 # ExpandA2F(obj, instream, outstream)
|
|
||||||
ERR_FUNC = 0x97E584 # Boxsim error logger
|
ENTRY = 0xA11880 # ExpandA2F(self, instream, outstream)
|
||||||
|
ERR_FUNC = 0x97E584 # Boxsim's error reporter
|
||||||
RAISE_FUNC = 0x80ACC4 # Delphi RaiseExcept
|
RAISE_FUNC = 0x80ACC4 # Delphi RaiseExcept
|
||||||
|
|
||||||
OBJ_BASE = 0x20000000 # decoder object (zero-init, ~11 MB of tables)
|
OBJ_BASE = 0x20000000 # decoder object: ~11 MB of zero-init tables
|
||||||
OBJ_SIZE = 0x01000000
|
OBJ_SIZE = 0x01000000
|
||||||
FAKE_BASE = 0x40000000 # fake TStream objects, vtables, trap stubs
|
FAKE_BASE = 0x40000000 # fake stream objects, vtables, trap stubs
|
||||||
STACK_BASE = 0x60000000
|
STACK_BASE = 0x60000000
|
||||||
STACK_SIZE = 0x100000
|
STACK_SIZE = 0x100000
|
||||||
GDT_BASE = 0x50000000
|
GDT_BASE = 0x50000000
|
||||||
FS_SEG = 0x50002000 # Delphi SEH chain scratch
|
FS_SEG = 0x50002000 # Delphi SEH chain scratch space
|
||||||
SENTINEL = 0x7F00DEAD
|
SENTINEL = 0x7F00DEAD
|
||||||
|
|
||||||
IN_OBJ, OUT_OBJ = FAKE_BASE, FAKE_BASE + 0x100
|
IN_OBJ, OUT_OBJ = FAKE_BASE, FAKE_BASE + 0x100
|
||||||
IN_VT, OUT_VT = FAKE_BASE + 0x1000, FAKE_BASE + 0x2000
|
IN_VT, OUT_VT = FAKE_BASE + 0x1000, FAKE_BASE + 0x2000
|
||||||
STUB = FAKE_BASE + 0x3000
|
STUB = FAKE_BASE + 0x3000
|
||||||
S_IN_SIZE, S_IN_READ, S_IN_SEEK, S_OUT_WRITE, S_OUT_SIZE, S_OUT_SEEK = range(6)
|
S_IN_SIZE, S_IN_READ, S_IN_SEEK, S_OUT_WRITE, S_OUT_SIZE, S_OUT_SEEK = range(6)
|
||||||
|
STUB_SPAN = 256
|
||||||
M32 = 1 << 32
|
|
||||||
|
|
||||||
|
|
||||||
class KryptPRNG:
|
def default_exe() -> str:
|
||||||
"""Boxsim file scrambler PRNG (Init at VA 0xa12be4, next at 0xa12c9c)."""
|
return os.environ.get(
|
||||||
|
"BOXSIM_EXE",
|
||||||
def __init__(self, s1=123, s2=456, threshold=0xFFFFFFFF):
|
os.path.join(
|
||||||
self.s1, self.s2, self.thr = s1, s2, threshold
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
self.s3 = (s1 % 0xFFFE) * (s2 % 0x7F23)
|
"boxsim_dl",
|
||||||
self.table = [0] * 256
|
"BoxSim 2.1 DE (Stand 12.2025)",
|
||||||
for i in range(256):
|
"Boxsim.exe",
|
||||||
self._step()
|
),
|
||||||
self.table[i] = (self.s1 + self.s2) % M32
|
)
|
||||||
|
|
||||||
def _step(self):
|
|
||||||
self.s1 = (self.s1 * 0x10B95 + 1) % M32
|
|
||||||
self.s2 = (self.s2 * 0x10BA9 + 1) % M32
|
|
||||||
if self.s2 >= self.thr:
|
|
||||||
self.s2 = 0
|
|
||||||
self.s3 = (self.s3 * 0x1155D + 1) % M32
|
|
||||||
|
|
||||||
def next_byte(self):
|
|
||||||
self._step()
|
|
||||||
idx = self.s3 & 0xFF
|
|
||||||
out = self.table[idx]
|
|
||||||
self.table[idx] = (self.s1 + self.s2) % M32
|
|
||||||
return out & 0xFF
|
|
||||||
|
|
||||||
|
|
||||||
def _gdt_entry(base, limit, access, gran):
|
def _gdt_entry(base: int, limit: int, access: int, gran: int) -> bytes:
|
||||||
e = limit & 0xFFFF
|
e = limit & 0xFFFF
|
||||||
e |= (base & 0xFFFFFF) << 16
|
e |= (base & 0xFFFFFF) << 16
|
||||||
e |= (access & 0xFF) << 40
|
e |= (access & 0xFF) << 40
|
||||||
@@ -83,32 +60,62 @@ def _gdt_entry(base, limit, access, gran):
|
|||||||
return struct.pack("<Q", e)
|
return struct.pack("<Q", e)
|
||||||
|
|
||||||
|
|
||||||
def expand_a2f(data):
|
def expand_a2f(data: bytes, exe: str | None = None) -> bytes:
|
||||||
"""Decompress a decrypted MyIni2CK stream by emulating Boxsim's decoder."""
|
"""Decompress a descrambled MyIni2CK stream using Boxsim's own decoder."""
|
||||||
pe = pefile.PE(EXE)
|
import pefile
|
||||||
|
from unicorn import UC_ARCH_X86, UC_HOOK_CODE, UC_MODE_32, Uc
|
||||||
|
from unicorn.x86_const import (
|
||||||
|
UC_X86_REG_CS,
|
||||||
|
UC_X86_REG_DS,
|
||||||
|
UC_X86_REG_EAX,
|
||||||
|
UC_X86_REG_ECX,
|
||||||
|
UC_X86_REG_EDX,
|
||||||
|
UC_X86_REG_ES,
|
||||||
|
UC_X86_REG_ESP,
|
||||||
|
UC_X86_REG_FS,
|
||||||
|
UC_X86_REG_GDTR,
|
||||||
|
UC_X86_REG_GS,
|
||||||
|
UC_X86_REG_SS,
|
||||||
|
)
|
||||||
|
|
||||||
|
exe = exe or default_exe()
|
||||||
|
if not os.path.exists(exe):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"oracle needs Boxsim.exe; set BOXSIM_EXE (looked at {exe!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
pe = pefile.PE(exe)
|
||||||
base = pe.OPTIONAL_HEADER.ImageBase
|
base = pe.OPTIONAL_HEADER.ImageBase
|
||||||
img = pe.get_memory_mapped_image(ImageBase=base)
|
img = pe.get_memory_mapped_image(ImageBase=base)
|
||||||
|
|
||||||
uc = Uc(UC_ARCH_X86, UC_MODE_32)
|
uc = Uc(UC_ARCH_X86, UC_MODE_32)
|
||||||
uc.mem_map(base, (len(img) + 0xFFF) & ~0xFFF)
|
uc.mem_map(base, (len(img) + 0xFFF) & ~0xFFF)
|
||||||
uc.mem_write(base, img)
|
uc.mem_write(base, img)
|
||||||
for addr, size in ((OBJ_BASE, OBJ_SIZE), (FAKE_BASE, 0x10000),
|
for addr, size in (
|
||||||
(STACK_BASE, STACK_SIZE), (GDT_BASE, 0x4000)):
|
(OBJ_BASE, OBJ_SIZE),
|
||||||
|
(FAKE_BASE, 0x10000),
|
||||||
|
(STACK_BASE, STACK_SIZE),
|
||||||
|
(GDT_BASE, 0x4000),
|
||||||
|
):
|
||||||
uc.mem_map(addr, size)
|
uc.mem_map(addr, size)
|
||||||
|
|
||||||
uc.mem_write(IN_OBJ, struct.pack("<I", IN_VT))
|
uc.mem_write(IN_OBJ, struct.pack("<I", IN_VT))
|
||||||
uc.mem_write(OUT_OBJ, struct.pack("<I", OUT_VT))
|
uc.mem_write(OUT_OBJ, struct.pack("<I", OUT_VT))
|
||||||
slot_map = ((IN_VT, {0x00: S_IN_SIZE, 0x0C: S_IN_READ, 0x28: S_IN_SEEK}),
|
layout = (
|
||||||
(OUT_VT, {0x00: S_OUT_SIZE, 0x0C: S_IN_READ,
|
(IN_VT, {0x00: S_IN_SIZE, 0x0C: S_IN_READ, 0x28: S_IN_SEEK}),
|
||||||
0x10: S_OUT_WRITE, 0x28: S_OUT_SEEK}))
|
(OUT_VT, {0x00: S_OUT_SIZE, 0x0C: S_IN_READ, 0x10: S_OUT_WRITE, 0x28: S_OUT_SEEK}),
|
||||||
for vt, stubs in slot_map:
|
)
|
||||||
|
for vt, stubs in layout:
|
||||||
for off in range(0, 0x30, 4):
|
for off in range(0, 0x30, 4):
|
||||||
sid = stubs.get(off, 15)
|
sid = stubs.get(off, 15)
|
||||||
uc.mem_write(vt + off, struct.pack("<I", STUB + sid * 16))
|
uc.mem_write(vt + off, struct.pack("<I", STUB + sid * 16))
|
||||||
for sid in range(16):
|
for sid in range(16):
|
||||||
|
# Delphi Seek is `stdcall`-ish with 8 bytes of args; the rest are cdecl.
|
||||||
body = b"\xc2\x08\x00" if sid in (S_IN_SEEK, S_OUT_SEEK) else b"\xc3"
|
body = b"\xc2\x08\x00" if sid in (S_IN_SEEK, S_OUT_SEEK) else b"\xc3"
|
||||||
uc.mem_write(STUB + sid * 16, body)
|
uc.mem_write(STUB + sid * 16, body)
|
||||||
|
|
||||||
|
# A full flat GDT. Loading FS alone resets the other segment registers'
|
||||||
|
# hidden descriptors in unicorn, which faults the very first push.
|
||||||
uc.mem_write(GDT_BASE + 8, _gdt_entry(0, 0xFFFFF, 0x9B, 0xC))
|
uc.mem_write(GDT_BASE + 8, _gdt_entry(0, 0xFFFFF, 0x9B, 0xC))
|
||||||
uc.mem_write(GDT_BASE + 16, _gdt_entry(0, 0xFFFFF, 0x93, 0xC))
|
uc.mem_write(GDT_BASE + 16, _gdt_entry(0, 0xFFFFF, 0x93, 0xC))
|
||||||
uc.mem_write(GDT_BASE + 24, _gdt_entry(FS_SEG, 0xFFF, 0x93, 0x4))
|
uc.mem_write(GDT_BASE + 24, _gdt_entry(FS_SEG, 0xFFF, 0x93, 0x4))
|
||||||
@@ -120,8 +127,8 @@ def expand_a2f(data):
|
|||||||
|
|
||||||
state = {"pos": 0, "out": bytearray(), "err": None}
|
state = {"pos": 0, "out": bytearray(), "err": None}
|
||||||
|
|
||||||
def hook_code(u, addr, _sz, _ud):
|
def hook_code(u, addr, _size, _ud):
|
||||||
if STUB <= addr < STUB + 256:
|
if STUB <= addr < STUB + STUB_SPAN:
|
||||||
sid = (addr - STUB) // 16
|
sid = (addr - STUB) // 16
|
||||||
eax = u.reg_read(UC_X86_REG_EAX)
|
eax = u.reg_read(UC_X86_REG_EAX)
|
||||||
edx = u.reg_read(UC_X86_REG_EDX)
|
edx = u.reg_read(UC_X86_REG_EDX)
|
||||||
@@ -139,9 +146,13 @@ def expand_a2f(data):
|
|||||||
esp = u.reg_read(UC_X86_REG_ESP)
|
esp = u.reg_read(UC_X86_REG_ESP)
|
||||||
off = struct.unpack("<i", u.mem_read(esp + 4, 4))[0]
|
off = struct.unpack("<i", u.mem_read(esp + 4, 4))[0]
|
||||||
origin = edx & 0xFF
|
origin = edx & 0xFF
|
||||||
state["pos"] = (off if origin == 0 else
|
state["pos"] = (
|
||||||
state["pos"] + off if origin == 1 else
|
off
|
||||||
len(data) + off)
|
if origin == 0
|
||||||
|
else state["pos"] + off
|
||||||
|
if origin == 1
|
||||||
|
else len(data) + off
|
||||||
|
)
|
||||||
u.reg_write(UC_X86_REG_EAX, state["pos"] & 0xFFFFFFFF)
|
u.reg_write(UC_X86_REG_EAX, state["pos"] & 0xFFFFFFFF)
|
||||||
u.reg_write(UC_X86_REG_EDX, 0)
|
u.reg_write(UC_X86_REG_EDX, 0)
|
||||||
elif sid == S_OUT_WRITE:
|
elif sid == S_OUT_WRITE:
|
||||||
@@ -162,12 +173,16 @@ def expand_a2f(data):
|
|||||||
state["err"] = "delphi exception"
|
state["err"] = "delphi exception"
|
||||||
u.emu_stop()
|
u.emu_stop()
|
||||||
|
|
||||||
uc.hook_add(UC_HOOK_CODE, hook_code, None)
|
# Bind the hooks to the trap addresses only. A single unbounded UC_HOOK_CODE
|
||||||
|
# fires the callback on every emulated instruction and costs ~5x wall clock.
|
||||||
|
uc.hook_add(UC_HOOK_CODE, hook_code, None, STUB, STUB + STUB_SPAN - 1)
|
||||||
|
uc.hook_add(UC_HOOK_CODE, hook_code, None, ERR_FUNC, ERR_FUNC)
|
||||||
|
uc.hook_add(UC_HOOK_CODE, hook_code, None, RAISE_FUNC, RAISE_FUNC)
|
||||||
|
|
||||||
esp = STACK_BASE + STACK_SIZE - 0x1000
|
esp = STACK_BASE + STACK_SIZE - 0x1000
|
||||||
uc.mem_write(esp, struct.pack("<I", SENTINEL))
|
uc.mem_write(esp, struct.pack("<I", SENTINEL))
|
||||||
uc.reg_write(UC_X86_REG_ESP, esp)
|
uc.reg_write(UC_X86_REG_ESP, esp)
|
||||||
# codec config as set by the compressor entry (0xa1013c)
|
# Codec config, as the compressor entry point (0xa1013c) sets it.
|
||||||
uc.mem_write(OBJ_BASE + 0x04, b"\x01\x01")
|
uc.mem_write(OBJ_BASE + 0x04, b"\x01\x01")
|
||||||
uc.mem_write(OBJ_BASE + 0x08, struct.pack("<I", 0x20))
|
uc.mem_write(OBJ_BASE + 0x08, struct.pack("<I", 0x20))
|
||||||
uc.mem_write(OBJ_BASE + 0x0C, struct.pack("<I", 0x47))
|
uc.mem_write(OBJ_BASE + 0x0C, struct.pack("<I", 0x47))
|
||||||
@@ -176,31 +191,20 @@ def expand_a2f(data):
|
|||||||
uc.reg_write(UC_X86_REG_EDX, IN_OBJ)
|
uc.reg_write(UC_X86_REG_EDX, IN_OBJ)
|
||||||
uc.reg_write(UC_X86_REG_ECX, OUT_OBJ)
|
uc.reg_write(UC_X86_REG_ECX, OUT_OBJ)
|
||||||
|
|
||||||
uc.emu_start(ENTRY, SENTINEL, timeout=120 * 1000000)
|
uc.emu_start(ENTRY, SENTINEL, timeout=600 * 1000000)
|
||||||
|
|
||||||
if state["err"]:
|
if state["err"]:
|
||||||
raise RuntimeError(state["err"])
|
raise RuntimeError(state["err"])
|
||||||
if state["pos"] != len(data):
|
if state["pos"] != len(data):
|
||||||
raise RuntimeError(f"decoder stopped early: {state['pos']}/{len(data)}")
|
raise RuntimeError(f"decoder stopped early: {state['pos']}/{len(data)}")
|
||||||
|
del uc, img, pe
|
||||||
return bytes(state["out"])
|
return bytes(state["out"])
|
||||||
|
|
||||||
|
|
||||||
def decode_bpj(raw):
|
def decode_bpj(raw: bytes, exe: str | None = None) -> bytes:
|
||||||
|
"""Full container decode using the oracle for the compressed layer."""
|
||||||
|
from .krypt import unscramble
|
||||||
|
|
||||||
if raw[:8] != MAGIC:
|
if raw[:8] != MAGIC:
|
||||||
raise ValueError("not a MyIni2CK file")
|
raise ValueError("not a MyIni2CK file")
|
||||||
prng = KryptPRNG()
|
return expand_a2f(MAGIC + unscramble(raw[8:]), exe=exe)
|
||||||
body = bytes(c ^ prng.next_byte() for c in raw[8:])
|
|
||||||
return expand_a2f(MAGIC + body)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
sys.exit(__doc__)
|
|
||||||
src = sys.argv[1]
|
|
||||||
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.splitext(src)[0] + ".ini"
|
|
||||||
text = decode_bpj(open(src, "rb").read())
|
|
||||||
open(dst, "wb").write(text)
|
|
||||||
print(f"{src}: {len(text)} bytes -> {dst}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
"""Pure Python implementation of Boxsim's A2F codec (decompression).
|
||||||
|
|
||||||
|
Reverse engineered from `ExpandA2F` at VA 0xa11880 in Boxsim 2.1. No part of
|
||||||
|
this needs Boxsim.exe; `boxsim._oracle` runs the original machine code and is
|
||||||
|
used only to differentially test this module.
|
||||||
|
|
||||||
|
Stream shape
|
||||||
|
------------
|
||||||
|
A bit stream, MSB first, read through a 31 bit accumulator. Symbols come from a
|
||||||
|
canonical Huffman code over 356 symbols decoded through a 65536 entry direct
|
||||||
|
lookup table:
|
||||||
|
|
||||||
|
0x000..0x0FF literal byte
|
||||||
|
0x100 end of stream
|
||||||
|
0x101 new code lengths follow, rebuild the tables
|
||||||
|
0x102 define N entries of the 64 entry digram dictionary
|
||||||
|
0x103..0x10A match, length 3, distance in k+3 bits
|
||||||
|
0x10B..0x114 match, length 4, distance in k+3 bits
|
||||||
|
0x115..0x123 match, unary coded length class, distance in k+3 bits
|
||||||
|
0x124..0x163 emit the two bytes of digram dictionary entry k
|
||||||
|
|
||||||
|
Distances use k = symbol - class base: k+3 raw bits biased by 2**(k+3) - 7, so
|
||||||
|
the classes tile 1.. without gaps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
NSYM = 0x164 # 356 symbols
|
||||||
|
TABLE_BITS = 16
|
||||||
|
TABLE_SIZE = 1 << TABLE_BITS
|
||||||
|
DICT_SIZE = 0x40
|
||||||
|
INITIAL_CODE_LENGTH = 9
|
||||||
|
MAX_CODE_LENGTH = 16
|
||||||
|
|
||||||
|
SYM_EOS = 0x100
|
||||||
|
SYM_REBUILD = 0x101
|
||||||
|
SYM_DIGRAM_DEF = 0x102
|
||||||
|
SYM_MATCH3 = 0x103 # 8 symbols
|
||||||
|
SYM_MATCH4 = 0x10B # 10 symbols
|
||||||
|
SYM_MATCHN = 0x115 # 15 symbols
|
||||||
|
SYM_DIGRAM = 0x124 # 64 symbols
|
||||||
|
|
||||||
|
# Unary length class -> (extra bits, base). Class 0 is unreachable because the
|
||||||
|
# unary read always consumes at least one bit.
|
||||||
|
LENGTH_CLASSES = {
|
||||||
|
1: (2, 5),
|
||||||
|
2: (4, 9),
|
||||||
|
3: (6, 0x19),
|
||||||
|
4: (8, 0x59),
|
||||||
|
5: (10, 0x159),
|
||||||
|
6: (12, 0x559),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class A2FError(ValueError):
|
||||||
|
"""Raised when the stream is malformed or uses an unreachable encoding."""
|
||||||
|
|
||||||
|
|
||||||
|
#: Bytes of zero padding tolerated past the end of the stream. Boxsim decodes
|
||||||
|
#: from a 0x4000 byte staging buffer and its refill reads on past the valid
|
||||||
|
#: length, so the final symbol's 16 bit lookup window routinely extends beyond
|
||||||
|
#: the last real byte. Those bits are never significant: the end-of-stream code
|
||||||
|
#: is fully contained in real bytes and the lookup table maps every window
|
||||||
|
#: sharing that prefix to the same symbol. The allowance stays small so a
|
||||||
|
#: genuinely truncated file still fails loudly instead of decoding garbage.
|
||||||
|
PAD_ALLOWANCE = 8
|
||||||
|
|
||||||
|
|
||||||
|
class _BitReader:
|
||||||
|
"""MSB-first bit reader with Boxsim's 23 bit accumulator mask.
|
||||||
|
|
||||||
|
The mask is load bearing: Boxsim shifts `(acc & 0x7FFFFF) << 8` on every
|
||||||
|
refill, which is safe only because a refill step runs when fewer than 19
|
||||||
|
bits are buffered, so no live bit is ever above bit 22.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("data", "pos", "acc", "navail", "padding")
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.data = data
|
||||||
|
self.pos = 0
|
||||||
|
self.acc = 0
|
||||||
|
self.navail = 0
|
||||||
|
self.padding = 0
|
||||||
|
|
||||||
|
def refill(self, n: int) -> None:
|
||||||
|
data, pos, acc, navail = self.data, self.pos, self.acc, self.navail
|
||||||
|
end = len(data)
|
||||||
|
while navail < n:
|
||||||
|
if pos < end:
|
||||||
|
byte = data[pos]
|
||||||
|
else:
|
||||||
|
self.padding += 1
|
||||||
|
if self.padding > PAD_ALLOWANCE:
|
||||||
|
raise A2FError(
|
||||||
|
"input exhausted: needed more than "
|
||||||
|
f"{PAD_ALLOWANCE} bytes of padding past the stream end"
|
||||||
|
)
|
||||||
|
byte = 0
|
||||||
|
acc = ((acc & 0x7FFFFF) << 8) | byte
|
||||||
|
pos += 1
|
||||||
|
navail += 8
|
||||||
|
self.pos, self.acc, self.navail = pos, acc, navail
|
||||||
|
|
||||||
|
def take(self, n: int) -> int:
|
||||||
|
"""Consume the next n bits."""
|
||||||
|
self.navail -= n
|
||||||
|
return (self.acc >> self.navail) & ((1 << n) - 1)
|
||||||
|
|
||||||
|
def peek16(self) -> int:
|
||||||
|
return (self.acc >> (self.navail - TABLE_BITS)) & 0xFFFF
|
||||||
|
|
||||||
|
|
||||||
|
class _Huffman:
|
||||||
|
"""Canonical Huffman decoder built exactly like Boxsim's VA 0xa10548."""
|
||||||
|
|
||||||
|
__slots__ = ("lengths", "symtab", "lentab")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lengths = [INITIAL_CODE_LENGTH] * NSYM
|
||||||
|
self.symtab = [0] * TABLE_SIZE
|
||||||
|
self.lentab = [0] * TABLE_SIZE
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
def build(self) -> None:
|
||||||
|
lengths = self.lengths
|
||||||
|
for sym, length in enumerate(lengths):
|
||||||
|
if not 0 < length <= MAX_CODE_LENGTH:
|
||||||
|
raise A2FError(f"symbol {sym} has invalid code length {length}")
|
||||||
|
|
||||||
|
# Boxsim accumulates codes in a 24 bit register, adding 2**(24-len) per
|
||||||
|
# assigned symbol, which is a plain canonical Huffman walk.
|
||||||
|
codes = [0] * NSYM
|
||||||
|
code = 0
|
||||||
|
for bitlen in range(1, MAX_CODE_LENGTH + 1):
|
||||||
|
step = 1 << (24 - bitlen)
|
||||||
|
shift = 24 - bitlen
|
||||||
|
for sym in range(NSYM):
|
||||||
|
if lengths[sym] == bitlen:
|
||||||
|
codes[sym] = code >> shift
|
||||||
|
code += step
|
||||||
|
if code > 0x1000000:
|
||||||
|
raise A2FError("over-subscribed Huffman code")
|
||||||
|
|
||||||
|
symtab, lentab = self.symtab, self.lentab
|
||||||
|
for sym in range(NSYM):
|
||||||
|
length = lengths[sym]
|
||||||
|
start = codes[sym] << (TABLE_BITS - length)
|
||||||
|
count = 1 << (TABLE_BITS - length)
|
||||||
|
symtab[start : start + count] = [sym] * count
|
||||||
|
lentab[start : start + count] = [length] * count
|
||||||
|
|
||||||
|
def decode(self, bits: _BitReader) -> int:
|
||||||
|
bits.refill(TABLE_BITS)
|
||||||
|
idx = bits.peek16()
|
||||||
|
length = self.lentab[idx]
|
||||||
|
if length == 0:
|
||||||
|
raise A2FError("incomplete Huffman code: undefined table entry")
|
||||||
|
bits.navail -= length
|
||||||
|
return self.symtab[idx]
|
||||||
|
|
||||||
|
def read_lengths(self, bits: _BitReader) -> None:
|
||||||
|
"""Symbol 0x101 payload: 356 code lengths.
|
||||||
|
|
||||||
|
Two zero bits repeat the previous length. Otherwise a 4 bit nibble plus
|
||||||
|
one gives the length, and the value 5 is an escape meaning a second
|
||||||
|
nibble follows and replaces it.
|
||||||
|
"""
|
||||||
|
lengths = self.lengths
|
||||||
|
prev = -1
|
||||||
|
for i in range(NSYM):
|
||||||
|
bits.refill(8)
|
||||||
|
if (bits.acc >> (bits.navail - 2)) & 3 == 0:
|
||||||
|
lengths[i] = prev
|
||||||
|
bits.navail -= 2
|
||||||
|
else:
|
||||||
|
value = bits.take(4) + 1
|
||||||
|
if value == 5:
|
||||||
|
value = bits.take(4) + 1
|
||||||
|
lengths[i] = value
|
||||||
|
prev = value
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
|
||||||
|
def _distance(bits: _BitReader, k: int) -> int:
|
||||||
|
"""Distance for match class index k: k+3 raw bits biased by 2**(k+3) - 7."""
|
||||||
|
bits.refill(0x13)
|
||||||
|
nbits = k + 3
|
||||||
|
return bits.take(nbits) + (1 << nbits) - 7
|
||||||
|
|
||||||
|
|
||||||
|
def expand(data: bytes, *, skip_magic: int = 8) -> bytes:
|
||||||
|
"""Decompress an A2F stream, skipping `skip_magic` leading bytes."""
|
||||||
|
bits = _BitReader(data[skip_magic:])
|
||||||
|
huff = _Huffman()
|
||||||
|
digrams = [0] * DICT_SIZE
|
||||||
|
dptr = 0
|
||||||
|
out = bytearray()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
sym = huff.decode(bits)
|
||||||
|
|
||||||
|
if sym < SYM_EOS:
|
||||||
|
out.append(sym)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sym == SYM_EOS:
|
||||||
|
break
|
||||||
|
|
||||||
|
if sym == SYM_REBUILD:
|
||||||
|
huff.read_lengths(bits)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sym == SYM_DIGRAM_DEF:
|
||||||
|
bits.refill(6)
|
||||||
|
count = bits.take(5) + 1
|
||||||
|
for _ in range(count):
|
||||||
|
bits.refill(0x11)
|
||||||
|
high = huff.decode(bits)
|
||||||
|
bits.refill(0x11)
|
||||||
|
low = huff.decode(bits)
|
||||||
|
digrams[dptr] = ((high << 8) | low) & 0xFFFF
|
||||||
|
dptr = (dptr + 1) & (DICT_SIZE - 1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if sym >= SYM_DIGRAM:
|
||||||
|
pair = digrams[sym - SYM_DIGRAM]
|
||||||
|
out.append(pair >> 8)
|
||||||
|
out.append(pair & 0xFF)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Match symbols.
|
||||||
|
if sym < SYM_MATCH4:
|
||||||
|
length = 3
|
||||||
|
dist = _distance(bits, sym - SYM_MATCH3)
|
||||||
|
elif sym < SYM_MATCHN:
|
||||||
|
length = 4
|
||||||
|
dist = _distance(bits, sym - SYM_MATCH4)
|
||||||
|
else:
|
||||||
|
k = sym - SYM_MATCHN
|
||||||
|
bits.refill(0x11)
|
||||||
|
unary = 0
|
||||||
|
while True:
|
||||||
|
bit = (bits.acc >> (bits.navail - 1)) & 1
|
||||||
|
unary += 1
|
||||||
|
bits.navail -= 1
|
||||||
|
if not bit:
|
||||||
|
break
|
||||||
|
bits.refill(0xD)
|
||||||
|
try:
|
||||||
|
nbits, base = LENGTH_CLASSES[unary]
|
||||||
|
except KeyError:
|
||||||
|
raise A2FError(f"unsupported length class {unary}") from None
|
||||||
|
length = bits.take(nbits) + base
|
||||||
|
dist = _distance(bits, k)
|
||||||
|
|
||||||
|
if dist > len(out):
|
||||||
|
raise A2FError(f"match distance {dist} exceeds {len(out)} bytes of output")
|
||||||
|
start = len(out) - dist
|
||||||
|
if dist >= length:
|
||||||
|
out += out[start : start + length]
|
||||||
|
else:
|
||||||
|
# Overlapping copy, byte by byte, as the original does.
|
||||||
|
for i in range(start, start + length):
|
||||||
|
out.append(out[i])
|
||||||
|
|
||||||
|
return bytes(out)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Visaton parts catalogue, as shipped with Boxsim.
|
||||||
|
|
||||||
|
`Weichenbauteile/Visatonteile.txt` is tab separated text:
|
||||||
|
|
||||||
|
<type>\t<series>\t<value>[\t<dcr>]
|
||||||
|
|
||||||
|
Type is R, C or L. Series strings are exactly the `_bem` values Boxsim writes
|
||||||
|
into a project, which makes this the authoritative mapping from project data to
|
||||||
|
a real part. Resistors are in Ohm, capacitors in microfarad, inductors in
|
||||||
|
millihenry with the fourth column giving the DC resistance in Ohm.
|
||||||
|
|
||||||
|
Beware: the file has no trailing newline, so `wc -l` undercounts it by one and
|
||||||
|
the final part is easy to lose.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .model import PartType
|
||||||
|
|
||||||
|
#: Catalogue series name -> library symbol series. Inductor gauges are carried
|
||||||
|
#: separately because the library encodes them as a `(0.6)` style suffix.
|
||||||
|
SERIES_TO_SYMBOL = {
|
||||||
|
"MOX 10W": "MOX-Widerstand",
|
||||||
|
"Keramik 10W": "Keramikwiderstand",
|
||||||
|
"Elko rau": "Elko",
|
||||||
|
"MKT-A": "MKT-A",
|
||||||
|
"MKP": "MKP",
|
||||||
|
"FC-Spule": "FC-Spule",
|
||||||
|
"LR-Spule": "LR-Spule",
|
||||||
|
"KN-Spule": "KN-Spule",
|
||||||
|
"SP 0,6mm": "SP-Spule",
|
||||||
|
"SP 1,0mm": "SP-Spule",
|
||||||
|
"SP 1,3mm": "SP-Spule",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Wire gauge per SP series, matching the library's symbol name suffix.
|
||||||
|
SERIES_GAUGE = {"SP 0,6mm": "0.6", "SP 1,0mm": "1.0", "SP 1,3mm": "1.3"}
|
||||||
|
|
||||||
|
TYPE_OF = {"R": PartType.RESISTOR, "C": PartType.CAPACITOR, "L": PartType.INDUCTOR}
|
||||||
|
|
||||||
|
#: Catalogue units to SI.
|
||||||
|
SI_SCALE = {
|
||||||
|
PartType.RESISTOR: 1.0,
|
||||||
|
PartType.CAPACITOR: 1e-6,
|
||||||
|
PartType.INDUCTOR: 1e-3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback series when a project leaves `_bem` empty and no sibling settles it.
|
||||||
|
#: The widest series for the type, so the substitution is at least stockable.
|
||||||
|
DEFAULT_SERIES = {
|
||||||
|
PartType.CAPACITOR: "MKT-A",
|
||||||
|
PartType.RESISTOR: "Keramik 10W",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Shape of the catalogue shipped with Boxsim 2.1 DE (12.2025), verified
|
||||||
|
#: 2026-09-29. Asserted when bundling the catalogue into an image, because
|
||||||
|
#: `cross_check` only proves the catalogue is a subset of the library: a
|
||||||
|
#: truncated catalogue would pass that and then reject valid projects with
|
||||||
|
#: "not stocked by Visaton". Bump these deliberately when Visaton's list changes.
|
||||||
|
EXPECTED_PART_COUNT = 95
|
||||||
|
EXPECTED_SERIES_COUNT = 11
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CatalogPart:
|
||||||
|
typ: PartType
|
||||||
|
series: str
|
||||||
|
value: float # SI: Ohm, Farad, Henry
|
||||||
|
dcr: float | None # Ohm, inductors only
|
||||||
|
raw_value: float # as printed in the catalogue
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gauge(self) -> str | None:
|
||||||
|
return SERIES_GAUGE.get(self.series)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def symbol_series(self) -> str:
|
||||||
|
return SERIES_TO_SYMBOL[self.series]
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog:
|
||||||
|
"""All parts Boxsim will let a designer pick."""
|
||||||
|
|
||||||
|
def __init__(self, parts: list[CatalogPart]):
|
||||||
|
self.parts = parts
|
||||||
|
self._by_series: dict[str, list[CatalogPart]] = {}
|
||||||
|
for p in parts:
|
||||||
|
self._by_series.setdefault(p.series, []).append(p)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.parts)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def series_names(self) -> list[str]:
|
||||||
|
return sorted(self._by_series)
|
||||||
|
|
||||||
|
def of_series(self, series: str) -> list[CatalogPart]:
|
||||||
|
return self._by_series.get(series, [])
|
||||||
|
|
||||||
|
def find(self, series: str, value: float, rtol: float = 1e-4) -> CatalogPart | None:
|
||||||
|
"""Nearest catalogue part in a series, within a relative tolerance."""
|
||||||
|
best: CatalogPart | None = None
|
||||||
|
best_err = None
|
||||||
|
for p in self.of_series(series):
|
||||||
|
if p.value == 0:
|
||||||
|
continue
|
||||||
|
err = abs(p.value - value) / p.value
|
||||||
|
if err <= rtol and (best_err is None or err < best_err):
|
||||||
|
best, best_err = p, err
|
||||||
|
return best
|
||||||
|
|
||||||
|
def nearest(self, series: str, value: float, count: int = 3) -> list[CatalogPart]:
|
||||||
|
"""Closest stocked values, for error messages."""
|
||||||
|
return sorted(
|
||||||
|
self.of_series(series), key=lambda p: abs(p.value - value)
|
||||||
|
)[:count]
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text: str) -> Catalog:
|
||||||
|
parts: list[CatalogPart] = []
|
||||||
|
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
for line in lines[1:]: # first line is the "Visaton-Weichenbauteile" banner
|
||||||
|
fields = line.split("\t")
|
||||||
|
if len(fields) < 3:
|
||||||
|
continue
|
||||||
|
kind = fields[0].strip()
|
||||||
|
series = fields[1].strip()
|
||||||
|
if kind not in TYPE_OF or not series or "---" in series:
|
||||||
|
continue
|
||||||
|
raw = fields[2].strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw_value = float(raw.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
typ = TYPE_OF[kind]
|
||||||
|
dcr = None
|
||||||
|
if len(fields) > 3 and fields[3].strip():
|
||||||
|
try:
|
||||||
|
dcr = float(fields[3].strip().replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
dcr = None
|
||||||
|
if series not in SERIES_TO_SYMBOL:
|
||||||
|
raise CatalogError(
|
||||||
|
f"catalogue series {series!r} has no library mapping; "
|
||||||
|
"add it to SERIES_TO_SYMBOL"
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
CatalogPart(
|
||||||
|
typ=typ,
|
||||||
|
series=series,
|
||||||
|
value=raw_value * SI_SCALE[typ],
|
||||||
|
dcr=dcr,
|
||||||
|
raw_value=raw_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not parts:
|
||||||
|
raise CatalogError("catalogue contained no parts")
|
||||||
|
return Catalog(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shape(catalog: Catalog) -> None:
|
||||||
|
"""Assert the catalogue is complete, not merely self-consistent."""
|
||||||
|
if len(catalog) != EXPECTED_PART_COUNT or (
|
||||||
|
len(catalog.series_names) != EXPECTED_SERIES_COUNT
|
||||||
|
):
|
||||||
|
raise CatalogError(
|
||||||
|
f"catalogue has {len(catalog)} parts across "
|
||||||
|
f"{len(catalog.series_names)} series, expected "
|
||||||
|
f"{EXPECTED_PART_COUNT} across {EXPECTED_SERIES_COUNT}. "
|
||||||
|
"A short catalogue is usually a truncated file (note it has no "
|
||||||
|
"trailing newline). If Visaton really changed their range, update "
|
||||||
|
"EXPECTED_PART_COUNT and EXPECTED_SERIES_COUNT."
|
||||||
|
)
|
||||||
|
missing = sorted(set(SERIES_TO_SYMBOL) - set(catalog.series_names))
|
||||||
|
if missing:
|
||||||
|
raise CatalogError(f"catalogue is missing series: {', '.join(missing)}")
|
||||||
|
|
||||||
|
|
||||||
|
def default_path() -> Path:
|
||||||
|
"""Where the catalogue lives, overridable for a relocated distribution."""
|
||||||
|
env = os.environ.get("BOXSIM_CATALOG")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
return (
|
||||||
|
root
|
||||||
|
/ "boxsim_dl"
|
||||||
|
/ "BoxSim 2.1 DE (Stand 12.2025)"
|
||||||
|
/ "Weichenbauteile"
|
||||||
|
/ "Visatonteile.txt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: str | os.PathLike[str] | None = None) -> Catalog:
|
||||||
|
p = Path(path) if path else default_path()
|
||||||
|
if not p.exists():
|
||||||
|
raise CatalogError(
|
||||||
|
f"Visaton catalogue not found at {p}; set BOXSIM_CATALOG to its location"
|
||||||
|
)
|
||||||
|
return parse(p.read_text(encoding="latin1"))
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
"""Command line interface.
|
||||||
|
|
||||||
|
boxsim decode file.bpj raw INI payload
|
||||||
|
boxsim extract file.bpj structured JSON model
|
||||||
|
boxsim netlist file.bpj crossover netlist
|
||||||
|
boxsim build file.bpj outdir/ a KiCad 10 project
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import catalog as catalog_mod
|
||||||
|
from . import container, ini, model
|
||||||
|
from . import project as project_mod
|
||||||
|
from . import symbols as symbols_mod
|
||||||
|
from .model import Project
|
||||||
|
|
||||||
|
|
||||||
|
def _add_library_args(p: argparse.ArgumentParser) -> None:
|
||||||
|
p.add_argument(
|
||||||
|
"--library",
|
||||||
|
help="path to the kicad-visaton-lib checkout or its Visaton.kicad_sym "
|
||||||
|
"(default: $BOXSIM_KICAD_LIB, else the copy bundled with this tool)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--catalog",
|
||||||
|
help="path to Visatonteile.txt (default: $BOXSIM_CATALOG, else the "
|
||||||
|
"copy bundled with this tool)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: str) -> bytes:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
raise SystemExit(f"boxsim: {path}: no such file")
|
||||||
|
return p.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_project(path: str) -> tuple[Project, str]:
|
||||||
|
raw = _read(path)
|
||||||
|
doc = ini.parse(container.decode_text(raw))
|
||||||
|
return model.from_ini(doc), Path(path).stem
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_decode(args: argparse.Namespace) -> int:
|
||||||
|
raw = _read(args.file)
|
||||||
|
if args.oracle:
|
||||||
|
from . import _oracle
|
||||||
|
|
||||||
|
payload = _oracle.decode_bpj(raw)
|
||||||
|
else:
|
||||||
|
payload = container.decode(raw)
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_bytes(payload)
|
||||||
|
print(f"{args.file}: {len(payload)} bytes -> {args.output}")
|
||||||
|
else:
|
||||||
|
sys.stdout.buffer.write(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _model_to_dict(project: Project, resolutions=None) -> dict:
|
||||||
|
by_slot = {r.part.slot: r for r in (resolutions or [])}
|
||||||
|
out: dict = {
|
||||||
|
"version": project.version,
|
||||||
|
"amp_count": project.amp_count,
|
||||||
|
"chassis_count": project.chassis_count,
|
||||||
|
"comments": project.comments,
|
||||||
|
"chassis": [
|
||||||
|
{
|
||||||
|
"index": c.index,
|
||||||
|
"name": c.name,
|
||||||
|
"comment": c.comment,
|
||||||
|
"re_ohm": c.re,
|
||||||
|
"fs_hz": c.fs,
|
||||||
|
"vas_m3": c.vas,
|
||||||
|
"qes": c.qes,
|
||||||
|
"qms": c.qms,
|
||||||
|
}
|
||||||
|
for c in project.chassis
|
||||||
|
],
|
||||||
|
"enclosures": [
|
||||||
|
{"index": e.index, "type": e.typ, "vb_m3": e.vb, "fb_hz": e.fb}
|
||||||
|
for e in project.enclosures
|
||||||
|
],
|
||||||
|
"baffles": [
|
||||||
|
{"index": b.index, "width_m": b.width, "height_m": b.height,
|
||||||
|
"shared_enclosure": b.shared}
|
||||||
|
for b in project.baffles
|
||||||
|
],
|
||||||
|
"crossovers": [],
|
||||||
|
}
|
||||||
|
for xo in project.crossovers:
|
||||||
|
netlist = xo.build_netlist()
|
||||||
|
parts = []
|
||||||
|
for p in xo.parts:
|
||||||
|
entry = {
|
||||||
|
"slot": p.slot,
|
||||||
|
"type": p.typ.name.lower(),
|
||||||
|
"orientation": p.orientation.name.lower(),
|
||||||
|
"row": p.row,
|
||||||
|
"col": p.col,
|
||||||
|
"nodes": [p.node_a, p.node_b],
|
||||||
|
"nets": [netlist.net_of(p.node_a), netlist.net_of(p.node_b)],
|
||||||
|
}
|
||||||
|
if p.value is not None:
|
||||||
|
entry["value_si"] = p.value
|
||||||
|
if p.label:
|
||||||
|
entry["series"] = p.label
|
||||||
|
if p.dcr is not None:
|
||||||
|
entry["dcr_ohm"] = p.dcr
|
||||||
|
if p.chassis_index is not None:
|
||||||
|
entry["chassis_index"] = p.chassis_index
|
||||||
|
if p.inverted:
|
||||||
|
entry["inverted"] = True
|
||||||
|
res = by_slot.get(p.slot)
|
||||||
|
if res is not None:
|
||||||
|
entry["symbol"] = res.symbol
|
||||||
|
entry["value_text"] = res.value_text
|
||||||
|
if res.notes:
|
||||||
|
entry["notes"] = res.notes
|
||||||
|
parts.append(entry)
|
||||||
|
out["crossovers"].append(
|
||||||
|
{
|
||||||
|
"index": xo.index,
|
||||||
|
"component_count": len(xo.components),
|
||||||
|
"wire_count": len(xo.wires),
|
||||||
|
"net_count": netlist.net_count,
|
||||||
|
"dangling_nets": netlist.dangling(),
|
||||||
|
"parts": parts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_extract(args: argparse.Namespace) -> int:
|
||||||
|
project, _ = _load_project(args.file)
|
||||||
|
resolutions = None
|
||||||
|
if not args.no_symbols:
|
||||||
|
try:
|
||||||
|
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||||||
|
resolver = symbols_mod.Resolver(cat, lib)
|
||||||
|
resolutions = [
|
||||||
|
r
|
||||||
|
for xo in project.crossovers
|
||||||
|
for r in resolver.resolve_all(xo.components)
|
||||||
|
]
|
||||||
|
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||||||
|
print(f"boxsim: symbol resolution skipped: {exc}", file=sys.stderr)
|
||||||
|
payload = json.dumps(_model_to_dict(project, resolutions), indent=2, sort_keys=False)
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(payload + "\n", encoding="utf-8")
|
||||||
|
print(f"{args.file} -> {args.output}")
|
||||||
|
else:
|
||||||
|
print(payload)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_netlist(args: argparse.Namespace) -> int:
|
||||||
|
project, _ = _load_project(args.file)
|
||||||
|
for xo in project.crossovers:
|
||||||
|
netlist = xo.build_netlist()
|
||||||
|
print(f"# crossover {xo.index}: {len(xo.components)} components, "
|
||||||
|
f"{netlist.net_count} nets")
|
||||||
|
groups: dict[int, list[str]] = {}
|
||||||
|
for p in xo.components:
|
||||||
|
label = f"Part{p.slot}({p.typ.name.lower()}"
|
||||||
|
label += f" {p.value:g})" if p.value is not None else ")"
|
||||||
|
for node in (p.node_a, p.node_b):
|
||||||
|
groups.setdefault(netlist.net_of(node), []).append(label)
|
||||||
|
for net in sorted(groups):
|
||||||
|
print(f" net {net:3d}: {', '.join(sorted(groups[net]))}")
|
||||||
|
dangling = netlist.dangling()
|
||||||
|
if dangling:
|
||||||
|
print(f" WARNING dangling nets: {dangling}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_build(args: argparse.Namespace) -> int:
|
||||||
|
project, name = _load_project(args.file)
|
||||||
|
try:
|
||||||
|
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||||||
|
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||||||
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||||||
|
|
||||||
|
extras = symbols_mod.cross_check(cat, lib)
|
||||||
|
if extras and args.verbose:
|
||||||
|
print(f"note: library has {len(extras)} symbol(s) Boxsim cannot select: "
|
||||||
|
f"{', '.join(extras)}")
|
||||||
|
|
||||||
|
outdir = Path(args.outdir)
|
||||||
|
try:
|
||||||
|
result = project_mod.build(
|
||||||
|
project, args.name or name, outdir, cat, lib, pitch=args.pitch
|
||||||
|
)
|
||||||
|
except (symbols_mod.SymbolError, NotImplementedError) as exc:
|
||||||
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||||||
|
|
||||||
|
print(f"{result.schematic.name}: {len(result.sheet.placements)} symbols, "
|
||||||
|
f"{len(result.sheet.wires)} wires, {len(result.sheet.junctions)} junctions, "
|
||||||
|
f"paper {result.sheet.paper}")
|
||||||
|
for note in result.notes:
|
||||||
|
print(f" note: {note}")
|
||||||
|
|
||||||
|
if args.verify:
|
||||||
|
from . import verify as verify_mod
|
||||||
|
|
||||||
|
try:
|
||||||
|
gate = verify_mod.run_gate(
|
||||||
|
result.schematic, project.crossovers[0], result.sheet.placements
|
||||||
|
)
|
||||||
|
except verify_mod.VerifyError as exc:
|
||||||
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||||||
|
for m in gate.messages:
|
||||||
|
print(f" {m}")
|
||||||
|
status = "PASS" if gate.ok else "FAIL"
|
||||||
|
print(f" gate {status}: KiCad {gate.kicad_version}, ERC "
|
||||||
|
f"{'clean' if gate.erc_ok else 'failed'}, netlist "
|
||||||
|
f"{'matches' if gate.netlist_matches else 'differs'} "
|
||||||
|
f"({len(gate.reference_nets)} nets)")
|
||||||
|
if not gate.ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
prog="boxsim",
|
||||||
|
description="Read Boxsim loudspeaker projects and turn their crossovers "
|
||||||
|
"into KiCad projects.",
|
||||||
|
)
|
||||||
|
sub = ap.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("decode", help="write the raw INI payload of a container")
|
||||||
|
p.add_argument("file")
|
||||||
|
p.add_argument("-o", "--output")
|
||||||
|
p.add_argument(
|
||||||
|
"--oracle",
|
||||||
|
action="store_true",
|
||||||
|
help="decode by emulating Boxsim.exe instead (development only)",
|
||||||
|
)
|
||||||
|
p.set_defaults(func=cmd_decode)
|
||||||
|
|
||||||
|
p = sub.add_parser("extract", help="write the structured project model as JSON")
|
||||||
|
p.add_argument("file")
|
||||||
|
p.add_argument("-o", "--output")
|
||||||
|
p.add_argument(
|
||||||
|
"--no-symbols", action="store_true", help="skip KiCad symbol resolution"
|
||||||
|
)
|
||||||
|
_add_library_args(p)
|
||||||
|
p.set_defaults(func=cmd_extract)
|
||||||
|
|
||||||
|
p = sub.add_parser("netlist", help="print the crossover netlist")
|
||||||
|
p.add_argument("file")
|
||||||
|
p.set_defaults(func=cmd_netlist)
|
||||||
|
|
||||||
|
p = sub.add_parser("build", help="write a KiCad project")
|
||||||
|
p.add_argument("file")
|
||||||
|
p.add_argument("outdir")
|
||||||
|
p.add_argument("--name", help="project name (default: input file stem)")
|
||||||
|
p.add_argument(
|
||||||
|
"--pitch", type=float, default=None,
|
||||||
|
help="grid pitch in mm (default 25.4, must exceed 22.86)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--verify", action="store_true",
|
||||||
|
help="run ERC and compare the extracted netlist against Boxsim "
|
||||||
|
"(requires kicad-cli 10)",
|
||||||
|
)
|
||||||
|
p.add_argument("-v", "--verbose", action="store_true")
|
||||||
|
_add_library_args(p)
|
||||||
|
p.set_defaults(func=cmd_build)
|
||||||
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = build_parser()
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
if getattr(args, "pitch", None) is None and args.command == "build":
|
||||||
|
from .schematic import DEFAULT_PITCH_MM
|
||||||
|
|
||||||
|
args.pitch = DEFAULT_PITCH_MM
|
||||||
|
try:
|
||||||
|
return args.func(args)
|
||||||
|
except (container.ContainerError, model.ModelError) as exc:
|
||||||
|
raise SystemExit(f"boxsim: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""The MyIni2CK container: magic, XOR scrambling, A2F compression.
|
||||||
|
|
||||||
|
Boxsim uses one container for project files (`.bpj`) and driver files (`.bch`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import a2f
|
||||||
|
from .krypt import unscramble
|
||||||
|
|
||||||
|
MAGIC = b"MyIni2CK"
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_container(raw: bytes) -> bool:
|
||||||
|
return raw[: len(MAGIC)] == MAGIC
|
||||||
|
|
||||||
|
|
||||||
|
def decode(raw: bytes) -> bytes:
|
||||||
|
"""Decode a Boxsim container to its raw INI payload."""
|
||||||
|
if not is_container(raw):
|
||||||
|
raise ContainerError(
|
||||||
|
f"not a MyIni2CK file (magic {raw[:8]!r}, expected {MAGIC!r})"
|
||||||
|
)
|
||||||
|
# The compressed layer reads its own 8 byte header back, so hand it the
|
||||||
|
# magic followed by the descrambled body exactly as Boxsim does.
|
||||||
|
return a2f.expand(MAGIC + unscramble(raw[len(MAGIC) :]))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_text(raw: bytes) -> str:
|
||||||
|
"""Decode to text. Boxsim writes Windows-1252 with CRLF line ends."""
|
||||||
|
return decode(raw).decode("cp1252")
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Parser for the INI-ish text Boxsim stores inside its containers.
|
||||||
|
|
||||||
|
Shape: `#Section` headers, `key=<TAB>value` pairs, CRLF line ends, German
|
||||||
|
decimal commas, and arrays as `;`-separated values. Section names are
|
||||||
|
case sensitive: a project contains both `#AktFilter1` and `#Aktfilter1`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
|
||||||
|
class Section(dict):
|
||||||
|
"""One `#Name` block. A plain dict of str -> str, plus the section name."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
super().__init__()
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||||
|
return f"Section({self.name!r}, {len(self)} keys)"
|
||||||
|
|
||||||
|
|
||||||
|
class IniDocument:
|
||||||
|
"""Ordered collection of sections, tolerant of repeated section names."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.sections: list[Section] = []
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[Section]:
|
||||||
|
return iter(self.sections)
|
||||||
|
|
||||||
|
def __contains__(self, name: str) -> bool:
|
||||||
|
return any(s.name == name for s in self.sections)
|
||||||
|
|
||||||
|
def first(self, name: str) -> Section | None:
|
||||||
|
for s in self.sections:
|
||||||
|
if s.name == name:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
def require(self, name: str) -> Section:
|
||||||
|
s = self.first(name)
|
||||||
|
if s is None:
|
||||||
|
raise KeyError(f"missing section #{name}")
|
||||||
|
return s
|
||||||
|
|
||||||
|
def all(self, name: str) -> list[Section]:
|
||||||
|
return [s for s in self.sections if s.name == name]
|
||||||
|
|
||||||
|
def merged(self, *names: str) -> Section:
|
||||||
|
"""Merge several sections into one, for split blocks like AktFilter."""
|
||||||
|
out = Section(names[0])
|
||||||
|
for name in names:
|
||||||
|
for s in self.all(name):
|
||||||
|
out.update(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text: str) -> IniDocument:
|
||||||
|
doc = IniDocument()
|
||||||
|
current: Section | None = None
|
||||||
|
for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = raw_line.rstrip("\n")
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
if line.startswith("#"):
|
||||||
|
current = Section(line[1:].strip())
|
||||||
|
doc.sections.append(current)
|
||||||
|
continue
|
||||||
|
if current is None:
|
||||||
|
# Content before any header: keep it in an unnamed section rather
|
||||||
|
# than dropping it silently.
|
||||||
|
current = Section("")
|
||||||
|
doc.sections.append(current)
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if not sep:
|
||||||
|
continue
|
||||||
|
current[key.strip()] = value.strip()
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def number(value: str) -> float:
|
||||||
|
"""Parse a Boxsim number. German decimal comma, exponents like `1,E-5`."""
|
||||||
|
return float(value.strip().replace(",", "."))
|
||||||
|
|
||||||
|
|
||||||
|
def integer(value: str) -> int:
|
||||||
|
return int(float(value.strip().replace(",", ".")))
|
||||||
|
|
||||||
|
|
||||||
|
def number_list(value: str) -> list[float]:
|
||||||
|
"""Parse a `;`-separated array, tolerating trailing separators."""
|
||||||
|
return [number(p) for p in value.split(";") if p.strip()]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Boxsim file scrambler: the XOR layer of the MyIni2CK container.
|
||||||
|
|
||||||
|
Reverse engineered from Boxsim 2.1 (Init at VA 0xa12be4, next byte at 0xa12c9c).
|
||||||
|
A table-shuffled combination of three linear congruential generators, seeded
|
||||||
|
with the same constants for every file Boxsim has ever written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
M32 = 1 << 32
|
||||||
|
|
||||||
|
SEED1 = 123
|
||||||
|
SEED2 = 456
|
||||||
|
# Compared against a global at VA 0xb10588, which the image ships as 0xffffffff
|
||||||
|
# and no code path ever writes.
|
||||||
|
THRESHOLD = 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
class KryptPRNG:
|
||||||
|
"""Keystream generator for the XOR layer."""
|
||||||
|
|
||||||
|
__slots__ = ("s1", "s2", "s3", "table", "thr")
|
||||||
|
|
||||||
|
def __init__(self, s1: int = SEED1, s2: int = SEED2, threshold: int = THRESHOLD):
|
||||||
|
self.s1, self.s2, self.thr = s1, s2, threshold
|
||||||
|
self.s3 = (s1 % 0xFFFE) * (s2 % 0x7F23)
|
||||||
|
self.table = [0] * 256
|
||||||
|
for i in range(256):
|
||||||
|
self._step()
|
||||||
|
self.table[i] = (self.s1 + self.s2) % M32
|
||||||
|
|
||||||
|
def _step(self) -> None:
|
||||||
|
self.s1 = (self.s1 * 0x10B95 + 1) % M32
|
||||||
|
self.s2 = (self.s2 * 0x10BA9 + 1) % M32
|
||||||
|
if self.s2 >= self.thr:
|
||||||
|
self.s2 = 0
|
||||||
|
self.s3 = (self.s3 * 0x1155D + 1) % M32
|
||||||
|
|
||||||
|
def next_byte(self) -> int:
|
||||||
|
self._step()
|
||||||
|
idx = self.s3 & 0xFF
|
||||||
|
out = self.table[idx]
|
||||||
|
self.table[idx] = (self.s1 + self.s2) % M32
|
||||||
|
return out & 0xFF
|
||||||
|
|
||||||
|
def keystream(self, n: int) -> bytes:
|
||||||
|
return bytes([self.next_byte() for _ in range(n)])
|
||||||
|
|
||||||
|
|
||||||
|
def unscramble(body: bytes) -> bytes:
|
||||||
|
"""XOR-decrypt the container body (everything after the 8-byte magic)."""
|
||||||
|
ks = KryptPRNG().keystream(len(body))
|
||||||
|
return bytes(c ^ k for c, k in zip(body, ks, strict=True))
|
||||||
|
|
||||||
|
|
||||||
|
# The layer is symmetric, but keep both names so call sites read correctly.
|
||||||
|
scramble = unscramble
|
||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
"""Typed model of a Boxsim project, including the crossover netlist.
|
||||||
|
|
||||||
|
The crossover editor is an 11 x 18 grid of nodes. A part occupies a slot
|
||||||
|
between two adjacent nodes, and the slot number encodes both position and
|
||||||
|
orientation:
|
||||||
|
|
||||||
|
1..180 horizontal, 10 per row, joins node(r,c) to node(r,c+1)
|
||||||
|
181..367 vertical, 11 per row, joins node(r,c) to node(r+1,c)
|
||||||
|
368..375 free link, endpoints given explicitly by _nod1 and _nod2
|
||||||
|
|
||||||
|
Nodes are numbered row-major and zero based: node = row * 11 + col.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from . import ini
|
||||||
|
from .ini import IniDocument
|
||||||
|
|
||||||
|
COLS = 11
|
||||||
|
ROWS = 18
|
||||||
|
H_SLOTS = (COLS - 1) * ROWS # 180
|
||||||
|
V_BASE = H_SLOTS + 1 # 181
|
||||||
|
V_SLOTS = COLS * (ROWS - 1) # 187
|
||||||
|
LINK_BASE = V_BASE + V_SLOTS # 368
|
||||||
|
LINK_SLOTS = 8
|
||||||
|
TOTAL_SLOTS = LINK_BASE + LINK_SLOTS - 1 # 375
|
||||||
|
|
||||||
|
|
||||||
|
class PartType(IntEnum):
|
||||||
|
EMPTY = 0
|
||||||
|
WIRE = 1
|
||||||
|
RESISTOR = 2
|
||||||
|
CAPACITOR = 3
|
||||||
|
INDUCTOR = 4
|
||||||
|
DRIVER = 5
|
||||||
|
AMP_INPUT = 6
|
||||||
|
|
||||||
|
|
||||||
|
#: SI unit per component type, for reporting.
|
||||||
|
UNIT = {
|
||||||
|
PartType.RESISTOR: "Ohm",
|
||||||
|
PartType.CAPACITOR: "F",
|
||||||
|
PartType.INDUCTOR: "H",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Orientation(IntEnum):
|
||||||
|
HORIZONTAL = 0
|
||||||
|
VERTICAL = 1
|
||||||
|
LINK = 2
|
||||||
|
|
||||||
|
|
||||||
|
class ModelError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def slot_geometry(slot: int) -> tuple[Orientation, int, int]:
|
||||||
|
"""Return (orientation, row, col) for a grid slot. Links have no cell."""
|
||||||
|
if 1 <= slot <= H_SLOTS:
|
||||||
|
idx = slot - 1
|
||||||
|
return Orientation.HORIZONTAL, idx // (COLS - 1), idx % (COLS - 1)
|
||||||
|
if V_BASE <= slot < V_BASE + V_SLOTS:
|
||||||
|
idx = slot - V_BASE
|
||||||
|
return Orientation.VERTICAL, idx // COLS, idx % COLS
|
||||||
|
if LINK_BASE <= slot <= TOTAL_SLOTS:
|
||||||
|
return Orientation.LINK, -1, -1
|
||||||
|
raise ModelError(f"slot {slot} is outside 1..{TOTAL_SLOTS}")
|
||||||
|
|
||||||
|
|
||||||
|
def node_at(row: int, col: int) -> int:
|
||||||
|
return row * COLS + col
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Part:
|
||||||
|
"""One populated slot of the crossover grid."""
|
||||||
|
|
||||||
|
slot: int
|
||||||
|
typ: PartType
|
||||||
|
orientation: Orientation
|
||||||
|
row: int
|
||||||
|
col: int
|
||||||
|
node_a: int
|
||||||
|
node_b: int
|
||||||
|
value: float | None = None
|
||||||
|
label: str = "" # Boxsim `_bem`, the Visaton series name
|
||||||
|
dcr: float | None = None # `_Rvalue`, inductor series resistance in Ohm
|
||||||
|
inverted: bool = False # `_verpolt`
|
||||||
|
chassis_index: int | None = None # `_chassisnr`
|
||||||
|
optimise: bool = False # `_bOpti`
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_component(self) -> bool:
|
||||||
|
"""True for anything that becomes a schematic symbol."""
|
||||||
|
return self.typ not in (PartType.EMPTY, PartType.WIRE)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
name = self.typ.name.lower()
|
||||||
|
if self.value is not None:
|
||||||
|
return f"Part{self.slot} {name} {self.value:g}"
|
||||||
|
return f"Part{self.slot} {name}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chassis:
|
||||||
|
"""A driver, from a `#Chassis<n>` section."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
comment: str = ""
|
||||||
|
re: float | None = None
|
||||||
|
fs: float | None = None
|
||||||
|
vas: float | None = None
|
||||||
|
qes: float | None = None
|
||||||
|
qms: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Enclosure:
|
||||||
|
"""A `#Einbau<n>` section. Type 1 is closed, 2+ are vented variants."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
typ: int
|
||||||
|
vb: float | None = None # net volume in m^3
|
||||||
|
fb: float | None = None # tuning frequency in Hz
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Baffle:
|
||||||
|
"""A `#Schallwand<n>` section. All lengths in metres."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
shared: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Netlist:
|
||||||
|
"""Electrical netlist: components joined through wires and links."""
|
||||||
|
|
||||||
|
nets: dict[int, int] # grid node -> canonical net id
|
||||||
|
components: list[Part]
|
||||||
|
|
||||||
|
def net_of(self, node: int) -> int:
|
||||||
|
return self.nets[node]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_count(self) -> int:
|
||||||
|
return len({self.nets[n] for p in self.components for n in (p.node_a, p.node_b)})
|
||||||
|
|
||||||
|
def dangling(self) -> list[int]:
|
||||||
|
"""Net ids with fewer than two component terminals attached."""
|
||||||
|
counts: dict[int, int] = {}
|
||||||
|
for p in self.components:
|
||||||
|
for node in (p.node_a, p.node_b):
|
||||||
|
net = self.nets[node]
|
||||||
|
counts[net] = counts.get(net, 0) + 1
|
||||||
|
return sorted(net for net, n in counts.items() if n < 2)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Crossover:
|
||||||
|
"""One `#Weiche<n>` section: the populated grid."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
parts: list[Part] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def components(self) -> list[Part]:
|
||||||
|
return [p for p in self.parts if p.is_component]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wires(self) -> list[Part]:
|
||||||
|
return [p for p in self.parts if p.typ == PartType.WIRE]
|
||||||
|
|
||||||
|
def build_netlist(self) -> Netlist:
|
||||||
|
"""Union-find over wires and free links to collapse nodes into nets."""
|
||||||
|
parent: dict[int, int] = {}
|
||||||
|
|
||||||
|
def find(x: int) -> int:
|
||||||
|
parent.setdefault(x, x)
|
||||||
|
root = x
|
||||||
|
while parent[root] != root:
|
||||||
|
root = parent[root]
|
||||||
|
while parent[x] != root: # path compression
|
||||||
|
parent[x], x = root, parent[x]
|
||||||
|
return root
|
||||||
|
|
||||||
|
def union(a: int, b: int) -> None:
|
||||||
|
ra, rb = find(a), find(b)
|
||||||
|
if ra != rb:
|
||||||
|
parent[ra] = rb
|
||||||
|
|
||||||
|
for p in self.parts:
|
||||||
|
find(p.node_a)
|
||||||
|
find(p.node_b)
|
||||||
|
if p.typ == PartType.WIRE:
|
||||||
|
union(p.node_a, p.node_b)
|
||||||
|
|
||||||
|
nets = {node: find(node) for node in list(parent)}
|
||||||
|
return Netlist(nets=nets, components=self.components)
|
||||||
|
|
||||||
|
def occupied_cells(self) -> list[tuple[int, int]]:
|
||||||
|
return [
|
||||||
|
(p.row, p.col) for p in self.parts if p.orientation != Orientation.LINK
|
||||||
|
]
|
||||||
|
|
||||||
|
def bounding_box(self) -> tuple[int, int, int, int]:
|
||||||
|
"""(min_row, min_col, max_row, max_col) over occupied cells."""
|
||||||
|
cells = self.occupied_cells()
|
||||||
|
if not cells:
|
||||||
|
raise ModelError("crossover has no populated slots")
|
||||||
|
rows = [r for r, _ in cells]
|
||||||
|
cols = [c for _, c in cells]
|
||||||
|
return min(rows), min(cols), max(rows), max(cols)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Project:
|
||||||
|
"""A whole Boxsim project."""
|
||||||
|
|
||||||
|
version: int = 0
|
||||||
|
amp_count: int = 1
|
||||||
|
chassis_count: int = 0
|
||||||
|
comments: list[str] = field(default_factory=list)
|
||||||
|
crossovers: list[Crossover] = field(default_factory=list)
|
||||||
|
chassis: list[Chassis] = field(default_factory=list)
|
||||||
|
enclosures: list[Enclosure] = field(default_factory=list)
|
||||||
|
baffles: list[Baffle] = field(default_factory=list)
|
||||||
|
|
||||||
|
def chassis_name(self, index: int) -> str:
|
||||||
|
for c in self.chassis:
|
||||||
|
if c.index == index:
|
||||||
|
return c.name
|
||||||
|
return f"Chassis{index + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_number(section: dict, key: str) -> float | None:
|
||||||
|
raw = section.get(key)
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ini.number(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_crossover(section: dict, index: int) -> Crossover:
|
||||||
|
"""Collect `_Part<N>_<field>` keys into Part objects."""
|
||||||
|
fields: dict[int, dict[str, str]] = {}
|
||||||
|
for key, value in section.items():
|
||||||
|
if not key.startswith("_Part"):
|
||||||
|
continue
|
||||||
|
rest = key[len("_Part") :]
|
||||||
|
slot_text, sep, attr = rest.partition("_")
|
||||||
|
if not sep or not slot_text.isdigit():
|
||||||
|
continue
|
||||||
|
fields.setdefault(int(slot_text), {})[attr] = value
|
||||||
|
|
||||||
|
parts: list[Part] = []
|
||||||
|
for slot in sorted(fields):
|
||||||
|
attrs = fields[slot]
|
||||||
|
typ_raw = attrs.get("typ")
|
||||||
|
if typ_raw is None:
|
||||||
|
continue
|
||||||
|
typ = PartType(ini.integer(typ_raw))
|
||||||
|
if typ == PartType.EMPTY:
|
||||||
|
continue
|
||||||
|
orientation, row, col = slot_geometry(slot)
|
||||||
|
if orientation == Orientation.LINK:
|
||||||
|
node_a = ini.integer(attrs.get("nod1", "0"))
|
||||||
|
node_b = ini.integer(attrs.get("nod2", "0"))
|
||||||
|
if node_a == node_b:
|
||||||
|
continue # unused link slot
|
||||||
|
elif orientation == Orientation.HORIZONTAL:
|
||||||
|
node_a, node_b = node_at(row, col), node_at(row, col + 1)
|
||||||
|
else:
|
||||||
|
node_a, node_b = node_at(row, col), node_at(row + 1, col)
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
Part(
|
||||||
|
slot=slot,
|
||||||
|
typ=typ,
|
||||||
|
orientation=orientation,
|
||||||
|
row=row,
|
||||||
|
col=col,
|
||||||
|
node_a=node_a,
|
||||||
|
node_b=node_b,
|
||||||
|
value=_opt_number(attrs, "value"),
|
||||||
|
label=attrs.get("bem", "").strip(),
|
||||||
|
dcr=_opt_number(attrs, "Rvalue"),
|
||||||
|
inverted=attrs.get("verpolt", "1").strip().startswith("-"),
|
||||||
|
chassis_index=(
|
||||||
|
ini.integer(attrs["chassisnr"]) if "chassisnr" in attrs else None
|
||||||
|
),
|
||||||
|
optimise=attrs.get("bOpti", "0").strip() not in ("", "0"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Crossover(index=index, parts=parts)
|
||||||
|
|
||||||
|
|
||||||
|
def from_ini(doc: IniDocument) -> Project:
|
||||||
|
"""Build a Project from a parsed INI document."""
|
||||||
|
header = doc.first("Header") or {}
|
||||||
|
general = doc.require("General")
|
||||||
|
|
||||||
|
project = Project(
|
||||||
|
version=ini.integer(header.get("Version", "0")),
|
||||||
|
amp_count=ini.integer(general.get("nAmps", "1")),
|
||||||
|
chassis_count=ini.integer(general.get("nChassis", "0")),
|
||||||
|
comments=[
|
||||||
|
general.get(k, "") for k in ("Comment1", "Comment2") if general.get(k, "")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
for n in range(1, project.amp_count + 1):
|
||||||
|
section = doc.first(f"Weiche{n}")
|
||||||
|
if section is not None:
|
||||||
|
project.crossovers.append(_parse_crossover(section, n))
|
||||||
|
|
||||||
|
for n in range(1, project.chassis_count + 1):
|
||||||
|
section = doc.first(f"Chassis{n}")
|
||||||
|
if section is None:
|
||||||
|
continue
|
||||||
|
project.chassis.append(
|
||||||
|
Chassis(
|
||||||
|
index=n - 1,
|
||||||
|
name=section.get("Name", "").strip(),
|
||||||
|
comment=section.get("Kommentar", "").strip(),
|
||||||
|
re=_opt_number(section, "Re"),
|
||||||
|
fs=_opt_number(section, "fs"),
|
||||||
|
vas=_opt_number(section, "Vas"),
|
||||||
|
qes=_opt_number(section, "Qes"),
|
||||||
|
qms=_opt_number(section, "Qms"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
enc = doc.first(f"Einbau{n}")
|
||||||
|
if enc is not None:
|
||||||
|
project.enclosures.append(
|
||||||
|
Enclosure(
|
||||||
|
index=n - 1,
|
||||||
|
typ=ini.integer(enc.get("Typ", "0")),
|
||||||
|
vb=_opt_number(enc, "Vb"),
|
||||||
|
fb=_opt_number(enc, "fb"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
baf = doc.first(f"Schallwand{n}")
|
||||||
|
if baf is not None:
|
||||||
|
project.baffles.append(
|
||||||
|
Baffle(
|
||||||
|
index=n - 1,
|
||||||
|
width=_opt_number(baf, "b"),
|
||||||
|
height=_opt_number(baf, "a"),
|
||||||
|
shared=baf.get("usegemgeh", "0").strip() not in ("", "0"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def load(raw: bytes) -> Project:
|
||||||
|
"""Decode a `.bpj` container and build the project model."""
|
||||||
|
from .container import decode_text
|
||||||
|
|
||||||
|
return from_ini(ini.parse(decode_text(raw)))
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Write a complete KiCad project directory.
|
||||||
|
|
||||||
|
Emits the schematic, a `.kicad_pro`, and project-local library tables pointing
|
||||||
|
at the Visaton library so the project opens with every symbol and footprint
|
||||||
|
resolved. No board is generated: KiCad's own *Update PCB from Schematic* does
|
||||||
|
that, and every Visaton symbol already carries a `Footprint` property.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import symbols as symbols_mod
|
||||||
|
from .catalog import Catalog
|
||||||
|
from .model import Project
|
||||||
|
from .schematic import DEFAULT_PITCH_MM, SchematicBuilder, Sheet
|
||||||
|
from .symbols import Resolution, Resolver, SymbolLibrary
|
||||||
|
|
||||||
|
LIB_NICKNAME = "Visaton"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BuildResult:
|
||||||
|
directory: Path
|
||||||
|
schematic: Path
|
||||||
|
project_file: Path
|
||||||
|
sheet: Sheet
|
||||||
|
resolutions: list[Resolution]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notes(self) -> list[str]:
|
||||||
|
out = []
|
||||||
|
for r in self.resolutions:
|
||||||
|
for note in r.notes:
|
||||||
|
out.append(f"Part{r.part.slot}: {note}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pro_contents(name: str) -> dict:
|
||||||
|
"""Minimal but complete project file. KiCad fills in the rest on open."""
|
||||||
|
return {
|
||||||
|
"board": {"design_settings": {}, "layer_presets": [], "viewports": []},
|
||||||
|
"boards": [],
|
||||||
|
"cvpcb": {"equivalence_files": []},
|
||||||
|
"libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},
|
||||||
|
"meta": {"filename": f"{name}.kicad_pro", "version": 3},
|
||||||
|
"net_settings": {
|
||||||
|
"classes": [
|
||||||
|
{
|
||||||
|
"bus_width": 12,
|
||||||
|
"clearance": 0.2,
|
||||||
|
"diff_pair_gap": 0.25,
|
||||||
|
"diff_pair_width": 0.2,
|
||||||
|
"line_style": 0,
|
||||||
|
"microvia_diameter": 0.3,
|
||||||
|
"microvia_drill": 0.1,
|
||||||
|
"name": "Default",
|
||||||
|
"pcb_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"schematic_color": "rgba(0, 0, 0, 0.000)",
|
||||||
|
"track_width": 0.8,
|
||||||
|
"via_diameter": 1.2,
|
||||||
|
"via_drill": 0.6,
|
||||||
|
"wire_width": 6,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"version": 4},
|
||||||
|
},
|
||||||
|
"pcbnew": {"page_layout_descr_file": ""},
|
||||||
|
"schematic": {
|
||||||
|
"legacy_lib_dir": "",
|
||||||
|
"legacy_lib_list": [],
|
||||||
|
"meta": {"version": 1},
|
||||||
|
"page_layout_descr_file": "",
|
||||||
|
},
|
||||||
|
"sheets": [],
|
||||||
|
"text_variables": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _lib_table(kind: str, entries: list[tuple[str, str]]) -> str:
|
||||||
|
"""Render a sym-lib-table or fp-lib-table."""
|
||||||
|
lines = [f"({kind}", "\t(version 7)"]
|
||||||
|
for nickname, uri in entries:
|
||||||
|
lines.append(
|
||||||
|
f'\t(lib (name "{nickname}")(type "KiCad")(uri "{uri}")'
|
||||||
|
f'(options "")(descr "Visaton crossover parts"))'
|
||||||
|
)
|
||||||
|
lines.append(")")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_library_root(library: SymbolLibrary) -> Path:
|
||||||
|
"""The checkout root, derived from the symbol file location."""
|
||||||
|
path = library.path.resolve()
|
||||||
|
if path.parent.name == "symbols":
|
||||||
|
return path.parent.parent
|
||||||
|
return path.parent
|
||||||
|
|
||||||
|
|
||||||
|
def build(
|
||||||
|
project: Project,
|
||||||
|
raw_name: str,
|
||||||
|
outdir: Path,
|
||||||
|
catalog: Catalog,
|
||||||
|
library: SymbolLibrary,
|
||||||
|
*,
|
||||||
|
pitch: float = DEFAULT_PITCH_MM,
|
||||||
|
crossover_index: int = 0,
|
||||||
|
) -> BuildResult:
|
||||||
|
if not project.crossovers:
|
||||||
|
raise ValueError("project contains no crossover")
|
||||||
|
if project.amp_count > 1:
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"project has {project.amp_count} amplifier channels; "
|
||||||
|
"this version handles single-channel projects only"
|
||||||
|
)
|
||||||
|
|
||||||
|
crossover = project.crossovers[crossover_index]
|
||||||
|
resolver = Resolver(catalog, library)
|
||||||
|
resolutions = resolver.resolve_all(crossover.components)
|
||||||
|
|
||||||
|
builder = SchematicBuilder(library, pitch=pitch)
|
||||||
|
sheet = builder.build(project, crossover, resolutions, title=raw_name)
|
||||||
|
text = builder.render(sheet, raw_name)
|
||||||
|
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sch_path = outdir / f"{raw_name}.kicad_sch"
|
||||||
|
sch_path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
pro_path = outdir / f"{raw_name}.kicad_pro"
|
||||||
|
pro_path.write_text(json.dumps(_pro_contents(raw_name), indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
root = resolve_library_root(library)
|
||||||
|
(outdir / "sym-lib-table").write_text(
|
||||||
|
_lib_table("sym_lib_table", [(LIB_NICKNAME, str(root / "symbols" / "Visaton.kicad_sym"))]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(outdir / "fp-lib-table").write_text(
|
||||||
|
_lib_table("fp_lib_table", [(LIB_NICKNAME, str(root / "footprints" / "Visaton.pretty"))]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return BuildResult(
|
||||||
|
directory=outdir,
|
||||||
|
schematic=sch_path,
|
||||||
|
project_file=pro_path,
|
||||||
|
sheet=sheet,
|
||||||
|
resolutions=resolutions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_parts(
|
||||||
|
catalog_path: str | None = None, library_path: str | None = None
|
||||||
|
) -> tuple[Catalog, SymbolLibrary]:
|
||||||
|
from . import catalog as catalog_mod
|
||||||
|
|
||||||
|
cat = catalog_mod.load(catalog_path)
|
||||||
|
lib = symbols_mod.load(library_path)
|
||||||
|
return cat, lib
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""Emit a KiCad 10 schematic from a Boxsim crossover.
|
||||||
|
|
||||||
|
The Boxsim crossover editor is already a schematic grid, so the layout is
|
||||||
|
carried over cell for cell instead of being re-derived: a node at grid (row,
|
||||||
|
col) becomes a sheet coordinate, wire slots become wire segments, and component
|
||||||
|
slots place a symbol between their two nodes.
|
||||||
|
|
||||||
|
Symbol placement uses KiCad's pin transform, determined empirically from the
|
||||||
|
shipped demo schematics over several thousand pin placements:
|
||||||
|
|
||||||
|
rot 0 pin (px, py) -> (X + px, Y - py)
|
||||||
|
rot 90 pin (px, py) -> (X - py, Y - px)
|
||||||
|
rot 180 pin (px, py) -> (X - px, Y + py)
|
||||||
|
rot 270 pin (px, py) -> (X + py, Y + px)
|
||||||
|
|
||||||
|
A symbol's origin is not necessarily its pin midpoint (the `Amplifier` symbol
|
||||||
|
has both pins at px=3.81), so the origin is derived by placing the transformed
|
||||||
|
pin midpoint on the slot midpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from .model import Crossover, Orientation, Part, PartType, Project
|
||||||
|
from .symbols import Resolution, SymbolLibrary
|
||||||
|
|
||||||
|
#: Schematic format emitted by KiCad 10.0.x.
|
||||||
|
SCH_VERSION = "20260101"
|
||||||
|
GENERATOR = "boxsim"
|
||||||
|
GENERATOR_VERSION = "10.0"
|
||||||
|
|
||||||
|
#: Must exceed the widest symbol's pin span (22.86 mm for `FC-Spule 10mH`)
|
||||||
|
#: so neighbouring cells cannot overlap.
|
||||||
|
DEFAULT_PITCH_MM = 25.4
|
||||||
|
MARGIN_MM = 25.4
|
||||||
|
|
||||||
|
#: KiCad standard sheets, landscape, smallest first.
|
||||||
|
PAPER_SIZES = [
|
||||||
|
("A4", 297.0, 210.0),
|
||||||
|
("A3", 420.0, 297.0),
|
||||||
|
("A2", 594.0, 420.0),
|
||||||
|
("A1", 841.0, 594.0),
|
||||||
|
("A0", 1189.0, 841.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Reference designator prefix per component type.
|
||||||
|
REFDES_PREFIX = {
|
||||||
|
PartType.RESISTOR: "R",
|
||||||
|
PartType.CAPACITOR: "C",
|
||||||
|
PartType.INDUCTOR: "L",
|
||||||
|
PartType.DRIVER: "LS",
|
||||||
|
PartType.AMP_INPUT: "J",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Stable namespace so regenerating a project yields identical UUIDs.
|
||||||
|
UUID_NS = uuid.UUID("b0c51a3e-7d61-5f0a-9c2e-0b0a5e1d4f77")
|
||||||
|
|
||||||
|
_PIN_RE = re.compile(
|
||||||
|
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
|
||||||
|
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
_PIN_NAME_RE = re.compile(
|
||||||
|
r'\(pin\s+\w+\s+\w+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+\d+\)'
|
||||||
|
r'(?:(?!\(pin\b).)*?\(name\s+"([^"]*)"'
|
||||||
|
r'(?:(?!\(pin\b).)*?\(number\s+"([^"]+)"',
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stable_uuid(*parts: object) -> str:
|
||||||
|
return str(uuid.uuid5(UUID_NS, "|".join(str(p) for p in parts)))
|
||||||
|
|
||||||
|
|
||||||
|
def transform(rot: int, px: float, py: float) -> tuple[float, float]:
|
||||||
|
if rot == 0:
|
||||||
|
return px, -py
|
||||||
|
if rot == 90:
|
||||||
|
return -py, -px
|
||||||
|
if rot == 180:
|
||||||
|
return -px, py
|
||||||
|
if rot == 270:
|
||||||
|
return py, px
|
||||||
|
raise ValueError(f"unsupported rotation {rot}")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: float) -> str:
|
||||||
|
"""KiCad writes millimetres with at most 6 decimals and no trailing zeros."""
|
||||||
|
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
||||||
|
return text if text not in ("", "-0") else "0"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SymbolPins:
|
||||||
|
"""Pin geometry of a library symbol, in library coordinates."""
|
||||||
|
|
||||||
|
by_number: dict[str, tuple[float, float]]
|
||||||
|
names: dict[str, str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def numbers(self) -> list[str]:
|
||||||
|
return sorted(self.by_number, key=lambda n: (len(n), n))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def axis(self) -> Orientation:
|
||||||
|
"""Whether the pins are separated mainly in x or in y."""
|
||||||
|
nums = self.numbers
|
||||||
|
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
|
||||||
|
return (
|
||||||
|
Orientation.HORIZONTAL
|
||||||
|
if abs(x1 - x2) >= abs(y1 - y2)
|
||||||
|
else Orientation.VERTICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def midpoint(self) -> tuple[float, float]:
|
||||||
|
nums = self.numbers
|
||||||
|
(x1, y1), (x2, y2) = self.by_number[nums[0]], self.by_number[nums[1]]
|
||||||
|
return (x1 + x2) / 2, (y1 + y2) / 2
|
||||||
|
|
||||||
|
def positive_number(self) -> str | None:
|
||||||
|
for num, name in self.names.items():
|
||||||
|
if name.strip() == "+":
|
||||||
|
return num
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def symbol_pins(library: SymbolLibrary, name: str) -> SymbolPins:
|
||||||
|
body = library.geometry_source(name)
|
||||||
|
by_number: dict[str, tuple[float, float]] = {}
|
||||||
|
names: dict[str, str] = {}
|
||||||
|
for m in _PIN_RE.finditer(body):
|
||||||
|
by_number.setdefault(m.group(3), (float(m.group(1)), float(m.group(2))))
|
||||||
|
for m in _PIN_NAME_RE.finditer(body):
|
||||||
|
names.setdefault(m.group(4), m.group(3))
|
||||||
|
if len(by_number) < 2:
|
||||||
|
raise ValueError(f"symbol {name!r} has fewer than two pins")
|
||||||
|
return SymbolPins(by_number=by_number, names=names)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Placement:
|
||||||
|
"""A resolved component positioned on the sheet."""
|
||||||
|
|
||||||
|
resolution: Resolution
|
||||||
|
refdes: str
|
||||||
|
origin: tuple[float, float]
|
||||||
|
rotation: int
|
||||||
|
pin_targets: dict[str, tuple[float, float]] # pin number -> node coordinate
|
||||||
|
pin_positions: dict[str, tuple[float, float]]
|
||||||
|
pin_nodes: dict[str, int] # pin number -> Boxsim grid node, for verification
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Sheet:
|
||||||
|
"""Everything that goes into one `.kicad_sch`."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
paper: str
|
||||||
|
placements: list[Placement] = field(default_factory=list)
|
||||||
|
wires: list[tuple[tuple[float, float], tuple[float, float]]] = field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
junctions: list[tuple[float, float]] = field(default_factory=list)
|
||||||
|
texts: list[tuple[str, tuple[float, float]]] = field(default_factory=list)
|
||||||
|
used_symbols: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SchematicBuilder:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
library: SymbolLibrary,
|
||||||
|
pitch: float = DEFAULT_PITCH_MM,
|
||||||
|
margin: float = MARGIN_MM,
|
||||||
|
):
|
||||||
|
self.library = library
|
||||||
|
self.pitch = pitch
|
||||||
|
self.margin = margin
|
||||||
|
if pitch <= 22.86:
|
||||||
|
raise ValueError(
|
||||||
|
f"pitch {pitch} mm is not wider than the widest symbol (22.86 mm)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- geometry ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _node_xy(self, node: int, min_row: int, min_col: int) -> tuple[float, float]:
|
||||||
|
from .model import COLS
|
||||||
|
|
||||||
|
row, col = divmod(node, COLS)
|
||||||
|
return (
|
||||||
|
self.margin + (col - min_col) * self.pitch,
|
||||||
|
self.margin + (row - min_row) * self.pitch,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _choose_paper(self, width: float, height: float) -> str:
|
||||||
|
for name, w, h in PAPER_SIZES:
|
||||||
|
if width <= w and height <= h:
|
||||||
|
return name
|
||||||
|
return PAPER_SIZES[-1][0]
|
||||||
|
|
||||||
|
# -- building ---------------------------------------------------------
|
||||||
|
|
||||||
|
def build(
|
||||||
|
self,
|
||||||
|
project: Project,
|
||||||
|
crossover: Crossover,
|
||||||
|
resolutions: list[Resolution],
|
||||||
|
title: str,
|
||||||
|
) -> Sheet:
|
||||||
|
min_row, min_col, max_row, max_col = crossover.bounding_box()
|
||||||
|
# A slot extends one node past its cell, so the node grid is one wider.
|
||||||
|
max_node_row, max_node_col = max_row + 1, max_col + 1
|
||||||
|
|
||||||
|
def xy(node: int) -> tuple[float, float]:
|
||||||
|
return self._node_xy(node, min_row, min_col)
|
||||||
|
|
||||||
|
width = 2 * self.margin + (max_node_col - min_col) * self.pitch
|
||||||
|
height = 2 * self.margin + (max_node_row - min_row) * self.pitch
|
||||||
|
sheet = Sheet(title=title, paper=self._choose_paper(width, height))
|
||||||
|
|
||||||
|
endpoints: dict[tuple[float, float], int] = {}
|
||||||
|
|
||||||
|
def add_wire(a: tuple[float, float], b: tuple[float, float]) -> None:
|
||||||
|
if a == b:
|
||||||
|
return
|
||||||
|
sheet.wires.append((a, b))
|
||||||
|
endpoints[a] = endpoints.get(a, 0) + 1
|
||||||
|
endpoints[b] = endpoints.get(b, 0) + 1
|
||||||
|
|
||||||
|
# Wires and free links.
|
||||||
|
for part in crossover.parts:
|
||||||
|
if part.typ != PartType.WIRE:
|
||||||
|
continue
|
||||||
|
a, b = xy(part.node_a), xy(part.node_b)
|
||||||
|
if part.orientation == Orientation.LINK and a[0] != b[0] and a[1] != b[1]:
|
||||||
|
corner = (b[0], a[1]) # route orthogonally, never diagonally
|
||||||
|
add_wire(a, corner)
|
||||||
|
add_wire(corner, b)
|
||||||
|
else:
|
||||||
|
add_wire(a, b)
|
||||||
|
|
||||||
|
# Components.
|
||||||
|
counters: dict[str, int] = {}
|
||||||
|
by_slot = {r.part.slot: r for r in resolutions}
|
||||||
|
for part in sorted(crossover.components, key=lambda p: p.slot):
|
||||||
|
res = by_slot.get(part.slot)
|
||||||
|
if res is None:
|
||||||
|
continue
|
||||||
|
placement = self._place(part, res, xy, counters, project)
|
||||||
|
sheet.placements.append(placement)
|
||||||
|
if res.symbol not in sheet.used_symbols:
|
||||||
|
sheet.used_symbols.append(res.symbol)
|
||||||
|
for num, target in placement.pin_targets.items():
|
||||||
|
add_wire(placement.pin_positions[num], target)
|
||||||
|
endpoints[target] = endpoints.get(target, 0) + 1
|
||||||
|
|
||||||
|
sheet.junctions = sorted(p for p, n in endpoints.items() if n >= 3)
|
||||||
|
sheet.texts = self._describe(project, crossover, height)
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
def _place(
|
||||||
|
self,
|
||||||
|
part: Part,
|
||||||
|
res: Resolution,
|
||||||
|
xy,
|
||||||
|
counters: dict[str, int],
|
||||||
|
project: Project,
|
||||||
|
) -> Placement:
|
||||||
|
pins = symbol_pins(self.library, res.symbol)
|
||||||
|
a, b = xy(part.node_a), xy(part.node_b)
|
||||||
|
slot_axis = (
|
||||||
|
Orientation.HORIZONTAL if abs(a[0] - b[0]) >= abs(a[1] - b[1])
|
||||||
|
else Orientation.VERTICAL
|
||||||
|
)
|
||||||
|
rotation = 0 if pins.axis == slot_axis else 90
|
||||||
|
|
||||||
|
mx, my = pins.midpoint
|
||||||
|
tmx, tmy = transform(rotation, mx, my)
|
||||||
|
mid = ((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)
|
||||||
|
origin = (mid[0] - tmx, mid[1] - tmy)
|
||||||
|
|
||||||
|
positions = {}
|
||||||
|
for num, (px, py) in pins.by_number.items():
|
||||||
|
tx, ty = transform(rotation, px, py)
|
||||||
|
positions[num] = (round(origin[0] + tx, 6), round(origin[1] + ty, 6))
|
||||||
|
|
||||||
|
# Assign pins to nodes along the slot axis.
|
||||||
|
nums = pins.numbers[:2]
|
||||||
|
key = (lambda n: positions[n][0]) if slot_axis == Orientation.HORIZONTAL \
|
||||||
|
else (lambda n: positions[n][1])
|
||||||
|
first, second = sorted(nums, key=key)
|
||||||
|
node_first, node_second = (a, b) if (
|
||||||
|
(a[0] <= b[0]) if slot_axis == Orientation.HORIZONTAL else (a[1] <= b[1])
|
||||||
|
) else (b, a)
|
||||||
|
targets = {first: node_first, second: node_second}
|
||||||
|
|
||||||
|
# `_verpolt` reverses a driver or the amplifier input. Swap the two
|
||||||
|
# terminals so the polarity marking matches Boxsim.
|
||||||
|
if part.inverted and pins.positive_number() is not None:
|
||||||
|
targets = {first: node_second, second: node_first}
|
||||||
|
|
||||||
|
prefix = REFDES_PREFIX[part.typ]
|
||||||
|
counters[prefix] = counters.get(prefix, 0) + 1
|
||||||
|
refdes = f"{prefix}{counters[prefix]}"
|
||||||
|
|
||||||
|
if part.typ == PartType.DRIVER and part.chassis_index is not None:
|
||||||
|
res.value_text = project.chassis_name(part.chassis_index)
|
||||||
|
|
||||||
|
coord_to_node = {a: part.node_a, b: part.node_b}
|
||||||
|
return Placement(
|
||||||
|
resolution=res,
|
||||||
|
refdes=refdes,
|
||||||
|
origin=(round(origin[0], 6), round(origin[1], 6)),
|
||||||
|
rotation=rotation,
|
||||||
|
pin_targets=targets,
|
||||||
|
pin_positions=positions,
|
||||||
|
pin_nodes={num: coord_to_node[xyc] for num, xyc in targets.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _describe(
|
||||||
|
self, project: Project, crossover: Crossover, height: float
|
||||||
|
) -> list[tuple[str, tuple[float, float]]]:
|
||||||
|
"""A short note recording which enclosure this crossover belongs to."""
|
||||||
|
lines = [f"Boxsim crossover {crossover.index} - {len(crossover.components)} parts"]
|
||||||
|
for ch in project.chassis:
|
||||||
|
enc = next((e for e in project.enclosures if e.index == ch.index), None)
|
||||||
|
baf = next((b for b in project.baffles if b.index == ch.index), None)
|
||||||
|
bits = [f"LS{ch.index + 1}: {ch.name}"]
|
||||||
|
if enc is not None and enc.vb:
|
||||||
|
kind = {1: "closed", 2: "vented", 3: "bandpass"}.get(enc.typ, f"type {enc.typ}")
|
||||||
|
bits.append(f"{kind}, Vb {enc.vb * 1000:g} l")
|
||||||
|
if baf is not None and baf.width and baf.height:
|
||||||
|
bits.append(f"baffle {baf.width * 1000:g} x {baf.height * 1000:g} mm")
|
||||||
|
lines.append(" ".join(bits))
|
||||||
|
y = height - self.margin / 2
|
||||||
|
return [(line, (self.margin, y + i * 4.0)) for i, line in enumerate(lines)]
|
||||||
|
|
||||||
|
# -- serialisation ----------------------------------------------------
|
||||||
|
|
||||||
|
def render(self, sheet: Sheet, project_name: str) -> str:
|
||||||
|
sheet_uuid = stable_uuid("sheet", project_name)
|
||||||
|
out: list[str] = []
|
||||||
|
w = out.append
|
||||||
|
w("(kicad_sch")
|
||||||
|
w(f'\t(version {SCH_VERSION})')
|
||||||
|
w(f'\t(generator "{GENERATOR}")')
|
||||||
|
w(f'\t(generator_version "{GENERATOR_VERSION}")')
|
||||||
|
w(f'\t(uuid "{sheet_uuid}")')
|
||||||
|
w(f'\t(paper "{sheet.paper}")')
|
||||||
|
w("\t(title_block")
|
||||||
|
w(f'\t\t(title "{sheet.title}")')
|
||||||
|
w('\t\t(comment 1 "Generated from a Boxsim project by boxsim")')
|
||||||
|
w("\t)")
|
||||||
|
|
||||||
|
w("\t(lib_symbols")
|
||||||
|
for name in sheet.used_symbols:
|
||||||
|
body = self.library.definition(name)
|
||||||
|
body = body.replace(f'(symbol "{name}"', f'(symbol "Visaton:{name}"', 1)
|
||||||
|
for line in body.splitlines():
|
||||||
|
w("\t\t" + line.strip() if not line.startswith("\t") else "\t\t" + line.lstrip("\t"))
|
||||||
|
w("\t)")
|
||||||
|
|
||||||
|
for (x1, y1), (x2, y2) in sheet.wires:
|
||||||
|
w("\t(wire")
|
||||||
|
w(f"\t\t(pts (xy {_fmt(x1)} {_fmt(y1)}) (xy {_fmt(x2)} {_fmt(y2)}))")
|
||||||
|
w("\t\t(stroke (width 0) (type default))")
|
||||||
|
w(f'\t\t(uuid "{stable_uuid("wire", x1, y1, x2, y2)}")')
|
||||||
|
w("\t)")
|
||||||
|
|
||||||
|
for x, y in sheet.junctions:
|
||||||
|
w("\t(junction")
|
||||||
|
w(f"\t\t(at {_fmt(x)} {_fmt(y)})")
|
||||||
|
w("\t\t(diameter 0)")
|
||||||
|
w("\t\t(color 0 0 0 0)")
|
||||||
|
w(f'\t\t(uuid "{stable_uuid("junction", x, y)}")')
|
||||||
|
w("\t)")
|
||||||
|
|
||||||
|
for text, (x, y) in sheet.texts:
|
||||||
|
w(f'\t(text "{text}"')
|
||||||
|
w("\t\t(exclude_from_sim yes)")
|
||||||
|
w(f"\t\t(at {_fmt(x)} {_fmt(y)} 0)")
|
||||||
|
w("\t\t(effects (font (size 1.27 1.27)) (justify left bottom))")
|
||||||
|
w(f'\t\t(uuid "{stable_uuid("text", x, y, text)}")')
|
||||||
|
w("\t)")
|
||||||
|
|
||||||
|
for p in sheet.placements:
|
||||||
|
self._render_symbol(out, p, project_name, sheet_uuid)
|
||||||
|
|
||||||
|
w("\t(sheet_instances")
|
||||||
|
w('\t\t(path "/"')
|
||||||
|
w('\t\t\t(page "1")')
|
||||||
|
w("\t\t)")
|
||||||
|
w("\t)")
|
||||||
|
w("\t(embedded_fonts no)")
|
||||||
|
w(")")
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
|
|
||||||
|
def _render_symbol(
|
||||||
|
self, out: list[str], p: Placement, project_name: str, sheet_uuid: str
|
||||||
|
) -> None:
|
||||||
|
w = out.append
|
||||||
|
res = p.resolution
|
||||||
|
part = res.part
|
||||||
|
x, y = p.origin
|
||||||
|
sym_uuid = stable_uuid("symbol", part.slot, res.symbol)
|
||||||
|
w("\t(symbol")
|
||||||
|
w(f'\t\t(lib_id "Visaton:{res.symbol}")')
|
||||||
|
w(f"\t\t(at {_fmt(x)} {_fmt(y)} {p.rotation})")
|
||||||
|
w("\t\t(unit 1)")
|
||||||
|
w("\t\t(exclude_from_sim no)")
|
||||||
|
w("\t\t(in_bom yes)")
|
||||||
|
w("\t\t(on_board yes)")
|
||||||
|
w("\t\t(dnp no)")
|
||||||
|
w(f'\t\t(uuid "{sym_uuid}")')
|
||||||
|
|
||||||
|
# Keep the labels clear of the body: above and below for a horizontal
|
||||||
|
# symbol, left and right for a rotated one, which would otherwise have
|
||||||
|
# its value text printed straight over the component.
|
||||||
|
if p.rotation == 0:
|
||||||
|
ref_at, val_at = (x, y - 5.08), (x, y + 5.08)
|
||||||
|
else:
|
||||||
|
ref_at, val_at = (x - 7.62, y), (x + 7.62, y)
|
||||||
|
props = [
|
||||||
|
("Reference", p.refdes, ref_at),
|
||||||
|
("Value", res.value_text, val_at),
|
||||||
|
]
|
||||||
|
if res.series:
|
||||||
|
props.append(("Visaton_Series", res.series, (x, y)))
|
||||||
|
if part.dcr is not None:
|
||||||
|
props.append(("DCR_Ohm", f"{part.dcr:g}", (x, y)))
|
||||||
|
if part.chassis_index is not None:
|
||||||
|
props.append(("Boxsim_Chassis", str(part.chassis_index + 1), (x, y)))
|
||||||
|
if part.inverted:
|
||||||
|
props.append(("Boxsim_Polarity", "inverted", (x, y)))
|
||||||
|
props.append(("Boxsim_Slot", str(part.slot), (x, y)))
|
||||||
|
|
||||||
|
for i, (name, value, (pxx, pyy)) in enumerate(props):
|
||||||
|
hidden = i >= 2
|
||||||
|
w(f'\t\t(property "{name}" "{value}"')
|
||||||
|
w(f"\t\t\t(at {_fmt(pxx)} {_fmt(pyy)} 0)")
|
||||||
|
if hidden:
|
||||||
|
w("\t\t\t(hide yes)")
|
||||||
|
w("\t\t\t(effects (font (size 1.27 1.27)))")
|
||||||
|
w("\t\t)")
|
||||||
|
|
||||||
|
for num in sorted(p.pin_positions):
|
||||||
|
w(f'\t\t(pin "{num}"')
|
||||||
|
w(f'\t\t\t(uuid "{stable_uuid("pin", part.slot, num)}")')
|
||||||
|
w("\t\t)")
|
||||||
|
|
||||||
|
w("\t\t(instances")
|
||||||
|
w(f'\t\t\t(project "{project_name}"')
|
||||||
|
w(f'\t\t\t\t(path "/{sheet_uuid}"')
|
||||||
|
w(f'\t\t\t\t\t(reference "{p.refdes}")')
|
||||||
|
w("\t\t\t\t\t(unit 1)")
|
||||||
|
w("\t\t\t\t)")
|
||||||
|
w("\t\t\t)")
|
||||||
|
w("\t\t)")
|
||||||
|
w("\t)")
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"""Index of the KiCad Visaton symbol library, and the resolver.
|
||||||
|
|
||||||
|
The library's own value strings are irregular: `MKP 1.0μF` sits next to
|
||||||
|
`MKP 10μF`, and `SP-Spule 1.0mH (0.6)` next to `LR-Spule 10mH`. Formatting a
|
||||||
|
number into a name would therefore break on some series, so the library is
|
||||||
|
parsed into `(series, value, gauge)` triples and matched numerically instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .catalog import DEFAULT_SERIES, SERIES_TO_SYMBOL, Catalog
|
||||||
|
from .model import Part, PartType
|
||||||
|
|
||||||
|
#: Symbols that carry no value and are addressed by name.
|
||||||
|
AMP_SYMBOL = "Amplifier"
|
||||||
|
DRIVER_SYMBOL = "Speaker"
|
||||||
|
|
||||||
|
#: Unit suffix per component type as the library writes it.
|
||||||
|
UNIT_SUFFIX = {
|
||||||
|
PartType.RESISTOR: "Ω",
|
||||||
|
PartType.CAPACITOR: "μF",
|
||||||
|
PartType.INDUCTOR: "mH",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Scale from SI to the unit the library names use.
|
||||||
|
NAME_SCALE = {
|
||||||
|
PartType.RESISTOR: 1.0,
|
||||||
|
PartType.CAPACITOR: 1e6,
|
||||||
|
PartType.INDUCTOR: 1e3,
|
||||||
|
}
|
||||||
|
|
||||||
|
_SP_RE = re.compile(r"^SP-Spule\s+([\d.]+)mH\s+\(([\d.]+)\)$")
|
||||||
|
_PLAIN_RE = re.compile(r"^(.*?)\s+([\d.]+)(mH|μF|Ω)$")
|
||||||
|
_SYMBOL_RE = re.compile(r'^\s*\(symbol\s+"([^"]+)"', re.MULTILINE)
|
||||||
|
_UNIT_SUFFIX_RE = re.compile(r"_\d+_\d+$")
|
||||||
|
_EXTENDS_RE = re.compile(r'\(extends\s+"([^"]+)"\)')
|
||||||
|
_LIB_VERSION_RE = re.compile(r"\(version\s+(\d+)\)")
|
||||||
|
|
||||||
|
#: Oldest symbol library format this tool will read. KiCad 6 era libraries use
|
||||||
|
#: `(id N)` properties and bare `hide` tokens, which cannot be embedded into a
|
||||||
|
#: current schematic verbatim. `kicad-cli sym upgrade` converts them.
|
||||||
|
MIN_LIB_VERSION = 20211014
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LibrarySymbol:
|
||||||
|
name: str
|
||||||
|
series: str | None # library-side series, e.g. "SP-Spule"
|
||||||
|
value: float | None # SI
|
||||||
|
gauge: str | None # "0.6" etc, SP inductors only
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Resolution:
|
||||||
|
"""The outcome of resolving one Boxsim part to a library symbol."""
|
||||||
|
|
||||||
|
part: Part
|
||||||
|
symbol: str
|
||||||
|
value_text: str
|
||||||
|
series: str | None = None
|
||||||
|
inferred_series: bool = False
|
||||||
|
dcr_note: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notes(self) -> list[str]:
|
||||||
|
out = []
|
||||||
|
if self.inferred_series:
|
||||||
|
out.append(f"series inferred as {self.series!r} (Boxsim left _bem empty)")
|
||||||
|
if self.dcr_note:
|
||||||
|
out.append(self.dcr_note)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_symbol_name(name: str) -> LibrarySymbol:
|
||||||
|
m = _SP_RE.match(name)
|
||||||
|
if m:
|
||||||
|
return LibrarySymbol(name, "SP-Spule", float(m.group(1)) * 1e-3, m.group(2))
|
||||||
|
m = _PLAIN_RE.match(name)
|
||||||
|
if m:
|
||||||
|
series, number, unit = m.group(1), float(m.group(2)), m.group(3)
|
||||||
|
scale = {"mH": 1e-3, "μF": 1e-6, "Ω": 1.0}[unit]
|
||||||
|
return LibrarySymbol(name, series, number * scale, None)
|
||||||
|
return LibrarySymbol(name, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
class SymbolLibrary:
|
||||||
|
"""Parsed `Visaton.kicad_sym`."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, names: list[str], source: str):
|
||||||
|
self.path = path
|
||||||
|
self.source = source
|
||||||
|
self.symbols = [_parse_symbol_name(n) for n in names]
|
||||||
|
self._by_name = {s.name: s for s in self.symbols}
|
||||||
|
self._index: dict[tuple[str, str | None], dict[int, LibrarySymbol]] = {}
|
||||||
|
for s in self.symbols:
|
||||||
|
if s.series is None or s.value is None:
|
||||||
|
continue
|
||||||
|
self._index.setdefault((s.series, s.gauge), {})[round(s.value, 12)] = s
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.symbols)
|
||||||
|
|
||||||
|
def __contains__(self, name: str) -> bool:
|
||||||
|
return name in self._by_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def names(self) -> list[str]:
|
||||||
|
return [s.name for s in self.symbols]
|
||||||
|
|
||||||
|
def lookup(
|
||||||
|
self, series: str, value: float, gauge: str | None = None, rtol: float = 1e-6
|
||||||
|
) -> LibrarySymbol | None:
|
||||||
|
bucket = self._index.get((series, gauge))
|
||||||
|
if not bucket:
|
||||||
|
return None
|
||||||
|
exact = bucket.get(round(value, 12))
|
||||||
|
if exact is not None:
|
||||||
|
return exact
|
||||||
|
best, best_err = None, None
|
||||||
|
for v, sym in bucket.items():
|
||||||
|
if v == 0:
|
||||||
|
continue
|
||||||
|
err = abs(v - value) / v
|
||||||
|
if err <= rtol and (best_err is None or err < best_err):
|
||||||
|
best, best_err = sym, err
|
||||||
|
return best
|
||||||
|
|
||||||
|
def raw_definition(self, name: str) -> str:
|
||||||
|
"""The s-expression block for one symbol, exactly as stored."""
|
||||||
|
needle = f'(symbol "{name}"'
|
||||||
|
start = self.source.find(needle)
|
||||||
|
if start < 0:
|
||||||
|
raise SymbolError(f"symbol {name!r} not found in {self.path}")
|
||||||
|
# Walk balanced parentheses, ignoring those inside strings. Symbol names
|
||||||
|
# such as `SP-Spule 0.82mH (0.6)` contain parentheses of their own.
|
||||||
|
depth, i, in_str, escaped = 0, start, False, False
|
||||||
|
while i < len(self.source):
|
||||||
|
ch = self.source[i]
|
||||||
|
if in_str:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = True
|
||||||
|
elif ch == "(":
|
||||||
|
depth += 1
|
||||||
|
elif ch == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return self.source[start : i + 1]
|
||||||
|
i += 1
|
||||||
|
raise SymbolError(f"unbalanced s-expression for symbol {name!r}")
|
||||||
|
|
||||||
|
def base_of(self, name: str) -> str | None:
|
||||||
|
"""The symbol this one `extends`, if any."""
|
||||||
|
m = _EXTENDS_RE.search(self.raw_definition(name))
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
def geometry_source(self, name: str) -> str:
|
||||||
|
"""The definition that actually carries pins and graphics."""
|
||||||
|
seen = set()
|
||||||
|
current = name
|
||||||
|
while True:
|
||||||
|
base = self.base_of(current)
|
||||||
|
if base is None:
|
||||||
|
return self.raw_definition(current)
|
||||||
|
if base in seen:
|
||||||
|
raise SymbolError(f"circular extends chain at {base!r}")
|
||||||
|
seen.add(base)
|
||||||
|
current = base
|
||||||
|
|
||||||
|
def definition(self, name: str) -> str:
|
||||||
|
"""A self-contained symbol block suitable for a schematic's lib_symbols.
|
||||||
|
|
||||||
|
A derived symbol is flattened: its own properties are kept and the base
|
||||||
|
symbol's graphical sub-units are copied in under the derived name.
|
||||||
|
KiCad never writes `extends` into a schematic cache, so neither do we.
|
||||||
|
"""
|
||||||
|
own = self.raw_definition(name)
|
||||||
|
base = self.base_of(name)
|
||||||
|
if base is None:
|
||||||
|
return own
|
||||||
|
|
||||||
|
body = _EXTENDS_RE.sub("", own, count=1)
|
||||||
|
closing = body.rstrip()
|
||||||
|
if not closing.endswith(")"):
|
||||||
|
raise SymbolError(f"malformed symbol block for {name!r}")
|
||||||
|
body = closing[:-1].rstrip()
|
||||||
|
|
||||||
|
base_block = self.geometry_source(base)
|
||||||
|
units = []
|
||||||
|
for unit_name in _SYMBOL_RE.findall(base_block):
|
||||||
|
if not _UNIT_SUFFIX_RE.search(unit_name):
|
||||||
|
continue
|
||||||
|
suffix = unit_name[len(base) :] if unit_name.startswith(base) else ""
|
||||||
|
unit_block = self._sub_block(base_block, unit_name)
|
||||||
|
units.append(
|
||||||
|
unit_block.replace(
|
||||||
|
f'(symbol "{unit_name}"', f'(symbol "{name}{suffix}"', 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not units:
|
||||||
|
raise SymbolError(f"base symbol {base!r} of {name!r} has no graphics")
|
||||||
|
indented = "\n".join("\t\t" + u.replace("\n", "\n\t\t") for u in units)
|
||||||
|
return f"{body}\n{indented}\n\t)"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sub_block(text: str, name: str) -> str:
|
||||||
|
needle = f'(symbol "{name}"'
|
||||||
|
start = text.find(needle)
|
||||||
|
if start < 0:
|
||||||
|
raise SymbolError(f"sub-symbol {name!r} not found")
|
||||||
|
depth, i, in_str, escaped = 0, start, False, False
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if in_str:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = False
|
||||||
|
elif ch == '"':
|
||||||
|
in_str = True
|
||||||
|
elif ch == "(":
|
||||||
|
depth += 1
|
||||||
|
elif ch == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[start : i + 1]
|
||||||
|
i += 1
|
||||||
|
raise SymbolError(f"unbalanced sub-symbol {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def default_path() -> Path:
|
||||||
|
env = os.environ.get("BOXSIM_KICAD_LIB")
|
||||||
|
if env:
|
||||||
|
p = Path(env)
|
||||||
|
return p if p.suffix == ".kicad_sym" else p / "symbols" / "Visaton.kicad_sym"
|
||||||
|
bundled = Path(__file__).resolve().parent / "data" / "Visaton.kicad_sym"
|
||||||
|
if bundled.exists():
|
||||||
|
return bundled
|
||||||
|
return Path("/home/magdev/workspaces/other/kicad-visaton-lib/symbols/Visaton.kicad_sym")
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: str | os.PathLike[str] | None = None) -> SymbolLibrary:
|
||||||
|
p = Path(path) if path else default_path()
|
||||||
|
if not p.exists():
|
||||||
|
raise SymbolError(
|
||||||
|
f"KiCad symbol library not found at {p}; "
|
||||||
|
"set BOXSIM_KICAD_LIB to the kicad-visaton-lib checkout"
|
||||||
|
)
|
||||||
|
source = p.read_text(encoding="utf-8")
|
||||||
|
m = _LIB_VERSION_RE.search(source[:400])
|
||||||
|
if m is None:
|
||||||
|
raise SymbolError(f"{p} has no (version ...) token; is it a symbol library?")
|
||||||
|
version = int(m.group(1))
|
||||||
|
if version < MIN_LIB_VERSION:
|
||||||
|
raise SymbolError(
|
||||||
|
f"{p} is in the legacy format (version {version}, need "
|
||||||
|
f"{MIN_LIB_VERSION} or newer). Convert it with:\n"
|
||||||
|
f" kicad-cli sym upgrade --force {p}"
|
||||||
|
)
|
||||||
|
names = [n for n in _SYMBOL_RE.findall(source) if not _UNIT_SUFFIX_RE.search(n)]
|
||||||
|
if not names:
|
||||||
|
raise SymbolError(f"no symbols found in {p}")
|
||||||
|
return SymbolLibrary(p, names, source)
|
||||||
|
|
||||||
|
|
||||||
|
def cross_check(catalog: Catalog, library: SymbolLibrary) -> list[str]:
|
||||||
|
"""Every catalogue part must exist in the library. Returns extra symbols."""
|
||||||
|
missing = []
|
||||||
|
matched = set()
|
||||||
|
for part in catalog.parts:
|
||||||
|
sym = library.lookup(part.symbol_series, part.value, part.gauge)
|
||||||
|
if sym is None:
|
||||||
|
missing.append(f"{part.series} {part.raw_value:g}")
|
||||||
|
else:
|
||||||
|
matched.add(sym.name)
|
||||||
|
if missing:
|
||||||
|
raise SymbolError(
|
||||||
|
f"{len(missing)} catalogue part(s) have no library symbol: "
|
||||||
|
+ ", ".join(missing[:10])
|
||||||
|
)
|
||||||
|
return sorted(
|
||||||
|
s.name
|
||||||
|
for s in library.symbols
|
||||||
|
if s.name not in matched and s.name not in (AMP_SYMBOL, DRIVER_SYMBOL)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(typ: PartType, value: float) -> str:
|
||||||
|
scaled = value * NAME_SCALE[typ]
|
||||||
|
text = f"{scaled:g}"
|
||||||
|
return f"{text}{UNIT_SUFFIX[typ]}"
|
||||||
|
|
||||||
|
|
||||||
|
class Resolver:
|
||||||
|
"""Maps Boxsim parts onto library symbols using the Visaton catalogue."""
|
||||||
|
|
||||||
|
def __init__(self, catalog: Catalog, library: SymbolLibrary):
|
||||||
|
self.catalog = catalog
|
||||||
|
self.library = library
|
||||||
|
|
||||||
|
def _series_for(self, part: Part, siblings: list[Part]) -> tuple[str, bool]:
|
||||||
|
"""Catalogue series for a part, inferring one when `_bem` is empty."""
|
||||||
|
if part.label:
|
||||||
|
if part.label in SERIES_TO_SYMBOL:
|
||||||
|
return part.label, False
|
||||||
|
# Tolerate stray whitespace or case differences before giving up.
|
||||||
|
for known in SERIES_TO_SYMBOL:
|
||||||
|
if known.lower() == part.label.lower():
|
||||||
|
return known, False
|
||||||
|
raise SymbolError(
|
||||||
|
f"Part{part.slot}: series {part.label!r} is not in the Visaton "
|
||||||
|
f"catalogue (known: {', '.join(sorted(SERIES_TO_SYMBOL))})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Empty _bem: prefer a sibling of the same type and value.
|
||||||
|
for other in siblings:
|
||||||
|
if (
|
||||||
|
other is not part
|
||||||
|
and other.typ == part.typ
|
||||||
|
and other.label
|
||||||
|
and other.value is not None
|
||||||
|
and part.value is not None
|
||||||
|
and abs(other.value - part.value) <= abs(part.value) * 1e-9
|
||||||
|
):
|
||||||
|
return other.label, True
|
||||||
|
|
||||||
|
fallback = DEFAULT_SERIES.get(part.typ)
|
||||||
|
if fallback is None:
|
||||||
|
raise SymbolError(
|
||||||
|
f"Part{part.slot}: no series given and no sibling or default for "
|
||||||
|
f"{part.typ.name.lower()}"
|
||||||
|
)
|
||||||
|
return fallback, True
|
||||||
|
|
||||||
|
def resolve(self, part: Part, siblings: list[Part] | None = None) -> Resolution:
|
||||||
|
siblings = siblings or []
|
||||||
|
|
||||||
|
if part.typ == PartType.AMP_INPUT:
|
||||||
|
return Resolution(part=part, symbol=AMP_SYMBOL, value_text="Amp")
|
||||||
|
if part.typ == PartType.DRIVER:
|
||||||
|
return Resolution(part=part, symbol=DRIVER_SYMBOL, value_text="")
|
||||||
|
|
||||||
|
if part.value is None:
|
||||||
|
raise SymbolError(f"Part{part.slot}: {part.typ.name.lower()} has no value")
|
||||||
|
|
||||||
|
series, inferred = self._series_for(part, siblings)
|
||||||
|
entry = self.catalog.find(series, part.value)
|
||||||
|
if entry is None:
|
||||||
|
near = self.catalog.nearest(series, part.value)
|
||||||
|
offered = ", ".join(f"{p.raw_value:g}" for p in near) or "none"
|
||||||
|
raise SymbolError(
|
||||||
|
f"Part{part.slot}: {series} "
|
||||||
|
f"{_format_value(part.typ, part.value)} is not stocked by Visaton; "
|
||||||
|
f"nearest available: {offered}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sym = self.library.lookup(entry.symbol_series, entry.value, entry.gauge)
|
||||||
|
if sym is None:
|
||||||
|
raise SymbolError(
|
||||||
|
f"Part{part.slot}: catalogue part {series} {entry.raw_value:g} "
|
||||||
|
f"has no symbol in {self.library.path.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
dcr_note = None
|
||||||
|
if (
|
||||||
|
part.dcr is not None
|
||||||
|
and entry.dcr is not None
|
||||||
|
and abs(part.dcr - entry.dcr) > 1e-9
|
||||||
|
):
|
||||||
|
dcr_note = (
|
||||||
|
f"DCR {part.dcr:g} Ohm differs from catalogue {entry.dcr:g} Ohm "
|
||||||
|
"(edited in Boxsim)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Resolution(
|
||||||
|
part=part,
|
||||||
|
symbol=sym.name,
|
||||||
|
value_text=_format_value(part.typ, part.value),
|
||||||
|
series=series,
|
||||||
|
inferred_series=inferred,
|
||||||
|
dcr_note=dcr_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_all(self, parts: list[Part]) -> list[Resolution]:
|
||||||
|
return [self.resolve(p, parts) for p in parts]
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Verify a generated project against the Boxsim data it came from.
|
||||||
|
|
||||||
|
The gate that matters is not ERC: a schematic can be perfectly well formed and
|
||||||
|
still wired to the wrong cells. Instead the netlist KiCad extracts from the
|
||||||
|
generated sheet is compared against the netlist derived independently from the
|
||||||
|
Boxsim grid. Two derivations that agree is real evidence; ERC alone is not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .model import Crossover
|
||||||
|
from .schematic import Placement
|
||||||
|
|
||||||
|
MIN_KICAD_MAJOR = 10
|
||||||
|
|
||||||
|
_NET_RE = re.compile(r"\(net\b(.*?)(?=\n\t\t\(net\b|\n\t\)\s*$)", re.S)
|
||||||
|
_NAME_RE = re.compile(r'\(name\s+"([^"]*)"\)')
|
||||||
|
_NODE_RE = re.compile(r'\(node\s*\(ref\s+"([^"]+)"\)\s*\(pin\s+"([^"]+)"\)', re.S)
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GateResult:
|
||||||
|
erc_ok: bool
|
||||||
|
erc_violations: int
|
||||||
|
netlist_matches: bool
|
||||||
|
kicad_version: str
|
||||||
|
reference_nets: list[frozenset[str]] = field(default_factory=list)
|
||||||
|
kicad_nets: list[frozenset[str]] = field(default_factory=list)
|
||||||
|
messages: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.erc_ok and self.netlist_matches
|
||||||
|
|
||||||
|
|
||||||
|
def kicad_cli() -> str:
|
||||||
|
exe = shutil.which("kicad-cli")
|
||||||
|
if exe is None:
|
||||||
|
raise VerifyError(
|
||||||
|
"kicad-cli not found. KiCad 10 is a hard dependency of the "
|
||||||
|
"verification gate; install it or run `boxsim build` without --verify."
|
||||||
|
)
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
def kicad_version() -> str:
|
||||||
|
out = subprocess.run(
|
||||||
|
[kicad_cli(), "version"], capture_output=True, text=True, check=True
|
||||||
|
).stdout.strip()
|
||||||
|
major = out.split(".")[0]
|
||||||
|
if not major.isdigit() or int(major) != MIN_KICAD_MAJOR:
|
||||||
|
raise VerifyError(
|
||||||
|
f"kicad-cli reports version {out!r}, but this tool targets "
|
||||||
|
f"KiCad {MIN_KICAD_MAJOR}.x schematics"
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reference_partition(
|
||||||
|
crossover: Crossover, placements: list[Placement]
|
||||||
|
) -> list[frozenset[str]]:
|
||||||
|
"""Group `REFDES.PIN` terminals by the net they sit on in the Boxsim grid."""
|
||||||
|
netlist = crossover.build_netlist()
|
||||||
|
groups: dict[int, set[str]] = {}
|
||||||
|
for p in placements:
|
||||||
|
for pin, node in p.pin_nodes.items():
|
||||||
|
net = netlist.net_of(node)
|
||||||
|
groups.setdefault(net, set()).add(f"{p.refdes}.{pin}")
|
||||||
|
return sorted((frozenset(v) for v in groups.values()), key=_net_key)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_netlist(text: str) -> list[frozenset[str]]:
|
||||||
|
"""Read the `(nets ...)` block of a KiCad sexpr netlist into a partition."""
|
||||||
|
start = text.find("\t(nets")
|
||||||
|
if start < 0:
|
||||||
|
raise VerifyError("exported netlist has no (nets ...) section")
|
||||||
|
body = text[start:]
|
||||||
|
out = []
|
||||||
|
for chunk in body.split("\t\t(net")[1:]:
|
||||||
|
terminals = {f"{ref}.{pin}" for ref, pin in _NODE_RE.findall(chunk)}
|
||||||
|
if terminals:
|
||||||
|
out.append(frozenset(terminals))
|
||||||
|
if not out:
|
||||||
|
raise VerifyError("exported netlist contained no nets")
|
||||||
|
return sorted(out, key=_net_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _net_key(net: frozenset[str]) -> tuple:
|
||||||
|
return (len(net), tuple(sorted(net)))
|
||||||
|
|
||||||
|
|
||||||
|
def run_gate(
|
||||||
|
schematic: Path,
|
||||||
|
crossover: Crossover,
|
||||||
|
placements: list[Placement],
|
||||||
|
*,
|
||||||
|
workdir: Path | None = None,
|
||||||
|
) -> GateResult:
|
||||||
|
version = kicad_version()
|
||||||
|
workdir = workdir or schematic.parent
|
||||||
|
erc_report = workdir / f"{schematic.stem}-erc.rpt"
|
||||||
|
netlist_path = workdir / f"{schematic.stem}.net"
|
||||||
|
|
||||||
|
erc = subprocess.run(
|
||||||
|
[
|
||||||
|
kicad_cli(), "sch", "erc",
|
||||||
|
"--severity-error", "--exit-code-violations",
|
||||||
|
"-o", str(erc_report), str(schematic),
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
violations = 0
|
||||||
|
if erc_report.exists():
|
||||||
|
m = re.search(r"Errors (\d+)", erc_report.read_text(encoding="utf-8"))
|
||||||
|
if m:
|
||||||
|
violations = int(m.group(1))
|
||||||
|
erc_ok = erc.returncode == 0 and violations == 0
|
||||||
|
|
||||||
|
export = subprocess.run(
|
||||||
|
[
|
||||||
|
kicad_cli(), "sch", "export", "netlist",
|
||||||
|
"--format", "kicadsexpr", "-o", str(netlist_path), str(schematic),
|
||||||
|
],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if export.returncode != 0:
|
||||||
|
raise VerifyError(f"netlist export failed: {export.stderr.strip()}")
|
||||||
|
|
||||||
|
reference = reference_partition(crossover, placements)
|
||||||
|
extracted = parse_netlist(netlist_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
if not erc_ok:
|
||||||
|
messages.append(f"ERC reported {violations} error(s); see {erc_report.name}")
|
||||||
|
|
||||||
|
matches = reference == extracted
|
||||||
|
if not matches:
|
||||||
|
only_ref = [sorted(n) for n in reference if n not in extracted]
|
||||||
|
only_kicad = [sorted(n) for n in extracted if n not in reference]
|
||||||
|
messages.append(
|
||||||
|
f"netlist mismatch: {len(reference)} reference nets vs "
|
||||||
|
f"{len(extracted)} extracted"
|
||||||
|
)
|
||||||
|
for net in only_ref[:8]:
|
||||||
|
messages.append(f" only in Boxsim reference: {net}")
|
||||||
|
for net in only_kicad[:8]:
|
||||||
|
messages.append(f" only in KiCad netlist: {net}")
|
||||||
|
|
||||||
|
return GateResult(
|
||||||
|
erc_ok=erc_ok,
|
||||||
|
erc_violations=violations,
|
||||||
|
netlist_matches=matches,
|
||||||
|
kicad_version=version,
|
||||||
|
reference_nets=reference,
|
||||||
|
kicad_nets=extracted,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "boxsim"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Read Boxsim loudspeaker projects and generate KiCad projects from their crossovers"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "Marco Grätsch", email = "magdev3.0@gmail.com" }]
|
||||||
|
keywords = ["boxsim", "visaton", "kicad", "loudspeaker", "crossover"]
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Development only: the reference decoder emulates Boxsim.exe to differentially
|
||||||
|
# test the pure Python codec. Not needed to use the tool.
|
||||||
|
oracle = ["unicorn>=2.0", "pefile>=2023.2.7"]
|
||||||
|
dev = ["pytest>=8", "ruff>=0.6"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
boxsim = "boxsim.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["boxsim"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
boxsim = ["data/*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 96
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
|
||||||
|
ignore = ["E501"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DIST = ROOT / "boxsim_dl" / "BoxSim 2.1 DE (Stand 12.2025)"
|
||||||
|
DEFAULT_BPJ = DIST / "Projekte" / "Default.BPJ"
|
||||||
|
MANIFEST = ROOT / "tests" / "oracle_manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
config.addinivalue_line("markers", "needs_kicad: requires kicad-cli 10 on PATH")
|
||||||
|
config.addinivalue_line("markers", "needs_dist: requires the Boxsim distribution")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def dist() -> Path:
|
||||||
|
if not DIST.exists():
|
||||||
|
pytest.skip(f"Boxsim distribution not present at {DIST}")
|
||||||
|
return DIST
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def default_bpj(dist: Path) -> Path:
|
||||||
|
if not DEFAULT_BPJ.exists():
|
||||||
|
pytest.skip("Default.BPJ not present")
|
||||||
|
return DEFAULT_BPJ
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def manifest() -> dict:
|
||||||
|
if not MANIFEST.exists():
|
||||||
|
pytest.skip("oracle manifest missing; run tools/difftest.py")
|
||||||
|
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def kicad_cli() -> str:
|
||||||
|
exe = shutil.which("kicad-cli")
|
||||||
|
if exe is None:
|
||||||
|
pytest.skip("kicad-cli not on PATH")
|
||||||
|
return exe
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def library():
|
||||||
|
from boxsim import symbols
|
||||||
|
|
||||||
|
try:
|
||||||
|
return symbols.load()
|
||||||
|
except symbols.SymbolError as exc:
|
||||||
|
pytest.skip(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def catalog():
|
||||||
|
from boxsim import catalog as catalog_mod
|
||||||
|
|
||||||
|
try:
|
||||||
|
return catalog_mod.load()
|
||||||
|
except catalog_mod.CatalogError as exc:
|
||||||
|
pytest.skip(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def default_project(default_bpj: Path):
|
||||||
|
from boxsim import model
|
||||||
|
|
||||||
|
return model.load(default_bpj.read_bytes())
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
{
|
||||||
|
"files": {
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL130-8.BCH": {
|
||||||
|
"len": 166589,
|
||||||
|
"sha256": "52563a9ec686c3fbb63ddcab63d21c18b3e42c1260816823c3be54e9296c7761"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL130M-8.BCH": {
|
||||||
|
"len": 166958,
|
||||||
|
"sha256": "6336ea4920427b8e90ef4d578f0d7c17a96eefd352f14aeead9e376dae6c6d03"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL170-8.BCH": {
|
||||||
|
"len": 167237,
|
||||||
|
"sha256": "85e527b16b6d4e5b9022899ffb68a377996d056b9759c1241560db2fafa7ade5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/AL200-8.BCH": {
|
||||||
|
"len": 166398,
|
||||||
|
"sha256": "0f3df9168ef1bcdf50fc1c04dbdf2d71882c9ae712576e2c11531dc025fee46a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B100-6.BCH": {
|
||||||
|
"len": 167545,
|
||||||
|
"sha256": "acb0479980734856d8daef68f2d334af3b1d8b271503a65a143df0ef4002d653"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B200-6.BCH": {
|
||||||
|
"len": 164948,
|
||||||
|
"sha256": "c9f66a0615ec5bbde6d04b0896729c1f127188501ebac074722a048240d71459"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/B80-8.BCH": {
|
||||||
|
"len": 167758,
|
||||||
|
"sha256": "046d00c3288d515148a05a08312d3432d69c803a068b3f83cadfa2fe2577716b"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32-4.BCH": {
|
||||||
|
"len": 168059,
|
||||||
|
"sha256": "b93583230b24073f4322cc32603cd5587690941f53673cc5cef34b8dfc667975"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32-8.BCH": {
|
||||||
|
"len": 167706,
|
||||||
|
"sha256": "b0bb6c9101c297f4939b7147ecaef2c3326b852414626617e2543b07b295f3a9"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF32SWP-8.BCH": {
|
||||||
|
"len": 168621,
|
||||||
|
"sha256": "24a8a65637b5668723496126d4dea7edfe6b6562df54f4410e643f3233a7de07"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF37-4.BCH": {
|
||||||
|
"len": 167856,
|
||||||
|
"sha256": "9b25d410d84252a18cf3c088d2a2586b94c57029ed41f104640407bb5a4b61eb"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF37-8.BCH": {
|
||||||
|
"len": 167492,
|
||||||
|
"sha256": "3635f5db87a7a893d071959badebb42443a1b5ae881bee905527d79962ec1d1c"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF45-4.BCH": {
|
||||||
|
"len": 168393,
|
||||||
|
"sha256": "589c6a365b2899b019c4c856eed621b2adf51951c39e9d24454421be923910cc"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BF45-8.BCH": {
|
||||||
|
"len": 167753,
|
||||||
|
"sha256": "fb1387ec0c26d0975b4ee2bd2948240c0fca6830d595435b034c87ecd5619297"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG13P-8.BCH": {
|
||||||
|
"len": 167761,
|
||||||
|
"sha256": "2d7cc9dce5da0039c1cdb9808817dd6eb8a519832d8a0a32f99cbc0d82a2ab45"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG17-8.BCH": {
|
||||||
|
"len": 167705,
|
||||||
|
"sha256": "63e521cddb82620b4b9866f2417c20126c810576ae61f875b7b8dd12f0d35d5c"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/BG20-8.BCH": {
|
||||||
|
"len": 158456,
|
||||||
|
"sha256": "0c616486aaa06dd4d728cab520b545ee00ab10774b041a8ad26bcecb6091e9fd"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DMR120-8.BCH": {
|
||||||
|
"len": 167930,
|
||||||
|
"sha256": "e9e0c6fce89307a392899516243993038e3429f3b48eb38ede3f87ebefb0630f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM25FFL-8.BCH": {
|
||||||
|
"len": 169090,
|
||||||
|
"sha256": "7a29bb64a89f5c987b7991d1f6697a29bf81f997d027d2c85d0d3fe61fca6c3b"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM25FFL-8_WG.BCH": {
|
||||||
|
"len": 169024,
|
||||||
|
"sha256": "ff3769dc79fc4786beb1ee6769cb0ce11a3cff3defab1b9e053c5bc070beeb5e"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DSM50FFL-8.BCH": {
|
||||||
|
"len": 167782,
|
||||||
|
"sha256": "8a6e5a6fb43635e5f8f83002c931a1d6d4a9604b82b0736db94631c013661f63"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DT94-4.BCH": {
|
||||||
|
"len": 160517,
|
||||||
|
"sha256": "6667fef358fee4126876a61ab4f07e495f0470c675e2d6794b5dd89e9291b02d"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DT94-8.BCH": {
|
||||||
|
"len": 160866,
|
||||||
|
"sha256": "49125ad11ac0c6c8dde04c19fb3af43cc7c0eb98fb2e6717016b99839ee1a0ad"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DTW72-8.BCH": {
|
||||||
|
"len": 168472,
|
||||||
|
"sha256": "eaefc3e044514529b0d672df913a60e613e8882c4c2f1f5fe2e55b226dafe5eb"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DTW95FFL-8.BCH": {
|
||||||
|
"len": 168777,
|
||||||
|
"sha256": "4ed1a4afbf547551b0907d2c85afc4e9cce57c450f6f615803c1a6d5214035ac"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DX10-4.BCH": {
|
||||||
|
"len": 167756,
|
||||||
|
"sha256": "ecc54f732bf04757f58f1a2fa2875e0f034138eb9c67e2274ad236226f1e61d0"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/DX13-4.BCH": {
|
||||||
|
"len": 168631,
|
||||||
|
"sha256": "d5c9ca84e49f26e021bfb7166163ceb23ec392f3be7b96a625c9469f3f839253"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10-4.BCH": {
|
||||||
|
"len": 167556,
|
||||||
|
"sha256": "88630ad18068860767b5c7a3d92d806c10bebc879f6a963b8a571a5651ae2206"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10-8.BCH": {
|
||||||
|
"len": 167133,
|
||||||
|
"sha256": "bfd49bdc06e1149dae80c7e0f70273ceb4755fcc82690dc487496858cf54c411"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HM-4.BCH": {
|
||||||
|
"len": 168758,
|
||||||
|
"sha256": "143dc6f5e8615a77cb88dba96a21fdddb9a3eb9f31d5064681111a127d2d504c"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HM-8.BCH": {
|
||||||
|
"len": 167612,
|
||||||
|
"sha256": "a805602adc1fedc1650dbea4380016e155e773b752ab7b88921e7eb957aae1aa"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR10HMP-4.BCH": {
|
||||||
|
"len": 169371,
|
||||||
|
"sha256": "a29084a06ba41dbe58261751922e81e9f988f77d56223488b8e67d188d8d1117"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR12-4.BCH": {
|
||||||
|
"len": 169156,
|
||||||
|
"sha256": "3aa62ed143eef3e2e50f743945bc69dfb92473f991a88e294117d87b88a8c2b0"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR12-8.BCH": {
|
||||||
|
"len": 168523,
|
||||||
|
"sha256": "e3766981e2a15f50bc108204e5a0065a070986ac48b20092d14bcc794d1cb62a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR58-4.BCH": {
|
||||||
|
"len": 168008,
|
||||||
|
"sha256": "9c9e7902e25f798da0cff00f0906c0fc9ddb8b459afa3fc53496de4c35078d76"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR58-8.BCH": {
|
||||||
|
"len": 167844,
|
||||||
|
"sha256": "37c87bec9e31d05a738533d8ab877446d26aafb0a0d567325075fe9595cd70df"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8JS-8.BCH": {
|
||||||
|
"len": 167871,
|
||||||
|
"sha256": "f76ffb278e5a76732ee3d255b870e66a1cb806ebbb1330736df8718fe5b4b30c"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8WP-4.BCH": {
|
||||||
|
"len": 168277,
|
||||||
|
"sha256": "e14621eb94f90aa72eea47a8c084f1a96839ec600e99c585fd1a1f519f3db97f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR8WP-8.BCH": {
|
||||||
|
"len": 168292,
|
||||||
|
"sha256": "c9ba868f0ec01edf84d6d5488733b02f2f50853491d58068b75d7814e33980a1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR9.15-4_horizontal.BCH": {
|
||||||
|
"len": 169606,
|
||||||
|
"sha256": "914cc40a0f02b1efa87a9e63f68690067b5512fdb71b1e69884a122f218fbdec"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FR9.15-4_vertikal.BCH": {
|
||||||
|
"len": 170049,
|
||||||
|
"sha256": "a88a684ce8a7f10aed0f72153c7f960bf06330331795eed68594b9915b55c00f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS5X-8.BCH": {
|
||||||
|
"len": 166753,
|
||||||
|
"sha256": "a523d440bab1a96de5a96cee96c52f7c81bcf2c5b0a49de1ff7f8a8d97a08a5a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7-4.BCH": {
|
||||||
|
"len": 167982,
|
||||||
|
"sha256": "2e3c1c49436cf6b9f4a00c966ec6ef25301614cff448c5540ba72275f7b1e81d"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7-8.BCH": {
|
||||||
|
"len": 168154,
|
||||||
|
"sha256": "3d119358c324194760264e0c26d6aec8c15d70fdb2b256456576c9d81bc22d49"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7S-8.BCH": {
|
||||||
|
"len": 167984,
|
||||||
|
"sha256": "0c387f6e5147f04add4aebdafa97da2e754f3da07782f0aad0179c15603797f4"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7W-8.BCH": {
|
||||||
|
"len": 168125,
|
||||||
|
"sha256": "520ab27b5bb120dde5a7a44f59e6d449996cb2c534b41332271847f781364785"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS7XWP-8.BCH": {
|
||||||
|
"len": 159786,
|
||||||
|
"sha256": "38b4433cf3cb7e09fd971ab8b56d8f067fc2997a973829bd57a72aa1f9000b06"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8-4.BCH": {
|
||||||
|
"len": 167487,
|
||||||
|
"sha256": "5dd7e15bda43e727d8bcb34dbdbbdebdfd797b487af9b62d40440416537744fe"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8-8.BCH": {
|
||||||
|
"len": 167191,
|
||||||
|
"sha256": "798183c313ef04fa9d0e2403c4131b0ba41fe2b704a99c24257c9ad9be1b27dc"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRS8M-8.BCH": {
|
||||||
|
"len": 167527,
|
||||||
|
"sha256": "0382528f2a44afdfe60fcbff1367b585a96a90bd9cab999e1e40dfd45051f3c5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS4ND-8.BCH": {
|
||||||
|
"len": 168207,
|
||||||
|
"sha256": "63d9db35ef1e74ca0f080ef6c1a0b28d395aa971420bcb175c736e8b412849dc"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5-4.BCH": {
|
||||||
|
"len": 167564,
|
||||||
|
"sha256": "6c74e49fe8e9d6bf4f6fc225fafd1a7fcd429a6d35a9b6fdf4f37bd7fb585dfb"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5-8.BCH": {
|
||||||
|
"len": 167743,
|
||||||
|
"sha256": "f8ef5c31c3283c3653f3edd146b5bb649d476beb906b9622283da97167730200"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FRWS5SC-8.BCH": {
|
||||||
|
"len": 167460,
|
||||||
|
"sha256": "b07b81fed5818d2aa11b4364bd14e24b4f1b0caa6b661185a106abaea552a102"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX10-4.BCH": {
|
||||||
|
"len": 167699,
|
||||||
|
"sha256": "4f70649c267f6d824f710c901daff648da072028a354966b9fc95cfe001ee467"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX13-4.BCH": {
|
||||||
|
"len": 168491,
|
||||||
|
"sha256": "85c4ad73a053ff58676ffe31e5450bd9b2281d9910e05eeb71385e43df80d637"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX16-4.BCH": {
|
||||||
|
"len": 168407,
|
||||||
|
"sha256": "df02baa2f1688ceddc5192da8546ddfac2b5746a7b1a459e0f6cc1c176d47886"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/FX16WP-4.BCH": {
|
||||||
|
"len": 168916,
|
||||||
|
"sha256": "f6c8d03f26a5f942dce524ddaf11931ba10e1436c26bf978dd6e04a235cbc3ed"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G20SC-8.BCH": {
|
||||||
|
"len": 168972,
|
||||||
|
"sha256": "549ea20a17763d75052ad872b3e650639c56d49597f2c9f6fca747faeadcbab5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25FFL-8.BCH": {
|
||||||
|
"len": 169098,
|
||||||
|
"sha256": "7004466c21b4a7e438d09e4b8373c20f58250adbf4e68c1998365151ba46fef4"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25FFL-8_WG.BCH": {
|
||||||
|
"len": 169005,
|
||||||
|
"sha256": "85d51691fb41410442c60efc9782c0b80061279f8571fafcf88129591b2715c1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G25NDWG-8.BCH": {
|
||||||
|
"len": 160482,
|
||||||
|
"sha256": "743de0c456c272620a8d5fcfe83e51a735f8ed88efa9681b95d1c4f4dc427abc"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G50FFL-8.BCH": {
|
||||||
|
"len": 168413,
|
||||||
|
"sha256": "951e5a3bcce8bb0453cf0e3a8f58a9c89fa690fb99fc00bb5de949317d2bcdd5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/G50FFL-8_WG.BCH": {
|
||||||
|
"len": 168650,
|
||||||
|
"sha256": "b3010ea9780428570922a513749110bfa83953cc7aa3c2b24abd503645aa66d4"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-2.BCH": {
|
||||||
|
"len": 166568,
|
||||||
|
"sha256": "13aa73962985e626969e37159c9a08f600b41fcad91ab1e6ad52584fe2b8b811"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-4.BCH": {
|
||||||
|
"len": 165632,
|
||||||
|
"sha256": "90a3164b9a602cebdab476370101146d6d058fd39c58bfa958159831cddf105e"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/GF200-8.BCH": {
|
||||||
|
"len": 166762,
|
||||||
|
"sha256": "be26e8616356d2bf9bbb5418383efaf3bf3aa8d6883ee3e0fe0eadd5d1dca03a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HT21_DR45-8_horizontal.BCH": {
|
||||||
|
"len": 169380,
|
||||||
|
"sha256": "57e5bede970aa72e4f524cc66c4d6e7fae8f549cf1487f31b21b60dae7b2d781"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HT21_DR45-8_vertikal.BCH": {
|
||||||
|
"len": 169432,
|
||||||
|
"sha256": "3239c9fad549ed904f8b3df174e6a21325d5b0ace33e27fffcc001107358ca1e"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HTH8.7-8_horizontal.BCH": {
|
||||||
|
"len": 169140,
|
||||||
|
"sha256": "03e2a4e185191161987279d6aefb6d396a11acaef781f3dc4a3b766447b1ef1b"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HTH8.7-8_vertikal.BCH": {
|
||||||
|
"len": 169142,
|
||||||
|
"sha256": "dd51cbef66b6c5a848d1dc777ca4805d32f544ad89c7f53aa5bcbaf59f226fbb"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/HX10-4.BCH": {
|
||||||
|
"len": 167294,
|
||||||
|
"sha256": "997a8f9ba8763e9309d2cb640e21d923a9344d29be263f2be1d1a1eed875f6f1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KE25SC-8.BCH": {
|
||||||
|
"len": 169009,
|
||||||
|
"sha256": "3c0a8a5ef64bf27a102c8dd09c0d87b456c8fdac006a6095d1304f7a1edd7184"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KE25SC-8_WG.BCH": {
|
||||||
|
"len": 169097,
|
||||||
|
"sha256": "81d987a1a2bf51ddf463d8422ce7881dc57b0cd7e0bd20fbd3e4f376abd69696"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/KT100V-4.BCH": {
|
||||||
|
"len": 167368,
|
||||||
|
"sha256": "82c4f9d62fe3114c4280a6cd63343833adbff29f398a9f94485fd0f763dcec03"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/MHT12-8_horizontal.BCH": {
|
||||||
|
"len": 169158,
|
||||||
|
"sha256": "eea5c3d1c7d8c12ee26bf2f0e4e93d7bb9a57c80ded560cf356ba22ad1168605"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/MHT12-8_vertikal.BCH": {
|
||||||
|
"len": 169757,
|
||||||
|
"sha256": "b294b6988b5745efa71547307690c8f1d3e1316c2b1184819fe24d0baf28fb35"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAM13-8.BCH": {
|
||||||
|
"len": 167870,
|
||||||
|
"sha256": "aab4c8c1e559807b96c8a53d04091c0296dc9c5ba8877f56af066dcdcdcabd31"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAW25-8.BCH": {
|
||||||
|
"len": 168066,
|
||||||
|
"sha256": "b594a2ea9297fc74cca7f22705ece652fa37565fa8ae885d493147dfb5be88a6"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/PAW30ND-8.BCH": {
|
||||||
|
"len": 167976,
|
||||||
|
"sha256": "1e0bc20347273e08f6c6c29139aa7921214b175b42f89c6a0b8ab65893b54676"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/R10S-4.BCH": {
|
||||||
|
"len": 170298,
|
||||||
|
"sha256": "121c01119b6aeb20464c09a6f0cdc8e4cdb17562ef1b86d850bdcbe665ad3176"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/R10S-8.BCH": {
|
||||||
|
"len": 168270,
|
||||||
|
"sha256": "fc6019cfb41cf4acee2e79f30503734abc4d0ae023f0ddc31d41f15c53ea3d53"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC10N-8.BCH": {
|
||||||
|
"len": 169400,
|
||||||
|
"sha256": "9f5d425b353efbea1d3fba20f0bc18ec889b3c8164a9a7d15e6a7574a55cada8"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC13-8.BCH": {
|
||||||
|
"len": 167377,
|
||||||
|
"sha256": "691a2b026ba63f9374e852cac955a91229cc6817912a4b1c366c1f6492b7b329"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC4.6FL-8_horizontal.BCH": {
|
||||||
|
"len": 166596,
|
||||||
|
"sha256": "a80c5f54e0076c90b4caac9260b5aee77c8ee339d807963685d09c735b4eae6a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC4.6FL-8_vertikal.BCH": {
|
||||||
|
"len": 165391,
|
||||||
|
"sha256": "5e307e4738b21a52de10540d5fde9a1cbd22b272cb1a41751df82489eab5285a"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC5-8.BCH": {
|
||||||
|
"len": 168689,
|
||||||
|
"sha256": "de0d2a411130436e596ff2c1ad97bfa679ba92e7e2b166cb71329ac5768b93e4"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SC8N-8.BCH": {
|
||||||
|
"len": 167245,
|
||||||
|
"sha256": "43fc053d9f583e2282660eec169f066ff4f92c904b46b5aba330d50d0cfe7d55"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL70NDV-4.BCH": {
|
||||||
|
"len": 166825,
|
||||||
|
"sha256": "5277f8c2712d4a5fe57b0b64dfdfe73a616932b57e58d1aed8ef77bade362555"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL713-4_horizontal.BCH": {
|
||||||
|
"len": 168817,
|
||||||
|
"sha256": "cfc643055f62e0a836e0f67720610663d32f6e3aba3c2c3bd0f14b86e3a3e6f8"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL713-4_vertikal.BCH": {
|
||||||
|
"len": 168657,
|
||||||
|
"sha256": "26cc25623bbeb0b022785a190dfde7bcbdd0785a5a41f363a4599d79cf31ef10"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL83-4.BCH": {
|
||||||
|
"len": 168108,
|
||||||
|
"sha256": "a35ff412b172c7ae23489ded7e12f2146c33bb3126d9a90a6ecf6c81c65f1ff1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87FE-8.BCH": {
|
||||||
|
"len": 167737,
|
||||||
|
"sha256": "115d35c6aa8fc19d52a146dec2810e524a1580c8b31f46f27ad8ab339ea6eee6"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87FS-8.BCH": {
|
||||||
|
"len": 159847,
|
||||||
|
"sha256": "ce73e4b9e8f98efe44d85bf9c67a63cb65546c88b1ee4e4a5ccf5af9758208ac"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/SL87ND-8.BCH": {
|
||||||
|
"len": 166764,
|
||||||
|
"sha256": "1a2fa67bcd7cdddbfdeb30fd4b4f36d49ea11b44b52d195833cc9a9b9c490d1c"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TI100-8.BCH": {
|
||||||
|
"len": 167102,
|
||||||
|
"sha256": "c9ef7266472e0c0a75de8fa05173ff8e6004f0e83f044557243b867af70d7558"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW200XS-8.BCH": {
|
||||||
|
"len": 167275,
|
||||||
|
"sha256": "a57a4bfc5aa1185b758ba182da2417f98030d1f16ac658a84073e1894e270ff5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW250XS-8.BCH": {
|
||||||
|
"len": 167470,
|
||||||
|
"sha256": "e0acbe4e8a520e4cc3339bdbf8b9ee071e4580ecd575d03043f80bee7fe7fe24"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TIW300-8.BCH": {
|
||||||
|
"len": 167601,
|
||||||
|
"sha256": "1f04190ed3fa3010d45f6f00366bdd55ff94e357ab8a031bd264503186412440"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/TW70-8.BCH": {
|
||||||
|
"len": 170337,
|
||||||
|
"sha256": "19083805ea97002c817871111cba605bc6b841bffba4373af2dc225b8ac2330e"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100S-4.BCH": {
|
||||||
|
"len": 166693,
|
||||||
|
"sha256": "e267b8524a33c50eb485d46c0f2606908c1c2181172841d0b7902792722054b2"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100S-8.BCH": {
|
||||||
|
"len": 166742,
|
||||||
|
"sha256": "bb509e566b55b46c1030b305fc6eeab22a52cca5323f94d45e9b92eb9b3d7e77"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-2.BCH": {
|
||||||
|
"len": 168225,
|
||||||
|
"sha256": "c4ecd29f912e680977eeb4ff8d786ab4ce1bc3b7cdbf5e68fbd32df5354e0c54"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-4.BCH": {
|
||||||
|
"len": 167513,
|
||||||
|
"sha256": "6b7eee3b7ca9e91e407409a9822a6706c13c375a0a423ba084f955fe0b6d39d0"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W100X-8.BCH": {
|
||||||
|
"len": 168268,
|
||||||
|
"sha256": "3377174cf9e99fa61d30307fa3f54c99e9dd18c10143abae90d4ef316083dbd3"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130S-4.BCH": {
|
||||||
|
"len": 166620,
|
||||||
|
"sha256": "a4c52c72fac5c9ecffb0db7d4aa54ca82ec0d8cb6cd4bf9f3542401b277d2dcf"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130S-8.BCH": {
|
||||||
|
"len": 166750,
|
||||||
|
"sha256": "6ed33ac44bbf9046eddd59ba7b54c3a3785d09244648555ae847683982c6909f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-2.BCH": {
|
||||||
|
"len": 167957,
|
||||||
|
"sha256": "0683f476f4eea32bc73351045ee3e221053ad197297cdd3bc4a2843b128267c1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-4.BCH": {
|
||||||
|
"len": 167030,
|
||||||
|
"sha256": "53ae4cffdf3ed2bf4cb19fadd22ba8dd8762c1063dbf7818a109add348506cf3"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W130X-8.BCH": {
|
||||||
|
"len": 167958,
|
||||||
|
"sha256": "2127c7eed9b6aa116ea2b5e3ddea0830e7816a6f61b66acd89b3912958fb594d"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W150X-4.BCH": {
|
||||||
|
"len": 169571,
|
||||||
|
"sha256": "8bc70cb3b380ab4d9bafefa6fbe72ebfb4c64b872dec02fc776f45194306e8d2"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W165A-2.BCH": {
|
||||||
|
"len": 167502,
|
||||||
|
"sha256": "660ad2655067e765d00ad735ae5a53a2b4b8eeda3934b9484361e04653c30053"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170-8.BCH": {
|
||||||
|
"len": 166591,
|
||||||
|
"sha256": "ff5564f0a05f880267ca2d528f8d9534bb90629139d27d56a9125f207a07acaf"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170S-4.BCH": {
|
||||||
|
"len": 166045,
|
||||||
|
"sha256": "83ae8d8d5c2f063b358ea4d2b7f60cc24c18af6d9a004d42266d2f1a2ac15846"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W170S-8.BCH": {
|
||||||
|
"len": 166277,
|
||||||
|
"sha256": "63606eca63bf6cff1df12b72bb01add86b20ec1d958370173f00916dfe671d47"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200-4.BCH": {
|
||||||
|
"len": 167312,
|
||||||
|
"sha256": "2d2f991f058c141442a3bade8e849c63571ce5d965d4e4b5e1cd18dd6a57c0d5"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200-8.BCH": {
|
||||||
|
"len": 166917,
|
||||||
|
"sha256": "02fbe70c17a3d142c36253fe75647f2514c84a85ef8b943029b0549163b02cc1"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200S-4.BCH": {
|
||||||
|
"len": 167865,
|
||||||
|
"sha256": "1d18f05ec042c96834da5d8c8da5d778d1a90577ff3aeb5c5ec1ebf98f2367a7"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W200S-8.BCH": {
|
||||||
|
"len": 167988,
|
||||||
|
"sha256": "acc138abeb612ba105eb1918caceec5db809dd14d1b63461f3b7b3c84bc0c555"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250-8.BCH": {
|
||||||
|
"len": 167424,
|
||||||
|
"sha256": "b91cdde6d85ded8133469e292e29ea3149375623ce2b8a89b5ce85d8f444c59f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250S-4.BCH": {
|
||||||
|
"len": 167964,
|
||||||
|
"sha256": "f5f1a678216f26c872668de068902e979bc3fc45ef14060760f74600f6976d4d"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W250S-8.BCH": {
|
||||||
|
"len": 167668,
|
||||||
|
"sha256": "0ff699238b80b2a129ca44f0c97925b0b553250a1cb0d234c298358fb097df31"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/W300-8.BCH": {
|
||||||
|
"len": 167900,
|
||||||
|
"sha256": "d0e9afe82f3124fa5e11325d6a7a8c4fb2970c0d64e7677a70412668e39cd791"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WF130ND-8.BCH": {
|
||||||
|
"len": 166121,
|
||||||
|
"sha256": "95bf3e21b4af281133f63c6ddbd305ddcdbc022bcedc495a974caa388c2bc7e6"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS13E-8.BCH": {
|
||||||
|
"len": 167806,
|
||||||
|
"sha256": "afe345e6be400e9780b5ceb9c3a90c4f4f095ea61196e10a45ce2b2072344c57"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS17E-4.BCH": {
|
||||||
|
"len": 167482,
|
||||||
|
"sha256": "55817cab04efd7582edcda75c17bdb1288e94ce8b591359393b19df84a4048f2"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS17E-8.BCH": {
|
||||||
|
"len": 167230,
|
||||||
|
"sha256": "a5a5f0ffd1bf9120fdf2de0033f5d07dc6aaf213fcc0296d81b627093e8873ea"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS20E-4.BCH": {
|
||||||
|
"len": 167030,
|
||||||
|
"sha256": "775e9162ce2d763b72ea53037541b89fdce980583836baa9e023937567595137"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS20E-8.BCH": {
|
||||||
|
"len": 167098,
|
||||||
|
"sha256": "658cc7a40aeaa3154bbcba033bf41076d14d014673528745d3324c3fe734833f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/aktuelle Daten 12.2025/WS25E-8.BCH": {
|
||||||
|
"len": 166786,
|
||||||
|
"sha256": "e99a0c69b3c590f276f165d2b45a79cb7d9a204045a0904ebe665beb3a568d21"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/DMR120-8.BCH": {
|
||||||
|
"len": 167930,
|
||||||
|
"sha256": "e9e0c6fce89307a392899516243993038e3429f3b48eb38ede3f87ebefb0630f"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/DTW95FFL-8.BCH": {
|
||||||
|
"len": 168777,
|
||||||
|
"sha256": "4ed1a4afbf547551b0907d2c85afc4e9cce57c450f6f615803c1a6d5214035ac"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/nicht mehr erh\u00e4ltlich/WS26SF-8.BCH": {
|
||||||
|
"len": 167601,
|
||||||
|
"sha256": "c8991c5973e0afa0298e3b6e5256fa72d6d56fca202d343c60376117c36872ff"
|
||||||
|
},
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Projekte/Default.BPJ": {
|
||||||
|
"len": 509573,
|
||||||
|
"sha256": "d687252660aeabff3be9203ebdc35cf35087266eda87eb3aa1c23b306983e552"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": "oracle digests from boxsim._oracle, see tools/difftest.py",
|
||||||
|
"rejected_not_myini2ck": [
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL130-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL130M-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL170-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL200-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/AL200DV-2x8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B100-6.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B200-6.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/B80-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF32-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF37-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BF45-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG13-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG13P-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG17-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG20-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BG30NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BGS40-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BZ130P-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/BZ300-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CF25-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 10 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 10 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 12 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CMC 12 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CP13-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/CT25A-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DHT9-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DHT9-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DK121-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DK8P-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL10-20.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL10-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL18-100V.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL18_2-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL5-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL8-20.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DL8-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DMR120-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DMR35-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DR45N+M300-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM25FFL-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM38FFL-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DSM50FFL-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT90-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT90-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT94-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DT94-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 104 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 110 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 110 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 SC - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 76 SC - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 78 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 78 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 SC - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 80 SC - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 87.130 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTC 87.130 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW110NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW72-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW72-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW8.12-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW8.12NG-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW95NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DTW97-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX10-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX13-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX16-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX4x6P-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/DX6x9-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/F8SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/F8SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10F-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10HM-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10HM-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR10WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR12-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR12-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR13WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR16WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR58-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR58-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR6.5-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR7-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR87-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8JS-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR8WP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FR9.15-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS10WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS10WP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5X-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS5XWP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS7W-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRS8M-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FRWS5SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/FX10-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G20SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25FFL-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25FFL-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25HE-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G25ND-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G50FFL-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/G50FFL-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-2.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-2_ 2Spulen_parallel.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4_1Spule_Kurzschluss.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-4_1Spule_offen.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF200-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-2.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/GF250-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/H11-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 11.19 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 11.19 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 A - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTC 87 A - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HTH8.7-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/HX10-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50WP-50.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K50WP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K57C-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K64WP-50.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/K64WP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/KE25SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/KT100V-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 13 HE - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 13 HE - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 16 HE - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 16 HE - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 X - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/LMC 20 X - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/M10-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/M10-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MHT12-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MR130-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/MR130-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW25-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW30ND-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW38-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/PAW46-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/R10SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC10N-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC13-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4.7ND-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4.7ND-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC4ND-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9ND-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC5.9ND-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC8-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SC8N-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL713-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87ND-8.bch",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87WP-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/SL87WP-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T130-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T200-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T250-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/T300-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TI100-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TI100M-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW200XS-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW250-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW250XS-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW300-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-2.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360DS-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW360X-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-2.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TIW400DS-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL10B50-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL14CH.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL15D61-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL16H-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL4Falnico-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TL8C50F-6.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TW6NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/TW70-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W100SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-2.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W130X-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W170SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200-4.bch",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200-8.bch",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200NG-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200SC-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W200SC-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250NG-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W250S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300NG-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300NG-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W300S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W400S-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/W400S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WF130ND-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 25 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 25 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 X - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WHC 30 X - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WPC 30 - 4 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WPC 30 - 8 Ohm.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13BF-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13E-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS13E-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17BF-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17E-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS17E-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS20E-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS20E-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS25E-4.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WS25E-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WSP21S-8.BCH",
|
||||||
|
"boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Chassis/_alte Simulationsdaten BoxSim 1.2/WSP26S-8.BCH"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""End to end: generate a project, then check KiCad agrees with Boxsim.
|
||||||
|
|
||||||
|
The netlist comparison is the real gate, so it gets its own teeth test: a
|
||||||
|
deliberately mis-wired schematic must be reported as a mismatch. A gate that
|
||||||
|
has never been shown to fail is decoration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from boxsim import project as project_mod
|
||||||
|
from boxsim import verify
|
||||||
|
from boxsim.schematic import DEFAULT_PITCH_MM, SchematicBuilder, transform
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_transform_matches_kicad():
|
||||||
|
# Derived empirically from KiCad's own demo schematics.
|
||||||
|
assert transform(0, 3.81, 2.54) == (3.81, -2.54)
|
||||||
|
assert transform(90, 3.81, 2.54) == (-2.54, -3.81)
|
||||||
|
assert transform(180, 3.81, 2.54) == (-3.81, 2.54)
|
||||||
|
assert transform(270, 3.81, 2.54) == (2.54, 3.81)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pitch_must_clear_widest_symbol(library):
|
||||||
|
with pytest.raises(ValueError, match="widest symbol"):
|
||||||
|
SchematicBuilder(library, pitch=20.0)
|
||||||
|
SchematicBuilder(library, pitch=DEFAULT_PITCH_MM) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def built(tmp_path_factory, default_project, catalog, library):
|
||||||
|
outdir = tmp_path_factory.mktemp("kicad")
|
||||||
|
return project_mod.build(default_project, "Default", outdir, catalog, library)
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_files_written(built):
|
||||||
|
assert built.schematic.exists()
|
||||||
|
assert built.project_file.exists()
|
||||||
|
assert (built.directory / "sym-lib-table").exists()
|
||||||
|
assert (built.directory / "fp-lib-table").exists()
|
||||||
|
text = built.schematic.read_text(encoding="utf-8")
|
||||||
|
assert text.startswith("(kicad_sch")
|
||||||
|
assert "(version 20260101)" in text
|
||||||
|
assert "extends" not in text, "derived symbols must be flattened"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_components_placed(built, default_project):
|
||||||
|
assert len(built.sheet.placements) == len(default_project.crossovers[0].components)
|
||||||
|
refdes = [p.refdes for p in built.sheet.placements]
|
||||||
|
assert len(set(refdes)) == len(refdes), "reference designators must be unique"
|
||||||
|
assert sum(1 for r in refdes if r.startswith("LS")) == 3
|
||||||
|
assert sum(1 for r in refdes if r.startswith("J")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_is_deterministic(tmp_path, default_project, catalog, library):
|
||||||
|
a = project_mod.build(default_project, "Default", tmp_path / "a", catalog, library)
|
||||||
|
b = project_mod.build(default_project, "Default", tmp_path / "b", catalog, library)
|
||||||
|
assert a.schematic.read_text() == b.schematic.read_text(), (
|
||||||
|
"UUIDs must be derived, not random, so regeneration is reproducible"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.needs_kicad
|
||||||
|
def test_gate_passes(built, default_project, kicad_cli):
|
||||||
|
gate = verify.run_gate(
|
||||||
|
built.schematic, default_project.crossovers[0], built.sheet.placements
|
||||||
|
)
|
||||||
|
assert gate.erc_ok, gate.messages
|
||||||
|
assert gate.netlist_matches, gate.messages
|
||||||
|
assert len(gate.reference_nets) == 14
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.needs_kicad
|
||||||
|
def test_gate_has_teeth(tmp_path, default_project, catalog, library, kicad_cli):
|
||||||
|
"""Swap one component's terminals and confirm the gate reports it.
|
||||||
|
|
||||||
|
The reference netlist is derived from the Boxsim grid while the compared one
|
||||||
|
is extracted from the sheet by KiCad, so mis-wiring a single part must break
|
||||||
|
the comparison. If this ever passes, the gate proves nothing.
|
||||||
|
"""
|
||||||
|
result = project_mod.build(
|
||||||
|
default_project, "Broken", tmp_path / "broken", catalog, library
|
||||||
|
)
|
||||||
|
crossover = default_project.crossovers[0]
|
||||||
|
|
||||||
|
# Pick a component whose two terminals are on different nets, then lie about
|
||||||
|
# which grid node one of its pins reaches.
|
||||||
|
netlist = crossover.build_netlist()
|
||||||
|
victim = next(
|
||||||
|
p
|
||||||
|
for p in result.sheet.placements
|
||||||
|
if len({netlist.net_of(n) for n in p.pin_nodes.values()}) == 2
|
||||||
|
)
|
||||||
|
pins = sorted(victim.pin_nodes)
|
||||||
|
victim.pin_nodes[pins[0]], victim.pin_nodes[pins[1]] = (
|
||||||
|
victim.pin_nodes[pins[1]],
|
||||||
|
victim.pin_nodes[pins[0]],
|
||||||
|
)
|
||||||
|
|
||||||
|
gate = verify.run_gate(result.schematic, crossover, result.sheet.placements)
|
||||||
|
assert not gate.netlist_matches, (
|
||||||
|
"a swapped terminal was not detected: the netlist comparison is not "
|
||||||
|
"actually comparing anything"
|
||||||
|
)
|
||||||
|
assert not gate.ok
|
||||||
|
assert any("mismatch" in m for m in gate.messages)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Codec tests. The heavy differential sweep against Boxsim.exe lives in
|
||||||
|
tools/difftest.py; these guard regressions against the digests it recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from boxsim import a2f, container
|
||||||
|
from boxsim.krypt import KryptPRNG
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_keystream_is_stable():
|
||||||
|
"""Regression guard on the keystream every Boxsim file is scrambled with.
|
||||||
|
|
||||||
|
The value is not independently derived, it is the keystream under which all
|
||||||
|
134 containers in the distribution decode byte-identically to Boxsim's own
|
||||||
|
decoder (see tools/difftest.py). Its worth here is catching an accidental
|
||||||
|
change to the generator, not proving the generator correct.
|
||||||
|
"""
|
||||||
|
ks = KryptPRNG().keystream(8)
|
||||||
|
assert ks.hex() == "ebe14331cb3183e1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_foreign_file():
|
||||||
|
with pytest.raises(container.ContainerError, match="not a MyIni2CK file"):
|
||||||
|
container.decode(b"not a boxsim file at all")
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncated_stream_fails_loudly():
|
||||||
|
raw = (ROOT / "boxsim_dl" / "BoxSim 2.1 DE (Stand 12.2025)"
|
||||||
|
/ "Projekte" / "Default.BPJ")
|
||||||
|
if not raw.exists():
|
||||||
|
pytest.skip("distribution not present")
|
||||||
|
data = raw.read_bytes()
|
||||||
|
with pytest.raises(a2f.A2FError):
|
||||||
|
container.decode(data[: len(data) // 2])
|
||||||
|
|
||||||
|
|
||||||
|
def test_padding_allowance_is_bounded():
|
||||||
|
# A stream of zeros must not decode forever; it has to hit the limit.
|
||||||
|
with pytest.raises(a2f.A2FError):
|
||||||
|
a2f.expand(b"MyIni2CK", skip_magic=8)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.needs_dist
|
||||||
|
def test_matches_oracle_digests(manifest: dict):
|
||||||
|
"""Every container the oracle decoded must still decode identically."""
|
||||||
|
files = manifest["files"]
|
||||||
|
assert files, "manifest has no entries"
|
||||||
|
checked = 0
|
||||||
|
for rel, expected in files.items():
|
||||||
|
path = ROOT / rel
|
||||||
|
if not path.exists():
|
||||||
|
pytest.fail(f"manifest references missing file {rel}")
|
||||||
|
payload = container.decode(path.read_bytes())
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
assert digest == expected["sha256"], f"{rel} digest changed"
|
||||||
|
assert len(payload) == expected["len"], f"{rel} length changed"
|
||||||
|
checked += 1
|
||||||
|
assert checked == len(files)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.needs_dist
|
||||||
|
def test_pre20_containers_are_rejected(manifest: dict):
|
||||||
|
"""The BoxSim 1.2 files use an older container and must stay rejected."""
|
||||||
|
rejected = manifest["rejected_not_myini2ck"]
|
||||||
|
assert rejected, "manifest recorded no rejected files"
|
||||||
|
for rel in rejected:
|
||||||
|
path = ROOT / rel
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
with pytest.raises(container.ContainerError):
|
||||||
|
container.decode(path.read_bytes())
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from boxsim import model
|
||||||
|
from boxsim.model import COLS, LINK_BASE, ROWS, TOTAL_SLOTS, Orientation, PartType
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_partition_is_exact():
|
||||||
|
"""The three slot ranges must tile 1..375 with no gap or overlap."""
|
||||||
|
seen = {}
|
||||||
|
for slot in range(1, TOTAL_SLOTS + 1):
|
||||||
|
orientation, row, col = model.slot_geometry(slot)
|
||||||
|
if orientation == Orientation.LINK:
|
||||||
|
continue
|
||||||
|
key = (orientation, row, col)
|
||||||
|
assert key not in seen, f"slot {slot} collides with {seen[key]}"
|
||||||
|
seen[key] = slot
|
||||||
|
horizontal = sum(1 for k in seen if k[0] == Orientation.HORIZONTAL)
|
||||||
|
vertical = sum(1 for k in seen if k[0] == Orientation.VERTICAL)
|
||||||
|
assert horizontal == (COLS - 1) * ROWS == 180
|
||||||
|
assert vertical == COLS * (ROWS - 1) == 187
|
||||||
|
assert LINK_BASE == 368 and TOTAL_SLOTS == 375
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_geometry_bounds():
|
||||||
|
with pytest.raises(model.ModelError):
|
||||||
|
model.slot_geometry(0)
|
||||||
|
with pytest.raises(model.ModelError):
|
||||||
|
model.slot_geometry(TOTAL_SLOTS + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_endpoints_are_adjacent_nodes():
|
||||||
|
orientation, row, col = model.slot_geometry(1)
|
||||||
|
assert (orientation, row, col) == (Orientation.HORIZONTAL, 0, 0)
|
||||||
|
orientation, row, col = model.slot_geometry(181)
|
||||||
|
assert (orientation, row, col) == (Orientation.VERTICAL, 0, 0)
|
||||||
|
# The last vertical slot must sit on the bottom-but-one row.
|
||||||
|
orientation, row, col = model.slot_geometry(367)
|
||||||
|
assert (orientation, row, col) == (Orientation.VERTICAL, ROWS - 2, COLS - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_project_shape(default_project):
|
||||||
|
p = default_project
|
||||||
|
assert p.version == 2000
|
||||||
|
assert p.amp_count == 1
|
||||||
|
assert p.chassis_count == 3
|
||||||
|
assert [c.name for c in p.chassis] == [
|
||||||
|
"GF 200 - 8 Ohm",
|
||||||
|
"B 100 - 6 Ohm",
|
||||||
|
"G 20 SC - 8 Ohm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_netlist_is_closed(default_project):
|
||||||
|
"""The published Default project must have no floating terminal."""
|
||||||
|
xo = default_project.crossovers[0]
|
||||||
|
netlist = xo.build_netlist()
|
||||||
|
assert len(xo.components) == 22
|
||||||
|
assert netlist.net_count == 14
|
||||||
|
assert netlist.dangling() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_drivers_and_amp_present(default_project):
|
||||||
|
xo = default_project.crossovers[0]
|
||||||
|
drivers = [p for p in xo.components if p.typ == PartType.DRIVER]
|
||||||
|
amps = [p for p in xo.components if p.typ == PartType.AMP_INPUT]
|
||||||
|
assert len(amps) == 1
|
||||||
|
assert sorted(d.chassis_index for d in drivers) == [0, 1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounding_box(default_project):
|
||||||
|
assert default_project.crossovers[0].bounding_box() == (0, 0, 8, 9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossover_corner_frequencies(default_project):
|
||||||
|
"""Sanity-check the reconstruction against loudspeaker physics.
|
||||||
|
|
||||||
|
A wrong slot-to-node mapping would still produce a netlist, but not one
|
||||||
|
whose series/shunt pairs land on sensible crossover points.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
xo = default_project.crossovers[0]
|
||||||
|
by_slot = {p.slot: p for p in xo.components}
|
||||||
|
# Woofer low-pass: 4.7 mH series with the 15 uF shunt.
|
||||||
|
fc = 1 / (2 * math.pi * math.sqrt(by_slot[5].value * by_slot[198].value))
|
||||||
|
assert 550 < fc < 650
|
||||||
|
# Tweeter high-pass: two series caps into the 0.22 mH shunt.
|
||||||
|
cser = 1 / (1 / by_slot[65].value + 1 / by_slot[67].value)
|
||||||
|
fc = 1 / (2 * math.pi * math.sqrt(by_slot[252].value * cser))
|
||||||
|
assert 5000 < fc < 6500
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from boxsim import symbols
|
||||||
|
from boxsim.catalog import SERIES_TO_SYMBOL
|
||||||
|
from boxsim.model import Orientation, Part, PartType
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogue_shape(catalog):
|
||||||
|
"""Guard the figures the resolver design relies on."""
|
||||||
|
assert len(catalog) == 95
|
||||||
|
assert len(catalog.series_names) == 11
|
||||||
|
assert set(catalog.series_names) == set(SERIES_TO_SYMBOL)
|
||||||
|
# The last catalogue line is a real part; the file has no trailing newline
|
||||||
|
# and is easy to truncate by one.
|
||||||
|
assert catalog.find("SP 1,3mm", 2.2e-3) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_catalogue_part_has_a_symbol(catalog, library):
|
||||||
|
extras = symbols.cross_check(catalog, library)
|
||||||
|
assert extras == ["MKT 22μF"], (
|
||||||
|
"library should contain exactly the catalogue plus Amplifier, Speaker "
|
||||||
|
"and the radial MKT 22uF that Boxsim cannot select"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_values_are_matched_numerically(library):
|
||||||
|
"""Irregular library names must still resolve: 1.0uF next to 10uF."""
|
||||||
|
assert library.lookup("MKP", 1e-6) is not None
|
||||||
|
assert library.lookup("MKP", 10e-6) is not None
|
||||||
|
assert library.lookup("SP-Spule", 1e-3, "0.6").name == "SP-Spule 1.0mH (0.6)"
|
||||||
|
assert library.lookup("LR-Spule", 10e-3).name == "LR-Spule 10mH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_symbols_resolve_geometry(library):
|
||||||
|
"""Most symbols use `extends`; pins live on the base symbol."""
|
||||||
|
from boxsim.schematic import symbol_pins
|
||||||
|
|
||||||
|
pins = symbol_pins(library, "SP-Spule 0.82mH (0.6)")
|
||||||
|
assert len(pins.by_number) == 2
|
||||||
|
assert library.base_of("SP-Spule 0.82mH (0.6)") == "FC-Spule 10mH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_flattened_definition_has_no_extends(library):
|
||||||
|
body = library.definition("SP-Spule 0.82mH (0.6)")
|
||||||
|
assert "extends" not in body
|
||||||
|
assert '(symbol "SP-Spule 0.82mH (0.6)_' in body, "graphics must be inlined"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_project_fully_resolves(catalog, library, default_project):
|
||||||
|
resolver = symbols.Resolver(catalog, library)
|
||||||
|
components = default_project.crossovers[0].components
|
||||||
|
results = resolver.resolve_all(components)
|
||||||
|
assert len(results) == len(components) == 22
|
||||||
|
assert all(r.symbol in library for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_series_is_inferred_from_sibling(catalog, library, default_project):
|
||||||
|
resolver = symbols.Resolver(catalog, library)
|
||||||
|
components = default_project.crossovers[0].components
|
||||||
|
results = {r.part.slot: r for r in resolver.resolve_all(components)}
|
||||||
|
part231 = results[231]
|
||||||
|
assert part231.inferred_series is True
|
||||||
|
assert part231.symbol == "MKT-A 15μF", "sibling Part198 is an MKT-A 15uF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dcr_deviation_is_reported_not_corrected(catalog, library, default_project):
|
||||||
|
resolver = symbols.Resolver(catalog, library)
|
||||||
|
results = {
|
||||||
|
r.part.slot: r
|
||||||
|
for r in resolver.resolve_all(default_project.crossovers[0].components)
|
||||||
|
}
|
||||||
|
assert results[5].dcr_note is not None, "0.28 vs catalogue 0.33"
|
||||||
|
assert results[48].dcr_note is not None, "5 vs catalogue 4.8"
|
||||||
|
assert results[5].part.dcr == 0.28, "the project value must win"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unstocked_value_is_a_hard_error(catalog, library):
|
||||||
|
resolver = symbols.Resolver(catalog, library)
|
||||||
|
part = Part(
|
||||||
|
slot=1,
|
||||||
|
typ=PartType.CAPACITOR,
|
||||||
|
orientation=Orientation.HORIZONTAL,
|
||||||
|
row=0,
|
||||||
|
col=0,
|
||||||
|
node_a=0,
|
||||||
|
node_b=1,
|
||||||
|
value=13e-6, # not a Visaton value
|
||||||
|
label="MKT-A",
|
||||||
|
)
|
||||||
|
with pytest.raises(symbols.SymbolError, match="not stocked"):
|
||||||
|
resolver.resolve(part)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_series_is_a_hard_error(catalog, library):
|
||||||
|
resolver = symbols.Resolver(catalog, library)
|
||||||
|
part = Part(
|
||||||
|
slot=2,
|
||||||
|
typ=PartType.CAPACITOR,
|
||||||
|
orientation=Orientation.HORIZONTAL,
|
||||||
|
row=0,
|
||||||
|
col=0,
|
||||||
|
node_a=0,
|
||||||
|
node_b=1,
|
||||||
|
value=10e-6,
|
||||||
|
label="Mundorf Supreme",
|
||||||
|
)
|
||||||
|
with pytest.raises(symbols.SymbolError, match="not in the Visaton catalogue"):
|
||||||
|
resolver.resolve(part)
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogue_shape_check_has_teeth(catalog):
|
||||||
|
"""A truncated catalogue must be rejected, not silently accepted.
|
||||||
|
|
||||||
|
`cross_check` only proves the catalogue is a subset of the library, so a
|
||||||
|
short catalogue passes it and then rejects valid projects at runtime with
|
||||||
|
"not stocked by Visaton". This is the check that catches it.
|
||||||
|
"""
|
||||||
|
from boxsim import catalog as catalog_mod
|
||||||
|
|
||||||
|
catalog_mod.check_shape(catalog) # the real thing must pass
|
||||||
|
|
||||||
|
short = catalog_mod.Catalog(catalog.parts[:40])
|
||||||
|
with pytest.raises(catalog_mod.CatalogError, match="expected"):
|
||||||
|
catalog_mod.check_shape(short)
|
||||||
|
|
||||||
|
# Losing a whole series must also be caught, even at the right total.
|
||||||
|
without_sp13 = catalog_mod.Catalog(
|
||||||
|
[p for p in catalog.parts if p.series != "SP 1,3mm"]
|
||||||
|
)
|
||||||
|
with pytest.raises(catalog_mod.CatalogError):
|
||||||
|
catalog_mod.check_shape(without_sp13)
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the boxsim image, staging the Visaton library and parts catalogue.
|
||||||
|
#
|
||||||
|
# Both live outside this repository, so they are copied into a staging directory
|
||||||
|
# inside the build context first. Nothing proprietary is baked in: the Visaton
|
||||||
|
# library is our own, and the catalogue is a plain text parts list.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
LIB_DIR="${BOXSIM_KICAD_LIB_DIR:-/home/magdev/workspaces/other/kicad-visaton-lib}"
|
||||||
|
CATALOG="${BOXSIM_CATALOG:-$ROOT/boxsim_dl/BoxSim 2.1 DE (Stand 12.2025)/Weichenbauteile/Visatonteile.txt}"
|
||||||
|
IMAGE="${IMAGE:-boxsim:0.1.0}"
|
||||||
|
|
||||||
|
SYM="$LIB_DIR/symbols/Visaton.kicad_sym"
|
||||||
|
FP="$LIB_DIR/footprints/Visaton.pretty"
|
||||||
|
|
||||||
|
# A missing input must stop the build, never silently produce an image without
|
||||||
|
# its library.
|
||||||
|
for path in "$SYM" "$FP" "$CATALOG"; do
|
||||||
|
if [[ ! -e "$path" ]]; then
|
||||||
|
echo "build-image: required input missing: $path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# The library must already be in the current KiCad format, otherwise the symbols
|
||||||
|
# cannot be embedded into a KiCad 10 schematic.
|
||||||
|
version="$(sed -n 's/.*(version \([0-9]\+\)).*/\1/p' "$SYM" | head -1)"
|
||||||
|
if [[ -z "$version" || "$version" -lt 20211014 ]]; then
|
||||||
|
echo "build-image: $SYM is in the legacy format (version ${version:-none})." >&2
|
||||||
|
echo " Run: kicad-cli sym upgrade --force '$SYM'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf stage
|
||||||
|
mkdir -p stage
|
||||||
|
cp "$SYM" stage/Visaton.kicad_sym
|
||||||
|
cp "$CATALOG" stage/Visatonteile.txt
|
||||||
|
cp -r "$FP" stage/footprints
|
||||||
|
|
||||||
|
echo "staged: symbol library (version $version), catalogue, $(ls stage/footprints | wc -l) footprints"
|
||||||
|
|
||||||
|
docker build -t "$IMAGE" .
|
||||||
|
rm -rf stage
|
||||||
|
|
||||||
|
echo "built $IMAGE"
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Differentially test boxsim.a2f against the Boxsim.exe oracle.
|
||||||
|
|
||||||
|
Every container in the Boxsim distribution is decoded twice, once by the pure
|
||||||
|
Python codec and once by emulating Boxsim's own machine code, and the results
|
||||||
|
must be byte identical. Writes the oracle digests to a manifest so the fast
|
||||||
|
test suite can guard regressions without needing Unicorn or Boxsim.exe.
|
||||||
|
|
||||||
|
Needs the dev venv (unicorn, pefile):
|
||||||
|
|
||||||
|
.venv/bin/python tools/difftest.py [--jobs N] [--limit N]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
MANIFEST = ROOT / "tests" / "oracle_manifest.json"
|
||||||
|
|
||||||
|
|
||||||
|
def find_containers(dist: Path) -> list[Path]:
|
||||||
|
files = sorted(
|
||||||
|
p
|
||||||
|
for p in dist.rglob("*")
|
||||||
|
if p.is_file() and p.suffix.upper() in (".BPJ", ".BCH")
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(f"no .bpj/.bch files found under {dist}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def check_one(path_str: str) -> dict:
|
||||||
|
"""Decode with both implementations and compare. Returns a result record."""
|
||||||
|
from boxsim import _oracle
|
||||||
|
from boxsim.container import decode
|
||||||
|
|
||||||
|
path = Path(path_str)
|
||||||
|
raw = path.read_bytes()
|
||||||
|
rec = {"file": str(path.relative_to(ROOT)), "size": len(raw)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mine = decode(raw)
|
||||||
|
rec["sha_python"] = hashlib.sha256(mine).hexdigest()
|
||||||
|
rec["len_python"] = len(mine)
|
||||||
|
except Exception as exc: # noqa: BLE001 - report, do not mask
|
||||||
|
rec["error_python"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
theirs = _oracle.decode_bpj(raw)
|
||||||
|
rec["sha_oracle"] = hashlib.sha256(theirs).hexdigest()
|
||||||
|
rec["len_oracle"] = len(theirs)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
rec["error_oracle"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
both_failed = "error_python" in rec and "error_oracle" in rec
|
||||||
|
if both_failed and "not a MyIni2CK file" in rec["error_python"]:
|
||||||
|
# Pre-2.0 Boxsim used a different container with no MyIni2CK magic.
|
||||||
|
# Both implementations refusing it is agreement, not a pass: count it
|
||||||
|
# separately so it can never masquerade as coverage.
|
||||||
|
rec["verdict"] = "rejected"
|
||||||
|
elif both_failed:
|
||||||
|
rec["verdict"] = "both-errored"
|
||||||
|
elif "error_python" in rec or "error_oracle" in rec:
|
||||||
|
rec["verdict"] = "one-errored"
|
||||||
|
elif rec["sha_python"] == rec["sha_oracle"]:
|
||||||
|
rec["verdict"] = "identical"
|
||||||
|
else:
|
||||||
|
rec["verdict"] = "mismatch"
|
||||||
|
rec["ok"] = rec["verdict"] in ("identical", "rejected")
|
||||||
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument(
|
||||||
|
"--dist",
|
||||||
|
default=str(ROOT / "boxsim_dl"),
|
||||||
|
help="Boxsim distribution directory to sweep",
|
||||||
|
)
|
||||||
|
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 2) - 1))
|
||||||
|
ap.add_argument("--limit", type=int, default=0, help="only the first N files")
|
||||||
|
ap.add_argument(
|
||||||
|
"--self-test",
|
||||||
|
action="store_true",
|
||||||
|
help="corrupt one expectation to prove the comparison has teeth",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
files = find_containers(Path(args.dist))
|
||||||
|
if args.limit:
|
||||||
|
files = files[: args.limit]
|
||||||
|
print(f"{len(files)} containers, {args.jobs} workers", flush=True)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
results = []
|
||||||
|
with ProcessPoolExecutor(max_workers=args.jobs) as pool:
|
||||||
|
for i, rec in enumerate(pool.map(check_one, [str(f) for f in files]), 1):
|
||||||
|
results.append(rec)
|
||||||
|
print(f" [{i}/{len(files)}] {rec['verdict']:10s} {rec['file']}", flush=True)
|
||||||
|
|
||||||
|
if args.self_test:
|
||||||
|
# Feed the comparison a deliberately wrong expectation. If this does not
|
||||||
|
# get reported as a mismatch, the gate is decorative.
|
||||||
|
print("\nself-test: corrupting one expectation", flush=True)
|
||||||
|
decoded = [r for r in results if r["verdict"] == "identical"]
|
||||||
|
if not decoded:
|
||||||
|
print("SELF-TEST INCONCLUSIVE: no decoded file to corrupt")
|
||||||
|
return 2
|
||||||
|
poisoned = dict(decoded[0])
|
||||||
|
poisoned["sha_oracle"] = "0" * 64
|
||||||
|
if poisoned["sha_python"] == poisoned["sha_oracle"]:
|
||||||
|
print("SELF-TEST FAILED: corrupt expectation still compared equal")
|
||||||
|
return 2
|
||||||
|
print(
|
||||||
|
f"self-test passed: a corrupt digest for {poisoned['file']} "
|
||||||
|
"is reported as a mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
failures = [r for r in results if not r["ok"]]
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for r in results:
|
||||||
|
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
|
||||||
|
print(f"\nverdicts after {elapsed:.0f}s: " + ", ".join(
|
||||||
|
f"{v}={n}" for v, n in sorted(counts.items())))
|
||||||
|
print(
|
||||||
|
f"{counts.get('identical', 0)} byte-identical, "
|
||||||
|
f"{counts.get('rejected', 0)} rejected by both as pre-2.0 containers"
|
||||||
|
)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("\nfailures:")
|
||||||
|
for r in failures:
|
||||||
|
print(f" {r['file']}")
|
||||||
|
for key in ("error_python", "error_oracle"):
|
||||||
|
if key in r:
|
||||||
|
print(f" {key}: {r[key]}")
|
||||||
|
if "sha_python" in r and "sha_oracle" in r:
|
||||||
|
print(f" python {r['sha_python'][:16]} len {r.get('len_python')}")
|
||||||
|
print(f" oracle {r['sha_oracle'][:16]} len {r.get('len_oracle')}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
decoded = {
|
||||||
|
r["file"]: {"sha256": r["sha_oracle"], "len": r["len_oracle"]}
|
||||||
|
for r in results
|
||||||
|
if r["verdict"] == "identical"
|
||||||
|
}
|
||||||
|
rejected = sorted(r["file"] for r in results if r["verdict"] == "rejected")
|
||||||
|
MANIFEST.parent.mkdir(exist_ok=True)
|
||||||
|
MANIFEST.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"note": "oracle digests from boxsim._oracle, see tools/difftest.py",
|
||||||
|
"files": decoded,
|
||||||
|
"rejected_not_myini2ck": rejected,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"wrote {MANIFEST.relative_to(ROOT)}: "
|
||||||
|
f"{len(decoded)} digests, {len(rejected)} rejected files recorded"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Post-install check for the bundled catalogue and symbol library.
|
||||||
|
|
||||||
|
Run during the image build so an image can never ship with an incomplete
|
||||||
|
library or a truncated catalogue. `cross_check` alone is not enough: it proves
|
||||||
|
only that the catalogue is a subset of the library, so a short catalogue passes
|
||||||
|
it and then rejects perfectly valid projects at runtime with "not stocked by
|
||||||
|
Visaton". `check_shape` is what gives this gate teeth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from boxsim import catalog as catalog_mod
|
||||||
|
from boxsim import symbols as symbols_mod
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
cat = catalog_mod.load()
|
||||||
|
catalog_mod.check_shape(cat)
|
||||||
|
lib = symbols_mod.load()
|
||||||
|
extras = symbols_mod.cross_check(cat, lib)
|
||||||
|
print(
|
||||||
|
f"bundled {len(cat)} catalogue parts across "
|
||||||
|
f"{len(cat.series_names)} series, {len(lib)} symbols; "
|
||||||
|
f"library-only symbols: {extras or 'none'}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user