supply.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. from __future__ import annotations
  2. import collections
  3. import dataclasses
  4. import json
  5. import math
  6. import typing
  7. import tap
  8. import cache
  9. from config import config
  10. import market
  11. class Args(tap.Tap):
  12. planets: tuple[str, ...]
  13. weight: float
  14. volume: float
  15. include_ship: tuple[str, ...] = ()
  16. def configure(self) -> None:
  17. self.add_argument('planets', nargs='+', metavar='planet') # take planets as positional args instead of flag
  18. def main() -> None:
  19. args = Args().parse_args()
  20. planets = [Planet(fio_burn) for fio_burn in get_fio_burn(args.planets)]
  21. if args.include_ship:
  22. stores: typing.Sequence[market.Storage] = cache.get('https://rest.fnar.net/storage/' + config.username,
  23. headers={'Authorization': config.fio_api_key})
  24. for ship in args.include_ship:
  25. ship_name, planet_name = ship.casefold().split('=')
  26. for store in stores:
  27. if store['Type'] == 'SHIP_STORE' and store['Name'].casefold() == ship_name:
  28. break
  29. else:
  30. raise Exception(f'ship storage {ship_name} not found')
  31. (planet,) = (p for p in planets if p.name.casefold() == planet_name)
  32. for item in store['StorageItems']:
  33. planet.inventory[item['MaterialTicker']] = planet.inventory.get(item['MaterialTicker'], 0) + item['MaterialAmount']
  34. raw_materials: typing.Sequence[Material] = cache.get('https://rest.fnar.net/material/allmaterials')
  35. materials = {mat['Ticker']: mat for mat in raw_materials}
  36. target_days = float('inf')
  37. for planet in planets:
  38. vol_per_day = weight_per_day = 0
  39. for consumption in planet.net_consumption:
  40. ticker = consumption['MaterialTicker']
  41. vol_per_day += materials[ticker]['Volume'] * consumption['net_consumption']
  42. weight_per_day += materials[ticker]['Weight'] * consumption['net_consumption']
  43. days = planet.inventory.get(ticker, 0) / consumption['net_consumption']
  44. if days < target_days:
  45. target_days = days
  46. print(planet.name, f'consumes {vol_per_day:.1f}㎥, {weight_per_day:.1f}t per day')
  47. optimal: dict[str, dict[str, int]] = None # pyright: ignore[reportAssignmentType]
  48. total_weight_used: float = None # pyright: ignore[reportAssignmentType]
  49. total_volume_used: float = None # pyright: ignore[reportAssignmentType]
  50. target_days = round(target_days + 0.05, 1)
  51. load_more = True
  52. while load_more:
  53. buys: dict[str, dict[str, int]] = {}
  54. iteration_weight = iteration_volume = 0
  55. for planet in planets:
  56. buy = planet.supply_for_days(target_days)
  57. weight_used, volume_used = shipping_used(materials, config.supply_config(planet.name).ignore_materials, buy)
  58. iteration_weight += weight_used
  59. iteration_volume += volume_used
  60. if iteration_weight > args.weight or iteration_volume > args.volume:
  61. load_more = False
  62. break
  63. buys[planet.name] = buy
  64. if load_more:
  65. optimal = buys
  66. total_weight_used = iteration_weight
  67. total_volume_used = iteration_volume
  68. target_days += 0.1
  69. print('supply for', round(target_days, 1), 'days,', end=' ')
  70. print(f'consuming {round(total_weight_used, 1)}t and {round(total_volume_used, 1)}㎥') # pyright: ignore[reportPossiblyUnboundVariable]
  71. raw_prices: typing.Mapping[str, market.RawPrice] = {p['MaterialTicker']: p
  72. for p in cache.get('https://refined-prun.github.io/refined-prices/all.json') if p['ExchangeCode'] == 'IC1'}
  73. warehouse = warehouse_inventory()
  74. total_cost = 0
  75. for i, planet in enumerate(planets):
  76. print('\n' + cyan(planet.name))
  77. supply_config = config.supply_config(planet.name)
  78. for consumption in planet.net_consumption:
  79. ticker = consumption['MaterialTicker']
  80. avail = planet.inventory.get(ticker, 0)
  81. daily_consumption = consumption['net_consumption']
  82. days = avail / daily_consumption
  83. print(f'{ticker:>3}: {avail:5d} ({daily_consumption:8.2f}/d) {days:4.1f} d', end='')
  84. if need := optimal[planet.name].get(ticker): # pyright: ignore[reportOptionalMemberAccess]
  85. if ticker in supply_config.ignore_materials:
  86. print(f' | {need:5.0f} (ignored)')
  87. else:
  88. print(f' | {need:5.0f}', end='')
  89. sources = []
  90. for exporter in planets[:i]:
  91. if ticker in exporter.exporting and (avail := exporter.inventory.get(ticker, 0)):
  92. need -= min(need, avail)
  93. exporter.inventory[ticker] -= max(avail - need, 0)
  94. sources.append(f'{exporter.name}: {avail}')
  95. if avail := warehouse.get(ticker, 0):
  96. need -= min(need, avail)
  97. warehouse[ticker] -= max(avail - need, 0)
  98. sources.append(f'WH: {avail}')
  99. cost = raw_prices[ticker]['Ask'] * need
  100. print(f' (${cost:6.0f}) ' + ' '.join(sources))
  101. total_cost += cost
  102. else:
  103. print()
  104. print(f'\ntotal cost: {total_cost:,}')
  105. combined_buy: dict[str, int] = collections.defaultdict(int)
  106. for planet_name, buy in optimal.items():
  107. supply_config = config.supply_config(planet_name)
  108. for ticker, amount in buy.items():
  109. if ticker not in supply_config.ignore_materials:
  110. combined_buy[ticker] += amount
  111. print(cyan('\nbuy:\n') + json.dumps({
  112. 'actions': [
  113. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  114. 'priceLimits': {}, 'buyPartial': False, 'useCXInv': True},
  115. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  116. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  117. ],
  118. 'global': {'name': 'supply ' + ' '.join(args.planets)},
  119. 'groups': [{
  120. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in combined_buy.items()}
  121. }],
  122. }))
  123. for planet in planets:
  124. buy = optimal[planet.name]
  125. print(cyan(f'unload {planet.name}:\n') + json.dumps({
  126. 'actions': [
  127. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  128. 'origin': 'Configure on Execution', 'dest': planet.name + ' Base'},
  129. ],
  130. 'global': {'name': 'unload ' + planet.name},
  131. 'groups': [{
  132. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in buy.items()}
  133. }],
  134. }))
  135. def get_fio_burn(planet_names: typing.Sequence[str]) -> typing.Iterator[FIOBurn]:
  136. planets: list[FIOBurn] = cache.get('https://rest.fnar.net/fioweb/burn/user/' + config.username,
  137. headers={'Authorization': config.fio_api_key})
  138. for name in planet_names:
  139. name = name.casefold()
  140. for planet_data in planets:
  141. if name in (planet_data['PlanetName'].casefold(), planet_data['PlanetNaturalId'].casefold()):
  142. assert planet_data['Error'] is None
  143. yield planet_data
  144. break
  145. else:
  146. raise ValueError(name + ' not found')
  147. def shipping_used(materials: dict[str, Material], ignore: typing.Collection[str], counts: dict[str, int]) -> tuple[float, float]:
  148. weight = volume = 0
  149. for ticker, amount in counts.items():
  150. if ticker in ignore:
  151. continue
  152. weight += amount * materials[ticker]['Weight']
  153. volume += amount * materials[ticker]['Volume']
  154. return weight, volume
  155. def warehouse_inventory() -> dict[str, int]:
  156. warehouses: typing.Sequence[market.Warehouse] = cache.get('https://rest.fnar.net/sites/warehouses/' + config.username,
  157. headers={'Authorization': config.fio_api_key})
  158. for warehouse in warehouses:
  159. if warehouse['LocationNaturalId'] == 'HRT':
  160. storage: market.Storage = cache.get(f'https://rest.fnar.net/storage/{config.username}/{warehouse["StoreId"]}',
  161. headers={'Authorization': config.fio_api_key})
  162. assert storage['Type'] == 'WAREHOUSE_STORE'
  163. return {item['MaterialTicker']: item['MaterialAmount'] for item in storage['StorageItems']}
  164. raise Exception("couldn't find HRT warehouse")
  165. def cyan(text: str) -> str:
  166. return '\033[36m' + text + '\033[0m'
  167. class FIOBurn(typing.TypedDict):
  168. PlanetName: str
  169. PlanetNaturalId: str
  170. Error: typing.Any
  171. OrderConsumption: list[Amount]
  172. WorkforceConsumption: list[Amount]
  173. Inventory: list[market.StorageItem]
  174. OrderProduction: list[Amount]
  175. @dataclasses.dataclass(init=False, eq=False, slots=True)
  176. class Planet:
  177. name: str
  178. inventory: dict[str, int]
  179. net_consumption: typing.Sequence[Amount]
  180. exporting: typing.Set[str]
  181. def __init__(self, fio_burn: FIOBurn) -> None:
  182. self.name = fio_burn['PlanetName'] or fio_burn['PlanetNaturalId']
  183. self.inventory = {item['MaterialTicker']: item['MaterialAmount'] for item in fio_burn['Inventory']}
  184. # producing any amount (including less than consumption)
  185. producing = {item['MaterialTicker']: item for item in fio_burn['OrderProduction']}
  186. self.net_consumption = []
  187. for c in fio_burn['OrderConsumption'] + fio_burn['WorkforceConsumption']:
  188. net = c['DailyAmount']
  189. if production := producing.get(c['MaterialTicker']):
  190. net -= production['DailyAmount']
  191. if net < 0:
  192. continue
  193. c['net_consumption'] = net
  194. self.net_consumption.append(c)
  195. consuming = {item['MaterialTicker'] for item in self.net_consumption}
  196. # producing more than consumption
  197. self.exporting = set()
  198. for item in fio_burn['OrderProduction']:
  199. if item['MaterialTicker'] not in consuming:
  200. self.exporting.add(item['MaterialTicker'])
  201. def supply_for_days(self, target_days: float) -> dict[str, int]:
  202. buy: dict[str, int] = {}
  203. for consumption in self.net_consumption:
  204. ticker = consumption['MaterialTicker']
  205. avail = self.inventory.get(ticker, 0)
  206. daily_consumption = consumption['net_consumption']
  207. days = avail / daily_consumption
  208. if days < target_days:
  209. buy[ticker] = math.ceil((target_days - days) * daily_consumption)
  210. return buy
  211. class Amount(typing.TypedDict):
  212. MaterialTicker: str
  213. DailyAmount: float
  214. net_consumption: float
  215. class Material(typing.TypedDict):
  216. Ticker: str
  217. Weight: float
  218. Volume: float
  219. if __name__ == '__main__':
  220. main()