Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ code.

## Package releases

### Unreleased

No code or number changed.

- README: a "What a row holds" section naming the columns most readers want,
held to the fixture's header by `tests/test_docs.py`, and how to read a
release's CSV with pandas without installing anything.
- pyproject: the Python versions CI runs, an Issues link, and economicspace's
link no longer labelled "Upstream", since it is the consumer.

### 0.1.0 - 2026-10-26

The first release, split out of economicspace, where this was Stage 2
Expand Down
2 changes: 1 addition & 1 deletion CITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ on the date each file is named for. `metals-2026-10-07` was not recorded at
the time: it holds the closes recovered exactly from economicspace's Stage 2
tables of that date, and its `note` column says so.

⚠️ A price is a price **on a date**. Every row carries `catalog_date`, the
A price is a price **on a date**. Every row carries `catalog_date`, the
day its quotes were recorded, and `ref_price_date` for its reference figure;
quote them with it.

Expand Down
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,22 @@ spacevalue quotes list

Or skip the code and download a table. Every
[release](https://github.com/loggger101/spacevalue/releases) tagged
`prices-YYYY-MM-DD` carries all eight as CSVs.
`prices-YYYY-MM-DD` carries all eight as CSVs, which pandas reads straight
from the release:

```python
import pandas as pd

url = ("https://github.com/loggger101/spacevalue/releases/download/"
"prices-2026-10-26/mineral_value_catalog.cislunar.csv")
table = pd.read_csv(url)
```

## Contents

- [The eight destinations](#the-eight-destinations)
- [How a price is made](#how-a-price-is-made)
- [What a row holds](#what-a-row-holds)
- [Prices come from recorded quotes](#prices-come-from-recorded-quotes)
- [Price releases](#price-releases)
- [Two version numbers](#two-version-numbers)
Expand Down Expand Up @@ -67,8 +77,9 @@ cheapest LEO price a buyer can book, and adds the stages the chain throws away.
| `mars_surface` | 186,306 | 4,400 m/s plus aeroentry |

The two surface figures are lower bounds. They price industrial-scale
transport with no development cost and no cadence limit. Real lunar delivery today costs
far more. `tests/test_docs.py` holds this table to the code.
transport with no development cost and no cadence limit. Real lunar delivery
today costs far more: a commercial lander charges on the order of $1M/kg.
`tests/test_docs.py` holds this table to the code.

## How a price is made

Expand All @@ -90,6 +101,29 @@ A mineral is priced through what it yields when refined: nickel-iron through
its iron, nickel, cobalt, platinum-group and trace metals, troilite through
its iron and sulfur. The yields are in `yields_json` on every row.

## What a row holds

One row per mineral or element in the reference table, and the same columns
at every destination. The ones you will read most:

| column | what it holds |
|---|---|
| `price_usd_per_kg` | the price at this destination |
| `value_route` | `used in space` or `shipped to Earth`, whichever paid more |
| `terrestrial_price_usd_per_kg` | the Earth price it started from |
| `in_space_utility` | how good a substitute it is for launched mass, 0 to 1 |
| `downleg_cost_usd_per_kg` | the trip home, taken off when it is shipped there |
| `in_space_processing_usd_per_kg` | the on-site processing, taken off when it is used there |
| `annual_market_kg` | how many kilograms a year that market can take |
| `price_basis` | at Earth's surface `live` (a futures close), `reference` or `unpriced`; elsewhere `derived-in-space` |
| `ref_price_date` | the date of the reference figure behind the row |
| `catalog_date` | the day the metal quotes were recorded |
| `yields_json` | for a mineral, kilograms of each element per kilogram refined |
| `notes` | where the row's number comes from |

`pipeline_version` and `delivery_destination` are stamped on every row, so a
table that has lost its filename still says what it is.

## Prices come from recorded quotes

Five metals have a traded future: gold, silver, platinum, palladium and copper.
Expand All @@ -113,8 +147,7 @@ price is a new file. The table's `catalog_date` is the day the quotes were
recorded, not the day you built it.

`metals-2026-10-07` was made differently. Nobody recorded those closes at the
time; economicspace's tables of that date kept only the
converted $/kg. Each of the five converts back to exactly one close that
time; economicspace's tables of that date kept only the converted $/kg. Each of the five converts back to exactly one close that
reproduces it bit for bit, so that is what the snapshot holds, and its `note`
column says where it came from.

Expand Down Expand Up @@ -179,8 +212,9 @@ ascent away.
The demand ceilings at a depot are sourced where a source exists (the GEO
ceiling counts the active geostationary satellites) and are estimates
elsewhere, with the published lunar propellant forecasts recorded beside the
cislunar and lunar ones as anchors rather than used as values. A commodity with no in-space market is not worthless at a depot: it
is worth its Earth price less the trip home.
cislunar and lunar ones as anchors rather than used as values. A commodity
with no in-space market is not worthless at a depot: it is worth its Earth
price less the trip home.

## Layout

Expand Down
13 changes: 12 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ authors = [{ name = "Logan M Edwards" }]
keywords = [
"asteroid-mining", "space-resources", "commodity-prices", "isru",
"mineral-economics", "cislunar", "reference-data", "reproducibility",
"moon", "mars", "metal-prices",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Science/Research",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Astronomy",
"Topic :: Office/Business :: Financial",
]
Expand All @@ -46,7 +54,10 @@ test = ["pytest>=7.0"]
Repository = "https://github.com/loggger101/spacevalue"
Changelog = "https://github.com/loggger101/spacevalue/blob/main/CHANGELOG.md"
Releases = "https://github.com/loggger101/spacevalue/releases"
"Upstream project" = "https://github.com/loggger101/economicspace"
Issues = "https://github.com/loggger101/spacevalue/issues"
# Not upstream: economicspace is where this came from, and it now installs
# a price release from here as its Stage 2.
economicspace = "https://github.com/loggger101/economicspace"

[project.scripts]
spacevalue = "spacevalue.cli:main"
Expand Down
24 changes: 12 additions & 12 deletions spacevalue/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ class MineralValueConfig:
# brakes most of the arrival, but the tug, aeroshell and
# lander are all expended (85% of the price).
#
# ⚠️ The two surface figures are MARGINAL-TRANSPORT LOWER BOUNDS. They
# The two surface figures are MARGINAL-TRANSPORT LOWER BOUNDS. They
# price the propellant and stages needed to move a kilogram, on the
# cheapest LEO price a buyer can book today (Falcon Heavy expendable,
# spacecost v0.4.0), with no first-of-kind development, no programme
Expand All @@ -147,7 +147,7 @@ class MineralValueConfig:
# DELIVERY_DESTINATIONS and IN_SPACE_UTILITY below for the numbers, the
# derivation, and which parts are judgement rather than measurement.
#
# ⚠️ economicspace's Stage 4 carries a delivery_destination of its own,
# economicspace's Stage 4 carries a delivery_destination of its own,
# and it must MATCH the one a table was priced for; it selects the mission
# architecture that actually delivers the cargo there. Every row carries
# the destination it was priced for, so a reader can check.
Expand Down Expand Up @@ -288,7 +288,7 @@ def _per_tonne_to_per_kg(usd_per_tonne: float) -> float:
# ladder in Module 3's DELTA_V_REFERENCE, and from a real launch price. Every
# constant and every leg of that derivation lives in `spacecost.delivery`.
#
# ⚠️ THE NAMES BELOW STAY BOUND ON PURPOSE, they are not leftovers.
# THE NAMES BELOW STAY BOUND ON PURPOSE, they are not leftovers.
# economicspace's worked calculation reaches `_DELIVERY_LEGS`,
# `_LEO_USD_PER_KG` and `delivered_cost_usd_per_kg` through its Stage 2
# adapter, and carries all three on its `BORROWED` register. Deleting an
Expand All @@ -311,7 +311,7 @@ def _per_tonne_to_per_kg(usd_per_tonne: float) -> float:
# and the output notes read it rather than typing it.
_LEO_USD_PER_KG = spacecost.LEO_LAUNCH_USD_PER_KG

# The leg chains. ⚠️ `None` and `[]` are DIFFERENT and both are used:
# The leg chains. `None` and `[]` are DIFFERENT and both are used:
# `earth_surface` has no chain and avoids no launch, `leo` has an EMPTY chain
# and avoids the whole LEO price. `_build_destination_table` below tests
# `is None` for exactly that reason, and a truthiness test there would price
Expand All @@ -335,7 +335,7 @@ def _per_tonne_to_per_kg(usd_per_tonne: float) -> float:
# sentence is describing, not retyped beside it: if a `DELTA_V_REFERENCE` row
# moves, the chain moves and the sentence moves with it.
#
# ⚠️ The four figures that are NOT the chain are named constants with their
# The four figures that are NOT the chain are named constants with their
# citations, rather than digits buried mid-sentence, so a reader can see at a
# glance which numbers this table asserts and which it derives. They are
# comparisons and context, not terms in the price.
Expand Down Expand Up @@ -476,7 +476,7 @@ def _build_destination_table() -> Dict[str, dict]:
# mass launched from Earth; 0.0 means there is no in-space market for it at
# all and it can only be sold by flying it down.
#
# ⚠️ THESE ARE ENGINEERING JUDGEMENTS, NOT MEASUREMENTS. Unlike the price
# THESE ARE ENGINEERING JUDGEMENTS, NOT MEASUREMENTS. Unlike the price
# above, which is derived from the rocket equation and a real launch price,
# no market exists yet to calibrate these against. They are the single
# biggest soft assumption in the in-space case, so they live here as one
Expand Down Expand Up @@ -587,7 +587,7 @@ def _build_destination_table() -> Dict[str, dict]:
# this table can be moved without the change becoming a way to manufacture
# viability, which is the one thing the in-space case must not be tuned into.
#
# ⚠️ CONSIDERED AND REJECTED: giving the precious metals a small non-zero
# CONSIDERED AND REJECTED: giving the precious metals a small non-zero
# utility (0.05) at the two SURFACE destinations. The physical argument is
# sound; a crewed base runs fuel cells, electrolysers and Sabatier reactors
# and wants Pt/Pd/Ru catalysts, where a propellant depot genuinely does not.
Expand All @@ -601,7 +601,7 @@ def _build_destination_table() -> Dict[str, dict]:
# in space, fly the rest home) and this pipeline cannot express a blend.
# Restoring it needs a quantity-aware route choice first, not a bigger table.
#
# ⚠️ Softer than the base table, which is already the softest thing in the
# Softer than the base table, which is already the softest thing in the
# pipeline. These are judgements about economies that do not exist.
IN_SPACE_UTILITY_BY_DESTINATION: Dict[str, Dict[str, float]] = {
"leo": {}, # base profile, no local resources
Expand All @@ -620,7 +620,7 @@ def _build_destination_table() -> Dict[str, dict]:
# crew, and no construction site. So the volatiles keep most of their
# value and the feedstock loses nearly all of it.
#
# ⚠️ That makes GEO the destination most sensitive to this table, which
# That makes GEO the destination most sensitive to this table, which
# is the softest thing in the pipeline. The price above is derived from
# the rocket equation; the numbers below are judgement about a market
# that does not exist yet. They are also still overrides that run
Expand Down Expand Up @@ -916,7 +916,7 @@ def in_space_utility(name: str, destination: str) -> float:
# What a theoretical in-space base can actually ABSORB per year, all
# commodities competing for the same import budget.
#
# ⚠️ JUDGEMENT, not measurement; no such market exists. Anchored to
# JUDGEMENT, not measurement; no such market exists. Anchored to
# publicly discussed architectures: a Starship-class refuelling campaign needs
# on the order of 1,000 t of propellant in LEO per Mars departure; an
# Artemis-scale NRHO depot is a fraction of that; surface bases are smaller
Expand Down Expand Up @@ -997,7 +997,7 @@ def in_space_utility(name: str, destination: str) -> float:
# The four bulk classes partition the budget; the trace slice is additive and
# negligible.
#
# ⚠️ JUDGEMENT, like everything else in this block. Anchored on what a
# JUDGEMENT, like everything else in this block. Anchored on what a
# propellant-and-construction outpost actually consumes by mass: propellant
# dominates, structure is next, shielding is bulky but occasional, and
# catalysts are measured in kilograms.
Expand Down Expand Up @@ -1626,7 +1626,7 @@ def _new_mineral(name: str, formula: str, density: float,
# an outer-belt body's ices and a C-type's carbonates, and the trace
# metals of meteoritic Fe-Ni that were never in the alloy's yields.
#
# ⚠️ REFERENCE PRICES ONLY, NO LIVE TICKER, AND THEY ARE APPROXIMATE.
# REFERENCE PRICES ONLY, NO LIVE TICKER, AND THEY ARE APPROXIMATE.
# None of these trades on a futures market this module reads. Each is
# the order of the 2024 figure in USGS Mineral Commodity Summaries 2025
# (published 2025-01-31) where USGS carries one, and a trade price where
Expand Down
11 changes: 11 additions & 0 deletions tests/test_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ def test_each_readme_delta_v_is_the_chain_total():
continue
row = re.search(r"^\| `%s` \|[^\n]*" % name, text, re.M).group(0)
assert "{:,.0f} m/s".format(dv) in row, (name, row)


def test_every_column_the_readme_names_is_a_column():
text = open(os.path.join(ROOT, "README.md"), encoding="utf-8").read()
section = text.split("## What a row holds", 1)[1].split("\n## ", 1)[0]
named = set(re.findall(r"^\| `([a-z_]+)` \|", section, re.M))
named |= set(re.findall(r"`([a-z_]+)` and `([a-z_]+)` are stamped", section)[0])
fixture = os.path.join(ROOT, "tests", "data", "prices-2026-10-07",
"mineral_value_catalog.leo.csv")
header = open(fixture, encoding="utf-8").readline().strip().split(",")
assert named and named <= set(header), sorted(named - set(header))
Loading