-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathProgram.fs
393 lines (335 loc) · 14.4 KB
/
Program.fs
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
open System
open Handmade.Collections.Generic
open Graf
module Seq =
/// Returns element count, min, max, average and (sample) variance of a given seq.
let statistics seq =
let init = (0, Double.PositiveInfinity, Double.NegativeInfinity, 0.0, 0.0)
let acc (n, m, M, s, ss) x = (n + 1, min m x, max M x, s + x, ss + x*x)
let len, min, max, sum, squaredSum = seq |> Seq.filter (not << Double.IsNaN) |> Seq.fold acc init
let n = float len
let avg = sum / n
let var = (squaredSum - (sum * sum / n )) / (n - 1.0)
len, min, max, avg, var
// XXX: beware of NaNs (see https://github.com/dotnet/fsharp/issues/13207)
let minNonNan s = seq { yield Double.PositiveInfinity; yield! s } |> Seq.min
let maxNonNan s = seq { yield Double.NegativeInfinity; yield! s } |> Seq.max
let fatal (msg: string) =
do Console.Error.WriteLine(msg)
exit -1
let version = "0.4.20"
let usage = $"\
Usage: graf [OPTION]...
Plot discretized line charts in your terminal.
Options:
-f, --file FILE If FILE is - or not specified, read from stdin.
-b, --batch Only plot on end of input (as opposed to real-time).
-t, --title TITLE Display TITLE on top of the plotted chart.
-s, --stats Show statistics, which are hidden by default.
-c, --color COLOR Color to plot the line in. See options below.
-n, --lines N Plot N <= 8 parallel lines. Default is inferred.
-p, --permissive Ignore badly-formatted lines instead of halting.
-r, --range MIN:MAX Fix plot bounds instead of choosing them dynamically.
-d, --digits DIGITS Ensure at least DIGITS significant digits are printed.
-W, --width WIDTH Maximum TUI width. Defaults to terminal width.
-H, --height HEIGHT Maximum TUI height. Defaults to terminal height.
-h, --help Print this help message and exit the program.
Notes:
- A single quantization range is used for the entire chart, so make sure
timeseries are similarly scaled when there are more than one.
- When the chart includes multiple lines, a default title is added in order
to help disambiguate them; furthermore, each one is colored differently.
- Options '--title' and '--color' can be specified multiple times, in which
case they will be applied to each timeseries in a corresponding position.
Colors:
\x1b[30m k, black \x1b[0m \t \x1b[31m r, red \x1b[0m \t \x1b[32m g, green \x1b[0m \t \x1b[33m y, yellow \x1b[0m
\x1b[34m b, blue \x1b[0m \t \x1b[35m m, magenta \x1b[0m \t \x1b[36m c, cyan \x1b[0m \t \x1b[37m w, white \x1b[0m
Links:
Project page https://github.com/baioc/graf
Bug tracker https://github.com/baioc/graf/issues
graf v{version}
Copyright (c) 2022 Gabriel B. Sant'Anna"
type Options = {
File: string
Batch: bool
Titles: seq<string>
Stats: bool
Colors: seq<string>
Lines: int
Permissive: bool
Range: string
Digits: int
Width: int
Height: int
Help: bool
}
let defaultOptions = {
File = "-"
Batch = false
Titles = []
Stats = false
Colors = []
Lines = 0
Permissive = false
Range = ""
Digits = 1
Width = Console.BufferWidth
Height = Console.BufferHeight
Help = false
}
let defaultColors = [|
AnsiColor.RED
AnsiColor.GREEN
AnsiColor.BLUE
AnsiColor.MAGENTA
AnsiColor.YELLOW
AnsiColor.CYAN
AnsiColor.WHITE
AnsiColor.BLACK
|]
// parse command line
let rec parseCLI seen opts args =
let set arg =
if Set.contains arg seen then
do Console.Error.WriteLine($"Warning: --{arg} option was set more than once")
seen
else
Set.add arg seen
let addColor c =
// normalization
let color = if c = "black" then "k" else c
let color = if color.Length > 1 then color.Substring(0, 1) else color
// deduplication
if Seq.contains color opts.Colors then
do Console.Error.WriteLine($"Warning: color '{c}' was used more than once")
opts.Colors
else
Seq.append opts.Colors [color]
match args with
| [] -> opts
| "-f"::file::rest | "--file"::file::rest ->
parseCLI (set "file") { opts with File = file } rest
| "-b"::rest | "--batch"::rest ->
parseCLI (set "batch") { opts with Batch = true } rest
| "-t"::title::rest | "--title"::title::rest ->
parseCLI seen { opts with Titles = Seq.append opts.Titles [title] } rest
| "-s"::rest | "--stats"::rest ->
parseCLI (set "stats") { opts with Stats = true } rest
| "-c"::color::rest | "--color"::color::rest ->
parseCLI seen { opts with Colors = addColor color } rest
| "-n"::lines::rest | "--lines"::lines::rest ->
parseCLI (set "lines") { opts with Lines = int lines } rest
| "-p"::rest | "--permissive"::rest ->
parseCLI (set "permissive") { opts with Permissive = true } rest
| "-r"::range::rest | "--range"::range::rest ->
parseCLI (set "range") { opts with Range = range } rest
| "-d"::digits::rest | "--digits"::digits::rest ->
parseCLI (set "digits") { opts with Digits = int digits } rest
| "-W"::width::rest | "--width"::width::rest ->
parseCLI (set "width") { opts with Width = int width } rest
| "-H"::height::rest | "--height"::height::rest ->
parseCLI (set "height") { opts with Height = int height } rest
| "-h"::rest | "--help"::rest ->
parseCLI (set "help") { opts with Help = true } rest
| unexpected ->
let sp = " "
failwith $"unexpected option or missing argument at '{String.concat sp args}'"
let runWith
batch (width, height)
multi permissive
(titles: string[]) (colors: AnsiColor[])
showStats
digits (lowerBound, upperBound)
=
// infer parameters from 1st input line
let mutable input = Console.In.ReadLine()
if isNull input then exit 0
let multi = if multi > 0 then multi else input.Trim().Split().Length
if multi > 8 then fatal $"Error: at most 8 lines can be plotted in a single chart"
assert (multi >= 1 && multi <= 8)
// derived parameters
let colors =
if colors.Length > 0 then colors
elif multi = 1 then [| AnsiColor.DEFAULT |]
else defaultColors
assert (colors.Length >= multi)
let title =
if multi = 1 && titles.Length > 0 then
titles[0]
elif multi = 1 && titles.Length = 0 then
""
else
Array.init multi (fun i -> if i < titles.Length then titles[i] else $"%%{i+1}")
|> Seq.mapi (fun i t -> AnsiColor.colorize colors[i] t)
|> String.concat "\t"
|> (fun s -> s.Trim())
let m = height - (if title.Length > 0 then 2 else 0) - (if showStats then 1 + multi else 0)
let worstCaseFloatCrap = 7 // sign + dot + e + eSign + 3 exponent digits
let numericWidth = digits + worstCaseFloatCrap
let n = width - numericWidth - 1 // 1 = space
// escape weird corners of the parameter space
if m < 2 then do fatal $"Error: {width} x {height} plot region is not tall enough"
if n < 2 then do fatal $"Error: {width} x {height} plot region is not wide enough"
// prepare format strings
let clearWidth = String.replicate width " "
let headerFormat =
if title.Length = 0 then ""
else $"{clearWidth}\rseq={{0:d}} now={{1}}\t{title}\n{clearWidth}\n"
let statsFormat =
let g = max 3 digits
$"\n{clearWidth}\r n={{0:d}} min={{1:g{g}}} max={{2:g{g}}} avg={{3:g{g}}} std={{4:g{g}}}"
let numberToString (width: int) (significantDigits: int) (x: float) =
String.Format($"{{0,{width}:g{significantDigits}}}", x)
let makeLabel x =
let sign = if x < 0.0 then 1 else 0
let dot = if round x <> x then 1 else 0
let leadingZeros =
String.Format("{0:g}", x)
|> Seq.takeWhile (fun c -> not (Char.IsNumber c) || c = '0')
|> Seq.filter (fun c -> c = '0')
|> Seq.length
let nonSignificant = sign + dot + leadingZeros
let maxPrecision = numberToString numericWidth (numericWidth - nonSignificant) x
if maxPrecision.Length > numericWidth then
let constrainedPrecision = numberToString numericWidth digits x
constrainedPrecision + " "
else
maxPrecision + " "
// allocate mutable buffers
let timeseries = Array.init multi (fun i -> RingBuffer.create n nan)
let nans = Array.create multi 0
let chart = Chart.create m n
let labels = Array.create m ""
// pre-loop setup
let mutable t = 1
do
Console.Clear()
Console.CancelKeyPress.Add (fun _ -> Console.Out.WriteLine(); Console.CursorVisible <- true)
Console.CursorVisible <- false
let render () = do
Console.SetCursorPosition(0, 0)
Console.Out.Write(headerFormat, t, DateTime.Now.ToString("HH:mm:ss.fff"))
Console.Out.Write(chart.ToString(labels))
if showStats then
Console.Out.Write("\n" + clearWidth)
timeseries |> Seq.map RingBuffer.toSeq |> Seq.iteri (fun i data ->
let len, min, max, avg, var = Seq.statistics data
let std = sqrt var
let statsColored = AnsiColor.colorize colors[i] statsFormat
Console.Out.Write(statsColored, len, min, max, avg, std))
Console.Out.Flush()
while not (isNull input) do
// parse new data points(s)
let mutable strs = input.Trim().Split()
if strs.Length <> multi then
let msg = $"expected {multi} whitespace-separated values, but found '{input}'"
if not permissive then
Console.CursorVisible <- true
fatal $"Error: {msg}"
else
Console.Error.WriteLine($"Warning: {msg}")
strs <- Array.create multi ""
assert (strs.Length = multi)
strs |> Seq.iteri (fun i str ->
let y = match Double.TryParse(str) with true, y -> y | _ -> nan
RingBuffer.enqueue timeseries[i] y
if not (Double.IsFinite y) then nans[i] <- nans[i] + 1)
assert (timeseries |> Array.forall (fun rb -> rb.Count > 0))
// compute global quantization range (beware of NaNs)
let min, max =
if Double.IsFinite lowerBound && Double.IsFinite upperBound then
lowerBound, upperBound
else
let min' =
if Double.IsFinite lowerBound then lowerBound
else timeseries |> Seq.map (RingBuffer.toSeq >> Seq.minNonNan) |> Seq.minNonNan
let max' =
if Double.IsFinite upperBound then upperBound
else timeseries |> Seq.map (RingBuffer.toSeq >> Seq.maxNonNan) |> Seq.maxNonNan
let min'' = if Double.IsFinite min' then min' else 0.0
let max'' = if Double.IsFinite max' then max' else 0.0
min'', max''
// clear the chart and (re)draw each line
Chart.clear chart
timeseries |> Array.iteri (fun i ys ->
RingBuffer.toSeq ys
|> ChartLine.ofSeq
|> ChartLine.withColor colors[i]
|> ChartLine.withBounds (min, max)
|> Chart.draw chart)
// prepare Y axis labels
let yaxis i = Math.lerp (0.0, float m - 1.0) (min, max) (float i)
for i = 0 to labels.Length - 1 do
labels[i] <- makeLabel (yaxis i)
// render in real time if running in interactive mode
if not batch then render()
// if next input is not available, call the GC before blocking
if Console.In.Peek() < 0 then GC.Collect()
input <- Console.In.ReadLine()
t <- t + 1
// on end of stream, return the number of values which failed to parse
do
if batch then render() else Console.Out.WriteLine()
Console.CursorVisible <- true
Array.sum nans
[<EntryPoint>]
let main argv =
let opts =
try
parseCLI Set.empty defaultOptions (List.ofArray argv)
with
| ex -> fatal $"Error: invalid command line syntax; {ex.Message}\n\n{usage}"
if opts.Help then
do Console.Out.WriteLine(usage); exit 0
if opts.File <> "-" then
try
do Console.SetIn(new IO.StreamReader(opts.File))
with
| _ -> fatal $"Error: could not open file '{opts.File}'"
let parseColor str =
let maybeColor =
Map.tryFind str (Map.ofSeq [
("" , AnsiColor.DEFAULT)
("k", AnsiColor.BLACK); ("black", AnsiColor.BLACK)
("r", AnsiColor.RED); ("red", AnsiColor.RED)
("g", AnsiColor.GREEN); ("green", AnsiColor.GREEN)
("y", AnsiColor.YELLOW); ("yellow", AnsiColor.YELLOW)
("b", AnsiColor.BLUE); ("blue", AnsiColor.BLUE)
("m", AnsiColor.MAGENTA); ("magenta", AnsiColor.MAGENTA)
("c", AnsiColor.CYAN); ("cyan", AnsiColor.CYAN)
("w", AnsiColor.WHITE); ("white", AnsiColor.WHITE)
])
if Option.isNone maybeColor then fatal $"Error: unknown color {str}"
else maybeColor.Value
let titles = Array.ofSeq opts.Titles
let colors =
if Seq.isEmpty opts.Colors then
[||]
else
let parsed = opts.Colors |> Seq.map parseColor
let exclude = Set.ofSeq parsed
let added = defaultColors |> Seq.filter (fun c -> not (Set.contains c exclude))
Seq.append parsed added |> Array.ofSeq
let min, max =
if opts.Range = "" then
Double.NegativeInfinity, Double.PositiveInfinity
else
try
let subs = opts.Range.Split(':')
if subs.Length <> 2 then failwith opts.Range
let lo, hi = subs[0], subs[1]
Double.Parse(lo), Double.Parse(hi)
with | _ ->
fatal $"Error: invalid range format '{opts.Range}'"
let enforce var pred n =
if not (pred n) then fatal $"Error: parameter {var} ({n}) out of range"
enforce "lines" (fun n -> n >= 0 && n <= 8) opts.Lines
enforce "digits" (fun n -> n > 0) opts.Digits
enforce "width" (fun n -> n > 0) opts.Width
enforce "height" (fun n -> n > 0) opts.Height
runWith
opts.Batch (opts.Width, opts.Height)
opts.Lines opts.Permissive
titles colors
opts.Stats
opts.Digits (min, max)