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 +103 -0
- crudecode/__init__.py +4 -0
- crudecode/__main__.py +3 -0
- crudecode/cli.py +151 -0
- crudecode/engine/__init__.py +3 -0
- crudecode/engine/assumptions.py +223 -0
- crudecode/engine/consequences.py +106 -0
- crudecode/engine/curves.py +88 -0
- crudecode/engine/deal.py +447 -0
- crudecode/engine/economics.py +290 -0
- crudecode/forecasting/GUIDE.md +706 -0
- crudecode/forecasting/__init__.py +2 -0
- crudecode/forecasting/check.py +154 -0
- crudecode/valuation/GUIDE.md +334 -0
- crudecode/valuation/__init__.py +2 -0
- crudecode/valuation/assumptions.default.toml +37 -0
- crudecode/valuation/examples/assumptions.toml +24 -0
- crudecode/valuation/examples/deal.json +62 -0
- crudecode/valuation/examples/output/README.md +72 -0
- crudecode/valuation/examples/output/parameters.csv +9 -0
- crudecode/valuation/examples/output/summary.txt +12 -0
- crudecode/valuation/examples/output/wells_monthly.csv +1441 -0
- crudecode/valuation/outputs.py +233 -0
- crudecode/valuation/run.py +72 -0
- crudecode/valuation/schema.json +117 -0
- crudecode-0.1.0a1.dist-info/METADATA +43 -0
- crudecode-0.1.0a1.dist-info/RECORD +30 -0
- crudecode-0.1.0a1.dist-info/WHEEL +4 -0
- crudecode-0.1.0a1.dist-info/entry_points.txt +2 -0
- crudecode-0.1.0a1.dist-info/licenses/LICENSE +201 -0
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
crudecode/__main__.py
ADDED
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,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
|
+
}
|