Scientific programming in Modula-9 — first steps

↖ contents

10 — A real dataset, end to end

Everything the eight chapters built, on real data: nine years of hourly CO2 measured 150 m above the forest at the ICOS station Hyltemossa (HTM, southern Sweden), read straight out of the Carbon Portal's zarr service, quality-controlled by the station's own flags, decomposed by ordinary least squares into a linear trend plus annual and semiannual harmonics, and plotted.

The data is the ICOS ObsPack collection (CC BY 4.0, doi:10.18160/JZ2X-GZGU), served at zarr.icos-cp.eu. The sandbox behind this page serves a raw byte mirror of the three HTM arrays — the portal's own objects, undecoded — so the program below runs here without network, and runs unchanged on your machine against the live service:

m9c -o co2fit C10Icos.m9
./co2fit https://zarr.icos-cp.eu/icos-obspack.zarr

Same bytes in, same numbers out — that is what the mirror being the live store's own objects buys.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
MODULE C10Icos ;

(* Chapter 10.  The capstone: nine years of real hourly CO2 from the
   ICOS station Hyltemossa (HTM, southern Sweden), measured 150 m
   above the forest, read straight out of the Carbon Portal's zarr
   service and decomposed with ordinary least squares into a linear
   trend plus annual and semiannual harmonics -- a Fourier fit:

     co2(t) ~ c0 + c1 t + c2 cos 2pi t + c3 sin 2pi t
                       + c4 cos 4pi t + c5 sin 4pi t

   The sandbox serves a raw byte MIRROR of the same store, so this
   program runs unchanged against the live service:

     ./c9 https://zarr.icos-cp.eu/icos-obspack.zarr

   Quality control is the ATC convention the store itself documents:
   keep an hour when its flag is 'U' or 'O'.  The normal equations
   are accumulated in one pass, solved by Cholesky, and the result
   plotted like chapter 9: every QC-passed hour a small black dot,
   monthly means and the fitted curve drawn over them.

   Data: ICOS ObsPack collection, CC BY 4.0,
   doi:10.18160/JZ2X-GZGU.                                          *)

IMPORT Io ;
IMPORT Faults ;
IMPORT Fmt ;
IMPORT Math ;
IMPORT Mat ;
IMPORT Plot ;
IMPORT ZarrStore ;

CONST
  Lo     = 2017.25 ;         (* the fit window: 2017-2025.  The live
                                store grows past it; the window is
                                what makes a live run comparable to
                                the recorded one *)
  Hi     = 2026.0 ;
  Mid    = 2021.5 ;          (* centre time: a well-behaved matrix *)
  Months = 105 ;             (* 1/12-year bins across the window   *)
  FitPts = 420 ;             (* the fitted curve, ~4 points/month  *)
  YearSec = 31556952.0 ;     (* mean Gregorian year: decimal years
                                from POSIX seconds, exact enough
                                that the phase moves under a day *)

PROCEDURE At (VAR arr: PTR ZarrStore.Array ; i: I64) : F64
  RAISES ZarrStore.StoreError =
  (* element i of a 1-D array, checked against the store's shape.

       arr -- VAR because a read can grow the chunk cache.
       i   -- 0-based; out of range raises IndexError, always.      *)
VAR ix : ARRAY 1 OF I64 ;
BEGIN
  ix [0] := i ;
  RETURN ZarrStore.GetF64 (arr, ix)
END At ;

PROCEDURE AtI (VAR arr: PTR ZarrStore.Array ; i: I64) : I64
  RAISES ZarrStore.StoreError, ValueRange =
  (* the integer twin: ValueRange is the store handing back
     something an I64 cannot hold -- declared, because chapter 2. *)
VAR ix : ARRAY 1 OF I64 ;
BEGIN
  ix [0] := i ;
  RETURN ZarrStore.GetI64 (arr, ix)
END AtI ;

