bolts 0.2.1__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.
Files changed (151) hide show
  1. bolts/__init__.py +22 -0
  2. bolts/__main__.py +8 -0
  3. bolts/_version.py +5 -0
  4. bolts/api.py +76 -0
  5. bolts/cli.py +168 -0
  6. bolts/config/__init__.py +2 -0
  7. bolts/config/defaults/bu.toml +6 -0
  8. bolts/config/defaults/cable.toml +18 -0
  9. bolts/config/defaults/earthing.toml +10 -0
  10. bolts/config/defaults/forcing.toml +10 -0
  11. bolts/config/defaults/lightning.toml +5 -0
  12. bolts/config/defaults/optical.toml +19 -0
  13. bolts/config/defaults/pfe.toml +21 -0
  14. bolts/config/defaults/repeater.toml +14 -0
  15. bolts/config/defaults/solver.toml +3 -0
  16. bolts/config/loader.py +76 -0
  17. bolts/config/overrides.py +23 -0
  18. bolts/config/provenance.py +56 -0
  19. bolts/config/schema.py +78 -0
  20. bolts/config/validate.py +28 -0
  21. bolts/constants.py +6 -0
  22. bolts/damage/__init__.py +2 -0
  23. bolts/damage/arrester_energy.py +11 -0
  24. bolts/damage/criteria.py +11 -0
  25. bolts/damage/insulation_withstand.py +29 -0
  26. bolts/damage/thermal_i2t.py +11 -0
  27. bolts/earthing/__init__.py +2 -0
  28. bolts/earthing/electrode_resistance.py +38 -0
  29. bolts/earthing/gpr.py +31 -0
  30. bolts/earthing/impulse_impedance.py +11 -0
  31. bolts/earthing/soil.py +17 -0
  32. bolts/elements/__init__.py +42 -0
  33. bolts/elements/arrester.py +16 -0
  34. bolts/elements/base.py +24 -0
  35. bolts/elements/beach_joint.py +29 -0
  36. bolts/elements/branching_unit.py +19 -0
  37. bolts/elements/cable_segment.py +41 -0
  38. bolts/elements/coil.py +25 -0
  39. bolts/elements/diode_chain.py +79 -0
  40. bolts/elements/earth_electrode.py +25 -0
  41. bolts/elements/fit_zener.py +11 -0
  42. bolts/elements/land_cable.py +12 -0
  43. bolts/elements/lumped.py +72 -0
  44. bolts/elements/pfe.py +31 -0
  45. bolts/elements/pfe_converter_bank.py +34 -0
  46. bolts/elements/pfe_filter.py +37 -0
  47. bolts/elements/pfe_regulator.py +54 -0
  48. bolts/elements/pump_laser.py +45 -0
  49. bolts/elements/relay.py +19 -0
  50. bolts/elements/repeater.py +34 -0
  51. bolts/elements/shunt_fault.py +19 -0
  52. bolts/elements/zener.py +101 -0
  53. bolts/errors.py +32 -0
  54. bolts/impact/__init__.py +2 -0
  55. bolts/impact/optical.py +120 -0
  56. bolts/impact/terminal.py +114 -0
  57. bolts/io/__init__.py +2 -0
  58. bolts/io/manifest.py +24 -0
  59. bolts/io/readers.py +22 -0
  60. bolts/io/writers.py +42 -0
  61. bolts/lightning/__init__.py +2 -0
  62. bolts/lightning/coupling.py +11 -0
  63. bolts/lightning/monte_carlo.py +11 -0
  64. bolts/lightning/protection_stage.py +11 -0
  65. bolts/lightning/statistics.py +11 -0
  66. bolts/lightning/stroke_parameters.py +11 -0
  67. bolts/lines/__init__.py +2 -0
  68. bolts/lines/bergeron.py +13 -0
  69. bolts/lines/lumped_ladder.py +19 -0
  70. bolts/lines/sectioning.py +17 -0
  71. bolts/lines/telegrapher.py +29 -0
  72. bolts/metrics/__init__.py +2 -0
  73. bolts/metrics/current_dip.py +22 -0
  74. bolts/metrics/device_stress.py +22 -0
  75. bolts/metrics/energy.py +11 -0
  76. bolts/metrics/headroom.py +20 -0
  77. bolts/metrics/pfe_stress.py +48 -0
  78. bolts/metrics/voltage_extremes.py +20 -0
  79. bolts/mna/__init__.py +2 -0
  80. bolts/mna/assemble.py +107 -0
  81. bolts/mna/dc_op.py +19 -0
  82. bolts/mna/integrators.py +31 -0
  83. bolts/mna/linear_solve.py +18 -0
  84. bolts/mna/newton.py +110 -0
  85. bolts/mna/stamp.py +158 -0
  86. bolts/mna/timestep.py +21 -0
  87. bolts/mna/transient.py +193 -0
  88. bolts/mna/window_run.py +15 -0
  89. bolts/network/__init__.py +2 -0
  90. bolts/network/builder.py +20 -0
  91. bolts/network/graph.py +43 -0
  92. bolts/network/power_modes.py +30 -0
  93. bolts/network/segments.py +11 -0
  94. bolts/network/switch_states.py +12 -0
  95. bolts/network/topology_p2p.py +95 -0
  96. bolts/network/topology_trunk_branch.py +11 -0
  97. bolts/network/validate.py +36 -0
  98. bolts/plotting/__init__.py +2 -0
  99. bolts/plotting/network.py +52 -0
  100. bolts/plotting/style.py +79 -0
  101. bolts/plotting/timeseries.py +54 -0
  102. bolts/properties/__init__.py +19 -0
  103. bolts/properties/base.py +26 -0
  104. bolts/properties/catalog.py +18 -0
  105. bolts/properties/constant.py +29 -0
  106. bolts/properties/depth_profile.py +44 -0
  107. bolts/properties/factory.py +52 -0
  108. bolts/properties/freq_dependent.py +18 -0
  109. bolts/properties/section_table.py +73 -0
  110. bolts/properties/temperature_R.py +31 -0
  111. bolts/py.typed +0 -0
  112. bolts/registry.py +64 -0
  113. bolts/result.py +144 -0
  114. bolts/space_weather/__init__.py +2 -0
  115. bolts/space_weather/from_efield.py +13 -0
  116. bolts/space_weather/gmd_source.py +28 -0
  117. bolts/space_weather/synthetic.py +74 -0
  118. bolts/spice/__init__.py +2 -0
  119. bolts/spice/compare.py +14 -0
  120. bolts/spice/netlist.py +95 -0
  121. bolts/spice/run_ngspice.py +39 -0
  122. bolts/studies/__init__.py +2 -0
  123. bolts/studies/map2d.py +19 -0
  124. bolts/studies/sensitivity.py +22 -0
  125. bolts/studies/sweep.py +17 -0
  126. bolts/units.py +28 -0
  127. bolts/validation/__init__.py +11 -0
  128. bolts/validation/_case.py +20 -0
  129. bolts/validation/diode_clamp.py +38 -0
  130. bolts/validation/energy_balance.py +32 -0
  131. bolts/validation/line_step.py +31 -0
  132. bolts/validation/rc_step.py +41 -0
  133. bolts/validation/rlc_ring.py +30 -0
  134. bolts/validation/zener_dc.py +29 -0
  135. bolts/validation/zener_knee.py +39 -0
  136. bolts/waveforms/__init__.py +16 -0
  137. bolts/waveforms/base.py +64 -0
  138. bolts/waveforms/combine.py +25 -0
  139. bolts/waveforms/csv_record.py +57 -0
  140. bolts/waveforms/double_exp.py +24 -0
  141. bolts/waveforms/heidler.py +21 -0
  142. bolts/waveforms/iec62305.py +13 -0
  143. bolts/waveforms/step_ramp_sine.py +37 -0
  144. bolts/waveforms/window.py +23 -0
  145. bolts-0.2.1.dist-info/METADATA +154 -0
  146. bolts-0.2.1.dist-info/RECORD +151 -0
  147. bolts-0.2.1.dist-info/WHEEL +5 -0
  148. bolts-0.2.1.dist-info/entry_points.txt +2 -0
  149. bolts-0.2.1.dist-info/licenses/COPYING +674 -0
  150. bolts-0.2.1.dist-info/licenses/LICENSE +165 -0
  151. bolts-0.2.1.dist-info/top_level.txt +1 -0
