-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathFFT_and_Soil_Temp.Rmd
1824 lines (1493 loc) · 84.1 KB
/
FFT_and_Soil_Temp.Rmd
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
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
---
title: "Using Fourier Transform to Model Soil Temperatures"
author: "Eric Graham"
date: "2023-10-23"
output:
html_document:
fragment.only: true
code_folding: hide
toc: true
toc_float:
collapsed: false
smooth_scroll: true
toc_depth: 4
number_sections: true
theme: yeti # many options for theme
#highlight: tango # specifies the syntax highlighting style
css: FFT.css # H1 and H2 are smaller
runtime: shiny
---
```{r setup, include=FALSE, message=FALSE, warning=FALSE}
knitr::opts_chunk$set(echo = TRUE)
```
```{r results='hide', message=FALSE, include=FALSE, warning=FALSE}
library(tidyverse)
library(cowplot)
library(shiny)
#setwd("C:/Users/erkso/Desktop/FFT")
```
# Intro
Being able to predict sub-surface soil temperatures accurately allows us to better understand the interactions of temperature with the biological components of the soil. Soil temperature depends on periodic (daily and annual) energy inputs (from the air and from solar radiation, sometimes geothermal). These temperature “waves” get damped down, or attenuated, with increasing depth in the soil, eventually reaching a constant temperature at a great depth. That final deep temperature depends on the latitude, climate, and other conditions.
A simple 1-dimensional model of heat conduction through the a solid [(Van Wijk and de Vries, 1966)](#refs) can be used to predict soil temperatures, as long as we have at least 1 day of temperature measurements (plus some other info). Because the world is messy, parameters of the model can change with soil types and water content. However, for a specific location and condition, we should be able to measure the surface temperature over at least one day and then calculate the soil temperatures at rooting depth, or some other important physiological/ecological depth.
This demonstration is based on work from this paper: [Graham et al. 2010](#refs).
Check out my other Shiny data project: [Exploring the Data: Who's Making News](https://erksome.shinyapps.io/WMN_Analysis/) or drop me a line: 'egraham.cens' at gmail!
***
# Explore Some Soil Temperature Data
First, let’s explore some real meteorological and soil temperature data from a NOAA weather station in Spokane, WA. The data are from: [ncei.noaa.gov/access/crn/qcdatasets.html](https://www.ncei.noaa.gov/access/crn/qcdatasets.html) from the good work by [Diamond et al. 2013, and Bell et al. 2013](#refs) and have already been cleaned up a bit.
We will be using [R Statistical Software](#refs) (v4.3.1; R Core Team 2021) with both the "tidyverse" set of packages (to make data transformation and plotting easier and clearer) and the "shiny" package for some interactive data exploration. Read in the R-formatted data with the `readRDS()` command and then display the first lines of the file using the `head()` function, like this:
```{r class.source = "fold-show", message=FALSE, warning=FALSE}
# Read in the data and examine the first 10 rows of data
soil_temps <- readRDS("soil_temps.RDS")
head(soil_temps, 10)
```
You should see a number of rows with column headings. The first column is "Date_time", which obviously holds the date and time information. The data are recorded every hour (each row). The second column is "solar", which holds the solar radiation hitting the soil surface in watts m^-2^. And then there are six more rows with "cm_" followed by a number. The numbers represent the depth in the soil where the temperature, in degrees Celsius, was measured (so, "cm_0" is the soil surface temperature and “cm_05” is the temperature at 5 cm depth).
Let’s plot the entire year’s worth of soil surface temperatures:
```{r class.source = "fold-show", message=FALSE, warning=FALSE}
# Plot the surface temperature for the entire, annual data set
plot(soil_temps$Date_time, soil_temps$cm_0, "l")
```
That’s a lot of messy data, with the temperature of the surface rising and falling each day for 365 days. What day of the year might have the largest diurnal (daily) temperature range? Which might have the smallest?
Let's add another line, in red, on the plot that corresponds to the temperature measured at 5 cm depth in the soil:
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Plot the surface temperature again and add the temperature at 10 cm depth
plot(soil_temps$Date_time, soil_temps$cm_0, "l")
lines(soil_temps$Date_time, soil_temps$cm_05, "l", col="red")
```
It looks like the 5 cm depth line tracks the average daily temperatures, more or less. The soil has also "smoothed out" the daily fluctuations, although there is still daily fluctuations and an annual rise-and-fall to the temperature in the soil.
To make the next set of plotting easier, first we'll rearrange our data and then use the "tidyverse" set of methods to subset and group the data.
To rearrange the data, we want the columns of different soil depths moved into a single column and then we'll add a new column of labels to identify which columns the data came from. This will involve pivoting the data from "wide" (many columns, fewer rows) to "long" (fewer columns, many rows). We'll exclude the Date_time column from this transformation and make sure that the labels for the soil depths are defined as "factors" (recognized like labels or categories, not character strings).
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Pivot the data from wide to long
soil_temps_longer <- soil_temps |>
pivot_longer(
cols = !Date_time,
names_to = "depth"
) |>
mutate(depth = factor(depth, levels = c("solar", "cm_0",
"cm_05", "cm_10",
"cm_20", "cm_50", "cm_100")))
# Show the first 10 lines of the new, longer data set
head(soil_temps_longer, 10)
```
The structure of the data is now just three columns: Date_time, depth (the labels as factors), and value (the temperatures).
Plotting the data, especially when looking at two different depths, is now easier. First, we subset the data by choosing three 24-hour periods in the summer and two depths, starting on the hour when the surface temperature is at its lowest (more or less). This will make it easier comparisons, later:
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Subset the data for three days in July and only the surface and 5 cm depths, start and end when the temperature is at its lowest, with a 2-minute wiggle for data time-stamp drift.
start_date <- mdy_hm("July 8, 2015 04:28", tz = "America/Los_Angeles")
end_date_3d <- mdy_hm("July 11, 2015 04:32", tz = "America/Los_Angeles")
end_date_1w <- mdy_hm("July 15, 2015 04:32", tz = "America/Los_Angeles")
soil_for_plotting <- soil_temps_longer |>
filter(Date_time >= start_date) |>
filter(Date_time < end_date_3d) |>
filter(depth == "cm_0" | depth == "cm_05")
```
Then, we plot this three-day subset of data using ggplot, grouping the two depths: (I've added some legends and things to the plot that are not part of the code below)
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Plot the subset of data
lineplot <- ggplot(soil_for_plotting, aes(x = Date_time, y = value, group = depth, color = depth)) +
geom_line() +
theme_light() +
theme(
legend.position = "bottom",
panel.border = element_blank(),
) +
scale_color_manual(name="",
labels = c("Surface", "at 5 cm"), values = c("black", "red"))
```
(If you want to see the code for adding the legends and labels: click the "Show" button on the right.)
```{r, message=FALSE, warning=FALSE}
# Add titles
lineplot <- lineplot +
labs(
title = "Soil temperatures",
subtitle = "Spokane, WA",
x = "Date",
y = "Degrees C"
)
# Make theme prettier
lineplot <- lineplot + theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.x = element_text(size=15),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
plot.subtitle = element_text( # font size "regular"
size = 15,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# Show the plot
lineplot
```
Looking at the plot above, it is clear that soil surface temperatures fluctuate more greatly than temperatures deeper in the soil. The daily temperature "wave" at the surface decreases in amplitude the deeper you go (we'll see this better, below).
The wave at depth in the soil is also delayed in time - a phase-shift! Looking at the plot above, when (what hour of the day) is the peak of the surface measurement (in black) compared to the peak of the 5 cm measurement on the same day (in red)? Deeper in the soil, the peak should occur later in the day. Here are the times of day for the maximum of the surface and 5 cm depth peaks:
* Peak time at surface: `r format(soil_for_plotting$Date_time[soil_for_plotting$value == max(soil_for_plotting$value[soil_for_plotting$depth == 'cm_0'])], format = "%H:%M")`
* Peak time at 5 cm depth: `r format(soil_for_plotting$Date_time[soil_for_plotting$value == max(soil_for_plotting$value[soil_for_plotting$depth == 'cm_05'])], format = "%H:%M")`
Rather than re-write the plotting code to examine other dates and compare other depths, a Shiny app can automate the process. Below, we start on the same day as above, but display an entire week. Check or un-check some variables and pick some date ranges to get a feeling for how the temperature "wave" passes through the soil and how it becomes damped and delayed with depth at different days of the year.
As an added bonus, the solar radiation (W m^-2^) is also plotted in the top panel - check out how the soil temperature is affected by sunny days (a nice sunrise-to-sunset arc) or cloud cover (a more jagged line through the day).
## Interactive - Browse the data
```{r, echo=FALSE, message=FALSE, warning=FALSE}
inputPanel(
checkboxGroupInput("include",
"Include in the plot:",
choiceNames = list("0 cm surface", "5 cm deep",
"10 cm deep", "20 cm deep",
"50 cm deep", "100 cm deep"),
choiceValues =list("cm_0", "cm_05",
"cm_10", "cm_20",
"cm_50", "cm_100"),
selected=c("cm_0", "cm_05")
),
dateRangeInput("daterange",
"Dates for 2015 data:",
start = ymd("2015-07-08"),
end = ymd("2015-07-15"),
min = ymd("2015-01-01"),
max = ymd("2015-12-31"),
format = "mm-dd",
startview = "month",
weekstart = 0,
language = "en",
separator = " to ",
width = NULL,
autoclose = TRUE
)
)
renderText({
if(is.null(input$include)){
"Please pick a depth to plot"
}
})
# Equally spaced hues around the color wheel, starting from 15
# gg_color_hue <- function(n) {
# hues = seq(15, 375, length = n + 1)
# hcl(h = hues, l = 65, c = 100)[1:n]
# }
# Fixed pallet for depths
hues <- seq(15, 375, length = 6 + 1)
pal <- hcl(h = hues, l = 65, c = 100)[1:6]
# Get legends and colors. Surface is always black, 5 cm always red
legendlabels <- function(depthslist, pal){
leglabels <- NULL
if(!is.null(depthslist)){
for(i in 1:length(depthslist)){
if(depthslist[i] == "cm_0"){
leglabels <- c(leglabels, "Surface")
pal[i] <- "black"
} else if(depthslist[i] == "cm_05"){
leglabels <- c(leglabels, "5 cm")
pal[i] <- "red"
} else if(depthslist[i] == "cm_10"){
leglabels <- c(leglabels, "10 cm")
} else if(depthslist[i] == "cm_20"){
leglabels <- c(leglabels, "20 cm")
} else if(depthslist[i] == "cm_50"){
leglabels <- c(leglabels, "50 cm")
} else if(depthslist[i] == "cm_100"){
leglabels <- c(leglabels, "100 cm")
}
}
}
labelslist <- list("leglabels" = leglabels, "pal" = pal)
return(labelslist)
}
renderPlot({
# Get soil depths from input as a text string
if(!is.null(input$include)){
cond <- paste("depth == ", "'", input$include[1], "'", sep="")
if(length(input$include) > 1){
for(i in 2:length(input$include)){
cond <- paste(cond, " | depth == ", "'", input$include[i], "'", sep="")
}
}
# Get legend labels
labelslist <- legendlabels(input$include, pal)
# Filter on date range
startdate <- input$daterange[1]
enddate <- input$daterange[2]
if(enddate < startdate){
tempdate <- startdate
startdate <- enddate
enddate <- tempdate
} else if(enddate == startdate){
enddate = startdate + days(1)
}
soil_for_plotting_shinyS <- soil_temps_longer |>
filter(Date_time >= startdate & Date_time < enddate) |>
filter(eval(parse(text=cond)))
soillineplot <- ggplot(soil_for_plotting_shinyS, aes(x=Date_time, y = value)) +
geom_line(aes(group = depth, color=depth)) +
theme_light() +
theme(
legend.position = "bottom",
panel.border = element_blank(),
) +
scale_color_manual(name="",
labels = labelslist$leglabels, values = labelslist$pal)
# Add titles
soillineplot <- soillineplot +
labs(
x = "Date",
y = "Degrees C"
)
# Make theme prettier
soillineplot <- soillineplot + theme(
#legend.position="none", # remove legend because tooltips will suffice
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.x = element_text(size=15),
axis.line.x = element_line(linewidth=0.25),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
plot.subtitle = element_text( # font size "regular"
size = 15,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
######## Solar top graph #########
solar_for_plotting <- soil_temps_longer |>
filter(Date_time >= startdate & Date_time < enddate) |>
filter(depth == 'solar')
solarlineplot <- ggplot(solar_for_plotting, aes(x=Date_time, y = value)) +
geom_line() +
ylim(0, NA) +
theme_light() +
theme(
#legend.position = "none",
panel.border = element_blank(),
)
# Add titles
solarlineplot <- solarlineplot +
labs(
x = "Date",
y = bquote("(W" ~ m^-2 ~ ")")
)
# Make theme prettier
solarlineplot <- solarlineplot + theme(
#legend.position="none", # remove legend because tooltips will suffice
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.title.x = element_blank(),
axis.text.x = element_blank(),
axis.line.x = element_line(linewidth=0.25),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
plot.subtitle = element_text( # font size "regular"
size = 15,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# Show the plots
plot_grid(solarlineplot, soillineplot,
nrow = 2,
#labels = "NONE",
label_size = 12,
align = "v",
rel_heights = c(1,3)
)
}
})
```
***
# Examine a Time Series Decomposition of Soil Temperature
'Time series' is a special case of data where each point is associated with a date or time, and usually there is some periodic or repeating pattern to the data. This is exactly what we see in the temperature "wave" both at the soil surface and at different depths, increasing and decreasing in a daily fashion (and a yearly fashion in areas outside of the tropics).
A Seasonal-Trend Decomposition ([Cleveland, 1979](#refs)), can be constructed from a time series and it results in three components: a trend component T~(m)~, a seasonal component S~(m)~, and a remainder R~(m)~, sometimes referred to as the irregular or random component:
$$Y_{(m)} = T_{(m)} + S_{(m)} + R_{(m)}$$
This type of analysis is often used in predicting trends in stock markets or housing prices but works nicely in Ecology as well.
Let’s look at our week’s worth of temperature data using a built-in Seasonal-Trend Decomposition time series function, `decompose()` (we can also use `stl()`, which is nearly identical).
First, we must turn our data into a “time series” object that R can recognize. To do this, we need to know the frequency of measurement (how many data points per period of time). In our Spokane data, soil temperature was measured every hour. So, if we want to look at the day level (a 24 hour period), we need to tell R to consider a frequency of 24 samples per day:
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Subset the data for seven days and only surface temperatures
time_series_data <- soil_temps_longer |>
filter(Date_time >= start_date & Date_time < end_date_1w) |>
filter(depth == 'cm_0') |>
select(value)
# Make the data into a time series object
time_series <- ts(time_series_data, frequency = 24)
```
Now, let’s 'decompose' our data and plot them:
```{r class.source = 'fold-show', message=FALSE, warning=FALSE}
# Seasonal Trend Decomposition
decomp_data <- decompose(time_series, "multiplicative") # can also use 'additive'
# Plot the data
plot(decomp_data)
```
The Decomposition output for our surface temperature data is four stacked plots:
1. **Observed** = the original data.
3. **Trend** = shifts in average over a longer period than the seasonal data.
2. **Seasonal** = the repeated data, the general "wave" of observed data.
4. **Random** = “noise” that can’t be accounted for.
Look at the y-axis magnitudes. The **Observed** data for this week ranges from about 10 to 60 degrees C. The **Trend** ranges from about 29 to 33 degrees, the **Seasonal** ranges from about 0.5 to 2, and the **Random** from about 0.8 to 1.5. We can multiply the Trend, Seasonal, and Noise values together to get back to the original, Observed data. (if we had used 'additive' in the `decompose()` function, then we would add the Trend, Seasonal, and Noise to get back to the original data)
We now can see that soil temperature can be expressed as a periodic wave, plus longer-term trends, and some unaccountable noise in the system.
***
# Explore the Simple Soil Temperature Model
The temperature model for predicting temperatures at different depths is based on a "simple" physics equation (that does not initially look very simple), below:
$$T_{z} = \bar{T}^{surf} + \Delta T^{surf} \times e^{-z/d} + cos( \frac{2\pi t}{p} - \frac{2\pi t_{max}}{p} - \frac{z}{d})$$
where:
* $T_{z}$ is the temperature at the depth of interest, $z$.
* $\bar{T}^{surf}$ is the average temperature at the surface.
* $\Delta T^{surf}$ is the amplitude of the temperature "wave" at the surface.
* $e$ is Euler's number, the constant 2.71828...
* $d$ is the **damping depth**, a very special number, in centimeters.
* $z$ is the depth of interest in centimeters. $z$ always shows up divided by the damping depth $\frac{z}{d}$ and occurs twice in the equation.
* $cos( \frac{2\pi t}{p})$ is the cosine with time $t$ over the period $p$.
* $t_{max}$ is the time at which the maximum temperature is observed (to make things neater).
The **damping depth** is a special, often empirically-derived, number that controls how deep the temperature wave reaches into the soil. It also controls the phase-shift (time delay) in the wave with depth. The **damping depth** depends on soil conditions: its composition and moisture content (moisture is a big part).
More technically, the **damping depth** is the depth where the temperature wave is 37% of the original at the surface (or reference depth). This 37% is $\frac{1}{e}$ and shows up in a lot of models of physical and chemical phenomena.
It is also useful to think about the **damping depth** and the period of oscillation being positively correlated: if the period is long (a whole day of the sun heating the surface of the soil, for example), then the temperature wave will reach deep into the soil and the **damping depth** will be large. If the period is very short, like a quick sun-fleck hitting the surface of the soil in a dense forest, then not much change in temperature will occur any deeper in the soil; the **damping depth** for this short period will be correspondingly small.
Let's play around with the model in another Shiny module, below. Try changing the depth of interest, $z$, and the **damping depth**, $d$, to see how the temperature at depth, $T_{z}$ and phase-shift both change with a simple sine-wave temperature fluctuation at the surface (using values like a hot July day):
## Interactive - Explore the soil temperature model
```{r, echo=FALSE, message=FALSE, warning=FALSE}
# generate fixed surface temperature wave of three periods, like in middle of July for magnitude
tmodel = seq(pi, 7 * pi, 0.05)
p <- 2 * pi
surf = cos(tmodel) * 30 + 35
e <- exp(1)
inputPanel(
sliderInput("depth", "Depth of interest, z:",
min = 0, max = 100,
value = 5, step = 1),
sliderInput("dampdepth", "Damping depth, d:",
min = 0, max = 20,
value = 5, step = 0.25)
)
renderPlot({
# Get depth and damping depth
z <- as.numeric(input$depth)
d <- as.numeric(input$dampdepth)
if(d == 0){d <- 0.01}
# equation is: Tz = mean(Tsurf)+amplitude(Tsurf)*e^(z/d)cos(2pi*t/p)
Tz <- mean(surf) + (max(surf)-min(surf))/2 * e^(-z/d) * cos(2*pi*tmodel/p - z/d)
modeldata <- as_tibble(cbind('time' = tmodel, 'surf' = surf, 'at_depth' = Tz))
modeldata_longer <- modeldata |>
pivot_longer(
cols = !time,
names_to = "depths"
) |>
mutate(depths = factor(depths, levels = c("surf", "at_depth")))
lineplot <- ggplot(modeldata_longer, aes(x = time, y = value, group = depths, color = depths)) +
geom_line() +
theme_light() +
theme(
legend.position = "bottom",
panel.border = element_blank(),
) +
scale_color_manual(name="",
labels = c("Surface", "at Depth 'z'"), values = c("black", "red"))
# add titles
lineplot <- lineplot +
labs(
title = "Model of surface temperature and at depth",
x = "Time",
y = "Degrees C"
)
# make theme prettier
lineplot <- lineplot +
theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.x = element_text(size=15),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# show the plot
lineplot
})
```
Notice that the greater ("deeper") the damping depth, the larger the temperature changes at depth in the soil. However, if you look deep enough (like at 100 cm, one meter), the temperature barely changes.
How does our model compare to the actual soil temperatures? If we look at those three days in July again and then make a model based on those same data (with the same mean and amplitude as the real data), we can plot the two on top of each other. We'll also use a "guessed at" damping depth of 4 cm (more on that guess, below). Let's see:
```{r, message=FALSE, warning=FALSE}
# Equation is as above: Tz = mean(Tsurf)+amplitude(Tsurf)*e^(z/d)cos(2pi*t/p)
# z is depth of interest, d is damping depth
# t is time, p is period
# Generate fixed surface temperature wave of three periods with same mean and amplitude as real data
soil_for_plotting_surf <- soil_for_plotting |>
filter(depth == "cm_0")
meanampsoilsurf <- soil_for_plotting_surf |>
summarise(mean = mean(value), amp = (max(value)-min(value))/2)
# Generate the sine wave for the surface temperature of the model
time_adjust <- 3*(6*pi)/(24*3) # signal needs to start at minimum
tmodex = seq(pi + time_adjust,7*pi + time_adjust,(6*pi)/(24*3)) # 3 days of observations
p <- 2*pi
surfmodex = cos(tmodex) * meanampsoilsurf$amp + meanampsoilsurf$mean
e <- exp(1)
z <- 5
d <- 4 # a guess
# Model the temperature at depth
Tz <- meanampsoilsurf$mean + meanampsoilsurf$amp * e^(-z/d) * cos(2*pi*tmodex/p - z/d)
# Pivot longer, then combine the two data sets for plotting
modeldata <- as_tibble(cbind('Date_time' = soil_for_plotting_surf$Date_time, 'msurf' = surfmodex, 'mdepth' = Tz))
modeldata_longer <- modeldata |>
pivot_longer(
cols = !Date_time,
names_to = "depth",
values_to = "value"
) |>
mutate(depth = factor(depth, levels = c("msurf", "mdepth")))
soil_and_model_for_plotting <-
rbind(soil_for_plotting, modeldata_longer)
# Plot the subset of data
lineplot <- ggplot(soil_and_model_for_plotting, aes(x = Date_time, y = value)) +
geom_line(aes(group = depth, color = depth, linetype = depth)) +
theme_light() +
theme(
legend.position = "bottom",
panel.border = element_blank(),
) +
scale_colour_manual(values = c("black", "red", "black", "red"),
breaks = c("cm_0",
"cm_05",
"msurf",
"mdepth"),
labels = c("Surface",
"5 cm",
"Model surface",
"Model 5 cm")) +
scale_linetype_manual(values = c("cm_0"="solid",
"cm_05"="solid",
"msurf"="dashed",
"mdepth"="dashed"),
breaks = c("cm_0",
"cm_05",
"msurf",
"mdepth"),
labels = c("Surface",
"5 cm",
"Model surface",
"Model 5 cm"))
# Add titles
lineplot <- lineplot +
labs(
title = "Soil and modeled temperatures",
subtitle = "Spokane, WA",
x = "Date",
y = "Degrees C",
color = "",
linetype = ""
)
# Make theme prettier
lineplot <- lineplot + theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.x = element_text(size=15),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
plot.subtitle = element_text( # font size "regular"
size = 15,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# Show the plot
lineplot
```
Perhaps not great, but not too bad. Any variation away from a regular sinusoidal pattern in the real world (especially that last day of the three) makes the model prediction worse. How can we improve on this?
***
# The Fourier Transform
```{r echo=FALSE, out.width = '20%', out.extra='style="float:left; padding:10px"'}
knitr::include_graphics("Fourier.png")
```
A Fourier analysis "decomposes" a periodic complicated signal into a sum of simple sines and cosines (the results of the decomposition is a set of "complex numbers", with Real and Imaginary parts, but we can deal with those easily).
“Fourier series were introduced by Joseph Fourier (1768–1830; the dead guy to the left) for the purpose of solving the heat equation in a metal plate. It led to a revolution in mathematics…” - Wikipedia
What makes this so interesting is:
1. Our actual soil temperature is a complicated signal that can be represented (as Fourier demonstrated) as a series of simple sine waves added together.
2. Our temperature model is just a simple sine wave calculation.
**Thus, it should be easy to combine the series of sine waves with our model applied to each wave, improving on the model!**
## Practice: Deconstruct and reconstruct a complex signal
But first, let's demonstrate what the Fourier decomposition does with a signal that we create (so it's easier to see the relationship, how it works).
Using the Shiny app below, you can create a complex signal (that might look like our soil temperature signal) from 3 sine waves, with a fixed offset of 0.3 (an arbitrary value). The period of the function is `2\pi`, the time it takes for one complete cycle.
Adjust the magnitude and periods of each additive wave, below. The period scale here is 0.5 = 4$\pi$, 1 = 2$\pi$, 2 = $\pi$, 3 = $\frac{2}{3}\pi$, 4 = $\frac{pi}{2}$, etc. The equation used is $magnitude \times cos( \frac{2\pi t}{period})$ We are constructing a **Complex wave signal** in the "time domain" (like changes in temperature over time).
## Interactive - Construct and deconstruct {.tabset}
```{r, echo=FALSE, message=FALSE, warning=FALSE}
inputPanel(
HTML("First Sine wave input:"),
sliderInput(inputId = "magnitude_1",
label = "Magnitude of first sine wave",
min = 0, max = 1, step = 0.1,
value = 0.5),
sliderInput(inputId = "period_1",
"Period of first sine wave",
min = 0, max = 5, step = 0.5,
value = 3.5)
)
inputPanel(
HTML("Second Sine wave input:"),
sliderInput(inputId = "magnitude_2",
label = "Magnitude of second sine wave",
min = 0, max = 1, step = 0.1,
value = 0.3),
sliderInput(inputId = "period_2",
"Period of second sine wave",
min = 0, max = 5, step = 0.5,
value = 2.5)
)
inputPanel(
HTML("Third Sine wave input:"),
sliderInput(inputId = "magnitude_3",
label = "Magnitude of third sine wave",
min = 0, max = 1, step = 0.1,
value = 0.7),
sliderInput(inputId = "period_3",
"Period of third sine wave",
min = 0, max = 5, step = 0.5,
value = 5))
```
First, make a complex wave that you are happy with.
Next, look at the **Fourier decomposition** tab to see how the data are represented in the "frequency domain". What are the main frequencies of the signal that you created and their magnitudes?
### Complex wave signal
```{r, echo=FALSE, message=FALSE, warning=FALSE}
tFFT = seq(pi,3*pi, 0.01)
renderPlot({
# strange things were happening with period = 0
if(input$period_1 != 0){
wave1 <- input$magnitude_1 * cos(input$period_1 * tFFT)
} else {
wave1 <- 0
}
if(input$period_2 != 0){
wave2 <- input$magnitude_2 * cos(input$period_2 * tFFT)
} else {
wave2 <- 0
}
if(input$period_3 != 0){
wave3 <- input$magnitude_3 * cos(input$period_3 * tFFT)
} else {
wave3 <- 0
}
wave <- wave1 + wave2 + wave3 + 0.3
signal <- as_tibble(cbind('time' = tFFT, 'wave' = wave))
lineplot <- ggplot(signal, aes(x = time, y = wave)) +
geom_line() +
theme_light() +
theme(
legend.position = "none",
panel.border = element_blank(),
)
# add titles
lineplot <- lineplot +
labs(
title = "Complex signal",
x = "Time",
y = "Degrees C"
)
# make theme prettier
lineplot <- lineplot +
theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.x = element_text(size=15),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# show the plot
lineplot
})
```
***
### Fourier decomposition
```{r, echo=FALSE, message=FALSE, warning=FALSE}
twopi <- parse(text = paste(2, "pi", sep = "*"))
renderPlot({
# strange things were happening with period = 0
if(input$period_1 != 0){
wave1 <- input$magnitude_1 * cos(input$period_1 * tFFT)
} else {
wave1 <- 0 * cos(tFFT)
}
if(input$period_2 != 0){
wave2 <- input$magnitude_2 * cos(input$period_2 * tFFT)
} else {
wave2 <- 0 * cos(tFFT)
}
if(input$period_3 != 0){
wave3 <- input$magnitude_3 * cos(input$period_3 * tFFT)
} else {
wave3 <- 0 * cos(tFFT)
}
wave <- wave1 + wave2 + wave3 + 0.3
# a series of fractions of frequencies
freqs <- c("C", as.character(round(1/seq(1:10),2)))
lbls <- c("C", "2*pi", paste("2*pi", seq(2, 10), sep = "/"))
# do an FFT of the wave & put into real values (remove imaginary)
fft_wave <- as_tibble(fft(wave)) |>
mutate('value' = Mod(value)) |>
mutate('value' = value/length(value)) |>
slice(1:11) |>
mutate('lbls' = lbls) |>
mutate('harm' = seq(1:11))
maxheight <- max(fft_wave$value)
barplot <- ggplot(fft_wave, aes(x = harm, y = value)) +
geom_col() +
geom_text(aes(label = lbls), parse = T, vjust = -0.5, colour = "black", size=5) +
ylim(0, maxheight + 0.05 * maxheight)
theme_light() +
theme(
legend.position = "none",
panel.border = element_blank(),
)
# add titles
barplot <- barplot +
labs(
title = "Fourier decomposition",
x = "Period",
y = "Magnitude"
)
# make theme prettier
barplot <- barplot +
theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
axis.text.x=element_blank(),
axis.ticks.x=element_blank(),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
# show the plot
barplot
})
```
The first column in the **Fourier decomposition** tab, labeled "C", is the constant, equal to our fixed offset of 0.3.
The second column is the longest period in our data set, 2$\pi$. If one of the waves chosen has that period (a period of "1" on the slider), then this will be large in magnitude. If not, it will be smaller.
The remaining columns are just fractions of the longest period, 2$\pi$, divided by 2, 3, 4, 5, etc., equivalent to shorter and shorter wavelengths.
Go back and try setting all the periods to the same value, for example "2" on the slider (equal to a period of $\pi$) and see how that is represented both in the "time domain" and the "frequency domain". Or set each one to a different whole number.
***
## Reconstruct a real temperature wave from the components
As you might guess, you can also go the other way, to construct a complex signal from the Fourier decomposition components. Depending on how many component periods you choose, the constructed signal will be a better and better fit to the original.
Let's look at those days again in July when it started sunny, became partly cloudy, and the soil temperature was weird. However, let's use a data set with more points this time, a 10-minute measurement data set to allow a better estimate. The data are plotted below to remind you.
The Fourier decomposition into its components has also already been done, below.
The Constant offset is about 30 degrees and the 3-day and 1.5-day components are relatively small (seen in the inset panel). The components in the main panel start with 24 hours, then that 24 hours is divided into smaller and smaller fractions (dividing by 2, by 3, by 4, ... ). We only show the first 12 periods here (there are 72 in total for 10-minute, 24-hour data sets because of the number of measurements in a day).
```{r, message=FALSE, warning=FALSE}
# get the 10-minute data for the three days in July at the surface and the 5 cm depth
min_soil_temps <- readRDS('min_soil_temps.RDS')
min_soil_temps_3d <- min_soil_temps |>
mutate(Date_time = mdy_hm(Date_time, tz = "America/Los_Angeles")) |>
select(Date_time, Surf_Temp_C, Soil_Temp_5) |>
filter(as.POSIXct(Date_time, tz = "America/Los_Angeles") >= start_date) |>
filter(as.POSIXct(Date_time, tz = "America/Los_Angeles") < end_date_3d) |>
pivot_longer(
cols = !Date_time,
names_to = "depth"
)
# take a look at the surface data
min_soil_temps_3d_surf <- min_soil_temps_3d |>
filter(depth == "Surf_Temp_C")
# a series of fractions of frequencies
lbls_3d <- c("C", "3d", "1.5 d", "24h", "12h", "8h", "6h")
lbls_24h <- c("24h", "12h", "8h", "6h", "4.8h", "4.0h", "3.4h", "3.0h", "2.7h", "2.4h", "2.2h", "2.0h")
# Do an FFT of the surface temperature & put into real values (remove imaginary, cut in half because output is symmetric)
fft_wave_3d <- as_tibble(fft(min_soil_temps_3d_surf$value)) |>
mutate('value' = Mod(value)) |>
mutate('value' = value/length(value))
fft_wave_24h <- fft_wave_3d |>
slice(4:15) |>
mutate('lbls' = lbls_24h) |>
mutate('harm' = seq(1:12))
fft_wave_3d <- fft_wave_3d |>
slice(1:7) |>
mutate('lbls' = lbls_3d) |>
mutate('harm' = seq(1:7))
# make a plot of the FFT
barplot <- ggplot(fft_wave_24h, aes(x = harm, y = value)) +
geom_col() +
geom_text(aes(label = lbls_24h), parse = F, vjust = -0.5, colour = "black", size=4) +
theme_light() +
theme(
legend.position = "none",
panel.border = element_blank(),
)
# add titles
barplot <- barplot +
labs(
title = "Fourier transform",
x = "Period",
y = "Magnitude"
)
# make theme prettier
barplot <- barplot +
theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.y = element_text(size=15),
axis.title.y = element_text(size=16),
axis.title.x = element_text(size=16),
axis.text.x=element_blank(),
axis.ticks.x=element_blank(),
plot.title = element_text( # font size "large"
size = 18,
hjust = 0, vjust = 1,
margin = margin(b = 15/2)
),
legend.title=element_text(size=14),
legend.text=element_text(size=14)
)
### inset ###
# make a plot of the 3d FFT
inset3d <- ggplot(fft_wave_3d, aes(x = harm, y = value)) +
geom_col() +
geom_text(aes(label = lbls_3d), parse = F, vjust = -0.5, colour = "black", size=4) +
theme_light() +
theme(
legend.position = "none",
panel.border = element_blank(),
) +
ylim(0,37)
# make theme prettier
inset3d <- inset3d +
theme(
panel.background = element_rect(fill = "white", colour = "white"),
panel.grid = element_line(colour = "grey92"),
panel.grid.minor = element_line(linewidth = rel(1)),
axis.text.y = element_text(size=9),
axis.title.y = element_blank(),
axis.title.x = element_blank(),
axis.text.x = element_blank(),
axis.ticks.x = element_blank(),
legend.title = element_blank(),
legend.text = element_blank()
)
# show the plots together
barplot + annotation_custom(ggplotGrob(inset3d), xmin = 5, xmax = 12,
ymin = 5, ymax = 11)
```
Clearly, the largest influence on our temperature is the 24-hour daily cycle (apart from the Constant offset).
To reconstruct the temperature "signal" $f(t)$ using just the components of the Fourier decomposition (above), we apply the following formula, summing up sines and cosines:
$$f(t) = F_{0} \phantom{.}\sum_{n=0}^{\text{max}\phantom{.}n}\phantom{.}[ 2a_{n}\cdot cos(n\phantom{.}\omega_{t}\phantom{.}t) - 2b_n \cdot sin(n\phantom{.}\omega_{t}\phantom{.}t)]$$
where:
* $F_{0}$ is the constant.
* $n$ is the component number.
* $a_{n}$ is the 'Real' part of the Fourier decomposition value.
* $b_{n}$ is the 'Imaginary' part of the Fourier decomposition value.
* $\omega$ = 2 $pi/period$.
* $t$ is the time.
Let's define a function with the `function()` command in R. This function will take 'n' number of Fourier decomposition components, plus a time and omega set of data, and return the reconstructed signal. The rebuilding of the signal from the component pieces is known as Fourier synthesis:
```{r class.source = "fold-show", message=FALSE, warning=FALSE}
# This is the synthesis of the components in the "frequency domain" into the "time domain"