Skip to content

Commit 849ccbc

Browse files
authored
Merge pull request #12 from jmbhughes/v1.0
V1.0
2 parents d765cdc + 6f036da commit 849ccbc

File tree

7 files changed

+200
-11
lines changed

7 files changed

+200
-11
lines changed

.github/workflows/ci.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
2+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3+
4+
name: CI
5+
6+
on:
7+
push:
8+
pull_request:
9+
schedule:
10+
- cron: '0 8 * * *'
11+
12+
jobs:
13+
build:
14+
runs-on: ubuntu-latest
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
python-version: ["3.10"]
19+
20+
steps:
21+
- uses: actions/checkout@v3
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v3
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
python -m pip install flake8 pytest pytest-cov hypothesis
30+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
31+
- name: Lint with flake8
32+
run: |
33+
# stop the build if there are Python syntax errors or undefined names
34+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
36+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37+
- name: Test with pytest
38+
run: |
39+
pip install .
40+
pytest --cov
41+
- name: Upload coverage to Codecov
42+
uses: codecov/codecov-action@v3
43+
with:
44+
fail_ci_if_error: true
45+
verbose: true

requirements.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
pyqt5 >= 5.15.9
2+
matplotlib >= 3.7.2
3+
astropy >= 5.3.1
4+
numpy >= 1.25.1
5+
goes-solar-retriever >= 0.4.0
6+
scipy >= 1.11.1
7+
scikit-image >= 0.21.0
8+
pillow >= 10.0.0
9+
sunpy >= 5.0.0
10+
lxml >= 4.9.3
11+
reproject >= 0.11.0
12+
zeep >= 4.2.1
13+
drms >= 0.6.4

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
name='solarannotator',
1212
long_description=long_description,
1313
long_description_content_type='text/markdown',
14-
version='0.3.1',
14+
version='1.0.0',
1515
packages=['solarannotator'],
1616
url='',
1717
license='',

solarannotator/gui.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import PyQt5
33
from PyQt5 import QtCore, QtWidgets
44
from PyQt5.QtWidgets import QWidget, QLabel, QAction, QTabWidget, QPushButton, QFileDialog, QRadioButton, QMessageBox, \
5-
QComboBox, QLineEdit, QSizePolicy
5+
QComboBox, QLineEdit, QSizePolicy, QCheckBox
66
from PyQt5.QtCore import QDateTime
77
from PyQt5.QtGui import QIcon, QDoubleValidator
88
from datetime import datetime, timedelta
@@ -18,6 +18,8 @@
1818
from matplotlib.backends.backend_qt5agg import FigureCanvas, NavigationToolbar2QT as NavigationToolbar
1919
from matplotlib.figure import Figure
2020

21+
from solarannotator.template import create_thmap_template
22+
2123
if hasattr(QtCore.Qt, 'AA_EnableHighDpiScaling'):
2224
PyQt5.QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
2325

@@ -69,7 +71,7 @@ def __init__(self, config):
6971
self.pix = np.vstack((xv.flatten(), yv.flatten())).T
7072

7173
lineprops = dict(color="red", linewidth=2)
72-
self.lasso = LassoSelector(self.axs[0], self.onlasso, lineprops=lineprops)
74+
self.lasso = LassoSelector(self.axs[0], self.onlasso, props=lineprops)
7375
self.fig.tight_layout()
7476

7577
def onlasso(self, verts):
@@ -209,7 +211,7 @@ def clearBoundaries(self):
209211
self.region_patches = []
210212
self.fig.canvas.draw_idle()
211213

