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
1 change: 1 addition & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function fetchTasks(): Promise<TaskInfo[]> {

export type TaskSchemaResponse = {
title: string;
description: string;
schema: Record<string, unknown>;
};

Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/TaskPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TaskPage } from "./TaskPage";

const fakeTaskSchema = {
title: "Fake Test Task",
description: "A task used in tests.",
schema: {
type: "object",
properties: {
Expand Down Expand Up @@ -59,6 +60,7 @@ describe("TaskPage", () => {
expect(screen.getByText("Fake Test Task")).toBeInTheDocument();
});

expect(screen.getByText("A task used in tests.")).toBeInTheDocument();
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/count/i)).toBeInTheDocument();
});
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/TaskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function TaskPage() {
const location = useLocation();
const [schema, setSchema] = useState<Record<string, unknown> | null>(null);
const [taskTitle, setTaskTitle] = useState<string | null>(null);
const [taskDescription, setTaskDescription] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [runId, setRunId] = useState<string | null>(null);
Expand All @@ -33,10 +34,11 @@ export function TaskPage() {
}
let alive = true;
fetchTaskSchema(taskId)
.then(({ title, schema: s }) => {
.then(({ title, description, schema: s }) => {
if (!alive) return;
setSchema(s);
setTaskTitle(title);
setTaskDescription(description);
setLoadError(null);
})
.catch((e) => {
Expand Down Expand Up @@ -73,9 +75,18 @@ export function TaskPage() {

return (
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
<Typography variant="h6" sx={{ mb: taskDescription ? 1 : 2 }}>
{taskTitle ?? taskId}
</Typography>
{taskDescription && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, whiteSpace: "pre-wrap" }}
>
{taskDescription}
</Typography>
)}
{submitError && (
<Alert severity="error" sx={{ mb: 2 }}>
{submitError}
Expand Down
1 change: 1 addition & 0 deletions tests/test_server_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def fake_handler(form: FakeTaskForm, emit: Callable[[report.Event], None]) -> No
schema_response = client.get("/api/tasks/fake-task/schema")
assert schema_response.status_code == 200
assert schema_response.json()["title"] == "Fake Task"
assert schema_response.json()["description"] == "Task used for integration testing."

submit_response = client.post("/api/tasks/fake-task/submit", json={"name": "alpha"})
assert submit_response.status_code == 200
Expand Down
162 changes: 162 additions & 0 deletions uploader/app/lib/expression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import ast
import operator
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import final

import astropy.constants as const
import astropy.units as u
import numpy as np

CONST_PREFIX = "const_"

NAMED_CONSTANTS: dict[str, u.Quantity] = {
"const_pi": np.pi * u.dimensionless_unscaled,
"const_c": const.c,
"const_deg": 1 * u.deg,
"const_rad": 1 * u.rad,
"const_arcmin": 1 * u.arcmin,
"const_arcsec": 1 * u.arcsec,
"const_mag": 1 * u.mag,
}


def expression_syntax_help() -> str:
constants = ", ".join(sorted(NAMED_CONSTANTS))
return (
"Bare identifiers refer to rawdata column names.\n"
"Identifiers starting with const_ refer to predefined constants.\n"
"Operators: + - * /.\n"
"Functions: sin(x), cos(x) (argument must be an angle).\n"
"Numbers are dimensionless.\n"
f"Available constants: {constants}."
)


type _QuantityBinOp = Callable[[u.Quantity, u.Quantity], u.Quantity]
type _QuantityUnaryOp = Callable[[u.Quantity], u.Quantity]
type _QuantityFunc = Callable[[u.Quantity], u.Quantity | float]

_BINOPS: dict[type[ast.operator], _QuantityBinOp] = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}

_UNARYOPS: dict[type[ast.unaryop], _QuantityUnaryOp] = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}

_FUNCTIONS: dict[str, _QuantityFunc] = {
"sin": np.sin,
"cos": np.cos,
}


@final
@dataclass
class Expression:
_tree: ast.Expression
referenced_columns: set[str] = field(default_factory=set)

def evaluate(self, values: dict[str, float], units: dict[str, str]) -> u.Quantity:
return _Evaluator(values, units).visit(self._tree.body)


def parse(source: str) -> Expression:
tree = ast.parse(source.strip(), mode="eval")
referenced_columns = _collect_columns(tree.body)
return Expression(_tree=tree, referenced_columns=referenced_columns)


def _collect_columns(node: ast.AST) -> set[str]:
return _ColumnCollector().collect(node)


@final
class _ColumnCollector(ast.NodeVisitor):
def __init__(self) -> None:
self.columns: set[str] = set()

def collect(self, node: ast.AST) -> set[str]:
self.visit(node)
return self.columns

def visit_Call(self, node: ast.Call) -> None:
for arg in node.args:
self.visit(arg)

def visit_Name(self, node: ast.Name) -> None:
if not node.id.startswith(CONST_PREFIX):
self.columns.add(node.id)


@final
class _Evaluator(ast.NodeVisitor):
def __init__(self, values: dict[str, float], units: dict[str, str]) -> None:
self._values = values
self._units = units

def visit(self, node: ast.AST) -> u.Quantity:
match node:
case ast.BinOp(left=left, op=op, right=right):
return self._binop(left, op, right)
case ast.UnaryOp(op=op, operand=operand):
return self._unaryop(op, operand)
case ast.Call(func=func, args=args, keywords=keywords):
return self._call(func, args, keywords)
case ast.Name(id=name):
return self._name(name)
case ast.Constant(value=value):
return self._constant(value)
case _:
raise ValueError(f"unsupported expression node: {type(node).__name__}")

def _binop(self, left: ast.AST, op: ast.operator, right: ast.AST) -> u.Quantity:
op_type = type(op)
if op_type not in _BINOPS:
raise ValueError(f"unsupported operator: {op_type.__name__}")
return _BINOPS[op_type](self.visit(left), self.visit(right))

def _unaryop(self, op: ast.unaryop, operand: ast.AST) -> u.Quantity:
op_type = type(op)
if op_type not in _UNARYOPS:
raise ValueError(f"unsupported unary operator: {op_type.__name__}")
return _UNARYOPS[op_type](self.visit(operand))

def _call(self, func: ast.AST, args: list[ast.AST], keywords: list[ast.keyword]) -> u.Quantity:
if keywords:
raise ValueError("keyword arguments are not allowed")
if not isinstance(func, ast.Name):
raise ValueError("only simple function calls are allowed")
fn = _FUNCTIONS.get(func.id)
if fn is None:
raise ValueError(f"unknown function: {func.id}")
if len(args) != 1:
raise ValueError(f"{func.id}() takes exactly one argument")
arg = self.visit(args[0]).to(u.rad)
result = fn(arg)
if isinstance(result, u.Quantity):
return result
return float(result) * u.dimensionless_unscaled

def _name(self, name: str) -> u.Quantity:
if name.startswith(CONST_PREFIX):
constant = NAMED_CONSTANTS.get(name)
if constant is None:
raise ValueError(f"unknown constant {name!r}")
return constant
if name not in self._values:
raise ValueError(f"unknown column {name!r}")
unit_str = self._units.get(name, "")
unit = u.Unit(unit_str) if unit_str else u.dimensionless_unscaled
return self._values[name] * unit

def _constant(self, value: object) -> u.Quantity:
if isinstance(value, bool):
raise ValueError("boolean constants are not allowed")
if isinstance(value, int | float):
return float(value) * u.dimensionless_unscaled
raise ValueError(f"unsupported constant type: {type(value).__name__}")
3 changes: 3 additions & 0 deletions uploader/app/structured/geometry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from uploader.app.structured.geometry.upload import upload_geometry_isophotal

__all__ = ["upload_geometry_isophotal"]
Loading
Loading