diff --git a/CHANGELOG.md b/CHANGELOG.md index 557d189..765e830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CITATIONS.md b/CITATIONS.md index bdf58e6..77243b1 100644 --- a/CITATIONS.md +++ b/CITATIONS.md @@ -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. diff --git a/README.md b/README.md index dde3781..474f949 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. @@ -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. @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ecf3236..ab956fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] @@ -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" diff --git a/spacevalue/catalog.py b/spacevalue/catalog.py index 2800844..f632764 100644 --- a/spacevalue/catalog.py +++ b/spacevalue/catalog.py @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. @@ -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 @@ -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. @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/tests/test_docs.py b/tests/test_docs.py index d827043..c5cffc1 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -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))