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.
- README.md +585 -0
- fortran_src/scalefree.f +3323 -0
- scalefree/__init__.py +12 -0
- scalefree/vmoments.py +837 -0
- scalefree-0.1.2.dist-info/METADATA +609 -0
- scalefree-0.1.2.dist-info/RECORD +8 -0
- scalefree-0.1.2.dist-info/WHEEL +4 -0
- scalefree-0.1.2.dist-info/entry_points.txt +0 -0
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
|
+
)
|