212-
def loadThematicMap(self, thmap):
214+
def loadThematicMap(self, thmap, template=True):
213215
try:
214216
download_message = QMessageBox.information(self,
215217
'Downloading',
@@ -220,9 +222,11 @@ def loadThematicMap(self, thmap):
220222
self.data_does_not_exist_popup()
221223
else:
222224
self.thmap = thmap
225+
if template:
226+
self.thmap = create_thmap_template(self.composites)
223227
self.thmap.copy_195_metadata(self.composites)
224228
self.history = [thmap.data.copy()]
225-
self.thmap_data = thmap.data
229+
self.thmap_data = self.thmap.data
226230
self.thmap_axesimage.set_data(self.thmap_data)
227231
self.preview_axesimage.set_data(self.composites['94'].data)
228232
self.fig.canvas.draw_idle()
@@ -479,9 +483,12 @@ def initUI(self):
479483
layout = QtWidgets.QHBoxLayout()
480484
instructions = QLabel("Please select a time for the new file.", self)
481485
self.dateEdit = QtWidgets.QDateTimeEdit(QDateTime.currentDateTime())
486+
self.template_option = QCheckBox("Use template")
487+
self.template_option.setChecked(True)
482488
submit_button = QPushButton("Submit")
483489
layout.addWidget(instructions)
484490
layout.addWidget(self.dateEdit)
491+
layout.addWidget(self.template_option)
485492
layout.addWidget(submit_button)
486493
self.setLayout(layout)
487494
submit_button.clicked.connect(self.onSubmit)
@@ -493,7 +500,7 @@ def onSubmit(self):
493500
{'DATE-OBS': str(self.parent.date),
494501
'DATE': str(datetime.today())},
495502
self.parent.config.solar_class_name)
496-
self.parent.annotator.loadThematicMap(new_thmap)
503+
self.parent.annotator.loadThematicMap(new_thmap, self.template_option.isChecked())
497504
self.parent.controls.onTabChange() # Us
498505
self.close()
499506
self.parent.setWindowTitle("SolarAnnotator: {}".format(new_thmap.date_obs))

solarannotator/io.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
import sunpy.map
1212
from sunpy.coordinates import Helioprojective
1313

14-
1514
Image = namedtuple('Image', 'data header')
1615

1716

