diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7e49bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +venv/ +.venv/ +env/ +*.env + +# Flask +instance/ +.flask_session/ + +# Electron / Node +node_modules/ +package-lock.json +out/ +dist-electron/ + +# IDE / OS +.DS_Store +.idea/ +.vscode/ +*.swp +*~ +Thumbs.db diff --git a/README.md b/README.md index ff38f9d..d3a61bb 100644 --- a/README.md +++ b/README.md @@ -1 +1,78 @@ -# gui-v2 \ No newline at end of file +# CosmoSIS GUI v2 + +A graphical pipeline editor for [CosmoSIS](https://cosmosis.readthedocs.io/). +Built with **Electron** (frontend shell) and **Python** (backend logic via stdin/stdout IPC). + +--- + +## Architecture + +The GUI uses a clean Electron-native architecture — no HTTP server required: + +``` +Electron main process + ├─ loads templates/index.html via loadFile() (file:// — no port) + ├─ spawns worker.py as a child process + └─ bridges IPC calls: renderer ↔ ipcMain ↔ worker.py stdin/stdout +``` + +All Python I/O (scanning module libraries, parsing pipeline INI files) is +handled by `worker.py` — a simple JSON-RPC worker that reads requests from +stdin and writes responses to stdout. This replaces the previous +Flask/Socket.IO HTTP server approach, which had several drawbacks: + +| | Old (Flask) | New (IPC) | +|-|-------------|-----------| +| Port conflicts | Yes (macOS blocked 5000) | None | +| Startup delay | 200 ms polling loop | Instant | +| Extra Python deps | Flask, flask-socketio, flask-cors, simple-websocket | None | +| Security surface | localhost HTTP socket | Subprocess pipe only | + +--- + +## Requirements + +| Tool | Version | +|------|---------| +| Python | ≥ 3.9 | +| Node.js | ≥ 18 | +| CosmoSIS | ≥ 3.22 | + +--- + +## Getting started + +### 1 — Install Python dependencies + +```bash +pip install -r requirements.txt +``` + +### 2 — Install Node.js / Electron dependencies + +```bash +npm install +``` + +### 3 — Run the application + +```bash +npm start +``` + +Electron will spawn `worker.py` as a subprocess and open the window immediately. + +--- + +## Usage + +* **Open Library** — click the button in the left sidebar and choose the root + of a CosmoSIS standard library directory. The Python worker recursively + scans for `module.yaml` files and populates the module library panel. + +* **Open Pipeline** — click the button and choose a CosmoSIS `.ini` pipeline + file. The worker parses it with `Inifile`, resolves each module against + the scanned library, and populates the pipeline canvas. + +* **Drag modules** from the library panel onto the canvas to build a custom + pipeline. diff --git a/inifile.py b/inifile.py new file mode 100644 index 0000000..3678161 --- /dev/null +++ b/inifile.py @@ -0,0 +1,183 @@ +"""Bundled subset of the CosmoSIS Inifile class (cosmosis.runtime.config). + +This module is used as a fallback when the full ``cosmosis`` package is not +installed (e.g. because its Fortran/C extensions cannot be compiled in the +current environment). When ``cosmosis`` *is* available the classes are +imported from there instead, so the behaviour is identical to the real package. + +Only the two pure-Python classes required by the GUI are included here: +``IncludingConfigParser`` and ``Inifile``. The source is verbatim from the +``cosmosis`` package (MIT licence). +""" + +try: + # Prefer the real cosmosis package when available. + from cosmosis.runtime.config import Inifile, IncludingConfigParser # noqa: F401 +except Exception: # ImportError, or compilation failures on some platforms + # ── Verbatim copy from cosmosis/runtime/config.py ──────────────────── + import collections + import configparser + import io + import os + + class IncludingConfigParser(configparser.ConfigParser): + """Extension of ConfigParser that supports ``%include filename.ini``.""" + + def __init__(self, defaults=None, print_include_messages=True, + no_expand_vars=False): + self.no_expand_vars = no_expand_vars + configparser.ConfigParser.__init__( + self, + defaults=defaults, + dict_type=collections.OrderedDict, + strict=False, + inline_comment_prefixes=(";", "#"), + ) + self.print_include_messages = print_include_messages + + def _read(self, fp, fpname): + s = io.StringIO() + for line in fp: + if not self.no_expand_vars: + line = os.path.expandvars(line) + if line.lower().startswith("%include"): + _, filename = line.split() + filename = filename.strip('"').strip("'") + if self.print_include_messages: + print(f"Reading included ini file: {filename}") + if not os.path.exists(filename): + raise ValueError( + f"Tried to include non-existent file {filename}" + ) + sub_ini = self.__class__(filename) + sub_ini.write(s) + else: + s.write(line) + s.seek(0) + return super()._read(s, fpname) + + class CosmosisConfigurationError(configparser.Error): + pass + + class Inifile(IncludingConfigParser): + """A ``(section, name) → value`` mapping backed by a ``.ini`` file.""" + + def __init__(self, filename, defaults=None, override=None, + print_include_messages=True, no_expand_vars=False): + IncludingConfigParser.__init__( + self, + defaults=defaults, + print_include_messages=print_include_messages, + no_expand_vars=no_expand_vars, + ) + if isinstance(filename, dict): + for section, values in filename.items(): + self.add_section(section) + for key, value in values.items(): + self.set(section, key, str(value)) + elif isinstance(filename, Inifile): + s = io.StringIO() + filename.write(s) + s.seek(0) + self.read_file(s) + elif hasattr(filename, "read"): + self.read_file(filename) + elif filename is not None: + if isinstance(filename, str) and not os.path.exists(filename): + raise IOError( + f"Unable to open configuration file `{filename}'" + ) + self.read(filename) + + if override: + for section, name in override: + if section == "DEFAULT": + self._defaults[name] = override[(section, name)] + else: + if not self.has_section(section): + self.add_section(section) + self.set(section, name, override[(section, name)]) + + def get(self, section, option, raw=False, vars=None, + fallback=configparser._UNSET): + try: + return IncludingConfigParser.get( + self, section, option, raw=raw, vars=vars, + fallback=fallback + ) + except (configparser.NoSectionError, + configparser.NoOptionError) as exc: + if fallback is configparser._UNSET: + raise CosmosisConfigurationError( + f"CosmoSIS looked for '{option}' in '[{section}]' " + "but it was not in the ini file" + ) from exc + return fallback + + def __getitem__(self, key): + section, option = key + return self.get(section, option) + + def __setitem__(self, key, value): + section, option = key + self.set(section, option, str(value)) + + def __iter__(self): + return ( + ((section, name), value) + for section in self.sections() + for name, value in self.items(section) + ) + + def getboolean(self, section, option, raw=False, vars=None, + fallback=configparser._UNSET): + try: + return IncludingConfigParser.getboolean( + self, section, option, raw=raw, vars=vars, + fallback=fallback + ) + except ValueError: + value = self.get(section, option).lower() + if value in ("y", "yes", "t", "true"): + return True + if value in ("n", "no", "f", "false"): + return False + raise ValueError( + f"Cannot parse '{value}' as boolean " + f"for [{section}] {option}" + ) + except (configparser.NoSectionError, + configparser.NoOptionError) as exc: + if fallback is configparser._UNSET: + raise CosmosisConfigurationError( + f"CosmoSIS looked for boolean '{option}' in " + f"'[{section}]'" + ) from exc + return fallback + + def gettyped(self, section, name): + """Best-guess the type of a parameter.""" + import re + value = IncludingConfigParser.get(self, section, name) + value = value.strip() + if not value: + return None + m = re.match(r"^(['\"])(.*?)\1$", value) + if m is not None: + return m.group(2) + value_list = value.split() + try: + parsed = [int(s) for s in value_list] + return parsed[0] if len(parsed) == 1 else parsed + except ValueError: + pass + try: + parsed = [float(s) for s in value_list] + return parsed[0] if len(parsed) == 1 else parsed + except ValueError: + pass + try: + return self.getboolean(section, name) + except ValueError: + pass + return value diff --git a/main.js b/main.js new file mode 100644 index 0000000..3b79b5a --- /dev/null +++ b/main.js @@ -0,0 +1,175 @@ +/** + * main.js — Electron main process for the CosmoSIS GUI. + * + * Responsibilities: + * 1. Spawn the Python JSON-RPC worker as a child process (stdin/stdout). + * 2. Load the HTML page directly from disk — no HTTP server required. + * 3. Forward native OS dialog requests from the renderer via ipcMain. + * 4. Bridge Python calls from the renderer (IPC ↔ Python stdin/stdout). + * 5. Terminate the Python process when the window is closed. + * + * Why no Flask? + * The previous approach spawned a Flask/Socket.IO HTTP server on localhost and + * polled it until ready before opening the window. That caused port-conflict + * issues on macOS and added four Python dependencies that this app doesn't need. + * Since the GUI runs entirely inside Electron there is no reason for an HTTP + * server: we load the HTML from disk with loadFile() and relay all Python I/O + * through Electron's built-in IPC + a simple stdin/stdout JSON-RPC worker. + */ + +const { app, BrowserWindow, ipcMain, dialog } = require("electron"); +const path = require("path"); +const readline = require("readline"); +const { spawn } = require("child_process"); + +let pythonWorker = null; +let mainWindow = null; + +// ── App directory (script location, not launch CWD) ─────────────────────── +const appDir = app.isPackaged + ? path.join(process.resourcesPath, "app") + : path.join(__dirname); + +// ── In-flight JSON-RPC calls ─────────────────────────────────────────────── +// Maps request id → { resolve, reject } +const _pending = new Map(); +let _nextId = 1; + +// ── Start the Python worker ──────────────────────────────────────────────── +function startWorker() { + const pythonExe = process.platform === "win32" ? "python" : "python3"; + + pythonWorker = spawn( + pythonExe, + [path.join(appDir, "worker.py")], + { + cwd: appDir, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + } + ); + + // Read responses line-by-line from the worker's stdout. + // Only lines that begin with '{' are JSON-RPC responses; any other output + // (e.g. direct prints from C extensions inside cosmosis that bypass the + // Python-level stdout redirect) is forwarded to stderr for diagnostics. + const rl = readline.createInterface({ input: pythonWorker.stdout }); + rl.on("line", line => { + if (!line.startsWith("{")) { + if (line.trim()) process.stderr.write(`[worker/stdout] ${line}\n`); + return; + } + let resp; + try { resp = JSON.parse(line); } + catch (e) { console.error("[worker] JSON parse error:", e.message, "Line:", line); return; } + + const pending = _pending.get(resp.id); + if (pending) { + _pending.delete(resp.id); + // Pass error as a plain string so the ipcMain handler can return it + // directly to the renderer without losing the message. + if (resp.error) pending.reject(resp.error); + else pending.resolve(resp.result); + } + }); + + pythonWorker.stderr.on("data", d => + process.stderr.write(`[worker] ${d}`) + ); + pythonWorker.on("exit", (code, signal) => { + if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGKILL") + console.error(`[worker] exited code=${code} signal=${signal}`); + }); +} + +// ── Send a JSON-RPC request to the Python worker ────────────────────────── +function callPython(method, params = {}) { + return new Promise((resolve, reject) => { + const id = _nextId++; + _pending.set(id, { resolve, reject }); + pythonWorker.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + }); +} + +// ── Create the browser window ────────────────────────────────────────────── +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + title: "CosmoSIS", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + // Load the HTML directly from disk — no HTTP server needed. + mainWindow.loadFile(path.join(__dirname, "templates", "index.html")); + + mainWindow.on("closed", () => { mainWindow = null; }); +} + +// ── IPC: native dialogs ─────────────────────────────────────────────────── + +ipcMain.handle("dialog:openDirectory", async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: "Select CosmoSIS standard library directory", + properties: ["openDirectory"], + }); + return result.canceled ? null : result.filePaths[0]; +}); + +ipcMain.handle("dialog:openIniFile", async () => { + const result = await dialog.showOpenDialog(mainWindow, { + title: "Open CosmoSIS pipeline INI file", + filters: [ + { name: "INI files", extensions: ["ini"] }, + { name: "All files", extensions: ["*"] }, + ], + properties: ["openFile"], + }); + return result.canceled ? null : result.filePaths[0]; +}); + +// ── IPC: startup auto-scan ──────────────────────────────────────────────── +// If npm was launched from a directory different to the script directory, +// return that directory so the renderer can auto-scan it for modules. +ipcMain.handle("app:getStartupScanDir", () => { + const launchDir = process.cwd(); + return path.resolve(launchDir) !== path.resolve(appDir) ? launchDir : null; +}); + +// ── IPC: Python calls ───────────────────────────────────────────────────── + +ipcMain.handle("python:call", async (_event, { method, params }) => { + try { + const result = await callPython(method, params); + return { result, error: null }; + } catch (err) { + // err is the error string from the Python worker (or a Node Error if the + // worker process itself fails). + return { result: null, error: typeof err === "string" ? err : err.message }; + } +}); + +// ── App lifecycle ────────────────────────────────────────────────────────── + +app.whenReady().then(() => { + startWorker(); + createWindow(); + + // macOS: re-open window when dock icon is clicked and no windows are open. + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +app.on("window-all-closed", () => { + if (pythonWorker) { pythonWorker.kill(); pythonWorker = null; } + if (process.platform !== "darwin") app.quit(); +}); + +app.on("before-quit", () => { + if (pythonWorker) { pythonWorker.kill(); pythonWorker = null; } +}); diff --git a/module_library.py b/module_library.py new file mode 100644 index 0000000..adfed14 --- /dev/null +++ b/module_library.py @@ -0,0 +1,202 @@ +"""CosmoSIS module library scanner. + +Parses module.yaml files from a CosmoSIS standard library directory. +The Python layer is responsible for all YAML parsing and data extraction +so that other parts of the application can rely on the structured data. +""" + +import os +import re + +import yaml + + +def _safe_str(val): + """Return val as a stripped string, or '' for None/falsy values.""" + if val is None: + return "" + return str(val).strip() + + +def _section_names(port_dict): + """Return the top-level section names from an inputs/outputs mapping.""" + if not isinstance(port_dict, dict): + return [] + return [k for k, v in port_dict.items() if k and isinstance(v, dict)] + + +def _extract_section_items(section_dict): + """Extract individual parameter info from a section dict. + + Returns a list of ``{"name", "type", "description"}`` dicts for each + parameter key found inside the section. + """ + if not isinstance(section_dict, dict): + return [] + result = [] + for param_name, info in section_dict.items(): + if not param_name or not isinstance(info, dict): + continue + result.append({ + "name": str(param_name), + "type": _safe_str(info.get("type", "")), + "description": _safe_str(info.get("meaning", "")), + }) + return result + + +def _extract_params(params_dict): + """Extract module configuration parameters from the ``params`` key. + + Returns a list of ``{"name", "type", "default", "meaning"}`` dicts. + Default values that are explicitly ``None``/empty in the YAML are + normalised to Python ``None``. + """ + if not isinstance(params_dict, dict): + return [] + result = [] + for name, info in params_dict.items(): + if not name or not isinstance(info, dict): + continue + default = info.get("default") + if default is not None and str(default).strip().lower() in ("", "none", "null"): + default = None + result.append({ + "name": str(name), + "type": _safe_str(info.get("type", "")), + "default": default, + "meaning": _safe_str(info.get("meaning", "")), + }) + return result + + +def parse_module_yaml(yaml_text, source_path=""): + """Parse the text of a single module.yaml file. + + Returns a module dict compatible with the GUI, or None if the file is + empty/a template (i.e. the ``name`` field is blank). + + The returned dict always contains a ``_raw`` key with the full parsed YAML + data so that other parts of the application can access all fields without + re-parsing. + + Inputs and outputs are represented as lists of section dicts:: + + {"name": section_name, "type": "section", "description": "", + "items": [{"name": param_name, "type": ..., "description": ...}, ...]} + + ``params`` contains the module's configuration parameters:: + + [{"name": ..., "type": ..., "default": ..., "meaning": ...}, ...] + """ + try: + data = yaml.safe_load(yaml_text) + except yaml.YAMLError: + return None + + if not isinstance(data, dict): + return None + + name = _safe_str(data.get("name", "")) + if not name: + # Template file or structurally empty — skip + return None + + category = _safe_str(data.get("category", "")) or "Other" + + # Use 'purpose' as the short description; fall back to first line of 'explanation' + description = _safe_str(data.get("purpose", "")) + if not description: + explanation = _safe_str(data.get("explanation", "")) + description = explanation.split("\n")[0].strip().strip('" ') + + inputs_raw = data.get("inputs") or {} + outputs_raw = data.get("outputs") or {} + params_raw = data.get("params") or {} + + inputs = [ + { + "name": s, + "type": "section", + "description": "", + "items": _extract_section_items(inputs_raw[s]), + } + for s in _section_names(inputs_raw) + ] + + outputs = [ + { + "name": s, + "type": "section", + "description": "", + "items": _extract_section_items(outputs_raw[s]), + } + for s in _section_names(outputs_raw) + ] + + params = _extract_params(params_raw) + + # Build a stable id from the name field + module_id = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower().strip("_") + + return { + "id": module_id, + "name": name, + "category": category, + "description": description, + "inputs": inputs, + "outputs": outputs, + "params": params, + # Retain the complete parsed YAML for future use + "_raw": data, + "_source": source_path, + } + + +def parse_yaml_files(file_list): + """Parse a list of ``{"path": str, "content": str}`` dicts. + + This is the entry-point used when the browser sends the raw YAML file + contents to the server (via the Socket.IO ``scan_library`` event). + + Returns a sorted list of module dicts (template/empty files omitted). + """ + modules = [] + for item in file_list: + path = item.get("path", "") + content = item.get("content", "") + module = parse_module_yaml(content, source_path=path) + if module is not None: + modules.append(module) + + modules.sort(key=lambda m: (m["category"].lower(), m["name"].lower())) + return modules + + +def scan_directory(directory): + """Walk *directory* recursively, find all ``module.yaml`` files, parse them. + + Returns a sorted list of module dicts (template/empty files omitted). + Raises ``ValueError`` if *directory* is not a valid directory path. + """ + directory = os.path.expanduser(directory) + if not os.path.isdir(directory): + raise ValueError(f"Not a directory: {directory!r}") + + modules = [] + for dirpath, _dirnames, filenames in os.walk(directory): + for filename in filenames: + if filename != "module.yaml": + continue + filepath = os.path.join(dirpath, filename) + try: + with open(filepath, encoding="utf-8", errors="replace") as fh: + text = fh.read() + except OSError: + continue + module = parse_module_yaml(text, source_path=filepath) + if module is not None: + modules.append(module) + + modules.sort(key=lambda m: (m["category"].lower(), m["name"].lower())) + return modules diff --git a/modules.py b/modules.py new file mode 100644 index 0000000..3232d8f --- /dev/null +++ b/modules.py @@ -0,0 +1,137 @@ +# Placeholder module definitions for CosmoSIS + +AVAILABLE_MODULES = [ + { + "id": "consistency", + "name": "Consistency", + "category": "Cosmology", + "description": "Derive consistent cosmological parameters (e.g. omega_m from omega_b + omega_cdm)", + "inputs": [ + {"name": "cosmological_parameters/omega_b", "type": "float", "description": "Baryon density"}, + {"name": "cosmological_parameters/omega_cdm", "type": "float", "description": "Cold dark matter density"}, + ], + "outputs": [ + {"name": "cosmological_parameters/omega_m", "type": "float", "description": "Total matter density"}, + {"name": "cosmological_parameters/omega_lambda", "type": "float", "description": "Dark energy density"}, + ], + }, + { + "id": "distances", + "name": "Distances", + "category": "Cosmology", + "description": "Compute cosmological distance measures", + "inputs": [ + {"name": "cosmological_parameters/omega_m", "type": "float", "description": "Matter density"}, + {"name": "cosmological_parameters/omega_lambda", "type": "float", "description": "Dark energy density"}, + {"name": "cosmological_parameters/h0", "type": "float", "description": "Hubble parameter"}, + ], + "outputs": [ + {"name": "distances/d_a", "type": "array", "description": "Angular diameter distances"}, + {"name": "distances/d_l", "type": "array", "description": "Luminosity distances"}, + {"name": "distances/d_c", "type": "array", "description": "Comoving distances"}, + ], + }, + { + "id": "boltzmann_camb", + "name": "CAMB", + "category": "Boltzmann Codes", + "description": "Boltzmann code to compute CMB and matter power spectra", + "inputs": [ + {"name": "cosmological_parameters/omega_b", "type": "float", "description": "Baryon density"}, + {"name": "cosmological_parameters/omega_cdm", "type": "float", "description": "Cold dark matter density"}, + {"name": "cosmological_parameters/h0", "type": "float", "description": "Hubble parameter"}, + {"name": "cosmological_parameters/n_s", "type": "float", "description": "Spectral index"}, + {"name": "cosmological_parameters/A_s", "type": "float", "description": "Scalar amplitude"}, + ], + "outputs": [ + {"name": "cmb_cl/tt", "type": "array", "description": "CMB TT power spectrum"}, + {"name": "cmb_cl/ee", "type": "array", "description": "CMB EE power spectrum"}, + {"name": "cmb_cl/te", "type": "array", "description": "CMB TE cross-spectrum"}, + {"name": "matter_power_lin/p_k", "type": "array", "description": "Linear matter power spectrum"}, + {"name": "distances/d_l", "type": "array", "description": "Luminosity distances"}, + ], + }, + { + "id": "halofit", + "name": "Halofit", + "category": "Power Spectra", + "description": "Non-linear matter power spectrum using the Halofit fitting formula", + "inputs": [ + {"name": "matter_power_lin/p_k", "type": "array", "description": "Linear matter power spectrum"}, + {"name": "cosmological_parameters/omega_m", "type": "float", "description": "Matter density"}, + ], + "outputs": [ + {"name": "matter_power_nl/p_k", "type": "array", "description": "Non-linear matter power spectrum"}, + ], + }, + { + "id": "shear_cl", + "name": "Shear Cl", + "category": "Weak Lensing", + "description": "Compute weak lensing shear angular power spectra", + "inputs": [ + {"name": "matter_power_nl/p_k", "type": "array", "description": "Non-linear matter power spectrum"}, + {"name": "nz/bin_1", "type": "array", "description": "Galaxy n(z) bin 1"}, + {"name": "nz/bin_2", "type": "array", "description": "Galaxy n(z) bin 2"}, + ], + "outputs": [ + {"name": "shear_cl/bin_1_1", "type": "array", "description": "Shear C_ℓ bin 1×1"}, + {"name": "shear_cl/bin_1_2", "type": "array", "description": "Shear C_ℓ bin 1×2"}, + {"name": "shear_cl/bin_2_2", "type": "array", "description": "Shear C_ℓ bin 2×2"}, + ], + }, + { + "id": "planck_likelihood", + "name": "Planck", + "category": "Likelihoods", + "description": "Planck 2018 CMB temperature and polarisation likelihood", + "inputs": [ + {"name": "cmb_cl/tt", "type": "array", "description": "CMB TT power spectrum"}, + {"name": "cmb_cl/ee", "type": "array", "description": "CMB EE power spectrum"}, + {"name": "cmb_cl/te", "type": "array", "description": "CMB TE cross-spectrum"}, + ], + "outputs": [ + {"name": "likelihoods/planck_like", "type": "float", "description": "Planck log-likelihood"}, + ], + }, + { + "id": "des_shear", + "name": "DES Shear", + "category": "Likelihoods", + "description": "DES Year 3 weak gravitational lensing likelihood", + "inputs": [ + {"name": "shear_cl/bin_1_1", "type": "array", "description": "Shear C_ℓ bin 1×1"}, + {"name": "shear_cl/bin_1_2", "type": "array", "description": "Shear C_ℓ bin 1×2"}, + {"name": "shear_cl/bin_2_2", "type": "array", "description": "Shear C_ℓ bin 2×2"}, + ], + "outputs": [ + {"name": "likelihoods/des_shear_like", "type": "float", "description": "DES shear log-likelihood"}, + ], + }, +] + +# The Sampler is always the first module in the pipeline and cannot be removed. +SAMPLER_MODULE = { + "id": "sampler", + "name": "Sampler", + "description": "Parameter sampler — draws samples from the cosmological parameter space", + "permanent": True, + "inputs": [], + "outputs": [ + {"name": "cosmological_parameters/omega_b", "type": "float", "description": "Baryon density"}, + {"name": "cosmological_parameters/omega_cdm", "type": "float", "description": "Cold dark matter density"}, + {"name": "cosmological_parameters/omega_m", "type": "float", "description": "Total matter density parameter Ωₘ"}, + {"name": "cosmological_parameters/h0", "type": "float", "description": "Hubble parameter"}, + {"name": "cosmological_parameters/n_s", "type": "float", "description": "Scalar spectral index"}, + {"name": "cosmological_parameters/A_s", "type": "float", "description": "Scalar amplitude"}, + {"name": "cosmological_parameters/sigma_8", "type": "float", "description": "Clustering amplitude σ₈"}, + ], +} + + +def get_available_modules(): + return AVAILABLE_MODULES + + +def get_initial_pipeline(): + return [SAMPLER_MODULE] diff --git a/package.json b/package.json new file mode 100644 index 0000000..48e90d6 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "cosmosis-gui", + "version": "0.1.0", + "description": "CosmoSIS graphical pipeline editor", + "main": "main.js", + "scripts": { + "start": "electron .", + "start:dev": "FLASK_DEBUG=1 electron ." + }, + "keywords": ["cosmosis", "pipeline", "electron"], + "license": "MIT", + "devDependencies": { + "electron": "^34.0.0" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..adf4570 --- /dev/null +++ b/preload.js @@ -0,0 +1,42 @@ +/** + * preload.js — runs in the renderer context with Node integration OFF. + * + * Exposes a minimal, safe API surface to the renderer via contextBridge so + * that the page JavaScript can trigger native OS dialogs and call the Python + * worker without having direct access to Node.js APIs. + */ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("electronAPI", { + /** + * Open a native directory-picker dialog. + * @returns {Promise} The chosen directory path, or null if + * the user cancelled. + */ + openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"), + + /** + * Open a native file-picker dialog filtered to .ini files. + * @returns {Promise} The chosen file path, or null if the + * user cancelled. + */ + openIniFile: () => ipcRenderer.invoke("dialog:openIniFile"), + + /** + * Call a method on the Python worker and return a promise resolving to + * { result, error }. The error field is null on success. + * + * @param {string} method JSON-RPC method name (e.g. "scan_library_dir") + * @param {object} params Method parameters + * @returns {Promise<{result: any, error: string|null}>} + */ + call: (method, params) => ipcRenderer.invoke("python:call", { method, params }), + + /** + * Returns the directory from which the app was launched (process.cwd()), + * or null if it is the same as the application directory. Used for + * auto-scanning a CosmoSIS standard library on startup. + * @returns {Promise} + */ + getStartupScanDir: () => ipcRenderer.invoke("app:getStartupScanDir"), +}); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fec8b9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyYAML>=6.0 +cosmosis>=3.22 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..5ddc58e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,905 @@ +/* ── Reset & custom properties ─────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #3b82f6; + --primary-dark: #1d4ed8; + --sampler-fill: #ede9fe; + --sampler-stroke: #a78bfa; + --sampler-hover: #ddd6fe; + --module-fill: #dbeafe; + --module-stroke: #93c5fd; + --module-hover: #bfdbfe; + --module-run-fill: #d1fae5; + --module-run-stroke: #059669; + --module-run-hover: #a7f3d0; + --sel-fill: #bfdbfe; + --sel-stroke: #3b82f6; + --inp-bg: #d1fae5; + --inp-border: #059669; + --inp-default-bg: #fff7ed; + --inp-default-border: #f97316; + --out-bg: #fef3c7; + --out-border: #d97706; + --bg: #f8fafc; + --canvas-bg: #f1f5f9; + --sidebar-bg: #f8fafc; + --border: #e2e8f0; + --text: #1e293b; + --text-light: #64748b; + --text-muted: #94a3b8; + --white: #ffffff; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 14px; + color: var(--text); + background: var(--bg); + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ── Header & tabs ─────────────────────────────────────────────── */ +header { + background: var(--white); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 20px; + height: 52px; + gap: 24px; + flex-shrink: 0; +} + +.app-title { + font-size: 17px; + font-weight: 700; + color: var(--primary-dark); + letter-spacing: -0.3px; + white-space: nowrap; +} + +.tab-bar { display: flex; gap: 2px; } + +.tab-btn { + padding: 6px 16px; + border: none; + background: none; + color: var(--text-light); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + height: 52px; +} +.tab-btn:hover { color: var(--text); } +.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } + +/* ── Main layout ───────────────────────────────────────────────── */ +main { flex: 1; overflow: hidden; display: flex; flex-direction: column; } + +.tab-content { display: none; flex: 1; overflow: hidden; flex-direction: column; } +.tab-content.active { display: flex; } + +.tab-placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 15px; +} + +/* ── Pipeline three-column layout ──────────────────────────────── */ +.pipeline-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +/* ── Left sidebar ──────────────────────────────────────────────── */ +.left-sidebar { + width: 210px; + flex-shrink: 0; + background: var(--sidebar-bg); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + padding: 10px 14px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-light); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.open-library-btn { + display: flex; + align-items: center; + gap: 5px; + background: var(--primary); + color: var(--white); + border: none; + border-radius: 6px; + padding: 5px 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + width: 100%; + justify-content: center; + transition: background 0.12s; + text-transform: none; + letter-spacing: 0; +} +.open-library-btn:hover { background: var(--primary-dark); } + +.open-pipeline-btn { + display: flex; + align-items: center; + gap: 5px; + background: var(--white); + color: var(--primary); + border: 1px solid var(--primary); + border-radius: 6px; + padding: 5px 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + width: 100%; + justify-content: center; + transition: background 0.12s, color 0.12s; + text-transform: none; + letter-spacing: 0; +} +.open-pipeline-btn:hover { background: var(--primary); color: var(--white); } + +.scan-status { + padding: 4px 8px; + font-size: 11px; + color: var(--text-light); + background: var(--border); + border-radius: 4px; + line-height: 1.35; + word-break: break-word; +} +.scan-status.scan-status-error { color: #dc2626; background: #fee2e2; } +.scan-status.hidden { display: none; } + +.module-list { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ── Category groups in module library ─────────────────────── */ +.category-group { display: flex; flex-direction: column; } + +.category-toggle { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + background: none; + border: none; + padding: 5px 4px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-light); + cursor: pointer; + text-align: left; + user-select: none; + border-radius: 5px; + transition: background 0.1s; +} +.category-toggle:hover { background: var(--border); color: var(--text); } + +.category-chevron { + display: inline-block; + width: 12px; + font-style: normal; + transition: transform 0.15s; + flex-shrink: 0; +} +/* Chevron: collapsed = ▶ (pointing right), expanded = ▼ (pointing down) */ +.category-chevron::before { content: "▶"; font-size: 9px; } +.category-toggle.expanded .category-chevron { transform: rotate(90deg); } + +.category-modules { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 8px; + margin-top: 2px; +} + +.module-card { + background: var(--white); + border: 1px solid var(--border); + border-radius: 7px; + padding: 9px 11px; + cursor: grab; + user-select: none; + transition: border-color 0.12s, box-shadow 0.12s; +} +.module-card:hover { + border-color: var(--primary); + box-shadow: 0 1px 5px rgba(59, 130, 246, 0.15); +} +.module-card.dragging { opacity: 0.5; cursor: grabbing; } +.module-card-name { font-weight: 600; font-size: 13px; margin-bottom: 2px; } +.module-card-desc { font-size: 11px; color: var(--text-light); line-height: 1.35; } + +/* ── Pipeline canvas ───────────────────────────────────────────── */ +.pipeline-canvas-container { + flex: 1; + overflow: auto; + background: var(--canvas-bg); + position: relative; + min-height: 0; +} + +.pipeline-svg { display: block; } + +/* SVG module boxes */ +.module-rect { + fill: var(--module-fill); + stroke: var(--module-stroke); + stroke-width: 1.5; +} +.module-rect.permanent { fill: var(--sampler-fill); stroke: var(--sampler-stroke); } +/* Run-ok state: module executed successfully */ +.module-rect.run-ok { fill: var(--module-run-fill); stroke: var(--module-run-stroke); } +.module-rect.permanent.run-ok { fill: var(--module-run-fill); stroke: var(--module-run-stroke); } +.module-rect.selected { fill: var(--sel-fill); stroke: var(--sel-stroke); stroke-width: 2.5; } +.module-rect.permanent.selected { fill: var(--sampler-hover); stroke: #7c3aed; stroke-width: 2.5; } + +.module-group { cursor: pointer; } +.module-group:hover .module-rect { fill: var(--module-hover); stroke: var(--primary); } +.module-group:hover .module-rect.permanent { fill: var(--sampler-hover); stroke: #7c3aed; } +.module-group:hover .module-rect.run-ok { fill: var(--module-run-hover); stroke: var(--module-run-stroke); } +.module-group:hover .module-rect.permanent.run-ok { fill: var(--module-run-hover); stroke: var(--module-run-stroke); } + +.module-text { + fill: var(--text); + font-family: inherit; + font-size: 13px; + font-weight: 600; + pointer-events: none; +} +.module-remove { + fill: var(--text-muted); + font-size: 15px; + cursor: pointer; + font-family: inherit; +} +.module-remove:hover { fill: #ef4444; } + +/* Port boxes */ +.port-rect { stroke-width: 1; } +.port-rect.input-port { fill: var(--inp-bg); stroke: var(--inp-border); } +.port-rect.output-port { fill: var(--out-bg); stroke: var(--out-border); } +/* Default input: READ-DEFAULT — value was provided by the module's own default */ +.port-rect.input-default-port { fill: var(--inp-default-bg); stroke: var(--inp-default-border); } + +.port-label { + font-family: inherit; + font-size: 10px; + fill: var(--text); + pointer-events: none; +} + +.port-connector { stroke: #cbd5e1; stroke-width: 1; stroke-dasharray: 3 2; } +.port-edge { pointer-events: none; } +.insertion-indicator { pointer-events: none; } + +/* Expandable section boxes */ +.section-port { cursor: pointer; } +.section-port:hover { filter: brightness(0.93); } + +/* Chevron inside port boxes */ +.port-chevron { + font-family: inherit; + font-size: 8px; + fill: var(--text-light); +} + +/* Sub-parameter boxes */ +.port-rect.input-sub-port { fill: #bbf7d0; stroke: #16a34a; } +.port-rect.output-sub-port { fill: #fde68a; stroke: #ca8a04; } +.port-rect.input-default-sub-port { fill: #fed7aa; stroke: #f97316; } + +.sub-label { + font-family: inherit; + font-size: 9px; + fill: var(--text); + pointer-events: none; +} + +/* Sub-connector: thinner, lighter than section connector */ +.sub-connector { + stroke: #94a3b8; + stroke-width: 0.75; + stroke-dasharray: 2 2; +} + +/* ── Right sidebar ─────────────────────────────────────────────── */ +.right-sidebar { + width: 300px; + flex-shrink: 0; + background: var(--sidebar-bg); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + transition: width 0.18s ease, border-color 0.18s; +} +.right-sidebar.hidden { + width: 0; + border-left-color: transparent; +} + +.sidebar-title-bar { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} +.sidebar-title-bar h2 { + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-close { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 5px; + border-radius: 4px; + line-height: 1; + flex-shrink: 0; +} +.sidebar-close:hover { background: var(--border); color: var(--text); } + +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: 14px; +} + +.module-detail-desc { + color: var(--text-light); + font-size: 12px; + line-height: 1.5; + margin-bottom: 14px; +} + +.detail-section { margin-bottom: 14px; } +.detail-section h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-light); + margin-bottom: 6px; +} + +.port-detail { + padding: 7px 9px; + border-radius: 5px; + margin-bottom: 4px; +} +.port-detail.input { background: var(--inp-bg); border-left: 3px solid var(--inp-border); } +.port-detail.output { background: var(--out-bg); border-left: 3px solid var(--out-border); } +/* Default input (READ-DEFAULT): used module default — shown in amber */ +.port-detail.input-default { background: var(--inp-default-bg); border-left: 3px solid var(--inp-default-border); } + +/* Expandable port rows in the sidebar */ +.port-detail.expandable-port { cursor: pointer; } +.port-detail.expandable-port:hover { filter: brightness(0.96); } + +.sidebar-chevron { + display: inline-block; + font-size: 9px; + margin-right: 4px; + color: var(--text-light); +} + +/* Sub-param list inside an expanded section */ +.sub-params-list { + margin: 2px 0 6px 14px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sub-param-row { + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.sub-param-row.input { background: #d1fce8; border-left: 2px solid #16a34a; } +.sub-param-row.output { background: #fef0a0; border-left: 2px solid #ca8a04; } +.sub-param-row.input-default { background: #fed7aa; border-left: 2px solid #f97316; } + +.sub-param-name { + font-family: "SFMono-Regular", Consolas, monospace; + font-weight: 600; + font-size: 11px; +} + +.port-name { + display: flex; + align-items: center; + font-size: 11px; + font-weight: 600; + font-family: "SFMono-Regular", Consolas, monospace; +} +.port-type { + display: inline-block; + font-size: 10px; + background: rgba(0, 0, 0, 0.07); + border-radius: 3px; + padding: 1px 5px; + margin-top: 2px; + color: var(--text-light); +} +.port-desc { + display: block; + font-size: 11px; + color: var(--text-light); + margin-top: 2px; + line-height: 1.3; +} + +.no-ports { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +/* ── Params form ───────────────────────────────────────────────── */ +.params-form { + display: flex; + flex-direction: column; + gap: 6px; +} + +.param-row { + display: flex; + flex-direction: column; + gap: 3px; +} + +.param-name-btn { + background: none; + border: none; + padding: 0; + text-align: left; + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 11px; + font-weight: 600; + color: var(--text); + cursor: pointer; + width: fit-content; +} +.param-name-btn:hover { color: var(--primary); text-decoration: underline; } + +.param-input-wrap { + display: flex; + flex-direction: column; + gap: 2px; +} + +.param-input { + width: 100%; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 12px; + font-family: inherit; + background: var(--white); + color: var(--text); + transition: border-color 0.12s; +} +.param-input:focus { outline: none; border-color: var(--primary); } +.param-input.param-input-invalid { border-color: #ef4444; background: #fff5f5; } + +.param-checkbox { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--primary); +} + +.param-error { + font-size: 10px; + color: #dc2626; +} +.param-error.hidden { display: none; } + +.param-meaning { + font-size: 11px; + color: var(--text-light); + background: #f1f5f9; + border-left: 2px solid var(--border); + padding: 4px 7px; + border-radius: 0 4px 4px 0; + line-height: 1.4; +} +.param-meaning.hidden { display: none; } + +/* ── Pipeline Actions section (bottom of left sidebar) ─────────── */ +.pipeline-actions { + flex-shrink: 0; + border-bottom: 1px solid var(--border); + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.pipeline-actions-header { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-light); +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + border-radius: 6px; + padding: 5px 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + width: 100%; + transition: background 0.12s, color 0.12s; + text-transform: none; + letter-spacing: 0; + border: none; +} + +.action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.action-btn-primary { + background: var(--primary); + color: var(--white); + border: none; +} +.action-btn-primary:hover:not(:disabled) { background: var(--primary-dark); } + +.action-btn-secondary { + background: var(--white); + color: var(--primary); + border: 1px solid var(--primary); +} +.action-btn-secondary:hover:not(:disabled) { background: var(--primary); color: var(--white); } + +.pipeline-status { + padding: 4px 8px; + font-size: 11px; + color: var(--text-light); + background: var(--border); + border-radius: 4px; + line-height: 1.35; + word-break: break-word; + display: flex; + align-items: center; + gap: 5px; +} +.pipeline-status.pipeline-status-error { color: #dc2626; background: #fee2e2; } +.pipeline-status.pipeline-status-ok { color: #15803d; background: #dcfce7; } +/* Amber: pipeline is setting up */ +.pipeline-status.pipeline-status-setup { color: #92400e; background: #fef3c7; } +/* Blue: likelihood is running */ +.pipeline-status.pipeline-status-run { color: #1e40af; background: #dbeafe; } +.pipeline-status.hidden { display: none; } + +/* Spinning indicator (shown during setup / run) */ +@keyframes spin { to { transform: rotate(360deg); } } +.pipeline-status-spinner { + display: inline-block; + width: 10px; + height: 10px; + border: 1.5px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} +.pipeline-status-spinner.hidden { display: none; } + +/* Pipeline-actions area turns green when ready to run */ +.pipeline-actions.pipeline-ready { + background: #f0fdf4; + border-bottom-color: #86efac; +} +.pipeline-actions.pipeline-ready .action-btn-secondary { + background: #15803d; + color: var(--white); + border-color: #15803d; +} +.pipeline-actions.pipeline-ready .action-btn-secondary:hover:not(:disabled) { + background: #166534; + border-color: #166534; +} + +/* ── Module setup output in right sidebar ──────────────────────── */ +.setup-output-pre { + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 10px; + color: var(--text); + background: #f8fafc; + border: 1px solid var(--border); + border-radius: 4px; + padding: 7px 9px; + margin: 0; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; + line-height: 1.4; +} + +/* ── Results tab ────────────────────────────────────────────────── */ +.results-layout { + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; + background: var(--bg); +} + +/* Left panel: collapsible section tree */ +.results-left-panel { + width: 50%; + flex-shrink: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--border); +} + +/* Right panel: Plotly plot */ +.results-right-panel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.results-placeholder { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 15px; + padding: 24px; + text-align: center; +} + +.results-tree { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 2px; +} +.results-tree.hidden { display: none; } + +.results-section { display: flex; flex-direction: column; } + +.results-section-header { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 7px 8px; + cursor: pointer; + text-align: left; + font-size: 13px; + font-weight: 600; + color: var(--text); + border-radius: 5px; + width: 100%; + transition: background 0.1s; +} +.results-section-header:hover { background: var(--border); } + +.results-chevron { + display: inline-block; + width: 12px; + flex-shrink: 0; + transition: transform 0.15s; +} +.results-chevron::before { content: "▶"; font-size: 9px; color: var(--text-light); } +.results-section-header.expanded .results-chevron { transform: rotate(90deg); } + +.results-section-name { flex: 1; font-family: "SFMono-Regular", Consolas, monospace; } + +.results-section-count { + font-size: 10px; + background: var(--border); + border-radius: 10px; + padding: 1px 7px; + color: var(--text-light); + font-weight: 400; +} + +.results-section-body { + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 1px; + margin-bottom: 4px; +} + +.results-value-row { + display: flex; + align-items: center; + gap: 5px; + padding: 3px 7px; + border-radius: 4px; + cursor: pointer; + transition: background 0.1s; +} +.results-value-row:hover { background: #e0f2fe; } +.results-value-row.sel-y { background: #dbeafe; } +.results-value-row.sel-x { background: #ffedd5; } + +.results-value-name { + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 12px; + font-weight: 600; + color: var(--text); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.results-value-type { + font-size: 10px; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 1px 5px; + color: var(--text-light); + white-space: nowrap; + flex-shrink: 0; +} + +.results-value-scalar { + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 11px; + color: var(--text-light); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; + flex-shrink: 1; + min-width: 0; +} + +/* X / Y axis-role buttons shown inside each plottable row */ +.results-axis-btn { + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + cursor: pointer; + line-height: 1.5; + flex-shrink: 0; + background: none; + transition: background 0.1s, color 0.1s, border-color 0.1s; +} +.results-axis-btn.btn-y { + border: 1px solid var(--primary); + color: var(--primary); +} +.results-axis-btn.btn-y:hover, +.results-axis-btn.btn-y.active { + background: var(--primary); + color: var(--white); +} +.results-axis-btn.btn-x { + border: 1px solid #f97316; + color: #f97316; +} +.results-axis-btn.btn-x:hover, +.results-axis-btn.btn-x.active { + background: #f97316; + color: var(--white); +} + +/* ── Plot toolbar (top of right panel) ─────────────────────────── */ +.results-plot-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + background: var(--sidebar-bg); + gap: 8px; +} + +.results-plot-info { + font-size: 12px; + color: var(--text-light); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.results-plot-btns { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.results-log-btn { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--white); + color: var(--text-light); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.results-log-btn:hover { border-color: var(--primary); color: var(--primary); } +.results-log-btn.active { + background: var(--primary); + color: var(--white); + border-color: var(--primary); +} + +.results-plot-div { + flex: 1; + min-height: 0; + overflow: hidden; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..77cf66f --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,907 @@ +/** + * app.js — main application logic + * + * Handles: + * - Tab switching + * - IPC calls to the Python Backend worker + * - Left sidebar: module library, "Open Library" / "Open Pipeline" buttons + * - Left sidebar: "Prepare Pipeline" / "Run Likelihood" buttons + * - Right sidebar: module details with expandable sections, params form, + * and per-module pipeline setup output + */ +document.addEventListener("DOMContentLoaded", () => { + + // ── Tab switching ──────────────────────────────────────────── + const tabBtns = document.querySelectorAll(".tab-btn"); + const tabPanels = document.querySelectorAll(".tab-content"); + tabBtns.forEach(btn => { + btn.addEventListener("click", () => { + tabBtns.forEach(b => { b.classList.remove("active"); b.setAttribute("aria-selected","false"); }); + tabPanels.forEach(p => p.classList.remove("active")); + btn.classList.add("active"); + btn.setAttribute("aria-selected","true"); + const panel = document.getElementById("tab-" + btn.dataset.tab); + if (panel) panel.classList.add("active"); + }); + }); + + // ── Pipeline canvas ────────────────────────────────────────── + const pipeline = new PipelineCanvas("pipeline-canvas-container"); + + // ── Python worker bridge ───────────────────────────────────── + async function pyCall(method, params = {}) { + if (!window.electronAPI?.call) { + throw new Error("Python worker not available (running outside Electron)."); + } + return window.electronAPI.call(method, params); + } + + // ── Per-module setup output storage ────────────────────────── + // Key: ini_section name (string), Value: output text (string) + const modulePrepOutput = new Map(); + + // ── Load initial data ───────────────────────────────────────── + async function loadInitialData() { + try { + const [modRes, pipRes] = await Promise.all([ + pyCall("get_modules", {}), + pyCall("get_pipeline", {}), + ]); + if (modRes.error) console.error("[get_modules]", modRes.error); + else renderModuleLibrary(modRes.result); + if (pipRes.error) console.error("[get_pipeline]", pipRes.error); + else pipeline.setModules(pipRes.result); + } catch (err) { + console.warn("Initial data load skipped:", err.message); + } + + // Auto-scan: if the app was launched from a directory that is different + // from the script directory, scan that launch directory for modules. + try { + const scanDir = window.electronAPI?.getStartupScanDir + ? await window.electronAPI.getStartupScanDir() + : null; + if (scanDir) { + setScanStatus(`Auto-scanning ${scanDir}\u2026`, false); + const res = await pyCall("scan_library_dir", { path: scanDir }); + if (res.error) setScanStatus("Auto-scan error: " + res.error, true); + else { renderModuleLibrary(res.result); setScanStatus("", false); } + } + } catch (err) { + console.warn("Startup auto-scan skipped:", err.message); + } + } + + loadInitialData(); + + // ── Open Library button ─────────────────────────────────────── + const openLibraryBtn = document.getElementById("open-library-btn"); + const scanStatusEl = document.getElementById("scan-status"); + + openLibraryBtn.addEventListener("click", async () => { + let dir; + if (window.electronAPI) { + dir = await window.electronAPI.openDirectory(); + if (!dir) return; + } else { + dir = prompt("Enter the path to the CosmoSIS standard library directory:", ""); + if (dir === null || dir.trim() === "") return; + dir = dir.trim(); + } + + setScanStatus("Scanning\u2026", false); + try { + const res = await pyCall("scan_library_dir", { path: dir }); + if (res.error) setScanStatus("Error: " + res.error, true); + else { renderModuleLibrary(res.result); setScanStatus("", false); } + } catch (err) { + setScanStatus("Error: " + err.message, true); + } + }); + + // ── Open Pipeline button ────────────────────────────────────── + const openPipelineBtn = document.getElementById("open-pipeline-btn"); + + openPipelineBtn.addEventListener("click", async () => { + let iniPath; + if (window.electronAPI) { + iniPath = await window.electronAPI.openIniFile(); + if (!iniPath) return; + } else { + iniPath = prompt("Enter the path to a CosmoSIS pipeline .ini file:", ""); + if (iniPath === null || iniPath.trim() === "") return; + iniPath = iniPath.trim(); + } + + setScanStatus("Loading pipeline\u2026", false); + try { + const res = await pyCall("load_pipeline_ini", { path: iniPath }); + if (res.error) { + setScanStatus("Pipeline load error: " + res.error, true); + } else { + // Clear any stale prep output from a previous pipeline. + modulePrepOutput.clear(); + pipeline.setModules(res.result); + setScanStatus("", false); + // Enable Prepare Pipeline now that a file is loaded; reset run button. + preparePipelineBtn.disabled = false; + runLikelihoodBtn.disabled = true; + setPipelineStatus("", ""); + } + } catch (err) { + setScanStatus("Pipeline load error: " + err.message, true); + } + }); + + function setScanStatus(msg, isError) { + if (!msg) { scanStatusEl.classList.add("hidden"); scanStatusEl.textContent = ""; return; } + scanStatusEl.textContent = msg; + scanStatusEl.classList.remove("hidden"); + scanStatusEl.classList.toggle("scan-status-error", isError); + } + + // ── Prepare Pipeline button ─────────────────────────────────── + const preparePipelineBtn = document.getElementById("prepare-pipeline-btn"); + const runLikelihoodBtn = document.getElementById("run-likelihood-btn"); + const pipelineStatusEl = document.getElementById("pipeline-status"); + const pipelineActionsEl = document.querySelector(".pipeline-actions"); + + preparePipelineBtn.addEventListener("click", async () => { + setPipelineStatus("Setting up pipeline\u2026", "setup"); + preparePipelineBtn.disabled = true; + runLikelihoodBtn.disabled = true; + pipelineActionsEl.classList.remove("pipeline-ready"); + + try { + const res = await pyCall("prepare_pipeline", {}); + if (res.error) { + setPipelineStatus(res.error, "error"); + pipelineActionsEl.classList.remove("pipeline-ready"); + } else { + // Store per-module output (clear stale entries first). + modulePrepOutput.clear(); + for (const entry of (res.result.modules || [])) { + if (entry.ini_section) { + modulePrepOutput.set(entry.ini_section, entry.output || ""); + } + } + // Clear any stale actual I/O from a previous run. + for (const mod of pipeline.modules) { + delete mod.runStatus; + delete mod.actualInputs; + delete mod.actualDefaults; + delete mod.actualOutputs; + } + pipeline._render(); + setPipelineStatus("Pipeline ready \u2014 click \u25b6 Run Likelihood.", "ok"); + runLikelihoodBtn.disabled = false; + pipelineActionsEl.classList.add("pipeline-ready"); + // Refresh right sidebar if a module is selected. + const sel = pipeline.getSelectedModule(); + if (sel && currentInstanceId === sel.instanceId) showDetails(sel); + } + } catch (err) { + setPipelineStatus("Error: " + err.message, "error"); + pipelineActionsEl.classList.remove("pipeline-ready"); + } finally { + preparePipelineBtn.disabled = false; + } + }); + + // ── Run Likelihood button ───────────────────────────────────── + // (stays disabled until prepare_pipeline succeeds) + runLikelihoodBtn.addEventListener("click", async () => { + setPipelineStatus("Running likelihood\u2026", "run"); + runLikelihoodBtn.disabled = true; + + try { + const res = await pyCall("run_likelihood", {}); + if (res.error) { + setPipelineStatus(res.error, "error"); + runLikelihoodBtn.disabled = false; + } else { + // Stamp every module with run-ok state. + for (const mod of pipeline.modules) { + mod.runStatus = "ok"; + } + + // Store per-module actual I/O and push it onto the live module objects. + for (const entry of (res.result.modules || [])) { + if (!entry.ini_section) continue; + const mod = pipeline.modules.find(m => m.ini_section === entry.ini_section); + if (mod) { + mod.actualInputs = entry.actual_inputs || []; + mod.actualDefaults = entry.actual_defaults || []; + mod.actualOutputs = entry.actual_outputs || []; + } + } + + pipeline._render(); + setPipelineStatus("Likelihood run complete.", "ok"); + runLikelihoodBtn.disabled = false; + + // Populate the Results tab with DataBlock contents. + renderResultsTab(res.result.block_contents || []); + + // Refresh right sidebar if a module is currently selected. + const sel = pipeline.getSelectedModule(); + if (sel && currentInstanceId === sel.instanceId) showDetails(sel); + } + } catch (err) { + setPipelineStatus("Error: " + err.message, "error"); + runLikelihoodBtn.disabled = false; + } + }); + + // ── Results tab ─────────────────────────────────────────────── + const resultsPlaceholder = document.getElementById("results-placeholder"); + const resultsTree = document.getElementById("results-tree"); + const resultsPlotDiv = document.getElementById("results-plot-div"); + const resultsPlotInfo = document.getElementById("results-plot-info"); + const resultsLogXBtn = document.getElementById("results-log-x"); + const resultsLogYBtn = document.getElementById("results-log-y"); + + // Plot state + let plotYItem = null; // { secName, valName, plot_data } + let plotXItem = null; // { secName, valName, plot_data } or null (→ use index) + let plotLogX = false; + let plotLogY = false; + let resultsHasRun = false; + + resultsLogXBtn.addEventListener("click", () => { + plotLogX = !plotLogX; + resultsLogXBtn.classList.toggle("active", plotLogX); + _updatePlot(); + }); + + resultsLogYBtn.addEventListener("click", () => { + plotLogY = !plotLogY; + resultsLogYBtn.classList.toggle("active", plotLogY); + _updatePlot(); + }); + + /** + * (Re-)render the Plotly chart with the current X/Y selections and + * log-scale settings. Safe to call even when Plotly is not yet loaded. + */ + function _updatePlot() { + if (typeof Plotly === "undefined") return; + + if (!plotYItem || !plotYItem.plot_data) { + resultsPlotInfo.textContent = "Select a 1D array in the tree to plot"; + Plotly.purge(resultsPlotDiv); + return; + } + + const yData = plotYItem.plot_data; + const xData = (plotXItem && plotXItem.plot_data) + ? plotXItem.plot_data + : Array.from({ length: yData.length }, (_, i) => i); + + const trace = { + x: xData, + y: yData, + mode: "lines", + type: "scatter", + name: plotYItem.valName, + line: { color: "#3b82f6", width: 1.5 }, + }; + + const xTitle = plotXItem + ? `${plotXItem.secName} / ${plotXItem.valName}` + : "index"; + const yTitle = `${plotYItem.secName} / ${plotYItem.valName}`; + + const layout = { + margin: { t: 28, r: 20, b: 60, l: 70 }, + xaxis: { title: { text: xTitle, font: { size: 11 } }, + type: plotLogX ? "log" : "linear" }, + yaxis: { title: { text: yTitle, font: { size: 11 } }, + type: plotLogY ? "log" : "linear" }, + font: { family: "inherit", size: 11 }, + paper_bgcolor: "transparent", + plot_bgcolor: "#f8fafc", + autosize: true, + }; + + resultsPlotInfo.textContent = + `Y: ${yTitle}` + (plotXItem ? ` | X: ${xTitle}` : " | X: index"); + + Plotly.react(resultsPlotDiv, [trace], layout, { responsive: true }); + } + + /** + * Update the visual selection state of all rows to match the current + * plotYItem / plotXItem. + */ + function _refreshRowHighlights() { + document.querySelectorAll(".results-value-row").forEach(row => { + row.classList.remove("sel-y", "sel-x"); + }); + document.querySelectorAll(".results-axis-btn.btn-y").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".results-axis-btn.btn-x").forEach(b => b.classList.remove("active")); + + if (plotYItem) { + const row = document.querySelector( + `.results-value-row[data-sec="${CSS.escape(plotYItem.secName)}"][data-key="${CSS.escape(plotYItem.valName)}"]` + ); + if (row) { + row.classList.add("sel-y"); + const btn = row.querySelector(".results-axis-btn.btn-y"); + if (btn) btn.classList.add("active"); + } + } + if (plotXItem) { + const row = document.querySelector( + `.results-value-row[data-sec="${CSS.escape(plotXItem.secName)}"][data-key="${CSS.escape(plotXItem.valName)}"]` + ); + if (row) { + row.classList.add("sel-x"); + const btn = row.querySelector(".results-axis-btn.btn-x"); + if (btn) btn.classList.add("active"); + } + } + } + + /** + * Populate the Results tab with the DataBlock contents returned by + * run_likelihood. Sections are rendered as collapsible groups. + * 1-D numeric arrays gain "Y" / "X" role buttons for Plotly. + * + * @param {Array} sections Array of {name, values} from worker.py. + */ + function renderResultsTab(sections) { + resultsTree.innerHTML = ""; + // After the first run, never show the pre-run placeholder again. + resultsHasRun = true; + resultsPlaceholder.classList.add("hidden"); + + if (!sections.length) { + resultsTree.classList.add("hidden"); + return; + } + + resultsTree.classList.remove("hidden"); + + sections.forEach(sec => { + const group = document.createElement("div"); + group.className = "results-section"; + + const header = document.createElement("button"); + header.type = "button"; + header.className = "results-section-header"; + header.innerHTML = + `` + + `${escHtml(sec.name)}` + + `${sec.values.length}`; + + const body = document.createElement("div"); + body.className = "results-section-body"; + body.style.display = "none"; + + header.addEventListener("click", () => { + const open = header.classList.toggle("expanded"); + body.style.display = open ? "flex" : "none"; + }); + + (sec.values || []).forEach(val => { + const row = document.createElement("div"); + row.className = "results-value-row"; + // Store identity on the element so _refreshRowHighlights can find it. + row.dataset.sec = sec.name; + row.dataset.key = val.name; + + const isPlottable = Boolean(val.plot_data); + + // Y-axis button (only for plottable rows) + if (isPlottable) { + const btnY = document.createElement("button"); + btnY.type = "button"; + btnY.className = "results-axis-btn btn-y"; + btnY.title = "Plot as Y axis"; + btnY.textContent = "Y"; + btnY.addEventListener("click", e => { + e.stopPropagation(); + if (plotYItem + && plotYItem.secName === sec.name + && plotYItem.valName === val.name) { + // Deselect Y + plotYItem = null; + } else { + plotYItem = { secName: sec.name, valName: val.name, plot_data: val.plot_data }; + // If the same item was selected as X, clear X + if (plotXItem + && plotXItem.secName === sec.name + && plotXItem.valName === val.name) { + plotXItem = null; + } + } + _refreshRowHighlights(); + _updatePlot(); + }); + row.appendChild(btnY); + + // X-axis button + const btnX = document.createElement("button"); + btnX.type = "button"; + btnX.className = "results-axis-btn btn-x"; + btnX.title = "Use as X axis"; + btnX.textContent = "X"; + btnX.addEventListener("click", e => { + e.stopPropagation(); + if (plotXItem + && plotXItem.secName === sec.name + && plotXItem.valName === val.name) { + // Deselect X + plotXItem = null; + } else { + plotXItem = { secName: sec.name, valName: val.name, plot_data: val.plot_data }; + // If the same item was selected as Y, clear Y + if (plotYItem + && plotYItem.secName === sec.name + && plotYItem.valName === val.name) { + plotYItem = null; + } + } + _refreshRowHighlights(); + _updatePlot(); + }); + row.appendChild(btnX); + } + + const nameSpan = document.createElement("span"); + nameSpan.className = "results-value-name"; + nameSpan.textContent = val.name; + + const typeSpan = document.createElement("span"); + typeSpan.className = "results-value-type"; + typeSpan.textContent = val.dtype + (val.shape ? ` [${val.shape}]` : ""); + + const valueSpan = document.createElement("span"); + valueSpan.className = "results-value-scalar"; + if (val.scalar !== null && val.scalar !== undefined) { + valueSpan.textContent = val.scalar; + } else if (val.preview) { + const more = val.n_elements > val.preview.length; + valueSpan.textContent = "[" + val.preview.join(", ") + (more ? ", …" : "") + "]"; + } + + row.appendChild(nameSpan); + row.appendChild(typeSpan); + row.appendChild(valueSpan); + + // Clicking the row body selects it as Y (if plottable). + if (isPlottable) { + row.style.cursor = "pointer"; + row.addEventListener("click", () => { + if (plotYItem + && plotYItem.secName === sec.name + && plotYItem.valName === val.name) { + plotYItem = null; + } else { + plotYItem = { secName: sec.name, valName: val.name, plot_data: val.plot_data }; + if (plotXItem + && plotXItem.secName === sec.name + && plotXItem.valName === val.name) { + plotXItem = null; + } + } + _refreshRowHighlights(); + _updatePlot(); + }); + } + + body.appendChild(row); + }); + + group.appendChild(header); + group.appendChild(body); + resultsTree.appendChild(group); + }); + } + + function setPipelineStatus(msg, kind) { + const spinner = pipelineStatusEl.querySelector(".pipeline-status-spinner"); + const textEl = document.getElementById("pipeline-status-text"); + pipelineStatusEl.className = "pipeline-status"; + if (!msg) { pipelineStatusEl.classList.add("hidden"); return; } + if (textEl) textEl.textContent = msg; + if (kind === "error") pipelineStatusEl.classList.add("pipeline-status-error"); + else if (kind === "ok") pipelineStatusEl.classList.add("pipeline-status-ok"); + else if (kind === "setup") pipelineStatusEl.classList.add("pipeline-status-setup"); + else if (kind === "run") pipelineStatusEl.classList.add("pipeline-status-run"); + if (spinner) spinner.classList.toggle("hidden", kind !== "setup" && kind !== "run"); + } + + // ── Left sidebar: module library ───────────────────────────── + function renderModuleLibrary(modules) { + const list = document.getElementById("module-list"); + list.innerHTML = ""; + if (!modules.length) { + const p = document.createElement("p"); + p.className = "no-ports"; + p.textContent = "No modules loaded. Use \u201cOpen Library\u201d to scan a CosmoSIS standard library."; + list.appendChild(p); + return; + } + const groups = {}; + modules.forEach(mod => { + const cat = mod.category || "Other"; + (groups[cat] = groups[cat] || []).push(mod); + }); + Object.entries(groups).forEach(([catName, mods]) => { + const section = document.createElement("div"); + section.className = "category-group"; + const header = document.createElement("button"); + header.className = "category-toggle"; + header.innerHTML = `${escHtml(catName)}`; + header.addEventListener("click", () => { + const open = header.classList.toggle("expanded"); + body.style.display = open ? "" : "none"; + }); + const body = document.createElement("div"); + body.className = "category-modules"; + body.style.display = "none"; + mods.forEach(mod => { + const card = document.createElement("div"); + card.className = "module-card"; + card.draggable = true; + card.innerHTML = `
${escHtml(mod.name)}
${escHtml(mod.description)}
`; + card.addEventListener("dragstart", e => { + e.dataTransfer.setData("application/json", JSON.stringify(mod)); + e.dataTransfer.effectAllowed = "copy"; + card.classList.add("dragging"); + }); + card.addEventListener("dragend", () => card.classList.remove("dragging")); + card.addEventListener("click", () => showDetails(mod)); + body.appendChild(card); + }); + section.appendChild(header); + section.appendChild(body); + list.appendChild(section); + }); + } + + // ── Right sidebar ───────────────────────────────────────────── + const rightSidebar = document.getElementById("right-sidebar"); + const detailsTitle = document.getElementById("details-title"); + const detailsBody = document.getElementById("details-content"); + const closeBtn = document.getElementById("close-details"); + + let currentInstanceId = null; + + closeBtn.addEventListener("click", () => { hideDetails(); pipeline.deselect(); }); + + document.addEventListener("pipeline:moduleSelected", e => { + if (e.detail) showDetails(e.detail); + else hideDetails(); + }); + + document.addEventListener("pipeline:sectionToggled", () => { + const mod = pipeline.getSelectedModule(); + if (mod && mod.instanceId === currentInstanceId) showDetails(mod); + }); + + function showDetails(mod) { + if (!mod) { hideDetails(); return; } + const live = pipeline.getSelectedModule() || mod; + currentInstanceId = live.instanceId; + detailsTitle.textContent = live.name; + rightSidebar.classList.remove("hidden"); + detailsBody.innerHTML = ""; + detailsBody.appendChild(_buildDetailsContent(live)); + } + + function hideDetails() { + currentInstanceId = null; + rightSidebar.classList.add("hidden"); + } + + // ── Right sidebar: detail sections ─────────────────────────── + + function _buildDetailsContent(mod) { + const wrap = document.createElement("div"); + + if (mod.description) { + const p = document.createElement("p"); + p.className = "module-detail-desc"; + p.textContent = mod.description; + wrap.appendChild(p); + } + + // After a successful run, show actual I/O from the DataBlock. + // Otherwise fall back to the YAML-declared inputs/outputs. + const hasActual = mod.actualInputs || mod.actualDefaults || mod.actualOutputs; + if (hasActual) { + _appendStaticPortSection(wrap, "input", mod.actualInputs || [], "Actual Inputs"); + _appendStaticPortSection(wrap, "input-default", mod.actualDefaults || [], "Default Inputs"); + _appendStaticPortSection(wrap, "output", mod.actualOutputs || [], "Actual Outputs"); + } else { + _appendPortSection(wrap, mod, "input", mod.inputs || []); + _appendPortSection(wrap, mod, "output", mod.outputs || []); + } + + const params = mod.params || []; + if (params.length) wrap.appendChild(_buildParamsForm(mod)); + + // Show per-module pipeline setup output when available. + const sectionName = mod.ini_section; + if (sectionName && modulePrepOutput.has(sectionName)) { + const output = modulePrepOutput.get(sectionName); + if (output) wrap.appendChild(_buildSetupOutputSection(output)); + } + + return wrap; + } + + function _buildSetupOutputSection(outputText) { + const section = document.createElement("div"); + section.className = "detail-section"; + const h3 = document.createElement("h3"); + h3.textContent = "Setup Output"; + section.appendChild(h3); + const pre = document.createElement("pre"); + pre.className = "setup-output-pre"; + pre.textContent = outputText; + section.appendChild(pre); + return section; + } + + /** + * Render a static (non-interactive) port section for actual I/O. + * + * Unlike _appendPortSection, this function does not link to the pipeline + * canvas expand/collapse state — items are always shown inline. + * + * @param {HTMLElement} wrap - Parent element to append into. + * @param {string} displayClass - CSS class for port rows ("input", + * "input-default", "output", …). + * @param {Array} ports - Array of port objects from the backend. + * @param {string} label - Section heading text. + */ + function _appendStaticPortSection(wrap, displayClass, ports, label) { + if (!ports.length) return; + const section = document.createElement("div"); + section.className = "detail-section"; + const h3 = document.createElement("h3"); + h3.textContent = `${label} (${ports.length})`; + section.appendChild(h3); + + ports.forEach(port => { + const row = document.createElement("div"); + row.className = `port-detail ${displayClass}`; + + const nameSpan = document.createElement("span"); + nameSpan.className = "port-name"; + nameSpan.textContent = port.name; + row.appendChild(nameSpan); + + if (port.type) { + const typeSpan = document.createElement("span"); + typeSpan.className = "port-type"; + typeSpan.textContent = port.type; + row.appendChild(typeSpan); + } + section.appendChild(row); + + // Always show items inline (no expand/collapse for actual I/O). + const items = port.type === "section" ? (port.items || []) : []; + if (items.length) { + const subWrap = document.createElement("div"); + subWrap.className = "sub-params-list"; + items.forEach(item => { + const sub = document.createElement("div"); + sub.className = `sub-param-row ${displayClass}`; + sub.innerHTML = `${escHtml(item.name)}` + + (item.type ? `${escHtml(item.type)}` : ""); + subWrap.appendChild(sub); + }); + section.appendChild(subWrap); + } + }); + + wrap.appendChild(section); + } + + function _appendPortSection(wrap, mod, portType, ports) { + if (!ports.length) return; + const section = document.createElement("div"); + section.className = "detail-section"; + const h3 = document.createElement("h3"); + h3.textContent = (portType === "input" ? "Inputs" : "Outputs") + ` (${ports.length})`; + section.appendChild(h3); + + ports.forEach((port, i) => { + const key = `${mod.instanceId}|${portType}|${i}`; + const expanded = pipeline.expandedPorts.has(key); + const hasItems = port.type === "section" && (port.items || []).length > 0; + + const row = document.createElement("div"); + row.className = "port-detail " + portType + (hasItems ? " expandable-port" : ""); + + const nameSpan = document.createElement("span"); + nameSpan.className = "port-name"; + if (hasItems) { + nameSpan.innerHTML = `${expanded ? "▼" : "▶"}${escHtml(port.name)}`; + row.style.cursor = "pointer"; + row.addEventListener("click", () => { + pipeline.togglePortFromSidebar(mod.instanceId, portType, i); + }); + } else { + nameSpan.textContent = port.name; + } + row.appendChild(nameSpan); + + if (port.type) { + const typeSpan = document.createElement("span"); + typeSpan.className = "port-type"; + typeSpan.textContent = port.type; + row.appendChild(typeSpan); + } + section.appendChild(row); + + if (hasItems && expanded) { + const subWrap = document.createElement("div"); + subWrap.className = "sub-params-list"; + (port.items || []).forEach(item => { + const sub = document.createElement("div"); + sub.className = "sub-param-row " + portType; + sub.innerHTML = `${escHtml(item.name)}` + + (item.type ? `${escHtml(item.type)}` : ""); + if (item.description) { + const desc = document.createElement("span"); + desc.className = "port-desc"; + desc.textContent = item.description; + sub.appendChild(desc); + } + subWrap.appendChild(sub); + }); + section.appendChild(subWrap); + } + }); + wrap.appendChild(section); + } + + // ── Params form ─────────────────────────────────────────────── + + function _buildParamsForm(mod) { + const section = document.createElement("div"); + section.className = "detail-section"; + const h3 = document.createElement("h3"); + h3.textContent = `Parameters (${mod.params.length})`; + section.appendChild(h3); + + const form = document.createElement("div"); + form.className = "params-form"; + + mod.params.forEach(param => { + const storedVal = (mod.paramValues || {})[param.name]; + const defVal = param.default != null ? String(param.default) : ""; + const curVal = storedVal !== undefined ? String(storedVal) : defVal; + + const row = document.createElement("div"); + row.className = "param-row"; + + const nameBtn = document.createElement("button"); + nameBtn.type = "button"; + nameBtn.className = "param-name-btn"; + nameBtn.title = "Click to show/hide description"; + nameBtn.textContent = param.name; + row.appendChild(nameBtn); + + const meaning = document.createElement("div"); + meaning.className = "param-meaning hidden"; + meaning.textContent = param.meaning || "(no description)"; + nameBtn.addEventListener("click", () => meaning.classList.toggle("hidden")); + + const inputWrap = document.createElement("div"); + inputWrap.className = "param-input-wrap"; + + const typeStr = (param.type || "").toLowerCase(); + let input; + + if (typeStr === "bool" || typeStr === "boolean") { + input = document.createElement("input"); + input.type = "checkbox"; + input.className = "param-checkbox"; + input.checked = ["true","1","yes","on"].includes(curVal.toLowerCase()); + input.addEventListener("change", () => { + _storeParam(mod, param.name, input.checked); + }); + } else { + input = document.createElement("input"); + input.className = "param-input"; + input.value = curVal; + if (typeStr === "int" || typeStr === "integer") { + input.type = "number"; + input.step = "1"; + input.placeholder = "integer"; + } else if (typeStr === "real" || typeStr === "float" || typeStr === "double") { + input.type = "number"; + input.step = "any"; + input.placeholder = "number"; + } else { + input.type = "text"; + input.placeholder = typeStr || "value"; + } + + const errSpan = document.createElement("span"); + errSpan.className = "param-error hidden"; + + // Debounced Python update — fires 400 ms after the last keystroke. + const debouncedPyUpdate = _debounce((section, key, val) => { + pyCall("update_param", { ini_section: section, key, value: val }) + .catch(err => console.warn("[update_param]", err)); + }, 400); + + input.addEventListener("input", () => { + const valid = _validateParam(input.value, param.type); + errSpan.classList.toggle("hidden", valid); + errSpan.textContent = valid ? "" : `Expected ${param.type}`; + input.classList.toggle("param-input-invalid", !valid); + if (valid) { + const coerced = _coerceParam(input.value, param.type); + _storeParam(mod, param.name, coerced); + const live = pipeline.modules.find(m => m.instanceId === mod.instanceId); + if (live && live.ini_section) { + debouncedPyUpdate(live.ini_section, param.name, String(coerced)); + } + } + }); + inputWrap.appendChild(errSpan); + } + + inputWrap.insertBefore(input, inputWrap.firstChild); + row.appendChild(inputWrap); + row.appendChild(meaning); + form.appendChild(row); + }); + + section.appendChild(form); + return section; + } + + /** Store a param value on the live pipeline module. */ + function _storeParam(mod, paramName, value) { + const live = pipeline.modules.find(m => m.instanceId === mod.instanceId); + if (live) { + if (!live.paramValues) live.paramValues = {}; + live.paramValues[paramName] = value; + } + } + + /** Return true if the string value is valid for the given type string. */ + function _validateParam(val, typeStr) { + if (!val && val !== "0") return true; + const t = (typeStr || "").toLowerCase(); + if (t === "int" || t === "integer") return /^-?\d+$/.test(val.trim()); + if (t === "real" || t === "float" || t === "double") + return /^-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(val.trim()); + return true; + } + + /** Coerce a string to the appropriate JS type. */ + function _coerceParam(val, typeStr) { + const t = (typeStr || "").toLowerCase(); + if (t === "int" || t === "integer") return parseInt(val, 10); + if (t === "real" || t === "float" || t === "double") return parseFloat(val); + return val; + } + + // ── Utility ─────────────────────────────────────────────────── + + function escHtml(str) { + return String(str) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + /** Return a debounced version of fn that fires delay ms after the last call. */ + function _debounce(fn, delay) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), delay); + }; + } +}); diff --git a/static/js/pipeline.js b/static/js/pipeline.js new file mode 100644 index 0000000..9a08dc5 --- /dev/null +++ b/static/js/pipeline.js @@ -0,0 +1,762 @@ +/** + * pipeline.js — SVG pipeline canvas (v3) + * + * New in this version + * ------------------- + * • Dynamic box widths (canvas-based text measurement, minimum enforced) + * • Section port boxes connect to the MODULE CENTRE with a bezier curve + * (no more flat horizontal dashed lines) + * • When a module is selected, preceding modules that supply its inputs + * show their matching output boxes; arcs flow from those output boxes + * to the selected module's input boxes + * • Section boxes are click-to-expand; individual parameter sub-boxes + * appear in a new colour beside the section box + * • Pipeline is centred horizontally so there is always room for sub-boxes + */ +class PipelineCanvas { + // ── Layout constants ────────────────────────────────────────────── + static SAMPLER_INDEX = 0; + + static MH = 58; // module box height + static MS = 65; // vertical gap between module boxes + static PADV = 100; // top / bottom canvas padding (extra room for expanded sub-params) + static PADH = 40; // minimum left / right canvas padding + + // Section (outer) port boxes + static PH = 22; // section box height + static PG = 4; // vertical gap between section boxes + static PO = 18; // gap: module edge ↔ section box near-side + static PW_MIN = 90; // minimum section box width + static PW_PAD = 18; // total inner horizontal text padding + + // Sub-parameter boxes (shown when a section is expanded) + static SPH = 18; // sub-param box height + static SPG = 3; // vertical gap between sub-param boxes + static SPO = 16; // gap: section box far-side ↔ sub-param box near-side + static SPW_MIN = 70; // minimum sub-param box width + static SPW_PAD = 12; // total inner horizontal text padding + + // Module box + static MW_MIN = 120; // minimum module box width + static MW_PAD = 24; // total inner horizontal text padding + + static MIN_BULGE = 20; // minimum bezier bulge (pixels) + static FONT_FAMILY = '-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif'; + + // ── Constructor ─────────────────────────────────────────────────── + + constructor(containerId) { + this.container = document.getElementById(containerId); + this.modules = []; + this.selectedId = null; + this.dropIndex = -1; + /** Set of "instanceId|input|idx" / "instanceId|output|idx" keys */ + this.expandedPorts = new Set(); + this._canvasDrag = null; + this._textCtx = null; // lazy canvas-2D for text measurement + this._initSVG(); + } + + // ── Public API ──────────────────────────────────────────────────── + + setModules(modules) { + this.modules = modules.map((m, i) => ({ + ...m, + instanceId: m.instanceId || `${m.id}_${i}`, + paramValues: m.paramValues || {}, + })); + this._render(); + } + + deselect() { + this._select(null); + } + + getSelectedModule() { + return this.selectedId + ? (this.modules.find(m => m.instanceId === this.selectedId) || null) + : null; + } + + /** Called from the sidebar when the user expands/collapses a section there. */ + togglePortFromSidebar(instanceId, portType, portIdx) { + const key = `${instanceId}|${portType}|${portIdx}`; + const wasExpanded = this.expandedPorts.has(key); + + // Close any other open sections of the same type for this module. + for (const k of [...this.expandedPorts]) { + if (k.startsWith(`${instanceId}|${portType}|`)) { + this.expandedPorts.delete(k); + } + } + + if (!wasExpanded) this.expandedPorts.add(key); + this._render(); + } + + // ── Text measurement ────────────────────────────────────────────── + + _textWidth(text, fontSize, bold) { + if (!this._textCtx) + this._textCtx = document.createElement("canvas").getContext("2d"); + const ff = PipelineCanvas.FONT_FAMILY; + this._textCtx.font = `${bold ? "600" : "400"} ${fontSize}px ${ff}`; + return this._textCtx.measureText(String(text)).width; + } + + _sectionBoxWidth(name) { + const { PW_MIN, PW_PAD } = PipelineCanvas; + return Math.max(PW_MIN, Math.ceil(this._textWidth(name, 10) + PW_PAD)); + } + + _subBoxWidth(name) { + const { SPW_MIN, SPW_PAD } = PipelineCanvas; + return Math.max(SPW_MIN, Math.ceil(this._textWidth(name, 9) + SPW_PAD)); + } + + _moduleBoxWidth(name) { + const { MW_MIN, MW_PAD } = PipelineCanvas; + return Math.max(MW_MIN, Math.ceil(this._textWidth(name, 13, true) + MW_PAD)); + } + + /** Maximum section-box width across every port in the current pipeline. */ + _maxSectionWidth() { + let max = PipelineCanvas.PW_MIN; + for (const m of this.modules) + for (const p of [...(m.inputs || []), ...(m.outputs || [])]) + max = Math.max(max, this._sectionBoxWidth(p.name)); + return max; + } + + /** Maximum module-box width across every module in the pipeline. */ + _maxModWidth() { + let max = PipelineCanvas.MW_MIN; + for (const m of this.modules) + max = Math.max(max, this._moduleBoxWidth(m.name)); + return max; + } + + // ── Coordinate helpers ──────────────────────────────────────────── + + /** + * X of the left edge of every module box. + * + * Always reserves room for sub-param boxes on the left so the pipeline + * does not shift when a section is expanded. + */ + _modX() { + const { PADH, PO, SPO, SPW_MIN } = PipelineCanvas; + return PADH + SPW_MIN + SPO + this._maxSectionWidth() + PO; + } + + /** + * Left edge of a specific module's box when all modules are centre-aligned + * within the column (i.e. their horizontal centres share a single x). + */ + _modLeft(module) { + return this._modX() + (this._maxModWidth() - this._moduleBoxWidth(module.name)) / 2; + } + + _modY(i) { + const { PADV, MH, MS } = PipelineCanvas; + return PADV + i * (MH + MS); + } + + _svgWidth() { + const natural = this._modX() * 2 + this._maxModWidth(); + return Math.max(natural, this.container.clientWidth || 700); + } + + _svgHeight() { + const { PADV, MH, MS } = PipelineCanvas; + const n = this.modules.length || 1; + const natural = PADV * 2 + n * MH + (n - 1) * MS; + return Math.max(natural, this.container.clientHeight || 400); + } + + // ── Render orchestration ────────────────────────────────────────── + + _render() { + while (this.svg.childNodes.length > 1) + this.svg.removeChild(this.svg.lastChild); + + const w = this._svgWidth(); + const h = this._svgHeight(); + this.svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + this.svg.setAttribute("width", w); + this.svg.setAttribute("height", h); + + this._appendBg(w, h); + + let selIdx = -1; + if (this.selectedId) + selIdx = this.modules.findIndex(m => m.instanceId === this.selectedId); + + const srcOutPos = selIdx >= 0 + ? this._computeSourceOutputPositions(selIdx) + : {}; + + if (selIdx >= 0) this._appendPortEdges(selIdx, srcOutPos); + this._appendConnections(); + this.modules.forEach((m, i) => { + this._appendModule(m, i); + if (selIdx >= 0 && i < selIdx && srcOutPos[i]) + this._appendSourceOutputBoxes(m, i, srcOutPos[i]); + }); + if (selIdx >= 0) this._appendPorts(this.modules[selIdx], selIdx); + if (this.dropIndex >= 0) this._appendDropIndicator(this.dropIndex); + } + + // ── Source-output position computation ─────────────────────────── + + _computeSourceOutputPositions(selIdx) { + const sel = this.modules[selIdx]; + const inputs = sel.inputs || []; + const { MH, PH, PG, PO } = PipelineCanvas; + + const matchMap = {}; + inputs.forEach(input => { + for (let j = selIdx - 1; j >= 0; j--) { + const src = this.modules[j]; + if (!(src.outputs || []).some(o => o.name === input.name)) continue; + if (!matchMap[j]) matchMap[j] = []; + if (!matchMap[j].includes(input.name)) matchMap[j].push(input.name); + break; + } + }); + + const result = {}; + Object.entries(matchMap).forEach(([jStr, names]) => { + const j = parseInt(jStr); + const srcMod = this.modules[j]; + const mw = this._moduleBoxWidth(srcMod.name); + const portX = this._modLeft(srcMod) + mw + PO; + const totalH = names.length * (PH + PG) - PG; + const startY = this._modY(j) + (MH - totalH) / 2; + result[j] = names.map((name, k) => ({ + name, + portX, + portY: startY + k * (PH + PG), + cy: startY + k * (PH + PG) + PH / 2, + portW: this._sectionBoxWidth(name), + })); + }); + return result; + } + + // ── SVG element builders ────────────────────────────────────────── + + _appendBg(w, h) { + const r = this._el("rect"); + r.setAttribute("width", w); + r.setAttribute("height", h); + r.setAttribute("fill", "#f1f5f9"); + r.setAttribute("class", "pipeline-bg"); + this.svg.appendChild(r); + } + + _appendConnections() { + // All modules are centre-aligned → the vertical connector always runs + // along the single shared centre x of the module column. + const cx = this._modX() + this._maxModWidth() / 2; + const { MH } = PipelineCanvas; + for (let i = 0; i < this.modules.length - 1; i++) { + const y1 = this._modY(i) + MH; + const y2 = this._modY(i + 1) - 6; + const el = this._el("line"); + el.setAttribute("x1", cx); + el.setAttribute("y1", y1); + el.setAttribute("x2", cx); + el.setAttribute("y2", y2); + el.setAttribute("stroke", "#94a3b8"); + el.setAttribute("stroke-width", "2"); + el.setAttribute("marker-end", "url(#arr-grey)"); + this.svg.appendChild(el); + } + } + + _appendModule(module, index) { + const mx = this._modLeft(module); + const my = this._modY(index); + const mw = this._moduleBoxWidth(module.name); + const { MH } = PipelineCanvas; + const id = module.instanceId; + const sel = id === this.selectedId; + const perm = !!module.permanent; + const isDragging = this._canvasDrag && this._canvasDrag.id === id && this._canvasDrag.moved; + + const g = this._el("g"); + g.setAttribute("class", "module-group"); + g.setAttribute("data-id", id); + if (isDragging) g.setAttribute("opacity", "0.4"); + + const rect = this._el("rect"); + rect.setAttribute("x", mx); + rect.setAttribute("y", my); + rect.setAttribute("width", mw); + rect.setAttribute("height", MH); + rect.setAttribute("rx", "8"); + let cls = "module-rect"; + if (perm) cls += " permanent"; + if (sel) cls += " selected"; + if (module.runStatus === "ok") cls += " run-ok"; + rect.setAttribute("class", cls); + g.appendChild(rect); + + const text = this._el("text"); + text.setAttribute("x", mx + mw / 2); + text.setAttribute("y", my + MH / 2); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("dominant-baseline", "middle"); + text.setAttribute("class", "module-text"); + text.textContent = module.name; + g.appendChild(text); + + if (!perm) { + const rm = this._el("text"); + rm.setAttribute("x", mx + mw - 9); + rm.setAttribute("y", my + 16); + rm.setAttribute("text-anchor", "middle"); + rm.setAttribute("class", "module-remove"); + rm.textContent = "×"; + rm.addEventListener("click", e => { + e.stopPropagation(); + this._removeModule(id); + }); + g.appendChild(rm); + } + + g.addEventListener("mousedown", e => this._onModuleMouseDown(e, id, index, perm)); + g.addEventListener("click", e => { + e.stopPropagation(); + if (!this._canvasDrag || !this._canvasDrag.moved) this._select(id); + }); + this.svg.appendChild(g); + } + + _appendSourceOutputBoxes(srcMod, srcIdx, positions) { + const { MH, PH } = PipelineCanvas; + const mx = this._modLeft(srcMod); + const mw = this._moduleBoxWidth(srcMod.name); + const my = this._modY(srcIdx); + + positions.forEach(({ name, portX, portY, cy, portW }) => { + const conn = this._makeBezierConnector(mx + mw, my + MH / 2, portX, cy, "right"); + this.svg.appendChild(conn); + this.svg.appendChild(this._makePortRect(portX, portY, portW, "output-port")); + this.svg.appendChild(this._makePortLabel(portX, portY, portW, PH, name)); + }); + } + + _appendPorts(module, index) { + const mx = this._modLeft(module); + const my = this._modY(index); + const mw = this._moduleBoxWidth(module.name); + const { MH, PH, PG, PO, SPH, SPG, SPO } = PipelineCanvas; + const id = module.instanceId; + + // Use actual I/O from the last run when available; otherwise fall back to YAML. + const hasActual = module.actualInputs || module.actualDefaults || module.actualOutputs; + const allInputs = hasActual + ? [ + ...(module.actualInputs || []), + ...(module.actualDefaults || []).map(p => ({ ...p, _isDefault: true })), + ] + : (module.inputs || []); + const allOutputs = hasActual + ? (module.actualOutputs || []) + : (module.outputs || []); + + const renderSection = (port, i, portType) => { + const isInput = portType === "input"; + const allPorts = isInput ? allInputs : allOutputs; + const pw = this._sectionBoxWidth(port.name); + const totalH = allPorts.length * (PH + PG) - PG; + const startY = my + (MH - totalH) / 2; + const portY = startY + i * (PH + PG); + const cy = portY + PH / 2; + const portX = isInput ? mx - PO - pw : mx + mw + PO; + const key = `${id}|${portType}|${i}`; + const hasItems = port.type === "section" && (port.items || []).length > 0; + const expanded = hasItems && this.expandedPorts.has(key); + + // Straight dashed connector between port and module. + // Input: right edge of section → left edge of module. + // Output: right edge of module → left edge of section. + const conn = this._el("path"); + conn.setAttribute("fill", "none"); + conn.setAttribute("class", "port-connector"); + conn.setAttribute("d", isInput + ? `M ${portX + pw} ${cy} L ${mx} ${my + MH / 2}` + : `M ${mx + mw} ${my + MH / 2} L ${portX} ${cy}`); + this.svg.appendChild(conn); + + const portCls = isInput + ? ((port._isDefault ? "input-default-port" : "input-port") + (hasItems ? " section-port" : "")) + : ("output-port" + (hasItems ? " section-port" : "")); + const rect = this._makePortRect(portX, portY, pw, portCls); + if (hasItems) { + rect.style.cursor = "pointer"; + rect.addEventListener("click", e => { + e.stopPropagation(); + this._toggleSection(id, portType, i); + }); + } + this.svg.appendChild(rect); + + if (hasItems) { + const ch = this._el("text"); + ch.setAttribute("x", isInput ? portX + 8 : portX + pw - 8); + ch.setAttribute("y", portY + PH / 2); + ch.setAttribute("dominant-baseline","middle"); + ch.setAttribute("text-anchor", "middle"); + ch.setAttribute("class", "port-chevron"); + ch.setAttribute("pointer-events", "none"); + ch.textContent = expanded ? "▼" : "▶"; + this.svg.appendChild(ch); + } + + const labelInset = hasItems ? 12 : 0; + const lx = isInput ? portX + labelInset : portX; + const lw = pw - labelInset; + this.svg.appendChild(this._makePortLabel(lx, portY, lw, PH, port.name)); + + if (expanded) { + const items = port.items || []; + const subTotalH = items.length * (SPH + SPG) - SPG; + const subStartY = cy - subTotalH / 2; + + items.forEach((item, k) => { + const spw = this._subBoxWidth(item.name); + const subX = isInput ? portX - SPO - spw : portX + pw + SPO; + const subY = subStartY + k * (SPH + SPG); + const subCY= subY + SPH / 2; + + // Straight dashed connector between section centre and sub-param. + // Input: right edge of sub-param → left edge of section. + // Output: right edge of section → left edge of sub-param. + const subConn = this._el("path"); + subConn.setAttribute("fill", "none"); + subConn.setAttribute("class", "sub-connector"); + subConn.setAttribute("d", isInput + ? `M ${subX + spw} ${subCY} L ${portX} ${cy}` + : `M ${portX + pw} ${cy} L ${subX} ${subCY}`); + this.svg.appendChild(subConn); + + const subCls = isInput + ? (port._isDefault ? "input-default-sub-port" : "input-sub-port") + : "output-sub-port"; + const subRect = this._makePortRect(subX, subY, spw, subCls); + subRect.setAttribute("height", String(SPH)); + subRect.setAttribute("rx", "3"); + this.svg.appendChild(subRect); + + this.svg.appendChild(this._makeSubLabel(subX, subY, spw, SPH, item.name)); + }); + } + }; + + allInputs .forEach((p, i) => renderSection(p, i, "input")); + allOutputs.forEach((p, i) => renderSection(p, i, "output")); + } + + _appendPortEdges(selIdx, srcOutPos) { + const sel = this.modules[selIdx]; + const inputs = sel.inputs || []; + if (!inputs.length) return; + + const { MH, PH, PG, PO } = PipelineCanvas; + const mx = this._modLeft(sel); + const my = this._modY(selIdx); + const mw = this._moduleBoxWidth(sel.name); + + const inTotalH = inputs.length * (PH + PG) - PG; + const inStartY = my + (MH - inTotalH) / 2; + + inputs.forEach((input, i) => { + const inPW = this._sectionBoxWidth(input.name); + const inPortX = mx - PO - inPW; + const inCY = inStartY + i * (PH + PG) + PH / 2; + + for (let j = selIdx - 1; j >= 0; j--) { + if (!srcOutPos[j]) continue; + const srcBox = srcOutPos[j].find(b => b.name === input.name); + if (!srcBox) continue; + + // Arc: bottom-centre of source output box → left edge of selected input box. + // Exits going downward then sweeps left to arrive horizontally at the input. + const srcBotX = srcBox.portX + srcBox.portW / 2; + const srcBotY = srcBox.portY + PH; + const dy = inCY - srcBotY; + const d = [ + `M ${srcBotX} ${srcBotY}`, + `C ${srcBotX} ${srcBotY + dy * 0.5},`, + ` ${inPortX - 40} ${inCY},`, + ` ${inPortX} ${inCY}`, + ].join(" "); + + const path = this._el("path"); + path.setAttribute("d", d); + path.setAttribute("class", "port-edge"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "#3b82f6"); + path.setAttribute("stroke-width", "1.5"); + path.setAttribute("opacity", "0.75"); + path.setAttribute("marker-end", "url(#arr-blue)"); + this.svg.appendChild(path); + break; + } + }); + } + + _appendDropIndicator(index) { + const { MH, MS } = PipelineCanvas; + const clamped = Math.max(PipelineCanvas.SAMPLER_INDEX + 1, + Math.min(index, this.modules.length)); + const y = clamped < this.modules.length + ? this._modY(clamped) - MS / 2 + : this._modY(this.modules.length - 1) + MH + MS / 2; + + const mx = this._modX(); + const mw = this._maxModWidth(); + const line = this._el("line"); + line.setAttribute("x1", mx - 22); + line.setAttribute("y1", y); + line.setAttribute("x2", mx + mw + 22); + line.setAttribute("y2", y); + line.setAttribute("class", "insertion-indicator"); + line.setAttribute("stroke", "#3b82f6"); + line.setAttribute("stroke-width", "2.5"); + line.setAttribute("stroke-dasharray", "6 3"); + this.svg.appendChild(line); + } + + // ── SVG element factories ───────────────────────────────────────── + + _makeBezierConnector(x1, y1, x2, y2, side) { + const span = Math.abs(x2 - x1); + const bulge = Math.max(PipelineCanvas.MIN_BULGE, span * 0.5); + const sign = side === "right" ? 1 : -1; + const cp1x = x1 + sign * bulge; + const cp2x = x2 + sign * bulge; + const d = `M ${x1} ${y1} C ${cp1x} ${y1}, ${cp2x} ${y2}, ${x2} ${y2}`; + const el = this._el("path"); + el.setAttribute("d", d); + el.setAttribute("fill", "none"); + el.setAttribute("class", "port-connector"); + return el; + } + + _makePortRect(x, y, width, cls) { + const { PH } = PipelineCanvas; + const el = this._el("rect"); + el.setAttribute("x", x); + el.setAttribute("y", y); + el.setAttribute("width", width); + el.setAttribute("height", PH); + el.setAttribute("rx", "4"); + el.setAttribute("class", `port-rect ${cls}`); + return el; + } + + _makePortLabel(portX, portY, portW, portH, name) { + const el = this._el("text"); + el.setAttribute("x", portX + portW / 2); + el.setAttribute("y", portY + portH / 2); + el.setAttribute("text-anchor", "middle"); + el.setAttribute("dominant-baseline","middle"); + el.setAttribute("class", "port-label"); + el.textContent = name; + return el; + } + + _makeSubLabel(portX, portY, portW, portH, name) { + const el = this._el("text"); + el.setAttribute("x", portX + portW / 2); + el.setAttribute("y", portY + portH / 2); + el.setAttribute("text-anchor", "middle"); + el.setAttribute("dominant-baseline","middle"); + el.setAttribute("class", "sub-label"); + el.textContent = name; + return el; + } + + _el(tag) { + return document.createElementNS("http://www.w3.org/2000/svg", tag); + } + + // ── Selection & mutation ────────────────────────────────────────── + + _select(id) { + this.selectedId = id; + this.expandedPorts.clear(); + this._render(); + const module = id + ? (this.modules.find(m => m.instanceId === id) || null) + : null; + document.dispatchEvent( + new CustomEvent("pipeline:moduleSelected", { detail: module }) + ); + } + + _removeModule(id) { + const idx = this.modules.findIndex(m => m.instanceId === id); + if (idx <= PipelineCanvas.SAMPLER_INDEX) return; + this.modules.splice(idx, 1); + if (this.selectedId === id) { + this.selectedId = null; + document.dispatchEvent( + new CustomEvent("pipeline:moduleSelected", { detail: null }) + ); + } + this._render(); + } + + _toggleSection(instanceId, portType, portIdx) { + const key = `${instanceId}|${portType}|${portIdx}`; + const wasExpanded = this.expandedPorts.has(key); + + // Close any other open sections of the same type for this module + // (input sections are independent from output sections). + for (const k of [...this.expandedPorts]) { + if (k.startsWith(`${instanceId}|${portType}|`)) { + this.expandedPorts.delete(k); + } + } + + // If the section was not already open, open it now. + if (!wasExpanded) this.expandedPorts.add(key); + + this._render(); + document.dispatchEvent(new CustomEvent("pipeline:sectionToggled", { + detail: { instanceId, portType, portIdx, expanded: this.expandedPorts.has(key) }, + })); + } + + // ── Canvas drag-to-reorder ──────────────────────────────────────── + + _onModuleMouseDown(e, id, index, perm) { + if (e.button !== 0 || perm) return; + if (e.target.classList.contains("module-remove")) return; + e.preventDefault(); + e.stopPropagation(); + this._canvasDrag = { id, fromIdx: index, startY: e.clientY, moved: false }; + } + + _onCanvasMouseMove(e) { + if (!this._canvasDrag) return; + if (Math.abs(e.clientY - this._canvasDrag.startY) > 5) + this._canvasDrag.moved = true; + if (!this._canvasDrag.moved) return; + const raw = this._insertionIndexFor(e.clientY); + if (raw !== this.dropIndex) { this.dropIndex = raw; this._render(); } + } + + _onCanvasMouseUp(e) { + if (!this._canvasDrag) return; + const ds = this._canvasDrag; + this._canvasDrag = null; + this.dropIndex = -1; + if (!ds.moved) { this._select(ds.id); return; } + + const fromIdx = ds.fromIdx; + const insertAt = this._insertionIndexFor(e.clientY); + const clamped = Math.max(PipelineCanvas.SAMPLER_INDEX + 1, insertAt); + const newIdx = clamped > fromIdx ? clamped - 1 : clamped; + if (newIdx !== fromIdx) { + const [mod] = this.modules.splice(fromIdx, 1); + this.modules.splice(newIdx, 0, mod); + this.selectedId = null; + document.dispatchEvent( + new CustomEvent("pipeline:moduleSelected", { detail: null }) + ); + } + this._render(); + } + + // ── Sidebar HTML drag & drop ────────────────────────────────────── + + _toSvgY(clientY) { + const rect = this.svg.getBoundingClientRect(); + const scale = this.svg.viewBox.baseVal.height > 0 + ? this.svg.viewBox.baseVal.height / rect.height : 1; + return (clientY - rect.top) * scale; + } + + _insertionIndexFor(clientY) { + const svgY = this._toSvgY(clientY); + const { MH } = PipelineCanvas; + for (let i = 0; i < this.modules.length; i++) + if (svgY < this._modY(i) + MH / 2) + return Math.max(PipelineCanvas.SAMPLER_INDEX + 1, i); + return this.modules.length; + } + + _onDragOver(e) { + if (this._canvasDrag) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + const idx = this._insertionIndexFor(e.clientY); + if (idx !== this.dropIndex) { this.dropIndex = idx; this._render(); } + } + + _onDragLeave(e) { + if (!e.relatedTarget || !this.container.contains(e.relatedTarget)) { + this.dropIndex = -1; + this._render(); + } + } + + _onDrop(e) { + e.preventDefault(); + this.dropIndex = -1; + const raw = e.dataTransfer.getData("application/json"); + if (!raw) { this._render(); return; } + try { + const data = JSON.parse(raw); + const idx = this._insertionIndexFor(e.clientY); + this.modules.splice(idx, 0, { + ...data, + instanceId: `${data.id}_${Date.now()}`, + paramValues: {}, + }); + if (this.selectedId !== null) { + this.selectedId = null; + document.dispatchEvent( + new CustomEvent("pipeline:moduleSelected", { detail: null }) + ); + } + } catch (err) { console.error("Pipeline drop error:", err); } + this._render(); + } + + // ── Initialisation ──────────────────────────────────────────────── + + _initSVG() { + this.svg = this._el("svg"); + this.svg.setAttribute("class", "pipeline-svg"); + this.container.appendChild(this.svg); + + const defs = this._el("defs"); + defs.innerHTML = ` + + + + + + `; + this.svg.appendChild(defs); + + this.svg.addEventListener("click", e => { + if (e.target === this.svg || e.target.classList.contains("pipeline-bg")) + this._select(null); + }); + this.svg.addEventListener("dragover", e => this._onDragOver(e)); + this.svg.addEventListener("dragleave", e => this._onDragLeave(e)); + this.svg.addEventListener("drop", e => this._onDrop(e)); + document.addEventListener("mousemove", e => this._onCanvasMouseMove(e)); + document.addEventListener("mouseup", e => this._onCanvasMouseUp(e)); + } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..6350e46 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,111 @@ + + + + + + CosmoSIS GUI + + + +
+ CosmoSIS + +
+ +
+ +
+
+ + + + + +
+ + + + +
+
+ + +
+
+ + +
+
+ Run a likelihood evaluation on the Pipeline tab to explore the DataBlock here. +
+ +
+ + +
+
+ Select a 1D array in the tree to plot +
+ + +
+
+
+
+ +
+
+
+
Sampling — coming soon
+
+
+
Campaign — coming soon
+
+
+ + + + + + diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..e2b6df9 --- /dev/null +++ b/worker.py @@ -0,0 +1,901 @@ +"""CosmoSIS GUI — Python JSON-RPC worker. + +Communicates with the Electron main process exclusively over stdin/stdout. +No HTTP server, no ports, no startup delay. + +Protocol +-------- +Each newline-delimited JSON object on **stdin** is a request:: + + {"id": N, "method": "scan_library_dir", "params": {"path": "..."}} + +Each newline-delimited JSON object on **stdout** is a response:: + + {"id": N, "result": [...], "error": null} + {"id": N, "result": null, "error": "Human-readable error message"} + +Architecture +------------ +All state is encapsulated in a single ``Backend`` instance. The main loop +deserialises each request line and dispatches it to the appropriate method. +""" + +import json +import os +import sys +import threading + +import numpy as np + +from inifile import Inifile +from module_library import parse_module_yaml, scan_directory +from modules import SAMPLER_MODULE, get_available_modules, get_initial_pipeline + +# ── Save the real stdout FD at startup ──────────────────────────────────── +# All JSON-RPC replies must go to this object. It is saved *before* any +# stdout redirection happens so that it always points at the actual pipe +# connecting this process to the Electron main process. +_ipc_stdout = sys.stdout + + +def _reply(obj): + _ipc_stdout.write(json.dumps(obj) + "\n") + _ipc_stdout.flush() + + +# ── Backend ──────────────────────────────────────────────────────────────── + +class Backend: + """Owns all mutable state and handles every JSON-RPC method call.""" + + def __init__(self): + self._library_directory = None + self._modules_by_yaml_path = {} + # Retained after load_pipeline_ini; updated by update_param + self._inifile = None + # Set by load_pipeline_ini; used by run_likelihood for actual I/O attribution + self._pipeline_modules = [] + # Set by prepare_pipeline + self._pipeline = None + # Set by run_likelihood + self._results = None + + # ── Read-only queries ────────────────────────────────────────────────── + + def get_modules(self, _params): + return get_available_modules() + + def get_pipeline(self, _params): + return get_initial_pipeline() + + # ── Library scanning ─────────────────────────────────────────────────── + + def scan_library_dir(self, params): + directory = (params or {}).get("path", "").strip() + if not directory: + raise ValueError("No directory path provided.") + + directory = os.path.expanduser(os.path.expandvars(directory)) + if not os.path.isdir(directory): + raise ValueError(f"Not a directory: {directory!r}") + + modules = scan_directory(directory) + + # Change the cwd so that relative paths inside ini files resolve + # correctly when a pipeline is subsequently loaded. + os.chdir(directory) + + self._library_directory = directory + self._modules_by_yaml_path = { + m["_source"]: m for m in modules if m.get("_source") + } + + return modules + + # ── Pipeline INI loading ─────────────────────────────────────────────── + + def load_pipeline_ini(self, params): + ini_path = (params or {}).get("path", "").strip() + if not ini_path: + raise ValueError("No INI file path provided.") + + ini_path = os.path.expanduser(os.path.expandvars(ini_path)) + if not os.path.isfile(ini_path): + raise FileNotFoundError(f"File not found: {ini_path!r}") + + try: + ini = Inifile(ini_path, print_include_messages=False) + except Exception as exc: + raise RuntimeError(f"Failed to parse INI: {exc}") from exc + + # Retain the Inifile so that param updates and pipeline creation can + # operate on the same in-memory object. + self._inifile = ini + # Reset pipeline / results / per-module data whenever a new ini is loaded. + self._pipeline = None + self._results = None + self._pipeline_modules = [] + + try: + modules_str = ini.get("pipeline", "modules", fallback="") + except Exception: + modules_str = "" + + module_names = modules_str.split() if modules_str else [] + sampler_module = self._build_sampler_from_values(ini, ini_path) + pipeline_mods = [sampler_module] + ini_dir = os.path.dirname(os.path.abspath(ini_path)) + + for mod_name in module_names: + if not ini.has_section(mod_name): + continue + module_file = ini.get(mod_name, "file", fallback="") + mod_dict = self._resolve_module(mod_name, module_file, ini_dir) + + # Record the ini-section name so the UI can route param updates. + mod_dict["ini_section"] = mod_name + + param_values = {} + reserved = {"file", "version", "keep_cosmosis_policy"} + for key, value in _ini_section_items(ini, mod_name): + if key not in reserved: + param_values[key] = value + mod_dict["paramValues"] = param_values + pipeline_mods.append(mod_dict) + + # Retain for later use by run_likelihood (actual I/O attribution). + self._pipeline_modules = pipeline_mods + + return pipeline_mods + + # ── Param update (from UI changes) ──────────────────────────────────── + + def update_param(self, params): + """Apply a single param change from the UI to the in-memory Inifile. + + Expects ``{"ini_section": str, "key": str, "value": str}``. + """ + if self._inifile is None: + # No pipeline loaded yet — nothing to update. + return {"ok": False, "reason": "No pipeline loaded."} + + ini_section = (params or {}).get("ini_section", "").strip() + key = (params or {}).get("key", "").strip() + value = str((params or {}).get("value", "")) + + if not ini_section or not key: + raise ValueError("update_param requires 'ini_section' and 'key'.") + + if not self._inifile.has_section(ini_section): + self._inifile.add_section(ini_section) + + self._inifile.set(ini_section, key, value) + return {"ok": True} + + # ── Pipeline preparation ─────────────────────────────────────────────── + + def prepare_pipeline(self, _params): + """Create a ``LikelihoodPipeline`` from the current Inifile. + + Steps: + 1. Set ``[runtime] verbosity = noisy``. + 2. Create ``cosmosis.runtime.pipeline.LikelihoodPipeline``. + 3. Capture stdout + stderr produced during setup. + 4. Parse captured output into per-module chunks. + 5. Return ``{"modules": [{"ini_section", "output"}, ...]}``. + """ + if self._inifile is None: + raise ValueError( + "No pipeline loaded. Use 'Open Pipeline' to load a .ini file first." + ) + + try: + from cosmosis.runtime.pipeline import LikelihoodPipeline + except ImportError as exc: + raise ImportError( + "CosmoSIS must be installed to prepare the pipeline " + f"(cosmosis>=3.22). Original error: {exc}" + ) from exc + + # Set verbosity before building the pipeline. + if not self._inifile.has_section("runtime"): + self._inifile.add_section("runtime") + self._inifile.set("runtime", "verbosity", "noisy") + + # Capture *all* output at the OS file-descriptor level so that writes + # from CosmoSIS C extensions (which bypass Python's redirect_stdout) + # are also included. Both stdout (fd 1) and stderr (fd 2) feed into + # the same pipe so their lines remain interleaved in arrival order. + cap = _FdCapture([1, 2]) + error = None + try: + self._pipeline = LikelihoodPipeline(self._inifile) + except Exception as exc: + error = exc + finally: + captured = cap.finish() + + if error is not None: + raise RuntimeError( + f"Pipeline setup failed: {error}" + + (f"\n\n--- Captured output ---\n{captured}" if captured else "") + ) + module_names = self._pipeline_module_names() + per_module = _parse_module_output(captured, module_names) + + print( + f"[worker] prepare_pipeline: {len(module_names)} modules ready.", + file=sys.stderr, + ) + + return {"modules": per_module, "all": captured} + + # ── Likelihood run ───────────────────────────────────────────────────── + + def run_likelihood(self, _params): + """Run one likelihood evaluation using the prepared pipeline. + + Calls ``pipeline.start_vector()`` then ``pipeline.run_results(v)`` + and stores the returned results object for later use. + + Returns per-module actual I/O extracted from the DataBlock log so that + the frontend can replace the YAML-declared inputs/outputs with what was + actually accessed during the run. + """ + if self._pipeline is None: + raise ValueError( + "Pipeline not prepared. Click 'Prepare Pipeline' first." + ) + + # Use /dev/null redirection instead of a pipe-based capture. + # Cosmosis (and its C extensions) may spawn child processes that inherit + # the pipe write end, preventing EOF from being seen in the drain thread + # and causing an indefinite hang. Since we never use the captured text + # on the success path, redirecting to /dev/null is simpler and avoids + # all blocking/buffering issues while still preventing cosmosis output + # from leaking into the IPC stdout channel. + redir = _FdDevNull([1, 2]) + error = None + try: + v = self._pipeline.start_vector() + self._results = self._pipeline.run_results(v) + except Exception as exc: + error = exc + finally: + redir.finish() + + if error is not None: + raise RuntimeError(f"Likelihood run failed: {error}") + + block = getattr(self._results, "block", None) + per_module = _extract_actual_module_io(block, self._pipeline_modules) + block_contents = _serialize_block(block) + + print( + f"[worker] run_likelihood: {len(per_module)} modules with actual I/O.", + file=sys.stderr, + ) + return {"ok": True, "modules": per_module, "block_contents": block_contents} + + # ── Internal helpers ─────────────────────────────────────────────────── + + def _pipeline_module_names(self): + """Return the ordered list of module names from pipeline.modules.""" + if self._inifile is None: + return [] + try: + s = self._inifile.get("pipeline", "modules", fallback="") + except Exception: + s = "" + return s.split() if s else [] + + def _build_sampler_from_values(self, ini, ini_path): + sampler = dict(SAMPLER_MODULE) + + values_path_raw = ini.get("pipeline", "values", fallback="") + if not values_path_raw: + return sampler + + values_path = os.path.expandvars(values_path_raw.strip()) + if not os.path.isabs(values_path): + values_path = os.path.join( + os.path.dirname(os.path.abspath(ini_path)), values_path + ) + values_path = os.path.normpath(values_path) + + if not os.path.isfile(values_path) and self._library_directory \ + and not os.path.isabs(values_path_raw.strip()): + alt = os.path.normpath( + os.path.join(self._library_directory, values_path_raw.strip()) + ) + if os.path.isfile(alt): + values_path = alt + + if not os.path.isfile(values_path): + return sampler + + try: + val_ini = Inifile(values_path, print_include_messages=False) + except Exception: + return sampler + + outputs = [] + for section in val_ini.sections(): + items = [ + {"name": name, "type": "real", "description": ""} + for name, _ in _ini_section_items(val_ini, section) + ] + outputs.append({ + "name": section, "type": "section", + "description": "", "items": items, + }) + + if outputs: + sampler = dict(sampler) + sampler["outputs"] = outputs + + return sampler + + def _resolve_module(self, mod_name, module_file, ini_dir): + def _try_yaml(yaml_path): + if yaml_path in self._modules_by_yaml_path: + return dict(self._modules_by_yaml_path[yaml_path]) + if os.path.isfile(yaml_path): + try: + with open(yaml_path, encoding="utf-8", errors="replace") as fh: + text = fh.read() + parsed = parse_module_yaml(text, source_path=yaml_path) + if parsed: + return parsed + except OSError: + pass + return None + + if module_file: + expanded = os.path.expandvars(module_file.strip()) + candidates = [] + if os.path.isabs(expanded): + candidates.append(os.path.dirname(expanded)) + else: + candidates.append(os.path.dirname(os.path.join(ini_dir, expanded))) + if self._library_directory: + candidates.append( + os.path.dirname( + os.path.join(self._library_directory, expanded) + ) + ) + for cand_dir in candidates: + yaml_path = os.path.normpath(os.path.join(cand_dir, "module.yaml")) + result = _try_yaml(yaml_path) + if result: + return result + + return { + "id": mod_name, + "name": mod_name, + "category": "Unknown", + "description": ( + f"Module loaded from pipeline ini " + f"(no YAML found for '{module_file}')" + ), + "inputs": [], + "outputs": [], + "params": [], + "_source": module_file or "", + } + + +# ── Module-level helpers ─────────────────────────────────────────────────── + +def _ini_section_items(ini, section): + """Return (key, value) pairs for *section*, excluding DEFAULT entries.""" + try: + return list(ini.items(section, defaults=False)) + except TypeError: + pass + try: + section_keys = set(ini.options(section)) - set(ini.defaults().keys()) + return [(k, ini.get(section, k)) for k in section_keys] + except Exception: + return [] + + +class _FdCapture: + """Capture all writes to a set of OS file descriptors via a pipe. + + Works for *both* Python-level writes (via ``sys.stdout`` / ``sys.stderr``) + and direct C-extension writes to the underlying file descriptor — the + latter are the ones that bypass Python's ``redirect_stdout`` context + manager. + + Usage:: + + cap = _FdCapture([1, 2]) # start capturing fd 1 + fd 2 + try: + do_something_that_produces_output() + finally: + text = cap.finish() # restore fds, return captured text + """ + + def __init__(self, fds): + self._fds = list(fds) + + # Flush Python wrappers before redirecting so no buffered data leaks + # into the capture pipe from a previous call. + _ipc_stdout.flush() + try: + sys.stderr.flush() + except Exception: + pass + + # Save a copy of each fd so we can restore later. + self._saved = {fd: os.dup(fd) for fd in self._fds} + + # All captured fds write into the *same* pipe so output is interleaved + # in arrival order, just as a reader would see it. + r, w = os.pipe() + for fd in self._fds: + os.dup2(w, fd) + os.close(w) # Only the dup'd fds keep the write end alive now. + + self._r = r # saved so finish() can force-close if needed + self._chunks: list[bytes] = [] + self._t = threading.Thread(target=self._drain, args=(r,), daemon=True) + self._t.start() + + def _drain(self, r: int) -> None: + """Background reader: drains the pipe until all writers close it.""" + while True: + try: + chunk = os.read(r, 4096) + except OSError: + break + if not chunk: + break + self._chunks.append(chunk) + try: + os.close(r) + except OSError: + pass + + def finish(self) -> str: + """Restore original fds and return all text written during capture.""" + # Flush Python-level wrappers one last time to drain any buffered data + # into the pipe before we close the write ends. + try: + _ipc_stdout.flush() + except Exception: + pass + try: + sys.stderr.flush() + except Exception: + pass + + # Restore originals. Each os.dup2 closes the redirected fd's handle + # to the write end of the pipe; the last one causes EOF in the drain + # thread — unless a child process or C-level thread has inherited a + # copy of the write end. + for fd in self._fds: + os.dup2(self._saved[fd], fd) + os.close(self._saved[fd]) + + # Give the drain thread a short time to finish naturally. + self._t.join(timeout=5) + + if self._t.is_alive(): + # Some fd outside our control is still holding the pipe write end + # open (e.g. a C thread or child process spawned during capture). + # Force-stop the drain thread by closing the read end of the pipe; + # os.read() in the drain thread will raise OSError → break. + try: + os.close(self._r) + except OSError: + pass + self._t.join(timeout=3) + if self._t.is_alive(): + print( + "[worker] _FdCapture: drain thread did not stop after force-close" + " — output may be incomplete.", + file=sys.stderr, + ) + + # Safe to read: drain thread has exited (or been signalled to exit). + return b"".join(self._chunks).decode("utf-8", errors="replace") + + +class _FdDevNull: + """Temporarily redirect file descriptors to /dev/null. + + Writes are instant and never block, so there is no risk of the calling + code deadlocking due to a full pipe buffer. Use this when the output + produced during an operation is not needed. + + Usage:: + + null = _FdDevNull([1, 2]) + try: + do_something_that_produces_output() + finally: + null.finish() # restores original fds; returns "" for API compat + """ + + def __init__(self, fds): + self._fds = list(fds) + _ipc_stdout.flush() + try: + sys.stderr.flush() + except Exception: + pass + self._saved = {fd: os.dup(fd) for fd in self._fds} + null_fd = os.open(os.devnull, os.O_WRONLY) + for fd in self._fds: + os.dup2(null_fd, fd) + os.close(null_fd) + + def finish(self) -> str: + for fd in self._fds: + os.dup2(self._saved[fd], fd) + os.close(self._saved[fd]) + return "" + + +def _serialize_block(block): + """Serialize all sections and values from a CosmoSIS DataBlock. + + Returns a list of section dicts:: + + [{"name": str, + "values": [{"name": str, "dtype": str, "shape": str|None, + "scalar": str|None}]}] + + Scalar values are stringified for direct display. + Array values carry a ``shape`` field; a short preview of up to 6 elements + is included as ``preview`` (a list of strings). + """ + if block is None: + return [] + + # Retrieve section and key listings robustly. + try: + section_names = list(block.sections()) + except Exception: + return [] + + result = [] + for sec in section_names: + try: + # block.keys(section) returns (section, name) pairs; extract just names. + keys = [name for _s, name in block.keys(sec)] + except Exception: + keys = [] + + values = [] + for key in keys: + try: + val = block[sec, key] + except Exception: + continue + entry = {"name": key, "dtype": _normalize_dtype(val), "shape": None, + "scalar": None, "preview": None, "plot_data": None} + if isinstance(val, np.ndarray): + entry["shape"] = "×".join(str(d) for d in val.shape) + flat = val.flat + preview = [] + for _i, x in enumerate(flat): + if _i >= 6: + break + preview.append(str(x)) + entry["preview"] = preview + entry["n_elements"] = int(val.size) + # For 1-D numeric arrays (up to 50 000 elements) include the + # full data so the frontend can pass it straight to Plotly. + if (val.ndim == 1 + and np.issubdtype(val.dtype, np.number) + and val.size <= 50_000): + try: + entry["plot_data"] = val.astype(float).tolist() + except Exception: + pass + else: + entry["scalar"] = str(val) + values.append(entry) + + result.append({"name": sec, "values": values}) + + return result + + +def _normalize_dtype(value): + """Convert a DataBlock data_type (possibly a Python type object) to a display string.""" + if value is None: + return "" + if isinstance(value, (int, np.integer)): + return "int" + elif isinstance(value, (float, np.floating)): + return "real" + elif isinstance(value, str): + return "str" + elif isinstance(value, bool): + return "bool" + elif isinstance(value, (complex, np.complexfloating)): + return "complex" + elif isinstance(value, np.ndarray): + dt = value.dtype + ndim = f"{value.ndim}D" + if dt == 'int': + return f"int {ndim}" + elif dt in [np.int32, np.int64]: + return f"int {ndim}" + elif dt == 'float': + return f"real {ndim}" + elif dt == 'complex': + return f"complex {ndim}" + elif dt == 'bool': + return f"bool {ndim}" + elif dt == 'str': + return f"str {ndim}" + elif dt.kind == 'U': + return f"str {ndim}" + else: + sys.stderr.write(f"??? Unrecognized array dtype: {dt}\n") + return f"???? {ndim}" + else: + sys.stderr.write(f"??? Unrecognized array dtype: {value}\n") + return "????" + + +def _extract_actual_module_io(block, pipeline_modules): + """Extract per-module actual I/O from a CosmoSIS DataBlock log. + + Cross-references log entries (READ-OK / READ-DEFAULT / WRITE-OK / + REPLACE-OK) against each module's YAML-declared input and output sections. + + Returns a list of dicts: + + {"ini_section": str, + "actual_inputs": [{"name": section, "type": "section", + "items": [{"name": key, "type": dtype}]}], + "actual_defaults": [...], # READ-DEFAULT entries — different colour + "actual_outputs": [...]} # WRITE-OK / REPLACE-OK entries + """ + if block is None or not pipeline_modules: + return [] + + try: + count = block.get_log_count() + except Exception: + return [] + + _READ_TYPES = {"read-ok", "read-default"} + _WRITE_TYPES = {"write-ok", "replace-ok"} + + # Collect all log entries into two maps: + # reads[(section_lc, name_lc)] = {"access_type", "data_type", "section", "name"} + # writes[(section_lc, name_lc)] = {"access_type", "data_type", "section", "name"} + # For reads: READ-OK supersedes READ-DEFAULT for the same key. + reads = {} + writes = {} + + for i in range(count): + try: + entry = block.get_log_entry(i) + except Exception: + continue + if not entry or len(entry) < 3: + continue + + access_type = str(entry[0]).lower() if entry[0] is not None else "" + section = str(entry[1]) if entry[1] is not None else "" + name = str(entry[2]) if entry[2] is not None else "" + if len(entry) <= 3: + data_type = "" + elif block.has_value(section, name): + value = block[section, name] + data_type = _normalize_dtype(value) + + key = (section.lower(), name.lower()) + + if access_type in _WRITE_TYPES: + writes[key] = { + "access_type": access_type, + "data_type": data_type, + "section": section, + "name": name, + } + elif access_type in _READ_TYPES: + existing = reads.get(key) + # READ-OK takes priority over READ-DEFAULT for the same key. + if existing is None or ( + access_type == "read-ok" and existing["access_type"] == "read-default" + ): + reads[key] = { + "access_type": access_type, + "data_type": data_type, + "section": section, + "name": name, + } + + result = [] + for mod in pipeline_modules: + ini_section = mod.get("ini_section") + if not ini_section: + continue # sampler module — no ini section, skip + + # Build lookup: lower-case section name → display name + input_sections = { + p["name"].lower(): p["name"] + for p in (mod.get("inputs") or []) + if p.get("type") == "section" + } + output_sections = { + p["name"].lower(): p["name"] + for p in (mod.get("outputs") or []) + if p.get("type") == "section" + } + + # Actual inputs (READ-OK) and defaults (READ-DEFAULT) for declared input sections. + actual_inputs = [] + actual_defaults = [] + for sec_lc, sec_display in input_sections.items(): + ok_items = [] + def_items = [] + for (s_lc, _n_lc), info in reads.items(): + if s_lc == sec_lc: + item = {"name": info["name"], "type": info["data_type"]} + if info["access_type"] == "read-ok": + ok_items.append(item) + else: + def_items.append(item) + if ok_items: + actual_inputs.append({ + "name": sec_display, "type": "section", "items": ok_items, + }) + if def_items: + actual_defaults.append({ + "name": sec_display, "type": "section", "items": def_items, + }) + + # Actual outputs (WRITE-OK / REPLACE-OK) for declared output sections. + actual_outputs = [] + for sec_lc, sec_display in output_sections.items(): + items = [] + for (s_lc, _n_lc), info in writes.items(): + if s_lc == sec_lc: + items.append({"name": info["name"], "type": info["data_type"]}) + if items: + actual_outputs.append({ + "name": sec_display, "type": "section", "items": items, + }) + + result.append({ + "ini_section": ini_section, + "actual_inputs": actual_inputs, + "actual_defaults": actual_defaults, + "actual_outputs": actual_outputs, + }) + + return result + + +def _parse_module_output(combined, module_names): + """Split captured output text into per-module chunks. + + CosmoSIS prints ``Setting up module `` before each module's setup + output, and ``Setup all pipeline modules`` at the very end. We use these + boundary markers to attribute lines to the correct module. + + Lines that appear before the first ``Setting up module`` marker are + attributed to the first module (if any). + + Returns a list of ``{"ini_section": name, "output": text}`` dicts in + pipeline order. + """ + if not module_names: + return [] + + _SETUP_PREFIX = "setting up module " + _SETUP_ALL = "setup all pipeline modules" + + # Build ordered list of (line_index, module_name) for each "Setting up + # module " boundary, then a sentinel for the final "Setup all …" line. + boundaries = [] # list of (line_number, module_name_or_None) + lines = combined.splitlines() + + for lineno, line in enumerate(lines): + stripped = line.strip() + lowered = stripped.lower() + if lowered.startswith(_SETUP_PREFIX): + # Extract the module name token immediately after the prefix. + rest = stripped[len(_SETUP_PREFIX):].strip() + parts = rest.split() + name_token = parts[0].rstrip(".,:;") if parts else "" + # Find the matching pipeline module (case-insensitive prefix match). + matched = None + for m in module_names: + if m.lower() == name_token.lower(): + matched = m + break + if matched is None and name_token: + # Fallback: accept any module whose name starts with the token. + for m in module_names: + if m.lower().startswith(name_token.lower()): + matched = m + break + if matched: + boundaries.append((lineno, matched)) + elif lowered.startswith(_SETUP_ALL): + boundaries.append((lineno, None)) # sentinel: end of last module + + # Assign line ranges to modules. + # If there are no boundaries, all output goes to the first module. + module_lines = {name: [] for name in module_names} + + if not boundaries: + # No "Setting up module" markers found — dump everything to the first module. + if module_names: + module_lines[module_names[0]] = lines + else: + # Lines before the first boundary → first recognised module. + first_boundary_line = boundaries[0][0] + if first_boundary_line > 0 and module_names: + module_lines[module_names[0]].extend(lines[:first_boundary_line]) + + for i, (start, name) in enumerate(boundaries): + if name is None: + break # sentinel reached + end = boundaries[i + 1][0] if i + 1 < len(boundaries) else len(lines) + module_lines[name].extend(lines[start:end]) + + return [ + {"ini_section": name, "output": "\n".join(module_lines[name])} + for name in module_names + ] + + +# ── Main dispatch loop ───────────────────────────────────────────────────── + +def main(): + backend = Backend() + + dispatch = { + "get_modules": backend.get_modules, + "get_pipeline": backend.get_pipeline, + "scan_library_dir": backend.scan_library_dir, + "load_pipeline_ini": backend.load_pipeline_ini, + "update_param": backend.update_param, + "prepare_pipeline": backend.prepare_pipeline, + "run_likelihood": backend.run_likelihood, + } + + for raw_line in sys.stdin: + line = raw_line.strip() + if not line: + continue + + try: + req = json.loads(line) + except json.JSONDecodeError as exc: + _reply({"id": None, "result": None, "error": f"JSON decode error: {exc}"}) + continue + + req_id = req.get("id") + method = req.get("method", "") + params = req.get("params") or {} + handler = dispatch.get(method) + + if handler: + try: + result = handler(params) + _reply({"id": req_id, "result": result, "error": None}) + except Exception as exc: + _reply({"id": req_id, "result": None, "error": str(exc)}) + else: + _reply({ + "id": req_id, + "result": None, + "error": f"Unknown method: {method!r}", + }) + + +if __name__ == "__main__": + main()