This example is a really basic/unrealistic example that was made up to demonstrate the use of missing observations with the StateSpace.jl package.
For those of you that do not need/want the explanation of the model and the code, you can skip right to the end of this notebook where the entire section of code required to run this example is given.
NOTE: An explanation of the affects of the different missing observation implementations is given just prior to the final chunk of code.
In this example we have three (spherical) balloons that deflate over time. However we only record (noisy) measurements of the radius of only one of the balloons over time. We consider 3 cases for the unobserved balloon:
We assume the process is that the balloon's radius decreases by 10% of it's previous radius with each observation. Written mathematically, this is
$$ \mathbf{x}_i = 0.9 \times \mathbf{x}_{i-1} + \mathbf{v}_i, $$Where $\mathbf{x}_i$ represents the state at time step $i$ and $\mathbf{v}_i$ is the process noise error at time step $i$. The state here is the vector containing the radius of each balloon.
In [1]:
using StateSpace
using Distributions
using Gadfly
using DataFrames
using Colors
In [6]:
initialState = [30.0, 15.0, 15.0]
initialCov = eye(length(initialState))
initialGuess = MvNormal(initialState, initialCov)
Out[6]:
Here we'll generate the noisy observations of each balloon's radius. Notice that there are only measurements for the first balloon - We don't observe the other two balloons. To represent missing observations we use NaN
values in their place. So here is the code to generate the noisy observations along with the missing ones.
In [7]:
numObs = 30
numBalloons = length(initialState)
observationVariance = 2.0
observations = Matrix{Float64}(numBalloons,numObs)
trueValue = Vector{Float64}(numObs)
trueValue[1] = 0.9 * initialState[1]
observations[1,1] = trueValue[1] + randn()*sqrt(observationVariance)
observations[2:end,1] = [NaN, NaN]
for i in 2:numObs
trueValue[i] = 0.9*trueValue[i-1]
observations[1,i] = trueValue[i] + randn()*sqrt(observationVariance)
observations[2:end,i] = [NaN, NaN]
end
We now need to define the process and observation model according to the model described above.
In [8]:
#Define the process matrix and covariance matrix.
processMatrix = 0.9 * eye(initialCov)
processCov = eye(initialCov)
observationMatrix = eye(processMatrix) #Define the observation matrix
Out[8]:
For this example we're going to explicitly describe the relationship of the measurements bewteen the balloons with a correlation matrix (note that a correlation matrix is a normalized covariance matrix with values between -1 and 1 corresponding to perfect anticorrelation and perfect correlation respectively). However the Kalman filter works with covariance matrices. This means that we need to define a function to convert bewteen a correlation matrix and a covariance matrix. So we'll define this function now.
In [9]:
function corr2covar{T}(correlationMatrix::Matrix{T}, sigmaVector::Vector{T})
N = length(sigmaVector)
covarianceMatrix = zeros(correlationMatrix)
for j in 1:N
for i in 1:N
covarianceMatrix[i, j] = correlationMatrix[i,j] * sigmaVector[i] * sigmaVector[j]
end
end
return covarianceMatrix
end
Out[9]:
Now we can define a correlation matrix and the standard deviations of the balloon measurement errors which can be converted into a covariance matrix. In this matrix, we assume the balloon measurements are uncorrelated. (For case 3 we'll change this to ensure two of the balloons have correlated measurement errors).
In [10]:
#Correlation Matrix
observationCorr = [1.0 0.0 0.0;
0.0 1.0 0.0;
0.0 0.0 1.0]
observationΣ = [1.0, 3.0, 3.0] #measurement error standard deviations
observationCov = corr2covar(observationCorr, observationΣ) #Conversion to covariance matrix
Out[10]:
Now we have defined all of the parameters of our model we can create the linear state space model object
In [11]:
linSSM = LinearGaussianSSM(processMatrix, processCov, observationMatrix, observationCov)
Out[11]:
If we're completely uncertain of the measurement variances then we update the state with our predicted state. The error in doing this will propagte and we'll have an estimate with a relatively large uncertainty. The syntax for this case is the same as the usual syntax for the package since this is the default behaviour (and the safest, although not necessarily optimal).
In [12]:
filtState_c1 = filter(linSSM, observations, initialGuess)
Out[12]:
We have some information on the measurement error. If we think that we have some information about the measurement errors then we can take advantage of that by using it on our predicted observersation (i.e. using our measurement errors on our predicted observations that are generated by applying the observation model to the state that is predicted from the process model). The syntax for this requires an extra parameter for the filter method - 'true'. This tells the filter that we are happy to use our measurement error estimates.
In [13]:
filtState_c2 = filter(linSSM, observations, initialGuess, true)
Out[13]:
In [15]:
observationCorr = [1.0 0.6 0.0;
0.6 1.0 0.0;
0.0 0.0 1.0]
observationCov = corr2covar(observationCorr, observationΣ)
linSSM2 = LinearGaussianSSM(processMatrix, processCov, observationMatrix, observationCov)
Out[15]:
Now we can perform the Kalman filter with the correlated observation errors. Notice that the syntax is exactly the same as before.
In [16]:
filtState_c3 = filter(linSSM2, observations, initialGuess, true)
Out[16]:
In [17]:
#Balloon radius and 95% confidence for each balloon - case 1
b1_c1 = Vector{Float64}(numObs+1)
b2_c1 = Vector{Float64}(numObs+1)
b3_c1 = Vector{Float64}(numObs+1)
b1Sig_c1 = Vector{Float64}(numObs+1)
b2Sig_c1 = Vector{Float64}(numObs+1)
b3Sig_c1 = Vector{Float64}(numObs+1)
#Balloon radius and 95% confidence for each balloon - case 2
b1_c2 = Vector{Float64}(numObs+1)
b2_c2 = Vector{Float64}(numObs+1)
b3_c2 = Vector{Float64}(numObs+1)
b1Sig_c2 = Vector{Float64}(numObs+1)
b2Sig_c2 = Vector{Float64}(numObs+1)
b3Sig_c2 = Vector{Float64}(numObs+1)
#Balloon radius and 95% confidence for each balloon - case 3
b1_c3 = Vector{Float64}(numObs+1)
b2_c3 = Vector{Float64}(numObs+1)
b3_c3 = Vector{Float64}(numObs+1)
b1Sig_c3 = Vector{Float64}(numObs+1)
b2Sig_c3 = Vector{Float64}(numObs+1)
b3Sig_c3 = Vector{Float64}(numObs+1)
#Extract state estimates from each balloon for each case
for i in 1:numObs+1
currentState1 = filtState_c1.state[i]
b1_c1[i] = currentState1.μ[1]
b2_c1[i] = currentState1.μ[2]
b3_c1[i] = currentState1.μ[3]
b1Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[1,1])
b2Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[2,2])
b3Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[3,3])
currentState2 = filtState_c2.state[i]
b1_c2[i] = currentState2.μ[1]
b2_c2[i] = currentState2.μ[2]
b3_c2[i] = currentState2.μ[3]
b1Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[1,1])
b2Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[2,2])
b3Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[3,3])
currentState3 = filtState_c3.state[i]
b1_c3[i] = currentState3.μ[1]
b2_c3[i] = currentState3.μ[2]
b3_c3[i] = currentState3.μ[3]
b1Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[1,1])
b2Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[2,2])
b3Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[3,3])
end
#Create data frames for each balloon for each case
df_b1_c1 = DataFrame(
x = 1:numObs+1,
y = b1_c1,
ymin = b1_c1 - b1Sig_c1,
ymax = b1_c1 + b1Sig_c1,
f = "Filtered values"
)
df_b2_c1 = DataFrame(
x = 1:numObs+1,
y = b2_c1,
ymin = b2_c1 - b2Sig_c1,
ymax = b2_c1 + b2Sig_c1,
f = "Filtered values"
)
df_b3_c1 = DataFrame(
x = 1:numObs+1,
y = b3_c1,
ymin = b3_c1 - b3Sig_c1,
ymax = b3_c1 + b3Sig_c1,
f = "Filtered values"
)
df_b1_c2 = DataFrame(
x = 1:numObs+1,
y = b1_c2,
ymin = b1_c2 - b1Sig_c2,
ymax = b1_c2 + b1Sig_c2,
f = "Filtered values"
)
df_b2_c2 = DataFrame(
x = 1:numObs+1,
y = b2_c2,
ymin = b2_c2 - b2Sig_c2,
ymax = b2_c2 + b2Sig_c2,
f = "Filtered values"
)
df_b3_c2 = DataFrame(
x = 1:numObs+1,
y = b3_c2,
ymin = b3_c2 - b3Sig_c2,
ymax = b3_c2 + b3Sig_c2,
f = "Filtered values"
)
df_b1_c3 = DataFrame(
x = 1:numObs+1,
y = b1_c3,
ymin = b1_c3 - b1Sig_c3,
ymax = b1_c3 + b1Sig_c3,
f = "Filtered values"
)
df_b2_c3 = DataFrame(
x = 1:numObs+1,
y = b2_c3,
ymin = b2_c3 - b2Sig_c3,
ymax = b2_c3 + b2Sig_c3,
f = "Filtered values"
)
df_b3_c3 = DataFrame(
x = 1:numObs+1,
y = b3_c3,
ymin = b3_c3 - b3Sig_c3,
ymax = b3_c3 + b3Sig_c3,
f = "Filtered values"
)
Out[17]:
Now we obtain the 3 colours required for each balloon
In [18]:
#Get the correct colours for the plots
n = 3
getColors = distinguishable_colors(n, Color[LCHab(70, 60, 240)],
transform=c -> deuteranopic(c, 0.5),
lchoices=Float64[65, 70, 75, 80],
cchoices=Float64[0, 50, 60, 70],
hchoices=linspace(0, 330, 24))
Out[18]:
In [22]:
plt_c1 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 1")
)
display(plt_c1)
In [20]:
plt_c2 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 2")
)
display(plt_c2)
In [24]:
plt_c3 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 3")
)
display(plt_c3)
In the first plot (case 1) we have the case where rather than make any inference about the state of the system using any measurements we may have, we simply just assume that the new state is exactly what we predicted - that is the case for all three balloons. This is made more obvious by looking at the blue curve which doesn't use any information from the measurements. Notice in this case that the errors on the estimate are propagated and so the shaded region (which represents the 95% confidence interval) gets larger as time goes on. This is the default behaviour of this package. This is because it's the safest option if you have no information at all about missing measurements. As highlighted in this example, all elements in the state vector are assumed to follow the predicted value, even if there are partial measurements at a given time interval i.e. in this case, even though we observed measurements for balloon 1, we didn't use those measurements because the other balloons had missing values.
In this case (the second plot) we notice that the blue line actually seems to use information from the measurements. This is because we have told the filter to use any measurements that we are given. If there are missing values then these observations are estimated. (so make sure you know what you are doing if you use this option). Since the balloon measurement errors are uncorrelated, the radius for balloons 2 and 3 are exactly the same (again). Their confidence region is smaller than in case 1 (i.e. we're more sure about the radius), simply because of the size of the measurement error standard deviation we gave earlier. If these values were increased for balloon 2 and/or 3, then we would see an increase in the interval. So I reiterate, if you use this option, you have to make sure you know what you're doing. You need an idea about the measurement error that you expect to have given your prediction.
This case uses exactly the same algorithm as case two - i.e. any of the actual observations are used as normal and the missing observations are estimated. But in this case the measurement errors of balloon 1 and balloon 2 were correlated. This means that we use this information to update the state of balloon 2. In the third plot you can see that balloon 2 deviates from what would usually be predicted (i.e. it deviates from the estimate of balloon 3). You can play about with the correlation matrix defined earlier to investigate how the correlation affects the state estimate.
For those that just want the code, without the explanation, you have come to the right place. Here it is:
In [1]:
using StateSpace
using Distributions
using Gadfly
using DataFrames
using Colors
#Set guess of the initial state
initialState = [30.0, 15.0, 15.0]
initialCov = eye(length(initialState))
initialGuess = MvNormal(initialState, initialCov)
#Generate noisy Observations
numObs = 30
numBalloons = length(initialState)
observationVariance = 2.0
observations = Matrix{Float64}(numBalloons,numObs)
trueValue = Vector{Float64}(numObs)
trueValue[1] = 0.9 * initialState[1]
observations[1,1] = trueValue[1] + randn()*sqrt(observationVariance)
observations[2:end,1] = [NaN, NaN]
for i in 2:numObs
trueValue[i] = 0.9*trueValue[i-1]
observations[1,i] = trueValue[i] + randn()*sqrt(observationVariance)
observations[2:end,i] = [NaN, NaN]
end
#Describe Kalman Filter parameters
processMatrix = 0.9 * eye(initialCov)
processCov = eye(initialCov)
#Define function to convert a correlation matrix to a covariance matrix
function corr2covar{T}(correlationMatrix::Matrix{T}, sigmaVector::Vector{T})
N = length(sigmaVector)
covarianceMatrix = zeros(correlationMatrix)
for j in 1:N
for i in 1:N
covarianceMatrix[i, j] = correlationMatrix[i,j] * sigmaVector[i] * sigmaVector[j]
end
end
return covarianceMatrix
end
observationMatrix = eye(processMatrix) #Define observation matrix
#define the correlation matrix
observationCorr = [1.0 0.0 0.0;
0.0 1.0 0.0;
0.0 0.0 1.0]
#Now we define a vector containing standard deviation values
observationΣ = [1.0, 3.0, 3.0]
observationCov = corr2covar(observationCorr, observationΣ) #convert correlation matrix to covariance matrix
#Create the linear StateSpace model.
linSSM = LinearGaussianSSM(processMatrix, processCov,
observationMatrix, observationCov)
#Section: Perform Kalman Filter for cases 1 and 2
#Case 1: Our best guess is the prior prediction
filtState_c1 = filter(linSSM, observations, initialGuess)
#Case 2: We have some information on the measurement error
filtState_c2 = filter(linSSM, observations, initialGuess, true)
#Section: Perform Kalman Filter for case 3
#Case 3: We know that there is some correlation between balloons 1 and 2.
observationCorr = [1.0 0.6 0.0;
0.6 1.0 0.0;
0.0 0.0 1.0]
observationCov = corr2covar(observationCorr, observationΣ) #convert correlation matrix to covariance matrix
#Create the linear StateSpace model with new observation covariance matrix.
linSSM2 = LinearGaussianSSM(processMatrix, processCov,
observationMatrix, observationCov)
filtState_c3 = filter(linSSM2, observations, initialGuess, true)
#Plot Filtered results
#Balloon radius and 95% confidence for each balloon - case 1
b1_c1 = Vector{Float64}(numObs+1)
b2_c1 = Vector{Float64}(numObs+1)
b3_c1 = Vector{Float64}(numObs+1)
b1Sig_c1 = Vector{Float64}(numObs+1)
b2Sig_c1 = Vector{Float64}(numObs+1)
b3Sig_c1 = Vector{Float64}(numObs+1)
#Balloon radius and 95% confidence for each balloon - case 2
b1_c2 = Vector{Float64}(numObs+1)
b2_c2 = Vector{Float64}(numObs+1)
b3_c2 = Vector{Float64}(numObs+1)
b1Sig_c2 = Vector{Float64}(numObs+1)
b2Sig_c2 = Vector{Float64}(numObs+1)
b3Sig_c2 = Vector{Float64}(numObs+1)
#Balloon radius and 95% confidence for each balloon - case 3
b1_c3 = Vector{Float64}(numObs+1)
b2_c3 = Vector{Float64}(numObs+1)
b3_c3 = Vector{Float64}(numObs+1)
b1Sig_c3 = Vector{Float64}(numObs+1)
b2Sig_c3 = Vector{Float64}(numObs+1)
b3Sig_c3 = Vector{Float64}(numObs+1)
#Extract state estimates from each balloon for each case
for i in 1:numObs+1
currentState1 = filtState_c1.state[i]
b1_c1[i] = currentState1.μ[1]
b2_c1[i] = currentState1.μ[2]
b3_c1[i] = currentState1.μ[3]
b1Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[1,1])
b2Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[2,2])
b3Sig_c1[i] = 2*sqrt(currentState1.Σ.mat[3,3])
currentState2 = filtState_c2.state[i]
b1_c2[i] = currentState2.μ[1]
b2_c2[i] = currentState2.μ[2]
b3_c2[i] = currentState2.μ[3]
b1Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[1,1])
b2Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[2,2])
b3Sig_c2[i] = 2*sqrt(currentState2.Σ.mat[3,3])
currentState3 = filtState_c3.state[i]
b1_c3[i] = currentState3.μ[1]
b2_c3[i] = currentState3.μ[2]
b3_c3[i] = currentState3.μ[3]
b1Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[1,1])
b2Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[2,2])
b3Sig_c3[i] = 2*sqrt(currentState3.Σ.mat[3,3])
end
#Create data frames for each balloon for each case
df_b1_c1 = DataFrame(
x = 1:numObs+1,
y = b1_c1,
ymin = b1_c1 - b1Sig_c1,
ymax = b1_c1 + b1Sig_c1,
f = "Filtered values"
)
df_b2_c1 = DataFrame(
x = 1:numObs+1,
y = b2_c1,
ymin = b2_c1 - b2Sig_c1,
ymax = b2_c1 + b2Sig_c1,
f = "Filtered values"
)
df_b3_c1 = DataFrame(
x = 1:numObs+1,
y = b3_c1,
ymin = b3_c1 - b3Sig_c1,
ymax = b3_c1 + b3Sig_c1,
f = "Filtered values"
)
df_b1_c2 = DataFrame(
x = 1:numObs+1,
y = b1_c2,
ymin = b1_c2 - b1Sig_c2,
ymax = b1_c2 + b1Sig_c2,
f = "Filtered values"
)
df_b2_c2 = DataFrame(
x = 1:numObs+1,
y = b2_c2,
ymin = b2_c2 - b2Sig_c2,
ymax = b2_c2 + b2Sig_c2,
f = "Filtered values"
)
df_b3_c2 = DataFrame(
x = 1:numObs+1,
y = b3_c2,
ymin = b3_c2 - b3Sig_c2,
ymax = b3_c2 + b3Sig_c2,
f = "Filtered values"
)
df_b1_c3 = DataFrame(
x = 1:numObs+1,
y = b1_c3,
ymin = b1_c3 - b1Sig_c3,
ymax = b1_c3 + b1Sig_c3,
f = "Filtered values"
)
df_b2_c3 = DataFrame(
x = 1:numObs+1,
y = b2_c3,
ymin = b2_c3 - b2Sig_c3,
ymax = b2_c3 + b2Sig_c3,
f = "Filtered values"
)
df_b3_c3 = DataFrame(
x = 1:numObs+1,
y = b3_c3,
ymin = b3_c3 - b3Sig_c3,
ymax = b3_c3 + b3Sig_c3,
f = "Filtered values"
)
#Get the correct colours for the plots
n = 3
getColors = distinguishable_colors(n, Color[LCHab(70, 60, 240)],
transform=c -> deuteranopic(c, 0.5),
lchoices=Float64[65, 70, 75, 80],
cchoices=Float64[0, 50, 60, 70],
hchoices=linspace(0, 330, 24))
#Plot the balloon radius estimates for each case
plt_c1 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c1, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 1")
)
display(plt_c1)
plt_c2 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c2, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 2")
)
display(plt_c2)
plt_c3 = plot(
layer(x=2:numObs+1, y=observations[1,:], Geom.point, Theme(default_color=getColors[1])),
layer(df_b1_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[1])),
layer(df_b2_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[2])),
layer(df_b3_c3, x=:x, y=:y, ymin=:ymin, ymax=:ymax, Geom.line, Geom.ribbon, Theme(default_color=getColors[3])),
Guide.xlabel("Measurement Number"), Guide.ylabel("Balloon Radius (cm)"),
Guide.manual_color_key("Colour Key",["Balloon 1", "Balloon 2","Balloon 3"],[getColors[1],getColors[2],getColors[3]]),
Guide.title("Missing Observations Example - Case 3")
)
display(plt_c3)