PROCEDURE Model (RO c: SLICE OF F64 ; t: F64) : F64
  RAISES ValueRange, Overflow, IndexError =
  (* the fitted curve at one time.

       c -- the six coefficients, in the order the design matrix
            declared them: constant, slope, annual cos/sin,
            semiannual cos/sin.
       t -- years relative to Mid, the fit's own time axis.         *)
BEGIN
  RETURN c [0] + c [1] * t
       + c [2] * Math.Cos (2.0 * Math.Pi * t)
       + c [3] * Math.Sin (2.0 * Math.Pi * t)
       + c [4] * Math.Cos (4.0 * Math.Pi * t)
       + c [5] * Math.Sin (4.0 * Math.Pi * t)
END Model ;

VAR
  pool : POOL ;
  st   : SHARED PTR ZarrStore.Store ;
  ay, ac, aq : PTR ZarrStore.Array ;
  url  : STR ;
  xtx, rhs, low, sol : PTR Mat.Matrix ;
  b    : ARRAY 6 OF F64 ;
  coef : ARRAY 6 OF F64 ;
  mSum : ARRAY 105 OF F64 ;
  mN   : ARRAY 105 OF I64 ;
  mx, my : ARRAY 105 OF F64 ;
  hx, hy : SLICE OF F64 ;
  fx, fy : ARRAY 420 OF F64 ;
  n, i, j, k, valid, mo, nm, flag : I64 ;
  y, v, t, amp1, amp2 : F64 ;
  svg  : STR ;

BEGIN
  IF Io.ArgCount () > 1 THEN
    url := Io.Arg (pool, 1)
  ELSE
    url := 'http://127.0.0.1:18931/icos-obspack.zarr'
  END ;
  st := ZarrStore.Open (url) ;
  ay := ZarrStore.OpenArray (st, 'HTM150/time_co2') ;
  ac := ZarrStore.OpenArray (st, 'HTM150/co2') ;
  aq := ZarrStore.OpenArray (st, 'HTM150/co2_qc_flag') ;
  n := ZarrStore.Extent (ac, 0) ;

  xtx := Mat.New (pool, 6, 6) ;
  rhs := Mat.New (pool, 6, 1) ;
  hx := NEW (pool, F64, n) ;      (* the hours themselves, plotted *)
  hy := NEW (pool, F64, n) ;
  valid := 0 ;
  FOR i := 0 TO n - 1 DO
    v := At (ac, i) ;
    flag := AtI (aq, i) ;               (* 'U'/'O' = usable, ATC *)
    IF v = v AND (flag = ORD ('U') OR flag = ORD ('O')) THEN
      y := 1970.0 + F64 (AtI (ay, i)) / YearSec ;
      IF y >= Lo AND y < Hi THEN
      t := y - Mid ;
      b [0] := 1.0 ;
      b [1] := t ;
      b [2] := Math.Cos (2.0 * Math.Pi * t) ;
      b [3] := Math.Sin (2.0 * Math.Pi * t) ;
      b [4] := Math.Cos (4.0 * Math.Pi * t) ;
      b [5] := Math.Sin (4.0 * Math.Pi * t) ;
      FOR j := 0 TO 5 DO
        FOR k := 0 TO 5 DO
          Mat.Set (xtx, j, k, Mat.Get (xtx, j, k) + b [j] * b [k])
        END ;
        Mat.Set (rhs, j, 0, Mat.Get (rhs, j, 0) + b [j] * v)
      END ;
      hx [valid] := y ;
      hy [valid] := v ;
      (* monthly bin for the plot *)
      mo := I64 (Math.Floor ((y - Lo) * 12.0)) ;
      IF mo >= 0 AND mo < Months THEN
        mSum [mo] := mSum [mo] + v ;
        mN [mo] := mN [mo] + 1
      END ;
      valid := valid + 1
      END
    END
  END ;

  low := Mat.Cholesky (pool, xtx) ;
  sol := Mat.CholSolve (pool, low, rhs) ;
  FOR j := 0 TO 5 DO coef [j] := Mat.Get (sol, j, 0) END ;
  amp1 := Math.Sqrt (coef [2] * coef [2] + coef [3] * coef [3]) ;
  amp2 := Math.Sqrt (coef [4] * coef [4] + coef [5] * coef [5]) ;

  Io.Write ('hours in the window ') ;
  Io.WriteI64 (valid) ;
  Io.WriteLine (' (of ' + Fmt.I64Str (n) + ' in the store)') ;
  Io.WriteLine ('mean 2021.5        ' +
                Fmt.Fixed (coef [0], 2) + ' umol/mol') ;
  Io.WriteLine ('trend              ' +
                Fmt.Fixed (coef [1], 3) + ' umol/mol per year') ;
  Io.WriteLine ('annual amplitude   ' +
                Fmt.Fixed (amp1, 2) + ' (peak to trough ' +
                Fmt.Fixed (2.0 * amp1, 2) + ')') ;
  Io.WriteLine ('semiannual         ' +
                Fmt.Fixed (amp2, 2)) ;

  nm := 0 ;
  FOR i := 0 TO Months - 1 DO
    IF mN [i] > 0 THEN
      mx [nm] := Lo + (F64 (i) + 0.5) / 12.0 ;
      my [nm] := mSum [i] / F64 (mN [i]) ;
      nm := nm + 1
    END
  END ;
  FOR i := 0 TO FitPts - 1 DO
    y := 2017.29 + F64 (i) * (Hi - 2017.29) / F64 (FitPts) ;
    fx [i] := y ;
    fy [i] := Model (coef, y - Mid)
  END ;
  Plot.ClearFigure () ;
  Plot.SetDots (SLICE (hx, 0, valid), SLICE (hy, 0, valid)) ;
  Plot.AddLine (SLICE (mx, 0, nm), SLICE (my, 0, nm), 0, 'monthly mean') ;
  Plot.AddLine (fx, fy, 1, 'trend + 2 harmonics') ;
  svg := Plot.Render ('CO2 at Hyltemossa, 150 m (ICOS, CC BY 4.0)',
                      'year', 'CO2 (umol/mol)') ;
  Io.WriteFile ('/tmp/htm.svg', svg) ;
  Io.Write ('wrote /tmp/htm.svg, ') ;
  Io.WriteI64 (LEN (svg)) ;
  Io.WriteLine (' bytes')
EXCEPT
| ZarrStore.StoreError :
    Io.ErrLine ('store unreachable -- is the local server running?') ;
    Io.Halt (1)
| ZarrStore.FormatError (what) :
    Io.ErrLine ('not a zarr v2 store') ;
    Io.Halt (1)
| Mat.NotSPD (row) :
    Io.ErrLine ('normal equations not positive definite') ;
    Io.Halt (1)
| Faults.SizeError (a, bb) :
    Io.ErrLine ('matrix sizes disagree') ;
    Io.Halt (1)
| ValueRange :
    Io.ErrLine ('a value did not fit') ;
    Io.Halt (1)
| Overflow :
    Io.ErrLine ('overflow') ;
    Io.Halt (1)
| IndexError :
    Io.ErrLine ('index out of range') ;
    Io.Halt (1)
| Io.IOError (p) :
    Io.ErrLine ('cannot write /tmp/htm.svg') ;
    Io.Halt (1)
END C10Icos.
C10Icos.m9

expected output:

hours in the window 72456 (of 84672 in the store)
mean 2021.5        419.92 umol/mol
trend              2.529 umol/mol per year
annual amplitude   9.01 (peak to trough 18.02)
semiannual         2.62
wrote /tmp/htm.svg, 3748547 bytes

nine years of CO2 at Hyltemossa: every QC-passed hour a dot, the monthly means and the six-coefficient Fourier fit over them

What the numbers say: CO2 at Hyltemossa climbs about 2.5 umol/mol a year, and the forest breathes an 18 umol/mol seasonal swing around that climb — summer drawdown, winter release — with a semiannual correction of a couple of umol/mol shaping the shoulders. The plot draws every QC-passed hour as a small black dot, with the monthly means and the six-coefficient fit over them — nine years of raw observations and their summary in one picture.

Worth noticing on the way out:

← Previous: preparing data for plotting

Next: threads, waiting in parallel →