crudecode 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crudecode/GUIDE.md ADDED
@@ -0,0 +1,103 @@
1
+ # crudecode: start here
2
+
3
+ `crudecode` is the method half of Crude Code. It is a small Python package you
4
+ run in your own sandbox. It gives you guides (how to forecast a well, how to
5
+ value a package), default assumptions, and the code that checks your forecasts
6
+ and does the economics. It never touches the network and it never chooses a
7
+ number for you.
8
+
9
+ The other half is the **Crude Code MCP server**, which serves the data: US well
10
+ headers and monthly production from state regulatory filings. The split is
11
+ deliberate:
12
+
13
+ | | Crude Code MCP (data) | `crudecode` (method) |
14
+ |---|---|---|
15
+ | Holds | well headers, monthly production, coverage, SQL over the warehouse | guides, decline-curve and cashflow code, default assumptions |
16
+ | You use it for | evidence: what did this well report? | judgment checks and arithmetic: what does this curve imply? what is this package worth? |
17
+ | Where it runs | the server | your sandbox |
18
+
19
+ Rule of thumb: **bulk data stays on the MCP; only small inputs reach the
20
+ sandbox, and you write them.** You do not copy production tables into the
21
+ sandbox. You read the history through the data tools, make your judgments, and
22
+ write a small file (about a few hundred bytes per well: curves, interests, a
23
+ few facts) that the kit reads.
24
+
25
+ ## The data tools (Crude Code MCP)
26
+
27
+ | Tool | Use it for |
28
+ |---|---|
29
+ | `get_coverage` | what data exists, per state; how complete each header field is. Call first when unsure a question can be answered. |
30
+ | `search` | resolve a well name, API number, operator or county to ids |
31
+ | `get_well` | full header for up to 50 wells: status, formation, lateral, key dates, cumulatives |
32
+ | `get_production` | monthly oil (bbl), gas (mcf), water, days produced for up to 50 wells, or summed across wells (`combine=true`) |
33
+ | `run_sql` | anything custom: one read-only SELECT against the warehouse (its description carries the schema). Use it for aggregates so the rows you read stay small. |
34
+
35
+ Facts you must carry with you: `api10` is the **undashed 10-digit** API number.
36
+ A **NULL volume means no number was reported, never zero**. `prod_month = 1`
37
+ is a well's first producing month (often partial). Some header columns are thin
38
+ in some states (check `get_coverage`); say so rather than presenting a partial
39
+ set as the whole.
40
+
41
+ ## Which guide for which job
42
+
43
+ | Job | Read | Commands |
44
+ |---|---|---|
45
+ | Forecast one well or many: assert decline curves and sanity-check them | `crudecode guide forecasting` | `crudecode forecast check deal.json` |
46
+ | Value a package: NPV by PDP / DUC / PUD, monthly volumes and cashflows | `crudecode guide valuation` (forecast first, then value) | `crudecode assumptions init`, `crudecode forecast check`, `crudecode value` |
47
+ | See the exact deal.json contract | `crudecode guide schema` | |
48
+ | See a worked deal.json | `crudecode guide example` (and `crudecode guide example-assumptions`) | |
49
+
50
+ A valuation always needs forecasts, so for a valuation read **forecasting
51
+ first**, then **valuation**.
52
+
53
+ ## The commands
54
+
55
+ ```
56
+ crudecode guide [forecasting|valuation|schema|example|example-assumptions]
57
+ print a guide
58
+ crudecode forecast check deal.json [--assumptions FILE] [--json]
59
+ validate asserted curves and echo what they imply
60
+ crudecode assumptions init assumptions.toml write the default economic assumptions to edit
61
+ crudecode value deal.json --assumptions assumptions.toml --out out/
62
+ run the economics; writes wells_monthly.csv,
63
+ parameters.csv, README.md into out/
64
+ ```
65
+
66
+ Exit codes: 0 fine, 1 `deal.json` failed validation (every violation is
67
+ listed at once; nothing is accepted piecemeal), 2 a file could not be read, the
68
+ assumptions file is invalid, or the command was used wrongly.
69
+
70
+ The guides are long. `crudecode guide forecasting --toc` lists a guide's
71
+ sections and `crudecode guide forecasting --section N` prints one, in case your
72
+ sandbox truncates long output.
73
+
74
+ ## Ground rules
75
+
76
+ 1. **You assert every parameter.** The kit validates sanity bounds and echoes
77
+ consequences; anything inside the bounds is your call. Write your reasoning
78
+ in each well's `rationale`.
79
+ 2. **Nothing is defaulted silently.** Every well needs an explicit bucket
80
+ (PDP, DUC or PUD). Economic assumptions come from an assumptions file you
81
+ have shown to the user. If the kit refuses an input, fix the input; do not
82
+ work around it.
83
+ 3. **Show the assumptions, get a yes, then run.** Before `crudecode value`,
84
+ present the assumptions (the valuation guide has the grid) and let the user
85
+ confirm or change them.
86
+ 4. **Units.** Oil in barrels, gas in mcf, rates per month, money in USD,
87
+ interests as fractions (0.75 = 75%), decline `di` as a nominal monthly rate.
88
+ 5. **Say what the number is.** This version prices on a **flat deck** only
89
+ (strip/futures pricing is not supported yet), models no NGLs and no
90
+ ownership or title diligence, and values what the wells' assertions say.
91
+ State those limits next to any NPV you report.
92
+ 6. **Hand over the files.** The outputs are plain CSV, JSON and Markdown. Give
93
+ the user the files as they are, and summarize the NPV in your reply.
94
+
95
+ ## Installing
96
+
97
+ ```
98
+ pip install --break-system-packages crudecode==0.1.0a1
99
+ ```
100
+
101
+ Two dependencies (numpy, python-dateutil). After install the kit needs no
102
+ network access. Problems with the kit or the data: tell the user, and use the
103
+ MCP's `message_team` tool to report them.
crudecode/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """crudecode: guides, forecast checks and valuation economics for oil & gas
2
+ analysis with an LLM. Pairs with the Crude Code MCP data server."""
3
+
4
+ __version__ = "0.1.0a1"
crudecode/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from crudecode.cli import main
2
+
3
+ raise SystemExit(main())
crudecode/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """The `crudecode` command line.
2
+
3
+ crudecode guide [name]
4
+ crudecode forecast check deal.json [--assumptions FILE] [--json]
5
+ crudecode assumptions init FILE [--force]
6
+ crudecode value deal.json --assumptions FILE --out DIR [--json]
7
+
8
+ The kit makes no network calls and writes only the files it is told to.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from importlib.resources import files
16
+
17
+ from crudecode import __version__
18
+
19
+ # `crudecode guide <name>` -> path inside the package.
20
+ _GUIDES = {
21
+ "index": ("GUIDE.md",),
22
+ "forecasting": ("forecasting", "GUIDE.md"),
23
+ "valuation": ("valuation", "GUIDE.md"),
24
+ "schema": ("valuation", "schema.json"),
25
+ "example": ("valuation", "examples", "deal.json"),
26
+ "example-assumptions": ("valuation", "examples", "assumptions.toml"),
27
+ }
28
+ _GUIDE_BLURB = {
29
+ "index": "what the kit is and which guide to read (default)",
30
+ "forecasting": "how to assert decline curves and check them",
31
+ "valuation": "how to build deal.json from the data tools, run the economics, read the results",
32
+ "schema": "the JSON Schema for deal.json",
33
+ "example": "one worked deal.json (synthetic wells)",
34
+ "example-assumptions": "the assumptions file that example was valued with",
35
+ }
36
+
37
+
38
+ def _sections(text: str) -> list[tuple[str, str]]:
39
+ """Split a Markdown guide into (heading, body) parts at its '## ' headings.
40
+ Anything before the first '## ' (title, intro) is part 0, headed by the title."""
41
+ parts: list[tuple[str, list[str]]] = [("(start)", [])]
42
+ in_fence = False
43
+ for line in text.splitlines(keepends=True):
44
+ if line.lstrip().startswith("```"):
45
+ in_fence = not in_fence
46
+ if line.startswith("## ") and not in_fence:
47
+ parts.append((line[3:].strip(), [line]))
48
+ else:
49
+ parts[-1][1].append(line)
50
+ return [(h, "".join(b)) for h, b in parts]
51
+
52
+
53
+ def _guide(name: str | None, section: str | None, toc: bool) -> int:
54
+ key = (name or "index").lower()
55
+ if key not in _GUIDES:
56
+ sys.stderr.write(f"no guide named {name!r}. Available:\n")
57
+ for k, blurb in _GUIDE_BLURB.items():
58
+ sys.stderr.write(f" crudecode guide {k:<12} {blurb}\n")
59
+ return 2
60
+ text = files("crudecode").joinpath(*_GUIDES[key]).read_text(encoding="utf-8")
61
+ if not (toc or section):
62
+ sys.stdout.write(text)
63
+ return 0
64
+ if not _GUIDES[key][-1].endswith(".md"):
65
+ sys.stderr.write(f"{key} is not a sectioned guide; print it whole\n")
66
+ return 2
67
+ parts = _sections(text)
68
+ if toc:
69
+ sys.stdout.write(f"crudecode guide {key}: {len(text):,} characters in {len(parts) - 1} sections\n")
70
+ for i, (heading, body) in enumerate(parts):
71
+ sys.stdout.write(f" {i} {heading} ({len(body):,} chars)\n")
72
+ sys.stdout.write(f"read one with: crudecode guide {key} --section N (or a word from the heading)\n")
73
+ return 0
74
+ if section.isdigit() and int(section) < len(parts):
75
+ sys.stdout.write(parts[int(section)][1])
76
+ return 0
77
+ hits = [p for p in parts[1:] if section.lower() in p[0].lower()]
78
+ if len(hits) != 1:
79
+ sys.stderr.write(f"--section {section!r} matches {len(hits)} sections; use a number from --toc\n")
80
+ return 2
81
+ sys.stdout.write(hits[0][1])
82
+ return 0
83
+
84
+
85
+ def _parser() -> argparse.ArgumentParser:
86
+ p = argparse.ArgumentParser(
87
+ prog="crudecode",
88
+ description="Guides, forecast checks and valuation economics for oil & gas analysis with an LLM. "
89
+ "Start with: crudecode guide")
90
+ p.add_argument("--version", action="version", version=f"crudecode {__version__}")
91
+ sub = p.add_subparsers(dest="cmd", metavar="command")
92
+
93
+ g = sub.add_parser("guide", help="print a guide (index, forecasting, valuation, schema, example, example-assumptions)")
94
+ g.add_argument("name", nargs="?", help="guide name; omit for the index")
95
+ g.add_argument("--toc", action="store_true", help="list the guide's sections and their sizes")
96
+ g.add_argument("--section", help="print one section only: a number from --toc, or a word from its heading")
97
+
98
+ f = sub.add_parser("forecast", help="forecast workflow")
99
+ fsub = f.add_subparsers(dest="forecast_cmd", metavar="action", required=True)
100
+ fc = fsub.add_parser("check", help="validate asserted decline curves in deal.json and echo what they imply")
101
+ fc.add_argument("deal", help="path to deal.json")
102
+ fc.add_argument("--assumptions", help="assumptions TOML (horizon, terminal decline); defaults if omitted")
103
+ fc.add_argument("--json", action="store_true", help="machine-readable output")
104
+
105
+ a = sub.add_parser("assumptions", help="economic assumptions file")
106
+ asub = a.add_subparsers(dest="assumptions_cmd", metavar="action", required=True)
107
+ ai = asub.add_parser("init", help="write the default assumptions to FILE")
108
+ ai.add_argument("file", help="where to write the TOML")
109
+ ai.add_argument("--force", action="store_true", help="overwrite an existing file")
110
+
111
+ v = sub.add_parser("value", help="run the economics for deal.json and write the result files")
112
+ v.add_argument("deal", help="path to deal.json")
113
+ v.add_argument("--assumptions", required=True,
114
+ help="assumptions TOML (create one with: crudecode assumptions init FILE)")
115
+ v.add_argument("--out", required=True, help="directory for wells_monthly.csv, parameters.csv, README.md")
116
+ v.add_argument("--json", action="store_true", help="print the summary as JSON instead of text")
117
+ return p
118
+
119
+
120
+ def main(argv: list[str] | None = None) -> int:
121
+ try:
122
+ return _run(argv)
123
+ except BrokenPipeError: # `crudecode guide forecasting | head`
124
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
125
+ return 0
126
+
127
+
128
+ def _run(argv: list[str] | None) -> int:
129
+ parser = _parser()
130
+ args = parser.parse_args(argv)
131
+ if args.cmd is None:
132
+ parser.print_help()
133
+ sys.stdout.write("\nStart here: crudecode guide\n")
134
+ return 0
135
+ if args.cmd == "guide":
136
+ return _guide(args.name, args.section, args.toc)
137
+ if args.cmd == "forecast":
138
+ from crudecode.forecasting import check
139
+ return check.run(args.deal, args.assumptions, args.json)
140
+ if args.cmd == "assumptions":
141
+ from crudecode.valuation import run
142
+ return run.init_assumptions(args.file, args.force)
143
+ if args.cmd == "value":
144
+ from crudecode.valuation import run
145
+ return run.run(args.deal, args.assumptions, args.out, args.json)
146
+ parser.error(f"unknown command {args.cmd!r}")
147
+ return 2
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
@@ -0,0 +1,3 @@
1
+ """Shared utilities: decline curves, forecast consequences, economics, the
2
+ assumptions loader and the deal.json parser. Workflows (forecasting/,
3
+ valuation/) import from here and never from each other."""
@@ -0,0 +1,223 @@
1
+ """Economic assumptions: the defaults, the TOML loader, the validation.
2
+
3
+ Every economic number a valuation uses lives here and nowhere else. The
4
+ defaults are the house defaults of the Crude Code valuation engine;
5
+ ``valuation/assumptions.default.toml`` is the same numbers as an editable file
6
+ (a test pins the two together). A file may set only some keys: the rest take
7
+ the defaults, and ``Assumptions.explicit`` records which keys the file set, so
8
+ every output can say which numbers are defaults.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import tomllib
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ BUCKETS = ("PDP", "DUC", "PUD")
17
+
18
+
19
+ class AssumptionsError(ValueError):
20
+ """The assumptions file is unreadable or has invalid values. ``problems``
21
+ lists every one (never fail-fast)."""
22
+
23
+ def __init__(self, problems: list[str]):
24
+ self.problems = problems
25
+ super().__init__("; ".join(problems))
26
+
27
+
28
+ def _default_rates() -> dict[str, float]:
29
+ return {"PDP": 0.15, "DUC": 0.20, "PUD": 0.25}
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Assumptions:
34
+ # Flat price deck. ("strip" needs futures data and is not supported in v0.)
35
+ price_deck_type: str = "flat"
36
+ oil_price: float = 70.0 # $/bbl
37
+ gas_price: float = 3.50 # $/MMBtu
38
+
39
+ # Differentials off the deck: realized = deck - diff. Positive = below the deck.
40
+ oil_diff: float = 0.0 # $/bbl
41
+ gas_diff: float = 0.0 # $/MMBtu
42
+
43
+ # Gas heat content, MMBtu per mcf. Volumes are mcf; the deck is $/MMBtu, so
44
+ # gas revenue is mcf x btu x ($/MMBtu). NGL uplift and shrink are not modeled.
45
+ gas_btu_factor: float = 1.05
46
+
47
+ # Taxes / deductions.
48
+ tax_pct: float = 0.075 # severance / production tax
49
+ gpt_pct: float = 0.05 # gathering, processing, transport
50
+
51
+ # Costs (working-interest deals only; minerals bear none).
52
+ opex_per_bbl_usd: float = 0.0 # per GROSS oil bbl, borne at WI share
53
+ opex_per_well_per_month_usd: float = 0.0 # per well-month while online, borne at WI share
54
+ capex_per_well_usd: float = 0.0 # gross 100% drill+complete cost of ONE well; x WI by the engine
55
+
56
+ # Cashflow horizon, months.
57
+ horizon_months: int = 360
58
+
59
+ # Terminal decline: once a curve's hyperbolic decline shallows to this
60
+ # annual rate it switches to an exponential tail at this rate. The
61
+ # calculator's tail policy, not a per-well assertion.
62
+ terminal_di_annual: float = 0.05
63
+
64
+ # Annual discount rate per bucket. Each bucket is discounted at its own rate.
65
+ discount_rates: dict = field(default_factory=_default_rates)
66
+
67
+ # Dotted TOML keys the loaded file set explicitly (empty for pure defaults).
68
+ explicit: frozenset = frozenset()
69
+
70
+
71
+ DEFAULTS = Assumptions()
72
+
73
+ # TOML key -> (table, key, Assumptions attribute)
74
+ _KEYS = {
75
+ ("price_deck", "type"): "price_deck_type",
76
+ ("price_deck", "oil_usd_bbl"): "oil_price",
77
+ ("price_deck", "gas_usd_mmbtu"): "gas_price",
78
+ ("economics", "oil_diff"): "oil_diff",
79
+ ("economics", "gas_diff"): "gas_diff",
80
+ ("economics", "gas_btu_factor"): "gas_btu_factor",
81
+ ("economics", "tax_pct"): "tax_pct",
82
+ ("economics", "gpt_pct"): "gpt_pct",
83
+ ("economics", "opex_per_bbl_usd"): "opex_per_bbl_usd",
84
+ ("economics", "opex_per_well_per_month_usd"): "opex_per_well_per_month_usd",
85
+ ("economics", "capex_per_well_usd"): "capex_per_well_usd",
86
+ ("economics", "horizon_months"): "horizon_months",
87
+ ("economics", "terminal_di_annual"): "terminal_di_annual",
88
+ }
89
+ _TABLES = {"price_deck", "economics", "discount_rates"}
90
+
91
+
92
+ def _is_number(v) -> bool:
93
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
94
+
95
+
96
+ def _check(problems: list[str], label: str, v, kind: str) -> None:
97
+ """Append a problem unless ``v`` satisfies ``kind``."""
98
+ ok, want = True, ""
99
+ if kind == "money":
100
+ ok, want = _is_number(v) and v >= 0, "a non-negative number"
101
+ elif kind == "signed":
102
+ ok, want = _is_number(v), "a number"
103
+ elif kind == "fraction_lt1":
104
+ ok, want = _is_number(v) and 0.0 <= v < 1.0, "a number in [0, 1)"
105
+ elif kind == "open_unit":
106
+ ok, want = _is_number(v) and 0.0 < v < 1.0, "a number in (0, 1)"
107
+ elif kind == "btu":
108
+ ok, want = _is_number(v) and 0.5 <= v <= 2.0, "a number in [0.5, 2.0] (MMBtu per mcf)"
109
+ elif kind == "horizon":
110
+ ok, want = isinstance(v, int) and not isinstance(v, bool) and 1 <= v <= 600, "an integer 1-600 (months)"
111
+ if not ok:
112
+ problems.append(f"{label} must be {want}, got {v!r}")
113
+
114
+
115
+ _KIND = {
116
+ "oil_price": "money", "gas_price": "money", "oil_diff": "signed", "gas_diff": "signed",
117
+ "gas_btu_factor": "btu", "tax_pct": "fraction_lt1", "gpt_pct": "fraction_lt1",
118
+ "opex_per_bbl_usd": "money", "opex_per_well_per_month_usd": "money",
119
+ "capex_per_well_usd": "money", "horizon_months": "horizon",
120
+ "terminal_di_annual": "open_unit",
121
+ }
122
+
123
+
124
+ def parse_assumptions(data: dict) -> Assumptions:
125
+ """A parsed TOML document -> validated Assumptions. Raises AssumptionsError
126
+ listing every problem."""
127
+ problems: list[str] = []
128
+ if not isinstance(data, dict):
129
+ raise AssumptionsError(["the assumptions file must be a TOML document"])
130
+
131
+ for table in data:
132
+ if table not in _TABLES:
133
+ problems.append(f"unknown table [{table}]; allowed: {sorted(_TABLES)}")
134
+
135
+ values: dict = {}
136
+ explicit: set[str] = set()
137
+
138
+ for table in ("price_deck", "economics"):
139
+ body = data.get(table)
140
+ if body is None:
141
+ continue
142
+ if not isinstance(body, dict):
143
+ problems.append(f"[{table}] must be a table")
144
+ continue
145
+ allowed = sorted(k for (t, k) in _KEYS if t == table)
146
+ for key, v in body.items():
147
+ attr = _KEYS.get((table, key))
148
+ if attr is None:
149
+ problems.append(f"[{table}] has unknown key {key!r}; allowed: {allowed}")
150
+ continue
151
+ explicit.add(f"{table}.{key}")
152
+ if attr == "price_deck_type":
153
+ if v == "strip":
154
+ problems.append(
155
+ "price_deck.type 'strip' is not supported in this version (a strip needs "
156
+ "futures data the kit does not have); use 'flat' with oil_usd_bbl / gas_usd_mmbtu")
157
+ elif v != "flat":
158
+ problems.append(f"price_deck.type must be 'flat', got {v!r}")
159
+ else:
160
+ values[attr] = v
161
+ continue
162
+ before = len(problems)
163
+ _check(problems, f"{table}.{key}", v, _KIND[attr])
164
+ if len(problems) == before:
165
+ values[attr] = int(v) if attr == "horizon_months" else float(v)
166
+
167
+ rates = data.get("discount_rates")
168
+ if rates is not None:
169
+ if not isinstance(rates, dict):
170
+ problems.append("[discount_rates] must be a table keyed by bucket (PDP, DUC, PUD)")
171
+ else:
172
+ merged = _default_rates()
173
+ for code, rate in rates.items():
174
+ if code not in BUCKETS:
175
+ problems.append(f"[discount_rates] has unknown bucket {code!r}; allowed: {list(BUCKETS)}")
176
+ continue
177
+ explicit.add(f"discount_rates.{code}")
178
+ before = len(problems)
179
+ _check(problems, f"discount_rates.{code}", rate, "open_unit")
180
+ if len(problems) == before:
181
+ merged[code] = float(rate)
182
+ values["discount_rates"] = merged
183
+
184
+ if problems:
185
+ raise AssumptionsError(problems)
186
+ return Assumptions(**values, explicit=frozenset(explicit))
187
+
188
+
189
+ def load_assumptions(path: str | Path) -> Assumptions:
190
+ """Read and validate an assumptions TOML file."""
191
+ p = Path(path)
192
+ try:
193
+ text = p.read_text(encoding="utf-8")
194
+ except OSError as e:
195
+ raise AssumptionsError([f"cannot read assumptions file {str(p)!r}: {e.strerror or e}"]) from e
196
+ return assumptions_from_text(text, source=str(p))
197
+
198
+
199
+ def assumptions_from_text(text: str, *, source: str = "assumptions") -> Assumptions:
200
+ try:
201
+ data = tomllib.loads(text)
202
+ except tomllib.TOMLDecodeError as e:
203
+ raise AssumptionsError([f"{source} is not valid TOML: {e}"]) from e
204
+ return parse_assumptions(data)
205
+
206
+
207
+ def describe(a: Assumptions) -> list[tuple[str, str, object, str]]:
208
+ """The assumptions as ``(section, name, value, source)`` rows for printing
209
+ and for the README, ``source`` being 'file' or 'default'."""
210
+ def src(dotted: str) -> str:
211
+ return "file" if dotted in a.explicit else "default"
212
+
213
+ rows: list[tuple[str, str, object, str]] = [
214
+ ("price_deck", "type", a.price_deck_type, src("price_deck.type")),
215
+ ("price_deck", "oil_usd_bbl", a.oil_price, src("price_deck.oil_usd_bbl")),
216
+ ("price_deck", "gas_usd_mmbtu", a.gas_price, src("price_deck.gas_usd_mmbtu")),
217
+ ]
218
+ for (table, key), attr in _KEYS.items():
219
+ if table == "economics":
220
+ rows.append((table, key, getattr(a, attr), src(f"{table}.{key}")))
221
+ for code in BUCKETS:
222
+ rows.append(("discount_rates", code, a.discount_rates[code], src(f"discount_rates.{code}")))
223
+ return rows
@@ -0,0 +1,106 @@
1
+ """Consequence math for the forecast check. Pure: no files, no network.
2
+
3
+ The check speaks in future volumes, never in fit quality (there is no fit).
4
+ The functions here turn an asserted curve into the numbers the sanity loop
5
+ interrogates: implied next-12 / next-24 cum against trailing actuals,
6
+ effective annual decline at years 1 and 5, EUR (cum-to-date plus forecast
7
+ remainder), EUR/ft, and where the terminal switch lands.
8
+
9
+ Conventions (shared with the economics schedule):
10
+
11
+ - t is months since the anchor; q(0) == qi, the anchor-month rate.
12
+ - For a producing well the anchor month itself is history: forecast volumes
13
+ are t = 1..N, so next-12 means sum q(1..12).
14
+ - For a not-yet-producing well the anchor IS the asserted online month: the
15
+ online month produces q(0), volumes are t = 0..N-1, matching where the
16
+ economics schedule places it.
17
+ - EUR = recorded cum through the anchor + the forecast remainder over the
18
+ horizon. When the anchor sits before the last reported month (contaminated
19
+ recent data), actuals after the anchor are REPLACED by the forecast, never
20
+ double-counted.
21
+ - The check runs on calendar months from the anchor; the economics schedule
22
+ starts at the valuation origin. When an anchor trails the origin the two views
23
+ differ by construction: the check answers "what does this curve say from
24
+ where it starts", not "what lands in this deal's month 1". A DUC anchored
25
+ +36 months also sees its economics contribution truncated at the deal
26
+ horizon while the check EUR is curve-life; that asymmetry is intentional.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from datetime import date
31
+
32
+ import numpy as np
33
+ from dateutil.relativedelta import relativedelta
34
+
35
+ from crudecode.engine.curves import DeclineCurve, curve_rate
36
+
37
+
38
+ def effective_annual_decline(curve: DeclineCurve, *, year: int) -> float | None:
39
+ """Effective (not nominal) annual decline over forecast year ``year``:
40
+ ``(q_start - q_end) / q_start`` across that year's 12 months. ``None``
41
+ when the starting rate is zero (a zero curve has no decline)."""
42
+ if year < 1:
43
+ raise ValueError(f"year must be >= 1; got {year}")
44
+ q_start = curve_rate(curve, float(12 * (year - 1)))
45
+ q_end = curve_rate(curve, float(12 * year))
46
+ if q_start <= 0.0:
47
+ return None
48
+ return (q_start - q_end) / q_start
49
+
50
+
51
+ def stream_consequences(
52
+ curve: DeclineCurve,
53
+ *,
54
+ anchor: date,
55
+ horizon_months: int,
56
+ trailing_12_actual: float | None,
57
+ cum_to_date: float,
58
+ lateral_ft: float | None,
59
+ anchor_is_future: bool = False,
60
+ ) -> dict:
61
+ """The check's numbers for one stream, per the module conventions.
62
+
63
+ ``anchor_is_future`` marks a not-yet-producing well: its forecast window
64
+ starts at t=0 (the online month) instead of t=1, and there are no trailing
65
+ actuals to compare against.
66
+ """
67
+ if horizon_months <= 0:
68
+ raise ValueError(f"horizon_months must be positive; got {horizon_months}")
69
+ anchor = anchor.replace(day=1)
70
+ t0 = 0 if anchor_is_future else 1
71
+ t = np.arange(t0, t0 + horizon_months, dtype=float)
72
+ rates = np.asarray(curve_rate(curve, t), dtype=float)
73
+
74
+ next_12 = float(rates[:12].sum()) if horizon_months >= 12 else None
75
+ next_24 = float(rates[:24].sum()) if horizon_months >= 24 else None
76
+ remaining = float(rates.sum())
77
+ eur = cum_to_date + remaining
78
+
79
+ ratio = None
80
+ if next_12 is not None and trailing_12_actual is not None and trailing_12_actual > 0.0:
81
+ ratio = next_12 / trailing_12_actual
82
+
83
+ yr1 = effective_annual_decline(curve, year=1)
84
+ yr5 = effective_annual_decline(curve, year=5)
85
+
86
+ switch = curve.switch_month_from_peak
87
+ if np.isfinite(switch):
88
+ switch_date = anchor + relativedelta(months=int(round(switch)))
89
+ terminal = {"months_from_anchor": round(float(switch), 1),
90
+ "date": switch_date.strftime("%Y-%m")}
91
+ else:
92
+ terminal = {"months_from_anchor": None, "date": None}
93
+
94
+ return {
95
+ "next_12_cum": None if next_12 is None else round(next_12, 1),
96
+ "next_24_cum": None if next_24 is None else round(next_24, 1),
97
+ "trailing_12_actual": None if trailing_12_actual is None else round(trailing_12_actual, 1),
98
+ "next12_over_trailing12": None if ratio is None else round(ratio, 3),
99
+ "eff_annual_decline_yr1": None if yr1 is None else round(yr1, 4),
100
+ "eff_annual_decline_yr5": None if yr5 is None else round(yr5, 4),
101
+ "eur": round(eur, 1),
102
+ "cum_to_date": round(cum_to_date, 1),
103
+ "eur_remaining": round(remaining, 1),
104
+ "eur_per_ft": None if not lateral_ft else round(eur / float(lateral_ft), 2),
105
+ "terminal_switch": terminal,
106
+ }