Skip to content

add AsimovReader for retrieving measurements from asimov.enci.dk#198

Merged
ScottSoren merged 46 commits into
mainfrom
asimov
Apr 30, 2026
Merged

add AsimovReader for retrieving measurements from asimov.enci.dk#198
ScottSoren merged 46 commits into
mainfrom
asimov

Conversation

@FrederikLizakJohansen

@FrederikLizakJohansen FrederikLizakJohansen commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

AsimovReader (src/ixdat/readers/asimov.py)
New reader class that connects to asimov.enci.dk and retrieves measurements via REST API. Supports retrieval by dataset_id: Direct lookup using the asimov database ID.

The reader returns ixdat Measurement objects with with data fetched from the asimov API.
KeycloakDeviceTokenProvider (src/ixdat/auth/keycloak.py)

Authentication handler implementing the OAuth 2.0 Device Flow protocol for Keycloak. This enables passwordless authentication to asimov.enci.dk without exposing user credentials directly to ixdat. The flow:

  1. Initiates authorization request
  2. User visits URL and enters code
  3. Exchanges authorization for access token
  4. Refreshes tokens as needed

Portable Serialization

Added to_portable_dict()/from_portable_dict() methods to:

  • Saveable base class
  • Measurement class
  • Series classes (ValueSeries, TimeSeries, etc.)

These provide backend-agnostic serialization that doesn't reference ixdat backend classes. The current as_dict() method has ties to potential ixdat DB plans discussed in PR #75. These new "portable" versions provide an alternative for storing measurements in external databases and passing them over web APIs until the #75 plan is finalized.

Note on dependency: The asimov DB internally uses ixdat to store Measurement objects in the form of the portable dicts generated by this implementation of to_portable_dict(). Therefore, this implementation have a direct dependency on that data flow - the reader retrieves the same portable dict format that asimov stores internally, ensuring compatibility between ixdat and the asimov backend.

Tests

  • Unit test for KeycloakDeviceTokenProvider (tests/unit/test_keycloak_token_provider.py)
  • Unit test for portable dict serialization (tests/unit/test_portable_dict.py)

Demo

A demo script (development_scripts/reader_demonstrators/demo_asimov_reader.py) showcases:

  • Retrieving measurements from asimov-db using database ID (dataset_id)
  • Using Keycloak authentication with user login credentials to query for measurements

…_portable_value. A version of as_dict() with no reference to the ixdat backend; for storage in databases and sent over web APIs (asimov project).
…where needed in series classes, to support [asimov f590483]
… in conjuction with asimov reader class for authenticating access to the asimov db
…nProvider [6e0514e] to authenticat the retrieval of measurements from asimov.enci.dk and to init ixdat Measurements from those queries
@FrederikLizakJohansen

FrederikLizakJohansen commented Feb 12, 2026

Copy link
Copy Markdown
Contributor Author

The current commits sketch out the base implementation. Additional endpoints for asimov are scheduled, meaning more potential properties for the AsimovReader class; like a list_datasets() and search_datasets() function. Imagining:

  • Filter by kind/technique (e.g., kind="ec")
  • Date range filtering (created_after, created_before), names, etc.
    This will require corresponding API endpoints on asimov.enci.dk (e.g., GET /api/datasets?kind=ec&limit=50).

@FrederikLizakJohansen

Copy link
Copy Markdown
Contributor Author

Added portable dict support for Spectrum and SpectrumSeries so the AsimovReader can handle these too, not just Measurement. Each portable dict now carries an object_type field ("measurement", "spectrum", "spectrum_series") so that the reader can automatically dispatch the correct class without needing the user to pass cls explicitly.

Also: Round-trip tests for Field, Spectrum, and SpectrumSeries added.

@FrederikLizakJohansen

FrederikLizakJohansen commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

After more consideration, I think it be best if we keep this PR to the base reader and portable dict, and move the search/list features to a new future PR. Created feature-issue #201 for now.

@ScottSoren

Copy link
Copy Markdown
Member

I'm having trouble testing this, possibly because I don't quite understand the demo script. Could you add some comments / documentation in that script?
Running it in spyder gives the error

demo_asimov_reader.py: error: the following arguments are required: --dataset-id
An exception has occurred, use %tb to see the full traceback.

SystemExit: 2

which I guess is generated by argparse

Finding dataset-id's on asimov is tricky, but clicking a "Measurement" from "Graph View" opens an "Inspector" which includes an "Entity ID". I copied the one from "demo_03" in project "ixdat_demo" and ran the following from a terminal (Anaconda prompt):

python demo_asimov_reader.py --dataset-id 3b644a7e-211c-4c24-beef-8bbc84470ee0

This opened a browser window asking for permissions, as described in the docstring. After I granted the permissions, a long error was printed to the Anaconda Prompt terminal, of which the important part is:

requests.exceptions.HTTPError: 404 Client Error: Not Found for url: https://asimov.enci.dk/api/datasets/3b644a7e-211c-4c24-beef-8bbc84470ee0

