Writing FRD#
This library writes the format as well as reading it, in all four encodings, and converts between them. This document is about how that is checked, because a writer is unusually easy to check badly.
The trap#
Write a file, read it back with your own reader, find the mesh you started with. It feels like a test. It is satisfied exactly as well by a matched pair of defects as by a correct pair: a writer that puts the node id in the wrong columns and a reader that looks for it there agree with each other perfectly, and the round trip is green.
So the round trip is not the gate here. The gate is CalculiX’s bytes.
The gate#
tools/sweep_rewrite.py reads a document and emits it again, and requires the
output to be the input byte for byte. Every field width, every wrapping rule
and every way of spelling a number has to be right for that to hold, and none
of them is being compared against this project’s opinion of the format.
corpus |
files with FRD blocks |
byte-identical |
differing |
|---|---|---|---|
CalculiX regression suite, solved |
834 |
834 |
0 |
GitHub code search, |
277 |
277 |
0 |
total |
1,111 |
1,111 |
0 |
A further 655 files in those corpora contain no node, element or result block
– the .frd extension is shared with loudspeaker measurements and at least
one fractal renderer – and they round-trip by being copied. They are counted
separately, and the sweep exits non-zero if no file with blocks round-trips,
so a run that graded nothing cannot report success.
In tree, tests/test_writer.py runs the same gate over the 24 fixtures under
tests/fixtures/generated/, which are CalculiX output. The hand-written
fixtures are deliberately not in CalculiX’s spelling – they carry 0.0 where
a solver writes 0.00000E+00 – so they get a different claim: the document
may be respelled, but it must not come back describing a different mesh or
different numbers, compared by bit pattern.
What the gate found#
Three things, none of which could have been authored as a fixture.
A second dialect of the format#
Two files in the GitHub corpus turned out to be cgx_2.x/examples/result.frd,
shipped with CalculiX GraphiX itself, and they are not written the way ccx
writes:
-1 1 0.86430E-07-0.16898E+02 0.55880E+02 cgx's example file
-1 1 8.64300E-08-1.68980E-01 5.58800E+01 everything else
Five-column id fields rather than ten, fifteen element ids to a line rather
than ten, and Fortran’s E12.5 edit descriptor – mantissa in [0.1, 1), five
significant digits – where ccx uses C’s %12.5E with six. The block header
states no format code at all, so there is nothing to read the width off.
The writer now measures both from the first record of each block and reproduces what it read. Imposing one dialect on a file written in the other would be reformatting somebody’s data on the way through.
CalculiX writes ASCII through single precision; this writer does not#
frd.c casts every value to float before printing it, so its ASCII output is
the six-digit rounding of a float32. This writer renders the double it
holds. The two agree except at a rounding tie: 6.464285098e-04 prints as
6.46429E-04 from the double and 6.46428E-04 from the float.
Converting the twelve binary fixtures to ASCII and comparing against the ASCII
CalculiX wrote from the same run of the same deck: 800 of 825 record lines
match, and 25 differ by one in the last digit. Every one of the 25 is a tie,
and matching all 825 needs only a static_cast<float> before the snprintf.
That cast is deliberately not there. A value this library writes is the nearest
six-digit decimal to the number it is holding, which is the stronger of the two
guarantees and the only one that stays true when the source was never a float
in the first place – a mesh handed to the builder from NumPy, or a float64
binary block. Reproducing CalculiX’s tie-breaking would mean discarding
precision on every value in order to agree about the 25 that differ – 3% of
the record lines, and 0.8% of the 3,044 values on them.
The consequence is stated where it can be acted on: this is the one respect in
which a converted file is not byte-identical to what CalculiX would have
written, and doc/divergences.md carries it. A file read and written back in
its own format is unaffected – there is no cast on that path, in either
direction, and the byte gate below covers it over 1,111 files.
Both claims are pinned exactly rather than by a band.
test_converted_ascii_is_the_double_rounded_not_the_float requires our text to
be the rounding of the stored double and CalculiX’s to be the rounding of its
float, so a decode reading the wrong bytes cannot satisfy either.
test_the_float_cast_explains_a_few_percent_and_no_more bounds the population
from both sides: nothing differing would mean the cast is never exercised, and
a large fraction differing would mean something other than a tie is at work.
Glued element ids#
Five-column fields holding five-digit ids leave nothing between them:
-21000110002100031000410005100061000710008
That is eight ids. Read as one token it overflows, is dropped, and the element
loses its connectivity in silence. The reader already chunked these
fixed-width; the writer’s own parser did not, and the repository’s
short-format fixture could not have caught it – glued_ids_short.frd has a
malformed element header, so its element block is passed through verbatim and
never reaches the face-parsing path it is named for.
test_glued_face_ids_survive_the_writer builds the case the fixture missed.
What the byte gate cannot see#
It compares a file with itself, so anything that is only wrong when the encoding changes is invisible to it. That is not a hypothetical either.
Binary blocks carry no -3 terminator – CalculiX writes it only in ASCII
mode – so converting binary to ASCII has to invent one. The first version did
not, and every same-format round trip still passed: a binary block with no
terminator still has none afterwards.
What found it was CalculiX. Its regression suite reads an FRD file back in as a submodel boundary condition, so a converted file can be handed to the solver:
*SUBMODEL,TYPE=NODE,INPUT=beampdouble.frd.ref
what CalculiX was given |
solver |
its |
|---|---|---|
its own binary file |
accepted |
the baseline |
our re-emission of it |
accepted |
byte-identical to the baseline |
our binary → ASCII conversion, before the fix |
rejected |
— |
our binary → ASCII conversion, after the fix |
accepted |
identical to CalculiX’s own ASCII file |
The rejection read *ERROR in getglobalresults: there are either no nodes or no elements ... in the master frd-file. CalculiX’s reader ends a block on the
terminator, so without one the whole rest of the file is still the node block.
The last row is the strongest single result here: our conversion of
beampdouble.frd.ref is identical to beamp.frd.ref, the ASCII file CalculiX
wrote of the same model, apart from the four header lines recording each run’s
date and version – and the solver produces the same .dat from either.
Reproducing it needs ccx and the test suite, so it is here rather than in the
suite.
What is not covered by any of it#
Worth stating plainly.
The constructed path has no original to be compared against. A mesh handed in through the builder produces header lines that no CalculiX file can be diffed against, because CalculiX never wrote this document. The records go through the same emitter the byte gate covers; the headers are backed by the reader accepting them, by
WriteTestin the gtest tier, and by the solver experiment above for the rewriting path only.Two of the four format codes have no external witness at all. A census of the block headers across all 1,766 files:
format code
blocks
files
0, short ASCII
0
0
1, long ASCII
2,138
1,070
2, binary
float320
0
3, binary
float641
1
none stated
4
2
Codes 0 and 2 are read and written from the format’s documented meaning and are checked only by this library reading back what it wrote – a closed loop, and the in-tree fixtures do not break it either: they carry codes 1 and 3 and nothing else. An earlier version of this list said the short ASCII format was “barely represented, two files in 1,766”. Those two files are the cgx dialect below, which states no format code, and their element records are 5 columns wide. Record width and format code are different things, and conflating them turned a gap into a thin spot.
Binary is thinner still. CalculiX publishes exactly one binary FRD –
beampdouble.frd.ref– plus the twelve this project generates by addingDOUBLEto a*NODE FILEcard. Format 2, binary float32, appears in neither: it is implemented from the format’s documented meaning and is checked only by this library reading back what it wrote.NaNis covered by two files. CalculiX spells itNaN; glibc’s printf spells itNANand Windows spells itnan, so the spelling is fixed in the emitter rather than left to the C library. Two files in the corpus would notice if that changed.It is agreement about bytes, not about meaning. A field this library and CalculiX both consider decorative would round-trip perfectly while being misunderstood by both.
Mutants#
A gate at 100% on its first run is indistinguishable from one that copies its
input. tools/mutate.py carries ten planted writer defects; each is a mistake
a careful implementation could actually make, and each names the gate that is
supposed to catch it, so being killed by the wrong gate is visible as such.
mutant |
the gate that catches it |
|---|---|
|
byte-for-byte round trip |
|
binary → ASCII vs CalculiX |
|
byte-for-byte round trip |
|
byte-for-byte round trip |
|
byte-for-byte round trip |
|
the glued-ids test |
|
the terminator test |
|
write-then-read |
|
|
|
the stray-line test |
All ten are killed by the gate named against them. The sweep also reports a
count of failing tests per mutant; those counts include
test_mutation_harness.py, which asserts each mutant’s anchor is still in the
source and therefore fails for every applied mutant. It is not evidence
about the writer.
Three of them are worth recording for what they cost.
Restoring a file’s timestamp between mutants made them leak into each
other. The suite refuses to grade a native library older than its sources, so
the harness was changed to put each source’s original mtime back after
restoring its contents — which is also exactly what tells the build system
there is nothing to recompile. The next mutant’s build reused the previous
one’s object file. The symptom was a mutant reported as killed by a test that
does not test it, and inflated counts: writer-truncates-array-names read as
29 tests and is 4. Timestamps are now restored once, at the end of the sweep,
after the final rebuild.
A mutant applied to a comment is a no-op. The first attempt at
writer-value-precision left all 60 files green, which read as a hole in the
gate. The replacement had landed on %12.5E in a comment — the string
appears in the header of document.cpp describing the layout — and a no-op
mutant reports exactly as a surviving one does.
writer-hoists-unparsed-lines survived its first real run, killed only by
the harness’s own anchor check while the gate named as its catcher stayed
green. That was correct: nothing tested it. Byte identity is claimed only for
solver-written files, which contain no unparsed lines, and moving a line the
parser cannot read changes no value the reader returns — so the semantic gate
could not see it either. test_an_unreadable_line_keeps_its_place_in_the_block
was written because the mutant said so, and it covers a result block as well as
a node block, because the two are separate branches of the parser and the first
version of the test only reached one of them.
Using it#
import pyvista_frd
mesh = pyvista_frd.read('result.frd')
pyvista_frd.write('copy.frd', mesh) # ASCII, six digits
pyvista_frd.write('small.frd', mesh, binary=True) # a third of the size, exact
# Convert without going through a mesh: no precision is lost either way that
# the target format can hold.
pyvista_frd.convert('binary.frd', 'ascii.frd', binary=False)
Files this library writes identify it as their writer. They are not labelled as CalculiX output – several checks in this repository, and possibly in yours, use that banner to tell solver output from anything else, and a writer that stamped somebody else’s name on its work would make every one of them a lie.