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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ on:
jobs:
build:

runs-on: ubuntu-latest
# Keep the runner on an OS with published Python 3.9 binaries.
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v6

- name: Set up Python 3.9
uses: actions/setup-python@v2
uses: actions/setup-python@v6
with:
python-version: '3.9'

Expand All @@ -45,4 +46,4 @@ jobs:
run: |
coveralls
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ repository.
Installation
------------

For staged HTTP compression and compact rendering responses, see
`Opt-in flight responses <docs/flight-response-options.rst>`_.

From an Anaconda environment
----------------------------

Expand Down
99 changes: 99 additions & 0 deletions docs/flight-response-options.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
Opt-in flight responses
=======================

These options apply to POST /api/flight_path, /api/flight_path_from_summary,
and /api/flight_paths, plus the two /api/ws/flight_path endpoints (including
/api/ws/flight_path_from_summary). Requests without
response_options retain the existing full-precision, uncompressed JSON response,
even if the client advertises gzip support. Physics and solver settings are unchanged.

Stage 1: lossless transport compression
--------------------------------------

Add this member to the existing flight request body::

"response_options": {"version": 1, "compression": "gzip"}

The HTTP client must also advertise gzip in Accept-Encoding. Both opt-ins are
required. Clients such as browsers manage Accept-Encoding themselves; do not
manually decompress a response already decoded by the networking library.

The response remains application/json. When compressed it carries
Content-Encoding: gzip and a Content-Length describing the encoded bytes.
Vary: Accept-Encoding is added for requests opting into gzip. Missing or
unacceptable gzip support falls back to identity encoding. Tiny responses remain
uncompressed if gzip would increase their size. Always inspect Content-Encoding
rather than assuming the response is compressed. Gzip level 1 is used to limit
CPU overhead. No new runtime dependency is required.

Stage 2: compact numerical output
--------------------------------

All fields, reduced precision::

"response_options": {
"version": 2,
"compression": "gzip",
"profile": "full"
}

Rendering fields, retaining spin animation::

"response_options": {
"version": 2,
"compression": "gzip",
"profile": "render",
"include_spin": true
}

The render profile omits v (velocity). Set include_spin to false only for
consumers that do not need gamma; the web spin animation uses gamma. Quaternions
are not changed to incorporate gamma when gamma is omitted. All positions,
timestamps, quaternions, and sample counts retain their existing array layout.
Batch responses retain their existing disc-name/index keys. Options apply to
every flight in the batch. There is no response envelope or downsampling.

Version 2 rounds only the serialized output:

* p: 3 decimal places in metres (0.5 mm maximum error per component).
* v: 3 decimal places in metres/second.
* t: exact original timestamps in seconds.
* qx, qy, qz, qw: 6 decimal places.
* gamma: 5 decimal places in radians, including any requested gamma offset.

Timestamps always retain their original precision to preserve tiny solver steps
and shared streaming boundaries. Clients should normalize rounded quaternions
before operations requiring exact unit length. Compact values are intended for
display; use version 1 for analytics requiring full precision, particularly
finite differences of position over short time intervals.

Version defaults to 1, compression to identity, profile to full, and include_spin
to true. Version 2 can be requested without compression. profile and include_spin
are only accepted with version 2; include_spin=false requires profile=render.
Unknown options, unsupported values, and incorrect types return HTTP 400 before
simulation. Brotli and binary formats are not supported in this initial rollout.

Rollout and measurement
-----------------------

Deploy with defaults unchanged. Opt a test client into stage 1, verify automatic
decoding and measure encoded bytes, request-to-response time and first rendered
frame on real mobile connections. Then opt that client into stage 2 and compare
flight appearance, landing position, and spin orientation. Compare p50/p95 under
matched concurrency and network conditions. Cloud request latency alone does not
measure client parsing/rendering. Removing response_options rolls back both stages.

WebSocket compact output
------------------------

Include response_options in the initial JSON message. Version 2 and profile full
retain all samples, velocity, and gamma for V2 spin processing. Chunk boundaries,
termination marker ({}), and physics are unchanged. Compact formatting only changes
sent values; continuing solver state always uses the original full-precision results.
Use compression=identity (the default). Requesting gzip on a WebSocket is rejected;
WebSocket compression is not implemented. Invalid options close the socket without
running a simulation. Existing clients omitting options receive legacy messages.

