supply.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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. optimal, from_cx = calculate_optimal(planets, materials, args.weight, args.volume)
  37. weight_used = volume_used = 0
  38. for mat, amount in from_cx.items():
  39. weight_used += amount * materials[mat]['Weight']
  40. volume_used += amount * materials[mat]['Volume']
  41. for planet in planets:
  42. unload = optimal[planet.name]
  43. for mat, amount in unload.items():
  44. weight_used -= amount * materials[mat]['Weight']
  45. volume_used -= amount * materials[mat]['Volume']
  46. for mat in planet.exporting:
  47. amount = planet.inventory.get(mat, 0)
  48. weight_used += amount * materials[mat]['Weight']
  49. volume_used += amount * materials[mat]['Volume']
  50. if weight_used > args.weight:
  51. print(yellow('warning'), f'need additional {weight_used - args.weight:.1f}t to pick up exports at {planet.name}')
  52. if volume_used > args.volume:
  53. print(yellow('warning'), f'need additional {volume_used - args.volume:.1f}m³ to pick up exports at {planet.name}')
  54. print(cyan('\nload at CX:\n') + json.dumps({
  55. 'actions': [
  56. {'name': 'BuyItems', 'type': 'CX Buy', 'group': 'A1', 'exchange': 'IC1',
  57. 'priceLimits': {}, 'buyPartial': False, 'useCXInv': True},
  58. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  59. 'origin': 'Hortus Station Warehouse', 'dest': 'Configure on Execution'},
  60. ],
  61. 'global': {'name': 'supply ' + ' '.join(args.planets)},
  62. 'groups': [{
  63. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in from_cx.items()}
  64. }],
  65. }))
  66. for planet in planets:
  67. buy = optimal[planet.name]
  68. print(cyan(f'unload {planet.name}:\n') + json.dumps({
  69. 'actions': [
  70. {'type': 'MTRA', 'name': 'TransferAction', 'group': 'A1',
  71. 'origin': 'Configure on Execution', 'dest': planet.name + ' Base'},
  72. ],
  73. 'global': {'name': 'unload ' + planet.name},
  74. 'groups': [{
  75. 'type': 'Manual', 'name': 'A1', 'materials': {mat: amount for mat, amount in buy.items()}
  76. }],
  77. }))
  78. def get_fio_burn(planet_names: typing.Sequence[str]) -> typing.Iterator[FIOBurn]:
  79. planets: list[FIOBurn] = cache.get('https://rest.fnar.net/fioweb/burn/user/' + config.username,
  80. headers={'Authorization': config.fio_api_key})
  81. for name in planet_names:
  82. name = name.casefold()
  83. for planet_data in planets:
  84. if name in (planet_data['PlanetName'].casefold(), planet_data['PlanetNaturalId'].casefold()):
  85. assert planet_data['Error'] is None
  86. yield planet_data
  87. break
  88. else:
  89. raise ValueError(name + ' not found')
  90. def calculate_optimal(planets: typing.Sequence[Planet], materials: typing.Mapping[str, Material], max_weight: float,
  91. max_volume: float) -> tuple[typing.Mapping[str, typing.Mapping[str, int]], typing.Mapping[str, int]]:
  92. target_days = float('inf')
  93. for planet in planets:
  94. vol_per_day = weight_per_day = 0
  95. for ticker, consumption in planet.net_consumption.items():
  96. if consumption <= 0:
  97. continue
  98. vol_per_day += materials[ticker]['Volume'] * consumption
  99. weight_per_day += materials[ticker]['Weight'] * consumption
  100. days = planet.inventory.get(ticker, 0) / consumption
  101. if days < target_days:
  102. target_days = days
  103. print(planet.name, f'consumes {vol_per_day:.1f}m³, {weight_per_day:.1f}t per day')
  104. optimal: dict[str, dict[str, int]] = None # pyright: ignore[reportAssignmentType]
  105. total_weight_used: float = None # pyright: ignore[reportAssignmentType]
  106. total_volume_used: float = None # pyright: ignore[reportAssignmentType]
  107. target_days = round(target_days + 0.05, 1)
  108. load_more = True
  109. while load_more:
  110. buys: dict[str, dict[str, int]] = {}
  111. iteration_weight = iteration_volume = 0
  112. for planet in planets:
  113. buy = planet.supply_for_days(target_days)
  114. weight_used, volume_used = shipping_used(materials, config.supply_config(planet.name).ignore_materials, buy)
  115. iteration_weight += weight_used
  116. iteration_volume += volume_used
  117. if iteration_weight > max_weight or iteration_volume > max_volume:
  118. load_more = False
  119. break
  120. buys[planet.name] = buy
  121. if load_more:
  122. optimal = buys
  123. total_weight_used = iteration_weight
  124. total_volume_used = iteration_volume
  125. target_days += 0.1
  126. print('supply for', round(target_days, 1), 'days,', end=' ')
  127. print(f'consuming {round(total_weight_used, 1)}t and {round(total_volume_used, 1)}m³') # pyright: ignore[reportPossiblyUnboundVariable]
  128. raw_prices: typing.Mapping[str, market.RawPrice] = {p['MaterialTicker']: p
  129. for p in cache.get('https://refined-prun.github.io/refined-prices/all.json') if p['ExchangeCode'] == 'IC1'}
  130. warehouse = warehouse_inventory()
  131. from_cx: dict[str, int] = collections.defaultdict(int)
  132. total_cost = 0
  133. for i, planet in enumerate(planets):
  134. print('\n' + cyan(planet.name))
  135. supply_config = config.supply_config(planet.name)
  136. planet_buy = optimal[planet.name]
  137. for ticker, consumption in planet.net_consumption.items():
  138. if consumption <= 0:
  139. continue
  140. avail = planet.inventory.get(ticker, 0)
  141. days = avail / consumption
  142. print(f'{ticker:>3}: {avail:5d} ({consumption:8.2f}/d) {days:5.1f} d', end='')
  143. if need := planet_buy.get(ticker): # pyright: ignore[reportOptionalMemberAccess]
  144. if ticker in supply_config.ignore_materials:
  145. print(f' | {need:5.0f} (ignored)')
  146. else:
  147. print(f' | {need:5.0f}', end='')
  148. sources = []
  149. for exporter in planets[:i]:
  150. if ticker in exporter.exporting and (avail := exporter.inventory.get(ticker, 0)):
  151. need -= min(need, avail)
  152. exporter.inventory[ticker] -= max(avail - need, 0)
  153. sources.append(f'{exporter.name}: {avail}')
  154. if need:
  155. from_cx[ticker] += need # count from_cx before subtracting warehouse
  156. if avail := warehouse.get(ticker, 0):
  157. need -= min(need, avail)
  158. warehouse[ticker] -= max(avail - need, 0)
  159. sources.append(f'WH: {avail}')
  160. if (ppu := raw_prices[ticker]['Ask']) is not None:
  161. cost = ppu * need
  162. print(f' (${cost:6.0f}) ' + ', '.join(sources))
  163. total_cost += cost
  164. else:
  165. print(yellow(' no supply ') + ', '.join(sources))
  166. else:
  167. print()
  168. print(f'\ntotal cost: {total_cost:,}')
  169. return optimal, from_cx
  170. def shipping_used(materials: typing.Mapping[str, Material], ignore: typing.Collection[str], counts: dict[str, int]) -> tuple[float, float]:
  171. weight = volume = 0
  172. for ticker, amount in counts.items():
  173. if ticker in ignore:
  174. continue
  175. weight += amount * materials[ticker]['Weight']
  176. volume += amount * materials[ticker]['Volume']
  177. return weight, volume
  178. def warehouse_inventory() -> dict[str, int]:
  179. warehouses: typing.Sequence[market.Warehouse] = cache.get('https://rest.fnar.net/sites/warehouses/' + config.username,
  180. headers={'Authorization': config.fio_api_key})
  181. for warehouse in warehouses:
  182. if warehouse['LocationNaturalId'] == 'HRT':
  183. storage: market.Storage = cache.get(f'https://rest.fnar.net/storage/{config.username}/{warehouse["StoreId"]}',
  184. headers={'Authorization': config.fio_api_key})
  185. assert storage['Type'] == 'WAREHOUSE_STORE'
  186. return {item['MaterialTicker']: item['MaterialAmount'] for item in storage['StorageItems']}
  187. raise Exception("couldn't find HRT warehouse")
  188. def yellow(text: str) -> str:
  189. return '\033[33m' + text + '\033[0m'
  190. def cyan(text: str) -> str:
  191. return '\033[36m' + text + '\033[0m'
  192. class FIOBurn(typing.TypedDict):
  193. PlanetName: str
  194. PlanetNaturalId: str
  195. Error: typing.Any
  196. OrderConsumption: list[Amount]
  197. WorkforceConsumption: list[Amount]
  198. Inventory: list[market.StorageItem]
  199. OrderProduction: list[Amount]
  200. class Amount(typing.TypedDict):
  201. MaterialTicker: str
  202. DailyAmount: float
  203. @dataclasses.dataclass(init=False, eq=False, slots=True)
  204. class Planet:
  205. name: str
  206. inventory: dict[str, int]
  207. net_consumption: dict[str, float]
  208. exporting: typing.Set[str]
  209. def __init__(self, fio_burn: FIOBurn) -> None:
  210. self.name = fio_burn['PlanetName'] or fio_burn['PlanetNaturalId']
  211. self.inventory = {item['MaterialTicker']: item['MaterialAmount'] for item in fio_burn['Inventory']}
  212. self.net_consumption = {}
  213. for item in fio_burn['OrderConsumption'] + fio_burn['WorkforceConsumption']:
  214. ticker = item['MaterialTicker']
  215. self.net_consumption[ticker] = self.net_consumption.get(ticker, 0) + item['DailyAmount']
  216. for item in fio_burn['OrderProduction']:
  217. ticker = item['MaterialTicker']
  218. self.net_consumption[ticker] = self.net_consumption.get(ticker, 0) - item['DailyAmount']
  219. # producing more than consumption
  220. self.exporting = set()
  221. for item in fio_burn['OrderProduction']:
  222. if item['MaterialTicker'] not in self.net_consumption:
  223. self.exporting.add(item['MaterialTicker'])
  224. def supply_for_days(self, target_days: float) -> dict[str, int]:
  225. buy: dict[str, int] = {}
  226. for ticker, consumption in self.net_consumption.items():
  227. if consumption <= 0:
  228. continue
  229. avail = self.inventory.get(ticker, 0)
  230. days = avail / consumption
  231. to_buy = 0
  232. if days < target_days:
  233. to_buy = math.ceil((target_days - days) * consumption)
  234. if avail + to_buy < 2:
  235. to_buy = 2 - avail # always stock at least 2 of everything
  236. if to_buy > 0:
  237. buy[ticker] = to_buy
  238. return buy
  239. class Material(typing.TypedDict):
  240. Ticker: str
  241. Weight: float
  242. Volume: float
  243. if __name__ == '__main__':
  244. main()