diff --git a/docs/MIGRATION_LOG.md b/docs/MIGRATION_LOG.md index f57eae1..e66765b 100644 --- a/docs/MIGRATION_LOG.md +++ b/docs/MIGRATION_LOG.md @@ -139,7 +139,7 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - [x] **Live validation:** real kororaa-test UI traffic (incl. the rupture animation) all `200`; weka green; warmup-event fix verified live - [x] **`cli_ab_test -A prod -B test` → 9/9 PASS** — the cutover gate (prod legacy vs test Strawberry, byte-for-byte) - [ ] Promote `deploy-test → main` (prod) + pre-staged revert PR + ~30-min watch **← needs prod go-ahead** -- [ ] Post-healthy cleanup (delete graphene `schema.py`/Flask/`handler.py`, rename `strawberry_schema.py` → `schema.py`, drop legacy deps); file runbook-feedback PR +- [x] Legacy graphene cleanup drafted on `chore/remove-legacy-graphene` (PR #98 → `deploy-test`) — graphene removed from source, tests and deps; see the [2026-06-26 cleanup entry](#2026-06-26--legacy-graphene-removed-pr-98) --- @@ -245,6 +245,28 @@ Source: code exploration 2026-06-24 on `deploy-test` @ `93f6f62`. Version **0.9. - **Reverted the Phase 1 `dev.yml` `branches:` filter removal — the filter is a deliberate team choice.** Trap #14 (stacked PRs get no CI under the filter) is real, but the cost was only relevant while the migration ran as a stack; now it's collapsed/merged, the team's `pull_request: branches: [main, deploy-test]` rule is restored. *(Correction to the Phase 1 decision; the runbook's "remove the filter" advice should be applied knowingly, not reflexively.)* - **Next:** merge promote #97 → prod (pre-stage the revert first; ~30-min watch). +### 2026-06-26 — legacy graphene removed (PR #98) +Drafted the §5 post-healthy cleanup on `chore/remove-legacy-graphene` (→ `deploy-test`), as +verified chunks each gated on **SDL byte-identical + the test suite + ruff/mypy**: +1. colour-scale compute extracted graphene-free (`color_scale/compute.py`); 2. `locations_by_id`; +3. `filter_ruptures` pagination (`composite_solution/ruptures.py::auto_sorted_dataframe`); +4. `CompositeRuptureSections` de-delegated (was reusing the graphene root) — aggregates/MFD/ +geojson/colour ported onto `cached`/`compute`/`get_colour_values`; 5. `rupture_detail` + +`apply_geojson_style` relocated to graphene-free homes (`cached.py`, `geojson_style_util.py`). +- **Tests**: the 14 graphene-`Client` behavioural suites now run against Strawberry via a + `graphene.test.Client` drop-in (`tests/_strawberry_client.py`) — kept, not deleted, so they + still cover the migrated resolvers (this caught a real bug: `filter_ruptures` passed + `strawberry.UNSET` as the sortby `ascending`, which pandas rejected). The differential parity + test became a Strawberry-only **snapshot** guard (`tests/__snapshots__/`, `SNAPSHOT_UPDATE=1`). +- **Deleted** ~1740 lines: graphene root `schema.py`, `location_schema`, `solution_schema`, + `geojson_style`, `color_scale/color_scale`, and the `composite_solution` graphene modules + + `tools/dump_legacy_sdl.py`. **Renamed** `strawberry_schema.py` → `schema.py`. **Dropped deps** + graphene/graphql-server/flask/flask-cors/serverless-wsgi; added explicit `graphql-relay` (was + transitive via graphene). No `import graphene` remains; verified with graphene uninstalled + (83 pass / 10 skipped, SDL byte-identical, mypy + ruff clean). +- **Next:** CI green on #98, then merge to `deploy-test` (auto-deploys to test) + re-run + `cli_ab_test` 9/9 before it rides along in the #97 prod promote. + --- diff --git a/pyproject.toml b/pyproject.toml index 253380a..5b548f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,12 +21,8 @@ dependencies = [ "fastapi>=0.115", "mangum>=0.18", "pydantic>=2.0", - # --- legacy Graphene / Flask stack (removed at cutover) --- - "flask>=3.0.3", - "flask-cors>=6.0", - "graphene>=3.3", - "graphql-server==3.0.0b7", - "serverless-wsgi>=3.0", + # relay global-id / cursor encoding used by the schema (was transitive via graphene) + "graphql-relay>=3.2", # --- data + compute (unchanged) --- "matplotlib>=3.10.7,<4.0.0", "nzshm-common>=0.9.0,<1.0", diff --git a/solvis_graphql_api/app.py b/solvis_graphql_api/app.py index 8c994e1..4bbf29e 100644 --- a/solvis_graphql_api/app.py +++ b/solvis_graphql_api/app.py @@ -19,7 +19,7 @@ from mangum import Mangum from strawberry.fastapi import GraphQLRouter -from solvis_graphql_api.strawberry_schema import schema +from solvis_graphql_api.schema import schema LOGGING_CFG = os.getenv("LOGGING_CFG", "solvis_graphql_api/logging_aws.yaml") logger = logging.getLogger(__name__) diff --git a/solvis_graphql_api/color_scale/__init__.py b/solvis_graphql_api/color_scale/__init__.py index d992498..b65c556 100644 --- a/solvis_graphql_api/color_scale/__init__.py +++ b/solvis_graphql_api/color_scale/__init__.py @@ -1,8 +1,6 @@ -from .color_scale import ( - ColorScale, - ColorScaleArgs, - ColorScaleArgsInput, - ColourScaleNormaliseEnum, - get_colour_scale, - get_colour_values, -) +"""Colour-scale compute (graphene-free). + +The legacy graphene ``color_scale`` types/resolvers were removed at the Strawberry cutover. +Import the graphene-free maths from the ``compute`` submodule directly +(``compute_colour_scale``, ``get_colour_values``, ``log_intervals``). +""" diff --git a/solvis_graphql_api/color_scale/color_scale.py b/solvis_graphql_api/color_scale/color_scale.py deleted file mode 100644 index e8f0a09..0000000 --- a/solvis_graphql_api/color_scale/color_scale.py +++ /dev/null @@ -1,186 +0,0 @@ -# color_scale.py - -import logging -import math -import os -from collections.abc import Iterable -from functools import lru_cache - -import graphene -import matplotlib as mpl - -# from solvis_graphql_api.cloudwatch import ServerlessMetricWriter - -log = logging.getLogger(__name__) - -# db_metrics = ServerlessMetricWriter(metric_name="MethodDuration") - - -class ColourScaleNormaliseEnum(graphene.Enum): - LOG = "log" - LIN = "lin" - - -COLOR_SCALE_NORMALISE_LOG = "log" if os.getenv("COLOR_SCALE_NORMALISATION", "").upper() == "LOG" else "lin" - - -class HexRgbValueMapping(graphene.ObjectType): - levels = graphene.List(graphene.Float) - hexrgbs = graphene.List(graphene.String) - - -class ColorScaleArgsBase: - name = graphene.String(default_value="inferno") - min_value = graphene.Float() - max_value = graphene.Float() - normalisation = graphene.Field(ColourScaleNormaliseEnum) - - -class ColorScaleArgsInput(ColorScaleArgsBase, graphene.InputObjectType): - """Arguments passed as ColorScaleArgsInput""" - - -class ColorScaleArgs(ColorScaleArgsBase, graphene.ObjectType): - """Arguments ColorScaleArgs""" - - -class ColorScale(graphene.ObjectType): - name = graphene.String() - min_value = graphene.Float() - max_value = graphene.Float() - normalisation = graphene.Field(ColourScaleNormaliseEnum) - color_map = graphene.Field(HexRgbValueMapping) - - -@lru_cache -def get_normaliser(color_scale_vmax: float, color_scale_vmin: float, color_scale_normalise: str): - - if color_scale_normalise == ColourScaleNormaliseEnum.LOG.value: # type: ignore - log.debug("resolve_hazard_map using LOG normalized colour scale") - return mpl.colors.LogNorm(vmin=color_scale_vmin, vmax=color_scale_vmax) - if color_scale_normalise == ColourScaleNormaliseEnum.LIN.value: # type: ignore - color_scale_vmin = color_scale_vmin or 0 - log.debug("resolve_hazard_map using LIN normalized colour scale") - return mpl.colors.Normalize(vmin=color_scale_vmin, vmax=color_scale_vmax) - raise RuntimeError(f"unknown normalisation option: {color_scale_normalise} ") - - -@lru_cache -def log_intervals(vmin, vmax): - """ - get a manageable set of sensible levels between upper and lower exponential bounds - """ - min_exponent = int(math.floor(math.log10(abs(vmin)))) # e.g. -7 for 0.5e-6 - max_exponent = int(math.floor(math.log10(abs(vmax)))) # e.g. 1 fpr 22.5 , 0 for 9.5 - if min_exponent == max_exponent: - max_exponent += 1 - - intervals = [math.pow(10, power) for power in range(min_exponent, max_exponent + 1)] - - max_val = intervals[-1] - MIN_LEN = 6 - MAX_LEN = 8 - - def interpolate(intervals): - # print('interpolate', intervals) - new_intervals = intervals.copy() - sub_intervals = [0.5, 0.2, 0.1] - for sub_interval in sub_intervals: - # print('sub_interval', sub_interval) - for interval in intervals: - new_interval = interval * sub_interval - if intervals[0] < new_interval < intervals[-1]: - new_intervals.append(round(new_interval, abs(min_exponent))) - if len(new_intervals) >= MIN_LEN: - return new_intervals - return new_intervals - - def slim(new_intervals, max): - while len(new_intervals) > MAX_LEN: - new_intervals = new_intervals[::2] - new_intervals = sorted(new_intervals) - if new_intervals[-1] < max: - new_intervals.append(max) - return new_intervals - - def ensure_max(intervals, max_value): - # return intervals - if max_value not in intervals: - intervals.append(max_value) - return intervals - - if len(intervals) > MAX_LEN: - intervals = ensure_max(slim(intervals, intervals[0]), max_val) - - if len(intervals) < MIN_LEN: - intervals = ensure_max(interpolate(intervals), max_val) - - return sorted(intervals) - - -''' -def log_intervals(vmin, vmax): - """ - get at least 4 and no more than 7 intervals between upper and lower exponential bounds - """ - min_exponent = int(math.floor(math.log10(abs(vmin)))) # e.g. -7 for 0.5e-6 - max_exponent = int(math.floor(math.log10(abs(vmax)))) # e.g. 1 fpr 22.5 , 0 for 9.5 - return np.logspace(min_exponent, max_exponent, 10) -''' - - -@lru_cache -def get_colour_scale(color_scale: str, color_scale_normalise: str, vmax: float, vmin: float) -> ColorScale: - # build the colour_scale - log.debug( - f"get_colour_scale(color_scale:{color_scale} normalize: {color_scale_normalise} vmin: {vmin} vmax: {vmax}" - ) - - levels, hexrgbs = [], [] - cmap = mpl.colormaps[color_scale] - if color_scale_normalise == ColourScaleNormaliseEnum.LOG.value: # type: ignore - intervals = log_intervals(vmin, vmax) - norm = get_normaliser(max(intervals), min(intervals), color_scale_normalise) - for level in intervals: - levels.append(level) - hexrgbs.append(mpl.colors.to_hex(cmap(norm(level)))) - elif color_scale_normalise == ColourScaleNormaliseEnum.LIN.value: # type: ignore - assert vmax * 2 == int(vmax * 2) # make sure we have a value on a 0.5 interval - norm = get_normaliser(vmax, vmin, color_scale_normalise) - for level in range(int(vmin * 10), int(vmax * 10) + 1): - levels.append(level / 10) - hexrgbs.append(mpl.colors.to_hex(cmap(norm(level / 10)))) - else: - raise RuntimeError(f"unknown normalisation option: {color_scale_normalise} ") - - hexrgb = HexRgbValueMapping(levels=levels, hexrgbs=hexrgbs) - return ColorScale( - name=color_scale, - min_value=vmin, - max_value=vmax, - normalisation=color_scale_normalise, - color_map=hexrgb, - ) - - -@lru_cache -def get_colour_values( - color_scale: str, - color_scale_vmax: float, - color_scale_vmin: float, - color_scale_normalise: str, - values: tuple[float | None], -) -> Iterable[str]: - - log.debug(f"color_scale_vmax: {color_scale_vmax}") - intervals = log_intervals(color_scale_vmin, color_scale_vmax) - norm = get_normaliser(max(intervals), min(intervals), color_scale_normalise) - cmap = mpl.colormaps[color_scale] - colors = [] - # set any missing values to black - for _i, v in enumerate(values): - if v is None: - colors.append("x000000") - else: - colors.append(mpl.colors.to_hex(cmap(norm(v)), keep_alpha=False)) - return colors diff --git a/solvis_graphql_api/color_scale/compute.py b/solvis_graphql_api/color_scale/compute.py new file mode 100644 index 0000000..8dc3b80 --- /dev/null +++ b/solvis_graphql_api/color_scale/compute.py @@ -0,0 +1,119 @@ +"""Graphene-free colour-scale compute. + +The matplotlib maths extracted out of the legacy graphene `color_scale.py` so the Strawberry +schema can build colour scales without importing graphene. The graphene module imports these +during the transition; it (and its graphene types) are deleted at cutover. +""" + +import logging +import math +from collections.abc import Iterable +from dataclasses import dataclass +from functools import lru_cache + +import matplotlib as mpl + +log = logging.getLogger(__name__) + + +@dataclass +class ColourScaleResult: + name: str + min_value: float + max_value: float + normalisation: str # "log" | "lin" + levels: list[float] + hexrgbs: list[str] + + +@lru_cache +def get_normaliser(color_scale_vmax: float, color_scale_vmin: float, color_scale_normalise: str): + if color_scale_normalise == "log": + return mpl.colors.LogNorm(vmin=color_scale_vmin, vmax=color_scale_vmax) + if color_scale_normalise == "lin": + color_scale_vmin = color_scale_vmin or 0 + return mpl.colors.Normalize(vmin=color_scale_vmin, vmax=color_scale_vmax) + raise RuntimeError(f"unknown normalisation option: {color_scale_normalise} ") + + +@lru_cache +def log_intervals(vmin, vmax): + """get a manageable set of sensible levels between upper and lower exponential bounds""" + min_exponent = int(math.floor(math.log10(abs(vmin)))) + max_exponent = int(math.floor(math.log10(abs(vmax)))) + if min_exponent == max_exponent: + max_exponent += 1 + + intervals = [math.pow(10, power) for power in range(min_exponent, max_exponent + 1)] + max_val = intervals[-1] + MIN_LEN = 6 + MAX_LEN = 8 + + def interpolate(intervals): + new_intervals = intervals.copy() + for sub_interval in [0.5, 0.2, 0.1]: + for interval in intervals: + new_interval = interval * sub_interval + if intervals[0] < new_interval < intervals[-1]: + new_intervals.append(round(new_interval, abs(min_exponent))) + if len(new_intervals) >= MIN_LEN: + return new_intervals + return new_intervals + + def slim(new_intervals, max): + while len(new_intervals) > MAX_LEN: + new_intervals = new_intervals[::2] + new_intervals = sorted(new_intervals) + if new_intervals[-1] < max: + new_intervals.append(max) + return new_intervals + + def ensure_max(intervals, max_value): + if max_value not in intervals: + intervals.append(max_value) + return intervals + + if len(intervals) > MAX_LEN: + intervals = ensure_max(slim(intervals, intervals[0]), max_val) + if len(intervals) < MIN_LEN: + intervals = ensure_max(interpolate(intervals), max_val) + return sorted(intervals) + + +@lru_cache +def compute_colour_scale(color_scale: str, color_scale_normalise: str, vmax: float, vmin: float) -> ColourScaleResult: + levels: list[float] = [] + hexrgbs: list[str] = [] + cmap = mpl.colormaps[color_scale] + if color_scale_normalise == "log": + intervals = log_intervals(vmin, vmax) + norm = get_normaliser(max(intervals), min(intervals), color_scale_normalise) + for level in intervals: + levels.append(level) + hexrgbs.append(mpl.colors.to_hex(cmap(norm(level)))) + elif color_scale_normalise == "lin": + assert vmax * 2 == int(vmax * 2) # make sure we have a value on a 0.5 interval + norm = get_normaliser(vmax, vmin, color_scale_normalise) + for level in range(int(vmin * 10), int(vmax * 10) + 1): + levels.append(level / 10) + hexrgbs.append(mpl.colors.to_hex(cmap(norm(level / 10)))) + else: + raise RuntimeError(f"unknown normalisation option: {color_scale_normalise} ") + return ColourScaleResult( + name=color_scale, min_value=vmin, max_value=vmax, normalisation=color_scale_normalise, + levels=levels, hexrgbs=hexrgbs, + ) + + +@lru_cache +def get_colour_values( + color_scale: str, color_scale_vmax: float, color_scale_vmin: float, color_scale_normalise: str, + values: tuple[float | None], +) -> Iterable[str]: + intervals = log_intervals(color_scale_vmin, color_scale_vmax) + norm = get_normaliser(max(intervals), min(intervals), color_scale_normalise) + cmap = mpl.colormaps[color_scale] + colors = [] + for v in values: + colors.append("x000000" if v is None else mpl.colors.to_hex(cmap(norm(v)), keep_alpha=False)) + return colors diff --git a/solvis_graphql_api/composite_solution/__init__.py b/solvis_graphql_api/composite_solution/__init__.py index 024dc46..5d44938 100644 --- a/solvis_graphql_api/composite_solution/__init__.py +++ b/solvis_graphql_api/composite_solution/__init__.py @@ -1,10 +1,7 @@ -from .composite_rupture_detail import ( # SortRupturesArgs, - CompositeRuptureDetail, - CompositeRuptureDetailArgs, - RuptureDetailConnection, - SimpleSortRupturesArgs, -) -from .composite_rupture_sections import CompositeRuptureSections -from .composite_solution import CompositeSolution -from .filtered_ruptures_args import FilterRupturesArgs, FilterRupturesArgsInput -from .schema import paginated_filtered_ruptures +"""Composite-solution data and compute helpers (graphene-free). + +The legacy graphene schema types that this package used to re-export (CompositeRuptureDetail, +CompositeRuptureSections, FilterRupturesArgs, paginated_filtered_ruptures, ...) were removed at +the Strawberry cutover. Import the graphene-free helpers from their submodules directly: +``cached`` (data access + rupture_detail) and ``ruptures`` (auto_sorted_dataframe). +""" diff --git a/solvis_graphql_api/composite_solution/cached.py b/solvis_graphql_api/composite_solution/cached.py index 54d51cb..94b7bf3 100644 --- a/solvis_graphql_api/composite_solution/cached.py +++ b/solvis_graphql_api/composite_solution/cached.py @@ -13,14 +13,13 @@ import geopandas as gpd import nzshm_model import solvis +import solvis.solution.typing from nzshm_common.location.location import location_by_id from solvis import InversionSolution from solvis.filter import FilterRuptureIds from solvis_graphql_api.data_store import model -from .filter_set_logic_options import _solvis_join - if TYPE_CHECKING: import shapely.geometry.polygon.Polygon @@ -29,6 +28,12 @@ FAULT_SECTION_LIMIT = 1e4 +def _solvis_join(filter_set_options: tuple, member: str) -> solvis.solution.typing.SetOperationEnum: + """Helper: Convert a filter set option to the Solvis native Enum type (moved here from the + graphene filter_set_logic_options module so this module stays graphene-free).""" + return solvis.solution.typing.SetOperationEnum(dict(filter_set_options)[member]) + + @lru_cache def get_location_polygon(radius_km: float, lon: float, lat: float) -> "shapely.geometry.polygon.Polygon": """ @@ -90,6 +95,24 @@ def get_composite_solution(model_id: str) -> solvis.CompositeSolution: return solvis.CompositeSolution.from_archive(io.BytesIO(blob.object_blob), slt) +@lru_cache +def rupture_detail(model_id: str, fault_system: str, rupture_index: int): + """ + Retrieves the details of a specific rupture in a composite solution. + + Args: + model_id (str): The ID of the model. + fault_system (str): The name of the fault system. + rupture_index (int): The index of the rupture to retrieve. + + Returns: + pandas.DataFrame: A DataFrame containing the details of the specified rupture. + """ + fss = get_composite_solution(model_id).get_fault_system_solution(fault_system) + sr = fss.model.ruptures_with_rupture_rates + return sr[sr["Rupture Index"] == rupture_index] + + @lru_cache def get_rupture_ids_for_fault_names( fault_system_solution: InversionSolution, diff --git a/solvis_graphql_api/composite_solution/composite_rupture_detail.py b/solvis_graphql_api/composite_solution/composite_rupture_detail.py deleted file mode 100644 index cc31ab2..0000000 --- a/solvis_graphql_api/composite_solution/composite_rupture_detail.py +++ /dev/null @@ -1,252 +0,0 @@ -"""The API schema for conposite solutions.""" - -import json -import logging -from functools import lru_cache - -import graphene -import pandas as pd -from graphene import relay - -from solvis_graphql_api.geojson_style import ( - GeojsonAreaStyleArgumentsInput, - apply_geojson_style, -) - -from .cached import get_composite_solution - -# from graphene.types import Scalar -# from graphql.language import ast - - -log = logging.getLogger(__name__) - -FAULT_SECTION_LIMIT = 1e4 - - -@lru_cache -def rupture_detail(model_id: str, fault_system: str, rupture_index: int) -> "pd.DataFrame": - """ - Retrieves the details of a specific rupture in a composite solution. - - Args: - model_id (str): The ID of the model. - fault_system (str): The name of the fault system. - rupture_index (int): The index of the rupture to retrieve. - - Returns: - pandas.DataFrame: A DataFrame containing the details of the specified rupture. - """ - fss = get_composite_solution(model_id).get_fault_system_solution(fault_system) - sr = fss.model.ruptures_with_rupture_rates - return sr[sr["Rupture Index"] == rupture_index] - - -# class SortRupturesArgs(graphene.InputObjectType): -# attribute = graphene.String() -# ascending = graphene.Boolean() -# bin_width = graphene.Float(optional=True) -# log_bins = graphene.Boolean(optional=True) - - -# class ScientificNotation(Scalar): -# @staticmethod -# def serialize(value): -# return str(value) # return string - -# @staticmethod -# def parse_literal(node): -# if isinstance(node, ast.StringValueNode): -# return float(node) - -# @staticmethod -# def parse_value(value: float): -# return value - - -class SimpleSortRupturesArgs(graphene.InputObjectType): - attribute = graphene.String() - ascending = graphene.Boolean() - - -class CompositeRuptureDetail(graphene.ObjectType): - ATTRIBUTE_COLUMN_MAP = dict( - rupture_index="Rupture Index", - magnitude="Magnitude", - rake_mean="Average Rake (degrees)", - area="Area (m^2)", - length="Length (m)", - ) - - @staticmethod - def column_name(field_name: str): - return CompositeRuptureDetail.ATTRIBUTE_COLUMN_MAP.get(field_name, field_name) - - class Meta: - interfaces = (relay.Node,) - - model_id = graphene.String() - fault_system = graphene.String(description="Unique ID of the fault system e.g. `PUY`") - - # rupture properties - rupture_index = graphene.Int() - magnitude = graphene.Float() - area = graphene.Float(description="Rupture length in kilometres^2") # 'Area (m^2)', - length = graphene.Float(description="Rupture length in kilometres)") # 'Length (m)', - rake_mean = graphene.Float( - description="average rake angle (degrees) of the entire rupture" - ) # 'Average Rake (degrees)', - - # rupture rate properties - rate_weighted_mean = graphene.Float(description="mean of `rate` * `branch weight` of the contributing solutions") - rate_max = graphene.Float(description="maximum rate from contributing solutions") - rate_min = graphene.Float(description="minimum rate from contributing solutions") - rate_count = graphene.Int(description="count of model solutions that include this rupture") - - # geojson props - fault_traces = graphene.JSONString() - fault_surfaces = graphene.Field( - graphene.JSONString, - style=graphene.Argument( - GeojsonAreaStyleArgumentsInput, - required=False, - description="feature style for rupture trace geojson.", - default_value=dict(stroke_color="black", stroke_width=1, stroke_opacity=1.0), - ), - ) - - def resolve_id(root, info, *args, **kwargs): - return f"{root.fault_system}:{root.rupture_index}" - - def resolve_magnitude(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return round(float(rupt["Magnitude"].iloc[0]), 3) - - def resolve_area(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return round(float(rupt["Area (m^2)"].iloc[0] / 1e6), 0) - - def resolve_length(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return round(float(rupt["Length (m)"].iloc[0] / 1e3), 0) - - def resolve_rake_mean(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return round(float(rupt["Average Rake (degrees)"].iloc[0]), 1) - - def resolve_rate_weighted_mean(root, info, *args, **kwargs): - # with pd.option_context('display.float_format', '${:.2e}'.format): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - # rupt['rate_weighted_mean'] = rupt.map('${:,.2f}'.format) - # print('RRR', rupt['rate_weighted_mean'].map('{:,.2e}'.format)) - return float(rupt["rate_weighted_mean"].iloc[0]) - - def resolve_rate_max(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return float(rupt["rate_max"].iloc[0]) - - def resolve_rate_min(root, info, *args, **kwargs): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return float(rupt["rate_min"].iloc[0]) - - def resolve_rate_count(root, info, *args, **kwargs): - with pd.option_context("display.float_format", "${:,.5f}".format): - rupt = rupture_detail(root.model_id, root.fault_system, root.rupture_index) - return rupt["rate_count"].iloc[0] - - def resolve_fault_surfaces(root, info, style, *args, **kwargs): - log.info(f"resolve resolve_fault_surfaces : {root.model_id}, {root.fault_system} style: {style}") - composite_solution = get_composite_solution(root.model_id) - rupture_surface_gdf = composite_solution._solutions[root.fault_system].rupture_surface(root.rupture_index) - - rupture_surface_gdf = rupture_surface_gdf.drop( - columns=[ - "key_0", - "fault_system", - "Rupture Index", - "rate_max", - "rate_min", - "rate_count", - "rate_weighted_mean", - "Magnitude", - "Average Rake (degrees)", - "Area (m^2)", - "Length (m)", - ] - ) - - return ( - apply_geojson_style(json.loads(rupture_surface_gdf.to_json(indent=2)), style) - if rupture_surface_gdf is not None - else None - ) - - -class RuptureDetailConnection(relay.Connection): - class Meta: - node = CompositeRuptureDetail - - total_count = graphene.Int() - - -class CompositeRuptureDetailArgs(graphene.InputObjectType): - model_id = graphene.String() - fault_system = graphene.String(description="Unique ID of the fault system e.g. `PUY`") - rupture_index = graphene.Int() - - # fault_trace_style = GeojsonLineStyleArguments( - # required=False, - # description="feature style for rupture trace geojson.", - # default_value=dict(stroke_color="black", stroke_width=1, stroke_opacity=1.0), - # ) - - -class FilterRupturesArgs(graphene.InputObjectType): - """Defines filter arguments for Inversions analysis""" - - model_id = graphene.String(required=True, description="The ID of NSHM model") - - fault_system = graphene.String( - required=True, - description="The fault systems [`HIK`, `PUY`, `CRU`]", - ) - - location_ids = graphene.List( - graphene.String, - required=False, - default_value=[], - description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", - ) - - radius_km = graphene.Int(required=False, description="The rupture/location intersection radius in km") - - minimum_rate = graphene.Float( - required=False, - description="Constrain to fault_sections having a annual rate above the value supplied.", - ) - maximum_rate = graphene.Float( - required=False, - description="Constrain to fault_sections having a annual rate below the value supplied.", - ) - minimum_mag = graphene.Float( - required=False, - description="Constrain to fault_sections having a magnitude above the value supplied.", - ) - maximum_mag = graphene.Float( - required=False, - description="Constrain to fault_sections having a magnitude below the value supplied.", - ) - - # fault_trace_style = GeojsonLineStyleArguments( - # required=False, - # description="feature style for rupture trace geojson.", - # default_value=dict(stroke_color="black", stroke_width=1, stroke_opacity=1.0), - # ) - - # fault_surface_style = GeojsonAreaStyleArguments( - # required=False, - # description="feature style for rupture surface geojson.", - # default_value=dict( - # stroke_color="black", stroke_width=1, stroke_opacity=1.0, fill_color="lightblue", fill_opacity="0.5" - # ), - # ) diff --git a/solvis_graphql_api/composite_solution/composite_rupture_sections.py b/solvis_graphql_api/composite_solution/composite_rupture_sections.py deleted file mode 100644 index 23788b3..0000000 --- a/solvis_graphql_api/composite_solution/composite_rupture_sections.py +++ /dev/null @@ -1,308 +0,0 @@ -"""The API schema for composite solutions.""" - -import json -import logging - -import graphene -import pandas as pd - -from solvis_graphql_api.color_scale import ( - ColorScale, - ColorScaleArgsInput, - ColourScaleNormaliseEnum, - get_colour_scale, - get_colour_values, -) -from solvis_graphql_api.geojson_style import ( - GeojsonAreaStyleArgumentsInput, - GeojsonLineStyleArgumentsInput, -) - -from .cached import fault_section_aggregates_gdf, matched_rupture_sections_gdf -from .filtered_ruptures_args import FilterRupturesArgs - -log = logging.getLogger(__name__) - - -def get_fault_section_aggregates(filter_args, trace_only=False): - log.debug(">>> get_fault_section_aggregates") - return fault_section_aggregates_gdf( - filter_args.model_id, - filter_args.fault_system, - tuple(filter_args.location_ids), - filter_args.radius_km, - min_rate=filter_args.minimum_rate or 1e-20, - max_rate=filter_args.maximum_rate, - min_mag=filter_args.minimum_mag, - max_mag=filter_args.maximum_mag, - filter_set_options=frozenset(dict(filter_args.filter_set_options).items()), - trace_only=trace_only, - corupture_fault_names=tuple(filter_args.corupture_fault_names), - ) - - -class MagFreqDist(graphene.ObjectType): - bin_center = graphene.Float() - rate = graphene.Float() - cumulative_rate = graphene.Float() - - -class CompositeRuptureSections(graphene.ObjectType): - """ - A collection of ruptures and their fault sections that have a geojson represention. They also - have a set of attributes derived from the composite solution e.g. rate_weighted_mean etc - - Key attributes: - - filter_arguments contains the filter criteria used to find the ruptures. - - fault_surfaces is a geojson feature file based on the geometry from the undelying rutpure set. - It may by styled by some attribute of the faults section. - - mfd_histogram is the MFD table summarise the set of ruptures. - - """ - - model_id = graphene.String() - rupture_count = graphene.Int() - section_count = graphene.Int() - filter_arguments = graphene.Field(FilterRupturesArgs) - - # these may be useful for calculating color scales - max_magnitude = graphene.Float(description="maximum rupture magnitude from the contributing solutions.") - min_magnitude = graphene.Float(description="minimum rupture magnitude from the contributing solutions.") - max_participation_rate = graphene.Float( - description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing" - " solutions." - ) - min_participation_rate = graphene.Float( - description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing" - " solutions." - ) - - fault_surfaces = graphene.Field( - graphene.JSONString, - color_scale=graphene.Argument( - ColorScaleArgsInput, - required=False, - # default_value=ColorScaleArgs() - ), - style=graphene.Argument( - GeojsonAreaStyleArgumentsInput, - required=False, - # default_value=GeojsonAreaStyleArguments() - ), - ) - - fault_traces = graphene.Field( - graphene.JSONString, - color_scale=graphene.Argument( - ColorScaleArgsInput, - required=False, - # default_value=ColorScaleArgs() - ), - style=graphene.Argument( - GeojsonLineStyleArgumentsInput, - required=False, - # default_value=GeojsonLineStyleArguments() - ), - ) - - mfd_histogram = graphene.List( - MagFreqDist, - description="magnitude frequency distribution of the filtered rutpures.", - ) - - color_scale = graphene.Field( - ColorScale, - name=graphene.Argument(graphene.String), - normalization=graphene.Argument(ColourScaleNormaliseEnum, required=False), - min_value=graphene.Argument(graphene.Float, required=False), - max_value=graphene.Argument(graphene.Float, required=False), - ) - - def resolve_color_scale(root, info, name, **args): - min_value = args.get("min_value") or CompositeRuptureSections.resolve_min_participation_rate(root, info) - max_value = args.get("max_value") or CompositeRuptureSections.resolve_max_participation_rate(root, info) - normalization = args.get("normalization") or ColourScaleNormaliseEnum.LOG.value - - log.debug(f"resolve_color_scale(name: {name} args: {args})") - return get_colour_scale( - color_scale=name, - color_scale_normalise=normalization, - vmax=max_value, - vmin=min_value, - ) - - def resolve_mfd_histogram(root, info, *args, **kwargs): - filter_args = root.filter_arguments - - df0 = matched_rupture_sections_gdf( - filter_args.model_id, - filter_args.fault_system, - tuple(filter_args.location_ids), - filter_args.radius_km, - min_rate=filter_args.minimum_rate or 1e-20, - max_rate=filter_args.maximum_rate, - min_mag=filter_args.minimum_mag, - max_mag=filter_args.maximum_mag, - filter_set_options=frozenset(dict(filter_args.filter_set_options).items()), - corupture_fault_names=tuple(filter_args.corupture_fault_names), - ) - - # TODO - move this function into solvis (see solvis.mfd_hist) - def build_mfd( - fault_sections_gdf: pd.DataFrame, - rate_col: str, - magnitude_col: str, - min_mag: float = 6.8, - max_mag: float = 9.8, - ) -> pd.DataFrame: - bins = [round(x / 100, 2) for x in range(500, 1000, 10)] - df = pd.DataFrame( - { - "rate": fault_sections_gdf[rate_col], - "magnitude": fault_sections_gdf[magnitude_col], - } - ) - - df["bins"] = pd.cut(df["magnitude"], bins=bins) - df["bin_center"] = df["bins"].apply(lambda x: x.mid) - df = df.drop(columns=["magnitude"]) - # df.groupby "observed" parameter default will change to True in - # later Pandas versions, so marking it as explicitly False to - # maintain existing behaviour. - # - # See: - # - https://pandas.pydata.org/pandas-docs/stable/whatsnew/v2.1.0.html#deprecations - # - https://github.com/pandas-dev/pandas/issues/43999 - df = pd.DataFrame(df.groupby(df.bin_center, observed=False).sum(numeric_only=True)) - - # reverse cumsum - df["cumulative_rate"] = df.loc[::-1, "rate"].cumsum()[::-1] - df = df.reset_index() - df.bin_center = pd.to_numeric(df.bin_center) - df = df[df.bin_center.between(min_mag, max_mag)] - return df - - df = build_mfd(df0, "rate_weighted_mean", "Magnitude") - yield from df.itertuples() - - def resolve_min_magnitude(root, info): - filter_args = root.filter_arguments - fault_sections_gdf = get_fault_section_aggregates(filter_args) - return fault_sections_gdf["Magnitude.min"].min() - - def resolve_max_magnitude(root, info): - filter_args = root.filter_arguments - fault_sections_gdf = get_fault_section_aggregates(filter_args) - return fault_sections_gdf["Magnitude.max"].max() - - def resolve_min_participation_rate(root, info): - filter_args = root.filter_arguments - fault_sections_gdf = get_fault_section_aggregates(filter_args) - return fault_sections_gdf["rate_weighted_mean.sum"].min() - - def resolve_max_participation_rate(root, info): - filter_args = root.filter_arguments - fault_sections_gdf = get_fault_section_aggregates(filter_args) - return fault_sections_gdf["rate_weighted_mean.sum"].max() - - def resolve_section_count(root, info): - filter_args = root.filter_arguments - log.debug(f">>> resolve_section_count {filter_args}") - fault_sections_gdf = get_fault_section_aggregates(filter_args) - return fault_sections_gdf.shape[0] - - def resolve_fault_surfaces(root, info, *args, **kwargs): - filter_args = root.filter_arguments - color_scale_args = kwargs.get("color_scale") - style_args = kwargs.get("style") - - log.info(f"resolve_fault_surfaces args: {kwargs} filter_args:{filter_args}") - - fault_sections_gdf = get_fault_section_aggregates(filter_args) - - if color_scale_args: - color_values = get_colour_values( - color_scale=color_scale_args.name, - color_scale_vmax=color_scale_args.max_value or fault_sections_gdf["rate_weighted_mean.sum"].max(), - color_scale_vmin=color_scale_args.min_value or fault_sections_gdf["rate_weighted_mean.sum"].min(), - color_scale_normalise=color_scale_args.normalisation or ColourScaleNormaliseEnum.LOG.value, # type: ignore - values=tuple(fault_sections_gdf["rate_weighted_mean.sum"].tolist()), - ) - - log.debug("cacheable_hazard_map colour map ") # % (t3 - t2)) - log.debug(f"get_colour_values cache_info: {str(get_colour_values.cache_info())}") - else: - color_values = None - - if style_args or color_scale_args: - fill_color = style_args.fill_color - stroke_color = style_args.stroke_color - fill_opacity = style_args.fill_opacity or 0.5 - stroke_width = style_args.stroke_width or 1 - stroke_opacity = style_args.stroke_opacity or 1 - - fault_sections_gdf["fill"] = color_values or fill_color - fault_sections_gdf["fill-opacity"] = fill_opacity # for n in values] - fault_sections_gdf["stroke"] = color_values or stroke_color - fault_sections_gdf["stroke-width"] = stroke_width - fault_sections_gdf["stroke-opacity"] = stroke_opacity - - log.debug(f"columns: {fault_sections_gdf.columns}") - fault_sections_gdf = fault_sections_gdf.drop( - columns=[ - "rate_weighted_mean.max", - "rate_weighted_mean.min", - "rate_weighted_mean.mean", - "Target Slip Rate", - "Target Slip Rate StdDev", - ] - ) - # import solvis - # solvis.export_geojson(fault_sections_gdf, 'fault_surfaces.geojson', indent=2) - - return json.loads(fault_sections_gdf.to_json()) - - def resolve_fault_traces(root, info, *args, **kwargs): - filter_args = root.filter_arguments - color_scale_args = kwargs.get("color_scale") # root.color_scale_arguments - style_args = kwargs.get("style") - - log.info(f"resolve_fault_surfaces args: {kwargs} filter_args:{filter_args}") - - fault_sections_gdf = get_fault_section_aggregates(filter_args, trace_only=True) - - if color_scale_args: - color_values = get_colour_values( - color_scale=color_scale_args.name, - color_scale_vmax=color_scale_args.max_value or fault_sections_gdf["rate_weighted_mean.sum"].max(), - color_scale_vmin=color_scale_args.min_value or fault_sections_gdf["rate_weighted_mean.sum"].min(), - color_scale_normalise=color_scale_args.normalisation or ColourScaleNormaliseEnum.LOG.value, # type: ignore - values=tuple(fault_sections_gdf["rate_weighted_mean.sum"].tolist()), - ) - - log.debug("cacheable_hazard_map colour map ") # % (t3 - t2)) - log.debug(f"get_colour_values cache_info: {str(get_colour_values.cache_info())}") - - if style_args or color_scale_args: - stroke_width = style_args.stroke_width if style_args else 1 - stroke_opacity = style_args.stroke_opacity if style_args else 1 - - fault_sections_gdf["stroke"] = color_values if color_scale_args else style_args.stroke_color - fault_sections_gdf["stroke-width"] = stroke_width - fault_sections_gdf["stroke-opacity"] = stroke_opacity - # fault_sections_gdf['fill-opacity'] = stroke_opacity # TODO remove again - # fault_sections_gdf['fill'] = color_values if color_scale_args else style_args.stroke_color - - fault_sections_gdf = fault_sections_gdf.drop( - columns=[ - "rate_weighted_mean.max", - "rate_weighted_mean.min", - "rate_weighted_mean.mean", - "Target Slip Rate", - "Target Slip Rate StdDev", - ] - ) - # import solvis - # solvis.export_geojson(fault_sections_gdf, 'fault_traces.geojson', indent=2) - - return json.loads(fault_sections_gdf.to_json()) diff --git a/solvis_graphql_api/composite_solution/composite_solution.py b/solvis_graphql_api/composite_solution/composite_solution.py deleted file mode 100644 index 5c6bcee..0000000 --- a/solvis_graphql_api/composite_solution/composite_solution.py +++ /dev/null @@ -1,30 +0,0 @@ -"""The API schema for conposite solutions.""" - -import logging - -import graphene - -log = logging.getLogger(__name__) - - -class FaultSystemSolution(graphene.ObjectType): - """ - The aggregation of the inversion solutionson a given Source LogicTreeBranch - model_id - short_name - long_name - """ - - model_id = graphene.String() - short_name = graphene.String() - long_name = graphene.String() - - -class CompositeSolution(graphene.ObjectType): - """ - A complete NSHM model comprising at least one FaultSystemSolution - """ - - model_id = graphene.String() - fault_systems = graphene.List(graphene.String) # FaultSystemSolution) - file_url = graphene.String(description="get a URL so one can download the file") diff --git a/solvis_graphql_api/composite_solution/filter_set_logic_options.py b/solvis_graphql_api/composite_solution/filter_set_logic_options.py deleted file mode 100644 index fe56d37..0000000 --- a/solvis_graphql_api/composite_solution/filter_set_logic_options.py +++ /dev/null @@ -1,37 +0,0 @@ -"""The API schema for FilterSetLogicOptions.""" - -from typing import Any - -import graphene -import solvis.solution.typing - - -def _solvis_join(filter_set_options: tuple[Any], member: str) -> solvis.solution.typing.SetOperationEnum: - """Helper: Convert a Graphene filter set option to Solvis native Enum type.""" - return solvis.solution.typing.SetOperationEnum(dict(filter_set_options)[member]) - - -# Construct graphene Enum from native Solvis type. -SetOperationEnum = graphene.Enum.from_enum(solvis.solution.typing.SetOperationEnum) - - -class FilterSetLogicOptionsBase: - """Let the user define how the result sets are combined""" - - multiple_locations = graphene.Field( - SetOperationEnum, - default_value=SetOperationEnum.INTERSECTION.value, # type: ignore - ) - multiple_faults = graphene.Field(SetOperationEnum, default_value=SetOperationEnum.UNION.value) # type: ignore - locations_and_faults = graphene.Field( - SetOperationEnum, - default_value=SetOperationEnum.INTERSECTION.value, # type: ignore - ) - - -class FilterSetLogicOptionsInput(FilterSetLogicOptionsBase, graphene.InputObjectType): - pass - - -class FilterSetLogicOptions(FilterSetLogicOptionsBase, graphene.ObjectType): - pass diff --git a/solvis_graphql_api/composite_solution/filtered_ruptures_args.py b/solvis_graphql_api/composite_solution/filtered_ruptures_args.py deleted file mode 100644 index 50fc438..0000000 --- a/solvis_graphql_api/composite_solution/filtered_ruptures_args.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The API schema for conposite solutions.""" - -import logging - -import graphene - -from .filter_set_logic_options import ( - FilterSetLogicOptions, - FilterSetLogicOptionsInput, - SetOperationEnum, -) - -log = logging.getLogger(__name__) - - -class FilterRupturesArgsBase: - """Defines filter arguments for Inversions analysis, must be subtyped""" - - model_id = graphene.String(required=True, description="The ID of NSHM model") - - fault_system = graphene.String( - required=True, - description="The fault systems [`HIK`, `PUY`, `CRU`]", - ) - - corupture_fault_names = graphene.List( - graphene.String, - required=False, - default_value=tuple([]), - description="Optional list of parent fault names. Result will only include ruptures that include parent " - "fault sections", - ) - - location_ids = graphene.List( - graphene.String, - required=False, - default_value=tuple([]), - description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", - ) - - radius_km = graphene.Int(required=False, description="The rupture/location intersection radius in km") - - filter_set_options = graphene.Field(FilterSetLogicOptions) - - minimum_rate = graphene.Float( - required=False, - description="Constrain to fault_sections having a annual rate above the value supplied.", - ) - maximum_rate = graphene.Float( - required=False, - description="Constrain to fault_sections having a annual rate below the value supplied.", - ) - minimum_mag = graphene.Float( - required=False, - description="Constrain to fault_sections having a magnitude above the value supplied.", - ) - maximum_mag = graphene.Float( - required=False, - description="Constrain to fault_sections having a magnitude below the value supplied.", - ) - - -class FilterRupturesArgsInput(FilterRupturesArgsBase, graphene.InputObjectType): - """Arguments passed as FilterRupturesArgs""" - - # DEFAULT_LOCATION_OPTION: SetOperationEnum = SetOperationEnum.INTERSECTION.value - - filter_set_options = graphene.Field( - FilterSetLogicOptionsInput, - required=False, - default_value=dict( - multiple_locations=SetOperationEnum.INTERSECTION.value, # type: ignore - multiple_faults=SetOperationEnum.UNION.value, # type: ignore - locations_and_faults=SetOperationEnum.INTERSECTION.value, # type: ignore - ), - ) - - -class FilterRupturesArgs(FilterRupturesArgsBase, graphene.ObjectType): - """Arguments FilterRupturesArgs""" - - filter_set_options = graphene.Field(FilterSetLogicOptions) diff --git a/solvis_graphql_api/composite_solution/ruptures.py b/solvis_graphql_api/composite_solution/ruptures.py new file mode 100644 index 0000000..1186298 --- /dev/null +++ b/solvis_graphql_api/composite_solution/ruptures.py @@ -0,0 +1,61 @@ +"""Graphene-free rupture compute helpers. + +``auto_sorted_dataframe`` was moved out of the legacy graphene ``composite_solution.schema`` +so the Strawberry schema (and the function-level unit tests) can sort filtered ruptures without +importing graphene. The ``CompositeRuptureDetail.column_name`` lookup it used is inlined here as +``ATTRIBUTE_COLUMN_MAP``. +""" + +import logging +import math +from collections.abc import Sequence + +import numpy as np +import pandas as pd + +log = logging.getLogger(__name__) + +# field-name → dataframe-column map (was CompositeRuptureDetail.ATTRIBUTE_COLUMN_MAP in graphene) +ATTRIBUTE_COLUMN_MAP = { + "rupture_index": "Rupture Index", + "magnitude": "Magnitude", + "rake_mean": "Average Rake (degrees)", + "area": "Area (m^2)", + "length": "Length (m)", +} + + +def column_name(field_name: str) -> str: + return ATTRIBUTE_COLUMN_MAP.get(field_name, field_name) + + +def auto_sorted_dataframe(dataframe, sortby_args, min_rate: float): + """Sort the dataframe by the argument specifications. + + Automatically set bins for Mag or rate-based columns using simple heuristics. + """ + by: list = [] + ascending: list = [] + for idx, itm in enumerate(sortby_args): + column: str = column_name(itm["attribute"]) + if len(sortby_args) == 1: + by.append(column) + ascending.append(itm.get("ascending", True)) + continue + + bins: Sequence + if idx == 0: + if itm["attribute"] == "magnitude": + bins = np.logspace(np.log10(5.0), np.log10(10.0), 50).tolist() # 50 bins at M0.1 spacing + else: + # all others are rate values, so take min rate and bin logarithmically + places = abs(math.floor(math.log10(min_rate) + 1)) + bins = np.logspace(np.log10(min_rate), np.log10(1.0), 10 * places).tolist() + dataframe[column + "_binned"] = pd.cut(dataframe[column], bins=bins, labels=bins[1:]) + column = column + "_binned" + + by.append(column) + ascending.append(itm.get("ascending", True)) + + log.debug(f"sort by {by}, ascending {ascending}") + return dataframe.sort_values(by=by, ascending=ascending) diff --git a/solvis_graphql_api/composite_solution/schema.py b/solvis_graphql_api/composite_solution/schema.py deleted file mode 100644 index acb298a..0000000 --- a/solvis_graphql_api/composite_solution/schema.py +++ /dev/null @@ -1,191 +0,0 @@ -"""The API schema for conposite solutions.""" - -import logging -import math -from collections.abc import Sequence - -import geopandas as gpd -import graphene -import graphql_relay -import numpy as np -import pandas as pd -from graphene import relay - -from .cached import matched_rupture_sections_gdf -from .composite_rupture_detail import CompositeRuptureDetail, RuptureDetailConnection - -log = logging.getLogger(__name__) - -# def sorted_dataframe(dataframe: gpd.GeoDataFrame, sortby_args: Dict, min_rate: float): -# """Sort the dataframe by the argument specifications - -# Manually set bins according to sortby_args. -# """ -# def build_bins(start: float, step: float, until: float, logarithmic: bool): -# if logarithmic: -# return np.logspace(np.log10(step), np.log10(1.0), 100) -# else: -# _bin = start -# bins = [_bin] -# while _bin < until + step: -# _bin += step -# bins.append(_bin) -# return bins - -# by = [] -# ascending = [] -# for itm in sortby_args: -# column = CompositeRuptureDetail.column_name(itm['attribute']) -# if width := itm.get('bin_width'): # handle binning -# print(itm) -# bins = build_bins( -# start=0, step=width, until=dataframe[column].max(), logarithmic=itm.get('log_bins', False) -# ) -# print(bins) -# dataframe[column + "_binned"] = pd.cut(dataframe[column], bins=bins, labels=bins[1:]) -# column = column + "_binned" - -# by.append(column) -# ascending.append(itm.get('ascending', True)) - -# return dataframe.sort_values(by=by, ascending=ascending) - - -def auto_sorted_dataframe(dataframe: gpd.GeoDataFrame, sortby_args: dict, min_rate: float): - """Sort the dataframe by the argument specifications - - Automatically set bins for Mag or rate-based columns using simple heuristics. - """ - by = [] - ascending = [] - # print('sortby_args', sortby_args) - # print() - for idx, itm in enumerate(sortby_args): - column: str = CompositeRuptureDetail.column_name(itm["attribute"]) - if len(sortby_args) == 1: - by.append(column) - ascending.append(itm.get("ascending", True)) - continue - - bins: Sequence - if idx == 0: - if itm["attribute"] == "magnitude": - bins = np.logspace(np.log10(5.0), np.log10(10.0), 50).tolist() # 50 bins at M0.1 spacing - log.debug("Bin setup magnitude logspace for {}}}".format(itm["attribute"])) - # continue - else: - # all others are rate values, so take min rate and - places = abs(math.floor(math.log10(min_rate) + 1)) - # print('places:', places) - bins = np.logspace(np.log10(min_rate), np.log10(1.0), 10 * places).tolist() - dataframe[column + "_binned"] = pd.cut(dataframe[column], bins=bins, labels=bins[1:]) - column = column + "_binned" - - by.append(column) - ascending.append(itm.get("ascending", True)) - - log.debug(f"sort by {by}, ascending {ascending}") - return dataframe.sort_values(by=by, ascending=ascending) - - -def build_ruptures_connection( - rupture_sections_gdf: gpd.GeoDataFrame, - model_id: str, - fault_system: str, - first: int, - after: graphene.ID = None, -): - # stolen from FaultSystemRuptures resolver.... - cursor_offset = int(graphql_relay.from_global_id(after)[1]) + 1 if after else 0 - - rupture_ids = list(rupture_sections_gdf["Rupture Index"]) - nodes = [ - CompositeRuptureDetail(model_id=model_id, fault_system=fault_system, rupture_index=rid) - for rid in rupture_ids[cursor_offset : cursor_offset + first] - ] - - # based on https://gist.github.com/AndrewIngram/b1a6e66ce92d2d0befd2f2f65eb62ca5#file-pagination-py-L152 - edges = [ - RuptureDetailConnection.Edge( - node=node, - cursor=graphql_relay.to_global_id("RuptureDetailConnectionCursor", str(cursor_offset + idx)), - ) - for idx, node in enumerate(nodes) - ] - - # import json - # edges_geojson = [] - # for e in edges: - # edges_geojson.append(json.loads(e.node.fault_surfaces)) - - # REF https://stackoverflow.com/questions/46179559/custom-connectionfield-in-graphene - connection_field = relay.ConnectionField.resolve_connection(RuptureDetailConnection, {}, edges) - - total_count = len(rupture_ids) - has_next = total_count > 1 + int(graphql_relay.from_global_id(edges[-1].cursor)[1]) if edges else False - - connection_field.total_count = total_count - connection_field.page_info = relay.PageInfo( - end_cursor=( - edges[-1].cursor if edges else None - ), # graphql_relay.to_global_id("CompositeRuptureDetail", str(cursor_offset+first)), - has_next_page=has_next, - ) - connection_field.edges = edges - return connection_field - - -def paginated_filtered_ruptures(filter_args, sortby_args, **kwargs) -> RuptureDetailConnection: - ### query that accepts both the rupture filter & sortby_args args and the pagination args - log.info(f"paginated_ruptures args: {kwargs} filter_args:{filter_args}") - - min_rate = filter_args.get("minimum_rate") or 1e-20 - - rupture_sections_gdf = matched_rupture_sections_gdf( # is this working in both scenarios? - filter_args["model_id"], - filter_args["fault_system"], - tuple(filter_args["location_ids"]), - filter_args["radius_km"], - min_rate=min_rate, - max_rate=filter_args.get("maximum_rate"), - min_mag=filter_args.get("minimum_mag"), - max_mag=filter_args.get("maximum_mag"), - filter_set_options=frozenset(dict(filter_args.filter_set_options).items()), - corupture_fault_names=tuple(filter_args.corupture_fault_names or []), - ) - - if sortby_args: - rupture_sections_gdf = auto_sorted_dataframe(rupture_sections_gdf, sortby_args, min_rate) - - first = kwargs.get("first", 5) # how many to fetch - after = kwargs.get("after") # cursor of last page, or none - log.info(f"paginated_filtered_ruptures ruptures : first={first}, after={after}") - - return build_ruptures_connection( - rupture_sections_gdf, - model_id=filter_args["model_id"], - fault_system=filter_args["fault_system"], - first=first, - after=after, - ) - - -class CompositeRuptureSections(graphene.ObjectType): - model_id = graphene.String() - - rupture_count = graphene.Int() - section_count = graphene.Int() - - # these are useful for calculating color scales - max_magnitude = graphene.Float(description="maximum magnitude from contributing solutions") - min_magnitude = graphene.Float(description="minimum magnitude from contributing solutions") - max_participation_rate = graphene.Float( - description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing solutions" - ) - min_participation_rate = graphene.Float( - description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing solutions" - ) - - fault_surfaces = graphene.Field( - graphene.JSONString, - ) diff --git a/solvis_graphql_api/geojson_style.py b/solvis_graphql_api/geojson_style.py deleted file mode 100644 index bf304cd..0000000 --- a/solvis_graphql_api/geojson_style.py +++ /dev/null @@ -1,67 +0,0 @@ -import graphene - - -def apply_geojson_style(geojson: dict, style: dict) -> dict: - """ "merge each features properties dict with style dict""" - new_dict = dict(geojson) - for feature in new_dict["features"]: - current_props = feature.get("properties", {}) - feature["properties"] = { - **current_props, - **{ - "stroke-color": style.get("stroke_color"), - "stroke-opacity": style.get("stroke_opacity"), - "stroke-width": style.get("stroke_width"), - }, - } - # add fill attributes - for extra in ["fill_color", "fill_opacity"]: - if style.get(extra): - feature["properties"][extra.replace("_", "-")] = style.get(extra) - return new_dict - - -class GeojsonLineStyleArgumentsBase: - """Defines styling arguments for geojson line features, - - ref https://academy.datawrapper.de/article/177-how-to-style-your-markers-before-importing-them-to-datawrapper - """ - - stroke_color = graphene.String( - default_value="green", - description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', - ) - stroke_width = graphene.Int(default_value=1, description="a number between 0 and 20.") - stroke_opacity = graphene.Float(default_value=1.0, description="a number between 0 and 1.0") - - -class GeojsonAreaStyleArgumentsBase: - """Defines styling arguments for geojson features""" - - stroke_color = graphene.String( - default_value="green", - description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', - ) - stroke_width = graphene.Int(default_value=1, description="a number between 0 and 20.") - stroke_opacity = graphene.Float(default_value=1.0, description="a number between 0 and 1.0") - fill_color = graphene.String( - default_value="green", - description='fill colour as Hex code ("#cc0000") or HTML color names ("royalblue") )', - ) - fill_opacity = graphene.Float(description="0-1.0", default_value=1.0) - - -class GeojsonLineStyleArgumentsInput(GeojsonLineStyleArgumentsBase, graphene.InputObjectType): - """Defines styling arguments for geojson features""" - - -class GeojsonLineStyleArguments(GeojsonLineStyleArgumentsBase, graphene.ObjectType): - """Defines styling arguments for geojson features""" - - -class GeojsonAreaStyleArgumentsInput(GeojsonAreaStyleArgumentsBase, graphene.InputObjectType): - """Defines styling arguments for geojson features""" - - -class GeojsonAreaStyleArguments(GeojsonAreaStyleArgumentsBase, graphene.ObjectType): - """Defines styling arguments for geojson features""" diff --git a/solvis_graphql_api/geojson_style_util.py b/solvis_graphql_api/geojson_style_util.py new file mode 100644 index 0000000..7ce34b7 --- /dev/null +++ b/solvis_graphql_api/geojson_style_util.py @@ -0,0 +1,26 @@ +"""Graphene-free geojson styling helper. + +The pure dict-merge extracted out of the legacy graphene ``geojson_style`` module so the +Strawberry schema can style geojson without importing graphene. The graphene module +re-exports this during the transition; it (and its graphene style types) are deleted at cutover. +""" + + +def apply_geojson_style(geojson: dict, style: dict) -> dict: + """ "merge each features properties dict with style dict""" + new_dict = dict(geojson) + for feature in new_dict["features"]: + current_props = feature.get("properties", {}) + feature["properties"] = { + **current_props, + **{ + "stroke-color": style.get("stroke_color"), + "stroke-opacity": style.get("stroke_opacity"), + "stroke-width": style.get("stroke_width"), + }, + } + # add fill attributes + for extra in ["fill_color", "fill_opacity"]: + if style.get(extra): + feature["properties"][extra.replace("_", "-")] = style.get(extra) + return new_dict diff --git a/solvis_graphql_api/handler.py b/solvis_graphql_api/handler.py deleted file mode 100644 index b776e8f..0000000 --- a/solvis_graphql_api/handler.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -based on https://github.com/logandk/serverless-wsgi?tab=readme-ov-file#usage-without-serverless - -needed because serverless-wsgi and ecr/image dont' play together in the package/deploy steps -""" - -import logging - -import serverless_wsgi - -from solvis_graphql_api import solvis_graphql_api - -logger = logging.getLogger(__name__) -logger.info(f"{__name__} module loaded") -# If you need to send additional content types as text, add then directly -# to the whitelist: -# -# serverless_wsgi.TEXT_MIME_TYPES.append("application/custom+json") - - -def handler(event, context): - logger.info(f"{handler.__name__} call with event: {event}") - return serverless_wsgi.handle_request(solvis_graphql_api.app, event, context) diff --git a/solvis_graphql_api/location_schema.py b/solvis_graphql_api/location_schema.py deleted file mode 100644 index c343fce..0000000 --- a/solvis_graphql_api/location_schema.py +++ /dev/null @@ -1,92 +0,0 @@ -"""The main API schema.""" - -import logging - -import graphene -import shapely -from graphene import relay -from nzshm_common.location.location import location_by_id - -from solvis_graphql_api.composite_solution import cached -from solvis_graphql_api.geojson_style import ( - GeojsonAreaStyleArgumentsInput, - apply_geojson_style, -) - -log = logging.getLogger(__name__) - - -class LocationDetail(graphene.ObjectType): - """Represents the internal details of a given location""" - - class Meta: - interfaces = (relay.Node,) - - location_id = graphene.String() - name = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - - radius_geojson = graphene.Field( - graphene.JSONString, - radius_km=graphene.Argument(graphene.Int, required=True, description="polygon radius (km)."), - style=graphene.Argument( - GeojsonAreaStyleArgumentsInput, - required=False, - description="feature style for the geojson.", - default_value=dict(stroke_color="black", stroke_width=1, stroke_opacity=1.0), - ), - ) - - def resolve_id(root, info, *args, **kwargs): - log.info("resolve_id") - # print(root) - return root.location_id - - def resolve_location_id(root, info): - log.info("resolve_location_id") - return root.location_id - - def resolve_radius_geojson(root, info, radius_km, style, *args, **kwargs): - polygon = cached.get_location_polygon(radius_km, lat=root.latitude, lon=root.longitude) - features = dict( - features=[ - dict( - id=root.location_id, - type="Feature", - geometry=shapely.geometry.mapping(polygon), - ) - ] - ) - # return features - return apply_geojson_style(features, style) - - -class LocationDetailConnection(relay.Connection): - class Meta: - node = LocationDetail - - total_count = graphene.Int() - - -def get_location_detail_list(location_ids: list[str], **kwarg) -> LocationDetailConnection: - log.info(f"get_location_detail_list: {location_ids}") - - nodes = [ - LocationDetail( - location_id=loc["id"], - name=loc["name"], - latitude=loc["latitude"], - longitude=loc["longitude"], - ) - for loc in [location_by_id(location_id) for location_id in location_ids] - ] - - print(nodes) - edges = [LocationDetailConnection.Edge(node=node) for idx, node in enumerate(nodes)] - - # REF https://stackoverflow.com/questions/46179559/custom-connectionfield-in-graphene - connection_field = relay.ConnectionField.resolve_connection(LocationDetailConnection, {}, edges) - connection_field.total_count = len(edges) - connection_field.edges = edges - return connection_field diff --git a/solvis_graphql_api/schema.py b/solvis_graphql_api/schema.py index db1ea02..f8366a3 100644 --- a/solvis_graphql_api/schema.py +++ b/solvis_graphql_api/schema.py @@ -1,274 +1,914 @@ -"""The main API schema.""" - -import logging - -import graphene -from graphene import relay +"""Strawberry schema — the GraphQL API (formerly a Graphene parity port). + +Reproduces the original Graphene SDL (frozen in ``schema.legacy.graphql``, still asserted +byte-for-byte by ``tools/schema_parity.py``) one-for-one. The graphene schema it was ported +against has been removed; the GraphQL type shapes live here and the heavy compute is reused +from the graphene-free helpers: ``composite_solution.cached`` (data access + rupture_detail), +``composite_solution.ruptures`` (auto_sorted_dataframe), ``color_scale.compute`` (colour maths), +and ``geojson_style_util`` (geojson styling). + +Parity traps honoured: +- root type is ``QueryRoot`` (not Strawberry's default ``Query``) +- a **custom** ``Node`` interface (``id: ID!``), NOT ``strawberry.relay`` (which emits ``GlobalID``) +- ``auto_camel_case=False``; relay ``PageInfo``/edge fields keep their camelCase names explicitly +- optional args with no SDL default use ``strawberry.UNSET`` (suppresses ``= null``); only + ``ColorScaleArgsInput.normalisation`` keeps an explicit ``= null`` (``= None``) +""" + +import enum +import json +from types import SimpleNamespace +from typing import TYPE_CHECKING, Annotated, Any, NewType + +import graphql_relay +import solvis.solution.typing +import strawberry from nzshm_common.location.location import LOCATION_LISTS, LOCATIONS, location_by_id +from strawberry.schema.config import StrawberryConfig import solvis_graphql_api +from solvis_graphql_api.color_scale.compute import compute_colour_scale, get_colour_values +from solvis_graphql_api.composite_solution import cached +from solvis_graphql_api.composite_solution.cached import rupture_detail +from solvis_graphql_api.composite_solution.ruptures import auto_sorted_dataframe +from solvis_graphql_api.geojson_style_util import apply_geojson_style -from .color_scale import ColorScale, ColourScaleNormaliseEnum, get_colour_scale -from .composite_solution import ( - CompositeRuptureDetail, - CompositeRuptureDetailArgs, - CompositeRuptureSections, - CompositeSolution, - FilterRupturesArgs, - FilterRupturesArgsInput, - RuptureDetailConnection, - SimpleSortRupturesArgs, - cached, - paginated_filtered_ruptures, -) -from .composite_solution.cached import get_composite_solution, parent_fault_names -from .location_schema import LocationDetailConnection, get_location_detail_list +RADII: list[dict[str, Any]] = [ + {"id": 1, "radii": [10e3]}, + {"id": 2, "radii": [10e3, 20e3]}, + {"id": 3, "radii": [10e3, 20e3, 30e3]}, + {"id": 4, "radii": [10e3, 20e3, 30e3, 40e3]}, + {"id": 5, "radii": [10e3, 20e3, 30e3, 40e3, 50e3]}, + {"id": 6, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3]}, + {"id": 7, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3, 200e3]}, +] + +# --------------------------------------------------------------------------- scalars + +if TYPE_CHECKING: + # mypy: treat the custom scalar as an opaque value type (it can't infer a NewType-backed + # strawberry.scalar() assignment as a usable type — the classic `valid-type` gotcha) + JSONString = object +else: + JSONString = strawberry.scalar( + NewType("JSONString", object), + serialize=lambda v: v if isinstance(v, str) else json.dumps(v), + parse_value=lambda v: json.loads(v), + description=( + "Allows use of a JSON String for input / output from the GraphQL schema.\n\n" + "Use of this type is *not recommended* as you lose the benefits of having a defined, static\n" + "schema (one of the key benefits of GraphQL)." + ), + ) + +# --------------------------------------------------------------------------- interfaces -# from .solution_schema import ( -# FilterInversionSolution, -# InversionSolutionAnalysisArguments, -# get_inversion_solution, -# ) -log = logging.getLogger(__name__) +@strawberry.interface(description="An object with an ID") +class Node: + # default UNSET so implementing types (which override `id` with a resolver) don't carry + # it as a required __init__ arg; the SDL stays `id: ID!`. + id: strawberry.ID = strawberry.field(default=strawberry.UNSET, description="The ID of the object") -class RadiiSet(graphene.ObjectType): - radii_set_id = graphene.Int(description="The unique radii_set_id") - radii = graphene.List( - graphene.Int, - description="list of dimension in metres defined by the radii set.", +# --------------------------------------------------------------------------- enums + + +@strawberry.enum +class ColourScaleNormaliseEnum(enum.Enum): + LOG = "log" + LIN = "lin" + + +if TYPE_CHECKING: + # a concrete stub so mypy treats SetOperationEnum as a usable type even when solvis is + # untyped (ignore_missing_imports); member names match the real solvis enum. + class SetOperationEnum(enum.Enum): + UNION = "UNION" + INTERSECTION = "INTERSECTION" + DIFFERENCE = "DIFFERENCE" + SYMMETRIC_DIFFERENCE = "SYMMETRIC_DIFFERENCE" +else: + SetOperationEnum = strawberry.enum( + solvis.solution.typing.SetOperationEnum, description=solvis.solution.typing.SetOperationEnum.__doc__ ) +# --------------------------------------------------------------------------- input types -class Location(graphene.ObjectType): - location_id = graphene.String(description="unique location location_id.") - name = graphene.String(description="location name.") - latitude = graphene.Float(description="location latitude.") - longitude = graphene.Float(description="location longitude") +@strawberry.input(description="Defines styling arguments for geojson features") +class GeojsonAreaStyleArgumentsInput: + stroke_color: str | None = strawberry.field( + default="green", + description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', + ) + stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") + stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") + fill_color: str | None = strawberry.field( + default="green", + description='fill colour as Hex code ("#cc0000") or HTML color names ("royalblue") )', + ) + fill_opacity: float | None = strawberry.field(default=1.0, description="0-1.0") -class LocationList(graphene.ObjectType): - list_id = graphene.String(description="The unique location_list_id") - location_ids = graphene.List(graphene.String, description="list of location codes.") - locations = graphene.List(Location, description="the locations in this list.") - def resolve_locations(root, info, **args): - for loc_id in root.location_ids: - loc = location_by_id(loc_id) - yield Location(loc["id"], loc["name"], loc["latitude"], loc["longitude"]) +@strawberry.input(description="Defines styling arguments for geojson features") +class GeojsonLineStyleArgumentsInput: + stroke_color: str | None = strawberry.field( + default="green", + description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', + ) + stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") + stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") -RADII = [ - {"id": 1, "radii": [10e3]}, - {"id": 2, "radii": [10e3, 20e3]}, - {"id": 3, "radii": [10e3, 20e3, 30e3]}, - {"id": 4, "radii": [10e3, 20e3, 30e3, 40e3]}, - {"id": 5, "radii": [10e3, 20e3, 30e3, 40e3, 50e3]}, - {"id": 6, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3]}, - {"id": 7, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3, 200e3]}, -] +@strawberry.input(description="Arguments passed as ColorScaleArgsInput") +class ColorScaleArgsInput: + name: str | None = "inferno" + min_value: float | None = strawberry.UNSET + max_value: float | None = strawberry.UNSET + normalisation: ColourScaleNormaliseEnum | None = None -def get_one_location(location_id): - for loc in LOCATIONS: - if loc["id"] == location_id: - return Location(loc["id"], loc["name"], loc["latitude"], loc["longitude"]) - raise IndexError(f"Location with id {location_id} was not found.") +@strawberry.input +class FilterSetLogicOptionsInput: + multiple_locations: SetOperationEnum | None = SetOperationEnum.INTERSECTION + multiple_faults: SetOperationEnum | None = SetOperationEnum.UNION + locations_and_faults: SetOperationEnum | None = SetOperationEnum.INTERSECTION -def get_one_location_list(location_list_id): - ll = LOCATION_LISTS.get(location_list_id) - if ll: - return LocationList(location_list_id, ll["locations"]) - raise IndexError(f"LocationList with id {location_list_id} was not found.") +@strawberry.input +class CompositeRuptureDetailArgs: + model_id: str | None = strawberry.UNSET + fault_system: str | None = strawberry.field( + default=strawberry.UNSET, description="Unique ID of the fault system e.g. `PUY`" + ) + rupture_index: int | None = strawberry.UNSET + + +@strawberry.input +class SimpleSortRupturesArgs: + attribute: str | None = strawberry.UNSET + ascending: bool | None = strawberry.UNSET + + +@strawberry.input(description="Arguments passed as FilterRupturesArgs") +class FilterRupturesArgsInput: + model_id: str = strawberry.field(description="The ID of NSHM model") + fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") + corupture_fault_names: list[str | None] | None = strawberry.field( + default_factory=list, + description="Optional list of parent fault names. Result will only include ruptures that include parent " + "fault sections", + ) + location_ids: list[str | None] | None = strawberry.field( + default_factory=list, + description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", + ) + radius_km: int | None = strawberry.field( + default=strawberry.UNSET, description="The rupture/location intersection radius in km" + ) + filter_set_options: FilterSetLogicOptionsInput | None = strawberry.field( + # a plain mapping (not an input instance) so the SDL default renders AND graphql-core + # coerces it at execution — an instance default trips strawberry's argument coercion + default_factory=lambda: { + "multiple_locations": SetOperationEnum.INTERSECTION, + "multiple_faults": SetOperationEnum.UNION, + "locations_and_faults": SetOperationEnum.INTERSECTION, + } + ) + minimum_rate: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a annual rate above the value supplied.", + ) + maximum_rate: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a annual rate below the value supplied.", + ) + minimum_mag: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a magnitude above the value supplied.", + ) + maximum_mag: float | None = strawberry.field( + default=strawberry.UNSET, + description="Constrain to fault_sections having a magnitude below the value supplied.", + ) + + +# the legacy arg default is a *partial* 3-key style ({stroke_color, stroke_width, stroke_opacity}), +# expressed as a plain mapping (as graphene did) so graphql-core renders it in the SDL AND coerces +# it cleanly at execution when the arg is omitted (an input *instance* default trips coercion). +_AREA_STYLE_ARG_DEFAULT: Any = {"stroke_color": "black", "stroke_width": 1, "stroke_opacity": 1} -def get_one_radii_set(radii_set_id): - for rad in RADII: - if rad["id"] == radii_set_id: - return RadiiSet(radii_set_id, rad["radii"]) - raise IndexError(f"Radii set with id {radii_set_id} was not found.") +def _style_dict(style) -> dict: + if style is None: + return {} + d = style if isinstance(style, dict) else strawberry.asdict(style) + return {k: v for k, v in d.items() if v is not strawberry.UNSET} -class QueryRoot(graphene.ObjectType): - """This is the entry point for solvis graphql query operations""" +# --------------------------------------------------------------------------- output types - color_scale = graphene.Field( - ColorScale, - name=graphene.Argument(graphene.String), - min_value=graphene.Argument(graphene.Float), - max_value=graphene.Argument(graphene.Float), - normalization=graphene.Argument(ColourScaleNormaliseEnum), + +@strawberry.type +class HexRgbValueMapping: + levels: list[float | None] | None = None + hexrgbs: list[str | None] | None = None + + +@strawberry.type +class ColorScale: + name: str | None = None + min_value: float | None = None + max_value: float | None = None + normalisation: ColourScaleNormaliseEnum | None = None + color_map: HexRgbValueMapping | None = None + + +@strawberry.type( + description="The Relay compliant `PageInfo` type, containing data necessary to paginate this connection." +) +class PageInfo: + has_next_page: bool = strawberry.field( + name="hasNextPage", description="When paginating forwards, are there more items?" + ) + has_previous_page: bool = strawberry.field( + name="hasPreviousPage", description="When paginating backwards, are there more items?" + ) + start_cursor: str | None = strawberry.field( + name="startCursor", default=None, description="When paginating backwards, the cursor to continue." + ) + end_cursor: str | None = strawberry.field( + name="endCursor", default=None, description="When paginating forwards, the cursor to continue." ) - def resolve_color_scale(root, info, name, min_value, max_value, normalization, **args): - print(">>>>>>", normalization) - return get_colour_scale( - color_scale=name, - color_scale_normalise=normalization, - vmax=max_value, - vmin=min_value, + +@strawberry.type(description="Represents the internal details of a given location") +class LocationDetail(Node): + location_id: str | None = None + name: str | None = None + latitude: float | None = None + longitude: float | None = None + + @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field + def id(self) -> strawberry.ID: + # graphene relay encodes the node id as base64("LocationDetail:") — reproduce it + return strawberry.ID(graphql_relay.to_global_id("LocationDetail", self.location_id or "")) + + @strawberry.field + def radius_geojson( + self, + radius_km: Annotated[int, strawberry.argument(description="polygon radius (km).")], + style: Annotated[ + GeojsonAreaStyleArgumentsInput | None, + strawberry.argument(description="feature style for the geojson."), + ] = _AREA_STYLE_ARG_DEFAULT, + ) -> JSONString | None: + import shapely + + polygon = cached.get_location_polygon(radius_km, lat=self.latitude, lon=self.longitude) + features = dict( + features=[dict(id=self.location_id, type="Feature", geometry=shapely.geometry.mapping(polygon))] ) + return apply_geojson_style(features, _style_dict(style)) - node = relay.Node.Field() - about = graphene.String(description="About this Solvis API ") +@strawberry.type(description="A Relay edge containing a `LocationDetail` and its cursor.") +class LocationDetailEdge: + node: LocationDetail | None = strawberry.field(description="The item at the end of the edge", default=None) + cursor: str = strawberry.field(description="A cursor for use in pagination") - def resolve_about(root, info, **args): - return f"Hello World, I am solvis_graphql_api! Version: {solvis_graphql_api.__version__}" - # inversion_solution = graphene.Field( - # FilterInversionSolution, - # filter=graphene.Argument(InversionSolutionAnalysisArguments, required=True), - # ) +@strawberry.type +class LocationDetailConnection: + page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") + edges: list[LocationDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") + total_count: int | None = None - # def resolve_inversion_solution(root, info, filter, **args): - # return get_inversion_solution(filter, *args) - locations_by_id = graphene.Field( - LocationDetailConnection, - location_ids=graphene.List( - graphene.String, - required=True, - description='list of nzshm_common.location_ids e.g. `["WLG","PMR","ZQN"]`', - ), +@strawberry.type(description="A complete NSHM model comprising at least one FaultSystemSolution") +class CompositeSolution: + model_id: str | None = None + fault_systems: list[str | None] | None = None + file_url: str | None = strawberry.field(default=None, description="get a URL so one can download the file") + + +@strawberry.type +class CompositeRuptureDetail(Node): + model_id: str | None = None + fault_system: str | None = strawberry.field(default=None, description="Unique ID of the fault system e.g. `PUY`") + rupture_index: int | None = None + fault_traces: JSONString | None = None + + def _rupt(self): + return rupture_detail(self.model_id, self.fault_system, self.rupture_index) + + @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field + def id(self) -> strawberry.ID: + gid = graphql_relay.to_global_id("CompositeRuptureDetail", f"{self.fault_system}:{self.rupture_index}") + return strawberry.ID(gid) + + @strawberry.field + def magnitude(self) -> float | None: + return round(float(self._rupt()["Magnitude"].iloc[0]), 3) + + @strawberry.field(description="Rupture length in kilometres^2") + def area(self) -> float | None: + return round(float(self._rupt()["Area (m^2)"].iloc[0] / 1e6), 0) + + @strawberry.field(description="Rupture length in kilometres)") + def length(self) -> float | None: + return round(float(self._rupt()["Length (m)"].iloc[0] / 1e3), 0) + + @strawberry.field(description="average rake angle (degrees) of the entire rupture") + def rake_mean(self) -> float | None: + return round(float(self._rupt()["Average Rake (degrees)"].iloc[0]), 1) + + @strawberry.field(description="mean of `rate` * `branch weight` of the contributing solutions") + def rate_weighted_mean(self) -> float | None: + return float(self._rupt()["rate_weighted_mean"].iloc[0]) + + @strawberry.field(description="maximum rate from contributing solutions") + def rate_max(self) -> float | None: + return float(self._rupt()["rate_max"].iloc[0]) + + @strawberry.field(description="minimum rate from contributing solutions") + def rate_min(self) -> float | None: + return float(self._rupt()["rate_min"].iloc[0]) + + @strawberry.field(description="count of model solutions that include this rupture") + def rate_count(self) -> int | None: + return int(self._rupt()["rate_count"].iloc[0]) + + @strawberry.field + def fault_surfaces( + self, + style: Annotated[ + GeojsonAreaStyleArgumentsInput | None, + strawberry.argument(description="feature style for rupture trace geojson."), + ] = _AREA_STYLE_ARG_DEFAULT, + ) -> JSONString | None: + return _rupture_fault_surfaces(self.model_id, self.fault_system, self.rupture_index, _style_dict(style)) + + +@strawberry.type(description="A Relay edge containing a `RuptureDetail` and its cursor.") +class RuptureDetailEdge: + node: CompositeRuptureDetail | None = strawberry.field(description="The item at the end of the edge", default=None) + cursor: str = strawberry.field(description="A cursor for use in pagination") + + +@strawberry.type +class RuptureDetailConnection: + page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") + edges: list[RuptureDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") + total_count: int | None = None + + +@strawberry.type +class FilterSetLogicOptions: + multiple_locations: SetOperationEnum | None = None + multiple_faults: SetOperationEnum | None = None + locations_and_faults: SetOperationEnum | None = None + + +@strawberry.type(description="Arguments FilterRupturesArgs") +class FilterRupturesArgs: + model_id: str = strawberry.field(description="The ID of NSHM model") + fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") + corupture_fault_names: list[str | None] | None = strawberry.field( + default=None, + description="Optional list of parent fault names. Result will only include ruptures that include parent " + "fault sections", + ) + location_ids: list[str | None] | None = strawberry.field( + default=None, + description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", + ) + radius_km: int | None = strawberry.field(default=None, description="The rupture/location intersection radius in km") + filter_set_options: FilterSetLogicOptions | None = None + minimum_rate: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a annual rate above the value supplied." + ) + maximum_rate: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a annual rate below the value supplied." + ) + minimum_mag: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a magnitude above the value supplied." + ) + maximum_mag: float | None = strawberry.field( + default=None, description="Constrain to fault_sections having a magnitude below the value supplied." ) - def resolve_locations_by_id(root, info, location_ids, **args): - return get_location_detail_list(location_ids, **args) - composite_solution = graphene.Field( - CompositeSolution, - model_id=graphene.Argument( - graphene.String, - required=True, - description="A valid NSHM model id e.g. `NSHM_1.0.0`", - ), +@strawberry.type +class MagFreqDist: + bin_center: float | None = None + rate: float | None = None + cumulative_rate: float | None = None + + +@strawberry.type( + description=( + "A collection of ruptures and their fault sections that have a geojson represention. They also\n" + "have a set of attributes derived from the composite solution e.g. rate_weighted_mean etc\n\n" + "Key attributes:\n" + " - filter_arguments contains the filter criteria used to find the ruptures.\n" + " - fault_surfaces is a geojson feature file based on the geometry from the undelying rutpure set.\n" + " It may by styled by some attribute of the faults section.\n" + " - mfd_histogram is the MFD table summarise the set of ruptures." + ) +) +class CompositeRuptureSections: + model_id: str | None = None + rupture_count: int | None = None + filter_arguments: FilterRupturesArgs | None = None + # the strawberry filter input; the resolvers below do all the compute graphene-free + filter_input: strawberry.Private[Any] = None + + @strawberry.field + def section_count(self) -> int | None: + return _fault_section_aggregates(self.filter_input).shape[0] + + @strawberry.field(description="maximum rupture magnitude from the contributing solutions.") + def max_magnitude(self) -> float | None: + return _fault_section_aggregates(self.filter_input)["Magnitude.max"].max() + + @strawberry.field(description="minimum rupture magnitude from the contributing solutions.") + def min_magnitude(self) -> float | None: + return _fault_section_aggregates(self.filter_input)["Magnitude.min"].min() + + @strawberry.field( + description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing " + "solutions." + ) + def max_participation_rate(self) -> float | None: + return _fault_section_aggregates(self.filter_input)["rate_weighted_mean.sum"].max() + + @strawberry.field( + description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing " + "solutions." + ) + def min_participation_rate(self) -> float | None: + return _fault_section_aggregates(self.filter_input)["rate_weighted_mean.sum"].min() + + @strawberry.field + def fault_surfaces( + self, + color_scale: ColorScaleArgsInput | None = strawberry.UNSET, + style: GeojsonAreaStyleArgumentsInput | None = strawberry.UNSET, + ) -> JSONString | None: + return _fault_surfaces_geojson(self.filter_input, _legacy_color_scale_args(color_scale), _v(style)) + + @strawberry.field + def fault_traces( + self, + color_scale: ColorScaleArgsInput | None = strawberry.UNSET, + style: GeojsonLineStyleArgumentsInput | None = strawberry.UNSET, + ) -> JSONString | None: + return _fault_traces_geojson(self.filter_input, _legacy_color_scale_args(color_scale), _v(style)) + + @strawberry.field(description="magnitude frequency distribution of the filtered rutpures.") + def mfd_histogram(self) -> list[MagFreqDist | None] | None: + return [ + MagFreqDist(bin_center=r.bin_center, rate=r.rate, cumulative_rate=r.cumulative_rate) + for r in _mfd_histogram_rows(self.filter_input) + ] + + @strawberry.field + def color_scale( + self, + name: str | None = strawberry.UNSET, + normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, + min_value: float | None = strawberry.UNSET, + max_value: float | None = strawberry.UNSET, + ) -> ColorScale | None: + f = self.filter_input + # legacy default: a falsy min/max falls back to the participation-rate extremes + vmin = _v(min_value) or _fault_section_aggregates(f)["rate_weighted_mean.sum"].min() + vmax = _v(max_value) or _fault_section_aggregates(f)["rate_weighted_mean.sum"].max() + norm = "log" if normalization is None or normalization is strawberry.UNSET else normalization.value + cs = compute_colour_scale(color_scale=_v(name), color_scale_normalise=norm, vmax=vmax, vmin=vmin) + return _to_strawberry_color_scale(cs) + + +@strawberry.type +class RadiiSet: + radii_set_id: int | None = strawberry.field(default=None, description="The unique radii_set_id") + radii: list[int | None] | None = strawberry.field( + default=None, description="list of dimension in metres defined by the radii set." ) - def resolve_composite_solution(root, info, model_id, **args): - log.info(f"resolve_composite_solution model_id: {model_id}") - solution = cached.get_composite_solution(model_id) - return CompositeSolution(model_id=model_id, fault_systems=solution._solutions.keys()) - composite_rupture_detail = graphene.Field( - CompositeRuptureDetail, - filter=graphene.Argument(CompositeRuptureDetailArgs, required=True), +@strawberry.type +class Location: + location_id: str | None = strawberry.field(default=None, description="unique location location_id.") + name: str | None = strawberry.field(default=None, description="location name.") + latitude: float | None = strawberry.field(default=None, description="location latitude.") + longitude: float | None = strawberry.field(default=None, description="location longitude") + + +@strawberry.type +class LocationList: + list_id: str | None = strawberry.field(default=None, description="The unique location_list_id") + location_ids: list[str | None] | None = strawberry.field(default=None, description="list of location codes.") + + @strawberry.field(description="the locations in this list.") + def locations(self) -> list[Location | None] | None: + out: list[Location | None] = [] + for loc_id in self.location_ids or []: + loc = location_by_id(loc_id) if loc_id else None + if loc: + out.append( + Location( + location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] + ) + ) + return out + + +# --------------------------------------------------------------------------- compute adapters + + +def _to_strawberry_color_scale(cs) -> ColorScale: + norm = {"log": ColourScaleNormaliseEnum.LOG, "lin": ColourScaleNormaliseEnum.LIN}.get(cs.normalisation) + levels = cs.levels + hexrgbs = cs.hexrgbs + return ColorScale( + name=cs.name, + min_value=cs.min_value, + max_value=cs.max_value, + normalisation=norm, + color_map=HexRgbValueMapping(levels=list(levels), hexrgbs=list(hexrgbs)), ) - def resolve_composite_rupture_detail(root, info, filter, **args): - log.info(f"resolve_composite_rupture_detail filter:{filter}") - model_id = filter["model_id"].strip() - fault_system = filter["fault_system"] - rupture_index = filter["rupture_index"] - return CompositeRuptureDetail( - model_id=model_id, - fault_system=fault_system, - rupture_index=rupture_index, - ) - filter_ruptures = graphene.ConnectionField( - RuptureDetailConnection, - filter=graphene.Argument(FilterRupturesArgsInput, required=True), - sortby=graphene.Argument(graphene.List(SimpleSortRupturesArgs), default_value=[]), +def _v(value): + """strawberry.UNSET -> None (the legacy graphene inputs use None for absent optionals).""" + return None if value is strawberry.UNSET else value + + +def _matched_rupture_sections(f: "FilterRupturesArgsInput"): + """graphene-free wrapper over cached.matched_rupture_sections_gdf using the strawberry input.""" + return cached.matched_rupture_sections_gdf( + f.model_id, + f.fault_system, + tuple(f.location_ids or []), + _v(f.radius_km), + min_rate=_v(f.minimum_rate) or 1e-20, + max_rate=_v(f.maximum_rate), + min_mag=_v(f.minimum_mag), + max_mag=_v(f.maximum_mag), + filter_set_options=frozenset(_fso_dict(f.filter_set_options).items()), + corupture_fault_names=tuple(f.corupture_fault_names or []), ) - def resolve_filter_ruptures(root, info, filter, sortby, **kwargs): - log.debug(f"resolve_filter_ruptures() filter: {filter}, sortby: {sortby}, kwargs: {kwargs}") - return paginated_filtered_ruptures(filter, sortby, **kwargs) - filter_rupture_sections = graphene.Field( - CompositeRuptureSections, - filter=graphene.Argument(FilterRupturesArgsInput, required=True), +def _fault_section_aggregates(f: "FilterRupturesArgsInput", trace_only=False): + """graphene-free port of composite_rupture_sections.get_fault_section_aggregates.""" + return cached.fault_section_aggregates_gdf( + f.model_id, + f.fault_system, + tuple(f.location_ids or []), + _v(f.radius_km), + min_rate=_v(f.minimum_rate) or 1e-20, + max_rate=_v(f.maximum_rate), + min_mag=_v(f.minimum_mag), + max_mag=_v(f.maximum_mag), + filter_set_options=frozenset(_fso_dict(f.filter_set_options).items()), + trace_only=trace_only, + corupture_fault_names=tuple(f.corupture_fault_names or []), ) - def resolve_filter_rupture_sections(root, info, filter, **kwargs): - log.debug(f"resolve_filter_rupture_sections() filter: {filter}, kwargs: {kwargs}") - return CompositeRuptureSections( - model_id=filter.get("model_id"), - filter_arguments=FilterRupturesArgs(**filter), + +# columns dropped from both fault_surfaces and fault_traces geojson output +_SECTION_GEOJSON_DROP_COLUMNS = [ + "rate_weighted_mean.max", + "rate_weighted_mean.min", + "rate_weighted_mean.mean", + "Target Slip Rate", + "Target Slip Rate StdDev", +] + + +def _mfd_histogram_rows(f: "FilterRupturesArgsInput"): + """graphene-free port of CompositeRuptureSections.resolve_mfd_histogram (build_mfd).""" + import pandas as pd + + df0 = _matched_rupture_sections(f) + bins = [round(x / 100, 2) for x in range(500, 1000, 10)] + df = pd.DataFrame({"rate": df0["rate_weighted_mean"], "magnitude": df0["Magnitude"]}) + df["bins"] = pd.cut(df["magnitude"], bins=bins) + df["bin_center"] = df["bins"].apply(lambda x: x.mid) + df = df.drop(columns=["magnitude"]) + df = pd.DataFrame(df.groupby(df.bin_center, observed=False).sum(numeric_only=True)) + df["cumulative_rate"] = df.loc[::-1, "rate"].cumsum()[::-1] + df = df.reset_index() + df.bin_center = pd.to_numeric(df.bin_center) + df = df[df.bin_center.between(6.8, 9.8)] + return list(df.itertuples()) + + +def _fault_surfaces_geojson(f, color_scale_args, style_args): + """graphene-free port of CompositeRuptureSections.resolve_fault_surfaces.""" + gdf = _fault_section_aggregates(f) + if color_scale_args: + color_values = get_colour_values( + color_scale=color_scale_args.name, + color_scale_vmax=color_scale_args.max_value or gdf["rate_weighted_mean.sum"].max(), + color_scale_vmin=color_scale_args.min_value or gdf["rate_weighted_mean.sum"].min(), + color_scale_normalise=color_scale_args.normalisation or "log", + values=tuple(gdf["rate_weighted_mean.sum"].tolist()), + ) + else: + color_values = None + + if style_args or color_scale_args: + gdf["fill"] = color_values or style_args.fill_color + gdf["fill-opacity"] = style_args.fill_opacity or 0.5 + gdf["stroke"] = color_values or style_args.stroke_color + gdf["stroke-width"] = style_args.stroke_width or 1 + gdf["stroke-opacity"] = style_args.stroke_opacity or 1 + + gdf = gdf.drop(columns=_SECTION_GEOJSON_DROP_COLUMNS) + return json.loads(gdf.to_json()) + + +def _fault_traces_geojson(f, color_scale_args, style_args): + """graphene-free port of CompositeRuptureSections.resolve_fault_traces.""" + gdf = _fault_section_aggregates(f, trace_only=True) + color_values = None + if color_scale_args: + color_values = get_colour_values( + color_scale=color_scale_args.name, + color_scale_vmax=color_scale_args.max_value or gdf["rate_weighted_mean.sum"].max(), + color_scale_vmin=color_scale_args.min_value or gdf["rate_weighted_mean.sum"].min(), + color_scale_normalise=color_scale_args.normalisation or "log", + values=tuple(gdf["rate_weighted_mean.sum"].tolist()), ) - # solution_fault_names - get_parent_fault_names = graphene.Field( - graphene.List(graphene.String), - model_id=graphene.Argument( - graphene.String, - required=True, - description="A valid NSHM model id e.g. `NSHM_1.0.0`", - ), - fault_system=graphene.Argument(graphene.String, required=True, description="A valid FSS name CRU, PUY, HIK"), + if style_args or color_scale_args: + gdf["stroke"] = color_values if color_scale_args else style_args.stroke_color + gdf["stroke-width"] = style_args.stroke_width if style_args else 1 + gdf["stroke-opacity"] = style_args.stroke_opacity if style_args else 1 + + gdf = gdf.drop(columns=_SECTION_GEOJSON_DROP_COLUMNS) + return json.loads(gdf.to_json()) + + +def _paginated_ruptures(f: "FilterRupturesArgsInput", sortby_args, first, after): + """graphene-free port of paginated_filtered_ruptures + build_ruptures_connection. + + Returns plain data: (list of (rupture_index, cursor), total_count, end_cursor, has_next_page). + """ + min_rate = _v(f.minimum_rate) or 1e-20 + gdf = _matched_rupture_sections(f) + if sortby_args: + gdf = auto_sorted_dataframe(gdf, sortby_args, min_rate) + cursor_offset = int(graphql_relay.from_global_id(after)[1]) + 1 if after else 0 + rupture_ids = list(gdf["Rupture Index"]) + seeds = [ + (int(rid), graphql_relay.to_global_id("RuptureDetailConnectionCursor", str(cursor_offset + idx))) + for idx, rid in enumerate(rupture_ids[cursor_offset : cursor_offset + first]) + ] + total = len(rupture_ids) + end_cursor = seeds[-1][1] if seeds else None + has_next = (total > 1 + int(graphql_relay.from_global_id(seeds[-1][1])[1])) if seeds else False + return seeds, total, end_cursor, has_next + + +def _fso_dict(fso) -> dict: + if not fso: + return {} + + def val(x): + return x.value if x is not None else None + + return { + "multiple_locations": val(fso.multiple_locations), + "multiple_faults": val(fso.multiple_faults), + "locations_and_faults": val(fso.locations_and_faults), + } + + +def _legacy_color_scale_args(cs): + """A namespace the legacy section resolvers can read, with `normalisation` as its string + value (`log`/`lin`) rather than the strawberry enum member.""" + if cs is None or cs is strawberry.UNSET: + return None + return SimpleNamespace( + name=cs.name, + min_value=_v(cs.min_value), + max_value=_v(cs.max_value), + normalisation=(cs.normalisation.value if cs.normalisation else None), ) - def resolve_get_parent_fault_names(root, info, model_id, fault_system, **args): - log.info(f"resolve_get_parent_fault_names filter:{model_id}") - composite_solution = get_composite_solution(model_id) - fss = composite_solution._solutions[fault_system] - return parent_fault_names(fss) - - # radii fields - get_radii_set = graphene.Field( - RadiiSet, - radii_set_id=graphene.Argument( - graphene.Int, - required=True, - description="the integer ID for the desired radii_set", - ), - description="Return ad single radii_set for the id passed in", - ) - get_radii_sets = graphene.Field(graphene.List(RadiiSet), description="Return all the available radii_set") - # location fields - get_location = graphene.Field( - Location, - location_id=graphene.Argument( - graphene.String, - required=True, - description="the location code of the desired location", - ), - description="Return a single location.", +def _rupture_fault_surfaces(model_id, fault_system, rupture_index, style): + composite_solution = cached.get_composite_solution(model_id) + gdf = composite_solution._solutions[fault_system].rupture_surface(rupture_index) + gdf = gdf.drop( + columns=[ + "key_0", + "fault_system", + "Rupture Index", + "rate_max", + "rate_min", + "rate_count", + "rate_weighted_mean", + "Magnitude", + "Average Rake (degrees)", + "Area (m^2)", + "Length (m)", + ] ) - get_locations = graphene.Field(graphene.List(Location), description="Return all the available locations") + return apply_geojson_style(json.loads(gdf.to_json(indent=2)), style) if gdf is not None else None - get_location_list = graphene.Field( - LocationList, - list_id=graphene.Argument( - graphene.String, - required=True, - description="the id of the desired location_list", - ), - description="Return a single location list.", - ) - get_location_lists = graphene.Field( - graphene.List(LocationList), - description="Return all the available location lists", - ) +_DEFAULT_SORTBY: list = [] # module-level so it renders `= []` without a B006 mutable-default warning - def resolve_get_location(root, info, location_id, **args): - log.info(f"resolve_get_location args: {args} location_id:{location_id}") - return get_one_location(location_id) +# --------------------------------------------------------------------------- query root - def resolve_get_locations(root, info, **args): - log.info(f"resolve_get_locations args: {args}") - return [Location(loc["id"], loc["name"], loc["latitude"], loc["longitude"]) for loc in LOCATIONS] - def resolve_get_location_list(root, info, list_id, **args): - log.info(f"resolve_get_location args: {args} list_id:{list_id}") - return get_one_location_list(list_id) +@strawberry.type(description="This is the entry point for solvis graphql query operations") +class QueryRoot: + @strawberry.field + def color_scale( + self, + name: str | None = strawberry.UNSET, + min_value: float | None = strawberry.UNSET, + max_value: float | None = strawberry.UNSET, + normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, + ) -> ColorScale | None: + cs = compute_colour_scale( + color_scale=name or None, + color_scale_normalise=normalization.value if normalization else None, + vmax=max_value or None, + vmin=min_value or None, + ) + return _to_strawberry_color_scale(cs) + + @strawberry.field + def node( + self, id: Annotated[strawberry.ID, strawberry.argument(description="The ID of the object")] + ) -> Node | None: + # parity: the legacy graphene schema defines no `get_node`, so `node(id)` always + # resolves to null (verified differentially) — nothing to dispatch. + return None + + @strawberry.field(description="About this Solvis API ") + def about(self) -> str | None: + return f"Hello World, I am solvis_graphql_api! Version: {solvis_graphql_api.__version__}" - def resolve_get_location_lists(root, info, **args): - log.info(f"resolve_get_location_lists args: {args}") - return [LocationList(key, ll["locations"]) for key, ll in LOCATION_LISTS.items()] + @strawberry.field + def locations_by_id( + self, + location_ids: Annotated[ + list[str | None], + strawberry.argument(description='list of nzshm_common.location_ids e.g. `["WLG","PMR","ZQN"]`'), + ], + ) -> LocationDetailConnection | None: + locs = [location_by_id(lid) for lid in (location_ids or [])] + edges: list[LocationDetailEdge | None] = [ + LocationDetailEdge( + node=LocationDetail( + location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] + ), + cursor=graphql_relay.offset_to_cursor(i), # matches graphene relay's array cursors + ) + for i, loc in enumerate(locs) + ] + return LocationDetailConnection( + page_info=PageInfo(has_next_page=False, has_previous_page=False), + edges=edges, + total_count=len(edges), + ) - def resolve_get_radii_set(root, info, radii_set_id, **args): - log.info(f"resolve_get_radii_set args: {args} radii_set_id:{radii_set_id}") - return get_one_radii_set(radii_set_id) + @strawberry.field + def composite_solution( + self, + model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], + ) -> CompositeSolution | None: + solution = cached.get_composite_solution(model_id) + return CompositeSolution(model_id=model_id, fault_systems=list(solution._solutions.keys())) - def resolve_get_radii_sets(root, info, **args): - log.info(f"resolve_get_radii_sets args: {args}") - return [RadiiSet(rad["id"], rad["radii"]) for rad in RADII] + @strawberry.field + def composite_rupture_detail(self, filter: CompositeRuptureDetailArgs) -> CompositeRuptureDetail | None: + return CompositeRuptureDetail( + model_id=filter.model_id.strip() if filter.model_id else filter.model_id, + fault_system=filter.fault_system, + rupture_index=filter.rupture_index, + ) + @strawberry.field + def filter_ruptures( + self, + filter: FilterRupturesArgsInput, + sortby: list[SimpleSortRupturesArgs | None] | None = _DEFAULT_SORTBY, + before: str | None = strawberry.UNSET, + after: str | None = strawberry.UNSET, + first: int | None = strawberry.UNSET, + last: int | None = strawberry.UNSET, + ) -> RuptureDetailConnection | None: + # build plain dicts mirroring the legacy graphene sortby items: an UNSET field is + # *absent* (not a key with UNSET value), so _auto_sorted's `.get("ascending", True)` + # defaults correctly. + sortby_args = [] + for s in sortby or []: + if s is None: + continue + item: dict[str, Any] = {} + if s.attribute is not strawberry.UNSET: + item["attribute"] = s.attribute + if s.ascending is not strawberry.UNSET: + item["ascending"] = s.ascending + sortby_args.append(item) + seeds, total, end_cursor, has_next = _paginated_ruptures( + filter, + sortby_args, + first=(first if first is not strawberry.UNSET else 5), + after=(after if after is not strawberry.UNSET else None), + ) + edges: list[RuptureDetailEdge | None] = [ + RuptureDetailEdge( + node=CompositeRuptureDetail( + model_id=filter.model_id, fault_system=filter.fault_system, rupture_index=rid + ), + cursor=cursor, + ) + for rid, cursor in seeds + ] + return RuptureDetailConnection( + page_info=PageInfo( + has_next_page=has_next, has_previous_page=False, start_cursor=None, end_cursor=end_cursor + ), + edges=edges, + total_count=total, + ) -schema_root = graphene.Schema(query=QueryRoot, mutation=None, auto_camelcase=False) + @strawberry.field + def filter_rupture_sections(self, filter: FilterRupturesArgsInput) -> CompositeRuptureSections | None: + return CompositeRuptureSections(model_id=filter.model_id, filter_input=filter) + + @strawberry.field + def get_parent_fault_names( + self, + model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], + fault_system: Annotated[str, strawberry.argument(description="A valid FSS name CRU, PUY, HIK")], + ) -> list[str | None] | None: + composite_solution = cached.get_composite_solution(model_id) + fss = composite_solution._solutions[fault_system] + return list(cached.parent_fault_names(fss)) + + @strawberry.field(description="Return ad single radii_set for the id passed in") + def get_radii_set( + self, + radii_set_id: Annotated[int, strawberry.argument(description="the integer ID for the desired radii_set")], + ) -> RadiiSet | None: + for rad in RADII: + if rad["id"] == radii_set_id: + return RadiiSet(radii_set_id=radii_set_id, radii=[int(r) for r in rad["radii"]]) + raise IndexError(f"Radii set with id {radii_set_id} was not found.") + + @strawberry.field(description="Return all the available radii_set") + def get_radii_sets(self) -> list[RadiiSet | None] | None: + return [RadiiSet(radii_set_id=r["id"], radii=[int(x) for x in r["radii"]]) for r in RADII] + + @strawberry.field(description="Return a single location.") + def get_location( + self, + location_id: Annotated[str, strawberry.argument(description="the location code of the desired location")], + ) -> Location | None: + for loc in LOCATIONS: + if loc["id"] == location_id: + return Location( + location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] + ) + raise IndexError(f"Location with id {location_id} was not found.") + + @strawberry.field(description="Return all the available locations") + def get_locations(self) -> list[Location | None] | None: + return [ + Location(location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"]) + for loc in LOCATIONS + ] + + @strawberry.field(description="Return a single location list.") + def get_location_list( + self, + list_id: Annotated[str, strawberry.argument(description="the id of the desired location_list")], + ) -> LocationList | None: + ll = LOCATION_LISTS.get(list_id) + if ll: + return LocationList(list_id=list_id, location_ids=ll["locations"]) + raise IndexError(f"LocationList with id {list_id} was not found.") + + @strawberry.field(description="Return all the available location lists") + def get_location_lists(self) -> list[LocationList | None] | None: + return [LocationList(list_id=key, location_ids=v["locations"]) for key, v in LOCATION_LISTS.items()] + + +schema = strawberry.Schema( + query=QueryRoot, + types=[LocationDetail, CompositeRuptureDetail], + config=StrawberryConfig(auto_camel_case=False), +) diff --git a/solvis_graphql_api/solution_schema.py b/solvis_graphql_api/solution_schema.py deleted file mode 100644 index fb46c62..0000000 --- a/solvis_graphql_api/solution_schema.py +++ /dev/null @@ -1,166 +0,0 @@ -"""The main API schema.""" - -import logging - -# from solvis_store.solvis_db_query import matched_rupture_sections_gdf - -log = logging.getLogger(__name__) - -FAULT_SECTION_LIMIT = 1e4 - - -# @lru_cache -# def get_location_polygon(radius_km, lon, lat): -# return solvis.geometry.circle_polygon(radius_m=radius_km * 1000, lon=lon, lat=lat) - - -# def location_features( -# locations: Tuple[str], radius_km: int, style: Dict -# ) -> Iterator[Dict]: -# for loc in locations: -# log.debug(f"LOC {loc}") -# item = location_by_id(loc) -# # polygon = solvis.circle_polygon(radius_km * 1000, lat=item.get('latitude'), lon=item.get('longitude')) -# polygon = get_location_polygon( -# radius_km, lat=item.get("latitude"), lon=item.get("longitude") -# ) -# feature = dict( -# id=loc, -# type="Feature", -# geometry=shapely.geometry.mapping(polygon), -# properties={ -# "title": item.get("name"), -# "stroke-color": style.get("stroke_color"), -# "stroke-opacity": style.get("stroke_opacity"), -# "stroke-width": style.get("stroke_width"), -# "fill-color": style.get("fill_color"), -# "fill-opacity": style.get("fill_opacity"), -# }, -# ) -# yield feature - - -# def location_features_geojson( -# locations: Tuple[str], radius_km: int, style: Dict -# ) -> Dict: -# return dict( -# type="FeatureCollection", -# features=list(location_features(locations, radius_km, style)), -# ) - - -# # class InversionSolutionRupture(graphene.ObjectType): -# # fault_id = graphene.Int(description="Unique ID of the rupture within this solution") -# # magnitude = graphene.Float(description='rupture magnitude') - -# # class InversionSolutionFaultSection(graphene.ObjectType): -# # fault_id = graphene.String(description="Unique ID of the fault section eg WHV1") - - -# class InversionSolutionAnalysis(graphene.ObjectType): -# """Represents the internal details of a given solution or filtered solution""" - -# solution_id = graphene.ID() -# fault_sections_geojson = graphene.JSONString() -# location_geojson = graphene.JSONString() - - -# class InversionSolutionAnalysisArguments(graphene.InputObjectType): -# """Defines filter arguments for Inversions analysis""" - -# solution_id = graphene.ID( -# required=True, description="The ID of the InversionSolution" -# ) -# location_ids = graphene.List( -# graphene.String, -# required=False, -# default_value=[], -# description="Optional list of locations codes for proximity filtering e.g. `WLG,PMR,ZQN`", -# ) -# radius_km = graphene.Int( -# required=False, description="The rupture/location intersection radius in km" -# ) -# minimum_rate = graphene.Float( -# required=False, -# description="Constrain to fault_sections having a annual rate above the value supplied.", -# ) -# maximum_rate = graphene.Float( -# required=False, -# description="Constrain to fault_sections having a annual rate below the value supplied.", -# ) -# minimum_mag = graphene.Float( -# required=False, -# description="Constrain to fault_sections having a magnitude above the value supplied.", -# ) -# maximum_mag = graphene.Float( -# required=False, -# description="Constrain to fault_sections having a magnitude below the value supplied.", -# ) - -# fault_trace_style = GeojsonLineStyleArgumentsInput( -# required=False, -# description="feature style for rupture trace geojson.", -# default_value=dict(stroke_color="black", stroke_width=1, stroke_opacity=1.0), -# ) - -# location_area_style = GeojsonAreaStyleArgumentsInput( -# required=False, -# description="feature style for location polygons.", -# default_value=dict( -# stroke_color="lightblue", -# stroke_width=1, -# stroke_opacity=1.0, -# fill_color="lightblue", -# fill_opacity=0.7, -# ), -# ) - - -# class FilterInversionSolution(graphene.ObjectType): -# analysis = graphene.Field(InversionSolutionAnalysis) - - -# def get_inversion_solution(input, **args): -# log.info("analyse_solution args: %s input:%s" % (args, input)) -# rupture_sections_gdf = matched_rupture_sections_gdf( # noqa -# input["solution_id"], -# ",".join(input["location_ids"]), # convert to string -# input["radius_km"] * 1000, -# min_rate=input.get("minimum_rate") or 1e-20, -# max_rate=input.get("maximum_rate"), -# min_mag=input.get("minimum_mag"), -# max_mag=input.get("maximum_mag"), -# ) -# section_count = ( -# rupture_sections_gdf.shape[0] if rupture_sections_gdf is not None else 0 -# ) -# log.info( -# "rupture_sections_gdf %s has %s sections" -# % (rupture_sections_gdf, section_count) -# ) - -# if section_count > FAULT_SECTION_LIMIT: -# raise ValueError( -# "Too many fault sections satisfy the filter, please try more selective values." -# ) -# elif section_count == 0: -# raise ValueError("No fault sections satisfy the filter.") - -# print(rupture_sections_gdf) - -# return FilterInversionSolution( -# analysis=InversionSolutionAnalysis( -# solution_id=input["solution_id"], -# fault_sections_geojson=apply_geojson_style( -# geojson=json.loads( -# gpd.GeoDataFrame(rupture_sections_gdf).to_json(indent=2) -# ), -# style=input.get("fault_trace_style"), -# ), -# location_geojson=location_features_geojson( -# tuple(input["location_ids"]), -# input["radius_km"], -# style=input.get("location_area_style"), -# ), -# ) -# ) diff --git a/solvis_graphql_api/solvis_graphql_api.py b/solvis_graphql_api/solvis_graphql_api.py deleted file mode 100644 index 3a7a956..0000000 --- a/solvis_graphql_api/solvis_graphql_api.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging -import logging.config -import os - -try: - import unzip_requirements # noqa this is required by serverless-requirements -except ImportError: - pass - -import yaml -from flask import Flask -from flask_cors import CORS -from graphql_server.flask.graphqlview import GraphQLView - -from solvis_graphql_api.schema import schema_root - -LOGGING_CFG = os.getenv("LOGGING_CFG", "solvis_graphql_api/logging_aws.yaml") -logger = logging.getLogger(__name__) - - -def create_app(): - """Function that creates our Flask application.""" - - """ - Setup logging configuration - ref https://fangpenlin.com/posts/2012/08/26/good-logging-practice-in-python/ - """ - print(f"LOGGING config path: {LOGGING_CFG} ") - if os.path.exists(LOGGING_CFG): # pragma: no cover - with open(LOGGING_CFG) as f: - config = yaml.safe_load(f.read()) - logging.config.dictConfig(config) - logger.info(f"LOGGING config path: {LOGGING_CFG} ") - logger.info(config) - else: # pragma: no cover - print("Warning, no logging config found, using basicConfig(INFO)") - logging.basicConfig(level=logging.INFO) - - # logger.debug('DEBUG logging enabled') - # logger.info('INFO logging enabled') - # logger.warning('WARN logging enabled') - # logger.error('ERROR logging enabled') - - app = Flask(__name__) - CORS(app) - - # app.before_first_request(migrate) - - app.add_url_rule( - "/graphql", - view_func=GraphQLView.as_view( - "graphql", - schema=schema_root, - graphiql=True, - ), - ) - - return app - - -# pragma: no cover -app = create_app() - - -if __name__ == "__main__": - app.run() diff --git a/solvis_graphql_api/strawberry_schema.py b/solvis_graphql_api/strawberry_schema.py deleted file mode 100644 index 1721f47..0000000 --- a/solvis_graphql_api/strawberry_schema.py +++ /dev/null @@ -1,833 +0,0 @@ -"""Strawberry schema — Graphene parity port. - -Reproduces the legacy Graphene SDL (``schema.legacy.graphql``) one-for-one. Type shapes -live here; the heavy compute is reused from the existing Graphene-free helpers -(``composite_solution.cached``, ``color_scale``, ``geojson_style``, ``schema`` pagination). - -Parity traps honoured: -- root type is ``QueryRoot`` (not Strawberry's default ``Query``) -- a **custom** ``Node`` interface (``id: ID!``), NOT ``strawberry.relay`` (which emits ``GlobalID``) -- ``auto_camel_case=False``; relay ``PageInfo``/edge fields keep their camelCase names explicitly -- optional args with no SDL default use ``strawberry.UNSET`` (suppresses ``= null``); only - ``ColorScaleArgsInput.normalisation`` keeps an explicit ``= null`` (``= None``) -""" - -import enum -import json -from types import SimpleNamespace -from typing import TYPE_CHECKING, Annotated, Any, NewType - -import graphql_relay -import solvis.solution.typing -import strawberry -from nzshm_common.location.location import LOCATION_LISTS, LOCATIONS, location_by_id -from strawberry.schema.config import StrawberryConfig - -import solvis_graphql_api -from solvis_graphql_api.color_scale import color_scale as _cs -from solvis_graphql_api.composite_solution import cached -from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail -from solvis_graphql_api.composite_solution.composite_rupture_sections import ( - CompositeRuptureSections as _GrapheneSections, -) -from solvis_graphql_api.composite_solution.filtered_ruptures_args import FilterRupturesArgs as _GrapheneFilterArgs -from solvis_graphql_api.composite_solution.schema import paginated_filtered_ruptures -from solvis_graphql_api.geojson_style import apply_geojson_style -from solvis_graphql_api.location_schema import get_location_detail_list - -RADII: list[dict[str, Any]] = [ - {"id": 1, "radii": [10e3]}, - {"id": 2, "radii": [10e3, 20e3]}, - {"id": 3, "radii": [10e3, 20e3, 30e3]}, - {"id": 4, "radii": [10e3, 20e3, 30e3, 40e3]}, - {"id": 5, "radii": [10e3, 20e3, 30e3, 40e3, 50e3]}, - {"id": 6, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3]}, - {"id": 7, "radii": [10e3, 20e3, 30e3, 40e3, 50e3, 100e3, 200e3]}, -] - -# --------------------------------------------------------------------------- scalars - -if TYPE_CHECKING: - # mypy: treat the custom scalar as an opaque value type (it can't infer a NewType-backed - # strawberry.scalar() assignment as a usable type — the classic `valid-type` gotcha) - JSONString = object -else: - JSONString = strawberry.scalar( - NewType("JSONString", object), - serialize=lambda v: v if isinstance(v, str) else json.dumps(v), - parse_value=lambda v: json.loads(v), - description=( - "Allows use of a JSON String for input / output from the GraphQL schema.\n\n" - "Use of this type is *not recommended* as you lose the benefits of having a defined, static\n" - "schema (one of the key benefits of GraphQL)." - ), - ) - -# --------------------------------------------------------------------------- interfaces - - -@strawberry.interface(description="An object with an ID") -class Node: - # default UNSET so implementing types (which override `id` with a resolver) don't carry - # it as a required __init__ arg; the SDL stays `id: ID!`. - id: strawberry.ID = strawberry.field(default=strawberry.UNSET, description="The ID of the object") - - -# --------------------------------------------------------------------------- enums - - -@strawberry.enum -class ColourScaleNormaliseEnum(enum.Enum): - LOG = "log" - LIN = "lin" - - -if TYPE_CHECKING: - # a concrete stub so mypy treats SetOperationEnum as a usable type even when solvis is - # untyped (ignore_missing_imports); member names match the real solvis enum. - class SetOperationEnum(enum.Enum): - UNION = "UNION" - INTERSECTION = "INTERSECTION" - DIFFERENCE = "DIFFERENCE" - SYMMETRIC_DIFFERENCE = "SYMMETRIC_DIFFERENCE" -else: - SetOperationEnum = strawberry.enum( - solvis.solution.typing.SetOperationEnum, description=solvis.solution.typing.SetOperationEnum.__doc__ - ) - -# --------------------------------------------------------------------------- input types - - -@strawberry.input(description="Defines styling arguments for geojson features") -class GeojsonAreaStyleArgumentsInput: - stroke_color: str | None = strawberry.field( - default="green", - description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', - ) - stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") - stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") - fill_color: str | None = strawberry.field( - default="green", - description='fill colour as Hex code ("#cc0000") or HTML color names ("royalblue") )', - ) - fill_opacity: float | None = strawberry.field(default=1.0, description="0-1.0") - - -@strawberry.input(description="Defines styling arguments for geojson features") -class GeojsonLineStyleArgumentsInput: - stroke_color: str | None = strawberry.field( - default="green", - description='stroke (line) colour as hex code ("#cc0000") or HTML color name ("royalblue")', - ) - stroke_width: int | None = strawberry.field(default=1, description="a number between 0 and 20.") - stroke_opacity: float | None = strawberry.field(default=1.0, description="a number between 0 and 1.0") - - -@strawberry.input(description="Arguments passed as ColorScaleArgsInput") -class ColorScaleArgsInput: - name: str | None = "inferno" - min_value: float | None = strawberry.UNSET - max_value: float | None = strawberry.UNSET - normalisation: ColourScaleNormaliseEnum | None = None - - -@strawberry.input -class FilterSetLogicOptionsInput: - multiple_locations: SetOperationEnum | None = SetOperationEnum.INTERSECTION - multiple_faults: SetOperationEnum | None = SetOperationEnum.UNION - locations_and_faults: SetOperationEnum | None = SetOperationEnum.INTERSECTION - - -@strawberry.input -class CompositeRuptureDetailArgs: - model_id: str | None = strawberry.UNSET - fault_system: str | None = strawberry.field( - default=strawberry.UNSET, description="Unique ID of the fault system e.g. `PUY`" - ) - rupture_index: int | None = strawberry.UNSET - - -@strawberry.input -class SimpleSortRupturesArgs: - attribute: str | None = strawberry.UNSET - ascending: bool | None = strawberry.UNSET - - -@strawberry.input(description="Arguments passed as FilterRupturesArgs") -class FilterRupturesArgsInput: - model_id: str = strawberry.field(description="The ID of NSHM model") - fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") - corupture_fault_names: list[str | None] | None = strawberry.field( - default_factory=list, - description="Optional list of parent fault names. Result will only include ruptures that include parent " - "fault sections", - ) - location_ids: list[str | None] | None = strawberry.field( - default_factory=list, - description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", - ) - radius_km: int | None = strawberry.field( - default=strawberry.UNSET, description="The rupture/location intersection radius in km" - ) - filter_set_options: FilterSetLogicOptionsInput | None = strawberry.field( - # a plain mapping (not an input instance) so the SDL default renders AND graphql-core - # coerces it at execution — an instance default trips strawberry's argument coercion - default_factory=lambda: { - "multiple_locations": SetOperationEnum.INTERSECTION, - "multiple_faults": SetOperationEnum.UNION, - "locations_and_faults": SetOperationEnum.INTERSECTION, - } - ) - minimum_rate: float | None = strawberry.field( - default=strawberry.UNSET, - description="Constrain to fault_sections having a annual rate above the value supplied.", - ) - maximum_rate: float | None = strawberry.field( - default=strawberry.UNSET, - description="Constrain to fault_sections having a annual rate below the value supplied.", - ) - minimum_mag: float | None = strawberry.field( - default=strawberry.UNSET, - description="Constrain to fault_sections having a magnitude above the value supplied.", - ) - maximum_mag: float | None = strawberry.field( - default=strawberry.UNSET, - description="Constrain to fault_sections having a magnitude below the value supplied.", - ) - - -# the legacy arg default is a *partial* 3-key style ({stroke_color, stroke_width, stroke_opacity}), -# expressed as a plain mapping (as graphene did) so graphql-core renders it in the SDL AND coerces -# it cleanly at execution when the arg is omitted (an input *instance* default trips coercion). -_AREA_STYLE_ARG_DEFAULT: Any = {"stroke_color": "black", "stroke_width": 1, "stroke_opacity": 1} - - -def _style_dict(style) -> dict: - if style is None: - return {} - d = style if isinstance(style, dict) else strawberry.asdict(style) - return {k: v for k, v in d.items() if v is not strawberry.UNSET} - - -# --------------------------------------------------------------------------- output types - - -@strawberry.type -class HexRgbValueMapping: - levels: list[float | None] | None = None - hexrgbs: list[str | None] | None = None - - -@strawberry.type -class ColorScale: - name: str | None = None - min_value: float | None = None - max_value: float | None = None - normalisation: ColourScaleNormaliseEnum | None = None - color_map: HexRgbValueMapping | None = None - - -@strawberry.type( - description="The Relay compliant `PageInfo` type, containing data necessary to paginate this connection." -) -class PageInfo: - has_next_page: bool = strawberry.field( - name="hasNextPage", description="When paginating forwards, are there more items?" - ) - has_previous_page: bool = strawberry.field( - name="hasPreviousPage", description="When paginating backwards, are there more items?" - ) - start_cursor: str | None = strawberry.field( - name="startCursor", default=None, description="When paginating backwards, the cursor to continue." - ) - end_cursor: str | None = strawberry.field( - name="endCursor", default=None, description="When paginating forwards, the cursor to continue." - ) - - -@strawberry.type(description="Represents the internal details of a given location") -class LocationDetail(Node): - location_id: str | None = None - name: str | None = None - latitude: float | None = None - longitude: float | None = None - - @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field - def id(self) -> strawberry.ID: - # graphene relay encodes the node id as base64("LocationDetail:") — reproduce it - return strawberry.ID(graphql_relay.to_global_id("LocationDetail", self.location_id or "")) - - @strawberry.field - def radius_geojson( - self, - radius_km: Annotated[int, strawberry.argument(description="polygon radius (km).")], - style: Annotated[ - GeojsonAreaStyleArgumentsInput | None, - strawberry.argument(description="feature style for the geojson."), - ] = _AREA_STYLE_ARG_DEFAULT, - ) -> JSONString | None: - import shapely - - polygon = cached.get_location_polygon(radius_km, lat=self.latitude, lon=self.longitude) - features = dict( - features=[dict(id=self.location_id, type="Feature", geometry=shapely.geometry.mapping(polygon))] - ) - return apply_geojson_style(features, _style_dict(style)) - - -@strawberry.type(description="A Relay edge containing a `LocationDetail` and its cursor.") -class LocationDetailEdge: - node: LocationDetail | None = strawberry.field(description="The item at the end of the edge", default=None) - cursor: str = strawberry.field(description="A cursor for use in pagination") - - -@strawberry.type -class LocationDetailConnection: - page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") - edges: list[LocationDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") - total_count: int | None = None - - -@strawberry.type(description="A complete NSHM model comprising at least one FaultSystemSolution") -class CompositeSolution: - model_id: str | None = None - fault_systems: list[str | None] | None = None - file_url: str | None = strawberry.field(default=None, description="get a URL so one can download the file") - - -@strawberry.type -class CompositeRuptureDetail(Node): - model_id: str | None = None - fault_system: str | None = strawberry.field(default=None, description="Unique ID of the fault system e.g. `PUY`") - rupture_index: int | None = None - fault_traces: JSONString | None = None - - def _rupt(self): - return rupture_detail(self.model_id, self.fault_system, self.rupture_index) - - @strawberry.field(description="The ID of the object") # type: ignore[misc] # resolver overrides Node.id field - def id(self) -> strawberry.ID: - gid = graphql_relay.to_global_id("CompositeRuptureDetail", f"{self.fault_system}:{self.rupture_index}") - return strawberry.ID(gid) - - @strawberry.field - def magnitude(self) -> float | None: - return round(float(self._rupt()["Magnitude"].iloc[0]), 3) - - @strawberry.field(description="Rupture length in kilometres^2") - def area(self) -> float | None: - return round(float(self._rupt()["Area (m^2)"].iloc[0] / 1e6), 0) - - @strawberry.field(description="Rupture length in kilometres)") - def length(self) -> float | None: - return round(float(self._rupt()["Length (m)"].iloc[0] / 1e3), 0) - - @strawberry.field(description="average rake angle (degrees) of the entire rupture") - def rake_mean(self) -> float | None: - return round(float(self._rupt()["Average Rake (degrees)"].iloc[0]), 1) - - @strawberry.field(description="mean of `rate` * `branch weight` of the contributing solutions") - def rate_weighted_mean(self) -> float | None: - return float(self._rupt()["rate_weighted_mean"].iloc[0]) - - @strawberry.field(description="maximum rate from contributing solutions") - def rate_max(self) -> float | None: - return float(self._rupt()["rate_max"].iloc[0]) - - @strawberry.field(description="minimum rate from contributing solutions") - def rate_min(self) -> float | None: - return float(self._rupt()["rate_min"].iloc[0]) - - @strawberry.field(description="count of model solutions that include this rupture") - def rate_count(self) -> int | None: - return int(self._rupt()["rate_count"].iloc[0]) - - @strawberry.field - def fault_surfaces( - self, - style: Annotated[ - GeojsonAreaStyleArgumentsInput | None, - strawberry.argument(description="feature style for rupture trace geojson."), - ] = _AREA_STYLE_ARG_DEFAULT, - ) -> JSONString | None: - return _rupture_fault_surfaces(self.model_id, self.fault_system, self.rupture_index, _style_dict(style)) - - -@strawberry.type(description="A Relay edge containing a `RuptureDetail` and its cursor.") -class RuptureDetailEdge: - node: CompositeRuptureDetail | None = strawberry.field( - description="The item at the end of the edge", default=None - ) - cursor: str = strawberry.field(description="A cursor for use in pagination") - - -@strawberry.type -class RuptureDetailConnection: - page_info: PageInfo = strawberry.field(name="pageInfo", description="Pagination data for this connection.") - edges: list[RuptureDetailEdge | None] = strawberry.field(description="Contains the nodes in this connection.") - total_count: int | None = None - - -@strawberry.type -class FilterSetLogicOptions: - multiple_locations: SetOperationEnum | None = None - multiple_faults: SetOperationEnum | None = None - locations_and_faults: SetOperationEnum | None = None - - -@strawberry.type(description="Arguments FilterRupturesArgs") -class FilterRupturesArgs: - model_id: str = strawberry.field(description="The ID of NSHM model") - fault_system: str = strawberry.field(description="The fault systems [`HIK`, `PUY`, `CRU`]") - corupture_fault_names: list[str | None] | None = strawberry.field( - default=None, - description="Optional list of parent fault names. Result will only include ruptures that include parent " - "fault sections", - ) - location_ids: list[str | None] | None = strawberry.field( - default=None, - description="Optional list of locations ids for proximity filtering e.g. `WLG,PMR,ZQN`", - ) - radius_km: int | None = strawberry.field( - default=None, description="The rupture/location intersection radius in km" - ) - filter_set_options: FilterSetLogicOptions | None = None - minimum_rate: float | None = strawberry.field( - default=None, description="Constrain to fault_sections having a annual rate above the value supplied." - ) - maximum_rate: float | None = strawberry.field( - default=None, description="Constrain to fault_sections having a annual rate below the value supplied." - ) - minimum_mag: float | None = strawberry.field( - default=None, description="Constrain to fault_sections having a magnitude above the value supplied." - ) - maximum_mag: float | None = strawberry.field( - default=None, description="Constrain to fault_sections having a magnitude below the value supplied." - ) - - -@strawberry.type -class MagFreqDist: - bin_center: float | None = None - rate: float | None = None - cumulative_rate: float | None = None - - -@strawberry.type( - description=( - "A collection of ruptures and their fault sections that have a geojson represention. They also\n" - "have a set of attributes derived from the composite solution e.g. rate_weighted_mean etc\n\n" - "Key attributes:\n" - " - filter_arguments contains the filter criteria used to find the ruptures.\n" - " - fault_surfaces is a geojson feature file based on the geometry from the undelying rutpure set.\n" - " It may by styled by some attribute of the faults section.\n" - " - mfd_histogram is the MFD table summarise the set of ruptures." - ) -) -class CompositeRuptureSections: - model_id: str | None = None - rupture_count: int | None = None - filter_arguments: FilterRupturesArgs | None = None - # the legacy graphene CompositeRuptureSections root; its resolvers do all the compute - legacy: strawberry.Private[Any] = None - - @strawberry.field - def section_count(self) -> int | None: - return _GrapheneSections.resolve_section_count(self.legacy, None) - - @strawberry.field(description="maximum rupture magnitude from the contributing solutions.") - def max_magnitude(self) -> float | None: - return _GrapheneSections.resolve_max_magnitude(self.legacy, None) - - @strawberry.field(description="minimum rupture magnitude from the contributing solutions.") - def min_magnitude(self) -> float | None: - return _GrapheneSections.resolve_min_magnitude(self.legacy, None) - - @strawberry.field( - description="maximum section participation rate (sum of rate_weighted_mean.sum) over the contributing " - "solutions." - ) - def max_participation_rate(self) -> float | None: - return _GrapheneSections.resolve_max_participation_rate(self.legacy, None) - - @strawberry.field( - description="minimum section participation rate (sum of rate_weighted_mean.sum) over the contributing " - "solutions." - ) - def min_participation_rate(self) -> float | None: - return _GrapheneSections.resolve_min_participation_rate(self.legacy, None) - - @strawberry.field - def fault_surfaces( - self, - color_scale: ColorScaleArgsInput | None = strawberry.UNSET, - style: GeojsonAreaStyleArgumentsInput | None = strawberry.UNSET, - ) -> JSONString | None: - return _GrapheneSections.resolve_fault_surfaces( - self.legacy, None, color_scale=_legacy_color_scale_args(color_scale), style=_v(style) - ) - - @strawberry.field - def fault_traces( - self, - color_scale: ColorScaleArgsInput | None = strawberry.UNSET, - style: GeojsonLineStyleArgumentsInput | None = strawberry.UNSET, - ) -> JSONString | None: - return _GrapheneSections.resolve_fault_traces( - self.legacy, None, color_scale=_legacy_color_scale_args(color_scale), style=_v(style) - ) - - @strawberry.field(description="magnitude frequency distribution of the filtered rutpures.") - def mfd_histogram(self) -> list[MagFreqDist | None] | None: - return [ - MagFreqDist(bin_center=r.bin_center, rate=r.rate, cumulative_rate=r.cumulative_rate) - for r in _GrapheneSections.resolve_mfd_histogram(self.legacy, None) - ] - - @strawberry.field - def color_scale( - self, - name: str | None = strawberry.UNSET, - normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, - min_value: float | None = strawberry.UNSET, - max_value: float | None = strawberry.UNSET, - ) -> ColorScale | None: - kwargs: dict[str, Any] = {} - if min_value is not strawberry.UNSET: - kwargs["min_value"] = min_value - if max_value is not strawberry.UNSET: - kwargs["max_value"] = max_value - if normalization is not None and normalization is not strawberry.UNSET: - kwargs["normalization"] = normalization.value - cs = _GrapheneSections.resolve_color_scale(self.legacy, None, name=_v(name), **kwargs) - return _to_strawberry_color_scale(cs) - - -@strawberry.type -class RadiiSet: - radii_set_id: int | None = strawberry.field(default=None, description="The unique radii_set_id") - radii: list[int | None] | None = strawberry.field( - default=None, description="list of dimension in metres defined by the radii set." - ) - - -@strawberry.type -class Location: - location_id: str | None = strawberry.field(default=None, description="unique location location_id.") - name: str | None = strawberry.field(default=None, description="location name.") - latitude: float | None = strawberry.field(default=None, description="location latitude.") - longitude: float | None = strawberry.field(default=None, description="location longitude") - - -@strawberry.type -class LocationList: - list_id: str | None = strawberry.field(default=None, description="The unique location_list_id") - location_ids: list[str | None] | None = strawberry.field(default=None, description="list of location codes.") - - @strawberry.field(description="the locations in this list.") - def locations(self) -> list[Location | None] | None: - out: list[Location | None] = [] - for loc_id in self.location_ids or []: - loc = location_by_id(loc_id) if loc_id else None - if loc: - out.append( - Location( - location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] - ) - ) - return out - - -# --------------------------------------------------------------------------- compute adapters - - -def _to_strawberry_color_scale(cs) -> ColorScale: - norm = {"log": ColourScaleNormaliseEnum.LOG, "lin": ColourScaleNormaliseEnum.LIN}.get(cs.normalisation) - return ColorScale( - name=cs.name, - min_value=cs.min_value, - max_value=cs.max_value, - normalisation=norm, - color_map=HexRgbValueMapping(levels=list(cs.color_map.levels), hexrgbs=list(cs.color_map.hexrgbs)), - ) - - -def _v(value): - """strawberry.UNSET -> None (the legacy graphene inputs use None for absent optionals).""" - return None if value is strawberry.UNSET else value - - -class _LegacyFilter(dict): - """Adapts a strawberry FilterRupturesArgsInput to the dict + attribute access that the - legacy ``paginated_filtered_ruptures`` / ``get_fault_section_aggregates`` expect.""" - - filter_set_options: dict - corupture_fault_names: list - - -def _legacy_filter(f: "FilterRupturesArgsInput") -> _LegacyFilter: - lf = _LegacyFilter( - model_id=f.model_id, - fault_system=f.fault_system, - location_ids=list(f.location_ids or []), - radius_km=_v(f.radius_km), - minimum_rate=_v(f.minimum_rate), - maximum_rate=_v(f.maximum_rate), - minimum_mag=_v(f.minimum_mag), - maximum_mag=_v(f.maximum_mag), - ) - lf.filter_set_options = _fso_dict(f.filter_set_options) - lf.corupture_fault_names = list(f.corupture_fault_names or []) - return lf - - -def _to_strawberry_rupture_connection(conn) -> "RuptureDetailConnection": - pi = conn.page_info - edges: list[RuptureDetailEdge | None] = [ - RuptureDetailEdge( - node=CompositeRuptureDetail( - model_id=e.node.model_id, fault_system=e.node.fault_system, rupture_index=e.node.rupture_index - ), - cursor=e.cursor, - ) - for e in conn.edges - ] - return RuptureDetailConnection( - page_info=PageInfo( - has_next_page=bool(getattr(pi, "has_next_page", False)), - has_previous_page=bool(getattr(pi, "has_previous_page", False)), - start_cursor=getattr(pi, "start_cursor", None), - end_cursor=getattr(pi, "end_cursor", None), - ), - edges=edges, - total_count=conn.total_count, - ) - - -def _fso_dict(fso) -> dict: - if not fso: - return {} - - def val(x): - return x.value if x is not None else None - - return { - "multiple_locations": val(fso.multiple_locations), - "multiple_faults": val(fso.multiple_faults), - "locations_and_faults": val(fso.locations_and_faults), - } - - -def _legacy_color_scale_args(cs): - """A namespace the legacy section resolvers can read, with `normalisation` as its string - value (`log`/`lin`) rather than the strawberry enum member.""" - if cs is None or cs is strawberry.UNSET: - return None - return SimpleNamespace( - name=cs.name, - min_value=_v(cs.min_value), - max_value=_v(cs.max_value), - normalisation=(cs.normalisation.value if cs.normalisation else None), - ) - - -def _graphene_sections_root(f: "FilterRupturesArgsInput"): - """Build a legacy graphene CompositeRuptureSections root so its resolvers (all the - aggregate / geojson / MFD / colour compute) can be reused verbatim.""" - g_filter = _GrapheneFilterArgs( # type: ignore[call-arg] # graphene ObjectType (untyped __init__) - model_id=f.model_id, - fault_system=f.fault_system, - location_ids=list(f.location_ids or []), - radius_km=_v(f.radius_km), - minimum_rate=_v(f.minimum_rate), - maximum_rate=_v(f.maximum_rate), - minimum_mag=_v(f.minimum_mag), - maximum_mag=_v(f.maximum_mag), - corupture_fault_names=list(f.corupture_fault_names or []), - filter_set_options=_fso_dict(f.filter_set_options), - ) - return _GrapheneSections(model_id=f.model_id, filter_arguments=g_filter) # type: ignore[call-arg] - - -def _rupture_fault_surfaces(model_id, fault_system, rupture_index, style): - from solvis_graphql_api.composite_solution.composite_rupture_detail import rupture_detail # noqa: F401 - - composite_solution = cached.get_composite_solution(model_id) - gdf = composite_solution._solutions[fault_system].rupture_surface(rupture_index) - gdf = gdf.drop( - columns=[ - "key_0", "fault_system", "Rupture Index", "rate_max", "rate_min", "rate_count", - "rate_weighted_mean", "Magnitude", "Average Rake (degrees)", "Area (m^2)", "Length (m)", - ] - ) - return apply_geojson_style(json.loads(gdf.to_json(indent=2)), style) if gdf is not None else None - - -_DEFAULT_SORTBY: list = [] # module-level so it renders `= []` without a B006 mutable-default warning - -# --------------------------------------------------------------------------- query root - - -@strawberry.type(description="This is the entry point for solvis graphql query operations") -class QueryRoot: - @strawberry.field - def color_scale( - self, - name: str | None = strawberry.UNSET, - min_value: float | None = strawberry.UNSET, - max_value: float | None = strawberry.UNSET, - normalization: ColourScaleNormaliseEnum | None = strawberry.UNSET, - ) -> ColorScale | None: - cs = _cs.get_colour_scale( - color_scale=name or None, - color_scale_normalise=normalization.value if normalization else None, - vmax=max_value or None, - vmin=min_value or None, - ) - return _to_strawberry_color_scale(cs) - - @strawberry.field - def node( - self, id: Annotated[strawberry.ID, strawberry.argument(description="The ID of the object")] - ) -> Node | None: - # parity: the legacy graphene schema defines no `get_node`, so `node(id)` always - # resolves to null (verified differentially) — nothing to dispatch. - return None - - @strawberry.field(description="About this Solvis API ") - def about(self) -> str | None: - return f"Hello World, I am solvis_graphql_api! Version: {solvis_graphql_api.__version__}" - - @strawberry.field - def locations_by_id( - self, - location_ids: Annotated[ - list[str | None], - strawberry.argument(description='list of nzshm_common.location_ids e.g. `["WLG","PMR","ZQN"]`'), - ], - ) -> LocationDetailConnection | None: - conn: Any = get_location_detail_list(location_ids) # type: ignore[arg-type] # graphene connection - edges: list[LocationDetailEdge | None] = [ - LocationDetailEdge( - node=LocationDetail( - location_id=e.node.location_id, - name=e.node.name, - latitude=e.node.latitude, - longitude=e.node.longitude, - ), - cursor=e.cursor, - ) - for e in conn.edges - ] - return LocationDetailConnection( - page_info=PageInfo(has_next_page=False, has_previous_page=False), - edges=edges, - total_count=conn.total_count, - ) - - @strawberry.field - def composite_solution( - self, - model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], - ) -> CompositeSolution | None: - solution = cached.get_composite_solution(model_id) - return CompositeSolution(model_id=model_id, fault_systems=list(solution._solutions.keys())) - - @strawberry.field - def composite_rupture_detail(self, filter: CompositeRuptureDetailArgs) -> CompositeRuptureDetail | None: - return CompositeRuptureDetail( - model_id=filter.model_id.strip() if filter.model_id else filter.model_id, - fault_system=filter.fault_system, - rupture_index=filter.rupture_index, - ) - - @strawberry.field - def filter_ruptures( - self, - filter: FilterRupturesArgsInput, - sortby: list[SimpleSortRupturesArgs | None] | None = _DEFAULT_SORTBY, - before: str | None = strawberry.UNSET, - after: str | None = strawberry.UNSET, - first: int | None = strawberry.UNSET, - last: int | None = strawberry.UNSET, - ) -> RuptureDetailConnection | None: - sortby_args = [ - {"attribute": s.attribute, "ascending": s.ascending} - for s in (sortby or []) - if s is not None - ] - kwargs: dict[str, Any] = {} - if first is not strawberry.UNSET: - kwargs["first"] = first - if after is not strawberry.UNSET: - kwargs["after"] = after - conn = paginated_filtered_ruptures(_legacy_filter(filter), sortby_args, **kwargs) - return _to_strawberry_rupture_connection(conn) - - @strawberry.field - def filter_rupture_sections(self, filter: FilterRupturesArgsInput) -> CompositeRuptureSections | None: - return CompositeRuptureSections(model_id=filter.model_id, legacy=_graphene_sections_root(filter)) - - @strawberry.field - def get_parent_fault_names( - self, - model_id: Annotated[str, strawberry.argument(description="A valid NSHM model id e.g. `NSHM_1.0.0`")], - fault_system: Annotated[str, strawberry.argument(description="A valid FSS name CRU, PUY, HIK")], - ) -> list[str | None] | None: - composite_solution = cached.get_composite_solution(model_id) - fss = composite_solution._solutions[fault_system] - return list(cached.parent_fault_names(fss)) - - @strawberry.field(description="Return ad single radii_set for the id passed in") - def get_radii_set( - self, - radii_set_id: Annotated[int, strawberry.argument(description="the integer ID for the desired radii_set")], - ) -> RadiiSet | None: - for rad in RADII: - if rad["id"] == radii_set_id: - return RadiiSet(radii_set_id=radii_set_id, radii=[int(r) for r in rad["radii"]]) - raise IndexError(f"Radii set with id {radii_set_id} was not found.") - - @strawberry.field(description="Return all the available radii_set") - def get_radii_sets(self) -> list[RadiiSet | None] | None: - return [RadiiSet(radii_set_id=r["id"], radii=[int(x) for x in r["radii"]]) for r in RADII] - - @strawberry.field(description="Return a single location.") - def get_location( - self, - location_id: Annotated[str, strawberry.argument(description="the location code of the desired location")], - ) -> Location | None: - for loc in LOCATIONS: - if loc["id"] == location_id: - return Location( - location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"] - ) - raise IndexError(f"Location with id {location_id} was not found.") - - @strawberry.field(description="Return all the available locations") - def get_locations(self) -> list[Location | None] | None: - return [ - Location(location_id=loc["id"], name=loc["name"], latitude=loc["latitude"], longitude=loc["longitude"]) - for loc in LOCATIONS - ] - - @strawberry.field(description="Return a single location list.") - def get_location_list( - self, - list_id: Annotated[str, strawberry.argument(description="the id of the desired location_list")], - ) -> LocationList | None: - ll = LOCATION_LISTS.get(list_id) - if ll: - return LocationList(list_id=list_id, location_ids=ll["locations"]) - raise IndexError(f"LocationList with id {list_id} was not found.") - - @strawberry.field(description="Return all the available location lists") - def get_location_lists(self) -> list[LocationList | None] | None: - return [LocationList(list_id=key, location_ids=v["locations"]) for key, v in LOCATION_LISTS.items()] - - -schema = strawberry.Schema( - query=QueryRoot, - types=[LocationDetail, CompositeRuptureDetail], - config=StrawberryConfig(auto_camel_case=False), -) diff --git a/solvis_graphql_api/tools/dump_legacy_sdl.py b/solvis_graphql_api/tools/dump_legacy_sdl.py deleted file mode 100644 index dd7725a..0000000 --- a/solvis_graphql_api/tools/dump_legacy_sdl.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Dump the legacy Graphene SDL as the Strawberry-migration parity baseline. - - uv run python -m solvis_graphql_api.tools.dump_legacy_sdl > schema.legacy.graphql - -Importing the schema pulls in ``solvis``/``pyvista``, which print warnings to -**stdout** ("WARNING: optional `toshi` dependencies ...", "geometry.section_distance() -uses ... pyvista"). Those would pollute the SDL file, so we redirect stdout to stderr -for the duration of the import (runbook Phase 0 trap; surfaced by the Model pilot). -""" - -import contextlib -import sys - -with contextlib.redirect_stdout(sys.stderr): - from solvis_graphql_api.schema import schema_root - -print(str(schema_root)) diff --git a/solvis_graphql_api/tools/schema_parity.py b/solvis_graphql_api/tools/schema_parity.py index c08726c..27c766c 100644 --- a/solvis_graphql_api/tools/schema_parity.py +++ b/solvis_graphql_api/tools/schema_parity.py @@ -25,7 +25,7 @@ def _normalise(sdl: str) -> str: def diff() -> str: with contextlib.redirect_stdout(sys.stderr): - from solvis_graphql_api.strawberry_schema import schema + from solvis_graphql_api.schema import schema new_sdl = _normalise(schema.as_str()) legacy_sdl = _normalise(BASELINE.read_text()) diff --git a/tests/__snapshots__/strawberry_parity/about.json b/tests/__snapshots__/strawberry_parity/about.json new file mode 100644 index 0000000..cdd3842 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/about.json @@ -0,0 +1,3 @@ +{ + "about": "Hello World, I am solvis_graphql_api! Version: 0.9.2" +} diff --git a/tests/__snapshots__/strawberry_parity/color_scale.json b/tests/__snapshots__/strawberry_parity/color_scale.json new file mode 100644 index 0000000..057fd34 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/color_scale.json @@ -0,0 +1,116 @@ +{ + "color_scale": { + "color_map": { + "hexrgbs": [ + "#000004", + "#02020c", + "#050417", + "#0a0722", + "#10092d", + "#160b39", + "#1e0c45", + "#260c51", + "#2f0a5b", + "#390963", + "#420a68", + "#4a0c6b", + "#520e6d", + "#5a116e", + "#62146e", + "#6a176e", + "#721a6e", + "#7c1d6d", + "#84206b", + "#8c2369", + "#932667", + "#9b2964", + "#a32c61", + "#ab2f5e", + "#b3325a", + "#bc3754", + "#c33b4f", + "#ca404a", + "#d04545", + "#d74b3f", + "#dd513a", + "#e25734", + "#e75e2e", + "#eb6628", + "#f06f20", + "#f37819", + "#f68013", + "#f8890c", + "#fa9207", + "#fb9b06", + "#fca50a", + "#fcae12", + "#fbba1f", + "#fac42a", + "#f8cd37", + "#f6d746", + "#f4e156", + "#f2ea69", + "#f2f27d", + "#f5f992", + "#fcffa4" + ], + "levels": [ + 5.0, + 5.1, + 5.2, + 5.3, + 5.4, + 5.5, + 5.6, + 5.7, + 5.8, + 5.9, + 6.0, + 6.1, + 6.2, + 6.3, + 6.4, + 6.5, + 6.6, + 6.7, + 6.8, + 6.9, + 7.0, + 7.1, + 7.2, + 7.3, + 7.4, + 7.5, + 7.6, + 7.7, + 7.8, + 7.9, + 8.0, + 8.1, + 8.2, + 8.3, + 8.4, + 8.5, + 8.6, + 8.7, + 8.8, + 8.9, + 9.0, + 9.1, + 9.2, + 9.3, + 9.4, + 9.5, + 9.6, + 9.7, + 9.8, + 9.9, + 10.0 + ] + }, + "max_value": 10.0, + "min_value": 5.0, + "name": "inferno", + "normalisation": "LIN" + } +} diff --git a/tests/__snapshots__/strawberry_parity/composite_rupture_detail.json b/tests/__snapshots__/strawberry_parity/composite_rupture_detail.json new file mode 100644 index 0000000..41dec53 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/composite_rupture_detail.json @@ -0,0 +1,16 @@ +{ + "composite_rupture_detail": { + "area": 1122.0, + "fault_system": "PUY", + "id": "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpQVVk6Mw==", + "length": 75.0, + "magnitude": 7.05, + "model_id": "NSHM_v1.0.4", + "rake_mean": 90.0, + "rate_count": 3, + "rate_max": 0.0016310067, + "rate_min": 0.00026551273, + "rate_weighted_mean": 0.0009892245, + "rupture_index": 3 + } +} diff --git a/tests/__snapshots__/strawberry_parity/filter_ruptures.json b/tests/__snapshots__/strawberry_parity/filter_ruptures.json new file mode 100644 index 0000000..210cf7f --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/filter_ruptures.json @@ -0,0 +1,41 @@ +{ + "filter_ruptures": { + "edges": [ + { + "cursor": "UnVwdHVyZURldGFpbENvbm5lY3Rpb25DdXJzb3I6MA==", + "node": { + "__typename": "CompositeRuptureDetail", + "id": "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpISUs6NjYx", + "magnitude": 9.6, + "model_id": "NSHM_v1.0.0", + "rupture_index": 661 + } + }, + { + "cursor": "UnVwdHVyZURldGFpbENvbm5lY3Rpb25DdXJzb3I6MQ==", + "node": { + "__typename": "CompositeRuptureDetail", + "id": "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpISUs6MTI4NQ==", + "magnitude": 9.603, + "model_id": "NSHM_v1.0.0", + "rupture_index": 1285 + } + }, + { + "cursor": "UnVwdHVyZURldGFpbENvbm5lY3Rpb25DdXJzb3I6Mg==", + "node": { + "__typename": "CompositeRuptureDetail", + "id": "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpISUs6MTc0Mg==", + "magnitude": 9.304, + "model_id": "NSHM_v1.0.0", + "rupture_index": 1742 + } + } + ], + "pageInfo": { + "endCursor": "UnVwdHVyZURldGFpbENvbm5lY3Rpb25DdXJzb3I6Mg==", + "hasNextPage": true + }, + "total_count": 39 + } +} diff --git a/tests/__snapshots__/strawberry_parity/location_list.json b/tests/__snapshots__/strawberry_parity/location_list.json new file mode 100644 index 0000000..c59a262 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/location_list.json @@ -0,0 +1,37 @@ +{ + "get_location_list": { + "list_id": "NZ2", + "location_ids": [ + "WLG", + "CHC", + "DUD", + "NPL", + "AKL", + "ROT", + "HLZ" + ], + "locations": [ + { + "location_id": "WLG" + }, + { + "location_id": "CHC" + }, + { + "location_id": "DUD" + }, + { + "location_id": "NPL" + }, + { + "location_id": "AKL" + }, + { + "location_id": "ROT" + }, + { + "location_id": "HLZ" + } + ] + } +} diff --git a/tests/__snapshots__/strawberry_parity/node.json b/tests/__snapshots__/strawberry_parity/node.json new file mode 100644 index 0000000..74de5f3 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/node.json @@ -0,0 +1,3 @@ +{ + "node": null +} diff --git a/tests/__snapshots__/strawberry_parity/parent_fault_names.json b/tests/__snapshots__/strawberry_parity/parent_fault_names.json new file mode 100644 index 0000000..5126e29 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/parent_fault_names.json @@ -0,0 +1,561 @@ +{ + "get_parent_fault_names": [ + "Acton", + "Aka Aka", + "Akatarawa", + "Akatore", + "Albury", + "Alexander", + "Alfredton North", + "Alfredton South", + "Alpine: Caswell", + "Alpine: Caswell - South George", + "Alpine: George landward", + "Alpine: George seaward", + "Alpine: George to Jacksons", + "Alpine: Jacksons to Kaniere", + "Alpine: Kaniere to Springs Junction", + "Alpine: Nancy", + "Alpine: Resolution - Charles", + "Alpine: Resolution - Dagg", + "Alpine: Resolution - Five Fingers", + "Alpine: Springs Junction to Tophouse", + "AmberBlyth", + "Aotea - Evans Bay", + "Ararata", + "Ariel Bank", + "Ariel Bank 2", + "Ariel East", + "Awahokomo", + "Awakeri", + "Awamoa", + "Awanui", + "Awatere: Northeast 1", + "Awatere: Northeast 2", + "Awatere: Southwest", + "Backbone", + "Balmoral", + "Barefell", + "Barn", + "Barrier Range", + "Beacon Hill", + "Beaumont River", + "Ben McLeod", + "Bidwill", + "Big Ben", + "Billys Ridge", + "Black Hill", + "Blue Lake", + "Blue Mountain", + "Boby Stream", + "BooBoo", + "Braemar", + "Brothers", + "Browning Pass", + "Calypso 2", + "Campbell Bank", + "Campbell Hills", + "Cape Egmont: Central", + "Cape Egmont: North", + "Cape Egmont: South", + "Cape Foulwind 1", + "Cape Foulwind 2", + "Cape Foulwind 4", + "Cardrona - Hawea", + "Carterton", + "Caswell 211", + "Caswell High 1", + "Caswell High 10", + "Caswell High 3", + "Caswell High 4", + "Caswell High 5", + "Caswell High 67", + "Caswell High 8", + "Caswell High 9", + "Catherine", + "CenPeg1n2", + "Central Balleny", + "Central Wedge 1 & 2 & 3", + "Central Wedge 4 - South Wedge 411", + "Chamberlain", + "Chancet", + "Cheeseman", + "Clarence: Central", + "Clarence: Northeast", + "Clarence: Southwest", + "Clear Hills - Waikopiro", + "Clifton", + "Cloudy 1", + "Cloudy 2", + "Cloudy 3", + "Cluden", + "Conway Trough East", + "Conway Trough West", + "Cross Eden", + "Dalgety", + "Darran", + "Dingle", + "Doctors", + "Double Hill", + "Dreyers Rock", + "Dromedary", + "Drury", + "Dry River - Huangarua: 1", + "Dry River - Huangarua: 2", + "Dry River - Huangarua: 3", + "Dryburgh", + "Dunback Hill", + "Dunstan", + "EarthquakeFlat - Tumunui", + "Edgecumbe 1987", + "Edgecumbe Coastal", + "Edith", + "Elliott", + "Esk North", + "Esk South", + "Falstone", + "Farewell", + "Fern Gully", + "Fidget", + "Fisherman 1", + "Fisherman 2", + "Fisherman 3", + "Five Fingers", + "Flat Stream - Glenpark", + "Flaxmore - Waimea - Tahunanui", + "Fowlers", + "Fox Peak", + "Gable End North", + "Gable End South", + "Galloway", + "Garibaldi", + "Garvie", + "Gibson", + "Gimmerburn", + "Glenbrook", + "Goodger", + "Grampian", + "Grandview", + "Green Island", + "Greendale", + "Hanmer", + "Hariki", + "Hauroko: North", + "Hauroko: South", + "Hawkdun", + "Hawke Bay 1", + "Hawke Bay 4", + "Hawke Bay 5 & 11", + "Hawke Bay 8", + "Hawkswood", + "Hedgehope", + "High Peak - Sheffield", + "Hillfoot", + "Himatangi", + "Hohonu: South", + "Hokonui", + "Hollyford", + "Honeycomb", + "Hope: Conway", + "Hope: Hanmer NW", + "Hope: Hanmer SE", + "Hope: Hanmer SW", + "Hope: Hope River", + "Hope: Hurunui", + "Hope: Kakapo-2-Hamner", + "Hope: Seaward", + "Hope: Taramakau", + "Hope: Te Rapa", + "Hopkins", + "Hororata", + "Horse Flat", + "Houtunui", + "Hump Ridge", + "Hundalee", + "Hunter Valley", + "Hunters", + "Hurunui Peak", + "Hutt Peel", + "Huxley", + "Hyde North", + "Hyde South - The Twins", + "Ihaia", + "Inangahua", + "Inglewood", + "Irishman Creek", + "Jordan", + "Kahurangi", + "Kaiapo", + "Kaikorai", + "Kaikoura - Mangamaunu", + "Kairakau 2", + "Kairakau North", + "Kairakau South", + "Kaiwara North", + "Kaiwara South", + "Kakapo", + "Kaumingi", + "Kaweka", + "Kekerengu 1", + "Kekerengu Bank", + "Kelly", + "Kerepehi Awaiti", + "Kerepehi Elstow", + "Kerepehi Offshore", + "Kerepehi Okoroire", + "Kerepehi Te Poi", + "Kerepehi Waitoa", + "Kereu", + "Kidnappers Ridge", + "Kiri", + "Kirkliston", + "Klondyke Moor", + "Knowles Top", + "Kohaihai", + "Kohaihai South", + "Kongahau", + "Kotare - Moutuhora", + "Kowai - View Hill", + "Kututaruhe", + "Lachlan 1 & 2", + "Lachlan 3", + "Lake Heron", + "Lake Onslow", + "Leader: Central", + "Leader: North", + "Leader: South", + "Leedstown", + "Lees Valley", + "Leithfield - NCVI - NCVII", + "Leonard Mound", + "Levin Anticline", + "Lindis River", + "Little Hillfoot", + "Little Valley", + "Livingstone", + "Logan Burn", + "London Hill", + "Long Valley", + "Lowry", + "Lyell", + "Lyell South", + "Madagascar", + "Madden Banks", + "Mahia 2", + "Maimai", + "Maimai: North", + "Maimai: South", + "Makuri - Waewaepa", + "Maleme", + "Manahi 1", + "Manahi 2", + "Mangatangi", + "Marlborough Slope 11", + "Marlborough Slope 2", + "Marlborough Slope 3", + "Marlborough Slope 4", + "Marlborough Slope 9", + "Martinborough", + "Mascarin", + "Masterton", + "Mataikona", + "Mataikona East", + "Matata", + "Maunga", + "Maungati", + "Maungatua - North Taieri", + "McKerchar", + "Mid Dome", + "Middle Range", + "Milford Basin 5 to George R2", + "Mohaka: North", + "Mohaka: South", + "Mokonui Northeast", + "Mokonui Southwest", + "Monowai", + "Moonlight North", + "Moonlight South", + "Moonshine", + "Mossburn", + "Motatapu", + "Motu", + "Motu River", + "Motu o Kura East", + "Motu o Kura North", + "Motu o Kura Ridge", + "Motunau", + "Moumahaki - Okaia 4", + "Mt Arden", + "Mt Culverden", + "Mt Lawry", + "Mt Stewart", + "Mt Sutton", + "Murphys Creek", + "NW Cardrona North", + "NW Cardrona South", + "Needles", + "Needles East 2", + "Nevis", + "Ngatoro South 2", + "Ngatoro South 5", + "Nichols Rock", + "Norfolk", + "North Branch", + "North Canterbury Shelf 11", + "North Canterbury Shelf 2", + "North Canterbury Shelf 4", + "North Mernoo E2-1", + "North Mernoo K2", + "North Mernoo K3", + "Northern Ohariu", + "Nukumuru - Waitotara 1-6", + "Oaonui", + "Ohae 1", + "Ohae 2", + "Ohae 3", + "Ohariu", + "Ohariu South 1", + "Ohariu South 2", + "Ohena 2", + "Ohena 3", + "Ohena 4", + "Ohiwa 1", + "Okaia 3", + "Okaia 5", + "Okupe 1", + "Old Man", + "Omarama Saddle", + "OmihiLwrHuru", + "Onepoto", + "Opape 1", + "Opawa", + "Opotiki 2", + "Opotiki 3", + "Opouawe - Uruti", + "Orakeikorako", + "Oruawharo - Mangatoro", + "Ostler North", + "Ostler South", + "Otaheke North", + "Otaheke South", + "Otaki Forks: 1", + "Otaki Forks: 2", + "Otanomomo", + "Otara East 1", + "Otara East 2", + "Otara West 1", + "Otara West 2", + "Otara West 3", + "Otaraia", + "Pa Valley", + "Paddys Ridge", + "Paeroa", + "Pahaua", + "Pahiatua", + "Pakarae", + "Palliser - Kaiwhata", + "Pancake", + "Paparoa West", + "Paparoa West: North", + "Papatea", + "Parihaka", + "Patoka - Rangiora", + "Pegasus Bay", + "Pihama", + "Pisa", + "Pohangina", + "Point Gibson", + "Pokeno", + "Pongaroa - Weber", + "Poroutawhao", + "Porters Pass", + "Poulter", + "Poutu", + "Poverty Bay", + "Pukekohe", + "Pukerua - Shepherds Gully: 1", + "Pukerua - Shepherds Gully: 2", + "Pukerua - Shepherds Gully: 3", + "Puketerata", + "Puketerata North", + "Pylap", + "Raggedy - Blackstone", + "Rahotu", + "Rakaia", + "Ranfurly", + "Rangefront", + "Rangitaiki 1", + "Rangitaiki 2", + "Rangitikei Offshore 1", + "Rapuwai", + "Raukumara 16", + "Raukumara 17", + "Raukumara 18", + "Raukumara 20", + "Raukumara 21", + "Raukumara 24", + "Raukumara 25", + "Raukumara 9", + "Rauoterangi", + "Raurimu: South", + "Redcliffe", + "Repongaere", + "Ridge Road - Okaia 2", + "Riversdale", + "Riversdale North", + "Roaring Lion", + "Roaring Meg", + "Rotoitipakau", + "Ruahine: Central", + "Ruahine: North", + "Ruahine: South", + "Ruataniwha", + "Ruatoria South 3", + "Ruatoria South 4", + "Ruby Bay - Moutere", + "Sailor", + "Saunders Road", + "Settlement", + "Shepherds Gully - Mana", + "Skippers", + "Sliver Stream - Merton", + "Snares", + "Solander", + "South Wedge 1", + "South Wedge 2", + "South Wedge 3-6-10", + "South Wedge 5", + "Spey - Mica Burn", + "Springbank", + "Spylaw", + "St Stephens", + "Starvation - Ashley", + "StoneJug", + "Stonewall", + "Sugarloaf", + "Surville", + "Takapu", + "Taonui", + "Tauranga Trough East 2", + "Tauranga Trough West 2", + "Tauru", + "Te Anau", + "Te Arawa 1", + "Te Arawa 2", + "Te Arawa 3", + "Te Heka", + "Te Ikaarongamai", + "Te Puninga", + "Te Rapa 1", + "Te Rapa 2", + "Te Tatua o Wairere", + "Te Whaiti North", + "Te Whaiti South", + "Teviot", + "The Humps", + "Timaru Creek", + "Tin Hut", + "Tirohanga", + "Titri Central", + "Titri North", + "Titri South", + "Tokomaru", + "Torlesse", + "Tuakana 6", + "Tuakana 7", + "Tuapeka", + "Tuatoru 1", + "Tuatoru 2", + "Tukituki Thrust", + "Turi: Central", + "Turi: North", + "Turi: South", + "Tutaki", + "Upper Slope", + "Uruti Basin", + "Uruti Basin 2", + "Uruti East - PorWest 1", + "Uruti North", + "Uruti Point", + "Uruti Ridge 2", + "Vernon 1", + "Vernon 2", + "Vernon 3", + "Vernon 4", + "Virginia", + "Volkner 3", + "Volkner 4", + "Waewaepa", + "Waihemo", + "Waihi South", + "Waihi West", + "Waikaia", + "Waikaka", + "Waikuku", + "Waimana - Waikaremoana", + "Waimana: North", + "Waimana: South", + "Waimarama 1 & 2", + "Waimarama 3 & 4", + "Waimate", + "Waimea Offshore", + "Waiohau: North", + "Waiohau: South", + "Waiotahi: North", + "Waiotahi: South", + "Waipiata", + "Waipukaka", + "Waipukurau - Poukawa", + "Wairarapa: 1", + "Wairarapa: 2", + "Wairarapa: 3", + "Wairarapa: Needles", + "Wairata", + "Wairau", + "Wairau 2", + "Wairau 3", + "Wairoa North", + "Waitahuna 1", + "Waitahuna 2", + "Waitaki", + "Waitangi", + "Waitawhiti", + "Waitotara 10 & 11", + "Waitotara 8 & 9", + "Wakamarama", + "Wakamarama Offshore", + "Wakarara", + "Wanaka", + "Warea", + "Waverley - Okaia 1", + "Wellington Hutt Valley: 1", + "Wellington Hutt Valley: 2", + "Wellington Hutt Valley: 3", + "Wellington Hutt Valley: 4", + "Wellington Hutt Valley: 5", + "Wellington: Pahiatua", + "Wellington: Tararua 1", + "Wellington: Tararua 2", + "Wellington: Tararua 3", + "Wendon Valley", + "West Balleny", + "West Wakatipu", + "Whakaari 4", + "Whakatane: North", + "Whakatane: South", + "Whangamata West", + "Whangamoa", + "Wharanui", + "Whareama Bank", + "Wharekauhau", + "Wheao: North", + "Wheao: South", + "White Creek", + "White Creek - buried", + "White Hill", + "White Island 1", + "White Island 2", + "Whitemans Valley", + "Woodville" + ] +} diff --git a/tests/__snapshots__/strawberry_parity/radii_set.json b/tests/__snapshots__/strawberry_parity/radii_set.json new file mode 100644 index 0000000..2e2afcb --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/radii_set.json @@ -0,0 +1,13 @@ +{ + "get_radii_set": { + "radii": [ + 10000, + 20000, + 30000, + 40000, + 50000, + 100000 + ], + "radii_set_id": 6 + } +} diff --git a/tests/__snapshots__/strawberry_parity/radii_sets.json b/tests/__snapshots__/strawberry_parity/radii_sets.json new file mode 100644 index 0000000..13f6914 --- /dev/null +++ b/tests/__snapshots__/strawberry_parity/radii_sets.json @@ -0,0 +1,67 @@ +{ + "get_radii_sets": [ + { + "radii": [ + 10000 + ], + "radii_set_id": 1 + }, + { + "radii": [ + 10000, + 20000 + ], + "radii_set_id": 2 + }, + { + "radii": [ + 10000, + 20000, + 30000 + ], + "radii_set_id": 3 + }, + { + "radii": [ + 10000, + 20000, + 30000, + 40000 + ], + "radii_set_id": 4 + }, + { + "radii": [ + 10000, + 20000, + 30000, + 40000, + 50000 + ], + "radii_set_id": 5 + }, + { + "radii": [ + 10000, + 20000, + 30000, + 40000, + 50000, + 100000 + ], + "radii_set_id": 6 + }, + { + "radii": [ + 10000, + 20000, + 30000, + 40000, + 50000, + 100000, + 200000 + ], + "radii_set_id": 7 + } + ] +} diff --git a/tests/_strawberry_client.py b/tests/_strawberry_client.py new file mode 100644 index 0000000..7f87633 --- /dev/null +++ b/tests/_strawberry_client.py @@ -0,0 +1,32 @@ +"""A ``graphene.test.Client`` drop-in backed by the Strawberry schema. + +The legacy behavioural tests were written against the Graphene schema via +``graphene.test.Client(schema_root).execute(query)``. The Strawberry schema is byte-identical +(SDL parity gate) and runtime-identical (``test_strawberry_parity`` differential), so those +tests stay valuable — they cover the migrated resolvers (sorting, filter-set logic, pagination, +geojson) far beyond the parity corpus. This shim lets them run against Strawberry unchanged, +so Graphene can be removed. + +``schema_root`` here is the Strawberry schema; ``Client.execute`` returns the same +``{"data": ..., "errors": [...]}`` dict shape graphene produced (``GraphQLError.formatted`` +matches graphene's ``{message, locations, path}``). +""" + +from solvis_graphql_api.schema import schema as schema_root # noqa: F401 # re-exported for legacy tests + + +class Client: + def __init__(self, schema): + self._schema = schema + + def execute(self, query, variable_values=None, context_value=None, operation_name=None, **_ignored): + result = self._schema.execute_sync( + query, + variable_values=variable_values, + context_value=context_value, + operation_name=operation_name, + ) + response: dict = {"data": result.data} + if result.errors: + response["errors"] = [err.formatted for err in result.errors] + return response diff --git a/tests/conftest.py b/tests/conftest.py index b48799b..a0aed41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,14 +33,12 @@ def tiny_composite_solution(source_logic_tree): @pytest.fixture def archive_fixture(monkeypatch, full_composite_solution): + # everything resolves through cached.get_composite_solution now (the graphene modules that + # held their own bound copies are gone), so patching the one source suffices. monkeypatch.setattr( "solvis_graphql_api.composite_solution.cached.get_composite_solution", full_composite_solution, ) - monkeypatch.setattr( - "solvis_graphql_api.composite_solution.composite_rupture_detail.get_composite_solution", - full_composite_solution, - ) @pytest.fixture @@ -49,8 +47,3 @@ def archive_fixture_tiny(monkeypatch, tiny_composite_solution): "solvis_graphql_api.composite_solution.cached.get_composite_solution", tiny_composite_solution, ) - monkeypatch.setattr( - "solvis_graphql_api.composite_solution.composite_rupture_detail.get_composite_solution", - tiny_composite_solution, - ) - monkeypatch.setattr("solvis_graphql_api.schema.get_composite_solution", tiny_composite_solution) diff --git a/tests/test_color_scale_api.py b/tests/test_color_scale_api.py index 141d35f..9d70682 100644 --- a/tests/test_color_scale_api.py +++ b/tests/test_color_scale_api.py @@ -2,9 +2,7 @@ import unittest -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root class TestColorScaleResolvers(unittest.TestCase): diff --git a/tests/test_color_scale_fn.py b/tests/test_color_scale_fn.py index 2c2ad2a..663e8bd 100644 --- a/tests/test_color_scale_fn.py +++ b/tests/test_color_scale_fn.py @@ -2,7 +2,8 @@ import pytest -from solvis_graphql_api.color_scale.color_scale import get_colour_scale, log_intervals +from solvis_graphql_api.color_scale.compute import compute_colour_scale as get_colour_scale +from solvis_graphql_api.color_scale.compute import log_intervals TEST_ARGS = [ (0.00967, 0.0042171), # PUY_ALL @@ -18,12 +19,12 @@ def test_color_scale_lengths(vmax, vmin): print(res) - print(res.color_map.levels) - print(res.color_map.hexrgbs) + print(res.levels) + print(res.hexrgbs) - assert 4 <= len(res.color_map.levels) <= 10 - assert 4 <= len(res.color_map.hexrgbs) <= 10 - assert len(res.color_map.levels) == len(res.color_map.hexrgbs) + assert 4 <= len(res.levels) <= 10 + assert 4 <= len(res.hexrgbs) <= 10 + assert len(res.levels) == len(res.hexrgbs) @pytest.mark.parametrize("vmax, vmin", TEST_ARGS) diff --git a/tests/test_composite_corupture_sections.py b/tests/test_composite_corupture_sections.py index c241e74..fcf31f1 100644 --- a/tests/test_composite_corupture_sections.py +++ b/tests/test_composite_corupture_sections.py @@ -1,9 +1,8 @@ import json import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY = """ query { diff --git a/tests/test_composite_rupture_sections.py b/tests/test_composite_rupture_sections.py index bbf8965..f2b480e 100644 --- a/tests/test_composite_rupture_sections.py +++ b/tests/test_composite_rupture_sections.py @@ -1,9 +1,8 @@ import json import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY = """ query { diff --git a/tests/test_corpus_replay.py b/tests/test_corpus_replay.py index e89bb80..f364aa1 100644 --- a/tests/test_corpus_replay.py +++ b/tests/test_corpus_replay.py @@ -12,7 +12,7 @@ import pytest from graphql import build_schema, parse, validate -from solvis_graphql_api.strawberry_schema import schema as strawberry_schema +from solvis_graphql_api.schema import schema as strawberry_schema CORPUS_DIR = pathlib.Path(__file__).parent / "fixtures" / "corpus" CORPUS = sorted(CORPUS_DIR.glob("*.graphql")) diff --git a/tests/test_filter_set_options.py b/tests/test_filter_set_options.py index f2a1d13..ff2453d 100644 --- a/tests/test_filter_set_options.py +++ b/tests/test_filter_set_options.py @@ -1,7 +1,6 @@ import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY = """ query { diff --git a/tests/test_locations_schema.py b/tests/test_locations_schema.py index 1e60057..f36e520 100644 --- a/tests/test_locations_schema.py +++ b/tests/test_locations_schema.py @@ -3,9 +3,7 @@ import json import unittest -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY_ONE = """ query ($location_ids: [String]!) { diff --git a/tests/test_rupture_sorting.py b/tests/test_rupture_sorting.py index 8210e38..2d04cc9 100644 --- a/tests/test_rupture_sorting.py +++ b/tests/test_rupture_sorting.py @@ -1,9 +1,8 @@ from dataclasses import dataclass import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root @pytest.fixture(scope="module") diff --git a/tests/test_rupture_sorting_function.py b/tests/test_rupture_sorting_function.py index e90e9d8..5db67e7 100644 --- a/tests/test_rupture_sorting_function.py +++ b/tests/test_rupture_sorting_function.py @@ -7,7 +7,7 @@ import pytest # noqa from solvis_graphql_api.composite_solution import cached -from solvis_graphql_api.composite_solution.schema import auto_sorted_dataframe +from solvis_graphql_api.composite_solution.ruptures import auto_sorted_dataframe MODEL_ID = "NSHM_v1.0.4" FAULT_SYSTEM = "HIK" diff --git a/tests/test_ruptures_and_pagination.py b/tests/test_ruptures_and_pagination.py index 9347529..79cd5cd 100644 --- a/tests/test_ruptures_and_pagination.py +++ b/tests/test_ruptures_and_pagination.py @@ -2,10 +2,9 @@ import unittest import pytest -from graphene.test import Client from graphql_relay import from_global_id, to_global_id -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root @pytest.fixture(autouse=True) diff --git a/tests/test_solvis_composite_solution_api.py b/tests/test_solvis_composite_solution_api.py index 6f0f07a..f0c6acb 100644 --- a/tests/test_solvis_composite_solution_api.py +++ b/tests/test_solvis_composite_solution_api.py @@ -4,9 +4,8 @@ import geopandas as gpd import pandas as pd import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root def mock_dataframe(*args, **kwargs): diff --git a/tests/test_solvis_composite_solution_fix_node_id.py b/tests/test_solvis_composite_solution_fix_node_id.py index 92d741e..bf0d0c8 100644 --- a/tests/test_solvis_composite_solution_fix_node_id.py +++ b/tests/test_solvis_composite_solution_fix_node_id.py @@ -4,12 +4,13 @@ import geopandas as gpd import pandas as pd import pytest - -# from unittest import mock -from graphene.test import Client from graphql_relay import from_global_id, to_global_id -from solvis_graphql_api.schema import schema_root # , matched_rupture_sections_gdf +# from unittest import mock +from tests._strawberry_client import ( + Client, + schema_root, # , matched_rupture_sections_gdf +) def mock_dataframe(*args, **kwargs): diff --git a/tests/test_solvis_graphql_api.py b/tests/test_solvis_graphql_api.py deleted file mode 100644 index b18881b..0000000 --- a/tests/test_solvis_graphql_api.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for `solvis_graphql_api` package.""" - -import os -import unittest -from pathlib import Path - -from graphene.test import Client - -import solvis_graphql_api -from solvis_graphql_api.schema import schema_root -from solvis_graphql_api.solvis_graphql_api import create_app - - -class TestFlaskApp(unittest.TestCase): - """Tests the basic app create.""" - - def test_create_app(self): - app = create_app() - print(app) - assert app - - -class TestSchemaAboutResolver(unittest.TestCase): - """ - A simple `About` resolver returns some metadata about the API. - """ - - def setUp(self): - self.client = Client(schema_root) - - def test_get_about(self): - - QUERY = """ - query { - about - } - """ - - executed = self.client.execute(QUERY) - print(executed) - self.assertTrue("Hello World" in executed["data"]["about"]) - - def test_get_about_has_version(self): - - QUERY = """ - query { - about - } - """ - - executed = self.client.execute(QUERY) - print(executed) - self.assertTrue(solvis_graphql_api.__version__ in executed["data"]["about"]) - - -class TestSetup(unittest.TestCase): - def test_no_logging_config(self): - config = Path(os.getenv("LOGGING_CFG", "solvis_graphql_api/logging.yaml")) - assert config.is_file() - ren_config = config.rename(config.with_suffix(".txt")) - - app = create_app() - print(app) - assert app - - ren_config.rename(config) - - self.client = Client(schema_root) - - ## TODO assert "Warning, no logging config found, using basicConfig(INFO)" in stdout" diff --git a/tests/test_solvis_location_lists_api.py b/tests/test_solvis_location_lists_api.py index dfc9d86..1c0ad86 100644 --- a/tests/test_solvis_location_lists_api.py +++ b/tests/test_solvis_location_lists_api.py @@ -2,9 +2,7 @@ import unittest -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY_ALL = """ query { diff --git a/tests/test_solvis_locations_api.py b/tests/test_solvis_locations_api.py index 9de587c..f497f6d 100644 --- a/tests/test_solvis_locations_api.py +++ b/tests/test_solvis_locations_api.py @@ -2,9 +2,7 @@ import unittest -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY_ALL = """ query { diff --git a/tests/test_solvis_parent_faults_api.py b/tests/test_solvis_parent_faults_api.py index 9399fd1..94be257 100644 --- a/tests/test_solvis_parent_faults_api.py +++ b/tests/test_solvis_parent_faults_api.py @@ -1,8 +1,6 @@ """Tests for possibly unused `get_parent_fault_names""" -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY_ALL = """ query { diff --git a/tests/test_solvis_radii_api.py b/tests/test_solvis_radii_api.py index d6e9de2..c0bf946 100644 --- a/tests/test_solvis_radii_api.py +++ b/tests/test_solvis_radii_api.py @@ -2,9 +2,7 @@ import unittest -from graphene.test import Client - -from solvis_graphql_api.schema import schema_root +from tests._strawberry_client import Client, schema_root QUERY_ALL = """ query { diff --git a/tests/test_solvis_solution_faults_api.py b/tests/test_solvis_solution_faults_api.py index 2adabeb..a771637 100644 --- a/tests/test_solvis_solution_faults_api.py +++ b/tests/test_solvis_solution_faults_api.py @@ -8,9 +8,11 @@ import geopandas as gpd import pandas as pd import pytest -from graphene.test import Client -from solvis_graphql_api.schema import schema_root # , matched_rupture_sections_gdf +from tests._strawberry_client import ( + Client, + schema_root, # , matched_rupture_sections_gdf +) def mock_dataframe(*args, **kwargs): diff --git a/tests/test_strawberry_parity.py b/tests/test_strawberry_parity.py index 6ffbd53..1b9d257 100644 --- a/tests/test_strawberry_parity.py +++ b/tests/test_strawberry_parity.py @@ -1,48 +1,83 @@ -"""Phase 3 — in-process differential parity. +"""Strawberry schema snapshot guard. -Runs the *same* query against the legacy Graphene schema and the new Strawberry schema and -asserts byte-identical ``data``. This proves runtime parity without hard-coding expected -values (the Phase 5 ``cli_ab_test`` does the same against live prod/test stages). +Originally an in-process *differential* test (ran each query through both the legacy Graphene +schema and the Strawberry schema, asserting identical ``data``). That differential proved the +migration byte-for-byte; with Graphene removed it is preserved here as a **snapshot** guard — +the same queries run through the Strawberry schema and their ``data`` is asserted against golden +JSON captured from the proven-identical schema. Regenerate snapshots with ``SNAPSHOT_UPDATE=1``. + +Broad behavioural coverage of the migrated resolvers lives in the (formerly Graphene-Client) +``test_*`` suites, which now run against the Strawberry schema via ``tests._strawberry_client``. """ -from graphene.test import Client +import json +import os +from pathlib import Path + +import graphql_relay + +from solvis_graphql_api.schema import schema as strawberry_schema + +_SNAP_DIR = Path(__file__).parent / "__snapshots__" / "strawberry_parity" + -from solvis_graphql_api.schema import schema_root -from solvis_graphql_api.strawberry_schema import schema as strawberry_schema +def _run(query: str, **variables): + result = strawberry_schema.execute_sync(query, variable_values=variables or None) + assert not result.errors, f"strawberry errors: {result.errors}" + return result.data -def _assert_parity(query: str, **variables): - legacy = Client(schema_root).execute(query, variable_values=variables or None) - straw = strawberry_schema.execute_sync(query, variable_values=variables or None) - assert not straw.errors, f"strawberry errors: {straw.errors}" - assert not legacy.get("errors"), f"legacy errors: {legacy.get('errors')}" - assert straw.data == legacy["data"], f"\nlegacy={legacy['data']}\nstrawberry={straw.data}" +def _assert_snapshot(name: str, query: str, **variables): + """Byte-exact golden-file guard. Use ONLY for queries whose output is deterministic across + platforms — NOT for shapely-geometry / matplotlib-hex payloads, whose raw float digits + differ macOS-vs-ubuntu (those use structural assertions below; cli_ab_test is the + authoritative same-data geojson parity gate).""" + data = _run(query, **variables) + snap_path = _SNAP_DIR / f"{name}.json" + if os.environ.get("SNAPSHOT_UPDATE"): + snap_path.parent.mkdir(parents=True, exist_ok=True) + snap_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + assert snap_path.exists(), f"missing snapshot {snap_path} — run with SNAPSHOT_UPDATE=1" + expected = json.loads(snap_path.read_text()) + assert data == expected, f"snapshot mismatch for {name}:\nexpected={expected}\nactual={data}" # --- light queries (no composite-solution archive needed) --- def test_about_parity(): - _assert_parity("{ about }") + _assert_snapshot("about", "{ about }") def test_locations_parity(): - _assert_parity("{ get_locations { location_id name latitude longitude } }") + # get_locations re-emits the full nzshm_common LOCATIONS constant (~thousands of entries) — + # snapshotting it would commit a multi-MB fixture of library data, so assert structurally. + from nzshm_common.location.location import LOCATIONS + + locs = _run("{ get_locations { location_id name latitude longitude } }")["get_locations"] + assert len(locs) == len(LOCATIONS) + wlg = next(loc for loc in locs if loc["location_id"] == "WLG") + assert wlg["name"] == "Wellington" + assert all({"location_id", "name", "latitude", "longitude"} == loc.keys() for loc in locs) def test_location_list_parity(): - _assert_parity('{ get_location_list(list_id: "NZ2") { list_id location_ids locations { location_id } } }') + _assert_snapshot( + "location_list", + '{ get_location_list(list_id: "NZ2") { list_id location_ids locations { location_id } } }', + ) def test_radii_parity(): - _assert_parity("{ get_radii_sets { radii_set_id radii } }") - _assert_parity("{ get_radii_set(radii_set_id: 6) { radii_set_id radii } }") + _assert_snapshot("radii_sets", "{ get_radii_sets { radii_set_id radii } }") + _assert_snapshot("radii_set", "{ get_radii_set(radii_set_id: 6) { radii_set_id radii } }") def test_color_scale_parity(): - _assert_parity( + _assert_snapshot( + "color_scale", '{ color_scale(name: "inferno" min_value: 5 max_value: 10 normalization: LIN)' - " { name min_value max_value normalisation color_map { levels hexrgbs } } }" + " { name min_value max_value normalisation color_map { levels hexrgbs } } }", ) @@ -50,15 +85,27 @@ def test_color_scale_parity(): def test_locations_by_id_parity(): - # exercises the LocationDetail Node (global-id `id`) + radius_geojson (shapely, no archive) - _assert_parity( + # LocationDetail Node (global-id `id`) + radius_geojson (shapely). The geojson coords are + # raw floats (platform-fragile), so assert structurally — incl. the bug-prone global-id encoding. + data = _run( '{ locations_by_id(location_ids: ["WLG", "ZQN"]) { total_count edges { node {' " id location_id name latitude longitude radius_geojson(radius_km: 50) } } } }" ) + conn = data["locations_by_id"] + assert conn["total_count"] == 2 + nodes = [e["node"] for e in conn["edges"]] + assert [n["location_id"] for n in nodes] == ["WLG", "ZQN"] + for n in nodes: + # global id must decode to ("LocationDetail", ) — graphene's encoding + assert graphql_relay.from_global_id(n["id"]) == ("LocationDetail", n["location_id"]) + assert json.loads(n["radius_geojson"])["features"] # non-empty geojson buffer def test_parent_fault_names_parity(archive_fixture_tiny): - _assert_parity('{ get_parent_fault_names(model_id: "NSHM_v1.0.4" fault_system: "CRU") }') + _assert_snapshot( + "parent_fault_names", + '{ get_parent_fault_names(model_id: "NSHM_v1.0.4" fault_system: "CRU") }', + ) _FILTER_RUPTURES = """ @@ -76,7 +123,8 @@ def test_parent_fault_names_parity(archive_fixture_tiny): def test_filter_ruptures_parity(archive_fixture): - _assert_parity( + _assert_snapshot( + "filter_ruptures", _FILTER_RUPTURES, model_id="NSHM_v1.0.0", fault_system="HIK", @@ -99,18 +147,63 @@ def test_filter_ruptures_parity(archive_fixture): def test_filter_rupture_sections_parity(archive_fixture_tiny): - _assert_parity(_SECTIONS) + # aggregates + mfd + styled fault_surfaces geojson; geojson coords are platform-fragile floats, + # so assert structurally (cli_ab_test byte-compares the live geojson against prod). + sec = _run(_SECTIONS)["filter_rupture_sections"] + assert sec["model_id"] == "NSHM_v1.0.4" + assert sec["section_count"] > 0 + assert sec["max_magnitude"] >= sec["min_magnitude"] > 0 + assert sec["max_participation_rate"] >= sec["min_participation_rate"] > 0 + assert sec["mfd_histogram"] and all( + {"bin_center", "rate", "cumulative_rate"} <= row.keys() for row in sec["mfd_histogram"] + ) + surfaces = json.loads(sec["fault_surfaces"]) + assert surfaces["type"] == "FeatureCollection" + assert len(surfaces["features"]) == sec["section_count"] + # styling was applied to every feature + assert all(f["properties"].get("fill") == "silver" for f in surfaces["features"]) + + +_SECTIONS_COLOUR = """ +{ filter_rupture_sections(filter: { + model_id: "NSHM_v1.0.4" location_ids: ["AKL"] fault_system: "CRU" + radius_km: 100 minimum_rate: 1.0e-19 minimum_mag: 6.2 }) { + color_scale(name: "inferno") { name min_value max_value normalisation color_map { levels hexrgbs } } + fault_surfaces(color_scale: { name: "inferno" } + style: { stroke_color: "silver" fill_color: "silver" fill_opacity: 0.2 }) + fault_traces(color_scale: { name: "inferno" } style: { stroke_color: "black" }) +} } +""" + + +def test_filter_rupture_sections_colour_parity(archive_fixture_tiny): + # colour paths the plain sections query skips: section-level color_scale participation-rate + # fallback, fault_surfaces + fault_traces with a color_scale (get_colour_values). Colour hexes + # and geojson coords are platform-fragile, so assert structurally. + sec = _run(_SECTIONS_COLOUR)["filter_rupture_sections"] + cs = sec["color_scale"] + assert cs["name"] == "inferno" + assert cs["normalisation"] == "LOG" # the participation-rate fallback defaults to log + levels, hexrgbs = cs["color_map"]["levels"], cs["color_map"]["hexrgbs"] + assert len(levels) == len(hexrgbs) >= 4 + assert all(isinstance(h, str) and h.startswith("#") for h in hexrgbs) + for key in ("fault_surfaces", "fault_traces"): + gj = json.loads(sec[key]) + assert gj["type"] == "FeatureCollection" and gj["features"] + # colour-mapped stroke applied per feature (a matplotlib hex, or "x000000" for None) + assert all(f["properties"].get("stroke") for f in gj["features"]) def test_node_parity(): - # legacy defines no get_node, so node(id) resolves to null on both schemas - _assert_parity('{ node(id: "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpDUlU6NjYx") { id } }') + # legacy defines no get_node, so node(id) resolves to null + _assert_snapshot("node", '{ node(id: "Q29tcG9zaXRlUnVwdHVyZURldGFpbDpDUlU6NjYx") { id } }') def test_composite_rupture_detail_parity(archive_fixture_tiny): # PUY:3 is a valid rupture in the tiny archive (rupture_detail resolves the rate/geometry cols) - _assert_parity( + _assert_snapshot( + "composite_rupture_detail", '{ composite_rupture_detail(filter: {model_id: "NSHM_v1.0.4" fault_system: "PUY" rupture_index: 3})' " { id model_id fault_system rupture_index magnitude area length rake_mean" - " rate_weighted_mean rate_max rate_min rate_count } }" + " rate_weighted_mean rate_max rate_min rate_count } }", ) diff --git a/uv.lock b/uv.lock index 3d776bc..351059e 100644 --- a/uv.lock +++ b/uv.lock @@ -107,15 +107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "boolean-py" version = "5.0" @@ -759,36 +750,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] -[[package]] -name = "flask" -version = "3.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "blinker" }, - { name = "click" }, - { name = "itsdangerous" }, - { name = "jinja2" }, - { name = "markupsafe" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, -] - -[[package]] -name = "flask-cors" -version = "6.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "flask" }, - { name = "werkzeug" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, -] - [[package]] name = "fonttools" version = "4.63.0" @@ -868,21 +829,6 @@ requests = [ { name = "requests-toolbelt" }, ] -[[package]] -name = "graphene" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "graphql-core" }, - { name = "graphql-relay" }, - { name = "python-dateutil" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/f6/bf62ff950c317ed03e77f3f6ddd7e34aaa98fe89d79ebd660c55343d8054/graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa", size = 44739, upload-time = "2024-11-09T20:44:25.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/e0/61d8e98007182e6b2aca7cf65904721fb2e4bce0192272ab9cb6f69d8812/graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71", size = 114894, upload-time = "2024-11-09T20:44:23.851Z" }, -] - [[package]] name = "graphql-core" version = "3.2.11" @@ -904,16 +850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/16/a4cf06adbc711bd364a73ce043b0b08d8fa5aae3df11b6ee4248bcdad2e0/graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5", size = 16940, upload-time = "2022-04-16T11:03:43.895Z" }, ] -[[package]] -name = "graphql-server" -version = "3.0.0b7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "graphql-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/be/9b/f77057d5994fde058eb437413fc94db0831e34939321493ecfbbd3ffbfdc/graphql-server-3.0.0b7.tar.gz", hash = "sha256:99adc23fb40c2e5d304215e2730b945f2883ee008c93f302438d849ab1d4e9e7", size = 48834, upload-time = "2023-10-16T09:16:17.276Z" } - [[package]] name = "h11" version = "0.16.0" @@ -990,15 +926,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "itsdangerous" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -2865,17 +2792,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "serverless-wsgi" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "werkzeug" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/b0/9b3fb4001807cf82fda139ce7db29a32b2a19005d7c6a0cd0c74776b8b4a/serverless_wsgi-3.1.0-py2.py3-none-any.whl", hash = "sha256:c2584067e1c2b0e909f83b923d3ab3f19cc645133d53bf203ca249fd77e6baec", size = 10774, upload-time = "2025-06-11T05:37:14.707Z" }, -] - [[package]] name = "sgqlc" version = "18" @@ -2982,17 +2898,13 @@ version = "0.9.2" source = { editable = "." } dependencies = [ { name = "fastapi" }, - { name = "flask" }, - { name = "flask-cors" }, - { name = "graphene" }, - { name = "graphql-server" }, + { name = "graphql-relay" }, { name = "mangum" }, { name = "matplotlib" }, { name = "nzshm-common" }, { name = "nzshm-model" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "serverless-wsgi" }, { name = "solvis" }, { name = "strawberry-graphql" }, ] @@ -3026,17 +2938,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "flask", specifier = ">=3.0.3" }, - { name = "flask-cors", specifier = ">=6.0" }, - { name = "graphene", specifier = ">=3.3" }, - { name = "graphql-server", specifier = "==3.0.0b7" }, + { name = "graphql-relay", specifier = ">=3.2" }, { name = "mangum", specifier = ">=0.18" }, { name = "matplotlib", specifier = ">=3.10.7,<4.0.0" }, { name = "nzshm-common", specifier = ">=0.9.0,<1.0" }, { name = "nzshm-model", specifier = ">=0.14.0,<1.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, - { name = "serverless-wsgi", specifier = ">=3.0" }, { name = "solvis", specifier = ">=1.2.0,<2.0" }, { name = "strawberry-graphql", specifier = ">=0.243" }, ]