Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,9 @@ site/
!/notebooks/outputs/
!/notebooks/outputs/*/
!/notebooks/outputs/*/.gitkeep

# Dashboard uploads and local archives
/dashboard_uploads/
*.zip
/results/
/.streamlit/
9 changes: 8 additions & 1 deletion common/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
from common.utils.display import format_duration
"""Shared PhosKinTime utilities."""


def format_duration(seconds):
"""Format a duration in seconds without importing heavy common utilities at package import time."""
from common.utils.display import format_duration as _format_duration

return _format_duration(seconds)
241 changes: 241 additions & 0 deletions common/results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
from __future__ import annotations

import hashlib
import json
import os
import platform
import shutil
import subprocess
import sys
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable

STANDARD_SUBDIRS = ("tables", "plots", "logs", "reports", "artifacts")


def ensure_result_dir(outdir: str | Path) -> dict[str, Path]:
root = Path(outdir).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
paths = {"root": root}
for name in STANDARD_SUBDIRS:
p = root / name
p.mkdir(parents=True, exist_ok=True)
paths[name] = p
return paths


def file_sha256(path: str | Path, chunk_size: int = 1024 * 1024) -> str | None:
p = Path(path)
if not p.is_file():
return None
h = hashlib.sha256()
with p.open("rb") as fh:
for chunk in iter(lambda: fh.read(chunk_size), b""):
h.update(chunk)
return h.hexdigest()


def describe_inputs(paths: Iterable[str | Path | None]) -> list[dict[str, Any]]:
out = []
for raw in paths:
if raw is None:
continue
p = Path(raw).expanduser()
item: dict[str, Any] = {"path": str(p)}
if p.exists():
try:
item["resolved_path"] = str(p.resolve())
except OSError:
pass
if p.is_file():
item["sha256"] = file_sha256(p)
item["size_bytes"] = p.stat().st_size
else:
item["missing"] = True
out.append(item)
return out


def package_version() -> str | None:
try:
import importlib.metadata as metadata
return metadata.version("phoskintime")
except Exception:
pass
try:
import tomllib
with Path("pixi.toml").open("rb") as fh:
return tomllib.load(fh).get("workspace", {}).get("version")
except Exception:
return None


def git_commit() -> str | None:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL, text=True
).strip()
except Exception:
return None


def pixi_environment() -> str | None:
return os.environ.get("PIXI_ENVIRONMENT_NAME") or os.environ.get("PIXI_ENVIRONMENT")


def command_text(argv: Iterable[str] | None = None) -> str:
args = list(sys.argv if argv is None else argv)
return " ".join(shlex_quote(a) for a in args)


def shlex_quote(value: str) -> str:
import shlex
return shlex.quote(str(value))


