import dateutil.parser # type: ignore
import glob
import os
import pickle
import astropy.constants as aconst
import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np
import sunkit_magex.pfss as pfsspy
import sunpy.map
import threadpoolctl
from astropy.coordinates import SkyCoord
from sunpy.net import Fido
from sunpy.net import attrs as a
# @st_cache_decorator
def get_pfss_hmimap(filepath, email, carrington_rot, date, rss=2.5, nrho=35):
"""
Downloading hmi map or calculating the PFSS solution
params
-------
filepath: str
Path to the hmimap, if exists.
email: str
The email address of a registered user
carrington_rot: int
The Carrington rotation corresponding to the hmi map
date: str
The date of the map. Format = 'YYYY/MM/DD'
rss: float (default = 2.5)
The height of the potential field source surface for the solution.
nrho: int (default = 35)
The resolution of the PFSS-solved field line objects
returns
-------
output: hmi_synoptic_map object
The PFSS-solution
"""
time = a.Time(date, date)
pfname = f"PFSS_output_{str(time.start.datetime.date())}_CR{str(carrington_rot)}_SS{str(rss)}_nrho{str(nrho)}.p"
# Check if PFSS file already exists locally:
print(f"Searching for PFSS file from {filepath}")
try:
with open(f"{filepath}/{pfname}", 'rb') as f:
u = pickle._Unpickler(f)
u.encoding = 'latin1'
output = u.load()
print("Found pickled PFSS file!")
# If not, then download MHI mag, calc. PFSS, and save as picle file for next time
except FileNotFoundError:
print("PFSS file not found.\nDownloading...")
series = a.jsoc.Series('hmi.synoptic_mr_polfil_720s')
crot = a.jsoc.PrimeKey('CAR_ROT', carrington_rot)
result = Fido.search(time, series, crot, a.jsoc.Notify(email))
files = Fido.fetch(result)
hmi_map = sunpy.map.Map(files[0])
# pfsspy.utils.fix_hmi_meta(hmi_map)
print('Data shape: ', hmi_map.data.shape)
hmi_map = hmi_map.resample([360, 180]*u.pix)
print('New shape: ', hmi_map.data.shape)
pfss_input = pfsspy.Input(hmi_map, nrho, rss)
output = pfsspy.pfss(pfss_input)
with open(pfname, 'wb') as f:
pickle.dump(output, f)
return output
def multicolorline(x, y, cvals, ax, vmin=-90, vmax=90):
"""
Constructs a line object, with each segment of the line color coded
Original example from: https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html
params
-------
x, y: float
cvals: str
ax: Figure.Axes object
vmin, vmax: int (default = -90, 90)
returns
-------
line: LineCollection object
"""
import cmasher as cmr
import matplotlib.colors as mcolors
from matplotlib.collections import LineCollection
# Create a set of line segments so that we can color them individually
# This creates the points as a N x 1 x 2 array so that we can stack points
# together easily to get the segments. The segments array for line collection
# needs to be (numlines) x (points per line) x 2 (for x and y)
points = np.array([x, y]).T.reshape(-1, 1, 2)
segments = np.concatenate([points[:-1], points[1:]], axis=1)
# Create a continuous norm to map from data points to colors
norm = plt.Normalize(vmin, vmax)
cmrmap = cmr.redshift
# sample the colormaps that you want to use. Use 90 from each so there is one
# color for each degree
colors_pos = cmrmap(np.linspace(0.0, 0.30, 45))
colors_neg = cmrmap(np.linspace(0.70, 1.0, 45))
# combine them and build a new colormap
colors = np.vstack((colors_pos, colors_neg))
mymap = mcolors.LinearSegmentedColormap.from_list('my_colormap', colors)
# establish the linecollection object
lc = LineCollection(segments, cmap=mymap, norm=norm)
# set the values used for colormapping
lc.set_array(cvals)
# set the width of line
lc.set_linewidth(3)
# this we want to return
line = ax.add_collection(lc)
return line
def get_field_line_coords(longitude, latitude, hmimap, seedheight):
"""
Returns triplets of open magnetic field line coordinates, and the field line object itself
params
-------
longitude: int/float
longitude of the seeding point for the FieldLine tracing
latitude: int/float
latitude of the seeding point for the FieldLine tracing
hmimap: hmi_synoptic_map object
hmimap
seedheight: float
Heliocentric height of the seeding point
returns
-------
coordlist: list[list[float,float,float]]
The list of lists of all coordinate triplets that correspond to the FieldLine objects traced
flinelist: list[FieldLine]
List of all FieldLine objects traced
"""
# The amount of coordinate triplets we are going to trace
try:
coord_triplets = len(latitude)
except TypeError:
coord_triplets = 1
latitude = [latitude]
longitude = [longitude]
# The loop in which we trace the field lines and collect them to the coordlist
coordlist = []
flinelist = []
for i in range(coord_triplets):
# Inits for finding another seed point if we hit null or closed line
turn = 'lon'
sign_switch = 1
# Steps to the next corner, steps taken
corner_tracker = [1, 0]
init_lon, init_lat = longitude[i], latitude[i]
# Keep tracing the field line until a valid one is found
while (True):
# Trace a field line downward from the point lon,lat on the pfss
fline = trace_field_line(longitude[i], latitude[i], hmimap, seedheight=seedheight)
radius0 = fline.coords.radius[0].value
radius9 = fline.coords.radius[-1].value
bool_key = (radius0==radius9)
# If fline is not a valid field line, then spiral out from init_point and try again
# Also check if this is a null line (all coordinates identical)
# Also check if polarity is 0, meaning that the field line is NOT open
if ((len(fline.coords) < 10) or bool_key or fline.polarity==0): # fline.polarity==0
longitude[i], latitude[i], sign_switch, corner_tracker, turn = spiral_out(longitude[i], latitude[i], sign_switch, corner_tracker, turn)
# If there was nothing wrong, break the loop and proceed with the traced field line
else:
break
# Check that we are not too far from the original coordinate
if (corner_tracker[0] >= 10):
print(f"no open field line found in the vicinity of {np.rad2deg(init_lon)}°, {np.rad2deg(init_lat)}°")
break
# Get the field line coordinate values in the correct order
# Start on the photopshere, end at the pfss
fl_r, fl_lon, fl_lat = get_coord_values(fline)
# Fill in the lists
triplet = [fl_r, fl_lon, fl_lat]
coordlist.append(triplet)
flinelist.append(fline)
return coordlist, flinelist
def vary_flines(lon, lat, hmimap, n_varies, seedheight):
"""
Finds a set of sub-pfss fieldlines connected to or very near a single footpoint on the pfss.
lon: longitude of the footpoint [rad]
lat: latitude of the footpoint [rad]
n_varies: tuple that holds the amount of circles and the number of dummy flines per circle
if type(n_varies)=int, consider that as the amount of circles, and set the
amount of dummy flines per circle to 16
params
-------
lon: int/float
The longitude of the footpoint in radians
lat: int/float
The latitude of the footpoint in radians
hmimap: hmi_synoptic_map object
The pfss-solution used to calculate the field lines
n_varies: list[int,int] or int
A list that holds the amount of circles and the number of dummy flines per circle
if type(n_varies)=int, consider that as the amount of circles, and set the
amount of dummy flines per circle to 16
seedheight: float
Heliocentric height of the tracing starting point
returns
-------
coordlist: list[float,float,float]
List of coordinate triplets of the original field lines (lon,lat,height)
flinelist: list[FieldLine-object]
List of Fieldline objects of the original field lines
varycoords: list[float,float,float]
List of coordinate triplets of the varied field lines
varyflines: list[FieldLine-object]
List of Fieldline objects of the varied field lines
"""
# Field lines per n_circles (circle)
if isinstance(n_varies, (list, tuple)):
print(f"n_varies: {n_varies}")
n_circles = n_varies[0]
n_flines = n_varies[1]
else:
n_circles = n_varies
n_flines = 16
# First produce new points around the given lonlat_pair
lons, lats= np.array([lon]), np.array([lat])
increments = np.array([0.03, 0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.17, 0.19, 0.21, 0.23, 0.25, 0.27, 0.29])
for circle in range(n_circles):
newlons, newlats = circle_around(lon, lat, n_flines, r=increments[circle])
lons, lats = np.append(lons, newlons), np.append(lats, newlats)
pointlist = np.array([lons, lats])
# Trace fieldlines from all of these points
varycoords, varyflines = get_field_line_coords(pointlist[0], pointlist[1], hmimap, seedheight)
# Because the original fieldlines and the varied ones are all in the same arrays,
# Extract the varied ones to their own arrays
coordlist, flinelist = [], []
# Total amount of flines = 1 + (circles) * (fieldlines_per_circle)
total_per_fp = n_flines*n_circles+1
erased_indices = []
for i in range(len(varycoords)):
# n_flines*n_circles = the amount of extra field lines between each "original" field line
if i%(total_per_fp)==0:
erased_indices.append(i)
# pop(i) removes the ith element from the list and returns it
# -> we append it to the list of original footpoint fieldlines
coordlist.append(varycoords[i]) # .pop(i)
flinelist.append(varyflines[i])
# Really ugly quick fix to erase values from varycoords and varyflines
for increment, index in enumerate(erased_indices):
varycoords.pop(index-increment)
varyflines.pop(index-increment)
return coordlist, flinelist, varycoords, varyflines
def trace_field_line(lon0, lat0, hmimap, seedheight, rad=True):
"""
Traces a single open magnetic field line at coordinates (lon0,lat0) on the pfss down
to the photosphere
Parameters
----------
lon0, lat0: float
Longitude and latitude of the seedpoint
hmimap: hmimap-object
hmimap
seedheight: float
The height at which field line tracing is started (in solar radii)
rad: bool, (default True)
Wether or not input coordinates are in radians. If False, consider them degrees
Returns
-------
field_lines: FieldLine or list[FieldLine]
A FieldLine object, or a list of them, if input coordinates were a list
"""
# from pfsspy import tracing
# if lat0 and lon0 are given in deg for some reason, transform them to rad
if not rad:
lat0 = np.deg2rad(lat0)
lon0 = np.deg2rad(lon0)
# Start tracing from a given height
height = seedheight*aconst.R_sun
tracer = pfsspy.tracing.PythonTracer()
# Add unit to longitude and latitude, so that SkyCoord understands them
lon, lat = lon0*u.rad, lat0*u.rad
# Seed the starting coordinate at the desired coordinates
seed = SkyCoord(lon, lat, height, frame=hmimap.coordinate_frame)
# Trace the field line from the seed point given the hmi map
field_lines = tracer.trace(seed, hmimap)
# Field_lines could be list of len=1, because there's only one seed point given to the tracer
if len(field_lines) == 1:
return field_lines[0]
else:
return field_lines
def spiral_out(lon, lat, sign_switch, corner_tracker, turn):
"""
Moves the seeding point in an outward spiral.
Parameters
---------
lon, lat: float
the carrington coordinates on a surface of a sphere (sun or pfss)
sign_switch: int
-1 or 1, dictates the direction in which lon or lat is incremented
corner_tracker: tuple
first entry is steps_unti_corner, int that tells how many steps to the next corner of a spiral
the second entry is steps taken on a given spiral turn
turn: str
indicates which is to be incremented, lon or lat
returns
-----------
lon, lat: float
new coordinate pair
"""
# In radians, 1 rad \approx 57.3 deg
step = 0.005
# Keeps track of how many steps until it's time to turn
steps_until_corner, steps_moved = corner_tracker[0], corner_tracker[1]
if turn=='lon':
lon = lon + step*sign_switch
lat = lat
steps_moved += 1
# We have arrived in a corner, time to move in lat direction
if steps_until_corner == steps_moved:
steps_moved = 0
turn = 'lat'
return lon, lat, sign_switch, [steps_until_corner, steps_moved], turn
if turn=='lat':
lon = lon
lat = lat + step*sign_switch
steps_moved += 1
# Hit a corner; start moving in the lon direction
if steps_until_corner == steps_moved:
steps_moved = 0
steps_until_corner += 1
turn = 'lon'
sign_switch = sign_switch*(-1)
return lon, lat, sign_switch, [steps_until_corner, steps_moved], turn
def get_coord_values(field_line):
"""
Gets the coordinate values from FieldLine object and makes sure that they are in the right order.
params
-------
field_line: FieldLine object
returns
-------
fl_r: list[float]
The list of heliocentric distances of each segment of the field line
fl_lon: list[float]
The list of longitudes of each field line segment
fl_lat: list[float]
The list of latitudes of each field line segment
"""
# first check that the field_line object is oriented correctly (start on photosphere and end at pfss)
fl_coordinates = field_line.coords
fl_coordinates = check_field_line_alignment(fl_coordinates)
fl_r = fl_coordinates.radius.value / aconst.R_sun.value
fl_lon = fl_coordinates.lon.value
fl_lat = fl_coordinates.lat.value
return fl_r, fl_lon, fl_lat
def circle_around(x, y, n, r=0.1):
"""
Produces new points around a (x,y) point in a circle.
At the moment does not work perfectly in the immediate vicinity of either pole.
params
-------
x,y: int/float
Coordinates of the original point
n: int
The amount of new points around the origin
r: int/float (default = 0.1)
The radius of the circle at which new points are placed
returns
-------
pointlist: list[float]
List of new points (tuples) around the original point in a circle, placed at equal intervals
"""
origin = (x, y)
x_coords = np.array([])
y_coords = np.array([])
for i in range(0, n):
theta = (2*i*np.pi)/n
newx = origin[0] + r*np.cos(theta)
newy = origin[1] + r*np.sin(theta)
if newx >= 2*np.pi:
newx = newx - 2*np.pi
if newy > np.pi/2:
overflow = newy - np.pi/2
newy = newy - 2*overflow
if newy < -np.pi/2:
overflow = newy + np.pi/2
newy = newy + 2*overflow
x_coords = np.append(x_coords, newx)
y_coords = np.append(y_coords, newy)
pointlist = np.array([x_coords, y_coords])
return pointlist
def check_field_line_alignment(coordinates):
"""
Checks that a field line object is oriented such that it starts from
the photpshere and ends at the pfss. If that is not the case, then
flips the field line coordinates over and returns the flipped object.
"""
fl_r = coordinates.radius.value
if fl_r[0] > fl_r[-1]:
coordinates = np.flip(coordinates)
return coordinates
def spheric2cartesian(r, theta, phi):
"""
Does a coordinate transformation from spherical to cartesian.
For Stonyhurst heliographic coordinates, this means converting to HEEQ
(Heliocentric Earth Equatorial), following Eq. (2) of Thompson (2006),
DOI: 10.1051/0004-6361:20054262
r : the distance to the origin
theta : the elevation angle (goes from -pi to pi)
phi : the azimuth angle (goes from 0 to 2pi)
"""
x = r * np.cos(phi) * np.cos(theta)
y = r * np.sin(phi) * np.cos(theta)
z = r * np.sin(theta)
return x, y, z
def sphere(radius, clr, dist=0):
"""
Constructs a sphere with a given radius and color.
params
---------
radius : float, int
The radius of the sphere
clr : str
The color code for the sphere
dist : float, int
The displacement of the sphere, if not centered at origin
"""
import plotly.graph_objects as go
# Set up 100 points. First, do angles
phi = np.linspace(0, 2*np.pi, 100) # phi, the azimuthal angle goes from 0 to 2pi
theta = np.linspace(0, np.pi, 100) # theta, the elevation angle goes from 0 to pi
# Set up coordinates for points on the sphere
x0 = dist + radius * np.outer(np.cos(phi), np.sin(theta))
y0 = radius * np.outer(np.sin(phi), np.sin(theta))
z0 = radius * np.outer(np.ones(100), np.cos(theta))
# Set up trace (the object that is then plottable)
trace= go.Surface(x=x0, y=y0, z=z0, colorscale=[[0, clr], [1, clr]], showscale=False, name="Sun")
return trace
[docs]
def calculate_pfss_solution(gong_map, rss, coord_sys, nrho=35):
"""
Calculates a Potential Field Source Surface (PFSS) solution based on a GONG map and parameters.
Parameters
----------
gong_map : {sunpy.map.Map}
GONG map in Carrington or Stonyhurst coordinates, obtained with get_gong_map()
rss : {float}
source surface height in solar radii
coord_sys: {str}
cordinate system used: either 'car' or 'Carrington', or 'sto' or 'Stonyhurst'
nrho : {float/int, optional}
rho = ln(r) -> nrho is the amount of points in this logarithmic range. Default is 35.
Returns
-------
pfss_solution : {pfsspy.Output}
The PFSS solution that can be used to plot magnetic field lines under the source surface
"""
# GONG map is in Carrington coordinates
if gong_map.coordinate_system.axis1=='CRLN-CEA':
if coord_sys.lower().startswith('sto'):
# Convert GONG map from default Carrington to Stonyhurst coordinate system
new_map_header = sunpy.map.header_helper.make_heliographic_header(date=gong_map.date, observer_coordinate=gong_map.observer_coordinate, shape=gong_map.data.shape, frame='stonyhurst', projection_code='CEA')
if coord_sys.lower().startswith('car'):
# Convert GONG map from default Carrington to Carrington coordinate system.
# This sounds useless, but is needed to rebuild the meta data of the GONG map when using sunpy>5.1.0
# cf. https://github.com/sunpy/sunpy/issues/7313
new_map_header = sunpy.map.header_helper.make_heliographic_header(date=gong_map.date, observer_coordinate=gong_map.observer_coordinate, shape=gong_map.data.shape, frame='carrington', projection_code='CEA')
gong_map = gong_map.reproject_to(new_map_header)
# GONG map is in Stonyhurst coordinates
elif gong_map.coordinate_system.axis1=='HGLN-CEA':
if coord_sys.lower().startswith('car'):
# Convert GONG map from Stonyhurst to Carrington coordinate system.
# This shouldn't be necessary, as Carrington is the default for GONG maps, but better be sure.
new_map_header = sunpy.map.header_helper.make_heliographic_header(date=gong_map.date, observer_coordinate=gong_map.observer_coordinate, shape=gong_map.data.shape, frame='carrington', projection_code='CEA')
gong_map = gong_map.reproject_to(new_map_header)
# The pfss input object, assembled from a gong map, resolution (nrho) and source surface height (rss)
pfss_in = pfsspy.Input(gong_map, nrho, rss)
# This is the pfss solution, calculated from the input object
with threadpoolctl.threadpool_limits(limits=1, user_api='blas'):
pfss_solution = pfsspy.pfss(pfss_in)
return pfss_solution
def load_gong_map(filepath:str):
"""
https://gong.nso.edu/data/magmap/
https://docs.sunpy.org/en/v4.0.6/generated/api/sunpy.net.dataretriever.GONGClient.html
https://gong2.nso.edu/oQR/zqs/202104/mrzqs210413/
Raises:
-------
RuntimeError: No maps loaded
If filepath==[]
ValueError: Invalid input: {filepath}
If filepath is None or int
"""
# Load a GONG (Global Oscillation Network Group) synoptic magnetic map
return sunpy.map.Map(filepath)
def download_gong_map(timestr: str, tolerance: int, filepath: str, verbose: bool):
"""
Gets the download link for a GONG synoptic map
Parameters
----------
timestr : {str}
String representation of the date and time, e.g., '2025-02-13 12:00'
tolerance : {int}
The maximum time difference, in hours, that is tolerated between the map and the given time string.
filepath : {str}
"""
import pandas as pd
from sunpy.net import Fido, attrs
if filepath is None:
filepath = os.getcwd()
desired_time = pd.to_datetime(timestr, yearfirst=True)
# Check tolerance type before feeding to pd.Timedelta
if not isinstance(tolerance, int):
raise TypeError(f"Input parameter 'tolerance' must be type int, but {type(tolerance)} was passed.")
desired_time_plus_hours = desired_time + pd.Timedelta(hours=tolerance)
search_results = Fido.search(attrs.Time(desired_time, desired_time_plus_hours), attrs.Instrument("GONG"))
if verbose:
print(search_results)
# Handle search errors
try:
if getattr(search_results, "errors", None):
formatted = _format_errors(search_results.errors)
print(f"GONG search errors:\n{formatted}")
try:
import streamlit as st
st.error(f"GONG search errors:\n{formatted}")
except ImportError:
pass
except AttributeError:
pass
# result is a type of sunpy.net.fido_factory.UnifiedResponse, which is similar to a pd.DataFrame.
# It contains the queries for the given time period of existing gong maps that can be downloaded.
# All of the listed files will be downloaded, but that's undesired, so choose only the first entry
if search_results.file_num > 1:
search_results = search_results[0][0]
elif search_results.file_num == 0:
msg = (f"No GONG maps found online for the given time range: {desired_time} to {desired_time_plus_hours}.")
print(msg)
try:
import streamlit as st
st.error(msg)
except ImportError:
pass
file = Fido.fetch(search_results, path=filepath)
# Handle fetch errors
try:
if getattr(file, "errors", None):
formatted = _format_errors(file.errors)
print(f"GONG download errors:\n{formatted}")
try:
import streamlit as st
st.error(f"GONG download errors:\n{formatted}")
except ImportError:
pass
except AttributeError:
pass
if verbose:
print(f"Downloaded GONG map to {file.data[0]}")
return file.data[0]
def construct_gongmap_filepath(timestr:str, directory:str, verbose:bool):
"""
Constructs a default filepath
Parameters
----------
timestr : {str}
The datetime string, e.g., '2025-09-16'
directory : {str}
The directory where the gong map is expected to be found
verbose : {bool}
Prints mid-step information.
Returns
-------
filepath : {str, None}
The path to the file, including the name of the file.
If file not found, returns None.
"""
dtime = dateutil.parser.parse(timestr)
# If directory is None, (equivalent to not directory in logic), then use the current directory as a base
if not directory:
directory = os.getcwd()
carrington_rot = sunpy.coordinates.sun.carrington_rotation_number(t=timestr).astype(int) # dynamic CR from date
filename = f"mrzqs{dtime.strftime('%y%m%dt%H')}*c{carrington_rot}_*.fits.gz"
filepath = f"{directory}{os.sep}{filename}"
filepaths = glob.glob(filepath)
# If the list is not empty, there is at least one match
if len(filepaths) > 0:
# Make sure the paths are sorted (in case more than one)
filepaths = sorted(filepaths)
filepath = filepaths[0]
# No matches -> return None
else:
filepath = None
# Just for printing information
if verbose:
if filepath is None:
print(f"Automatic file search based on time ({timestr}) failed in directory: {directory}")
else:
print(f"Automatic file search based on time ({timestr}) found: {filepath}")
return filepath
[docs]
def get_gong_map(time:str, filepath:str=None, autodownload:bool=True, tolerance:int=1, verbose:bool=True):
"""
Returns a GONG map if one is found locally or if autodownload is True and a matching
map can be downloaded. Returns None if no map is found and autodownload is False.
Parameters
----------
time : str
A pandas-compatible timestring, e.g., '2010-11-29 12:45'.
filepath : str, optional, default=None
Accepted forms:
- Full path including filename ending in '.fits.gz', e.g.,
'/path/to/mrzqs211009t0814c2249_105.fits.gz'. Used directly.
- Directory path only. The expected filename is constructed from `time`
and the directory is searched for a matching file.
- None. The current working directory is used as the search directory.
In all cases, the resolved directory is also used as the download
destination if autodownload is triggered.
autodownload : bool, optional, default=True
If the file is not found locally, attempt to download it automatically.
tolerance : int, optional, default=1
Maximum allowed difference in hours between the requested timestamp and
the timestamp of the GONG map.
verbose : bool, optional, default=True
Enables print statements inside the called functions.
Returns
-------
gong_map : object or None
The loaded GONG map, or None if no map was found and autodownload is False.
"""
# Try to construct a complete filepath (including filename) from current working directory and given datetime.
# Here if filepath==None -> the second clause is not checked, i.e., no TypeError will be raised
# for trying to subscript a NoneType.
if not filepath or filepath[-8:] not in (".fits.gz"):
# Remember the given path to where the file is expected to be, before generating one with
# the complete path including the filename. If a file is not found in the
save_dir = filepath if filepath is not None else os.getcwd()
filepath = construct_gongmap_filepath(timestr=time, directory=filepath, verbose=verbose)
try:
gong_map = load_gong_map(filepath=filepath)
except ValueError as vee_ee:
# ValueError is raised because a gong map was not found from the specified path
if autodownload:
new_filepath = download_gong_map(time, tolerance=tolerance, filepath=save_dir, verbose=verbose)
gong_map = load_gong_map(filepath=new_filepath)
else:
gong_map = None
# These are printed even if verbose=False, to avert a situation where user THINKS that
# they acquired a map but actually did not. One might consider changing this.
print(f"GONG map not found locally from given path: {save_dir}")
print("Automatic downloading not enabled, no GONG map obtained.")
return gong_map
def _format_errors(errors):
"""Convert an errors list or object into a human-readable string."""
if isinstance(errors, (list, tuple)):
# Join each item as string, separated by newlines or commas
return "\n".join(str(e) for e in errors)
return str(errors)