bolts/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """BOLTS: Branching-unit and Optical-repeater power-feed Loop Transient Simulator.
4
+
5
+ Stress and vulnerability of submarine power-feed systems (PFE, cable, repeaters, branching units) under geomagnetic disturbances
6
+ (GMD / space weather), lightning and switching surges, down to loss of signal, failures and traffic loss.
7
+ """
8
+ from . import api as _api
9
+ from ._version import __version__
10
+ from .api import (
11
+ Result,
12
+ assess,
13
+ build_network,
14
+ export_spice,
15
+ load_parameters,
16
+ load_waveform,
17
+ register_element,
18
+ register_waveform,
19
+ run_transient,
20
+ )
21
+
22
+ __all__ = ["__version__", *_api.__all__]
bolts/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """``python -m bolts`` is the same as the ``bolts`` command."""
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ sys.exit(main())
bolts/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """Single source of the package version (read by ``pyproject.toml``)."""
4
+
5
+ __version__ = "0.2.1"
bolts/api.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """The public API: a few functions that cover a whole study. Everything here is also re-exported from ``bolts``.
4
+
5
+ >>> import bolts
6
+ >>> params = bolts.load_parameters() # shipped defaults (every value with unit, status and source)
7
+ >>> record = bolts.load_waveform("data/example_vind_1s.csv", params=params)
8
+ >>> net = bolts.build_network(params, record)
9
+ >>> result = bolts.run_transient(net, params)
10
+ >>> summary = bolts.assess(result)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+
16
+ import numpy as np
17
+
18
+ from .config.loader import load_parameters
19
+ from .config.schema import ParameterSet
20
+ from .mna.timestep import fixed_axis
21
+ from .mna.transient import run_transient as _run
22
+ from .network.builder import build_network
23
+ from .network.graph import Network
24
+ from .registry import register_element, register_waveform
25
+ from .result import Result
26
+ from .waveforms.base import Record
27
+ from .waveforms.csv_record import read_csv_record, record_from_parameters
28
+
29
+
30
+ def load_waveform(path=None, params: ParameterSet | None = None, **kwargs) -> Record:
31
+ """Read a voltage record. With ``params`` the columns, time format, window, scale and step come from its ``[forcing]`` section
32
+ (``path`` overrides the file); without it pass ``time_column``, ``value_column`` ... as keywords."""
33
+ if params is not None:
34
+ return record_from_parameters(params, path)
35
+ return read_csv_record(path, **kwargs)
36
+
37
+
38
+ def run_transient(network: Network, params: ParameterSet, dt_s: float | None = None, t_s: np.ndarray | None = None, newton: bool | None = None) -> Result:
39
+ """Run a built network. The time axis is ``t_s`` if given, else the record's duration at ``dt_s`` (default: the record's own spacing,
40
+ or ``solver.dt_s`` without a record). The regulator is exact for any dt / tau. A network with nonlinear elements is solved by Newton iteration
41
+ automatically; ``newton=True`` forces that path for a linear network (a check that both paths agree)."""
42
+ rec = network.meta.get("record")
43
+ # the time axis comes from the record unless the caller gives one (needed for a network without a record)
44
+ if t_s is None:
45
+ if rec is None:
46
+ dt = float(dt_s or params.get("solver", "dt_s"))
47
+ raise ValueError(f"no record in the network: pass t_s (for example a {dt:g} s axis) or build the network with a record")
48
+ dt = float(dt_s or rec.dt_s)
49
+ t_s = fixed_axis(rec.duration_s, dt, rec.t_s[0])
50
+ system = network.compile() # stamp the elements and assemble the matrices
51
+ res = _run(system, t_s, network.controller, str(params.get("solver", "integrator")), newton)
52
+ return Result(network, res, params)
53
+
54
+
55
+ def assess(result: Result, targets=(100, 200, 250)) -> dict:
56
+ """Electrical, optical and traffic metrics of a run; ``targets`` are the terminal rates (Gbit/s) to evaluate."""
57
+ return result.assess(targets)
58
+
59
+
60
+ def export_spice(result_or_network, path, params: ParameterSet | None = None) -> pathlib.Path:
61
+ """Write an ngspice netlist of the circuit, for an independent cross-check. Imported lazily: the SPICE code is optional."""
62
+ from .spice.netlist import write_netlist
63
+ return write_netlist(result_or_network, path)
64
+
65
+
66
+ __all__ = [
67
+ "Result",
68
+ "assess",
69
+ "build_network",
70
+ "export_spice",
71
+ "load_parameters",
72
+ "load_waveform",
73
+ "register_element",
74
+ "register_waveform",
75
+ "run_transient",
76
+ ]
bolts/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """Command line: ``bolts run | params | validate | spice | doctor``."""
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .api import assess, build_network, load_parameters, load_waveform, run_transient
11
+ from .config.overrides import parse_overrides
12
+ from .errors import BoltsError
13
+
14
+
15
+ def _add_input_args(sp: argparse.ArgumentParser) -> None:
16
+ """Options shared by every command that reads a voltage record (``run`` and ``spice``)."""
17
+ sp.add_argument("--params", help="parameter TOML merged over the shipped defaults")
18
+ sp.add_argument("--set", action="append", metavar="section.key=value", help="override any parameter (repeatable)")
19
+ sp.add_argument("--csv", help="voltage CSV (overrides forcing.csv_path)")
20
+ sp.add_argument("--start", type=float, help="window start, s from the first row")
21
+ sp.add_argument("--end", type=float, help="window end, s from the first row (negative = end of file)")
22
+ sp.add_argument("--scale", type=float, help="multiply the voltages")
23
+ sp.add_argument("--dt", type=float, help="time step of the run, s (default: the record's spacing)")
24
+
25
+
26
+ def _params(a) -> object:
27
+ """Parameters from the defaults, the optional file, ``--set`` items and the window / scale flags (flags win over ``--set``)."""
28
+ ov = parse_overrides(a.set)
29
+ for key, val in (("forcing.window_start_s", a.start), ("forcing.window_end_s", a.end), ("forcing.scale", a.scale)):
30
+ if val is not None:
31
+ ov[key] = val
32
+ return load_parameters(a.params, ov or None)
33
+
34
+
35
+ def _cmd_run(a) -> int:
36
+ """``bolts run``: record -> network -> transient -> assessment, printed and saved."""
37
+ p = _params(a)
38
+ rec = load_waveform(a.csv, params=p)
39
+ net = build_network(p, rec)
40
+ res = run_transient(net, p, dt_s=a.dt)
41
+ s = assess(res)
42
+ print(f"{rec.label}: {s['samples']} samples at {s['dt_s']:g} s = {s['duration_s']:.0f} s; V_ind {s['v_in_min_V']:.1f} to {s['v_in_max_V']:.1f} V")
43
+ print(f"PFE total {s['v_pfe_min_V']:.0f} to {s['v_pfe_max_V']:.0f} V (static need {s['static_need_V']:.0f} V, limit {s['v_tot_max_V']:.0f} V); "
44
+ f"clamped {100 * s['frac_pfe_clamped']:.2f}% of the time")
45
+ print(f"repeater current change {s['di_min_mA']:.1f} to {s['di_max_mA']:+.1f} mA (margin {s['margin_mA']:.0f} mA; below it "
46
+ f"{100 * s['frac_time_below_margin']:.2f}% of the time); an instantaneous jump of the largest sample step would reach {s['jump_bound_mA']:.0f} mA")
47
+ print(f"Q margin down to {s['min_margin_dB']:.2f} dB; outage {100 * s['frac_time_outage']:.2f}% of the time; largest conductor-to-sea voltage {s['v_cs_peak_V']:.0f} V")
48
+ for tg in (100, 200, 250): # one line per terminal target rate: delivered share when adaptive vs fixed-format
49
+ k = f"adaptive_{tg}G_mean_rate_fraction"
50
+ if k in s:
51
+ print(f" {tg}G target: adaptive {100 * s[k]:.1f}% delivered ({s[f'adaptive_{tg}G_ses']} SES), fixed {100 * s[f'fixed_{tg}G_mean_rate_fraction']:.1f}%")
52
+ out = res.save(a.out, extra_manifest={"command": "run", "csv": str(a.csv or p.get('forcing', 'csv_path'))})
53
+ print(f"wrote series.csv, summary.json, manifest.json, node_peaks_V.csv in {out}")
54
+ if a.plot:
55
+ from .plotting.timeseries import plot_result
56
+ print("wrote", plot_result(res, out / "run.png"))
57
+ return 0
58
+
59
+
60
+ def _cmd_params(a) -> int:
61
+ """``bolts params``: list parameters with their status; filter by text or by status (``--status unknown`` shows what is missing)."""
62
+ p = load_parameters(a.params, parse_overrides(a.set) or None)
63
+ rows = [q for q in p.params() if (not a.filter or a.filter in q.key) and (not a.status or q.status.value == a.status)]
64
+ print(f"{'key':44s} {'value':>14s} {'unit':10s} {'status':9s}")
65
+ for q in rows:
66
+ print(f"{q.key:44s} {str(q.value)[:14]:>14s} {q.unit:10s} {q.status.value:9s}")
67
+ print(f"\n{len(rows)} shown; counts {p.counts()}; fingerprint {p.fingerprint()}")
68
+ return 0
69
+
70
+
71
+ def _cmd_validate(a) -> int:
72
+ """``bolts validate``: run the analytic cases; the exit code is 1 if any fails (usable in CI)."""
73
+ from .validation import run_all
74
+ bad = 0
75
+ for c in run_all():
76
+ ok = "PASS" if c.passed else "FAIL"
77
+ bad += not c.passed
78
+ print(f"{c.name:16s} error {c.error:.2e} (tolerance {c.tolerance:.1e}) {ok} {c.detail}")
79
+ return 1 if bad else 0
80
+
81
+
82
+ def _cmd_spice(a) -> int:
83
+ """``bolts spice``: write the netlist; with ``--run`` also run ngspice and compare every node voltage."""
84
+ from .spice.compare import compare_voltages
85
+ from .spice.netlist import write_netlist
86
+ from .spice.run_ngspice import ngspice_path, run_ngspice
87
+ p = _params(a)
88
+ rec = load_waveform(a.csv, params=p)
89
+ res = run_transient(build_network(p, rec), p, dt_s=a.dt)
90
+ path = write_netlist(res, a.out)
91
+ print("wrote", path)
92
+ if a.run:
93
+ if ngspice_path() is None:
94
+ print("ngspice not found on PATH; netlist written only", file=sys.stderr)
95
+ return 1
96
+ v = run_ngspice(res.transient.system, res.transient.t_s, res.transient.v_tot_V)
97
+ c = compare_voltages(res.transient.nodes_V, v)
98
+ print(f"BOLTS against ngspice: max difference {c['max_abs_V']:.3e} V ({c['max_rel']:.2e} of {c['scale_V']:.0f} V), RMS {c['rms_V']:.3e} V")
99
+ return 0
100
+
101
+
102
+ def _cmd_doctor(a) -> int:
103
+ """``bolts doctor``: versions, optional tools found, and what is registered (first thing to run when something misbehaves)."""
104
+ import platform
105
+
106
+ import numpy
107
+ import scipy
108
+
109
+ from .registry import element_names, waveform_names
110
+ from .spice.run_ngspice import ngspice_path
111
+ print(f"bolts {__version__} python {platform.python_version()} numpy {numpy.__version__} scipy {scipy.__version__}")
112
+ print(f"ngspice: {ngspice_path() or 'not found (optional; needed only for the SPICE cross-check)'}")
113
+ for mod in ("matplotlib", "pandas"):
114
+ try:
115
+ __import__(mod)
116
+ print(f"{mod}: available")
117
+ except ImportError:
118
+ print(f"{mod}: not installed (optional extra)")
119
+ p = load_parameters()
120
+ print(f"parameters: {p.counts()} fingerprint {p.fingerprint()}")
121
+ print(f"elements: {', '.join(element_names())}")
122
+ print(f"waveforms: {', '.join(waveform_names()) or '(none registered yet)'}")
123
+ return 0
124
+
125
+
126
+ def build_parser() -> argparse.ArgumentParser:
127
+ """The argument parser (separate from :func:`main` so tests can inspect it)."""
128
+ ap = argparse.ArgumentParser(prog="bolts", description="BOLTS: Branching-unit and Optical-repeater power-feed Loop Transient Simulator.")
129
+ ap.add_argument("--version", action="version", version=f"bolts {__version__}")
130
+ sub = ap.add_subparsers(dest="cmd", required=True)
131
+ r = sub.add_parser("run", help="run a voltage record through the whole chain")
132
+ _add_input_args(r)
133
+ r.add_argument("--out", default="outputs/run", help="output directory")
134
+ r.add_argument("--plot", action="store_true", help="also write a figure (needs matplotlib)")
135
+ r.set_defaults(fn=_cmd_run)
136
+ q = sub.add_parser("params", help="list parameters with unit, status and source")
137
+ q.add_argument("--params")
138
+ q.add_argument("--set", action="append")
139
+ q.add_argument("--filter", help="only keys containing this text")
140
+ q.add_argument("--status", choices=["sourced", "assumed", "unknown", "override"])
141
+ q.set_defaults(fn=_cmd_params)
142
+ v = sub.add_parser("validate", help="run the analytic validation cases")
143
+ v.set_defaults(fn=_cmd_validate)
144
+ s = sub.add_parser("spice", help="export an ngspice netlist (and compare with ngspice)")
145
+ _add_input_args(s)
146
+ s.add_argument("--out", default="outputs/bolts.cir")
147
+ s.add_argument("--run", action="store_true", help="run ngspice and compare the node voltages")
148
+ s.set_defaults(fn=_cmd_spice)
149
+ d = sub.add_parser("doctor", help="check the installation")
150
+ d.set_defaults(fn=_cmd_doctor)
151
+ return ap
152
+
153
+
154
+ def main(argv: list[str] | None = None) -> int:
155
+ """Entry point (``bolts`` script). Returns 0 on success, 1 for failed validation, 2 for a user error such as a missing file."""
156
+ a = build_parser().parse_args(argv)
157
+ try:
158
+ return a.fn(a)
159
+ except BoltsError as e:
160
+ print(f"bolts: {e}", file=sys.stderr)
161
+ return 2
162
+ except FileNotFoundError as e:
163
+ print(f"bolts: {e}", file=sys.stderr)
164
+ return 2
165
+
166
+
167
+ if __name__ == "__main__":
168
+ raise SystemExit(main())
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
@@ -0,0 +1,6 @@
1
+ # Branching units: placeholders for v0.3. All unknown until an architecture and vendor data exist.
2
+ [bu]
3
+ sea_earth_resistance_ohm = { value = "TBD", unit = "ohm", status = "unknown", source = "BU sea-earth electrode; design data" }
4
+ relay_on_resistance_ohm = { value = "TBD", unit = "ohm", status = "unknown", source = "power-switching relay in the closed state" }
5
+ current_limiting_ohm = { value = "TBD", unit = "ohm", status = "unknown", source = "resistor bank used against hot switching (Chesnoy p. 395)" }
6
+ electrode_mass_kg = { value = 25.0, unit = "kg", status = "sourced", source = "Chesnoy p. 395: a copper BU sea-earth electrode has a mass of about 25 kg" }
@@ -0,0 +1,18 @@
1
+ # Cable and link. Every parameter: value, unit, status (sourced | assumed | unknown | override), source.
2
+ [cable]
3
+ length_km = { value = 750.8, unit = "km", status = "assumed", source = "Point Hope to Gambell link of the pfe_design study (route chainage 5601.2 to 6352.0 km); replace for another link" }
4
+ resistance_ohm_per_km = { value = 1.0, unit = "ohm/km", status = "sourced", source = "Chesnoy ch.13 (printed p. 479): typical linear ohmic resistance about 1 ohm/km" }
5
+ capacitance_uF_per_km = { value = 0.2, unit = "uF/km", status = "assumed", source = "placeholder, conductor to sea; vendor value unknown. Provider: bolts.properties (constant now, section table in v0.2)" }
6
+ leakage_S_per_km = { value = 0.0, unit = "S/km", status = "assumed", source = "placeholder (G' = 0); vendor value unknown. Provider: bolts.properties" }
7
+ inductance_mH_per_km = { value = "TBD", unit = "mH/km", status = "unknown", source = "needed for microsecond surges (v0.2); not used by the slow GMD runs. A few tenths of a henry over 750 km is the pfe_design estimate" }
8
+ emf_share_of_loop = { value = 1.0, status = "assumed", source = "fraction of the record's net end-to-end voltage applied along the cable (spread evenly over every span)" }
9
+ seabed_temperature_C = { value = 4.0, unit = "C", status = "assumed", source = "deep-sea water temperature (about 2 to 4 C); used only by the temperature-dependent provider (bolts.properties.temperature_R), which is opt-in. Replace with the route's measured seabed temperature" }
10
+ conductor_tempco_per_K = { value = 0.00393, unit = "1/K", status = "assumed", source = "textbook temperature coefficient of copper resistance at 20 C; check against the cable vendor's conductor datasheet" }
11
+ resistance_reference_temperature_C = { value = 20.0, unit = "C", status = "assumed", source = "temperature at which resistance_ohm_per_km is taken to apply when the temperature-dependent provider is used; the sourced 1 ohm/km does not state one" }
12
+ temperature_model = { value = "off", status = "assumed", source = "'off' keeps the constant resistance_ohm_per_km; 'copper' scales it by 1 + alpha (T - T_ref) at the seabed temperature (bolts.properties.temperature_R). Off by default so the pfe_design regression holds" }
13
+ section_table_csv = { value = "none", status = "assumed", source = "path to a per-section table (x_start_km, x_end_km, r_ohm_per_km, c_uF_per_km[, g_S_per_km, l_mH_per_km]) from the cable design; 'none' = uniform values above. data/example_cable_sections.csv is an illustration only" }
14
+ depth_profile_csv = { value = "none", status = "assumed", source = "path to the route's water depth (columns x_km, depth_m); 'none' = depth ignored. data/example_route_depth.csv is an illustration only" }
15
+ depth_temperature_csv = { value = "none", status = "assumed", source = "path to seabed temperature by depth (columns depth_m, temperature_C); 'none' = the constant seabed_temperature_C. data/example_depth_temperature.csv is an illustration only" }
16
+ insulation_exponent_n = { value = "TBD", status = "unknown", source = "exponent n of the insulation life law V^n t = constant (Chesnoy Eq. 10.1, from Dakin 1983); device-specific, unpublished. Sweep 10 to 40 in studies" }
17
+ design_life_years = { value = 25.0, unit = "years", status = "assumed", source = "typical design life of submerged plant; used with the nominal end voltage as the reference point of the life law. Confirm with the operator" }
18
+
@@ -0,0 +1,10 @@
1
+ [earthing]
2
+ landing_earth_resistance_ohm = { value = 3.0, unit = "ohm", status = "sourced", source = "Chesnoy Sec. 10.2.4 (p. 387): system earth electrodes typically below 3 ohm (upper bound used)" }
3
+ station_vs_system_earth_V_nominal = { value = 20.0, unit = "V", status = "sourced", source = "Chesnoy Sec. 10.2.4 (p. 387): recommended nominal difference between system earth and station earth, at most 20 V (reference)" }
4
+ station_vs_system_earth_V_lightning = { value = 90.0, unit = "V", status = "sourced", source = "Chesnoy Sec. 10.2.4 (p. 387): exceptional increase to 90 V in a lightning environment (reference)" }
5
+ soil_resistivity_ohm_m = { value = "TBD", unit = "ohm m", status = "unknown", source = "soil resistivity at the landing; measure with the Wenner method (bolts.earthing.soil.wenner_resistivity_ohm_m). With the four rod_* parameters it sets the landing earth resistance instead of the 3 ohm bound. Typical range 10 (wet) to 3000 ohm m (rock)" }
6
+ rod_count = { value = "TBD", status = "unknown", source = "number of buried rods in a line at each landing (1 to 4; Chesnoy Eq. 10.4 tabulates lambda for 2, 3 and 4); station design" }
7
+ rod_length_m = { value = "TBD", unit = "m", status = "unknown", source = "buried length b of each rod (Chesnoy Eq. 10.3); station design" }
8
+ rod_radius_m = { value = "TBD", unit = "m", status = "unknown", source = "radius r of each rod; station design" }
9
+ rod_spacing_m = { value = "TBD", unit = "m", status = "unknown", source = "spacing a between rods in the line (needed for n >= 2); station design" }
10
+
@@ -0,0 +1,10 @@
1
+ # Your own voltage record (net end-to-end induced voltage V_ind(t), volts; positive drives current toward increasing chainage).
2
+ [forcing]
3
+ csv_path = { value = "data/example_vind_1s.csv", status = "assumed", source = "path to the voltage CSV, relative to the current directory (or absolute). The shipped file is SYNTHETIC (storm backbone (peak about -200 V), random 2.5 to 600 s pulsations, one onset impulse; scripts/make_synthetic_vind.py); replace it with your 1-second or sub-second record" }
4
+ time_column = { value = "t_s", status = "assumed", source = "name of the time column" }
5
+ time_format = { value = "seconds", status = "assumed", source = "'seconds' (any origin; the run starts at 0) or 'iso' (ISO-8601 timestamps)" }
6
+ voltage_column = { value = "v_ind_V", status = "assumed", source = "name of the voltage column (volts)" }
7
+ scale = { value = 1.0, status = "assumed", source = "multiplies every voltage in the file" }
8
+ window_start_s = { value = 0.0, unit = "s", status = "assumed", source = "start of the window, seconds from the first row" }
9
+ window_end_s = { value = -1.0, unit = "s", status = "assumed", source = "end of the window, seconds from the first row; negative means the end of the file" }
10
+ model_dt_s = { value = 0.0, unit = "s", status = "assumed", source = "step of the run; 0 = the file's own spacing; otherwise resampled by linear interpolation" }
@@ -0,0 +1,5 @@
1
+ # Lightning: placeholders for v0.2 to v0.4. Standard values must be verified from IEC 62305 and CIGRE before use.
2
+ [lightning]
3
+ peak_current_median_kA = { value = "TBD", unit = "kA", status = "unknown", source = "median first-stroke peak current (log-normal statistics); verify against CIGRE / IEEE" }
4
+ ground_flash_density_per_km2_yr = { value = "TBD", unit = "1/(km2 yr)", status = "unknown", source = "site thunderstorm statistics (keraunic level)" }
5
+ rod_to_pfe_earth_separation_m = { value = "TBD", unit = "m", status = "unknown", source = "Chesnoy p. 390: the influence is roughly inversely proportional to the separation of the two earths" }
@@ -0,0 +1,19 @@
1
+ # Optical chain and terminal (ported from the pfe_design study; Chesnoy printed pages in the sources).
2
+ [optical]
3
+ laser_threshold_fraction = { value = 0.2, status = "assumed", source = "pump threshold as a fraction of the nominal laser current; vendor unknown (swept 0, 0.2, 0.4 in pfe_design)" }
4
+ noise_figure_dB = { value = 4.5, unit = "dB", status = "sourced", source = "Chesnoy Sec. 4.4.5 (printed p. 143): noise figure 4.5 dB in the worked example" }
5
+ output_power_per_channel_dBm = { value = -2.5, unit = "dBm", status = "assumed", source = "repeater output per channel; from the Chesnoy p. 143 example (does not reproduce with its own formula)" }
6
+ fiber_attenuation_dB_per_km = { value = 0.2, unit = "dB/km", status = "sourced", source = "Chesnoy Sec. 5.4.2.2 (printed p. 191)" }
7
+ extra_span_loss_dB = { value = 0.0, unit = "dB", status = "assumed", source = "splices, connectors, repairs (repairs 3 dB deep water, 0.5 dB shallow, p. 191)" }
8
+ osnr_constant_dB = { value = 58.0, unit = "dB", status = "sourced", source = "Chesnoy Eq. 5.23 (printed p. 189): OSNR = P_in - 10 log10 k - NF + 58 (P_in = repeater INPUT power per wavelength)" }
9
+ optical_bandwidth_GHz = { value = 12.5, unit = "GHz", status = "sourced", source = "Chesnoy Sec. 3.4.6 (printed p. 88): 0.1 nm = 12.5 GHz at 1550 nm" }
10
+ baud_rate_GBd = { value = 25.0, unit = "GBd", status = "sourced", source = "Chesnoy Fig. 5.25 (printed p. 190): theoretical 25 GBd PDM-QPSK transponder (the ITU table implies about 44 GBd)" }
11
+ channel_rate_Gbps = { value = 100.0, unit = "Gbit/s", status = "assumed", source = "reference channel for the Shannon-gap calibration" }
12
+ penalties_total_dB = { value = 3.5, unit = "dB", status = "sourced", source = "Chesnoy Table 5.1 (p. 199): 3.5 dB of impairments of the ITU-T G.977 example (10 000 km; conservative for a short link)" }
13
+ terminal_back_to_back_Q_dB = { value = 17.1, unit = "dB", status = "sourced", source = "Chesnoy Table 5.1 (p. 199) line 4; combined by Eq. 5.29 (p. 200)" }
14
+ q2_limit_dB = { value = 5.0, unit = "dB", status = "sourced", source = "Chesnoy Table 5.1 (p. 199) line 6 'Q limit compliance with ITU after FEC' = 5.0 dB (defined p. 201)" }
15
+ bits_per_symbol_ladder = { value = [2, 3, 4, 5, 6], status = "sourced", source = "Chesnoy Sec. 5.7.2 (printed pp. 225-227): QPSK 2, 8QAM 3, 16QAM 4, 32QAM 5, 64QAM 6 bits per symbol" }
16
+ relock_time_s = { value = 10.0, unit = "s", status = "assumed", source = "receiver relock after an outage; not given by Chesnoy (cycle slips corrupt symbols until the next pilot frame, p. 187); vendor. Swept 0 to 30 s in pfe_design" }
17
+ adaptation_delay_s = { value = 5.0, unit = "s", status = "assumed", source = "outage time before the terminal steps down a format; vendor unknown" }
18
+ upshift_hold_s = { value = 60.0, unit = "s", status = "assumed", source = "margin must hold this long before stepping back up" }
19
+ switch_margin_dB = { value = 0.5, unit = "dB", status = "assumed", source = "spare SNR required when stepping to another format" }
@@ -0,0 +1,21 @@
1
+ [pfe]
2
+ feed_current_A = { value = 1.0, unit = "A", status = "assumed", source = "inside the sourced 0.5 to 1.5 A repeater range (Chesnoy ch.12)" }
3
+ max_voltage_kV_per_end = { value = 2.5, unit = "kV", status = "assumed", source = "operator basis of pfe_design: 5 kV in total, 2.5 kV per end with the 50/50 split. Override for what-ifs (pfe_design also ran 0.75 kV per end = 1.5 kV total)" }
4
+ split_fraction_end_A = { value = 0.5, status = "sourced", source = "NEC / Chesnoy p. 392: each end normally feeds half of the system voltage, with opposite signs" }
5
+ feed_configuration = { value = "double_end", status = "sourced", source = "Chesnoy pp. 392-393: double-end feeding; single_end after one PFE fails" }
6
+ feed_direction = { value = 1, status = "assumed", source = "+1: feed current flows toward increasing chainage; an induced voltage along +chainage then assists the PFEs" }
7
+ regulator_bandwidth_Hz = { value = 200.0, unit = "Hz", status = "sourced", source = "Chesnoy Sec. 10.3.2 (p. 390): a PFE current controller typically operates on a control loop of around 200 Hz bandwidth (stated in a lightning discussion; no loop shape given)" }
8
+ regulator_response_s = { value = 0.005, unit = "s", status = "assumed", source = "tau = 1 / regulator_bandwidth_Hz = 5 ms (convention of the pfe_design study, no 2 pi); the 1/(2 pi 200 Hz) = 0.8 ms reading is a sensitivity. First-order loop, integrated exactly over each step" }
9
+ converter_modules = { value = "TBD", status = "unknown", source = "series converter modules per PFE (n+1 redundancy); a vendor catalogue shows 6 with 5 needed, Chesnoy p. 398: N:1 protected" }
10
+ failover_time_s = { value = "TBD", unit = "s", status = "unknown", source = "converter transfer time; a web claim of under 50 ms is unverified and is NOT used" }
11
+ output_filter_L_mH = { value = "TBD", unit = "mH", status = "unknown", source = "PFE output filter inductance (v0.2 surges); vendor" }
12
+ output_filter_C_uF = { value = "TBD", unit = "uF", status = "unknown", source = "PFE output filter capacitance (v0.2 surges); vendor" }
13
+ converter_module_rating_V = { value = "TBD", unit = "V", status = "unknown", source = "voltage rating of one series converter module (the PFE cap is about modules x rating); vendor. For studies the cap is max_voltage_kV_per_end" }
14
+ output_slew_limit_V_per_s = { value = "TBD", unit = "V/s", status = "unknown", source = "fastest rate at which the PFE can change its output (slow ramp-up/down, Chesnoy p. 397); vendor. Sweep 10 V/s to 1 kV/s in studies" }
15
+ overcurrent_trip_fraction = { value = "TBD", status = "unknown", source = "line current, as a multiple of the set current, above which the PFE shuts itself down (Chesnoy p. 397: 'a specified threshold'); vendor. Study 08 sweeps 1.2, 1.5 and 2.0" }
16
+ overcurrent_trip_delay_s = { value = "TBD", unit = "s", status = "unknown", source = "how long the current must stay above the trip level before the PFE shuts down; vendor. Study 08 uses 1 s" }
17
+ trip_hold_s = { value = "TBD", unit = "s", status = "unknown", source = "time from a trip to a restart attempt, including discharge of the cable (resistive, then short, Chesnoy p. 397); operator procedure. Study 08 uses 30 s" }
18
+ restart_ramp_V_per_s = { value = "TBD", unit = "V/s", status = "unknown", source = "voltage ramp-up rate of a restart; vendor. Study 08 uses 20 V/s" }
19
+ converter_spare_modules = { value = 1, status = "assumed", source = "N:1 protection (Chesnoy p. 397): one spare module per bank; a vendor catalogue shows 6 modules with 5 needed" }
20
+ converter_failed_modules = { value = 0, status = "assumed", source = "modules out of service in a what-if; 0 = healthy. Used only when converter_modules and converter_module_rating_V are given" }
21
+
@@ -0,0 +1,14 @@
1
+ [repeater]
2
+ spacing_km = { value = 60.0, unit = "km", status = "assumed", source = "typical spacing; Chesnoy examples use 50 to 70 km (p. 191)" }
3
+ voltage_drop_V = { value = 56.0, unit = "V", status = "sourced", source = "Chesnoy ch.12 (pp. 430-431): about 56 V for an 8 amp-pair unipolar repeater (Zener stack)" }
4
+ dynamic_resistance_ohm = { value = 5.0, unit = "ohm", status = "assumed", source = "placeholder slope of the Zener stack; vendor value unknown (pfe_design swept 0 to 60 ohm)" }
5
+ current_margin_fraction = { value = 0.10, status = "sourced", source = "Chesnoy Table 10.1 (p. 385): 10% electroding margin" }
6
+ pump_current_fraction = { value = 0.8, status = "sourced", source = "Chesnoy Table 10.1 (p. 385): laser current 80% of the line current" }
7
+ control_current_fraction = { value = 0.1, status = "sourced", source = "Chesnoy Table 10.1 (p. 385): control circuit 10%" }
8
+ zener_knee_current_A = { value = "TBD", unit = "A", status = "unknown", source = "v0.2 piecewise Zener: current where the stack starts to conduct; vendor datasheet" }
9
+ zener_power_rating_W = { value = "TBD", unit = "W", status = "unknown", source = "continuous power rating of the shunt stack (damage criterion); vendor" }
10
+ zener_surge_rating_A = { value = "TBD", unit = "A", status = "unknown", source = "8/20 us surge rating of the clamp; the book gives only the repeater bypass (200 A long pulses, 450 A at 15 kV short pulses, p. 392)" }
11
+ zener_temp_coeff_mV_per_K = { value = "TBD", unit = "mV/K", status = "unknown", source = "temperature coefficient of the stack voltage (v0.5 electro-thermal Zener); vendor" }
12
+ housing_capacitance_nF = { value = "TBD", unit = "nF", status = "unknown", source = "repeater housing to sea, the surge path to earth (v0.2); vendor" }
13
+ zener_below_knee_fraction = { value = "TBD", status = "unknown", source = "voltage across the repeater at zero current as a fraction of its value at the knee: 0 = the repeater circuit is resistive below the knee (the voltage falls to zero with the current), 1 = the voltage stays put (laser-diode-like). Used with zener_knee_current_A; if only the knee is given the model uses 0, the strongest effect. Study range 0 to 0.9" }
14
+
@@ -0,0 +1,3 @@
1
+ [solver]
2
+ dt_s = { value = 0.1, unit = "s", status = "assumed", source = "time step of the transient. The regulator is integrated exactly, so any dt / tau is accepted; a jump between two samples is a ramp over dt" }
3
+ integrator = { value = "backward_euler", status = "assumed", source = "backward_euler (stable for the stiff ladder); trapezoid is planned" }
bolts/config/loader.py ADDED
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """Read parameter files (TOML) and merge them over the shipped defaults."""
4
+ from __future__ import annotations
5
+
6
+ import pathlib
7
+ import tomllib
8
+ from typing import Any
9
+
10
+ from ..errors import ConfigError
11
+ from .provenance import TBD, Param, Status
12
+ from .schema import ParameterSet
13
+ from .validate import validate
14
+
15
+ DEFAULTS_DIR = pathlib.Path(__file__).with_name("defaults")
16
+
17
+
18
+ def _parse_table(section: str, name: str, raw: Any, from_user: bool) -> Param:
19
+ """Turn one TOML entry into a :class:`Param`. Shipped defaults must be complete tables (value, status, source) so that nothing enters
20
+ the package without provenance; user files may use a bare value, which becomes an ``override``."""
21
+ if isinstance(raw, dict):
22
+ if "value" not in raw:
23
+ raise ConfigError(f"{section}.{name}: a table needs a 'value'")
24
+ status = raw.get("status", "override" if from_user else None)
25
+ if status is None:
26
+ raise ConfigError(f"{section}.{name}: missing 'status'")
27
+ if "source" not in raw and not from_user:
28
+ raise ConfigError(f"{section}.{name}: missing 'source'")
29
+ try:
30
+ st = Status(status)
31
+ except ValueError as e:
32
+ raise ConfigError(f"{section}.{name}: status {status!r} not in {[s.value for s in Status]}") from e
33
+ return Param(section, name, raw["value"], raw.get("unit", ""), st, raw.get("source", "user file"))
34
+ if not from_user:
35
+ raise ConfigError(f"{section}.{name}: defaults must be tables with value, status and source")
36
+ return Param(section, name, raw, "", Status.OVERRIDE, "user file") # a bare value in a user file
37
+
38
+
39
+ def _read(path: pathlib.Path) -> dict[str, Any]:
40
+ """Parse a TOML file (stdlib ``tomllib``, so no extra dependency)."""
41
+ with open(path, "rb") as f:
42
+ return tomllib.load(f)
43
+
44
+
45
+ def load_parameters(path: str | pathlib.Path | None = None, overrides: dict[str, Any] | None = None) -> ParameterSet:
46
+ """Shipped defaults, then the optional user TOML ``path``, then ``overrides`` ({"section.name": value}).
47
+
48
+ In a user file a parameter may be a full table (``{value, unit, status, source}``) or a bare value, which becomes an
49
+ ``override``. Unknown (TBD) parameters stay unknown until somebody supplies them."""
50
+ data: dict[str, dict[str, Param]] = {}
51
+ # layer 1: every shipped defaults file (one per topic: cable, repeater, pfe, ...)
52
+ for f in sorted(DEFAULTS_DIR.glob("*.toml")):
53
+ for sec, params in _read(f).items():
54
+ for name, raw in params.items():
55
+ data.setdefault(sec, {})[name] = _parse_table(sec, name, raw, from_user=False)
56
+ # layer 2: the user's file; a key that does not exist in the defaults is a typo, so it is an error, not a silent new setting
57
+ if path is not None:
58
+ p = pathlib.Path(path)
59
+ if not p.exists():
60
+ raise ConfigError(f"parameter file {p} not found")
61
+ for sec, params in _read(p).items():
62
+ for name, raw in params.items():
63
+ if sec not in data or name not in data[sec]:
64
+ raise ConfigError(f"{p.name}: unknown parameter {sec}.{name}")
65
+ old = data[sec][name]
66
+ new = _parse_table(sec, name, raw, from_user=True)
67
+ data[sec][name] = Param(sec, name, new.value, new.unit or old.unit, new.status, new.source) # keep the unit if the user gave none
68
+ ps = ParameterSet(data)
69
+ # layer 3: programmatic or command-line overrides
70
+ if overrides:
71
+ ps = ps.with_overrides(overrides)
72
+ validate(ps) # last: reject values that would make the physics meaningless
73
+ return ps
74
+
75
+
76
+ __all__ = ["DEFAULTS_DIR", "TBD", "load_parameters"]
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """Parse ``section.key=value`` strings (command line ``--set``) into an override dictionary."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from ..errors import ConfigError
10
+
11
+
12
+ def parse_overrides(items: list[str] | None) -> dict[str, Any]:
13
+ """``["pfe.max_voltage_kV_per_end=2.5", ...]`` -> ``{"pfe.max_voltage_kV_per_end": 2.5}``."""
14
+ out: dict[str, Any] = {}
15
+ for item in items or []:
16
+ if "=" not in item:
17
+ raise ConfigError(f"override {item!r} must look like section.key=value")
18
+ key, raw = item.split("=", 1)
19
+ try:
20
+ out[key.strip()] = json.loads(raw) # numbers, true/false, lists, "quoted strings"
21
+ except json.JSONDecodeError:
22
+ out[key.strip()] = raw # bare words stay strings
23
+ return out
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ # Copyright (C) 2026 Shibaji Chakraborty
3
+ """Parameter provenance: every number carries its unit, its status and where it came from.
4
+
5
+ Statuses
6
+ sourced taken from a document (the source string names it)
7
+ assumed an illustrative placeholder, to be replaced or swept
8
+ unknown ``TBD``: asking for it raises :class:`~bolts.errors.MissingParameter` instead of guessing
9
+ override changed in code or on the command line for one run
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from enum import StrEnum
15
+ from typing import Any
16
+
17
+ from ..errors import MissingParameter
18
+
19
+ TBD = "TBD"
20
+
21
+
22
+ class Status(StrEnum):
23
+ """Where a number stands; see the module docstring for what each value commits the code to."""
24
+
25
+ SOURCED = "sourced"
26
+ ASSUMED = "assumed"
27
+ UNKNOWN = "unknown"
28
+ OVERRIDE = "override"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Param:
33
+ """One parameter with its unit, status and source. Frozen: a changed value is a new Param with status ``override``."""
34
+
35
+ section: str
36
+ name: str
37
+ value: Any
38
+ unit: str
39
+ status: Status
40
+ source: str
41
+
42
+ @property
43
+ def key(self) -> str:
44
+ """``section.name``, as used in overrides and messages."""
45
+ return f"{self.section}.{self.name}"
46
+
47
+ @property
48
+ def is_unknown(self) -> bool:
49
+ """True while the value is the ``TBD`` marker."""
50
+ return isinstance(self.value, str) and self.value == TBD
51
+
52
+ def require(self) -> Any:
53
+ """The value, or :class:`MissingParameter` naming the parameter and the reason it is unknown."""
54
+ if self.is_unknown:
55
+ raise MissingParameter(f"{self.key} is unknown (TBD): {self.source}")
56
+ return self.value