diff --git a/buildcache.py b/buildcache.py index 086f7105..fa79164c 100644 --- a/buildcache.py +++ b/buildcache.py @@ -330,7 +330,7 @@ def getItemByNameIndex(cur, itemNamesAreUnique): } -def genSQLFromPriceLines(priceFile, db, defaultZero, debug=0): +def genSQLFromPriceLines(tdenv, priceFile, db, defaultZero): """ Yields SQL for populating the database with prices by reading the file handle for price lines. @@ -373,8 +373,7 @@ def genSQLFromPriceLines(priceFile, db, defaultZero, debug=0): facility = systemName.upper() + '/' + stationName categoryID, uiOrder = None, 0 - if debug > 1: - print("NEW STATION: {}".format(facility)) + tdenv.DEBUG(1, "NEW STATION: {}", format(facility)) # Make sure it's valid. try: @@ -405,8 +404,7 @@ def genSQLFromPriceLines(priceFile, db, defaultZero, debug=0): categoryName = matches.group(1) categoryID, uiOrder = categoriesByName[categoryName], 0 - if debug > 1: - print("NEW CATEGORY: {}".format(categoryName)) + tdenv.DEBUG(1, "NEW CATEGORY: {}", categoryName) continue if not categoryID: @@ -448,7 +446,7 @@ def genSQLFromPriceLines(priceFile, db, defaultZero, debug=0): try: itemID = itemByName[itemKey] except KeyError: - raise UnknownItemError(priceFile, lineNo, itemname) + raise UnknownItemError(priceFile, lineNo, itemName) # Check for duplicate items within the station. if itemID in processedItems: @@ -471,12 +469,11 @@ def genSQLFromPriceLines(priceFile, db, defaultZero, debug=0): ###################################################################### -def processPricesFile(db, pricesPath, stationID=None, defaultZero=False, debug=0): - if debug: print("* Processing Prices file '{}'".format(str(pricesPath))) +def processPricesFile(tdenv, db, pricesPath, stationID=None, defaultZero=False): + tdenv.DEBUG(0, "Processing Prices file '{}'", pricesPath) if stationID: - if debug: - print("* Deleting stale entries for {}".format(stationID)) + tdenv.DEBUG(0, "Deleting stale entries for {}", stationID) db.execute( "DELETE FROM Price WHERE station_id = ?", [stationID] @@ -484,8 +481,8 @@ def processPricesFile(db, pricesPath, stationID=None, defaultZero=False, debug=0 with pricesPath.open() as pricesFile: bindValues = [] - for price in genSQLFromPriceLines(pricesFile, db, defaultZero, debug): - if debug > 2: print(price) + for price in genSQLFromPriceLines(tdenv, pricesFile, db, defaultZero): + tdenv.DEBUG(2, price) bindValues += [ price ] stmt = """ INSERT OR REPLACE INTO Price ( @@ -509,11 +506,8 @@ def processPricesFile(db, pricesPath, stationID=None, defaultZero=False, debug=0 ###################################################################### -def processImportFile(db, importPath, tableName, debug=0): - if debug: - print("* Processing import file '{}' for table '{}'". \ - format(str(importPath), tableName) - ) +def processImportFile(tdenv, db, importPath, tableName, debug=0): + tdenv.DEBUG(0, "Processing import file '{}' for table '{}'", str(importPath), tableName) fkeySelectStr = "(SELECT {newValue} FROM {table} WHERE {table}.{column} = ?)" @@ -554,15 +548,13 @@ def processImportFile(db, importPath, tableName, debug=0): columns=','.join(bindColumns), values=','.join(bindValues) ) - if debug: - print("* SQL-Statement: {}".format(sql_stmt)) + tdenv.DEBUG(1, "SQL-Statement: {}", sql_stmt) # import the data importCount = 0 for linein in csvin: if len(linein) == columnCount: - if debug > 1: - print("- Values: {}".format(', '.join(linein))) + tdenv.DEBUG(2, " Values: {}", ', '.join(linein)) db.execute(sql_stmt, linein) importCount += 1 db.commit() @@ -573,7 +565,7 @@ def processImportFile(db, importPath, tableName, debug=0): ###################################################################### -def buildCache(dbPath, sqlPath, pricesPath, importTables, defaultZero=False, debug=0): +def buildCache(tdenv, dbPath, sqlPath, pricesPath, importTables, defaultZero=False): """ Rebuilds the SQlite database from source files. @@ -589,13 +581,11 @@ def buildCache(dbPath, sqlPath, pricesPath, importTables, defaultZero=False, deb """ # Create an in-memory database to populate with our data. - if debug: - print("* Creating temporary database in memory") + tdenv.DEBUG(0, "Creating temporary database in memory") tempDB = sqlite3.connect(':memory:') # Read the SQL script so we are ready to populate structure, etc. - if debug: - print("* Executing SQL Script '{}' from '{}'".format(sqlPath, os.getcwd())) + tdenv.DEBUG(0, "Executing SQL Script '{}' from '{}'", sqlPath, os.getcwd()) with sqlPath.open() as sqlFile: sqlScript = sqlFile.read() tempDB.executescript(sqlScript) @@ -603,88 +593,83 @@ def buildCache(dbPath, sqlPath, pricesPath, importTables, defaultZero=False, deb # import standard tables for (importName, importTable) in importTables: try: - processImportFile(tempDB, Path(importName), importTable, debug=debug) + processImportFile(tdenv, tempDB, Path(importName), importTable) except FileNotFoundError: - if debug: - print("WARNING: processImportFile found no {} file". \ - format(importName)) + tdenv.DEBUG(0, "WARNING: processImportFile found no {} file", importName) # Parse the prices file - processPricesFile(tempDB, pricesPath, defaultZero=defaultZero, debug=debug) + processPricesFile(tdenv, tempDB, pricesPath, defaultZero=defaultZero) # Database is ready; copy it to a persistent store. - if debug: print("* Populating SQLite database file '%s'" % dbPath) + tdenv.DEBUG(0, "Populating SQLite database file {}", str(dbPath)) if dbPath.exists(): - if debug: print("- Removing old database file") + tdenv.DEBUG(0, "Removing old database file") dbPath.unlink() newDB = sqlite3.connect(str(dbPath)) importScript = "".join(tempDB.iterdump()) - if debug > 3: print(importScript) + tdenv.DEBUG(3, importScript) newDB.executescript(importScript) newDB.commit() - if debug: print("* Finished") + tdenv.DEBUG(0, "Finished") ###################################################################### def commandLineBuildCache(): - # Because it looks less sloppy that doing this in if __name__ == '__main__'... from tradedb import TradeDB - dbFilename = TradeDB.defaultDB - sqlFilename = TradeDB.defaultSQL - pricesFilename = TradeDB.defaultPrices - importTables = TradeDB.defaultTables - - # Check command line for -w/--debug inputs. - import argparse - parser = argparse.ArgumentParser( - description='Build TradeDangerous cache file from source files' - ) - parser.add_argument( - '--db', default=dbFilename, - help='Specify database file to build. Default: {}'.format(dbFilename), + from tradeenv import TradeEnv, ParseArgument + arguments = [ + ParseArgument( + '--sql', default=None, dest='sqlFilename', + help='Specify SQL script to execute.', + ), + ParseArgument( + '--prices', default=None, dest='pricesFilename', + help='Specify the prices file to load.', + ), + ParseArgument( + '-f', '--force', default=False, action='store_true', + dest='force', + help='Overwite existing file', + ), + ] + tdenv = TradeEnv( + description='Build TradeDangerous cache file from source files', + extraArgs=arguments, ) - parser.add_argument( - '--sql', default=sqlFilename, - help='Specify SQL script to execute. Default: {}'.format(sqlFilename), - ) - parser.add_argument( - '--prices', default=pricesFilename, - help='Specify the prices file to load. Default: {}'.format(pricesFilename), - ) - parser.add_argument( - '-f', '--force', default=False, action='store_true', - dest='force', - help='Overwite existing file', - ) - parser.add_argument( - '-w', '--debug', dest='debug', default=0, action='count', - help='Increase level of diagnostic output', - ) - args = parser.parse_args() import pathlib # Check that the file doesn't already exist. - dbPath = pathlib.Path(args.db) - sqlPath = pathlib.Path(args.sql) - pricesPath = pathlib.Path(args.prices) - if not args.force: + dbFilename = tdenv.dbFilename or TradeDB.defaultDB + sqlFilename = tdenv.sqlFilename or TradeDB.defaultSQL + pricesFilename = tdenv.pricesFilename or TradeDB.defaultPrices + importTables = TradeDB.defaultTables + + dbPath = pathlib.Path(dbFilename) + sqlPath = pathlib.Path(sqlFilename) + pricesPath = pathlib.Path(pricesFilename) + if not tdenv.force: if dbPath.exists(): - print("{}: ERROR: SQLite3 database '{}' already exists. Please remove it first.".format(sys.argv[0], args.db)) + print("{}: ERROR: SQLite3 database '{}' already exists. " + "Please remove it first.".format( + sys.argv[0], dbFilename)) sys.exit(1) if not sqlPath.exists(): - print("SQL file '{}' does not exist.".format(args.sql)) + print("SQL file '{}' does not exist.".format(sqlFilename)) sys.exit(1) if not pricesPath.exists(): - print("Prices file '{}' does not exist.".format(args.prices)) + print("Prices file '{}' does not exist.".format(pricesFilename)) - buildCache(dbPath, sqlPath, pricesPath, importTables, debug=args.debug) + try: + buildCache(tdenv, dbPath, sqlPath, pricesPath, importTables) + except TradeException as e: + print("ERROR: {}".format(str(e))) if __name__ == '__main__': diff --git a/data/stars.py b/data/stars.py deleted file mode 100644 index 13d43bed..00000000 --- a/data/stars.py +++ /dev/null @@ -1,326 +0,0 @@ -#! /usr/bin/env python -# Table of stars we know about so far. - -class Star(object): - def __init__(self, name, x, y, z): - self.name, self.x, self.y, self.z = name, x, y, z - self.tdID = None - self.tdStar = None - def __repr__(self): - return "Star(%s,%f,%f,%f)\n" % (self.name, self.x, self.y, self.z) - -# List of star system coordinates provided by wtbw & Brookes -stars = [ - Star("26 Draconis",-39.000000,24.906250,-0.656250), - Star("Acihaut",-18.500000,25.281250,-4.000000), - Star("Aganippe",-11.562500,43.812500,11.625000), - Star("Asellus Primus",-23.937500,40.875000,-1.343750), - Star("Aulin",-19.687500,32.687500,4.750000), - Star("Aulis",-16.468750,44.187500,-11.437500), - Star("BD+47 2112",-14.781250,33.468750,-0.406250), - Star("BD+55 1519",-16.937500,44.718750,-16.593750), - Star("Bolg",-7.906250,34.718750,2.125000), - Star("Chi Herculis",-30.750000,39.718750,12.781250), - Star("CM Draco",-35.687500,30.937500,2.156250), - Star("Dahan",-19.750000,41.781250,-3.187500), - Star("DN Draconis",-27.093750,21.625000,0.781250), - Star("DP Draconis",-17.500000,25.968750,-11.375000), - Star("Eranin",-22.843750,36.531250,-1.187500), - Star("G 239-25",-22.687500,25.812500,-6.687500), - Star("GD 319",-19.375000,43.625000,-12.750000), - Star("h Draconis",-39.843750,29.562500,-3.906250), - Star("Hermitage",-28.750000,25.000000,10.437500), - Star("i Bootis",-22.375000,34.843750,4.000000), - Star("Ithaca",-8.093750,44.937500,-9.281250), - Star("Keries",-18.906250,27.218750,12.593750), - Star("Lalande 29917",-26.531250,22.156250,-4.562500), - Star("LFT 1361",-38.781250,24.718750,-0.500000), - Star("LFT 880",-22.812500,31.406250,-18.343750), - Star("LFT 992",-7.562500,42.593750,0.687500), - Star("LHS 2819",-30.500000,38.562500,-13.437500), - Star("LHS 2884",-22.000000,48.406250,1.781250), - Star("LHS 2887",-7.343750,26.781250,5.718750), - Star("LHS 3006",-21.968750,29.093750,-1.718750), - Star("LHS 3262",-24.125000,18.843750,4.906250), - Star("LHS 417",-18.312500,18.187500,4.906250), - Star("LHS 5287",-36.406250,48.187500,-0.781250), - Star("LHS 6309",-33.562500,33.125000,13.468750), - Star("LP 271-25",-10.468750,31.843750,7.312500), - Star("LP 275-68",-23.343750,25.062500,15.187500), - Star("LP 64-194",-21.656250,32.218750,-16.218750), - Star("LP 98-132",-26.781250,37.031250,-4.593750), - Star("Magec",-32.875000,36.156250,15.500000), - Star("Meliae",-17.312500,49.531250,-1.687500), - Star("Morgor",-15.250000,39.531250,-2.250000), - Star("Nang Ta-khian",-18.218750,26.562500,-6.343750), - Star("Naraka",-34.093750,26.218750,-5.531250), - Star("Opala",-25.500000,35.250000,9.281250), - Star("Ovid",-28.062500,35.156250,14.812500), - Star("Pi-fang",-34.656250,22.843750,-4.593750), - Star("Rakapila",-14.906250,33.625000,9.125000), - Star("Ross 1015",-6.093750,29.468750,3.031250), - Star("Ross 1051",-37.218750,44.500000,-5.062500), - Star("Ross 1057",-32.312500,26.187500,-12.437500), - Star("Styx",-24.312500,37.750000,6.031250), - Star("Surya",-38.468750,39.250000,5.406250), - Star("Tilian",-21.531250,22.312500,10.125000), - Star("WISE 1647+5632",-21.593750,17.718750,1.750000), - Star("Wyrd",-11.625000,31.531250,-3.937500), - Star("Coquenchis",-62.78125,23.09375,-36.4375), - Star("Mistana",-103.125,27.6875,-61.84375), - Star("Gera",-88.8125,11.6875,-33.59375), - Star("HIP 2422",-103.21875,31.34375,-64.375), - Star("HR 8474",-97.96875,21.3125,-36.125), - Star("Nyon T'ao Wujin",-81.71875,58.8125,-35.28125), - Star("BD+71 1033",-104.125,33.875,-30.9375), - Star("Adepti",-74.09375,36.5625,-40.1875), - Star("Kulkanabossongma",-106.125,18.46875,-58.03125), - Star("HR 8423",-105.375,48.25,-55.53125), - Star("LP 29-188",-67.625,17.0625,-50.21875), - Star("Basium",-71.25,49.875,-30.21875), - Star("Chirichianco",-114.0625,8.25,-43.84375), - Star("V740 Cassiopeiae",-86.1875,7.15625,-47.78125), - Star("Gliese 9843",-106.25,33.59375,-61), - Star("HR 8133",-133.875,26.28125,-28.96875), - Star("Toyota",-67.0625,52.625,-37.375), - Star("Psamathe",-76.71875,7.34375,-28.0625), - Star("Tapari",-66.4375,32.125,-31), - Star("35 Draconis",-85.125,51.75,-28.375), - Star("BD+63 1764",-113.25,16.78125,-28.34375), - Star("Poqomathi",-101.40625,24.65625,-69.5625), - Star("Titicate",-122.5,10.09375,-36.96875), - Star("Andjeri",-87,28.90625,-28.46875), - Star("MCC 858",-74,4.9375,-29.875), - Star("Fu Haiting",-69.46875,32.125,-48.875), - Star("Tring",-125.375,9.5,-47.96875), - Star("GCRV 13292",-99.84375,27.5625,-28.1875), - Star("LHS 3877",-74.28125,10.90625,-30.84375), - Star("NLTT 50716",-90.96875,40.40625,-42.0625), - Star("Verboni",-82.71875,21.46875,-34.78125), - Star("HIP 110773",-120.375,46.375,-60.6875), - Star("HIP 2453",-110.90625,16.5,-67.84375), - Star("HIP 105906",-114.40625,51.40625,-56.125), - Star("HR 158",-85.875,36.90625,-55.09375), - Star("HIP 107457",-116.25,28.34375,-39.3125), - Star("Loga",-79.78125,36.53125,-42.0625), - Star("Chemetitana",-83.34375,8.84375,-27.875), - Star("Killke",-108,8.5625,-31.15625), - Star("Chapoyo",-72.0625,31.84375,-47.53125), - Star("Balmus",-89,9.375,-52.0625), - Star("LTT 16523",-72.65625,13.28125,-25.9375), - Star("HIP 7338",-96.5,33.21875,-68.65625), - Star("Lalande 37923",-84.125,38.9375,-28.15625), - Star("LP 48-567",-67.0625,14.96875,-25.5625), - Star("Jaoismonjin",-83.90625,33,-53.5625), - Star("LTT 16979",-97.25,24.3125,-52.5625), - Star("LHS 3631",-91.625,30.34375,-31.65625), - Star("Hsini",-81.75,15.59375,-46.78125), - Star("Tyr",-79.625,21.25,-25.0625), - Star("47 Cassiopeiae",-83.03125,28.5625,-63.1875), - Star("BD+67 1409",-89.4375,16.0625,-30.25), - Star("LTT 17102",-73.84375,16.5625,-45.59375), - Star("Maidubii",-115.4375,11.90625,-60.875), - Star("Er Lo Wu Di",-83.03125,19.84375,-59.78125), - Star("Pata Thewi",-108.46875,52.0625,-27.34375), - Star("HR 7925",-104.53125,21.0625,-12.1875), - Star("Baga",-107.3125,35.5,-58.84375), - Star("HIP 113477",-121.40625,19.78125,-52.46875), - Star("Tsetan",-102.625,19.65625,-41.125), - Star("Kuunggati",-85.40625,18.375,-29.53125), - Star("Cuages",-124.875,20.03125,-23.78125), - Star("HIP 108110",-122.625,22,-37.34375), - Star("Ross 210",-73.5,10.0625,-15.71875), - Star("Macomaneleng Mu",-101,16.375,-30.65625), - Star("Wikmeang",-123.59375,25.875,-33.25), - Star("Taurawa",-98.125,21.84375,-13.09375), - Star("Djedet",-81.46875,30.5625,-1.1875), - Star("Bhotepa",-134.625,33.21875,-38.0625), - Star("Pareco",-78.8125,30.34375,2.90625), - Star("Altais",-88.625,38.09375,-13.65625), - Star("Harpulo",-75.375,40.28125,-0.46875), - Star("Pemede",-88.90625,22.28125,-21.40625), - Star("Arabha",-120.75,43.78125,-22.125), - Star("San Guaralaru",-124.34375,41.8125,-60.71875), - Star("LHS 64",-76.6875,11.09375,-10.8125), - Star("Lugiu Bezelana",-126.15625,44.5625,-33.1875), - Star("Xi Wangkala",-133.0625,38.6875,-38.125), - Star("Tai Qing",-70.90625,50.75,-4.09375), - Star("Kamchaultultula",-101.09375,11,-24.09375), - Star("Ba Bhumiang Ku",-104.28125,25.0625,-45.25), - Star("Mistae",-72.34375,56.6875,-24.8125), - Star("Ngoloki Anaten",-135.9375,33.5,-40.5), - Star("Ross 211",-123.71875,17.3125,-30.21875), - Star("NLTT 53889",-76.5,7.6875,-24.96875), - Star("Clotti",-88.03125,47.1875,-6.15625), - Star("Njirika",-117.46875,36.28125,-15.40625), - Star("Aladu Kuan Gon",-108.75,15.8125,-14.25), - Star("Dharai",-70.9375,32.34375,-14.21875), - Star("LP 27-9",-70.40625,25.4375,-32.40625), - Star("Manamaya",-43.53125,35.03125,-19.15625), - Star("Zhu Rong",-39.875,34.9375,-24.1875), - Star("Sumi",-59.53125,16.4375,-37.6875), - Star("Haras",-118.75,14.40625,-21.40625), - Star("Ao Shun",-75.3125,22.59375,1.09375), - Star("Medusa",-67.96875,42.5,-14.5), - Star("Tapipinouphinien",-63.90625,19.84375,-49.1875), - Star("Tatil",-77.84375,23.1875,-22.59375), - Star("Merki",-81.625,50.625,-19.28125), - Star("LHS 3586",-82.09375,25.96875,-23.15625), - Star("Aurea",-88,23.53125,-8.28125), - Star("LP Draconis",-87.9375,49.34375,-15.5), - Star("Apishim",-62.71875,20.28125,-39.15625), - Star("Alderamin",-47.5,7.84375,-9.46875), - Star("Bangati",-85.21875,56.28125,-35.125), - Star("BD+75 58",-43.5625,13.46875,-30.46875), - Star("NLTT 49528",-72.625,18.5,-11.75), - Star("Zosia",-62.625,25.5625,-30.09375), - Star("Evergreen",-86.6875,25.34375,-3.3125), - Star("21 Eta Ursae Minoris",-74.625,56.25,-26), - Star("LHS 1101",-54.46875,9.375,-33.46875), - Star("LTT 15294",-84.6875,50.21875,-6.40625), - Star("Jurua",-60.84375,23.84375,-50.75), - Star("Men Samit",-120.375,55.6875,-40.6875), - Star("Jaitu",-92.59375,15.1875,-9.15625), - Star("Nimba",-71.5625,42.96875,-4.84375), - Star("Kambalua",-62.90625,15.34375,-44.21875), - Star("Jieguaje",-85.25,16.9375,-4), - Star("Lakluit",-83.875,21.125,-10.0625), - Star("LTT 10482",-61.1875,23.59375,-42), - Star("Moscab Kutja",-75.75,25.40625,-23.78125), - Star("Taleachishvakhrud",-96.5625,56.71875,-19.5), - Star("BD+64 1452",-98.5625,25.6875,-17.40625), - Star("BD+65 1846",-60.3125,7.125,-25.53125), - Star("Korubu",-102,36.3125,-7.8125), - Star("Reiene Maorai",-99.75,39.125,-16.84375), - Star("LHS 250",-19.1875,24.34375,-29.53125), - Star("HIP 93119",-113.5625,57.40625,-33.9375), - Star("HIP 91906",-108.09375,57.1875,-33.59375), - Star("Kwaraseti",-53.5,33.75,-39.875), - Star("Keian Gu",-6,36.28125,-19.875), - Star("Boro Kacharis",-27.125,18.90625,-25.875), - Star("Grabri",-81.1875,42.28125,-5.1875), - Star("Huokang",-12.1875,35.46875,-25.28125), - Star("LHS 246",-19,22.5625,-28.21875), - Star("Exbeur",-35.15625,27.84375,-33.65625), - Star("Darahk",-65,27,-37), - Star("Nguruai Trimpaso",-84.46875,54.625,-22.375), - Star("Theta Draconis",-48.9375,48.09375,0.03125), - Star("Yoruba",-46,35.84375,-34.21875), - Star("StKM 1-1676",-74.875,31.75,-5.96875), - Star("LP 45-128",-69.28125,32.25,-13.90625), - Star("Sofagre",-54.21875,48.53125,-30.15625), - Star("Miola",-64,24,-41), - Star("Ehlangai",-11.21875,32.53125,-23.03125), - Star("BD+87 118",-58.59375,40.65625,-37.75), - Star("LHS 1065",-51.71875,15.84375,-31.75), - Star("Aeolus",-43.28125,37.28125,-31.53125), - Star("LP 1-52",-49.9375,28.8125,-33.28125), - Star("LP 7-226",-42.78125,31.75,-27.25), - Star("Moros",-45.96875,17.375,-31.3125), - Star("BD+74 526",-48.65625,53.28125,-29.25), - Star("LHS 5072",-33.34375,15.15625,-27.53125), - Star("Alrai",-38.71875,12.3125,-21.625), - Star("Mufrid",-1.125,35.25,11.09375), - Star("47 Ursae Majoris",-1.4375,41.1875,-20.09375), - Star("LFT 1446",-38.8125,18.25,-7.9375), - Star("14 Herculis",-36.71875,41.6875,14.125), - Star("LHS 215",-20.59375,13.28125,-18.3125), - Star("Ackycha",-29.03125,39.8125,-25.03125), - Star("Arcturus",-3.53125,34.15625,12.96875), - Star("Ross 640",-31.6875,35.125,18.9375), - Star("Wunjo",-65,53.3125,-22.59375), - Star("Chara",-4.84375,26.65625,-4.78125), - Star("LTT 16016",-62.53125,12.375,-5.5), - Star("LP 37-75",-27.25,38.5,-33.65625), - Star("41 Gamma Serpentis",-12.125,26,22.875), - Star("LHS 2123",-56.53125,42.40625,-44.75), - Star("LHS 3549",-29.625,6.125,-1.9375), - Star("Beta Comae Berenices",-1.6875,29.65625,2.03125), - Star("72 Herculis",-32.875,24.6875,22.21875), - Star("Hyperion",-51.1875,7.5,-16.71875), - Star("Sigma Bootis",-14.46875,47.4375,14.40625), - Star("44 chi Draconis",-22.5625,12.375,-5.40625), - Star("Eta Coronae Borealis",-23.78125,48.4375,21.9375), - Star("G 203-47",-18.34375,14.25,7.09375), - Star("Lalande 30699",-64.34375,48.96875,-10.875), - Star("Eta Cephei",-45.125,9.375,-6.4375), - Star("21 Draco",-60.03125,45.65625,8.09375), - Star("Paul-Friedrichs Star(",-48.15625,34.3125,8.78125), - Star("LHS 2405",-45.875,45.5625,-36.28125), - Star("G 230-27",-58.125,11.4375,2.6875), - Star("Quiness",-46.46875,37.78125,-24.9375), - Star("36 Ursae Majoris",-11.125,32.9375,-23), - Star("Perendi",-52.5625,33.09375,-11.875), - Star("Vaccimici",-33.21875,37.34375,12.75), - Star("Vetr",-59.0625,12.65625,1.46875), - Star("Xi Ursae Majoris",2.65625,27.09375,-9.9375), - Star("Connla",-29.90625,14.5625,13.65625), - Star("CR Draconis",-48.3125,46.15625,4.46875), - Star("86 Mu Herculis",-19.5,11.53125,14.875), - Star("Ao Qin",-62.90625,46.78125,1.53125), - Star("Wolf 1409",-56.125,29.8125,9.0625), - Star("LFT 1073",-38.9375,32.5625,-21.65625), - Star("LHS 140",-34.53125,16.1875,-23.0625), - Star("LP 25-2",-48.9375,27.25,-19.5625), - Star("LP 71-157",-52.34375,28.375,-8.03125), - Star("Eos",-51.875,16.78125,-0.375), - Star("Tun",-47.96875,20.96875,-17.53125), - Star("NLTT 46621",-43.8125,20.34375,4.8125), - Star("Caer Bran",-31.28125,14.3125,13.78125), - Star("V1090 Herculis",-44.9375,36.9375,13.46875), - Star("LFT 1072",-45.90625,43.71875,-23.25), - Star("Malina",-48.1875,23,14.40625), - Star("Wolf 654",-28.46875,22.40625,14.8125), - Star("Coelrind",3.0625,35.28125,6.96875), - Star("LFT 1421",-45.46875,18.5625,12.59375), - Star("LP 102-320",-60.625,35.125,3.21875), - Star("LHS 3057",-50.75,50.03125,-13.28125), - Star("Belobog",-13.34375,53.78125,12.5625), - Star("61 Ursae Majoris",0.5625,30.09375,-8.6875), - Star("Demeter",-55.34375,40.8125,7.5625), - Star("LP 5-88",-27.28125,19.59375,-23.03125), - Star("Ross 1003",-4.3125,33.90625,-11.96875), -# Star("Training",-22,36,1), -# Star("Destination",-22,37,4), - Star("Tollan",-14.78125,20,-21.96875), - Star("LHS 287",1.03125,29.59375,-16.5), - Star("Ross 905",4.46875,31.9375,-7.21875), - Star("LHS 283",-20.78125,29.59375,-23.53125), - Star("CR Draco",-49.46875,47.4375,4.375), - Star("LHS 293",1.84375,28.3125,-14.4375), - Star("37 Xi Bootis",-4.21875,19.09375,9.8125), - Star("Mullag",0.5,35.09375,-19.1875), - Star("Flousop",-1.625,16.8125,4), - Star("CW Ursae Majoris",2.65625,36.78125,-14.90625), - Star("LP 440-38",-4.65625,43.78125,20.6875), - Star("Miquich",-17.71875,19.125,19.59375), - Star("LHS 355",1.09375,46.15625,9.71875), - Star("Hepa",-29.4375,34.46875,21.53125), - Star("Ross 130",-3.90625,41,19.75), - Star("LP 320-359",1.25,49.71875,-6.5625), - Star("LHS 411",-13.125,23.59375,18.625), - Star("LHS 3080",-21.625,42.90625,21.375), - Star("LAWD 52",-52.28125,61.8125,-26.5625), - Star("10 Canum Venaticorum",-9.375,55.4375,-7), - Star("LHS 350",-1.46875,44.59375,5.75), - Star("Zeta Herculis",-21.34375,22.375,16.25), - Star("LHS 2764a",-4.71875,54.125,0.65625), - Star("Hera",-36.8125,55.59375,-17.96875), - Star("LHS 6282",-11.46875,39.78125,22.78125), - Star("LHS 2522",-24.8125,56.53125,-23.4375), - Star("G 180-18",-28.96875,40.6875,19.03125), - Star("Ross 860",-20.125,19.28125,19), - Star("LHS 391",-15.90625,45.15625,21.375), - Star("Wolf 497",0.125,41.46875,11.96875), - Star("Rahu",-43.78125,62.4375,-0.25), - Star("G 224-46",-55.25,60.34375,-12.71875), - Star("BF Canis Venatici",-8.25,62.1875,-3.0625), - Star("LHS 371",-9.625,49.4375,17.625), - Star("LHS 2936",-31.6875,55.5625,0.5), - Star("Parcae",-8.125,55.09375,-17), - Star("LP 322-836",-3.4375,51.59375,2.03125), - Star("Ori",-26.28125,56.09375,2.4375), - Star("LHS 2948",-21.6875,60.4375,15.09375), - Star("CE Bootis",-5.40625,29.375,16.46875), - Star("LHS 2651",-22.84375,60.375,-13.8125), -] diff --git a/subcommands/buy.py b/subcommands/buy.py new file mode 100644 index 00000000..ae309ca6 --- /dev/null +++ b/subcommands/buy.py @@ -0,0 +1,127 @@ +from tradeenv import * + +def buyCommand(tdb, tdenv): + """ + Locate places selling a given item. + """ + + item = tdb.lookupItem(tdenv.item) + + # Constraints + constraints = [ "(item_id = ?)", "buy_from > 0", "stock != 0" ] + bindValues = [ item.ID ] + + if tdenv.quantity: + constraints.append("(stock = -1 or stock >= ?)") + bindValues.append(tdenv.quantity) + + near = tdenv.near + if near: + tdb.buildLinks() + nearSystem = tdb.lookupSystem(near) + maxLy = float("inf") if tdenv.maxLyPer is None else tdenv.maxLyPer + # Uh - why haven't I made a function on System to get a + # list of all the systems within N hops at L ly per hop? + stations = [] + for station in nearSystem.stations: + if station.itemCount > 0: + stations.append(str(station.ID)) + for system, dist in nearSystem.links.items(): + if dist <= maxLy: + for station in system.stations: + if station.itemCount > 0: + stations.append(str(station.ID)) + if not stations: + raise NoDataError("No stations listed as selling items within range") + constraints.append("station_id IN ({})".format(','.join(stations))) + + whereClause = ' AND '.join(constraints) + stmt = """ + SELECT station_id, buy_from, stock + FROM Price + WHERE {} + """.format(whereClause) + if tdenv.debug: + print("* SQL: {}".format(stmt)) + cur = tdb.query(stmt, bindValues) + + from collections import namedtuple + Result = namedtuple('Result', [ 'station', 'cost', 'stock', 'dist' ]) + results = [] + stationByID = tdb.stationByID + dist = 0.0 + for (stationID, costCr, stock) in cur: + stn = stationByID[stationID] + if near: + dist = stn.system.links[nearSystem] if stn.system != nearSystem else 0.0 + results.append(Result(stationByID[stationID], costCr, stock, dist)) + + if not results: + raise NoDataError("No available items found") + + if tdenv.sortByStock: + results.sort(key=lambda result: result.cost) + results.sort(key=lambda result: result.stock, reverse=True) + else: + results.sort(key=lambda result: result.stock, reverse=True) + results.sort(key=lambda result: result.cost) + if near and not tdenv.sortByPrice: + results.sort(key=lambda result: result.dist) + + maxStnNameLen = len(max(results, key=lambda result: len(result.station.dbname) + len(result.station.system.dbname) + 1).station.name()) + tdenv.printHeading("{station:<{maxStnLen}} {cost:>10} {stock:>10} {dist:{distFmt}}".format( + station="Station", cost="Cost", stock="Stock", + dist="Ly" if near else "", + maxStnLen=maxStnNameLen, + distFmt=">6" if near else "" + )) + for result in results: + print("{station:<{maxStnLen}} {cost:>10n} {stock:>10} {dist:{distFmt}}".format( + station=result.station.name(), + cost=result.cost, + stock="{:n}".format(result.stock) if result.stock > 0 else "", + dist=result.dist if near else "", + maxStnLen=maxStnNameLen, + distFmt=">6.2f" if near else "" + )) + +subCommand_BUY = SubCommandParser( + name='buy', + cmdFunc=buyCommand, + help='Find places to buy a given item within range of a given station.', + arguments = [ + ParseArgument('item', help='Name of item to query.', type=str), + ], + switches = [ + ParseArgument('--quantity', + help='Require at least this quantity.', + default=0, + type=int, + ), + ParseArgument('--near', + help='Find sellers within jump range of this system.', + type=str + ), + ParseArgument('--ly-per', + help='Maximum light years per jump.', + default=None, + dest='maxLyPer', + metavar='N.NN', + type=float, + ), + MutuallyExclusiveGroup( + ParseArgument('--price-sort', '-P', + help='(When using --near) Sort by price not distance', + action='store_true', + default=False, + dest='sortByPrice', + ), + ParseArgument('--stock-sort', '-S', + help='Sort by stock followed by price', + action='store_true', + default=False, + dest='sortByStock', + ), + ), + ], +) diff --git a/subcommands/local.py b/subcommands/local.py new file mode 100644 index 00000000..ae40696b --- /dev/null +++ b/subcommands/local.py @@ -0,0 +1,91 @@ +from tradeenv import * +import math + +def distanceAlongPill(tdb, sc, percent): + """ + Estimate a distance along the Pill using 2 reference systems + """ + sa = tdb.lookupSystem("Eranin") + sb = tdb.lookupSystem("HIP 107457") + dotProduct = (sb.posX-sa.posX) * (sc.posX-sa.posX) \ + + (sb.posY-sa.posY) * (sc.posY-sa.posY) \ + + (sb.posZ-sa.posZ) * (sc.posZ-sa.posZ) + length = math.sqrt((sb.posX-sa.posX) * (sb.posX-sa.posX) + + (sb.posY-sa.posY) * (sb.posY-sa.posY) + + (sb.posZ-sa.posZ) * (sb.posZ-sa.posZ)) + if percent: + return 100. * dotProduct / length / length + + return dotProduct / length + + +def localCommand(tdb, tdenv): + """ + Local systems + """ + + srcSystem = tdenv.nearSystem + + ly = tdenv.maxLyPer or tdb.maxSystemLinkLy + + tdb.buildLinks() + + tdenv.printHeading("Local systems to {} within {} ly.".format(srcSystem.name(), ly)) + + distances = { } + + for (destSys, destDist) in srcSystem.links.items(): + if destDist <= ly: + distances[destSys] = destDist + + detail, pill, percent = tdenv.detail, tdenv.pill, tdenv.percent + for (system, dist) in sorted(distances.items(), key=lambda x: x[1]): + pillLength = "" + if pill or percent: + pillLengthFormat = " [{:4.0f}%]" if percent else " [{:5.1f}]" + pillLength = pillLengthFormat.format(distanceAlongPill(tdb, system, percent)) + print("{:5.2f}{} {}".format(dist, pillLength, system.str())) + if detail: + for (station) in system.stations: + stationDistance = " {} ls".format(station.lsFromStar) if station.lsFromStar > 0 else "" + print("\t<{}>{}".format(station.str(), stationDistance)) + + +subCommand_LOCAL = SubCommandParser( + name='local', + cmdFunc=localCommand, + help='Calculate local systems.', + arguments = [ + ParseArgument('near', help='System to measure from', type=str), + ], + switches = [ + ParseArgument('--ship', + help='Use maximum jump distance of the specified ship.', + metavar='shiptype', + type=str, + ), + ParseArgument('--full', + help='(With --ship) Limits the jump distance to that of a full ship.', + action='store_true', + default=False, + ), + ParseArgument('--ly', + help='Maximum light years to measure.', + dest='maxLyPer', + metavar='N.NN', + type=float, + ), + MutuallyExclusiveGroup( + ParseArgument('--pill', + help='Show distance along the pill in ly.', + action='store_true', + default=False, + ), + ParseArgument('--percent', + help='Show distance along pill as percentage.', + action='store_true', + default=False, + ), + ), + ] +) diff --git a/subcommands/nav.py b/subcommands/nav.py new file mode 100644 index 00000000..ddf524b3 --- /dev/null +++ b/subcommands/nav.py @@ -0,0 +1,118 @@ +from tradeenv import * + +def navCommand(tdb, tdenv): + """ + Give player directions A->B + """ + + srcSystem = tdenv.startSystem + dstSystem = tdenv.stopSystem + + avoiding = [] + maxLyPer = tdenv.maxLyPer or tdb.maxSystemLinkLy + + tdenv.DEBUG(0, "Route from {} to {} with max {} ly per jump.", + srcSystem.name(), dstSystem.name(), maxLyPer) + + openList = { srcSystem: 0.0 } + distances = { srcSystem: [ 0.0, None ] } + + tdb.buildLinks() + + # As long as the open list is not empty, keep iterating. + while openList and not dstSystem in distances: + # Expand the search domain by one jump; grab the list of + # nodes that are this many hops out and then clear the list. + openNodes, openList = openList, {} + + for (node, startDist) in openNodes.items(): + for (destSys, destDist) in node.links.items(): + if destDist > maxLyPer: + continue + dist = startDist + destDist + # If we already have a shorter path, do nothing + try: + distNode = distances[destSys] + if distNode[0] <= dist: + continue + distNode[0], distNode[1] = dist, node + except KeyError: + distances[destSys] = [ dist, node ] + assert not destSys in openList or openList[destSys] > dist + openList[destSys] = dist + + # Unravel the route by tracing back the vias. + route = [ dstSystem ] + try: + while route[-1] != srcSystem: + jumpEnd = route[-1] + jumpStart = distances[jumpEnd][1] + route.append(jumpStart) + except KeyError: + print("No route found between {} and {} with {}ly jump limit.".format(srcSystem.name(), dstSystem.name(), maxLyPer)) + return + route.reverse() + titleFormat = "From {src} to {dst} with {mly}ly per jump limit." + if tdenv.detail: + labelFormat = "{act:<6} | {sys:<30} | {jly:<7} | {tly:<8}" + stepFormat = "{act:<6} | {sys:<30} | {jly:>7.2f} | {tly:>8.2f}" + elif not tdenv.quiet: + labelFormat = "{sys:<30} ({jly:<7})" + stepFormat = "{sys:<30} ({jly:>7.2f})" + elif tdenv.quiet == 1: + titleFormat = "{src}->{dst} limit {mly}ly:" + labelFormat = None + stepFormat = " {sys}" + else: + titleFormat, labelFormat, stepFormat = None, None, "{sys}" + + if titleFormat: + print(titleFormat.format(src=srcSystem.name(), dst=dstSystem.name(), mly=maxLyPer)) + + if labelFormat: + tdenv.printHeading(labelFormat.format(act='Action', sys='System', jly='Jump Ly', tly='Total Ly')) + + lastHop, totalLy = None, 0.00 + def present(action, system): + nonlocal lastHop, totalLy + jumpLy = system.links[lastHop] if lastHop else 0.00 + totalLy += jumpLy + print(stepFormat.format(act=action, sys=system.name(), jly=jumpLy, tly=totalLy)) + lastHop = system + + present('Depart', srcSystem) + for viaSys in route[1:-1]: + present('Via', viaSys) + present('Arrive', dstSystem) + + + +subCommand_NAV = SubCommandParser( + name='nav', + cmdFunc=navCommand, + help='Calculate a route between two systems.', + arguments = [ + ParseArgument('startSys', help='System to start from', type=str), + ParseArgument('endSys', help='System to end at', type=str), + ], + switches = [ + ParseArgument('--ship', + help='Use the maximum jump distance of the specified ship.', + metavar='shiptype', + type=str, + ), + ParseArgument('--full', + help='(With --ship) ' + 'Limits the jump distance to that of a full ship.', + action='store_true', + default=False, + ), + ParseArgument('--ly-per', + help='Maximum light years per jump.', + dest='maxLyPer', + metavar='N.NN', + type=float, + ), + ] +) + diff --git a/subcommands/run.py b/subcommands/run.py new file mode 100644 index 00000000..7df6372c --- /dev/null +++ b/subcommands/run.py @@ -0,0 +1,371 @@ +from tradeenv import * + +###################################################################### +# Checklist functions + +class Checklist(object): + def __init__(self, tdb, tdenv): + self.tdb = tdb + self.tdenv = tdenv + self.mfd = tdenv.mfd + + + def doStep(self, action, detail=None, extra=None): + self.stepNo += 1 + try: + self.mfd.display("#{} {}".format(self.stepNo, action), detail or "", extra or "") + except AttributeError: pass + input(" {:<3}: {}: ".format(self.stepNo, " ".join([item for item in [action, detail, extra] if item]))) + + + def note(self, str, addBreak=True): + print("(i) {} (i){}".format(str, "\n" if addBreak else "")) + + + def run(self, route, credits): + tdb, mfd = self.tdb, self.mfd + stations, hops, jumps = route.route, route.hops, route.jumps + lastHopIdx = len(stations) - 1 + gainCr = 0 + self.stepNo = 0 + + printHeading("(i) BEGINNING CHECKLIST FOR {} (i)".format(route.str())) + print() + + tdenv = self.tdenv + if tdenv.detail: + print(route.summary()) + print() + + for idx in range(lastHopIdx): + hopNo = idx + 1 + cur, nxt, hop = stations[idx], stations[idx + 1], hops[idx] + sortedTradeOptions = sorted(hop[0], key=lambda tradeOption: tradeOption[1] * tradeOption[0].gainCr, reverse=True) + + # Tell them what they need to buy. + if tdenv.detail: + self.note("HOP {} of {}".format(hopNo, lastHopIdx)) + + self.note("Buy at {}".format(cur.name())) + for (trade, qty) in sortedTradeOptions: + self.doStep('Buy {} x'.format(qty), trade.name(), '@ {}cr'.format(localedNo(trade.costCr))) + if tdenv.detail: + self.doStep('Refuel') + print() + + # If there is a next hop, describe how to get there. + self.note("Fly {}".format(" -> ".join([ jump.name() for jump in jumps[idx] ]))) + if idx < len(hops) and jumps[idx]: + for jump in jumps[idx][1:]: + self.doStep('Jump to', jump.name()) + if tdenv.detail: + self.doStep('Dock at', nxt.str()) + print() + + self.note("Sell at {}".format(nxt.name())) + for (trade, qty) in sortedTradeOptions: + self.doStep('Sell {} x'.format(localedNo(qty)), trade.name(), '@ {}cr'.format(localedNo(trade.costCr + trade.gainCr))) + print() + + gainCr += hop[1] + if tdenv.detail and gainCr > 0: + self.note("GAINED: {}cr, CREDITS: {}cr".format(localedNo(gainCr), localedNo(credits + gainCr))) + + if hopNo < lastHopIdx: + print("\n--------------------------------------\n") + + if mfd: + mfd.display('FINISHED', "+{}cr".format(localedNo(gainCr)), "={}cr".format(localedNo(credits + gainCr))) + mfd.attention(3) + time.sleep(1.5) + + +###################################################################### +# "run" command functionality. + +def validateRunArguments(tdb, tdenv): + """ + Process arguments to the 'run' option. + """ + + if tdenv.credits < 0: + raise CommandLineError("Invalid (negative) value for initial credits") + # I'm going to allow 0 credits as a future way of saying "just fly" + + if tdenv.routes < 1: + raise CommandLineError("Maximum routes has to be 1 or higher") + if tdenv.routes > 1 and tdenv.checklist: + raise CommandLineError("Checklist can only be applied to a single route.") + + if tdenv.hops < 1: + raise CommandLineError("Minimum of 1 hop required") + if tdenv.hops > 64: + raise CommandLineError("Too many hops without more optimization") + + if tdenv.maxJumpsPer < 0: + raise CommandLineError("Negative jumps: you're already there?") + + if tdenv.startStation: + tdenv.origins = [ tdenv.startStation ] + else: + tdenv.origins = [ station for station in tdb.stationByID.values() ] + + if tdenv.stopStation: + if tdenv.hops == 1 and tdenv.startStation: + if tdenv.startStation == tdenv.stopStation: + raise CommandLineError("Same to/from; more than one hop required.") + else: + tdenv.stopStation = None + + viaSet = tdenv.viaSet = set(tdenv.viaStations) + for station in viaSet: + if station.itemCount == 0: + raise NoDataError("No price data available for via station {}.".format( + station.name())) + + unspecifiedHops = ( + tdenv.hops + + (0 if tdenv.startStation else 1) -(1 if tdenv.stopStation else 0) + ) + if len(viaSet) > unspecifiedHops: + raise CommandLineError("Too many vias: {} stations vs {} hops available.".format( + len(viaSet), unspecifiedHops + )) + tdenv.unspecifiedHops = unspecifiedHops + + if tdenv.capacity is None: + raise CommandLineError("Missing '--capacity' or '--ship' argument") + if tdenv.maxLyPer is None: + raise CommandLineError("Missing '--ly-per' or '--ship' argument") + if tdenv.capacity < 0: + raise CommandLineError("Invalid (negative) cargo capacity") + if tdenv.capacity > 1000: + raise CommandLineError("Capacity > 1000 not supported (you specified {})".format( + tdenv.capacity)) + + if tdenv.limit and tdenv.limit > tdenv.capacity: + raise CommandLineError("'limit' must be <= capacity") + if tdenv.limit and tdenv.limit < 0: + raise CommandLineError("'limit' can't be negative, silly") + tdenv.maxUnits = tdenv.limit if tdenv.limit else tdenv.capacity + + arbitraryInsuranceBuffer = 42 + if tdenv.insurance and tdenv.insurance >= (tdenv.credits + arbitraryInsuranceBuffer): + raise CommandLineError("Insurance leaves no margin for trade") + + startStn, stopStn = tdenv.startStation, tdenv.stopStation + if tdenv.unique and tdenv.hops >= len(tdb.stationByID): + raise CommandLineError("Requested unique trip with more hops than there are stations...") + if tdenv.unique: + startConflict = (startStn and (startStn == stop or startStn in viaSet)) + stopConflict = (stop and stop in viaSet) + if startConflict or stopConflict: + raise CommandLineError("from/to/via repeat conflicts with --unique") + + if tdenv.mfd: + tdenv.mfd.display("Loading Trades") + tdb.loadTrades() + + if startStn and startStn.itemCount == 0: + raise NoDataError("Start station {} doesn't have any price data.".format( + startStn.name())) + if stopStn and stopStn.itemCount == 0: + raise NoDataError("End station {} doesn't have any price data.".format( + stopStn.name())) + if stopStn and tdenv.hops == 1 and startStn and not stopStn in startStn.tradingWith: + raise CommandLineError("No profitable items found between {} and {}".format( + startStn.name(), stopStn.name())) + if startStn and len(startStn.tradingWith) == 0: + raise NoDataError("No data found for potential buyers for items from {}.".format( + startStn.name())) + + +def runCommand(tdb, tdenv): + """ Calculate trade runs. """ + + tdenv.DEBUG(1, "'run' mode") + + if tdb.tradingCount == 0: + raise NoDataError("Database does not contain any profitable trades.") + + validateRunArguments(tdb, tdenv) + + from tradecalc import TradeCalc, Route + + startStn, stopStn, viaSet = tdenv.startStation, tdenv.stopStation, tdenv.viaSet + avoidSystems = tdenv.avoidSystems + avoidStations = tdenv.avoidStations + + startCr = tdenv.credits - tdenv.insurance + routes = [ + Route(stations=[src], hops=[], jumps=[], startCr=startCr, gainCr=0) + for src in tdenv.origins + if not (src in avoidStations or + src.system in avoidSystems) + ] + numHops = tdenv.hops + lastHop = numHops - 1 + viaStartPos = 1 if startStn else 0 + tdenv.maxJumps = None + + tdenv.format("runSummary", + "From {fromStn}, To {toStn}, Via {via}, " + "Cap {cap}, Credits {cr}, " + "Hops {hops}, Jumps/Hop {jumpsPer}, Ly/Jump {lyPer:.2f}" + "\n", + isInfo=True, + fromStn=startStn.name() if startStn else 'Anywhere', + toStn=stopStn.name() if stopStn else 'Anywhere', + via=';'.join([stn.name() for stn in viaSet]) or 'None', + cap=tdenv.capacity, + cr=startCr, + hops=numHops, + jumpsPer=tdenv.maxJumpsPer, + lyPer=tdenv.maxLyPer, + ) + + # Instantiate the calculator object + calc = TradeCalc(tdb, tdenv) + + tdenv.DEBUG(1, "unspecified hops {}, numHops {}, viaSet {}", + tdenv.unspecifiedHops, numHops, len(viaSet)) + + for hopNo in range(numHops): + tdenv.DEBUG(1, "Hop {}", hopNo) + + restrictTo = None + if hopNo == lastHop and stopStn: + restrictTo = set([stopStn]) + ### TODO: + ### if hopsLeft < len(viaSet): + ### ... only keep routes that include len(viaSet)-hopsLeft routes + ### Thus: If you specify 5 hops via a,b,c and we are on hop 3, only keep + ### routes that include a, b or c. On hop 4, only include routes that + ### already include 2 of the vias, on hop 5, require all 3. + if viaSet: + routes = [ route for route in routes if viaSet & set(route.route[viaStartPos:]) ] + elif tdenv.unspecifiedHops == len(viaSet): + # Everywhere we're going is in the viaSet. + restrictTo = viaSet + + routes = calc.getBestHops(routes, restrictTo=restrictTo) + + if viaSet: + routes = [ route for route in routes if viaSet & set(route.route[viaStartPos:]) ] + + if not routes: + raise NoDataError("No profitable trades matched your critera, or price data along the route is missing.") + + routes.sort() + + for i in range(0, min(len(routes), tdenv.routes)): + print(routes[i].detail(detail=tdenv.detail)) + + # User wants to be guided through the route. + if tdenv.checklist: + assert tdenv.routes == 1 + cl = Checklist(tdb, tdenv.mfd) + cl.run(routes[0], tdenv.credits) + + +subCommand_RUN = SubCommandParser( + name='run', + cmdFunc=runCommand, + help='Calculate best trade run.', + arguments = [ + ParseArgument('--credits', help='Starting credits.', metavar='CR', type=int) + ], + switches = [ + ParseArgument('--ship', + help='Set capacity and ly-per from ship type.', + metavar='shiptype', + type=str, + ), + ParseArgument('--capacity', + help='Maximum capacity of cargo hold.', + metavar='N', + type=int, + ), + ParseArgument('--from', + help='Starting system/station.', + dest='origin', + metavar='STATION', + ), + ParseArgument('--to', + help='Final system/station.', + dest='dest', + metavar='STATION', + ), + ParseArgument('--via', + help='Require specified systems/stations to be en-route.', + action='append', + metavar='PLACE[,PLACE,...]', + ), + ParseArgument('--avoid', + help='Exclude an item, system or station from trading. ' + 'Partial matches allowed, ' + 'e.g. "dom.App" or "domap" matches "Dom. Appliances".', + action='append', + ), + ParseArgument('--hops', + help='Number of hops (station-to-station) to run.', + default=2, + type=int, + metavar='N', + ), + ParseArgument('--jumps-per', + help='Maximum number of jumps (system-to-system) per hop.', + default=2, + dest='maxJumpsPer', + metavar='N', + type=int, + ), + ParseArgument('--ly-per', + help='Maximum light years per jump.', + dest='maxLyPer', + metavar='N.NN', + type=float, + ), + ParseArgument('--limit', + help='Maximum units of any one cargo item to buy (0: unlimited).', + metavar='N', + type=int, + ), + ParseArgument('--unique', + help='Only visit each station once.', + action='store_true', + default=False, + ), + ParseArgument('--margin', + help='Reduce gains made on each hop to provide a margin of error ' + 'for market fluctuations (e.g: 0.25 reduces gains by 1/4). ' + '0<: N<: 0.25.', + default=0.00, + metavar='N.NN', + type=float, + ), + ParseArgument('--insurance', + help='Reserve at least this many credits to cover insurance.', + default=0, + metavar='CR', + type=int, + ), + ParseArgument('--routes', + help='Maximum number of routes to show. DEFAULT: 1', + default=1, + metavar='N', + type=int, + ), + ParseArgument('--checklist', + help='Provide a checklist flow for the route.', + action='store_true', + default=False, + ), + ParseArgument('--x52-pro', + help='Enable experimental X52 Pro MFD output.', + action='store_true', + default=False, + dest='x52pro', + ), + ] +) diff --git a/subcommands/update.py b/subcommands/update.py new file mode 100644 index 00000000..40bebde3 --- /dev/null +++ b/subcommands/update.py @@ -0,0 +1,232 @@ + +from tradeenv import * + +def getEditorPaths(tdenv, editorName, envVar, windowsFolders, winExe, nixExe): + tdenv.DEBUG(0, "Locating {} editor", editorName) + try: + return os.environ[envVar] + except KeyError: pass + + paths = [] + + import platform + system = platform.system() + if system == 'Windows': + binary = winExe + for folder in ["Program Files", "Program Files (x86)"]: + for version in windowsFolders: + paths.append("{}\\{}\\{}".format(os.environ['SystemDrive'], folder, version)) + else: + binary = nixExe + + try: + paths += os.environ['PATH'].split(os.pathsep) + except KeyError: pass + + for path in paths: + candidate = os.path.join(path, binary) + try: + if pathlib.Path(candidate).exists(): + return candidate + except OSError: + pass + + raise CommandLineError("ERROR: Unable to locate {} editor.\nEither specify the path to your editor with --editor or set the {} environment variable to point to it.".format(editorName, envVar)) + + +def editUpdate(tdb, tdenv, stationID): + """ + Dump the price data for a specific station to a file and + launch the user's text editor to let them make changes + to the file. + + If the user makes changes, re-load the file, update the + database and regenerate the master .prices file. + """ + + tdenv.DEBUG(0, "'update' mode with editor. editor:{} station:{}", + tdenv.editor, tdenv.origin) + + import buildcache + import prices + import subprocess + import os + + editor, editorArgs = tdenv.editor, [] + if tdenv.sublime: + tdenv.DEBUG(0, "Sublime mode") + if not editor: + editor = getEditorPaths(tdenv, "sublime", "SUBLIME_EDITOR", ["Sublime Text 3", "Sublime Text 2"], "sublime_text.exe", "subl") + editorArgs += [ "--wait" ] + elif tdenv.npp: + tdenv.DEBUG(0, "Notepad++ mode") + if not editor: + editor = getEditorPaths(tdenv, "notepad++", "NOTEPADPP_EDITOR", ["Notepad++"], "notepad++.exe", "notepad++") + if not tdenv.quiet: + print("NOTE: You'll need to exit Notepad++ to return control back to trade.py") + elif tdenv.vim: + tdenv.DEBUG(0, "VI iMproved mode") + if not editor: + # Hack... Hackity hack, hack, hack. + # This has a disadvantage in that: we don't check for just "vim" (no .exe) under Windows + vimDirs = [ "Git\\share\\vim\\vim{}".format(vimVer) for vimVer in range(70,75) ] + editor = getEditorPaths(tdenv, "vim", "EDITOR", vimDirs, "vim.exe", "vim") + elif tdenv.notepad: + tdenv.DEBUG(0, "Notepad mode") + editor = "notepad.exe" # herp + + try: + envArgs = os.environ["EDITOR_ARGS"] + if envArgs: editorArgs += envArgs.split(' ') + except KeyError: pass + + # Create a temporary text file with a list of the price data. + tmpPath = pathlib.Path("prices.tmp") + if tmpPath.exists(): + print("ERROR: Temporary file ({}) already exists.".format(tmpPath)) + sys.exit(1) + absoluteFilename = None + dbFilename = tdenv.dbFilename or tdb.defaultDB + try: + elementMask = prices.Element.basic + if tdenv.supply: elementMask |= prices.Element.supply + if tdenv.timestamps: elementMask |= prices.Element.timestamp + # Open the file and dump data to it. + with tmpPath.open("w") as tmpFile: + # Remember the filename so we know we need to delete it. + absoluteFilename = str(tmpPath.resolve()) + prices.dumpPrices(dbFilename, elementMask, + file=tmpFile, + stationID=stationID, + defaultZero=tdenv.forceNa, + debug=tdenv.debug) + + # Stat the file so we can determine if the user writes to it. + # Use the most recent create/modified timestamp. + preStat = tmpPath.stat() + preStamp = max(preStat.st_mtime, preStat.st_ctime) + + # Launch the editor + editorCommandLine = [ editor ] + editorArgs + [ absoluteFilename ] + tdenv.DEBUG(0, "Invoking [{}]", ' '.join(editorCommandLine)) + try: + result = subprocess.call(editorCommandLine) + except FileNotFoundError: + raise CommandLineError("Unable to launch specified editor: {}".format(editorCommandLine)) + if result != 0: + print("NOTE: Edit failed ({}), nothing to import.".format(result)) + sys.exit(1) + + # Did they update the file? Some editors destroy the file and rewrite it, + # other files just write back to it, and some OSes do weird things with + # these timestamps. That's why we have to use both mtime and ctime. + postStat = tmpPath.stat() + postStamp = max(postStat.st_mtime, postStat.st_ctime) + + if postStamp == preStamp: + import random + print("- No changes detected - doing nothing. {}".format(random.choice([ + "Brilliant!", "I'll get my coat.", "I ain't seen you.", "You ain't seen me", "... which was nice", "Bingo!", "Scorchio!", "Boutros, boutros, ghali!", "I'm Ed Winchester!", "Suit you, sir! Oh!" + ]))) + sys.exit(0) + + tdenv.DEBUG(0, "File changed - importing data.") + + buildcache.processPricesFile(tdenv, + db=tdb.getDB(), + pricesPath=tmpPath, + stationID=stationID, + defaultZero=tdenv.forceNa + ) + + # If everything worked, we need to re-build the prices file. + tdenv.DEBUG(0, "Update complete, regenerating .prices file") + + with tdb.pricesPath.open("w") as pricesFile: + prices.dumpPrices(dbFilename, prices.Element.full, file=pricesFile, debug=tdenv.debug) + + # Update the DB file so we don't regenerate it. + pathlib.Path(dbFilename).touch() + + finally: + # If we created the file, we delete the file. + if absoluteFilename: tmpPath.unlink() + + +def updateCommand(tdb, tdenv): + """ + Allow the user to update the prices database. + """ + + station = tdenv.startStation + stationID = station.ID + + if tdenv.all or tdenv.zero: + raise CommandLineError("--all and --zero have been removed. Use '--supply' (-S for short) if you want to edit demand and stock values during update. Use '--timestamps' (-T for short) if you want to include timestamps.") + + if tdenv._editing: + # User specified one of the options to use an editor. + return editUpdate(tdb, tdenv, stationID) + + tdenv.DEBUG(0, 'guided "update" mode station:{}', station) + + print("Guided mode not implemented yet. Try using --editor, --sublime or --notepad") + + +subCommand_UPDATE = SubCommandParser( + name='update', + cmdFunc=updateCommand, + help='Update prices for a station.', + epilog="Generates a human-readable version of the price list for a given station " + "and opens it in the specified text editor.\n" + "The format is intended to closely resemble the presentation of the " + "market in-game. If you change the order items are listed in, " + "the order will be kept for future edits, making it easier to quickly " + "check for changes.", + arguments = [ + ParseArgument('origin', help='Name of the station to update.', type=str) + ], + switches = [ + ParseArgument('--editor', + help='Generates a text file containing the prices for the station and ' + 'loads it into the specified editor for you.', + action=EditAction, + default=None, + type=str, + ), + ParseArgument('--supply', '-S', + help='Includes demand and stock (supply) values in the update.', + action='store_true', + default=False, + ), + ParseArgument('--timestamps', '-T', + help='Includes timestamps in the update.', + action='store_true', + default=False, + ), + ParseArgument('--force-na', '-0', + help="Forces 'unk' supply to become 'n/a' by default", + action='store_true', + default=False, + dest='forceNa', + ), + MutuallyExclusiveGroup( + ParseArgument('--sublime', + help='Like --editor but uses Sublime Text (2 or 3), which is nice.', + action=EditActionStoreTrue, + ), + ParseArgument('--notepad', + help='Like --editor but uses Notepad.', + action=EditActionStoreTrue, + ), + ParseArgument('--npp', + help='Like --editor but uses Notepad++.', + action=EditActionStoreTrue, + ), + ParseArgument('--vim', + help='Like --editor but uses vim.', + action=EditActionStoreTrue, + ), + ) + ] +) diff --git a/trade.py b/trade.py index 773e7712..62cce8cb 100755 --- a/trade.py +++ b/trade.py @@ -45,1048 +45,51 @@ # of idiot puts globals in their programs? import errno -args = None -originStation, finalStation = None, None -# Things not to do, places not to go, people not to see. -avoidItems, avoidSystems, avoidStations = [], [], [] -# Stations we need to visit -viaStations = set() -originName, destName = "Any", "Any" -origins = [] -maxUnits = 0 - ###################################################################### # Database and calculator modules. from tradeexcept import TradeException +from tradeenv import * from tradedb import TradeDB, AmbiguityError from tradecalc import Route, TradeCalc, localedNo ###################################################################### -# Helpers - -class CommandLineError(TradeException): - """ - Raised when you provide invalid input on the command line. - Attributes: - errorstr What to tell the user. - """ - def __init__(self, errorStr): - self.errorStr = errorStr - def __str__(self): - return 'Error in command line: {}'.format(self.errorStr) - - -class NoDataError(TradeException): - """ - Raised when a request is made for which no data can be found. - Attributes: - errorStr Describe the problem to the user. - """ - def __init__(self, errorStr): - self.errorStr = errorStr - def __str__(self): - return "Error: {}\n".format(self.errorStr) + \ - "This can happen if you have not yet entered any price data for the station(s) involved, " + \ - "if there are no profitable trades between them, " + \ - "or the items are marked as 'n/a'.\n" + \ - "See 'trade.py update -h' for help entering prices, or obtain a '.prices' file from the interwebs.\n" + \ - "Or see https://bitbucket.org/kfsone/tradedangerous/wiki/Price%20Data for more help.\n" - - -class HelpAction(argparse.Action): - """ - argparse action helper for printing the argument usage, - because Python 3.4's argparse is ever so subtly very broken. - """ - def __call__(self, parser, namespace, values, option_string=None): - parser.print_help() - sys.exit(0) - - -class EditAction(argparse.Action): - """ - argparse action that stores a value and also flags args._editing - """ - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, "_editing", True) - setattr(namespace, self.dest, values or self.default) - - -class EditActionStoreTrue(argparse.Action): - """ - argparse action that stores True but also flags args._editing - """ - def __init__(self, option_strings, dest, nargs=None, **kwargs): - if nargs is not None: - raise ValueError("nargs not allowed") - super(EditActionStoreTrue, self).__init__(option_strings, dest, nargs=0, **kwargs) - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, "_editing", True) - setattr(namespace, self.dest, True) - - -def new_file_arg(string): - """ argparse action handler for specifying a file that does not already exist. """ - - path = pathlib.Path(string) - if not path.exists(): return path - sys.stderr.write("ERROR: Specified file, \"{}\", already exists.\n".format(path)) - sys.exit(errno.EEXIST) - - -class ParseArgument(object): - """ - Provides argument forwarding so that 'makeSubParser' can take function-like arguments. - """ - def __init__(self, *args, **kwargs): - self.args, self.kwargs = args, kwargs - - -def makeSubParser(subparsers, name, help, commandFunc, arguments=None, switches=None, epilog=None): - """ - Provide a normalized sub-parser for a specific command. This helps to - make it easier to keep the command lines consistent and makes the calls - to build them easier to write/read. - """ - - subParser = subparsers.add_parser(name, help=help, add_help=False, epilog=epilog) - - def addArguments(group, options, required, topGroup=None): - """ - Registers a list of options to the specified group. Nodes - are either an instance of ParseArgument or a list of - ParseArguments. The list form is considered to be a - mutually exclusive group of arguments. - """ - - for option in options: - # lists indicate mutually exclusive subgroups - if isinstance(option, list): - addArguments((topGroup or group).add_mutually_exclusive_group(), option, required, topGroup=group) - else: - assert not required in option.kwargs - if option.args[0][0] == '-': - group.add_argument(*(option.args), required=required, **(option.kwargs)) - else: - group.add_argument(*(option.args), **(option.kwargs)) - - if arguments: - argParser = subParser.add_argument_group('Required Arguments') - addArguments(argParser, arguments, True) - - switchParser = subParser.add_argument_group('Optional Switches') - switchParser.add_argument('-h', '--help', help='Show this help message and exit.', action=HelpAction, nargs=0) - addArguments(switchParser, switches, False) - - subParser.set_defaults(proc=commandFunc) - - return subParser - - -def printHeading(text): - """ Print a line of text followed by a matching line of '-'s. """ - print(text) - print('-' * len(text)) - - -###################################################################### -# Checklist functions - -class Checklist(object): - def __init__(self, tdb, mfd): - self.tdb = tdb - self.mfd = mfd - - def doStep(self, action, detail=None, extra=None): - self.stepNo += 1 - try: - self.mfd.display("#{} {}".format(self.stepNo, action), detail or "", extra or "") - except AttributeError: pass - input(" {:<3}: {}: ".format(self.stepNo, " ".join([item for item in [action, detail, extra] if item]))) - - - def note(self, str, addBreak=True): - print("(i) {} (i){}".format(str, "\n" if addBreak else "")) - - - def run(self, route, credits): - tdb, mfd = self.tdb, self.mfd - stations, hops, jumps = route.route, route.hops, route.jumps - lastHopIdx = len(stations) - 1 - gainCr = 0 - self.stepNo = 0 - - printHeading("(i) BEGINNING CHECKLIST FOR {} (i)".format(route.str())) - print() - - if args.detail: - print(route.summary()) - print() - - for idx in range(lastHopIdx): - hopNo = idx + 1 - cur, nxt, hop = stations[idx], stations[idx + 1], hops[idx] - sortedTradeOptions = sorted(hop[0], key=lambda tradeOption: tradeOption[1] * tradeOption[0].gainCr, reverse=True) - - # Tell them what they need to buy. - if args.detail: - self.note("HOP {} of {}".format(hopNo, lastHopIdx)) - - self.note("Buy at {}".format(cur.name())) - for (trade, qty) in sortedTradeOptions: - self.doStep('Buy {} x'.format(qty), trade.name(), '@ {}cr'.format(localedNo(trade.costCr))) - if args.detail: - self.doStep('Refuel') - print() - - # If there is a next hop, describe how to get there. - self.note("Fly {}".format(" -> ".join([ jump.name() for jump in jumps[idx] ]))) - if idx < len(hops) and jumps[idx]: - for jump in jumps[idx][1:]: - self.doStep('Jump to', jump.name()) - if args.detail: - self.doStep('Dock at', nxt.str()) - print() - - self.note("Sell at {}".format(nxt.name())) - for (trade, qty) in sortedTradeOptions: - self.doStep('Sell {} x'.format(localedNo(qty)), trade.name(), '@ {}cr'.format(localedNo(trade.costCr + trade.gainCr))) - print() - - gainCr += hop[1] - if args.detail and gainCr > 0: - self.note("GAINED: {}cr, CREDITS: {}cr".format(localedNo(gainCr), localedNo(credits + gainCr))) - - if hopNo < lastHopIdx: - print("\n--------------------------------------\n") - - if mfd: - mfd.display('FINISHED', "+{}cr".format(localedNo(gainCr)), "={}cr".format(localedNo(credits + gainCr))) - mfd.attention(3) - time.sleep(1.5) - - -###################################################################### -# "run" command functionality. - -def parseAvoids(tdb, args): - """ - Process a list of avoidances. - """ - - global avoidItems, avoidSystems, avoidStations - - avoidances = args.avoid - - # You can use --avoid to specify an item, system or station. - # and you can group them together with commas or list them - # individually. - for avoid in ','.join(avoidances).split(','): - # Is it an item? - item, system, station = None, None, None - try: - item = tdb.lookupItem(avoid) - avoidItems.append(item) - if TradeDB.normalizedStr(item.name()) == TradeDB.normalizedStr(avoid): - continue - except LookupError: - pass - # Is it a system perhaps? - try: - system = tdb.lookupSystem(avoid) - avoidSystems.append(system) - if TradeDB.normalizedStr(system.str()) == TradeDB.normalizedStr(avoid): - continue - except LookupError: - pass - # Or perhaps it is a station - try: - station = tdb.lookupStationExplicitly(avoid) - if (not system) or (station.system is not system): - avoidSystems.append(station.system) - avoidStations.append(station) - if TradeDB.normalizedStr(station.str()) == TradeDB.normalizedStr(avoid): - continue - except LookupError as e: - pass - - # If it was none of the above, whine about it - if not (item or system or station): - raise CommandLineError("Unknown item/system/station: %s" % avoid) - - # But if it matched more than once, whine about ambiguity - if item and system: raise AmbiguityError('Avoidance', avoid, [ item, system.str() ]) - if item and station: raise AmbiguityError('Avoidance', avoid, [ item, station.str() ]) - if system and station and station.system != system: raise AmbiguityError('Avoidance', avoid, [ system.str(), station.str() ]) - - if args.debug: - print("Avoiding items %s, systems %s, stations %s" % ( - [ item.name() for item in avoidItems ], - [ system.name() for system in avoidSystems ], - [ station.name() for station in avoidStations ] - )) - - -def parseVias(tdb, args): - """ - Process a list of station names and build them into a - list of waypoints for the route. - """ - - # accept [ "a", "b,c", "d" ] by joining everything and then splitting it. - global viaStations - - for via in ",".join(args.via).split(","): - station = tdb.lookupStation(via) - if station.itemCount == 0: - raise NoDataError("No price data available for via station {}.".format(station.name())) - viaStations.add(station) - - -def processRunArguments(tdb, args): - """ - Process arguments to the 'run' option. - """ - - global origins, originStation, finalStation, maxUnits, originName, destName, unspecifiedHops - - if args.credits < 0: - raise CommandLineError("Invalid (negative) value for initial credits") - # I'm going to allow 0 credits as a future way of saying "just fly" - - if args.routes < 1: - raise CommandLineError("Maximum routes has to be 1 or higher") - if args.routes > 1 and args.checklist: - raise CommandLineError("Checklist can only be applied to a single route.") - - if args.hops < 1: - raise CommandLineError("Minimum of 1 hop required") - if args.hops > 64: - raise CommandLineError("Too many hops without more optimization") - - if args.maxJumpsPer < 0: - raise CommandLineError("Negative jumps: you're already there?") - - if args.origin: - originName = args.origin - originStation = tdb.lookupStation(originName) - origins = [ originStation ] - else: - origins = [ station for station in tdb.stationByID.values() ] - - if args.dest: - destName = args.dest - finalStation = tdb.lookupStation(destName) - if args.hops == 1 and originStation and finalStation and originStation == finalStation: - raise CommandLineError("More than one hop required to use same from/to destination") - - if args.avoid: - parseAvoids(tdb, args) - if args.via: - parseVias(tdb, args) - - unspecifiedHops = args.hops + (0 if originStation else 1) - (1 if finalStation else 0) - if len(viaStations) > unspecifiedHops: - raise CommandLineError("Too many vias: {} stations vs {} hops available.".format(len(viaStations), unspecifiedHops)) - - # If the user specified a ship, use it to fill out details unless - # the user has explicitly supplied them. E.g. if the user says - # --ship sidewinder --capacity 2, use their capacity limit. - if args.ship: - ship = tdb.lookupShip(args.ship) - args.ship = ship - if args.capacity is None: args.capacity = ship.capacity - if args.maxLyPer is None: args.maxLyPer = ship.maxLyFull - if args.capacity is None: - raise CommandLineError("Missing '--capacity' or '--ship' argument") - if args.maxLyPer is None: - raise CommandLineError("Missing '--ly-per' or '--ship' argument") - if args.capacity < 0: - raise CommandLineError("Invalid (negative) cargo capacity") - if args.capacity > 1000: - raise CommandLineError("Capacity > 1000 not supported (you specified %s)" % args.capacity) - - if args.limit and args.limit > args.capacity: - raise CommandLineError("'limit' must be <= capacity") - if args.limit and args.limit < 0: - raise CommandLineError("'limit' can't be negative, silly") - maxUnits = args.limit if args.limit else args.capacity - - arbitraryInsuranceBuffer = 42 - if args.insurance and args.insurance >= (args.credits + arbitraryInsuranceBuffer): - raise CommandLineError("Insurance leaves no margin for trade") - - if args.unique and args.hops >= len(tdb.stationByID): - raise CommandLineError("Requested unique trip with more hops than there are stations...") - if args.unique: - if ((originStation and originStation == finalStation) or - (originStation and originStation in viaStations) or - (finalStation and finalStation in viaStations)): - raise CommandLineError("from/to/via repeat conflicts with --unique") - - tdb.loadTrades() - - if originStation and originStation.itemCount == 0: - raise NoDataError("Start station {} doesn't have any price data.".format(originStation.name())) - if finalStation and finalStation.itemCount == 0: - raise NoDataError("End station {} doesn't have any price data.".format(finalStation.name())) - if finalStation and args.hops == 1 and originStation and not finalStation in originStation.tradingWith: - raise CommandLineError("No profitable items found between {} and {}".format(originStation.name(), finalStation.name())) - if originStation and len(originStation.tradingWith) == 0: - raise NoDataError("No data found for potential buyers for items from {}.".format(originStation.name())) - - if args.x52pro: - from mfd import X52ProMFD - args.mfd = X52ProMFD() - else: - args.mfd = None - - -def runCommand(tdb, args): - """ Calculate trade runs. """ - - if args.debug: print("# 'run' mode") - - if tdb.tradingCount == 0: - raise NoDataError("Database does not contain any profitable trades.") - - processRunArguments(tdb, args) - - startCr = args.credits - args.insurance - routes = [ - Route(stations=[src], hops=[], jumps=[], startCr=startCr, gainCr=0) - for src in origins - if not (src in avoidStations or src.system in avoidSystems) - ] - numHops = args.hops - lastHop = numHops - 1 - viaStartPos = 1 if originStation else 0 - - if args.debug or args.detail: - summaries = [ 'With {}cr'.format(localedNo(args.credits)) ] - summaries += [ 'From {}'.format(originStation.str() if originStation else 'Anywhere') ] - summaries += [ 'To {}'.format(finalStation.str() if finalStation else 'Anywhere') ] - if viaStations: summaries += [ 'Via {}'.format(', '.join([ station.str() for station in viaStations ])) ] - print(*summaries, sep=' / ') - print("%d cap, %d hops, max %d jumps/hop and max %0.2f ly/jump" % (args.capacity, numHops, args.maxJumpsPer, args.maxLyPer)) - print() - - # Instantiate the calculator object - calc = TradeCalc(tdb, debug=args.debug, capacity=args.capacity, maxUnits=maxUnits, margin=args.margin, unique=args.unique) - - # Build a single list of places we want to avoid - # TODO: Keep these seperate because we wind up spending - # time breaking the list down in getDestinations. - avoidPlaces = avoidSystems + avoidStations - - if args.debug: print("unspecified hops {}, numHops {}, viaStations {}".format(unspecifiedHops, numHops, len(viaStations))) - for hopNo in range(numHops): - if calc.debug: print("# Hop %d" % hopNo) - if args.mfd: - args.mfd.display('TradeDangerous', 'CALCULATING', 'Hop {}'.format(hopNo)) - - restrictTo = None - if hopNo == lastHop and finalStation: - restrictTo = set([finalStation]) - ### TODO: - ### if hopsLeft < len(viaStations): - ### ... only keep routes that include len(viaStations)-hopsLeft routes - ### Thus: If you specify 5 hops via a,b,c and we are on hop 3, only keep - ### routes that include a, b or c. On hop 4, only include routes that - ### already include 2 of the vias, on hop 5, require all 3. - if viaStations: - routes = [ route for route in routes if viaStations & set(route.route[viaStartPos:]) ] - elif unspecifiedHops == len(viaStations): - # Everywhere we're going is in the viaStations list. - restrictTo = viaStations - - routes = calc.getBestHops(routes, startCr, - restrictTo=restrictTo, avoidItems=avoidItems, avoidPlaces=avoidPlaces, - maxJumpsPer=args.maxJumpsPer, maxLyPer=args.maxLyPer) - - if viaStations: - routes = [ route for route in routes if viaStations & set(route.route[viaStartPos:]) ] - - if not routes: - print("No profitable trades matched your critera, or price data along the route is missing.") - return - - routes.sort() - - for i in range(0, min(len(routes), args.routes)): - print(routes[i].detail(detail=args.detail)) - - # User wants to be guided through the route. - if args.checklist: - assert args.routes == 1 - cl = Checklist(tdb, args.mfd) - cl.run(routes[0], args.credits) - - -###################################################################### -# "update" command functionality. - -def getEditorPaths(args, editorName, envVar, windowsFolders, winExe, nixExe): - if args.debug: print("# Locating {} editor".format(editorName)) - try: - return os.environ[envVar] - except KeyError: pass - - paths = [] - - import platform - system = platform.system() - if system == 'Windows': - binary = winExe - for folder in ["Program Files", "Program Files (x86)"]: - for version in windowsFolders: - paths.append("{}\\{}\\{}".format(os.environ['SystemDrive'], folder, version)) - else: - binary = nixExe - - try: - paths += os.environ['PATH'].split(os.pathsep) - except KeyError: pass - - for path in paths: - candidate = os.path.join(path, binary) - try: - if pathlib.Path(candidate).exists(): - return candidate - except OSError: - pass - - raise CommandLineError("ERROR: Unable to locate {} editor.\nEither specify the path to your editor with --editor or set the {} environment variable to point to it.".format(editorName, envVar)) - - -def editUpdate(tdb, args, stationID): - """ - Dump the price data for a specific station to a file and - launch the user's text editor to let them make changes - to the file. - - If the user makes changes, re-load the file, update the - database and regenerate the master .prices file. - """ - - if args.debug: print("# 'update' mode with editor. editor:{} station:{}".format(args.editor, args.station)) - - import buildcache - import prices - import subprocess - import os - - editor, editorArgs = args.editor, [] - if args.sublime: - if args.debug: print("# Sublime mode") - if not editor: - editor = getEditorPaths(args, "sublime", "SUBLIME_EDITOR", ["Sublime Text 3", "Sublime Text 2"], "sublime_text.exe", "subl") - editorArgs += [ "--wait" ] - elif args.npp: - if args.debug: print("# Notepad++ mode") - if not editor: - editor = getEditorPaths(args, "notepad++", "NOTEPADPP_EDITOR", ["Notepad++"], "notepad++.exe", "notepad++") - if not args.quiet: print("NOTE: You'll need to exit Notepad++ to return control back to trade.py") - elif args.vim: - if args.debug: print("# VI iMproved mode") - if not editor: - # Hack... Hackity hack, hack, hack. - # This has a disadvantage in that: we don't check for just "vim" (no .exe) under Windows - vimDirs = [ "Git\\share\\vim\\vim{}".format(vimVer) for vimVer in range(70,75) ] - editor = getEditorPaths(args, "vim", "EDITOR", vimDirs, "vim.exe", "vim") - elif args.notepad: - if args.debug: print("# Notepad mode") - editor = "notepad.exe" # herp - - try: - envArgs = os.environ["EDITOR_ARGS"] - if envArgs: editorArgs += envArgs.split(' ') - except KeyError: pass - - # Create a temporary text file with a list of the price data. - tmpPath = pathlib.Path("prices.tmp") - if tmpPath.exists(): - print("ERROR: Temporary file ({}) already exists.".format(tmpPath)) - sys.exit(1) - absoluteFilename = None - try: - elementMask = prices.Element.basic - if args.supply: elementMask |= prices.Element.supply - if args.timestamps: elementMask |= prices.Element.timestamp - # Open the file and dump data to it. - with tmpPath.open("w") as tmpFile: - # Remember the filename so we know we need to delete it. - absoluteFilename = str(tmpPath.resolve()) - prices.dumpPrices(args.db, elementMask, file=tmpFile, stationID=stationID, defaultZero=args.forceNa, debug=args.debug) - - # Stat the file so we can determine if the user writes to it. - # Use the most recent create/modified timestamp. - preStat = tmpPath.stat() - preStamp = max(preStat.st_mtime, preStat.st_ctime) - - # Launch the editor - editorCommandLine = [ editor ] + editorArgs + [ absoluteFilename ] - if args.debug: print("# Invoking [{}]".format(' '.join(editorCommandLine))) - try: - result = subprocess.call(editorCommandLine) - except FileNotFoundError: - raise CommandLineError("Unable to launch specified editor: {}".format(editorCommandLine)) - if result != 0: - print("NOTE: Edit failed ({}), nothing to import.".format(result)) - sys.exit(1) - - # Did they update the file? Some editors destroy the file and rewrite it, - # other files just write back to it, and some OSes do weird things with - # these timestamps. That's why we have to use both mtime and ctime. - postStat = tmpPath.stat() - postStamp = max(postStat.st_mtime, postStat.st_ctime) - - if postStamp == preStamp: - import random - print("- No changes detected - doing nothing. {}".format(random.choice([ - "Brilliant!", "I'll get my coat.", "I ain't seen you.", "You ain't seen me", "... which was nice", "Bingo!", "Scorchio!", "Boutros, boutros, ghali!", "I'm Ed Winchester!", "Suit you, sir! Oh!" - ]))) - sys.exit(0) - - if args.debug: - print("# File changed - importing data.") - - buildcache.processPricesFile(db=tdb.getDB(), pricesPath=tmpPath, stationID=stationID, defaultZero=args.forceNa, debug=args.debug) - - # If everything worked, we need to re-build the prices file. - if args.debug: - print("# Update complete, regenerating .prices file") - - with tdb.pricesPath.open("w") as pricesFile: - prices.dumpPrices(args.db, prices.Element.full, file=pricesFile, debug=args.debug) - - # Update the DB file so we don't regenerate it. - pathlib.Path(args.db).touch() - - finally: - # If we created the file, we delete the file. - if absoluteFilename: tmpPath.unlink() - - -def updateCommand(tdb, args): - """ - Allow the user to update the prices database. - """ - - station = tdb.lookupStation(args.station) - stationID = station.ID - - if args.all or args.zero: - raise CommandLineError("--all and --zero have been removed. Use '--supply' (-S for short) if you want to edit demand and stock values during update. Use '--timestamps' (-T for short) if you want to include timestamps.") - - if args._editing: - # User specified one of the options to use an editor. - return editUpdate(tdb, args, stationID) - - if args.debug: print('# guided "update" mode station:{}'.format(args.station)) - - print("Guided mode not implemented yet. Try using --editor, --sublime or --notepad") - - -###################################################################### -# - -def lookupSystemByNameOrStation(tdb, name, intent): - """ - Look up a name using either a system or station name. - """ - - try: - return tdb.lookupSystem(name) - except LookupError: - try: - return tdb.lookupStationExplicitly(name).system - except LookupError: - raise CommandLineError("Unknown {} system/station, '{}'".format(intent, name)) - - -def distanceAlongPill(tdb, sc, percent): - """ - Estimate a distance along the Pill using 2 reference systems - """ - sa = tdb.lookupSystem("Eranin") - sb = tdb.lookupSystem("HIP 107457") - dotProduct = (sb.posX-sa.posX) * (sc.posX-sa.posX) \ - + (sb.posY-sa.posY) * (sc.posY-sa.posY) \ - + (sb.posZ-sa.posZ) * (sc.posZ-sa.posZ) - length = math.sqrt((sb.posX-sa.posX) * (sb.posX-sa.posX) - + (sb.posY-sa.posY) * (sb.posY-sa.posY) - + (sb.posZ-sa.posZ) * (sb.posZ-sa.posZ)) - if percent: - return 100. * dotProduct / length / length - - return dotProduct / length - - -def localCommand(tdb, args): - """ - Local systems - """ - - srcSystem = lookupSystemByNameOrStation(tdb, args.system, 'system') - - if args.ship: - ship = tdb.lookupShip(args.ship) - args.ship = ship - if args.ly is None: args.ly = (ship.maxLyFull if args.full else ship.maxLyEmpty) - ly = args.ly or tdb.maxSystemLinkLy - - tdb.buildLinks() - - printHeading("Local systems to {} within {} ly.".format(srcSystem.name(), ly)) - - distances = { } - - for (destSys, destDist) in srcSystem.links.items(): - if args.debug: - print("Checking {} dist={:5.2f}".format(destSys.str(), destDist)) - if destDist > ly: - continue - distances[destSys] = destDist - - for (system, dist) in sorted(distances.items(), key=lambda x: x[1]): - pillLength = "" - if args.pill or args.percent: - pillLengthFormat = " [{:4.0f}%]" if args.percent else " [{:5.1f}]" - pillLength = pillLengthFormat.format(distanceAlongPill(tdb, system, args.percent)) - print("{:5.2f}{} {}".format(dist, pillLength, system.str())) - if args.detail: - for (station) in system.stations: - stationDistance = " {} ls".format(station.lsFromStar) if station.lsFromStar > 0 else "" - print("\t<{}>{}".format(station.str(), stationDistance)) - - -def navCommand(tdb, args): - """ - Give player directions A->B - """ - - srcSystem = lookupSystemByNameOrStation(tdb, args.start, 'start') - dstSystem = lookupSystemByNameOrStation(tdb, args.end, 'end') - - avoiding = [] - if args.ship: - ship = tdb.lookupShip(args.ship) - args.ship = ship - if args.maxLyPer is None: args.maxLyPer = (ship.maxLyFull if args.full else ship.maxLyEmpty) - maxLyPer = args.maxLyPer or tdb.maxSystemLinkLy - - if args.debug: - print("# Route from {} to {} with max {} ly per jump.".format(srcSystem.name(), dstSystem.name(), maxLyPer)) - - openList = { srcSystem: 0.0 } - distances = { srcSystem: [ 0.0, None ] } - - tdb.buildLinks() - - # As long as the open list is not empty, keep iterating. - while openList and not dstSystem in distances: - # Expand the search domain by one jump; grab the list of - # nodes that are this many hops out and then clear the list. - openNodes, openList = openList, {} - - for (node, startDist) in openNodes.items(): - for (destSys, destDist) in node.links.items(): - if destDist > maxLyPer: - continue - dist = startDist + destDist - # If we already have a shorter path, do nothing - try: - distNode = distances[destSys] - if distNode[0] <= dist: - continue - distNode[0], distNode[1] = dist, node - except KeyError: - distances[destSys] = [ dist, node ] - assert not destSys in openList or openList[destSys] > dist - openList[destSys] = dist - - # Unravel the route by tracing back the vias. - route = [ dstSystem ] - try: - while route[-1] != srcSystem: - jumpEnd = route[-1] - jumpStart = distances[jumpEnd][1] - route.append(jumpStart) - except KeyError: - print("No route found between {} and {} with {}ly jump limit.".format(srcSystem.name(), dstSystem.name(), maxLyPer)) - return - route.reverse() - titleFormat = "From {src} to {dst} with {mly}ly per jump limit." - if args.detail: - labelFormat = "{act:<6} | {sys:<30} | {jly:<7} | {tly:<8}" - stepFormat = "{act:<6} | {sys:<30} | {jly:>7.2f} | {tly:>8.2f}" - elif not args.quiet: - labelFormat = "{sys:<30} ({jly:<7})" - stepFormat = "{sys:<30} ({jly:>7.2f})" - elif args.quiet == 1: - titleFormat = "{src}->{dst} limit {mly}ly:" - labelFormat = None - stepFormat = " {sys}" - else: - titleFormat, labelFormat, stepFormat = None, None, "{sys}" - - if titleFormat: - print(titleFormat.format(src=srcSystem.name(), dst=dstSystem.name(), mly=maxLyPer)) - - if labelFormat: - printHeading(labelFormat.format(act='Action', sys='System', jly='Jump Ly', tly='Total Ly')) - - lastHop, totalLy = None, 0.00 - def present(action, system): - nonlocal lastHop, totalLy - jumpLy = system.links[lastHop] if lastHop else 0.00 - totalLy += jumpLy - print(stepFormat.format(act=action, sys=system.name(), jly=jumpLy, tly=totalLy)) - lastHop = system - - present('Depart', srcSystem) - for viaSys in route[1:-1]: - present('Via', viaSys) - present('Arrive', dstSystem) - - -###################################################################### -# - -def buyCommand(tdb, args): - """ - Locate places selling a given item. - """ - - item = tdb.lookupItem(args.item) - - # Constraints - constraints = [ "(item_id = ?)", "buy_from > 0", "stock != 0" ] - bindValues = [ item.ID ] - - if args.quantity: - constraints.append("(stock = -1 or stock >= ?)") - bindValues.append(args.quantity) - - near = args.near - if near: - tdb.buildLinks() - nearSystem = tdb.lookupSystem(near) - maxLy = float("inf") if args.maxLyPer is None else args.maxLyPer - # Uh - why haven't I made a function on System to get a - # list of all the systems within N hops at L ly per hop? - stations = [] - for station in nearSystem.stations: - if station.itemCount > 0: - stations.append(str(station.ID)) - for system, dist in nearSystem.links.items(): - if dist <= maxLy: - for station in system.stations: - if station.itemCount > 0: - stations.append(str(station.ID)) - if not stations: - raise NoDataError("No stations listed as selling items within range") - constraints.append("station_id IN ({})".format(','.join(stations))) - - whereClause = ' AND '.join(constraints) - stmt = """ - SELECT station_id, buy_from, stock - FROM Price - WHERE {} - """.format(whereClause) - if args.debug: - print("* SQL: {}".format(stmt)) - cur = tdb.query(stmt, bindValues) - - from collections import namedtuple - Result = namedtuple('Result', [ 'station', 'cost', 'stock', 'dist' ]) - results = [] - stationByID = tdb.stationByID - dist = 0.0 - for (stationID, costCr, stock) in cur: - stn = stationByID[stationID] - if near: - dist = stn.system.links[nearSystem] if stn.system != nearSystem else 0.0 - results.append(Result(stationByID[stationID], costCr, stock, dist)) - - if not results: - raise NoDataError("No available items found") - - if args.sortByStock: - results.sort(key=lambda result: result.cost) - results.sort(key=lambda result: result.stock, reverse=True) - else: - results.sort(key=lambda result: result.stock, reverse=True) - results.sort(key=lambda result: result.cost) - if near and not args.sortByPrice: - results.sort(key=lambda result: result.dist) - - maxStnNameLen = len(max(results, key=lambda result: len(result.station.dbname) + len(result.station.system.dbname) + 1).station.name()) - printHeading("{:<{maxStnLen}} {:>10} {:>10} {:{distFmt}}".format( - "Station", "Cost", "Stock", "Ly" if near else "", - maxStnLen=maxStnNameLen, - distFmt=">6" if near else "" - )) - for result in results: - print("{:<{maxStnLen}} {:>10n} {:>10} {:{distFmt}}".format( - result.station.name(), - result.cost, - localedNo(result.stock) if result.stock > 0 else "", - result.dist if near else "", - maxStnLen=maxStnNameLen, - distFmt=">6.2f" if near else "" - )) +# Definitions for the sub-commands we support and their arguments. + +from subcommands.buy import subCommand_BUY +from subcommands.local import subCommand_LOCAL +from subcommands.nav import subCommand_NAV +from subcommands.run import subCommand_RUN +from subcommands.update import subCommand_UPDATE + +subCommandParsers = [ + subCommand_BUY, + subCommand_LOCAL, + subCommand_NAV, + subCommand_RUN, + subCommand_UPDATE, +] ###################################################################### # main entry point def main(): - global args - - parser = argparse.ArgumentParser(description='Trade run calculator', add_help=False, epilog='For help on a specific command, use the command followed by -h.') - parser.set_defaults(_editing=False) + # load arguments/environment + tdenv = TradeEnv('TradeDangerous: ED trading database tools.', subCommandParsers) - # Arguments common to all subparsers. - commonArgs = parser.add_argument_group('Common Switches') - commonArgs.add_argument('-h', '--help', help='Show this help message and exit.', action=HelpAction, nargs=0) - commonArgs.add_argument('--debug', '-w', help='Enable diagnostic output.', default=0, required=False, action='count') - commonArgs.add_argument('--detail', '-v', help='Increase level of detail in output.', default=0, required=False, action='count') - commonArgs.add_argument('--quiet', '-q', help='Reduce level of detail in output.', default=0, required=False, action='count') - commonArgs.add_argument('--db', help='Specify location of the SQLite database. Default: {}'.format(TradeDB.defaultDB), type=str, default=str(TradeDB.defaultDB)) - commonArgs.add_argument('--cwd', '-C', help='Change the directory relative to which TradeDangerous will try to access files such as the .db, etc.', type=str, required=False) + # load the database + tdb = TradeDB(tdenv, buildLinks=False, includeTrades=False) - subparsers = parser.add_subparsers(dest='subparser', title='Commands') - - # Find places that are selling an item within range of a specified system. - buyParser = makeSubParser(subparsers, 'buy', 'Find places to buy a given item within range of a given station.', buyCommand, - arguments = [ - ParseArgument('item', help='Name of item to query.', type=str), - ], - switches = [ - ParseArgument('--quantity', help='Require at least this quantity.', type=int, default=0), - ParseArgument('--near', help='Find sellers within jump range of this system.', type=str), - ParseArgument('--ly-per', help='Maximum light years per jump.', metavar='N.NN', dest='maxLyPer', type=float, default=None), - [ - ParseArgument('--price-sort', '-P', help='(When using --near) Sort by price not distance', dest='sortByPrice', action='store_true', default=False), - ParseArgument('--stock-sort', '-S', help='Sort by stock followed by price', dest='sortByStock', action='store_true', default=False), - ], - ], - ) - - # "nav" tells you how to get from one place to another. - navParser = makeSubParser(subparsers, 'nav', 'Calculate a route between two systems.', navCommand, - arguments = [ - ParseArgument('start', help='System to start from', type=str), - ParseArgument('end', help='System to end at', type=str), - ], - switches = [ - ParseArgument('--ship', help='Use the maximum jump distance of the specified ship (defaults to the empty value).', metavar='shiptype', type=str), - ParseArgument('--full', help='(With --ship) Limits the jump distance to that of a full ship.', action='store_true', default=False), - ParseArgument('--ly-per', help='Maximum light years per jump.', metavar='N.NN', type=float, dest='maxLyPer'), - ] - ) - - # "local" shows systems local to given system. - localParser = makeSubParser(subparsers, 'local', 'Calculate local systems.', localCommand, - arguments = [ - ParseArgument('system', help='System to measure from', type=str), - ], - switches = [ - ParseArgument('--ship', help='Use the maximum jump distance of the specified ship (defaults to the empty value).', metavar='shiptype', type=str), - ParseArgument('--full', help='(With --ship) Limits the jump distance to that of a full ship.', action='store_true', default=False), - ParseArgument('--ly', help='Maximum light years to measure.', metavar='N.NN', type=float, dest='ly'), - [ - ParseArgument('--pill', help='Show distance along the pill in ly.', action='store_true', default=False), - ParseArgument('--percent', help='Show distance along pill as percentage.', action='store_true', default=False), - ], - ] - ) - - # "run" calculates a trade run. - runParser = makeSubParser(subparsers, 'run', 'Calculate best trade run.', runCommand, - arguments = [ - ParseArgument('--credits', help='Starting credits.', metavar='CR', type=int) - ], - switches = [ - ParseArgument('--ship', help='Set capacity and ly-per from ship type.', metavar='shiptype', type=str), - ParseArgument('--capacity', help='Maximum capacity of cargo hold.', metavar='N', type=int), - ParseArgument('--from', help='Starting system/station.', metavar='STATION', dest='origin'), - ParseArgument('--to', help='Final system/station.', metavar='STATION', dest='dest'), - ParseArgument('--via', help='Require specified systems/stations to be en-route.', metavar='PLACE[,PLACE,...]', action='append'), - ParseArgument('--avoid', help='Exclude an item, system or station from trading. Partial matches allowed, e.g. "dom.App" or "domap" matches "Dom. Appliances".', action='append'), - ParseArgument('--hops', help='Number of hops (station-to-station) to run.', metavar='N', type=int, default=2), - ParseArgument('--jumps-per', help='Maximum number of jumps (system-to-system) per hop.', metavar='N', dest='maxJumpsPer', type=int, default=2), - ParseArgument('--ly-per', help='Maximum light years per jump.', metavar='N.NN', type=float, dest='maxLyPer'), - ParseArgument('--limit', help='Maximum units of any one cargo item to buy (0: unlimited).', metavar='N', type=int), - ParseArgument('--unique', help='Only visit each station once.', action='store_true', default=False), - ParseArgument('--margin', help='Reduce gains made on each hop to provide a margin of error for market fluctuations (e.g: 0.25 reduces gains by 1/4). 0<: N<: 0.25.', metavar='N.NN', type=float, default=0.00), - ParseArgument('--insurance', help='Reserve at least this many credits to cover insurance.', metavar='CR', type=int, default=0), - ParseArgument('--routes', help='Maximum number of routes to show. DEFAULT: 1', metavar='N', type=int, default=1), - ParseArgument('--checklist', help='Provide a checklist flow for the route.', action='store_true', default=False), - ParseArgument('--x52-pro', help='Enable experimental X52 Pro MFD output.', action='store_true', dest='x52pro', default=False), - ] - ) - - # "update" provides the user a way to edit prices. - updateParser = makeSubParser(subparsers, 'update', 'Update prices for a station.', updateCommand, - epilog="Generates a human-readable version of the price list for a given station and opens it in the specified text editor.\n" - "The format is intended to closely resemble the presentation of the market in-game. If you change the order items are listed in, " - "the order will be kept for future edits, making it easier to quickly check for changes.", - arguments = [ - ParseArgument('station', help='Name of the station to update.', type=str) - ], - switches = [ - ParseArgument('--editor', help='Generates a text file containing the prices for the station and loads it into the specified editor.', default=None, type=str, action=EditAction), - ParseArgument('--all', help='DEPRECATED - See --supply and --timestamps instead.', action='store_true', default=False), - ParseArgument('--zero', help='DEPRECATED - See --force-na instead.', action='store_true', default=False), - ParseArgument('--supply', '-S', help='Includes demand and stock (supply) values in the update.', action='store_true', default=False), - ParseArgument('--timestamps', '-T', help='Includes timestamps in the update.', action='store_true', default=False), - ParseArgument('--force-na', '-0', help="Forces 'unk' supply to become 'n/a' by default", action='store_true', default=False, dest='forceNa'), - [ # Mutually exclusive group: - ParseArgument('--sublime', help='Like --editor but uses Sublime Text (2 or 3), which is nice.', action=EditActionStoreTrue), - ParseArgument('--notepad', help='Like --editor but uses Notepad.', action=EditActionStoreTrue), - ParseArgument('--npp', help='Like --editor but uses Notepad++.', action=EditActionStoreTrue), - ParseArgument('--vim', help='Like --editor but uses vim.', action=EditActionStoreTrue), - ] - ] - ) - - args = parser.parse_args() - if not 'proc' in args: - helpText = "No sub-command specified.\n" + parser.format_help() + "\nNote: As of v3 you need to specify one of the 'sub-commands' listed above (run, nav, etc)." - raise CommandLineError(helpText) - - if args.detail and args.quiet: - raise CommandLineError("'--detail' (-v) and '--quiet' (-q) are mutually exclusive.") - - # If a directory was specified, relocate to it. - # Otherwise, try to chdir to - if args.cwd: - os.chdir(args.cwd) - else: - if sys.argv[0]: - cwdPath = pathlib.Path('.').resolve() - exePath = pathlib.Path(sys.argv[0]).parent.resolve() - if cwdPath != exePath: - if args.debug: print("# cwd at launch was: {}, changing to {} to match trade.py".format(cwdPath, exePath)) - os.chdir(str(exePath)) - - # load the database - tdb = TradeDB(debug=args.debug, dbFilename=args.db, buildLinks=False, includeTrades=False) - - # run the commands - commandFunction = args.proc - return commandFunction(tdb, args) + tdenv.parse(tdb) ###################################################################### if __name__ == "__main__": - try: - main() - except (TradeException) as e: - print("%s: %s" % (sys.argv[0], str(e))) + try: + main() + except (TradeException) as e: + print("%s: %s" % (sys.argv[0], str(e))) + diff --git a/tradecalc.py b/tradecalc.py index b51ebf7d..ef1cbcf0 100644 --- a/tradecalc.py +++ b/tradecalc.py @@ -28,8 +28,8 @@ from collections import namedtuple class TradeLoad(namedtuple('TradeLoad', [ 'items', 'gainCr', 'costCr', 'units' ])): - def __bool__(self): - return self.units > 0 + def __bool__(self): + return self.units > 0 emptyLoad = TradeLoad([], 0, 0, 0) @@ -39,391 +39,379 @@ def __bool__(self): # Classes class Route(object): - """ - Describes a series of CargoRuns, that is CargoLoads - between several stations. E.g. Chango -> Gateway -> Enterprise - """ - __slots__ = ('route', 'hops', 'startCr', 'gainCr', 'jumps') - - def __init__(self, stations, hops, startCr, gainCr, jumps): - self.route = stations - self.hops = hops - self.startCr = startCr - self.gainCr = gainCr - self.jumps = jumps - - - def plus(self, dst, hop, jumps): - """ - Returns a new route describing the sum of this route plus a new hop. - """ - return Route(self.route + [dst], self.hops + [hop], self.startCr, self.gainCr + hop[1], self.jumps + [jumps]) - - - def __lt__(self, rhs): - if rhs.gainCr < self.gainCr: # reversed - return True - return rhs.gainCr == self.gainCr and len(rhs.jumps) < len(self.jumps) - - - def __eq__(self, rhs): - return self.gainCr == rhs.gainCr and len(self.jumps) == len(rhs.jumps) - - - def str(self): - return "%s -> %s" % (self.route[0].name(), self.route[-1].name()) - - - def detail(self, detail=0): - """ - Return a string describing this route to a given level of detail. - """ - - credits = self.startCr - gainCr = 0 - route = self.route - - text = self.str() + ":\n" - if detail > 1: - text += self.summary() + "\n" + "\n" - for i in range(len(route) - 1): - hop = self.hops[i] - hopGainCr, hopTonnes = hop[1], 0 - text += " >-> " if i == 0 else " + " - text += "At %s, Buy:" % (route[i].name()) - for (trade, qty) in sorted(hop[0], key=lambda tradeOption: tradeOption[1] * tradeOption[0].gainCr, reverse=True): - if detail > 1: - text += "\n | %4d x %-30s" % (qty, trade.name()) - text += " @ %10scr each, %10scr total" % (localedNo(trade.costCr), localedNo(trade.costCr * qty)) - elif detail: - text += " %d x %s (@%dcr)" % (qty, trade.name(), trade.costCr) - else: - text += " %d x %s" % (qty, trade.name()) - text += "," - hopTonnes += qty - text += "\n" - if detail: - text += " | " - if detail > 1: - text += "%scr => " % localedNo((credits + gainCr)) - text += " -> ".join([ jump.name() for jump in self.jumps[i] ]) - if detail > 1: - text += " => Gain %scr (%scr/ton) => %scr" % (localedNo(hopGainCr), localedNo(hopGainCr / hopTonnes), localedNo(credits + gainCr + hopGainCr)) - text += "\n" - gainCr += hopGainCr - - text += " <-< %s gaining %scr => %scr total" % (route[-1].name(), localedNo(gainCr), localedNo(credits + gainCr)) - text += "\n" - - return text - - - def summary(self): - """ - Returns a string giving a short summary of this route. - """ - - credits, hops, jumps = self.startCr, self.hops, self.jumps - ttlGainCr = sum([hop[1] for hop in hops]) - numJumps = sum([len(hopJumps)-1 for hopJumps in jumps]) - return "\n".join([ - "Start CR: %10s" % localedNo(credits), - "Hops : %10s" % localedNo(len(hops)), - "Jumps : %10s" % localedNo(numJumps), # always includes start point - "Gain CR : %10s" % localedNo(ttlGainCr), - "Gain/Hop: %10s" % localedNo(ttlGainCr / len(hops)), - "Final CR: %10s" % localedNo(credits + ttlGainCr), - ]) + """ + Describes a series of CargoRuns, that is CargoLoads + between several stations. E.g. Chango -> Gateway -> Enterprise + """ + __slots__ = ('route', 'hops', 'startCr', 'gainCr', 'jumps') + + def __init__(self, stations, hops, startCr, gainCr, jumps): + self.route = stations + self.hops = hops + self.startCr = startCr + self.gainCr = gainCr + self.jumps = jumps + + + def plus(self, dst, hop, jumps): + """ + Returns a new route describing the sum of this route plus a new hop. + """ + return Route(self.route + [dst], self.hops + [hop], self.startCr, self.gainCr + hop[1], self.jumps + [jumps]) + + + def __lt__(self, rhs): + if rhs.gainCr < self.gainCr: # reversed + return True + return rhs.gainCr == self.gainCr and len(rhs.jumps) < len(self.jumps) + + + def __eq__(self, rhs): + return self.gainCr == rhs.gainCr and len(self.jumps) == len(rhs.jumps) + + + def str(self): + return "%s -> %s" % (self.route[0].name(), self.route[-1].name()) + + + def detail(self, detail=0): + """ + Return a string describing this route to a given level of detail. + """ + + credits = self.startCr + gainCr = 0 + route = self.route + + text = self.str() + ":\n" + if detail > 1: + text += self.summary() + "\n" + "\n" + for i in range(len(route) - 1): + hop = self.hops[i] + hopGainCr, hopTonnes = hop[1], 0 + text += " >-> " if i == 0 else " + " + text += "At %s, Buy:" % (route[i].name()) + for (trade, qty) in sorted(hop[0], key=lambda tradeOption: tradeOption[1] * tradeOption[0].gainCr, reverse=True): + if detail > 1: + text += "\n | %4d x %-30s" % (qty, trade.name()) + text += " @ %10scr each, %10scr total" % (localedNo(trade.costCr), localedNo(trade.costCr * qty)) + elif detail: + text += " %d x %s (@%dcr)" % (qty, trade.name(), trade.costCr) + else: + text += " %d x %s" % (qty, trade.name()) + text += "," + hopTonnes += qty + text += "\n" + if detail: + text += " | " + if detail > 1: + text += "%scr => " % localedNo((credits + gainCr)) + text += " -> ".join([ jump.name() for jump in self.jumps[i] ]) + if detail > 1: + text += " => Gain %scr (%scr/ton) => %scr" % (localedNo(hopGainCr), localedNo(hopGainCr / hopTonnes), localedNo(credits + gainCr + hopGainCr)) + text += "\n" + gainCr += hopGainCr + + text += " <-< %s gaining %scr => %scr total" % (route[-1].name(), localedNo(gainCr), localedNo(credits + gainCr)) + text += "\n" + + return text + + + def summary(self): + """ + Returns a string giving a short summary of this route. + """ + + credits, hops, jumps = self.startCr, self.hops, self.jumps + ttlGainCr = sum([hop[1] for hop in hops]) + numJumps = sum([len(hopJumps)-1 for hopJumps in jumps]) + return "\n".join([ + "Start CR: %10s" % localedNo(credits), + "Hops : %10s" % localedNo(len(hops)), + "Jumps : %10s" % localedNo(numJumps), # always includes start point + "Gain CR : %10s" % localedNo(ttlGainCr), + "Gain/Hop: %10s" % localedNo(ttlGainCr / len(hops)), + "Final CR: %10s" % localedNo(credits + ttlGainCr), + ]) class TradeCalc(object): - """ - Container for accessing trade calculations with common properties. - """ - - def __init__(self, tdb, debug=0, capacity=None, maxUnits=None, margin=0.01, unique=False, fit=None): - self.tdb = tdb - self.debug = debug - self.capacity = capacity or 4 - self.margin = float(margin) - self.unique = unique - self.maxUnits = maxUnits or 0 - self.defaultFit = fit or self.fastFit - - - def bruteForceFit(self, items, credits, capacity, maxUnits): - """ - Brute-force generation of all possible combinations of items. This is provided - to make it easy to validate the results of future variants or optimizations of - the fit algorithm. - """ - def _fitCombos(offset, cr, cap): - if offset >= len(items): - return emptyLoad - # yield items below us too - bestLoad = _fitCombos(offset + 1, cr, cap) - item = items[offset] - itemCost = item.costCr - maxQty = min(maxUnits, cap, cr // itemCost) - - # Adjust for age for "M"/"H" items with low units. - if item.stock < maxQty and item.stock > 0: # -1 = unknown - level = item.stockLevel - if level > 1: - # Assume 2 units per 10 minutes for high, - # 1 unit per 15 minutes for medium - units = level - 1 - interval = (30 / level) * 60 - adjustment = units * math.floor(item.srcAge / interval) - maxQty = min(maxQty, item.stock + adjustment) - - if maxQty > 0: - itemGain = item.gainCr - for qty in range(maxQty): - load = TradeLoad([[item, maxQty]], itemGain * maxQty, itemCost * maxQty, maxQty) - subLoad = _fitCombos(offset + 1, cr - load.costCr, cap - load.units) - combGain = load.gainCr + subLoad.gainCr - if combGain < bestLoad.gainCr: - continue - combCost, combWeight = load.costCr + subLoad.costCr, load.units + subLoad.units - if combGain == bestLoad.gainCr: - if combWeight > bestLoad.units: - continue - if combWeight == bestLoad.units: - if combCost >= bestLoad.costCr: - continue - bestLoad = TradeLoad( - load.items + subLoad.items, - load.gainCr + subLoad.gainCr, - load.costCr + subLoad.costCr, - load.units + subLoad.units - ) - return bestLoad - - bestLoad = _fitCombos(0, credits, capacity) - return bestLoad - - - def fastFit(self, items, credits, capacity, maxUnits): - """ - Best load calculator using a recursive knapsack-like - algorithm to find multiple loads and return the best. - """ - - def _fitCombos(offset, cr, cap): - """ - Starting from offset, consider a scenario where we - would purchase the maximum number of each item - given the cr/cap limitations. Then, assuming that - load, solve for the remaining cr+cap from the next - value of offset. - - The "best fit" is not always the most profitable, - so we yield all the results and leave the caller - to determine which is actually most profitable. - """ - - # Note: both - # for (itemNo, item) in enumerate(items[offset:]): - # and - # for itemNo in range(offset, len(items)): - # item = items[itemNo] - # seemed significantly slower than this approach. - for item in items[offset:]: - itemCostCr = item.costCr - maxQty = min(maxUnits, cap, cr // itemCostCr) - - # Adjust for age for "M"/"H" items with low units. - if item.stock < maxQty and item.stock > 0: # -1 = unknown - level = item.stockLevel - if level > 1: - # Assume 2 units per 10 minutes for high, - # 1 unit per 15 minutes for medium - units = level - 1 - interval = (30 / level) * 60 - adjustment = units * math.floor(item.srcAge / interval) - maxQty = min(maxQty, item.stock + adjustment) - - if maxQty > 0: - loadItems = [[item, maxQty]] - loadCostCr = maxQty * itemCostCr - loadGainCr = maxQty * item.gainCr - bestGainCr = -1 - crLeft, capLeft = cr - loadCostCr, cap - maxQty - if crLeft > 0 and capLeft > 0: - # Solve for the remaining credits and capacity with what - # is left in items after the item we just checked. - for subLoad in _fitCombos(offset + 1, crLeft, capLeft): - if subLoad.gainCr > 0 and subLoad.gainCr >= bestGainCr: - yield TradeLoad( - subLoad.items + loadItems, - subLoad.gainCr + loadGainCr, - subLoad.costCr + loadCostCr, - subLoad.units + maxQty - ) - bestGainCr = subLoad.gainCr - # If there were no good additions, yield what we have. - if bestGainCr < 0: - yield TradeLoad(loadItems, loadGainCr, loadCostCr, maxQty) - offset += 1 - - bestLoad = emptyLoad - for result in _fitCombos(0, credits, capacity): - if not bestLoad or (result.gainCr > bestLoad.gainCr or (result.gainCr == bestLoad.gainCr and (result.units < bestLoad.units or (result.units == bestLoad.units and result.costCr < bestLoad.costCr)))): - bestLoad = result - - return bestLoad - - - def getBestTrade(self, src, dst, credits, capacity=None, avoidItems=None, focusItems=None, fitFunction=None): - """ - Find the most profitable trade between stations src and dst. - If avoidItems is populated, the items in it will not be considered for trading. - If focusItems is populated, only items listed in it will be considered for trading. - 'fitFunction' lets you specify a function to use for performing the fit. - """ - if not avoidItems: avoidItems = [] - if not focusItems: focusItems = [] - if self.debug: print("# %s -> %s with %dcr" % (src.name(), dst.name(), credits)) - - if not dst in src.tradingWith: - raise ValueError("%s does not have a link to %s" % (src.name(), dst.name())) - - capacity = capacity or self.capacity - if not capacity: - raise ValueError("zero capacity") - - maxUnits = self.maxUnits or capacity - - items = src.tradingWith[dst] - if avoidItems: - items = [ item for item in items if not item.item in avoidItems ] - if focusItems: - items = [ item for item in items if item.item in focusItems ] - - # Remove any items with less gain (value) than the cheapest item, or that are outside our budget. - # This should reduce the search domain for the majority of cases, especially low-end searches. - if items: - firstItem = min(items, key=lambda item: item.costCr) - firstCost, firstGain = firstItem.costCr, firstItem.gainCr - items = [item for item in items if item.costCr <= credits and (item.gainCr > firstGain or item == firstItem)] - - # Make sure there's still something to trade. - if not items: - return emptyLoad - - # Short-circuit: Items are sorted from highest to lowest gain. So if we can fill up with the first - # item in the list, we don't need to try any other combinations. - # NOTE: The payoff for this comes from higher-end searches that would normally be more expensive, - # at the cost of a slight hitch in lower-end searches. - firstItem = items[0] - if maxUnits >= capacity and firstItem.costCr * capacity <= credits: - if firstItem.stock < 0 or firstItem.stock >= maxUnits: - return TradeLoad([[items[0], capacity]], capacity * firstItem.gainCr, capacity * firstItem.costCr, capacity) - - # Go ahead and find the best combination out of what's left. - fitFunction = fitFunction or self.defaultFit - return fitFunction(items, credits, capacity, maxUnits) - - - def getBestHopFrom(self, src, credits, capacity=None, maxJumps=None, maxLyPer=None): - """ - Determine the best trade run from a given station. - """ - src = self.tdb.lookupStation(src) - bestHop = None - for dest in src.getDestinations(maxJumps=maxJumps, maxLyPer=maxLyPer): - load = self.getBestTrade(src, dest.station, credits, capacity=capacity) - if not load: - continue - if bestHop: - if load.gainCr > bestHop.gainCr: continue - if load.gainCr == bestHop.gainCr: - if dest.jumps > bestHop.jumps: continue - if dest.jumps == bestHop.jumps: - if dest.ly >= bestHop.ly: - continue - bestHop = TradeHop(destSys=dest.system, destStn=dest.station, load=load.items, gainCr=load.gainCr, jumps=dest.jumps, ly=dest.ly) - return bestHop - - - def getBestHops(self, routes, credits, - restrictTo=None, avoidItems=None, avoidPlaces=None, maxJumps=None, - maxJumpsPer=None, maxLyPer=None): - """ - Given a list of routes, try all available next hops from each - route. - - Store the results by destination so that we pick the - best route-to-point for each destination at each step. - - If we have two routes: A->B->D, A->C->D and A->B->D produces - more profit, there's no point continuing the A->C->D path. - """ - - if not avoidItems: avoidItems = [] - if not avoidPlaces: avoidPlaces = [] - assert not restrictTo or isinstance(restrictTo, set) - - bestToDest = {} - safetyMargin = 1.0 - self.margin - unique = self.unique - perJumpLimit = maxJumpsPer if (maxJumpsPer or 0) > 0 else 0 - for route in routes: - src = route.route[-1] - if self.debug: print("# route = %s" % route.str()) - startCr = credits + int(route.gainCr * safetyMargin) - routeJumps = len(route.jumps) - jumpLimit = perJumpLimit - if (maxJumps or 0) > 0: - jumpLimit = min(maxJumps - routeJumps, perJumpLimit) if perJumpLimit > 0 else maxJumps - routeJumps - if jumpLimit <= 0: - continue - - for dest in src.getDestinations(maxJumps=jumpLimit, maxLyPer=maxLyPer, avoiding=avoidPlaces): - if self.debug > 1: - print("#destSys = %s, destStn = %s, jumps = %s, distLy = %s" % (dest.system.name(), dest.station.name(), "->".join([jump.str() for jump in dest.via]), dest.distLy)) - if not dest.station in src.tradingWith: - if self.debug > 2: print("#%s is not in my station list" % dest.station.name()) - continue - if restrictTo: - if not dest.station in restrictTo and not dest.system in restrictTo: - if self.debug > 2: print("#%s doesn't match restrict %s" % (dest.station.name(), restrictTo)) - continue - if unique and dest.station in route.route: - if self.debug > 2: print("#%s is already in the list, not unique" % dest.station.name()) - continue - - trade = self.getBestTrade(src, dest.station, startCr, avoidItems=avoidItems) - if not trade: - if self.debug > 2: print("#* No trade") - continue - - dstID = dest.station.ID - try: - # See if there is already a candidate for this destination - (bestStn, bestRoute, bestTrade, bestJumps, bestLy) = bestToDest[dstID] - # Check if it is a better option than we just produced - bestRouteGainCr = bestRoute.gainCr + bestTrade[1] - newRouteGainCr = route.gainCr + trade[1] - if bestRouteGainCr > newRouteGainCr: - continue - if bestRouteGainCr == newRouteGainCr and bestLy <= dest.distLy: - continue - except KeyError: - # No existing candidate, we win by default - pass - bestToDest[dstID] = [ dest.station, route, trade, dest.via, dest.distLy ] - - result = [] - for (dst, route, trade, jumps, ly) in bestToDest.values(): - result.append(route.plus(dst, trade, jumps)) - - return result + """ + Container for accessing trade calculations with common properties. + """ + + def __init__(self, tdb, tdenv, fit=None): + self.tdb = tdb + self.tdenv = tdenv + self.defaultFit = fit or self.fastFit + + + def bruteForceFit(self, items, credits, capacity, maxUnits): + """ + Brute-force generation of all possible combinations of items. This is provided + to make it easy to validate the results of future variants or optimizations of + the fit algorithm. + """ + def _fitCombos(offset, cr, cap): + if offset >= len(items): + return emptyLoad + # yield items below us too + bestLoad = _fitCombos(offset + 1, cr, cap) + item = items[offset] + itemCost = item.costCr + maxQty = min(maxUnits, cap, cr // itemCost) + + # Adjust for age for "M"/"H" items with low units. + if item.stock < maxQty and item.stock > 0: # -1 = unknown + level = item.stockLevel + if level > 1: + # Assume 2 units per 10 minutes for high, + # 1 unit per 15 minutes for medium + units = level - 1 + interval = (30 / level) * 60 + adjustment = units * math.floor(item.srcAge / interval) + maxQty = min(maxQty, item.stock + adjustment) + + if maxQty > 0: + itemGain = item.gainCr + for qty in range(maxQty): + load = TradeLoad([[item, maxQty]], itemGain * maxQty, itemCost * maxQty, maxQty) + subLoad = _fitCombos(offset + 1, cr - load.costCr, cap - load.units) + combGain = load.gainCr + subLoad.gainCr + if combGain < bestLoad.gainCr: + continue + combCost, combWeight = load.costCr + subLoad.costCr, load.units + subLoad.units + if combGain == bestLoad.gainCr: + if combWeight > bestLoad.units: + continue + if combWeight == bestLoad.units: + if combCost >= bestLoad.costCr: + continue + bestLoad = TradeLoad( + load.items + subLoad.items, + load.gainCr + subLoad.gainCr, + load.costCr + subLoad.costCr, + load.units + subLoad.units + ) + return bestLoad + + bestLoad = _fitCombos(0, credits, capacity) + return bestLoad + + + def fastFit(self, items, credits, capacity, maxUnits): + """ + Best load calculator using a recursive knapsack-like + algorithm to find multiple loads and return the best. + """ + + def _fitCombos(offset, cr, cap): + """ + Starting from offset, consider a scenario where we + would purchase the maximum number of each item + given the cr/cap limitations. Then, assuming that + load, solve for the remaining cr+cap from the next + value of offset. + + The "best fit" is not always the most profitable, + so we yield all the results and leave the caller + to determine which is actually most profitable. + """ + + # Note: both + # for (itemNo, item) in enumerate(items[offset:]): + # and + # for itemNo in range(offset, len(items)): + # item = items[itemNo] + # seemed significantly slower than this approach. + for item in items[offset:]: + itemCostCr = item.costCr + maxQty = min(maxUnits, cap, cr // itemCostCr) + + # Adjust for age for "M"/"H" items with low units. + if item.stock < maxQty and item.stock > 0: # -1 = unknown + level = item.stockLevel + if level > 1: + # Assume 2 units per 10 minutes for high, + # 1 unit per 15 minutes for medium + units = level - 1 + interval = (30 / level) * 60 + adjustment = units * math.floor(item.srcAge / interval) + maxQty = min(maxQty, item.stock + adjustment) + + if maxQty > 0: + loadItems = [[item, maxQty]] + loadCostCr = maxQty * itemCostCr + loadGainCr = maxQty * item.gainCr + bestGainCr = -1 + crLeft, capLeft = cr - loadCostCr, cap - maxQty + if crLeft > 0 and capLeft > 0: + # Solve for the remaining credits and capacity with what + # is left in items after the item we just checked. + for subLoad in _fitCombos(offset + 1, crLeft, capLeft): + if subLoad.gainCr > 0 and subLoad.gainCr >= bestGainCr: + yield TradeLoad( + subLoad.items + loadItems, + subLoad.gainCr + loadGainCr, + subLoad.costCr + loadCostCr, + subLoad.units + maxQty + ) + bestGainCr = subLoad.gainCr + # If there were no good additions, yield what we have. + if bestGainCr < 0: + yield TradeLoad(loadItems, loadGainCr, loadCostCr, maxQty) + offset += 1 + + bestLoad = emptyLoad + for result in _fitCombos(0, credits, capacity): + if not bestLoad or (result.gainCr > bestLoad.gainCr or (result.gainCr == bestLoad.gainCr and (result.units < bestLoad.units or (result.units == bestLoad.units and result.costCr < bestLoad.costCr)))): + bestLoad = result + + return bestLoad + + + def getBestTrade(self, src, dst, credits=None, fitFunction=None): + """ + Find the most profitable trade between stations src and dst. + If avoidItems is populated, the items in it will not be considered for trading. + 'fitFunction' lets you specify a function to use for performing the fit. + """ + tdenv = self.tdenv + if credits is None: credits = tdenv.credits - getattr(tdenv, 'insurance', 0) + capacity = tdenv.capacity + avoidItems = tdenv.avoidItems + self.tdenv.DEBUG(0, "{} -> {} with {:n}cr", src.name(), dst.name(), credits) + + if not dst in src.tradingWith: + raise ValueError("%s does not have a link to %s" % (src.name(), dst.name())) + + if not capacity: + raise ValueError("zero capacity") + + maxUnits = getattr(tdenv, 'limit') or capacity + + items = src.tradingWith[dst] + if avoidItems: + items = [ item for item in items if not item.item in avoidItems ] + + # Remove any items with less gain (value) than the cheapest item, or that are outside our budget. + # This should reduce the search domain for the majority of cases, especially low-end searches. + if items: + firstItem = min(items, key=lambda item: item.costCr) + firstCost, firstGain = firstItem.costCr, firstItem.gainCr + items = [ item + for item + in items + if item.costCr <= credits and ( + item.gainCr > firstGain or + item == firstItem) + ] + + # Make sure there's still something to trade. + if not items: + return emptyLoad + + # Short-circuit: Items are sorted from highest to lowest gain. So if we can fill up with the first + # item in the list, we don't need to try any other combinations. + # NOTE: The payoff for this comes from higher-end searches that would normally be more expensive, + # at the cost of a slight hitch in lower-end searches. + firstItem = items[0] + if maxUnits >= capacity and firstItem.costCr * capacity <= credits: + if firstItem.stock < 0 or firstItem.stock >= maxUnits: + return TradeLoad([[items[0], capacity]], + capacity * firstItem.gainCr, + capacity * firstItem.costCr, + capacity + ) + + # Go ahead and find the best combination out of what's left. + fitFunction = fitFunction or self.defaultFit + return fitFunction(items, credits, capacity, maxUnits) + + + def getBestHops(self, routes, restrictTo=None): + """ + Given a list of routes, try all available next hops from each + route. + + Store the results by destination so that we pick the + best route-to-point for each destination at each step. + + If we have two routes: A->B->D, A->C->D and A->B->D produces + more profit, there's no point continuing the A->C->D path. + """ + + tdenv = self.tdenv + avoidItems = tdenv.avoidItems + avoidPlaces = tdenv.avoidSystems + tdenv.avoidStations + assert not restrictTo or isinstance(restrictTo, set) + maxJumpsPer = tdenv.maxJumpsPer or 0 + maxLyPer = tdenv.maxLyPer + credits = tdenv.credits - getattr(tdenv, 'insurance', 0) + + bestToDest = {} + safetyMargin = 1.0 - tdenv.margin + unique = tdenv.unique + for route in routes: + tdenv.DEBUG(1, "Route = {}", route.str()) + + src = route.route[-1] + startCr = credits + int(route.gainCr * safetyMargin) + routeJumps = len(route.jumps) + + for dest in src.getDestinations( + maxJumps=maxJumpsPer, + maxLyPer=maxLyPer, + avoiding=avoidPlaces, + ): + tdenv.DEBUG(2, "destSys {}, destStn {}, jumps {}, distLy {}", + dest.system.name(), + dest.station.name(), + "->".join([jump.str() for jump in dest.via]), + dest.distLy) + if not dest.station in src.tradingWith: + tdenv.DEBUG(3, "{} is not in my station list", dest.station.name()) + continue + if restrictTo: + if not dest.station in restrictTo and not dest.system in restrictTo: + tdenv.DEBUG(3, "{} doesn't match restrict {}", + dest.station.name(), restrictTo) + continue + if unique and dest.station in route.route: + tdenv.DEBUG(3, "{} is already in the list, not unique", dest.station.name()) + continue + + trade = self.getBestTrade(src, dest.station, startCr) + if not trade: + tdenv.DEBUG(3, "No trade") + continue + + dstID = dest.station.ID + try: + # See if there is already a candidate for this destination + (bestStn, bestRoute, bestTrade, bestJumps, bestLy) = bestToDest[dstID] + # Check if it is a better option than we just produced + bestRouteGainCr = bestRoute.gainCr + bestTrade[1] + newRouteGainCr = route.gainCr + trade[1] + if bestRouteGainCr > newRouteGainCr: + continue + if bestRouteGainCr == newRouteGainCr and bestLy <= dest.distLy: + continue + except KeyError: + # No existing candidate, we win by default + pass + bestToDest[dstID] = [ dest.station, route, trade, dest.via, dest.distLy ] + + result = [] + for (dst, route, trade, jumps, ly) in bestToDest.values(): + result.append(route.plus(dst, trade, jumps)) + + return result def localedNo(num): # as in "transformed into the current locale" - """ - Returns a locale-formatted version of a number, e.g. 1,234,456. - """ - return locale.format('%d', num, grouping=True) + """ + Returns a locale-formatted version of a number, e.g. 1,234,456. + """ + return locale.format('%d', num, grouping=True) diff --git a/tradedb.py b/tradedb.py index e099ec05..62b53c71 100644 --- a/tradedb.py +++ b/tradedb.py @@ -296,7 +296,6 @@ class TradeDB(object): Encapsulation for the database layer. Attributes: - debug - Debugging level for this instance. dbPath - Path object describing the db location. dbURI - String representation of the db location (e.g. filename). conn - The database connection. @@ -341,20 +340,29 @@ class TradeDB(object): ] - def __init__(self, dbFilename=None, sqlFilename=None, pricesFilename=None, debug=0, maxSystemLinkLy=None, buildLinks=True, includeTrades=True): - self.dbPath = Path(dbFilename or TradeDB.defaultDB) + def __init__(self, + tdenv, + sqlFilename=None, + pricesFilename=None, + buildLinks=True, + includeTrades=True + ): + self.tdenv = tdenv + self.dbPath = Path(tdenv.dbFilename or TradeDB.defaultDB) self.dbURI = str(self.dbPath) self.sqlPath = Path(sqlFilename or TradeDB.defaultSQL) self.pricesPath = Path(pricesFilename or TradeDB.defaultPrices) self.importTables = TradeDB.defaultTables - self.debug = debug self.conn = None self.numLinks = None self.tradingCount = None self.reloadCache() - self.load(maxSystemLinkLy=maxSystemLinkLy, buildLinks=buildLinks, includeTrades=includeTrades) + self.load(maxSystemLinkLy=tdenv.maxSystemLinkLy, + buildLinks=buildLinks, + includeTrades=includeTrades, + ) ############################################################ @@ -363,7 +371,7 @@ def __init__(self, dbFilename=None, sqlFilename=None, pricesFilename=None, debug def getDB(self): if self.conn: return self.conn try: - if self.debug > 1: print("* Connecting to DB") + self.tdenv.DEBUG(1, "Connecting to DB") import sqlite3 return sqlite3.connect(self.dbURI) except ImportError as e: @@ -412,17 +420,18 @@ def getMostRecentTimestamp(altPath): # check if any of the table files have changed. changedFiles = [ fileName for (fileName, _) in self.importTables if getMostRecentTimestamp(Path(fileName)) > dbFileCreatedTimestamp ] if not changedFiles: - if self.debug > 1: print("- DB Cache is up to date.") + self.tdenv.DEBUG(1, "DB Cache is up to date.") return - if self.debug: print("* Rebuilding DB Cache because of modified {}".format(', '.join(changedFiles))) + self.tdenv.DEBUG(0, "Rebuilding DB Cache because of modified {}", + ', '.join(changedFiles)) else: - if self.debug: print("* Rebuilding DB Cache [db:{}, sql:{}, prices:{}]".format(dbFileCreatedTimestamp, sqlTimestamp, pricesTimestamp)) + self.tdenv.DEBUG(0, "Rebuilding DB Cache [db:{}, sql:{}, prices:{}]", + dbFileCreatedTimestamp, sqlTimestamp, pricesTimestamp) else: - if self.debug: - print("* Building DB cache") + self.tdenv.DEBUG(0, "Building DB Cache") import buildcache - buildcache.buildCache(dbPath=self.dbPath, sqlPath=self.sqlPath, pricesPath=self.pricesPath, importTables=self.importTables, debug=self.debug) + buildcache.buildCache(self.tdenv, dbPath=self.dbPath, sqlPath=self.sqlPath, pricesPath=self.pricesPath, importTables=self.importTables) ############################################################ @@ -448,7 +457,7 @@ def _loadSystems(self): systemByID[ID] = systemByName[name] = System(ID, name, posX, posY, posZ) self.systemByID, self.systemByName = systemByID, systemByName - if self.debug > 1: print("# Loaded %d Systems" % len(systemByID)) + self.tdenv.DEBUG(1, "Loaded {:n} Systems", len(systemByID)) def buildLinks(self): @@ -473,7 +482,10 @@ def buildLinks(self): lhs.links[rhs] = rhs.links[lhs] = math.sqrt(distSq) self.numLinks += 1 - if self.debug > 2: print("# Number of links between systems: %d" % self.numLinks) + self.tdenv.DEBUG(2, "Number of links between systems: {:n}", self.numLinks) + + if self.tdenv.debug: + self._validate() def lookupSystem(self, key): @@ -533,7 +545,7 @@ def _loadStations(self): stationByID[ID] = stationByName[name] = Station(ID, systemByID[systemID], name, lsFromStar, itemCount) self.stationByID, self.stationByName = stationByID, stationByName - if self.debug > 1: print("# Loaded %d Stations" % len(stationByID)) + self.tdenv.DEBUG(1, "Loaded {:n} Stations", len(stationByID)) def lookupStation(self, name, system=None): @@ -614,7 +626,7 @@ def _loadShips(self): self.cur.execute(stmt) self.shipByID = { row[0]: Ship(*row, stations=[]) for row in self.cur } - if self.debug > 1: print("# Loaded %d Ships" % len(self.shipByID)) + self.tdenv.DEBUG(1, "Loaded {} Ships", len(self.shipByID)) def lookupShip(self, name): @@ -645,7 +657,7 @@ def _loadCategories(self): """ self.categoryByID = { ID: Category(ID, name, []) for (ID, name) in self.cur.execute(stmt) } - if self.debug > 1: print("# Loaded %d Categories" % len(self.categoryByID)) + self.tdenv.DEBUG(1, "Loaded {} Categories", len(self.categoryByID)) def lookupCategory(self, name): @@ -691,14 +703,13 @@ def _loadItems(self): item = itemByID[itemID] item.altname = altName itemByName[altName] = item - if self.debug > 1: print("# '{}' alias for #{} '{}'".format(altName, itemID, item.fullname)) + self.tdenv.DEBUG(1, "'{}' alias for #{} '{}'", altName, itemID, item.fullname) self.itemByID = itemByID self.itemByName = itemByName - if self.debug > 1: - print("# Loaded %d Items" % len(self.itemByID)) - print("# Loaded %d AltItemNames" % aliases) + self.tdenv.DEBUG(1, "Loaded {:n} Items, {:n} AltItemNames", + len(self.itemByID), aliases) def lookupItem(self, name): @@ -789,7 +800,7 @@ def load(self, dbFilename=None, maxSystemLinkLy=None, buildLinks=True, includeTr tdb.load() # x now points to an orphan Aulin """ - if self.debug > 1: print("* Loading data") + self.tdenv.DEBUG(1, "Loading data") conn = self.getDB() self.cur = conn.cursor() @@ -811,7 +822,8 @@ def load(self, dbFilename=None, maxSystemLinkLy=None, buildLinks=True, includeTr self.maxSystemLinkLy = longestJumper.maxLyEmpty else: self.maxSystemLinkLy = maxSystemLinkLy - if self.debug > 2: print("# Max ship jump distance: %s @ %f" % (longestJumper.name(), self.maxSystemLinkLy)) + self.tdenv.DEBUG(2, "Max ship jump distance: {} @ {:.02f}", + longestJumper.name(), self.maxSystemLinkLy) if buildLinks: self.buildLinks() @@ -819,16 +831,12 @@ def load(self, dbFilename=None, maxSystemLinkLy=None, buildLinks=True, includeTr if includeTrades: self.loadTrades() - # In debug mode, check that everything looks sane. - if self.debug: - self._validate() - def _validate(self): # Check that things correctly reference themselves. # Check that system links are bi-directional for (name, sys) in self.systemByName.items(): - if not sys.links and self.debug: + if not sys.links: print("NOTE: System '%s' has no links" % name) if sys in sys.links: raise ValueError("System %s has a link to itself!" % name) diff --git a/tradeenv.py b/tradeenv.py new file mode 100644 index 00000000..c5943d34 --- /dev/null +++ b/tradeenv.py @@ -0,0 +1,418 @@ +# Copyright (C) Oliver 'kfsone' Smith 2014 : +# You are free to use, redistribute, or even print and eat a copy of +# this software so long as you include this copyright notice. +# I guarantee there is at least one bug neither of us knew about. +#--------------------------------------------------------------------- +# TradeDangerous :: Argument Parsing/Objects + +import argparse # For parsing command line args. +import sys +import pathlib +import os + +from tradeexcept import TradeException + +###################################################################### +# Exceptions + +class CommandLineError(TradeException): + """ + Raised when you provide invalid input on the command line. + Attributes: + errorstr What to tell the user. + """ + def __init__(self, errorStr): + self.errorStr = errorStr + def __str__(self): + return 'Error in command line: {}'.format(self.errorStr) + + +class NoDataError(TradeException): + """ + Raised when a request is made for which no data can be found. + Attributes: + errorStr Describe the problem to the user. + """ + def __init__(self, errorStr): + self.errorStr = errorStr + def __str__(self): + return "Error: {}\n".format(self.errorStr) + ( + "This can happen when there are no profitable trades" + " matching your criteria, or if you have not yet entered" + " any price data for the station(s) involved.\n" + "\n" + "See '{} update -h' for help entering/updating prices, or" + " obtain a '.prices' file from the web, such as maddavo's:" + " http://www.davek.com.au/td/\n" + "\n" + "See https://bitbucket.org/kfsone/tradedangerous/wiki/" + "Price%20Data" + " for more help." + ).format(sys.argv[0]) + + +###################################################################### +# Helpers + +class HelpAction(argparse.Action): + """ + argparse action helper for printing the argument usage, + because Python 3.4's argparse is ever-so subtly very broken. + """ + def __call__(self, parser, namespace, values, option_string=None): + parser.print_help() + sys.exit(0) + + +class EditAction(argparse.Action): + """ + argparse action that stores a value and also flags args._editing + """ + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, "_editing", True) + setattr(namespace, self.dest, values or self.default) + + +class EditActionStoreTrue(argparse.Action): + """ + argparse action that stores True but also flags args._editing + """ + def __init__(self, option_strings, dest, nargs=None, **kwargs): + if nargs is not None: + raise ValueError("nargs not allowed") + super(EditActionStoreTrue, self).__init__(option_strings, dest, nargs=0, **kwargs) + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, "_editing", True) + setattr(namespace, self.dest, True) + + +class ParseArgument(object): + """ + Provides argument forwarding so that 'makeSubParser' can take function-like arguments. + """ + def __init__(self, *args, **kwargs): + self.args, self.kwargs = args, kwargs + + +class MutuallyExclusiveGroup(object): + def __init__(self, *args): + self.arguments = list(args) + + +def _addArguments(group, options, required, topGroup=None): + """ + Registers a list of options to the specified group. Nodes + are either an instance of ParseArgument or a list of + ParseArguments. The list form is considered to be a + mutually exclusive group of arguments. + """ + for option in options: + if isinstance(option, MutuallyExclusiveGroup): + exGrp = (topGroup or group).add_mutually_exclusive_group() + _addArguments(exGrp, option.arguments, required, topGroup=group) + else: + assert not required in option.kwargs + if option.args[0][0] == '-': + group.add_argument(*(option.args), required=required, **(option.kwargs)) + else: + group.add_argument(*(option.args), **(option.kwargs)) + + +class SubCommandParser(object): + """ + Provide a normalized sub-parser for a specific command. This helps to + make it easier to keep the command lines consistent and makes the calls + to build them easier to write/read. + """ + def __init__(self, name, cmdFunc, help, arguments=None, switches=None, epilog=None): + self.name, self.cmdFunc, self.help = name, cmdFunc, help + self.arguments = arguments + self.switches = switches + self.epilog = epilog + + + def apply(self, subparsers): + subParser = subparsers.add_parser(self.name, + help=self.help, + add_help=False, + epilog=self.epilog, + ) + + arguments = self.arguments + if arguments: + argParser = subParser.add_argument_group('Required Arguments') + _addArguments(argParser, arguments, True) + + switchParser = subParser.add_argument_group('Optional Switches') + switchParser.add_argument('-h', '--help', + help='Show this help message and exit.', + action=HelpAction, nargs=0 + ) + _addArguments(switchParser, self.switches, False) + + subParser.set_defaults(subCommand=self.name) + subParser.set_defaults(cmdFunc=self.cmdFunc) + + return subParser + +###################################################################### +# TradeEnv class + +class TradeEnv(object): + """ + Container for command line arguments/settings + for TradeDangerous. + """ + + def __init__(self, description=None, subCommandParsers=None, extraArgs=None): + self.tdb = None + self._formats = { + } + self.subCommand = None + self.mfd = None + + parser = argparse.ArgumentParser( + description=description or "TradeDangerous", + add_help=False, + epilog='For help on a specific command, use the command followed by -h.' + ) + parser.set_defaults(_editing=False) + + # Arguments common to all subparsers. + stdArgs = parser.add_argument_group('Common Switches') + stdArgs.add_argument('-h', '--help', + help='Show this help message and exit.', + action=HelpAction, nargs=0, + ) + stdArgs.add_argument('--debug', '-w', + help='Enable diagnostic output.', + default=0, required=False, action='count', + ) + stdArgs.add_argument('--detail', '-v', + help='Increase level of detail in output.', + default=0,required=False, action='count', + ) + stdArgs.add_argument('--quiet', '-q', + help='Reduce level of detail in output.', + default=0, required=False, action='count', + ) + stdArgs.add_argument('--db', + help='Specify location of the SQLite database.', + default=None, dest='dbFilename', type=str, + ) + stdArgs.add_argument('--cwd', '-C', + help='Change the working directory file accesses are made from.', + type=str, required=False, + ) + stdArgs.add_argument('--link-ly', '-L', + help='Maximum lightyears between systems to be considered linked.', + default=None, dest='linkLy', + ) + + if extraArgs: + for arg in extraArgs: + parser.add_argument(*(arg.args), **(arg.kwargs)) + + if subCommandParsers: + self.subparsers = parser.add_subparsers(dest='subparser', title='Sub-Commands') + + for subParser in subCommandParsers: + subParser.apply(self.subparsers) + + self._clargs = parser.parse_args() + if subCommandParsers and 'subCommand' not in self._clargs: + helpText = "No sub-command specified.\n" + parser.format_help() + raise CommandLineError(helpText) + + if self.detail and self.quiet: + raise CommandLineError("'--detail' (-v) and '--quiet' (-q) are mutually exclusive.") + + # If a directory was specified or the program is being run + # from a different directory than the path of the executable,m + # change directory. + if not self.cwd and sys.argv[0]: + cwdPath = pathlib.Path('.').resolve() + exePath = pathlib.Path(sys.argv[0]).parent.resolve() + if cwdPath != exePath: + self.cwd = str(exePath) + self.DEBUG(1, "cwd at launch was: {}, changing to {} to match trade.py", + cwdPath, self.cwd) + if self.cwd: + os.chdir(self.cwd) + + + def __getattr__(self, key, default=None): + try: + return getattr(self._clargs, key, default) + except AttributeError: + return default + + + def format(self, formatName, defaultFormat, + *args, + debugLevel=None, detailLevel=None, isInfo=False, + **kwargs + ): + if debugLevel and self.debug < debugLevel: + return "" + if detailLevel and self.detail < detailLevel: + return "" + if isInfo and not (self.debug or self.detail): + return "" + + try: + formatMask = self._formats[formatName] + except KeyError: + formatMask = defaultFormat + return formatMask.format(*args, **kwargs) + + def printHeading(self, text): + """ Print a line of text followed by a matching line of '-'s. """ + print(text) + print('-' * len(text)) + + + + def DEBUG(self, debugLevel, outText, *args, **kwargs): + if self.debug > debugLevel: + print('#', outText.format(*args, **kwargs)) + + + def parse(self, tdb): + self.tdb = tdb + + self.parseMFD() + self.parseFromToNear() + self.parseAvoids() + self.parseVias() + self.parseShip() + + cmdFunc = self._clargs.cmdFunc + return cmdFunc(tdb, self) + + def parseMFD(self): + self.mfd = None + try: + if not self.x52pro: + return + except AttributeError: + return + + from mfd import X52ProMFD + self.mfd = X52ProMFD() + + + def parseFromToNear(self): + origin = getattr(self, 'origin', None) + if origin: + self.startStation = self.tdb.lookupStation(origin) + else: + self.startStation = None + origin = getattr(self, 'startSys', None) + if origin: + self.startSystem = self.tdb.lookupSystemRelaxed(origin) + else: + self.startSystem = None + dest = getattr(self, 'dest', None) + if dest: + self.stopStation = self.tdb.lookupStation(dest) + else: + self.stopStation = None + dest = getattr(self, 'endSys', None) + if dest: + self.stopSystem = self.tdb.lookupSystemRelaxed(dest) + else: + self.stopSystem = None + near = getattr(self, 'near', None) + if near: + self.nearSystem = self.tdb.lookupSystemRelaxed(near) + else: + self.nearSystem = None + + + def parseAvoids(self): + """ + Process a list of avoidances. + """ + + avoidItems = self.avoidItems = [] + avoidSystems = self.avoidSystems = [] + avoidStations = self.avoidStations = [] + + try: + avoidances = self.avoid + if not avoidances: + return + except AttributeError: + return + + tdb = self.tdb + + # You can use --avoid to specify an item, system or station. + # and you can group them together with commas or list them + # individually. + for avoid in ','.join(avoidances).split(','): + # Is it an item? + item, system, station = None, None, None + try: + item = tdb.lookupItem(avoid) + avoidItems.append(item) + if tdb.normalizedStr(item.name()) == tdb.normalizedStr(avoid): + continue + except LookupError: + pass + # Is it a system perhaps? + try: + system = tdb.lookupSystem(avoid) + avoidSystems.append(system) + if tdb.normalizedStr(system.str()) == tdb.normalizedStr(avoid): + continue + except LookupError: + pass + # Or perhaps it is a station + try: + station = tdb.lookupStationExplicitly(avoid) + if (not system) or (station.system is not system): + avoidSystems.append(station.system) + avoidStations.append(station) + if tdb.normalizedStr(station.str()) == tdb.normalizedStr(avoid): + continue + except LookupError as e: + pass + + # If it was none of the above, whine about it + if not (item or system or station): + raise CommandLineError("Unknown item/system/station: {}".format(avoid)) + + # But if it matched more than once, whine about ambiguity + if item and system: + raise AmbiguityError('Avoidance', avoid, [ item, system.str() ]) + if item and station: + raise AmbiguityError('Avoidance', avoid, [ item, station.str() ]) + if system and station and station.system != system: + raise AmbiguityError('Avoidance', avoid, [ system.str(), station.str() ]) + + self.DEBUG(0, "Avoiding items %s, systems %s, stations %s" % ( + [ item.name() for item in avoidItems ], + [ system.name() for system in avoidSystems ], + [ station.name() for station in avoidStations ] + )) + + + def parseVias(self): + """ Process a list of station names and build them into a list of waypoints. """ + viaStationNames = getattr(self._clargs, 'via', None) + viaStations = self.viaStations = [] + # accept [ "a", "b,c", "d" ] by joining everything and then splitting it. + if viaStationNames: + for via in ",".join(viaStationNames).split(","): + viaStations.add(self.tdb.lookupStation(via)) + + + def parseShip(self): + """ Parse user-specified ship and populate capacity and maxLyPer from it. """ + ship = getattr(self._clargs, 'ship', None) + if ship: + ship = self.ship = self.tdb.lookupShip(ship) + self.capacity = self.capacity or ship.capacity + self.maxLyPer = self.maxLyPer or ship.maxLyFull