My normal workflow is to start with the demo script. I'll start on code review of asimov.py in parallel, but if you could add a bit of documentation to the demo and/or change the if __name__ == "__main__" section to run without arguments using some demo data as default, that would help!

@FrederikLizakJohansen

Copy link
Copy Markdown
Contributor Author

I pulled the shared portable-dict metadata handling into a couple of small helpers so we are not repeating the same name / technique / metadata / tstamp / sample_name boilerplate in several places, while still leaving the actual object-specific parts like series_list, field, and durations where they are easiest to maintain.

@ScottSoren I also simplified the demo flow so it no longer depends on argparse or on the user hunting down a valid dataset ids. The script now runs directly against a fixed pair of static example IDs, which makes it easier to start from the demo script in the usual workflow.

@ScottSoren ScottSoren left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi Frederik,

Great work. However, there are serious problems in the portable_dict implementation, which I've commented on in data_series.py. I would much rather have the AsimovReader do what it needs to to get the data from asimov into a normal ixdat dictionary. I also recommend going through this totorial to get a better sense of what ixdat has to do (and tries to do in a smart way, though it can surely be smarter) with regards to organising its data series.

A tool that you might need instead could be an AsimovExporter on asimov's side, to format the data coming out of asimov. That would be a more appropriate place to put a new structure of ixdat dat, since it defeats the point of a modular structure, and would set a precedent leading to a huge proliferation of representations of ixdat data, if a PR adding a new reader could also add something like to_portable_dict.

Other comments I have center around the question of whether some of the keycloak interface could be done with another package? python-keycloak for example has 166 contributors, many of whom are probably professionals in exactly that - the more we can rely on something like that, the more robust I think it makes ixdat going forward. It could be that our use case is so special that it requires bottom-up coding, but then my question is why? (Some of your instinct to make a new serialization is no doubt because I kind of poorly re-invented the wheel of object-relational mapping instead of using something like SQLalchemy from the start.)

Comment thread development_scripts/reader_demonstrators/demo_asimov_reader.py Outdated
Comment thread development_scripts/reader_demonstrators/demo_asimov_reader.py
Comment thread src/ixdat/auth/__init__.py
Comment thread src/ixdat/auth/keycloak.py
Comment thread src/ixdat/tools.py
Comment thread src/ixdat/spectra.py Outdated
Comment thread src/ixdat/data_series.py Outdated
Comment thread src/ixdat/data_series.py Outdated
Comment thread src/ixdat/data_series.py Outdated
Comment thread src/ixdat/data_series.py Outdated
@FrederikLizakJohansen

FrederikLizakJohansen commented Apr 27, 2026

Copy link
Copy Markdown
Contributor Author

@ScottSoren; thanks for the thorough review. You were right on the big stuff. This turned out to be a bit more complex than I first thought, so bear with me here; I am just gonna one reply here since the remaining review comments fit well together. The changes made here are in 4464fb7.

The big one: portable_dict in core
You're right. A reader shouldn't reshape ixdat's core just because its format looks different. I simply hadn't thought this through. So I pulled it out again:

  • db.py, data_series.py, measurement_base.py, spectra.py: every to_portable_dict / from_portable_dict is gone. Those files are back to pre-PR state.
  • tests/unit/test_portable_dict.py deleted.
  • The payload-to-ixdat translation now lives in the reader: AsimovReader._build_kwargs and AsimovReader._build_series. I've tried to keep this as simple as can be, but please let me know if you have other suggestions. All importing on the asimov-server-side is handled exclusively by Asimov now to not bloat this reader with functionality that it does not own.
  • New payload-parsing tests in tests/unit/test_asimov_reader.py.

The "ixdat dicts should be redone" conversation stays a separate PR, as you suggested.

Fixing the name-collision and the duplication of data
The old payload pointed at a TimeSeries from a ValueSeries using its name string, and a Field's t-axis was inlined under axes_series even when the same TimeSeries was already in series_list. Both were really broken.

To fix the naming-collision I had to redo the payload format itself on the server-side. Asimov's writer and the reader now both use payload-local keys. Every entry in series_list carries a key like "s0", "s1", a ValueSeries points at its TimeSeries with tseries_key, and a Field whose axis is also a top-level series uses axes_keys: ["s0"] instead of inlining. I went with short string keys ("s0") over plain integers mostly because they read as "series 0" in a JSON, and they don't get mixed up with backend ids from the ixdat backend (more on this below). The Asimov writer was updated alongside this, and all stored payloads were re-parsed into the new shape, so there should be no back-compat issues.

While going through this, I realized that I might be re-inventing the wheel once again, and so I went into a rabbit hole of trying to figure out if the reader could just lean on ixdat's as_dict()/from_dict() directly and skip the custom translation; since ofc the key/tseries_key/axes_keys pattern is doing more or less the same job as ixdat's existing s_ids/a_ids. I tried two versions. One where Asimov serializes with as_dict() and the reader calls from_dict() directly, and one with a small AsimovExport along the lines of what you proposed, so that it could go through ixdat's backend hooks. Both "solutions" came out approx the same size as the current implementation, but ofc they were tied closely to ixdat's column_attrs/extra_linkers setup, which is the part being redone in #75, and I also ran into the SpectrumSeries.durations/continuous thing that as_dict doesn't pick up. So I dropped the experimentation and stuck with the explicit s0/s1 shape here. It's the same id-based reference idea, just in a format Asimov and the reader can own, so nothing has to move when #75 lands. When that PR happens, this will probably end up looking a lot like whatever the new format becomes, and the custom bits on both sides can go away. But I would rather that this implementation work before we delve into #75, which this PR would then otherwise depend heavily on.

