Can You See the War from Space?

Author:Murphy  |  View: 28770  |  Time: 2025-03-23 11:27:01

Almost three years have passed since Russia invaded Ukraine's territory on February 24th, 2024. This bloody war has destroyed or somehow affected dozens of thousands of families on both sides of the conflict. There is plenty of evidence across the internet shedding light on human suffering and pain brought by this war, one of the main sources of data being aerial and space imagery.

Many private and military high-resolution space sensors are monitoring this region to obtain crucial information about troop movement, infrastructure, and environment near-real time. Unfortunately, this kind of data is often not available to a regular user, like we are, but a great amount of other satellites pass over Ukraine daily, so we could try to extract some meaningful information from free-access datasets and have a look at what's going on there.

In this article, let's try to find out if there is a change in night time lights radiance after the beginning of the war and see if there is a decline of this value in contrast, before/after the beginning of the war. This short investigation will be focused on three major cities in Ukraine: Kiev, Kharkov, and Odessa.

NASA Visible Infrared Imaging Radiometer Suite (VIIRS) has Day/Night Band (DNB) onboard, which is perfect for our purposes. This data is distributed with daily temporal resolution and ~500 m spatial one. But since we don't want to deal with at least 365*3 files to produce the analysis, instead we'll investigate monthly average composites with atmospheric corrections. This data product is distributed by Google Earth Engine (GEE) with free access, so no data downloading is required.

The content of the article is split on the following sections:

  • Data acquisition and preprocessing
  • Anomaly calculation
  • Mapping and creating a GIF
  • Correlation with attacks

As always the code of this article you can find on my GitHub.


Data acquisition and preprocessing

First thing first, to begin the analysis we need to have on hand the actual territory of these cities. To get them you can either use Google Earth Engine dataset called FAO GAUL: Global Administrative Unit Layers 2015 or the GADM website. As a result we should end up with a bunch of polygons each resembling a Ukrainian region.

Image by author.

To create such a visualization you'll need to download the aforementioned boundaries and read them using geopandas library:

shape = gpd.read_file('YOUR_FILE.shp')
shape = shape[(shape['NAME_1']=='Kiev') | (shape['NAME_1']=='Kiev City') | (shape['NAME_1']=='?') | (shape['NAME_1']=='Kharkiv')|
              (shape['NAME_1']=='Odessa')]
shape.plot(color='grey', edgecolor='black')

plt.axis('off')
plt.text(35,48, 'Kharkov', fontsize=20)
plt.text(31,46, 'Odessa', fontsize=20)
plt.text(31,49, 'Kiev', fontsize=20)
plt.savefig('UKR_shape.png')
plt.show()

The second step for us is going to be acquisition of VIIRS data through GEE. If you download the shape of Ukrainian regions from the internet, you'll need to wrap it into a GEE geometry object. Otherwise you've already got it ready to use.

import json
import ee

js = json.loads(shape.to_json())
roi = ee.Geometry(ee.FeatureCollection(js).geometry())

Now let's define the timeline of our research. Conceptually, to understand if the night time light radiance after the beginning of the war was anomalous, we need to know the values before. So we will be working with the whole time frame available: from 2012–01–01 to 2024–04–01. Data before 2022–02–01 will be considered as «a norm» and everything after will be subtracted from this norm, hence, representing a deviation (anomaly).

startDate = pd.to_datetime('2012-01-01')
endDate = pd.to_datetime('2024-04-01')
data = ee.ImageCollection("NOAA/VIIRS/DNB/MONTHLY_V1/VCMSLCFG")
                  .filterBounds(roi)
                  .filterDate(start = startDate, end=endDate)

Our final result will include a map and anomaly plot. To perform this visualization we need to collect monthly night time lights radiance __ maps between 2022–02–01 and 2024–04–01 and average monthly night time lights radiance (in the form of time series) for each region. The best way to do that is to iterate through a list of GEE images and saving a _.csv and .np_y files as the result.

Important! The VIIRS dataset comprises a really valuable variable cf_cvg, which describes the total number of observations that went into each pixel (cloud-free pixels). In essence, it's a quality flag. The bigger this number, the higher quality we have. In this analysis, when calculating the norm, we will filter out all the pixels with _cf_cvg≤1._

arrays, dates, rads = [], [], []
  if data.size().getInfo()!=0:
      data_list = data.toList(data.size())
      for i in range(data_list.size().getInfo()):
          array, date = to_array(data_list,i, roi)

          rads.append(array['avg_rad'][np.where(array['cf_cvg']>1)].mean())
          dates.append(date)
          if date>=pd.to_datetime('2022-01-01'):
            arrays.append(array['avg_rad'])
          print(f'Index: {i+1}/{data_list.size().getInfo()+1}')
  df = pd.DataFrame({'date': dates, 'avg_rad':rads})
  np.save(f'{city}.npy', arrays, allow_pickle=True)
  df.to_csv(f'{city}.csv', index=None)

