diff --git a/satellite_tle/__init__.py b/satellite_tle/__init__.py new file mode 100644 index 0000000..42d10af --- /dev/null +++ b/satellite_tle/__init__.py @@ -0,0 +1,2 @@ +from .fetch_tle import * # noqa +from .fetch_tles import * # noqa diff --git a/satellite_tle/fetch_tle.py b/satellite_tle/fetch_tle.py new file mode 100644 index 0000000..68c3807 --- /dev/null +++ b/satellite_tle/fetch_tle.py @@ -0,0 +1,64 @@ +import csv +from lxml import html +import requests +import pkg_resources + +SOURCES_LIST = pkg_resources.resource_filename('satellite_tle', 'sources.csv') + + +def get_tle_sources(): + ''' + Returns a list of (source, url)-tuples for well-known TLE sources. + ''' + + sources = [] + + with open(SOURCES_LIST) as csvfile: + csv_reader = csv.reader(csvfile, + delimiter=',', + quotechar='\'', + quoting=csv.QUOTE_NONNUMERIC) + for row in csv_reader: + source, url = row + sources.append((source, url)) + + return sources + + +def fetch_tle_from_celestrak(norad_cat_id): + ''' + Returns the TLE for a given norad_cat_id as currently available from CelesTrak. + Raises IndexError if no data is available for the given norad_cat_id. + ''' + + r = requests.get('https://www.celestrak.com/satcat/tle.php?CATNR={}'.format(norad_cat_id)) + page = html.fromstring(r.text) + + tle = page.xpath('//pre/text()')[0].split('\n') + if tle[1].strip() == 'No TLE found': + raise LookupError + + return tle[1].strip(), tle[2].strip(), tle[3].strip() + + +def fetch_tles_from_url(url): + ''' + Downloads the TLE set from the given url. + Returns a dictionary of the form {norad_id1: tle1, norad_id2: tle2} for all TLEs found. + tleN is returned as list of three strings: [satellite_name, line1, line2]. + ''' + + r = requests.get(url) + + tles = dict() + l0 = '' + l1 = '' + lines = r.text.splitlines() + for l in lines[:-1]: + if l[0] == '2': + norad_cat_id = int(l1[2:7].encode('ascii')) + tles[norad_cat_id] = (l0.strip(), l1, l) + l0 = l1 + l1 = l + + return tles diff --git a/satellite_tle/fetch_tles.py b/satellite_tle/fetch_tles.py new file mode 100644 index 0000000..916b757 --- /dev/null +++ b/satellite_tle/fetch_tles.py @@ -0,0 +1,66 @@ +from __future__ import print_function + +from sgp4.earth_gravity import wgs72 +from sgp4.io import twoline2rv + +from . import get_tle_sources, fetch_tles_from_url, fetch_tle_from_celestrak + + +def fetch_tles(requested_norad_ids): + ''' + Returns the most recent TLEs found for the requested satellites + available via Celestrak, CalPoly and AMSAT. + ''' + + # List of 2-tuples of the form (source, tle) + # source is a human-readable string + # tle is a 3-tuple of strings + tles = dict() + + def update_tles(source, tle): + if norad_id not in requested_norad_ids: + # Satellite not requested, + # skip. + return + + if norad_id not in tles.keys(): + # Satellite requested and first occurence in the downloaded data, + # store new TLE. + #print('Found {}'.format(norad_id)) + tles[norad_id] = source, tle + return + + # There are multiple TLEs for this satellite available. + # Parse and compare epoch of both TLEs and choose the most recent one. + current_sat = twoline2rv(tles[norad_id][1][1], tles[norad_id][1][2], wgs72) + new_sat = twoline2rv(tle[1], tle[2], wgs72) + if new_sat.epoch > current_sat.epoch: + # Found a more recent TLE than the current one, + # store the new TLE. + + tles[norad_id] = source, tle + + # Fetch TLE sets from well-known TLE sources + sources = get_tle_sources() + + for source, url in sources: + #print('Fetch from {}'.format(url)) + new_tles = fetch_tles_from_url(url=url) + + for norad_id, tle in new_tles.items(): + update_tles(source, tle) + + # Try fetching missing sats from another Celestrak endoint + missing_norad_ids = set(requested_norad_ids) - set(tles.keys()) + + for norad_id in missing_norad_ids: + try: + #print('Fetching {} from Celestrak (satcat):'.format(norad_id), end='') + tle = fetch_tle_from_celestrak(norad_id) + #print(' ok, ', end='') + update_tles('Celestrak (satcat)', tle) + except LookupError: + #print(' failed.') + continue + + return tles diff --git a/satellite_tle/sources.csv b/satellite_tle/sources.csv new file mode 100644 index 0000000..2752a80 --- /dev/null +++ b/satellite_tle/sources.csv @@ -0,0 +1,46 @@ +'CalPoly','http://mstl.atl.calpoly.edu/~ops/keps/kepler.txt' +'AMSAT','https://www.amsat.org/amsat/ftp/keps/current/nasabare.txt' +'Celestrak (tle-new)','https://www.celestrak.com/NORAD/elements/tle-new.txt' +'Celestrak (stations)','https://www.celestrak.com/NORAD/elements/stations.txt' +'Celestrak (visual)','https://www.celestrak.com/NORAD/elements/visual.txt' +'Celestrak (1999-025)','https://www.celestrak.com/NORAD/elements/1999-025.txt' +'Celestrak (iridium-33-debris)','https://www.celestrak.com/NORAD/elements/iridium-33-debris.txt' +'Celestrak (cosmos-2251-debris)','https://www.celestrak.com/NORAD/elements/cosmos-2251-debris.txt' +'Celestrak (2012-044)','https://www.celestrak.com/NORAD/elements/2012-044.txt' +'Celestrak (weather)','https://www.celestrak.com/NORAD/elements/weather.txt' +'Celestrak (noaa)','https://www.celestrak.com/NORAD/elements/noaa.txt' +'Celestrak (goes)','https://www.celestrak.com/NORAD/elements/goes.txt' +'Celestrak (resource)','https://www.celestrak.com/NORAD/elements/resource.txt' +'Celestrak (sarsat)','https://www.celestrak.com/NORAD/elements/sarsat.txt' +'Celestrak (dmc)','https://www.celestrak.com/NORAD/elements/dmc.txt' +'Celestrak (tdrss)','https://www.celestrak.com/NORAD/elements/tdrss.txt' +'Celestrak (argos)','https://www.celestrak.com/NORAD/elements/argos.txt' +'Celestrak (planet)','https://www.celestrak.com/NORAD/elements/planet.txt' +'Celestrak (spire)','https://www.celestrak.com/NORAD/elements/spire.txt' +'Celestrak (geo)','https://www.celestrak.com/NORAD/elements/geo.txt' +'Celestrak (intelsat)','https://www.celestrak.com/NORAD/elements/intelsat.txt' +'Celestrak (ses)','https://www.celestrak.com/NORAD/elements/ses.txt' +'Celestrak (iridium)','https://www.celestrak.com/NORAD/elements/iridium.txt' +'Celestrak (iridium-NEXT)','https://www.celestrak.com/NORAD/elements/iridium-NEXT.txt' +'Celestrak (orbcomm)','https://www.celestrak.com/NORAD/elements/orbcomm.txt' +'Celestrak (globalstar)','https://www.celestrak.com/NORAD/elements/globalstar.txt' +'Celestrak (amateur)','https://www.celestrak.com/NORAD/elements/amateur.txt' +'Celestrak (other-comm)','https://www.celestrak.com/NORAD/elements/other-comm.txt' +'Celestrak (gorizont)','https://www.celestrak.com/NORAD/elements/gorizont.txt' +'Celestrak (raduga)','https://www.celestrak.com/NORAD/elements/raduga.txt' +'Celestrak (molniya)','https://www.celestrak.com/NORAD/elements/molniya.txt' +'Celestrak (gps-ops)','https://www.celestrak.com/NORAD/elements/gps-ops.txt' +'Celestrak (glo-ops)','https://www.celestrak.com/NORAD/elements/glo-ops.txt' +'Celestrak (galileo)','https://www.celestrak.com/NORAD/elements/galileo.txt' +'Celestrak (beidou)','https://www.celestrak.com/NORAD/elements/beidou.txt' +'Celestrak (sbas)','https://www.celestrak.com/NORAD/elements/sbas.txt' +'Celestrak (nnss)','https://www.celestrak.com/NORAD/elements/nnss.txt' +'Celestrak (musson)','https://www.celestrak.com/NORAD/elements/musson.txt' +'Celestrak (science)','https://www.celestrak.com/NORAD/elements/science.txt' +'Celestrak (geodetic)','https://www.celestrak.com/NORAD/elements/geodetic.txt' +'Celestrak (engineering)','https://www.celestrak.com/NORAD/elements/engineering.txt' +'Celestrak (education)','https://www.celestrak.com/NORAD/elements/education.txt' +'Celestrak (military)','https://www.celestrak.com/NORAD/elements/military.txt' +'Celestrak (radar)','https://www.celestrak.com/NORAD/elements/radar.txt' +'Celestrak (cubesat)','https://www.celestrak.com/NORAD/elements/cubesat.txt' +'Celestrak (other)','https://www.celestrak.com/NORAD/elements/other.txt' diff --git a/satnogs.py b/satnogs.py new file mode 100644 index 0000000..3b3afbd --- /dev/null +++ b/satnogs.py @@ -0,0 +1,206 @@ +from datetime import datetime , timedelta +import requests +from flask import Flask , render_template,redirect,url_for +import json +from apscheduler.schedulers.background import BackgroundScheduler +import ephem +from satnogs_api_client import fetch_satellites, DB_BASE_URL +from satellite_tle import fetch_tles + +scheduler = BackgroundScheduler() +app = Flask(__name__) + +modes = {1: 'FM', 2: 'AFSK1k2', 5: 'SSTV', 6: 'CW', 7: 'FMN', 9: 'USB', 15: 'GFSK4k8', 17: 'AHRPT', 18: 'AFSK9k6', 19: 'AM', 20: 'LSB', 21: 'FSK1k2', 22: 'FSK9k6', 26: 'GFSK1k2', 27: 'GFSK2k4', 28: 'GFSK9k6', 29: 'GFSK19k2', 30: 'MSK1k2', 31: 'MSK2k4', 32: 'MSK4k8', 33: 'MSK9k6', 34: 'MSK19k2', 35: 'MSK38k4', 36: 'GMSK1k2', 37: 'GMSK2k4', 38: 'GMSK4k8', 39: 'GMSK9k6', 40: 'PSK31', 41: 'PSK63', 42: 'QPSK31', 43: 'QPSK63', 44: 'APT', 45: 'HRPT', 46: 'FSK4k8', 47: 'BPSK1k2', 48: 'GMSK19k2', 49: 'AFSK', 50: 'BPSK', 51: 'FSK19k2', 52: 'BPSK115k2', 53: 'LRPT', 54: 'BPSK9k6', 55: 'FFSK1k2', 56: 'FSK2k4', 57: 'DSTAR', 58: 'DUV', 59: 'CERTO', 60: 'BPSK400', 61: 'OFDM', 62: 'QPSK38k4'} + + +Passes = [] +Stations = [] +TLEs = {} + +class Pass: + id = 0 + start = None + end = None + ground_station = None + satellite = None + transmitter = None + + def __repr__(self): + return "\n: {}\n: {}\n: {}\n: {}\n {}\n: {}".format(self.id,self.start.strftime('%Y-%m-%dT%H:%M:%S%z'),self.end.strftime('%Y-%m-%dT%H:%M:%S%z'),self.ground_station,json.dumps(self.satellite,indent = 1),json.dumps(self.transmitter,indent=1)) + + + +def getActive(): + start = (datetime.utcnow() - timedelta(0,0,0,0,20)).strftime('%Y-%m-%dT%H:%M:%S%z') + end = (datetime.utcnow() + timedelta(0,0,0,0,30)).strftime('%Y-%m-%dT%H:%M:%S%z') + + passesR = requests.get("https://network.satnogs.org/api/observations/?end="+end+"&format=json&start="+start) + passes = passesR.json() + if passesR.links.has_key("next"): + while passesR.links.has_key("next"): + passesR = requests.get(passesR.links["next"]["url"]) + passes += passesR.json() + ground_stations = {} + for x in passes: + if datetime.strptime(x["start"],'%Y-%m-%dT%H:%M:%Sz') > datetime.utcnow() or datetime.strptime(x["end"],'%Y-%m-%dT%H:%M:%Sz') < datetime.utcnow(): + passes.remove(x) + else: + if ground_stations.has_key(x["ground_station"]): + ground_stations[x["ground_station"]].append(x) + else: + ground_stations[x["ground_station"]] = [] + ground_stations[x["ground_station"]].append(x) + passes = [] + for x in ground_stations: + start = datetime.utcnow() + current = {"start":datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S%z')+"z"} + for y in ground_stations[x]: + if datetime.strptime(y["start"],'%Y-%m-%dT%H:%M:%Sz') < datetime.strptime(current["start"],'%Y-%m-%dT%H:%M:%Sz'): + current = y + passes.append(current) + Passes =[] + for x in passes: + temp = Pass() + temp.id = x["id"] + temp.start = datetime.strptime(x["start"],'%Y-%m-%dT%H:%M:%Sz') + temp.end = datetime.strptime(x["end"],'%Y-%m-%dT%H:%M:%Sz') + temp.ground_station = x["ground_station"] + temp.satellite = requests.get("https://db.satnogs.org/api/satellites/"+str(x["norad_cat_id"])) .json() + temp.transmitter = requests.get("https://db.satnogs.org/api/transmitters/"+x["transmitter"]).json() + Passes.append(temp) + + + + return Passes + + +def GetGroundStations(): + stationsR = requests.get("https://network.satnogs.org/api/stations/") + stations = stationsR.json() + while stationsR.links.has_key("next"): + stationsR = requests.get(stationsR.links["next"]["url"]) + stations += stationsR.json() + + for x in stations: + if x["last_seen"] == None: + stations.remove(x) + continue + + if datetime.strptime(x["last_seen"],'%Y-%m-%dT%H:%M:%Sz') < (datetime.utcnow()- timedelta(10,0,0,0)): + stations.remove(x) + for x in stations: + if x["last_seen"] == None: + stations.remove(x) + continue + + if datetime.strptime(x["last_seen"],'%Y-%m-%dT%H:%M:%Sz') < (datetime.utcnow()- timedelta(10,0,0,0)): + stations.remove(x) + + + return stations + + +@scheduler.scheduled_job('interval',minutes=3) +def updatePasses(): + global Passes + print "Updating Passes" + Passes = getActive() + +@scheduler.scheduled_job('interval',hours=10) +def updateStations(): + global Stations + print "Updating Stations" + Stations = GetGroundStations() + +@scheduler.scheduled_job('interval',days=1) +def updateTLE(): + print "Updating TLE" + sats = fetch_satellites(url=DB_BASE_URL, max_satellites=None) + satnogs_db_norad_ids = set(sat['norad_cat_id'] for sat in sats if sat['status'] != 're-entered') + # Remove satellites with temporary norad ids + temporary_norad_ids = set(filter(lambda norad_id: norad_id >= 99900, satnogs_db_norad_ids)) + satnogs_db_norad_ids = satnogs_db_norad_ids - temporary_norad_ids + + # Fetch TLEs for the satellites of interest + tles = fetch_tles(satnogs_db_norad_ids) + + for norad_id, (source, tle) in tles.items(): + TLEs[norad_id] = [str(tle[0]),str(tle[1]),str(tle[2])] + print('\nTLEs for {} of {} requested satellites found ({} satellites with temporary norad ids skipped).'.format(len(tles), len(satnogs_db_norad_ids), len(temporary_norad_ids))) + + + +@app.route('/') +def index(): + return 'Satnogs Network Info
Updated every 5 min
IDs of all occuring observations with links to the obs
IDs of all stations actively in an observation
All online stations
IDs of all occuring observations' + +@app.route("/map_view") +def map_view(): + stations = [] + for x in Stations: + stations.append({'id':x['id'],'name':x['name'],'lat_lng':[x["lat"],x['lng']]}) + return render_template("map.html",stations = stations) + +@app.route('/occuringobservations') +def occuring_observations(): + obs = [] + for x in Passes: + obs.append(""+str(x.id)+"") + return json.dumps(obs) + +@app.route('/api/activestations') +def api_active_stations(): + stations = [] + for x in Passes: + stations.append(x.ground_station) + return json.dumps(stations) + +@app.route('/api/onlinestations') +def api_online_stations(): + stations = [] + for x in Stations: + stations.append(x["id"]) + return json.dumps(stations) + + + +@app.route('/api/occuringobservations') +def api_occuring_observations(): + obs = [] + for x in Passes: + obs.append(x.id) + return json.dumps(obs) + +@app.route('/api/occuringsats') +def api_occuring_sats(): + obs = {} + for x in Passes: + satellite = ephem.readtle(TLEs[x.satellite['norad_cat_id']][0],TLEs[x.satellite['norad_cat_id']][1],TLEs[x.satellite['norad_cat_id']][2]) + now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + satellite.compute(now) + lat = satellite.sublat*57.295779514 + long = satellite.sublong*57.295779514 + obs[x.satellite['norad_cat_id']] = {"name":x.satellite["name"],"transmitter_name":x.transmitter["description"],"transmitter_downlink":x.transmitter["downlink_low"],"transmitter_mode":modes[x.transmitter["mode_id"]],"lat_lng":[lat,long]} + return json.dumps(obs) + +@app.route('/api/satstationpairs') +def api_sat_station_pairs(): + pairs = [] + for x in Passes: + pairs.append([x.ground_station,x.satellite['norad_cat_id']]) + return json.dumps(pairs) + +@app.route('/api/getsatloc/') +def get_sat_loc(norad): + satellite = ephem.readtle(TLEs[norad][0],TLEs[norad][1],TLEs[norad][2]) + now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + satellite.compute(now) + lat = satellite.sublat*57.295779514 + long = satellite.sublong*57.295779514 + return json.dumps([lat,long]) + +updatePasses() +updateStations() +updateTLE() +scheduler.start() +app.run(use_reloader=False,host = "0.0.0.0") \ No newline at end of file diff --git a/satnogs_api_client.py b/satnogs_api_client.py new file mode 100644 index 0000000..82b5e9e --- /dev/null +++ b/satnogs_api_client.py @@ -0,0 +1,188 @@ +import requests +import re + + +NETWORK_DEV_BASE_URL = 'https://network-dev.satnogs.org' +NETWORK_BASE_URL = 'https://network.satnogs.org' +DB_BASE_URL = 'https://db.satnogs.org' +DB_DEV_BASE_URL = 'https://db-dev.satnogs.org' + + +def fetch_observation_data_from_id(norad_id, start, end, prod=True): + # Get all observations of the satellite with the given `norad_id` in the given timeframe + # https://network.satnogs.org/api/observations/?satellite__norad_cat_id=25544&start=2018-06-10T00:00&end=2018-06-15T00:00 + + query_str = '{}/api/observations/?satellite__norad_cat_id={}&start={}&end={}' + + url = query_str.format(NETWORK_BASE_URL if prod else NETWORK_DEV_BASE_URL, + norad_id, + start.isoformat(), + end.isoformat()) + + # print(url) + r = requests.get(url=url) + + if r.status_code != requests.codes.ok: + print("No observations found for {}, start: {}, end: {}.".format(norad_id, start_time, end_time)) + raise + + observations = r.json() + + next_page_available = ('Link' in r.headers.keys()) + + if next_page_available: + parts = r.headers['Link'].split(',') + for part in parts: + if part[-5:-1] == 'next': + next_page_url = part[1:-13] + + while next_page_available: + # print(next_page_url) + r = requests.get(url=next_page_url) + + observations.extend(r.json()) + + next_page_available = False + + if 'Link' in r.headers.keys(): + parts = r.headers['Link'].split(',') + for part in parts: + if part[-5:-1] == 'next': + next_page_url = part[1:-13] + next_page_available = True + + # Current prod is broken and can't filter on NORAD ID correctly, use client-side filtering instead + observations = list(filter(lambda o: o['norad_cat_id'] == norad_id, observations)) + + return observations + + +def fetch_observation_data(observation_ids, prod=True): + # Get station location from the observation via the observation_id + + observations = [] + for observation_id in observation_ids: + r = requests.get(url='{}/api/observations/{}/'.format(NETWORK_BASE_URL if prod else NETWORK_DEV_BASE_URL, + observation_id)) + if r.status_code != requests.codes.ok: + print("Observation {} not found in network.".format(observation_id)) + continue + observations.append(r.json()) + + return observations + +def fetch_ground_station_data(ground_station_ids, prod=True): + # Fetch ground station metadata from network + ground_stations = [] + for ground_station_id in ground_station_ids: + r = requests.get(url='{}/api/stations/{}/'.format(NETWORK_BASE_URL if prod else NETWORK_DEV_BASE_URL, + ground_station_id)) + if r.status_code != requests.codes.ok: + print("Ground Station {} not found in db.".format(ground_station_id)) + raise + data = r.json() + ground_stations.append(r.json()) + return ground_stations + +def fetch_satellite_data(norad_cat_id): + # Fetch satellite metadata from network + r = requests.get(url='{}/api/satellites/{}/'.format(DB_BASE_URL, norad_cat_id)) + if r.status_code != requests.codes.ok: + print("ERROR: Satellite {} not found in network.".format(norad_cat_id)) + + return r.json() + +def fetch_tle_of_observation(observation_id, prod=True): + url = '{}/observations/{}/'.format(NETWORK_BASE_URL if prod else NETWORK_DEV_BASE_URL, + observation_id) + r = requests.get(url=url) + observation_page_html = r.text + + regex = r"
1 (.*)
2 (.*)
" + matches = re.search(regex, observation_page_html) + + obs_tle_2 = '1 ' + matches.group(1) + obs_tle_3 = '2 ' + matches.group(2) + + return [obs_tle_2, obs_tle_3] + + +def fetch_telemetry(norad_id, max_frames, url): + # http://db-dev.satnogs.org/api/telemetry/?satellite=43595 + + query_str = '{}/api/telemetry/?satellite={}' + url = query_str.format(url, norad_id) + + try: + data = fetch_multi_page_api_endpoint(url, max_entries=max_frames) + except HTTPError: + print("No telemetry found for {}.".format(norad_id)) + raise + + return data + + +def fetch_satellites(max_satellites, url): + query_str = '{}/api/satellites/' + url = query_str.format(url) + + try: + data = fetch_multi_page_api_endpoint(url, max_entries=max_satellites) + except HTTPError: + print("An HTTPError occured.") + raise + + return data + +def fetch_multi_page_api_endpoint(url, max_entries): + # print(url) + r = requests.get(url=url) + r.raise_for_status() + + data = r.json() + + next_page_available = ('Link' in r.headers.keys()) + + if next_page_available and (not max_entries or len(data) < max_entries): + parts = r.headers['Link'].split(',') + for part in parts: + if part[-5:-1] == 'next': + next_page_url = part[1:-13] + + while next_page_available and (not max_entries or len(data) < max_entries): + # print(next_page_url) + r = requests.get(url=next_page_url) + + data.extend(r.json()) + + next_page_available = False + + if 'Link' in r.headers.keys(): + parts = r.headers['Link'].split(',') + for part in parts: + if part[-5:-1] == 'next': + next_page_url = part[1:-13] + next_page_available = True + + return data + + +def post_telemetry(norad_id, + source, + lon, + lat, + timestamp, + frame, + base_url=DB_DEV_BASE_URL): + payload = {'noradID': norad_id, + 'source': source, + 'timestamp': timestamp, + 'latitude': lat, + 'longitude': lon, + 'frame': frame} + + url = '{}/api/telemetry/'.format(base_url) + r = requests.post(url, data=payload) + + if r.status_code != 201: + print('ERROR {}: {}'.format(r.status_code, r.text)) diff --git a/static/active-station-marker.png b/static/active-station-marker.png new file mode 100644 index 0000000..0d9450c Binary files /dev/null and b/static/active-station-marker.png differ diff --git a/static/satellite-marker.png b/static/satellite-marker.png new file mode 100644 index 0000000..697846d Binary files /dev/null and b/static/satellite-marker.png differ diff --git a/static/satnogs-net-logo.png b/static/satnogs-net-logo.png new file mode 100644 index 0000000..30be271 Binary files /dev/null and b/static/satnogs-net-logo.png differ diff --git a/static/station-marker.png b/static/station-marker.png new file mode 100644 index 0000000..3a1de70 Binary files /dev/null and b/static/station-marker.png differ diff --git a/templates/map.html b/templates/map.html new file mode 100644 index 0000000..f631bf9 --- /dev/null +++ b/templates/map.html @@ -0,0 +1,147 @@ + + + + + + + + +
+
. +
+
+ +
+
+ +
+
+
+
+ + \ No newline at end of file