def to_json_safe(value: Any) -> Any:
"""Convert values such as NumPy arrays/scalars and Paths to JSON-safe objects."""
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {str(k): to_json_safe(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [to_json_safe(v) for v in value]
if hasattr(value, "tolist") and callable(value.tolist):
return to_json_safe(value.tolist())
if hasattr(value, "item") and callable(value.item):
try:
return to_json_safe(value.item())
except (TypeError, ValueError):
pass
if hasattr(value, "__dict__"):
return to_json_safe(vars(value))
try:
json.dumps(value)
return value
except TypeError:
return str(value)


def _jsonable(value: Any) -> Any:
return to_json_safe(value)


def write_command(outdir: str | Path, argv: Iterable[str] | None = None) -> Path:
root = ensure_result_dir(outdir)["root"]
path = root / "command.txt"
path.write_text(command_text(argv) + "\n", encoding="utf-8")
return path


def write_metadata(
outdir: str | Path,
workflow: str,
args: Any | None = None,
inputs: Iterable[str | Path | None] = (),
extra: dict[str, Any] | None = None,
) -> Path:
root = ensure_result_dir(outdir)["root"]
metadata = {
"workflow": workflow,
"timestamp": datetime.now(timezone.utc).isoformat(),
"command_arguments": _jsonable(args) if args is not None else sys.argv[1:],
"output_directory": str(root),
"package_version": package_version(),
"git_commit": git_commit(),
"python_version": platform.python_version(),
"python_executable": sys.executable,
"pixi_environment": pixi_environment(),
"inputs": describe_inputs(inputs),
}
if extra:
metadata.update(_jsonable(extra))
path = root / "metadata.json"
path.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path


def write_resolved_config(outdir: str | Path, config: Any | None) -> Path | None:
if config is None:
return None
root = ensure_result_dir(outdir)["root"]
path = root / "config_resolved.yaml"
try:
import yaml # type: ignore
text = yaml.safe_dump(_jsonable(config), sort_keys=True)
except Exception:
text = json.dumps(_jsonable(config), indent=2, sort_keys=True)
path.write_text(text, encoding="utf-8")
return path


@contextmanager
def tee_console_log(outdir: str | Path):
root = ensure_result_dir(outdir)["root"]
log_path = root / "console.log"
class Tee:
def __init__(self, stream, fh):
self.stream = stream
self.fh = fh
def write(self, data):
self.stream.write(data)
self.fh.write(data)
def flush(self):
self.stream.flush(); self.fh.flush()
with log_path.open("a", encoding="utf-8") as fh:
old_out, old_err = sys.stdout, sys.stderr
sys.stdout, sys.stderr = Tee(old_out, fh), Tee(old_err, fh)
try:
yield log_path
finally:
sys.stdout, sys.stderr = old_out, old_err


def attach_file_console_logger(logger, outdir: str | Path, filename: str = "console.log"):
import logging
root = ensure_result_dir(outdir)["root"]
log_path = root / filename
abs_path = str(log_path.resolve())
for handler in logger.handlers:
if getattr(handler, "baseFilename", None) == abs_path:
return log_path
handler = logging.FileHandler(log_path, mode="a", encoding="utf-8")
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
logger.addHandler(handler)
return log_path


def populate_standard_subdirs(outdir: str | Path, *, copy: bool = True) -> None:
paths = ensure_result_dir(outdir)
root = paths["root"]
skip_names = set(STANDARD_SUBDIRS) | {"metadata.json", "command.txt", "console.log", "config_resolved.yaml"}
table_ext = {".csv", ".tsv", ".xlsx", ".xls", ".parquet", ".json", ".npy", ".npz"}
plot_ext = {".png", ".jpg", ".jpeg", ".svg", ".pdf", ".html"}
Comment on lines +215 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep populated suffixes discoverable by the dashboard

When runs produce non-CSV table/artifact outputs or PDF reports, this sends .json, .npy, .npz, .parquet, and .pdf files into tables/ or plots/, but discover_result_directory() only scans tables with TABLE_EXTENSIONS (.csv/.tsv/.xlsx/.xls) and plots with PLOT_EXTENSIONS (.png/.jpg/.jpeg/.svg/.html). As a result files such as multistart_params.npz, optimized_entities.json, or S_rates_report.pdf are copied into standard folders but then disappear from the dashboard browser; either route unsupported suffixes to artifacts/ or make the parser/viewers include them.

Useful? React with 👍 / 👎.

report_names = {"report.html"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include model-specific HTML reports in reports

When ProtWise calls common.utils.display.create_report(out_dir) it writes a model-specific filename such as Protein-wise_report.html, not report.html, so this exact-name check routes that HTML report through the generic .html plot branch instead of reports/. In normal ProtWise runs the dashboard Reports tab therefore stays empty even though the report was generated; treat *_report.html/HTML report stems as reports or pass a standard report filename.

Useful? React with 👍 / 👎.

artifact_ext = {".pkl", ".pickle", ".joblib"}
for p in list(root.iterdir()):
if p.name in skip_names or p.is_dir():
continue
ext = p.suffix.lower()
if p.name in report_names:
dest_dir = paths["reports"]
elif ext in table_ext:
dest_dir = paths["tables"]
elif ext in plot_ext:
dest_dir = paths["plots"]
elif ext in artifact_ext:
dest_dir = paths["artifacts"]
elif ext == ".log":
dest_dir = paths["logs"]
else:
dest_dir = paths["artifacts"]
dest = dest_dir / p.name
if dest.exists():
continue
if copy:
shutil.copy2(p, dest)
else:
shutil.move(str(p), str(dest))
23 changes: 15 additions & 8 deletions config/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def _run(cmd: list[str]) -> None:
sp.check_call([PY, "-m", *cmd], cwd=ROOT)


def _python_module(module: str, cfg: Path | None) -> list[str]:
def _python_module(module: str, cfg: Path | None, outdir: Path | None = None) -> list[str]:
"""
Return `python module [--conf path]`.

Expand All @@ -67,6 +67,8 @@ def _python_module(module: str, cfg: Path | None) -> list[str]:
cmd = [module]
if cfg is not None:
cmd += ["--conf", str(cfg)]
if outdir is not None:
cmd += ["--outdir", str(outdir)]
return cmd


Expand All @@ -84,6 +86,7 @@ def prep():
@app.command()
def tfopt(
mode: str = typer.Option("local", help="local | evol"),
outdir: Path | None = typer.Option(None, "--outdir", "--output-dir", file_okay=False, dir_okay=True, help="Directory for workflow outputs."),
conf: Path | None = typer.Option(
None, "--conf", file_okay=True, dir_okay=False, writable=False,
help="Path to TOML/YAML config. Uses defaults if omitted."
Expand All @@ -99,12 +102,13 @@ def tfopt(
None
"""
module = f"tfopt.{mode}"
_run(_python_module(module, conf))
_run(_python_module(module, conf, outdir if mode == "local" else None))


@app.command()
def kinopt(
mode: str = typer.Option("local", help="local | evol"),
outdir: Path | None = typer.Option(None, "--outdir", "--output-dir", file_okay=False, dir_okay=True, help="Directory for workflow outputs."),
conf: Path | None = typer.Option(
None, "--conf", file_okay=True, dir_okay=False, writable=False,
help="Path to TOML/YAML config. Uses defaults if omitted."
Expand All @@ -120,11 +124,12 @@ def kinopt(
None
"""
module = f"kinopt.{mode}"
_run(_python_module(module, conf))
_run(_python_module(module, conf, outdir if mode == "local" else None))


@app.command()
def model(
outdir: Path | None = typer.Option(None, "--outdir", "--output-dir", file_okay=False, dir_okay=True, help="Directory for workflow outputs."),
conf: Path | None = typer.Option(
None, "--conf", file_okay=True, dir_okay=False, writable=False,
help="Path to model config file. Uses defaults if omitted."
Expand All @@ -138,11 +143,12 @@ def model(
Returns:
None
"""
_run(_python_module("protwise.runner.main", conf))
_run(_python_module("protwise.runner.main", conf, outdir))


@app.command()
def networkmodel(
outdir: Path | None = typer.Option(None, "--outdir", "--output-dir", file_okay=False, dir_okay=True, help="Directory for workflow outputs."),
conf: Path | None = typer.Option(
"config.toml", "--conf", file_okay=True, dir_okay=False, writable=False,
help="Path to global model config file. Uses config.toml by default."
Expand All @@ -155,7 +161,7 @@ def networkmodel(
section of your configuration.
"""
# Assuming the package is named 'networkmodel' and has a 'runner.py'
_run(_python_module("networkmodel.runner", conf))
_run(_python_module("networkmodel.runner", conf, outdir))


@app.command()
Expand Down Expand Up @@ -201,6 +207,7 @@ def all(
tf_conf: Path | None = typer.Option(None, help="tfopt config file"),
kin_conf: Path | None = typer.Option(None, help="kinopt config file"),
model_conf: Path | None = typer.Option(None, help="model config file"),
outdir: Path | None = typer.Option(None, "--outdir", "--output-dir", file_okay=False, dir_okay=True, help="Base directory for workflow outputs."),
):
"""
Run every stage in sequence.
Expand All @@ -220,9 +227,9 @@ def all(
None
"""
prep()
tfopt.callback(mode=tf_mode, conf=tf_conf)
kinopt.callback(mode=kin_mode, conf=kin_conf)
model.callback(conf=model_conf)
tfopt.callback(mode=tf_mode, conf=tf_conf, outdir=(outdir / "tfopt" if outdir else None))
kinopt.callback(mode=kin_mode, conf=kin_conf, outdir=(outdir / "kinopt" if outdir else None))
model.callback(conf=model_conf, outdir=(outdir / "protwise" if outdir else None))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass generated optimization workbooks into model

When all runs the local TF/KinOpt stages, those stages now keep tfopt_results.xlsx and kinopt_results.xlsx in their result directories and this commit explicitly removed the old copy into data/. The subsequent model.callback(...) only changes ProtWise's output directory, so protwise.runner still reads its defaults from data/tfopt_results.xlsx and data/kinopt_results.xlsx; on a fresh run these files are missing, and on a dirty workspace it can silently model stale results instead of the just-produced workbooks. Pass the newly produced workbook paths as ProtWise inputs, or keep a handoff copy for the chained workflow.

Useful? React with 👍 / 👎.

Comment on lines +230 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Surface child outputs for all-workflow runs

For dashboard phoskintime-all runs, the launcher opens built.outdir after success, but these callbacks write the actual TFOpt/KinOpt/ProtWise outputs under outdir/tfopt, outdir/kinopt, and outdir/protwise. The parent run directory only has launcher provenance, and discover_result_directory() does not recurse into arbitrary child workflow directories, so the successful all-workflow run appears to have no tables/plots/reports unless the user manually finds a nested folder; aggregate/copy the child results or have the dashboard present/open those child directories.

Useful? React with 👍 / 👎.



if __name__ == "__main__":
Expand Down
Loading
Loading