CASA00025 Group Project: Assessing the Impact of the Photovoltaic Heat Island Effect on Fish Farms in Southeastern Taiwan
Project Summary
Problem Statement
Although renewable energy is crucial to meeting Taiwan’s Net Zero goals, land availability represents a critical constraint (Hsiao et al., 2021). To address this, the Taiwanese government has implemented an aquavoltaics programme, integrating solar panels into fish farms to generate space-efficient energy. However, this initiative faces significant local opposition, with some authorities even banning construction due to concerns about socio-environmental impacts such as temperature increases, known as the photovoltaic heat island effect (PHVI). There is a clear need for an accessible, evidence-based participatory planning tool to resolve local conflicts, overcome planning roadblocks, and provide a foundation for informed programme expansion.
End User
This application serves as a participatory planning tool to overcome conflicts between stakeholders with opposing perspectives on aquavoltaic expansion. It does this by openly exploring the PHVI impacts of past and prospective solar sites. Alongside expediting planning, helping mitigate local impacts, and supporting Taiwan’s Net Zero goals, additional benefits for each stakeholder are outlined below:
- National government: communicating policy; mitigating unfounded concerns.
- Local government: evidence-based assessment of past projects; informed future planning within jurisdictions.
- Fish farmers: presenting a case for having solar panels installed on their sites.
- Local residents: reducing concerns; empowering them to challenge decisions at higher governance levels.
Data
Landsat 8 Collection 1 Top of Atmosphere imagery at 30m resolution for temperature change assessments.
Sentinel-2 imagery at 10m resolution for fish farm identification.
Solar panel polygons and construction dates from the Taiwanese Civil Service..
Population estimates at 30m resolution from Meta’s Data for Good
Digital elevation data at 30m resolution from the NASA Shuttle Radar Topography Mission.
Methodology
First, land surface temperature (LST) before and after solar panel installation is calculated using the method detailed by Xu et al., (2024), which averages satellite images for a period of three years before and after construction of the solar panel. A random forest model uses these changes, alongside optical and thermal imagery, slope, and elevation to predict temperature impacts at other sites. A second random forest identifies fish farms, ensuring users can only select prospective sites which could be included in the programme. Finally, predicted temperature change and local population estimates indicate the impact of developments on local communities.
Interface
The application provides a transparent, accessible tool to foster communication, improve collaboration, and bring clarity to a contentious issue. The interface is divided into two: the Explore tab provides a broad overview of PHVI impacts through summary statistics and charts, while the Predict tab enables site-specific predictions, allowing users to tailor insights to their local context. Users can click on solar farms to receive a summary at any point, either supplementing the overview or facilitating comparisons with prospective sites. A blue-to-red colour scheme intuitively communicates temperature changes, and clear chart titles and disclaimers ensure the analysis is understandable and transparent.
The Application
How it Works
Analysis
Data Processing
First, the data collection is filtered with a 25% cloud cover threshold and mean pixel values are calculated for a period of 3 years pre- and post-construction of the solar panel. LST is calculated using the following equation: LST = (BT / (1 + (0.00115 * (BT / 1.4388)) * Ln(ε))) found in the USGS handbook (2019). Other indices like NDVI and NDBI are also calculated in this step for the prediction model.
// ------ LST Calculations ------
function getLST(geom, start, end) {
var collection = ee.ImageCollection('LANDSAT/LC08/C02/T1_TOA')
.filterBounds(geom)
.filterDate(start, end)
.filter(ee.Filter.lt('CLOUD_COVER',25));
var lstCollection = collection.map(function(img) {
var ndvi = img.normalizedDifference(['B5', 'B4']).rename('NDVI');
var fv = ndvi.subtract(0).divide(1 - 0).rename('FV');
var em = fv.multiply(0.004).add(0.986).rename('EM');
var thermal = img.select('B10');
var lst = thermal.expression(
'(Tb / (1 + (0.00115 * (Tb / 1.438)) * log(Ep))) - 273.15',
{'Tb': thermal,
'Ep': em
}.rename('LST');
)
//Extra variables for the random forest: optical bands, thermal bands, NDBI, and NDVI, FV, EM
var optical = img.select(['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7']);
var thermalBands = img.select(['B10', 'B11']);
var ndbi = img.normalizedDifference(['B6', 'B5']).rename('NDBI');
return lst.addBands([ndvi, fv, em, ndbi]).addBands(optical).addBands(thermalBands).copyProperties(img, img.propertyNames());
;
})
var mean = ee.Image(lstCollection.mean());
var bands = mean.bandNames();
var hasLST = bands.contains('LST');
return ee.Algorithms.If(hasLST, mean.clip(geom), ee.Image().rename('LST').clip(geom));
}
// Calculate LST for all polygons
function calculateLST(feature) {
var dateString = ee.String(feature.get('dateright'));
var parts = dateString.split('-');
var year = ee.Number.parse(parts.get(0));
var month = ee.Number.parse(parts.get(1));
var day = ee.Number.parse(parts.get(2));
var constructDate = ee.Date.fromYMD(year, month, day);
var preStart = constructDate.advance(-3, 'year');
var preEnd = constructDate;
var postStart = constructDate.advance(1, 'year');
var postEnd = constructDate.advance(4, 'year');
var geom = feature.geometry();
var preImage = ee.Image(getLST(geom, preStart, preEnd));
var postImage = ee.Image(getLST(geom, postStart, postEnd));
var diff = postImage.select('LST').subtract(preImage.select('LST')).rename('LST_Difference');
// Reducers for LST calculations
var meanPreLSTDict = preImage.select('LST').reduceRegion({reducer: ee.Reducer.mean(), geometry: geom, scale: 30, maxPixels: 1e13});
var meanPostLSTDict = postImage.select('LST').reduceRegion({reducer: ee.Reducer.mean(), geometry: geom, scale: 30, maxPixels: 1e13});
var meanDiffDict = diff.reduceRegion({reducer: ee.Reducer.mean(), geometry: geom, scale: 30, maxPixels: 1e13});
// Reducers for other indices for RF
var preOpticalDict = preImage.select(['NDVI', 'FV', 'EM', 'NDBI', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B10', 'B11']).reduceRegion({
reducer: ee.Reducer.mean(),
geometry: geom,
scale: 30,
maxPixels: 1e13
;
})
var meanPreLST = ee.Algorithms.If(meanPreLSTDict.contains('LST'), meanPreLSTDict.get('LST'), null);
var meanPostLST = ee.Algorithms.If(meanPostLSTDict.contains('LST'), meanPostLSTDict.get('LST'), null);
var meanDiff = ee.Algorithms.If(meanDiffDict.contains('LST_Difference'), meanDiffDict.get('LST_Difference'), null);
return feature.set({
'mean_preLST': meanPreLST,
'mean_postLST': meanPostLST,
'mean_LST_diff': meanDiff
.set(preOpticalDict).setGeometry(feature.geometry());
})
}var results = polygons.map(calculateLST);
Given local government and resident concerns about PHVI’s impact on surrounding communities, we define a function called popBuffer to sum estimated populations within 730 metres of solar farms: a distance typically affected by PHVI (Guoqing et al., 2021).
//Load population from Data For Good
var HRSL_total = ee.ImageCollection('projects/sat-io/open-datasets/hrsl/hrslpop').filterBounds(taiwan).median();
//Vulnerable population: sum of 0-5 and 60+
var HRSL_0_5 = ee.ImageCollection("projects/sat-io/open-datasets/hrsl/hrsl_children_under_five").filterBounds(taiwan).median();
var HRSL_60plus = ee.ImageCollection("projects/sat-io/open-datasets/hrsl/hrsl_elderly_over_sixty").filterBounds(taiwan).median();
var HRSL_vulnerable = HRSL_0_5.add(HRSL_60plus).rename('HRSL_vulnerable');
//Calculate population within 730m buffer
function popBuffer(panel) {
var geom = panel.geometry().buffer(730);
var totalPop = ee.Number(HRSL_total.reduceRegion({reducer: ee.Reducer.sum(), geometry: geom, scale: 30, maxPixels: 1e13}).get('b1')).round();
var vulnerablePop = ee.Number(HRSL_vulnerable.reduceRegion({reducer: ee.Reducer.sum(), geometry: geom, scale: 30, maxPixels: 1e13}).get('HRSL_vulnerable')).round();
return panel.set({'total_buffer_pop': totalPop, 'vulnerable_buffer_pop': vulnerablePop});
}var all_results = results.map(popBuffer);
Finally, due to their impact on LST (Šafanda, 1999), we load slope and elevation data and reduce these to the means for each solar polygon. We also calculate polygon areas in hectares.
//Add extra non-Landsat features: elevation, topography, and polygon area
var srtm = ee.Image('USGS/SRTMGL1_003').clip(taiwan);
var elevation = srtm.select('elevation');
var slope = ee.Terrain.slope(srtm);
var allFeatures = validFeatures.map(function(feature) {
var geom = feature.geometry();
var meanElevation = elevation.reduceRegion({reducer: ee.Reducer.mean(), geometry: geom, scale: 30, maxPixels: 1e13}).get('elevation');
var meanSlope = slope.reduceRegion({reducer: ee.Reducer.mean(), geometry: geom, scale: 30, maxPixels: 1e13}).get('slope');
var area = geom.area().divide(10000); //converting to ha as metres were overwhelming the model
return feature.set({
'elevation': meanElevation,
'slope': meanSlope,
'area': area});
; })
Modelling
After filtering all polygons to ensure they contain the necessary data, variables were extracted to conduct principal component analysis in Python to reduce dimensionality and prevent multicollinearity. The resultant random forest model is trained on 70% of the polygons, and has an R^2 of 0.79. It has a low RMSE and MAE relative to average temperature change, making it suitable for predicting changes in new sites.
//Extract training data
var bands = test.select(['mean_preLST', 'mean_postLST', 'mean_LST_diff', 'NDVI', 'NDBI', 'FV', 'EM', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B10', 'B11', 'elevation', 'slope', 'area'])
.randomColumn();
//Define test-train split
var split=0.7
var training_sample = bands.filter(ee.Filter.lt('random', split));
var validation_sample = bands.filter(ee.Filter.gte('random', split));
print('Sample training feature:', training_sample.first())
//Set up RF
var model = ee.Classifier.smileRandomForest(100)
.setOutputMode('REGRESSION')
.train({
features: training_sample,
classProperty: 'mean_postLST',
//removed mean_preLST, EM, FV due to multicollinearity
inputProperties: ['NDVI', 'NDBI', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B10', 'B11', 'elevation', 'slope', 'area']});
If users are to select potential aquavoltaic installation sites, it is imperative that these are actually fish farms. We used a random forest model to identify existing fish farms based on Sentinel-2 imagery, drawing from Ballinger’s (2024) oil refinery identification. The model is trained and tested on manually drawn land identification polygons. The resulting prediction data was then manually cleaned in QGIS. In the prediction tab of the final application, the user’s polygon selection is required to intersect with a fish farm. While the model is not perfectly accurate, it sufficiently limits user input to areas with fish farms.
// pre-process imagery
var start='2021-04-14';
var end='2025-04-14';
var bands = ['B2', 'B3', 'B4','B5','B6','B7','B8', 'B8A','B11','B12'];
var sentinel = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
.filter(ee.Filter.date(start, end))
.filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', 10))
.mean()
.select(bands);
var s_rgb = {
min: 0.0,
max: 3000,
bands:['B4', 'B3', 'B2'],
opacity:1
;
}
var sentinel1 = ee.ImageCollection('COPERNICUS/S1_GRD')
.filterBounds(AOI)
.filterDate(start, end)
.filter(ee.Filter.listContains('transmitterReceiverPolarisation', 'VV'))
.select('VV')
.mean();
var ndvi=sentinel.normalizedDifference(['B8','B4']).select(['nd'],['ndvi']);
var ndwi=sentinel.normalizedDifference(['B3','B8']).select(['nd'],['ndwi'])
var newBands = ee.Image([ndwi,ndvi,sentinel1.rename('S1_VV')]);
var image=sentinel.addBands(newBands).clip(AOI);
// add AOI and satellite imagery to map
Map.addLayer(image.clip(AOI), s_rgb, 'Sentinel');
Map.addLayer(AOI,null,"AOI",false);
// select random points from each land type for training/validation
var fishfarm_points=ee.FeatureCollection.randomPoints(fishfarms, 3000).map(function(i){
return i.set({'class': 0})});
var urban_points=ee.FeatureCollection.randomPoints(urban, 1000).map(function(i){
return i.set({'class': 1})});
var river_points=ee.FeatureCollection.randomPoints(rivers, 2000).map(function(i){
return i.set({'class': 2})});
var sample=ee.FeatureCollection([urban_points,
,
fishfarm_points
river_points
]).flatten()
.randomColumn();
// take samples from image for training and validation
var split=0.7
var training_sample = sample.filter(ee.Filter.lt('random', split));
var validation_sample = sample.filter(ee.Filter.gte('random', split));
var training = image.sampleRegions({
collection: training_sample,
properties: ['class'],
scale: 10,
;
})
var validation = image.sampleRegions({
collection: validation_sample,
properties: ['class'],
scale: 10
;
})
// create model and run to create predictions
var model = ee.Classifier.smileRandomForest(400)
.train(training, 'class');
var prediction = image.classify(model);
var fishfarm_prediction=prediction.updateMask(prediction.eq(0));
Map.addLayer(fishfarm_prediction,{palette:'red'},'Predicted Fish Farms');
// Assess accuracy of model
var validated = validation.classify(model);
var testAccuracy = validated.errorMatrix('class', 'classification');
print('Confusion Matrix ', testAccuracy);
print('Validation overall accuracy: ', testAccuracy.accuracy())
User Interface
UI Structure
This code builds an interactive user interface (UI) in Google Earth Engine. It structures the app into two main parts: a Main Panel and a Map. The Main Panel includes two navigation buttons (to switch between exploring existing solar farms or predicting impacts for new sites), and a content container that updates to show either statistics, charts, and layer controls, or drawing tools for prediction.
/*
Root
├── Main Panel
│ ├── Title
│ ├── Button Panel
│ │ ├── Visualize Button
│ │ └── Predict Button
│ └── Content Container
│ ├── Visualize Content
│ │ ├── Statistics Cards
│ │ ├── Charts
│ │ └── Layer Controls
│ └── Predict Content
│ ├── Drawing Tools
│ └── Results Panel
└── Map
├── Base Layer
├── Solar Panels Layer
├── Fish Farms Layer
└── Population Layer
*/
// Clear UI and define core functions
.root.clear();
ui
// Initialize main UI components
var mainPanel = ui.Panel({
layout: ui.Panel.Layout.flow('vertical'),
style: {width: '500px', padding: '10px'}
;
})
var map = ui.Map();
.setOptions('SATELLITE');
map.setCenter(120.10159388310306, 23.119258878572882, 13.5)
map
//Add a legend
var legend = ui.Panel({style: {position: 'bottom-left', padding: '8px 15px'}});
var legendTitle = ui.Label({value: 'Temperature Difference (°C)', style: {fontWeight: 'bold', fontSize: '14px', margin: '0 0 4px 0'}});
.add(legendTitle);
legend//Set visualisation parameters - same as polygons
var palette = palettes.colorbrewer.RdBu[9].reverse();
var min = -6;
var max = 6;
//Set up colour bar
var colorBar = ui.Thumbnail({image: ee.Image.pixelLonLat().select(0).multiply((max - min) / 100.0).add(min)
.visualize({min: min, max: max, palette: palette}),
params: {bbox: [0, 0, 100, 10], dimensions: '100x10'},
style: {stretch: 'horizontal', margin: '0px 8px', maxHeight: '24px'}
;
}).add(colorBar);
legend//Add labels
var legendLabels = ui.Panel({
layout: ui.Panel.Layout.flow('horizontal'),
style: {margin: '1px 0 0 0'}
;
}).add(ui.Label(min.toString(), {fontSize: '12px'}));
legendLabels.add(ui.Label(' ', {stretch: 'horizontal'})); // Spacer
legendLabels.add(ui.Label(max.toString(), {fontSize: '12px'}));
legendLabels.add(legendLabels);
legend.add(legend);
map
// Create UI panels and buttons
.add(ui.Panel({
mainPanelwidgets: [ui.Label('Tainan Solar Farm Heat Impact App',
fontWeight: 'bold', fontSize: '22px', margin: '0 0 10px 0', padding: '6px'})],
{style: {padding: '0'}
;
}))
// Content panels
var visualizeContent = ui.Panel({style: {border: '1px solid #999', padding: '8px'}});
var predictedContent = ui.Panel({
widgets: [ui.Label('Still working', {fontSize: '16px', padding: '20px'})],
style: {border: '1px solid #999', padding: '8px'}
;
})
// Navigation buttons
var buttons = {
visualize: ui.Button({
label: 'Explore Existing Solar Farms',
onClick: function() {
showPanel(visualizeContent, buttons.visualize, buttons.predict);
,
}style: {padding: '4px', fontWeight: 'bold',
border: '1px solid #dddddd', margin: '0 2px 0 0'}
,
})predict: ui.Button({
label: 'Predict Change in a New Site',
onClick: function() {
showPanel(predictedContent, buttons.predict, buttons.visualize);
,
}style: {padding: '4px', fontWeight: 'bold',
border: '1px solid #dddddd', margin: '0 2px 0 0'}
});
}// Button panel and container
var buttonPanel = ui.Panel([buttons.visualize, buttons.predict],
.Panel.Layout.flow('horizontal'), {margin: '0 0 20px 0'});
uivar contentContainer = ui.Panel();
UI Visualisation Components
This code builds a visualization interface to analyze solar farm impacts. It features three toggleable layers (solar panels, fish ponds, population estimates) managed by layerConfigs and createLayerControl. It displays solar farm counts, installation dates, statistic cards, and three charts. Users can interactively click farms for details and customize visible data through the control panel.
//Set solar panel visualisation parameters
var solarStyle = {min: -6, max: 6, palette: palettes.colorbrewer.RdBu[9]}; //for some reason we don't reverse it bc we've already reversed the legend!
//Reduce to image for faster loading
var solarImage = results.reduceToImage({properties: ['mean_LST_diff'], reducer: ee.Reducer.mean()}).rename('mean_LST_diff');
//Add outlines so users can later select polygons
var outlinedPolygons = results.style({color: 'black', fillColor: '00000000', width: 0.5});
Map.addLayer(outlinedPolygons, {}, 'Polygon Outlines');
// define layerConfigs
var layerConfigs = {
'Solar Panels': {
layer: solarImage,
defaultVisible: true,
visParams: solarStyle,
type: 'raster'
,
}
'Fish Farms': {
layer: fishfarms,
defaultVisible: false,
visParams: {
color: 'blue',
fillColor: '#87CEEB88',
width: 0
,
}type: 'vector'
,
}'Population Estimates': {
layer: HRSL_total, //.select('b1'),
defaultVisible: false,
visParams: {
min: 0,
max: 16,
palette: ['#A902A9'], //just a single colour, we don't want to complicate visualisation by having different pop colours too
opacity: 0.5},
type: 'raster'}
;
}
// define layer cache
var layerCache = {};
// define layer order for UI display
var uiLayerOrder = [
'Solar Panels',
'Fish Farms',
'Population Estimates'
;
]
// define layer order for map display
var layerOrder = {
'Fish Farms': 1,
'Population Estimates': 2,
'Solar Panels': 3
;
}
//Add general instructions first
.add(ui.Label('Welcome!', {fontWeight:'bold', fontSize:'18px'}));
visualizeContent.add(ui.Label(
visualizeContent'This app uses satellite imagery to explore how solar farms influence local temperatures and communities.\n\n' +
'Use the map and this Explore tab to get a broad understanding of solar farm impacts. Click on a solar farm on the map to get more information about it. Finally, visit the Prediction tab to assess the potential effects of building a new solar farm in a location of your choice.',
whiteSpace: 'pre-line'}
{;
))
// add layer control to visualizeContent
.add(ui.Label('Select Data to Display:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));
visualizeContent.forEach(function(layerName) {
uiLayerOrder.add(createLayerControl(layerName));
visualizeContent;
})
// add Summary Statistics panel
.add(ui.Label('Overview:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));
visualizeContent
//Add total polygon numbers
var totalPanelsLabel = ui.Label('Loading total polygons count...', {
fontSize: '14px',
color: 'gray'});
.add(totalPanelsLabel);
visualizeContent
.evaluate(function(count) { //replace when calculated
totalPanels.remove(totalPanelsLabel);
visualizeContentvar boldLabel = ui.Label(String(count), {
fontSize: '15px', fontWeight: 'bold', color: 'black', padding: '0', margin: '0 4px 0 0'});
var regularLabel = ui.Label(' solar farms installed since March 2019.', {
fontSize: '15px', color: 'black', padding: '0', margin: '0'});
//Use a panel to make sure they're added next to each other
var labelPanel = ui.Panel({
widgets: [boldLabel, regularLabel],
layout: ui.Panel.Layout.flow('horizontal'),
style: {padding: '0', margin: '4px'}});
.widgets().insert(7, labelPanel); //make sure it's added in same position as before - ChatGPT helped
visualizeContent;
})
//Add summary statistics
var statCardsPanel = ui.Panel({
layout: ui.Panel.Layout.flow('horizontal'),
style: {stretch: 'horizontal', margin: '10px 0'}
;
})
//Add a loading screen before the stats are calculated
var loadingCard = ui.Label('Loading maximum, minimum, and average temperature change...', {
fontSize: '14px',
color: 'gray',
;
}).add(loadingCard);
statCardsPanel
function createStatCard(label, value, color,textColor) {
return ui.Panel([
.Label(label, {
uifontWeight: 'bold',
fontSize: '14px',
color: textColor,
backgroundColor: color
,
}).Label(value, {
uifontSize: '18px',
color: textColor,
backgroundColor: color
}), ui.Panel.Layout.flow('vertical'), {
]padding: '10px',
backgroundColor: color,
borderRadius: '8px',
margin: '4px',
width: '30%'
;
})
}
//Add Stat Cards in the order: min, max, average
.evaluate(function(min) {
minTempChange.clear(); //remove the loading bit
statCardsPanel.add(createStatCard('Min Temp Change', min.toFixed(2) + ' °C', '#2166ac','white'));
statCardsPanel
.evaluate(function(avg) {
averageTempChange.add(createStatCard('Avg Temp Change', avg.toFixed(2) + ' °C', '#f7f7f7','black'));
statCardsPanel
.evaluate(function(max) {
maxTempChange.add(createStatCard('Max Temp Change', max.toFixed(2) + ' °C', '#b2182b','white'));
statCardsPanel;
});
});
}).add(statCardsPanel);
visualizeContent
// add chart label and container
.add(ui.Label('Deeper Trends:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));
visualizeContent
// Create charts directly
var tempDistChart = ui.Chart.feature.histogram({
features: sample,
property: 'mean_LST_diff',
minBucketWidth: 0.1
.setOptions({
})title: 'What is the distribution of temperature change?',
hAxis: {title: 'Temperature Change (°C)'},
vAxis: {title: 'Number of Solar Farms'},
legend: {position: 'none'},
colors: ['#FE8789']
;
})
var popDistChart = ui.Chart.feature.histogram({
features: sample,
property: 'total_buffer_pop',
minBucketWidth: 50
.setOptions({
})title: 'How many people typically live near a solar farm?',
hAxis: {title: 'Total Population Within 730m'},
vAxis: {title: 'Number of Solar Farms'},
legend: {position: 'none'},
colors: ['#A902A9']
;
})
var nicerName = allFeatures.map(function(feature) { //improve appearance
return feature.set('Temperature Change (°C)', feature.get('mean_LST_diff'));
;
})
var tempVsAreaChart = ui.Chart.feature.byFeature(
.filter(ee.Filter.notNull(['area', 'Temperature Change (°C)'])),
nicerName'area',
'Temperature Change (°C)'
.setChartType('ScatterChart')
).setOptions({
title: 'Is there a relationship between solar farm area and temperature?',
hAxis: {
title: 'Logged Area (hectares)',
scaleType: 'log',
format: 'short'
,
}vAxis: {
title: 'Temperature Change (°C)',
viewWindow: {
min: -1,
max: 5
},
}pointSize: 1,
colors: ['#ff8800'],
legend: {position: 'none'},
chartArea: {width: '85%', height: '80%'},
series: {0: {labelInLegend: 'Temp Change (°C)'}}
;
})
// Create a container for all charts
var chartsContainer = ui.Panel({
style: {margin: '10px 0'}
;
})
// Add charts to the container
.add(tempDistChart);
visualizeContent.add(popDistChart);
visualizeContent.add(tempVsAreaChart);
visualizeContent
//add disclaimer
.add(ui.Label(
visualizeContent'Please note that charts and summary statistics are based on a random sample of all solar farms. Although they closely reflect overall trends, exact values may vary slightly.',
fontSize: '13px', fontStyle: 'italic'}));
{
// then define createLayerControl function
function createLayerControl(layerName) {
var config = layerConfigs[layerName];
function createLayer() {
if (config.type === 'vector') {
return ui.Map.Layer({
eeObject: config.layer.style(config.visParams),
name: layerName,
shown: config.defaultVisible
;
})
}return ui.Map.Layer({
eeObject: config.layer,
visParams: config.visParams,
name: layerName,
shown: config.defaultVisible
;
})
}
var checkbox = ui.Checkbox({
label: layerName,
value: config.defaultVisible,
onChange: function(checked) {
if (!layerCache[layerName]) {
= createLayer();
layerCache[layerName]
}
.setShown(checked);
layerCache[layerName]
// Add logic to link solar panel outlines (i.e. features) to the coloured panels (images) - ChatGPT helped here
if (layerName === 'Solar Panels') {
if (checked) {
'Polygon Outlines'] = ui.Map.Layer(outlinedPolygons, {}, 'Polygon Outlines');
layerCache[else {
} 'Polygon Outlines'] = null;
layerCache[
}
}
var visibleLayers = [];
// Sort layers by layerOrder
var sortedLayers = Object.keys(layerConfigs).sort(function(a, b) {
return layerOrder[a] - layerOrder[b];
;
})
.forEach(function(name) {
sortedLayersif (layerCache[name] && layerCache[name].getShown()) {
.push(layerCache[name]);
visibleLayers
}
//Again, ensure solar panel outlines are being shown if solar panels are
if (name === 'Solar Panels' && layerCache['Polygon Outlines']) {
.push(layerCache['Polygon Outlines']);
visibleLayers
};
})
.layers().reset(visibleLayers);
map
};
})
//Default load solar panel outlines, even though we don't want this to be shown in the UI
if (config.defaultVisible) {
= createLayer();
layerCache[layerName] .add(layerCache[layerName]);
map
if (layerName === 'Solar Panels') {
'Polygon Outlines'] = ui.Map.Layer(outlinedPolygons, {}, 'Polygon Outlines');
layerCache[.add(layerCache['Polygon Outlines']);
map
}
}
return ui.Panel([checkbox], ui.Panel.Layout.flow('horizontal'));
}
// craete cache function
var chartCache = {
visualizeContent: null,
charts: []
;
}
// showPanel function
function showPanel(panel, activeButton, inactiveButton) {
// hide all panels
.style().set('shown', false);
visualizeContent.style().set('shown', false);
predictedContent
// show the selected panel
if (panel === visualizeContent) {
.style().set('shown', true);
visualizeContent.add(visualizeContent);
contentContainerelse {
} .style().set('shown', true);
predictedContent.add(predictedContent);
contentContainer
}
.style().set({fontWeight: 'bold'});
activeButton.style().set({fontWeight: 'bold'});
inactiveButton
}
// Assemble UI and initialize
.add(buttonPanel);
mainPanel.add(contentContainer);
mainPanel
// Feature to click on solar farm polygons for more info:
var panel = null;
var highlightLayer = null;
// Add map click handler
.onClick(function(coords) {
mapvar point = ee.Geometry.Point(coords.lon, coords.lat);
// remove existing panel/highlight
if (panel !== null) {
.remove(panel);
map= null;
panel
}
if (highlightLayer !== null) {
.remove(highlightLayer);
map= null;
highlightLayer
}
// create panel
= ui.Panel({
panel style: {
position: 'top-right',
padding: '8px',
width: '320px',
backgroundColor: 'rgba(25, 25, 25, 0.8)'
};
})
//define button to close the pop-up
var closeButton = ui.Button({
label: 'Close Panel',
style: {margin: '4px', backgroundColor: '00000000'}, //color: 'white'},
onClick: function() {
.remove(panel);
map= null;
panel if (highlightLayer !== null) {
.remove(highlightLayer);
map= null;
highlightLayer
}
};
})
// show initial loading panel so the user knows something's happening
.add(ui.Label('Solar Farm Summary:', {fontSize: '16px', fontWeight: 'bold', color: 'white', backgroundColor: '00000000'}))
panel.add(ui.Label('Calculating...', {color: 'white', backgroundColor: '00000000'}));
.add(panel);
map
// extract properties from all_results
var featureWithArea = all_results
.filterBounds(point)
.map(function(f) {
return f.set('area_hectare', f.geometry().area().divide(1e6));
}).first();
.evaluate(function(feature) {
featureWithArea//in case the user didn't select a panel
if (!feature) {
.clear();
panel.add(ui.Label('There are no solar farms at this location. Please select a new site.',
panelfontSize: '16px', color: 'white', backgroundColor: '00000000'}))
{.add(closeButton);
return;
}
// draw outline of selected feature
var geom = ee.Feature(feature).geometry();
= ui.Map.Layer(geom, {color: 'yellow', fillColor: '00000000', width: 3}, 'Selected Area');
highlightLayer .add(highlightLayer);
map
//extract properties from all_results
var props = feature.properties;
// Update panel with actual info
.clear();
panel.add(ui.Label('Solar Farm Summary:', {fontSize: '16px', fontWeight: 'bold', color: 'white', backgroundColor: '00000000'}))
panel.add(ui.Label('Installation date: ' + props.dateright, {color: 'white', backgroundColor: '00000000'}))
.add(ui.Label('Average temperature change: ' + props.mean_LST_diff.toFixed(2) + '°C', {color: 'white', backgroundColor: '00000000'}))
.add(ui.Label('Area: ' + props.area_hectare.toFixed(2) + ' hectares', {color: 'white', backgroundColor: '00000000'}))
.add(ui.Label('Potential population affected: ' + props.total_buffer_pop, {color: 'white', backgroundColor: '00000000'}))
.add(closeButton);
;
}); })
UI Prediction Components
This section implements a prediction interface for analyzing the impact of solar farms on temperature and population. The main features include:
Drawing Tools: users can draw a polygon on the map to select an area of interest.
Prediction Processing: the model calculates temperature and population changes based on the selected area.
Results Display: the interface shows the predicted temperature change, potential population affected, and a detailed summary of the results.
// Initialize default view
showPanel(visualizeContent, buttons.visualize, buttons.predict);
// Add to UI root
.root.add(ui.Panel([mainPanel, map], ui.Panel.Layout.flow('horizontal'),
uiwidth: '100%', height: '100%'}));
{
// clear the predictedContent
.clear();
predictedContent
// add a description label
.add(ui.Label('To explore the effects of building a solar farm in a new site, please click the button below and draw a polygon on the map. Please make sure you draw the panel over a fish farm.',
predictedContentfontSize: '14px', margin: '0 0 10px 0'}));
{
//Add button to draw the polygons
var drawButton = ui.Button({
label: 'Draw a new solar farm',
onClick: function() {
// clear the previous drawing
.drawingTools().layers().reset();
map.drawingTools().setShape('polygon');
map.drawingTools().draw();
map
// Disable the draw button and prevent further drawing
.setDisabled(true);
drawButton
// Start drawing and disable the drawing tools until drawing is complete
.drawingTools().setShown(false);
map,
}style: {margin: '0 0 10px 0'}
;
}).add(drawButton);
predictedContent
// add a results panel
var resultsPanel = ui.Panel({
style: {
margin: '10px 0',
padding: '5px',
border: '1px solid #ddd',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
shown: false
};
}).add(resultsPanel);
predictedContent
// add a loading label to the predictedContent
var loadingLabel = ui.Label('Please wait while the model runs...', {
fontStyle: 'italic',
fontSize: '15px',
//color: '#1a73e8',
margin: '10px 0',
shown: false
;
}).add(loadingLabel);
predictedContent
// Create a small text label to appear under the results panel
var modelInfo = ui.Label('Please be aware that although the model is a useful tool, its predictions are unlikely to be perfectly accurate. The model explains 79% of variation in temperature change, with an average error of approximately 0.33°C.', {
shown:false
;
}).add(modelInfo);
predictedContent
// Modify the map drawing completion event processing
.drawingTools().onDraw(function(geometry) {
map.clear();
resultsPanel.style().set('shown', true); // show the loading label
loadingLabel.style().set('shown', false); //ensure model explanation and results panel are hidden, even if they were shown before
modelInfo.style().set('shown', false);
resultsPanel
//Only run if there is some intersection with fishfarms
var intersection = fishfarms.filterBounds(geometry).size().gt(0);
.evaluate(function(intersects) {
intersectionif (intersects) {
//Slightly changed version of the original analysis - does all calculations simultaneously to reduce waiting time
var computeScale = 30;
var feature = ee.Feature(geometry);
var pop = popBuffer(feature); //run pop function from above
var now = ee.Date(Date.now());
var polygonStart = now.advance(-3, 'year');
var polygonEnd = now;
var currentImage = ee.Image(getLST(geometry, polygonStart, polygonEnd)); //run LST calculation from above
var allComputations = ee.Dictionary({});
// perform the calculations separately and merge the results
var lstDict = currentImage.select('LST').reduceRegion({reducer: ee.Reducer.mean(), geometry: geometry, scale: computeScale, maxPixels: 1e13});
var indicesDict = currentImage.select(['NDVI', 'NDBI', 'FV', 'EM', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B10', 'B11']).reduceRegion({
reducer: ee.Reducer.mean(), geometry: geometry, scale: computeScale, maxPixels: 1e13});
var elevationDict = elevation.reduceRegion({reducer: ee.Reducer.mean(),geometry: geometry,scale: computeScale, maxPixels: 1e13});
var slopeDict = slope.reduceRegion({reducer: ee.Reducer.mean(), geometry: geometry, scale: computeScale, maxPixels: 1e13});
// Combine all the results
var combinedResults = ee.Dictionary(lstDict)
.combine(indicesDict)
.combine(elevationDict)
.combine(slopeDict)
.combine(pop.toDictionary(['total_buffer_pop', 'vulnerable_buffer_pop', 'child_buffer_pop', 'elderly_buffer_pop']));
.evaluate(function(results) {
combinedResultsif (results.LST !== null) {
var currentLST = results.LST;
//Create finished feature
var predictionFeature = ee.Feature(geometry, {
'NDVI': results.NDVI,
'NDBI': results.NDBI,
'B1': results.B1,
'B2': results.B2,
'B3': results.B3,
'B4': results.B4,
'B5': results.B5,
'B6': results.B6,
'B7': results.B7,
'B10': results.B10,
'B11': results.B11,
'elevation': results.elevation,
'slope': results.slope,
'area': geometry.area().divide(10000)});
//Predict using model
var predicted = ee.FeatureCollection([predictionFeature]).classify(model);
.first().get('classification').evaluate(function(futureTemp) {
predictedvar tempDiff = futureTemp - currentLST;
//Hide loading label
.style().set('shown', false);
loadingLabel
//Print results
.style().set('shown', true);
resultsPanel.widgets().reset([
resultsPanel.Label('Site Summary:', {fontWeight: 'bold', margin: '0 0 8px 0'}),
ui.Label('Current temperature:' + currentLST.toFixed(2) + '°C'),
ui.Label('Predicted temperature with solar farm:' + futureTemp.toFixed(2) + '°C'),
ui.Label('Predicted temperature change:' + tempDiff.toFixed(2) + '°C'),
ui.Label('Potential population affected:' + (results.total_buffer_pop || 0) + ' people'),
ui.Label('Potential vulnerable population affected:' + (results.vulnerable_buffer_pop || 0) + ' people'),
ui.Label('Populations are calculated within 730m of the polygon. Vulnerable population refers to estimated numbers of children (0-5) and elderly (60+) individuals living within this area.', {
uifontSize: '12px', fontStyle: 'italic'})
;
]).style().set('shown', true);
modelInfo.setDisabled(false);
drawButton
;
})else {
} .style().set('shown', false);
loadingLabel.style().set('shown', true);
resultsPanel.add(ui.Label('There is insufficient satellite imagery to calculate temperature for this location. Please select a different area.'));
resultsPanel.setDisabled(false);
drawButton
};
})else {
} .style().set('shown', false);
loadingLabel.style().set('shown', true);
resultsPanel.add(ui.Label('This polygon does not intersect with any fish farms. Please redraw in a different location.'));
resultsPanel.setDisabled(false);
drawButton
}
//Stop and hide drawing tools once processing is finished
.drawingTools().stop();
map.drawingTools().setShown(false);
map;
}); })
Links
References
Ballinger, O. (1 January 2024) Refinery Identification [Module content], Building Spatial Applications with Big Data CASA0025, University College London.
Guoqing, L., Hernandez, R.R., Blackburn, G.A., Davies, G., Hunt, M., Whyatt, J.D. and Armstrong, A., 2021. Ground-mounted photovoltaic solar parks promote land surface cool islands in arid ecosystems. Renewable and Sustainable Energy Transition, 1, p.100008.
Hsiao, Y.J., Chen, J.L. and Huang, C.T., 2021. What are the challenges and opportunities in implementing Taiwan’s aquavoltaics policy? A roadmap for achieving symbiosis between small-scale aquaculture and photovoltaics. Energy Policy, 153, p.112264.
Šafanda, J., 1999. Ground surface temperature as a function of slope angle and slope orientation and its effect on the subsurface temperature field. Tectonophysics, 306(3-4), pp.367-375.
USGS, 2019. Landsat 8 (L8) Data Users Handbook. Obtained from https://d9-wret.s3.us-west-2.amazonaws.com/assets/palladium/production/s3fs-public/atoms/files/LSDS-1574_L8_Data_Users_Handbook-v5.0.pdf
Xu, Z., Li, Y., Qin, Y. and Bach, E., 2024. A global assessment of the effects of solar farms on albedo, vegetation, and land surface temperature using remote sensing. Solar Energy, 268, p.112198. https://doi.org/10.1016/j.solener.2023.112198