diff --git a/EDMarketConnector.py b/EDMarketConnector.py
index d050773f..b335e3ae 100755
--- a/EDMarketConnector.py
+++ b/EDMarketConnector.py
@@ -23,6 +23,7 @@ import eddn
import loadout
import flightlog
import stats
+import chart
import prefs
from config import appname, applongname, config
@@ -202,10 +203,20 @@ class AppWindow:
self.status['text'] = "Where are you?!" # Shouldn't happen
elif not data.get('ship') or not data['ship'].get('modules') or not data['ship'].get('name','').strip():
self.status['text'] = "What are you flying?!" # Shouldn't happen
+
elif (config.getint('output') & config.OUT_EDDN) and data['commander'].get('docked') and not data['lastStarport'].get('ships') and not retrying:
- # API is flakey about shipyard info - retry if missing (<1s is usually sufficient - 4s for margin).
+ # API is flakey about shipyard info - retry if missing (<1s is usually sufficient - 5s for margin).
self.w.after(shipyard_retry * 1000, lambda:self.getandsend(retrying=True))
+
+ # Stuff we can do while waiting for retry
+ if config.getint('output') & config.OUT_STAT:
+ chart.export(data)
+ if config.getint('output') & config.OUT_LOG:
+ flightlog.export(data)
+ if config.getint('output') & config.OUT_SHIP:
+ loadout.export(data)
return
+
else:
if __debug__ and retrying: print data['lastStarport'].get('ships') and 'Retry for shipyard - Success' or 'Retry for shipyard - Fail'
@@ -213,10 +224,14 @@ class AppWindow:
if __debug__: # Recording
with open('%s%s.%s.json' % (data['lastSystem']['name'], data['commander'].get('docked') and '.'+data['lastStarport']['name'] or '', strftime('%Y-%m-%dT%H.%M.%S', localtime())), 'wt') as h:
h.write(json.dumps(data, indent=2, sort_keys=True))
- if config.getint('output') & config.OUT_LOG:
- flightlog.export(data)
- if config.getint('output') & config.OUT_SHIP:
- loadout.export(data)
+
+ if not retrying:
+ if config.getint('output') & config.OUT_STAT:
+ chart.export(data)
+ if config.getint('output') & config.OUT_LOG:
+ flightlog.export(data)
+ if config.getint('output') & config.OUT_SHIP:
+ loadout.export(data)
if not (config.getint('output') & (config.OUT_CSV|config.OUT_TD|config.OUT_BPC|config.OUT_EDDN)):
# no further output requested
diff --git a/EDMarketConnector.wxs b/EDMarketConnector.wxs
index c23ad5c6..4461bbdc 100644
--- a/EDMarketConnector.wxs
+++ b/EDMarketConnector.wxs
@@ -51,6 +51,9 @@
+
+
+
@@ -78,6 +81,9 @@
+
+
+
@@ -318,6 +324,7 @@
+
@@ -327,6 +334,7 @@
+
diff --git a/README.md b/README.md
index e81175d1..3aa1e21a 100644
--- a/README.md
+++ b/README.md
@@ -5,12 +5,14 @@ This app downloads commodity market and other data from the game [Elite: Dangero
* sends the data to the [Elite Dangerous Data Network](http://eddn-gateway.elite-markets.net/) ("EDDN") from where you and others can use it via online trading tools such as [eddb](http://eddb.io/), [Elite Trade Net](http://etn.io/), [Inara](http://inara.cz), etc.
* saves the data to files on your computer that you can load into trading tools such as [Slopey's BPC Market Tool](https://forums.frontier.co.uk/showthread.php?t=76081), [Trade Dangerous](https://bitbucket.org/kfsone/tradedangerous/wiki/Home), [Thrudd's Trading Tools](http://www.elitetradingtool.co.uk/), [Inara](http://inara.cz), etc.
-* saves a record of your ship loadout and/or flight log.
+* saves a record of your ship loadout, flight log and/or statistics.
The user-interface is deliberately minimal - when you land at a station just switch to the app and press the "Update" button or press Enter to automatically download and transmit and/or save your choice of data.
 
+
+
Installation
--------
@@ -60,6 +62,9 @@ This app can save a variety of data in a variety of formats:
* Flight log
* Adds a record of your location, ship and cargo to a file that you can open in a text editor or a spreadsheet program such as Excel. Note: Don't edit, rename or move this file - take a copy if you wish to change it.
+* Cmdr statstics
+ * Generates a number of charts of your progress in an Excel file. Note: Don't edit, rename or move this file.
+
By default these files will be placed in your Documents folder. Since this app will create a lot of files if you use it for a while you may wish to create a separate folder for the files and tell the app to place them there.
Statistics
@@ -100,6 +105,8 @@ Linux:
* Requires the Python "imaging-tk", "iniparse" and "requests" modules. On Debian-based systems install these with `sudo apt-get install python-imaging-tk python-iniparse python-requests` .
* Run with `./EDMarketConnector.py` .
+On all platforms charting Cmdr statistics additionally requires verison 2.3.0 or later of the [openpyxl](https://pypi.python.org/pypi/openpyxl) module.
+
Packaging for distribution
--------
diff --git a/trends.py b/chart.py
old mode 100755
new mode 100644
similarity index 51%
rename from trends.py
rename to chart.py
index 202671a6..128d8cbb
--- a/trends.py
+++ b/chart.py
@@ -1,92 +1,45 @@
#!/usr/bin/python
#
-# Creates an Excel spreadsheet graphing player stats using data from .json dumps created by
-# EDMarketconnector in interactive mode.
+# Creates an Excel spreadsheet graphing player stats
#
-# Requires XlsxWriter
+# Requires openpyxl >= 2.3
#
-import json
-import os
-import re
+try:
+ import lxml._elementpath # Explicit dependency for py2exe
+ import openpyxl
+ if map(int, openpyxl.__version__.split('.')[:2]) < [2,3]:
+ raise ImportError()
+ have_openpyxl = True
+except:
+ have_openpyxl = False
+
import datetime
-import xlsxwriter
+import time
+import re
-
-workbook = xlsxwriter.Workbook('trends.xlsx')
-
-F_TITLE = workbook.add_format({'align': 'center', 'bold':True})
-F_SUB = workbook.add_format({'align': 'right', 'bold':True})
-F_DATE = workbook.add_format({'num_format': 'yy-mm-dd hh:mm:ss'})
-
-def makesheet(workbook, name, titles):
- worksheet = workbook.add_worksheet(name)
- worksheet.write(0, 0, 'Date', F_SUB)
- if isinstance(titles[0], tuple):
- start = end = 1
- for (head, subtitles) in titles:
- for i in range(len(subtitles)):
- worksheet.write(1, end, subtitles[i], F_SUB)
- end += 1
- worksheet.merge_range(0, start, 0, end-1, head, F_TITLE)
- start = end
- worksheet.set_column(0, end, 15)
- else:
- worksheet.set_column(0, len(titles), 15)
- for i in range(len(titles)):
- worksheet.write(0, i+1, titles[i], F_SUB)
- return worksheet
-
-def addrow(worksheet, row, dt, items):
- worksheet.write_datetime(row, 0, dt, F_DATE)
- for i in range(len(items)):
- worksheet.write(row, i+1, items[i])
-
-def makechart(workbook, worksheet, title, axes=None):
- chart = workbook.add_chart({'type': 'scatter', 'subtype': 'straight'})
- chart.set_title({'name': title})
- chart.set_size({'width': 2400, 'height': 1600})
- chart.set_x_axis({'date_axis': True, 'num_format': 'yyyy-mm-dd'})
- if axes:
- if isinstance(axes, list) or isinstance(axes, tuple):
- chart.set_y_axis( {'name': axes[0]})
- chart.set_y2_axis({'name': axes[1]})
- else:
- chart.set_y_axis( {'name': axes})
- worksheet.insert_chart('B2', chart)
- return chart
-
-
-
-inputs = {}
-regexp = re.compile('.+\.(\d\d\d\d\-\d\d\-\d\dT\d\d\.\d\d\.\d\d)\.json$')
-for f in os.listdir('.'):
- match = regexp.match(f)
- if match:
- inputs[datetime.datetime.strptime(match.group(1), '%Y-%m-%dT%H.%M.%S')] = json.loads(open(f).read())
-if not inputs:
- print "No data!"
- exit()
+from os.path import isfile, join
+from config import config
dataseries = [
{
'name': 'Combat',
- 'axes': ['qty', 'CR'],
+ 'axes': ['Quantity', 'Profit [CR]'],
'keys': [
('Bounties', ['stats', 'combat', 'bounty', 'qty']),
('Profit from bounties', ['stats', 'combat', 'bounty', 'value'], True),
('Bonds', ['stats', 'combat', 'bond', 'qty']),
('Profit from bonds', ['stats', 'combat', 'bond', 'value'], True),
- ('Assassin', ['stats', 'missions', 'assassin', 'missionsCompleted']),
+ ('Assassinations', ['stats', 'missions', 'assassin', 'missionsCompleted']),
('Profit from assassin', ['stats', 'missions', 'assassin', 'creditsEarned'], True),
('Hunting', ['stats', 'missions', 'bountyHunter', 'missionsCompleted']),
('Profit from hunting', ['stats', 'missions', 'bountyHunter', 'creditsEarned'], True),
- ],
+ ],
},
{
'name': 'Trade',
- 'axes': ['qty', 'CR'],
+ 'axes': ['Quantity', 'Profit [CR]'],
'keys': [
('Profit from trading', ['stats', 'trade', 'profit'], True),
('Commodities traded', ['stats', 'trade', 'qty']),
@@ -98,8 +51,8 @@ dataseries = [
],
},
{
- 'name': 'Explore',
- 'axes': ['qty', 'CR'],
+ 'name': 'Explorer',
+ 'axes': ['Quantity', 'Profit [CR]'],
'keys': [
('Profits from exploration', ['stats', 'explore', 'creditsEarned'], True),
('Discovery scans', ['stats', 'explore', 'scanSoldLevels', 'lev_0']),
@@ -111,7 +64,7 @@ dataseries = [
},
{
'name': 'Crime',
- 'axes': ['qty', 'CR'],
+ 'axes': ['Quantity', 'Profit [CR]'],
'keys': [
('Fines', ['stats', 'crime', 'fine', 'qty']),
('Lifetime fine value', ['stats', 'crime', 'fine', 'value'], True),
@@ -125,17 +78,19 @@ dataseries = [
},
{
'name': 'NPC',
+ 'axes': 'Quantity',
'prefix': ['stats', 'NPC', 'kills', 'ranks'],
'keys': [('Harmless', 'r0'), ('Mostly Harmless', 'r1'), ('Novice', 'r2'), ('Competent', 'r3'), ('Expert', 'r4'), ('Master', 'r5'), ('Dangerous', 'r6'), ('Deadly', 'r7'), ('Elite', 'r8'), ('Capital', 'rArray')],
},
{
'name': 'PVP',
+ 'axes': 'Quantity',
'prefix': ['stats', 'PVP', 'kills', 'ranks'],
'keys': [('Harmless', 'r0'), ('Mostly Harmless', 'r1'), ('Novice', 'r2'), ('Competent', 'r3'), ('Expert', 'r4'), ('Master', 'r5'), ('Dangerous', 'r6'), ('Deadly', 'r7'), ('Elite', 'r8'), ('Capital', 'rArray')],
},
{
'name': 'Balance',
- 'axes': ['qty', 'CR'],
+ 'axes': ['Quantity', '[CR]'],
'keys': [
('Current balance', ['commander', 'credits'], True),
('Spent on ships', ['stats', 'ship', 'spend', 'ships'], True),
@@ -153,63 +108,99 @@ dataseries = [
]
-for thing in dataseries:
- if isinstance(thing['keys'][0], tuple):
+def export(data, csv=False):
+
+ if not have_openpyxl: return False
+
+ TITLE_F = openpyxl.styles.Font(bold=True)
+ TITLE_A = openpyxl.styles.Alignment(horizontal='right')
+
+ querytime = config.getint('querytime') or int(time.time())
+
+ filename = join(config.get('outdir'), 'Cmdr %s.xlsx' % re.sub(r'[\\/:*?"<>|]', '_', data['commander']['name']))
+ if not isfile(filename):
+ wb = openpyxl.Workbook()
+ try:
+ wb.active.title = 'Combat' # Workbook is created with one sheet - rename it
+ except:
+ pass # except that it isn't under 2.30b1
+ else:
+ wb = openpyxl.load_workbook(filename)
+
+ for thing in dataseries:
legends = [x[0] for x in thing['keys']]
keys = [x[1] for x in thing['keys']]
- if thing.get('axes') and (isinstance(thing['axes'], list) or isinstance(thing['axes'], tuple)):
+ if thing.get('axes') and isinstance(thing['axes'], (list, tuple)):
y2_axis = [len(x)>2 and x[2] for x in thing['keys']]
else:
- y2_axis = [False] * len(keys)
- else:
- legends = keys = thing['keys']
- y2_axis = [False] * len(keys)
+ y2_axis = None
- sheet = makesheet(workbook, thing['name'], legends)
+ if thing['name'] in wb:
+ ws = wb[thing['name']]
+ else:
+ ws = wb.create_sheet(title=thing['name'])
- timeseries = sorted(inputs)
- for i in range(len(inputs)):
- row = i+1
- dt = timeseries[i]
- data = inputs[dt]
+ # Add header row
+ if ws.max_row <= 1: # Returns 1 for empty sheet
+ ws.append(['Date'] + legends)
+ for i in range(ws.max_column):
+ ws.column_dimensions[openpyxl.utils.get_column_letter(i+1)].width = 17
+ for row in ws.get_squared_range(1, 1, ws.max_column, 1):
+ for cell in row:
+ cell.font = TITLE_F
+ cell.alignment = TITLE_A
+
+ # Add data row
+ vals = [datetime.datetime.fromtimestamp(querytime)]
+ mydata = data
if thing.get('prefix'):
for key in thing['prefix']:
- data = data[key]
- vals = []
+ mydata = mydata[key]
for key2 in keys:
if isinstance(key2, basestring):
- vals.append(data.get(key2, 0))
+ vals.append(mydata.get(key2, 0))
else:
- value = data
+ value = mydata
for key in key2:
value = value.get(key, 0)
if not value: break
vals.append(value)
+ ws.append(vals)
+ ws.cell(row=ws.max_row, column=1).number_format = 'yyyy-mm-dd hh:mm:ss' # just a string, not a style
- addrow(sheet, row, dt, vals)
+ dates = openpyxl.chart.Reference(ws, 1, 2, 1, ws.max_row)
- chart = makechart(workbook, sheet, thing['name'], thing.get('axes'))
- for i in range(len(thing['keys'])):
- chart.add_series({'categories': [thing['name'], 1, 0, row, 0],
- 'values': [thing['name'], 1, 1+i, row, 1+i],
- 'name': legends[i],
- 'marker': {'type': 'diamond'},
- 'y2_axis': y2_axis[i],
- })
- # Label each line
- if y2_axis[i]:
- chart.add_series({'categories': [thing['name'], row, 0, row, 0], # last row
- 'values': [thing['name'], row, 1+i, row, 1+i],
- 'name': legends[i],
- 'data_labels': {'series_name': True, 'position': 'right'},
- 'y2_axis': True,
- })
- else:
- chart.add_series({'categories': [thing['name'], 1, 0, 1, 0], # first row
- 'values': [thing['name'], 1, 1+i, 1, 1+i],
- 'name': legends[i],
- 'data_labels': {'series_name': True, 'position': 'left'},
- })
- chart.set_legend({'delete_series': range(1, 2*len(thing['keys']), 2)})
+ chart = openpyxl.chart.ScatterChart()
+ chart.title = thing['name']
+ chart.width, chart.height = 60, 30 # in cm!
+ chart.scatterStyle = 'lineMarker'
+ chart.set_categories(dates)
+ chart.x_axis.number_format = ('yyyy-mm-dd') # date only
+ chart.x_axis.majorGridlines = None
-workbook.close()
+ if y2_axis:
+ chart.y_axis.majorGridlines = None # prefer grid lines on secondary axis
+ chart2 = openpyxl.chart.ScatterChart()
+ chart2.scatterStyle = 'lineMarker'
+ # Hack - second chart must have different axis ID
+ chart2.y_axis = openpyxl.chart.axis.NumericAxis(axId=30, crossAx=10, axPos='r', crosses='max')
+
+ for i in range(len(keys)):
+ series = openpyxl.chart.Series(openpyxl.chart.Reference(ws, i+2, 1, i+2, ws.max_row), dates, title_from_data=True)
+ series.marker.symbol = 'diamond'
+ if y2_axis and y2_axis[i]:
+ series.dLbls = openpyxl.chart.label.DataLabels([openpyxl.chart.label.DataLabel(idx=ws.max_row-2, dLblPos='r', showSerName=True)])
+ chart2.series.append(series)
+ else:
+ series.dLbls = openpyxl.chart.label.DataLabels([openpyxl.chart.label.DataLabel(idx=0, dLblPos='l', showSerName=True)])
+ chart.series.append(series)
+ if y2_axis:
+ chart.y_axis.title = thing['axes'][0]
+ chart2.y_axis.title = thing['axes'][1]
+ chart.z_axis = chart2.y_axis
+ chart += chart2
+ elif thing['axes']:
+ chart.y_axis.title = thing['axes']
+ ws.add_chart(chart, 'B2')
+
+ wb.save(filename)
diff --git a/config.py b/config.py
index 6f31727e..d759164e 100644
--- a/config.py
+++ b/config.py
@@ -71,6 +71,7 @@ class Config:
OUT_CSV = 8
OUT_SHIP = 16
OUT_LOG = 32
+ OUT_STAT = 64
if platform=='darwin':
diff --git a/img/Balance.png b/img/Balance.png
new file mode 100644
index 00000000..8696f44c
Binary files /dev/null and b/img/Balance.png differ
diff --git a/prefs.py b/prefs.py
index 4551db29..467b8073 100644
--- a/prefs.py
+++ b/prefs.py
@@ -9,6 +9,7 @@ import ttk
import tkFileDialog
from config import config
+from chart import have_openpyxl
if platform=='win32':
@@ -77,10 +78,10 @@ class PreferencesDialog(tk.Toplevel):
outframe.grid(padx=10, pady=10, sticky=tk.NSEW)
outframe.columnconfigure(0, weight=1)
- output = config.getint('output') or (config.OUT_EDDN | config.OUT_SHIP)
+ output = config.getint('output') or (config.OUT_EDDN | config.OUT_SHIP | config.OUT_STAT)
ttk.Label(outframe, text="Please choose what data to save").grid(row=0, columnspan=2, padx=5, pady=3, sticky=tk.W)
self.out_eddn= tk.IntVar(value = (output & config.OUT_EDDN) and 1 or 0)
- ttk.Checkbutton(outframe, text="Send market data to the Elite Dangerous Data Network", variable=self.out_eddn).grid(row=1, columnspan=2, padx=5, sticky=tk.W)
+ ttk.Checkbutton(outframe, text="Send station data to the Elite Dangerous Data Network", variable=self.out_eddn).grid(row=1, columnspan=2, padx=5, sticky=tk.W)
self.out_bpc = tk.IntVar(value = (output & config.OUT_BPC ) and 1 or 0)
ttk.Checkbutton(outframe, text="Market data in Slopey's BPC format", variable=self.out_bpc, command=self.outvarchanged).grid(row=2, columnspan=2, padx=5, sticky=tk.W)
self.out_td = tk.IntVar(value = (output & config.OUT_TD ) and 1 or 0)
@@ -89,14 +90,17 @@ class PreferencesDialog(tk.Toplevel):
ttk.Checkbutton(outframe, text="Market data in CSV format", variable=self.out_csv, command=self.outvarchanged).grid(row=4, columnspan=2, padx=5, sticky=tk.W)
self.out_ship= tk.IntVar(value = (output & config.OUT_SHIP) and 1 or 0)
ttk.Checkbutton(outframe, text="Ship loadout in E:D Shipyard format", variable=self.out_ship, command=self.outvarchanged).grid(row=5, columnspan=2, padx=5, sticky=tk.W)
- self.out_log = tk.IntVar(value = (output & config.OUT_LOG) and 1 or 0)
+ self.out_log = tk.IntVar(value = (output & config.OUT_LOG ) and 1 or 0)
ttk.Checkbutton(outframe, text="Flight log", variable=self.out_log, command=self.outvarchanged).grid(row=6, columnspan=2, padx=5, sticky=tk.W)
- ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=7, padx=5, pady=(5,0), sticky=tk.NSEW)
+ self.out_stat= tk.IntVar(value = have_openpyxl and (output & config.OUT_STAT) and 1 or 0)
+ ttk.Checkbutton(outframe, text="Cmdr statistics", variable=self.out_stat, command=self.outvarchanged, state=have_openpyxl and tk.NORMAL or tk.DISABLED).grid(row=7, columnspan=2, padx=5, sticky=tk.W)
+
+ ttk.Label(outframe, text=(platform=='darwin' and 'Where:' or 'File location:')).grid(row=8, padx=5, pady=(5,0), sticky=tk.NSEW)
self.outbutton = ttk.Button(outframe, text=(platform=='darwin' and 'Change...' or 'Browse...'), command=self.outbrowse)
- self.outbutton.grid(row=7, column=1, padx=5, pady=(5,0), sticky=tk.NSEW)
+ self.outbutton.grid(row=8, column=1, padx=5, pady=(5,0), sticky=tk.NSEW)
self.outdir = ttk.Entry(outframe)
self.outdir.insert(0, config.get('outdir'))
- self.outdir.grid(row=8, columnspan=2, padx=5, pady=5, sticky=tk.EW)
+ self.outdir.grid(row=9, columnspan=2, padx=5, pady=5, sticky=tk.EW)
self.outvarchanged()
privacyframe = ttk.LabelFrame(frame, text='Privacy')
@@ -123,7 +127,7 @@ class PreferencesDialog(tk.Toplevel):
#self.wait_window(self) # causes duplicate events on OSX
def outvarchanged(self):
- local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship.get() or self.out_log.get()
+ local = self.out_bpc.get() or self.out_td.get() or self.out_csv.get() or self.out_ship.get() or self.out_log.get() or self.out_stat.get()
self.outbutton['state'] = local and tk.NORMAL or tk.DISABLED
self.outdir['state'] = local and 'readonly' or tk.DISABLED
@@ -162,7 +166,7 @@ class PreferencesDialog(tk.Toplevel):
credentials = (config.get('username'), config.get('password'))
config.set('username', self.username.get().strip())
config.set('password', self.password.get().strip())
- config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0) + (self.out_ship.get() and config.OUT_SHIP or 0) + (self.out_log.get() and config.OUT_LOG or 0))
+ config.set('output', (self.out_eddn.get() and config.OUT_EDDN or 0) + (self.out_bpc.get() and config.OUT_BPC or 0) + (self.out_td.get() and config.OUT_TD or 0) + (self.out_csv.get() and config.OUT_CSV or 0) + (self.out_ship.get() and config.OUT_SHIP or 0) + (self.out_log.get() and config.OUT_LOG or 0) + (self.out_stat.get() and config.OUT_STAT or 0))
config.set('outdir', self.outdir.get().strip())
config.set('anonymous', self.out_anon.get())
self.destroy()