davinci-resolve-mcp 2.67.0 → 2.68.0

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.
@@ -0,0 +1,323 @@
1
+ """Caption generation from word timings: SRT / WebVTT under broadcast rules.
2
+
3
+ Turning a transcript into captions is not line-wrapping. A caption that is
4
+ technically synchronised but breaks mid-clause, orphans a word, or sits on
5
+ screen for nine seconds fails QC and is unreadable, so the rules a human
6
+ captioner follows are encoded here rather than left to the caller:
7
+
8
+ - a character cap per line, and a cap on lines per block;
9
+ - a maximum and a **minimum** block duration — a 4-frame flash is unreadable
10
+ even though it is perfectly in sync;
11
+ - breaks preferred at sentence ends, then at clause boundaries, then at long
12
+ pauses, and only then on length alone;
13
+ - timings snapped to the words actually spoken, never interpolated;
14
+ - a minimum gap between blocks so consecutive captions do not visually merge;
15
+ - no single-word orphan line.
16
+
17
+ ## Reading rate is a cap, not a target
18
+
19
+ Blocks are extended toward `min_block_seconds` when the words are quick, but
20
+ never past the next block's start. A caption that overruns its successor is
21
+ worse than a short one.
22
+
23
+ ## Frame rates
24
+
25
+ SRT and WebVTT both carry wall-clock timestamps, so nothing here needs a frame
26
+ rate. Retiming across rates is a separate concern and deliberately not folded
27
+ in — a caption file that has been silently conformed is very hard to debug.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from typing import Any, Dict, List, Optional, Sequence
34
+
35
+ #: Broadcast-conventional defaults. Every one is overridable per call.
36
+ DEFAULT_MAX_CHARS_PER_LINE = 42
37
+ DEFAULT_MAX_LINES = 2
38
+ DEFAULT_MAX_BLOCK_SECONDS = 7.0
39
+ DEFAULT_MIN_BLOCK_SECONDS = 0.833 # ~20 frames at 24fps; below this is a flash
40
+ DEFAULT_MIN_GAP_SECONDS = 0.084 # ~2 frames, so blocks do not visually merge
41
+ #: A silence at least this long is a natural caption break.
42
+ DEFAULT_PAUSE_BREAK_SECONDS = 0.6
43
+
44
+ _SENTENCE_END_RE = re.compile(r"[.!?]['\"”’)]*$")
45
+ _CLAUSE_END_RE = re.compile(r"[,;:—-]['\"”’)]*$")
46
+
47
+ FORMATS = ("srt", "vtt")
48
+
49
+
50
+ class CaptionError(ValueError):
51
+ """Invalid caption parameters — refused rather than silently clamped."""
52
+
53
+
54
+ def _word_time(word: Dict[str, Any], key: str, fallback: str) -> Optional[float]:
55
+ value = word.get(key, word.get(fallback))
56
+ return float(value) if isinstance(value, (int, float)) else None
57
+
58
+
59
+ def _clean(word: Dict[str, Any]) -> str:
60
+ return str(word.get("word") or "").strip()
61
+
62
+
63
+ def _wrap(text: str, max_chars: int, max_lines: int) -> Optional[List[str]]:
64
+ """Greedy wrap; None when it will not fit in `max_lines`.
65
+
66
+ Returning None rather than truncating is deliberate — silently dropping
67
+ words from a caption is the worst possible failure for accessibility.
68
+ """
69
+ lines: List[str] = []
70
+ current = ""
71
+ for token in text.split():
72
+ candidate = f"{current} {token}".strip()
73
+ if len(candidate) <= max_chars:
74
+ current = candidate
75
+ continue
76
+ if current:
77
+ lines.append(current)
78
+ current = token
79
+ if len(current) > max_chars:
80
+ return None # a single token longer than the line cap
81
+ if current:
82
+ lines.append(current)
83
+ return lines if len(lines) <= max_lines else None
84
+
85
+
86
+ def _no_orphan(lines: List[str]) -> List[str]:
87
+ """Rebalance so no line is a lone word when a neighbour could give one up."""
88
+ if len(lines) < 2:
89
+ return lines
90
+ if len(lines[-1].split()) == 1 and len(lines[-2].split()) > 2:
91
+ head = lines[-2].split()
92
+ lines = lines[:-2] + [" ".join(head[:-1]), f"{head[-1]} {lines[-1]}"]
93
+ return lines
94
+
95
+
96
+ def _break_score(word: Dict[str, Any], gap_after: float, pause_break: float) -> int:
97
+ """Higher is a better place to end a caption block."""
98
+ text = _clean(word)
99
+ if _SENTENCE_END_RE.search(text):
100
+ return 3
101
+ if gap_after >= pause_break:
102
+ return 2
103
+ if _CLAUSE_END_RE.search(text):
104
+ return 1
105
+ return 0
106
+
107
+
108
+ def build_blocks(
109
+ words: Sequence[Dict[str, Any]],
110
+ *,
111
+ max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE,
112
+ max_lines: int = DEFAULT_MAX_LINES,
113
+ max_block_seconds: float = DEFAULT_MAX_BLOCK_SECONDS,
114
+ min_block_seconds: float = DEFAULT_MIN_BLOCK_SECONDS,
115
+ min_gap_seconds: float = DEFAULT_MIN_GAP_SECONDS,
116
+ pause_break_seconds: float = DEFAULT_PAUSE_BREAK_SECONDS,
117
+ ) -> List[Dict[str, Any]]:
118
+ """Group words into caption blocks obeying every rule above."""
119
+ if max_chars_per_line < 8:
120
+ raise CaptionError("max_chars_per_line below 8 cannot hold readable text")
121
+ if max_lines < 1:
122
+ raise CaptionError("max_lines must be at least 1")
123
+ if min_block_seconds > max_block_seconds:
124
+ raise CaptionError("min_block_seconds cannot exceed max_block_seconds")
125
+
126
+ timed = [
127
+ w for w in words
128
+ if _clean(w) and _word_time(w, "start_seconds", "start") is not None
129
+ ]
130
+ if not timed:
131
+ return []
132
+
133
+ capacity = max_chars_per_line * max_lines
134
+ blocks: List[Dict[str, Any]] = []
135
+ current: List[Dict[str, Any]] = []
136
+
137
+ def flush() -> None:
138
+ if not current:
139
+ return
140
+ text = " ".join(_clean(w) for w in current)
141
+ lines = _wrap(text, max_chars_per_line, max_lines)
142
+ if lines is None:
143
+ lines = [text[:max_chars_per_line]]
144
+ blocks.append({
145
+ "start_seconds": _word_time(current[0], "start_seconds", "start"),
146
+ "end_seconds": (
147
+ _word_time(current[-1], "end_seconds", "end")
148
+ or _word_time(current[-1], "start_seconds", "start")
149
+ ),
150
+ "lines": _no_orphan(lines),
151
+ "word_count": len(current),
152
+ })
153
+ current.clear()
154
+
155
+ for index, word in enumerate(timed):
156
+ current.append(word)
157
+ following = timed[index + 1] if index + 1 < len(timed) else None
158
+ if following is None:
159
+ break
160
+
161
+ this_end = _word_time(word, "end_seconds", "end") or _word_time(word, "start_seconds", "start")
162
+ next_start = _word_time(following, "start_seconds", "start")
163
+ gap = max(0.0, (next_start or 0.0) - (this_end or 0.0))
164
+
165
+ text_now = " ".join(_clean(w) for w in current)
166
+ text_next = f"{text_now} {_clean(following)}"
167
+ block_start = _word_time(current[0], "start_seconds", "start") or 0.0
168
+ would_be_long = ((next_start or 0.0) - block_start) > max_block_seconds
169
+ would_not_fit = len(text_next) > capacity or _wrap(text_next, max_chars_per_line, max_lines) is None
170
+
171
+ score = _break_score(word, gap, pause_break_seconds)
172
+ # Sentence ends and real pauses always break. A block that spans a
173
+ # silence leaves text on screen through it, which is the thing captioners
174
+ # break on — and short blocks are already made readable by the
175
+ # min_block_seconds extension below, so no length guard is needed here.
176
+ # Clause breaks (score 1) are only a preference and never force a flush.
177
+ if would_not_fit or would_be_long or score >= 2:
178
+ flush()
179
+ flush()
180
+
181
+ # Extend short blocks toward the readable minimum, never into the next one.
182
+ for index, block in enumerate(blocks):
183
+ limit = (
184
+ blocks[index + 1]["start_seconds"] - min_gap_seconds
185
+ if index + 1 < len(blocks)
186
+ else block["end_seconds"] + min_block_seconds
187
+ )
188
+ if block["end_seconds"] - block["start_seconds"] < min_block_seconds:
189
+ block["end_seconds"] = max(block["end_seconds"], min(block["start_seconds"] + min_block_seconds, limit))
190
+ # Enforce the inter-block gap even when no extension happened.
191
+ if index + 1 < len(blocks):
192
+ block["end_seconds"] = min(block["end_seconds"], blocks[index + 1]["start_seconds"] - min_gap_seconds)
193
+ block["end_seconds"] = max(block["end_seconds"], block["start_seconds"] + 1e-3)
194
+ block["start_seconds"] = round(block["start_seconds"], 3)
195
+ block["end_seconds"] = round(block["end_seconds"], 3)
196
+ block["duration_seconds"] = round(block["end_seconds"] - block["start_seconds"], 3)
197
+ return blocks
198
+
199
+
200
+ # ── serialisation ────────────────────────────────────────────────────────────
201
+
202
+
203
+ def _timestamp(seconds: float, *, comma: bool) -> str:
204
+ seconds = max(0.0, float(seconds))
205
+ hours, rest = divmod(seconds, 3600.0)
206
+ minutes, secs = divmod(rest, 60.0)
207
+ millis = int(round((secs - int(secs)) * 1000))
208
+ if millis == 1000: # rounding carried into the next second
209
+ secs, millis = int(secs) + 1, 0
210
+ sep = "," if comma else "."
211
+ return f"{int(hours):02d}:{int(minutes):02d}:{int(secs):02d}{sep}{millis:03d}"
212
+
213
+
214
+ def to_srt(blocks: Sequence[Dict[str, Any]]) -> str:
215
+ out: List[str] = []
216
+ for index, block in enumerate(blocks, start=1):
217
+ out.append(str(index))
218
+ out.append(
219
+ f"{_timestamp(block['start_seconds'], comma=True)} --> "
220
+ f"{_timestamp(block['end_seconds'], comma=True)}"
221
+ )
222
+ out.extend(block["lines"])
223
+ out.append("")
224
+ return "\n".join(out)
225
+
226
+
227
+ def to_vtt(blocks: Sequence[Dict[str, Any]]) -> str:
228
+ out: List[str] = ["WEBVTT", ""]
229
+ for block in blocks:
230
+ out.append(
231
+ f"{_timestamp(block['start_seconds'], comma=False)} --> "
232
+ f"{_timestamp(block['end_seconds'], comma=False)}"
233
+ )
234
+ out.extend(block["lines"])
235
+ out.append("")
236
+ return "\n".join(out)
237
+
238
+
239
+ def render(blocks: Sequence[Dict[str, Any]], fmt: str) -> str:
240
+ if fmt not in FORMATS:
241
+ raise CaptionError(f"unknown caption format {fmt!r}; valid: {', '.join(FORMATS)}")
242
+ return to_srt(blocks) if fmt == "srt" else to_vtt(blocks)
243
+
244
+
245
+ # ── chapters ─────────────────────────────────────────────────────────────────
246
+
247
+
248
+ def chapters_from_blocks(
249
+ blocks: Sequence[Dict[str, Any]],
250
+ *,
251
+ min_spacing_seconds: float = 30.0,
252
+ max_title_chars: int = 60,
253
+ ) -> List[Dict[str, Any]]:
254
+ """Derive chapters from caption blocks by topic-shift heuristic.
255
+
256
+ A long pause landing on a sentence start is the cheapest usable signal for
257
+ "new topic". This is explicitly a heuristic — it produces a starting point
258
+ an editor renames, not a finished chapter list.
259
+
260
+ The first chapter is always forced to 00:00 because that is a hard
261
+ requirement of the YouTube description format, not a stylistic choice.
262
+ """
263
+ if not blocks:
264
+ return []
265
+ chapters: List[Dict[str, Any]] = [{
266
+ "start_seconds": 0.0,
267
+ "title": " ".join(blocks[0]["lines"])[:max_title_chars].strip(),
268
+ }]
269
+ for previous, block in zip(blocks, blocks[1:]):
270
+ gap = block["start_seconds"] - previous["end_seconds"]
271
+ if gap < DEFAULT_PAUSE_BREAK_SECONDS:
272
+ continue
273
+ if block["start_seconds"] - chapters[-1]["start_seconds"] < min_spacing_seconds:
274
+ continue
275
+ chapters.append({
276
+ "start_seconds": round(block["start_seconds"], 3),
277
+ "title": " ".join(block["lines"])[:max_title_chars].strip(),
278
+ })
279
+ return chapters
280
+
281
+
282
+ def chapters_to_youtube(chapters: Sequence[Dict[str, Any]]) -> str:
283
+ """YouTube description text. Always starts at 00:00 or YouTube ignores it."""
284
+ lines = []
285
+ for chapter in chapters:
286
+ total = int(chapter["start_seconds"])
287
+ hours, rest = divmod(total, 3600)
288
+ minutes, secs = divmod(rest, 60)
289
+ stamp = f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
290
+ lines.append(f"{stamp} {chapter['title']}".rstrip())
291
+ return "\n".join(lines)
292
+
293
+
294
+ def generate(
295
+ words: Sequence[Dict[str, Any]],
296
+ *,
297
+ fmt: str = "srt",
298
+ with_chapters: bool = False,
299
+ **options: Any,
300
+ ) -> Dict[str, Any]:
301
+ """Word timings → caption text plus the block list and optional chapters."""
302
+ if fmt not in FORMATS:
303
+ raise CaptionError(f"unknown caption format {fmt!r}; valid: {', '.join(FORMATS)}")
304
+ blocks = build_blocks(words, **options)
305
+ if not blocks:
306
+ return {
307
+ "success": False,
308
+ "error": "No timed words to caption.",
309
+ "remediation": "Run transcription for this clip first, then retry.",
310
+ }
311
+ result: Dict[str, Any] = {
312
+ "success": True,
313
+ "format": fmt,
314
+ "text": render(blocks, fmt),
315
+ "blocks": blocks,
316
+ "block_count": len(blocks),
317
+ "duration_seconds": round(blocks[-1]["end_seconds"], 3),
318
+ }
319
+ if with_chapters:
320
+ chapters = chapters_from_blocks(blocks)
321
+ result["chapters"] = chapters
322
+ result["chapters_youtube"] = chapters_to_youtube(chapters)
323
+ return result
@@ -0,0 +1,196 @@
1
+ """Display-referred colour science: sRGB ↔ linear ↔ XYZ ↔ CIE Lab (D65), ΔE2000.
2
+
3
+ Pure numpy, no third-party colour library, so the conversions can be unit-tested
4
+ against published reference values rather than trusted.
5
+
6
+ Everything here is **display-referred**. RGB is float in [0, 1] with an sRGB
7
+ transfer function; Lab is L* in [0, 100] with a/b roughly [-128, 128]. Feeding
8
+ log or scene-referred RGB (ACEScct, S-Log3, LogC) through these functions
9
+ produces numbers that look plausible and mean nothing — `image_qc` is the layer
10
+ that refuses that case, and it refuses rather than converting silently because
11
+ the correct conversion depends on a transform this module cannot know.
12
+
13
+ ΔE2000 is implemented rather than ΔE76 because ΔE76 badly under-weights hue
14
+ error in the blues and over-weights chroma error in saturated colours, and any
15
+ number a human reads as "how close is this" should not carry that distortion.
16
+ The implementation is validated against the Sharma/Wu/Dalal (2005) test set,
17
+ which is specifically constructed to exercise the hue-rotation and chroma
18
+ discontinuities where naive implementations disagree.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import numpy as np
24
+
25
+ # sRGB (IEC 61966-2-1) companding thresholds.
26
+ _SRGB_THRESH = 0.04045
27
+ _SRGB_LIN_THRESH = 0.0031308
28
+
29
+ # sRGB primaries → XYZ (D65).
30
+ _RGB2XYZ = np.array(
31
+ [
32
+ [0.4124564, 0.3575761, 0.1804375],
33
+ [0.2126729, 0.7151522, 0.0721750],
34
+ [0.0193339, 0.1191920, 0.9503041],
35
+ ],
36
+ dtype=np.float64,
37
+ )
38
+ _XYZ2RGB = np.linalg.inv(_RGB2XYZ)
39
+
40
+ #: D65 reference white, derived from the matrix rather than quoted.
41
+ #:
42
+ #: The usual rounded literal [0.95047, 1.0, 1.08883] is *not* exactly the white
43
+ #: this matrix produces from RGB 1,1,1 — they disagree in the 6th decimal, which
44
+ #: leaves sRGB white landing at L* = 100.0000039 with a non-zero a*/b*. Deriving
45
+ #: it keeps the two constants consistent by construction, so white is exactly
46
+ #: neutral and the Lab round-trip closes.
47
+ _D65 = _RGB2XYZ @ np.ones(3, dtype=np.float64)
48
+
49
+ # CIE Lab constants, in the exact-rational form (CIE 15:2004) rather than the
50
+ # rounded 0.008856 / 903.3 that older references use — the rounded pair puts a
51
+ # small discontinuity at the linear/cube-root join.
52
+ _LAB_EPS = 216.0 / 24389.0 # (6/29)^3
53
+ _LAB_KAPPA = 24389.0 / 27.0 # (29/3)^3
54
+
55
+
56
+ def srgb_to_linear(rgb: np.ndarray) -> np.ndarray:
57
+ rgb = np.asarray(rgb, dtype=np.float64)
58
+ return np.where(rgb <= _SRGB_THRESH, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
59
+
60
+
61
+ def linear_to_srgb(rgb: np.ndarray) -> np.ndarray:
62
+ rgb = np.asarray(rgb, dtype=np.float64)
63
+ return np.where(
64
+ rgb <= _SRGB_LIN_THRESH,
65
+ rgb * 12.92,
66
+ 1.055 * np.power(np.clip(rgb, 0.0, None), 1.0 / 2.4) - 0.055,
67
+ )
68
+
69
+
70
+ def linear_to_xyz(rgb_lin: np.ndarray) -> np.ndarray:
71
+ return np.asarray(rgb_lin, dtype=np.float64) @ _RGB2XYZ.T
72
+
73
+
74
+ def xyz_to_linear(xyz: np.ndarray) -> np.ndarray:
75
+ return np.asarray(xyz, dtype=np.float64) @ _XYZ2RGB.T
76
+
77
+
78
+ def _f_forward(t: np.ndarray) -> np.ndarray:
79
+ return np.where(t > _LAB_EPS, np.cbrt(t), (_LAB_KAPPA * t + 16.0) / 116.0)
80
+
81
+
82
+ def _f_inverse(f: np.ndarray) -> np.ndarray:
83
+ f3 = f**3
84
+ return np.where(f3 > _LAB_EPS, f3, (116.0 * f - 16.0) / _LAB_KAPPA)
85
+
86
+
87
+ def xyz_to_lab(xyz: np.ndarray) -> np.ndarray:
88
+ scaled = np.asarray(xyz, dtype=np.float64) / _D65
89
+ fx, fy, fz = (_f_forward(scaled[..., i]) for i in range(3))
90
+ return np.stack([116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)], axis=-1)
91
+
92
+
93
+ def lab_to_xyz(lab: np.ndarray) -> np.ndarray:
94
+ lab = np.asarray(lab, dtype=np.float64)
95
+ fy = (lab[..., 0] + 16.0) / 116.0
96
+ fx = fy + lab[..., 1] / 500.0
97
+ fz = fy - lab[..., 2] / 200.0
98
+ return np.stack([_f_inverse(fx) * _D65[0], _f_inverse(fy) * _D65[1], _f_inverse(fz) * _D65[2]], axis=-1)
99
+
100
+
101
+ def srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
102
+ return xyz_to_lab(linear_to_xyz(srgb_to_linear(rgb)))
103
+
104
+
105
+ def lab_to_srgb(lab: np.ndarray, *, clip: bool = True) -> np.ndarray:
106
+ rgb = linear_to_srgb(xyz_to_linear(lab_to_xyz(lab)))
107
+ return np.clip(rgb, 0.0, 1.0) if clip else rgb
108
+
109
+
110
+ def delta_e76(lab_a: np.ndarray, lab_b: np.ndarray) -> np.ndarray:
111
+ """Plain Euclidean distance in Lab. Kept for cheap internal comparisons only.
112
+
113
+ Not for anything a human reads as a quality figure — use `delta_e2000`.
114
+ """
115
+ a = np.asarray(lab_a, dtype=np.float64)
116
+ b = np.asarray(lab_b, dtype=np.float64)
117
+ return np.sqrt(np.sum((a - b) ** 2, axis=-1))
118
+
119
+
120
+ def delta_e2000(
121
+ lab_a: np.ndarray, lab_b: np.ndarray, *, k_l: float = 1.0, k_c: float = 1.0, k_h: float = 1.0
122
+ ) -> np.ndarray:
123
+ """CIEDE2000 colour difference (CIE 142:2001).
124
+
125
+ Validated against the Sharma/Wu/Dalal (2005) test set — see
126
+ `tests/test_colorimetry.py`. The awkward parts are the two hue-difference
127
+ branches, which must use the 180°/360° wrap conventions exactly as specified;
128
+ getting them subtly wrong still passes most casual test pairs.
129
+ """
130
+ a = np.asarray(lab_a, dtype=np.float64)
131
+ b = np.asarray(lab_b, dtype=np.float64)
132
+ l1, a1, b1 = a[..., 0], a[..., 1], a[..., 2]
133
+ l2, a2, b2 = b[..., 0], b[..., 1], b[..., 2]
134
+
135
+ c1 = np.hypot(a1, b1)
136
+ c2 = np.hypot(a2, b2)
137
+ c_bar = (c1 + c2) / 2.0
138
+ g = 0.5 * (1.0 - np.sqrt(c_bar**7 / (c_bar**7 + 25.0**7)))
139
+
140
+ a1p, a2p = (1.0 + g) * a1, (1.0 + g) * a2
141
+ c1p, c2p = np.hypot(a1p, b1), np.hypot(a2p, b2)
142
+
143
+ # Hue angles in degrees; undefined (0) when chroma is zero, per the standard.
144
+ h1p = np.degrees(np.arctan2(b1, a1p)) % 360.0
145
+ h2p = np.degrees(np.arctan2(b2, a2p)) % 360.0
146
+ h1p = np.where(c1p == 0, 0.0, h1p)
147
+ h2p = np.where(c2p == 0, 0.0, h2p)
148
+
149
+ dlp = l2 - l1
150
+ dcp = c2p - c1p
151
+
152
+ # Δh′: zero when either chroma is zero, else wrapped to (-180, 180].
153
+ dhp = h2p - h1p
154
+ dhp = np.where(dhp > 180.0, dhp - 360.0, dhp)
155
+ dhp = np.where(dhp < -180.0, dhp + 360.0, dhp)
156
+ dhp = np.where(c1p * c2p == 0, 0.0, dhp)
157
+ dhp_term = 2.0 * np.sqrt(c1p * c2p) * np.sin(np.radians(dhp) / 2.0)
158
+
159
+ lp_bar = (l1 + l2) / 2.0
160
+ cp_bar = (c1p + c2p) / 2.0
161
+
162
+ # H̄′: the mean hue, which needs the wrap handled separately from Δh′ — the
163
+ # sum/2 convention differs from the difference convention above.
164
+ h_sum = h1p + h2p
165
+ h_abs = np.abs(h1p - h2p)
166
+ hp_bar = np.where(
167
+ c1p * c2p == 0,
168
+ h_sum,
169
+ np.where(
170
+ h_abs <= 180.0,
171
+ h_sum / 2.0,
172
+ np.where(h_sum < 360.0, (h_sum + 360.0) / 2.0, (h_sum - 360.0) / 2.0),
173
+ ),
174
+ )
175
+
176
+ t = (
177
+ 1.0
178
+ - 0.17 * np.cos(np.radians(hp_bar - 30.0))
179
+ + 0.24 * np.cos(np.radians(2.0 * hp_bar))
180
+ + 0.32 * np.cos(np.radians(3.0 * hp_bar + 6.0))
181
+ - 0.20 * np.cos(np.radians(4.0 * hp_bar - 63.0))
182
+ )
183
+
184
+ d_theta = 30.0 * np.exp(-(((hp_bar - 275.0) / 25.0) ** 2))
185
+ r_c = 2.0 * np.sqrt(cp_bar**7 / (cp_bar**7 + 25.0**7))
186
+ s_l = 1.0 + (0.015 * (lp_bar - 50.0) ** 2) / np.sqrt(20.0 + (lp_bar - 50.0) ** 2)
187
+ s_c = 1.0 + 0.045 * cp_bar
188
+ s_h = 1.0 + 0.015 * cp_bar * t
189
+ r_t = -np.sin(np.radians(2.0 * d_theta)) * r_c
190
+
191
+ return np.sqrt(
192
+ (dlp / (k_l * s_l)) ** 2
193
+ + (dcp / (k_c * s_c)) ** 2
194
+ + (dhp_term / (k_h * s_h)) ** 2
195
+ + r_t * (dcp / (k_c * s_c)) * (dhp_term / (k_h * s_h))
196
+ )