#Toolkit version number
r.tool.name = "Model Evaluation"
r.eval.toolkit.version = "5.4"
tkm.version = "tkmfileversion24"
vsm.version="VSMfileversion24"
svn.version.eval = "$ProjectRevision: SVN 13255 (2026-06-03) $"
svn.prj.name.eval = "$ProjectName: /cerc_cerc/Products/Utilities/ModelEvaluationToolkit/Tags/ModelEvaluationToolkit_5_4_1 $"


#Suppress warnings that we don't want to worry the user with
options(warn=-1)

RHC.n = 26 # Robust highest concentration: number of data points that represent the highest values (tail) of the concentration distribution
# RHC = X(n) + (\bar{X} - X(n)) * ln( (3n-1)/2 ) 
#  where X are the (n-1) largest concentrations and X(n) is the n^th largest concentration

#Load common functions
argv = commandArgs(trailingOnly = FALSE)
install_dir = dirname(substring(argv[grep("--file=", argv)], 8))
source(paste(install_dir, "CommonFunctions.r", sep=.Platform$file.sep))
#Check CommonFunctions.r is compatible with this script
if (!exists("r.common.functions.version")){
  stop("The file CommonFunctions.r is incompatible with this version of the Model Evaluation tool.")
  quit("no")
} else if (exists("r.common.functions.version")){
  if (r.common.functions.version != r.eval.toolkit.version){
    stop("The file CommonFunctions.r is incompatible with this version of the Model Evaluation tool.")
    quit("no")
  }
}

#Set up input and output
settings.eval.file = commandArgs(trailingOnly=TRUE)[[1]]
setwd(dirname(settings.eval.file))
# Determine if the settings.eval.file is a TKM for VSM file 
settings.file.type=tolower(substr(settings.eval.file, nchar(settings.eval.file)-3, nchar(settings.eval.file)))
# Check settings file type is valid and get output.file.path.root 
eval.output.root = substr(settings.eval.file,0,nchar(settings.eval.file)-4)
if (settings.file.type ==".vsm"){
  settings.file.type="VSM"
} else if (settings.file.type==".tkm"){
  settings.file.type="TKM"
} else {
  stop("The settings file is not a recognised file type. Settings files for the Model Evaluation Tool must be either TKM or VSM.")
}

log.file = file(paste(eval.output.root,".log",sep=""),open="wt")
sink(file=log.file,type="output",split=TRUE)
sink(file=log.file,type="message")
cat(paste("Model Evaluation Toolkit version ",r.eval.toolkit.version,": ",r.tool.name," Tool \n",sep=""))
if (length(grep("$ProjectRevision: ",svn.version.eval,fixed=TRUE))>0) {
  svn.version.eval.numberonly=sub("$ProjectRevision: ","",svn.version.eval,fixed=TRUE)
  svn.version.eval.numberonly=sub(" $","",svn.version.eval.numberonly,fixed=TRUE)
  cat(paste("Internal version ",svn.version.eval.numberonly," \n",sep=""))
} else {
  cat("CERC INTERNAL USE ONLY\n")
}
cat(paste("Running in",R.version$version.string,"\n"))
cat(paste(date(),"\n\n",sep=""))

#Check length of eval.output.root to ensure it is not too long (exceeds 200 characters) which could cause problems later
if(nchar(eval.output.root)>200){
  if (settings.file.type=="TKM"){
    stop(paste("The TKM file path has too many characters: ", nchar(eval.output.root),". This may cause issues with saving outputs later on. \nPlease reduce the length of the TKM file path to be less than 200 characters.", sep=""))
    quit("no")
  } else if(settings.file.type=="VSM") {
    stop(paste("The VSM file path has too many characters: ", nchar(eval.output.root),". This may cause issues with saving outputs later on. \nPlease reduce the length of the VSM file path to be less than 200 characters.", sep=""))
    quit("no") 
  }
} 

#Read in settings file
if (!file.exists(settings.eval.file)) {
  if (settings.file.type=="TKM") {
    stop(paste("The .TKM file specified does not exist:",settings.eval.file))
    quit("no")   
  } else if (settings.file.type=="VSM"){
    stop(paste("The .VSM file specified does not exist:",settings.eval.file))
    quit("no") 
  }
}


#If settings file is a VSM file, remove commented out lines
if(settings.file.type=="VSM"){
  VSM.data=readLines(settings.eval.file)
  VSM.data=VSM.data[-which(startsWith(VSM.data,"#"))]  
  tkm.data=VSM.data
} else {
  tkm.data = readLines(settings.eval.file)
}


#getting the data from tkm
tkm.data = sub("PATH = \"","",as.character(tkm.data),fixed=TRUE)
tkm.data = sub("\"","",as.character(tkm.data),fixed=TRUE)

#Check that the tkm/VSMfile is for the right version
if (settings.file.type=="TKM"){
  tkm.version.in = as.character(tkm.data[1])
  if (tkm.version.in != tkm.version){
    stop(paste("The TKM file version (",tkm.version.in,") does not match the expected version(",tkm.version,")",sep=""))
    quit("no")
  }
} else if (settings.file.type=="VSM"){
  vsm.version.in = as.character(tkm.data[1])
  if (vsm.version.in != vsm.version){
    stop(paste("The VSM file version (",vsm.version.in,") does not match the expected version(",vsm.version,")",sep=""))
    quit("no")
  }
}


#LOAD R WORKSPACE
nProject = which(tkm.data == "Project")
data.input.workspace = tkm.data[nProject+2]
cat("Loading workspace...\n")
if (data.input.workspace=="") {
  stop("No workspace selected.")
  quit("no")
}
data.loaded=tryCatch(load(data.input.workspace),error=function(e) return(NULL))
if (is.null(data.loaded)){
  stop("The workspace could not be imported.")
  quit("no")
}

#check R script compatibility
if (!exists("r.data.toolkit.version")){
  stop("The selected workspace is incompatible with this version of the Model Evaluation tool.")
  quit("no")
} else if (exists("r.data.toolkit.version")){
  if (r.data.toolkit.version != r.eval.toolkit.version){
    stop("The selected workspace is incompatible with this version of the Model Evaluation tool.")
    quit("no")
  }
}

#Maintain compatibility with workspaces generated before the change to user-formatted station names in output (CR0117)
if (!("station.output.name" %in% names(all.data))) {
  all.data$station.output.name=all.data$station
}
if (!("station.output.name" %in% names(stations))) {
  stations$station.output.name=stations$name
}

#Finding line numbers for the data
nProject = which(tkm.data == "Project")
nRSettings = which(tkm.data=="RSettings")
nModStatPoll = which(tkm.data == "Models, Stations and Pollutants")
nGroupFilt = which(tkm.data == "Grouping and Filtering")
nConcEval = which(tkm.data == "Concentration Evaluation")
nForeIndEval = which(tkm.data == "Forecast index Evaluation")
nOutput = which(tkm.data == "Output")

#PROJECT
project.name = tkm.data[nProject+3]
if (project.name==""){
  stop("No project name entered")
  quit("no")
}
if (grepl(",",project.name)) {
  stop("Project name must not contain commas")
  quit("no")
}

#OTHER DATA
bAllModels = tkm.data[nModStatPoll+2]=="0"
bSelectedModels = tkm.data[nModStatPoll+2]=="1"
selected.models = tkm.data[nModStatPoll+3]
if (bSelectedModels & selected.models=="") {
  stop("No model name entered.")
  quit("no")
}
bEnsemble = tkm.data[nModStatPoll+4]=="Y"
bAllNetworks = tkm.data[nModStatPoll+5]=="0"
bSelectedNetworks = tkm.data[nModStatPoll+5]=="1"
selected.networks=tkm.data[nModStatPoll+6]
if (bSelectedNetworks & selected.networks=="") {
  stop("No station network entered and selected networks has been chosen for analysis.")
  quit("no")
}
bAllStationTypes = tkm.data[nModStatPoll+7]=="0"
bSelectedStationTypes = tkm.data[nModStatPoll+7]=="1"
selected.station.types = tkm.data[nModStatPoll+8]
if (bSelectedStationTypes & selected.station.types=="") {
  stop("No station type entered.")
  quit("no")
}
bAllStations = tkm.data[nModStatPoll+9]=="0"
bSelectedStations = tkm.data[nModStatPoll+9]=="1"
selected.stations = tkm.data[nModStatPoll+10]
if (bSelectedStations & selected.stations=="") {
  stop("No station entered.")
  quit("no")
}
bAllPols = tkm.data[nModStatPoll+11]=="0"
bSelectedPollutants = tkm.data[nModStatPoll+11]=="1"
selected.pollutants = tkm.data[nModStatPoll+12]
composite.pollutants=tkm.data[nModStatPoll+13]
composite.pollutants=trimws(composite.pollutants, which="both")
if (nchar(composite.pollutants)>0){
  bCompositePollutants=TRUE
  composite.pollutants=tolower(composite.pollutants)
} else {
  bCompositePollutants=FALSE
}
bSetNO2asLowerNOx=tkm.data[nModStatPoll+14]=="Y"
NO2.SetNO2asLowerNOx=tkm.data[nModStatPoll+15]
NOx.SetNO2asLowerNOx=tkm.data[nModStatPoll+16]
bDateSubset = tkm.data[nModStatPoll+17]=="Y"
start.date.string = tkm.data[nModStatPoll+18]
end.date.string = tkm.data[nModStatPoll+19]
bRemoveStations = tkm.data[nModStatPoll+20]=="Y"
stations.to.remove = tkm.data[nModStatPoll+21]
if (bSelectedPollutants & selected.pollutants=="") {
  stop("No pollutant name entered.")
  quit("no")
}
if (bSelectedStationTypes & bSelectedStations) {
  stop("Either enter a station type or selected stations, not both.")
  quit("no")  
}
if (bSelectedNetworks & bSelectedStations) {
  stop("Either enter a station network or selected stations, not both.")
  quit("no")  
}
# Check that if set no2 as lower limit for nox has been selected, that a nox and no2 pollutant has been input 
if (bSetNO2asLowerNOx &(nchar(NO2.SetNO2asLowerNOx)==0 | nchar(NOx.SetNO2asLowerNOx)==0)){
  if((nchar(NOx.SetNO2asLowerNOx)==0) & (nchar(NO2.SetNO2asLowerNOx)==0)){
    stop("Set observed NO2 as lower limit for observed NOx has been selected, but no NO2 or NOx pollutant has been specified.")
    quit("no")
  } else if (nchar(NOx.SetNO2asLowerNOx)==0){
    stop("Set observed NO2 as lower limit for observed NOx has been selected, but no NOx pollutant has been specified.")
    quit("no")
  } else if (nchar(NO2.SetNO2asLowerNOx)==0 ){
    stop("Set observed NO2 as lower limit for observed NOx has been selected, but no NO2 pollutant has been specified.")
    quit("no")
  }
}

#Define single values for filtering, to be set to TRUE if only one model/pollutant/station is selected
bSingleModel=FALSE
bSinglePollutant=FALSE
bSingleStation=FALSE 
bSingleNetwork=FALSE

#Convert date strings to date values
if (bDateSubset) {
  start.date.req = as.POSIXct(start.date.string,format="%Y,%m,%d,%H",tz="GMT")
  end.date.req = as.POSIXct(end.date.string,format="%Y,%m,%d,%H",tz="GMT")
  if (end.date.req<start.date.req) {
    stop("The end date required must be later than or the same as the start date required.")
    quit("no")    
  }
}
if (bRemoveStations & stations.to.remove=="") {
  stop("No stations to remove listed.")
  quit("no")
}

#GROUPING and FILTERING
Grouping.and.filtering = tkm.data[nGroupFilt+2]


if (Grouping.and.filtering == "0"){
  #group by station, filter by pollutant and model
  bGrpStnName = TRUE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = TRUE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "1"){
  #group by station, filter by pollutant, station type and model
  bGrpStnName = TRUE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = TRUE
  bFilterStnType = TRUE
}else if (Grouping.and.filtering == "2"){
  #group by station, filter by model and station type
  bGrpStnName = TRUE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = FALSE
  bFilterStnType = TRUE
}else if (Grouping.and.filtering == "3"){
  #group by station name, filter by model
  bGrpStnName = TRUE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = FALSE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "4"){
  #group by station type, filter by model and pollutant
  bGrpStnName = FALSE
  bGrpStnType = TRUE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = TRUE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "5"){
  #group by station type, filter by model 
  bGrpStnName = FALSE
  bGrpStnType = TRUE 
  bGrpPollutant = FALSE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = FALSE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "6"){
  #group by model, filter by pollutant and station type
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = TRUE
  bFilterModel = FALSE
  bFilterPollutant = TRUE
  bFilterStnType = TRUE
}else if (Grouping.and.filtering == "7"){
  #group by model, filter by pollutant
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = TRUE
  bFilterModel = FALSE
  bFilterPollutant = TRUE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "8"){
  #group by model, filter by station type
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = TRUE
  bFilterModel = FALSE
  bFilterPollutant = FALSE
  bFilterStnType = TRUE
}else if (Grouping.and.filtering == "9"){
  #group by model, no filtering
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = FALSE
  bGrpModel = TRUE
  bFilterModel = FALSE
  bFilterPollutant = FALSE
  bFilterStnType = FALSE
}else if (Grouping.and.filtering == "10"){
  #group by pollutant, filter by model and station type
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = TRUE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = FALSE
  bFilterStnType = TRUE
}else if (Grouping.and.filtering == "11"){
  #Group by pollutant, filter by model
  bGrpStnName = FALSE
  bGrpStnType = FALSE 
  bGrpPollutant = TRUE
  bGrpModel = FALSE
  bFilterModel = TRUE
  bFilterPollutant = FALSE
  bFilterStnType = FALSE
}
bFilterModelRGB=bFilterModel

#CONCENTRATION EVALUATION
bAssessConcs = tkm.data[nConcEval+1]=="Y"
output.averaging.times.file = tkm.data[nConcEval+2]
data.threshold.conc = as.numeric(tkm.data[nConcEval+3])
station.threshold.conc = as.numeric(tkm.data[nConcEval+4])
facn.int = as.numeric(tkm.data[nConcEval+5])
bPersistence = tkm.data[nConcEval+6]=="Y"
persistence.day = as.numeric(tkm.data[nConcEval+7])
bOpenairModStats = tkm.data[nConcEval+8]=="Y"
specify.model.rgb.colours = tkm.data[nConcEval+9]=="Y"
model.rgb.colours.filepath = tkm.data[nConcEval+10]
bSpatialMetrics = tkm.data[nConcEval+11]=="Y"
bBoxWhisker = tkm.data[nConcEval+12]=="Y"
bBoxOutliers = tkm.data[nConcEval+13]=="Y"
bBoxLog = tkm.data[nConcEval+14]=="Y"
bScatterPlot = tkm.data[nConcEval+15]=="Y" & tkm.data[nConcEval+18]=="1"
bFreqScatterPlot = tkm.data[nConcEval+15]=="Y" & tkm.data[nConcEval+18]=="0"
bScatterLog = tkm.data[nConcEval+16]=="Y"
bScatterBestLine = tkm.data[nConcEval+17]=="Y"
bStatisticalScatter= tkm.data[nConcEval+19]=="Y"
bStatisticalScatterMean =tkm.data[nConcEval+20]=="0"
bStatisticalScatterMax =tkm.data[nConcEval+20]=="1"
bStatisticalScatterSD=tkm.data[nConcEval+20]=="2"
bStatisticalScatterAOT40.forests=tkm.data[nConcEval+20]=="3"
bStatisticalScatterAOT40.crops=tkm.data[nConcEval+20]=="4"
bStatisticalScatterSOMO35=tkm.data[nConcEval+20]=="5"
bStatisticalScatterPercentile=tkm.data[nConcEval+20]=="6"
statistical.scatter.point.size = as.numeric(tkm.data[nConcEval+21])
bSSstatlabs =tkm.data[nConcEval+22]=="Y"
bSSstntypecols = tkm.data[nConcEval+23]=="Y"
bSSstntypesym=tkm.data[nConcEval+24]=="Y"
bSSstntypegraph=tkm.data[nConcEval+25]=="Y"
bSSreferencelines=tkm.data[nConcEval+26]=="Y"
reference.gradients=tkm.data[nConcEval+27]
reference.line.types=tkm.data[nConcEval+28]
bTimeVariation=tkm.data[nConcEval+29]=="Y"
bTimeVariationAll=tkm.data[nConcEval+30]=="0"
bTimeVariationday.hour=tkm.data[nConcEval+30]=="1"
bTimeVariationhour=tkm.data[nConcEval+30]=="2"
bTimeVariationday=tkm.data[nConcEval+30]=="3"
bTimeVariationmonth=tkm.data[nConcEval+30]=="4"
bTimeVariationMultiPlots=tkm.data[nConcEval+31]=="Y"
bQQPlot = tkm.data[nConcEval+32]=="Y"
bQQLog = tkm.data[nConcEval+33]=="Y"
bTimePlot = tkm.data[nConcEval+34]=="Y"
bTargetPlot = tkm.data[nConcEval+35]=="Y"
bTargetPlotlabs=tkm.data[nConcEval+36]=="Y"
bTargetPlotFiltering.pol.subplots=tkm.data[nConcEval+37]=="0"
bTargetPlotFiltering.pol.col=tkm.data[nConcEval+37]=="1"
bTargetPlotFiltering.pol.sym=tkm.data[nConcEval+37]=="2"
bTargetPlotFiltering.pol.colsym=tkm.data[nConcEval+37]=="3"
bTargetPlotFiltering.model.subplots=tkm.data[nConcEval+38]=="0"
bTargetPlotFiltering.model.col=tkm.data[nConcEval+38]=="1"
bTargetPlotFiltering.model.sym=tkm.data[nConcEval+38]=="2"
bTargetPlotFiltering.model.colsym=tkm.data[nConcEval+38]=="3"
bTargetPlotFiltering.stntype.subplots=tkm.data[nConcEval+39]=="0"
bTargetPlotFiltering.stntype.col=tkm.data[nConcEval+39]=="1"
bTargetPlotFiltering.stntype.sym=tkm.data[nConcEval+39]=="2"
bTargetPlotFiltering.stntype.colsym=tkm.data[nConcEval+39]=="3"
TargetPlot.uncertainty.file = tkm.data[nConcEval+40]

if (bAssessConcs) {
  #Giving an error if an acceptable persistence forecast day is not provided
  if (bPersistence & persistence.day < 1){
    stop("Persistence forecast day must be at least 1.\n")
    quit("no")
  }
  #Giving an error if model.rgb.colours.filepath is not given when it needs to be
  if (specify.model.rgb.colours & model.rgb.colours.filepath==""){
    stop("No model RGB colours file given.\n")
    quit("no")
  }
  #check filtering options for stn type on statistical scatter
  if (bStatisticalScatter & bFilterStnType ) {
    if (!bSSstntypecols & !bSSstntypesym & !bSSstntypegraph){
      stop("Filter by station type has been selected, but no station type filter option \n has been selected for the Statistical Scatter plot. \n", "Please select one or more of the Statistical Scatter filter by station type options \n","(e.g. by colour, symbol or graph) on the concentration evaluation screen.")
      quit("no")
    }
  }
  if (bStatisticalScatter & !bFilterStnType) {
    if (bSSstntypecols | bSSstntypesym | bSSstntypegraph){
      cat("   Warning: Filtering by station type has not been selected on the Grouping and Filtering screen, \n so Statistical Scatter plots will not be filtered by station type.\n")
      bSSstntypecols=FALSE
      bSSstntypesym=FALSE
      bSSstntypegraph=FALSE
    }
  } 
  
  # Check that if statistical scatter plot is selected with ozone statistic the correct averaging time and ozone pollutant are present
  if (bStatisticalScatter & (bStatisticalScatterSOMO35|bStatisticalScatterAOT40.crops|bStatisticalScatterAOT40.forests)){
    if (!(any(pollutants$ozone %in% "yes"))){
      cat("Warning: Statistical Scatter Plot will not be output as an ozone statistic (AOT40/SOMO35) has been selected as the statistic but no ozone pollutant is present. \n")
      bStatisticalScatter=FALSE 
      bStatisticalScatterAOT40.crops=FALSE
      bStatisticalScatterAOT40.forests=FALSE
      bStatisticalScatterSOMO35=FALSE
    }
  }
  
  #Check the station data coverage threshold 
  if (!(station.threshold.conc==0)){
    bApplyStnThresholdconc = TRUE
  } else {
    bApplyStnThresholdconc = FALSE
  }
  
  #Check output averaging times file has been selected and that it contains no commas
  if (output.averaging.times.file=="") {
    stop("No output averaging times file selected.")
    quit("no")
  }
  if (grepl(",",output.averaging.times.file)) {
    stop("Output averaging times file path must not contain commas")
    quit("no")
  }
  if (Grouping.and.filtering != "0" & Grouping.and.filtering != "1" & bTargetPlot){
    #Only want to produce a target plot if the grouping and filtering options are "0" or "1"
    bTargetPlot=FALSE
    cat("Grouping and filtering options are not suitable for a target plot. No target plot will be output \n")
  }
  # check some output has been selected
  if (!bTargetPlot & !bBoxWhisker & !bFreqScatterPlot & !bScatterPlot & !bQQPlot & !bTimePlot &!bStatisticalScatter & !bTimeVariation) {
    stop("No concentration evaluation graph option selected.")
    quit("no")
  }
  
  if (bTargetPlot) {
    #Check the combinations of filtering options are allowed
    col.filtering=c(bTargetPlotFiltering.pol.col,bTargetPlotFiltering.model.col,bTargetPlotFiltering.stntype.col,
                    bTargetPlotFiltering.pol.colsym,bTargetPlotFiltering.model.colsym,bTargetPlotFiltering.stntype.colsym)
    if (length(which(col.filtering))>1) {
      stop("Target plot symbol colour can only be used to indicate one of pollutant, station type or model.")
      quit("no")
    }
    sym.filtering=c(bTargetPlotFiltering.pol.sym,bTargetPlotFiltering.model.sym,bTargetPlotFiltering.stntype.sym,
                    bTargetPlotFiltering.pol.colsym,bTargetPlotFiltering.model.colsym,bTargetPlotFiltering.stntype.colsym)
    if (length(which(sym.filtering))>1) {
      stop("Target plot symbol shape can only be used to indicate one of pollutant, station type or model.")
      quit("no")
    }
  }
  
  # check compatibility of output options with group/filter options
  # note that (bGrpModel | bFilterModel) = TRUE earlier so don't need to repeat bFilterModel check
  if (bTargetPlot & ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType & !bFilterPollutant)  | (bGrpModel & !bFilterPollutant) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for target plot - this plot will not be output\n")
    bTargetPlot = FALSE  
  }
  if (bBoxWhisker & ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType & !bFilterPollutant) | (bGrpPollutant) | (bGrpModel & (!bFilterPollutant | bFilterStnType)) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for box and whisker plot - this plot will not be output\n")
    bBoxWhisker = FALSE
  }
  if (bFreqScatterPlot & ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType) | (bGrpPollutant) | (bGrpModel) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for frequency scatter plot - this plot will not be output\n")
    bFreqScatterPlot = FALSE
  }
  if (bScatterPlot & ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType) | (bGrpPollutant) | (bGrpModel) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for scatter plot - this plot will not be output\n")
    bScatterPlot = FALSE
  }
  if (bStatisticalScatter &  ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType) | (bGrpPollutant) | (bGrpModel) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for Statistical Scatter plot - this plot will not be output\n")
    bStatisticalScatter = FALSE
  }
  if (bQQPlot & ( (bGrpStnName & !bFilterPollutant) | (bGrpStnType) | (bGrpPollutant) | (bGrpModel) )) {
    cat("   Warning: Grouping and/or filtering options not suitable for quantile-quantile plot - this plot will not be output\n")
    bQQPlot = FALSE
  }
  if (bTimePlot & !(bGrpStnName & bFilterPollutant & bFilterModel & !bFilterStnType)) {
    cat("   Warning: Grouping and/or filtering options not suitable for time plot averaged over all stations - this plot will not be output\n")
    bTimePlot = FALSE
  }
  if (bTimeVariation & ((bGrpStnName & !bFilterPollutant) | (bGrpStnType) | (bGrpModel & !bFilterPollutant) | (bGrpPollutant))){
    cat("   Warning: Grouping and/or filtering options not suitable for time variation plot - this plot will not be output\n")
    bTimeVariation=FALSE
  }
  
  if(!any(names(pollutants)%in% "ozone")){
    pollutants$ozone="no"
  }
  
  # Check reference line settings are valid
  if (bSSreferencelines & bStatisticalScatter){ #Only need to check settings are valid if lines are to be added to plot
    reference.gradients.list=as.numeric(unlist(strsplit(reference.gradients,","))) #Converting from string to vector of numbers
    if (any(is.na(reference.gradients.list)) | reference.gradients==""){
      stop("Gradients for linear reference lines on statistical scatter plot must be numeric and separated by commas.\n")
      quit("no")
    }
    if (any(reference.gradients.list > 50) | any(reference.gradients.list < 0.01)){
      stop("Gradients for linear reference lines on statistical scatter plot must be between 0.01 and 50.\n")
      quit("no")
    }
    reference.line.types.list=tolower(gsub(" ","",unlist(strsplit(reference.line.types,",")))) #Converting the reference line types to a list without spaces
    if (any(!reference.line.types.list %in% c("dotted","dashed","solid")) | reference.line.types==""){
      stop("Line types for linear reference lines on statistical scatter plot must be either dashed, dotted or solid.\n")
      quit("no")
    }
    if(length(reference.gradients.list) != length(reference.line.types.list)){
      stop("The number of reference line gradients defined must be equal to the number of line types.\n")
      quit("no")
    }
  }
  
  # check uncertainty file has been entered for target plot
  if (bTargetPlot) {
    if (TargetPlot.uncertainty.file=="") {
      stop("No uncertainties file selected.")
      quit("no")
    }
    if (grepl(",",TargetPlot.uncertainty.file)) {
      stop("Uncertainties file path must not contain commas")
      quit("no")
    }
  }
}

#FORECAST INDEX EVALUATION
bAssessForecasts = tkm.data[nForeIndEval+1]=="Y"
index.scale.file = tkm.data[nForeIndEval+2]
thresholds.file = tkm.data[nForeIndEval+3]
data.threshold.index = as.numeric(tkm.data[nForeIndEval+4])
station.threshold.index = as.numeric(tkm.data[nForeIndEval+5])


if (bAssessForecasts) {
  if (index.scale.file=="") {
    stop("No index scale file selected.")
    quit("no")
  }
  
  if (thresholds.file=="") {
    stop("No alert threshold file selected.")
    quit("no")
  }
  
  if (!(station.threshold.index==0)){
    bApplyStnThresholdindex = TRUE
  } else {
    bApplyStnThresholdindex = FALSE
  }
}
episode.min.days = as.numeric(tkm.data[nForeIndEval+6])
episode.min.alert.level = tkm.data[nForeIndEval+7]
bForecastAccuracy = tkm.data[nForeIndEval+8]=="Y"
bOddsRatioSkillScore = tkm.data[nForeIndEval+9]=="Y"
bForecastPerformanceMetrics = tkm.data[nForeIndEval+10]=="Y"
if (bAssessForecasts & !bForecastAccuracy & !bOddsRatioSkillScore & !bForecastPerformanceMetrics) {
  stop("No forecast assessment graph output selected.")
  quit("no")
}
if (bAssessForecasts & bCompositePollutants) {
  stop("Forecast index evaluation cannot be performed with composite pollutants.")
  quit("no")
}

# regarding compatibility of output options with group/filter options
# note that (bGrpModel | bFilterModel) = TRUE earlier so don't need any other checks

#OUTPUT
output.types = c("JPG","PNG","PDF")
output.type = output.types[as.numeric(tkm.data[nOutput+2])+1]
output.selected = (output.type == output.types)
bJPG = output.selected[1]
bPNG = output.selected[2]
bPDF = output.selected[3]
output.paper.sizes = c("Large (A4)","Medium","Small")
output.paper.size = output.paper.sizes[as.numeric(tkm.data[nOutput+3])+1]
output.paper.selected = (output.paper.size == output.paper.sizes)
bPaperLarge = output.paper.selected[1]
bPaperMedium = output.paper.selected[2]
bPaperSmall = output.paper.selected[3]
boutput.eval.stats.as.csv = tkm.data[nOutput+4]=="Y"

if (!bAssessForecasts & !bAssessConcs) {
  stop("No output selected.")
  quit("no")
}

#re-define overwritten settings after data read-in
boutput.stats.as.csv = boutput.eval.stats.as.csv

#FUNCTIONS
CRMSE = function(x) {sqrt(mean(((x$mod - mean(x$mod)) - (x$obs - mean(x$obs)))^2))}
RMSE = function(x) {sqrt(mean((x$mod - x$obs)^2))}
StDev = function (x) {sqrt(mean((x - mean(x))^2))}
MBias = function(x) {mean(x$mod-x$obs)}
NMSE = function(x) {mean((x$mod - x$obs)^2) / (mean(x$obs)*mean(x$mod))}
cor.data = function(x) {cor(x$obs,x$mod)}
FAC2 = function(x) { 
  y = subset(x, x$obs>0)
  FAC2 = nrow( subset(y, abs(y$mod/y$obs)>=0.5 & abs(y$mod/y$obs)<=2.0 )) / nrow(y) 
}
FB = function(x) { (mean(x$mod) - mean(x$obs))/(0.5*(mean(x$obs)+mean(x$mod))) }
Fs = function(x) { (StDev(x$mod) - StDev(x$obs)) /(0.5*(StDev(x$obs)+StDev(x$mod))) }
RHC = function(x) { y = sort(x, decreasing=TRUE)
RHC = y[RHC.n] + ( (mean(y[1:(RHC.n-1)])-y[RHC.n])*log((3*RHC.n-1)/2) )}
NMSD = function(x) { (StDev(x$mod) - StDev(x$obs)) /(StDev(x$obs)) }
boxplot.stats.wrap = function(x) {boxplot.stats(x)$stats}
length.unique = function(x) {length(unique(x)[,1])}
ME = function(x) {mean(abs(x$mod - x$obs))}
MSE = function(x) {sum((x$mod - x$obs)^2/(length(x$mod)))}
MSEs = function(x) {(mean(x$mod) - mean(x$obs))^2}
MSE.cor = function(x) {(StDev(x$mod) - cor(x$obs,x$mod)*StDev(x$obs))^2}
mMSE = function(x) {(1-cor(x$obs,x$mod)^2)*StDev(x$obs)^2}
B_NMBF = function(x) { if (mean(x$mod) < mean(x$obs)) { return(1 - (mean(x$obs) / mean(x$mod))) } else { return((mean(x$mod) / mean(x$obs)) - 1) } }
MAE = function(x) {abs(mean(x$obs) - mean(x$mod))}
NAE = function(x) {mean(abs(x$obs - x$mod)) / (0.5*(mean(x$obs) + mean(x$mod)))}
MFE = function(x) { if(any(x$mod==0 & x$obs==0)) return(NA); if(nrow(x)==0) return(NA); mean(abs(x$mod - x$obs) / (0.5*(x$mod + x$obs))) }
NRMSE = function (x) {sqrt((mean((x$obs - x$mod)^2))/(mean(x$obs)*mean(x$mod)))}
NCRMSE = function(x) {sqrt(mean(((x$mod - mean(x$mod))-(x$obs - mean(x$obs)))^2))/StDev(x$obs)}
tau = function(x) {as.numeric(difftime(x$date[which.max(x$mod)], x$date[which.max(x$obs)], units="hours"))}
epsilon = function(x) {max(x$mod) - max(x$obs)}
NMSEs = function(x) {(4 * FB(x)^2) / (4 - FB(x)^2)}
NMSEu = function(x) {NMSE(x) - NMSEs(x)}
FACn = function(x) {nrow(subset(x, (x$mod / x$obs) >= (1/facn.int) & (x$mod / x$obs) <= facn.int)) / nrow(x)}
AE_band = function(x) {100*length(which((abs(x$mod - x$obs) < ae.band.conc)))/length(x$mod)}
rrank = function(x) {cor(rank(x$obs), rank(x$mod))}
NSD = function(x) {StDev(x$mod) / StDev(x$obs)}
theta = function(x) {mean((x$mod - mean(x$mod)) * (x$obs - mean(x$obs))) / mean((x$mod - mean(x$mod))^2)}
AU = function(x) {(max(x$mod) - max(x$obs)) / max(x$obs)}
AP = function(x) {(x$mod[which.max(x$obs)] - x$obs[which.max(x$obs)]) / x$obs[which.max(x$obs)]}
Spatial_MPI_corr = function(x, beta) {(1 - cor(x$obs.mean, x$mod.mean)) / (0.5 * beta^2 * mean((x$UOmean)^2) / (StDev(x$obs.mean) * StDev(x$mod.mean)))}
Spatial_MPI_sd = function(x, beta) {abs(StDev(x$obs.mean) - StDev(x$mod.mean)) / (beta * sqrt(mean((x$UOmean)^2)))}
FMT = function(x) {100 * sum(pmin(x$mod, x$obs)) / sum(pmax(x$mod, x$obs))}
HH = function(x) {sqrt( mean((x$mod - x$obs)^2) / (mean(x$mod * x$obs)) )}
KS = function(x) { ks.test(x$obs,x$mod)$statistic }
MSI = function(x, bModified = FALSE) {
  if(bModified) {
    cor.x = cor(x$obs, x$mod)
    if (is.na(cor.x)) {
      return(NA)
    }
    if(cor.x >= 0) {
      kappa = 0
    } else {
      kappa = 2 * abs(sum((x$obs - mean(x$obs)) * (x$mod - mean(x$mod))))
    }
    denominator = mean((x$obs - mean(x$obs))^2) + mean((x$mod - mean(x$mod))^2) + (mean(x$obs) - mean(x$mod))^2 + (kappa/nrow(x))
  } else {
    denominator = mean((x$obs - mean(x$obs))^2) + mean((x$mod - mean(x$mod))^2) + (mean(x$obs) - mean(x$mod))^2
  }
  
  1 - (sum((x$obs - x$mod)^2)/nrow(x) / denominator)
}
MSEu = function(x) {MSE.cor(x) + mMSE(x)}
NMSE.cor = function(x) {MSE.cor(x) / (mean(x$obs) * mean(x$mod))}
mNMSE = function(x) {mMSE(x) / (mean(x$obs) * mean(x$mod))}
# Spatial metrics
# Area of false negatives
AFN = function(x) { x = x[!(is.na(x$obs) | is.na(x$mod)), ]; if(nrow(x)==0) return(NA); 0.5*sum(abs(x$obs - x$mod) + (x$obs - x$mod)) }
# Area of false positives
AFP = function(x) { x = x[!(is.na(x$obs) | is.na(x$mod)), ]; if(nrow(x)==0) return(NA); 0.5*sum(abs(x$obs - x$mod) + (x$mod - x$obs)) }
# Figure of merit in space
FMS = function(x, ct) { x = x[!(is.na(x$obs) | is.na(x$mod)), ]; if(nrow(x)==0) return(NA); A_int=0.5*sum((x$obs+x$mod)-abs(x$obs-x$mod)-ct); A_fn=AFN(x); A_fp=AFP(x); A_union=A_int+A_fn+A_fp; if(is.na(A_int)|is.na(A_union)|A_union==0) return(NA); A_int/A_union }
# User-oriented 1-dimensional measure of effectiveness
MOE1D = function(x, ct, gamma.fn, gamma.fp) { x = x[!(is.na(x$obs) | is.na(x$mod)), ]; if(nrow(x)==0) return(NA); A_int=0.5*sum((x$obs+x$mod)-abs(x$obs-x$mod)-ct); A_fn=AFN(x); A_fp=AFP(x); denom=A_int+gamma.fn*A_fn+gamma.fp*A_fp; if(is.na(A_int)|is.na(denom)|denom==0) return(NA); A_int/denom }
# 2-dimensional measure of effectiveness
MOE2D = function(x, ct) { x = x[!(is.na(x$obs) | is.na(x$mod)), ]; if(nrow(x)==0) return(c(NA,NA)); A_int=0.5*sum((x$obs+x$mod)-abs(x$obs-x$mod)-ct); A_fn=AFN(x); A_fp=AFP(x); v1=if(is.na(A_int)|is.na(A_fn)|(A_int+A_fn)==0) NA else A_int/(A_int+A_fn); v2=if(is.na(A_int)|is.na(A_fp)|(A_int+A_fp)==0) NA else A_int/(A_int+A_fp); c(v1,v2) }
# Combined Ranking metric
RANK = function(x, ct) {(cor(x$obs, x$mod))^2 + (1 - abs(FB(x) / 2)) + FMS(x, ct) + (1 - KS(x)/(100*nrow(x)))}