The V2 gateway requests version 2/profile full automatically, HTTP gzip for full
responses, and identity transport for WebSockets. Direct FrisPy clients must still
opt in themselves. Deploy FrisPy before the companion V2 gateway for upstream savings.
85 changes: 85 additions & 0 deletions service/flight_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Opt-in HTTP transport and rendering formats; never change solver precision."""

import gzip
from dataclasses import dataclass

from flask import jsonify, request
from werkzeug.exceptions import BadRequest


@dataclass(frozen=True)
class ResponseOptions:
version: int = 1
compression: str = "identity"
profile: str = "full"
include_spin: bool = True


def parse_response_options(content):
options = content.get("response_options", {})
if not isinstance(options, dict):
raise BadRequest("response_options must be an object")
allowed = {"version", "compression", "profile", "include_spin"}
if options.keys() - allowed:
raise BadRequest("Unknown response_options field")
version = options.get("version", 1)
compression = options.get("compression", "identity")
profile = options.get("profile", "full")
include_spin = options.get("include_spin", True)
if type(version) is not int or version not in (1, 2):
raise BadRequest("response_options.version must be 1 or 2")
if compression not in ("identity", "gzip"):
raise BadRequest("response_options.compression must be identity or gzip")
if profile not in ("full", "render"):
raise BadRequest("response_options.profile must be full or render")
if type(include_spin) is not bool:
raise BadRequest("response_options.include_spin must be a boolean")
if version == 1 and ("profile" in options or "include_spin" in options):
raise BadRequest("profile and include_spin require response version 2")
if profile != "render" and not include_spin:
raise BadRequest("include_spin=false requires the render profile")
return ResponseOptions(version, compression, profile, include_spin)


def parse_websocket_response_options(content):
options = parse_response_options(content)
if options.compression != "identity":
raise BadRequest("WebSocket compression is not supported")
return options


def format_flight_result(result, options):
if options.version == 1:
return result
precision = {"p": 3, "v": 3, "t": 5, "qx": 6, "qy": 6,
"qz": 6, "qw": 6, "gamma": 5}

def rounded(value, digits):
if isinstance(value, list):
return [rounded(item, digits) for item in value]
return round(float(value), digits)

compact = {
key: value if key == "t" else rounded(value, precision[key])
for key, value in result.items()
if not (options.profile == "render" and
(key == "v" or (key == "gamma" and not options.include_spin)))
}
# Exact timestamps preserve short steps and shared WebSocket chunk boundaries.
return compact


def flight_response(data, options):
response = jsonify(data)
if options.compression != "gzip":
return response
response.vary.add("Accept-Encoding")
# The body opt-in alone does not prove the client can decode gzip.
if request.accept_encodings.quality("gzip") <= 0:
return response
raw = response.get_data()
compressed = gzip.compress(raw, compresslevel=1, mtime=0)
if len(compressed) < len(raw):
response.set_data(compressed)
response.headers["Content-Encoding"] = "gzip"
return response
29 changes: 19 additions & 10 deletions service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
from flask_cors import CORS
from flask_sock import Sock
from frispy.disc import FrisPyResults
from service.flight_response import (
ResponseOptions, parse_response_options, parse_websocket_response_options,
format_flight_result, flight_response,
)

# import google.cloud.logging
# client = google.cloud.logging.Client()
Expand All @@ -33,9 +37,10 @@ def ws_flight_path(s):
return

content = json.loads(data)
options = parse_websocket_response_options(content)
gamma = content.get('gamma', 0)
disc = create_disc(content)
inc_send_over_websocket(disc, gamma, s)
inc_send_over_websocket(disc, gamma, s, options)
finally:
s.close()

Expand All @@ -48,24 +53,25 @@ def ws_flight_path_from_summary(s):
return

content = json.loads(data)
options = parse_websocket_response_options(content)
content = to_flight_path_request(content)

disc = create_disc(content)
inc_send_over_websocket(disc, 0, s)
inc_send_over_websocket(disc, 0, s, options)
finally:
s.close()


def inc_send_over_websocket(disc, gamma, s) -> bool:
def inc_send_over_websocket(disc, gamma, s, options=ResponseOptions()) -> bool:
results = compute_trajectory(disc, 1.0)
s.send(json.dumps(to_result(gamma, results)))
s.send(json.dumps(format_flight_result(to_result(gamma, results), options)))
disc.set_initial_conditions_from_prev_results(results)
while results.z[-1] > 1e-6:
# bail early if socket is closed
if not s.connected:
return False
results = compute_trajectory(disc, 1.0, results.times[-1])
s.send(json.dumps(to_result(gamma, results)))
s.send(json.dumps(format_flight_result(to_result(gamma, results), options)))
disc.set_initial_conditions_from_prev_results(results)
# send empty object to signal end of flight
s.send("{}")
Expand All @@ -76,24 +82,25 @@ def inc_send_over_websocket(disc, gamma, s) -> bool:
@app.route('/api/flight_paths', methods=['POST'])
def flight_paths():
content = request.json
options = parse_response_options(content)
discs = content.get('disc_names')
res = {}
if discs:
for discName in discs:
content['disc_name'] = discName
disc = create_disc(content)
result = compute_trajectory(disc)
res[discName] = to_result(content.get('gamma', 0), result)
res[discName] = format_flight_result(to_result(content.get('gamma', 0), result), options)
else:
discs = content.get('disc_numbers')
if discs: # Check if discs is not None before iterating
for index, discNumbers in enumerate(discs):
content['flight_numbers'] = discNumbers
disc = create_disc(content)
result = compute_trajectory(disc)
res[index] = to_result(content.get('gamma', 0), result)
res[index] = format_flight_result(to_result(content.get('gamma', 0), result), options)

return res
return flight_response(res, options)


# this method assumes the disc velocity is in the X direction.
Expand All @@ -104,9 +111,10 @@ def flight_paths():
@app.route('/api/flight_path', methods=['POST'])
def flight_path():
content = request.json
options = parse_response_options(content)
disc = create_disc(content)
result = compute_trajectory(disc)
return to_result(content.get('gamma', 0), result)
return flight_response(format_flight_result(to_result(content.get('gamma', 0), result), options), options)


# send over the throw summary to get the flight directly.
Expand All @@ -115,10 +123,11 @@ def flight_path():
@app.route('/api/flight_path_from_summary', methods=['POST'])
def flight_path_from_summary():
content = request.json
options = parse_response_options(content)
content = to_flight_path_request(content)
disc = create_disc(content)
result = compute_trajectory(disc)
return to_result(0, result)
return flight_response(format_flight_result(to_result(0, result), options), options)


def create_disc(content) -> Disc:
Expand Down
Loading