scalefree 0.1.2__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.
scalefree/vmoments.py ADDED
@@ -0,0 +1,837 @@
1
+ """
2
+ scalefree.vmoments
3
+
4
+ Prompt-driven Python wrapper for the ScaleFree Fortran executable.
5
+
6
+ Behavior regarding the Fortran backend
7
+ -------------------------------------
8
+ - If exe_path is provided: use it; if missing and gfortran exists,
9
+ we can build it there.
10
+ - If exe_path is not provided:
11
+ 1) use SCALEFREE_EXE env var if set
12
+ 2) else use a cached executable in a user cache directory
13
+ 3) else, if gfortran exists, auto-compile from packaged
14
+ fortran_src/scalefree.f
15
+ 4) else raise a clear, actionable error instructing how to install gfortran
16
+
17
+ Behavior regarding output (Option A)
18
+ -----------------------------------
19
+ - Default: do NOT leave output files behind.
20
+ - We still answer the Fortran "Output file" prompt with a temporary filename
21
+ (so the interactive program never blocks), but we prefer parsing the
22
+ structured
23
+ "# kind=..." blocks from STDOUT. Any temporary file is deleted at the end.
24
+ - If the caller provides output_path, we treat it as an explicit
25
+ request to write
26
+ a persistent file and we parse that file (more stable for regression tests).
27
+
28
+ Note on pip install messages
29
+ ----------------------------
30
+ Reliable messages at *pip install time* are not guaranteed for wheels.
31
+ We therefore warn at import-time (non-fatal) and error at runtime
32
+ (fatal if missing).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Any, Dict, Optional, Union, List, Tuple
40
+
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ import warnings
46
+
47
+ import numpy as np
48
+
49
+
50
+ # ---------------------------------------------------------------------
51
+ # Fortran number parsing helpers
52
+ # ---------------------------------------------------------------------
53
+
54
+ _FORTRAN_EXP_RE = re.compile(
55
+ r"""^([+-]?(?:\d+(?:\.\d*)?|\.\d+))([+-]\d{2,4})$""", re.VERBOSE
56
+ )
57
+
58
+
59
+ def _to_float(tok: Any) -> float:
60
+ """
61
+ Parse a token emitted by Fortran into a Python float.
62
+
63
+ Handles:
64
+ - native numeric types
65
+ - Fortran D exponents (1.0D-10)
66
+ - rare "mantissa-EXP" tokens without E (0.12-322 -> 0.12e-322)
67
+ """
68
+ if isinstance(tok, (int, float, np.integer, np.floating)):
69
+ return float(tok)
70
+
71
+ t = str(tok).strip()
72
+ t = t.replace("D", "E").replace("d", "e")
73
+
74
+ m = _FORTRAN_EXP_RE.match(t)
75
+ if m:
76
+ t = f"{m.group(1)}e{m.group(2)}"
77
+
78
+ return float(t)
79
+
80
+
81
+ def _fmt(x: Union[float, int, bool]) -> str:
82
+ """Format numeric scalars robustly for Fortran stdin."""
83
+ if isinstance(x, bool):
84
+ return "1" if x else "0"
85
+ if isinstance(x, int):
86
+ return str(x)
87
+ return format(_to_float(x), ".17g") # round-trip safe for IEEE-754 double
88
+
89
+
90
+ def _potential_code(potential: Any) -> int:
91
+ """
92
+ Fortran prompt: Kepler (1) or Logarithmic (2).
93
+
94
+ Accepts:
95
+ - int already in {1,2}
96
+ - string: "kepler"/"logarithmic"/"log"
97
+ - callable returning int
98
+ - object with .ipot/.code/.fortran_id
99
+ """
100
+ if isinstance(potential, int):
101
+ return int(potential)
102
+
103
+ if isinstance(potential, str):
104
+ key = potential.strip().lower()
105
+ mapping = {"kepler": 1, "k": 1, "logarithmic": 2, "log": 2}
106
+ if key not in mapping:
107
+ raise ValueError(
108
+ f"Unknown potential='{potential}'. "
109
+ "Use 'kepler'/'logarithmic' or an int 1/2."
110
+ )
111
+ return mapping[key]
112
+
113
+ for attr in ("ipot", "code", "fortran_id"):
114
+ if hasattr(potential, attr):
115
+ return int(getattr(potential, attr))
116
+
117
+ if callable(potential):
118
+ v = potential()
119
+ if isinstance(v, (int, np.integer)):
120
+ return int(v)
121
+
122
+ raise TypeError(
123
+ "Could not interpret 'potential'. "
124
+ "Provide int 1/2, string, callable->int, "
125
+ "or an object with ipot/code/fortran_id."
126
+ )
127
+
128
+
129
+ # ---------------------------------------------------------------------
130
+ # Backend resolution / compilation helpers
131
+ # ---------------------------------------------------------------------
132
+
133
+
134
+ class ScaleFreeBackendError(RuntimeError):
135
+ """Raised when the Fortran backend cannot be found or built."""
136
+
137
+
138
+ def _have_gfortran() -> bool:
139
+ return shutil.which("gfortran") is not None
140
+
141
+
142
+ def _site_packages_root() -> Path:
143
+ # scalefree/vmoments.py -> scalefree/ -> (site-packages or repo root)
144
+ return Path(__file__).resolve().parent.parent
145
+
146
+
147
+ def _packaged_fortran_source() -> Path:
148
+ """
149
+ Locate the packaged Fortran source.
150
+
151
+ In wheels, this should be installed as:
152
+ <site-packages>/fortran_src/scalefree.f
153
+
154
+ In editable/repo contexts, this typically is:
155
+ <repo-root>/fortran_src/scalefree.f
156
+
157
+ Both are covered because _site_packages_root()
158
+ is the parent of 'scalefree/'.
159
+ """
160
+ return _site_packages_root() / "fortran_src" / "scalefree.f"
161
+
162
+
163
+ def _user_cache_dir() -> Path:
164
+ """
165
+ Cross-platform-ish cache directory without extra dependencies.
166
+ - Linux: $XDG_CACHE_HOME/scalefree or ~/.cache/scalefree
167
+ - macOS: ~/Library/Caches/scalefree
168
+ - Windows: %LOCALAPPDATA%\\scalefree\\Cache
169
+ """
170
+ if os.name == "nt":
171
+ base = os.environ.get("LOCALAPPDATA") or str(
172
+ Path.home() / "AppData" / "Local",
173
+ )
174
+ return Path(base) / "scalefree" / "Cache"
175
+
176
+ # macOS
177
+ sysname = ""
178
+ if hasattr(os, "uname"):
179
+ try:
180
+ sysname = os.uname().sysname.lower()
181
+ except Exception:
182
+ sysname = ""
183
+ if sysname == "darwin":
184
+ return Path.home() / "Library" / "Caches" / "scalefree"
185
+
186
+ # Linux/other POSIX
187
+ xdg = os.environ.get("XDG_CACHE_HOME")
188
+ if xdg:
189
+ return Path(xdg) / "scalefree"
190
+ return Path.home() / ".cache" / "scalefree"
191
+
192
+
193
+ def _default_cached_exe() -> Path:
194
+ exe_name = "scalefree.e" if os.name != "nt" else "scalefree.exe"
195
+ return _user_cache_dir() / exe_name
196
+
197
+
198
+ def _compile_backend(*, exe: Path, src: Path) -> None:
199
+ """
200
+ Compile the Fortran backend. Raises ScaleFreeBackendError on failure.
201
+ """
202
+ if not _have_gfortran():
203
+ raise ScaleFreeBackendError(_missing_gfortran_message())
204
+
205
+ if not src.exists():
206
+ raise ScaleFreeBackendError(
207
+ "ScaleFree Fortran source file was not "
208
+ "found inside the installation.\n\n"
209
+ f"Expected: {src}\n"
210
+ "This likely means the wheel/sdist was built without including "
211
+ "fortran_src/scalefree.f."
212
+ )
213
+
214
+ exe.parent.mkdir(parents=True, exist_ok=True)
215
+
216
+ cmd = ["gfortran", "-O2", "-std=legacy", "-o", str(exe), str(src)]
217
+ try:
218
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
219
+ except subprocess.CalledProcessError as e:
220
+ raise ScaleFreeBackendError(
221
+ "gfortran was found, "
222
+ "but compilation of the ScaleFree backend failed.\n\n"
223
+ f"Command: {' '.join(cmd)}\n\n"
224
+ f"STDOUT:\n{e.stdout}\n\nSTDERR:\n{e.stderr}\n"
225
+ ) from e
226
+
227
+ # Ensure executable bit on POSIX
228
+ if os.name != "nt":
229
+ try:
230
+ mode = exe.stat().st_mode
231
+ exe.chmod(mode | 0o111)
232
+ except Exception:
233
+ pass
234
+
235
+
236
+ def _missing_gfortran_message() -> str:
237
+ return (
238
+ "ScaleFree Fortran backend is not available.\n\n"
239
+ "This package requires a Fortran compiler "
240
+ "(gfortran) to build the backend "
241
+ "executable.\n"
242
+ "Please install gfortran and re-run.\n\n"
243
+ "Typical install commands:\n"
244
+ " Debian/Ubuntu: sudo apt-get install gfortran\n"
245
+ " Fedora: sudo dnf install gcc-gfortran\n"
246
+ " macOS (brew): brew install gcc # provides gfortran\n"
247
+ " Windows: use WSL or MSYS2 to install gfortran\n\n"
248
+ "Alternatively, if you already built the executable elsewhere, set:\n"
249
+ " export SCALEFREE_EXE=/path/to/scalefree.e\n"
250
+ "or pass exe_path=... explicitly."
251
+ )
252
+
253
+
254
+ def _resolve_executable(
255
+ exe_path: Optional[Union[str, Path]],
256
+ workdir: Optional[Union[str, Path]],
257
+ ) -> Tuple[Path, Path]:
258
+ """
259
+ Resolve and (if needed) build the backend executable.
260
+
261
+ Returns (exe, resolved_workdir).
262
+ """
263
+ # Workdir: if caller provides, respect it; else pick a safe default
264
+ if workdir is not None:
265
+ wd = Path(workdir).expanduser().resolve()
266
+ wd.mkdir(parents=True, exist_ok=True)
267
+ else:
268
+ wd = _user_cache_dir()
269
+ wd.mkdir(parents=True, exist_ok=True)
270
+
271
+ # 1) explicit exe_path
272
+ if exe_path is not None:
273
+ exe = Path(exe_path).expanduser().resolve()
274
+ if exe.exists():
275
+ return exe, wd
276
+ # If they provided an exe_path that doesn't exist,
277
+ # build there if possible
278
+ src = _packaged_fortran_source()
279
+ _compile_backend(exe=exe, src=src)
280
+ return exe, wd
281
+
282
+ # 2) env var
283
+ env = os.environ.get("SCALEFREE_EXE")
284
+ if env:
285
+ exe = Path(env).expanduser().resolve()
286
+ if exe.exists():
287
+ return exe, wd
288
+ raise ScaleFreeBackendError(
289
+ f"SCALEFREE_EXE is set but does not exist: {exe}\n"
290
+ "Either unset SCALEFREE_EXE "
291
+ "or point it to a valid compiled executable."
292
+ )
293
+
294
+ # 3) cached exe
295
+ cached = _default_cached_exe()
296
+ if cached.exists():
297
+ return cached, wd
298
+
299
+ # 4) build into cache if gfortran exists; else fail with clear instructions
300
+ src = _packaged_fortran_source()
301
+ _compile_backend(exe=cached, src=src)
302
+ return cached, wd
303
+
304
+
305
+ # Best-effort import-time warning (non-fatal).
306
+ try:
307
+ _cached = _default_cached_exe()
308
+ _src = _packaged_fortran_source()
309
+ if (not _cached.exists()) and _src.exists() and (not _have_gfortran()):
310
+ warnings.warn(
311
+ "scalefree: gfortran not found. "
312
+ "The Fortran backend will not be usable "
313
+ "until you install gfortran. "
314
+ "See scalefree.vmoments.ScaleFreeBackendError "
315
+ "for install instructions.",
316
+ RuntimeWarning,
317
+ stacklevel=2,
318
+ )
319
+ except Exception:
320
+ pass
321
+
322
+
323
+ # ---------------------------------------------------------------------
324
+ # Output container
325
+ # ---------------------------------------------------------------------
326
+
327
+
328
+ @dataclass
329
+ class ScaleFreeResult:
330
+ blocks: Dict[str, Any]
331
+ raw_text: str
332
+ output_path: Optional[Path]
333
+ stdout: str
334
+ stderr: str
335
+
336
+
337
+ # ---------------------------------------------------------------------
338
+ # Parser for structured output
339
+ # ---------------------------------------------------------------------
340
+
341
+
342
+ def parse_scalefree_output(text: str) -> Dict[str, Any]:
343
+ """
344
+ Parse the structured ASCII output produced by the modified Fortran code.
345
+
346
+ Recognizes:
347
+ - "# kind=XYZ" blocks with optional "# columns: ..." line
348
+ - "# vp_table iproj X" blocks with optional "# columns: ..." line
349
+ """
350
+ lines = [ln.rstrip("\n") for ln in text.splitlines()]
351
+ blocks: Dict[str, Any] = {}
352
+ i = 0
353
+
354
+ def parse_columns(ln: str) -> List[str]:
355
+ _, rhs = ln.split(":", 1)
356
+ return rhs.strip().split()
357
+
358
+ while i < len(lines):
359
+ s = lines[i].strip()
360
+ if not (s.startswith("# kind=") or s.startswith("# vp_table")):
361
+ i += 1
362
+ continue
363
+
364
+ # vp_table iproj X
365
+ if s.startswith("# vp_table"):
366
+ parts = s.split()
367
+ iproj = int(parts[-1])
368
+ i += 1
369
+ cols = None
370
+ while i < len(lines) and lines[i].strip().startswith("#"):
371
+ if lines[i].strip().startswith("# columns:"):
372
+ cols = parse_columns(lines[i].strip())
373
+ i += 1
374
+
375
+ data = []
376
+ while i < len(lines):
377
+ row = lines[i].strip()
378
+ if row == "" or row.startswith("#"):
379
+ break
380
+ data.append([_to_float(x) for x in row.split()])
381
+ i += 1
382
+
383
+ blocks.setdefault("vp_table", {})[iproj] = {
384
+ "columns": cols if cols else ["v", "vp"],
385
+ "data": np.array(data, dtype=float),
386
+ }
387
+ continue
388
+
389
+ # kind=...
390
+ kind = s.replace("# kind=", "").strip()
391
+ i += 1
392
+ cols = None
393
+ while i < len(lines) and lines[i].strip().startswith("#"):
394
+ if lines[i].strip().startswith("# columns:"):
395
+ cols = parse_columns(lines[i].strip())
396
+ i += 1
397
+
398
+ data = []
399
+ while i < len(lines):
400
+ row = lines[i].strip()
401
+ if row == "" or row.startswith("#"):
402
+ break
403
+ data.append([_to_float(x) for x in row.split()])
404
+ i += 1
405
+
406
+ arr = (
407
+ np.array(data, dtype=float)
408
+ if data
409
+ else np.empty(
410
+ (0, 0),
411
+ dtype=float,
412
+ )
413
+ )
414
+ block: Dict[str, Any] = {"columns": cols if cols else [], "data": arr}
415
+
416
+ # Convenience indexing for tables where first column is iproj
417
+ if cols and cols[0].lower() == "iproj" and arr.shape[0] > 0:
418
+ by_iproj: Dict[int, Dict[str, float]] = {}
419
+ for r in arr:
420
+ ip = int(r[0])
421
+ by_iproj[ip] = {
422
+ cols[j]: r[j]
423
+ for j in range(
424
+ min(len(cols), len(r)),
425
+ )
426
+ }
427
+ block["by_iproj"] = by_iproj
428
+
429
+ blocks[kind] = block
430
+
431
+ return blocks
432
+
433
+
434
+ # ---------------------------------------------------------------------
435
+ # STDOUT structured extraction (Option A)
436
+ # ---------------------------------------------------------------------
437
+
438
+
439
+ _NUMERIC_ROW_RE = re.compile(
440
+ r"""^\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:(?:[EeDd])[+-]?\d+)?(?:\s+|$)"""
441
+ )
442
+
443
+
444
+ def _extract_structured_from_stdout(stdout_text: str) -> str:
445
+ """
446
+ Extract only the structured blocks from STDOUT.
447
+
448
+ We keep:
449
+ - lines starting with '#'
450
+ - numeric rows ONLY when we are "inside" a block (after a '# kind=' or
451
+ '# vp_table' marker), stopping when we hit a non-numeric, non-# line.
452
+ """
453
+ keep: List[str] = []
454
+ in_block = False
455
+
456
+ for ln in stdout_text.splitlines():
457
+ s = ln.strip()
458
+ if not s:
459
+ # do not force-close blocks on blank lines; just skip
460
+ continue
461
+
462
+ if s.startswith("#"):
463
+ keep.append(ln)
464
+ in_block = (
465
+ s.startswith(
466
+ "# kind=",
467
+ )
468
+ or s.startswith("# vp_table")
469
+ or in_block
470
+ )
471
+ continue
472
+
473
+ if in_block and _NUMERIC_ROW_RE.match(ln):
474
+ keep.append(ln)
475
+ continue
476
+
477
+ # Any other non-comment, non-numeric line breaks a block context
478
+ in_block = False
479
+
480
+ return "\n".join(keep)
481
+
482
+
483
+ # ---------------------------------------------------------------------
484
+ # Prompt-driven runner
485
+ # ---------------------------------------------------------------------
486
+
487
+
488
+ class ScaleFreeRunner:
489
+ """
490
+ Runs the ScaleFree Fortran executable and parses structured output.
491
+
492
+ Defaults to "no persistent files":
493
+ - if output_path is None, we still answer the Fortran
494
+ "Output file" prompt with a
495
+ temporary filename, but we delete it after the run and
496
+ return output_path=None.
497
+ """
498
+
499
+ def __init__(
500
+ self,
501
+ exe_path: Optional[Union[str, Path]] = None,
502
+ workdir: Optional[Union[str, Path]] = None,
503
+ ):
504
+ exe, wd = _resolve_executable(exe_path, workdir)
505
+ self.exe_path = exe
506
+ self.workdir = wd
507
+
508
+ if not self.exe_path.exists():
509
+ raise FileNotFoundError(f"Executable not found: {self.exe_path}")
510
+
511
+ def vprofile(
512
+ self,
513
+ *,
514
+ potential: Any,
515
+ gamma: float,
516
+ q: float,
517
+ beta: float,
518
+ s: float,
519
+ t: float,
520
+ inclination: float,
521
+ xi: float,
522
+ theta: float,
523
+ df: int = 1,
524
+ integration: int = 1,
525
+ # 0 Romberg, 1 Gauss-Legendre
526
+ ngl_or_eps: float = 0.0,
527
+ # eps if Romberg; nGL if Gauss-Legendre (0 -> default)
528
+ algorithm: int = 1,
529
+ # VP algorithm (1 default)
530
+ maxmom: int = 4,
531
+ average: bool = False,
532
+ usevp: bool = False,
533
+ verbose_vp: int = 0,
534
+ output_path: Optional[Union[str, Path]] = None,
535
+ timeout_s: int = 120,
536
+ parse_stdout_fallback: bool = False,
537
+ # legacy flag; kept for compatibility
538
+ debug_prompts: bool = False,
539
+ ) -> ScaleFreeResult:
540
+ ipot = _potential_code(potential)
541
+
542
+ # ---------------------------------------------------------
543
+ # Output handling
544
+ # ---------------------------------------------------------
545
+ persist_file = output_path is not None
546
+ delete_after = False
547
+
548
+ if output_path is None:
549
+ # Create a temp filename to satisfy Fortran prompt,
550
+ # but delete afterwards.
551
+ outname = (
552
+ "scalefree_"
553
+ + "tmp_"
554
+ + f"{os.getpid()}_{id(self) % 10_000_000}"
555
+ + ".txt"
556
+ )
557
+ delete_after = True
558
+ else:
559
+ outname = str(output_path)
560
+ outname = (
561
+ Path(
562
+ outname,
563
+ ).name
564
+ if Path(outname).is_absolute()
565
+ else outname
566
+ )
567
+
568
+ out_path = self.workdir / outname
569
+
570
+ # ---------------------------------------------------------
571
+ # Prompt answers
572
+ # ---------------------------------------------------------
573
+ answers: Dict[str, str] = {
574
+ "Kepler (1) or Logarithmic (2)": str(ipot),
575
+ "Power-law slope gamma": _fmt(gamma),
576
+ "Intrinsic axial ratio q": _fmt(q),
577
+ "Case I (1) or Case II (2) DF": str(int(df)),
578
+ "Case I (1) or Case II (2)": str(int(df)),
579
+ "Anisotropy parameter beta": _fmt(beta),
580
+ "Odd part parameters s and t": f"{_fmt(s)} {_fmt(t)}",
581
+ "Viewing inclination i": _fmt(inclination),
582
+ "Use Romberg (0) or Gauss-Legendre (1)": str(int(integration)),
583
+ "Give the fractional accuracy epsilon": _fmt(ngl_or_eps),
584
+ "Give number of quadrature points": (
585
+ str(int(ngl_or_eps)) if float(ngl_or_eps).is_integer() else "0"
586
+ ),
587
+ "Choose 1 for default.": str(int(algorithm)),
588
+ "Give the maximum number of projected moments": str(int(maxmom)),
589
+ "Give the number of projected moments": str(int(maxmom)),
590
+ # Always answer output file prompt to avoid blocking.
591
+ "Output file": outname,
592
+ }
593
+
594
+ # Choose iwhat values based on average flag
595
+ if average:
596
+ iwhat_intr = 2
597
+ iwhat_proj = 3
598
+ else:
599
+ iwhat_intr = 0
600
+ iwhat_proj = 1
601
+
602
+ phase = {"step": 0} # 0 intrinsic, 1 projected
603
+
604
+ def respond(line: str) -> Optional[str]:
605
+ for key, val in answers.items():
606
+ if key in line:
607
+ return val
608
+
609
+ # iwhat prompt
610
+ if "Calculate intrinsic (0) or projected (1)" in line:
611
+ return (
612
+ str(iwhat_intr)
613
+ if phase["step"] == 0
614
+ else str(
615
+ iwhat_proj,
616
+ )
617
+ )
618
+
619
+ # theta prompt (intrinsic)
620
+ if "Give angle theta in the meridional plane" in line:
621
+ return _fmt(theta)
622
+
623
+ # xi prompt (projected)
624
+ if "Give angle on the projected plane" in line:
625
+ return _fmt(xi)
626
+
627
+ # verbose prompt (only for projected modes)
628
+ if "Give verbose output of intermediate steps" in line:
629
+ return str(int(verbose_vp))
630
+
631
+ # VP prompt variants
632
+ if (
633
+ ("Calculate VPs" in line)
634
+ or ("Use VPs" in line)
635
+ or ("VP" in line and "?" in line)
636
+ ):
637
+ return "1" if usevp else "0"
638
+
639
+ # continue? ("Calculate something else for this model?")
640
+ if "Calculate something else for this model" in line:
641
+ if phase["step"] == 0:
642
+ phase["step"] = 1
643
+ return "1"
644
+ return "0"
645
+
646
+ return None
647
+
648
+ # ---------------------------------------------------------
649
+ # Run Fortran interactively
650
+ # ---------------------------------------------------------
651
+ p = subprocess.Popen(
652
+ [str(self.exe_path)],
653
+ cwd=str(self.workdir),
654
+ stdin=subprocess.PIPE,
655
+ stdout=subprocess.PIPE,
656
+ stderr=subprocess.PIPE,
657
+ text=True,
658
+ bufsize=1,
659
+ )
660
+
661
+ stdout_lines: List[str] = []
662
+ stderr_text = ""
663
+
664
+ try:
665
+ assert p.stdout is not None and p.stdin is not None
666
+
667
+ for line in p.stdout:
668
+ stdout_lines.append(line)
669
+ if debug_prompts:
670
+ print(line, end="")
671
+
672
+ ans = respond(line)
673
+ if ans is not None:
674
+ p.stdin.write(ans + "\n")
675
+ p.stdin.flush()
676
+
677
+ stderr_text = p.stderr.read() if p.stderr else ""
678
+ rc = p.wait(timeout=timeout_s)
679
+
680
+ except subprocess.TimeoutExpired:
681
+ p.kill()
682
+ raise RuntimeError(f"Fortran run timed out after {timeout_s}s.")
683
+
684
+ stdout_text = "".join(stdout_lines)
685
+
686
+ try:
687
+ if rc != 0 or "STOP Wrong answer" in stderr_text:
688
+ raise RuntimeError(
689
+ "Fortran execution failed.\n\n"
690
+ f"Return code: {rc}\n"
691
+ f"STDERR:\n{stderr_text}\n\n"
692
+ f"STDOUT (first 2000 chars):\n{stdout_text[:2000]}\n"
693
+ )
694
+
695
+ # ---------------------------------------------------------
696
+ # Parse output: stable preference depends on whether
697
+ # caller requested a file
698
+ # ---------------------------------------------------------
699
+
700
+ # (A) If caller explicitly requested a file,
701
+ # prefer parsing that file
702
+ if persist_file and out_path.exists():
703
+ raw = out_path.read_text(encoding="utf-8", errors="replace")
704
+ blocks = parse_scalefree_output(raw)
705
+ return ScaleFreeResult(
706
+ blocks=blocks,
707
+ raw_text=raw,
708
+ output_path=out_path,
709
+ stdout=stdout_text,
710
+ stderr=stderr_text,
711
+ )
712
+
713
+ # (B) Otherwise, prefer structured blocks from stdout (Option A)
714
+ raw_stdout_struct = _extract_structured_from_stdout(stdout_text)
715
+ if raw_stdout_struct.strip():
716
+ blocks = parse_scalefree_output(raw_stdout_struct)
717
+ if blocks:
718
+ return ScaleFreeResult(
719
+ blocks=blocks,
720
+ raw_text=raw_stdout_struct,
721
+ output_path=(
722
+ None
723
+ if delete_after
724
+ else (out_path if persist_file else None)
725
+ ),
726
+ stdout=stdout_text,
727
+ stderr=stderr_text,
728
+ )
729
+
730
+ # (C) Fallback: if a file exists (temp or explicit), parse it
731
+ if out_path.exists():
732
+ raw = out_path.read_text(encoding="utf-8", errors="replace")
733
+ blocks = parse_scalefree_output(raw)
734
+ return ScaleFreeResult(
735
+ blocks=blocks,
736
+ raw_text=raw,
737
+ output_path=out_path if persist_file else None,
738
+ stdout=stdout_text,
739
+ stderr=stderr_text,
740
+ )
741
+
742
+ # (D) Legacy fallback flag (kept, but typically redundant now)
743
+ if parse_stdout_fallback:
744
+ blocks = parse_scalefree_output(raw_stdout_struct)
745
+ return ScaleFreeResult(
746
+ blocks=blocks,
747
+ raw_text=raw_stdout_struct,
748
+ output_path=None,
749
+ stdout=stdout_text,
750
+ stderr=stderr_text,
751
+ )
752
+
753
+ raise RuntimeError(
754
+ "Fortran returned success but no structured"
755
+ "output was detected.\n"
756
+ "Expected either structured '# kind=...' "
757
+ "blocks in STDOUT or an output file.\n"
758
+ f"STDOUT (first 2000 chars):\n{stdout_text[:2000]}\n"
759
+ )
760
+
761
+ finally:
762
+ # Clean up temporary file if we created it
763
+ # purely to satisfy Fortran prompts
764
+ if delete_after:
765
+ try:
766
+ if out_path.exists():
767
+ out_path.unlink()
768
+ except Exception:
769
+ # Non-fatal cleanup failure
770
+ pass
771
+
772
+
773
+ # ---------------------------------------------------------------------
774
+ # Simple functional API (what most users will call)
775
+ # ---------------------------------------------------------------------
776
+
777
+
778
+ def vprofile(
779
+ *,
780
+ exe_path: Optional[Union[str, Path]] = None,
781
+ potential: Any,
782
+ gamma: float,
783
+ q: float,
784
+ beta: float,
785
+ s: float,
786
+ t: float,
787
+ inclination: float,
788
+ xi: float,
789
+ theta: float,
790
+ df: int = 1,
791
+ integration: int = 1,
792
+ ngl_or_eps: float = 0.0,
793
+ algorithm: int = 1,
794
+ maxmom: int = 4,
795
+ average: bool = False,
796
+ usevp: bool = False,
797
+ verbose_vp: int = 0,
798
+ output_path: Optional[Union[str, Path]] = None,
799
+ timeout_s: int = 120,
800
+ parse_stdout_fallback: bool = False,
801
+ debug_prompts: bool = False,
802
+ workdir: Optional[Union[str, Path]] = None,
803
+ ) -> ScaleFreeResult:
804
+ """
805
+ Convenience function that instantiates a runner and executes vprofile.
806
+
807
+ Users can omit exe_path; the backend will be resolved/built automatically.
808
+
809
+ Output behavior:
810
+ - output_path=None (default): do not leave output files behind
811
+ - output_path="something.txt": write and keep that file
812
+ (useful for regression)
813
+ """
814
+ runner = ScaleFreeRunner(exe_path=exe_path, workdir=workdir)
815
+ return runner.vprofile(
816
+ potential=potential,
817
+ gamma=gamma,
818
+ q=q,
819
+ beta=beta,
820
+ s=s,
821
+ t=t,
822
+ inclination=inclination,
823
+ xi=xi,
824
+ theta=theta,
825
+ df=df,
826
+ integration=integration,
827
+ ngl_or_eps=ngl_or_eps,
828
+ algorithm=algorithm,
829
+ maxmom=maxmom,
830
+ average=average,
831
+ usevp=usevp,
832
+ verbose_vp=verbose_vp,
833
+ output_path=output_path,
834
+ timeout_s=timeout_s,
835
+ parse_stdout_fallback=parse_stdout_fallback,
836
+ debug_prompts=debug_prompts,
837
+ )