Anomaly Calculation

The generated files of the format city.csv with the _avg_rad_ time series inside are perfect for anomaly calculation. The process is extremely simple:

  1. Filter out observations before 2022–02–01;
  2. Group by all the observations by month (in total – 12 groups);
  3. Take a mean;
  4. Subtract the mean from the observations after 2022–02–01 for each month respectively.
df = pd.read_csv(f'{city}.csv')
df.date = pd.to_datetime(df.date)
ts_lon = df[df.date=pd.to_datetime('2022-01-01')].set_index('date')
ts_short['month'] = ts_short.index.month
anomaly = ts_short['avg_rad']-ts_short['month'].map(means['avg_rad'])

Mapping and creating a GIF

Our last step to actually see the first result is building two subplots: a map + anomalies time series. We are not going to do any static maps today. To implement a GIF, let's build a nested function drawing our subplots:

def plot(city, arrays, dates, rads):
  def update(frame):
    im1.set_data(arrays[frame])

    info_text = (
        f"Date: {pd.to_datetime(dates[frame]).strftime(format='%Y-%m-%d')}n"
    )
    text.set_text(info_text)
    ax[0].axis('off')

    im2.set_data(dates[0:frame+1], rads[0:frame+1])

    ax[1].relim()
    return [im1, im2]

  colors = [(0, 0, 0), (1, 1, 0)]
  cmap_name = 'black_yellow'
  black_yellow_cmap = LinearSegmentedColormap.from_list(cmap_name, colors)

  llim = -1 

  fig, ax = plt.subplots(1,2,figsize=(12,8), frameon=False)
  im1 = ax[0].imshow(arrays[0], vmax=10, cmap=black_yellow_cmap)
  text = ax[0].text(20, 520, "", ha='left', fontsize=14, fontname='monospace', color='white')

  im2, = ax[1].plot(dates[0], rads[0], marker='o',color='black', lw=2)
  plt.xticks(rotation=45)
  ax[1].axhline(0, lw=3, color='black')
  ax[1].axhline(0, lw=1.5, ls='--', color='yellow')
  ax[1].grid(False)
  ax[1].spines[['right', 'top']].set_visible(False)
  ax[1].set_xlabel('Date', fontsize=14, fontname='monospace')
  ax[1].set_ylabel('Average DNB radiance', fontsize=14, fontname='monospace')
  ax[1].set_ylim(llim, max(rads)+0.1)
  ax[1].set_xlim(min(dates), max(dates))

  ani = animation.FuncAnimation(fig, update, frames=27, interval=40)
  ani.save(f'{city}.gif', fps=0.5, savefig_kwargs={'pad_inches':0, 'bbox_inches': 'tight'})
  plt.show()

The code above might be challenging to understand at first glance. But it's actually quite simple:

  1. Defining the update function. This function is used by matplotlib function FuncAnimation. The idea is that it passes (adds) new data to the existing plot and returns a new figure (frame). A list of frames is then transformed to a GIF file.
  2. Creating a custom color map. This one is the easiest. I simply don't like the colors of the built-in matplotlib cmaps for this project. Since we are working with light in the current analysis, let's use black and yellow.
  3. Building and formating the plots. Everything else is just a regular map + line plot with labels, limits and ticks formatting. Nothing special.

Let's see what we've got here:

1. Kiev

Image by author.

2. Kharkov

Image by author.

3. Odessa

Image by author.

I don't know about you, but these images really terrify me. Large developed cities, like Kiev and Kharkov, are clearly being "turned off" right after February 2024.

Let's compare the lineplots separately.

Image by author.

Without any statistical analysis, there is an evident correlation between these three time series. By analysing anomalies (not the actual time series) we tried to exclude seasonal component (change in night time lights radiance cause by snow). So arguably, all the negative anomalies we see should be related to the drone/missile attacks.

The plots clearly indicate that Kiev and Kharkov experienced very similar blackouts in January of 2023 and 2024, whereas Odessa almost avoided any negative anomalies over this time.

Summing up, this article is not a scientific research. To become one, it definitely needs more high-resolution data, statistical analysis, and uncertainty estimation.

However, being a brief geospatial investigation, it gives a good perspective on how this bloodshed affected the three largest Ukrainian cities and the people living there. Hopefully it'll inspire you to dig deeper into the topic and produce your own comprehensive analysis.

Make love and data science, not war

Peace

Tags: Data Science Geospatial Satellite Ukraine War

Comment