supply.py 9.2 KB

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