# Function to get AOT40 statistics for ozone
# Requires pollutants dataframe to have ozone column with ozone pollutant specified.
# Requires packages: Lubridate
# Requires CommonFunctions function "convert.units()"
# Units of output are ppb.hours 
# Output averaging time and statistic required to be hourly mean
getAOT40=function(mydata,obs.mod,pollutant,type){ #requires all.data.onepol dataframe, obs or mod, pollutant and type
  AOT40.pol=pollutant
  # Check to see if pollutant is marked as Yes in the ozone column in the pollutants definition file
  AOT40.pol.o3=pollutants$ozone[which(pollutants$pollutant==AOT40.pol)]
  # Set AOT40 value as NA so that NA is returned if further criteria are not met
  AOT40.value=NA
  if (AOT40.pol.o3 %in% "yes"){
    # Check to see if the output averaging time and statistic is hourly mean
    AOT40.pol.output.time=pollutants$output.avg.time.and.statistic[which(pollutants$pollutant==AOT40.pol)]
    if (AOT40.pol.output.time %in% "hourly mean"){
      # Subset the dataframe to date and obs or mod columns depending on the value of obs.mod
      AOT40.pol.data=subset(mydata, select=c("date", obs.mod))
      names(AOT40.pol.data)=c("date","raw_ozone")
      # Find units for the pollutant from the pollutants dataframe
      AOT40.pol.units=pollutants$output.units[which(pollutants$pollutant==AOT40.pol)]
      # If units aren't ppb, convert to ppb using function convert.units()
      if ( AOT40.pol.units %in% "ppb"){
        AOT40.threshold=40 
      } else { 
        AOT40.pol.ppb.conv=pollutants$conv.ugm3.ppb[which(pollutants$pollutant==AOT40.pol)]
        AOT40.threshold=convert.units(40, "ppb", AOT40.pol.units, AOT40.pol.ppb.conv)  
      }
      # Subset for forests or crops for AOT40 statistic
      # Forests months: April, May, June or July
      # Crops months: May, June, July
      AOT40.pol.data$month=month(AOT40.pol.data$date)
      if (type %in% "forests"){
        AOT40.pol.data=subset(AOT40.pol.data, month %in% c(4,5,6,7,8,9))
      } else if (type %in%"crops"){
        AOT40.pol.data=subset(AOT40.pol.data, month %in% c(5,6,7))
      }
      # Create new exceedence column and subtract 40 from this
      AOT40.pol.data$exceedance=AOT40.pol.data$raw_ozone-AOT40.threshold 
      # Replace negative values with 0
      AOT40.pol.data$exceedance[which(AOT40.pol.data$exceedance<0)]=0
      # Add all values in the column together to get the AOT40 value
      AOT40.value=sum(AOT40.pol.data$exceedance)
    }
    
  }
  return(AOT40.value)
}
# Function to produce SOMO35 statistic for ozone. 
# Requires pollutants dataframe to have ozone column with ozone pollutant specified.
# Requires packages: Lubridate
# Requires CommonFunctions function "convert.units()"
# Units of output are ppb.days. 
# Output averaging time and statistic required to be daily maximum of 8-hour rolling mean.
getSOMO35=function(mydata,obs.mod,pollutant){
  SOMO35.pol=pollutant 
  # Look up the pollutant in pollutants dataframe to see if it marked as yes in the Ozone column 
  SOMO35.pol.o3=pollutants$ozone[which(pollutants$pollutant==SOMO35.pol)] 
  # Set SOMO35 value to NA so that NA is returned if conditions are not met
  SOMO35.value=NA
  if (SOMO35.pol.o3 %in% "yes"){ 
    # Look up output averaging time of the pollutant and check it is daily maximum of 8-hour rolling mean
    SOMO35.pol.output.time=pollutants$output.avg.time.and.statistic[which(pollutants$pollutant==SOMO35.pol)]
    if (SOMO35.pol.output.time %in% "daily maximum of 8-hour rolling mean"){
      # Select date and either obs or mod column depending on the value of obs.mod
      SOMO35.pol.data=subset(mydata, select=c("date", obs.mod))
      names(SOMO35.pol.data)=c("date","raw_ozone")
      #Check units, required to be in ppb so if not ppb convert to ppb
      SOMO35.pol.units=pollutants$output.units[which(pollutants$pollutant==SOMO35.pol)]
      if (SOMO35.pol.units %in% "ppb"){
        SOMO35.threshold=35 
      } else {
        SOMO35.pol.ppb.conv=pollutants$conv.ugm3.ppb[which(pollutants$pollutant==SOMO35.pol)]
        SOMO35.threshold=convert.units(35,"ppb",SOMO35.pol.units,SOMO35.pol.ppb.conv)
      }
      # Create new column exceedence and subtract 35 from each value 
      SOMO35.pol.data$exceedance=SOMO35.pol.data$raw_ozone-SOMO35.threshold 
      # Remove negative values 
      SOMO35.pol.data$exceedance[which(SOMO35.pol.data$exceedance<0)]=0
      # Sum column to get SOMO35 value
      SOMO35.value=sum(SOMO35.pol.data$exceedance)
    }
  }
  return(SOMO35.value)
}

#Functions for drawing a table
make.table = function(nr, nc) { 
  plot(c(0, nc*6 + 1), c(0, -(nr + 1)), 
       type="n", xlab="", ylab="", axes=FALSE)
}
get.r = function(i, nr) i-floor((i-1)/nr)*nr 
get.c = function(i, nr) 1+floor((i-1)/nr) 
draw.title = function(title, nc) { 
  text((nc*6 + 1)/2, 0, title) 
}
draw.sample.cell = function(id, key, i, nr) { 
  r = get.r(i, nr) 
  c = get.c(i, nr)
  text(6*(c - 1) + 2, -r, id, col="violetred",cex=0.7) 
  text(6*c-1, -r, key,cex=0.7) 
  rect(6*(c - 1) + .5, -(r - .5), 6*c + .5, -(r + .5), border="grey") 
}

#R CODE:

#Load the required packages
argv = commandArgs(trailingOnly = FALSE)
install_dir = dirname(substring(argv[grep("--file=", argv)], 8))
packages.required = c("lattice","nlme","openair","hexbin","tibble","dplyr","ggplot2","lubridate","tidyr","reshape2","tools","RColorBrewer","ggforce","stringr","data.table","colorspace")
install.load.packages(packages.required)

# Openair versions 3.1 or higher is required to run the Toolkit due to plotting changes
if (packageVersion("openair") < "3.1.0") {
  stop("Openair v3.1.0 or higher is required to run the Toolkit. \nTo update packages go to 'R Support > Update R packages' in the Toolkit interface.\n")
  quit("no")
}

#Set embedded title for PDF files
pdf.title = paste("Output from DS-LOCAL Model Evaluation Toolkit Version",r.eval.toolkit.version)

#Having written the settings and loaded the data, check whether only a single 
# pollutant and / or model exists
modelled.data.names = names(all.data)[names(all.data)!="date" & names(all.data)!="obs" & 
                                        names(all.data)!="station" & names(all.data)!="pollutant" & names(all.data)!="station.output.name" & 
                                        names(all.data)!="type" & names(all.data)!="network" & names(all.data)!="auto.download"]
if (bAllModels & length(modelled.data.names)==1) {
  bAllModels = FALSE
  bSingleModel = TRUE
  single.model = as.character(modelled.data.names)
}
if (bAllPols & length(unique(pollutants$pollutant))==1) {
  bAllPols = FALSE
  bSinglePollutant = TRUE
  single.pollutant = as.character(unique(pollutants$pollutant))
}

#MODEL
#Check model name exists and reduce data set if required, prior to any further work
if (bSelectedModels) {
  selected.models.vector=gsub(", ",",",selected.models)
  selected.models.vector=unlist(strsplit(selected.models.vector,","))
  if (length(selected.models.vector)==1) {
    bSingleModel=TRUE
    single.model= selected.models.vector[1]
  } else {
    bSingleModel=FALSE
  }
  for (i in 1:length(selected.models.vector)) {
    if (!(tolower(selected.models.vector)[i] %in% tolower(modelled.data.names))) {
      models.available = paste(modelled.data.names,collapse=", ")
      stop(paste("No data for selected model ",selected.models.vector[i],". Data are available for the following models: ",models.available,"\n",sep=""))
      quit("no")
    } else { # match case of selected model vector to the case in the dataframe
      selected.models.vector[i]=modelled.data.names[which(tolower(modelled.data.names) %in% tolower(selected.models.vector[i]))]
    }
  }
  all.data = subset(all.data,select=c("date","obs","station","pollutant","station.output.name","type","network","auto.download",selected.models.vector))
  modelled.data.names = selected.models.vector
}

#if there is only one model and the ensemble output is selected, the ensemble median won't be calculated
if(bEnsemble){
  if(bSingleModel){
    bEnsemble=FALSE
    cat("Only one model dataset available. Ensemble modelled dataset will not be calculated \n")
  } else {
    #add the ensemble model to the list of modelled datasets
    modelled.data.names=c(modelled.data.names,"EnsembleMedian")
  }
}
modelled.data.names=sort(modelled.data.names) #Putting the modelled data names in alphabetical order to ensure the correct colours are used in the plots.

#POLLUTANTS
#Reduce to a subset of pollutants if required
if (bSelectedPollutants) {
  selected.pollutants.vector=gsub(", ",",",selected.pollutants)
  selected.pollutants.vector=unlist(strsplit(selected.pollutants.vector,","))
  if (length(selected.pollutants.vector)==1) {
    bSinglePollutant=TRUE
    single.pollutant=selected.pollutants.vector[1]
  } else {
    bSinglePollutant=FALSE
  }
  available.pollutants=unique(pollutants$pollutant)
  for(i in 1:length(selected.pollutants.vector)) {
    if(!(tolower(selected.pollutants.vector[i]) %in% tolower(available.pollutants))) {
      available.pollutants.str= paste(available.pollutants,collapse=", ")
      stop(paste("No data for selected pollutant ",selected.pollutants.vector[i],". Data are available for the following pollutants: ",available.pollutants.str, "\n",sep="" ))
      quit("no")
    } else{# match case of selected pollutants to the case in the workspace
      selected.pollutants.vector[i]=available.pollutants[which(tolower(available.pollutants) %in% tolower(selected.pollutants.vector[i]))]
    }
  }
  pollutants= subset(pollutants,pollutant %in% tolower(selected.pollutants.vector))
  all.data = subset(all.data,pollutant %in% selected.pollutants.vector)
}

# output averaging time and statistic
# check whether data is to be averaged over different time period or statistic than the modelled data was output:
if (bAssessConcs) {
  # process output.averaging.times.file and append to pollutants data frame
  pollutants = process.output.averaging(output.averaging.times.file, pollutants)
  
  # Check to see if 'percentile' column exists
  bPercentiles=FALSE
  if ("percentile" %in% names(pollutants)){ #Case insensitive because process.output.averaging already sets to lower case.
    #Checking that the percentile values are valid
    if (any(is.na(pollutants$percentile)) | any(is.null(pollutants$percentile)) | any(!is.numeric(pollutants$percentile))){
      stop("The percentile column in the output averaging times file contains invalid data.\n All values must be between 0 and 100 or -999.\n")
      quit("no")
    }
    if (all(pollutants$percentile == -999) & (!bStatisticalScatterPercentile | !bStatisticalScatter)){ #Percentile column is not needed for all plot types, but warning will be given
      cat("Warning: No valid percentile values found in the output averaging times file. Percentile calculations will be skipped.\n")
      pollutants=subset(pollutants,select = c(-percentile)) #Removing the percentile column since all values are invalid
    } else if (all(pollutants$percentile == -999) & (bStatisticalScatterPercentile & bStatisticalScatter)){
      stop("The percentile column in the output averaging times file must contain at least one valid value to produce a period percentile statistical scatter plot. \n")
      quit("no")
    } else{
      bPercentiles=TRUE
    }
  }
  #Giving an error if period percentile is selected for statistical scatter plots but no percentile data is given
  if (bStatisticalScatter & bStatisticalScatterPercentile & !bPercentiles){
    stop("Statistical scatter plot, period percentile has been selected, but no percentiles have been defined in the output averaging times file.\n Add a percentile column to the output averaging times file or change the statistical scatter plot statistic.\n")
    quit("no")
  }
  
  # store title strings for output averaging times and statistic
  pollutants$output.avg.time.and.statistic = vector(mode="character", length = length(pollutants$pollutant)) # preallocate
  for (j in 1:length(pollutants$pollutant)) {
    if ( pollutants$output.avg.time[j] %in% c("day","1 day")) {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = "daily maximum"
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste("daily", pollutants$output.statistic[j], sep=" ")
      }
    } else if (pollutants$output.avg.time[j] %in% c("hour","1 hour")) {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = "hourly maximum"
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste("hourly", pollutants$output.statistic[j], sep=" ")  
      }
    } else if (pollutants$output.avg.time[j] %in% c("week","1 week")) {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = "weekly maximum"
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste("weekly", pollutants$output.statistic[j], sep=" ")  
      }
    } else if (pollutants$output.avg.time[j] %in% c("month","1 month")) {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = "monthly maximum"
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste("monthly", pollutants$output.statistic[j], sep=" ")  
      }
    } else if (pollutants$output.avg.time[j] %in% c("year","1 year")) {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = "annual maximum"
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste("annual", pollutants$output.statistic[j], sep=" ")  
      }
    } else {
      if (pollutants$output.statistic[j]=="max") {
        pollutants$output.avg.time.and.statistic[j] = paste(gsub(" hour","-hour" ,pollutants$output.avg.time[j],), "maximum", sep=" ")
      } else {
        pollutants$output.avg.time.and.statistic[j] = paste(gsub(" hour","-hour" ,pollutants$output.avg.time[j],),pollutants$output.statistic[j],sep=" ")
      }
    }
    if (pollutants$output.daily.max[j]=="yes") {
      pollutants$output.avg.time.and.statistic[j]=paste("daily maximum of",pollutants$output.avg.time.and.statistic[j])
    }
  }
  #Check output averaging time if SOMO35 or AOT40 being output on statistical scatter plots
  if (bStatisticalScatter){
    if (bStatisticalScatterSOMO35){
      ozone.pol.time=pollutants$output.avg.time.and.statistic[which(pollutants$ozone=="yes")]
      if (! ozone.pol.time %in% "daily maximum of 8-hour rolling mean"){
        cat("Warning: Statistical Scatter Plot cannot be produced as SOMO35 requires ozone data as the daily maximum of an 8 hour rolling mean. \n")
        bStatisticalScatter=FALSE
      }
    } else if (bStatisticalScatterAOT40.crops|bStatisticalScatterAOT40.forests){
      ozone.pol.time=pollutants$output.avg.time.and.statistic[which(pollutants$ozone=="yes")]
      if (! ozone.pol.time %in% "hourly mean"){
        cat("Warning: Statistical Scatter Plot cannot be produced as AOT40 requires ozone data as hourly mean. \n")
        bStatisticalScatter=FALSE
      }
    }
  }
  
}

#Read and process the index scales file
if (bAssessForecasts) {
  
  #Read in index scales file and add to pollutants
  pollutants = process.index.scales(index.scale.file, pollutants)   
  
  #Find lower bounds of index
  index.lower.bounds = find.index.lower.bounds(pollutants)
  
}

#STATIONS
if (bSelectedStationTypes) {
  selected.station.types.vector=gsub(", ",",",selected.station.types)
  selected.station.types.vector=unlist(strsplit(tolower(selected.station.types.vector), ","))
  available.station.types=unique(all.data$type)
  for (i in 1:length(selected.station.types.vector)){
    if (!(tolower(selected.station.types.vector[i]) %in% tolower(available.station.types))){
      available.station.types=paste(available.station.types, collapse=", ")
      stop(paste("No data is available for selected station type ", selected.station.types.vector[i], ". Data are available for the following station types: ", available.station.types, "\n", sep=""))
      quit("no")
    } else {
      selected.station.types.vector[i]=available.station.types[which(tolower(available.station.types) %in% tolower(selected.station.types.vector[i]))]
    }
  }
  stations.new = subset(stations,type %in% selected.station.types.vector)
  stations = stations.new   
} else if (bSelectedStations) {
  selected.stations.vector=gsub(", ",",",selected.stations)
  selected.stations.vector=unlist(strsplit(tolower(selected.stations.vector), ","))
  if (length(selected.stations.vector)==1) {
    bSingleStation=TRUE
    single.station= selected.stations.vector[1]
  } else {
    bSingleStation=FALSE
  }
  available.stations=unique(stations$name)
  for (i in 1:length(selected.stations.vector)) {
    if (!(tolower(selected.stations.vector[i]) %in% tolower(available.stations))) {
      available.stations.str=paste(available.stations,collapse=", ")
      stop(paste("No data for selected station ",selected.stations.vector[i],". Data are available for the following stations: ",available.stations.str,"\n",sep=""))
      quit("no")
    } else {
      selected.stations.vector[i]=available.stations[which(tolower(available.stations) %in% tolower(selected.stations.vector[i]))]
    }
  }
  stations=subset(stations,name %in% selected.stations.vector)
  all.data = subset(all.data,station %in% selected.stations.vector)
} 
if (bSelectedNetworks){
  stations$network=tolower(stations$network)
  selected.networks.vector=gsub(", ",",",selected.networks)
  selected.networks.vector=unlist(strsplit(tolower(selected.networks.vector),","))
  available.networks=unique(stations$network)
  for (i in 1:length(selected.networks.vector)){
    if (!(tolower(selected.networks.vector[i]) %in% tolower(available.networks))){
      available.networks=paste(available.networks, collapse=", ")
      if (bSelectedStationTypes){
        stop(paste("No data for selected station network(s): ",selected.networks.vector[i]," and selected station type(s): ",selected.station.types, ". \n Data are available for the following station networks: ",available.networks,"\n",sep=""))
        quit("no")  
      } else {
        stop(paste("No data for selected station network(s): ",selected.networks.vector[i],". Data are available for the following station networks: ",available.networks,"\n",sep=""))
        quit("no")
      }
    } else {
      selected.networks.vector[i]=available.networks[which(tolower(available.networks) %in% tolower(selected.networks.vector[i]))]
    }
  }
  stations.new=subset(stations, network %in% selected.networks.vector)
  if (length(selected.networks.vector)==1){
    bSingleNetwork=TRUE
  } else {
    bSingleNetwork=FALSE
  }
}
# remove selected stations from the analysis if required
if (bRemoveStations) {
  stations.to.remove.vector=trimws(unlist(strsplit(tolower(stations.to.remove), ",")))
  stations.new = subset(stations,!(name %in% stations.to.remove.vector))
  stations.to.remove.string = paste(stations.to.remove.vector,collapse=", ")
  if (length(stations.new$name)==0) {
    stop(paste("Excluding selected stations (",stations.to.remove.string,") has removed all stations.\n",sep=""))
    quit("no")
  }
  cat(paste("The following stations will be removed:",stations.to.remove.string,"\n"))
  stations = stations.new   
}
#Check that if bSetNo2asLowerNOx, the NO2 and NOx pollutant listed in the interface exist
if (bSetNO2asLowerNOx){
  #get available pollutants in the pollutants dataframe, convert to lower case
  available.pols=tolower(pollutants$pollutant)
  #Remove white space and convert input NO2 and NOx values to lower case 
  NO2.SetNO2asLowerNOx=tolower(trimws(NO2.SetNO2asLowerNOx))
  NOx.SetNO2asLowerNOx=tolower(trimws(NOx.SetNO2asLowerNOx))
  if (!(NO2.SetNO2asLowerNOx %in% available.pols) | !(NOx.SetNO2asLowerNOx %in% available.pols)){
    if (!(NO2.SetNO2asLowerNOx %in% available.pols) & !(NOx.SetNO2asLowerNOx %in% available.pols)){
      stop(paste("Set observed NO2 as the lower limit for observed NOx has been selected, but the input NOX pollutant: ",NOx.SetNO2asLowerNOx," and the input NO2 pollutant: ",NO2.SetNO2asLowerNOx," cannot be found. Available pollutants are: ", paste(available.pols, collapse=","),". \n", sep=""))
      quit("no")
    } else if (!(NO2.SetNO2asLowerNOx %in% available.pols)){
      stop(paste("Set observed NO2 as the lower limit for observed NOx has been selected, but the input NO2 pollutant: ",NO2.SetNO2asLowerNOx," cannot be found. Available pollutants are: ", paste(available.pols, collapse=","),". \n", sep=""))
      quit("no")
    } else if (!(NOx.SetNO2asLowerNOx %in% available.pols)){
      stop(paste("Set observed NO2 as the lower limit for observed NOx has been selected, but the input NOX pollutant: ",NOx.SetNO2asLowerNOx," cannot be found. Available pollutants are: ", paste(available.pols, collapse=","),". \n", sep=""))
      quit("no")
    }
  }
}

# rename stations column "name" to "station" for subsequent merging 
names(stations)[names(stations)=="name"]="station"
stations.station = subset(stations,select=c("station","type","station.output.name"))
station.types.available = sort(as.character(unique(stations$type)))

#GROUPING AND FILTERING BY
#Amend plotting settings if only one value, for titles and layout using filtering options
if (bSingleModel) {
  bFilterModel = FALSE
}
if (bSinglePollutant) {
  bFilterPollutant = FALSE
}
# predefine some strings and variables 
if (bGrpStnName) {
  groupby.col.label = "station.output.name"
  groupby.col.openair = "site"
  groupby.key.string = "Station" 
  groupby.title.string = "ALL STATIONS"  
} else if (bGrpStnType) {
  groupby.col.label = "type"
  groupby.key.string = "Station type"
  groupby.col.openair = "type"
  groupby.title.string = "ALL STATION TYPES" 
} else if (bGrpPollutant) { 
  groupby.col.label = "pollutant"
  groupby.key.string = "Pollutant"
  groupby.col.openair = "pollutant"
  groupby.title.string = "ALL POLLUTANTS" 
} else if (bGrpModel) {
  groupby.col.label = "model"
  groupby.key.string = "Model"
  groupby.col.openair = "model"
  groupby.title.string = "ALL MODELS" 
}
if (bGrpModel | !bFilterModel) {
  if (!bFilterStnType & !bFilterPollutant) {
    # length of the plotting loop
    filterby.length = 1
    filterby.list = "all data"
    filterby.col.label = groupby.col.label  
  } else if (bFilterStnType & !bFilterPollutant) {
    # length of the plotting loop
    filterby.length = length(station.types.available)
    filterby.list = as.data.frame(station.types.available)
    names(filterby.list) = "type"
    filterby.col.label = "type"    
  } else if (bFilterPollutant & !bFilterStnType) { 
    # length of the plotting loop
    filterby.length = length(pollutants$pollutant)
    filterby.list = as.data.frame(as.character(pollutants$pollutant))
    names(filterby.list) = "pollutant"
    filterby.col.label = "pollutant"   
  } else if (bFilterPollutant & bFilterStnType) {
    # length of the plotting loop
    filterby.length = length(station.types.available) * length(pollutants$pollutant)
    filterby.list = as.data.frame(merge(station.types.available,as.character(pollutants$pollutant)))
    names(filterby.list) = c("type","pollutant")
    filterby.col.label = c("type","pollutant")
  }
} else { #bFilterModel
  if (!bFilterStnType & !bFilterPollutant) {
    # length of the plotting loop
    filterby.length = length(modelled.data.names)
    filterby.list = as.data.frame(as.character(modelled.data.names))
    names(filterby.list) = "model"
    filterby.col.label = "model"  
  } else if (bFilterStnType & !bFilterPollutant) {
    # length of the plotting loop
    filterby.length = length(modelled.data.names) * length(station.types.available)
    filterby.list = as.data.frame(merge(station.types.available,as.character(modelled.data.names)))
    names(filterby.list) = c("type","model")
    filterby.col.label = c("type","model")    
  } else if (bFilterPollutant & !bFilterStnType) { 
    # length of the plotting loop
    filterby.length = length(modelled.data.names) * length(pollutants$pollutant)
    filterby.list = as.data.frame(merge(as.character(pollutants$pollutant),as.character(modelled.data.names))) 
    names(filterby.list) = c("pollutant","model")
    filterby.col.label = c("pollutant","model")   
  } else if (bFilterPollutant & bFilterStnType) {
    # length of the plotting loop
    filterby.length = length(modelled.data.names) * length(station.types.available) * length(pollutants$pollutant)
    filterby.list = as.data.frame(merge(station.types.available,as.character(pollutants$pollutant)))
    names(filterby.list) = c("type","pollutant")
    filterby.list = merge(filterby.list,as.character(modelled.data.names))
    names(filterby.list) = c("type","pollutant","model")      
    filterby.col.label = c("type","pollutant","model")
  }
}

#READ AND PROCESS THRESHOLD LEVELS FILE (separator here is ",")
#header = "index, name"
if (bAssessForecasts) {
  cat("Importing alert threshold details...\n")
  thresholds = read.csv(thresholds.file,header=TRUE,sep=",")
  episode.alert.threshold = which(tolower(thresholds$name)==tolower(episode.min.alert.level))
  if (length(episode.alert.threshold)==0) {
    stop(paste("Unrecognised alert name in episode definition. Alert name should be one of",
               paste(thresholds$name,collapse=", "),"\n"))
    quit("no")
  }   
} 

#Loop over model and pollutants
if (bSelectedStationTypes) {
  all.data = subset(all.data,type %in% selected.station.types.vector)
} else if (bSingleStation) {
  all.data = subset(all.data,station==tolower(single.station))
}
if (bSelectedNetworks){
  all.data=subset(all.data, network %in% selected.networks.vector)
}
#Remove selected stations from the analysis if required
if (bRemoveStations) {all.data = subset(all.data,!(station %in% stations.to.remove.vector))}

station.types = as.data.frame(table(stations$type))
names(station.types)=c("type","num.stations")

#Subset by date if required
if (bDateSubset) {
  cat(paste("Selecting data from",format(start.date.req,format="%d/%m/%y %H:%M",tz="GMT"),"to",format(end.date.req,format="%d/%m/%y %H:%M",tz="GMT"),"inclusive\n"))
  all.data=subset(all.data,(date >= start.date.req) & (date <= end.date.req))
  if (length(all.data$date)==0) {
    stop("No data for selected date period")
    quit("no")
  }
}

#Remove single rows of data to stop rolling average errors
cat("Removing single rows of data for rolling averages to prevent errors...\n")
all.data$keep = TRUE
total.num.stns.removed = 0
for (j in 1:length(pollutants$pollutant)) {
  pol = pollutants$pollutant[j]
  num.stns.removed = 0
  bCheckRollingForConcs = FALSE
  bCheckRollingForFcsts = FALSE
  if (bAssessConcs) {
    if (pollutants$output.statistic[j]=="rolling mean" & 
        !(pollutants$statistic[j]==pollutants$output.statistic[j] & pollutants$avg.time[j]==pollutants$output.avg.time[j])) {
      bCheckRollingForConcs = TRUE
    }
  }
  if (bAssessForecasts) {
    if (pollutants$index.statistic[j]=="rolling mean" & 
        !(pollutants$statistic[j]==pollutants$index.statistic[j] & pollutants$avg.time[j]==pollutants$index.avg.time[j])) {
      bCheckRollingForFcsts = TRUE
    }
  }
  if (bCheckRollingForConcs | bCheckRollingForFcsts) {
    for (i in 1:length(stations$station)) {
      stn = stations$station[i]
      num.records = nrow(subset(all.data,pollutant==pol & station==stn))
      if (num.records==1) {
        all.data$keep[all.data$pollutant==pol & all.data$station==stn] = FALSE
        cat(paste("  Station ",stn," removed from dataset for ",toupper(pol),"\n",sep=""))
        num.stns.removed = num.stns.removed+1
      }
    }
    cat(paste("  ",num.stns.removed," station(s) removed from dataset for ",toupper(pol),"\n",sep=""))
    total.num.stns.removed = total.num.stns.removed + num.stns.removed
  }
}
all.data = subset(all.data,keep==TRUE)
all.data = all.data[-which(names(all.data)=="keep")]
cat(paste("  In total, ",total.num.stns.removed," station(s) removed from dataset \n",sep=""))


#Only evaluate rows of data where data is available for all models being evaluated
#The ensemble has not been calculated yet, but has been added to the modelled.data.names vector, skip this one
cat("Reduce dataset to only hours where data are available for all models being evaluated \n")
for (m in 1:length(modelled.data.names)) {
  if(modelled.data.names[m]=="EnsembleMedian") next
  imodel=which(names(all.data)==modelled.data.names[m])
  all.data=all.data[which(!is.na(all.data[,imodel])),]
}
#Only evaluate rows of data where observed data are available (modelled NAs already removed)
all.data=subset(all.data,!is.na(obs))

#calculate ensemble as the median of all modelled datasets
if(bEnsemble){
  all.data$EnsembleMedian=apply(subset(all.data,select=c(names(all.data) %in% modelled.data.names))
                                ,1,median,na.rm=FALSE)
}


#Get the start and end dates in a format suitable for output to graphs
#These will be output as the date range on each graph.
data.start.date.for.graphs = format(range(all.data$date)[1],"%d/%m/%y %H:%M")
data.end.date.for.graphs = format(range(all.data$date)[2], "%d/%m/%y %H:%M")

#If setting NO2 as lower limit for NOx
if (bSetNO2asLowerNOx){
  cat("Setting observed NO2 as lower limit for observed NOx concentrations...\n")
  #subset data to get obs and mod dataframes
  mod.all.data=subset(all.data, select=c(-obs))
  obs.all.data=subset(all.data, select=c("date","obs","station", "pollutant","station.output.name","type","network","auto.download"))
  #subset the observed dataframe to get the no2 and nox pollutants
  obs.data.no2.nox=subset(obs.all.data, pollutant %in% c(NO2.SetNO2asLowerNOx,NOx.SetNO2asLowerNOx))
  #Create a wide dataframe with a column per pollutant
  obs.data.no2.nox.wide=pivot_wider(obs.data.no2.nox, values_from = "obs", names_from="pollutant")
  #Create a check column on the dataframe for the difference between no2 and nox 
  obs.data.no2.nox.wide$check=obs.data.no2.nox.wide[[NOx.SetNO2asLowerNOx]]-obs.data.no2.nox.wide[[NO2.SetNO2asLowerNOx]]
  #identify negative check values, i.e. where the no2>nox 
  ref=which(obs.data.no2.nox.wide$check<0)
  if (length(ref)==0){
    cat("   All observed NOx values exceed or are equal to observed NO2 values. No adjustments required.\n")
  } else {
    percentage.changed=100*length(ref)/nrow(obs.data.no2.nox.wide)
    cat(paste("   Adjusting ",length(ref)," observed NOx values. This is ",round(percentage.changed,3),"% of observed NOx measurements...\n", sep="" ))
    #get a dataframe of all measurements that have been adjusted with the date, station, no2 and original nox measurements 
    adjusted.ref=obs.data.no2.nox.wide[ref,]
    #Remove the check column from the adjusted reference data frame
    adjusted.ref=subset(adjusted.ref, select=-c(check))
    adjusted.ref$adjusted_nox=adjusted.ref[[NO2.SetNO2asLowerNOx]]
    #Write out the adjusted dataframe to a csv file in the directory for reference 
    cat("   Writing adjusted observed NOx values to csv....\n")
    write.csv(adjusted.ref, paste(eval.output.root,"_adjusted_obs_nox_values.csv",sep=""),row.names = FALSE)
    #For all values where the no2 > nox, set nox as no2
    obs.data.no2.nox.wide[[all_of(NOx.SetNO2asLowerNOx)]][ref]=obs.data.no2.nox.wide[[all_of(NO2.SetNO2asLowerNOx)]][ref]
    #Remove the check and no2 column from the dataframe
    obs.data.nox.wide=subset(obs.data.no2.nox.wide, select=-c(check,eval(parse(text=all_of(NO2.SetNO2asLowerNOx)))))
    #remove na nox values 
    obs.data.nox.wide=obs.data.nox.wide[-which(is.na(obs.data.nox.wide$nox)),]
    #Create a longer dataframe with one obs column and a pollutants column 
    obs.nox.data.adj=pivot_longer(obs.data.nox.wide, cols=c(all_of(NOx.SetNO2asLowerNOx)), names_to="pollutant", values_to="obs")
    #Add original columns back into the obs.no2.nox.data.adjusted dataframe and remove the no2 to get just nox
    obs.all.data.without.nox=subset(obs.all.data, !pollutant %in% NOx.SetNO2asLowerNOx)
    obs.nox.data.adj=subset(obs.nox.data.adj, select=names(obs.all.data.without.nox))
    obs.all.data.adj.nox=rbind(obs.all.data.without.nox, obs.nox.data.adj)
    all.data=merge(mod.all.data, obs.all.data.adj.nox, by=c("date","station","pollutant","station.output.name","type","network","auto.download"))
  }
}


# Check requirements for composite pollutants 
# Check composite pollutants if listed and calculate new pollutants
if (bCompositePollutants){
  cat("Checking composite pollutants...\n")
  available.pollutants.for.comp=pollutants$pollutant
  available.pollutants.for.comp=paste(available.pollutants.for.comp,collapse=",")
  # Split comma separated list of component pollutants into individual equations 
  composite.pollutants=unlist(strsplit(composite.pollutants, ",", fixed=TRUE))
  composite.pollutants.names=composite.pollutants
  no.composite.pollutants=length(composite.pollutants)
  for (i in (1:no.composite.pollutants)){ #Loop through each of the composite pollutants 
    cat(paste("   Checking composite pollutant: ", composite.pollutants.names[i], "\n", sep=""))
    # Create temp.pollutants vector that will hold each of the pollutants from the pollutants dataframefound in the composite pollutant equation
    temp.pollutants=c()
    comp.pol.out.units=composite.pollutants[i]
    # Find which pollutants are in composite equation
    for (j in 1:length(pollutants$pollutant)){
      if (grepl(pollutants$pollutant[j], composite.pollutants[i], ignore.case = TRUE)){
        temp.pollutants=c(temp.pollutants, pollutants$pollutant[j])
        # rename the pollutants in the equation with the prefix all.data.onemodel.df$ followed by the pol name that will later be replaced with the dataframe (obs or mod) and pol column reference
        composite.pollutants[i]=gsub(pollutants$pollutant[j],paste("all.data.onemodel.df$",as.character(pollutants$pollutant[j]),sep=""),composite.pollutants[i])
        comp.pol.out.units=gsub(pollutants$pollutant[j],pollutants$output.units[j],comp.pol.out.units)
      }
    }
    
    # Check if any composite pollutants are found
    if (length(temp.pollutants)==0){
      stop(paste("The composite pollutant: ", composite.pollutants.names[i], " cannot be found in the workspace. \n Data is available for the following pollutants: ", available.pollutants.for.comp, ".", sep=""))
      quit("no")
    }
    
    # Subset the pollutants definition dataframe to be the composite pollutants 
    composite.pollutants.def=subset(pollutants, pollutant %in% temp.pollutants)
    
    # Check the component pollutants have the same output units
    if (!(length(unique(composite.pollutants.def$output.units))==1)){
      stop(paste("The composite pollutants: ",composite.pollutants.names[i]," do not have the same output units."))
      quit("no")
    } 
    # Check the component pollutants have the same avg.time
    if (!((length(unique(composite.pollutants.def$avg.time)))==1) | !((length(unique(composite.pollutants.def$statistic)))==1)){
      stop(paste("The composite pollutants: ",composite.pollutants.names[i]," do not have the same averaging time or statistic"))
      quit("no")
    }
    # Add a new row to the pollutants dataframe for the composite pollutants with appropriate values for each
    no.pollutants=nrow(pollutants)
    # add new row to the pollutants dataframe for the composite pollutant
    pollutants[no.pollutants+1,]="tbc"
    # set the name of the new composite pollutant to be the composite pollutant name
    pollutants$pollutant[no.pollutants+1]=composite.pollutants.names[i]
    # set the units of the composite pollutant to be the formulated units 
    pollutants$output.units[no.pollutants+1]=comp.pol.out.units
    # Do not need to set the conversion factor for the composite pollutant 
    pollutants$conv.ugm3.ppb[no.pollutants+1]=NA
    # Do not need to set the min and max allowed values for composite pollutants
    pollutants$min.allowed[no.pollutants+1]=NA
    pollutants$max.allowed[no.pollutants+1]=NA
    # Do not need to set the mod.alias
    pollutants$mod.alias[no.pollutants+1]=NA
    # composite pollutant will not be the "ozone" pollutant so set to "no"
    pollutants$ozone[no.pollutants+1]="no"
    # Set the pollutant.noperiod value to be the same as the composite pollutants name but with any '.' removed
    pollutants$pollutant.noperiod[no.pollutants+1]=gsub("\\.","",composite.pollutants.names[i])
    # find the unique value in the composite pollutant definitions dataframe for the avg.time
    pollutants$avg.time[no.pollutants+1]=unique(composite.pollutants.def$avg.time)
    # find the unique value in the composite pollutant definitions dataframe for the output statistic 
    pollutants$statistic[no.pollutants+1]=unique(composite.pollutants.def$statistic)
    # Mod.units is an unused variable, which is set to NA for the composite pollutants. We require all columns to be set, even if they are not needed
    pollutants$mod.units[no.pollutants+1]=NA
    # ensure the conversion factors remain as numeric 
    pollutants$conv.ugm3.ppb=as.numeric(pollutants$conv.ugm3.ppb)
    # mod.avg.time and mod.statistic are not used for the composite pollutants. These are set to NA to avoid triggering an error because they are missing.
    pollutants$mod.avg.time[no.pollutants+1]=NA
    pollutants$mod.statistic[no.pollutants+1]=NA
    if (bAssessConcs) {
      # find the unique value in the composite pollutant definitions dataframe for the output averaging time
      if (length(unique(composite.pollutants.def$output.avg.time)) != 1) {
        stop(paste0("The composite pollutant: ", composite.pollutants.names[i], " has components with different output.avg.time values.\n"))
        quit("no")
      } else {
        pollutants$output.avg.time[no.pollutants+1]=unique(composite.pollutants.def$output.avg.time)
      }
      # find the unique value in the composite pollutant definitions dataframe for the output averaging time (hours)
      pollutants$output.avg.time.hours[no.pollutants+1]=unique(composite.pollutants.def$output.avg.time.hours)
      # find the unique value in the composite pollutant definitions dataframe for the output statistic
      if (length(unique(composite.pollutants.def$output.statistic)) != 1) {
        stop(paste0("The composite pollutant: ", composite.pollutants.names[i], " has components with different output.statistic values.\n"))
        quit("no")
      } else {
        pollutants$output.statistic[no.pollutants+1]=unique(composite.pollutants.def$output.statistic)
      }
      # find the unique value in the composite pollutant definitions dataframe for the output daily max value
      if (length(unique(composite.pollutants.def$output.daily.max)) != 1) {
        stop(paste0("The composite pollutant: ", composite.pollutants.names[i], " has components with different output.daily.max values.\n"))
        quit("no")
      } else {
        pollutants$output.daily.max[no.pollutants+1]=unique(composite.pollutants.def$output.daily.max)
      }
      # find the unique value in the composite pollutant definitions dataframe for the output averaging time and statistic string
      pollutants$output.avg.time.and.statistic[no.pollutants+1]=unique(composite.pollutants.def$output.avg.time.and.statistic)
      # ensure the output averaging time values are numeric
      pollutants$output.avg.time.hours=as.numeric(pollutants$output.avg.time.hours)
      # define ae.band for the composite pollutants, only if the column exists and the constituent pollutants have the same ae.band value
      if ("ae.band" %in% colnames(pollutants)) {
        # Ensure all component pollutants have the same ae.band value
        if (length(unique(composite.pollutants.def$ae.band)) != 1) {
          stop(paste0("The composite pollutant: ", composite.pollutants.names[i], " has components with different ae.band values.\n"))
          quit("no")
        }
        pollutants$ae.band[no.pollutants+1]=unique(composite.pollutants.def$ae.band)
      }
    }
    #Check for incomplete rows 
    if (any(pollutants[no.pollutants+1,] %in% "tbc")){
      missing.col=names(pollutants)[which(pollutants[no.pollutants+1,] %in% "tbc")]
      missing.col=paste(missing.col, collapse=", ")
      stop(paste("The following pollutants definition information cannot be found for composite pollutant: ", composite.pollutants.names[i],": ",missing.col,"\nPlease correct this before continuing.", sep=""))
      quit("no")
    }
    
  }
}
# Check that if hourly time variation plots are required, that the output time is of hourly resolution for each pollutant and if not, write this to the log file 
if (bAssessConcs & bTimeVariation){
  for (i in 1:length(pollutants$pollutant)){ 
    if ((bTimeVariationday.hour | bTimeVariationhour | bTimeVariationAll)& !(pollutants$output.avg.time[i] %in% c("hour","1 hour"))) {
      cat(paste("Warning: Pollutant: ", pollutants$pollutant[i], " does not have output data at an hourly resolution and therefore the hourly time variation plots will be blank.\n", sep=""))
    }
    if ((bTimeVariationday | bTimeVariationAll)& !(grepl("hour", pollutants$output.avg.time[i])  | pollutants$output.avg.time[i] %in% c("day", "1 day"))) {
      cat(paste("Warning: Pollutant: ", pollutants$pollutant[i], " does not have output data at a daily resolution and therefore the daily time variation plots will be blank.\n", sep=""))
    }
  }
}

