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

  1. Landsat 8 Collection 1 Top of Atmosphere imagery at 30m resolution for temperature change assessments.

  2. Sentinel-2 imagery at 10m resolution for fish farm identification.

  3. Solar panel polygons and construction dates from the Taiwanese Civil Service..

  4. Population estimates at 30m resolution from Meta’s Data for Good

  5. 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
ui.root.clear();

// Initialize main UI components
var mainPanel = ui.Panel({
  layout: ui.Panel.Layout.flow('vertical'),
  style: {width: '500px', padding: '10px'}
});

var map = ui.Map();
map.setOptions('SATELLITE');
map.setCenter(120.10159388310306, 23.119258878572882, 13.5)

//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'}});
legend.add(legendTitle);
//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'}
});
legend.add(colorBar);
//Add labels
var legendLabels = ui.Panel({
  layout: ui.Panel.Layout.flow('horizontal'),
  style: {margin: '1px 0 0 0'}
});
legendLabels.add(ui.Label(min.toString(), {fontSize: '12px'}));
legendLabels.add(ui.Label(' ', {stretch: 'horizontal'})); // Spacer
legendLabels.add(ui.Label(max.toString(), {fontSize: '12px'}));
legend.add(legendLabels);
map.add(legend);

// Create UI panels and buttons
mainPanel.add(ui.Panel({
  widgets: [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], 
  ui.Panel.Layout.flow('horizontal'), {margin: '0 0 20px 0'});
var 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
visualizeContent.add(ui.Label('Welcome!', {fontWeight:'bold', fontSize:'18px'}));
visualizeContent.add(ui.Label(
  '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
visualizeContent.add(ui.Label('Select Data to Display:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));
uiLayerOrder.forEach(function(layerName) {
  visualizeContent.add(createLayerControl(layerName));
});

// add Summary Statistics panel
visualizeContent.add(ui.Label('Overview:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));

//Add total polygon numbers
var totalPanelsLabel = ui.Label('Loading total polygons count...', {
  fontSize: '14px',
  color: 'gray'});
visualizeContent.add(totalPanelsLabel);

totalPanels.evaluate(function(count) { //replace when calculated
  visualizeContent.remove(totalPanelsLabel);
  var 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'}});
  visualizeContent.widgets().insert(7, labelPanel); //make sure it's added in same position as before - ChatGPT helped
});

//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',
});
statCardsPanel.add(loadingCard);

function createStatCard(label, value, color,textColor) {
  return ui.Panel([
    ui.Label(label, {
      fontWeight: 'bold',
      fontSize: '14px',
      color: textColor,
      backgroundColor: color
    }),
    ui.Label(value, {
      fontSize: '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
minTempChange.evaluate(function(min) {
  statCardsPanel.clear(); //remove the loading bit
  statCardsPanel.add(createStatCard('Min Temp Change', min.toFixed(2) + ' °C', '#2166ac','white'));
  
  averageTempChange.evaluate(function(avg) {
    statCardsPanel.add(createStatCard('Avg Temp Change', avg.toFixed(2) + ' °C', '#f7f7f7','black'));

    maxTempChange.evaluate(function(max) {
      statCardsPanel.add(createStatCard('Max Temp Change', max.toFixed(2) + ' °C', '#b2182b','white'));
    });
  });
});
visualizeContent.add(statCardsPanel);

// add chart label and container
visualizeContent.add(ui.Label('Deeper Trends:', {fontWeight: 'bold', fontSize: '16px', margin: '15px 0 5px 0'}));

// 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(
  nicerName.filter(ee.Filter.notNull(['area', 'Temperature Change (°C)'])),
  '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
visualizeContent.add(tempDistChart);
visualizeContent.add(popDistChart);
visualizeContent.add(tempVsAreaChart);


//add disclaimer
visualizeContent.add(ui.Label(
  '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]) {
        layerCache[layerName] = createLayer();
      }
      
      layerCache[layerName].setShown(checked);

      // Add logic to link solar panel outlines (i.e. features) to the coloured panels (images) - ChatGPT helped here
      if (layerName === 'Solar Panels') {
        if (checked) {
          layerCache['Polygon Outlines'] = ui.Map.Layer(outlinedPolygons, {}, 'Polygon Outlines');
        } else {
          layerCache['Polygon Outlines'] = null;
        }
      }

      var visibleLayers = [];
      // Sort layers by layerOrder
      var sortedLayers = Object.keys(layerConfigs).sort(function(a, b) {
        return layerOrder[a] - layerOrder[b];
      });
      
      sortedLayers.forEach(function(name) {
        if (layerCache[name] && layerCache[name].getShown()) {
          visibleLayers.push(layerCache[name]);
        }

        //Again, ensure solar panel outlines are being shown if solar panels are
        if (name === 'Solar Panels' && layerCache['Polygon Outlines']) {
          visibleLayers.push(layerCache['Polygon Outlines']);
        }
      });

      map.layers().reset(visibleLayers);
    }
  });

  //Default load solar panel outlines, even though we don't want this to be shown in the UI
  if (config.defaultVisible) {
    layerCache[layerName] = createLayer();
    map.add(layerCache[layerName]);

    if (layerName === 'Solar Panels') {
      layerCache['Polygon Outlines'] = ui.Map.Layer(outlinedPolygons, {}, 'Polygon Outlines');
      map.add(layerCache['Polygon Outlines']);
    }
  }

  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
  visualizeContent.style().set('shown', false);
  predictedContent.style().set('shown', false);
  
  // show the selected panel
  if (panel === visualizeContent) {
    visualizeContent.style().set('shown', true);
    contentContainer.add(visualizeContent);
  } else {
    predictedContent.style().set('shown', true);
    contentContainer.add(predictedContent);
  }
  
  activeButton.style().set({fontWeight: 'bold'});
  inactiveButton.style().set({fontWeight: 'bold'});
}

// Assemble UI and initialize
mainPanel.add(buttonPanel);
mainPanel.add(contentContainer);

// Feature to click on solar farm polygons for more info:
var panel = null;
var highlightLayer = null;

// Add map click handler
map.onClick(function(coords) {
  var point = ee.Geometry.Point(coords.lon, coords.lat);
  
  // remove existing panel/highlight
  if (panel !== null) {
    map.remove(panel);
    panel = null;
  }
  
  if (highlightLayer !== null) {
    map.remove(highlightLayer);
    highlightLayer = null;
  }
  
  // create panel
  panel = ui.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() {
      map.remove(panel);
      panel = null;
      if (highlightLayer !== null) {
        map.remove(highlightLayer);
        highlightLayer = null;
      }
    }
  });

  // show initial loading panel so the user knows something's happening
  panel.add(ui.Label('Solar Farm Summary:', {fontSize: '16px', fontWeight: 'bold', color: 'white', backgroundColor: '00000000'}))
       .add(ui.Label('Calculating...', {color: 'white', backgroundColor: '00000000'}));

  map.add(panel);
  
  // 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();

  featureWithArea.evaluate(function(feature) { 
    //in case the user didn't select a panel
    if (!feature) {
      panel.clear();
      panel.add(ui.Label('There are no solar farms at this location. Please select a new site.', 
      {fontSize: '16px', color: 'white', backgroundColor: '00000000'}))
      .add(closeButton);
      return;
    }

    // draw outline of selected feature
    var geom = ee.Feature(feature).geometry();
    highlightLayer = ui.Map.Layer(geom, {color: 'yellow', fillColor: '00000000', width: 3}, 'Selected Area');
    map.add(highlightLayer);
    
    //extract properties from all_results
    var props = feature.properties;
  
    // Update panel with actual info
    panel.clear();
    panel.add(ui.Label('Solar Farm Summary:', {fontSize: '16px', fontWeight: 'bold', color: 'white', backgroundColor: '00000000'}))
         .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:

  1. Drawing Tools: users can draw a polygon on the map to select an area of interest.

  2. Prediction Processing: the model calculates temperature and population changes based on the selected area.

  3. 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
ui.root.add(ui.Panel([mainPanel, map], ui.Panel.Layout.flow('horizontal'), 
  {width: '100%', height: '100%'}));

// clear the predictedContent
predictedContent.clear();

// add a description label
predictedContent.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.', 
  {fontSize: '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
    map.drawingTools().layers().reset();
    map.drawingTools().setShape('polygon');
    map.drawingTools().draw();
    
    // Disable the draw button and prevent further drawing
    drawButton.setDisabled(true);
    
    // Start drawing and disable the drawing tools until drawing is complete
    map.drawingTools().setShown(false);
  },
  style: {margin: '0 0 10px 0'}
});
predictedContent.add(drawButton);

// 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
  }
});
predictedContent.add(resultsPanel);

// 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
});
predictedContent.add(loadingLabel);

// 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
});
predictedContent.add(modelInfo);

// Modify the map drawing completion event processing
map.drawingTools().onDraw(function(geometry) {
  resultsPanel.clear();
  loadingLabel.style().set('shown', true);  // show the loading label
  modelInfo.style().set('shown', false); //ensure model explanation and results panel are hidden, even if they were shown before
  resultsPanel.style().set('shown', false);
  
  //Only run if there is some intersection with fishfarms
  var intersection = fishfarms.filterBounds(geometry).size().gt(0);
  
  intersection.evaluate(function(intersects) {
    if (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']));

      combinedResults.evaluate(function(results) {
        if (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);
          predicted.first().get('classification').evaluate(function(futureTemp) {
            var tempDiff = futureTemp - currentLST;
            
            //Hide loading label
            loadingLabel.style().set('shown', false);
            
            //Print results
            resultsPanel.style().set('shown', true);
            resultsPanel.widgets().reset([ 
              ui.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.', {
                  fontSize: '12px', fontStyle: 'italic'})
            ]);
            modelInfo.style().set('shown', true); 
            drawButton.setDisabled(false);
            
          });
        } else {
          loadingLabel.style().set('shown', false);
          resultsPanel.style().set('shown', true);
          resultsPanel.add(ui.Label('There is insufficient satellite imagery to calculate temperature for this location. Please select a different area.'));
          drawButton.setDisabled(false);
        }
      });
    } else {
      loadingLabel.style().set('shown', false);
      resultsPanel.style().set('shown', true);
      resultsPanel.add(ui.Label('This polygon does not intersect with any fish farms. Please redraw in a different location.'));
      drawButton.setDisabled(false);
    }

    //Stop and hide drawing tools once processing is finished
    map.drawingTools().stop();
    map.drawingTools().setShown(false);
  });
});

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