Case Study 1: Auditing a Legacy Text-Output Pipeline
"The bytes were fine. Nobody could tell us what they meant." — a data manager, on inheriting a decade of simulation output
Executive Summary
A research group runs a working 2D diffusion solver that has, for years, dumped its temperature field to
plain-text .dat files — one file per snapshot, a grid of numbers per file. The science is sound and the
code is trusted, so nobody wants to touch the solver. But the output has become a liability: the archive is
enormous, painfully slow to reload for analysis, and — the real problem — undocumented. A new collaborator
sends her own data as CF-compliant NetCDF and asks the group to match it. This case study does the
audit: we read the legacy writer to see exactly what it records and omits, quantify what the text format
costs, read the collaborator's NetCDF file to understand the target, and write a small Fortran reader that
validates it. We change nothing yet — the port itself is Case Study 2 — but by the end we know precisely
what has to change and why.
Skills applied - Reading legacy text-I/O code and cataloguing its metadata gaps (§25.1) - Quantifying the size and speed cost of text at scale (§25.1) - Reading a NetCDF file's structure from its CDL header (§25.2, §25.4) - Mapping an ad-hoc format onto the NetCDF data model — dimensions, variables, attributes (§25.2) - Reading and validating a real NetCDF file from Fortran (§25.4), with CF metadata (§25.5)
Background
The solver writes a snapshot with a routine much like the write_field you built in
Chapter 7 — assumed-shape array in, one text row
per grid row out. The production version looks like this:
subroutine write_dat(u, filename)
use, intrinsic :: iso_fortran_env, only: dp => real64
real(dp), intent(in) :: u(:,:)
character(len=*), intent(in) :: filename
integer :: unit, i, j, nrow, ncol
nrow = size(u, 1); ncol = size(u, 2)
open(newunit=unit, file=filename, status='replace', action='write')
do i = 1, nrow
write(unit, '(*(es24.16e3))') (u(i, j), j = 1, ncol) ! full precision, so "safe"
end do
close(unit)
end subroutine write_dat
It is careful code: full-precision es24.16e3, so no digits are lost, and an unlimited-repeat format so it
handles any width. And it is still the wrong format for an archive. The audit says why in numbers.
Phase 1 — Read the Writer: What Does It Record?
Before measuring anything, we catalogue what a .dat file actually contains versus what a reader needs.
Walk the code and list every piece of information, present or absent:
| Needed to interpret the data | In the .dat file? |
|---|---|
| The grid values themselves | ✅ yes, full precision |
| Number of rows / columns | ⚠️ implicit — you count lines and fields |
| Which index is $x$ vs $y$ | ❌ no |
Physical grid spacing (dx, dy) |
❌ no |
| Units (Kelvin? Celsius?) | ❌ no |
| Simulated time of the snapshot | ❌ no — it is encoded in the filename, if at all |
| The code/version and run parameters | ❌ no |
| Byte order | n/a (text) — but see the speed cost |
The verdict is immediate: the file records the data and essentially none of the metadata. Everything a
reader needs to interpret it lives in the group's collective memory and the solver's source. That is the
"mute" failure of §25.1 in the wild — and it is why the collaborator, handed a .dat file, cannot use it.
Finding. The format is not lossy (full precision) but it is mute. The engineering fix is not "more decimals"; it is metadata that travels with the data.
Phase 2 — Measure the Cost
Now quantify the two costs that make the archive painful, so the case for change is a number, not an opinion. The production grid is $2048 \times 2048$; the run saves 2,000 snapshots. A short program does the arithmetic (pure Fortran, so the output is exact):
program audit_cost
use, intrinsic :: iso_fortran_env, only: dp => real64, int64
implicit none
integer(int64), parameter :: nx = 2048, ny = 2048, n_snaps = 2000
integer(int64) :: values, bin_bytes, txt_bytes
real(dp) :: bin_gb, txt_gb
values = nx * ny ! per snapshot
bin_bytes = values * 8_int64 * n_snaps ! real64 binary
txt_bytes = values * 25_int64 * n_snaps ! ~25 chars per value (es24.16e3 + sep)
bin_gb = real(bin_bytes, dp) / 1.0e9_dp
txt_gb = real(txt_bytes, dp) / 1.0e9_dp
print '(a, i0)', 'values per snapshot : ', values
print '(a, f0.1)', 'binary archive (GB) : ', bin_gb
print '(a, f0.1)', 'text archive (GB) : ', txt_gb
end program audit_cost
values per snapshot : 4194304
binary archive (GB) : 67.1
text archive (GB) : 209.7
Hand-check the magnitudes: $2048^2 = 4{,}194{,}304$ values; at 8 bytes over 2,000 snapshots that is $\approx 6.71 \times 10^{10}$ bytes $= 67.1$ GB binary, and at ~25 characters, $\approx 2.10 \times 10^{11}$ bytes $= 209.7$ GB of text. The text archive is roughly three times the size — 140 GB of pure overhead — and every byte of it was produced by a decimal conversion the CPU had to perform, and must perform again in reverse every time the data is reloaded.
⚡ The speed cost, illustratively. Reloading 200 GB of text means parsing ~8 billion numbers from decimal. At a Tier-2 illustrative rate of tens of millions of parses per second, that is minutes to tens of minutes of pure conversion per full-archive pass — before any analysis. The same data as compressed NetCDF is a fraction of the size and reads with no per-value conversion. (Order-of-magnitude figures, not a benchmark.)
Phase 3 — Read the Target: The Collaborator's NetCDF
The collaborator's file, reference.nc, is what the group must match. We inspect it with ncdump -h
(header only) to learn the target structure — reading, not writing:
netcdf reference {
dimensions:
x = 2048 ;
y = 2048 ;
variables:
double temperature(y, x) ;
temperature:units = "K" ;
temperature:long_name = "temperature" ;
temperature:_FillValue = -999. ;
double x(x) ;
x:units = "m" ;
x:axis = "X" ;
double y(y) ;
y:units = "m" ;
y:axis = "Y" ;
// global attributes:
:title = "diffusion benchmark" ;
:Conventions = "CF-1.11" ;
:history = "2026-05-02: created by diffuse v2.1" ;
}
Everything the .dat file omitted is here, in the file itself: the axes are named and located in metres
(coordinate variables x(x) and y(y)), the field carries its units and a fill value, and the global
attributes record the title, the convention, and the provenance. This is what "self-describing and
reproducible" looks like as an artifact.
Phase 4 — Map the Ad-hoc Format onto the Model
With source and target in hand, the port becomes a mapping problem. Each piece of the group's world lands somewhere in the NetCDF data model:
| Legacy world | NetCDF model | Source of the value |
|---|---|---|
rows of u(:,:) |
double temperature(y, x) variable |
the array (unchanged) |
size(u,1), size(u,2) |
dimensions x, y |
from the array shape |
grid spacing dx, dy |
coordinate variables x(x), y(y) |
from the solver's config (namelist, Ch. 7) |
| "it's Kelvin" (in someone's head) | temperature:units = "K" |
make the tacit explicit |
| snapshot time (in the filename) | global time attribute or a time coordinate |
from the time loop |
| code + parameters | global source, history |
from the run |
The mapping exposes the one input the solver must start passing through: the grid spacing and units it already knows but currently throws away. Nothing about the physics changes; the port is about preserving metadata the code already has. That is the whole insight of the audit.
Phase 5 — Validate the Target File from Fortran
Finally, confirm the group can read the collaborator's format — a reader is the other half of any format
decision. This program opens reference.nc, discovers its shape from the file, reads the field, checks the
units attribute is what the group expects, and reports a sanity statistic. (Requires NetCDF installed;
not run here.)
program validate_reference
use, intrinsic :: iso_fortran_env, only: dp => real64
use netcdf
implicit none
real(dp), allocatable :: temperature(:,:)
character(len=32) :: units
integer :: ncid, varid, xid, yid, nx, ny
call check( nf90_open('reference.nc', NF90_NOWRITE, ncid) )
call check( nf90_inq_dimid(ncid, 'x', xid) )
call check( nf90_inq_dimid(ncid, 'y', yid) )
call check( nf90_inquire_dimension(ncid, xid, len=nx) )
call check( nf90_inquire_dimension(ncid, yid, len=ny) )
allocate(temperature(nx, ny))
call check( nf90_inq_varid(ncid, 'temperature', varid) )
call check( nf90_get_var(ncid, varid, temperature) )
units = ''
call check( nf90_get_att(ncid, varid, 'units', units) ) ! read the attribute
call check( nf90_close(ncid) )
print '(a, i0, a, i0)', 'grid : ', nx, ' x ', ny
print '(a, a)', 'units : ', trim(units)
print '(a, f0.2, a, f0.2)', 'range : ', minval(temperature), ' to ', maxval(temperature)
if (trim(units) /= 'K') print '(a)', 'WARNING: units are not Kelvin!'
contains
subroutine check(status)
integer, intent(in) :: status
if (status /= nf90_noerr) then
print '(a)', 'NetCDF error: ' // trim(nf90_strerror(status))
error stop 1
end if
end subroutine check
end program validate_reference
The structure of the expected output is fixed even though the numbers depend on the file:
grid : 2048 x 2048
units : K
range : <min> to <max>
The units check is the point: because the file declares its units, the reader can verify them and
warn if a file arrives in Celsius by mistake. That safety is impossible with the .dat files, which never
said what unit they were in — the very failure this audit set out to document.
Sanity check. A correct read reports the same $2048 \times 2048$ shape the header advertised,
units=K, and a physically plausible temperature range. A shape mismatch would mean a corrupt file; a units mismatch would fire the warning — both caught because the file describes itself.
Discussion Questions
- The legacy writer used full-precision
es24.16e3, so it is not lossy. Why is "not lossy" insufficient for an archive? Which of §25.1's four failures does full precision not fix? - The grid spacing and units the port needs are values the solver already computes and then discards. What does that tell you about the cost of the port versus the cost of the science?
- The collaborator's file encodes axes as coordinate variables in metres. What can she do with her data that the group currently cannot do with theirs, purely because of that metadata?
- The reader validates
units == 'K'and warns otherwise. Name two other CF attributes a defensive reader might check before trusting a file, and what each protects against.
Your Turn: Extensions
- Option A (analysis). Extend
validate_referenceto also read and print the globalhistoryandConventionsattributes, and toerror stopifConventionsdoes not start with"CF-". What does requiring a convention buy a downstream pipeline? - Option B (measurement). Reproduce
audit_costfor your target grid and snapshot count, then add a fourth line: the compressed NetCDF size at an assumed 3× deflate ratio. How does the compressed archive compare to the original text archive — one number that would justify the port to a skeptical PI. - Option C (mapping). Write the mapping table of Phase 4 as a checklist for your code's output. Which metadata does your solver already know and discard? That list is your port's scope.
Key Takeaways
- A working format can still be a broken archive. The legacy
.datfiles were correct to the last bit and useless to a stranger, because they were mute: no shape, no units, no provenance. Full precision fixes lossiness; it does nothing for muteness. - Quantify the cost before you argue for change. 210 GB of text versus 67 GB of binary — a measured 3×, plus the reload conversion tax — turns "we should modernize the output" from a preference into a decision.
- The target teaches the port. Reading the collaborator's CDL header showed exactly which metadata was missing and where each piece belongs in the NetCDF model. Most of it — grid spacing, units — the solver already knows and throws away.
- A self-describing file can be validated, not just read. Because
reference.ncdeclares its units, a reader can check them and refuse a wrong file. That verification is the reproducibility payoff, and it is impossible with an ad-hoc text dump. The build — actually writing the CF NetCDF — is Case Study 2.