SIERRA-FLOW · chapter three · COBOL to C#
A COBOL batch job checks eight Sierra Nevada rivers every morning. I read it the way you read a system someone is handing you, wrote down what it really does, and rebuilt it in C# on .NET. Then I ran the two side by side on every night it has ever run, until they printed the same bytes.
Below is SIERRA-FLOW's 132-column report. The left side is what GnuCOBOL printed, on green-bar paper. The right side is what the C# port printed. Drag the seam. Nothing shifts, because the two files are byte-for-byte the same.
Every production night since COBOL version 3 is replayed from the old job's own git history, with both programs' clocks pinned to the second that night ran. Each case compares the report, results.csv, every console message and the return code. The COBOL replay is also checked against what production really printed that night.
| Case | What I broke | RC | Result | Finding |
|---|
| Case | Input | RC | Result | Lines |
|---|
A test that has never failed hasn't proven anything. So I changed one setting: rounding in C# the way C# does by default (half to even, "banker's rounding") instead of the way COBOL does (half away from zero). Then I ran everything again.
The parallel run caught it on two real nights, plus a third of the normals. A person reading the report would almost certainly miss a one-cent difference.
I wrote these down before porting a line. None has caused a wrong number in production. Each is a trap for whoever changes the job next, and each has an edge case above that shows it happening. The C# copies every one of them on purpose. A port that quietly fixes things while it's being proved can never be compared with the original. The fixes come in phase two.
COBOL's NUMVAL reads "Ice" as 0.00 cfs, and an unsigned field turns −12.50 into 12.50. Only the Python fetcher keeps them out.
A 468,000 cfs flood prints as 68000.00. The leading digit is dropped with no error. Sections II and III have room for it.
The table holds 70 days per gage. The newest days are the ones dropped, so a gage with current data is reported stale.
"Latest day" means the last row read. Rows out of date order give a wrong day, mean and trend, without a warning.
The age field is unsigned, so a date 5 days ahead counts as 5 days old. The gage is flagged stale, for the wrong reason.
The size-error value is one digit short of the field. A huge rise prints smaller than a real 50,000% rise would.
12 gages (a 13th is ignored without a word), 70 days, 5 basins (a 6th writes past the end of the table), and 9 gages per basin.
USGS site IDs can be 15 digits long. Two of those could look like the same gage to this job.
If a day is missing, the window reaches back 31 calendar days.
The six days around New Year show 31 years from a 30-year record, because the 7-day window crosses from one water year into the next.
COBOL's SORT doesn't guarantee the order of ties. The keys are unique today, and the SQL schema now makes sure they stay that way.
Any more are dropped silently. Thirty years times a 7-day window is 210, so it fits.
Every C# method names the COBOL paragraph it replaces, so the two programs can be read side by side. Here is the rule at the heart of it: is this river normal for this date?
4100-CLASSIFY. EVALUATE TRUE WHEN N-YEARS(SX, WS-SLOT) < 10 MOVE 'NO NORMAL' TO WS-CLASS MOVE 0 TO WS-CLASS-I WHEN WS-FLOW < N-P10(SX, WS-SLOT) MOVE 'MUCH BELOW NORMAL' TO WS-CLASS MOVE 1 TO WS-CLASS-I WHEN WS-FLOW < N-P25(SX, WS-SLOT) MOVE 'BELOW NORMAL' TO WS-CLASS MOVE 2 TO WS-CLASS-I WHEN WS-FLOW > N-P90(SX, WS-SLOT) MOVE 'MUCH ABOVE NORMAL' TO WS-CLASS MOVE 5 TO WS-CLASS-I WHEN WS-FLOW > N-P75(SX, WS-SLOT) MOVE 'ABOVE NORMAL' TO WS-CLASS MOVE 4 TO WS-CLASS-I WHEN OTHER MOVE 'NORMAL' TO WS-CLASS MOVE 3 TO WS-CLASS-I END-EVALUATE.
// 4100-CLASSIFY: USGS WaterWatch classes static (string Name, int Index) Classify(decimal flow, Normal n) => n.Years < 10 ? ("NO NORMAL", 0) : flow < n.P10 ? ("MUCH BELOW NORMAL", 1) : flow < n.P25 ? ("BELOW NORMAL", 2) : flow > n.P90 ? ("MUCH ABOVE NORMAL", 5) : flow > n.P75 ? ("ABOVE NORMAL", 4) : ("NORMAL", 3); // MOVE truncates. COMPUTE ... ROUNDED rounds half // away from zero. Math.Round's default is half to // even, so the port never uses the default. public static decimal Rounded(decimal v, int dec) => Math.Round(v, dec, MidpointRounding.AwayFromZero);
COBOL ROUNDED: half away from zero. Grand Canyon's 30-day mean on October 4.
C#'s Math.Round by default: half to even. One cent off, on a real night.
Binary floating point can't hold 0.1 exactly. PIC 9(7)V99 is base ten, and so is decimal.
The C# job writes a T-SQL load script. One transaction MERGEs the gages (with geography points), every daily mean, and the night's results into SQL Server. Then 02_agreement.sql works out the 30-day numbers and the trend again from the raw rows and THROWs if they differ from what the batch job wrote. That's three independent implementations that have to reach one answer.
| Date | First published (cfs) | Revised (cfs) | Change |
|---|