From c84f1fc8a6a602d0974a96b8ce114957af0d7894 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 23 May 2026 18:25:26 +0100 Subject: [PATCH 1/2] Geometry catalog uploading (isophotal) --- uploader/app/lib/expression.py | 149 ++++++++++++++ uploader/app/structured/geometry/__init__.py | 3 + uploader/app/structured/geometry/upload.py | 191 ++++++++++++++++++ .../forms/structured_geometry_isophotal.py | 86 ++++++++ uploader/task_registry.py | 14 ++ 5 files changed, 443 insertions(+) create mode 100644 uploader/app/lib/expression.py create mode 100644 uploader/app/structured/geometry/__init__.py create mode 100644 uploader/app/structured/geometry/upload.py create mode 100644 uploader/forms/structured_geometry_isophotal.py diff --git a/uploader/app/lib/expression.py b/uploader/app/lib/expression.py new file mode 100644 index 0000000..8bf797e --- /dev/null +++ b/uploader/app/lib/expression.py @@ -0,0 +1,149 @@ +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, +} + +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__}") diff --git a/uploader/app/structured/geometry/__init__.py b/uploader/app/structured/geometry/__init__.py new file mode 100644 index 0000000..20acc88 --- /dev/null +++ b/uploader/app/structured/geometry/__init__.py @@ -0,0 +1,3 @@ +from uploader.app.structured.geometry.upload import upload_geometry_isophotal + +__all__ = ["upload_geometry_isophotal"] diff --git a/uploader/app/structured/geometry/upload.py b/uploader/app/structured/geometry/upload.py new file mode 100644 index 0000000..d5cca36 --- /dev/null +++ b/uploader/app/structured/geometry/upload.py @@ -0,0 +1,191 @@ +from collections.abc import Callable + +import astropy.units as u +from psycopg import sql + +import uploader.app.report as report +from uploader.app.display import format_table +from uploader.app.lib.expression import Expression, parse +from uploader.app.lib.rawdata import rawdata_batches +from uploader.app.storage import PgStorage +from uploader.app.upload import handle_call +from uploader.clients.gen.client import adminapi +from uploader.clients.gen.client.adminapi.api.default import get_table, save_structured_data +from uploader.clients.gen.client.adminapi.models.save_structured_data_request import ( + SaveStructuredDataRequest, +) +from uploader.clients.gen.client.adminapi.models.save_structured_data_request_units import ( + SaveStructuredDataRequestUnits, +) + +GEOMETRY_COLUMNS = ["band", "method", "isophote", "a", "e_a", "b", "e_b", "pa", "e_pa"] + +TARGET_UNITS = { + "a": "arcsec", + "e_a": "arcsec", + "b": "arcsec", + "e_b": "arcsec", + "pa": "deg", + "e_pa": "deg", + "isophote": "mag/arcmin2", +} + +EXPR_FIELDS = ["a", "e_a", "b", "e_b", "pa", "e_pa", "isophote"] + +GEOMETRY_UNITS = SaveStructuredDataRequestUnits.from_dict(TARGET_UNITS) + + +def _fetch_column_units( + client: adminapi.AuthenticatedClient, + table_name: str, +) -> tuple[set[str], dict[str, str]]: + resp = handle_call(get_table.sync_detailed(client=client, table_name=table_name)) + column_names: set[str] = set() + column_units: dict[str, str] = {} + for col in resp.data.column_info: + column_names.add(col.name) + if isinstance(col.unit, str): + column_units[col.name] = col.unit + return column_names, column_units + + +def _parse_expressions(expressions: dict[str, str]) -> dict[str, Expression]: + return {field: parse(source) for field, source in expressions.items()} + + +def _validate_columns( + table_name: str, + needed_cols: set[str], + column_names: set[str], +) -> None: + missing = sorted(col for col in needed_cols if col not in column_names) + if missing: + raise RuntimeError(f"Table {table_name} has no column(s): {missing}") + + +def _evaluate_field( + expr: Expression, + values: dict[str, float], + column_units: dict[str, str], + field: str, +) -> float: + quantity = expr.evaluate(values, column_units).to(u.Unit(TARGET_UNITS[field])) + return float(quantity.value) + + +def upload_geometry_isophotal( + storage: PgStorage, + table_name: str, + band: str, + expressions: dict[str, str], + batch_size: int, + client: adminapi.AuthenticatedClient, + *, + write: bool = False, + report_func: Callable[[report.Event], None], +) -> int: + parsed = _parse_expressions(expressions) + needed_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) + column_names, column_units = _fetch_column_units(client, table_name) + _validate_columns(table_name, needed_cols, column_names) + + uploaded = 0 + skipped = 0 + a_min = float("inf") + a_max = float("-inf") + a_sum = 0.0 + cnt = storage.query( + sql.SQL("SELECT COUNT(*) AS cnt FROM rawdata.{}").format(sql.Identifier(table_name)), + (), + ) + total_count = int(cnt[0]["cnt"]) if cnt else 0 + processed_rows = 0 + + for rows in rawdata_batches(storage, table_name, sorted(needed_cols), batch_size): + batch_ids: list[str] = [] + batch_data: list[list[str | float]] = [] + + for row in rows: + if any(row[col] is None for col in needed_cols): + skipped += 1 + continue + + values = {col: float(row[col]) for col in needed_cols} + try: + evaluated = { + field: _evaluate_field(expr, values, column_units, field) for field, expr in parsed.items() + } + except (ValueError, u.UnitConversionError, u.UnitTypeError) as e: + raise RuntimeError( + f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}", + ) from e + + batch_ids.append(row["hyperleda_internal_id"]) + batch_data.append( + [ + band, + "isophotal", + evaluated["isophote"], + evaluated["a"], + evaluated["e_a"], + evaluated["b"], + evaluated["e_b"], + evaluated["pa"], + evaluated["e_pa"], + ], + ) + uploaded += 1 + a_val = evaluated["a"] + a_min = min(a_min, a_val) + a_max = max(a_max, a_val) + a_sum += a_val + + if write and batch_ids: + handle_call( + save_structured_data.sync_detailed( + client=client, + body=SaveStructuredDataRequest( + catalog="geometry", + columns=GEOMETRY_COLUMNS, + ids=batch_ids, + data=batch_data, + units=GEOMETRY_UNITS, + ), + ), + ) + + processed_rows += len(rows) + row_pct = int(100 * processed_rows / total_count) if total_count else 0 + report_func(report.ProgressEvent(percent=min(99, row_pct))) + report_func( + report.LogEvent( + message=f"batch: rows_read={len(rows)} uploaded={uploaded} skipped={skipped}", + ), + ) + + total = uploaded + skipped + + def row_pct_label(n: int) -> float: + return (100.0 * n / total) if total else 0.0 + + table_rows: list[tuple[str, int | float, float | str]] = [ + ("Uploaded", uploaded, row_pct_label(uploaded)), + ("Skipped (null)", skipped, row_pct_label(skipped)), + ] + if uploaded > 0: + a_mean = a_sum / uploaded + table_rows.extend( + [ + ("a min (arcsec)", round(a_min, 3), "-"), + ("a max (arcsec)", round(a_max, 3), "-"), + ("a mean (arcsec)", round(a_mean, 3), "-"), + ], + ) + report_func(report.ProgressEvent(percent=100)) + summary = format_table( + ("Status", "Count", "%"), + table_rows, + title=f"Total rows: {total}\n", + ) + report_func(report.DoneEvent(message=summary)) + return total diff --git a/uploader/forms/structured_geometry_isophotal.py b/uploader/forms/structured_geometry_isophotal.py new file mode 100644 index 0000000..161c3ac --- /dev/null +++ b/uploader/forms/structured_geometry_isophotal.py @@ -0,0 +1,86 @@ +from collections.abc import Callable +from typing import Literal, cast +from urllib.parse import quote_plus + +from psycopg import connect +from pydantic import BaseModel, Field + +import uploader.app.report as report +from uploader.app.endpoints import db_dsn_map, env_map +from uploader.app.lib.expression import NAMED_CONSTANTS +from uploader.app.storage import PgStorage +from uploader.app.structured.geometry import upload_geometry_isophotal +from uploader.clients.gen.client import adminapi +from uploader.credentials import load_credentials, load_token + +_EXPRESSION_HELP = ( + "Bare identifiers refer to rawdata column names. " + "Identifiers starting with const_ refer to predefined constants. " + "Operators: + - * /. Functions: sin(x), cos(x) (argument must be an angle). " + "Numbers are dimensionless. " + f"Available constants: {', '.join(sorted(NAMED_CONSTANTS))}." +) + + +class StructuredGeometryIsophotalAdvancedSettings(BaseModel): + endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") + batch_size: int = Field(default=10000, title="Batch size", ge=1, le=500_000) + + +class StructuredGeometryIsophotalForm(BaseModel): + table_name: str = Field(..., title="Rawdata table name") + band: str = Field(..., title="Band", description="Calibrated passband id.") + a: str = Field(..., title="a", description=_EXPRESSION_HELP) + e_a: str = Field(..., title="e_a", description=_EXPRESSION_HELP) + b: str = Field(..., title="b", description=_EXPRESSION_HELP) + e_b: str = Field(..., title="e_b", description=_EXPRESSION_HELP) + pa: str = Field(..., title="pa", description=_EXPRESSION_HELP) + e_pa: str = Field(..., title="e_pa", description=_EXPRESSION_HELP) + isophote: str = Field(..., title="isophote", description=_EXPRESSION_HELP) + write: bool = Field( + default=False, + title="Write to API", + description="If enabled, upload results; otherwise dry-run (statistics only).", + ) + advanced: StructuredGeometryIsophotalAdvancedSettings = Field( + default_factory=StructuredGeometryIsophotalAdvancedSettings, + title="Advanced settings", + ) + + +def handle_structured_geometry_isophotal( + form: BaseModel, + report_func: Callable[[report.Event], None], +) -> None: + f = cast(StructuredGeometryIsophotalForm, form) + advanced = f.advanced + db_user, db_password = load_credentials() + dsn = db_dsn_map[advanced.endpoint].format( + user=quote_plus(db_user), + password=quote_plus(db_password), + ) + client = adminapi.AuthenticatedClient( + base_url=env_map[advanced.endpoint], + token=load_token(), + ) + expressions = { + "a": f.a.strip(), + "e_a": f.e_a.strip(), + "b": f.b.strip(), + "e_b": f.e_b.strip(), + "pa": f.pa.strip(), + "e_pa": f.e_pa.strip(), + "isophote": f.isophote.strip(), + } + with connect(dsn) as conn: + storage = PgStorage(conn) + upload_geometry_isophotal( + storage, + f.table_name.strip(), + f.band.strip(), + expressions, + advanced.batch_size, + client, + write=f.write, + report_func=report_func, + ) diff --git a/uploader/task_registry.py b/uploader/task_registry.py index 28aaa63..7656085 100644 --- a/uploader/task_registry.py +++ b/uploader/task_registry.py @@ -6,6 +6,10 @@ StructuredDesignationForm, handle_structured_designation, ) +from uploader.forms.structured_geometry_isophotal import ( + StructuredGeometryIsophotalForm, + handle_structured_geometry_isophotal, +) from uploader.forms.structured_icrs import StructuredIcrsForm, handle_structured_icrs from uploader.forms.structured_nature import StructuredNatureForm, handle_structured_nature from uploader.forms.structured_note import StructuredNoteForm, handle_structured_note @@ -122,6 +126,16 @@ def register_all_tasks() -> None: group="Catalogs", ), ) + register_task( + TaskDefinition( + id="upload-structured-geometry-isophotal", + title="Isophotal geometry", + description="Upload isophotal ellipse geometry (a, b, pa, isophote) from rawdata columns via expressions.", + form_model=StructuredGeometryIsophotalForm, + handler=handle_structured_geometry_isophotal, + group="Catalogs", + ), + ) register_task( TaskDefinition( id="crossmatch-default", From d7c5aa30a682c42c5586a19a11f9d7a20ebdc270 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 23 May 2026 18:40:40 +0100 Subject: [PATCH 2/2] add descriptions to tasks make pa optional --- frontend/src/api.ts | 1 + frontend/src/components/TaskPage.test.tsx | 2 + frontend/src/components/TaskPage.tsx | 15 ++++++- tests/test_server_integration.py | 1 + uploader/app/lib/expression.py | 13 ++++++ uploader/app/structured/geometry/upload.py | 42 ++++++++++--------- uploader/cli.py | 2 +- .../forms/structured_geometry_isophotal.py | 35 +++++++--------- uploader/task_registry.py | 6 ++- 9 files changed, 74 insertions(+), 43 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d2aeff9..dd9b644 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -14,6 +14,7 @@ export async function fetchTasks(): Promise { export type TaskSchemaResponse = { title: string; + description: string; schema: Record; }; diff --git a/frontend/src/components/TaskPage.test.tsx b/frontend/src/components/TaskPage.test.tsx index 76f289d..2080036 100644 --- a/frontend/src/components/TaskPage.test.tsx +++ b/frontend/src/components/TaskPage.test.tsx @@ -5,6 +5,7 @@ import { TaskPage } from "./TaskPage"; const fakeTaskSchema = { title: "Fake Test Task", + description: "A task used in tests.", schema: { type: "object", properties: { @@ -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(); }); diff --git a/frontend/src/components/TaskPage.tsx b/frontend/src/components/TaskPage.tsx index f734a70..6f7d91b 100644 --- a/frontend/src/components/TaskPage.tsx +++ b/frontend/src/components/TaskPage.tsx @@ -15,6 +15,7 @@ export function TaskPage() { const location = useLocation(); const [schema, setSchema] = useState | null>(null); const [taskTitle, setTaskTitle] = useState(null); + const [taskDescription, setTaskDescription] = useState(null); const [loadError, setLoadError] = useState(null); const [submitError, setSubmitError] = useState(null); const [runId, setRunId] = useState(null); @@ -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) => { @@ -73,9 +75,18 @@ export function TaskPage() { return ( - + {taskTitle ?? taskId} + {taskDescription && ( + + {taskDescription} + + )} {submitError && ( {submitError} diff --git a/tests/test_server_integration.py b/tests/test_server_integration.py index 324473d..f778ad6 100644 --- a/tests/test_server_integration.py +++ b/tests/test_server_integration.py @@ -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 diff --git a/uploader/app/lib/expression.py b/uploader/app/lib/expression.py index 8bf797e..e155a9d 100644 --- a/uploader/app/lib/expression.py +++ b/uploader/app/lib/expression.py @@ -20,6 +20,19 @@ "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] diff --git a/uploader/app/structured/geometry/upload.py b/uploader/app/structured/geometry/upload.py index d5cca36..67738c2 100644 --- a/uploader/app/structured/geometry/upload.py +++ b/uploader/app/structured/geometry/upload.py @@ -18,7 +18,8 @@ SaveStructuredDataRequestUnits, ) -GEOMETRY_COLUMNS = ["band", "method", "isophote", "a", "e_a", "b", "e_b", "pa", "e_pa"] +BASE_GEOMETRY_COLUMNS = ["band", "method", "isophote", "a", "e_a", "b", "e_b"] +OPTIONAL_GEOMETRY_COLUMNS = ["pa", "e_pa"] TARGET_UNITS = { "a": "arcsec", @@ -30,10 +31,6 @@ "isophote": "mag/arcmin2", } -EXPR_FIELDS = ["a", "e_a", "b", "e_b", "pa", "e_pa", "isophote"] - -GEOMETRY_UNITS = SaveStructuredDataRequestUnits.from_dict(TARGET_UNITS) - def _fetch_column_units( client: adminapi.AuthenticatedClient, @@ -85,6 +82,10 @@ def upload_geometry_isophotal( report_func: Callable[[report.Event], None], ) -> int: parsed = _parse_expressions(expressions) + geometry_columns = BASE_GEOMETRY_COLUMNS + [col for col in OPTIONAL_GEOMETRY_COLUMNS if col in parsed] + geometry_units = SaveStructuredDataRequestUnits.from_dict( + {col: TARGET_UNITS[col] for col in geometry_columns if col in TARGET_UNITS}, + ) needed_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) column_names, column_units = _fetch_column_units(client, table_name) _validate_columns(table_name, needed_cols, column_names) @@ -120,20 +121,21 @@ def upload_geometry_isophotal( f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}", ) from e + row_data: dict[str, str | float | None] = { + "band": band, + "method": "isophotal", + "isophote": evaluated["isophote"], + "a": evaluated["a"], + "e_a": evaluated["e_a"], + "b": evaluated["b"], + "e_b": evaluated["e_b"], + } + for col in OPTIONAL_GEOMETRY_COLUMNS: + if col in parsed: + row_data[col] = evaluated[col] + batch_ids.append(row["hyperleda_internal_id"]) - batch_data.append( - [ - band, - "isophotal", - evaluated["isophote"], - evaluated["a"], - evaluated["e_a"], - evaluated["b"], - evaluated["e_b"], - evaluated["pa"], - evaluated["e_pa"], - ], - ) + batch_data.append([row_data[col] for col in geometry_columns]) uploaded += 1 a_val = evaluated["a"] a_min = min(a_min, a_val) @@ -146,10 +148,10 @@ def upload_geometry_isophotal( client=client, body=SaveStructuredDataRequest( catalog="geometry", - columns=GEOMETRY_COLUMNS, + columns=geometry_columns, ids=batch_ids, data=batch_data, - units=GEOMETRY_UNITS, + units=geometry_units, ), ), ) diff --git a/uploader/cli.py b/uploader/cli.py index d78006b..0049e35 100644 --- a/uploader/cli.py +++ b/uploader/cli.py @@ -55,7 +55,7 @@ def task_schema(task_id: str) -> dict[str, object]: task = TASKS[task_id] schema = task.form_model.model_json_schema() schema.pop("title", None) - return {"title": task.title, "schema": schema} + return {"title": task.title, "description": task.description, "schema": schema} @app.post("/api/tasks/{task_id}/submit") diff --git a/uploader/forms/structured_geometry_isophotal.py b/uploader/forms/structured_geometry_isophotal.py index 161c3ac..013c7bf 100644 --- a/uploader/forms/structured_geometry_isophotal.py +++ b/uploader/forms/structured_geometry_isophotal.py @@ -7,20 +7,11 @@ import uploader.app.report as report from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.lib.expression import NAMED_CONSTANTS from uploader.app.storage import PgStorage from uploader.app.structured.geometry import upload_geometry_isophotal from uploader.clients.gen.client import adminapi from uploader.credentials import load_credentials, load_token -_EXPRESSION_HELP = ( - "Bare identifiers refer to rawdata column names. " - "Identifiers starting with const_ refer to predefined constants. " - "Operators: + - * /. Functions: sin(x), cos(x) (argument must be an angle). " - "Numbers are dimensionless. " - f"Available constants: {', '.join(sorted(NAMED_CONSTANTS))}." -) - class StructuredGeometryIsophotalAdvancedSettings(BaseModel): endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") @@ -30,13 +21,17 @@ class StructuredGeometryIsophotalAdvancedSettings(BaseModel): class StructuredGeometryIsophotalForm(BaseModel): table_name: str = Field(..., title="Rawdata table name") band: str = Field(..., title="Band", description="Calibrated passband id.") - a: str = Field(..., title="a", description=_EXPRESSION_HELP) - e_a: str = Field(..., title="e_a", description=_EXPRESSION_HELP) - b: str = Field(..., title="b", description=_EXPRESSION_HELP) - e_b: str = Field(..., title="e_b", description=_EXPRESSION_HELP) - pa: str = Field(..., title="pa", description=_EXPRESSION_HELP) - e_pa: str = Field(..., title="e_pa", description=_EXPRESSION_HELP) - isophote: str = Field(..., title="isophote", description=_EXPRESSION_HELP) + a: str = Field(..., title="a", description="Expression. Semi-major axis length.") + e_a: str = Field(..., title="e_a", description="Expression. Error on semi-major axis.") + b: str = Field(..., title="b", description="Expression. Semi-minor axis length.") + e_b: str = Field(..., title="e_b", description="Expression. Error on semi-minor axis.") + pa: str = Field( + default="", title="pa", description="Expression. Position angle (east of north). Leave empty to store null." + ) + e_pa: str = Field( + default="", title="e_pa", description="Expression. Error on position angle. Leave empty to store null." + ) + isophote: str = Field(..., title="isophote", description="Expression. Surface brightness level.") write: bool = Field( default=False, title="Write to API", @@ -63,15 +58,17 @@ def handle_structured_geometry_isophotal( base_url=env_map[advanced.endpoint], token=load_token(), ) - expressions = { + expressions: dict[str, str] = { "a": f.a.strip(), "e_a": f.e_a.strip(), "b": f.b.strip(), "e_b": f.e_b.strip(), - "pa": f.pa.strip(), - "e_pa": f.e_pa.strip(), "isophote": f.isophote.strip(), } + if f.pa.strip(): + expressions["pa"] = f.pa.strip() + if f.e_pa.strip(): + expressions["e_pa"] = f.e_pa.strip() with connect(dsn) as conn: storage = PgStorage(conn) upload_geometry_isophotal( diff --git a/uploader/task_registry.py b/uploader/task_registry.py index 7656085..d8490ae 100644 --- a/uploader/task_registry.py +++ b/uploader/task_registry.py @@ -1,3 +1,4 @@ +from uploader.app.lib.expression import expression_syntax_help from uploader.forms.authenticate import AuthenticateForm, handle_authenticate from uploader.forms.crossmatch_default import CrossmatchDefaultForm, handle_crossmatch_default from uploader.forms.crossmatch_layered import CrossmatchLayeredForm, handle_crossmatch_layered @@ -130,7 +131,10 @@ def register_all_tasks() -> None: TaskDefinition( id="upload-structured-geometry-isophotal", title="Isophotal geometry", - description="Upload isophotal ellipse geometry (a, b, pa, isophote) from rawdata columns via expressions.", + description=( + "Upload isophotal ellipse geometry (a, b, pa, isophote) from rawdata columns.\n\n" + f"{expression_syntax_help()}" + ), form_model=StructuredGeometryIsophotalForm, handler=handle_structured_geometry_isophotal, group="Catalogs",