I hope you can see the point in doing this; else let me know!

New: AsimovConfig
I also pulled all the connection vars out of AsimovReader.__init__ and into a small AsimovConfig dataclass. Before, things like base_url, Keycloak realm, client ID, timeouts, and cache path were either kwargs on the reader or scattered constants. That made the reader signature long and messy. Now:

  • AsimovConfig holds connection + auth settings, with one big docstring explaining each field.
  • AsimovConfig.from_env() layers env vars on defaults (ASIMOV_BASE_URL, KEYCLOAK_REALM, etc.) so CI and headless runs just set env vars.
  • A module-level ASIMOV_CONFIG is built at import. Power users can mutate it before the first read, or pass their own AsimovConfig for tests. The idea is "you don't need to touch any of this unless you're on a private deployment or in a headless box."
  • Added full docstrings on AsimovConfig and AsimovReader.__init__, covering the fields, env vars, and when you'd touch them.

Already pushed earlier in the branch

  • Cache path now auth/keycloak/<realm>_<client_id>.json: 1e371d6
  • Comment on the HTTP-error JSON unraveling: 75e95e7
  • Comments on the device-flow polling loop: 3e7575f
  • Demo uses meas.plot(): f86df71

Still open between us
The python-keycloak / plugins / webbrowser thread: I haven't touched it yet, since I think we should decide together. My opinion is still that device flow + token cache + silent refresh is enough custom code that python-keycloak only replaces ~40 lines, in trade for 3–4 new deps. But I'm open. If we go that way, moving the whole module under plugins/ makes sense at the same time. Your call.

Other
While testing I hit a ResourceWarning from webbrowser.open. Replaced it with a small _open_in_browser helper in keycloak.py.

@ScottSoren ScottSoren left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Very nice! Thanks for the thoughtful addressing of my previous review. Mainly questions remaining, plus some small change requests, mostly asking for comments.
I like your solution with "key", and though I also in principle think that it should be covered by the id's of ixdat objects, I know that the latter are a bit of a mess at present.
I'd like to see where the "key"s get set, though, for my curiosity and thinking ahead to #75 ! Can you point me to it in the asimov code base?

Comment thread src/ixdat/auth/keycloak.py
Comment thread src/ixdat/auth/keycloak.py
Comment thread tests/functional/test_backends.py Outdated
Comment thread src/ixdat/tools.py
}


@dataclass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

dataclass is new to me :) thanks! I've used attrs before for the same. For curiosity: https://www.attrs.org/en/stable/why.html
It seems like this means that we're killing python 3.6 compatability, though. I think that's fine - we already had to stop testing for it in the continuous integration. We'll have to warn about that somehow when releasing this on PyPI. There are a few groups around the world using ixdat for EC-MS who might be affected. Also, Spectro Inlets, who have built some things on top of ixdat.

Let's sit together when releasing this and work through it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah okay haha, this is where it shows when we both entered the python programming scene! I really only started programming for real in python when 3.7 was released, so I have only used that. We should definitely think about how to release this in a way that does not break current users; but I also think that we could argue that they should be using >=3.7 in 2026 ;-)

Lets agree fully on this before we merge.

Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py
Comment thread tests/unit/test_asimov_reader.py
Comment thread tests/unit/test_asimov_reader.py

@FrederikLizakJohansen FrederikLizakJohansen left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I do not know why I need to "submit review" here since I just commented on your review. But here it is.

Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py
Comment thread src/ixdat/readers/asimov.py Outdated
Comment thread src/ixdat/readers/asimov.py Outdated
Comment thread development_scripts/reader_demonstrators/demo_asimov_reader.py Outdated
Comment thread src/ixdat/readers/asimov.py Outdated
@ScottSoren

Copy link
Copy Markdown
Member

All comments resolved except for the one reminding us to deal with dropping python 3.6 support when releasing.
Great work! Approved and merging :D
Can you point me to where in the asimov code base key_map gets built? Might be that that's the way ixdat should do it in the future.

@ScottSoren ScottSoren merged commit 26d5d2e into main Apr 30, 2026
9 checks passed
@FrederikLizakJohansen

Copy link
Copy Markdown
Contributor Author

@ScottSoren Thanks for the review and the merge!

In asimov, the key map is built in services/api/parsing/ixdat_payload.py:172:

all_series = list(getattr(measurement, "series_list", []) or [])
series_key_map: dict[int, str] = {id(s): f"s{i}" for i, s in enumerate(all_series)}

Here series_list is ixdat’s list of data-series objects attached to the measurement

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants