brooksgroves.com

SIERRA-FLOW · chapter three · COBOL to C#

THE HANDoff

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.

One report, two programs

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.

◂ GnuCOBOL 3.1.2 · SIERRA-FLOW.cobsierraflow run · .NET 8 ▸

          

          
        
132 columns · scroll sideways on a phone

The parallel run nightly in CI

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.

Edge cases: the latest night, broken on purpose

CaseWhat I brokeRCResultFinding

NORMALS: 30 years into 2,928 daily percentiles

CaseInputRCResultLines

Then I broke the port on purpose

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.


      

What the old code knew

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.

  1. 1

    The validation lives in the fetcher, not the job

    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.

    seen in garbage-values
  2. 2

    Section I tops out at 99,999.99 cfs

    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.

    seen in flood
  3. 3

    Past 70 days, it keeps the oldest

    The table holds 70 days per gage. The newest days are the ones dropped, so a gage with current data is reported stale.

    seen in too-many-days
  4. 4

    File order is trusted

    "Latest day" means the last row read. Rows out of date order give a wrong day, mean and trend, without a warning.

    seen in out-of-order
  5. 5

    Tomorrow is accepted

    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.

    seen in future-date
  6. 6

    The trend overflows to 9,999.9%

    The size-error value is one digit short of the field. A huge rise prints smaller than a real 50,000% rise would.

    seen in huge-rise
  7. 7

    Fixed tables, mostly unchecked

    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.

    seen in thirteen-gages
  8. 8

    Gage IDs are cut to 8 characters

    USGS site IDs can be 15 digits long. Two of those could look like the same gage to this job.

    found by reading
  9. 9

    "Last 30 days" means the last 30 rows

    If a day is missing, the window reaches back 31 calendar days.

    found by reading
  10. 10

    Normals count calendar years

    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.

    seen in normals.csv, 84 rows
  11. 11

    Equal sort keys have no promised order

    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.

    found by reading
  12. 12

    NORMALS keeps at most 400 values a day

    Any more are dropped silently. Thirty years times a 7-day window is 210, so it fits.

    found by reading

Paragraph for paragraph

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?

SIERRA-FLOW.coblines 652–672
       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.
FlowJob.csClassify
// 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);
11.805 → 11.81

COBOL ROUNDED: half away from zero. Grand Canyon's 30-day mean on October 4.

11.805 → 11.80

C#'s Math.Round by default: half to even. One cent off, on a real night.

decimal, never double

Binary floating point can't hold 0.1 exactly. PIC 9(7)V99 is base ten, and so is decimal.

Then the database checks both SQL Server · T-SQL

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.

USGS revised its own numbers, and only the database noticed

First publishedRevised
Show the numbers
DateFirst published (cfs)Revised (cfs)Change