Created
November 15, 2025 09:39
-
-
Save aanastasiou/850fb6eef158e60a37611b07ebb53093 to your computer and use it in GitHub Desktop.
A stacked plot procedure for Racket
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #lang racket/base | |
| (require | |
| racket/vector | |
| racket/match | |
| racket/class | |
| plot | |
| plot/utils | |
| data-frame | |
| ; These imports are structured this way here because of the way colors exposes | |
| ; (or not) its internals. To expose specific structs for example, we need to import from | |
| ; deeper in the module, which then clashes with other module declarations and this leads to | |
| ; this kind of cherry picking. Ideally, colors should expose its internal data structs. | |
| (only-in math/statistics statistics statistics-max statistics-min) | |
| (only-in math exact-round pi) | |
| (only-in colors color->hsl hsl->color hsl) | |
| (only-in colors/private/shared hsl-color) | |
| (only-in racket/draw color%)) | |
| ; ---FUNCTIONS THAT GENERATE RANDOM TIME SERIES AND DATA FRAMES (skip)----- | |
| ; None of this is optimised in any way, just stuff I needed to build random data-frames practically | |
| ; Generates a random tag of length len using the characters in alphabet | |
| (define (get-random-string alphabet slen) | |
| (define alphabet-len (sub1 (string-length alphabet))) | |
| (build-string slen (lambda (n) (string-ref alphabet (exact-round (* (random) alphabet-len)))))) | |
| ; Generates a ramp vector from 0 to max in N steps | |
| (define (ramp N y-start y-end) | |
| (build-vector N (lambda (n) (+ y-start (* (/ n N) (- y-end y-start)))))) | |
| ; Generates a vector of length N with random values in the range y-m \pm y-s | |
| (define (random-data N y-m y-s) | |
| (build-vector N (lambda (d) (+ y-m (* 2.0 y-s (- (random) 0.5)))))) | |
| ; Generates a vector of length N with a sinusoid of frequency freq and amplitude amp and then | |
| ; mixes it with a random noise vector of equal amplitude. | |
| (define (random-data-with-sin N freq amp mix) | |
| (define lin-phase (ramp N 0.0 (* 2.0 pi))) | |
| (define sin-component (vector-map (lambda (n) (* amp (sin (* freq n)))) lin-phase)) | |
| (define noise-component (random-data N 0 amp)) | |
| (vector-map (lambda (n1 n2) (+ (* (- 1.0 mix) n1) (* mix n2))) sin-component noise-component)) | |
| (define (build-random-data-frame-with-common-reference M N) | |
| ; Add a time series for common reference | |
| (define cmn (make-series "RefX" | |
| #:data (ramp N 0 N))) | |
| ; Add M series with random names | |
| (define data (build-list M (lambda (d) | |
| (make-series (get-random-string "0123456789ABCDEF" 8) | |
| #:data (if (> 0.5 (random)) | |
| (random-data N 0 (+ 0.1 (* (random) 2))) | |
| (random-data-with-sin N | |
| (+ 2.0 | |
| (exact-round (* (random) 2))) | |
| (+ 0.1 (* (random) 2)) | |
| (+ 0.1 (* (random) 0.8)))))))) | |
| (make-data-frame #:series (cons cmn data))) | |
| ; -------- | |
| ; --- FUNCTIONS THAT ARE USED IN THE GENERATION OF A SENSIBLE COLOR PALETTE (skip) ----- | |
| ; Takes an x \in R[0..1] --> Z[0..255]. Used to generate random colours | |
| (define (->u8 x) (exact-round (* x 255))) | |
| ; Constructs a colour object given RGB or RGBA triplets | |
| (define (color r g b (a 1.0)) (make-object color% r g b a)) | |
| ; Builds a palette of N colours by inscribing a regular N-gon in the colour circle. | |
| ; In this way, colours have enough separation between them to be distinct enough. | |
| ; Also adds an initial offset (an initial colour) which gives some control over | |
| ; the range of colours generated. If the initial offset is not provided, a random | |
| ; colour is generated. | |
| (define (build-palette n-colors | |
| #:starting-color (starting-color | |
| ;Need to refine the way the random colour is generated to | |
| ; avoid the grey "line" and very saturated options | |
| ; (i.e generate HSL with specific ranges for H S L instead of | |
| ; an RGB triplet) | |
| (color (->u8 (random)) (->u8 (random))(->u8 (random))))) | |
| ; This destructuring is impossible by simply importing colors. | |
| (match-define (hsl-color init-h init-s init-l init-a) (color->hsl starting-color)) | |
| ; x that represents Hue here can take values \in R[-inf..inf]. In reality, Hue represents an | |
| ; angle around the HSL color space that is theta \n Z[0..360]. | |
| ; This function takes an R value and ensures that it wraps around the 0..360 range. | |
| (define (modulo-hue x) (/ (modulo (round (* x 360)) 360) 360.0)) | |
| (build-list n-colors (lambda (n) (hsl->color (hsl (modulo-hue (+ init-h (* n (/ 1.0 n-colors)))) | |
| init-s | |
| init-l | |
| init-a))))) | |
| ; ------------------------ | |
| ; ---- CODE ESSENTIAL TO MULTIPLOT STARTS HERE--------------- | |
| ; Generates a line renderer given specific range parameters for one of the time series in data-frame | |
| ; df. This is an internal function and handles ONLY the rendering. | |
| (define (plot-param->line df x-series-name y-series-name | |
| y-series-min y-series-max ; Specify the time series range (in time-series units) | |
| y-plot-min y-plot-max ; Specify the plot Y-axis range to place it in (in plot units) | |
| #:label (label #f) ; Any non-#f string will be added as a label | |
| #:colour (colour 'random)) | |
| ; Resolve the requested colour value (either random or a user-defined one) | |
| (define colour-resolved (if (eq? colour 'random) | |
| (color (->u8 (random)) (->u8 (random))(->u8 (random))) | |
| colour)) | |
| ; Isolate the X-axis data from the data-frame (no further transformation) | |
| (define x-plot-data (df-select df | |
| x-series-name)) | |
| ; Pre-compute the time series range (it is used very frequently later on) | |
| (define y-series-range (- y-series-max y-series-min)) | |
| ; Pre-compute the plot range | |
| (define y-plot-range (- y-plot-max y-plot-min)) | |
| ; Re-scale the specified time series so that its y-series-range fits in the y-plot-range | |
| (define y-plot-data (vector-map (lambda (u) (+ y-plot-min | |
| ; TODO: If it is not a number it should become nan | |
| ; TODO: Clip outside the specified range to y-plot-range | |
| (* (/ (- (or (and (number? u) u) 0.0) y-series-min) | |
| y-series-range) | |
| y-plot-range))) | |
| (df-select df y-series-name))) | |
| ; Create the renderer. The lines renderer is complemented by two hrules at the min/max limits. | |
| ; If a label is provided, the top hrule (corresponding to the local max value) is not added. | |
| (let [(basic-plot-data | |
| ; TODO: The combination to a 2-vector should be hapenning during the first pass, | |
| ; during pre-processing | |
| (list (lines (vector-map (lambda (u v) (vector u v)) x-plot-data y-plot-data) | |
| #:color colour-resolved) | |
| (hrule y-plot-min #:color '(0.0 0.0 0.0))))] | |
| ; If the plot-range includes zero, then add a horizontal rule (makes it easier to interpret the plot) | |
| (if (and (> 0 y-series-min) | |
| (< 0 y-series-max)) | |
| (cons (hrule (+ (* (/ (abs y-series-min) | |
| y-series-range) | |
| y-plot-range) | |
| y-plot-min) | |
| #:color '(0.0 0.0 0.0)) | |
| basic-plot-data) | |
| basic-plot-data) | |
| ; If a label is provided add it, otherwise append an hrule at the y-plot-max level. | |
| (if label | |
| (cons (point-label (vector (vector-ref (df-select df x-series-name) 0) | |
| y-plot-max) | |
| label | |
| #:point-sym 'none | |
| #:color '(0.0 0.0 0.0)) | |
| basic-plot-data) | |
| (cons | |
| (hrule y-plot-max #:color '(0.0 0.0 0.0)) | |
| basic-plot-data)))) | |
| ; A multiplot is configured via a source data-frame and one or more time-series plot configurations. | |
| (struct data-source-conf (df subplot-confs)) | |
| ; A subplot configuration includes the X/Y time series names and the time series Y ranges to plot | |
| (struct subplot-conf (x-series-name y-series-name y-series-min y-series-max)) | |
| ; A subplot trace configuration is basically a subplot-conf, plus the y-plot range and the function | |
| ; that accepts plot-units and returns time-series units (more on this later). This is an | |
| ; internal data structure. | |
| (struct subplot-trace-conf subplot-conf (y-plot-min y-plot-max plot->series)) | |
| ; The actual function that generates the plot. It accepts a data-source-conf and returns a plot (snip) | |
| (define (multiplot data | |
| #:width (width 1.0) ;The width of each time series in plot units | |
| #:separation (separation 0.25) ;The separation between the max of one waveform and the min of another (in plot units) | |
| #:start-y (start-y 0.0)) ; The y-min of the first waveform (in plot-units) | |
| ; Create the subplot trace data for each time series | |
| ; TODO: These should be done with destructuring, it will be more understandable | |
| ; Isolate the data frame | |
| (define df (data-source-conf-df data)) | |
| ; Isolate the list of configurations | |
| (define confs (data-source-conf-subplot-confs data)) | |
| ; Given a plot configuration, generate a trace configuration which basically arranges each | |
| ; plot in a stacked array of plots | |
| (define traces | |
| (for/list [(a-subplot-conf (in-list confs)) | |
| (k (in-range 0 (length confs)))] | |
| (let* ([y-series-name (subplot-conf-y-series-name a-subplot-conf)] | |
| [y-series-max (subplot-conf-y-series-max a-subplot-conf)] | |
| [y-series-min (subplot-conf-y-series-min a-subplot-conf)] | |
| [stats (and (or (eq? y-series-min 'series-min) | |
| (eq? y-series-max 'series-max)) | |
| (df-statistics df y-series-name))] | |
| [y-series-max-resolved (or (and (eq? y-series-max 'series-max) (statistics-max stats)) y-series-max)] | |
| [y-series-min-resolved (or (and (eq? y-series-min 'series-min) (statistics-min stats)) y-series-min)] | |
| [y-plot-starts-at (+ start-y (* k (+ width separation)))] | |
| [y-plot-ends-at (+ y-plot-starts-at width)]) | |
| (subplot-trace-conf (subplot-conf-x-series-name a-subplot-conf) | |
| y-series-name | |
| y-series-min-resolved | |
| y-series-max-resolved | |
| y-plot-starts-at | |
| y-plot-ends-at | |
| ; Given a d value WITHIN the plot-range, it produces a linear mapping of d to | |
| ; the time-series range. If d is OUTSIDE of the plot-range, it produces #f | |
| (lambda (d) | |
| (and | |
| (>= d y-plot-starts-at) | |
| (<= d y-plot-ends-at) | |
| (+ y-series-min-resolved | |
| (* (/ (- d y-plot-starts-at) | |
| (- y-plot-ends-at y-plot-starts-at)) | |
| (- y-series-max-resolved y-series-min-resolved))))))))) | |
| ; This is basically a linear-ticks-layout with a custom label function that chooses the label | |
| ; value depending on the y-plot-range | |
| (define (segmented-ticks #:number [number 10] | |
| #:base [base 10] | |
| #:divisors [divisors '(1 2 4 5)]) | |
| (ticks | |
| (linear-ticks-layout #:number number | |
| #:base base | |
| #:divisors divisors) | |
| (lambda (u v w) | |
| (for/list [(k (in-list w))] | |
| (let* [(current-value (pre-tick-value k)) | |
| ; TODO: Could not (apply or ...), but this ormap is simplistic, needs improvement | |
| (current-label-value (ormap (lambda (m) m) (for/list ([l (in-list traces)]) ((subplot-trace-conf-plot->series l) current-value))))] | |
| ; If a number is produced it is converted to a label, otherwise to an empty string. | |
| (if (number? current-label-value) | |
| ; TODO: This 2 here, should really be a user define parameter. | |
| (real->plot-label current-label-value 2) | |
| "")))))) | |
| ; Build a palette for the N waveforms being plotted | |
| ; TODO: The colour of each waveform should become a subplot-conf parameter | |
| (define palette (build-palette (length traces))) | |
| ; Create the plot | |
| ; TODO: It should be noted that any plot-y-ticks parameterization will be overriden by multiplot | |
| (parameterize [(plot-y-ticks (segmented-ticks))] | |
| (plot-snip | |
| (for/list ([a-trace (in-list traces)] | |
| [a-color (in-list palette)]) | |
| (plot-param->line df | |
| (subplot-conf-x-series-name a-trace) | |
| (subplot-conf-y-series-name a-trace) | |
| (subplot-conf-y-series-min a-trace) | |
| (subplot-conf-y-series-max a-trace) | |
| (subplot-trace-conf-y-plot-min a-trace) | |
| (subplot-trace-conf-y-plot-max a-trace) | |
| #:label (subplot-conf-y-series-name a-trace) | |
| #:colour a-color)) | |
| ; Confine the range of the plot | |
| #:y-min start-y | |
| #:y-max (+ start-y (* (length traces) (+ width separation))) | |
| ; TODO: These should become user defined parameters | |
| #:height 1280 | |
| #:width 720))) | |
| ; ------------------------------ | |
| ; -- USAGE EXAMPLE | |
| ; Data file to use in this example (if available) | |
| ; The CSV should only contain numeric data | |
| ; (define data-file-name "some/csv/data/file") | |
| ; Construct the data-frame from CSV | |
| ; (define df (df-read/csv data-file-name)) | |
| ; Here, I am building a dummy data-frame for demo purposes | |
| ; The dummy data-frame contains 16 time-series under a common reference channel called RefX | |
| ; Each time seres is of length 100 | |
| (define df (build-random-data-frame-with-common-reference 16 100)) | |
| ; Get all the time-series name minus the RefX | |
| (define sn (remove* '("RefX") (df-series-names df))) | |
| ; Define the data for the multiplot | |
| (define my-plot (data-source-conf df | |
| (for/list ([a-series-name (in-list sn)]) | |
| (subplot-conf "RefX" | |
| a-series-name | |
| 'series-min | |
| 'series-max)))) | |
| ; Generate the plot (notice here, still able to parameterize if required) | |
| (define p-snip (parameterize [(plot-x-label "Time (Samples)") | |
| (plot-y-label "")] | |
| (multiplot my-plot))) | |
| p-snip |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment