added find pi

This commit is contained in:
Dominik Krenn 2025-11-05 12:20:31 +01:00
parent f5cc02a08c
commit 88ee579114
8 changed files with 632 additions and 0 deletions

140
find_my.py Normal file
View File

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Pi explorer powered by mathstream.
Computes π using the Nilakantha series with streamed big-int arithmetic and
optionally renders the progress in a curses dashboard (`--ui`).
"""
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Iterable, Optional
from mathstream import (
clear_logs,
manual_free_only_enabled,
set_manual_free_only,
)
from pi_finder import iterate_nilakantha
def run_cli_mode(
*,
digits: int,
iterations: int,
extra_precision: int,
show_steps: bool,
) -> tuple[str, int]:
previous_manual = manual_free_only_enabled()
set_manual_free_only(True)
clear_logs()
final_value: Optional[str] = None
last_iteration = 0
try:
for state in iterate_nilakantha(
iterations,
digits=digits,
extra_precision=extra_precision,
):
final_value = state.digits
last_iteration = state.iteration
if show_steps:
print(f"[iter {state.iteration:02d}] {state.digits}")
if final_value is None:
final_value = "0"
last_iteration = 0
if show_steps:
print(final_value)
finally:
set_manual_free_only(previous_manual)
return final_value, last_iteration
def run_ui_mode(max_digits: Optional[int]) -> None:
try:
from pi_finder.ui import launch_pi_ui
except ImportError as exc: # pragma: no cover - import guard
raise SystemExit(f"pi UI is unavailable: {exc}") from exc
launch_pi_ui(max_digits=max_digits)
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Nilakantha-based π explorer using mathstream streamed arithmetic."
)
parser.add_argument(
"--digits",
type=int,
default=25,
help="Digits of π to keep after the decimal point (default: 25).",
)
parser.add_argument(
"--iterations",
type=int,
default=10,
help="Nilakantha iterations to run (each adds roughly two decimal digits).",
)
parser.add_argument(
"--extra",
type=int,
default=4,
help="Extra guard digits maintained during calculation for stability (default: 4).",
)
parser.add_argument(
"--show-steps",
action="store_true",
help="Print every intermediate approximation instead of just the final value.",
)
parser.add_argument(
"--output",
type=Path,
default=Path("found.pi"),
help="Optional path to write the final approximation (default: found.pi).",
)
parser.add_argument(
"--ui",
action="store_true",
help="Launch the curses dashboard instead of printing digits.",
)
parser.add_argument(
"--max-digits",
type=int,
default=None,
help="Limit digits in UI mode (defaults to --digits).",
)
return parser.parse_args(list(argv) if argv is not None else None)
def main(argv: Optional[Iterable[str]] = None) -> None:
args = parse_args(argv)
if args.ui:
precision = args.max_digits if args.max_digits is not None else max(10, args.digits)
run_ui_mode(precision)
else:
digits = max(0, args.digits)
iterations = max(0, args.iterations)
extra = max(0, args.extra)
result, last_iter = run_cli_mode(
digits=digits,
iterations=iterations,
extra_precision=extra,
show_steps=args.show_steps,
)
output_path: Path = args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
data = result if result.endswith("\n") else f"{result}\n"
output_path.write_text(data, encoding="utf-8")
digits_written = sum(ch.isdigit() for ch in result)
print(
f"π approximation (iteration {last_iter}, {digits_written} digits) written to {output_path}"
)
if __name__ == "__main__":
main()

9
pi_finder/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from __future__ import annotations
from .engine import PiApproximation, compute_pi_text, iterate_nilakantha
__all__ = [
"PiApproximation",
"iterate_nilakantha",
"compute_pi_text",
]

142
pi_finder/engine.py Normal file
View File

@ -0,0 +1,142 @@
from __future__ import annotations
import itertools
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Iterator
from mathstream import StreamNumber, add, sub, mul, div
from mathstream.number import LOG_DIR
@dataclass
class PiApproximation:
iteration: int
digits: str
def _literal(value: int | str) -> StreamNumber:
return StreamNumber(literal=str(value))
_SCALE_CACHE: dict[int, Path] = {}
def _scaled_one(scale_power: int) -> StreamNumber:
if scale_power < 0:
raise ValueError("scale_power must be non-negative")
cached = _SCALE_CACHE.get(scale_power)
if cached and cached.exists():
return StreamNumber(cached)
LOG_DIR.mkdir(parents=True, exist_ok=True)
path = LOG_DIR / f"scale_1e{scale_power}.txt"
if not path.exists():
with open(path, "w", encoding="utf-8") as handle:
handle.write("1")
remaining = scale_power
if remaining > 0:
chunk_size = 1_000_000
zero_chunk = "0" * chunk_size
while remaining > 0:
write_size = min(chunk_size, remaining)
handle.write(zero_chunk[:write_size])
remaining -= write_size
_SCALE_CACHE[scale_power] = path
return StreamNumber(path)
def _format_scaled(value: StreamNumber, scale_power: int, digits: int) -> str:
raw = "".join(value.stream())
negative = raw.startswith("-")
if negative:
raw = raw[1:]
if len(raw) <= scale_power:
raw = raw.zfill(scale_power + 1)
integer_part = raw[:-scale_power] or "0"
fractional_part = raw[-scale_power:] if scale_power else ""
if digits >= 0 and fractional_part:
fractional_part = fractional_part[:digits]
text = integer_part
if digits != 0:
text = f"{integer_part}.{fractional_part or '0'}"
if negative:
text = f"-{text}"
return text
def iterate_nilakantha(
iterations: int | None,
*,
digits: int,
extra_precision: int = 4,
) -> Iterator[PiApproximation]:
"""Yield successive Nilakantha approximations of π using mathstream primitives."""
if iterations is not None and iterations < 0:
raise ValueError("iterations must be non-negative")
if digits < 0:
raise ValueError("digits must be non-negative")
scale_power = digits + extra_precision
scale = _scaled_one(scale_power)
three = _literal(3)
four = _literal(4)
total = mul(three, scale)
numerator = mul(four, scale)
try:
yield PiApproximation(0, _format_scaled(total, scale_power, digits))
counter: Iterable[int]
if iterations is None:
counter = itertools.count(1)
else:
counter = range(1, iterations + 1)
for idx in counter:
two_n = _literal(2 * idx)
two_n_plus_one = _literal(2 * idx + 1)
two_n_plus_two = _literal(2 * idx + 2)
product = mul(two_n, two_n_plus_one)
denominator = mul(product, two_n_plus_two)
term = div(numerator, denominator)
updated = add(total, term) if idx % 2 else sub(total, term)
total.free()
total = updated
yield PiApproximation(idx, _format_scaled(total, scale_power, digits))
for number in (
two_n,
two_n_plus_one,
two_n_plus_two,
product,
denominator,
term,
):
number.free()
finally:
for number in (total, numerator, scale, three, four):
number.free()
def compute_pi_text(
iterations: int,
*,
digits: int,
extra_precision: int = 4,
) -> str:
"""Return the final Nilakantha approximation after the requested iterations."""
approximation: str = "0"
for state in iterate_nilakantha(
iterations,
digits=digits,
extra_precision=extra_precision,
):
approximation = state.digits
return approximation

18
pi_finder/ui/__init__.py Normal file
View File

@ -0,0 +1,18 @@
from __future__ import annotations
import curses
from functools import partial
from typing import Optional
from .app import PiApp
def launch_pi_ui(max_digits: Optional[int] = None) -> None:
"""Launch the curses dashboard for streaming π digits."""
wrapper = partial(_run_curses_app, max_digits=max_digits)
curses.wrapper(wrapper) # type: ignore[arg-type]
def _run_curses_app(stdscr: "curses._CursesWindow", max_digits: Optional[int]) -> None:
app = PiApp(stdscr, max_digits=max_digits)
app.run()

132
pi_finder/ui/app.py Normal file
View File

@ -0,0 +1,132 @@
from __future__ import annotations
import curses
import threading
import time
from dataclasses import dataclass
from typing import Optional
from mathstream import clear_logs, manual_free_only_enabled, set_manual_free_only
from pi_finder.engine import iterate_nilakantha
from .dashboard import PiDashboard
@dataclass
class SharedState:
digits_text: str = "0"
digits_count: int = 0
last_digit: Optional[str] = None
computing: bool = False
iteration: int = 0
class PiApp:
"""Controller coordinating the π spigot worker and curses dashboard."""
def __init__(
self,
stdscr: "curses._CursesWindow",
*,
max_digits: Optional[int],
) -> None:
self.stdscr = stdscr
self.dashboard = PiDashboard(stdscr)
requested = max_digits if max_digits is not None else 50
self.precision = max(1, requested)
self.state = SharedState()
self._state_lock = threading.Lock()
self._stop_event = threading.Event()
self._worker: Optional[threading.Thread] = None
self._start_time = time.time()
def run(self) -> None:
self._start_time = time.time()
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
self._worker.start()
try:
self._ui_loop()
finally:
self._stop_event.set()
if self._worker:
self._worker.join()
def _ui_loop(self) -> None:
while True:
snapshot = self._snapshot()
elapsed = time.time() - self._start_time
rate = snapshot.digits_count / elapsed if elapsed > 0 else 0.0
self.dashboard.render(
snapshot.digits_text,
snapshot.digits_count,
elapsed,
rate,
snapshot.last_digit,
snapshot.computing,
snapshot.iteration,
)
ch = self.stdscr.getch()
if ch == ord("q"):
self._stop_event.set()
break
if ch != -1:
self.dashboard.handle_input(ch)
if self._stop_event.is_set():
break
time.sleep(0.05)
def _worker_loop(self) -> None:
previous_manual = manual_free_only_enabled()
set_manual_free_only(True)
clear_logs()
generator = None
try:
generator = iterate_nilakantha(
iterations=None,
digits=self.precision,
extra_precision=4,
)
for state in generator:
if self._stop_event.is_set():
break
with self._state_lock:
self.state.computing = True
calc_start = time.time()
digits_text = state.digits
calc_duration = time.time() - calc_start
numeric_chars = [ch for ch in digits_text if ch.isdigit()]
last_digit = numeric_chars[-1] if numeric_chars else None
with self._state_lock:
self.state.digits_text = digits_text
self.state.digits_count = len(numeric_chars)
self.state.last_digit = last_digit
self.state.iteration = state.iteration
self.state.computing = False
if calc_duration < 0.05:
time.sleep(0.05)
finally:
if generator is not None:
generator.close()
set_manual_free_only(previous_manual)
with self._state_lock:
self.state.computing = False
def _snapshot(self) -> SharedState:
with self._state_lock:
return SharedState(
digits_text=self.state.digits_text,
digits_count=self.state.digits_count,
last_digit=self.state.last_digit,
computing=self.state.computing,
iteration=self.state.iteration,
)

38
pi_finder/ui/colors.py Normal file
View File

@ -0,0 +1,38 @@
from __future__ import annotations
import curses
class UIColors:
"""Shared color palette for the π dashboard."""
HEADER = 1
SEPARATOR = 2
NUMBER = 3
STATUS = 4
WARNING = 5
SUCCESS = 6
METRIC = 7
@classmethod
def setup(cls) -> None:
if not curses.has_colors():
return
curses.start_color()
curses.use_default_colors()
curses.init_pair(cls.HEADER, curses.COLOR_CYAN, -1)
curses.init_pair(cls.SEPARATOR, curses.COLOR_BLUE, -1)
curses.init_pair(cls.NUMBER, curses.COLOR_WHITE, -1)
curses.init_pair(cls.STATUS, curses.COLOR_MAGENTA, -1)
curses.init_pair(cls.WARNING, curses.COLOR_RED, -1)
curses.init_pair(cls.SUCCESS, curses.COLOR_GREEN, -1)
curses.init_pair(cls.METRIC, curses.COLOR_YELLOW, -1)
@staticmethod
def style(pair: int, *, bold: bool = False, dim: bool = False) -> int:
attr = curses.color_pair(pair)
if bold:
attr |= curses.A_BOLD
if dim:
attr |= curses.A_DIM
return attr

58
pi_finder/ui/dashboard.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import annotations
import curses
from .colors import UIColors
from .views import DigitsView, HeaderView, HelpView
class PiDashboard:
"""Coordinates curses windows for the π streaming UI."""
def __init__(self, stdscr: "curses._CursesWindow") -> None:
self.stdscr = stdscr
try:
curses.curs_set(0)
except curses.error:
pass
self.stdscr.nodelay(True)
self.stdscr.timeout(100)
UIColors.setup()
lines, cols = curses.LINES, curses.COLS
header_h = 6
help_h = 1
digits_h = max(1, lines - header_h - help_h - 1)
self.header = HeaderView(header_h, cols, 0, 0)
self.digits = DigitsView(digits_h, cols, header_h, 0)
self.help = HelpView(header_h + digits_h, 0, cols)
self.scroll = 0
def render(
self,
digits_text: str,
digits_count: int,
elapsed: float,
rate: float,
last_digit: str | None,
computing: bool,
iteration: int,
) -> None:
self.header.render(
iteration,
digits_count,
elapsed,
rate,
last_digit,
computing,
)
self.scroll = self.digits.render(digits_text, self.scroll)
self.help.render()
curses.doupdate()
def handle_input(self, ch: int) -> None:
if ch == curses.KEY_UP:
self.scroll = max(0, self.scroll - 1)
elif ch == curses.KEY_DOWN:
self.scroll += 1

95
pi_finder/ui/views.py Normal file
View File

@ -0,0 +1,95 @@
from __future__ import annotations
import curses
from typing import Optional
from .colors import UIColors
class HeaderView:
"""Top panel with live metrics."""
def __init__(self, height: int, width: int, y: int, x: int) -> None:
self.win = curses.newwin(height, width, y, x)
def render(
self,
iteration: int,
digits: int,
elapsed: float,
rate: float,
last_digit: Optional[str],
computing: bool,
) -> None:
self.win.erase()
style = UIColors.style(UIColors.HEADER, bold=True)
metric_style = UIColors.style(UIColors.METRIC, bold=True)
status_style = UIColors.style(UIColors.STATUS)
lines = [
" π Stream Dashboard ",
f" Iteration: {iteration:,}",
f" Digits: {digits:,}",
f" Elapsed: {elapsed:8.2f}s • Rate: {rate:8.2f} digits/s",
]
status = " Generating…" if computing else " Idle"
digit_line = f" Last digit: {last_digit if last_digit is not None else ''}"
try:
self.win.addstr(0, 0, lines[0], style)
self.win.addstr(1, 0, lines[1], metric_style)
self.win.addstr(2, 0, lines[2], metric_style)
self.win.addstr(3, 0, lines[3], metric_style)
self.win.addstr(4, 0, digit_line, metric_style)
self.win.addstr(5, 0, status, status_style)
width = max(1, self.win.getmaxyx()[1] - 1)
self.win.addstr(6, 0, "" * width, UIColors.style(UIColors.SEPARATOR))
except curses.error:
pass
self.win.noutrefresh()
class DigitsView:
"""Scrollable window for the π digits."""
def __init__(self, height: int, width: int, y: int, x: int) -> None:
self.win = curses.newwin(height, width, y, x)
def render(self, digits_text: str, scroll: int) -> int:
self.win.erase()
max_y, max_x = self.win.getmaxyx()
if max_y <= 0 or max_x <= 0:
self.win.noutrefresh()
return 0
cols = max(1, max_x - 1)
chunks = [digits_text[i : i + cols] for i in range(0, len(digits_text), cols)] or ["0"]
max_scroll = max(0, len(chunks) - max_y)
scroll = max(0, min(scroll, max_scroll))
style = UIColors.style(UIColors.NUMBER)
for idx, chunk in enumerate(chunks[scroll : scroll + max_y]):
try:
self.win.addstr(idx, 0, chunk, style)
except curses.error:
break
self.win.noutrefresh()
return scroll
class HelpView:
"""Bottom row with key hints."""
def __init__(self, y: int, x: int, width: int) -> None:
self.win = curses.newwin(1, width, y, x)
def render(self) -> None:
message = " ↑↓ scroll • q quit"
try:
self.win.addstr(0, 0, message, UIColors.style(UIColors.STATUS, dim=True))
except curses.error:
pass
self.win.noutrefresh()