#Read and process the uncertainty values file
if (bAssessConcs & bTargetPlot) {
  Target.plot.uncertainties =  read.csv(file=TargetPlot.uncertainty.file)
  names(Target.plot.uncertainties) = tolower(names(Target.plot.uncertainties))
  # make sure each column exists
  if (is.null(Target.plot.uncertainties$beta) | is.null(Target.plot.uncertainties$urlv) | is.null(Target.plot.uncertainties$lv)| is.null(Target.plot.uncertainties$alpha) | 
      is.null(Target.plot.uncertainties$np)| is.null(Target.plot.uncertainties$nnp) |
      is.null(Target.plot.uncertainties$target.units) | is.null(Target.plot.uncertainties$target.avg.time) | is.null(Target.plot.uncertainties$target.statistic) | is.null(Target.plot.uncertainties$target.daily.max) ) {
    stop("You have chosen to create a target plot but you have not included all of the required information in the uncertainties file.")
    quit("no")
  }
  Target.plot.uncertainties$pollutant = tolower(as.character(Target.plot.uncertainties$pollutant))
  Target.plot.uncertainties$target.units = tolower(as.character(Target.plot.uncertainties$target.units))
  Target.plot.uncertainties$target.daily.max = tolower(as.character(Target.plot.uncertainties$target.daily.max))
  
  #Perform checks on the target averaging times and statistics
  reformatted.avg.time.statistic = check_and_reformat_averaging_times_and_statistics(Target.plot.uncertainties$target.avg.time,
                                                                Target.plot.uncertainties$target.statistic,Target.plot.uncertainties$pollutant,"target")
  #Update using the re-formatted averaging times and statistics
  Target.plot.uncertainties$target.avg.time = reformatted.avg.time.statistic$avg.time
  Target.plot.uncertainties$target.statistic = reformatted.avg.time.statistic$statistic
  
  # Initialise plotting target plot and produce target annual statistics to true
  pollutants$bTargetPlot = TRUE
  pollutants$bTargetAnnualStats = TRUE
  # check each pollutant in the pollutant data frame (which may be a single pollutant) is included in the user defined averaging file
  for (j in 1:length(pollutants$pollutant)) {   
    if (length(which(Target.plot.uncertainties$pollutant==pollutants$pollutant[j]))==0 ) {
      cat(paste("Warning: Pollutant",toupper(pollutants$pollutant[j]),"is not in the uncertainties file, so no target plot or annual target stats will be produced for this pollutant.\n"))
      pollutants$bTargetPlot[j] = FALSE
      pollutants$bTargetAnnualStats[j] = FALSE
    } else if (length(which(Target.plot.uncertainties$pollutant==pollutants$pollutant[j])) > 1 ) {
      stop(paste("Pollutant",toupper(pollutants$pollutant[j]),"is listed more than once in the uncertainties file."))
      quit("no")
    }
  }
  
  # now merge with pollutants file
  pollutants = merge(pollutants,Target.plot.uncertainties,by="pollutant",all.x=TRUE)
  for (j in 1:length(pollutants$pollutant)) { 
    if (pollutants$bTargetPlot[j]) {
      #Check output units and uncertainty file units are the same
      if (!(pollutants$output.units[j] == pollutants$target.units[j])) {
        cat(paste("Warning: Pollutant",toupper(pollutants$pollutant[j]),"does not have the correct units in the output averaging times file for a target plot.\n"))
        pollutants$bTargetPlot[j] = FALSE
        pollutants$bTargetAnnualStats[j] = FALSE
      } else {
        #Check output averaging time, statistic and daily max setting are the same as in the uncertainty file, or else annual average (for annual target stats only)
        bTargetAvgTimeMatch = TRUE
        if (pollutants$output.avg.time[j] != pollutants$target.avg.time[j]) {
          cat(paste("Warning: Pollutant",toupper(pollutants$pollutant[j])," does not have the correct averaging time in the output averaging times file for a target plot.\n"))
          bTargetAvgTimeMatch = FALSE
        }
        bTargetStatisticMatch = TRUE
        if (pollutants$output.statistic[j] != pollutants$target.statistic[j]) {
          cat(paste("Warning: Pollutant",toupper(pollutants$pollutant[j])," does not have the correct statistic in the output averaging times file for a target plot.\n"))
          bTargetStatisticMatch = FALSE
        }
        bTargetDailyMaxMatch = TRUE
        if (pollutants$output.daily.max[j] != pollutants$target.daily.max[j]) {
          cat(paste("Warning: Pollutant",toupper(pollutants$pollutant[j])," does not have the correct daily max setting in the output averaging times file for a target plot.\n"))
          bTargetDailyMaxMatch = FALSE
        }
        if ((!bTargetAvgTimeMatch|!bTargetStatisticMatch|!bTargetDailyMaxMatch) & (pollutants$output.avg.time[j] == "1 year") & (pollutants$output.statistic[j] == "mean")) {
          #Can't do target plot for this pollutant but can still do annual target stats
          cat(paste("Pollutant",toupper(pollutants$pollutant[j])," has annual mean output so annual target statistics will still be produced.\n"))
          pollutants$bTargetPlot[j] = FALSE
        } else if ((!bTargetAvgTimeMatch|!bTargetStatisticMatch|!bTargetDailyMaxMatch) ){
          #Can't do target plot or annual target stats for this pollutant
          pollutants$bTargetPlot[j] = FALSE
          pollutants$bTargetAnnualStats[j] = FALSE
        }
      }
    }
  }
  # Check there is at least one pollutant needing a target plot or annual target statistics
  if (all(!pollutants$bTargetPlot) & all(!pollutants$bTargetAnnualStats)) {
    cat("Warning: No valid pollutants for creating target plots or annual target statistics.\n")
    bTargetPlot = FALSE
  }
}

#Read the model RGB colours file
if (specify.model.rgb.colours){
  model.rgb.colours=fread(model.rgb.colours.filepath)
  names(model.rgb.colours)=tolower(names(model.rgb.colours)) #Changing all the column headers to lower case
  expected.col.names=c("model","red","green","blue")
  missing.col.ids = which(!(expected.col.names %in% names(model.rgb.colours)))
  if (length(missing.col.ids)>0) {
    missing.cols = paste(expected.col.names[missing.col.ids],collapse=", ")
    stop(paste("Missing columns in model RGB colours file: ",missing.cols,"\n"))
    quit("no")
  }
  if (nrow(subset(model.rgb.colours,model=="obs")[,1]) != 1){ #Gives an error if no obs row or multiple obs rows
    stop("Missing row in model RGB colours file: obs\n")
    quit("no")
  }
  if (bSingleModel){
    if (nrow(subset(model.rgb.colours,model==single.model)[,1]) != 1){ #Gives an error if no row in the RGB file for model
      stop("Missing row in model RGB colours file for selected model:",single.model,"\n")
      quit("no")
    }
  } else{ #If no models are selected then check that all models in modelled data are present in RGB file
    missing.row.ids=which(!(modelled.data.names %in% model.rgb.colours$model))
    if (length(missing.row.ids) > 0){
      missing.models=paste(modelled.data.names[missing.row.ids],collapse=", ")
      stop("Missing rows in model RGB colours file for selected models: ",missing.models,"\n")
      quit("no")
    }
  }
  if (bEnsemble){ #Checking if the ensemble median row exists if ensemble median option is selected
    if (length(which(model.rgb.colours$model=="EnsembleMedian"))==0) {
      stop(paste("Missing row in model RGB colours file: EnsembleMedian\n"))
      quit("no")
    }
  }
  invalid.RGB.values=subset(model.rgb.colours, red < 0 | red > 255 | blue < 0 | blue > 255 | green < 0 | green > 255)
  if (length(invalid.RGB.values$model) > 0){
    stop("All RGB values in the model RGB colours file must be between 0 and 255\n")
    quit("no")
  }
}


# Loop through each model and pollutant to initially compare obs and mod data
#all comparisons between obs and modelled data are by date-time / station / pollutant / model combination
bDoneFirstModelConc = FALSE
bDoneFirstModelIndex = FALSE
for (m in 1:length(modelled.data.names)) {
  cat(paste("Processing model ",modelled.data.names[m],"...\n",sep=""))
  #Get all the data for this model
  all.data.onemodel = subset(all.data,select=c("date","station","station.output.name","type","pollutant","obs",modelled.data.names[m]))
  names(all.data.onemodel)[names(all.data.onemodel)==modelled.data.names[m]] = "mod"
  all.data.onemodel$model = modelled.data.names[m]
  
  bDoneFirstPolIndex = FALSE
  bDoneFirstPolConc = FALSE
  
  # Check composite pollutants if listed and calculate new pollutants
  if (bCompositePollutants){
    for (i in (1:no.composite.pollutants)){ #Loop through each of the composite pollutants 
      cat(paste("   Calculating composite pollutant: ", composite.pollutants.names[i], "\n", sep=""))
      
      temp.pollutants=c()
      # Find which pollutants are in composite equation
      for (j in 1:length(pollutants$pollutant)){
        if (grepl(pollutants$pollutant[j], composite.pollutants[i], ignore.case = TRUE)){
          temp.pollutants=c(temp.pollutants, pollutants$pollutant[j])
        }
      }
      
      #Subset obs dataframe to get just composite pollutants and use pivot wider to get a column per pollutant 
      all.data.onemodel.obs.comp=subset(all.data.onemodel,  select=-c(mod), pollutant %in% temp.pollutants)
      all.data.onemodel.obs.wide=pivot_wider(all.data.onemodel.obs.comp, names_from=pollutant, values_from=obs)
      
      # Create modelled data dataframe and use the spread function to get a column per pollutant
      all.data.onemodel.mod.comp=subset(all.data.onemodel, select=-c(obs), pollutant %in% temp.pollutants)
      all.data.onemodel.mod.wide=pivot_wider(all.data.onemodel.mod.comp, names_from=pollutant, values_from=mod)
      
      # Create observed and modelled equations by substituting "df" in the equation with the correct dataframe extension
      obs.composite.pollutants=gsub("all.data.onemodel.df$","all.data.onemodel.obs.wide$", composite.pollutants[i], fixed=TRUE)
      mod.composite.pollutants=gsub("all.data.onemodel.df$","all.data.onemodel.mod.wide$", composite.pollutants[i], fixed=TRUE)
      
      #  USe trycatch to see if valid pollutant concentrations can be calculated
      all.data.onemodel.obs.wide[[composite.pollutants.names[i]]]=tryCatch(eval(str2expression(obs.composite.pollutants)), error=function(e) return(NA))
      all.data.onemodel.mod.wide[[composite.pollutants.names[i]]]=tryCatch(eval(str2expression(mod.composite.pollutants)), error=function(e) return(NA))
      
      if(all(is.na(all.data.onemodel.obs.wide[[composite.pollutants.names[i]]])) | all(is.na(all.data.onemodel.mod.wide[[composite.pollutants.names[i]]]))){
        stop(paste("The composite pollutant ",composite.pollutants.names[i]," cannot be evaluated. Check pollutants and operators.", sep=""))
        quit("no")
      }
      
      if (grepl("/",composite.pollutants.names[i])){
        names(all.data.onemodel.mod.wide)[which(names(all.data.onemodel.mod.wide)==composite.pollutants.names[i])]=gsub("\\/","_over_",composite.pollutants.names[i])
        names(all.data.onemodel.obs.wide)[which(names(all.data.onemodel.obs.wide)==composite.pollutants.names[i])]=gsub("\\/","_over_",composite.pollutants.names[i])
        pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\/","_over_",pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\/","_over_", pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\/","_over_", pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])])
        composite.pollutants.names[i]=gsub("\\/","_over_",composite.pollutants.names[i])
      }
      if (grepl("*", composite.pollutants.names[i])){
        names(all.data.onemodel.mod.wide)[which(names(all.data.onemodel.mod.wide)==composite.pollutants.names[i])]=gsub("\\*","_multip_",composite.pollutants.names[i])
        names(all.data.onemodel.obs.wide)[which(names(all.data.onemodel.obs.wide)==composite.pollutants.names[i])]=gsub("\\*","_multip_",composite.pollutants.names[i])
        pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\*","_multip_",pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\*","_multip_", pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\*","_multip_", pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])])
        composite.pollutants.names[i]=gsub("\\*","_multip_",composite.pollutants.names[i])
      }
      if (grepl("+", composite.pollutants.names[i])){
        names(all.data.onemodel.mod.wide)[which(names(all.data.onemodel.mod.wide)==composite.pollutants.names[i])]=gsub("\\+","_plus_",composite.pollutants.names[i])
        names(all.data.onemodel.obs.wide)[which(names(all.data.onemodel.obs.wide)==composite.pollutants.names[i])]=gsub("\\+","_plus_",composite.pollutants.names[i])
        pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\+","_plus_",pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\+","_plus_", pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\+","_plus_", pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])])
        composite.pollutants.names[i]=gsub("\\+","_plus_",composite.pollutants.names[i])
      }
      if (grepl("-", composite.pollutants.names[i])){
        names(all.data.onemodel.mod.wide)[which(names(all.data.onemodel.mod.wide)==composite.pollutants.names[i])]=gsub("\\-","_minus_",composite.pollutants.names[i])
        names(all.data.onemodel.obs.wide)[which(names(all.data.onemodel.obs.wide)==composite.pollutants.names[i])]=gsub("\\-","_minus_",composite.pollutants.names[i])
        pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\-","_minus_",pollutants$pollutant.noperiod[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\-","_minus_", pollutants$mod.alias[which(pollutants$pollutant==composite.pollutants.names[i])])
        pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])]=gsub("\\-","_minus_", pollutants$pollutant[which(pollutants$pollutant==composite.pollutants.names[i])])
        composite.pollutants.names[i]=gsub("\\-","_minus_",composite.pollutants.names[i])
        
      }
      
      composite.pol.data.mod=subset(all.data.onemodel.mod.wide, select=c("date","station","station.output.name","type","model",composite.pollutants.names[i]))
      names(composite.pol.data.mod)[which(names(composite.pol.data.mod)==composite.pollutants.names[i])]="mod"
      composite.pol.data.mod$pollutant=composite.pollutants.names[i]
      composite.pol.data=cbind(composite.pol.data.mod, subset(all.data.onemodel.obs.wide, select=c(composite.pollutants.names[i])))
      names(composite.pol.data)[which(names(composite.pol.data)==composite.pollutants.names[i])]="obs"
      
      all.data.onemodel=rbind(all.data.onemodel, composite.pol.data)
    }
    rm(all.data.onemodel.obs.comp)
    rm(all.data.onemodel.mod.comp)
    rm(all.data.onemodel.mod.wide)
    rm(all.data.onemodel.obs.wide)
    
  }
  
  
  for (j in 1:length(pollutants$pollutant)) {
    bNullIndex = FALSE
    bNullConc = FALSE
    #Get all the data for this pollutant
    all.data.onepol = subset(all.data.onemodel,pollutant==pollutants$pollutant[j])
    if (length(all.data.onepol$station)==0) next   
    cat(paste("   Processing pollutant ",toupper(pollutants$pollutant[j]),"...\n",sep=""))
    all.data.onepol = subset(all.data.onepol,select=c("date","station","station.output.name","type","obs","mod","model"))
    #rename 'station' to 'site' so that openair recognises it
    names(all.data.onepol)[names(all.data.onepol)=="station"] = "site"
    if (bAssessForecasts) {
      cat(paste("      Performing forecast assessment...\n",sep=""))
      
      #Obtain indices for data
      all.data.for.indexing = find.data.for.indexing(all.data.onepol,pollutants,j,index.lower.bounds,data.threshold.index)
      
      #EVALUATE THE MODEL INDEX FORECAST PERFORMANCE
      all.data.for.indexing$index.absdiff = abs(all.data.for.indexing$index.mod - all.data.for.indexing$index.obs)
      
      #add the station type and pollutant back into the data frame for completeness
      names(all.data.for.indexing)[names(all.data.for.indexing)=="site"] = "station"
      all.data.for.indexing = merge(all.data.for.indexing, stations[ ,c("station", "type","station.output.name")], by="station")
      names(all.data.for.indexing)[names(all.data.for.indexing)=="station"] = "site"
      all.data.for.indexing$pollutant = pollutants$pollutant[j]
      all.data.for.indexing$model = modelled.data.names[m]
      
      #Save the indexed data including missing data
      fcst.data.for.output.onepol = all.data.for.indexing
      
      # get time sequence to calculate station data coverage threshold 
      if (bApplyStnThresholdindex & (pollutants$index.daily.max[j]=="yes")){
        alldays=seq.Date(from=as.Date(range(all.data.for.indexing$date)[1]),to=as.Date(range(all.data.for.indexing$date)[2]),by="day")
      } else if (bApplyStnThresholdindex){
        allperiods=seq.POSIXt(from=range(all.data.for.indexing$date)[1],to=range(all.data.for.indexing$date)[2],by=pollutants$index.avg.time[j])
      }
      
      #Remove missing data
      all.data.for.indexing = subset(all.data.for.indexing,!is.na(obs.output.units) & !is.na(mod.output.units))
      
      if (bApplyStnThresholdindex){
        if (pollutants$index.daily.max[j]=="yes") {
          #Count the total number of periods
          numdays=length(alldays)
          #Count the number of valid periods
          all.data.for.indexing$day=as.character(all.data.for.indexing$date,format="%Y-%m-%d")
          station.validity=aggregate(day ~ site, all.data.for.indexing, function(x) length(unique(x)))
          names(station.validity)[names(station.validity)=="day"]="valid.periods"
          station.validity$max.periods=length(alldays)
          all.data.for.indexing = subset(all.data.for.indexing,select=-day)
        } else {
          #Count the total number of periods
          numperiods=length(allperiods)
          #Count the number of valid periods per station
          station.validity=aggregate(index.mod ~ site, all.data.for.indexing, function(x) length(x))
          names(station.validity)[names(station.validity)=="index.mod"]="valid.periods"
          station.validity$max.periods=numperiods
        }
        #Remove invalid stations
        station.validity$perc.valid = 100.0*station.validity$valid.periods/station.validity$max.periods
        station.validity$valid.station = station.validity$perc.valid >= station.threshold.index
        num.stations=length(station.validity$site)
        num.valid.stations=length(subset(station.validity,valid.station==TRUE)[["site"]])
        perc.stations.valid=100*num.valid.stations/num.stations
        cat(paste("         ",num.valid.stations," out of ",num.stations," stations (",round(perc.stations.valid,1),"%) exceed the ",station.threshold.index, "% station data capture threshold \n", sep=""))
        if (! num.valid.stations == num.stations){
          cat(paste("         Invalid stations:\n"))
          for (s in 1:num.stations) {
            if (!station.validity$valid.station[s]) {cat(paste("           ",station.validity$site[s],"\n"))}
          }
        }
        all.data.for.indexing=merge(all.data.for.indexing,station.validity)
        all.data.for.indexing=subset(all.data.for.indexing,valid.station==TRUE)
      }
      
      
      if (length(all.data.for.indexing$date)==0) {
        
        cat(paste("      Warning: No valid forecast index data for ",modelled.data.names[m]," ",toupper(pollutants$pollutant[j])," with ", data.threshold.index, "% data capture threshold\n",sep=""))
        bNullIndex = TRUE
        
      } else {
        #Determine the statistics about accuracy of index prediction for each station
        fcst.accuracy.onepol = data.frame(station = unique(all.data.for.indexing$site), station.output.name = unique(all.data.for.indexing$station.output.name), pollutant = pollutants$pollutant[j], model=modelled.data.names[m])
        #Need station type for filtering or grouping later
        if (bFilterStnType | bGrpStnType) {
          fcst.accuracy.onepol = as.data.frame(merge(fcst.accuracy.onepol,stations[ ,c("station","type")], by="station"))
        }
        scores = table(subset(all.data.for.indexing,select=c("site","index.absdiff")),useNA="no")
        fcst.accuracy.onepol$num.valid.indices = rowSums(scores)
        if (any(dimnames(scores)[[2]]=="0")) {
          num.same = scores[,name="0"]
        } else {
          num.same = 0
        }
        if (any(dimnames(scores)[[2]]=="1")) {
          num.oneapart = scores[,name="1"]
        } else {
          num.oneapart = 0
        }
        fcst.accuracy.onepol$indices.same = num.same
        fcst.accuracy.onepol$indices.oneapart = num.oneapart
        
        #Find the alert level for both obs and mod for each station
        all.data.for.indexing$alert.level.obs=findInterval(all.data.for.indexing$index.obs,thresholds$index)
        all.data.for.indexing$alert.level.mod=findInterval(all.data.for.indexing$index.mod,thresholds$index)
        
        bDoneFirstAlert=FALSE
        for (k in 1:length(thresholds$name)) {
          
          #Work out whether an event was forecast AND observed, forecast but not observed etc
          all.data.for.indexing$mod.obs = 0
          all.data.for.indexing$mod.obs[all.data.for.indexing$alert.level.mod == k & all.data.for.indexing$alert.level.obs == k] = 1
          all.data.for.indexing$mod.noobs = 0
          all.data.for.indexing$mod.noobs[all.data.for.indexing$alert.level.mod == k & all.data.for.indexing$alert.level.obs != k] = 1
          all.data.for.indexing$nomod.obs = 0
          all.data.for.indexing$nomod.obs[all.data.for.indexing$alert.level.mod != k & all.data.for.indexing$alert.level.obs == k] = 1
          all.data.for.indexing$nomod.noobs = 0
          all.data.for.indexing$nomod.noobs[all.data.for.indexing$alert.level.mod != k & all.data.for.indexing$alert.level.obs != k] = 1
          #Work out the components of the odds ratio skill score
          fcst.onealert = data.frame(alert.name=thresholds$name[k],pollutant=pollutants$pollutant[j],station=unique(all.data.for.indexing$site),station.output.name=unique(all.data.for.indexing$station.output.name),model=modelled.data.names[m])
          
          if (any(all.data.for.indexing$alert.level.obs==k)) {
            fcst.onealert$num.obs.alerts = table(subset(all.data.for.indexing,select=c("site","alert.level.obs")))[,name=as.character(k)]
          } else {
            fcst.onealert$num.obs.alerts = 0
          }
          if (any(all.data.for.indexing$mod.obs==1)) {
            fcst.onealert$a = table(subset(all.data.for.indexing,select=c("site","mod.obs")))[,name="1"]
          } else {
            fcst.onealert$a = 0
          }
          if (any(all.data.for.indexing$mod.noobs==1)) {
            fcst.onealert$b = table(subset(all.data.for.indexing,select=c("site","mod.noobs")))[,name="1"]
          } else {
            fcst.onealert$b = 0
          }
          if (any(all.data.for.indexing$nomod.obs==1)) {
            fcst.onealert$c = table(subset(all.data.for.indexing,select=c("site","nomod.obs")))[,name="1"]
          } else {
            fcst.onealert$c = 0
          }
          if (any(all.data.for.indexing$nomod.noobs==1)) {
            fcst.onealert$d = table(subset(all.data.for.indexing,select=c("site","nomod.noobs")))[,name="1"]
          } else {
            fcst.onealert$d = 0
          }
          
          if (bDoneFirstAlert) {
            fcst.onepol = rbind(fcst.onepol,fcst.onealert)
          } else {
            fcst.onepol = fcst.onealert
            bDoneFirstAlert = TRUE
          }
          
        } #End of loop over thresholds
        
        #Need station type for filtering or grouping later
        if (bFilterStnType | bGrpStnType) {
          fcst.onepol = as.data.frame(merge(fcst.onepol,stations[ ,c("station","type")], by="station"))
        }
        
      } #End of check for null data   
      
    } #End of forecast assessment code
    
    if (bAssessConcs) {
      cat(paste("      Performing concentration assessment...\n",sep=""))
      
      # ASSESS CONCS AVERAGING
      # we want the output averaging time and statistic for the data in the concentration evaluation,
      # for the statistics and the plots.
      if ( (pollutants$avg.time[j]==pollutants$output.avg.time[j]) & identical(pollutants$statistic[j],pollutants$output.statistic[j])) {
        # no change to data
        all.data.onepol.averaged = all.data.onepol
      } else {
        all.data.onepol.averaged = average.data(all.data.onepol, pollutants, j, stations.station, data.threshold.conc) # function defined in DataInput.rgg
        all.data.onepol.averaged$model = modelled.data.names[m]
      } 
      #Get the expected length of the data frame, so that we can assess data coverage
      if (bApplyStnThresholdconc & (pollutants$output.daily.max[j]=="yes")){
        alldays=seq.Date(from=as.Date(range(all.data.onepol.averaged$date)[1]),to=as.Date(range(all.data.onepol.averaged$date)[2]),by="day")
      } else if (bApplyStnThresholdconc){
        allperiods=seq.POSIXt(from=range(all.data.onepol.averaged$date)[1],to=range(all.data.onepol.averaged$date)[2],by=pollutants$output.avg.time[j])
      } 
      
      # keep averaged data for plots.  NB includes all data inc NAs.
      all.data.onepol.averaged$pollutant = pollutants$pollutant[j]
      all.data.onepol.averaged$output.avg.time.and.statistic = pollutants$output.avg.time.and.statistic[j]
      
      #remove all lines where one or other or both of obs and mod is missing
      all.data.onepol.averaged = subset(all.data.onepol.averaged,!is.na(obs) & !is.na(mod))
      all.data.onepol= subset(all.data.onepol.averaged,!is.na(obs) & !is.na(mod))
      
      if (bApplyStnThresholdconc){
        if (pollutants$output.daily.max[j]=="yes") {
          #Count the total number of periods
          numdays=length(alldays)
          #Count the number of valid periods
          all.data.onepol$day=as.character(all.data.onepol$date,format="%Y-%m-%d")
          station.validity=aggregate(day ~ site, all.data.onepol, function(x) length(unique(x)))
          names(station.validity)[names(station.validity)=="day"]="valid.periods"
          station.validity$max.periods=length(alldays)
          all.data.onepol = subset(all.data.onepol,select=-day)
        } else {
          #Count the total number of periods
          numperiods=length(allperiods)
          #Count the number of valid periods per station
          station.validity=aggregate(mod ~ site, all.data.onepol, function(x) length(x))
          names(station.validity)[names(station.validity)=="mod"]="valid.periods"
          station.validity$max.periods=numperiods
        }
        #Remove invalid stations
        station.validity$perc.valid = 100.0*station.validity$valid.periods/station.validity$max.periods
        station.validity$valid.station = station.validity$perc.valid >= station.threshold.conc
        num.stations=length(station.validity$site)
        num.valid.stations=length(subset(station.validity,valid.station==TRUE)[["site"]])
        perc.stations.valid=100*num.valid.stations/num.stations
        cat(paste("         ",num.valid.stations," out of ",num.stations," stations (",round(perc.stations.valid,1),"%) exceed the ",station.threshold.conc, "% station data capture threshold \n", sep=""))
        if (! num.valid.stations == num.stations){
          cat(paste("         Invalid stations:\n"))
          for (s in 1:num.stations) {
            if (!station.validity$valid.station[s]) {cat(paste("           ",station.validity$site[s],"\n"))}
          }
        }
        all.data.onepol.averaged=merge(all.data.onepol.averaged,station.validity)
        all.data.onepol.averaged=subset(all.data.onepol.averaged,valid.station==TRUE)
        all.data.onepol=merge(all.data.onepol,station.validity)
        all.data.onepol=subset(all.data.onepol,valid.station==TRUE)
      }
      
      
      if (bBoxWhisker & bBoxLog){
        all.data.onepol=subset(all.data.onepol, (obs!=0 & mod!=0))
      }
      
      # Construct persistence model, if required
      if (bPersistence) {
        all.data.onepol.persistence=all.data.onepol #Copy the existing data

        # If averaging time is greater than 24 hours, exit with an error.
        if (avg_time_hours(pollutants$output.avg.time[j]) > 24) {
          stop("Persistence model can only be constructed for averaging times less than or equal to 24 hours.\n")
          quit("no")
        }
        
        # Construct the persistence model
        persistence.data = all.data.onepol.persistence[, c("site", "date", "obs")] # Make a copy of the data, selecting only the required columns
        names(persistence.data)[names(persistence.data) == "obs"] = "mod"
        persistence.data$date = persistence.data$date + days(persistence.day) # Add the number of days to the forecast given by the persistence forecast day
        all.data.onepol.persistence = all.data.onepol.persistence[, names(all.data.onepol.persistence) != "mod"] # Remove the real mod data from the data frame, to be replaced with the persistence model
        all.data.onepol.persistence = merge(all.data.onepol.persistence, persistence.data, by = c("site", "date"), all.x = TRUE) # Merge the persistence data with the original data frame. The persistence model is in the "mod" column
        rm(persistence.data) # No longer needed
      }
      
      # Extract AE band concentration from the output averaging times file
      if ("ae.band" %in% colnames(pollutants)) {
        ae.band.conc = as.numeric(pollutants$ae.band[j]) # Used in AE band calculations
        if (is.na(ae.band.conc)) {
          stop("AE band values in output averaging times file must be numeric.\n")
          quit("no")
        }
      } else {
        ae.band.conc = NULL
      }
      
      # Extract spatial metric factors from the output averaging times file
      if (bSpatialMetrics) {
        if ("ct" %in% colnames(pollutants)) {
          ct = as.numeric(pollutants$ct[j]) # Used in spatial metrics calculations
          if (is.na(ct)) {
            stop("Ct values in output averaging times file must be numeric.\n")
            quit("no")
          }
        } else {
          stop("The output averaging times file is missing the column ct.\n")
          quit("no")
        }
        if ("gamma.fn" %in% colnames(pollutants)) {
          gamma.fn = as.numeric(pollutants$gamma.fn[j]) # Used in spatial metrics calculations
          if (is.na(gamma.fn)) {
            stop("Gamma.fn values in output averaging times file must be numeric.\n")
            quit("no")
          }
        } else {
          stop("The output averaging times file is missing the column gamma.fn.\n")
          quit("no")
        }
        if ("gamma.fp" %in% colnames(pollutants)) {
          gamma.fp = as.numeric(pollutants$gamma.fp[j]) # Used in spatial metrics calculations
          if (is.na(gamma.fp)) {
            stop("Gamma.fp values in output averaging times file must be numeric.\n")
            quit("no")
          }
        } else {
          stop("The output averaging times file is missing the column gamma.fp.\n")
          quit("no")
        }
      }
      
      if (length(all.data.onepol$site)==0) {
        
        cat(paste("      Warning: No valid concentration data for ",modelled.data.names[m]," ",toupper(pollutants$pollutant[j])," with ", data.threshold.conc, "% data capture threshold\n",sep=""))
        bNullConc = TRUE
        
      } else {
        
        #store the data inc. NAs for plotting - revert back to columns for model labels
        names(all.data.onepol.averaged)[names(all.data.onepol.averaged)=="mod"] = modelled.data.names[m]
        all.data.onepol.averaged = subset(all.data.onepol.averaged, select=names(all.data.onepol.averaged)[names(all.data.onepol.averaged)!="model"])
        # if not first model, append other model data on to data frame, 
        # for each pollutant; can then rbind at end 
        if (bDoneFirstModelConc) {
          all.data.onepol.averaged = merge(all.data.onepol.averaged, all.data.averaged[ all.data.averaged$pollutant==pollutants$pollutant[j], ], all.y=TRUE)   
        }
        
        #Calculate statistics
        # define the columns needed by the grouping and filtering option chosen
        # pollutant and model are singular (subsetted) and type relates to site so want just one column:
        if (bGrpStnName) {
          conc.col.string = groupby.col.openair # "site"
        } else {
          if (bGrpStnType | bFilterStnType) {
            conc.col.string = "type"         
          } else {
            conc.col.string = "pollutant"         
          }
        }      
        
        # allocate the identifiers in the first columns
        if (bGrpStnName) {
          conc.onepol.grouped = data.frame(station=sort(unique(all.data.onepol$site)), station.output.name=sort(unique(all.data.onepol$station.output.name)),pollutant=pollutants$pollutant[j], output.avg.time.and.statistic=pollutants$output.avg.time.and.statistic[j], model=modelled.data.names[m])         
          conc.onepol.grouped$station = as.character(conc.onepol.grouped$station) # 'station' as character to allow row for "all" later
          conc.onepol.grouped$station.output.name = as.character(conc.onepol.grouped$station.output.name) # 'station.output.name' as character to allow row for "all" later
          if (bFilterStnType) {
            conc.onepol.grouped = as.data.frame(merge(stations[ ,c("station","type")], conc.onepol.grouped, by="station"))
          } 
        } else {
          if (bGrpStnType | bFilterStnType) {
            conc.onepol.grouped = data.frame(type=sort(unique(all.data.onepol$type)), pollutant=pollutants$pollutant[j], output.avg.time.and.statistic=pollutants$output.avg.time.and.statistic[j], model=modelled.data.names[m])         
            conc.onepol.grouped$type = as.character(conc.onepol.grouped$type) # 'type' as character to allow row for "all" later
          } else {
            conc.onepol.grouped = data.frame(pollutant=pollutants$pollutant[j], output.avg.time.and.statistic=pollutants$output.avg.time.and.statistic[j], model=modelled.data.names[m])         
          }
        } 
        conc.onepol.grouped$model = as.character(conc.onepol.grouped$model) # 'model' as character to allow row for "all" later
        # then apply validation / BOOT-like statistics over the group/filter combinations
        conc.onepol.grouped$num.valid.values = as.integer(table(all.data.onepol[ ,c(conc.col.string)])) #nb table sorts alphabetically
        conc.onepol.grouped$obs.mean = as.numeric(t(as.data.frame(lapply(split(all.data.onepol$obs, all.data.onepol[,conc.col.string]), FUN=mean))))
        conc.onepol.grouped$mod.mean = as.numeric(t(as.data.frame(lapply(split(all.data.onepol$mod, all.data.onepol[,conc.col.string]), FUN=mean))))
        conc.onepol.grouped$SDO = as.numeric(t(as.data.frame(lapply(split(all.data.onepol$obs, all.data.onepol[,conc.col.string]), FUN=StDev))))
        conc.onepol.grouped$SDM = as.numeric(t(as.data.frame(lapply(split(all.data.onepol$mod, all.data.onepol[,conc.col.string]), FUN=StDev))))
        conc.onepol.grouped$MB= gapply(all.data.onepol,FUN=MBias,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$R = gapply(all.data.onepol,FUN=cor.data,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$R2 =  conc.onepol.grouped$R^2 
        conc.onepol.grouped$FAC2 = gapply(all.data.onepol,FUN=FAC2,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$FB = gapply(all.data.onepol,FUN=FB,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$Fs = gapply(all.data.onepol,FUN=Fs,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$RMSE = gapply(all.data.onepol,FUN=RMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NMSD = gapply(all.data.onepol,FUN=NMSD,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$ME = gapply(all.data.onepol,FUN=ME,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MSE = gapply(all.data.onepol,FUN=MSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MSEs = gapply(all.data.onepol,FUN=MSEs,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MSEu = gapply(all.data.onepol,FUN=MSEu,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MSE.cor = gapply(all.data.onepol,FUN=MSE.cor,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mMSE = gapply(all.data.onepol,FUN=mMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$obs.max = gapply(all.data.onepol,which="obs",FUN=max,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mod.max = gapply(all.data.onepol,which="mod",FUN=max,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$obs.RHC = tapply(all.data.onepol$obs, all.data.onepol[,conc.col.string], RHC)
        conc.onepol.grouped$mod.RHC = tapply(all.data.onepol$mod, all.data.onepol[,conc.col.string], RHC)
        if (pollutants$ozone[j] %in% c("yes") & (! pollutants$output.avg.time.and.statistic[j] %in% c("hourly mean"))){
          cat("         Warning: AOT40 Ozone statistics will not be produced. AOT40 requires the ozone output averaging time to be a 1 hour mean. \n")
        }
        if (pollutants$ozone[j] %in% c("yes") & (! pollutants$output.avg.time.and.statistic[j] %in% c("daily maximum of 8-hour rolling mean"))){
          cat("         Warning: SOMO35 Ozone statistics will not be produced. SOMO35 requires the ozone output averaging time to be the daily maximum of an 8-hour rolling mean. \n")
        }
        conc.onepol.grouped$obs.AOT40.forests=gapply(all.data.onepol,FUN=getAOT40, pollutant=pollutants$pollutant[j], obs.mod="obs",type="forests",groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mod.AOT40.forests=gapply(all.data.onepol,FUN=getAOT40,  pollutant=pollutants$pollutant[j], obs.mod="mod",type="forests",groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$obs.AOT40.crops=gapply(all.data.onepol,FUN=getAOT40, pollutant=pollutants$pollutant[j], obs.mod="obs",type="crops",groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mod.AOT40.crops=gapply(all.data.onepol,FUN=getAOT40, pollutant=pollutants$pollutant[j], obs.mod="mod",type="crops",groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$obs.SOMO35=gapply(all.data.onepol,FUN=getSOMO35, pollutant=pollutants$pollutant[j], obs.mod="obs",groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mod.SOMO35=gapply(all.data.onepol,FUN=getSOMO35, pollutant=pollutants$pollutant[j], obs.mod="mod",groups=all.data.onepol[,conc.col.string])
        
        #Stats to give percentile output
        if (bPercentiles){
          conc.onepol.grouped$percentile=pollutants$percentile[j]
          if (pollutants$percentile[j] == -999){
            conc.onepol.grouped$obs.perc=-999
            conc.onepol.grouped$mod.perc=-999
          } else{ #Calculating the mod and obs values at the given percentiles
            conc.onepol.grouped$obs.perc=gapply(all.data.onepol,which="obs",FUN=quantile,groups=all.data.onepol[,conc.col.string],probs=0.01*pollutants$percentile[j],na.rm=TRUE)
            conc.onepol.grouped$mod.perc=gapply(all.data.onepol,which="mod",FUN=quantile,groups=all.data.onepol[,conc.col.string],probs=0.01*pollutants$percentile[j],na.rm=TRUE)
          }
        }
        
        #Calculate box and whisker plot statistics
        #Observed statistics
        obs.data.box.stats = t(as.data.frame(lapply(split(all.data.onepol[,"obs"],all.data.onepol[,conc.col.string]),FUN=boxplot.stats.wrap)))
        conc.onepol.grouped$obs.lower.whisker=obs.data.box.stats[,1]
        conc.onepol.grouped$obs.lower.quartile=obs.data.box.stats[,2]
        conc.onepol.grouped$obs.median=obs.data.box.stats[,3]
        conc.onepol.grouped$obs.upper.quartile=obs.data.box.stats[,4]
        conc.onepol.grouped$obs.upper.whisker=obs.data.box.stats[,5]
        #Modelled statistics
        mod.data.box.stats = t(as.data.frame(lapply(split(all.data.onepol[,"mod"],all.data.onepol[,conc.col.string]),FUN=boxplot.stats.wrap)))
        conc.onepol.grouped$mod.lower.whisker=mod.data.box.stats[,1]
        conc.onepol.grouped$mod.lower.quartile=mod.data.box.stats[,2]
        conc.onepol.grouped$mod.median=mod.data.box.stats[,3]
        conc.onepol.grouped$mod.upper.quartile=mod.data.box.stats[,4]
        conc.onepol.grouped$mod.upper.whisker=mod.data.box.stats[,5]
        # target plot statistics
        conc.onepol.grouped$CRMSE = gapply(all.data.onepol,FUN=CRMSE,groups=all.data.onepol[,conc.col.string])
        # Bias statistics
        conc.onepol.grouped$B_NMBF = gapply(all.data.onepol,FUN=B_NMBF,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MAE = gapply(all.data.onepol,FUN=MAE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NAE = gapply(all.data.onepol,FUN=NAE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MFE = gapply(all.data.onepol,FUN=MFE,groups=all.data.onepol[,conc.col.string])
        if (bPersistence) {
          conc.onepol.grouped$MFEp = gapply(all.data.onepol.persistence,FUN=MFE,groups=all.data.onepol.persistence[,conc.col.string])
          conc.onepol.grouped$MPI_FP = conc.onepol.grouped$MFE / conc.onepol.grouped$MFEp
        }
        conc.onepol.grouped$NRMSE = gapply(all.data.onepol,FUN=NRMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NCRMSE = gapply(all.data.onepol,FUN=NCRMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$tau = gapply(all.data.onepol,FUN=tau,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$epsilon = gapply(all.data.onepol,FUN=epsilon,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NMSE = gapply(all.data.onepol,FUN=NMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NMSEs = gapply(all.data.onepol,FUN=NMSEs,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NMSEu = gapply(all.data.onepol,FUN=NMSEu,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NMSE.cor = gapply(all.data.onepol,FUN=NMSE.cor,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$mNMSE = gapply(all.data.onepol,FUN=mNMSE,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$FACn = gapply(all.data.onepol,FUN=FACn,groups=all.data.onepol[,conc.col.string])
        if (!is.null(ae.band.conc)) {
          conc.onepol.grouped$AE_band_conc = ae.band.conc
          conc.onepol.grouped$AE_band = gapply(all.data.onepol,FUN=AE_band,groups=all.data.onepol[,conc.col.string])
        }
        conc.onepol.grouped$rrank = gapply(all.data.onepol,FUN=rrank,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$NSD = gapply(all.data.onepol,FUN=NSD,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$theta = gapply(all.data.onepol,FUN=theta,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$AU = gapply(all.data.onepol,FUN=AU,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$AP = gapply(all.data.onepol,FUN=AP,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$FMT = gapply(all.data.onepol,FUN=FMT,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$HH = gapply(all.data.onepol,FUN=HH,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$KS = gapply(all.data.onepol,FUN=KS,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MSI = gapply(all.data.onepol,FUN=MSI,groups=all.data.onepol[,conc.col.string])
        conc.onepol.grouped$MMSI = gapply(all.data.onepol,FUN=function(x) MSI(x, bModified = TRUE),groups=all.data.onepol[,conc.col.string])
        # Calculating spatial metrics, if required
        if (bSpatialMetrics) {
          conc.onepol.grouped$AFN = gapply(all.data.onepol,FUN=AFN,groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$AFP = gapply(all.data.onepol,FUN=AFP,groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$FMS = gapply(all.data.onepol,FUN=function(x) FMS(x, ct = ct),groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$`1DMOE` = gapply(all.data.onepol,FUN=function(x) MOE1D(x, ct = ct, gamma.fn = gamma.fn, gamma.fp = gamma.fp),groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$`2DMOE.x` = gapply(all.data.onepol, FUN=function(x) MOE2D(x, ct = ct)[1], groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$`2DMOE.y` = gapply(all.data.onepol, FUN=function(x) MOE2D(x, ct = ct)[2], groups=all.data.onepol[,conc.col.string])
          conc.onepol.grouped$RANK = gapply(all.data.onepol, FUN=function(x) RANK(x, ct = ct), groups=all.data.onepol[,conc.col.string])
        }

        # and create a row for "all" data for each pollutant - i.e. over the whole model
        if (!all(c(groupby.col.label,filterby.col.label) == "pollutant" | c(groupby.col.label,filterby.col.label) == "model")) { # ignore if only grouping by pollutant or model with same filters
          end.row = length(conc.onepol.grouped[,1])      
          end.row.data = conc.onepol.grouped[1,] # preallocate
          end.row.data[1, ] = rep(NA,length(end.row.data[1,])) # preallocate with NAs
          conc.onepol.grouped = rbind(conc.onepol.grouped, end.row.data) # preallocate with NAs
          if (any(names(conc.onepol.grouped)=="station")) {
            conc.onepol.grouped[end.row+1, "station"] = "all"   # can't use conc.col.string as use "site" in all.data.onepol not "station"
          } 
          if (any(names(conc.onepol.grouped)=="station.output.name")) {
            conc.onepol.grouped[end.row+1, "station.output.name"] = "all"   
          } 
          if (any(names(conc.onepol.grouped)=="type")) {
            conc.onepol.grouped[end.row+1, "type"] = "all"          
          }     
          conc.onepol.grouped[end.row+1, c("pollutant","output.avg.time.and.statistic","model")] = c(pollutants$pollutant[j],pollutants$output.avg.time.and.statistic[j],modelled.data.names[m])
          conc.onepol.grouped[end.row+1,"num.valid.values"] = length(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"obs.mean"] = mean(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"mod.mean"] = mean(all.data.onepol$mod)
          conc.onepol.grouped[end.row+1,"SDO"] = StDev(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"SDM"] = StDev(all.data.onepol$mod)
          conc.onepol.grouped[end.row+1,"MB"] = MBias(all.data.onepol)
          conc.onepol.grouped[end.row+1,"R"] = cor.data(all.data.onepol)
          conc.onepol.grouped[end.row+1,"R2"] = (conc.onepol.grouped$R[end.row+1])^2
          conc.onepol.grouped[end.row+1,"FAC2"] = FAC2(all.data.onepol)
          conc.onepol.grouped[end.row+1,"FB"] = FB(all.data.onepol)
          conc.onepol.grouped[end.row+1,"Fs"] = Fs(all.data.onepol)
          conc.onepol.grouped[end.row+1,"RMSE"] = RMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NMSD"] = NMSD(all.data.onepol)
          conc.onepol.grouped[end.row+1,"ME"] = ME(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MSE"] = MSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MSEs"] = MSEs(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MSEu"] = MSEu(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MSE.cor"] = MSE.cor(all.data.onepol)
          conc.onepol.grouped[end.row+1,"mMSE"] = mMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"obs.max"] = max(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"mod.max"] = max(all.data.onepol$mod)
          conc.onepol.grouped[end.row+1,"obs.RHC"] = RHC(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"mod.RHC"] = RHC(all.data.onepol$mod)
          #Stats to give percentile output
          if (bPercentiles){
            if (pollutants$percentile[j] == -999){
              conc.onepol.grouped[end.row+1,"obs.perc"] = -999
              conc.onepol.grouped[end.row+1,"mod.perc"] = -999
            } else{ #Calculating the mod and obs values at the given percentiles
              conc.onepol.grouped[end.row+1,"obs.perc"] = quantile(all.data.onepol$obs,probs=0.01*pollutants$percentile[j],na.rm=TRUE)
              conc.onepol.grouped[end.row+1,"mod.perc"] = quantile(all.data.onepol$mod,probs=0.01*pollutants$percentile[j],na.rm=TRUE)
            }
            conc.onepol.grouped[end.row+1,"percentile"] = pollutants$percentile[j]
          }
          
          #Box plot stats
          obs.data.box.stats = boxplot.stats.wrap(all.data.onepol$obs)
          conc.onepol.grouped[end.row+1,"obs.lower.whisker"]=obs.data.box.stats[1]
          conc.onepol.grouped[end.row+1,"obs.lower.quartile"]=obs.data.box.stats[2]
          conc.onepol.grouped[end.row+1,"obs.median"]=obs.data.box.stats[3]
          conc.onepol.grouped[end.row+1,"obs.upper.quartile"]=obs.data.box.stats[4]
          conc.onepol.grouped[end.row+1,"obs.upper.whisker"]=obs.data.box.stats[5]
          mod.data.box.stats = boxplot.stats.wrap(all.data.onepol$mod)
          conc.onepol.grouped[end.row+1,"mod.lower.whisker"]=mod.data.box.stats[1]
          conc.onepol.grouped[end.row+1,"mod.lower.quartile"]=mod.data.box.stats[2]
          conc.onepol.grouped[end.row+1,"mod.median"]=mod.data.box.stats[3]
          conc.onepol.grouped[end.row+1,"mod.upper.quartile"]=mod.data.box.stats[4]
          conc.onepol.grouped[end.row+1,"mod.upper.whisker"]=mod.data.box.stats[5]
          conc.onepol.grouped[end.row+1,"CRMSE"] = CRMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"obs.AOT40.forests"]=getAOT40(all.data.onepol,obs.mod="obs",type="forests", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"mod.AOT40.forests"]=getAOT40(all.data.onepol,obs.mod="mod",type="forests", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"obs.AOT40.crops"]=getAOT40(all.data.onepol,obs.mod="obs",type="crops", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"mod.AOT40.crops"]=getAOT40(all.data.onepol,obs.mod="mod",type="crops", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"obs.SOMO35"]=getSOMO35(all.data.onepol,obs.mod="obs", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"mod.SOMO35"]=getSOMO35(all.data.onepol,obs.mod="mod", pollutants$pollutant[j])
          conc.onepol.grouped[end.row+1,"B_NMBF"] = B_NMBF(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MAE"] = MAE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NAE"] = NAE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MFE"] = MFE(all.data.onepol)
          if (bPersistence) {
            conc.onepol.grouped[end.row+1,"MFEp"] = MFE(all.data.onepol.persistence)
            conc.onepol.grouped[end.row+1,"MPI_FP"] = conc.onepol.grouped[end.row+1,"MFE"] / conc.onepol.grouped[end.row+1,"MFEp"]
            rm(all.data.onepol.persistence) # No longer needed
          }
          conc.onepol.grouped[end.row+1,"NRMSE"] = NRMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NCRMSE"] = NCRMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"tau"] = tau(all.data.onepol)
          conc.onepol.grouped[end.row+1,"epsilon"] = epsilon(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NMSE"] = NMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NMSEs"] = NMSEs(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NMSEu"] = NMSEu(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NMSE.cor"] = NMSE.cor(all.data.onepol)
          conc.onepol.grouped[end.row+1,"mNMSE"] = mNMSE(all.data.onepol)
          conc.onepol.grouped[end.row+1,"FACn"] = FACn(all.data.onepol)
          if (!is.null(ae.band.conc)) {
            conc.onepol.grouped[end.row+1,"AE_band_conc"] = ae.band.conc
            conc.onepol.grouped[end.row+1,"AE_band"] = AE_band(all.data.onepol)
          }
          conc.onepol.grouped[end.row+1,"rrank"] = rrank(all.data.onepol)
          conc.onepol.grouped[end.row+1,"NSD"] = NSD(all.data.onepol)
          conc.onepol.grouped[end.row+1,"theta"] = theta(all.data.onepol)
          conc.onepol.grouped[end.row+1,"AU"] = AU(all.data.onepol)
          conc.onepol.grouped[end.row+1,"AP"] = AP(all.data.onepol)
          conc.onepol.grouped[end.row+1,"FMT"] = FMT(all.data.onepol)
          conc.onepol.grouped[end.row+1,"HH"] = HH(all.data.onepol)
          conc.onepol.grouped[end.row+1,"KS"] = KS(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MSI"] = MSI(all.data.onepol)
          conc.onepol.grouped[end.row+1,"MMSI"] = MSI(all.data.onepol, bModified=TRUE)
          if (bSpatialMetrics) {
            conc.onepol.grouped[end.row+1,"AFN"] = AFN(all.data.onepol)
            conc.onepol.grouped[end.row+1,"AFP"] = AFP(all.data.onepol)
            conc.onepol.grouped[end.row+1,"FMS"] = FMS(all.data.onepol, ct=ct)
            conc.onepol.grouped[end.row+1,"1DMOE"] = MOE1D(all.data.onepol, ct=ct, gamma.fn=gamma.fn, gamma.fp=gamma.fp)
            conc.onepol.grouped[end.row+1, "2DMOE.x"] = MOE2D(all.data.onepol, ct = ct)[1]
            conc.onepol.grouped[end.row+1, "2DMOE.y"] = MOE2D(all.data.onepol, ct = ct)[2]
            conc.onepol.grouped[end.row+1, "RANK"] = RANK(all.data.onepol, ct= ct)
          }
        }

        # Rename FACn column to include the value of its variable
        colnames(conc.onepol.grouped)[which(colnames(conc.onepol.grouped) == "FACn")] = paste("FAC", facn.int, sep = "")
        
        # and add target plot stats (applies to every row)
        if (bTargetPlot) {
          if (pollutants$bTargetPlot[j]) {
            #Time series statistics
            conc.onepol.grouped$RMSU = pollutants$urlv[j]*sqrt(
              (1-pollutants$alpha[j]^2)*(conc.onepol.grouped$obs.mean^2+conc.onepol.grouped$SDO^2)+
                (pollutants$alpha[j]^2)*(pollutants$lv[j]^2))
            conc.onepol.grouped$NMB=conc.onepol.grouped$MB/(conc.onepol.grouped$obs.mean)
            conc.onepol.grouped$MQI = conc.onepol.grouped$RMSE / (pollutants$beta[j] * conc.onepol.grouped$RMSU)
            conc.onepol.grouped$MPI.bias = (abs(conc.onepol.grouped$obs.mean - conc.onepol.grouped$mod.mean))/(pollutants$beta[j] * conc.onepol.grouped$RMSU)
            conc.onepol.grouped$RMSUM=conc.onepol.grouped$RMSU*sqrt(((conc.onepol.grouped$RMSE/conc.onepol.grouped$RMSU)^2)-1)
            #Replacing NaN with 0 in RMSUM
            conc.onepol.grouped$RMSUM[which(conc.onepol.grouped$RMSUM=="NaN")]=0
            conc.onepol.grouped$RMSUM.perc=conc.onepol.grouped$RMSUM*(pollutants$urlv[j]/conc.onepol.grouped$RMSU)*100
            conc.onepol.grouped$MPI.r = (1 - conc.onepol.grouped$R)*(conc.onepol.grouped$SDM*conc.onepol.grouped$SDO)/(0.5*(pollutants$beta[j]^2)*(conc.onepol.grouped$RMSU^2))
            conc.onepol.grouped$MPI.sd = (abs(conc.onepol.grouped$SDM - conc.onepol.grouped$SDO)) / (pollutants$beta[j] * conc.onepol.grouped$RMSU)
            conc.onepol.grouped$CRMSE.sign = sign(abs((conc.onepol.grouped$SDM-conc.onepol.grouped$SDO)^2)-2*(conc.onepol.grouped$SDO*conc.onepol.grouped$SDM*(1-conc.onepol.grouped$R)))*conc.onepol.grouped$CRMSE
          } else {
            conc.onepol.grouped$RMSU = NA
            conc.onepol.grouped$NMB=NA
            conc.onepol.grouped$MQI = NA
            conc.onepol.grouped$MPI.bias = NA
            conc.onepol.grouped$RMSUM=NA
            conc.onepol.grouped$RMSUM.perc=NA
            conc.onepol.grouped$MPI.r = NA
            conc.onepol.grouped$MPI.sd = NA
            conc.onepol.grouped$CRMSE.sign = NA 
          } 
          if (pollutants$bTargetAnnualStats[j]){
            #Annual statistics
            conc.onepol.grouped$UOmean = pollutants$urlv[j] * sqrt(((conc.onepol.grouped$obs.mean^2)/pollutants$np[j])*(1-pollutants$alpha[j]^2)+
                                                                     (pollutants$alpha[j]^2)*(pollutants$lv[j]^2)/pollutants$nnp[j])
            conc.onepol.grouped$MQI.annual = (abs(conc.onepol.grouped$obs.mean - conc.onepol.grouped$mod.mean))/(pollutants$beta[j] * conc.onepol.grouped$UOmean)
            conc.onepol.grouped$RMSUM.annual=conc.onepol.grouped$UOmean*sqrt((((conc.onepol.grouped$obs.mean-conc.onepol.grouped$mod.mean)/conc.onepol.grouped$UOmean)^2)-1)
            if (any(is.na(conc.onepol.grouped$RMSUM.annual))) {
              conc.onepol.grouped$RMSUM.annual[which(is.na(conc.onepol.grouped$RMSUM.annual))]= 0
            }
            conc.onepol.grouped$RMSUM.annual.perc=conc.onepol.grouped$RMSUM.annual*(pollutants$urlv[j]/conc.onepol.grouped$UOmean)*100
            
            #Calculating Spatial MPI
            conc.onepol.grouped$Spatial_MPI_corr = Spatial_MPI_corr(conc.onepol.grouped, pollutants$beta[j])
            conc.onepol.grouped$Spatial_MPI_sd = Spatial_MPI_sd(conc.onepol.grouped, pollutants$beta[j])
            
          } else {
            conc.onepol.grouped$UOmean = NA
            conc.onepol.grouped$MQI.annual = NA
            conc.onepol.grouped$RMSUM.annual=NA
            conc.onepol.grouped$RMSUM.annual.perc=NA
            conc.onepol.grouped$Spatial_MPI_corr = NA
            conc.onepol.grouped$Spatial_MPI_sd = NA
          }
          
        }  #End of check for null concentration data
      } 
    } #End of concentration assessment code
    
    if (bAssessConcs & !bNullConc) {
      #Store summary information for each pollutant   
      summary.onepol.conc = station.types[order(station.types$type), ]
      summary.onepol.conc$pollutant = pollutants$pollutant[j]
      summary.onepol.conc$model = modelled.data.names[m]
      temp = as.data.frame(gapply(all.data.onepol,which="site",FUN=length.unique,groups=all.data.onepol$type))
      names(temp)="num.valid.stations"
      temp$type = row.names(temp)
      summary.onepol.conc = merge(summary.onepol.conc,temp,by="type",all.x=TRUE)
      if (bDoneFirstPolConc) {
        summary.by.pol.and.type.one.model.conc = rbind(summary.by.pol.and.type.one.model.conc,summary.onepol.conc)
        all.data.averaged.one.model = rbind(all.data.averaged.one.model, all.data.onepol.averaged)
        rm(all.data.onepol.averaged, all.data.onepol)
        conc.grouped.one.model = rbind(conc.grouped.one.model,conc.onepol.grouped)
      } else {
        summary.by.pol.and.type.one.model.conc = summary.onepol.conc
        # keep averaged data for plots
        # NB "site" not "station"
        all.data.averaged.one.model = all.data.onepol.averaged 
        rm(all.data.onepol.averaged, all.data.onepol)
        # and concentration summary information by station and for all
        conc.grouped.one.model = conc.onepol.grouped
        bDoneFirstPolConc = TRUE
      }
    }
    if (bAssessForecasts & !bNullIndex) {
      #Store summary information for each pollutant   
      summary.onepol.index = station.types[order(station.types$type), ]
      summary.onepol.index$pollutant = pollutants$pollutant[j]
      summary.onepol.index$model = modelled.data.names[m]
      temp.data =  merge(fcst.onepol[!is.na(fcst.onepol$num.obs.alerts),c("station","num.obs.alerts")], stations)
      temp = as.data.frame(gapply(temp.data,which="station",FUN=length.unique,groups=temp.data$type))
      names(temp)="num.valid.stations"
      temp$type = row.names(temp)
      summary.onepol.index = merge(summary.onepol.index,temp,by="type",all.x=TRUE)
      if (bDoneFirstPolIndex) {
        summary.by.pol.and.type.one.model.index = rbind(summary.by.pol.and.type.one.model.index,summary.onepol.index)
        fcst.allstns.one.model = rbind(fcst.allstns.one.model,fcst.onepol)
        fcst.data.for.output.one.model = rbind(fcst.data.for.output.one.model,fcst.data.for.output.onepol)
        fcst.accuracy.allstns.one.model = rbind(fcst.accuracy.allstns.one.model,fcst.accuracy.onepol)
      } else {
        summary.by.pol.and.type.one.model.index = summary.onepol.index
        fcst.allstns.one.model = fcst.onepol
        fcst.data.for.output.one.model = fcst.data.for.output.onepol
        fcst.accuracy.allstns.one.model = fcst.accuracy.onepol
        bDoneFirstPolIndex = TRUE
      }
    }                  
    
  } #End of loop over pollutants
  
  if (bAssessConcs) {
    if (bDoneFirstPolConc) {
      if (bDoneFirstModelConc) {
        summary.by.pol.and.type.conc = rbind(summary.by.pol.and.type.conc,summary.by.pol.and.type.one.model.conc)
        all.data.averaged = all.data.averaged.one.model
        rm(all.data.averaged.one.model, all.data.onemodel) 
        conc.grouped = rbind(conc.grouped,conc.grouped.one.model)
      } else {
        summary.by.pol.and.type.conc = summary.by.pol.and.type.one.model.conc
        # keep averaged data for plots
        # NB "site" not "station"
        all.data.averaged = all.data.averaged.one.model
        rm(all.data.averaged.one.model, all.data.onemodel) 
        # and concentration summary information by station and for all
        conc.grouped = conc.grouped.one.model
        bDoneFirstModelConc = TRUE
      }
    }
  }
  if (bAssessForecasts) {
    if (bDoneFirstPolIndex) {
      if (bDoneFirstModelIndex) {
        summary.by.pol.and.type.index = rbind(summary.by.pol.and.type.index,summary.by.pol.and.type.one.model.index)
        fcst.allstns = rbind(fcst.allstns,fcst.allstns.one.model)
        fcst.data.for.output = rbind(fcst.data.for.output,fcst.data.for.output.one.model)
        fcst.accuracy.allstns = rbind(fcst.accuracy.allstns,fcst.accuracy.allstns.one.model)
      } else {
        summary.by.pol.and.type.index = summary.by.pol.and.type.one.model.index
        fcst.allstns = fcst.allstns.one.model
        fcst.data.for.output = fcst.data.for.output.one.model
        fcst.accuracy.allstns = fcst.accuracy.allstns.one.model
        bDoneFirstModelIndex = TRUE
      }
    }
  }
  
} #end of loop over models

if (bAssessConcs & !bDoneFirstModelConc) {
  cat(paste("Warning: No valid concentration data with ", data.threshold.conc, "% data capture threshold - no concentration evaluation plots will be output\n",sep=""))
  bAssessConcs = FALSE
}

if (bAssessForecasts & !bDoneFirstModelIndex) {
  cat(paste("Warning: No valid forecast index data with ", data.threshold.index, "% data capture threshold - no forecast index evaluation plots will be output\n",sep=""))
  bAssessForecasts = FALSE
}


if (!bAssessConcs & !bAssessForecasts) {
  stop("No valid data to graph - stopping")
  quit("no")
}

# once collated for all pollutants, make names(all.data.averaged)==names(all.data)
if (bAssessConcs) {
  names(all.data.averaged)[names(all.data.averaged)=="site"] = "station"
}

#Get summary data by filter variable for plots
#create summary data frame
if (bAssessConcs) {
  summary.by.filter.conc = as.data.frame(filterby.list)
  names(summary.by.filter.conc) = filterby.col.label
}
if (bAssessForecasts) {
  summary.by.filter.index = as.data.frame(filterby.list)
  names(summary.by.filter.index) = filterby.col.label
}
#When grouping by model with no filters or single pollutant, single model chosen
# need to amend the filterby.col.label column in order to 'group' over all data
if (all(filterby.col.label == groupby.col.label)) {
  if (bAssessConcs) {summary.by.pol.and.type.conc$station.output.name = rep("all data", length(summary.by.pol.and.type.conc[,1]))}
  if (bAssessForecasts) {summary.by.pol.and.type.index$station.output.name = rep("all data", length(summary.by.pol.and.type.index[,1]))}
  filterby.col.label = "station.output.name"
}
# picking a single pollutant removes the filtering by pollutant, which is needed for the box plot
if (bSinglePollutant) {
  if (bAssessConcs) {summary.by.filter.conc$pollutant = tolower(single.pollutant)}
  if (bAssessForecasts) {summary.by.filter.index$pollutant = tolower(single.pollutant)}
}
if (bGrpStnName) {
  # create a list of the variables to be applied to group in gapply
  if (all(filterby.list == "all data")) {
    if (bAssessConcs) {filterby.data.as.list.conc = list(rep("all data",length(summary.by.pol.and.type.conc[,1])))}
    if (bAssessForecasts) {filterby.data.as.list.index = list(rep("all data",length(summary.by.pol.and.type.index[,1])))}
  } else {
    if (length(filterby.list) == 1) {
      if (bAssessConcs) {filterby.data.as.list.conc = eval(parse(text=paste("list(summary.by.pol.and.type.conc$",filterby.col.label[1],")",sep="")))}
      if (bAssessForecasts) {filterby.data.as.list.index = eval(parse(text=paste("list(summary.by.pol.and.type.index$",filterby.col.label[1],")",sep="")))}
    } else if (length(filterby.list)==2) {
      if (bAssessConcs) {filterby.data.as.list.conc = eval(parse(text=paste("list(summary.by.pol.and.type.conc$",filterby.col.label[1],",summary.by.pol.and.type.conc$",filterby.col.label[2],")",sep="")))}
      if (bAssessForecasts) {filterby.data.as.list.index = eval(parse(text=paste("list(summary.by.pol.and.type.index$",filterby.col.label[1],",summary.by.pol.and.type.index$",filterby.col.label[2],")",sep="")))}
    } else if (length(filterby.list)==3) {
      if (bAssessConcs) {filterby.data.as.list.conc = eval(parse(text=paste("list(summary.by.pol.and.type.conc$",filterby.col.label[1],",summary.by.pol.and.type.conc$",filterby.col.label[2],",summary.by.pol.and.type.conc$",filterby.col.label[3],")",sep="")))}
      if (bAssessForecasts) {filterby.data.as.list.index = eval(parse(text=paste("list(summary.by.pol.and.type.index$",filterby.col.label[1],",summary.by.pol.and.type.index$",filterby.col.label[2],",summary.by.pol.and.type.index$",filterby.col.label[3],")",sep="")))}
    }
  }
  #apply gapply to summary.by.pol.and.type depending on filters
  if (!bFilterStnType) {
    #collate all station types together so SUM
    if (bAssessConcs) {temp.b.conc = aggregate(summary.by.pol.and.type.conc$num.stations, by=filterby.data.as.list.conc,FUN=sum,na.rm=TRUE)} 
    if (bAssessForecasts) {temp.b.index = aggregate(summary.by.pol.and.type.index$num.stations, by=filterby.data.as.list.index,FUN=sum,na.rm=TRUE)} 
    if (bAssessConcs) {temp.a.conc = aggregate(summary.by.pol.and.type.conc$num.valid.stations, by=filterby.data.as.list.conc,FUN=sum,na.rm=TRUE)} 
    if (bAssessForecasts) {temp.a.index = aggregate(summary.by.pol.and.type.index$num.valid.stations, by=filterby.data.as.list.index,FUN=sum,na.rm=TRUE)} 
  } else {
    #find maximums in each station type or model, over pollutant/model groups
    if (bAssessConcs) {temp.b.conc = aggregate(summary.by.pol.and.type.conc$num.stations, by=filterby.data.as.list.conc,FUN=max,na.rm=TRUE)}
    if (bAssessForecasts) {temp.b.index = aggregate(summary.by.pol.and.type.index$num.stations, by=filterby.data.as.list.index,FUN=max,na.rm=TRUE)}
    if (bAssessConcs) {temp.a.conc = aggregate(summary.by.pol.and.type.conc$num.valid.stations, by=filterby.data.as.list.conc,FUN=max,na.rm=TRUE)}
    if (bAssessForecasts) {temp.a.index = aggregate(summary.by.pol.and.type.index$num.valid.stations, by=filterby.data.as.list.index,FUN=max,na.rm=TRUE)}
  }
  if (bAssessConcs) {
    names(temp.b.conc) = c(filterby.col.label,"num.stations")
    names(temp.a.conc) = c(filterby.col.label,"num.valid.stations")
    summary.by.filter.conc = merge(summary.by.filter.conc, temp.a.conc, by=filterby.col.label)
    summary.by.filter.conc = merge(summary.by.filter.conc, temp.b.conc, by=filterby.col.label)
    # re-order to filterby.list (alphabetical) order following merge
    for (j in 1:length(filterby.list)) {
      summary.by.filter.conc = summary.by.filter.conc[order(summary.by.filter.conc[, filterby.col.label[j]]), ]
    }
  }
  if (bAssessForecasts) {
    names(temp.b.index) = c(filterby.col.label,"num.stations")
    names(temp.a.index) = c(filterby.col.label,"num.valid.stations")
    summary.by.filter.index = merge(summary.by.filter.index, temp.a.index, by=filterby.col.label)
    summary.by.filter.index = merge(summary.by.filter.index, temp.b.index, by=filterby.col.label)
    # re-order to filterby.list (alphabetical) order following merge
    for (j in 1:length(filterby.list)) {
      summary.by.filter.index = summary.by.filter.index[order(summary.by.filter.index[, filterby.col.label[j]]), ]
    }
  }
} else if (bGrpStnType) {
  if (bAssessConcs) {
    #need to remove NA lines before finding length of unique 'type'
    summary.by.pol.and.type.conc = summary.by.pol.and.type.conc[ !is.na(summary.by.pol.and.type.conc$num.valid.stations), ]
    summary.by.filter.conc[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.conc, which="type",FUN=length.unique, groups=summary.by.pol.and.type.conc[ ,filterby.col.label]))
    summary.by.filter.conc[ ,c("num.stations")] = as.data.frame(rep(length(station.types.available), filterby.length))
  }
  if (bAssessForecasts) {
    #need to remove NA lines before finding length of unique 'type'
    summary.by.pol.and.type.index = summary.by.pol.and.type.index[ !is.na(summary.by.pol.and.type.index$num.valid.stations), ]
    summary.by.filter.index[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.index, which="type",FUN=length.unique, groups=summary.by.pol.and.type.index[ ,filterby.col.label]))
    summary.by.filter.index[ ,c("num.stations")] = as.data.frame(rep(length(station.types.available), filterby.length))
  }
} else if (bGrpPollutant) {
  if (bAssessConcs) {
    #need to remove NA lines before finding length of unique 'pollutant'
    summary.by.pol.and.type.conc = summary.by.pol.and.type.conc[ !is.na(summary.by.pol.and.type.conc$num.valid.stations), ]
    summary.by.filter.conc[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.conc, which="pollutant",FUN=length.unique, groups=summary.by.pol.and.type.conc[ ,filterby.col.label]))
    summary.by.filter.conc[ ,c("num.stations")] = as.data.frame(rep(length(unique(pollutants$pollutant)), filterby.length))
  }
  if (bAssessForecasts) {
    #need to remove NA lines before finding length of unique 'pollutant'
    summary.by.pol.and.type.index = summary.by.pol.and.type.index[ !is.na(summary.by.pol.and.type.index$num.valid.stations), ]
    summary.by.filter.index[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.index, which="pollutant",FUN=length.unique, groups=summary.by.pol.and.type.index[ ,filterby.col.label]))
    summary.by.filter.index[ ,c("num.stations")] = as.data.frame(rep(length(unique(pollutants$pollutant)), filterby.length))
  }
} else if (bGrpModel) {
  if (bAssessConcs) {
    #need to remove NA lines before finding length of unique 'model'
    summary.by.pol.and.type.conc = summary.by.pol.and.type.conc[ !is.na(summary.by.pol.and.type.conc$num.valid.stations), ]
    summary.by.filter.conc[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.conc, which="model",FUN=length.unique, groups=summary.by.pol.and.type.conc[ ,filterby.col.label]))
    summary.by.filter.conc[ ,c("num.stations")] = as.data.frame(rep(length(names(all.data)[names(all.data)!="date" & names(all.data)!="obs" & names(all.data)!="station" & names(all.data)!="type" & names(all.data)!="pollutant"]), filterby.length))
  }
  if (bAssessForecasts) {
    #need to remove NA lines before finding length of unique 'model'
    summary.by.pol.and.type.index = summary.by.pol.and.type.index[ !is.na(summary.by.pol.and.type.index$num.valid.stations), ]
    summary.by.filter.index[ ,c("num.valid.stations")] = as.data.frame(gapply(summary.by.pol.and.type.index, which="model",FUN=length.unique, groups=summary.by.pol.and.type.index[ ,filterby.col.label]))
    summary.by.filter.index[ ,c("num.stations")] = as.data.frame(rep(length(names(all.data)[names(all.data)!="date" & names(all.data)!="obs" & names(all.data)!="station" & names(all.data)!="type" & names(all.data)!="pollutant"]), filterby.length))
  }
}
if (bAssessConcs) {rm(temp.a.conc, temp.b.conc)}
if (bAssessForecasts) {rm(temp.a.index, temp.b.index)}
if (all(filterby.col.label == "station.output.name")) {
  filterby.col.label = groupby.col.label
}

#Set up graphics page size
if (bPDF & (bPaperMedium | bPaperSmall)) {
  bPaperLarge = TRUE
  bPaperMedium = FALSE
  bPaperSmall = FALSE
  cat("   Warning: PDF output will be to A4\n")   
}
# note "height" is longest length, for square output use "width" twice
if (bPaperLarge) {  
  paper.height = 297
  paper.width = 210
} else if (bPaperMedium) {
  paper.height = round(170*sqrt(2))
  paper.width = 170   
} else if (bPaperSmall) { # set square plots to width*width, mimics A6 but a bit smaller
  paper.height = round(125*sqrt(2))
  paper.width = 125 
}

if (bAssessForecasts) {
  #Summarise the data over the "groupby" variable, with appropriate filtering
  # Index stats table
  temp.accuracy.allstns = fcst.accuracy.allstns
  # Use ave() function to allow more than one column in the grouping
  temp.accuracy.allstns$sum.num.valid.indices = ave(temp.accuracy.allstns$num.valid.indices, temp.accuracy.allstns[ ,c(groupby.col.label, filterby.col.label)], FUN = sum)
  #do percentage calculations based on number of indices / total number valid indices
  temp.accuracy.allstns$perc.indices.same = 100*ave(temp.accuracy.allstns$indices.same, temp.accuracy.allstns[ ,c(groupby.col.label, filterby.col.label)], FUN=sum)/temp.accuracy.allstns$sum.num.valid.indices
  temp.accuracy.allstns$perc.indices.oneapart = 100*ave(temp.accuracy.allstns$indices.oneapart, temp.accuracy.allstns[ ,c(groupby.col.label, filterby.col.label)], FUN=sum)/temp.accuracy.allstns$sum.num.valid.indices
  # create data frame
  fcst.accuracy = temp.accuracy.allstns[!duplicated(temp.accuracy.allstns[ ,c(groupby.col.label, filterby.col.label)]), c(groupby.col.label, filterby.col.label,"sum.num.valid.indices","perc.indices.same","perc.indices.oneapart")] 
  #remove duplicate columns when no filtering occurs
  fcst.accuracy = fcst.accuracy[ ,!colnames(fcst.accuracy)==paste(groupby.col.label,".1",sep="")]
  rm(temp.accuracy) 
  #rename columns of data frame to match those used up to v2.2 of the toolkit
  names(fcst.accuracy)[names(fcst.accuracy)=="sum.num.valid.indices"] = "num.valid.indices"   
  
  #Alert stats table
  temp.fcst = fcst.allstns
  temp.fcst$sum.num.obs.alerts = ave(temp.fcst$num.obs.alerts, temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")], FUN=sum) 
  temp.fcst$sum.a = ave(temp.fcst$a, temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")], FUN=sum)
  temp.fcst$sum.b = ave(temp.fcst$b, temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")], FUN=sum)
  temp.fcst$sum.c = ave(temp.fcst$c, temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")], FUN=sum)
  temp.fcst$sum.d = ave(temp.fcst$d, temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")], FUN=sum)
  fcst = temp.fcst[!duplicated(temp.fcst[ ,c(groupby.col.label, filterby.col.label,"alert.name")]), c(groupby.col.label, filterby.col.label,"alert.name","sum.num.obs.alerts","sum.a","sum.b","sum.c","sum.d")] 
  #remove duplicate columns when no filtering occurs
  fcst = fcst[ ,!colnames(fcst)==paste(groupby.col.label,".1",sep="")]
  rm(temp.fcst)
  #rename columns of data frame to match those used up to v2.2 of the toolkit
  names(fcst)[names(fcst)=="sum.num.obs.alerts"] = "num.obs.alerts"
  names(fcst)[names(fcst)=="sum.a"] = "a" 
  names(fcst)[names(fcst)=="sum.b"] = "b" 
  names(fcst)[names(fcst)=="sum.c"] = "c"
  names(fcst)[names(fcst)=="sum.d"] = "d"
  
  #Calculate FAC2 for use in RANK2 calculation
  fcst.data.fac2 = fcst.data.for.output 
  fcst.data.fac2 = rename(fcst.data.fac2, obs = obs.index.units, mod = mod.index.units)
  # Group the data by station, pollutant, and model
  grouping.cols = names(fcst)[1:(which(names(fcst) == "alert.name") - 1)] # Column names to the left of alert.name are the grouping options
  grouped.data = group_by(fcst.data.fac2, across(all_of(grouping.cols)))
  # Apply the FAC2 function to each group and compute the summary
  fac2.values = summarise(grouped.data, FAC2 = FAC2(pick(everything())), .groups = "drop")

  #Calculate forecast assessment metrics
  cat("Calculating forecast assessment metrics...\n")
  #Forecast performance metrics:
  #Frequency bias score (FBS) [NA implies no events observed]
  fcst$FBS=NA
  fcst$FBS = (fcst$a + fcst$b)/(fcst$a + fcst$c)
  fcst$FBS[(fcst$a + fcst$c) == 0] = NA
  #Probability of event detection (POD) [NA implies no events observed]
  fcst$POD=NA
  fcst$POD = fcst$a /(fcst$a + fcst$c)
  fcst$POD[(fcst$a + fcst$c) == 0] = NA
  #Probability of a correct forecast (PCF) [NA implies no data - shouldn't happen]
  fcst$PCF=NA
  fcst$PCF = (fcst$a + fcst$d)/(fcst$a + fcst$b + fcst$c + fcst$d)
  fcst$PCF[(fcst$a + fcst$b + fcst$c + fcst$d) == 0] = NA
  #False alarm ratio (FAR) [NA implies no events predicted]
  fcst$FAR=NA
  fcst$FAR = fcst$b /(fcst$a + fcst$b)
  fcst$FAR[(fcst$a + fcst$b) == 0] = NA
  #Probability of false detection (POFD) [NA implies no non-events]
  fcst$POFD=NA
  fcst$POFD = fcst$b /(fcst$b + fcst$d)
  fcst$POFD[(fcst$b + fcst$d) == 0] = NA
  #Overall skill
  fcst$total.skill = 0.25*(fcst$PCF+fcst$POD+(1-fcst$FAR)+(1-fcst$POFD))
  #Odds ratio skill score (ORSS)
  fcst$OR = NA
  fcst$OR = (fcst$a*fcst$d)/(fcst$b*fcst$c)
  #Meaningless if a+c=0 (no obs alerts) or a+b=0 (no mod alerts)
  fcst$OR[fcst$a+fcst$c==0 | fcst$a+fcst$b==0] = NA
  fcst$ORSS = NA
  fcst$ORSS = (fcst$OR-1)/(fcst$OR+1)
  #ORSS score is perfect (1) if b*c=0
  fcst$ORSS[fcst$b*fcst$c==0]=1
  #Meaningless if a+c=0 (no obs alerts) or a+b=0 (no mod alerts)
  fcst$ORSS[fcst$a+fcst$c==0 | fcst$a+fcst$b==0] = NA
  # Threat score (TS)
  fcst$TS = NA
  fcst$TS = fcst$a / (fcst$a + fcst$c + fcst$b)
  # Gilbert Skill Score (GSS)
  fcst$H_random = NA
  fcst$H_random = (fcst$a + fcst$c) * (fcst$a + fcst$b) / (fcst$a + fcst$b + fcst$c + fcst$d)
  fcst$GSS = NA
  fcst$GSS = (fcst$a - fcst$H_random) / (fcst$a + fcst$c + fcst$b - fcst$H_random)
  fcst$H_random = NULL # Deletes the H_random column after it has been used for calculations
  # Heidke Skill Score (HSS)
  fcst$HSS = NA
  fcst$HSS = (2 * ((fcst$a * fcst$d) - (fcst$b * fcst$c))) / ((fcst$a + fcst$c) * (fcst$d + fcst$c) + (fcst$a + fcst$b) * (fcst$d + fcst$b))
  # Peirce Skill Score (PSS)
  fcst$PSS = NA
  fcst$PSS = ((fcst$a * fcst$d) - (fcst$b * fcst$c)) / ((fcst$a + fcst$c) * (fcst$b + fcst$d))
  # Success Ratio (SR)
  fcst$SR = NA
  fcst$SR = fcst$a / (fcst$a + fcst$b)
  
  # Merge with FAC2 data to allow RANK2 calculation
  fcst = merge(fcst, fac2.values[, c(grouping.cols, "FAC2")], by = grouping.cols, all.x = TRUE)
  # RANK2
  fcst$RANK2 = NA
  fcst$RANK2 =  fcst$FAC2 + fcst$POD + ifelse(fcst$FAR == 0, 0, 1) * (1 - fcst$FAR)
  
  #Episode analysis
  cat("Performing pollution episode analysis ...\n")
  fcst.data.for.output$alert.level.obs=findInterval(fcst.data.for.output$index.obs,thresholds$index)
  fcst.data.for.output$alert.level.mod=findInterval(fcst.data.for.output$index.mod,thresholds$index)
  episode.days = subset(fcst.data.for.output,alert.level.obs>=episode.alert.threshold)
  bDoneOneEpisode = FALSE
  if (length(episode.days$site) == 0) {
    cat("  No pollution episodes measured\n")
  } else {
    model.names = unique(episode.days$model)
    sNoAlert = "no alert"
    e=0
    episode.days$episode.id = e
    for (m in 1:length(model.names)) {
      for (j in 1:length(pollutants$pollutant)) {
        episode.days.pol.model = subset(episode.days,pollutant==pollutants$pollutant[j] & model==model.names[m])
        episode.sites = unique(episode.days.pol.model$site)
        for (i in 1:length(episode.sites)) {
          episode.days.site=subset(episode.days.pol.model,site==episode.sites[i])
          episode.days.site.numdays = length(unique(episode.days.site$date))
          if (episode.days.site.numdays >= episode.min.days) {
            #Initialisations
            last.date = episode.days.site$date[1]
            n = 1
            bEpisode.started = FALSE
            for (d in 2:episode.days.site.numdays) { #Loop over episode days to see if they are consecutive
              current.date = episode.days.site$date[d]
              if (current.date==last.date+60*60*24) {
                #Current date and last date are consecutive, so keep track of this potential episode
                n = n+1
                if (n >= episode.min.days) {
                  if (!bEpisode.started) {
                    #Start an episode
                    e = e+1
                    bEpisode.started = TRUE
                    episode.days.site$episode.id[(d-episode.min.days+1):d]=e
                  } else {
                    episode.days.site$episode.id[d]=e
                  }
                } 
              } else {
                #End this episode, ready to start another one
                bEpisode.started = FALSE
              }
              last.date = current.date
            }
            #Remove non-episode days from the data
            episode.days.site = subset(episode.days.site,episode.id!=0)
            if (length(episode.days.site$date)>0) {
              #This station has at least one episode, so put together some episode data
              episode.nums = unique(episode.days.site$episode.id)
              for (o in 1:length(episode.nums)) {
                episode=data.frame(episode=episode.nums[o])
                episode.data = subset(episode.days.site,episode.id==episode.nums[o])
                episode$start.date = min(episode.data$date)
                episode$end.date = max(episode.data$date)
                episode$num.days = length(episode.data$date)
                episode$num.alerts.same = length(subset(episode.data,alert.level.obs==alert.level.mod)[,1])
                temp = max(episode.data$alert.level.obs)
                if (temp==0) episode$max.alert.obs=sNoAlert else episode$max.alert.obs=thresholds$name[temp] 
                temp = max(episode.data$alert.level.mod)
                if (temp==0) episode$max.alert.mod=sNoAlert else episode$max.alert.mod=thresholds$name[temp] 
                temp = min(episode.data$alert.level.obs)
                if (temp==0) episode$min.alert.obs=sNoAlert else episode$min.alert.obs=thresholds$name[temp] 
                temp = min(episode.data$alert.level.mod)
                if (temp==0) episode$min.alert.mod=sNoAlert else episode$min.alert.mod=thresholds$name[temp] 
                episode$site = episode.data$station.output.name[1]
                episode$pollutant = episode.data$pollutant[1]
                episode$model = episode.data$model[1]
                if (!bDoneOneEpisode) {
                  episode.days.allsites = episode.days.site
                  episodes=episode
                  bDoneOneEpisode = TRUE
                } else {
                  episode.days.allsites = rbind(episode.days.allsites,episode.days.site)
                  episodes = rbind(episodes,episode)
                }
              }
            }
          }
        }
      }
    }
  }
  
  #Output data to csv
  if (boutput.stats.as.csv) {
    cat("Exporting forecast index data, stats and alert stats to CSV...\n")
    
    #Forecast alert stats
    #Change name of 'station.output.name' column to 'station'
    fcst.out = fcst
    names(fcst.out)[names(fcst.out)=="station.output.name"]="station"      
    write.csv(fcst.out,file=paste(eval.output.root,"_forecast_alert_stats.csv",sep=""),row.names = FALSE)
    rm(fcst.out)
    
    #Forecast index data
    #Copy station names formatted for output into 'site' column and delete 'station.output.name' column
    fcst.data.for.output.out = fcst.data.for.output
    fcst.data.for.output.out$site = fcst.data.for.output$station.output.name
    fcst.data.for.output.out = subset(fcst.data.for.output.out, select=-station.output.name)
    write.csv(fcst.data.for.output.out,file=paste(eval.output.root,"_forecast_index_data.csv",sep=""),row.names = FALSE)
    rm(fcst.data.for.output.out)
    
    #Forecast index stats
    #Change name of 'station.output.name' column to 'station'
    fcst.accuracy.out = fcst.accuracy
    names(fcst.accuracy.out)[names(fcst.accuracy.out)=="station.output.name"]="station"      
    write.csv(fcst.accuracy.out,file=paste(eval.output.root,"_forecast_index_stats.csv",sep=""),row.names = FALSE)
    rm(fcst.accuracy.out)
    
    if (bDoneOneEpisode) {
      #Episodes
      write.csv(episodes,file=paste(eval.output.root,"_episodes.csv",sep=""),row.names = FALSE)
      
      #Episode data
      #Forecast index data
      #Copy station names formatted for output into 'site' column and delete 'station.output.name' column
      episode.days.allsites.out = episode.days.allsites
      episode.days.allsites.out$site = episode.days.allsites$station.output.name
      episode.days.allsites.out = subset(episode.days.allsites.out, select=-station.output.name)
      write.csv(episode.days.allsites.out,file=paste(eval.output.root,"_episode_data.csv",sep=""),row.names = FALSE)
      rm(episode.days.allsites.out)
    } else {
      cat("No episodes were recorded, so no episode data will be output...\n")
    }
  }
  
  if (bPDF) {
    cat("Sending forecast assessment charts to pdf...\n")
  } else if (bJPG) {
    cat("Sending forecast assessment charts to jpg...\n")
  } else if (bPNG) {
    cat("Sending forecast assessment charts to png...\n")
  }
  max.x=max(fcst$num.obs.alerts)
  
  #Forecast index accuracy: stacked bar chart with number of valid forecasts marked on
  if (bForecastAccuracy) {      
    if (bPDF) {
      pdf(paste(eval.output.root,"_forecast_accuracy.pdf",sep=""),paper="a4r",width=0,height=0,title=pdf.title)
    }
    for (j in 1:filterby.length) {
      # data
      if (!bFilterPollutant & !bFilterStnType & !bFilterModel) {
        fcst.onepol.allstations = fcst.accuracy
      } else {
        # multiple filters
        subset.string = ""
        for (s in 1:(bFilterPollutant + bFilterStnType + bFilterModel)) {
          subset.string = paste(subset.string,filterby.col.label[s],"=='",filterby.list[j,s],"' & ",sep="")
        }
        subset.string = substr(subset.string, 1, nchar(subset.string)-3)  #remove final " & " from subset string
        fcst.onepol.allstations = subset(fcst.accuracy,eval(parse(text=subset.string)))             
      }
      # strings for plot titles
      if (bFilterStnType) {
        fcst.string.stn.j = paste(toupper(filterby.list[j, 'type'])) 
      } else if (bSelectedStationTypes) {
        fcst.string.stn.j = toupper(selected.station.types)
      } else if (bGrpStnType) {
        fcst.string.stn.j = "ALL STATION TYPES"
      } else if (bSingleStation) {
        fcst.string.stn.j=toupper(single.station)
      } else if (bSelectedStations) {
        fcst.string.stn.j="SELECTED STATIONS"
      } else {
        fcst.string.stn.j = "ALL STATIONS"            
      }
      if (bSingleNetwork){
        fcst.string.stn.j=paste(fcst.string.stn.j, ", ",toupper(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        fcst.string.stn.j = paste(fcst.string.stn.j,", SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")
      }
      
      if (bFilterPollutant) {
        fcst.string.pol.j = paste(toupper(filterby.list[j, 'pollutant'])) 
      } else if (bSinglePollutant) {
        fcst.string.pol.j = toupper(single.pollutant)
      } else if (bSelectedPollutants) {
        fcst.string.pol.j="SELECTED POLLUTANTS"  
      } else {
        fcst.string.pol.j = "ALL POLLUTANTS"            
      }
      if (bFilterModel) {
        filterby.string.j = paste(filterby.list[j, 'model'],fcst.string.stn.j,fcst.string.pol.j)
      } else if (bSingleModel) {
        filterby.string.j = paste(single.model,fcst.string.stn.j,fcst.string.pol.j)
      }else if (bSelectedModels) {
        filterby.string.j=paste("SELECTED MODELS",fcst.string.stn.j,fcst.string.pol.j)
      } else {
        filterby.string.j = paste(fcst.string.stn.j,fcst.string.pol.j)         
      }
      if (bFilterPollutant | bSinglePollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = paste(tolower(fcst.string.stn.j), "_",tolower(fcst.string.pol.j), sep="")
        } else if (!bFilterStnType) {
          filterby.filename.j = tolower(fcst.string.pol.j)
        }
      } else if (!bFilterPollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = tolower(fcst.string.stn.j)
        } else if (!bFilterStnType) {
          filterby.filename.j = "all"            
        }
      }
      if (bFilterModel) {
        filterby.filename.j = paste(filterby.list[j, 'model'],"_",filterby.filename.j,sep="")
      } else if (bSingleModel) {
        filterby.filename.j = paste(single.model,"_",filterby.filename.j,sep="")
      }
      
      if (length(fcst.onepol.allstations[ ,1])==0) {
        cat(paste("   Warning: no data to graph for",tolower(filterby.string.j),"- skipping this selection\n"))
        next
      }
      
      max.num.indices = max(fcst.onepol.allstations$num.valid.indices)
      sorted.data = fcst.onepol.allstations[order(fcst.onepol.allstations$num.valid.indices),]
      sorted.data$sort.order = c(1:length(sorted.data[ ,1]))
      sorted.data.matrix = matrix(c(sorted.data$perc.indices.same,sorted.data$perc.indices.oneapart),
                                  nrow=length(sorted.data$sort.order),ncol=2)
      filename.root=paste(eval.output.root,"_",filterby.filename.j,"_forecast_accuracy",sep="")
      filename.root=substr(filename.root,1,min(250,nchar(filename.root)))
      if (bJPG) {
        jpeg(paste(filename.root,".jpg",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
      } else if (bPNG) {
        png(paste(filename.root,".png",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
      }
      #Set up the three drawing panels
      layout(matrix(c(1,2,3),nrow=3,ncol=1,byrow=TRUE),heights=c(8,3,2))
      #Put the graph in the top panel
      par(mar=c(4,4,5,5),las=1,col.axis="violetred")
      barplot(t(sorted.data.matrix),border=NA,col=c("lightgreen","lightgrey"),
              xlab=paste(groupby.key.string,"number (ordered by the number of forecast indices valid for comparison)"),
              ylab="Percentage of forecast indices valid for comparison",
              ylim=c(0,120),
              names.arg=sorted.data$sort.order,
              main=list(paste("Forecast Index Accuracy:",filterby.string.j),cex=1.5),
              legend.text=c("modelled = observed",expression("modelled = observed" %+-% "1")),
              axes=FALSE,space=0.5,
              args.legend=c(x="top",bty="n",horiz=TRUE)
      )
      box(which="plot",col="grey")
      par(col.axis="black")
      axis(2, at=c(0,50,100))
      abline(a=100,b=0,lty=2,col="grey")
      if (bPaperMedium | bPaperSmall) { 
        mtext("Percentage of forecast indices valid for comparison where the modelled index is either equal to or 1 band apart from the observed index",
              3,line=0.5,font=3,cex=0.7)
      } else {
        mtext("Percentage of forecast indices valid for comparison where the modelled index is either equal to or 1 band apart from the observed index",
              3,line=0.5,font=3)
      }
      par(new=TRUE)
      #Add the number of valid indices for each station
      plot(sorted.data$num.valid.indices,axes=FALSE,
           xlab=NA,ylab=NA,
           ylim=c(0,1.2*max.num.indices),yaxs="i",
           xlim=c(0.75,length(sorted.data$num.valid.indices)+0.25),
           col="blue")
      par(col.axis="blue")
      axis(4,col="blue",col.text="blue",at=floor(seq(from=0,to=max.num.indices, by = max.num.indices/5)))
      mtext("Number of forecast indices valid for comparison",4,line=3,col="blue",las=0,cex=0.7)
      #Put the legend in the middle panel
      par(mar=c(1,0,1,1))
      nr=10
      nc=floor((length(sorted.data$sort.order)-1)/nr)+1
      make.table(nr,nc)
      draw.title("Key to numbers",nc)
      for (n in 1:length(sorted.data$sort.order)) {
        draw.sample.cell(sorted.data$sort.order[n], eval(parse(text=paste("sorted.data$",groupby.col.label,"[n]",sep=""))), n, nr)
      }
      #Put the summary table in the bottom panel
      par(mar=c(1,0,3,0))
      make.table(1,2)
      draw.title("Summary",2)
      draw.sample.cell("Date range",paste(data.start.date.for.graphs," to ",data.end.date.for.graphs),1,1)
      draw.sample.cell(paste("Valid",paste(tolower(groupby.key.string),"s",sep="")),paste(summary.by.filter.index$num.valid.stations[j],"out of",summary.by.filter.index$num.stations[j]),2,1)
      if (bJPG | bPNG) {
        graphics.off()
      }
    }
    if (bPDF) {
      graphics.off()
    }
  }      
  #Odds ratio skill score (ORSS)
  if (bOddsRatioSkillScore) {
    if (bPDF) {
      pdf(paste(eval.output.root,"_forecast_ORSS.pdf",sep=""),paper="a4r",width=0,height=0,title=pdf.title)
    }
    for (j in 1:filterby.length) {
      # data
      if (!bFilterPollutant & !bFilterStnType & !bFilterModel) {
        fcst.onepol = fcst
      } else {
        # multiple filters
        subset.string = ""
        for (s in 1:(bFilterPollutant + bFilterStnType + bFilterModel)) {
          subset.string = paste(subset.string,filterby.col.label[s],"=='",filterby.list[j,s],"' & ",sep="")
        }
        subset.string = substr(subset.string, 1, nchar(subset.string)-3)  #remove final " & " from subset string
        fcst.onepol = subset(fcst,eval(parse(text=subset.string)))             
      }
      # strings for plot titles
      if (bFilterStnType) {
        fcst.string.stn.j = paste(toupper(filterby.list[j, 'type'])) 
      } else if (bSelectedStationTypes) {
        fcst.string.stn.j = toupper(selected.station.types)
      }  else if (bGrpStnType) {
        fcst.string.stn.j = "ALL STATION TYPES"
      } else if (bSingleStation){
        fcst.string.stn.j=toupper(single.station)
      } else if (bSelectedStations) {
        fcst.string.stn.j="SELECTED STATIONS"
      } else {
        fcst.string.stn.j = "ALL STATIONS"            
      }
      if (bSingleNetwork){
        fcst.string.stn.j=paste(fcst.string.stn.j, ", ",toupper(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        fcst.string.stn.j = paste(fcst.string.stn.j,", SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")
      }
      if (bFilterPollutant) {
        fcst.string.pol.j = paste(toupper(filterby.list[j, 'pollutant'])) 
      } else if (bSinglePollutant) {
        fcst.string.pol.j = toupper(single.pollutant)
      } else if (bSelectedPollutants){
        fcst.string.pol.j="SELECTED POLLUTANTS"
      } else {
        fcst.string.pol.j = "ALL POLLUTANTS"            
      }
      if (bFilterModel) {
        filterby.string.j = paste(filterby.list[j, 'model'],fcst.string.stn.j)
      } else if (bSingleModel) {
        filterby.string.j = paste(single.model,fcst.string.stn.j)
      } else if (bSelectedModels){
        filterby.string.j=paste("SELECTED MODELS",fcst.string.stn.j)
      } else {
        filterby.string.j = fcst.string.stn.j        
      }
      if (bFilterPollutant | bSinglePollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = paste(tolower(fcst.string.stn.j), "_",tolower(fcst.string.pol.j), sep="")
        } else if (!bFilterStnType) {
          filterby.filename.j = tolower(fcst.string.pol.j)
        }
      } else if (!bFilterPollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = tolower(fcst.string.stn.j)
        } else if (!bFilterStnType) {
          filterby.filename.j = "all"            
        }
      }
      if (bFilterModel) {
        filterby.filename.j = paste(filterby.list[j, 'model'],"_",filterby.filename.j,sep="")
      } else if (bSingleModel) {
        filterby.filename.j = paste(single.model,"_",filterby.filename.j,sep="")
      }
      
      if (length(fcst.onepol[ ,1])==0) {
        cat(paste("   Warning: no data to graph for",tolower(fcst.string.stn.j),tolower(fcst.string.pol.j),"- skipping this selection\n"))
        next
      }
      for (k in 1:length(thresholds$name)) {
        fcst.onepol.onealert = subset(fcst.onepol,alert.name==thresholds$name[k])
        sorted.data = fcst.onepol.onealert[order(fcst.onepol.onealert$num.obs.alerts),]
        sorted.data$sort.order = c(1:length(sorted.data[ ,1]))
        max.alerts = max(max(sorted.data$num.obs.alerts),max(sorted.data$a+sorted.data$b))
        filename.root=paste(eval.output.root,"_",filterby.filename.j,"_",thresholds$name[k],"_forecast_ORSS",sep="")
        filename.root=substr(filename.root,1,min(250,nchar(filename.root)))
        if (bJPG) {
          jpeg(paste(filename.root,".jpg",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
        } else if (bPNG) {
          png(paste(filename.root,".png",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
        }
        #Set up the three drawing panels
        layout(matrix(c(1,2,3),nrow=3,ncol=1,byrow=TRUE),heights=c(8,3,2))
        #Put the graph in the top panel
        par(mar=c(4,4,5,5),las=1,col.axis="violetred",xpd=FALSE)
        barplot(sorted.data$ORSS,border=NA,
                xlab=paste(groupby.key.string,"number (ordered by the number of observed alert threshold exceedences)"),
                ylab="Odds Ratio Skill Score (ORSS)",
                ylim=c(-1,1.3),
                names.arg=sorted.data$sort.order,
                main=list(paste("Odds Ratio Skill Score (ORSS):",filterby.string.j,toupper(thresholds$name[k]),fcst.string.pol.j),cex=1.5),
                axes=FALSE,space=0.5,
        )
        par(col.axis="black")
        axis(2, at=c(-1,0,1), labels=c("-1 (Bad)","0 (Random)","1 (Good)"),las=0)
        abline(a=1,b=0,col="green")
        abline(a=0,b=0,col="grey")
        abline(a=-1,b=0,col="red")
        mtext("No bars are shown where ORSS is invalid (the number of observed or modelled alert threshold exceedences is zero)",
              3,line=0.5,font=3,cex=0.7)
        #Add the number of observed alert threshold exceedences for each station
        if (max.alerts==0) {
          axis.limits = c(0,1.15)
          axis.ticks = c(0,1)
        } else {
          axis.limits = c(0,1.15*max.alerts)
          axis.ticks=floor(seq(from=0,to=max.alerts, by = max.alerts/5))
        }  
        par(new=TRUE,xpd=TRUE)
        plot(sorted.data$num.obs.alerts,axes=FALSE,
             xlab=NA,ylab=NA,
             xlim=c(0.75,length(sorted.data$sort.order)+0.25),
             ylim=axis.limits,yaxs="i",
             col="blue")
        par(col.axis="blue")
        axis(4,col="blue",col.text="blue",at=axis.ticks)
        mtext("Number of alert threshold exceedences",4,line=3,col="blue",las=0, cex=0.7)
        #Add the number of modelled alert threshold exceedences for each station
        par(new=TRUE,xpd=TRUE)
        plot(sorted.data$a+sorted.data$b,axes=FALSE,
             xlab=NA,ylab=NA,
             xlim=c(0.75,length(sorted.data$sort.order)+0.25),
             ylim=axis.limits,yaxs="i",
             col="blue",pch=4)
        legend(x="topright",c("observed","modelled"),pch=c(1,4),bty="n",text.col="blue",col="blue",horiz=TRUE)
        #Put the legend in the middle panel
        par(mar=c(1,0,1,1))
        nr=10
        nc=floor((length(sorted.data$sort.order)-1)/nr)+1
        make.table(nr,nc)
        draw.title("Key to numbers",nc)
        for (n in 1:length(sorted.data$sort.order)) {
          draw.sample.cell(sorted.data$sort.order[n], eval(parse(text=paste("sorted.data$",groupby.col.label,"[n]",sep=""))), n, nr)
        }
        #Put the summary table in the bottom panel
        par(mar=c(1,0,3,0))
        make.table(1,2)
        draw.title("Summary",2)
        draw.sample.cell("Date range",paste(data.start.date.for.graphs," to ",data.end.date.for.graphs),1,1)
        draw.sample.cell(paste("Valid",paste(tolower(groupby.key.string),"s",sep="")),paste(summary.by.filter.index$num.valid.stations[j],"out of",summary.by.filter.index$num.stations[j]),2,1)
        if (bJPG | bPNG) {
          graphics.off()
        }            
      }
    }
    if (bPDF) {
      graphics.off()
    }
  }
  
  #Function to draw one of the performance metrics graphs
  # - plots scatter plot of y as a function of x (black circles)
  # - title.text is the title for the graph
  # - a green line will be plotted at y=good
  # - a red line will be plotted at y=bad
  performance.graph = function(x,y,title.text,good,bad) {
    par(mar=c(2,3,2,0),las=1,xpd=TRUE,col.axis="black")
    plot(x,y,
         xlab=NA,
         ylab=NA,
         ylim=c(0,1),yaxs="i",
         main=title.text,
         axes=FALSE,type="n"
    )
    box(which="plot",col="grey")
    axis(2, at=c(0,0.5,1))
    par(xpd=FALSE)
    abline(a=0.5,b=0,lty=3,col="grey")
    abline(v=c(1:length(x)),col="grey")
    par(col.axis="violetred")
    axis(1,at=floor(seq(from=1,to=length(sorted.data$sort.order),by=(length(sorted.data$sort.order)-1)/10)))
    abline(a=good,b=0,col="green")
    abline(a=bad,b=0,col="red")
    par(new=TRUE,xpd=TRUE)
    plot(x,y,
         xlab=NA,
         ylab=NA,
         ylim=c(0,1),yaxs="i",
         axes=FALSE
    )
  }
  
  #Forecast performance metrics
  if (bForecastPerformanceMetrics) {
    #This document has one page per pollutant per threshold
    if (bPDF) {
      pdf(paste(eval.output.root,"_forecast_performance.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
    }
    for (j in 1:filterby.length) {
      # data
      if (!bFilterPollutant & !bFilterStnType & !bFilterModel) {
        fcst.onepol = fcst
      } else {
        # multiple filters
        subset.string = ""
        for (s in 1:(bFilterPollutant + bFilterStnType + bFilterModel)) {
          subset.string = paste(subset.string,filterby.col.label[s],"=='",filterby.list[j,s],"' & ",sep="")
        }
        subset.string = substr(subset.string, 1, nchar(subset.string)-3)  #remove final " & " from subset string
        fcst.onepol = subset(fcst,eval(parse(text=subset.string)))             
      }
      # strings for plot titles
      if (bFilterStnType) {
        fcst.string.stn.j = paste(toupper(filterby.list[j, 'type'])) 
      } else if (bSelectedStationTypes) {
        fcst.string.stn.j = toupper(selected.station.types)
      } else if (bGrpStnType & bAllStationTypes) {
        fcst.string.stn.j = "ALL STATION TYPES"
      } else if (bSingleStation) {
        fcst.string.stn.j=toupper(single.station)
      } else if (bSelectedStations){
        fcst.string.stn.j="SELECTED STATIONS"
      } else {
        fcst.string.stn.j = "ALL STATIONS"            
      }
      if (bSingleNetwork){
        fcst.string.stn.j=paste(fcst.string.stn.j, ", ",toupper(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        fcst.string.stn.j = paste(fcst.string.stn.j,", SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")
      }
      if (bFilterPollutant) {
        fcst.string.pol.j = paste(toupper(filterby.list[j, 'pollutant'])) 
      } else if (bSinglePollutant) {
        fcst.string.pol.j = toupper(single.pollutant)         
      } else if (bSelectedPollutants){
        fcst.string.pol.j="SELECTED POLLUTANTS"
      } else {
        fcst.string.pol.j = "ALL POLLUTANTS"            
      }
      if (bFilterModel) {
        filterby.string.j = paste(filterby.list[j, 'model'],fcst.string.stn.j)
      } else if (bSingleModel) {
        filterby.string.j = paste(single.model,fcst.string.stn.j)
      } else if (bSelectedModels) {
        filterby.string.j = paste("SELECTED MODELS",fcst.string.stn.j)
      } else {
        filterby.string.j = fcst.string.stn.j        
      }
      if (bFilterPollutant | bSinglePollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = paste(tolower(fcst.string.stn.j), "_",tolower(fcst.string.pol.j), sep="")
        } else if (!bFilterStnType) {
          filterby.filename.j = tolower(fcst.string.pol.j)
        }
      } else if (!bFilterPollutant) {
        if (bFilterStnType | bSelectedStationTypes) {
          filterby.filename.j = tolower(fcst.string.stn.j)
        } else if (!bFilterStnType) {
          filterby.filename.j = "all"            
        }
      }
      if (bFilterModel) {
        filterby.filename.j = paste(filterby.list[j, 'model'],"_",filterby.filename.j,sep="")
      } else if (bSingleModel) {
        filterby.filename.j = paste(single.model,"_",filterby.filename.j,sep="")
      } else if (bSelectedModels){
        filterby.filename.j = paste("selected_models","_",filterby.filename.j,sep="")
      }
      
      if (length(fcst.onepol[ ,1])==0) {
        cat(paste("   Warning: no data to graph for",tolower(fcst.string.stn.j), tolower(fcst.string.pol.j),"- skipping this selection\n"))
        next
      }
      for (k in 1:length(thresholds$name)) {
        fcst.onepol.onealert = subset(fcst.onepol,alert.name==thresholds$name[k])
        sorted.data = fcst.onepol.onealert[order(fcst.onepol.onealert$num.obs.alerts),]
        sorted.data$sort.order = c(1:length(sorted.data[ ,1]))
        max.alerts = max(max(sorted.data$num.obs.alerts),max(sorted.data$a+sorted.data$b))
        filename.root=paste(eval.output.root,"_",filterby.filename.j,"_",thresholds$name[k],"_forecast_performance",sep="")
        filename.root=substr(filename.root,1,min(250,nchar(filename.root)))
        if (bJPG) {
          jpeg(paste(filename.root,".jpg",sep=""),width=paper.width,height=paper.height,units="mm",res=300)
        } else if (bPNG) {
          png(paste(filename.root,".png",sep=""),width=paper.width,height=paper.height,units="mm",res=300)
        }
        #Set the outer margins
        par(oma=c(0,0,3,0))
        #Set up the 7 equally-sized drawing panels 
        #(5 metrics graphs, 1 graph of number of alert threshold exceedences, 1 table (key))
        layout(matrix(c(1,2,3,4,5,6,7),nrow=7,ncol=1,byrow=TRUE),heights=c(1,1,1,1,1,1.5,1))
        #Draw first graph
        performance.graph(sorted.data$sort.order,sorted.data$PCF,"Probability of a correct forecast (PCF)",1,0)
        #Add title for the whole page
        titletext = paste("Forecast Performance Metrics:",filterby.string.j,toupper(thresholds$name[k]),fcst.string.pol.j)
        if ((nchar(titletext)>90 & bPaperLarge) | (nchar(titletext)>75 & bPaperMedium) | (nchar(titletext)>65 & bPaperSmall)) {
          titletext = paste("Forecast Performance Metrics:",paste(fcst.string.stn.j,toupper(thresholds$name[k]),fcst.string.pol.j),sep="\n")
        }
        mtext(side=3,outer=TRUE,font=2,titletext)
        #Draw the other 3 metrics graphs
        performance.graph(sorted.data$sort.order,sorted.data$POD,"Probability of detection (POD)",1,0)
        performance.graph(sorted.data$sort.order,sorted.data$FAR,"False alarm ratio (FAR)",0,1)
        performance.graph(sorted.data$sort.order,sorted.data$POFD,"Probability of false detection (POFD)",0,1)
        #Now draw the graph of the number of observed and modelled alert threshold exceedences
        if (max.alerts==0) {
          axis.limits = c(0,1.3)
          axis.ticks = c(0,1)
        } else {
          axis.limits = c(0,1.3*max.alerts)
          axis.ticks=floor(seq(from=0,to=max.alerts, by = max.alerts/5))
        }  
        par(mar=c(1,3,2,0))
        plot(sorted.data$num.obs.alerts,axes=FALSE,
             xlab=NA,ylab=NA,
             xlim=c(0.75,length(sorted.data$sort.order)+0.25),
             ylim=axis.limits,yaxs="i",
             col="blue",type="n")
        box(which="plot",col="grey")
        par(xpd=FALSE)
        abline(v=c(1:length(sorted.data$sort.order)),col="grey")
        par(xpd=TRUE)
        par(new=TRUE)
        plot(sorted.data$num.obs.alerts,axes=FALSE,
             xlab=NA,ylab=NA,
             xlim=c(0.75,length(sorted.data$sort.order)+0.25),
             ylim=axis.limits,yaxs="i",
             col="blue")
        par(col.axis="blue")
        axis(2,col="blue",col.text="blue",at=axis.ticks)
        par(col.axis="violetred")
        axis(1,at=floor(seq(from=1,to=length(sorted.data$sort.order),by=(length(sorted.data$sort.order)-1)/10)))
        mtext(paste(groupby.key.string, "number (ordered by the number of observed alert threshold exceedences)"),1,line=2,cex=0.7)
        par(new=TRUE)
        plot(sorted.data$a+sorted.data$b,axes=FALSE,
             xlab=NA,ylab=NA,
             xlim=c(0.75,length(sorted.data$sort.order)+0.25),
             ylim=axis.limits,yaxs="i",
             main="Number of alert threshold exceedences",
             col="blue",pch=4)
        par(xpd=FALSE)
        abline(a=max.alerts,b=0,col="blue",lty=3)
        legend(x="topright",c("observed","modelled"),pch=c(1,4),bty="n",text.col="blue",col="blue",horiz=TRUE)
        #Draw the key
        par(mar=c(0,0,3,0))
        nr=10
        nc=floor((length(sorted.data$sort.order)-1)/nr)+1
        make.table(nr,nc)
        draw.title("Key to numbers",nc)
        for (n in 1:length(sorted.data$sort.order)) {
          draw.sample.cell(sorted.data$sort.order[n], eval(parse(text=paste("sorted.data$",groupby.col.label,"[n]",sep=""))), n, nr)
        }
        #Draw the summary table
        par(mar=c(0,0,3,0))
        make.table(1,2)
        draw.title("Summary",2)
        draw.sample.cell("Date range",paste(data.start.date.for.graphs," to ",data.end.date.for.graphs),1,1)    
        draw.sample.cell(paste("Valid",paste(tolower(groupby.key.string),"s",sep="")),paste(summary.by.filter.index$num.valid.stations[j],"out of",summary.by.filter.index$num.stations[j]),2,1)
        if (bJPG | bPNG) {
          graphics.off()
        }
      }
    }
    if (bPDF) {
      graphics.off()
    }
  }
  
}  #End of forecast assessment graphics

if (bAssessConcs) {
  
  #Output data to csv
  if (boutput.stats.as.csv) {
    
    if (bOpenairModStats) {
      #Calculate COE and IOA using openair modStats function
      cat("Calculating additional statistics using openair modStats function...\n")
      if (bGrpStnName) {
        filter.text = c("station","pollutant")
      } else if (bGrpStnType) {
        filter.text = c("Type","pollutant")
      } else if (bGrpPollutant) {
        if (bFilterStnType) {
          filter.text = c("Type","pollutant")
        } else {
          filter.text = "pollutant"
        }
      } else if (bGrpModel) {
        if (bFilterStnType) {
          filter.text = c("Type","pollutant")
        } else {
          filter.text = "pollutant"
        }
      }
      if ("Type" %in% filter.text){ #Changing the column header from "type" to "Type" to avoid errors
        colnames(all.data.averaged)[colnames(all.data.averaged)=="type"]="Type"
      }
      all.data.averaged.tripled=rbind(all.data.averaged,all.data.averaged,all.data.averaged) #So there are always at least 3 measurement for each station, pollutant etc
      for (i in 1:length(modelled.data.names)) {
        extra.stats.onemodel=modStats(all.data.averaged.tripled,mod=modelled.data.names[i],obs="obs",type=filter.text) 
        extra.stats.onemodel$n=extra.stats.onemodel$n/3 #Divide the number of data points by 3 since it was tripled earlier
        if (bGrpStnName) {
          #Add extra "all" station
          extra.stats.all.stations = modStats(all.data.averaged.tripled,mod=modelled.data.names[i],obs="obs",type="pollutant")
          extra.stats.all.stations$station="all"
          extra.stats.all.stations$n=extra.stats.all.stations$n/3
          extra.stats.onemodel=rbind(extra.stats.onemodel,extra.stats.all.stations)
        }
        #Rename columns to avoid conflicts with existing stats
        names(extra.stats.onemodel)[names(extra.stats.onemodel) != filter.text]=paste("modstats.",names(extra.stats.onemodel)[names(extra.stats.onemodel) != filter.text],sep="")
        #Add the model name
        extra.stats.onemodel$model = modelled.data.names[i]
        if (i==1) {
          extra.stats=extra.stats.onemodel
        } else {
          extra.stats=rbind(extra.stats,extra.stats.onemodel)
        }
      }
      rm(all.data.averaged.tripled) #No longer needed, removed to free up memory
      if ("Type" %in% filter.text){ #Changing the header back from "Type" to "type" to allow merging
        colnames(extra.stats)[colnames(extra.stats)=="Type"]="type"
        colnames(all.data.averaged)[colnames(all.data.averaged)=="Type"]="type"
        filter.text[which(filter.text=="Type")]="type"
      }
      merge.by.columns = c(filter.text,"model")
      #Merge the extra stats with the existing stats
      conc.grouped=merge(conc.grouped,extra.stats,by=merge.by.columns)
    }
    
    cat("Exporting concentration stats to CSV...\n")
    
    #Copy station names formatted for output into 'station' column and remove 'station.output.name' column
    conc.grouped.out = conc.grouped
    if (!is.null(conc.grouped.out$station.output.name)) {
      conc.grouped.out$station = conc.grouped.out$station.output.name
      conc.grouped.out = subset(conc.grouped.out, select=-station.output.name)
    }
    write.csv(conc.grouped.out,file=paste(eval.output.root,"_conc_stats.csv",sep=""),row.names = FALSE)
    rm(conc.grouped.out)
  }
  
  
  #Target plot
  if (bTargetPlot) {
    #If outputting any target plot define parameters 
    if (any(pollutants$bTargetPlot)){
      #Set the plot title text
      if (bAllPols & all(pollutants$bTargetPlot)){
        text.pol = "ALL POLLUTANTS" 
      } else if (bSinglePollutant | bSelectedPollutants | (bAllPols & any(pollutants$bTargetPlot))){
        if (bSinglePollutant | (bAllPols & length(pollutants$bTargetPlot)==1)){
          text.pol = toupper(paste(unique(conc.grouped$output.avg.time.and.statistic), single.pollutant))
        } else {
          text.pol = toupper(paste(pollutants$pollutant[which(pollutants$bTargetPlot)], collapse=", "))
        }
      }
      
      if (bSingleStation){
        text.stntype=toupper(single.station)
      } else if (bSelectedStations){
        text.stntype="SELECTED STATIONS"
      } else if (bSelectedStationTypes){
        text.stntype=paste("SELECTED STATION TYPES (",toupper(paste(selected.station.types.vector, collapse=", ")), ")", sep="")
      } else {
        text.stntype="ALL STATIONS"
      }
      
      if (bSingleNetwork){
        text.stntype=paste(text.stntype,", ", toupper(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        text.stntype=paste(text.stntype,", ", paste("SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")),")", sep=""), sep="")
      } 
      
      if (bAllModels) {
        text.filters = paste(toupper(text.stntype),", ",text.pol,sep="")
      } else if (bSingleModel) {
        text.filters=paste(toupper(single.model),", ",toupper(text.stntype),", ",text.pol,sep="")
      } else if (bSelectedModels) {
        text.filters=paste("SELECTED MODELS",", ",toupper(text.stntype),", ",text.pol,sep="")
      }
      title.text=paste("Target plot: ",text.filters,", from ", data.start.date.for.graphs," to ",data.end.date.for.graphs, sep="")
      if ((nchar(title.text) >90 & bPaperLarge) | (nchar(title.text) >75 & bPaperMedium) | (nchar(title.text) >65 & bPaperSmall)) {
        title.text = paste(paste("Target plot: ",text.filters, sep=""),paste("from ", data.start.date.for.graphs," to ",data.end.date.for.graphs),sep="\n")
      }
      
      #Set the graph size and resolution
      graph.size=4000
      if (bPaperLarge) {
        resolution=300
      } else if (bPaperMedium) {
        resolution=400
      } else if (bPaperSmall) {
        resolution=500
      }
      if (bPDF) {    
        cat("Sending concentration assessment target plots to pdf...\n")
        pdf(paste(eval.output.root,"_conc_targetplot.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
      } else if (bJPG) {
        cat("Sending concentration assessment target plots to jpg...\n")
        jpeg(paste(eval.output.root,"_conc_targetplot.jpg",sep=""),width=graph.size,height=graph.size,res=resolution)
      } else if (bPNG) {
        cat("Sending concentration assessment target plots to png...\n")
        png(paste(eval.output.root,"_conc_targetplot.png",sep=""),width=graph.size,height=graph.size,res=resolution)
      }
    } else {
      cat("Sending concentration assessment target statistics to CSV...\n")
    }
    if (!bFilterStnType) {
      bTargetPlotFiltering.stntype.col=FALSE
      bTargetPlotFiltering.stntype.sym=FALSE
      bTargetPlotFiltering.stntype.colsym=FALSE
      bTargetPlotFiltering.stntype.subplots=FALSE
      cat("   Station types can only be distinguished on the target plot if 'filter by station type' is selected for the Grouping and Filtering option.\n")
    }
    if (!bFilterPollutant) {
      bTargetPlotFiltering.pol.col=FALSE
      bTargetPlotFiltering.pol.sym=FALSE
      bTargetPlotFiltering.pol.colsym=FALSE
      bTargetPlotFiltering.pol.subplots=FALSE
      if (!bSinglePollutant){
        cat("   Pollutants can only be distinguished on the target plot if 'filter by pollutant' is selected for the Grouping and Filtering option.\n")
      }
    }
    if (!bFilterModel) {
      bTargetPlotFiltering.model.col=FALSE
      bTargetPlotFiltering.model.sym=FALSE
      bTargetPlotFiltering.model.colsym=FALSE
      bTargetPlotFiltering.model.subplots=FALSE
      if (!bSingleModel){
        cat("   Models can only be distinguished on the target plot if 'filter by model' is selected for the Grouping and Filtering option.\n")
      }
    }
    bAddResultsToTargetPlots=FALSE
    if ((bTargetPlotFiltering.model.subplots | bSingleModel) & (bTargetPlotFiltering.pol.subplots | bSinglePollutant)){
      bAddResultsToTargetPlots=TRUE
    }
    cat("   To be consistent with FAIRMODE Delta Target Plot version 6, data should cover exactly 1 year. \n")
    #setup data frame for target plot with a vector of appropriate strings for the titles  
    conc.grouped$polstring = toTitleCase(paste(conc.grouped$output.avg.time.and.statistic, toupper(conc.grouped$pollutant)))
    if (bSinglePollutant){ #If only single pollutant, ensure pollutant column is also extracted 
      target.plot.data = conc.grouped[ ,c("pollutant","polstring",groupby.col.label,filterby.col.label,"RMSU","MB","CRMSE.sign","MQI","MPI.bias","MPI.r","MPI.sd","MQI.annual","UOmean","RMSUM","RMSUM.perc","RMSUM.annual","RMSUM.annual.perc")]
    } else {
      target.plot.data = conc.grouped[ ,c("polstring",groupby.col.label,filterby.col.label,"RMSU","MB","CRMSE.sign","MQI","MPI.bias","MPI.r","MPI.sd","MQI.annual","UOmean","RMSUM","RMSUM.perc","RMSUM.annual","RMSUM.annual.perc")]
    }
    if (bGrpStnName){
      if (!any(names(target.plot.data)%in%"station")){
        target.plot.data$station=tolower(target.plot.data$station.output.name)
      }
      if (! any(names(target.plot.data)%in%"type")){
        station.types=subset(stations, select=c("station","type"))
        target.plot.data=merge(target.plot.data,station.types,by=c("station"), all.x=TRUE )
      }
    }
    #Add to target plot dataframe whether plots and annual statistics should be calculated
    for (j in 1:length(pollutants$pollutant)) {
      target.plot.data$output.plot[target.plot.data$pollutant==pollutants$pollutant[j]]=pollutants$bTargetPlot[j]
      target.plot.data$output.annual.statistics[target.plot.data$pollutant==pollutants$pollutant[j]]=pollutants$bTargetAnnualStats[j]
      if (pollutants$bTargetPlot[j]) {
        target.plot.data$beta[target.plot.data$pollutant==pollutants$pollutant[j]]=pollutants$beta[j]
        
      }
    }
    
    
    # subset dataframe to exclude pollutants where bTargetPlot is false e.g. for annual pollutants or where output averaging times and statistics don't have an uncertainty value
    pollutants.target=pollutants$pollutant[which(pollutants$bTargetPlot==TRUE | pollutants$bTargetAnnualStats==TRUE)]
    pollutants.target.plot=pollutants$pollutant[which(pollutants$bTargetPlot==TRUE)]
    target.plot.data=subset(target.plot.data, pollutant %in% pollutants.target)
    # remove 'all' stations row used for statistics
    target.plot.data = target.plot.data[!apply(target.plot.data[,c(filterby.col.label,groupby.col.label)], MARGIN=1, FUN=function(x) any(x=="all")), ]
    polstrings=unique(target.plot.data$polstring)
    #Calculate the non annual statistics if any plots being output
    if (any(pollutants$bTargetPlot)) {
      #Calculate the MQI.90
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) MQI.90=quantile(target.plot.data$MQI,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.90=aggregate(MQI~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        MQI.90=data.frame(MQI.90=MQI.90)
        names(MQI.90)="MQI.90"
      } else {
        names(MQI.90)[names(MQI.90)=="MQI"]="MQI.90"
      }
      if (bSinglePollutant) {MQI.90$polstring=polstrings}
      
      #Calculate the MPI.bias.90
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.bias.90=quantile(target.plot.data$MPI.bias,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MPI.bias.90=aggregate(MPI.bias~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        MPI.bias.90=data.frame(MPI.bias.90=MPI.bias.90)
      } else {
        names(MPI.bias.90)[names(MPI.bias.90)=="MPI.bias"]="MPI.bias.90"
      }
      if (bSinglePollutant) {MPI.bias.90$polstring=polstrings}
      
      #Calculate the MPI.r.90
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.r.90=quantile(target.plot.data$MPI.r,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.r.90=aggregate(MPI.r~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        MPI.r.90=data.frame(MPI.r.90=MPI.r.90)
      } else {
        names(MPI.r.90)[names(MPI.r.90)=="MPI.r"]="MPI.r.90"
      }
      if (bSinglePollutant) {MPI.r.90$polstring=polstrings}
      
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) MPI.sd.90=quantile(target.plot.data$MPI.sd,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MPI.sd.90=aggregate(MPI.sd~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        MPI.sd.90=data.frame(MPI.sd.90=MPI.sd.90)
      } else {
        names(MPI.sd.90)[names(MPI.sd.90)=="MPI.sd"]="MPI.sd.90"
      }
      if (bSinglePollutant) {MPI.sd.90$polstring=polstrings}
      
      # Calculate the model uncertainty parameter RMSUM
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.90=quantile(target.plot.data$RMSUM,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.90=aggregate(RMSUM~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        RMSUM.90=data.frame(RMSUM.90=RMSUM.90)
      } else {
        names(RMSUM.90)[names(RMSUM.90)=="RMSUM"]="RMSUM.90"
      }
      if (bSinglePollutant) {RMSUM.90$polstring=polstrings}
      
      #replace NaN with 0 before calculating the percentile as NaN corresponds to 0% uncertainty 
      target.plot.data$RMSUM.perc[which(target.plot.data$RMSUM.perc %in% "NaN")]=0
      
      
      if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=quantile(target.plot.data$RMSUM.perc,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.perc.90=aggregate(RMSUM.perc~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
      
      if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
        RMSUM.perc.90=data.frame(RMSUM.perc.90=RMSUM.perc.90)
      } else {
        names(RMSUM.perc.90)[names(RMSUM.perc.90)=="RMSUM.perc"]="RMSUM.perc.90"
      }
      if (bSinglePollutant) {RMSUM.perc.90$polstring=polstrings}
    }
    #Calculate the MQI.annual.90
    if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) MQI.annual.90=quantile(target.plot.data$MQI.annual,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) MQI.annual.90=aggregate(MQI.annual~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    
    if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
      MQI.annual.90=data.frame(MQI.annual.90=MQI.annual.90)
    } else {
      names(MQI.annual.90)[names(MQI.annual.90)=="MQI.annual"]="MQI.annual.90"
    }
    if (bSinglePollutant) {MQI.annual.90$polstring=polstrings}
    
    #Calculate RMSUM.annual
    target.plot.data$RMSUM.annual[which(target.plot.data$RMSUM.annual %in% "NaN")]=0
    if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=quantile(target.plot.data$RMSUM.annual,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.90=aggregate(RMSUM.annual~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    
    if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
      RMSUM.annual.90=data.frame(RMSUM.annual.90=RMSUM.annual.90)
    } else {
      names(RMSUM.annual.90)[names(RMSUM.annual.90)=="RMSUM.annual"]="RMSUM.annual.90"
    }
    if (bSinglePollutant) {RMSUM.annual.90$polstring=polstrings}
    
    
    #Calculate the RMSUM.annual.perc.90
    if (bSingleModel & !bSinglePollutant & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSingleModel & !bSinglePollutant & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~model,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~model+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~model+polstring,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (!bSinglePollutant & !bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~model+polstring+type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & !bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=quantile(target.plot.data$RMSUM.annual.perc,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    if (bSinglePollutant & bSingleModel & bTargetPlotFiltering.stntype.subplots) RMSUM.annual.perc.90=aggregate(RMSUM.annual.perc~type,target.plot.data,quantile,probs=c(0.9),type=4,names=FALSE, na.rm=TRUE)
    
    if (bSinglePollutant & bSingleModel &!bTargetPlotFiltering.stntype.subplots) {
      RMSUM.annual.perc.90=data.frame(RMSUM.annual.perc.90=RMSUM.annual.perc.90)
    } else {
      names(RMSUM.annual.perc.90)[names(RMSUM.annual.perc.90)=="RMSUM.annual.perc"]="RMSUM.annual.perc.90"
    }
    if (bSinglePollutant) {RMSUM.annual.perc.90$polstring=polstrings}
    # subset target plot data to get just plot output data 
    target.plot.data=subset(target.plot.data, output.plot %in% "TRUE")
    plot.polstrings=unique(target.plot.data$polstring)
    
    if (!any(pollutants$bTargetPlot)) {
      model.quality=merge(RMSUM.annual.90,RMSUM.annual.perc.90, all=TRUE)
      model.quality=merge(model.quality, MQI.annual.90, all=TRUE)
    } else {
      # Merge annual statistics first 
      model.quality=merge(RMSUM.annual.90,RMSUM.annual.perc.90, all=TRUE)
      model.quality=merge(model.quality, RMSUM.90, all=TRUE)
      model.quality=merge(model.quality, RMSUM.perc.90, all=TRUE)
      model.quality=merge(model.quality, MQI.annual.90, all=TRUE)
      model.quality=merge(model.quality,MQI.90, all=TRUE)
      model.quality=merge(model.quality,MPI.bias.90, all=TRUE)
      model.quality=merge(model.quality,MPI.r.90, all=TRUE)
      model.quality=merge(model.quality,MPI.sd.90, all=TRUE)
      for (i in 1:length(polstrings)){
        target.plot.data.polstring=subset(target.plot.data, polstring %in% polstrings[i])
        model.quality$output.plot[model.quality$polstring %in% polstrings[i]]=unique(target.plot.data.polstring$output.plot)
        rm(target.plot.data.polstring)
      }
      # subset data to get just plot output data 
      MQI.90.plot=subset(MQI.90, polstring %in% plot.polstrings)
      MQI.annual.90.plot=subset(MQI.annual.90, polstring %in% plot.polstrings)
      RMSUM.perc.90.plot=subset(RMSUM.perc.90, polstring %in% plot.polstrings)
      
      
      
      if (bGrpPollutant) {
        groupby.for.target = "polstring" # use string including averaging time in the key
      } else {
        groupby.for.target = groupby.col.label
      }
      
      #Make the text labels for every graph for the different quadrants 
      text.labels=data.frame(x=c(-0.05,-0.05,-1.8,1.8),
                             y=c(1.5,-1.8,-0.1,-0.1),
                             label=c("Bias > 0","Bias < 0","R","SD"))
      
      # Target plots, create the basic plot 
      targetplot_colours=c("red","darkturquoise","chartreuse2","darkorchid", "deeppink2","darkslategray2","aquamarine3","azure3","blue","brown1","cadetblue2","chartreuse4","coral1","cornsilk4","cyan4","darkmagenta")
      targetplot=ggplot(target.plot.data,aes(y=(MB/(beta * RMSU)),x=(CRMSE.sign/(beta * RMSU))))+
        scale_x_continuous(labels = c(2,1,0,1,2),breaks=seq(-2,2,1),limits = c(-2.1,2.1),minor_breaks = NULL)+
        scale_y_continuous(breaks=seq(-2,2,1),limits = c(-2.1,2.1),minor_breaks = NULL)+
        geom_abline(slope=1,intercept=0,colour="darkgrey", linetype="dashed", size=0.8)+
        geom_abline(slope=-1,intercept=0,colour="darkgrey", linetype="dashed", size=0.8)+
        coord_fixed()+
        geom_hline(yintercept = 0)+
        geom_vline(xintercept = 0)+
        geom_circle(data=data.frame(x0=c(0),y0=c(0),r=c(1)),aes(x0=x0,y0=y0,r=r),inherit.aes = FALSE, fill="white")+
        geom_circle(data=data.frame(x0=c(0),y0=c(0),r=c(0.5)),aes(x0=x0,y0=y0,r=r),inherit.aes = FALSE,linetype="dashed",size=0.8)+
        labs(title=title.text,x="CRMSE / (Beta * RMSU)",y="BIAS / (Beta * RMSU)")+
        theme_bw()+
        theme(legend.position="bottom",legend.title = element_blank(),panel.grid.major = element_blank(),panel.grid.minor = element_blank())
      
      # Based on different grouping and filtering options, create sub plots if required  
      # Only need code to deal with the case when bGrpStnName is true because any other grouping will not give a target plot
      if (bSingleModel & bSinglePollutant & bTargetPlotFiltering.stntype.subplots) targetplot = targetplot + facet_wrap(~type) #If there is only one model and one pollutant there can only be different plots for each type
      if (bSingleModel & !bSinglePollutant) { #If there is only one model we do not need any facets of models
        if (bTargetPlotFiltering.pol.subplots & bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_grid(polstring~type)
        } else if (bTargetPlotFiltering.pol.subplots) {
          targetplot = targetplot + facet_wrap(~polstring)
        } else if (bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_wrap(~type)
        }
      }
      if (!bSingleModel & bSinglePollutant) { #If there is only one pollutant we do not need facets of pollutants
        if (bTargetPlotFiltering.model.subplots & bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_grid(type~model)
        } else if (bTargetPlotFiltering.model.subplots) {
          targetplot = targetplot + facet_wrap(~model)
        } else if (bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_wrap(~type)
        }
      }
      
      if (!bSingleModel & !bSinglePollutant) { #If there are multiple pollutants and models then the plots are made based on the filtering options. e.g. only plots for different models are possible if the data is filtered by model
        if (bTargetPlotFiltering.model.subplots & bTargetPlotFiltering.pol.subplots & bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_grid(polstring+type~model)
        } else if (bTargetPlotFiltering.model.subplots & bTargetPlotFiltering.pol.subplots) {
          targetplot = targetplot + facet_grid(polstring~model)
        } else if (bTargetPlotFiltering.pol.subplots & bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_grid(polstring~type)
        } else if (bTargetPlotFiltering.model.subplots & bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_grid(type~model)
        } else if (bTargetPlotFiltering.model.subplots) {
          targetplot = targetplot + facet_wrap(~model)
        } else if (bTargetPlotFiltering.pol.subplots) {
          targetplot = targetplot + facet_wrap(~polstring)
        } else if (bTargetPlotFiltering.stntype.subplots) {
          targetplot = targetplot + facet_wrap(~type)
        }
      }
      
      #At this point we can assume bGrpStnName and bFilterPollutant are true
      if (bSingleModel & !bFilterStnType){ #Cannot represent model filtering because there is one model and cannot represent station type filtering because station type filtering is not selected.
        if (bTargetPlotFiltering.pol.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring, colour=target.plot.data$polstring),size=2.2, show.legend=TRUE) +scale_colour_manual(values=targetplot_colours) 
        } else if (bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)
        } else{ #If all filtering is represented by sub-plots then add points to all the plots
          targetplot=targetplot+geom_point(size=2.2, show.legend=TRUE)
        }
      } else if (!bSingleModel & !bFilterStnType){ #Can represent model filtering because there are multiple models. Still cannot represent station type filtering
        if (bTargetPlotFiltering.model.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$model, colour=target.plot.data$model),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring, colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.col & bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$model, shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.sym & bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring, shape=target.plot.data$model),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$model),size=2.2, show.legend=TRUE)
        } else if (bTargetPlotFiltering.model.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$model),size=2.2, show.legend=TRUE)
        } else if (bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)
        } else if (bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)
        } else{ #If all filtering is represented by sub-plots then add points to all the plots
          targetplot=targetplot+geom_point(size=2.2, show.legend=TRUE)
        }
      } else if (bSingleModel & bFilterStnType){ #Cannot represent model filtering since there is one model but can represent station type filtering
        if (bTargetPlotFiltering.pol.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring, colour=target.plot.data$polstring),size=2.2, show.legend=TRUE) +scale_colour_manual(values=targetplot_colours) 
        } else if (bTargetPlotFiltering.stntype.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$type, colour=target.plot.data$type),size=2.2, show.legend=TRUE) +scale_colour_manual(values=targetplot_colours) 
        } else if (bTargetPlotFiltering.pol.col & bTargetPlotFiltering.stntype.sym){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring, shape=target.plot.data$type),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.sym & bTargetPlotFiltering.stntype.col){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring, colour=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.stntype.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$type),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.stntype.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else{ #If all filtering is represented by sub-plots then add points to all the plots
          targetplot=targetplot+geom_point(size=2.2, show.legend=TRUE)
        }
      } else if (!bSingleModel & bFilterStnType){ #Able to represent model and station type filtering since there are multiple models and station type filtering is selected.
        if (bTargetPlotFiltering.model.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$model, colour=target.plot.data$model),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring, colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.col & bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$model, shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.sym & bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring, shape=target.plot.data$model),size=2.2, show.legend=TRUE)+scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.sym & bTargetPlotFiltering.stntype.col){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$model, colour=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$model),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.col & bTargetPlotFiltering.stntype.sym){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$model, shape=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.model.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$model),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.sym & bTargetPlotFiltering.stntype.col){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring,colour=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$polstring),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.col & bTargetPlotFiltering.stntype.sym){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring, shape=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.pol.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$polstring),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.stntype.colsym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$type, colour=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.stntype.col){
          targetplot=targetplot+geom_point(aes(colour=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else if (bTargetPlotFiltering.stntype.sym){
          targetplot=targetplot+geom_point(aes(shape=target.plot.data$type),size=2.2, show.legend=TRUE)  +scale_colour_manual(values=targetplot_colours)
        } else{ #If all filtering is represented by sub-plots then add points to all the plots
          targetplot=targetplot+geom_point(size=2.2, show.legend=TRUE)
        }
      }
      
      target.plot.dimensions=ggplot_build(targetplot)
      target.plot.columns=length(levels(target.plot.dimensions$data[[1]]$PANEL))
      if (target.plot.columns <2){
        label.size=4
        MQI.90.size=5.5
        pol.data.size=4.5
        MQI.90.y2.pos=1.75
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.95
        delta.lab.y.lv=1.8
        delta.lab.y.beta=1.65
        delta.lab.x=1.5
        MQI.90.x=-1.8
      }else if (target.plot.columns <3){
        label.size=3
        MQI.90.size=4
        pol.data.size=3.2
        MQI.90.y2.pos=1.75
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.95
        delta.lab.y.lv=1.8
        delta.lab.y.beta=1.65
        delta.lab.x=0.8
        MQI.90.x=-2
      } else if (target.plot.columns< 6){
        label.size=2.7
        MQI.90.size=3.9
        pol.data.size=3
        MQI.90.y2.pos=1.7
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.9
        delta.lab.y.lv=1.7
        delta.lab.y.beta=1.5
        delta.lab.x=0.75
        MQI.90.x=-2
      } else if (target.plot.columns <8){
        label.size=2.5
        MQI.90.size=3.3
        pol.data.size=2.9
        MQI.90.y2.pos=1.6
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.85
        delta.lab.y.lv=1.6
        delta.lab.y.beta=1.35
        delta.lab.x=0.7
        MQI.90.x=-2
      } else if (target.plot.columns<12){
        label.size=2.2
        MQI.90.size=2.9
        MQI.90.y2.pos=1.65
        pol.data.size=2.7
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.85
        delta.lab.y.lv=1.6
        delta.lab.y.beta=1.35
        delta.lab.x=0.4
        MQI.90.x=-2.1
      } else{
        label.size=2
        MQI.90.size=2.8
        pol.data.size=2.7
        MQI.90.y2.pos=1.65
        delta.lab.y.urlv=2.1
        delta.lab.y.alpha=1.85
        delta.lab.y.lv=1.6
        delta.lab.y.beta=1.35
        delta.lab.x=0.27
        MQI.90.x=-2.15
      }
      #Add R, SD, Bias labels
      targetplot=targetplot + geom_text(data=text.labels,aes(x=x,y=y,label=label),colour="darkgrey",inherit.aes = FALSE, size=MQI.90.size)
      
      if (bAddResultsToTargetPlots) {
        #Add MQI.90
        if(class(MQI.90.plot)=="data.frame") {
          MQI.90.plot$col_key=case_when(MQI.90.plot$MQI.90<1~"green",MQI.90.plot$MQI.90>1~"red")
        } else {
          MQI.90.plot=as.data.frame(MQI.90.plot, col.names="MQI.90")
          MQI.90.plot$col_key=case_when(MQI.90.plot$MQI.90<1~"green",MQI.90.plot$MQI.90>1~"red")
        }
        if (bTargetPlotFiltering.stntype.col | bTargetPlotFiltering.stntype.colsym){ # If colour coding points, do not colour code MQI.90 value
          targetplot=targetplot+geom_text(data=MQI.90.plot, x=MQI.90.x,y=2, aes(label=paste("MQI_HD =",round(MQI.90,3))),hjust="left",vjust="bottom", size=MQI.90.size, show.legend = FALSE)
        } else { # If not colour coding points, colour code MQI.90 value 
          targetplot=targetplot+geom_text(data=MQI.90.plot, x=MQI.90.x,y=2, aes(label=paste("MQI_HD =",round(MQI.90,3)), colour=col_key),hjust="left",vjust="bottom", size=MQI.90.size, show.legend = FALSE)+scale_color_manual(values=c("green"="chartreuse4", "red"="red"), guide="none")
        }
        
        #Add MQI.annual.90
        if(class(MQI.annual.90.plot)=="data.frame") {
          MQI.annual.90.plot$col_key=case_when(MQI.annual.90.plot$MQI.annual.90<1~"green",MQI.annual.90.plot$MQI.annual.90>1~"red")
        } else {
          MQI.annual.90.plot=as.data.frame(MQI.annual.90.plot, col.names="MQI.annual.90")
          MQI.annual.90.plot$col_key=case_when(MQI.annual.90.plot$MQI.annual.90<1~"green",MQI.annual.90.plot$MQI.annual.90>1~"red")
        }
        if (bTargetPlotFiltering.stntype.col |bTargetPlotFiltering.stntype.colsym){ # If points are being colour coded, do not colour code the MQI.90 value 
          targetplot=targetplot+geom_text(data=MQI.annual.90.plot, x=MQI.90.x,y=MQI.90.y2.pos, aes(label=paste("MQI_YR=",round(MQI.annual.90,3), sep="")), hjust="left",vjust="bottom", size=MQI.90.size, show.legend = FALSE)
        } else {
          targetplot=targetplot+geom_text(data=MQI.annual.90.plot, x=MQI.90.x,y=MQI.90.y2.pos, aes(label=paste("MQI_YR=",round(MQI.annual.90,3)), colour=col_key), hjust="left",vjust="bottom", size=MQI.90.size, show.legend = FALSE)
        }
      }
      
      # Add labels to target plot if selected and appropriate 
      if (bTargetPlotlabs){
        if (bTargetPlotFiltering.stntype.colsym | bTargetPlotFiltering.stntype.col){   
          targetplot=targetplot+geom_text(label=target.plot.data$station.output.name, aes(colour=target.plot.data$type), nudge_x = -0.2 ,nudge_y=-0.05, size=label.size)  
        } else if (bTargetPlotFiltering.pol.colsym | bTargetPlotFiltering.pol.col){   
          targetplot=targetplot+geom_text(label=target.plot.data$station.output.name, aes(colour=target.plot.data$polstring), nudge_x = -0.2 ,nudge_y=-0.05, size=label.size)  
        } else if ((bTargetPlotFiltering.model.colsym | bTargetPlotFiltering.model.col) & !bSingleModel) {
          targetplot=targetplot+geom_text(label=target.plot.data$station.output.name, aes(colour=target.plot.data$model), nudge_x = -0.2 ,nudge_y=-0.05, size=label.size)  
        } else{ #Black labels if no colours used
          targetplot=targetplot+geom_text(label=target.plot.data$station.output.name, nudge_x = -0.2 ,nudge_y=-0.05, size=label.size) 
        } 
      }
      
      if (!(class(RMSUM.perc.90.plot)=="data.frame")){
        RMSUM.perc.90.plot=as.data.frame(RMSUM.perc.90.plot, col.names="RMSUM.perc.90.plot")
        names(RMSUM.perc.90.plot)="RMSUM.perc.90"
      }
      #Add Umod (RV) value
      if (bAddResultsToTargetPlots){
        targetplot=targetplot+geom_text(data=RMSUM.perc.90.plot,x=delta.lab.x, y=delta.lab.y.beta-0.2, aes(label=paste("Umod (RV) =",round((RMSUM.perc.90.plot$RMSUM.perc.90),1), "%",sep="")), colour="blue",hjust="left",vjust="bottom", size=pol.data.size)
      }
      
      #Add pollutant-specific params if filter by pol
      if ((bAddResultsToTargetPlots & bTargetPlotFiltering.pol.subplots) | bSinglePollutant | (!bAddResultsToTargetPlots & bSingleModel & bTargetPlotFiltering.pol.subplots)) {
        polstrings=unique(subset(target.plot.data,select=c("pollutant","polstring")))
        delta.params=merge(polstrings,subset(pollutants,select=c("pollutant","beta","urlv","alpha","lv","target.units")))
        delta.labels=data.frame(polstring=delta.params$polstring,
                                urlv.label=paste("UrLV =",round(100*delta.params$urlv,0),"%"),
                                alpha.label=paste("Alpha =",round(delta.params$alpha,2)),
                                lv.label=paste("LV =",delta.params$lv,delta.params$target.units),
                                beta.label=paste("Beta =",round(delta.params$beta,2)))
        delta.labels=reshape2::melt(delta.labels,id.vars = "polstring",variable.name = "label.type",value.name = "label")
        delta.labels$x=delta.lab.x
        delta.labels$y=delta.lab.y.alpha
        delta.labels$y[delta.labels$label.type=="alpha.label"]=delta.lab.y.urlv
        delta.labels$y[delta.labels$label.type=="lv.label"]=delta.lab.y.lv
        delta.labels$y[delta.labels$label.type=="beta.label"]=delta.lab.y.beta
        targetplot = targetplot +
          geom_text(data=subset(delta.labels,label.type=="urlv.label"),aes(x=x,y=y,label=label),inherit.aes =FALSE,
                    hjust="left",vjust="bottom",colour="black", size=pol.data.size)+
          geom_text(data=subset(delta.labels,label.type=="alpha.label"),aes(x=x,y=y,label=label),inherit.aes =FALSE,
                    hjust="left",vjust="bottom",colour="black", size=pol.data.size)+
          geom_text(data=subset(delta.labels,label.type=="lv.label"),aes(x=x,y=y,label=label),inherit.aes = FALSE,
                    hjust="left",vjust="bottom",colour="black", size=pol.data.size)+
          geom_text(data=subset(delta.labels,label.type=="beta.label"),aes(x=x,y=y,label=label),inherit.aes = FALSE,
                    hjust="left",vjust="bottom",colour="black", size=pol.data.size)
        
      }
      plot(targetplot)
      graphics.off() #relevant to all output types as target plots are always output onto one page
    }
    
    #Output model quality objective results to file
    if (!any(pollutants$bTargetPlot)){
      model.quality.columns=c("MQI.annual.90","RMSUM.annual.90","RMSUM.annual.perc.90")
    } else {
      # Replace output plot NAs with FALSE
      model.quality$output.plot[which(is.na(model.quality$output.plot))]=FALSE
      model.quality.columns=c("MQI.90","MPI.bias.90","MPI.r.90","MPI.sd.90", "RMSUM.90","RMSUM.perc.90","MQI.annual.90","RMSUM.annual.90","RMSUM.annual.perc.90","output.plot")
    }
    additional.cols=names(model.quality)[!(names(model.quality) %in% model.quality.columns)]
    model.quality.columns=c(additional.cols, model.quality.columns)
    model.quality=subset(model.quality, select=model.quality.columns)
    mqo.file = paste(eval.output.root,"_conc_target_stats.csv",sep="")
    write.csv(model.quality,mqo.file,row.names = FALSE,quote=FALSE)
  }
  
  #Box and whisker plot
  if (bBoxWhisker) {
    if (bPDF) {
      cat("Sending concentration assessment box and whisker plots to pdf...\n")
      pdf(paste(eval.output.root,"_conc_boxplot.pdf",sep=""),paper="a4r",width=0,height=0,title=pdf.title)
    } else if (bJPG) {
      cat("Sending concentration assessment box and whisker plots to jpg...\n")
    } else if (bPNG) {
      cat("Sending concentration assessment box and whisker plots to png...\n")
    }
    #Loop over pollutants
    for (j in 1:length(pollutants$pollutant)) {
      #to make conditioning work, need "mod" as column and rows corresponding to models
      bDoneFirstModel = FALSE
      for (m in (1:length(modelled.data.names))) {
        temp.data.onemodel = subset(all.data.averaged, pollutant==pollutants$pollutant[j], select=c("date","obs","station","station.output.name","type","pollutant","output.avg.time.and.statistic",modelled.data.names[m]))      
        names(temp.data.onemodel)[names(temp.data.onemodel)==modelled.data.names[m]] = "mod"
        if (length(temp.data.onemodel$mod)==0) {
          next
        }
        temp.data.onemodel$model = modelled.data.names[m]
        if (bDoneFirstModel) {
          all.data.by.pol = rbind(all.data.by.pol,temp.data.onemodel)
        } else {
          all.data.by.pol = temp.data.onemodel
          bDoneFirstModel = TRUE
        }         
        rm(temp.data.onemodel)
      }
      rm(temp)
      #Check there is some valid data and all.data.by.pol exists
      if (!bDoneFirstModel) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }
      
      all.data.by.pol = subset(all.data.by.pol, !is.na(obs) & !is.na(mod) & !is.infinite(obs) & !is.infinite(mod))
      
      #Check that there is data to plot and return an error if not
      if (length(all.data.by.pol$pollutant)==0) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }
      
      #can't plot zeros on log scale
      if (bBoxLog & any(all.data.by.pol[,c("obs","mod")] == 0)) {
        all.data.by.pol = subset(all.data.by.pol, (obs!=0 & mod!=0))
      }   
      
      #Determine if outputs are to be filtered by station type
      if (!bFilterStnType) {
        if (bSelectedStationTypes) {
          station.type = selected.station.types
        } else if (bSingleStation) {
          station.type = single.station
        } else {
          station.type = "all stations"
        }
      } else if (bFilterStnType) {
        station.type = sort(unique(all.data.by.pol$type))
      }
      #Find maximum y values for axis based on outliers
      if (bBoxOutliers) {
        box.outline = TRUE
        pollutant.max = max(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
        pollutant.min = min(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
      } else {
        box.outline = FALSE
        #Find the maximum concentration within range of data to be shown on graph
        conc.pol = subset(conc.grouped, pollutant==pollutants$pollutant[j])
        #remove 'all' stations statistics from box plot data
        conc.pol = conc.pol[eval(parse(text=paste("!conc.pol$",groupby.col.label," == 'all'",sep=""))), ] 
        pollutant.max = max(conc.pol$obs.upper.whisker, conc.pol$mod.upper.whisker, na.rm=TRUE)
        pollutant.min = min(conc.pol$obs.lower.whisker, conc.pol$mod.lower.whisker,na.rm=TRUE)
      }
      
      #Set up graph labels
      pollutant.units = pollutants$output.units[j]
      if (bGrpModel) {
        x.axis.label = paste("Stations",sep="")         
      } else {
        x.axis.label = paste(groupby.key.string,"s",sep="")
      }
      y.axis.label = paste(toupper(pollutants$pollutant[j])," (",pollutant.units,") ")
      
      #Loop over station types
      for (i in 1:length(station.type)) {
        
        #Set the plot title text and subset of data to use
        if (bSelectedStationTypes) {
          all.data.by.pol.and.type=all.data.by.pol
          text.stntype.title = toupper(selected.station.types)
          summary.stats=subset(summary.by.filter.conc,pollutant==pollutants$pollutant[j])
        } else if (bSingleStation) {
          all.data.by.pol.and.type=all.data.by.pol
          text.stntype.title = unique(all.data.by.pol.and.type$station.output.name)
          summary.stats=subset(summary.by.filter.conc,pollutant==pollutants$pollutant[j])
        } else if (bSelectedStations){
          all.data.by.pol.and.type=all.data.by.pol
          text.stntype.title = "SELECTED STATIONS"
          summary.stats=subset(summary.by.filter.conc,pollutant==pollutants$pollutant[j])
        } else if (!bFilterStnType) {
          all.data.by.pol.and.type=all.data.by.pol
          text.stntype.title = toupper(station.type)
          summary.stats=subset(summary.by.filter.conc,pollutant==pollutants$pollutant[j])
        } else if (bFilterStnType) {
          all.data.by.pol.and.type=subset(all.data.by.pol, type==station.type[i])
          summary.stats=subset(summary.by.filter.conc,pollutant==pollutants$pollutant[j] & type==station.type[i])
          text.stntype.title = toupper(station.type[i])
        }
        
        if (bSingleNetwork){
          text.stntype.title=paste(text.stntype.title, ", ", toupper(selected.networks.vector), sep="")
        } else if (bSelectedNetworks){
          text.stntype.title=paste(text.stntype.title, ", SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse =", ")),")", sep="")
        } 
        
        text.stntype.filename = gsub(" ", "", text.stntype.title)  # remove any white space for the filename
        text.stntype.filename = tolower(gsub(",","_", text.stntype.filename))
        models.available.by.pol.and.type = unique(all.data.by.pol.and.type$model)
        group.var.available.by.pol.and.type = unique(all.data.by.pol.and.type[,groupby.col.label])
        
        number.stations = length(group.var.available.by.pol.and.type)
        #Set the size of outliers
        if (number.stations>=100) {
          outlier.size = 0.25
        } else {
          outlier.size = 1.25 - (number.stations/100)
        }
        
        #set log/linear options
        if (bBoxLog) {
          #y-axis range and labels
          log.y.max = 10^ceiling(log10(1.05*pollutant.max))
          log.y.min = 10^floor(log10(0.95*pollutant.min))
          log.y.labels = as.vector(log.y.max)
          n=2
          while (log.y.labels[n-1] > min(1,log.y.min)) {
            log.y.labels[n] = log.y.labels[n-1]/10
            n = n+1
          }  
        }
        
        #If JPG or PNG is selected, set up the page
        if (bJPG) {
          jpeg(paste(eval.output.root,"_",pollutants$pollutant[j],"_", text.stntype.filename,"_conc_boxplot.jpg",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
        } else if (bPNG) {
          png(paste(eval.output.root,"_",pollutants$pollutant[j],"_",text.stntype.filename,"_conc_boxplot.png",sep=""),width=paper.height,height=paper.width,units="mm",res=300)
        }   
        
        if (bGrpModel) {
          boxplotobsformula = as.formula(paste("obs~pollutant"))
          boxplotmodformula = as.formula(paste("mod~pollutant"))
          # predefine formatting for placement of bars forced to one central point
          at.sequence.centered = seq(from=1,to=1,length=1)         
        } else {
          # predefine formatting for placement of bars because of number of models
          at.sequence.centered = seq(from=1,to=(number.stations),length=(number.stations))
          boxplotobsformula = as.formula(paste("obs~",groupby.col.label))
          boxplotmodformula = as.formula(paste("mod~",groupby.col.label))
        }
        number.models = length(modelled.data.names)
        if (number.models > 2) {  # place bars at +- 0.2 from each groupby.col.label
          delta.at.models = 0.4/number.models
        } else {  # is small number ,place bars at +- 0.1
          delta.at.models = 0.2/number.models
        }
        #predefine at-sequence depending on if a model-station combination has valid data
        at.entry = at.sequence.centered-(delta.at.models*number.models/2)
        #Aesthetics
        if (specify.model.rgb.colours & bFilterModelRGB) {
          colours.rgb=data.frame(model=c(modelled.data.names))
          colours.rgb=merge(colours.rgb,model.rgb.colours)
          box.model.outline.colours=rgb(red = colours.rgb$red,
                                        green = colours.rgb$green,
                                        blue = colours.rgb$blue,
                                        maxColorValue = 255)
          obs.outline.colour = subset(model.rgb.colours,model=="obs")
          obs.outline.colour = rgb(red = obs.outline.colour$red,green = obs.outline.colour$green,blue = obs.outline.colour$blue,maxColorValue = 255)
        } else {
          box.model.outline.colours = rep(rainbow(8),ceiling(number.models/8))
          obs.outline.colour = rgb(red = 51,green = 153,blue = 255,maxColorValue = 255)
        }
        box.model.fill.colours = lighten(box.model.outline.colours,amount=0.4)
        obs.fill.colour=lighten(obs.outline.colour,amount=0.4)
        title.text=paste("Box and Whisker Plot: ",toupper(project.name),", ", text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),sep="")  
        if ((nchar(title.text)>90 & bPaperLarge) | (nchar(title.text)>75 & bPaperMedium) | (nchar(title.text)>65 & bPaperSmall)) {
          title.text = paste(paste("Box and Whisker plot: ",toupper(project.name),sep=""),paste(text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),sep=""),sep="\n")
        } 
        
        if (bPaperLarge) {
          box.cex.axis = 0.7
        } else {
          box.cex.axis = 0.9
        }
        
        #If large number of stations, resort to numbering
        if ((number.stations >= 100 & bPaperLarge) | (number.stations >= 65 & bPaperMedium) | (number.stations >= 45 & bPaperSmall)) {
          #Set up 3 drawing panels
          layout(matrix(c(1,2,3),nrow=3,ncol=1,byrow=TRUE),heights=c(8,3,2))
          # boxplot
          par(mar=c(4,4,5,4),xpd=FALSE)
          #Draw first boxplot to set plot area
          if (bBoxLog) {
            boxplot(boxplotobsformula,data=all.data.by.pol.and.type,log="y",col="white",border="white",xlab=x.axis.label,ylab=y.axis.label,main=title.text,boxwex=0.5,outline=box.outline,
                    outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis, cex.lab=1.0,axes=FALSE,ylim=c(log.y.min,log.y.max),las=2)
          } else {
            boxplot(boxplotobsformula,data=all.data.by.pol.and.type,col="white",border="white",xlab=x.axis.label,ylab=y.axis.label,main=title.text,boxwex=0.5,outline=box.outline,
                    outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis, cex.lab=1.0,axes=FALSE,ylim=c(min(0,1.2*pollutant.min), 1.2*pollutant.max),las=2)               
          }
          axis(side=1,at=unique(5*floor(c(1:number.stations)/5)+1),labels=as.character(unique(5*floor(c(1:number.stations)/5)+1)),las=0,tick=TRUE) # make numered x-axis rather than station names
          #Draw horizontal grid lines
          grid(nx=NA,ny=NULL,lty=3,col="grey")
          #Draw vertical grid lines
          abline(v=c(1:number.stations),lty=3,col="grey")
          par(new=TRUE)
          if (bBoxLog) {
            #Plot observed data boxplot
            boxplotobs.params=boxplot(boxplotobsformula,data=all.data.by.pol.and.type,log="y",col=obs.fill.colour,border=obs.outline.colour,outline=box.outline,boxwex=0.5,ylim=c(log.y.min,log.y.max),outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                                      at=at.entry,las=2,outwex=0.5,xaxt="n",yaxt="n",ann=FALSE)
            #Plot modelled data boxplot
            for (m in 1:number.models) {
              #Reduce size of 'at' if some stations/entries are invalid for this model
              at.entry.mod = at.entry[match(unique(all.data.by.pol.and.type[all.data.by.pol.and.type[,"model"]==models.available.by.pol.and.type[m], groupby.col.label]), unique(all.data.by.pol.and.type[ , groupby.col.label]))]
              #plot
              boxplot(boxplotmodformula,data=all.data.by.pol.and.type,subset=all.data.by.pol.and.type$model==models.available.by.pol.and.type[m],log="y",col=box.model.fill.colours[m],boxwex=0.5,add=TRUE,border=box.model.outline.colours[m],outline=box.outline,ylim=c(log.y.min,log.y.max),outpch=4,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                      at=at.entry.mod + (m*delta.at.models),outwex=0.5,las=2,xaxt="n",yaxt="n",width=rep(1,length(at.entry.mod)))
            }
            #add y-axis and labels
            axis(side=2, at=log.y.labels, lab=as.character(log.y.labels), las=2, cex.axis=box.cex.axis)
          } else {
            #Plot observed data boxplot
            boxplotobs.params=boxplot(boxplotobsformula,data=all.data.by.pol.and.type,col=obs.fill.colour,border=obs.outline.colour,outline=box.outline,boxwex=0.5,ylim=c(min(0,1.2*pollutant.min),1.2*pollutant.max),outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                                      at=at.entry,las=2,outwex=0.5,xaxt="n",ann=FALSE)
            #Plot modelled data boxplot
            for (m in 1:number.models) {
              #Reduce size of 'at' if some stations/entries are invalid for this model
              at.entry.mod = at.entry[match(unique(all.data.by.pol.and.type[all.data.by.pol.and.type[,"model"]==models.available.by.pol.and.type[m], groupby.col.label]), unique(all.data.by.pol.and.type[ , groupby.col.label]))]
              #plot
              boxplot(boxplotmodformula,data=all.data.by.pol.and.type,subset=all.data.by.pol.and.type$model==models.available.by.pol.and.type[m],col=box.model.fill.colours[m],boxwex=0.5,add=TRUE,border=box.model.outline.colours[m],outline=box.outline,ylim=c(0,1.2*pollutant.max),outpch=4,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                      at=at.entry.mod + (m*delta.at.models) ,outwex=0.5,las=2,xaxt="n",width=rep(1,length(at.entry.mod)))
            }
          }
          legend(x="topleft",c("observed",models.available.by.pol.and.type),pch=22,col=c(obs.outline.colour,box.model.outline.colours),pt.bg=c(obs.fill.colour,box.model.fill.colours) ,bg="white")       
          
          
          #Key to stations   
          #Draw the key
          par(mar=c(0,0,3,0),xpd=FALSE)
          nr=10
          nc=floor((number.stations-1)/nr)+1
          make.table(nr,nc)
          draw.title("Key to numbers",nc)
          for (n in 1:number.stations) {
            draw.sample.cell(n, boxplotobs.params$names[n], n, nr)
          }
          
          #Draw the summary table
          par(mar=c(0,0,3,0))
          make.table(1,2)
          draw.title("Summary",2)
          draw.sample.cell("Date range",paste(data.start.date.for.graphs," to ",data.end.date.for.graphs),1,1)        
          draw.sample.cell(paste("Valid",paste(tolower(groupby.key.string),"s",sep="")),paste(max(summary.stats$num.valid.stations),"out of",max(summary.stats$num.stations)),2,1)
          
        } else {             
          #Set up 2 drawing panels
          if (bPaperLarge) {
            layout(matrix(c(1,2),nrow=2,ncol=1,byrow=TRUE),heights=c(8,2))
          } else {  # medium or small, increase Summary proportion of page for aesthetics
            layout(matrix(c(1,2),nrow=2,ncol=1,byrow=TRUE),heights=c(3,1))
          }
          par(mar=c(4,4,5,4),xpd=FALSE)
          #Draw first boxplot to set plot area
          if (bBoxLog) {
            boxplot(boxplotobsformula,data=all.data.by.pol.and.type,log="y",yaxt="n",col="white",border="white",xlab=x.axis.label,ylab=y.axis.label,main=title.text,boxwex=0.5,outline=box.outline,
                    outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis, cex.lab=1.0,ylim=c(log.y.min,log.y.max),las=2)
          } else {
            boxplot(boxplotobsformula,data=all.data.by.pol.and.type,col="white",border="white",xlab=x.axis.label,ylab=y.axis.label,main=title.text,boxwex=0.5,outline=box.outline,
                    outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis, cex.lab=1.0,ylim=c(min(0,1.2*pollutant.min), 1.2*pollutant.max),las=2)               
          }
          #Draw horizontal grid lines
          grid(nx=NA,ny=NULL,lty=3,col="grey")
          #Draw vertical grid lines
          abline(v=c(1:number.stations),lty=3,col="grey")
          par(new=TRUE)
          if (bBoxLog) {
            #Plot observed data boxplot
            boxplotobs.params=boxplot(boxplotobsformula,data=all.data.by.pol.and.type,log="y",col=obs.fill.colour,border=obs.outline.colour,outline=box.outline,boxwex=0.5,ylim=c(log.y.min,log.y.max),outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                                      at=at.entry,las=2,outwex=0.5,xaxt="n",yaxt="n",ann=FALSE)
            #Plot modelled data boxplot
            for (m in 1:number.models) {
              #Reduce size of 'at' if some stations/entries are invalid for this model
              at.entry.mod = at.entry[match(unique(all.data.by.pol.and.type[all.data.by.pol.and.type[,"model"]==models.available.by.pol.and.type[m], groupby.col.label]), unique(all.data.by.pol.and.type[ , groupby.col.label]))]
              #plot
              boxplot(boxplotmodformula,data=all.data.by.pol.and.type,subset=all.data.by.pol.and.type$model==models.available.by.pol.and.type[m],log="y",col=box.model.fill.colours[m],boxwex=0.5,add=TRUE,border=box.model.outline.colours[m],outline=box.outline,ylim=c(log.y.min,log.y.max),outpch=4,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                      at=at.entry.mod + (m*delta.at.models),outwex=0.5,las=2,xaxt="n",yaxt="n",width=rep(1,length(at.entry.mod)))
            }
            #add y-axis and labels
            axis(side=2, at=log.y.labels, lab=as.character(log.y.labels), las=2, cex.axis=box.cex.axis)
          } else {
            #Plot observed data boxplot
            boxplot(boxplotobsformula,data=all.data.by.pol.and.type,col=obs.fill.colour,border=obs.outline.colour,outline=box.outline,boxwex=0.5,ylim=c(min(0,1.2*pollutant.min),1.2*pollutant.max),outpch=21,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                    at=at.entry,las=2,outwex=0.5,xaxt="n",ann=FALSE)
            #Plot modelled data boxplot
            for (m in 1:number.models) {
              #Reduce size of 'at' if some stations/entries are invalid for this model
              at.entry.mod = at.entry[match(unique(all.data.by.pol.and.type[all.data.by.pol.and.type[,"model"]==models.available.by.pol.and.type[m], groupby.col.label]), unique(all.data.by.pol.and.type[ , groupby.col.label]))]
              #plot
              boxplot(boxplotmodformula,data=all.data.by.pol.and.type,subset=all.data.by.pol.and.type$model==models.available.by.pol.and.type[m],col=box.model.fill.colours[m],boxwex=0.5,add=TRUE,border=box.model.outline.colours[m],outline=box.outline,ylim=c(min(0,1.2*pollutant.min),1.2*pollutant.max),outpch=4,outcex=outlier.size,lty=1,cex.axis=box.cex.axis,
                      at=at.entry.mod + (m*delta.at.models),outwex=0.5,las=2,xaxt="n",width=rep(1,length(at.entry.mod)))
            }
          }
          legend(x="topleft",c("observed",models.available.by.pol.and.type),pch=22,col=c(obs.outline.colour,box.model.outline.colours),pt.bg=c(obs.fill.colour,box.model.fill.colours) ,bg="white")       
          
          
          #If there is only one station on the graph, make this the x-axis label
          if (number.stations==1) {
            if (bGrpModel) {
              axis(side=1,at=at.sequence.centered - (delta.at.models*number.models/2),labels=c("all stations"),cex.axis=box.cex.axis,las=0)                  
            } else {
              axis(side=1,at=at.sequence.centered - (delta.at.models*number.models/2),labels=sort(group.var.available.by.pol.and.type),cex.axis=box.cex.axis,las=2)
            }
          }
          
          #Draw the summary table
          par(mar=c(1,0,3,0),xpd=TRUE)
          make.table(1,2)
          draw.title("Summary",2)
          draw.sample.cell("Date range",paste(data.start.date.for.graphs," to ",data.end.date.for.graphs),1,1)        
          draw.sample.cell(paste("Valid",paste(tolower(groupby.key.string),"s",sep="")),paste(max(summary.stats$num.valid.stations),"out of",max(summary.stats$num.stations)),2,1)
          
        }
        if (bJPG | bPNG) {
          graphics.off()
        }
        rm(all.data.by.pol.and.type)
      } #end loop over station types
      rm(all.data.by.pol)                        
    } #end loop over pollutants
    if (bPDF) {
      graphics.off()
    }
  }   
  
  #Scatter Plot, noting only one of FreqScatterPlot or bScatterPlot may be true 
  if (bFreqScatterPlot | bScatterPlot) {
    # predefine strings associated with conventional or frequency scatter plot
    if (bFreqScatterPlot) {
      scatter.string = "frequency"
      scatter.title.string = "Frequency Scatter Plot: "
    } else if (bScatterPlot) {
      scatter.string = "conventional"
      scatter.title.string = "Scatter Plot: "
    }
    
    if (bPDF) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to pdf...\n"))
      pdf(paste(eval.output.root,"_conc_",scatter.string,"_scatter_plot.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
    } else if (bJPG) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to jpg...\n"))
    } else if (bPNG) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to png...\n"))
    }
    
    #Loop over pollutants
    for (j in 1:length(pollutants$pollutant)) {
      #to make conditioning work, need "mod" as column and rows corresponding to models
      bDoneFirstModel = FALSE
      for (m in (1:length(modelled.data.names))) {
        temp.data.onemodel = subset(all.data.averaged, pollutant==pollutants$pollutant[j], select=c("date","obs","station","station.output.name","type","pollutant","output.avg.time.and.statistic",modelled.data.names[m]))      
        names(temp.data.onemodel)[names(temp.data.onemodel)==modelled.data.names[m]] = "mod"
        if (length(temp.data.onemodel$mod)==0) {
          next
        }
        temp.data.onemodel$model = modelled.data.names[m]
        if (bDoneFirstModel) {
          all.data.by.pol = rbind(all.data.by.pol,temp.data.onemodel)
        } else {
          all.data.by.pol = temp.data.onemodel
          bDoneFirstModel = TRUE
        }         
        rm(temp.data.onemodel)
      }
      rm(temp)  
      #Check that there is valid data to plot and all.data.by.pol exists
      if (!bDoneFirstModel) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }
      
      all.data.by.pol = subset(all.data.by.pol, !is.na(obs) & !is.na(mod) & !is.infinite(obs) & !is.infinite(mod))
      
      #Check that there is data to plot and return an error if not
      if (length(all.data.by.pol$pollutant)==0) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }   
      
      #can't plot zeros on log scale
      if (bScatterLog & any(all.data.by.pol[,c("obs","mod")] == 0)) {
        all.data.by.pol = subset(all.data.by.pol, (obs!=0 & mod!=0))
      } 
      
      #setup pollutants
      pollutant.units = pollutants$output.units[j]         
      
      #Set graphical parameters
      graph.obs = "obs"
      graph.mod = "mod"
      pollutant.max = max(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
      pollutant.min = min(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
      if (bScatterLog) {
        max.val = 10^ceiling(log10(1.05*pollutant.max))
        min.val = 10^floor(log10(0.95*pollutant.min))
      } else {   
        # define linear axis extent and labels
        max.val = 1.05*pollutant.max
        min.val = min(0,1.05*pollutant.min)
      }
      pol.display = paste(" (",pollutant.units,")",sep="")
      graph.z=NA
      if (bFreqScatterPlot) {
        # determine range of data and no. hexbins to modify colour key if necessary
        if (bScatterLog) {
          # if log-log scale, need to determine hexbin extents in log-log transford
          scatter.dummy.data = all.data.by.pol
          scatter.dummy.data$obs = log10(scatter.dummy.data$obs)
          scatter.dummy.data$mod = log10(scatter.dummy.data$mod)
        } else {
          scatter.dummy.data = all.data.by.pol
        }
        # determine the maximum count (in bins) i.e. max label of colour scale
        if (bFilterStnType) {
          station.types.available.by.pol = unique(scatter.dummy.data$type)
          max.count = rep(NA, length(station.types.available.by.pol))
          for (i in 1:length(station.types.available.by.pol)) {
            type.subset = subset(scatter.dummy.data,type==station.types.available.by.pol[i])[ ,c("obs","mod")]
            max.count[i] = max(hexbin(type.subset$obs, type.subset$mod, xbnds=range(type.subset[,c("obs","mod")]), ybnds=range(type.subset[,c("obs","mod")]))@count)
            rm(type.subset)
          }
          hexbin.count = max(max.count, na.rm=TRUE)
        } else {
          hexbin.count = max(hexbin(scatter.dummy.data$obs, scatter.dummy.data$mod, xbnds=range(scatter.dummy.data[,c("obs","mod")]), ybnds=range(scatter.dummy.data[,c("obs","mod")]))@count)
        }
        rm(scatter.dummy.data)  
        # check frequency scatter plot has .gt. 3 count in each bin to avoid plotting errors
        if (hexbin.count <= 3) {
          cat(paste("   Warning: Too few data for frequency scatter plot output for pollutant", pollutants$pollutant[j], "- choose conventional scatter plot instead\n"))
          next
        } 
        # If hexbin.count is 18 or higher, use a log10 colour scale. Otherwise, apply no transformation 
        if (hexbin.count <= 17) {
          scatter.trans = "identity"
        } else {
          scatter.trans = "log10"
        }
        # other graphical settings
        if (bScatterLog) {
          scatter.xbnds=range(log10(all.data.by.pol[,c("obs","mod")]))
        } else {
          scatter.xbnds=range(all.data.by.pol[,c("obs","mod")])            
        }
        scatter.cols=c(hsv(seq(0.675,0.400,by=-0.275/4), 0.6, 0.75), hsv(seq(0.350,0.2,by=-0.2/3),0.35,0.9), hsv(seq(0.2,0.1,by=-0.1/3),0.5,1.0), hsv(seq(0.1,0.0,by=-0.1/4), 0.75, 0.9), hsv(seq(1.0, 0.975,by=-0.025/1),0.75, 0.6)) # colour palette with reduced saturation, to mimic "hue", 17 colours to match default levels used by hexbin 
        scatter.pch=1
        scatter.cex=NULL
        scatter.mod.line=TRUE
        scatter.method="hexbin"     
      } else if (bScatterPlot) {
        scatter.trans = NULL
        scatter.xbnds=NULL
        scatter.cols="black" 
        scatter.pch=16
        scatter.cex=NULL
        scatter.mod.line=TRUE
        scatter.method="scatter"          
      }
      
      # R doesn't like the column header "type" as it is also an intrinsic function
      scatter.plot.data = all.data.by.pol
      names(scatter.plot.data)[names(scatter.plot.data)=="type"] = "station.type"   
      
      
      #Determine if outputs are to be filtered by station type
      # setup options related to filtering or not
      if (bAllStationTypes & bFilterStnType) {
        # use scatterPlot's intrinsic filter function to display each station type on one page, one page per pollutant:
        filter.type=c("model","station.type")
        # titles
        text.stntype.title = "ALL STATIONS"
        text.stntype.filename = "allstationsbytype"
      } else {
        # scatterPlot(type = ...)          
        filter.type="model" 
        # titles
        if (bSelectedStationTypes) {
          #Check that there is data to plot and return an error if not
          if (length(scatter.plot.data$pollutant)==0) {
            cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
            next
          }   
          text.stntype.title = toupper(selected.station.types)
          text.stntype.filename = gsub(",","_",selected.station.types)
          text.stntype.filename = gsub(" ","",text.stntype.filename)
        } 
        else if (bSingleStation) {
          text.stntype.title = unique(all.data.by.pol$station.output.name)
          text.stntype.filename = text.stntype.title
        } else if (bSelectedStations) {
          text.stntype.title="SELECTED STATIONS"
          text.stntype.filename="selectedstations"
        }else if (bAllStationTypes & !bFilterStnType) {
          text.stntype.title = "ALL STATIONS"
          text.stntype.filename = "allstations"
        }        
      }
      
      if (bSingleNetwork){
        text.stntype.title=paste(text.stntype.title, ", ",toupper(selected.networks.vector), sep="")
        text.stntype.filename=paste(text.stntype.filename,"_",tolower(selected.networks.vector))
      } else if (bSelectedNetworks){
        text.stntype.title=paste(text.stntype.title, ", ", paste("SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep=""), sep="")
        text.stntype.filename=paste(text.stntype.filename,"_selcted_nwrks", sep="")
      }
      
      if (bSingleModel) {
        text.filters = paste(single.model,", ",text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),pol.display,sep="")
      } else if (bSelectedModels) {
        text.filters = paste("SELECTED MODELS",", ",text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),pol.display,sep="")
      } else {
        text.filters = paste(text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),pol.display,sep="")
      }
      
      # setup output page
      filename=paste(eval.output.root,"_",pollutants$pollutant[j],"_",text.stntype.filename,"_conc_",scatter.string,"_scatter_plot",sep="")
      filename=substr(filename,1,min(250,nchar(filename)))
      if (bJPG) {
        jpeg(paste(filename,".jpg",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } else if (bPNG) {
        png(paste(filename,".png",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } 
      #title
      titletext=paste(scatter.title.string,toupper(project.name),", ",text.filters,sep="")  
      if (nchar(titletext)>65) {  # split onto two rows
        titletext=paste(paste(scatter.title.string,toupper(project.name), sep=""), text.filters, sep="\n")  
      }         
      # Draw scatter plot
      plot_object = suppressMessages(scatterPlot(
        scatter.plot.data,
        x=graph.obs,y=graph.mod,z=graph.z,
        log.x=bScatterLog,log.y=bScatterLog,
        xlab="Observed",ylab="Modelled",
        cols=scatter.cols,pch=scatter.pch,cex=scatter.cex,
        type=filter.type,
        method=scatter.method,
        mod.line=scatter.mod.line,
        main=titletext,
        sub=paste("Date range: ", data.start.date.for.graphs," to ", data.end.date.for.graphs, sep = ""),
        xbnds=scatter.xbnds, ybnds=scatter.xbnds,
        trans=scatter.trans,
        linear=bScatterBestLine,
        plot=FALSE           # return ggplot object, don't auto-plot
      )$plot +                       # extract the ggplot object and add to it
        coord_fixed(ratio = 1))
      
      # Set the axes based on the value of bScatterLog
      if (bScatterLog) {
        plot_object = suppressMessages(plot_object + 
          scale_x_log10(limits = c(min.val, max.val)) + 
          scale_y_log10(limits = c(min.val, max.val)))
      } else {
        plot_object = suppressMessages(plot_object + 
          scale_x_continuous(limits = c(min.val, max.val)) + 
          scale_y_continuous(limits = c(min.val, max.val)))
      }
      
      suppressMessages(plot(plot_object))              # render the plot
      
      if (bJPG | bPNG) {
        graphics.off()
      }           
      rm(all.data.by.pol, scatter.plot.data)
    } # next pollutant
    
    if (bPDF) {
      graphics.off()
    }
    
  }  # endif bFreqScatterPlot | bScatterPlot
  
  
  if (bStatisticalScatter) {
    scatter.string="statistical"
    scatter.title.string="Statistical Scatter Plot: "
    # predefine strings associated with conventional or frequency scatter plot
    
    if (bPDF) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to pdf...\n"))
      pdf(paste(eval.output.root,"_conc_",scatter.string,"_scatter_plot.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
    } else if (bJPG) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to jpg...\n"))
    } else if (bPNG) {
      cat(paste("Sending concentration assessment",scatter.string,"scatter plots to png...\n"))
    }
    
    statisticalscatter_colours=c("red","darkturquoise","chartreuse2","darkorchid", "deeppink2","darkslategray2","aquamarine3","azure3","blue","brown1","cadetblue2","chartreuse4","coral1","cornsilk4","cyan4","darkmagenta")
    statisticalscatter_shapes=c(15,16,17,18,19,20,8,11,7,9,13,12,10,0,1,2,3,4,5,6,14,21,22,23,24,25)
    types=sort(unique(conc.grouped$type)[which(unique(conc.grouped$type) != "all")]) #Find all station types in the data
    if (bSSstntypecols & length(types) <= length(statisticalscatter_colours)){
      statisticalscatter_colours=statisticalscatter_colours[1:length(types)] #Only picks the colours needed
      names(statisticalscatter_colours)=types
    } else if (bSSstntypecols & length(types) > length(statisticalscatter_colours)){
      stop(paste("There are too many station types (", length(types),") to filter by colour. Turn off 'Filter station type by colour' or reduce the number of station types (max ", length(statisticalscatter_colours),").\n",sep=""))
      quit("no")
    }
    if(bSSstntypesym & length(types) <= length(statisticalscatter_shapes)){
      statisticalscatter_shapes=statisticalscatter_shapes[1:length(types)] #Only picks the shapes needed
      names(statisticalscatter_shapes)=types
    } else if (bSSstntypesym & length(types) > length(statisticalscatter_shapes)){
      stop(paste("There are too many station types (", length(types),") to filter by symbol. Turn off 'Filter station type by symbol' or reduce the number of station types (max ", length(statisticalscatter_shapes),").\n",sep=""))
      quit("no")
    }
    
    #Loop over pollutants
    for (j in 1:length(pollutants$pollutant)) {
      #Skipping the scatter plot if percentile is selected but not available
      if (bStatisticalScatterPercentile){
        if (pollutants$percentile[j]==-999){ #Has to be nested if statements because if bStatisticalScatterPercentile is false, pollutants$percentile doesn't necessarily exist
          cat(paste("   Warning: Skipping period percentile statistical scatter plot for pollutant", toupper(pollutants$pollutant[j]), "because no percentile has been defined in the output averaging times file.\n"))
          next
        }
      }
      
      #to make conditioning work, need "mod" as column and rows corresponding to models
      bDoneFirstModel = FALSE
      for (m in (1:length(modelled.data.names))) {
        temp.data.onemodel=subset(conc.grouped, (pollutant %in% pollutants$pollutant[j]) & model %in% (modelled.data.names[m]))
        if (bPercentiles){
          if (bFilterStnType) {
            temp.data.onemodel=subset(temp.data.onemodel, select=c("station","type","model","station.output.name","pollutant","mod.mean", "obs.mean","mod.max","obs.max","SDM","SDO","obs.AOT40.forests","mod.AOT40.forests","obs.AOT40.crops","mod.AOT40.crops","obs.SOMO35","mod.SOMO35","mod.perc","obs.perc"))
          } else {
            temp.data.onemodel=subset(temp.data.onemodel, select=c("station","model","station.output.name","pollutant","mod.mean", "obs.mean","mod.max","obs.max","SDM","SDO","obs.AOT40.forests","mod.AOT40.forests","obs.AOT40.crops","mod.AOT40.crops","obs.SOMO35","mod.SOMO35","mod.perc","obs.perc"))
          }
        } else{
          if (bFilterStnType) {
            temp.data.onemodel=subset(temp.data.onemodel, select=c("station","type","model","station.output.name","pollutant","mod.mean", "obs.mean","mod.max","obs.max","SDM","SDO","obs.AOT40.forests","mod.AOT40.forests","obs.AOT40.crops","mod.AOT40.crops","obs.SOMO35","mod.SOMO35"))
          } else {
            temp.data.onemodel=subset(temp.data.onemodel, select=c("station","model","station.output.name","pollutant","mod.mean", "obs.mean","mod.max","obs.max","SDM","SDO","obs.AOT40.forests","mod.AOT40.forests","obs.AOT40.crops","mod.AOT40.crops","obs.SOMO35","mod.SOMO35"))
          }
        }
        
        if (bDoneFirstModel) {
          all.data.by.pol = rbind(all.data.by.pol,temp.data.onemodel)
        } else {
          all.data.by.pol = temp.data.onemodel
          bDoneFirstModel = TRUE
        }         
        rm(temp.data.onemodel)
      }
      rm(temp)
      if (!bDoneFirstModel) {
        next
      }
      
      if (bStatisticalScatterMean){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","mod.mean","obs.mean","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","mod","obs","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","mod.mean","obs.mean","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","mod","obs","model")
        }
        text.statistic="Period Mean"
      }
      if (bStatisticalScatterMax){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","mod.max","obs.max","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","mod","obs","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","mod.max","obs.max","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","mod","obs","model")
        }
        text.statistic="Period Maximum"
      }
      if (bStatisticalScatterSD){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","SDM","SDO","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","mod","obs","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","SDM","SDO","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","mod","obs","model")
        }
        text.statistic="Period Standard Deviation"
      }
      if (bStatisticalScatterAOT40.forests){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","obs.AOT40.forests","mod.AOT40.forests","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","obs","mod","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","obs.AOT40.forests","mod.AOT40.forests","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","obs","mod","model")
        }
        text.statistic="Ozone AOT40 Forests (April to September)"
      }
      if (bStatisticalScatterAOT40.crops){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","obs.AOT40.crops","mod.AOT40.crops","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","obs","mod","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","obs.AOT40.crops","mod.AOT40.crops","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","obs","mod","model")
        }
        text.statistic="Ozone AOT40 Crops (May to July)"
      }
      if (bStatisticalScatterSOMO35){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","obs.SOMO35","mod.SOMO35","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","obs","mod","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","obs.SOMO35","mod.SOMO35","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","obs","mod","model")
        }
        text.statistic="Ozone SOMO35"
      }
      if (bStatisticalScatterPercentile){
        if (bFilterStnType){
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","type","pollutant","mod.perc","obs.perc","model"))
          names(all.data.by.pol)=c("station","station.output.name","type","pollutant","mod","obs","model")
        } else {
          all.data.by.pol=subset(all.data.by.pol, select=c("station", "station.output.name","pollutant","mod.perc","obs.perc","model"))
          names(all.data.by.pol)=c("station","station.output.name","pollutant","mod","obs","model")
        }
        text.statistic=paste0("Period ", pollutants$percentile[j],"th Percentile")
      }
      
      #Check that there is valid data to plot and all.data.by.pol exists
      if (!bDoneFirstModel) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }
      
      all.data.by.pol = subset(all.data.by.pol, !is.na(obs) & !is.na(mod) & !is.infinite(obs) & !is.infinite(mod))
      
      #Check that there is data to plot and return an error if not
      if (length(all.data.by.pol$mod)==0) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }   
      
      
      #setup pollutants
      pollutant.units = pollutants$output.units[j] 
      
      
      # R doesn't like the column header "type" as it is also an intrinsic function
      scatter.plot.data = all.data.by.pol
      if (bFilterStnType){
        names(scatter.plot.data)[names(scatter.plot.data)=="type"] = "station.type"   
      }
      if(length(which(scatter.plot.data$station == "all"))!=0){
        scatter.plot.data=scatter.plot.data[-which(scatter.plot.data$station == "all"),]
      }
      
      #Set graphical parameters
      graph.obs = "obs"
      graph.mod = "mod"
      pollutant.max = max(scatter.plot.data$obs,scatter.plot.data$mod,na.rm=TRUE)
      pollutant.min = min(scatter.plot.data$obs,scatter.plot.data$mod,na.rm=TRUE)
      max.val = 1.05*pollutant.max
      min.val = min(0,1.05*pollutant.min)
      if (bStatisticalScatterAOT40.crops | bStatisticalScatterAOT40.forests){
        pol.display = paste(" (",pollutant.units,".h)",sep="")  
      } else if (bStatisticalScatterSOMO35){
        pol.display = paste(" (",pollutant.units,".d)",sep="")  
      } else {
        pol.display = paste(" (",pollutant.units,")",sep="")
      }

      #Determine if outputs are to be filtered by station type
      # setup options related to filtering or not
      if (bAllStationTypes & bFilterStnType) {
        # use scatterPlot's intrinsic filter function to display each station type on one page, one page per pollutant:
        filter.type=c("model")
        # titles
        if (bSelectedNetworks){
          text.stntype.title = "SELECTED NETWORKS"
          text.stntype.filename = "selectednetworks"
        } else {
          text.stntype.title = "ALL STATIONS"
          text.stntype.filename = "allstationsbytype"
        }
      } else {
        # scatterPlot(type = ...)          
        filter.type="model" }
      # titles
      if (bSelectedStationTypes) {
        #Check that there is data to plot and return an error if not
        if (length(scatter.plot.data$mod)==0) {
          cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
          next
        }   
        text.stntype.title = toupper(selected.station.types)
        text.stntype.filename = gsub(",","_",selected.station.types)
        text.stntype.filename = gsub(" ","",text.stntype.filename)
      } else if (bSingleStation) {
        text.stntype.title = subset(unique(all.data.by.pol$station.output.name),unique(all.data.by.pol$station.output.name)!="all")
        text.stntype.filename = text.stntype.title
      } else if (bSelectedStations) {
        text.stntype.title="SELECTED STATIONS"
        text.stntype.filename="selectedstations"
      }else if (bAllStationTypes & !bFilterStnType) {
        text.stntype.title = "ALL STATIONS"
        text.stntype.filename = "allstations"
      }        
      
      if (bSingleNetwork){
        text.stntype.title=paste(text.stntype.title, ", ", toupper(selected.networks.vector), sep="")
        text.stntype.filename=paste(text.stntype.filename,"_",tolower(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        text.stntype.title=paste(text.stntype.title, ", ", paste("SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep=""), sep="")
        text.stntype.filename=paste(text.stntype.filename,"_selcted_nwrks", sep="")
      }
      
      if (bStatisticalScatterSOMO35 | bStatisticalScatterAOT40.crops | bStatisticalScatterAOT40.forests){# remove pollutant section from text filters if SOMO35 is the selected statistic to prevent using quickText on SOMO35
        if (bSingleModel) {
          text.filters = paste(single.model,", ",text.stntype.title,", ",toupper(text.statistic),sep="")
        } else if (bSelectedModels) {
          text.filters = paste("SELECTED MODELS",", ",text.stntype.title,", ",toupper(text.statistic),sep="")
        } else {
          text.filters = paste(text.stntype.title,", ",toupper(text.statistic),sep="")
        }
      } else {
        if (bSingleModel) {
          text.filters = paste(single.model,", ",text.stntype.title,", ",toupper(text.statistic),", ",toupper(pollutants$pollutant[j]),pol.display,sep="")
        } else if (bSelectedModels) {
          text.filters = paste("SELECTED MODELS",", ",text.stntype.title,", ",toupper(text.statistic),", ",toupper(pollutants$pollutant[j]),pol.display,sep="")
        } else {
          text.filters = paste(text.stntype.title,", ",toupper(text.statistic),", ",toupper(pollutants$pollutant[j]),pol.display,sep="")
        } 
      }
      # setup output page
      filename=paste(eval.output.root,"_",pollutants$pollutant[j],"_",text.stntype.filename,"_conc_",scatter.string,"_scatter_plot",sep="")
      filename=substr(filename,1,min(250,nchar(filename)))
      if (bJPG) {
        jpeg(paste(filename,".jpg",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } else if (bPNG) {
        png(paste(filename,".png",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } 
      #title
      titletext=paste(scatter.title.string,toupper(project.name),sep="")  
      if (nchar(titletext)>65) {  # split onto two rows
        titletext=paste(paste(scatter.title.string,toupper(project.name), sep=""))  
      }  
      subtitletext=paste(text.filters)
      
      # Draw statistical scatter plot
      
      stat.scat=ggplot(scatter.plot.data, aes(x=obs, y=mod))+
        theme_bw()+
        xlim(min.val,max.val)+ylim(min.val,max.val)+
        coord_fixed(ratio=1) +
        theme(plot.title = element_text(hjust=0.5), plot.subtitle = element_text(hjust=0.5), plot.caption = element_text(hjust=0.5, face="bold"),legend.text = element_text(size=12), legend.key.size =unit(2,"line"))
      
      if (bStatisticalScatterSOMO35 | bStatisticalScatterAOT40.crops | bStatisticalScatterAOT40.forests){
        pollutant.string=paste(toupper(pollutants$pollutant[j]),pol.display, sep="")
        stat.scat=stat.scat+labs(title=titletext,
                                 subtitle=subtitletext,
                                 x=quickText(paste("Observed", pollutant.string)),
                                 y=quickText(paste("Modelled", pollutant.string)),
                                 color="Station Types",
                                 caption=paste("Date range: ",data.start.date.for.graphs," to ",data.end.date.for.graphs,sep=""))
        
      } else {
        stat.scat=stat.scat+labs(title=titletext,
                                 subtitle=quickText(subtitletext),
                                 x="Observed",
                                 y="Modelled",
                                 color="Station Types",
                                 caption=paste("Date range: ",data.start.date.for.graphs," to ",data.end.date.for.graphs,sep=""))
        
      }
      
      if(bSSstatlabs){
        stat.scat=stat.scat +
          geom_text(label=scatter.plot.data$station.output.name, nudge_x =0, nudge_y = -(max.val/40), size=3, show.legend=FALSE)
      }
      
      #Adding the reference lines if required
      if (bSSreferencelines){
        #Creates equations of the reference lines to be used in the plot caption
        reference.line.equations=c()
        for (a in 1:length(reference.gradients.list)){
          if (reference.gradients.list[a] == 1){
            reference.line.equations=c(reference.line.equations,"y=x")
          } else{
            reference.line.equations=c(reference.line.equations,paste0("y=",reference.gradients.list[a],"x"))
          }
        }
        stat.scat=stat.scat+labs(caption=paste0(stat.scat$labels$caption,"\nReference lines shown: ", paste(reference.line.equations,collapse = ", ")))
        for (a in 1:length(reference.gradients.list)){
          stat.scat=stat.scat+geom_abline(intercept=0, slope=reference.gradients.list[a], colour="black", lty=reference.line.types.list[a])
        }
      }
      
      if(bSSstntypecols & bSSstntypesym ) {
        stat.scat=stat.scat+aes(shape=scatter.plot.data$station.type,  color=scatter.plot.data$station.type)+geom_point(size=statistical.scatter.point.size) +
          labs(shape="Station Types: ",colour="Station Types: ")+guides(shape = guide_legend(override.aes = list(size = 2)))+scale_shape_manual(values=statisticalscatter_shapes[which(types %in% scatter.plot.data$station.type)])+scale_colour_manual(values=statisticalscatter_colours[which(types %in% scatter.plot.data$station.type)])
      } else if(bSSstntypesym){
        stat.scat=stat.scat+aes(shape=scatter.plot.data$station.type)+geom_point(size=statistical.scatter.point.size,color="black")+
          labs(shape="Station Types: ")+scale_shape_manual(values=statisticalscatter_shapes[which(types %in% scatter.plot.data$station.type)])
      } else if(bSSstntypecols) {
        stat.scat=stat.scat+aes(color=scatter.plot.data$station.type)+geom_point(size=statistical.scatter.point.size)+labs(color="Station Types: ")+scale_colour_manual(values=statisticalscatter_colours[which(types %in% scatter.plot.data$station.type)])
      } else {
        stat.scat=stat.scat+geom_point(size=statistical.scatter.point.size, color="black")
      }
      if (bStatisticalScatterAOT40.crops){
        AOT40.pol.units=pollutants$output.units[which(pollutants$ozone=="yes")]
        if (AOT40.pol.units %in% "ug/m3"){
          AOT40.intercept=6000
        } else {
          AOT40.ugm3.ppb=pollutants$conv.ugm3.ppb[which(pollutants$ozone=="yes")]
          AOT40.intercept=convert.units(6000,"ug/m3",AOT40.pol.units,AOT40.ugm3.ppb)
        }
        stat.scat=stat.scat+geom_hline(yintercept = AOT40.intercept, color="red", linetype="dotted", size =0.7)+geom_vline(xintercept=AOT40.intercept, color="red",linetype="dotted", size =0.7)+annotate("text", label=quickText(paste("Limit: ",AOT40.intercept, AOT40.pol.units,".h", sep=" ")), colour="red", x=mean(c(AOT40.intercept,max(scatter.plot.data$obs)))*1.2, y=AOT40.intercept*1.1)
      }
      if (bStatisticalScatterAOT40.forests){
        AOT40.pol.units=pollutants$output.units[which(pollutants$ozone=="yes")]
        if (AOT40.pol.units %in% "ug/m3"){
          AOT40.intercept=20000
        } else {
          AOT40.ugm3.ppb=pollutants$conv.ugm3.ppb[which(pollutants$ozone=="yes")]
          AOT40.intercept=convert.units(20000,"ug/m3",AOT40.pol.units,AOT40.ugm3.ppb)
        }
        stat.scat=stat.scat+geom_hline(yintercept = AOT40.intercept, color="red", linetype="dotted", size =0.7)+geom_vline(xintercept=AOT40.intercept, color="red",linetype="dotted", size =0.7)+annotate("text", label=quickText(paste("Limit: ",AOT40.intercept, AOT40.pol.units,".h", sep=" ")), colour="red", x=mean(c(AOT40.intercept,max(scatter.plot.data$obs)))*1.2, y=AOT40.intercept*1.1)
      }
      
      
      
      #determine legend size based on paper size 
      if (bSSstntypecols | bSSstntypesym){
        if (bPaperLarge){
          stat.scat=stat.scat+theme(legend.key.height = unit(0.7,"cm"), legend.key.width = unit(0.2,"cm"), legend.position="bottom")
        }else if (bPaperMedium){
          stat.scat=stat.scat+theme(legend.key.height = unit(0.5,"cm"), legend.key.width = unit(0.15,"cm"),legend.position="bottom")
        } else {
          stat.scat=stat.scat+theme(legend.key.height = unit(0.4,"cm"), legend.key.width = unit(0.1,"cm"),legend.position="bottom")
          
        }
      }
      
      
      if(bSSstntypegraph){
        stat.scat=stat.scat+facet_grid(~station.type~model)
      }else {
        stat.scat=stat.scat+facet_wrap(filter.type)
      }
      
      plot(stat.scat)
      
      if (bJPG | bPNG) {
        graphics.off()
      }           
      rm(all.data.by.pol, scatter.plot.data,stat.scat)
    } # next pollutant
    
    if (bPDF) {
      graphics.off()
    }
    
  }  # end of statistical scatter plot 
  
  
  # Time Variation Plot output 
  if (bTimeVariation){
    
    #Define title strings to use in file names and the plot.subset.str if not all analysis being output
    #time.variation.string.tile="Time Variation"
    if (bTimeVariationAll){
      time.var.type.string="all"
      if (bPaperLarge) {  
        allowed.station.count=6
      } else if (bPaperMedium) {  
        allowed.station.count=4
      } else if (bPaperSmall) { 
        allowed.station.count=3
      }
    } else if (bTimeVariationday.hour){
      time.var.type.string="hour_day"
      plot.subsetstr="day.hour"
      if (bPaperLarge) {  
        allowed.station.count=10
      } else if (bPaperMedium) {  
        allowed.station.count=8
      } else if (bPaperSmall) { 
        allowed.station.count=6
      }
    } else if (bTimeVariationhour){
      time.var.type.string="hour"
      plot.subsetstr="hour"
      if (bPaperLarge) {  
        allowed.station.count=16
      } else if (bPaperMedium) {  
        allowed.station.count=12
      } else if (bPaperSmall) { 
        allowed.station.count=9
      }
    } else if (bTimeVariationday){
      time.var.type.string="day"
      plot.subsetstr="day"
      if (bPaperLarge) {  
        allowed.station.count=16
      } else if (bPaperMedium) {  
        allowed.station.count=12
      } else if (bPaperSmall) { 
        allowed.station.count=9
      }
    } else if (bTimeVariationmonth){
      time.var.type.string="month"
      plot.subsetstr="month"
      if (bPaperLarge) {  
        allowed.station.count=16
      } else if (bPaperMedium) {  
        allowed.station.count=12
      } else if (bPaperSmall) { 
        allowed.station.count=9
      }
    }
    
    # Write to log tile that time variation plots are being output with the subset 
    if (bPDF) {
      cat(paste("Sending concentration assessment Time Variation Plot (",time.var.type.string,") to pdf...\n", sep=""))
    } else if (bJPG) {
      cat(paste("Sending concentration assessment Time Variation Plot (",time.var.type.string,") to jpg...\n", sep=""))
    } else if (bPNG) {
      cat(paste("Sending concentration assessment Time Variation Plot (",time.var.type.string,") to png...\n", sep=""))
    }
    #Start defining station strings for plot titles 
    if (bSingleStation){ #if single station or selected station 
      text.stn=selected.stations
    } else if (bSelectedStations){
      text.stn="SELECTED STATIONS"
    } else if (bSelectedStationTypes){ #if selected station types 
      text.stn=toupper(paste(selected.station.types.vector, collapse=", "))
    } else {
      text.stn="ALL STATIONS"
    }
    
    if (bSingleNetwork){ #If selected station networks
      text.stn=paste(text.stn, ", ", toupper(selected.networks.vector), sep="")
    } else if (bSelectedNetworks){
      text.stn=paste(text.stn, ", SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")
    }
    #Set plot colours
    if (specify.model.rgb.colours) {
      colours.rgb=data.frame(model=c("obs",modelled.data.names))
      colours.rgb=merge(colours.rgb,model.rgb.colours)
      obs.row.num=which(colours.rgb$model=="obs")
      colours.rgb=rbind(colours.rgb[obs.row.num, ],colours.rgb[-obs.row.num, ]) #Putting obs first
      colours.tv=rgb(red = colours.rgb$red,
                     green = colours.rgb$green,
                     blue = colours.rgb$blue,
                     maxColorValue = 255)
      
    } else {
      colours.tv = "hue"
    }
    # Loop through pollutants as a separate plot page is produced per pollutant 
    for (i in 1:length(pollutants$pollutant)){
      # Subset dataframe to get single pollutant data 
      time.variation.data=subset(all.data.averaged, pollutant %in% pollutants$pollutant[i])
      if (length(time.variation.data$date)==0) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[i], "- skipping this pollutant\n"))
        next}
      # Write to log that the plot is being produced for the pollutant 
      cat(paste("    Producing Time Variation Plot for pollutant: ", toupper(pollutants$pollutant[i]),"...\n", sep=""))
      # If group by model and not filter by station, output a page per pollutant and line per model 
      
      text.filters=paste(text.stn,", ", pollutants$output.avg.time.and.statistic[i]," ", toupper(pollutants$pollutant[i]), sep="")
      
      
      if (bGrpModel & !bFilterStnType){
        # If not having separate plots for grouping (separate plots for models)
        if (!bTimeVariationMultiPlots){
          # Start graphics device and file name with the time variation type and pollutant 
          titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters,sep="")  
          if (nchar(titletext)>65) {  # split onto two rows
            titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""), "\n", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters, sep="")  
          }
          sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
          if (bPDF) {
            pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
          } else if (bJPG) {
            jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
          } else if (bPNG) {
            png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
          }
          #Create time variation plot 
          time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
          # Plot the time variation plot and subset if requried e.g. to give just day of the week 
          if (bTimeVariationAll){
            plot(plot(time.variation.plot)+
              patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                plot.caption = ggplot2::element_text(hjust=0.5))))
          } else {
            plot(time.variation.plot, subset=plot.subsetstr)
          }
          graphics.off()
        } else { # create a separate page per model
          for (m in 1:length(modelled.data.names)){
            if (bPDF) {
              pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".pdf", sep=""),paper="a4",width=0,height=0,title=pdf.title)
            } else if (bJPG) {
              jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            } else if (bPNG) {
              png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            }
            titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(modelled.data.names[m]),", ", text.filters,sep="")  
            if (nchar(titletext)>65) {  # split onto two rows
              titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""), "\n", toupper(modelled.data.names[m]),", ", text.filters, sep="")  
            }
            sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
            time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
            if (bTimeVariationAll){
              plot(plot(time.variation.plot)+
                patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                  plot.caption = ggplot2::element_text(hjust=0.5))))
            } else {
              plot(time.variation.plot, subset=plot.subsetstr)
            }
            graphics.off()
          } # end of loop over models 
        } # End of if grp by model and not filter by stn type
      } else if (bGrpModel & bFilterStnType) {
        if (!bTimeVariationMultiPlots){
          if (bPDF) {
            pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
          } else if (bJPG) {
            jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
          } else if (bPNG) {
            png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
          }
          titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters,sep="")  
          if (nchar(titletext)>65) {  # split onto two rows
            titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""), "\n", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters, sep="")  
          }
          sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
          time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), type="type", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
          if (bTimeVariationAll){
            plot(plot(time.variation.plot)+
              patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                plot.caption = ggplot2::element_text(hjust=0.5))))
          } else {
            plot(time.variation.plot, subset=plot.subsetstr)
          }
          graphics.off()
        } else if (bTimeVariationMultiPlots) { # create a separate page per model
          for (m in 1:length(modelled.data.names)){
            if (bPDF) {
              pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".pdf", sep=""),paper="a4",width=0,height=0,title=pdf.title)
            } else if (bJPG) {
              jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            } else if (bPNG) {
              png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            }
            titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(modelled.data.names[m]),", ", text.filters,sep="")  
            if (nchar(titletext)>65) {  # split onto two rows
              titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""), "\n", toupper(modelled.data.names[m]),", ", text.filters, sep="")  
            }
            sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
            time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), type="type", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
            if (bTimeVariationAll){
              plot(plot(time.variation.plot)+
                patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                  plot.caption = ggplot2::element_text(hjust=0.5))))
            } else {
              plot(time.variation.plot, subset=plot.subsetstr)
            }
            graphics.off()
          } # end of loop over models 
        } # End of if grp by model and not filter by stn type
      } else if (bGrpStnName & !bFilterStnType) { #If group by station and not filter by station type 
        station.count=length(unique(time.variation.data$station))
        if (!bTimeVariationMultiPlots){
          # Check how many stations there are and if it exceeds the allowed station count 
          if (station.count > allowed.station.count){
            cat("   Warning: Time Variation plot for pollutant: ", pollutants$pollutant[i], " cannot be output as there are too many stations (",station.count,") for the plot. \n Either subset the dataset, select a different grouping and filtering option, or use the Model Diagnostics Tool.\n")
          } else {
            # Set tile text and sub title text 
            titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters,sep="")  
            if (nchar(titletext)>65) {  # split onto two rows
              titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""), "\n", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters, sep="")  
            }
            sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
            if (!bSingleStation){
              time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), type="station", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv,plot=FALSE)
            } else {
              time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv,plot=FALSE)
            }
            if (bPDF) {
              pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
            } else if (bJPG) {
              jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            } else if (bPNG) {
              png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
            } 
            if (bTimeVariationAll){
              plot(plot(time.variation.plot) +
                     patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                       plot.caption = ggplot2::element_text(hjust=0.5))))
            } else {
              plot(time.variation.plot, subset=plot.subsetstr)
            }
            graphics.off()
          }
        } else if (bTimeVariationMultiPlots){ # produce a separate page per model
          if (station.count > allowed.station.count){
            cat("   Warning: Time Variation plot for pollutant: ", pollutants$pollutant[i], " cannot be output as there are too many stations (",station.count,") for the plot. \n Either subset the dataset, select a different grouping and filtering option, or use the Model Diagnostics Tool.\n")
          } else {
            #loop through modelled.data.names
            for (m in 1:length(modelled.data.names)){ 
              # Set tile text and sub title text 
              titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(modelled.data.names[m]),", ", text.filters,sep="")  
              if (nchar(titletext)>65) {  # split onto two rows
                titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""),"\n", toupper(modelled.data.names[m]),", ", text.filters, sep="")  
              }
              sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
              if (!bSingleStation){
                time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), type="station", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
              } else {
                time.variation.plot=timeVariation(time.variation.data, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
              }
              if (bPDF) {
                pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", modelled.data.names[m],".pdf", sep=""),paper="a4",width=0,height=0,title=pdf.title)
              } else if (bJPG) {
                jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", modelled.data.names[m],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
              } else if (bPNG) {
                png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", modelled.data.names[m],".png",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
              } 
              if (bTimeVariationAll){
                plot(plot(time.variation.plot)+
                  patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                    plot.caption = ggplot2::element_text(hjust=0.5))))
              } else {
                plot(time.variation.plot, subset=plot.subsetstr)
              }
              graphics.off()
            }
          }
        }
      } else if (bGrpStnName & bFilterStnType) {
        # Get list of all station types 
        station.types=unique(time.variation.data$type)
        #Loop through station types and produce an output page per station type
        for (s in 1:length(station.types)){
          time.variation.data.stn.type=subset(time.variation.data, type %in% station.types[s])
          station.count=length(unique(time.variation.data.stn.type$station))
          if (station.count > allowed.station.count){
            cat("   Warning: Time Variation plot for pollutant: ", pollutants$pollutant[i], "and station type ", station.types[s]," cannot be output as there are too many stations (",station.count,") for the plot. \n Either subset the dataset, select a different grouping and filtering option, or use the Model Diagnostics Tool.\n")
          } else {
            if (!bTimeVariationMultiPlots){
              # Set tile text and sub title text 
              titletext=paste("Time Variation Plot: ",toupper(project.name),", ", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters,sep="")  
              if (nchar(titletext)>65) {  # split onto two rows
                titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""),"\n", toupper(paste(modelled.data.names, collapse=", ")),", ", text.filters, sep="")  
              }
              sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
              if (!bSingleStation){
                time.variation.plot=timeVariation(time.variation.data.stn.type, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), type="station", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
              } else {
                time.variation.plot=timeVariation(time.variation.data.stn.type, pollutant=c("obs",modelled.data.names), name.pol=c("Observed", modelled.data.names), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
              }
              if (bPDF) {
                pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", station.types[s],".pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
              } else if (bJPG) {
                jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", station.types[s],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
              } else if (bPNG) {
                png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_", station.types[s],".png",sep=""),width=paper.height*1.15,,height=paper.width,units="mm",res=300)
              } 
              if (bTimeVariationAll){
                plot(plot(time.variation.plot)+
                  patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                    plot.caption = ggplot2::element_text(hjust=0.5))))
              } else {
                plot(time.variation.plot, subset=plot.subsetstr)
              }
              graphics.off()
            } else if (bTimeVariationMultiPlots){
              for (m in 1:length(modelled.data.names)){
                # Set tile text and sub title text 
                titletext=paste("Time Variation Plot: ",toupper(project.name),", ",toupper(modelled.data.names[m]),", ", text.filters,sep="") 
                if (nchar(titletext)>65) {  # split onto two rows
                  titletext=paste(paste("Time Variation Plot: ",toupper(project.name), sep=""),"\n", toupper(modelled.data.names[m]),", ", text.filters, sep="")  
                }
                sub.title.text=paste("Date range from: ", data.start.date.for.graphs, " to: ", data.end.date.for.graphs,".", sep="")
                if (!bSingleStation){
                  time.variation.plot=timeVariation(time.variation.data.stn.type, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), type="station", title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
                } else {
                  time.variation.plot=timeVariation(time.variation.data.stn.type, pollutant=c("obs",modelled.data.names[m]), name.pol=c("Observed", modelled.data.names[m]), title=titletext, caption=sub.title.text, ylab=paste(toupper(pollutants$pollutant[i]), " (", pollutants$output.units[i],") ", sep=""),cols=colours.tv)
                }
                if (bPDF) {
                  pdf(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],"_", station.types[s],".pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
                } else if (bJPG) {
                  jpeg(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],"_", station.types[s],".jpg",sep=""),width=paper.height*1.15,height=paper.width,units="mm",res=300)
                } else if (bPNG) {
                  png(paste(eval.output.root,"_conc_time_var_",time.var.type.string,"_",pollutants$pollutant[i],"_",modelled.data.names[m],"_", station.types[s],".png",sep=""),width=paper.height*1.15,,height=paper.width,units="mm",res=300)
                } 
                if (bTimeVariationAll){
                  plot(plot(time.variation.plot)+
                    patchwork::plot_annotation(theme = ggplot2::theme(plot.title = ggplot2::element_text(hjust=0.5),
                                                                      plot.caption = ggplot2::element_text(hjust=0.5))))
                } else {
                  plot(time.variation.plot, subset=plot.subsetstr)
                }
                graphics.off()
              } # End of loop over models
            } # end of if multiple plots required 
          } # end of producing plots if station count does not exceed allowed station count
        }# end of loop over station types 
      }
    }    
    
    
  } # End of time variation plot 
  
  
  #Quantile-Quantile
  if (bQQPlot) {  
    if (bPDF) {
      cat("Sending concentration assessment quantile-quantile plots to pdf...\n")
      pdf(paste(eval.output.root,"_conc_qq_plot.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
    } else if (bJPG) {
      cat("Sending concentration assessment quantile-quantile plots to jpg...\n")
    } else if (bPNG) {
      cat("Sending concentration assessment quantile-quantile plots to png...\n")
    }
    
    #Loop over pollutants
    for (j in 1:length(pollutants$pollutant)) {
      #to make conditioning work, need "mod" as column and rows corresponding to models
      bDoneFirstModel = FALSE
      for (m in (1:length(modelled.data.names))) {
        temp.data.onemodel = subset(all.data.averaged, pollutant==pollutants$pollutant[j], select=c("date","obs","station","station.output.name","type","pollutant","output.avg.time.and.statistic",modelled.data.names[m]))      
        names(temp.data.onemodel)[names(temp.data.onemodel)==modelled.data.names[m]] = "mod"
        if (length(temp.data.onemodel$mod)==0) {
          next
        }
        temp.data.onemodel$model = modelled.data.names[m]
        if (bDoneFirstModel) {
          all.data.by.pol = rbind(all.data.by.pol,temp.data.onemodel)
        } else {
          all.data.by.pol = temp.data.onemodel
          bDoneFirstModel = TRUE
        }         
        rm(temp.data.onemodel)
      }
      rm(temp)  
      #Check that there is valid data to plot and all.data.by.pol exists
      if (!bDoneFirstModel) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      } 
      
      all.data.by.pol = subset(all.data.by.pol, !is.na(obs) & !is.na(mod) & !is.infinite(obs) & !is.infinite(mod))
      
      #Check that there is data to plot and return an error if not
      if (length(all.data.by.pol$pollutant)==0) {
        cat(paste("   Warning: no data to graph for pollutant", pollutants$pollutant[j], "- skipping this pollutant\n"))
        next
      }   
      
      #can't plot zeros on log scale
      if (bQQLog & any(all.data.by.pol[,c("obs","mod")] == 0)) {
        all.data.by.pol = subset(all.data.by.pol, (obs!=0 & mod!=0))
      }
      
      #setup pollutants
      pollutant.units = pollutants$output.units[j]         
      
      #Set graphical parameters
      graph.obs = "obs"
      graph.mod = "mod"
      pollutant.max = max(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
      pollutant.min = min(all.data.by.pol$obs,all.data.by.pol$mod,na.rm=TRUE)
      if (bQQLog) {
        max.val = 10^ceiling(log10(1.05*pollutant.max))
        min.val = 10^floor(log10(0.95*pollutant.min))
      } else {   
        # define linear axis extent and labels
        max.val = 1.05*pollutant.max
        min.val = min(0,1.05*pollutant.min) 
      }   
      pol.display = paste(" (",pollutant.units,")",sep="")
      graph.z=NA
      QQ.cols="black"
      QQ.pch=16
      scatter.cex=NULL
      scatter.mod.line=TRUE
      QQ.method="scatter"
      filter.type="default"
      
      # sort QQ data (obs, mod) separately
      # has to be filtered by model, so needs ordering within 'model' group
      # if filtered by type, each subset needs ordering within the 'type' of station
      bDoneFirstModel = FALSE
      for (m in 1:length(modelled.data.names)) {
        data.bymodel = subset(all.data.by.pol, model==unique(all.data.by.pol$model)[m]) 
        if (bAllStationTypes & bFilterStnType) {
          for (k in 1:length(unique(all.data.by.pol$type))) {
            data.bytype = subset(data.bymodel, type==unique(all.data.by.pol$type)[k], select=c("obs","mod","type","model"))
            # sort QQ data (obs, mod) separately within each 'type'
            data.bytype$obs = sort(data.bytype$obs, decreasing = TRUE)
            data.bytype$mod = sort(data.bytype$mod, decreasing = TRUE)              
            if (k == 1) {
              qq.plot.data.bymodel = data.bytype
            } else {
              qq.plot.data.bymodel = rbind(qq.plot.data.bymodel, data.bytype)
            }              
          }
        } else {
          # sort QQ data (obs, mod) separately within each 'type'
          qq.plot.data.bymodel = data.bymodel[ , c("obs", "mod", "model")]
          qq.plot.data.bymodel$obs = sort(data.bymodel$obs, decreasing = TRUE)
          qq.plot.data.bymodel$mod = sort(data.bymodel$mod, decreasing = TRUE)              
        }                
        if (!bDoneFirstModel) {
          qq.plot.data = qq.plot.data.bymodel
          bDoneFirstModel = TRUE
        } else {
          qq.plot.data = rbind(qq.plot.data, qq.plot.data.bymodel)            
        } 
        rm(qq.plot.data.bymodel, data.bymodel, data.bytype)           
      }         
      # setup plot options:         
      if (bAllStationTypes & bFilterStnType) {
        # use scatterPlot's intrinsic filter function to display each station type on one page, one page per pollutant.
        # R doesn't like the column header "type" as it is also an intrinsic function
        names(qq.plot.data)[names(qq.plot.data)=="type"] = "station.type"
        filter.type=c("model","station.type")
        # titles
        if(bSingleStation) {
          text.stntype.title=toupper(single.station)
          text.stntype.filename=text.stntype.title
        } else if (bSelectedStations) {
          text.stntype.title="SELECTED STATIONS"
          text.stntype.filename="selectedstationsbytype"
        } else if (bSelectedNetworks){
          text.stntype.title=selected.networks
          text.stntype.filename="selectednetworksbytype"
        } else{
          text.stntype.title = "ALL STATIONS"
          text.stntype.filename = "allstationsbytype"
        }
      } else {
        filter.type="model" 
        # titles
        if (bSelectedStationTypes) {
          text.stntype.title = toupper(paste(selected.station.types.vector, collapse=", "))
          text.stntype.filename = tolower(gsub(",","_",selected.station.types))
        } else if (bSingleStation) {
          text.stntype.title = unique(all.data.by.pol$station.output.name)
          text.stntype.filename = text.stntype.title
        } else if (bSelectedStations) {
          text.stntype.title ="SELECTED STATIONS"
          text.stntype.filename="selectedstations"
        } else if (bAllStationTypes & !bFilterStnType) {
          text.stntype.title = "ALL STATIONS"
          text.stntype.filename = "allstations"
        } 
      }
      if (bSingleNetwork){
        text.stntype.title=paste(text.stntype.title, ", ", toupper(selected.networks.vector), sep="")
        text.stntype.filename=paste(text.stntype.filename,"_",tolower(selected.networks.vector), sep="")
      } else if (bSelectedNetworks){
        text.stntype.title=paste(text.stntype.title, ", ",paste("SELECTED NETWORKS (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep=""), sep="")
        stntype.filename=paste(text.stntype.filename,"_selcted_nwrks", sep="")
      }
      
      if (bSingleModel) {
        text.filters = paste(single.model,", ",text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),pol.display,sep="")
      } else {
        text.filters = paste(text.stntype.title,", ",toupper(pollutants$output.avg.time.and.statistic[j])," ",toupper(pollutants$pollutant[j]),pol.display,sep="")
      }
      # setup output page
      if (bJPG) {
        jpeg(paste(eval.output.root,"_",pollutants$pollutant[j],"_",text.stntype.filename,"_conc_qq_plot.jpg",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } else if (bPNG) {
        png(paste(eval.output.root,"_",pollutants$pollutant[j],"_",text.stntype.filename,"_conc_qq_plot.png",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
      } 
      #title
      titletext=paste("Quantile-Quantile Plot: ",toupper(project.name),", ",text.filters,sep="")  
      if (nchar(titletext)>65) {  # split onto two rows
        titletext=paste(paste("Quantile-Quantile Plot: ",toupper(project.name), sep=""), text.filters, sep="\n")  
      }
      
      # Draw quantile-quantile plot
      plot_object = suppressMessages(scatterPlot(
        qq.plot.data,
        x = graph.obs,y = graph.mod,z = graph.z,
        log.x = bQQLog,log.y = bQQLog,
        xlab = "Observed",ylab = "Modelled",
        cols = QQ.cols,pch = QQ.pch,cex = scatter.cex,
        type = filter.type,
        method = QQ.method,
        mod.line = scatter.mod.line,
        main = titletext,
        sub = paste("Date range: ", data.start.date.for.graphs, " to ", data.end.date.for.graphs, sep = ""),
        plot = FALSE           # return ggplot object, do not auto-plot
      )$plot +                 # extract the ggplot object and add to it
        coord_fixed(ratio = 1))
      
      # Set the axes based on the value of bQQLog
      if (bQQLog) {
        plot_object = suppressMessages(plot_object + 
                                         scale_x_log10(limits = c(min.val, max.val)) + 
                                         scale_y_log10(limits = c(min.val, max.val)))
      } else {
        plot_object = suppressMessages(plot_object + 
                                         scale_x_continuous(limits = c(min.val, max.val)) + 
                                         scale_y_continuous(limits = c(min.val, max.val)))
      }
      
      suppressMessages(plot(plot_object))        # render the plot

      if (bJPG | bPNG) {
        graphics.off()
      }           
      
      rm(all.data.by.pol, qq.plot.data)   
    } # next pollutant
    
    if (bPDF) {
      graphics.off()
    }
    
  }  # endif bQQPlot
  
  if (bTimePlot) {
    # note "height" is longest length, for square output use "width" twice
    if (bPaperLarge) {  
      paper.height = 297
      paper.width = 210
    } else if (bPaperMedium) {
      paper.height = round(170*sqrt(2))
      paper.width = 170   
    } else if (bPaperSmall) { # set square plots to width*width, mimics A6 but a bit smaller
      paper.height = round(125*sqrt(2))
      paper.width = 125   
    }
    if (bPDF) {
      cat("Sending concentration assessment time plots to pdf...\n")
      pdf(paste(eval.output.root,"_conc_timeplot.pdf",sep=""),paper="a4",width=0,height=0,title=pdf.title)
    } else if (bJPG) {
      cat("Sending concentration assessment time plots to jpg...\n")
      jpeg(paste(eval.output.root,"_conc_timeplot.jpg",sep=""),width=300,height=150,units="mm",res=300)
    } else if (bPNG) {
      cat("Sending concentration assessment time plots to png...\n")
      png(paste(eval.output.root,"_conc_timeplot.png",sep=""),width=paper.width,height=paper.width,units="mm",res=300)
    }
    
    timeplot.data = aggregate(subset(all.data.averaged,select=c("obs",modelled.data.names)),
                              list(pollutant=all.data.averaged$pollutant,date=all.data.averaged$date),
                              FUN=mean,na.rm=TRUE)
    all.data.averaged.na.omit = na.omit(all.data.averaged)
    stations.per.datapoint = aggregate(subset(all.data.averaged.na.omit,select=c("obs",modelled.data.names)),
                                       list(pollutant=all.data.averaged.na.omit$pollutant,date=all.data.averaged.na.omit$date),
                                       FUN=length)
    names(stations.per.datapoint)[names(stations.per.datapoint)=="obs"]="num.obs.stations"
    for (i in 1:length(modelled.data.names)) {
      names(stations.per.datapoint)[names(stations.per.datapoint)==modelled.data.names[i]] = paste("num.",modelled.data.names[i],".stations",sep="")
    }         
    timeplot.data.plot = merge(timeplot.data,stations.per.datapoint,by=c("date","pollutant"))
    timeplot.data.csv = timeplot.data.plot
    if (bSelectedStationTypes | bSelectedNetworks){
      if (bSelectedStationTypes & bSelectedNetworks){
        if (bSingleNetwork){
          titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over ",selected.station.types, " stations for network: ", toupper(selected.networks), sep="") 
        } else if (bSelectedNetworks){
          titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over ",selected.station.types, " stations for selected networks (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")   
        }
      } else if (!bSelectedStationTypes & bSelectedNetworks){
        if (bSingleNetwork){
          titletext=paste("Time Plot: ",toupper(project.name)," Average concentration for network: ", toupper(selected.networks), sep="") 
        } else if (bSelectedNetworks){
          titletext=paste("Time Plot: ",toupper(project.name)," Average concentration for selected networks (", toupper(paste(selected.networks.vector, collapse=", ")), ")", sep="")   
        }
      } else if (bSelectedStationTypes & !bSelectedNetworks){
        titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over ",selected.station.types, " stations ",sep="")  
      }
    } else if (bAllStations){
      titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over all stations",sep="")  
    } else if (bSelectedStations) {
      if (bSingleStation) {
        titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over station: ",toupper(selected.stations.vector),sep="")
      } else { 
        titletext=paste("Time Plot: ",toupper(project.name)," Average concentration over selected stations",sep="")   
      }
    }
    
    if (bAssessForecasts){
      # Finding the alert threshold levels columns in 'pollutants'
      threshold.names=thresholds$name
      threshold.levels=paste0("i",thresholds$index)
      pollutant.indices = c()
      for (i in 1:length(threshold.levels)){
        pollutant.indices=c(pollutant.indices,which(colnames(pollutants)==threshold.levels[i]))
      }
      #Adding columns to timeplot.data.csv for each alert level
      timeplot.data.csv[ ,threshold.names]=NA
      timeplot.data.plot[ ,threshold.names]=NA
      
      for (i in 1:length(pollutants$pollutant)) {
        if (pollutants$pollutant[i]=="nox") {next}
        if (any(is.na(pollutants[i, pollutant.indices]))) {next} # No index information 
        conc.values=subset(timeplot.data.plot,select=c("obs",modelled.data.names),pollutant==pollutants$pollutant[i])
        max.conc=range(conc.values)[2]
        for (j in 1:length(threshold.names)){
          current.threshold = pollutants[i, pollutant.indices[j]]
          if (current.threshold < 1.5*max.conc) {
            timeplot.data.plot[[threshold.names[j]]][timeplot.data.plot$pollutant==pollutants$pollutant[i]]=current.threshold
          }
          timeplot.data.csv[[threshold.names[j]]][timeplot.data.csv$pollutant==pollutants$pollutant[i]]=current.threshold
        }
      }
    }
    
    pollutants.list = c(modelled.data.names,"obs","num.obs.stations")
    cols.list = c(rainbow(length(modelled.data.names),start=2/6,v=0.7),"black","slategrey")
    names.list = c(modelled.data.names,"Observed","Number of monitoring stations available")
    lty.list = c(rep(1,times=length(modelled.data.names)),1,1)
    lwd.list = c(rep(1,times=length(modelled.data.names)),1,0.5)
    
    if (bAssessForecasts){
      threshold.cols.function=colorRampPalette(c("yellow","darkorange","red","purple"))
      threshold.cols=threshold.cols.function(length(threshold.names)) # Colours to be used for threshold lines on time plot
      for (k in 1:length(threshold.names)){
        if(!all(is.na(timeplot.data.plot[threshold.names[k]]))){
          pollutants.list = c(pollutants.list,threshold.names[k])
          cols.list = c(cols.list,threshold.cols[k])
          names.list = c(names.list,threshold.names[k])
          lty.list = c(lty.list,2)
          lwd.list = c(lwd.list,1.5)
        }
      }
    }
    if (length(pollutants$pollutant)==4) {
      plot.layout=c(2,2)
    } else {
      plot.layout=NULL
    }
    names(timeplot.data.plot)[which(names(timeplot.data.plot)=="pollutant")]="pollutant.new" # Renaming the pollutant column to pollutant.new so the plots display correctly.
    if (length(unique(timeplot.data.plot$date)) == 1){
      cat("   Insufficient data to produce a time plot. Time plot will be empty.\n")
    } else{
      plot_object = timePlot(
        timeplot.data.plot,
        pollutant=pollutants.list,
        group=TRUE,
        type="pollutant.new",
        key=TRUE,
        y.relation="free",
        cols=cols.list,
        name.pol=names.list,
        lty=lty.list,
        lwd=lwd.list,
        date.format="%d/%m",
        layout=plot.layout,
        key.columns=2,
        main=titletext,
        sub=paste("Date range: ", data.start.date.for.graphs, " to ", data.end.date.for.graphs, sep = ""),
        plot=FALSE
      )$plot
      plot(plot_object)
    }
    graphics.off()
    #Output data to csv
    if (boutput.stats.as.csv) {
      cat("Exporting aggregated time series to CSV...\n")
      write.csv(timeplot.data.csv,file=paste(eval.output.root,"_conc_agg.csv",sep=""),row.names = FALSE)
    }
    
  }
} #End of concentration assessment graphics

#The openair rollingMean() function seems to generate a spurious 'Rplots.pdf' file, so remove it here
if (file.exists("Rplots.pdf")) {bDum = file.remove("Rplots.pdf")}

cat("\nFinished!\n")