@@ -29,9 +28,9 @@ def retrieve(date):
2928
@staticmethod
3029
def _load_gong_image(date, suvi_195_image):
3130
# Find an image and download it
32-
results = Fido.search(a.Time(date-timedelta(hours=1), date+timedelta(hours=1)),
31+
results = Fido.search(a.Time(date - timedelta(hours=1), date + timedelta(hours=1)),
3332
a.Wavelength(6563 * u.Angstrom), a.Source("GONG"))
34-
selection = results[0][len(results[0])//2] # only download the middle image
33+
selection = results[0][len(results[0]) // 2] # only download the middle image
3534
downloads = Fido.fetch(selection)
3635
with fits.open(downloads[0]) as hdul:
3736
gong_data = hdul[1].data
@@ -56,7 +55,6 @@ def _load_gong_image(date, suvi_195_image):
5655

5756
return Image(out.data, dict(out.meta))
5857

59-
6058
@staticmethod
6159
def _load_suvi_composites(date):
6260
satellite = Satellite.GOES16
@@ -77,7 +75,6 @@ def _load_suvi_composites(date):
7775
os.remove(fn)
7876
return composites
7977

80-
8178
@staticmethod
8279
def create_empty():
8380
mapping = {"94": Image(np.zeros((1280, 1280)), {}),
@@ -95,6 +92,52 @@ def __getitem__(self, key):
9592
def channels(self):
9693
return list(self.images.keys())
9794

95+
def get_solar_radius(self, channel="304", refine=True):
96+
"""
97+
Gets the solar radius from the header of the specified channel
98+
:param channel: channel to get radius from
99+
:param refine: whether to refine the metadata radius to better approximate the edge
100+
:return: solar radius specified in the header
101+
"""
102+
103+
# Return the solar radius
104+
if channel not in self.channels():
105+
raise RuntimeError("Channel requested must be one of {}".format(self.channels()))
106+
try:
107+
solar_radius = self.images[channel].header['DIAM_SUN'] / 2
108+
if refine:
109+
composite_img = self.images[channel].data
110+
# Determine image size
111+
image_size = np.shape(composite_img)[0]
112+
# Find center and radial mesh grid
113+
center = (image_size / 2) - 0.5
114+
xm, ym = np.meshgrid(np.linspace(0, image_size - 1, num=image_size),
115+
np.linspace(0, image_size - 1, num=image_size))
116+
xm_c = xm - center
117+
ym_c = ym - center
118+
rads = np.sqrt(xm_c ** 2 + ym_c ** 2)
119+
# Iterate through radii within a range past the solar radius
120+
accuracy = 15
121+
rad_iterate = np.linspace(solar_radius, solar_radius + 50, num=accuracy)
122+
img_avgs = []
123+
for rad in rad_iterate:
124+
# Create a temporary solar image corresponding to the layer
125+
solar_layer = np.zeros((image_size, image_size))
126+
# Find indices in mask of the layer
127+
indx_layer = np.where(rad >= rads)
128+
# Set temporary image corresponding to indices to solar image values
129+
solar_layer[indx_layer] = composite_img[indx_layer]
130+
# Appends average to image averages
131+
img_avgs.append(np.mean(solar_layer))
132+
# Find "drop off" where mask causes average image brightness to drop
133+
diff_avgs = np.asarray(img_avgs[0:accuracy - 1]) - np.asarray(img_avgs[1:accuracy])
134+
# Return the radius that best represents the edge of the sun
135+
solar_radius = rad_iterate[np.where(np.amax(diff_avgs))[0] + 1]
136+
except KeyError:
137+
raise RuntimeError("Header does not include the solar diameter or radius")
138+
else:
139+
return solar_radius
140+
98141

99142
class ThematicMap:
100143
def __init__(self, data, metadata, theme_mapping):

solarannotator/template.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from solarannotator.io import ImageSet, ThematicMap
2+
from datetime import datetime, timedelta
3+
import numpy as np
4+
5+
6+
def create_mask(radius, image_size):
7+
"""
8+
Inputs:
9+
- Radius: Radius (pixels) within which a certain theme should be assigned
10+
- Image size: tuple of (x, y) size (pixels) that represents size of image
11+
"""
12+
# Define image center
13+
center_x = (image_size[0] / 2) - 0.5
14+
center_y = (image_size[1] / 2) - 0.5
15+
16+
# Create mesh grid of image coordinates
17+
xm, ym = np.meshgrid(np.linspace(0, image_size[0] - 1, num=image_size[0]),
18+
np.linspace(0, image_size[1] - 1, num=image_size[1]))
19+
20+
# Center each mesh grid (zero at the center)
21+
xm_c = xm - center_x
22+
ym_c = ym - center_y
23+
24+
# Create array of radii
25+
rad = np.sqrt(xm_c ** 2 + ym_c ** 2)
26+
27+
# Create empty mask of same size as the image
28+
mask = np.zeros((image_size[0], image_size[1]))
29+
30+
# Apply the mask as true for anything within a radius
31+
mask[(rad < radius)] = 1
32+
33+
# Return the mask
34+
return mask.astype('bool')
35+
36+
37+
def create_thmap_template(image_set, limb_thickness=10):
38+
"""
39+
Input: Image set object as input, and limb thickness in pixels
40+
Output: thematic map object
41+
Process:
42+
- Get the solar radius with predefined function
43+
- Create empty thematic map
44+
- Define concentric layers separated by solar radius + limb thickness, and create thmap
45+
- Return the thematic map object
46+
47+
Note copied from Alison Jarvis
48+
"""
49+
50+
# Get the solar radius with class function
51+
solar_radius = image_set.get_solar_radius()
52+
53+
# Define end of disk and end of limb radii
54+
disk_radius = solar_radius - (limb_thickness / 2)
55+
limb_radius = solar_radius + (limb_thickness / 2)
56+
57+
# Create concentric layers for disk, limb, and outer space
58+
# First template layer, outer space (value 1) with same size as composites
59+
imagesize = np.shape(image_set['171'].data)
60+
thmap_data = np.ones(imagesize)
61+
# Mask out the limb (value 8)
62+
limb_mask = create_mask(limb_radius, imagesize)
63+
thmap_data[limb_mask] = 8
64+
# Mask out the disk with quiet sun (value 7)
65+
qs_mask = create_mask(disk_radius, imagesize)
66+
thmap_data[qs_mask] = 7
67+
68+
# Create a thematic map object with this data and return it
69+
theme_mapping = {1: 'outer_space', 3: 'bright_region', 4: 'filament', 5: 'prominence', 6: 'coronal_hole',
70+
7: 'quiet_sun', 8: 'limb', 9: 'flare'}
71+
thmap_template = ThematicMap(thmap_data, {'DATE-OBS': image_set['171'].header['DATE-OBS']}, theme_mapping)
72+
73+
# Return the thematic map object
74+
return thmap_template

tests/test_template.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from solarannotator.template import create_mask
2+
3+
4+
def test_mask_creation():
5+
mask = create_mask(500, (2048, 2048))
6+
assert not mask[0, 0]
7+
assert mask[1024, 1024]

0 commit comments

Comments
 (0)